Compare commits

..

2 Commits

Author SHA1 Message Date
Aakansha Doshi 555bf6338f fix test 2022-11-09 22:55:27 +05:30
Daniel J. Geiger 0bef3945f6 Fix #5855. 2022-11-09 07:54:49 -06:00
243 changed files with 11990 additions and 17417 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: Auto release excalidraw next
on: on:
push: push:
branches: branches:
- release - master
jobs: jobs:
Auto-release-excalidraw-next: Auto-release-excalidraw-next:
+1 -1
View File
@@ -3,7 +3,7 @@ name: Build Docker image
on: on:
push: push:
branches: branches:
- release - master
jobs: jobs:
build-docker: build-docker:
+1 -1
View File
@@ -3,7 +3,7 @@ name: Cancel previous runs
on: on:
push: push:
branches: branches:
- release - master
pull_request: pull_request:
jobs: jobs:
+1 -1
View File
@@ -3,7 +3,7 @@ name: Publish Docker
on: on:
push: push:
branches: branches:
- release - master
jobs: jobs:
publish-docker: publish-docker:
+1 -1
View File
@@ -3,7 +3,7 @@ name: New Sentry production release
on: on:
push: push:
branches: branches:
- release - master
jobs: jobs:
sentry: sentry:
+1 -1
View File
@@ -1,2 +1,2 @@
#!/bin/sh #!/bin/sh
# yarn lint-staged yarn lint-staged
+6 -6
View File
@@ -4692,9 +4692,9 @@ json-schema-traverse@^1.0.0:
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
json5@^2.1.2, json5@^2.2.1: json5@^2.1.2, json5@^2.2.1:
version "2.2.3" version "2.2.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
jsonfile@^6.0.1: jsonfile@^6.0.1:
version "6.1.0" version "6.1.0"
@@ -4755,9 +4755,9 @@ loader-runner@^4.2.0:
integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
loader-utils@^2.0.0: loader-utils@^2.0.0:
version "2.0.4" version "2.0.2"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129"
integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==
dependencies: dependencies:
big.js "^5.2.2" big.js "^5.2.2"
emojis-list "^3.0.0" emojis-list "^3.0.0"
+6 -22
View File
@@ -19,7 +19,6 @@
] ]
}, },
"dependencies": { "dependencies": {
"@dwelle/tunnel-rat": "0.1.1",
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2", "@testing-library/jest-dom": "5.16.2",
@@ -32,7 +31,6 @@
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.7",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4", "i18next-browser-languagedetector": "6.1.4",
@@ -52,24 +50,11 @@
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-scripts": "5.0.1", "react-scripts": "4.0.3",
"roughjs": "4.5.2", "roughjs": "4.5.2",
"sass": "1.51.0", "sass": "1.51.0",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"tunnel-rat": "0.1.0", "typescript": "4.5.5"
"typescript": "4.9.4",
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.5.4",
"workbox-expiration": "^6.5.4",
"workbox-google-analytics": "^6.5.4",
"workbox-navigation-preload": "^6.5.4",
"workbox-precaching": "^6.5.4",
"workbox-range-requests": "^6.5.4",
"workbox-routing": "^6.5.4",
"workbox-strategies": "^6.5.4",
"workbox-streams": "^6.5.4"
}, },
"devDependencies": { "devDependencies": {
"@excalidraw/eslint-config": "1.0.0", "@excalidraw/eslint-config": "1.0.0",
@@ -82,7 +67,6 @@
"dotenv": "16.0.1", "dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1",
"husky": "7.0.4", "husky": "7.0.4",
"jest-canvas-mock": "2.4.0", "jest-canvas-mock": "2.4.0",
"lint-staged": "12.3.7", "lint-staged": "12.3.7",
@@ -105,10 +89,11 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true react-scripts build", "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build", "build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:version": "node ./scripts/build-version.js", "build:version": "node ./scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build:prebuild": "node ./scripts/prebuild.js",
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"fix:code": "yarn test:code --fix", "fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write", "fix:other": "yarn prettier --write",
@@ -118,7 +103,6 @@
"prepare": "husky install", "prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start", "start": "react-scripts start",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
"test:app": "react-scripts test --passWithNoTests", "test:app": "react-scripts test --passWithNoTests",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
+4 -2
View File
@@ -146,8 +146,7 @@
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw"; window.name = "_excalidraw";
</script> </script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' && <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script <script
async async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%" src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
@@ -167,6 +166,9 @@
body, body,
html { html {
margin: 0; margin: 0;
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system,
Segoe UI, Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
width: 100%; width: 100%;
+81
View File
@@ -0,0 +1,81 @@
// eslint-disable-next-line no-restricted-globals
// eslint-disable-next-line no-unused-expressions
/* eslint-disable no-restricted-globals */
/* global importScripts, workbox */
/**
* Welcome to your Workbox-powered service worker!
*
* You'll need to register this file in your web app and you should
* disable HTTP caching for this file too.
* See https://goo.gl/nhQhGp
*
* The rest of the code is auto-generated. Please don't update this file
* directly; instead, make changes to your Workbox build configuration
* and re-run your build process.
* See https://goo.gl/2aRDsh
*/
// in dev, `process` is undefined because this file is not compiled until build
const IS_DEVELOPMENT =
typeof process === "undefined" || process.env.NODE_ENV !== "production";
if (IS_DEVELOPMENT) {
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
);
workbox.setConfig({
debug: true,
});
} else {
importScripts("/workbox/workbox-sw.js");
workbox.setConfig({
modulePathPrefix: "/workbox/",
});
}
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
workbox.core.clientsClaim();
if (!IS_DEVELOPMENT) {
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
workbox.routing.registerNavigationRoute(
workbox.precaching.getCacheKeyForURL("./index.html"),
{
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
},
);
}
// Cache relevant font files
workbox.routing.registerRoute(
new RegExp("/(fonts.css|.+.(ttf|woff2|otf))"),
new workbox.strategies.StaleWhileRevalidate({
cacheName: "fonts",
plugins: [new workbox.expiration.Plugin({ maxEntries: 10 })],
}),
);
self.addEventListener("fetch", (event) => {
if (
event.request.method === "POST" &&
event.request.url.endsWith("/web-share-target")
) {
return event.respondWith(
(async () => {
const formData = await event.request.formData();
const file = formData.get("file");
const webShareTargetCache = await caches.open("web-share-target");
await webShareTargetCache.put("shared-file", new Response(file));
return Response.redirect("/?web-share-target", 303);
})(),
);
}
});
+2 -4
View File
@@ -50,8 +50,8 @@ const crowdinMap = {
"lv-LV": "en-lv", "lv-LV": "en-lv",
"cs-CZ": "en-cs", "cs-CZ": "en-cs",
"kk-KZ": "en-kk", "kk-KZ": "en-kk",
"vi-VN": "en-vi", "vi-vn": "en-vi",
"mr-IN": "en-mr", "mr-in": "en-mr",
}; };
const flags = { const flags = {
@@ -120,7 +120,6 @@ const languages = {
"fa-IR": "فارسی", "fa-IR": "فارسی",
"fi-FI": "Suomi", "fi-FI": "Suomi",
"fr-FR": "Français", "fr-FR": "Français",
"gl-ES": "Galego",
"he-IL": "עברית", "he-IL": "עברית",
"hi-IN": "हिन्दी", "hi-IN": "हिन्दी",
"hu-HU": "Magyar", "hu-HU": "Magyar",
@@ -130,7 +129,6 @@ const languages = {
"kab-KAB": "Taqbaylit", "kab-KAB": "Taqbaylit",
"kk-KZ": "Қазақ тілі", "kk-KZ": "Қазақ тілі",
"ko-KR": "한국어", "ko-KR": "한국어",
"ku-TR": "Kurdî",
"lt-LT": "Lietuvių", "lt-LT": "Lietuvių",
"lv-LV": "Latviešu", "lv-LV": "Latviešu",
"my-MM": "Burmese", "my-MM": "Burmese",
+21
View File
@@ -0,0 +1,21 @@
const fs = require("fs");
const path = require("path");
// for development purposes we want to have the service-worker.js file
// accessible from the public folder. On build though, we need to compile it
// and CRA expects that file to be in src/ folder.
const moveServiceWorkerScript = () => {
const oldPath = path.resolve(__dirname, "../public/service-worker.js");
const newPath = path.resolve(__dirname, "../src/service-worker.js");
fs.rename(oldPath, newPath, (error) => {
if (error) {
throw error;
}
console.info("public/service-worker.js moved to src/");
});
};
// -----------------------------------------------------------------------------
moveServiceWorkerScript();
+2 -14
View File
@@ -6,10 +6,6 @@ import {
measureText, measureText,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element/textElement"; } from "../element/textElement";
import {
getOriginalContainerHeightFromCache,
resetOriginalContainerCache,
} from "../element/textWysiwyg";
import { import {
hasBoundTextElement, hasBoundTextElement,
isTextBindableContainer, isTextBindableContainer,
@@ -26,7 +22,7 @@ export const actionUnbindText = register({
name: "unbindText", name: "unbindText",
contextItemLabel: "labels.unbindText", contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element)); return selectedElements.some((element) => hasBoundTextElement(element));
}, },
@@ -42,11 +38,6 @@ export const actionUnbindText = register({
boundTextElement.originalText, boundTextElement.originalText,
getFontString(boundTextElement), getFontString(boundTextElement),
); );
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
);
resetOriginalContainerCache(element.id);
mutateElement(boundTextElement as ExcalidrawTextElement, { mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null, containerId: null,
width, width,
@@ -58,9 +49,6 @@ export const actionUnbindText = register({
boundElements: element.boundElements?.filter( boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id, (ele) => ele.id !== boundTextElement.id,
), ),
height: originalContainerHeight
? originalContainerHeight
: element.height,
}); });
} }
}); });
@@ -76,7 +64,7 @@ export const actionBindText = register({
name: "bindText", name: "bindText",
contextItemLabel: "labels.bindText", contextItemLabel: "labels.bindText",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 2) { if (selectedElements.length === 2) {
+47 -66
View File
@@ -1,7 +1,13 @@
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { ZoomInIcon, ZoomOutIcon } from "../components/icons"; import {
eraser,
MoonIcon,
SunIcon,
ZoomInIcon,
ZoomOutIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element"; import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -10,25 +16,19 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, setCursor, updateActiveTool } from "../utils"; import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { import { getDefaultAppState, isEraserActive } from "../appState";
getDefaultAppState, import ClearCanvas from "../components/ClearCanvas";
isEraserActive, import clsx from "clsx";
isHandToolActive, import MenuItem from "../components/MenuItem";
} from "../appState"; import { getShortcutFromShortcutName } from "./shortcuts";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
trackEvent: false, trackEvent: false,
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.changeViewBackgroundColor &&
!appState.viewModeEnabled
);
},
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
appState: { ...appState, ...value }, appState: { ...appState, ...value },
@@ -36,7 +36,6 @@ export const actionChangeViewBackgroundColor = register({
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<ColorPicker <ColorPicker
@@ -60,12 +59,6 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({ export const actionClearCanvas = register({
name: "clearCanvas", name: "clearCanvas",
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.clearCanvas &&
!appState.viewModeEnabled
);
},
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
app.imageCache.clear(); app.imageCache.clear();
return { return {
@@ -91,11 +84,12 @@ export const actionClearCanvas = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
}); });
export const actionZoomIn = register({ export const actionZoomIn = register({
name: "zoomIn", name: "zoomIn",
viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
@@ -132,7 +126,6 @@ export const actionZoomIn = register({
export const actionZoomOut = register({ export const actionZoomOut = register({
name: "zoomOut", name: "zoomOut",
viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
@@ -169,7 +162,6 @@ export const actionZoomOut = register({
export const actionResetZoom = register({ export const actionResetZoom = register({
name: "resetZoom", name: "resetZoom",
viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
@@ -279,7 +271,6 @@ export const actionZoomToSelected = register({
export const actionZoomToFit = register({ export const actionZoomToFit = register({
name: "zoomToFit", name: "zoomToFit",
viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false), perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) => keyTest: (event) =>
@@ -291,7 +282,6 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({ export const actionToggleTheme = register({
name: "toggleTheme", name: "toggleTheme",
viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
@@ -303,21 +293,33 @@ export const actionToggleTheme = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ appState, updateData }) => (
<MenuItem
label={
appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
onClick={() => {
updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
}}
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
dataTestId="toggle-dark-mode"
shortcut={getShortcutFromShortcutName("toggleTheme")}
/>
),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
predicate: (elements, appState, props, app) => {
return !!app.props.UIOptions.canvasActions.toggleTheme;
},
}); });
export const actionToggleEraserTool = register({ export const actionErase = register({
name: "toggleEraserTool", name: "eraser",
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
perform: (elements, appState) => { perform: (elements, appState) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveToolBeforeEraser || {
type: "selection", type: "selection",
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
@@ -340,38 +342,17 @@ export const actionToggleEraserTool = register({
}; };
}, },
keyTest: (event) => event.key === KEYS.E, keyTest: (event) => event.key === KEYS.E,
}); PanelComponent: ({ elements, appState, updateData, data }) => (
<ToolButton
export const actionToggleHandTool = register({ type="button"
name: "toggleHandTool", icon={eraser}
trackEvent: { category: "toolbar" }, className={clsx("eraser", { active: isEraserActive(appState) })}
perform: (elements, appState, _, app) => { title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
let activeTool: AppState["activeTool"]; aria-label={t("toolBar.eraser")}
onClick={() => {
if (isHandToolActive(appState)) { updateData(null);
activeTool = updateActiveTool(appState, { }}
...(appState.activeTool.lastActiveTool || { size={data?.size || "medium"}
type: "selection", ></ToolButton>
}), ),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "hand",
lastActiveToolBeforeEraser: appState.activeTool,
});
setCursor(app.canvas, CURSOR_TYPE.GRAB);
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeTool,
},
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.H,
}); });
+1 -31
View File
@@ -3,7 +3,6 @@ import { register } from "./register";
import { import {
copyTextToSystemClipboard, copyTextToSystemClipboard,
copyToClipboard, copyToClipboard,
probablySupportsClipboardBlob,
probablySupportsClipboardWriteText, probablySupportsClipboardWriteText,
} from "../clipboard"; } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
@@ -24,31 +23,11 @@ export const actionCopy = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy", contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined, keyTest: undefined,
}); });
export const actionPaste = register({
name: "paste",
trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => {
app.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionCut = register({ export const actionCut = register({
name: "cut", name: "cut",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
@@ -56,9 +35,6 @@ export const actionCut = register({
actionCopy.perform(elements, appState, data, app); actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState); return actionDeleteSelected.perform(elements, appState);
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.cut", contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
}); });
@@ -101,9 +77,6 @@ export const actionCopyAsSvg = register({
}; };
} }
}, },
predicate: (elements) => {
return probablySupportsClipboardWriteText && elements.length > 0;
},
contextItemLabel: "labels.copyAsSvg", contextItemLabel: "labels.copyAsSvg",
}); });
@@ -158,9 +131,6 @@ export const actionCopyAsPng = register({
}; };
} }
}, },
predicate: (elements) => {
return probablySupportsClipboardBlob && elements.length > 0;
},
contextItemLabel: "labels.copyAsPng", contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
}); });
@@ -188,7 +158,7 @@ export const copyText = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
predicate: (elements, appState) => { contextItemPredicate: (elements, appState) => {
return ( return (
probablySupportsClipboardWriteText && probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, true).some(isTextElement) getSelectedElements(elements, appState, true).some(isTextElement)
+20 -16
View File
@@ -1,6 +1,7 @@
import { questionCircle, saveAs } from "../components/icons"; import { LoadIcon, questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { DarkModeToggle } from "../components/DarkModeToggle"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
@@ -14,11 +15,12 @@ import { getExportSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants"; import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types"; import { Theme } from "../element/types";
import MenuItem from "../components/MenuItem";
import "../components/ToolIcon.scss"; import { getShortcutFromShortcutName } from "./shortcuts";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
@@ -131,13 +133,6 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({ export const actionSaveToActiveFile = register({
name: "saveToActiveFile", name: "saveToActiveFile",
trackEvent: { category: "export" }, trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.saveToActiveFile &&
!!appState.fileHandle &&
!appState.viewModeEnabled
);
},
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle; const fileHandleExists = !!appState.fileHandle;
@@ -174,11 +169,16 @@ export const actionSaveToActiveFile = register({
}, },
keyTest: (event) => keyTest: (event) =>
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey, event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
PanelComponent: ({ updateData, appState }) => (
<ActiveFile
onSave={() => updateData(null)}
fileName={appState.fileHandle?.name}
/>
),
}); });
export const actionSaveFileToDisk = register({ export const actionSaveFileToDisk = register({
name: "saveFileToDisk", name: "saveFileToDisk",
viewMode: true,
trackEvent: { category: "export" }, trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
try { try {
@@ -219,11 +219,6 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({ export const actionLoadScene = register({
name: "loadScene", name: "loadScene",
trackEvent: { category: "export" }, trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled
);
},
perform: async (elements, appState, _, app) => { perform: async (elements, appState, _, app) => {
try { try {
const { const {
@@ -251,6 +246,15 @@ export const actionLoadScene = register({
} }
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData }) => (
<MenuItem
label={t("buttons.load")}
icon={LoadIcon}
onClick={updateData}
dataTestId="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
/>
),
}); });
export const actionExportWithDarkMode = register({ export const actionExportWithDarkMode = register({
+1 -1
View File
@@ -145,7 +145,7 @@ export const actionFinalize = register({
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
if (appState.activeTool.type === "eraser") { if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveToolBeforeEraser || {
type: "selection", type: "selection",
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
+13 -7
View File
@@ -14,7 +14,6 @@ import {
} from "../element/bounds"; } from "../element/bounds";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { KEYS } from "../keys";
const enableActionFlipHorizontal = ( const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -50,7 +49,7 @@ export const actionFlipHorizontal = register({
}, },
keyTest: (event) => event.shiftKey && event.code === "KeyH", keyTest: (event) => event.shiftKey && event.code === "KeyH",
contextItemLabel: "labels.flipHorizontal", contextItemLabel: "labels.flipHorizontal",
predicate: (elements, appState) => contextItemPredicate: (elements, appState) =>
enableActionFlipHorizontal(elements, appState), enableActionFlipHorizontal(elements, appState),
}); });
@@ -64,10 +63,9 @@ export const actionFlipVertical = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
keyTest: (event) => keyTest: (event) => event.shiftKey && event.code === "KeyV",
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
contextItemLabel: "labels.flipVertical", contextItemLabel: "labels.flipVertical",
predicate: (elements, appState) => contextItemPredicate: (elements, appState) =>
enableActionFlipVertical(elements, appState), enableActionFlipVertical(elements, appState),
}); });
@@ -153,7 +151,11 @@ const flipElement = (
let initialPointsCoords; let initialPointsCoords;
if (isLinearElement(element)) { if (isLinearElement(element)) {
initialPointsCoords = getElementPointsCoords(element, element.points); initialPointsCoords = getElementPointsCoords(
element,
element.points,
element.strokeSharpness,
);
} }
const initialElementAbsoluteCoords = getElementAbsoluteCoords(element); const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
@@ -211,7 +213,11 @@ const flipElement = (
// Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin. // Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
// There's still room for improvement since when the line roughness is > 1 // There's still room for improvement since when the line roughness is > 1
// we still have a small offset of the origin when fliipping the element. // we still have a small offset of the origin when fliipping the element.
const finalPointsCoords = getElementPointsCoords(element, element.points); const finalPointsCoords = getElementPointsCoords(
element,
element.points,
element.strokeSharpness,
);
const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0]; const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2]; const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
+4 -2
View File
@@ -129,7 +129,8 @@ export const actionGroup = register({
}; };
}, },
contextItemLabel: "labels.group", contextItemLabel: "labels.group",
predicate: (elements, appState) => enableActionGroup(elements, appState), contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState),
keyTest: (event) => keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G, !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@@ -192,7 +193,8 @@ export const actionUngroup = register({
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
event.key === KEYS.G.toUpperCase(), event.key === KEYS.G.toUpperCase(),
contextItemLabel: "labels.ungroup", contextItemLabel: "labels.ungroup",
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0, contextItemPredicate: (elements, appState) =>
getSelectedGroupIds(appState).length > 0,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
+1 -2
View File
@@ -5,11 +5,10 @@ import { t } from "../i18n";
import History, { HistoryEntry } from "../history"; import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { KEYS } from "../keys"; import { isWindows, KEYS } from "../keys";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
const writeData = ( const writeData = (
prevElements: readonly ExcalidrawElement[], prevElements: readonly ExcalidrawElement[],
+1 -1
View File
@@ -10,7 +10,7 @@ export const actionToggleLinearEditor = register({
trackEvent: { trackEvent: {
category: "element", category: "element",
}, },
predicate: (elements, appState) => { contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
return true; return true;
+15 -3
View File
@@ -1,10 +1,12 @@
import { HamburgerMenuIcon, palette } from "../components/icons"; import { HamburgerMenuIcon, HelpIcon, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register"; import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils"; import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { HelpButton } from "../components/HelpButton";
import MenuItem from "../components/MenuItem";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu", name: "toggleCanvasMenu",
@@ -54,7 +56,6 @@ export const actionToggleEditMenu = register({
export const actionFullScreen = register({ export const actionFullScreen = register({
name: "toggleFullScreen", name: "toggleFullScreen",
viewMode: true,
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() }, trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => { perform: () => {
if (!isFullScreen()) { if (!isFullScreen()) {
@@ -72,7 +73,6 @@ export const actionFullScreen = register({
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" }, trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => { perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog === "help") { if (appState.openDialog === "help") {
@@ -86,5 +86,17 @@ export const actionShortcuts = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ updateData, isInHamburgerMenu }) =>
isInHamburgerMenu ? (
<MenuItem
label={t("helpDialog.title")}
dataTestId="help-menu-item"
icon={HelpIcon}
onClick={updateData}
shortcut="?"
/>
) : (
<HelpButton title={t("helpDialog.title")} onClick={updateData} />
),
keyTest: (event) => event.key === KEYS.QUESTION_MARK, keyTest: (event) => event.key === KEYS.QUESTION_MARK,
}); });
-1
View File
@@ -6,7 +6,6 @@ import { register } from "./register";
export const actionGoToCollaborator = register({ export const actionGoToCollaborator = register({
name: "goToCollaborator", name: "goToCollaborator",
viewMode: true,
trackEvent: { category: "collab" }, trackEvent: { category: "collab" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"]; const point = value as Collaborator["pointer"];
+58 -64
View File
@@ -42,7 +42,6 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { import {
@@ -58,7 +57,7 @@ import {
import { import {
isBoundToContainer, isBoundToContainer,
isLinearElement, isLinearElement,
isUsingAdaptiveRadius, isLinearElementType,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import {
Arrowhead, Arrowhead,
@@ -73,7 +72,7 @@ import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { import {
canChangeRoundness, canChangeSharpness,
canHaveArrowheads, canHaveArrowheads,
getCommonAttributeOfSelectedElements, getCommonAttributeOfSelectedElements,
getSelectedElements, getSelectedElements,
@@ -817,19 +816,16 @@ export const actionChangeVerticalAlign = register({
value: VERTICAL_ALIGN.TOP, value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"), text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />, icon: <TextAlignTopIcon theme={appState.theme} />,
testId: "align-top",
}, },
{ {
value: VERTICAL_ALIGN.MIDDLE, value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"), text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />, icon: <TextAlignMiddleIcon theme={appState.theme} />,
testId: "align-middle",
}, },
{ {
value: VERTICAL_ALIGN.BOTTOM, value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"), text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />, icon: <TextAlignBottomIcon theme={appState.theme} />,
testId: "align-bottom",
}, },
]} ]}
value={getFormValue(elements, appState, (element) => { value={getFormValue(elements, appState, (element) => {
@@ -849,71 +845,69 @@ export const actionChangeVerticalAlign = register({
}, },
}); });
export const actionChangeRoundness = register({ export const actionChangeSharpness = register({
name: "changeRoundness", name: "changeSharpness",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
roundness:
value === "round"
? {
type: isUsingAdaptiveRadius(el.type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
}),
),
appState: {
...appState,
currentItemRoundness: value,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
); );
const shouldUpdateForNonLinearElements = targetElements.length
const hasLegacyRoundness = targetElements.some( ? targetElements.every((el) => !isLinearElement(el))
(el) => el.roundness?.type === ROUNDNESS.LEGACY, : !isLinearElementType(appState.activeTool.type);
); const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
return ( : isLinearElementType(appState.activeTool.type);
<fieldset> return {
<legend>{t("labels.edges")}</legend> elements: changeProperty(elements, appState, (el) =>
<ButtonIconSelect newElementWith(el, {
group="edges" strokeSharpness: value,
options={[ }),
{ ),
value: "sharp", appState: {
text: t("labels.sharp"), ...appState,
icon: EdgeSharpIcon, currentItemStrokeSharpness: shouldUpdateForNonLinearElements
}, ? value
{ : appState.currentItemStrokeSharpness,
value: "round", currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
text: t("labels.round"), ? value
icon: EdgeRoundIcon, : appState.currentItemLinearStrokeSharpness,
}, },
]} commitToHistory: true,
value={getFormValue( };
elements,
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(canChangeRoundness(appState.activeTool.type) &&
appState.currentItemRoundness) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
}, },
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.edges")}</legend>
<ButtonIconSelect
group="edges"
options={[
{
value: "sharp",
text: t("labels.sharp"),
icon: EdgeSharpIcon,
},
{
value: "round",
text: t("labels.round"),
icon: EdgeRoundIcon,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeSharpness,
(canChangeSharpness(appState.activeTool.type) &&
(isLinearElementType(appState.activeTool.type)
? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
}); });
export const actionChangeArrowhead = register({ export const actionChangeArrowhead = register({
+1 -13
View File
@@ -13,11 +13,7 @@ import {
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getBoundTextElement } from "../element/textElement"; import { getBoundTextElement } from "../element/textElement";
import { import { hasBoundTextElement } from "../element/typeChecks";
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
@@ -81,14 +77,6 @@ export const actionPasteStyles = register({
fillStyle: elementStylesToCopyFrom?.fillStyle, fillStyle: elementStylesToCopyFrom?.fillStyle,
opacity: elementStylesToCopyFrom?.opacity, opacity: elementStylesToCopyFrom?.opacity,
roughness: elementStylesToCopyFrom?.roughness, roughness: elementStylesToCopyFrom?.roughness,
roundness: elementStylesToCopyFrom.roundness
? canApplyRoundnessTypeToElement(
elementStylesToCopyFrom.roundness.type,
element,
)
? elementStylesToCopyFrom.roundness
: getDefaultRoundnessTypeForElement(element)
: null,
}); });
if (isTextElement(newElement)) { if (isTextElement(newElement)) {
-4
View File
@@ -5,7 +5,6 @@ import { AppState } from "../types";
export const actionToggleGridMode = register({ export const actionToggleGridMode = register({
name: "gridMode", name: "gridMode",
viewMode: true,
trackEvent: { trackEvent: {
category: "canvas", category: "canvas",
predicate: (appState) => !appState.gridSize, predicate: (appState) => !appState.gridSize,
@@ -20,9 +19,6 @@ export const actionToggleGridMode = register({
}; };
}, },
checked: (appState: AppState) => appState.gridSize !== null, checked: (appState: AppState) => appState.gridSize !== null,
predicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
},
contextItemLabel: "labels.showGrid", contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
}); });
+9 -3
View File
@@ -41,9 +41,15 @@ export const actionToggleLock = register({
: "labels.elementLock.lock"; : "labels.elementLock.lock";
} }
return getOperation(selected) === "lock" if (selected.length > 1) {
? "labels.elementLock.lockAll" return getOperation(selected) === "lock"
: "labels.elementLock.unlockAll"; ? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
}
throw new Error(
"Unexpected zero elements to lock/unlock. This should never happen.",
);
}, },
keyTest: (event, appState, elements) => { keyTest: (event, appState, elements) => {
return ( return (
-1
View File
@@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({ export const actionToggleStats = register({
name: "stats", name: "stats",
viewMode: true,
trackEvent: { category: "menu" }, trackEvent: { category: "menu" },
perform(elements, appState) { perform(elements, appState) {
return { return {
-4
View File
@@ -3,7 +3,6 @@ import { register } from "./register";
export const actionToggleViewMode = register({ export const actionToggleViewMode = register({
name: "viewMode", name: "viewMode",
viewMode: true,
trackEvent: { trackEvent: {
category: "canvas", category: "canvas",
predicate: (appState) => !appState.viewModeEnabled, predicate: (appState) => !appState.viewModeEnabled,
@@ -18,9 +17,6 @@ export const actionToggleViewMode = register({
}; };
}, },
checked: (appState) => appState.viewModeEnabled, checked: (appState) => appState.viewModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.viewModeEnabled === "undefined";
},
contextItemLabel: "labels.viewMode", contextItemLabel: "labels.viewMode",
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
-4
View File
@@ -3,7 +3,6 @@ import { register } from "./register";
export const actionToggleZenMode = register({ export const actionToggleZenMode = register({
name: "zenMode", name: "zenMode",
viewMode: true,
trackEvent: { trackEvent: {
category: "canvas", category: "canvas",
predicate: (appState) => !appState.zenModeEnabled, predicate: (appState) => !appState.zenModeEnabled,
@@ -18,9 +17,6 @@ export const actionToggleZenMode = register({
}; };
}, },
checked: (appState) => appState.zenModeEnabled, checked: (appState) => appState.zenModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.zenModeEnabled === "undefined";
},
contextItemLabel: "buttons.zenMode", contextItemLabel: "buttons.zenMode",
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
+1 -2
View File
@@ -5,7 +5,7 @@ import {
moveAllLeft, moveAllLeft,
moveAllRight, moveAllRight,
} from "../zindex"; } from "../zindex";
import { KEYS, CODES } from "../keys"; import { KEYS, isDarwin, CODES } from "../keys";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
@@ -15,7 +15,6 @@ import {
SendBackwardIcon, SendBackwardIcon,
SendToBackIcon, SendToBackIcon,
} from "../components/icons"; } from "../components/icons";
import { isDarwin } from "../constants";
export const actionSendBackward = register({ export const actionSendBackward = register({
name: "sendBackward", name: "sendBackward",
+12 -13
View File
@@ -9,6 +9,7 @@ import {
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
import { MODES } from "../constants";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
const trackAction = ( const trackAction = (
@@ -102,8 +103,11 @@ export class ActionManager {
const action = data[0]; const action = data[0];
if (this.getAppState().viewModeEnabled && action.viewMode !== true) { const { viewModeEnabled } = this.getAppState();
return false; if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) {
return false;
}
} }
const elements = this.getElementsIncludingDeleted(); const elements = this.getElementsIncludingDeleted();
@@ -131,7 +135,11 @@ export class ActionManager {
/** /**
* @param data additional data sent to the PanelComponent * @param data additional data sent to the PanelComponent
*/ */
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => { renderAction = (
name: ActionName,
data?: PanelComponentProps["data"],
isInHamburgerMenu = false,
) => {
const canvasActions = this.app.props.UIOptions.canvasActions; const canvasActions = this.app.props.UIOptions.canvasActions;
if ( if (
@@ -166,20 +174,11 @@ export class ActionManager {
updateData={updateData} updateData={updateData}
appProps={this.app.props} appProps={this.app.props}
data={data} data={data}
isInHamburgerMenu={isInHamburgerMenu}
/> />
); );
} }
return null; return null;
}; };
isActionEnabled = (action: Action) => {
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
return (
!action.predicate ||
action.predicate(elements, appState, this.app.props, this.app)
);
};
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { isDarwin } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { ActionName } from "./types"; import { ActionName } from "./types";
@@ -48,7 +48,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")], copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")], pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")], selectAll: [getShortcutKey("CtrlOrCmd+A")],
deleteSelectedElements: [getShortcutKey("Delete")], deleteSelectedElements: [getShortcutKey("Del")],
duplicateSelection: [ duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"), getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`), getShortcutKey(`Alt+${t("helpDialog.drag")}`),
+7 -11
View File
@@ -91,7 +91,7 @@ export type ActionName =
| "ungroup" | "ungroup"
| "goToCollaborator" | "goToCollaborator"
| "addToLibrary" | "addToLibrary"
| "changeRoundness" | "changeSharpness"
| "alignTop" | "alignTop"
| "alignBottom" | "alignBottom"
| "alignLeft" | "alignLeft"
@@ -109,11 +109,10 @@ export type ActionName =
| "decreaseFontSize" | "decreaseFontSize"
| "unbindText" | "unbindText"
| "hyperlink" | "hyperlink"
| "eraser"
| "bindText" | "bindText"
| "toggleLock" | "toggleLock"
| "toggleLinearEditor" | "toggleLinearEditor";
| "toggleEraserTool"
| "toggleHandTool";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -125,7 +124,9 @@ export type PanelComponentProps = {
export interface Action { export interface Action {
name: ActionName; name: ActionName;
PanelComponent?: React.FC<PanelComponentProps>; PanelComponent?: React.FC<
PanelComponentProps & { isInHamburgerMenu: boolean }
>;
perform: ActionFn; perform: ActionFn;
keyPriority?: number; keyPriority?: number;
keyTest?: ( keyTest?: (
@@ -139,11 +140,9 @@ export interface Action {
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
) => string); ) => string);
predicate?: ( contextItemPredicate?: (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: trackEvent:
@@ -165,7 +164,4 @@ export interface Action {
value: any, value: any,
) => boolean; ) => boolean;
}; };
/** if set to `true`, allow action to be performed in viewMode.
* Defaults to `false` */
viewMode?: boolean;
} }
+5 -13
View File
@@ -28,11 +28,12 @@ export const getDefaultAppState = (): Omit<
currentItemFillStyle: "hachure", currentItemFillStyle: "hachure",
currentItemFontFamily: DEFAULT_FONT_FAMILY, currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE, currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemLinearStrokeSharpness: "round",
currentItemOpacity: 100, currentItemOpacity: 100,
currentItemRoughness: 1, currentItemRoughness: 1,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: oc.black, currentItemStrokeColor: oc.black,
currentItemRoundness: "round", currentItemStrokeSharpness: "sharp",
currentItemStrokeStyle: "solid", currentItemStrokeStyle: "solid",
currentItemStrokeWidth: 1, currentItemStrokeWidth: 1,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
@@ -45,7 +46,7 @@ export const getDefaultAppState = (): Omit<
type: "selection", type: "selection",
customType: null, customType: null,
locked: false, locked: false,
lastActiveTool: null, lastActiveToolBeforeEraser: null,
}, },
penMode: false, penMode: false,
penDetected: false, penDetected: false,
@@ -64,7 +65,6 @@ export const getDefaultAppState = (): Omit<
lastPointerDownWith: "mouse", lastPointerDownWith: "mouse",
multiElement: null, multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`, name: `${t("labels.untitled")}-${getDateTime()}`,
contextMenu: null,
openMenu: null, openMenu: null,
openPopup: null, openPopup: null,
openSidebar: null, openSidebar: null,
@@ -120,7 +120,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemFillStyle: { browser: true, export: false, server: false }, currentItemFillStyle: { browser: true, export: false, server: false },
currentItemFontFamily: { browser: true, export: false, server: false }, currentItemFontFamily: { browser: true, export: false, server: false },
currentItemFontSize: { browser: true, export: false, server: false }, currentItemFontSize: { browser: true, export: false, server: false },
currentItemRoundness: { currentItemLinearStrokeSharpness: {
browser: true, browser: true,
export: false, export: false,
server: false, server: false,
@@ -129,6 +129,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemRoughness: { browser: true, export: false, server: false }, currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false }, currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeSharpness: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false },
@@ -158,7 +159,6 @@ const APP_STATE_STORAGE_CONF = (<
name: { browser: true, export: false, server: false }, name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false }, offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false }, offsetTop: { browser: false, export: false, server: false },
contextMenu: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false }, openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false }, openPopup: { browser: false, export: false, server: false },
openSidebar: { browser: true, export: false, server: false }, openSidebar: { browser: true, export: false, server: false },
@@ -228,11 +228,3 @@ export const isEraserActive = ({
}: { }: {
activeTool: AppState["activeTool"]; activeTool: AppState["activeTool"];
}) => activeTool.type === "eraser"; }) => activeTool.type === "eraser";
export const isHandToolActive = ({
activeTool,
}: {
activeTool: AppState["activeTool"];
}) => {
return activeTool.type === "hand";
};
+2 -2
View File
@@ -172,7 +172,7 @@ const commonProps = {
opacity: 100, opacity: 100,
roughness: 1, roughness: 1,
strokeColor: colors.elementStroke[0], strokeColor: colors.elementStroke[0],
roundness: null, strokeSharpness: "sharp",
strokeStyle: "solid", strokeStyle: "solid",
strokeWidth: 1, strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
@@ -322,7 +322,7 @@ const chartBaseElements = (
text: spreadsheet.title, text: spreadsheet.title,
x: x + chartWidth / 2, x: x + chartWidth / 2,
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null, strokeSharpness: "sharp",
strokeStyle: "solid", strokeStyle: "solid",
textAlign: "center", textAlign: "center",
}) })
-27
View File
@@ -1,27 +0,0 @@
import { parseClipboard } from "./clipboard";
describe("Test parseClipboard", () => {
it("should parse valid json correctly", async () => {
let text = "123";
let clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
text = "[123]";
clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
});
});
+19 -30
View File
@@ -109,16 +109,16 @@ const parsePotentialSpreadsheet = (
* Retrieves content from system clipboard (either from ClipboardEvent or * Retrieves content from system clipboard (either from ClipboardEvent or
* via async clipboard API if supported) * via async clipboard API if supported)
*/ */
export const getSystemClipboard = async ( const getSystemClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent | null,
): Promise<string> => { ): Promise<string> => {
try { try {
const text = event const text = event
? event.clipboardData?.getData("text/plain") ? event.clipboardData?.getData("text/plain").trim()
: probablySupportsClipboardReadText && : probablySupportsClipboardReadText &&
(await navigator.clipboard.readText()); (await navigator.clipboard.readText());
return (text || "").trim(); return text || "";
} catch { } catch {
return ""; return "";
} }
@@ -129,25 +129,19 @@ export const getSystemClipboard = async (
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent | null,
isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event); const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously // if system clipboard empty, couldn't be resolved, or contains previously
// copied excalidraw scene as SVG, fall back to previously copied excalidraw // copied excalidraw scene as SVG, fall back to previously copied excalidraw
// elements // elements
if ( if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
!systemClipboard ||
(!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
) {
return getAppClipboard(); return getAppClipboard();
} }
// if system clipboard contains spreadsheet, use it even though it's // if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard // technically possible it's staler than in-app clipboard
const spreadsheetResult = const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) { if (spreadsheetResult) {
return spreadsheetResult; return spreadsheetResult;
} }
@@ -160,36 +154,30 @@ export const parseClipboard = async (
return { return {
elements: systemClipboardData.elements, elements: systemClipboardData.elements,
files: systemClipboardData.files, files: systemClipboardData.files,
text: isPlainPaste
? JSON.stringify(systemClipboardData.elements, null, 2)
: undefined,
}; };
} }
} catch (e) {} return appClipboardData;
// system clipboard doesn't contain excalidraw elements → return plaintext } catch {
// unless we set a flag to prefer in-app clipboard because browser didn't // system clipboard doesn't contain excalidraw elements → return plaintext
// support storing to system clipboard on copy // unless we set a flag to prefer in-app clipboard because browser didn't
return PREFER_APP_CLIPBOARD && appClipboardData.elements // support storing to system clipboard on copy
? { return PREFER_APP_CLIPBOARD && appClipboardData.elements
...appClipboardData, ? appClipboardData
text: isPlainPaste : { text: systemClipboard };
? JSON.stringify(appClipboardData.elements, null, 2) }
: undefined,
}
: { text: systemClipboard };
}; };
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => { export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
let promise;
try { try {
// in Safari so far we need to construct the ClipboardItem synchronously // in Safari so far we need to construct the ClipboardItem synchronously
// (i.e. in the same tick) otherwise browser will complain for lack of // (i.e. in the same tick) otherwise browser will complain for lack of
// user intent. Using a Promise ClipboardItem constructor solves this. // user intent. Using a Promise ClipboardItem constructor solves this.
// https://bugs.webkit.org/show_bug.cgi?id=222262 // https://bugs.webkit.org/show_bug.cgi?id=222262
// //
// Note that Firefox (and potentially others) seems to support Promise // not await so that we can detect whether the thrown error likely relates
// ClipboardItem constructor, but throws on an unrelated MIME type error. // to a lack of support for the Promise ClipboardItem constructor
// So we need to await this and fallback to awaiting the blob if applicable. promise = navigator.clipboard.write([
await navigator.clipboard.write([
new window.ClipboardItem({ new window.ClipboardItem({
[MIME_TYPES.png]: blob, [MIME_TYPES.png]: blob,
}), }),
@@ -207,6 +195,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
throw error; throw error;
} }
} }
await promise;
}; };
export const copyTextToSystemClipboard = async (text: string | null) => { export const copyTextToSystemClipboard = async (text: string | null) => {
+12 -12
View File
@@ -5,7 +5,7 @@ import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { import {
canChangeRoundness, canChangeSharpness,
canHaveArrowheads, canHaveArrowheads,
getTargetElements, getTargetElements,
hasBackground, hasBackground,
@@ -25,12 +25,11 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks"; import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
import clsx from "clsx"; import clsx from "clsx";
import { actionToggleZenMode } from "../actions"; import { actionToggleZenMode } from "../actions";
import "./Actions.scss"; import "./Actions.scss";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { shouldAllowVerticalAlign } from "../element/textElement";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@@ -110,9 +109,9 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{(canChangeRoundness(appState.activeTool.type) || {(canChangeSharpness(appState.activeTool.type) ||
targetElements.some((element) => canChangeRoundness(element.type))) && ( targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeRoundness")}</> <>{renderAction("changeSharpness")}</>
)} )}
{(hasText(appState.activeTool.type) || {(hasText(appState.activeTool.type) ||
@@ -126,8 +125,10 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{shouldAllowVerticalAlign(targetElements) && {targetElements.some(
renderAction("changeVerticalAlign")} (element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(appState.activeTool.type) || {(canHaveArrowheads(appState.activeTool.type) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
@@ -219,10 +220,9 @@ export const ShapesSwitcher = ({
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const label = t(`toolBar.${value}`); const label = t(`toolBar.${value}`);
const letter = const letter = key && (typeof key === "string" ? key : key[0]);
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}` ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`; : `${numericKey}`;
return ( return (
<ToolButton <ToolButton
@@ -233,7 +233,7 @@ export const ShapesSwitcher = ({
checked={activeTool.type === value} checked={activeTool.type === value}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter} keyBindingLabel={numericKey}
aria-label={capitalizeString(label)} aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`} data-testid={`toolbar-${value}`}
-35
View File
@@ -1,35 +0,0 @@
import { atom, useAtom } from "jotai";
import { actionClearCanvas } from "../actions";
import { t } from "../i18n";
import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog";
export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom,
);
const actionManager = useExcalidrawActionManager();
if (!activeConfirmDialog) {
return null;
}
if (activeConfirmDialog === "clearCanvas") {
return (
<ConfirmDialog
onConfirm={() => {
actionManager.executeAction(actionClearCanvas);
setActiveConfirmDialog(null);
}}
onCancel={() => setActiveConfirmDialog(null)}
title={t("clearCanvasDialog.title")}
>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
</ConfirmDialog>
);
}
return null;
};
+23
View File
@@ -0,0 +1,23 @@
// TODO barnabasmolnar/editor-redesign
// this icon is not great
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { save } from "../components/icons";
import { t } from "../i18n";
import "./ActiveFile.scss";
import MenuItem from "./MenuItem";
type ActiveFileProps = {
fileName?: string;
onSave: () => void;
};
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
<MenuItem
label={`${t("buttons.save")}`}
shortcut={getShortcutFromShortcutName("saveScene")}
dataTestId="save-button"
onClick={onSave}
icon={save}
/>
);
+446 -579
View File
File diff suppressed because it is too large Load Diff
+1 -12
View File
@@ -4,8 +4,8 @@
.Avatar { .Avatar {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
position: relative;
border-radius: 100%; border-radius: 100%;
outline: 2px solid var(--avatar-border-color);
outline-offset: 2px; outline-offset: 2px;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -21,16 +21,5 @@
height: 100%; height: 100%;
border-radius: 100%; border-radius: 100%;
} }
&::before {
content: "";
position: absolute;
top: -3px;
right: -3px;
bottom: -3px;
left: -3px;
border: 1px solid var(--avatar-border-color);
border-radius: 100%;
}
} }
} }
-7
View File
@@ -1,7 +0,0 @@
@import "../css/theme";
.excalidraw {
.excalidraw-button {
@include outlineButtonStyles;
}
}
-35
View File
@@ -1,35 +0,0 @@
import "./Button.scss";
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
type?: "button" | "submit" | "reset";
onSelect: () => any;
children: React.ReactNode;
className?: string;
}
/**
* A generic button component that follows Excalidraw's design system.
* Style can be customised using `className` or `style` prop.
* Accepts all props that a regular `button` element accepts.
*/
export const Button = ({
type = "button",
onSelect,
children,
className = "",
...rest
}: ButtonProps) => {
return (
<button
onClick={(event) => {
onSelect();
rest.onClick?.(event);
}}
type={type}
className={`excalidraw-button ${className}`}
{...rest}
>
{children}
</button>
);
};
+39
View File
@@ -0,0 +1,39 @@
import { useState } from "react";
import { t } from "../i18n";
import { TrashIcon } from "./icons";
import ConfirmDialog from "./ConfirmDialog";
import MenuItem from "./MenuItem";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false);
const toggleDialog = () => {
setShowDialog(!showDialog);
};
return (
<>
<MenuItem
label={t("buttons.clearReset")}
icon={TrashIcon}
onClick={toggleDialog}
dataTestId="clear-canvas-button"
/>
{showDialog && (
<ConfirmDialog
onConfirm={() => {
onConfirm();
toggleDialog();
}}
onCancel={toggleDialog}
title={t("clearCanvasDialog.title")}
>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
</ConfirmDialog>
)}
</>
);
};
export default ClearCanvas;
@@ -1,23 +1,30 @@
@import "../../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.collab-button { .collab-button {
--button-bg: var(--color-primary); @include outlineButtonStyles;
--button-color: white; width: var(--lg-button-size);
--button-border: var(--color-primary); height: var(--lg-button-size);
--button-width: var(--lg-button-size);
--button-height: var(--lg-button-size);
--button-hover-bg: var(--color-primary-darker);
--button-hover-border: var(--color-primary-darker);
--button-active-bg: var(--color-primary-darker);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
background-color: var(--color-primary);
border-color: var(--color-primary);
color: white;
flex-shrink: 0; flex-shrink: 0;
// double .active to force specificity &:hover {
&.active.active { background-color: var(--color-primary-darker);
border-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darker);
}
&.active {
background-color: #0fb884; background-color: #0fb884;
border-color: #0fb884; border-color: #0fb884;
+49
View File
@@ -0,0 +1,49 @@
import { t } from "../i18n";
import { UsersIcon } from "./icons";
import "./CollabButton.scss";
import MenuItem from "./MenuItem";
import clsx from "clsx";
const CollabButton = ({
isCollaborating,
collaboratorCount,
onClick,
isInHamburgerMenu = true,
}: {
isCollaborating: boolean;
collaboratorCount: number;
onClick: () => void;
isInHamburgerMenu?: boolean;
}) => {
return (
<>
{isInHamburgerMenu ? (
<MenuItem
label={t("labels.liveCollaboration")}
dataTestId="collab-button"
icon={UsersIcon}
onClick={onClick}
isCollaborating={isCollaborating}
/>
) : (
<button
className={clsx("collab-button", { active: isCollaborating })}
type="button"
onClick={onClick}
style={{ position: "relative" }}
title={t("labels.liveCollaboration")}
>
{UsersIcon}
{collaboratorCount > 0 && (
<div className="CollabButton-collaborators">
{collaboratorCount}
</div>
)}
</button>
)}
</>
);
};
export default CollabButton;
+3 -6
View File
@@ -66,13 +66,10 @@ const getColor = (color: string): string | null => {
return color; return color;
} }
// testing for `#` first fixes a bug on Electron (more specfically, an return isValidColor(color)
// Obsidian popout window), where a hex color without `#` is (incorrectly)
// considered valid
return isValidColor(`#${color}`)
? `#${color}`
: isValidColor(color)
? color ? color
: isValidColor(`#${color}`)
? `#${color}`
: null; : null;
}; };
+9 -8
View File
@@ -3,9 +3,9 @@ import { Dialog, DialogProps } from "./Dialog";
import "./ConfirmDialog.scss"; import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { isMenuOpenAtom } from "./App";
import { isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { useExcalidrawSetAppState } from "./App";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void; onConfirm: () => void;
@@ -23,8 +23,9 @@ const ConfirmDialog = (props: Props) => {
className = "", className = "",
...rest ...rest
} = props; } = props;
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
return ( return (
<Dialog <Dialog
@@ -38,16 +39,16 @@ const ConfirmDialog = (props: Props) => {
<DialogActionButton <DialogActionButton
label={cancelText} label={cancelText}
onClick={() => { onClick={() => {
setAppState({ openMenu: null }); setIsMenuOpen(false);
setIsLibraryMenuOpen(false); setIsDropdownOpen(false);
onCancel(); onCancel();
}} }}
/> />
<DialogActionButton <DialogActionButton
label={confirmText} label={confirmText}
onClick={() => { onClick={() => {
setAppState({ openMenu: null }); setIsMenuOpen(false);
setIsLibraryMenuOpen(false); setIsDropdownOpen(false);
onConfirm(); onConfirm();
}} }}
actionType="danger" actionType="danger"
+11 -11
View File
@@ -19,7 +19,7 @@
color: var(--popup-text-color); color: var(--popup-text-color);
} }
.context-menu-item { .context-menu-option {
position: relative; position: relative;
width: 100%; width: 100%;
min-width: 9.5rem; min-width: 9.5rem;
@@ -43,16 +43,16 @@
} }
&.dangerous { &.dangerous {
.context-menu-item__label { .context-menu-option__label {
color: $oc-red-7; color: $oc-red-7;
} }
} }
.context-menu-item__label { .context-menu-option__label {
justify-self: start; justify-self: start;
margin-inline-end: 20px; margin-inline-end: 20px;
} }
.context-menu-item__shortcut { .context-menu-option__shortcut {
justify-self: end; justify-self: end;
opacity: 0.6; opacity: 0.6;
font-family: inherit; font-family: inherit;
@@ -60,37 +60,37 @@
} }
} }
.context-menu-item:hover { .context-menu-option:hover {
color: var(--popup-bg-color); color: var(--popup-bg-color);
background-color: var(--select-highlight-color); background-color: var(--select-highlight-color);
&.dangerous { &.dangerous {
.context-menu-item__label { .context-menu-option__label {
color: var(--popup-bg-color); color: var(--popup-bg-color);
} }
background-color: $oc-red-6; background-color: $oc-red-6;
} }
} }
.context-menu-item:focus { .context-menu-option:focus {
z-index: 1; z-index: 1;
} }
@include isMobile { @include isMobile {
.context-menu-item { .context-menu-option {
display: block; display: block;
.context-menu-item__label { .context-menu-option__label {
margin-inline-end: 0; margin-inline-end: 0;
} }
.context-menu-item__shortcut { .context-menu-option__shortcut {
display: none; display: none;
} }
} }
} }
.context-menu-item-separator { .context-menu-option-separator {
border: none; border: none;
border-top: 1px solid $oc-gray-5; border-top: 1px solid $oc-gray-5;
} }
+126 -101
View File
@@ -1,3 +1,4 @@
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx"; import clsx from "clsx";
import { Popover } from "./Popover"; import { Popover } from "./Popover";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -9,116 +10,140 @@ import {
} from "../actions/shortcuts"; } from "../actions/shortcuts";
import { Action } from "../actions/types"; import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { import { AppState } from "../types";
useExcalidrawAppState, import { NonDeletedExcalidrawElement } from "../element/types";
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import React from "react";
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action; export type ContextMenuOption = "separator" | Action;
export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[];
type ContextMenuProps = { type ContextMenuProps = {
actionManager: ActionManager; options: ContextMenuOption[];
items: ContextMenuItems; onCloseRequest?(): void;
top: number; top: number;
left: number; left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
elements: readonly NonDeletedExcalidrawElement[];
}; };
export const CONTEXT_MENU_SEPARATOR = "separator"; const ContextMenu = ({
options,
export const ContextMenu = React.memo( onCloseRequest,
({ actionManager, items, top, left }: ContextMenuProps) => { top,
const appState = useExcalidrawAppState(); left,
const setAppState = useExcalidrawSetAppState(); actionManager,
const elements = useExcalidrawElements(); appState,
elements,
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => { }: ContextMenuProps) => {
if ( return (
item && <Popover
(item === CONTEXT_MENU_SEPARATOR || onCloseRequest={onCloseRequest}
!item.predicate || top={top}
item.predicate( left={left}
elements, fitInViewport={true}
appState, offsetLeft={appState.offsetLeft}
actionManager.app.props, offsetTop={appState.offsetTop}
actionManager.app, viewportWidth={appState.width}
)) viewportHeight={appState.height}
) { >
acc.push(item); <ul
} className="context-menu"
return acc; onContextMenu={(event) => event.preventDefault()}
}, []);
return (
<Popover
onCloseRequest={() => setAppState({ contextMenu: null })}
top={top}
left={left}
fitInViewport={true}
offsetLeft={appState.offsetLeft}
offsetTop={appState.offsetTop}
viewportWidth={appState.width}
viewportHeight={appState.height}
> >
<ul {options.map((option, idx) => {
className="context-menu" if (option === "separator") {
onContextMenu={(event) => event.preventDefault()} return <hr key={idx} className="context-menu-option-separator" />;
> }
{filteredItems.map((item, idx) => {
if (item === CONTEXT_MENU_SEPARATOR) {
if (
!filteredItems[idx - 1] ||
filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR
) {
return null;
}
return <hr key={idx} className="context-menu-item-separator" />;
}
const actionName = item.name; const actionName = option.name;
let label = ""; let label = "";
if (item.contextItemLabel) { if (option.contextItemLabel) {
if (typeof item.contextItemLabel === "function") { if (typeof option.contextItemLabel === "function") {
label = t(item.contextItemLabel(elements, appState)); label = t(option.contextItemLabel(elements, appState));
} else { } else {
label = t(item.contextItemLabel); label = t(option.contextItemLabel);
}
} }
}
return ( return (
<li <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
key={idx} <button
data-testid={actionName} className={clsx("context-menu-option", {
onClick={() => { dangerous: actionName === "deleteSelectedElements",
// we need update state before executing the action in case checkmark: option.checked?.(appState),
// the action uses the appState it's being passed (that still })}
// contains a defined contextMenu) to return the next state. onClick={() =>
setAppState({ contextMenu: null }, () => { actionManager.executeAction(option, "contextMenu")
actionManager.executeAction(item, "contextMenu"); }
});
}}
> >
<button <div className="context-menu-option__label">{label}</div>
className={clsx("context-menu-item", { <kbd className="context-menu-option__shortcut">
dangerous: actionName === "deleteSelectedElements", {actionName
checkmark: item.checked?.(appState), ? getShortcutFromShortcutName(actionName as ShortcutName)
})} : ""}
> </kbd>
<div className="context-menu-item__label">{label}</div> </button>
<kbd className="context-menu-item__shortcut"> </li>
{actionName );
? getShortcutFromShortcutName(actionName as ShortcutName) })}
: ""} </ul>
</kbd> </Popover>
</button> );
</li> };
);
})} const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
</ul>
</Popover> const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
); let contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
return contextMenuNode;
}
contextMenuNode = document.createElement("div");
container
.querySelector(".excalidraw-contextMenuContainer")!
.appendChild(contextMenuNode);
contextMenuNodeByContainer.set(container, contextMenuNode);
return contextMenuNode;
};
type ContextMenuParams = {
options: (ContextMenuOption | false | null | undefined)[];
top: ContextMenuProps["top"];
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
container: HTMLElement;
elements: readonly NonDeletedExcalidrawElement[];
};
const handleClose = (container: HTMLElement) => {
const contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
unmountComponentAtNode(contextMenuNode);
contextMenuNode.remove();
contextMenuNodeByContainer.delete(container);
}
};
export default {
push(params: ContextMenuParams) {
const options = Array.of<ContextMenuOption>();
params.options.forEach((option) => {
if (option) {
options.push(option);
}
});
if (options.length) {
render(
<ContextMenu
top={params.top}
left={params.left}
options={options}
onCloseRequest={() => handleClose(params.container)}
actionManager={params.actionManager}
appState={params.appState}
elements={params.elements}
/>,
getContextMenuNode(params.container),
);
}
}, },
); };
+6 -10
View File
@@ -2,11 +2,7 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n"; import { t } from "../i18n";
import { import { useExcalidrawContainer, useDevice } from "../components/App";
useExcalidrawContainer,
useDevice,
useExcalidrawSetAppState,
} from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import "./Dialog.scss"; import "./Dialog.scss";
import { back, CloseIcon } from "./icons"; import { back, CloseIcon } from "./icons";
@@ -14,8 +10,8 @@ import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { AppState } from "../types"; import { AppState } from "../types";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { isMenuOpenAtom, isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
export interface DialogProps { export interface DialogProps {
children: React.ReactNode; children: React.ReactNode;
@@ -71,12 +67,12 @@ export const Dialog = (props: DialogProps) => {
return () => islandNode.removeEventListener("keydown", handleKeyDown); return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]); }, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState(); const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
const onClose = () => { const onClose = () => {
setAppState({ openMenu: null }); setIsMenuOpen(false);
setIsLibraryMenuOpen(false); setIsDropdownOpen(false);
(lastActiveElement as HTMLElement).focus(); (lastActiveElement as HTMLElement).focus();
props.onCloseRequest(); props.onCloseRequest();
}; };
@@ -1,8 +1,8 @@
import { shield } from "../../components/icons"; import { t } from "../i18n";
import { Tooltip } from "../../components/Tooltip"; import { shield } from "./icons";
import { t } from "../../i18n"; import { Tooltip } from "./Tooltip";
export const EncryptedIcon = () => ( const EncryptedIcon = () => (
<a <a
className="encrypted-icon tooltip" className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/" href="https://blog.excalidraw.com/end-to-end-encryption/"
@@ -15,3 +15,5 @@ export const EncryptedIcon = () => (
</Tooltip> </Tooltip>
</a> </a>
); );
export default EncryptedIcon;
-4
View File
@@ -96,10 +96,6 @@
width: 5rem; width: 5rem;
height: 5rem; height: 5rem;
margin: 0 0.2em; margin: 0 0.2em;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 1rem; border-radius: 1rem;
background-color: var(--button-color); background-color: var(--button-color);
+4 -6
View File
@@ -1,5 +1,3 @@
@import "../css/variables.module";
.excalidraw { .excalidraw {
.FixedSideContainer { .FixedSideContainer {
position: absolute; position: absolute;
@@ -11,10 +9,10 @@
} }
.FixedSideContainer_side_top { .FixedSideContainer_side_top {
left: var(--editor-container-padding); left: 1rem;
top: var(--editor-container-padding); top: 1rem;
right: var(--editor-container-padding); right: 1rem;
bottom: var(--editor-container-padding); bottom: 1rem;
z-index: 2; z-index: 2;
} }
@@ -1,36 +1,35 @@
import clsx from "clsx"; import clsx from "clsx";
import { actionShortcuts } from "../../actions"; import { ActionManager } from "../actions/manager";
import { ActionManager } from "../../actions/manager"; import { t } from "../i18n";
import { AppState } from "../../types"; import { AppState, ExcalidrawProps } from "../types";
import { import {
ExitZenModeAction, ExitZenModeAction,
FinalizeAction, FinalizeAction,
UndoRedoActions, UndoRedoActions,
ZoomActions, ZoomActions,
} from "../Actions"; } from "./Actions";
import { useDevice } from "../App"; import { useDevice } from "./App";
import { useTunnels } from "../context/tunnels"; import { WelcomeScreenHelpArrow } from "./icons";
import { HelpButton } from "../HelpButton"; import { Section } from "./Section";
import { Section } from "../Section"; import Stack from "./Stack";
import Stack from "../Stack"; import WelcomeScreenDecor from "./WelcomeScreenDecor";
const Footer = ({ const Footer = ({
appState, appState,
actionManager, actionManager,
renderCustomFooter,
showExitZenModeBtn, showExitZenModeBtn,
renderWelcomeScreen, renderWelcomeScreen,
}: { }: {
appState: AppState; appState: AppState;
actionManager: ActionManager; actionManager: ActionManager;
renderCustomFooter?: ExcalidrawProps["renderFooter"];
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
}) => { }) => {
const { footerCenterTunnel, welcomeScreenHelpHintTunnel } = useTunnels();
const device = useDevice(); const device = useDevice();
const showFinalize = const showFinalize =
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen; !appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
return ( return (
<footer <footer
role="contentinfo" role="contentinfo"
@@ -70,17 +69,33 @@ const Footer = ({
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>
<footerCenterTunnel.Out /> <div
className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
},
)}
>
{renderCustomFooter?.(false, appState)}
</div>
<div <div
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", { className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
"transition-right disable-pointerEvents": appState.zenModeEnabled, "transition-right disable-pointerEvents": appState.zenModeEnabled,
})} })}
> >
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />} <WelcomeScreenDecor
<HelpButton shouldRender={renderWelcomeScreen && !appState.isLoading}
onClick={() => actionManager.executeAction(actionShortcuts)} >
/> <div className="virgil WelcomeScreen-decor WelcomeScreen-decor--help-pointer">
<div>{t("welcomeScreen.helpHints")}</div>
{WelcomeScreenHelpArrow}
</div>
</WelcomeScreenDecor>
{actionManager.renderAction("toggleShortcuts")}
</div> </div>
</div> </div>
<ExitZenModeAction <ExitZenModeAction
@@ -92,4 +107,3 @@ const Footer = ({
}; };
export default Footer; export default Footer;
Footer.displayName = "Footer";
-32
View File
@@ -1,32 +0,0 @@
import "./ToolIcon.scss";
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { handIcon } from "./icons";
import { KEYS } from "../keys";
type LockIconProps = {
title?: string;
name?: string;
checked: boolean;
onChange?(): void;
isMobile?: boolean;
};
export const HandButton = (props: LockIconProps) => {
return (
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={handIcon}
name="editor-current-shape"
checked={props.checked}
title={`${props.title} — H`}
keyBindingLabel={!props.isMobile ? KEYS.H.toLocaleUpperCase() : undefined}
aria-label={`${props.title} — H`}
aria-keyshortcuts={KEYS.H}
data-testid={`toolbar-hand`}
onChange={() => props.onChange?.()}
/>
);
};
+8 -36
View File
@@ -1,12 +1,10 @@
import React from "react"; import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { isDarwin, isWindows, KEYS } from "../keys";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import "./HelpDialog.scss"; import "./HelpDialog.scss";
import { ExternalLinkIcon } from "./icons"; import { ExternalLinkIcon } from "./icons";
import { probablySupportsClipboardBlob } from "../clipboard";
import { isDarwin, isFirefox, isWindows } from "../constants";
const Header = () => ( const Header = () => (
<div className="HelpDialog__header"> <div className="HelpDialog__header">
@@ -69,10 +67,6 @@ function* intersperse(as: JSX.Element[][], delim: string | null) {
} }
} }
const upperCaseSingleChars = (str: string) => {
return str.replace(/\b[a-z]\b/, (c) => c.toUpperCase());
};
const Shortcut = ({ const Shortcut = ({
label, label,
shortcuts, shortcuts,
@@ -87,9 +81,7 @@ const Shortcut = ({
? [...shortcut.slice(0, -2).split("+"), "+"] ? [...shortcut.slice(0, -2).split("+"), "+"]
: shortcut.split("+"); : shortcut.split("+");
return keys.map((key) => ( return keys.map((key) => <ShortcutKey key={key}>{key}</ShortcutKey>);
<ShortcutKey key={key}>{upperCaseSingleChars(key)}</ShortcutKey>
));
}); });
return ( return (
@@ -126,7 +118,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
className="HelpDialog__island--tools" className="HelpDialog__island--tools"
caption={t("helpDialog.tools")} caption={t("helpDialog.tools")}
> >
<Shortcut label={t("toolBar.hand")} shortcuts={[KEYS.H]} />
<Shortcut <Shortcut
label={t("toolBar.selection")} label={t("toolBar.selection")}
shortcuts={[KEYS.V, KEYS["1"]]} shortcuts={[KEYS.V, KEYS["1"]]}
@@ -149,11 +140,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/> />
<Shortcut <Shortcut
label={t("toolBar.line")} label={t("toolBar.line")}
shortcuts={[KEYS.L, KEYS["6"]]} shortcuts={[KEYS.P, KEYS["6"]]}
/> />
<Shortcut <Shortcut
label={t("toolBar.freedraw")} label={t("toolBar.freedraw")}
shortcuts={[KEYS.P, KEYS["7"]]} shortcuts={["Shift + P", KEYS["7"]]}
/> />
<Shortcut <Shortcut
label={t("toolBar.text")} label={t("toolBar.text")}
@@ -166,10 +157,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/> />
<Shortcut <Shortcut
label={t("helpDialog.editSelectedShape")} label={t("helpDialog.editSelectedShape")}
shortcuts={[ shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]}
getShortcutKey("CtrlOrCmd+Enter"),
getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`),
]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.textNewLine")} label={t("helpDialog.textNewLine")}
@@ -239,14 +227,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("helpDialog.zoomToSelection")} label={t("helpDialog.zoomToSelection")}
shortcuts={["Shift+2"]} shortcuts={["Shift+2"]}
/> />
<Shortcut
label={t("helpDialog.movePageUpDown")}
shortcuts={["PgUp/PgDn"]}
/>
<Shortcut
label={t("helpDialog.movePageLeftRight")}
shortcuts={["Shift+PgUp/PgDn"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} /> <Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut <Shortcut
label={t("buttons.zenMode")} label={t("buttons.zenMode")}
@@ -310,17 +290,9 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("CtrlOrCmd+V")]} shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/> />
<Shortcut <Shortcut
label={t("labels.pasteAsPlaintext")} label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]} shortcuts={[getShortcutKey("Shift+Alt+C")]}
/> />
{/* firefox supports clipboard API under a flag, so we'll
show users what they can do in the error message */}
{(probablySupportsClipboardBlob || isFirefox) && (
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}
/>
)}
<Shortcut <Shortcut
label={t("labels.copyStyles")} label={t("labels.copyStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]} shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
@@ -331,7 +303,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/> />
<Shortcut <Shortcut
label={t("labels.delete")} label={t("labels.delete")}
shortcuts={[getShortcutKey("Delete")]} shortcuts={[getShortcutKey("Del")]}
/> />
<Shortcut <Shortcut
label={t("labels.sendToBack")} label={t("labels.sendToBack")}
+20 -11
View File
@@ -1,7 +1,9 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { probablySupportsClipboardBlob } from "../clipboard"; import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob"; import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export"; import { exportToCanvas } from "../scene/export";
@@ -12,7 +14,7 @@ import Stack from "./Stack";
import "./ExportDialog.scss"; import "./ExportDialog.scss";
import OpenColor from "open-color"; import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants"; import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
@@ -31,6 +33,19 @@ export const ErrorCanvasPreview = () => {
); );
}; };
const renderPreview = (
content: HTMLCanvasElement | Error,
previewNode: HTMLDivElement,
) => {
unmountComponentAtNode(previewNode);
previewNode.innerHTML = "";
if (content instanceof HTMLCanvasElement) {
previewNode.appendChild(content);
} else {
render(<ErrorCanvasPreview />, previewNode);
}
};
export type ExportCB = ( export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
scale?: number, scale?: number,
@@ -84,7 +99,6 @@ const ImageExportModal = ({
const [exportSelected, setExportSelected] = useState(someElementIsSelected); const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState; const { exportBackground, viewBackgroundColor } = appState;
const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected const exportedElements = exportSelected
? getSelectedElements(elements, appState, true) ? getSelectedElements(elements, appState, true)
@@ -105,16 +119,15 @@ const ImageExportModal = ({
exportPadding, exportPadding,
}) })
.then((canvas) => { .then((canvas) => {
setRenderError(null);
// if converting to blob fails, there's some problem that will // if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big) // likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas).then(() => { return canvasToBlob(canvas).then(() => {
previewNode.replaceChildren(canvas); renderPreview(canvas, previewNode);
}); });
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
setRenderError(error); renderPreview(new CanvasError(), previewNode);
}); });
}, [ }, [
appState, appState,
@@ -127,9 +140,7 @@ const ImageExportModal = ({
return ( return (
<div className="ExportDialog"> <div className="ExportDialog">
<div className="ExportDialog__preview" ref={previewRef}> <div className="ExportDialog__preview" ref={previewRef} />
{renderError && <ErrorCanvasPreview />}
</div>
{supportsContextFilters && {supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")} actionManager.renderAction("exportWithDarkMode")}
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
@@ -190,9 +201,7 @@ const ImageExportModal = ({
> >
SVG SVG
</ExportButton> </ExportButton>
{/* firefox supports clipboard API under a flag, {probablySupportsClipboardBlob && (
so let's throw and tell people what they can do */}
{(probablySupportsClipboardBlob || isFirefox) && (
<ExportButton <ExportButton
title={t("buttons.copyPngToClipboard")} title={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements)} onClick={() => onExportToClipboard(exportedElements)}
+16 -7
View File
@@ -1,10 +1,10 @@
import React from "react"; import React, { useState } from "react";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState, ExportOpts, BinaryFiles } from "../types"; import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { exportToFileIcon, LinkIcon } from "./icons"; import { ExportIcon, exportToFileIcon, LinkIcon } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { actionSaveFileToDisk } from "../actions/actionExport"; import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card"; import { Card } from "./Card";
@@ -14,6 +14,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getFrame } from "../utils"; import { getFrame } from "../utils";
import MenuItem from "./MenuItem";
export type ExportCB = ( export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -93,7 +94,6 @@ export const JSONExportDialog = ({
actionManager, actionManager,
exportOpts, exportOpts,
canvas, canvas,
setAppState,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: AppState;
@@ -101,15 +101,24 @@ export const JSONExportDialog = ({
actionManager: ActionManager; actionManager: ActionManager;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
}) => { }) => {
const [modalIsShown, setModalIsShown] = useState(false);
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
setAppState({ openDialog: null }); setModalIsShown(false);
}, [setAppState]); }, []);
return ( return (
<> <>
{appState.openDialog === "jsonExport" && ( <MenuItem
icon={ExportIcon}
label={t("buttons.export")}
onClick={() => {
setModalIsShown(true);
}}
dataTestId="json-export-button"
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}> <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal <JSONExportModal
elements={elements} elements={elements}
+6
View File
@@ -80,6 +80,12 @@
} }
} }
.layer-ui__wrapper__footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
}
.layer-ui__wrapper__footer-left, .layer-ui__wrapper__footer-left,
.layer-ui__wrapper__footer-right, .layer-ui__wrapper__footer-right,
.disable-zen-mode--visible { .disable-zen-mode--visible {
+175 -115
View File
@@ -9,8 +9,9 @@ import { Language, t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
import { isShallowEqual, muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
@@ -34,18 +35,26 @@ import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useDevice } from "../components/App"; import { isMenuOpenAtom, useDevice } from "../components/App";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats"; import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer"; import Footer from "./Footer";
import {
ExportImageIcon,
HamburgerMenuIcon,
WelcomeScreenMenuArrow,
WelcomeScreenTopToolbarArrow,
} from "./icons";
import { MenuLinks, Separator } from "./MenuUtils";
import { useOutsideClickHook } from "../hooks/useOutsideClick";
import WelcomeScreen from "./WelcomeScreen";
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar"; import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import { Provider, useAtom } from "jotai"; import { useAtom } from "jotai";
import MainMenu from "./main-menu/MainMenu"; import { LanguageList } from "../excalidraw-app/components/LanguageList";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import WelcomeScreenDecor from "./WelcomeScreenDecor";
import { HandButton } from "./HandButton"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { isHandToolActive } from "../appState"; import MenuItem from "./MenuItem";
import { TunnelsContext, useInitializeTunnels } from "./context/tunnels";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -54,13 +63,15 @@ interface LayerUIProps {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void;
onLockToggle: () => void; onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: () => void; onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
langCode: Language["code"]; langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomFooter?: ExcalidrawProps["renderFooter"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderCustomSidebar?: ExcalidrawProps["renderSidebar"]; renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -70,35 +81,7 @@ interface LayerUIProps {
id: string; id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
children?: React.ReactNode;
} }
const DefaultMainMenu: React.FC<{
UIOptions: AppProps["UIOptions"];
}> = ({ UIOptions }) => {
return (
<MainMenu __fallback>
<MainMenu.DefaultItems.LoadScene />
<MainMenu.DefaultItems.SaveToActiveFile />
{/* FIXME we should to test for this inside the item itself */}
{UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
{/* FIXME we should to test for this inside the item itself */}
{UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage />
)}
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials />
</MainMenu.Group>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
);
};
const LayerUI = ({ const LayerUI = ({
actionManager, actionManager,
appState, appState,
@@ -106,12 +89,14 @@ const LayerUI = ({
setAppState, setAppState,
elements, elements,
canvas, canvas,
onCollabButtonClick,
onLockToggle, onLockToggle,
onHandToolToggle,
onPenModeToggle, onPenModeToggle,
onInsertElements, onInsertElements,
showExitZenModeBtn, showExitZenModeBtn,
isCollaborating,
renderTopRightUI, renderTopRightUI,
renderCustomFooter,
renderCustomStats, renderCustomStats,
renderCustomSidebar, renderCustomSidebar,
libraryReturnUrl, libraryReturnUrl,
@@ -121,12 +106,9 @@ const LayerUI = ({
id, id,
onImageAction, onImageAction,
renderWelcomeScreen, renderWelcomeScreen,
children,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels();
const renderJSONExportDialog = () => { const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) { if (!UIOptions.canvasActions.export) {
return null; return null;
@@ -140,7 +122,6 @@ const LayerUI = ({
actionManager={actionManager} actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export} exportOpts={UIOptions.canvasActions.export}
canvas={canvas} canvas={canvas}
setAppState={setAppState}
/> />
); );
}; };
@@ -194,12 +175,100 @@ const LayerUI = ({
); );
}; };
const [isMenuOpen, setIsMenuOpen] = useAtom(isMenuOpenAtom);
const menuRef = useOutsideClickHook(() => setIsMenuOpen(false));
const renderCanvasActions = () => ( const renderCanvasActions = () => (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{/* wrapping to Fragment stops React from occasionally complaining <WelcomeScreenDecor
about identical Keys */} shouldRender={renderWelcomeScreen && !appState.isLoading}
<tunnels.mainMenuTunnel.Out /> >
{renderWelcomeScreen && <tunnels.welcomeScreenMenuHintTunnel.Out />} <div className="virgil WelcomeScreen-decor WelcomeScreen-decor--menu-pointer">
{WelcomeScreenMenuArrow}
<div>{t("welcomeScreen.menuHints")}</div>
</div>
</WelcomeScreenDecor>
<button
data-prevent-outside-click
className={clsx("menu-button", "zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
onClick={() => setIsMenuOpen(!isMenuOpen)}
type="button"
data-testid="menu-button"
>
{HamburgerMenuIcon}
</button>
{isMenuOpen && (
<div
ref={menuRef}
style={{ position: "absolute", top: "100%", marginTop: ".25rem" }}
>
<Section heading="canvasActions">
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island
className="menu-container"
padding={2}
style={{ zIndex: 1 }}
>
{!appState.viewModeEnabled &&
actionManager.renderAction("loadScene")}
{/* // TODO barnabasmolnar/editor-redesign */}
{/* is this fine here? */}
{appState.fileHandle &&
actionManager.renderAction("saveToActiveFile")}
{renderJSONExportDialog()}
{UIOptions.canvasActions.saveAsImage && (
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
/>
)}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{actionManager.renderAction("toggleShortcuts", undefined, true)}
{!appState.viewModeEnabled &&
actionManager.renderAction("clearCanvas")}
<Separator />
<MenuLinks />
<Separator />
<div
style={{
display: "flex",
flexDirection: "column",
rowGap: ".5rem",
}}
>
<div>{actionManager.renderAction("toggleTheme")}</div>
<div style={{ padding: "0 0.625rem" }}>
<LanguageList style={{ width: "100%" }} />
</div>
{!appState.viewModeEnabled && (
<div>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
)}
</div>
</Island>
</Section>
</div>
)}
</div> </div>
); );
@@ -236,6 +305,9 @@ const LayerUI = ({
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
{renderWelcomeScreen && !appState.isLoading && (
<WelcomeScreen appState={appState} actionManager={actionManager} />
)}
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col <Stack.Col
gap={6} gap={6}
@@ -250,9 +322,17 @@ const LayerUI = ({
<Section heading="shapes" className="shapes-section"> <Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => ( {(heading: React.ReactNode) => (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderWelcomeScreen && ( <WelcomeScreenDecor
<tunnels.welcomeScreenToolbarHintTunnel.Out /> shouldRender={renderWelcomeScreen && !appState.isLoading}
)} >
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--top-toolbar-pointer">
<div className="WelcomeScreen-decor--top-toolbar-pointer__label">
{t("welcomeScreen.toolbarHints")}
</div>
{WelcomeScreenTopToolbarArrow}
</div>
</WelcomeScreenDecor>
<Stack.Col gap={4} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row
gap={1} gap={1}
@@ -282,20 +362,13 @@ const LayerUI = ({
penDetected={appState.penDetected} penDetected={appState.penDetected}
/> />
<LockButton <LockButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.activeTool.locked} checked={appState.activeTool.locked}
onChange={onLockToggle} onChange={() => onLockToggle()}
title={t("toolBar.lock")} title={t("toolBar.lock")}
/> />
<div className="App-toolbar__divider"></div> <div className="App-toolbar__divider"></div>
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher <ShapesSwitcher
appState={appState} appState={appState}
canvas={canvas} canvas={canvas}
@@ -307,6 +380,9 @@ const LayerUI = ({
}); });
}} }}
/> />
{/* {actionManager.renderAction("eraser", {
// size: "small",
})} */}
</Stack.Row> </Stack.Row>
</Island> </Island>
</Stack.Row> </Stack.Row>
@@ -323,7 +399,18 @@ const LayerUI = ({
}, },
)} )}
> >
<UserList collaborators={appState.collaborators} /> <UserList
collaborators={appState.collaborators}
actionManager={actionManager}
/>
{onCollabButtonClick && (
<CollabButton
isInHamburgerMenu={false}
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{renderTopRightUI?.(device.isMobile, appState)} {renderTopRightUI?.(device.isMobile, appState)}
{!appState.viewModeEnabled && ( {!appState.viewModeEnabled && (
<LibraryButton appState={appState} setAppState={setAppState} /> <LibraryButton appState={appState} setAppState={setAppState} />
@@ -351,18 +438,8 @@ const LayerUI = ({
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope); const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
const layerUIJSX = ( return (
<> <>
{/* ------------------------- tunneled UI ---------------------------- */}
{/* make sure we render host app components first so that we can detect
them first on initial render to optimize layout shift */}
{children}
{/* render component fallbacks. Can be rendered anywhere as they'll be
tunneled away. We only render tunneled components that actually
have defaults when host do not render anything. */}
<DefaultMainMenu UIOptions={UIOptions} />
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />} {appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && ( {appState.errorMessage && (
<ErrorDialog <ErrorDialog
@@ -377,9 +454,7 @@ const LayerUI = ({
}} }}
/> />
)} )}
<ActiveConfirmDialog />
{renderImageExportDialog()} {renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && ( {appState.pasteDialog.shown && (
<PasteChartDialog <PasteChartDialog
setAppState={setAppState} setAppState={setAppState}
@@ -394,16 +469,19 @@ const LayerUI = ({
)} )}
{device.isMobile && ( {device.isMobile && (
<MobileMenu <MobileMenu
renderWelcomeScreen={renderWelcomeScreen}
appState={appState} appState={appState}
elements={elements} elements={elements}
actionManager={actionManager} actionManager={actionManager}
renderJSONExportDialog={renderJSONExportDialog} renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog} renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState} setAppState={setAppState}
onLockToggle={onLockToggle} onCollabButtonClick={onCollabButtonClick}
onHandToolToggle={onHandToolToggle} onLockToggle={() => onLockToggle()}
onPenModeToggle={onPenModeToggle} onPenModeToggle={onPenModeToggle}
canvas={canvas} canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
onImageAction={onImageAction} onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats} renderCustomStats={renderCustomStats}
@@ -431,13 +509,13 @@ const LayerUI = ({
: {} : {}
} }
> >
{renderWelcomeScreen && <tunnels.welcomeScreenCenterTunnel.Out />}
{renderFixedSideContainer()} {renderFixedSideContainer()}
<Footer <Footer
renderWelcomeScreen={renderWelcomeScreen}
appState={appState} appState={appState}
actionManager={actionManager} actionManager={actionManager}
renderCustomFooter={renderCustomFooter}
showExitZenModeBtn={showExitZenModeBtn} showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen}
/> />
{appState.showStats && ( {appState.showStats && (
<Stats <Stats
@@ -468,49 +546,31 @@ const LayerUI = ({
)} )}
</> </>
); );
return (
<Provider scope={tunnels.jotaiScope}>
<TunnelsContext.Provider value={tunnels}>
{layerUIJSX}
</TunnelsContext.Provider>
</Provider>
);
}; };
const stripIrrelevantAppStateProps = ( const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
appState: AppState, const getNecessaryObj = (appState: AppState): Partial<AppState> => {
): Partial<AppState> => { const {
const { suggestedBindings, startBoundElement, cursorButton, ...ret } = suggestedBindings,
appState; startBoundElement: boundElement,
return ret; ...ret
}; } = appState;
return ret;
};
const prevAppState = getNecessaryObj(prev.appState);
const nextAppState = getNecessaryObj(next.appState);
const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => { const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
// short-circuit early
if (prevProps.children !== nextProps.children) {
return false;
}
const {
canvas: _prevCanvas,
// not stable, but shouldn't matter in our case
onInsertElements: _prevOnInsertElements,
appState: prevAppState,
...prev
} = prevProps;
const {
canvas: _nextCanvas,
onInsertElements: _nextOnInsertElements,
appState: nextAppState,
...next
} = nextProps;
return ( return (
isShallowEqual( prev.renderCustomFooter === next.renderCustomFooter &&
stripIrrelevantAppStateProps(prevAppState), prev.renderTopRightUI === next.renderTopRightUI &&
stripIrrelevantAppStateProps(nextAppState), prev.renderCustomStats === next.renderCustomStats &&
) && isShallowEqual(prev, next) prev.renderCustomSidebar === next.renderCustomSidebar &&
prev.langCode === next.langCode &&
prev.elements === next.elements &&
prev.files === next.files &&
keys.every((key) => prevAppState[key] === nextAppState[key])
); );
}; };
-23
View File
@@ -129,27 +129,4 @@
padding-right: 0; padding-right: 0;
} }
} }
.layer-ui__sidebar__header .dropdown-menu {
&.dropdown-menu--mobile {
top: 100%;
}
.dropdown-menu-container {
--gap: 0;
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
width: 196px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
}
}
} }
+77 -82
View File
@@ -13,15 +13,14 @@ import {
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { atom, useAtom } from "jotai"; import { useAtom } from "jotai";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary"; import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { useOutsideClickHook } from "../hooks/useOutsideClick";
import DropdownMenu from "./dropdownMenu/DropdownMenu"; import MenuItem from "./MenuItem";
import { isDropdownOpenAtom } from "./App";
export const isLibraryMenuOpenAtom = atom(false);
const getSelectedItems = ( const getSelectedItems = (
libraryItems: LibraryItems, libraryItems: LibraryItems,
@@ -46,9 +45,7 @@ export const LibraryMenuHeader: React.FC<{
appState, appState,
}) => { }) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom,
);
const renderRemoveLibAlert = useCallback(() => { const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length const content = selectedItems.length
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length }) ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
@@ -176,87 +173,85 @@ export const LibraryMenuHeader: React.FC<{
}); });
}; };
const renderLibraryMenu = () => { const [isDropdownOpen, setIsDropdownOpen] = useAtom(isDropdownOpenAtom);
return ( const dropdownRef = useOutsideClickHook(() => setIsDropdownOpen(false));
<DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger
className="Sidebar__dropdown-btn"
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
>
{DotsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsLibraryMenuOpen(false)}
onSelect={() => setIsLibraryMenuOpen(false)}
className="library-menu"
>
{!itemsSelected && (
<DropdownMenu.Item
onSelect={onLibraryImport}
icon={LoadIcon}
data-testid="lib-dropdown--load"
>
{t("buttons.load")}
</DropdownMenu.Item>
)}
{!!items.length && (
<DropdownMenu.Item
onSelect={onLibraryExport}
icon={ExportIcon}
data-testid="lib-dropdown--export"
>
{t("buttons.export")}
</DropdownMenu.Item>
)}
{!!items.length && (
<DropdownMenu.Item
onSelect={() => setShowRemoveLibAlert(true)}
icon={TrashIcon}
>
{resetLabel}
</DropdownMenu.Item>
)}
{itemsSelected && (
<DropdownMenu.Item
icon={publishIcon}
onSelect={() => setShowPublishLibraryDialog(true)}
data-testid="lib-dropdown--remove"
>
{t("buttons.publishLibrary")}
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu>
);
};
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderLibraryMenu()} <button
type="button"
className="Sidebar__dropdown-btn"
data-prevent-outside-click
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
{DotsIcon}
</button>
{selectedItems.length > 0 && ( {selectedItems.length > 0 && (
<div className="library-actions-counter">{selectedItems.length}</div> <div className="library-actions-counter">{selectedItems.length}</div>
)} )}
{showRemoveLibAlert && renderRemoveLibAlert()}
{showPublishLibraryDialog && ( {isDropdownOpen && (
<PublishLibrary <div
onClose={() => setShowPublishLibraryDialog(false)} className="Sidebar__dropdown-content menu-container"
libraryItems={getSelectedItems( ref={dropdownRef}
libraryItemsData.libraryItems, >
selectedItems, {!itemsSelected && (
<MenuItem
label={t("buttons.load")}
icon={LoadIcon}
dataTestId="lib-dropdown--load"
onClick={onLibraryImport}
/>
)} )}
appState={appState} {showRemoveLibAlert && renderRemoveLibAlert()}
onSuccess={(data) => {showPublishLibraryDialog && (
onPublishLibSuccess(data, libraryItemsData.libraryItems) <PublishLibrary
} onClose={() => setShowPublishLibraryDialog(false)}
onError={(error) => window.alert(error)} libraryItems={getSelectedItems(
updateItemsInStorage={() => libraryItemsData.libraryItems,
library.setLibrary(libraryItemsData.libraryItems) selectedItems,
} )}
onRemove={(id: string) => appState={appState}
onSelectItems(selectedItems.filter((_id) => _id !== id)) onSuccess={(data) =>
} onPublishLibSuccess(data, libraryItemsData.libraryItems)
/> }
onError={(error) => window.alert(error)}
updateItemsInStorage={() =>
library.setLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) =>
onSelectItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
{!!items.length && (
<>
<MenuItem
label={t("buttons.export")}
icon={ExportIcon}
onClick={onLibraryExport}
dataTestId="lib-dropdown--export"
/>
<MenuItem
label={resetLabel}
icon={TrashIcon}
onClick={() => setShowRemoveLibAlert(true)}
dataTestId="lib-dropdown--remove"
/>
</>
)}
{itemsSelected && (
<MenuItem
label={t("buttons.publishLibrary")}
icon={publishIcon}
dataTestId="lib-dropdown--publish"
onClick={() => setShowPublishLibraryDialog(true)}
/>
)}
</div>
)} )}
{publishLibSuccess && renderPublishSuccess()}
</div> </div>
); );
}; };
-1
View File
@@ -44,7 +44,6 @@ export const LibraryUnit = ({
}, },
null, null,
); );
svg.querySelector(".style-fonts")?.remove();
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
+1
View File
@@ -9,6 +9,7 @@ type LockIconProps = {
name?: string; name?: string;
checked: boolean; checked: boolean;
onChange?(): void; onChange?(): void;
zenModeEnabled?: boolean;
isMobile?: boolean; isMobile?: boolean;
}; };
+85
View File
@@ -0,0 +1,85 @@
@import "../css/variables.module";
.excalidraw {
.menu-container {
background-color: #fff !important;
max-height: calc(100vh - 150px);
overflow-y: auto;
}
.menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
.menu-item {
display: flex;
background-color: transparent;
border: 0;
align-items: center;
padding: 0 0.625rem;
height: 2rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
cursor: pointer;
border-radius: var(--border-radius-md);
width: 100%;
box-sizing: border-box;
font-weight: normal;
font-family: inherit;
@media screen and (min-width: 1921px) {
height: 2.25rem;
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__shortcut {
margin-inline-start: auto;
opacity: 0.5;
}
&:hover {
background-color: var(--button-hover);
text-decoration: none;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
&.active-collab {
background-color: #ecfdf5;
color: #064e3c;
}
}
&.theme--dark {
.menu-item {
color: var(--color-gray-40);
&.active-collab {
background-color: #064e3c;
color: #ecfdf5;
}
}
.menu-container {
background-color: var(--color-gray-90) !important;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import clsx from "clsx";
import "./Menu.scss";
interface MenuProps {
icon: JSX.Element;
onClick: () => void;
label: string;
dataTestId: string;
shortcut?: string;
isCollaborating?: boolean;
}
const MenuItem = ({
icon,
onClick,
label,
dataTestId,
shortcut,
isCollaborating,
}: MenuProps) => {
return (
<button
className={clsx("menu-item", { "active-collab": isCollaborating })}
aria-label={label}
onClick={onClick}
data-testid={dataTestId}
title={label}
type="button"
>
<div className="menu-item__icon">{icon}</div>
<div className="menu-item__text">{label}</div>
{shortcut && <div className="menu-item__shortcut">{shortcut}</div>}
</button>
);
};
export default MenuItem;
+53
View File
@@ -0,0 +1,53 @@
import { GithubIcon, DiscordIcon, PlusPromoIcon, TwitterIcon } from "./icons";
export const MenuLinks = () => (
<>
<a
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
target="_blank"
rel="noreferrer"
className="menu-item"
style={{ color: "var(--color-promo)" }}
>
<div className="menu-item__icon">{PlusPromoIcon}</div>
<div className="menu-item__text">Excalidraw+</div>
</a>
<a
className="menu-item"
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{GithubIcon}</div>
<div className="menu-item__text">GitHub</div>
</a>
<a
className="menu-item"
target="_blank"
href="https://discord.gg/UexuTaE"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{DiscordIcon}</div>
<div className="menu-item__text">Discord</div>
</a>
<a
className="menu-item"
target="_blank"
href="https://twitter.com/excalidraw"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{TwitterIcon}</div>
<div className="menu-item__text">Twitter</div>
</a>
</>
);
export const Separator = () => (
<div
style={{
height: "1px",
backgroundColor: "var(--default-border-color)",
margin: ".5rem 0",
}}
/>
);
+120 -26
View File
@@ -11,15 +11,18 @@ import { HintViewer } from "./HintViewer";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section"; import { Section } from "./Section";
import CollabButton from "./CollabButton";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { UserList } from "./UserList";
import { LibraryButton } from "./LibraryButton"; import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import { HandButton } from "./HandButton"; import { MenuLinks, Separator } from "./MenuUtils";
import { isHandToolActive } from "../appState"; import WelcomeScreen from "./WelcomeScreen";
import { useTunnels } from "./context/tunnels"; import MenuItem from "./MenuItem";
import { ExportImageIcon } from "./icons";
type MobileMenuProps = { type MobileMenuProps = {
appState: AppState; appState: AppState;
@@ -28,11 +31,15 @@ type MobileMenuProps = {
renderImageExportDialog: () => React.ReactNode; renderImageExportDialog: () => React.ReactNode;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void;
onLockToggle: () => void; onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: () => void; onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: ( renderTopRightUI?: (
isMobile: boolean, isMobile: boolean,
@@ -41,28 +48,35 @@ type MobileMenuProps = {
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
device: Device; device: Device;
renderWelcomeScreen?: boolean;
}; };
export const MobileMenu = ({ export const MobileMenu = ({
appState, appState,
elements, elements,
actionManager, actionManager,
renderJSONExportDialog,
renderImageExportDialog,
setAppState, setAppState,
onCollabButtonClick,
onLockToggle, onLockToggle,
onHandToolToggle,
onPenModeToggle, onPenModeToggle,
canvas, canvas,
isCollaborating,
renderCustomFooter,
onImageAction, onImageAction,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
renderSidebars, renderSidebars,
device, device,
renderWelcomeScreen,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels();
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
<FixedSideContainer side="top" className="App-top-bar"> <FixedSideContainer side="top" className="App-top-bar">
<welcomeScreenCenterTunnel.Out /> {renderWelcomeScreen && !appState.isLoading && (
<WelcomeScreen appState={appState} actionManager={actionManager} />
)}
<Section heading="shapes"> <Section heading="shapes">
{(heading: React.ReactNode) => ( {(heading: React.ReactNode) => (
<Stack.Col gap={4} align="center"> <Stack.Col gap={4} align="center">
@@ -70,6 +84,20 @@ export const MobileMenu = ({
<Island padding={1} className="App-toolbar App-toolbar--mobile"> <Island padding={1} className="App-toolbar App-toolbar--mobile">
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
{/* <PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
<div className="App-toolbar__divider"></div> */}
<ShapesSwitcher <ShapesSwitcher
appState={appState} appState={appState}
canvas={canvas} canvas={canvas}
@@ -85,19 +113,13 @@ export const MobileMenu = ({
</Island> </Island>
{renderTopRightUI && renderTopRightUI(true, appState)} {renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container"> <div className="mobile-misc-tools-container">
{!appState.viewModeEnabled && (
<LibraryButton
appState={appState}
setAppState={setAppState}
isMobile
/>
)}
<PenModeButton <PenModeButton
checked={appState.penMode} checked={appState.penMode}
onChange={onPenModeToggle} onChange={onPenModeToggle}
title={t("toolBar.penMode")} title={t("toolBar.penMode")}
isMobile isMobile
penDetected={appState.penDetected} penDetected={appState.penDetected}
// penDetected={true}
/> />
<LockButton <LockButton
checked={appState.activeTool.locked} checked={appState.activeTool.locked}
@@ -105,12 +127,13 @@ export const MobileMenu = ({
title={t("toolBar.lock")} title={t("toolBar.lock")}
isMobile isMobile
/> />
<HandButton {!appState.viewModeEnabled && (
checked={isHandToolActive(appState)} <LibraryButton
onChange={() => onHandToolToggle()} appState={appState}
title={t("toolBar.hand")} setAppState={setAppState}
isMobile isMobile
/> />
)}
</div> </div>
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
@@ -130,14 +153,14 @@ export const MobileMenu = ({
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<mainMenuTunnel.Out /> {actionManager.renderAction("toggleCanvasMenu")}
</div> </div>
); );
} }
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<mainMenuTunnel.Out /> {actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")} {actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")} {actionManager.renderAction("undo")}
{actionManager.renderAction("redo")} {actionManager.renderAction("redo")}
@@ -149,6 +172,58 @@ export const MobileMenu = ({
); );
}; };
const renderCanvasActions = () => {
if (appState.viewModeEnabled) {
return (
<>
{renderJSONExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
/>
{renderImageExportDialog()}
</>
);
}
return (
<>
{!appState.viewModeEnabled && actionManager.renderAction("loadScene")}
{renderJSONExportDialog()}
{renderImageExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
/>
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{actionManager.renderAction("toggleShortcuts", undefined, true)}
{!appState.viewModeEnabled && actionManager.renderAction("clearCanvas")}
<Separator />
<MenuLinks />
<Separator />
{!appState.viewModeEnabled && (
<div style={{ marginBottom: ".5rem" }}>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
)}
{actionManager.renderAction("toggleTheme")}
</>
);
};
return ( return (
<> <>
{renderSidebars()} {renderSidebars()}
@@ -173,9 +248,28 @@ export const MobileMenu = ({
}} }}
> >
<Island padding={0}> <Island padding={0}>
{appState.openMenu === "shape" && {appState.openMenu === "canvas" ? (
!appState.viewModeEnabled && <Section className="App-mobile-menu" heading="canvasActions">
showSelectedShapeActions(appState, elements) ? ( <div className="panelColumn">
<Stack.Col gap={2}>
{renderCanvasActions()}
{renderCustomFooter?.(true, appState)}
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList
mobile
collaborators={appState.collaborators}
actionManager={actionManager}
/>
</fieldset>
)}
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions"> <Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions <SelectedShapeActions
appState={appState} appState={appState}
-1
View File
@@ -46,7 +46,6 @@ const ChartPreviewBtn = (props: {
}, },
null, // files null, // files
); );
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
+18
View File
@@ -3,6 +3,24 @@
.excalidraw { .excalidraw {
.Sidebar { .Sidebar {
&__dropdown-content {
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
margin-top: 0.25rem;
width: 180px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
}
&__close-btn, &__close-btn,
&__pin-btn, &__pin-btn,
&__dropdown-btn { &__dropdown-btn {
+1 -1
View File
@@ -19,7 +19,7 @@ type ToolButtonBaseProps = {
name?: string; name?: string;
id?: string; id?: string;
size?: ToolButtonSize; size?: ToolButtonSize;
keyBindingLabel?: string | null; keyBindingLabel?: string;
showAriaLabel?: boolean; showAriaLabel?: boolean;
hidden?: boolean; hidden?: boolean;
visible?: boolean; visible?: boolean;
-1
View File
@@ -7,7 +7,6 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
gap: 0.625rem; gap: 0.625rem;
&:empty { &:empty {
+24 -4
View File
@@ -4,16 +4,16 @@ import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import { AppState, Collaborator } from "../types"; import { AppState, Collaborator } from "../types";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { useExcalidrawActionManager } from "./App"; import { ActionManager } from "../actions/manager";
export const UserList: React.FC<{ export const UserList: React.FC<{
className?: string; className?: string;
mobile?: boolean; mobile?: boolean;
collaborators: AppState["collaborators"]; collaborators: AppState["collaborators"];
}> = ({ className, mobile, collaborators }) => { actionManager: ActionManager;
const actionManager = useExcalidrawActionManager(); }> = ({ className, mobile, collaborators, actionManager }) => {
const uniqueCollaborators = new Map<string, Collaborator>(); const uniqueCollaborators = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => { collaborators.forEach((collaborator, socketId) => {
uniqueCollaborators.set( uniqueCollaborators.set(
// filter on user id, else fall back on unique socketId // filter on user id, else fall back on unique socketId
@@ -44,6 +44,26 @@ export const UserList: React.FC<{
); );
}); });
// TODO barnabasmolnar/editor-redesign
// probably remove before shipping :)
// 20 fake collaborators; for easy, convenient debug purposes ˇˇ
// const avatars = Array.from({ length: 20 }).map((_, index) => {
// const avatarJSX = actionManager.renderAction("goToCollaborator", [
// index.toString(),
// {
// username: `User ${index}`,
// },
// ]);
// return mobile ? (
// <Tooltip label={`User ${index}`} key={index}>
// {avatarJSX}
// </Tooltip>
// ) : (
// <React.Fragment key={index}>{avatarJSX}</React.Fragment>
// );
// });
return ( return (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}> <div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{avatars} {avatars}
@@ -3,39 +3,29 @@
font-family: "Virgil"; font-family: "Virgil";
} }
// WelcomeSreen common .WelcomeScreen-logo {
// --------------------------------------------------------------------------- display: flex;
align-items: center;
column-gap: 0.75rem;
font-size: 2.25rem;
.welcome-screen-decor { svg {
width: 1.625rem;
height: auto;
}
}
.WelcomeScreen-decor {
pointer-events: none; pointer-events: none;
color: var(--color-gray-40); color: var(--color-gray-40);
}
&.theme--dark { &--subheading {
.welcome-screen-decor { font-size: 1.125rem;
color: var(--color-gray-60); text-align: center;
}
}
// WelcomeScreen.Hints
// ---------------------------------------------------------------------------
.welcome-screen-decor-hint {
@media (max-height: 599px) {
display: none !important;
} }
@media (max-width: 1024px), (max-width: 800px) { &--help-pointer {
.welcome-screen-decor {
&--help,
&--menu {
display: none;
}
}
}
&--help {
display: flex; display: flex;
position: absolute; position: absolute;
right: 0; right: 0;
@@ -59,7 +49,7 @@
} }
} }
&--toolbar { &--top-toolbar-pointer {
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 50%; left: 50%;
@@ -68,7 +58,7 @@
display: flex; display: flex;
align-items: baseline; align-items: baseline;
.welcome-screen-decor-hint__label { &__label {
width: 120px; width: 120px;
position: relative; position: relative;
top: -0.5rem; top: -0.5rem;
@@ -84,7 +74,7 @@
} }
} }
&--menu { &--menu-pointer {
position: absolute; position: absolute;
width: 320px; width: 320px;
font-size: 1rem; font-size: 1rem;
@@ -105,19 +95,10 @@
transform: scaleX(-1); transform: scaleX(-1);
} }
} }
@media (max-width: 860px) {
.welcome-screen-decor-hint__label {
max-width: 160px;
}
}
} }
} }
// WelcomeSreen.Center .WelcomeScreen-container {
// ---------------------------------------------------------------------------
.welcome-screen-center {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
@@ -131,24 +112,7 @@
bottom: 1rem; bottom: 1rem;
} }
.welcome-screen-center__logo { .WelcomeScreen-items {
display: flex;
align-items: center;
column-gap: 0.75rem;
font-size: 2.25rem;
svg {
width: 1.625rem;
height: auto;
}
}
.welcome-screen-center__heading {
font-size: 1.125rem;
text-align: center;
}
.welcome-screen-menu {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
@@ -156,7 +120,7 @@
align-items: center; align-items: center;
} }
.welcome-screen-menu-item { .WelcomeScreen-item {
box-sizing: border-box; box-sizing: border-box;
pointer-events: all; pointer-events: all;
@@ -164,10 +128,8 @@
color: var(--color-gray-50); color: var(--color-gray-50);
font-size: 0.875rem; font-size: 0.875rem;
width: 100%;
min-width: 300px; min-width: 300px;
max-width: 400px; display: flex;
display: grid;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -178,49 +140,44 @@
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
grid-template-columns: calc(var(--default-icon-size) + 0.5rem) 1fr 3rem; &__label {
&__text {
display: flex; display: flex;
align-items: center; align-items: center;
margin-right: auto;
text-align: left;
column-gap: 0.5rem; column-gap: 0.5rem;
}
&__icon { svg {
width: var(--default-icon-size); width: var(--default-icon-size);
height: var(--default-icon-size); height: var(--default-icon-size);
}
} }
&__shortcut { &__shortcut {
margin-left: auto;
color: var(--color-gray-40); color: var(--color-gray-40);
font-size: 0.75rem; font-size: 0.75rem;
} }
} }
&:not(:active) .welcome-screen-menu-item:hover { &:not(:active) .WelcomeScreen-item:hover {
text-decoration: none; text-decoration: none;
background: var(--color-gray-10); background: var(--color-gray-10);
.welcome-screen-menu-item__shortcut { .WelcomeScreen-item__shortcut {
color: var(--color-gray-50); color: var(--color-gray-50);
} }
.welcome-screen-menu-item__text { .WelcomeScreen-item__label {
color: var(--color-gray-100); color: var(--color-gray-100);
} }
} }
.welcome-screen-menu-item:active { .WelcomeScreen-item:active {
background: var(--color-gray-20); background: var(--color-gray-20);
.welcome-screen-menu-item__shortcut { .WelcomeScreen-item__shortcut {
color: var(--color-gray-50); color: var(--color-gray-50);
} }
.welcome-screen-menu-item__text { .WelcomeScreen-item__label {
color: var(--color-gray-100); color: var(--color-gray-100);
} }
@@ -228,7 +185,7 @@
color: var(--color-promo) !important; color: var(--color-promo) !important;
&:hover { &:hover {
.welcome-screen-menu-item__text { .WelcomeScreen-item__label {
color: var(--color-promo) !important; color: var(--color-promo) !important;
} }
} }
@@ -236,7 +193,11 @@
} }
&.theme--dark { &.theme--dark {
.welcome-screen-menu-item { .WelcomeScreen-decor {
color: var(--color-gray-60);
}
.WelcomeScreen-item {
color: var(--color-gray-60); color: var(--color-gray-60);
&__shortcut { &__shortcut {
@@ -244,41 +205,69 @@
} }
} }
&:not(:active) .welcome-screen-menu-item:hover { &:not(:active) .WelcomeScreen-item:hover {
background: var(--color-gray-85); background: var(--color-gray-85);
.welcome-screen-menu-item__shortcut { .WelcomeScreen-item__shortcut {
color: var(--color-gray-50); color: var(--color-gray-50);
} }
.welcome-screen-menu-item__text { .WelcomeScreen-item__label {
color: var(--color-gray-10); color: var(--color-gray-10);
} }
} }
.welcome-screen-menu-item:active { .WelcomeScreen-item:active {
background-color: var(--color-gray-90); background-color: var(--color-gray-90);
.welcome-screen-menu-item__text { .WelcomeScreen-item__label {
color: var(--color-gray-10); color: var(--color-gray-10);
} }
} }
} }
// Can tweak these values but for an initial effort, it looks OK to me
@media (max-width: 1024px) {
.WelcomeScreen-decor {
&--help-pointer,
&--menu-pointer {
display: none;
}
}
}
// @media (max-height: 400px) {
// .WelcomeScreen-container {
// margin-top: 0;
// }
// }
@media (max-height: 599px) { @media (max-height: 599px) {
.welcome-screen-center { .WelcomeScreen-container {
margin-top: 4rem; margin-top: 4rem;
} }
} }
@media (min-height: 600px) and (max-height: 900px) { @media (min-height: 600px) and (max-height: 900px) {
.welcome-screen-center { .WelcomeScreen-container {
margin-top: 8rem; margin-top: 8rem;
} }
} }
@media (max-height: 500px), (max-width: 320px) { @media (max-height: 630px) {
.welcome-screen-center { .WelcomeScreen-decor--top-toolbar-pointer {
display: none;
}
}
@media (max-height: 500px) {
.WelcomeScreen-container {
display: none; display: none;
} }
} }
// --------------------------------------------------------------------------- // @media (max-height: 740px) {
// .WelcomeScreen-decor {
// &--help-pointer,
// &--top-toolbar-pointer,
// &--menu-pointer {
// display: none;
// }
// }
// }
} }
+141
View File
@@ -0,0 +1,141 @@
import { useAtom } from "jotai";
import { actionLoadScene, actionShortcuts } from "../actions";
import { ActionManager } from "../actions/manager";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { COOKIES } from "../constants";
import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab";
import { t } from "../i18n";
import { AppState } from "../types";
import {
ExcalLogo,
HelpIcon,
LoadIcon,
PlusPromoIcon,
UsersIcon,
} from "./icons";
import "./WelcomeScreen.scss";
const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);
const WelcomeScreenItem = ({
label,
shortcut,
onClick,
icon,
link,
}: {
label: string;
shortcut: string | null;
onClick?: () => void;
icon: JSX.Element;
link?: string;
}) => {
if (link) {
return (
<a
className="WelcomeScreen-item"
href={link}
target="_blank"
rel="noreferrer"
>
<div className="WelcomeScreen-item__label">
{icon}
{label}
</div>
</a>
);
}
return (
<button className="WelcomeScreen-item" type="button" onClick={onClick}>
<div className="WelcomeScreen-item__label">
{icon}
{label}
</div>
{shortcut && (
<div className="WelcomeScreen-item__shortcut">{shortcut}</div>
)}
</button>
);
};
const WelcomeScreen = ({
appState,
actionManager,
}: {
appState: AppState;
actionManager: ActionManager;
}) => {
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
let subheadingJSX;
if (isExcalidrawPlusSignedUser) {
subheadingJSX = t("welcomeScreen.switchToPlusApp")
.split(/(Excalidraw\+)/)
.map((bit, idx) => {
if (bit === "Excalidraw+") {
return (
<a
style={{ pointerEvents: "all" }}
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
key={idx}
>
Excalidraw+
</a>
);
}
return bit;
});
} else {
subheadingJSX = t("welcomeScreen.data");
}
return (
<div className="WelcomeScreen-container">
<div className="WelcomeScreen-logo virgil WelcomeScreen-decor">
{ExcalLogo} Excalidraw
</div>
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--subheading">
{subheadingJSX}
</div>
<div className="WelcomeScreen-items">
{!appState.viewModeEnabled && (
<WelcomeScreenItem
// TODO barnabasmolnar/editor-redesign
// do we want the internationalized labels here that are currently
// in use elsewhere or new ones?
label={t("buttons.load")}
onClick={() => actionManager.executeAction(actionLoadScene)}
shortcut={getShortcutFromShortcutName("loadScene")}
icon={LoadIcon}
/>
)}
<WelcomeScreenItem
label={t("labels.liveCollaboration")}
shortcut={null}
onClick={() => setCollabDialogShown(true)}
icon={UsersIcon}
/>
<WelcomeScreenItem
onClick={() => actionManager.executeAction(actionShortcuts)}
label={t("helpDialog.title")}
shortcut="?"
icon={HelpIcon}
/>
{!isExcalidrawPlusSignedUser && (
<WelcomeScreenItem
link="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
label="Try Excalidraw Plus!"
shortcut={null}
icon={PlusPromoIcon}
/>
)}
</div>
</div>
);
};
export default WelcomeScreen;
+11
View File
@@ -0,0 +1,11 @@
import { ReactNode } from "react";
const WelcomeScreenDecor = ({
children,
shouldRender,
}: {
children: ReactNode;
shouldRender: boolean;
}) => (shouldRender ? <>{children}</> : null);
export default WelcomeScreenDecor;
-32
View File
@@ -1,32 +0,0 @@
import React from "react";
import tunnel from "@dwelle/tunnel-rat";
type Tunnel = ReturnType<typeof tunnel>;
type TunnelsContextValue = {
mainMenuTunnel: Tunnel;
welcomeScreenMenuHintTunnel: Tunnel;
welcomeScreenToolbarHintTunnel: Tunnel;
welcomeScreenHelpHintTunnel: Tunnel;
welcomeScreenCenterTunnel: Tunnel;
footerCenterTunnel: Tunnel;
jotaiScope: symbol;
};
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext);
export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => {
return {
mainMenuTunnel: tunnel(),
welcomeScreenMenuHintTunnel: tunnel(),
welcomeScreenToolbarHintTunnel: tunnel(),
welcomeScreenHelpHintTunnel: tunnel(),
welcomeScreenCenterTunnel: tunnel(),
footerCenterTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
};
@@ -1,127 +0,0 @@
@import "../../css/variables.module";
.excalidraw {
.dropdown-menu {
position: absolute;
top: 100%;
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
display: flex;
flex-direction: column;
row-gap: 0.75rem;
.dropdown-menu-container {
padding: 8px 8px;
box-sizing: border-box;
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
position: relative;
transition: box-shadow 0.5s ease-in-out;
&.zen-mode {
box-shadow: none;
}
}
}
.dropdown-menu-container {
background-color: #fff !important;
max-height: calc(100vh - 150px);
overflow-y: auto;
--gap: 2;
}
.dropdown-menu-item-base {
display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
width: 100%;
box-sizing: border-box;
font-weight: normal;
font-family: inherit;
}
.dropdown-menu-item {
background-color: transparent;
border: 0;
align-items: center;
height: 2rem;
cursor: pointer;
border-radius: var(--border-radius-md);
@media screen and (min-width: 1921px) {
height: 2.25rem;
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__shortcut {
margin-inline-start: auto;
opacity: 0.5;
}
&:hover {
background-color: var(--button-hover-bg);
text-decoration: none;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
}
.dropdown-menu-item-custom {
margin-top: 0.5rem;
}
.dropdown-menu-group-title {
font-size: 14px;
text-align: left;
margin: 10px 0;
font-weight: 500;
}
}
&.theme--dark {
.dropdown-menu-item {
color: var(--color-gray-40);
}
.dropdown-menu-container {
background-color: var(--color-gray-90) !important;
}
}
.dropdown-menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&--mobile {
border: none;
margin: 0;
padding: 0;
width: var(--default-button-size);
height: var(--default-button-size);
}
}
}
@@ -1,43 +0,0 @@
import React from "react";
import DropdownMenuTrigger from "./DropdownMenuTrigger";
import DropdownMenuItem from "./DropdownMenuItem";
import MenuSeparator from "./DropdownMenuSeparator";
import DropdownMenuGroup from "./DropdownMenuGroup";
import DropdownMenuContent from "./DropdownMenuContent";
import DropdownMenuItemLink from "./DropdownMenuItemLink";
import DropdownMenuItemCustom from "./DropdownMenuItemCustom";
import {
getMenuContentComponent,
getMenuTriggerComponent,
} from "./dropdownMenuUtils";
import "./DropdownMenu.scss";
const DropdownMenu = ({
children,
open,
}: {
children?: React.ReactNode;
open: boolean;
}) => {
const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children);
return (
<>
{MenuTriggerComp}
{open && MenuContentComp}
</>
);
};
DropdownMenu.Trigger = DropdownMenuTrigger;
DropdownMenu.Content = DropdownMenuContent;
DropdownMenu.Item = DropdownMenuItem;
DropdownMenu.ItemLink = DropdownMenuItemLink;
DropdownMenu.ItemCustom = DropdownMenuItemCustom;
DropdownMenu.Group = DropdownMenuGroup;
DropdownMenu.Separator = MenuSeparator;
export default DropdownMenu;
DropdownMenu.displayName = "DropdownMenu";
@@ -1,62 +0,0 @@
import { useOutsideClickHook } from "../../hooks/useOutsideClick";
import { Island } from "../Island";
import { useDevice } from "../App";
import clsx from "clsx";
import Stack from "../Stack";
import React from "react";
import { DropdownMenuContentPropsContext } from "./common";
const MenuContent = ({
children,
onClickOutside,
className = "",
onSelect,
style,
}: {
children?: React.ReactNode;
onClickOutside?: () => void;
className?: string;
/**
* Called when any menu item is selected (clicked on).
*/
onSelect?: (event: Event) => void;
style?: React.CSSProperties;
}) => {
const device = useDevice();
const menuRef = useOutsideClickHook(() => {
onClickOutside?.();
});
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.isMobile,
}).trim();
return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<div
ref={menuRef}
className={classNames}
style={style}
data-testid="dropdown-menu"
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
{device.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
className="dropdown-menu-container"
padding={2}
style={{ zIndex: 1 }}
>
{children}
</Island>
)}
</div>
</DropdownMenuContentPropsContext.Provider>
);
};
MenuContent.displayName = "DropdownMenuContent";
export default MenuContent;
@@ -1,23 +0,0 @@
import React from "react";
const MenuGroup = ({
children,
className = "",
style,
title,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
title?: string;
}) => {
return (
<div className={`dropdown-menu-group ${className}`} style={style}>
{title && <p className="dropdown-menu-group-title">{title}</p>}
{children}
</div>
);
};
export default MenuGroup;
MenuGroup.displayName = "DropdownMenuGroup";
@@ -1,40 +0,0 @@
import React from "react";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
const DropdownMenuItem = ({
icon,
onSelect,
children,
shortcut,
className,
...rest
}: {
icon?: JSX.Element;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<button
{...rest}
onClick={handleClick}
type="button"
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</button>
);
};
export default DropdownMenuItem;
DropdownMenuItem.displayName = "DropdownMenuItem";
@@ -1,23 +0,0 @@
import { useDevice } from "../App";
const MenuItemContent = ({
icon,
shortcut,
children,
}: {
icon?: JSX.Element;
shortcut?: string;
children: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
<div className="dropdown-menu-item__icon">{icon}</div>
<div className="dropdown-menu-item__text">{children}</div>
{shortcut && !device.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>
);
};
export default MenuItemContent;
@@ -1,21 +0,0 @@
import React from "react";
const DropdownMenuItemCustom = ({
children,
className = "",
...rest
}: {
children: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
{...rest}
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
>
{children}
</div>
);
};
export default DropdownMenuItemCustom;
@@ -1,44 +0,0 @@
import MenuItemContent from "./DropdownMenuItemContent";
import React from "react";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
const DropdownMenuItemLink = ({
icon,
shortcut,
href,
children,
onSelect,
className = "",
...rest
}: {
href: string;
icon?: JSX.Element;
children: React.ReactNode;
shortcut?: string;
className?: string;
onSelect?: (event: Event) => void;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<a
{...rest}
href={href}
target="_blank"
rel="noreferrer"
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
onClick={handleClick}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</a>
);
};
export default DropdownMenuItemLink;
DropdownMenuItemLink.displayName = "DropdownMenuItemLink";
@@ -1,14 +0,0 @@
import React from "react";
const MenuSeparator = () => (
<div
style={{
height: "1px",
backgroundColor: "var(--default-border-color)",
margin: ".5rem 0",
}}
/>
);
export default MenuSeparator;
MenuSeparator.displayName = "DropdownMenuSeparator";
@@ -1,37 +0,0 @@
import clsx from "clsx";
import { useDevice, useExcalidrawAppState } from "../App";
const MenuTrigger = ({
className = "",
children,
onToggle,
}: {
className?: string;
children: React.ReactNode;
onToggle: () => void;
}) => {
const appState = useExcalidrawAppState();
const device = useDevice();
const classNames = clsx(
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"transition-left": appState.zenModeEnabled,
"dropdown-menu-button--mobile": device.isMobile,
},
).trim();
return (
<button
data-prevent-outside-click
className={classNames}
onClick={onToggle}
type="button"
data-testid="dropdown-menu-button"
>
{children}
</button>
);
};
export default MenuTrigger;
MenuTrigger.displayName = "DropdownMenuTrigger";
-31
View File
@@ -1,31 +0,0 @@
import React, { useContext } from "react";
import { EVENT } from "../../constants";
import { composeEventHandlers } from "../../utils";
export const DropdownMenuContentPropsContext = React.createContext<{
onSelect?: (event: Event) => void;
}>({});
export const getDropdownMenuItemClassName = (className = "") => {
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
};
export const useHandleDropdownMenuItemClick = (
origOnClick:
| React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>
| undefined,
onSelect: ((event: Event) => void) | undefined,
) => {
const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext);
return composeEventHandlers(origOnClick, (event) => {
const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, {
bubbles: true,
cancelable: true,
});
onSelect?.(itemSelectEvent);
if (!itemSelectEvent.defaultPrevented) {
DropdownMenuContentProps.onSelect?.(itemSelectEvent);
}
});
};
@@ -1,35 +0,0 @@
import React from "react";
export const getMenuTriggerComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuTrigger",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};
export const getMenuContentComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuContent",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};
-10
View File
@@ -1,10 +0,0 @@
.footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
display: flex;
width: 100%;
justify-content: flex-start;
}
-24
View File
@@ -1,24 +0,0 @@
import clsx from "clsx";
import { useExcalidrawAppState } from "../App";
import { useTunnels } from "../context/tunnels";
import "./FooterCenter.scss";
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
const { footerCenterTunnel } = useTunnels();
const appState = useExcalidrawAppState();
return (
<footerCenterTunnel.In>
<div
className={clsx("footer-center zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
>
{children}
</div>
</footerCenterTunnel.In>
);
};
export default FooterCenter;
FooterCenter.displayName = "FooterCenter";
@@ -1,52 +0,0 @@
import { atom, useAtom } from "jotai";
import React, { useLayoutEffect } from "react";
import { useTunnels } from "../context/tunnels";
export const withInternalFallback = <P,>(
componentName: string,
Component: React.FC<P>,
) => {
const counterAtom = atom(0);
// flag set on initial render to tell the fallback component to skip the
// render until mount counter are initialized. This is because the counter
// is initialized in an effect, and thus we could end rendering both
// components at the same time until counter is initialized.
let preferHost = false;
const WrapperComponent: React.FC<
P & {
__fallback?: boolean;
}
> = (props) => {
const { jotaiScope } = useTunnels();
const [counter, setCounter] = useAtom(counterAtom, jotaiScope);
useLayoutEffect(() => {
setCounter((counter) => counter + 1);
return () => {
setCounter((counter) => counter - 1);
};
}, [setCounter]);
if (!props.__fallback) {
preferHost = true;
}
// ensure we don't render fallback and host components at the same time
if (
// either before the counters are initialized
(!counter && props.__fallback && preferHost) ||
// or after the counters are initialized, and both are rendered
// (this is the default when host renders as well)
(counter > 1 && props.__fallback)
) {
return null;
}
return <Component {...props} />;
};
WrapperComponent.displayName = componentName;
return WrapperComponent;
};
+10 -21
View File
@@ -883,7 +883,7 @@ export const CenterHorizontallyIcon = createIcon(
modifiedTablerIconProps, modifiedTablerIconProps,
); );
export const usersIcon = createIcon( export const UsersIcon = createIcon(
<g strokeWidth="1.5"> <g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="9" cy="7" r="4"></circle> <circle cx="9" cy="7" r="4"></circle>
@@ -1470,11 +1470,11 @@ export const TextAlignRightIcon = createIcon(
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) => export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon( createIcon(
<g <g
strokeWidth="1.5" stroke-width="1.5"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
strokeLinecap="round" stroke-linecap="round"
strokeLinejoin="round" stroke-linejoin="round"
> >
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="4" x2="20" y2="4" /> <line x1="4" y1="4" x2="20" y2="4" />
@@ -1488,11 +1488,11 @@ export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) => export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon( createIcon(
<g <g
strokeWidth="2" stroke-width="2"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
strokeLinecap="round" stroke-linecap="round"
strokeLinejoin="round" stroke-linejoin="round"
> >
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="20" x2="20" y2="20" /> <line x1="4" y1="20" x2="20" y2="20" />
@@ -1506,11 +1506,11 @@ export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) => export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon( createIcon(
<g <g
strokeWidth="1.5" stroke-width="1.5"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
strokeLinecap="round" stroke-linecap="round"
strokeLinejoin="round" stroke-linejoin="round"
> >
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="12" x2="9" y2="12" /> <line x1="4" y1="12" x2="9" y2="12" />
@@ -1532,14 +1532,3 @@ export const publishIcon = createIcon(
export const eraser = createIcon( export const eraser = createIcon(
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />, <path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
); );
export const handIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M8 13v-7.5a1.5 1.5 0 0 1 3 0v6.5"></path>
<path d="M11 5.5v-2a1.5 1.5 0 1 1 3 0v8.5"></path>
<path d="M14 5.5a1.5 1.5 0 0 1 3 0v6.5"></path>
<path d="M17 7.5a1.5 1.5 0 0 1 3 0v8.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7a69.74 69.74 0 0 1 -.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47"></path>
</g>,
tablerIconProps,
);
@@ -1,40 +0,0 @@
import { t } from "../../i18n";
import { usersIcon } from "../icons";
import { Button } from "../Button";
import clsx from "clsx";
import { useExcalidrawAppState } from "../App";
import "./LiveCollaborationTrigger.scss";
const LiveCollaborationTrigger = ({
isCollaborating,
onSelect,
...rest
}: {
isCollaborating: boolean;
onSelect: () => void;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const appState = useExcalidrawAppState();
return (
<Button
{...rest}
className={clsx("collab-button", { active: isCollaborating })}
type="button"
onSelect={onSelect}
style={{ position: "relative" }}
title={t("labels.liveCollaboration")}
>
{usersIcon}
{appState.collaborators.size > 0 && (
<div className="CollabButton-collaborators">
{appState.collaborators.size}
</div>
)}
</Button>
);
};
export default LiveCollaborationTrigger;
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
-268
View File
@@ -1,268 +0,0 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n";
import {
useExcalidrawAppState,
useExcalidrawSetAppState,
useExcalidrawActionManager,
} from "../App";
import {
ExportIcon,
ExportImageIcon,
HelpIcon,
LoadIcon,
MoonIcon,
save,
SunIcon,
TrashIcon,
usersIcon,
} from "../icons";
import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import {
actionClearCanvas,
actionLoadScene,
actionSaveToActiveFile,
actionShortcuts,
actionToggleTheme,
} from "../../actions";
import "./DefaultItems.scss";
import clsx from "clsx";
import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
export const LoadScene = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionLoadScene)) {
return null;
}
return (
<DropdownMenuItem
icon={LoadIcon}
onSelect={() => actionManager.executeAction(actionLoadScene)}
data-testid="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
aria-label={t("buttons.load")}
>
{t("buttons.load")}
</DropdownMenuItem>
);
};
LoadScene.displayName = "LoadScene";
export const SaveToActiveFile = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
return null;
}
return (
<DropdownMenuItem
shortcut={getShortcutFromShortcutName("saveScene")}
data-testid="save-button"
onSelect={() => actionManager.executeAction(actionSaveToActiveFile)}
icon={save}
aria-label={`${t("buttons.save")}`}
>{`${t("buttons.save")}`}</DropdownMenuItem>
);
};
SaveToActiveFile.displayName = "SaveToActiveFile";
export const SaveAsImage = () => {
const setAppState = useExcalidrawSetAppState();
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return (
<DropdownMenuItem
icon={ExportImageIcon}
data-testid="image-export-button"
onSelect={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
aria-label={t("buttons.exportImage")}
>
{t("buttons.exportImage")}
</DropdownMenuItem>
);
};
SaveAsImage.displayName = "SaveAsImage";
export const Help = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
return (
<DropdownMenuItem
data-testid="help-menu-item"
icon={HelpIcon}
onSelect={() => actionManager.executeAction(actionShortcuts)}
shortcut="?"
aria-label={t("helpDialog.title")}
>
{t("helpDialog.title")}
</DropdownMenuItem>
);
};
Help.displayName = "Help";
export const ClearCanvas = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionClearCanvas)) {
return null;
}
return (
<DropdownMenuItem
icon={TrashIcon}
onSelect={() => setActiveConfirmDialog("clearCanvas")}
data-testid="clear-canvas-button"
aria-label={t("buttons.clearReset")}
>
{t("buttons.clearReset")}
</DropdownMenuItem>
);
};
ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionToggleTheme)) {
return null;
}
return (
<DropdownMenuItem
onSelect={(event) => {
// do not close the menu when changing theme
event.preventDefault();
return actionManager.executeAction(actionToggleTheme);
}}
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
data-testid="toggle-dark-mode"
shortcut={getShortcutFromShortcutName("toggleTheme")}
aria-label={
appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
>
{appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")}
</DropdownMenuItem>
);
};
ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
return null;
}
return (
<div style={{ marginTop: "0.5rem" }}>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
);
};
ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
export const Export = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
return (
<DropdownMenuItem
icon={ExportIcon}
onSelect={() => {
setAppState({ openDialog: "jsonExport" });
}}
data-testid="json-export-button"
aria-label={t("buttons.export")}
>
{t("buttons.export")}
</DropdownMenuItem>
);
};
Export.displayName = "Export";
export const Socials = () => (
<>
<DropdownMenuItemLink
icon={GithubIcon}
href="https://github.com/excalidraw/excalidraw"
aria-label="GitHub"
>
GitHub
</DropdownMenuItemLink>
<DropdownMenuItemLink
icon={DiscordIcon}
href="https://discord.gg/UexuTaE"
aria-label="Discord"
>
Discord
</DropdownMenuItemLink>
<DropdownMenuItemLink
icon={TwitterIcon}
href="https://twitter.com/excalidraw"
aria-label="Twitter"
>
Twitter
</DropdownMenuItemLink>
</>
);
Socials.displayName = "Socials";
export const LiveCollaborationTrigger = ({
onSelect,
isCollaborating,
}: {
onSelect: () => void;
isCollaborating: boolean;
}) => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return (
<DropdownMenuItem
data-testid="collab-button"
icon={usersIcon}
className={clsx({
"active-collab": isCollaborating,
})}
onSelect={onSelect}
>
{t("labels.liveCollaboration")}
</DropdownMenuItem>
);
};
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
-84
View File
@@ -1,84 +0,0 @@
import React from "react";
import {
useDevice,
useExcalidrawAppState,
useExcalidrawSetAppState,
} from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import * as DefaultItems from "./DefaultItems";
import { UserList } from "../UserList";
import { t } from "../../i18n";
import { HamburgerMenuIcon } from "../icons";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { composeEventHandlers } from "../../utils";
import { useTunnels } from "../context/tunnels";
const MainMenu = Object.assign(
withInternalFallback(
"MainMenu",
({
children,
onSelect,
}: {
children?: React.ReactNode;
/**
* Called when any menu item is selected (clicked on).
*/
onSelect?: (event: Event) => void;
}) => {
const { mainMenuTunnel } = useTunnels();
const device = useDevice();
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile
? undefined
: () => setAppState({ openMenu: null });
return (
<mainMenuTunnel.In>
<DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger
onToggle={() => {
setAppState({
openMenu: appState.openMenu === "canvas" ? null : "canvas",
});
}}
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={onClickOutside}
onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null });
})}
>
{children}
{device.isMobile && appState.collaborators.size > 0 && (
<fieldset className="UserList-Wrapper">
<legend>{t("labels.collaborators")}</legend>
<UserList
mobile={true}
collaborators={appState.collaborators}
/>
</fieldset>
)}
</DropdownMenu.Content>
</DropdownMenu>
</mainMenuTunnel.In>
);
},
),
{
Trigger: DropdownMenu.Trigger,
Item: DropdownMenu.Item,
ItemLink: DropdownMenu.ItemLink,
ItemCustom: DropdownMenu.ItemCustom,
Group: DropdownMenu.Group,
Separator: DropdownMenu.Separator,
DefaultItems,
},
);
export default MainMenu;

Some files were not shown because too many files have changed in this diff Show More