diff --git a/.dockerignore b/.dockerignore index 1f38a978c..8f757e505 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,8 +4,15 @@ !.eslintrc.json !.npmrc !.prettierrc +!excalidraw-app/ !package.json !public/ !packages/ !tsconfig.json !yarn.lock + +# keep (sub)sub directories at the end to exclude from explicit included +# e.g. ./packages/excalidraw/{dist,node_modules} +**/build +**/dist +**/node_modules diff --git a/.eslintrc.json b/.eslintrc.json index fbb12f59d..86d5c2990 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,6 +2,7 @@ "extends": ["@excalidraw/eslint-config", "react-app"], "rules": { "import/no-anonymous-default-export": "off", - "no-restricted-globals": "off" + "no-restricted-globals": "off", + "@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }] } } diff --git a/.gitignore b/.gitignore index 21d2730a2..81b63339f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ packages/excalidraw/types coverage dev-dist html -examples/**/bundle.* \ No newline at end of file +examples/**/bundle.* +meta*.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a044f40f6..31487e287 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,16 +2,18 @@ FROM node:18 AS build WORKDIR /opt/node_app -COPY package.json yarn.lock ./ -RUN yarn --ignore-optional --network-timeout 600000 +COPY . . + +# do not ignore optional dependencies: +# Error: Cannot find module @rollup/rollup-linux-x64-gnu +RUN yarn --network-timeout 600000 ARG NODE_ENV=production -COPY . . RUN yarn build:app:docker -FROM nginx:1.21-alpine +FROM nginx:1.24-alpine -COPY --from=build /opt/node_app/build /usr/share/nginx/html +COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1 diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx index ffff19fb0..f479ccaa1 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx @@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git | API | Signature | Usage | | --- | --- | --- | | [updateScene](#updatescene) | `function` | updates the scene with the sceneData | -| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData | +| [updateLibrary](#updatelibrary) | `function` | updates the library | | [addFiles](#addfiles) | `function` | add files data to the appState | | [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. | | [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene | @@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the | `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene | | `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. | | `collaborators` | MapCollaborator> | The list of collaborators to be updated in the scene. | -| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. | +| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. | ```jsx live function App() { @@ -115,7 +115,7 @@ function App() { - setExcalidrawAPI(api)} /> + setExcalidrawAPI(api)} /> ); } @@ -188,7 +188,7 @@ function App() { Update Library setExcalidrawAPI(api)} + excalidrawAPI={(api) => setExcalidrawAPI(api)} // initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/packages/excalidraw/initialData.js initialData={{ libraryItems: initialData.libraryItems, diff --git a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx index d6bf3fd0d..391b5800b 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx @@ -58,7 +58,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor ```jsx showLineNumbers "use client"; - import { Excalidraw. convertToExcalidrawElements } from "@excalidraw/excalidraw"; + import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw"; import "@excalidraw/excalidraw/index.css"; @@ -70,7 +70,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor height: 141.9765625, },])); return ( -
); diff --git a/examples/excalidraw/components/App.tsx b/examples/excalidraw/components/App.tsx index eea0da6ca..3b553a453 100644 --- a/examples/excalidraw/components/App.tsx +++ b/examples/excalidraw/components/App.tsx @@ -12,9 +12,9 @@ import type * as TExcalidraw from "@excalidraw/excalidraw"; import { nanoid } from "nanoid"; +import type { ResolvablePromise } from "../utils"; import { resolvablePromise, - ResolvablePromise, distance2d, fileOpen, withBatchedUpdates, diff --git a/examples/excalidraw/components/MobileFooter.tsx b/examples/excalidraw/components/MobileFooter.tsx index 7ab62b918..e008e1f30 100644 --- a/examples/excalidraw/components/MobileFooter.tsx +++ b/examples/excalidraw/components/MobileFooter.tsx @@ -1,4 +1,4 @@ -import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types"; +import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types"; import CustomFooter from "./CustomFooter"; import type * as TExcalidraw from "@excalidraw/excalidraw"; diff --git a/examples/excalidraw/utils.ts b/examples/excalidraw/utils.ts index 822be29b7..ab754aeb1 100644 --- a/examples/excalidraw/utils.ts +++ b/examples/excalidraw/utils.ts @@ -1,6 +1,6 @@ import { unstable_batchedUpdates } from "react-dom"; import { fileOpen as _fileOpen } from "browser-fs-access"; -import type { MIME_TYPES } from "@excalidraw/excalidraw"; +import { MIME_TYPES } from "@excalidraw/excalidraw"; import { AbortError } from "../../packages/excalidraw/errors"; type FILE_EXTENSION = Exclude; diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index e7083ca9e..b9ee083f3 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -14,11 +14,10 @@ import { VERSION_TIMEOUT, } from "../packages/excalidraw/constants"; import { loadFromBlob } from "../packages/excalidraw/data/blob"; -import { - ExcalidrawElement, +import type { FileId, NonDeletedExcalidrawElement, - Theme, + OrderedExcalidrawElement, } from "../packages/excalidraw/element/types"; import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState"; import { t } from "../packages/excalidraw/i18n"; @@ -28,32 +27,34 @@ import { LiveCollaborationTrigger, TTDDialog, TTDDialogTrigger, -} from "../packages/excalidraw/index"; -import { + StoreAction, + reconcileElements, +} from "../packages/excalidraw"; +import type { AppState, - LibraryItems, ExcalidrawImperativeAPI, BinaryFiles, ExcalidrawInitialDataState, UIAppState, } from "../packages/excalidraw/types"; +import type { ResolvablePromise } from "../packages/excalidraw/utils"; import { debounce, getVersion, getFrame, isTestEnv, preventUnload, - ResolvablePromise, resolvablePromise, isRunningInIframe, } from "../packages/excalidraw/utils"; import { FIREBASE_STORAGE_PREFIXES, + isExcalidrawPlusSignedUser, STORAGE_KEYS, SYNC_BROWSER_TABS_TIMEOUT, } from "./app_constants"; +import type { CollabAPI } from "./collab/Collab"; import Collab, { - CollabAPI, collabAPIAtom, isCollaboratingAtom, isOfflineAtom, @@ -65,16 +66,12 @@ import { loadScene, } from "./data"; import { - getLibraryItemsFromStorage, importFromLocalStorage, importUsernameFromLocalStorage, } from "./data/localStorage"; import CustomStats from "./CustomStats"; -import { - restore, - restoreAppState, - RestoredDataState, -} from "../packages/excalidraw/data/restore"; +import type { RestoredDataState } from "../packages/excalidraw/data/restore"; +import { restore, restoreAppState } from "../packages/excalidraw/data/restore"; import { ExportToExcalidrawPlus, exportToExcalidrawPlus, @@ -83,10 +80,13 @@ import { updateStaleImageStatuses } from "./data/FileManager"; import { newElementWith } from "../packages/excalidraw/element/mutateElement"; import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks"; import { loadFilesFromFirebase } from "./data/firebase"; -import { LocalData } from "./data/LocalData"; +import { + LibraryIndexedDBAdapter, + LibraryLocalStorageMigrationAdapter, + LocalData, +} from "./data/LocalData"; import { isBrowserStorageStateNewer } from "./data/tabSync"; import clsx from "clsx"; -import { reconcileElements } from "./collab/reconciliation"; import { parseLibraryTokensFromUrl, useHandleLibrary, @@ -99,12 +99,29 @@ import { useAtomWithInitialValue } from "../packages/excalidraw/jotai"; import { appJotaiStore } from "./app-jotai"; import "./index.scss"; -import { ResolutionType } from "../packages/excalidraw/utility-types"; +import type { ResolutionType } from "../packages/excalidraw/utility-types"; import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog"; import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState"; import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm"; import Trans from "../packages/excalidraw/components/Trans"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; +import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError"; +import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile"; +import { + CommandPalette, + DEFAULT_CATEGORIES, +} from "../packages/excalidraw/components/CommandPalette/CommandPalette"; +import { + GithubIcon, + XBrandIcon, + DiscordIcon, + ExcalLogo, + usersIcon, + exportToPlus, + share, + youtubeIcon, +} from "../packages/excalidraw/components/icons"; +import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; polyfill(); @@ -253,7 +270,7 @@ const initializeScene = async (opts: { }, elements: reconcileElements( scene?.elements || [], - excalidrawAPI.getSceneElementsIncludingDeleted(), + excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[], excalidrawAPI.getAppState(), ), }, @@ -284,6 +301,9 @@ const ExcalidrawWrapper = () => { const [langCode, setLangCode] = useAtom(appLangCodeAtom); const isCollabDisabled = isRunningInIframe(); + const [appTheme, setAppTheme] = useAtom(appThemeAtom); + const { editorTheme } = useHandleAppTheme(); + // initial state // --------------------------------------------------------------------------- @@ -313,10 +333,13 @@ const ExcalidrawWrapper = () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { return isCollaborationLink(window.location.href); }); + const collabError = useAtomValue(collabErrorIndicatorAtom); useHandleLibrary({ excalidrawAPI, - getInitialLibraryItems: getLibraryItemsFromStorage, + adapter: LibraryIndexedDBAdapter, + // TODO maybe remove this in several months (shipped: 24-03-11) + migrationAdapter: LibraryLocalStorageMigrationAdapter, }); useEffect(() => { @@ -414,7 +437,7 @@ const ExcalidrawWrapper = () => { excalidrawAPI.updateScene({ ...data.scene, ...restore(data.scene, null, null, { repairBindings: true }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } }); @@ -445,9 +468,14 @@ const ExcalidrawWrapper = () => { setLangCode(langCode); excalidrawAPI.updateScene({ ...localDataState, + storeAction: StoreAction.UPDATE, }); - excalidrawAPI.updateLibrary({ - libraryItems: getLibraryItemsFromStorage(), + LibraryIndexedDBAdapter.load().then((data) => { + if (data) { + excalidrawAPI.updateLibrary({ + libraryItems: data.libraryItems, + }); + } }); collabAPI?.setUsername(username || ""); } @@ -542,25 +570,8 @@ const ExcalidrawWrapper = () => { languageDetector.cacheUserLanguage(langCode); }, [langCode]); - const [theme, setTheme] = useState( - () => - (localStorage.getItem( - STORAGE_KEYS.LOCAL_STORAGE_THEME, - ) as Theme | null) || - // FIXME migration from old LS scheme. Can be removed later. #5660 - importFromLocalStorage().appState?.theme || - THEME.LIGHT, - ); - - useEffect(() => { - localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme); - // currently only used for body styling during init (see public/index.html), - // but may change in the future - document.documentElement.classList.toggle("dark", theme === THEME.DARK); - }, [theme]); - const onChange = ( - elements: readonly ExcalidrawElement[], + elements: readonly OrderedExcalidrawElement[], appState: AppState, files: BinaryFiles, ) => { @@ -568,8 +579,6 @@ const ExcalidrawWrapper = () => { collabAPI.syncElements(elements); } - setTheme(appState.theme); - // this check is redundant, but since this is a hot path, it's best // not to evaludate the nested expression every time if (!LocalData.isSavePaused()) { @@ -595,6 +604,7 @@ const ExcalidrawWrapper = () => { if (didChange) { excalidrawAPI.updateScene({ elements, + storeAction: StoreAction.UPDATE, }); } } @@ -659,15 +669,6 @@ const ExcalidrawWrapper = () => { ); }; - const onLibraryChange = async (items: LibraryItems) => { - if (!items.length) { - localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY); - return; - } - const serializedItems = JSON.stringify(items); - localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems); - }; - const isOffline = useAtomValue(isOfflineAtom); const onCollabDialogOpen = useCallback( @@ -694,6 +695,45 @@ const ExcalidrawWrapper = () => { ); } + const ExcalidrawPlusCommand = { + label: "Excalidraw+", + category: DEFAULT_CATEGORIES.links, + predicate: true, + icon:
{ExcalLogo}
, + keywords: ["plus", "cloud", "server"], + perform: () => { + window.open( + `${ + import.meta.env.VITE_APP_PLUS_LP + }/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`, + "_blank", + ); + }, + }; + const ExcalidrawPlusAppCommand = { + label: "Sign up", + category: DEFAULT_CATEGORIES.links, + predicate: true, + icon:
{ExcalLogo}
, + keywords: [ + "excalidraw", + "plus", + "cloud", + "server", + "signin", + "login", + "signup", + ], + perform: () => { + window.open( + `${ + import.meta.env.VITE_APP_PLUS_APP + }?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`, + "_blank", + ); + }, + }; + return (
{ toggleTheme: true, export: { onExportToBackend, - renderCustomUI: (elements, appState, files) => { - return ( - { - excalidrawAPI?.updateScene({ - appState: { - errorMessage: error.message, - }, - }); - }} - onSuccess={() => { - excalidrawAPI?.updateScene({ - appState: { openDialog: null }, - }); - }} - /> - ); - }, + renderCustomUI: excalidrawAPI + ? (elements, appState, files) => { + return ( + { + excalidrawAPI?.updateScene({ + appState: { + errorMessage: error.message, + }, + }); + }} + onSuccess={() => { + excalidrawAPI.updateScene({ + appState: { openDialog: null }, + }); + }} + /> + ); + } + : undefined, }, }, }} @@ -740,20 +783,22 @@ const ExcalidrawWrapper = () => { renderCustomStats={renderCustomStats} detectScroll={false} handleKeyboardGlobally={true} - onLibraryChange={onLibraryChange} autoFocus={true} - theme={theme} + theme={editorTheme} renderTopRightUI={(isMobile) => { if (isMobile || !collabAPI || isCollabDisabled) { return null; } return ( - - setShareDialogState({ isOpen: true, type: "share" }) - } - /> +
+ {collabError.message && } + + setShareDialogState({ isOpen: true, type: "share" }) + } + /> +
); }} > @@ -761,6 +806,8 @@ const ExcalidrawWrapper = () => { onCollabDialogOpen={onCollabDialogOpen} isCollaborating={isCollaborating} isCollabEnabled={!isCollabDisabled} + theme={appTheme} + setTheme={(theme) => setAppTheme(theme)} /> { excalidrawAPI.getSceneElements(), excalidrawAPI.getAppState(), excalidrawAPI.getFiles(), + excalidrawAPI.getName(), ); }} > @@ -882,6 +930,181 @@ const ExcalidrawWrapper = () => { {errorMessage} )} + + { + setShareDialogState({ + isOpen: true, + type: "collaborationOnly", + }); + }, + }, + { + label: t("roomDialog.button_stopSession"), + category: DEFAULT_CATEGORIES.app, + predicate: () => !!collabAPI?.isCollaborating(), + keywords: [ + "stop", + "session", + "end", + "leave", + "close", + "exit", + "collaboration", + ], + perform: () => { + if (collabAPI) { + collabAPI.stopCollaboration(); + if (!collabAPI.isCollaborating()) { + setShareDialogState({ isOpen: false }); + } + } + }, + }, + { + label: t("labels.share"), + category: DEFAULT_CATEGORIES.app, + predicate: true, + icon: share, + keywords: [ + "link", + "shareable", + "readonly", + "export", + "publish", + "snapshot", + "url", + "collaborate", + "invite", + ], + perform: async () => { + setShareDialogState({ isOpen: true, type: "share" }); + }, + }, + { + label: "GitHub", + icon: GithubIcon, + category: DEFAULT_CATEGORIES.links, + predicate: true, + keywords: [ + "issues", + "bugs", + "requests", + "report", + "features", + "social", + "community", + ], + perform: () => { + window.open( + "https://github.com/excalidraw/excalidraw", + "_blank", + "noopener noreferrer", + ); + }, + }, + { + label: t("labels.followUs"), + icon: XBrandIcon, + category: DEFAULT_CATEGORIES.links, + predicate: true, + keywords: ["twitter", "contact", "social", "community"], + perform: () => { + window.open( + "https://x.com/excalidraw", + "_blank", + "noopener noreferrer", + ); + }, + }, + { + label: t("labels.discordChat"), + category: DEFAULT_CATEGORIES.links, + predicate: true, + icon: DiscordIcon, + keywords: [ + "chat", + "talk", + "contact", + "bugs", + "requests", + "report", + "feedback", + "suggestions", + "social", + "community", + ], + perform: () => { + window.open( + "https://discord.gg/UexuTaE", + "_blank", + "noopener noreferrer", + ); + }, + }, + { + label: "YouTube", + icon: youtubeIcon, + category: DEFAULT_CATEGORIES.links, + predicate: true, + keywords: ["features", "tutorials", "howto", "help", "community"], + perform: () => { + window.open( + "https://youtube.com/@excalidraw", + "_blank", + "noopener noreferrer", + ); + }, + }, + ...(isExcalidrawPlusSignedUser + ? [ + { + ...ExcalidrawPlusAppCommand, + label: "Sign in / Go to Excalidraw+", + }, + ] + : [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]), + + { + label: t("overwriteConfirm.action.excalidrawPlus.button"), + category: DEFAULT_CATEGORIES.export, + icon: exportToPlus, + predicate: true, + keywords: ["plus", "export", "save", "backup"], + perform: () => { + if (excalidrawAPI) { + exportToExcalidrawPlus( + excalidrawAPI.getSceneElements(), + excalidrawAPI.getAppState(), + excalidrawAPI.getFiles(), + excalidrawAPI.getName(), + ); + } + }, + }, + { + ...CommandPalette.defaultItems.toggleTheme, + perform: () => { + setAppTheme( + editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK, + ); + }, + }, + ]} + />
); diff --git a/excalidraw-app/CustomStats.tsx b/excalidraw-app/CustomStats.tsx index f2ce80f21..f609096b9 100644 --- a/excalidraw-app/CustomStats.tsx +++ b/excalidraw-app/CustomStats.tsx @@ -7,8 +7,8 @@ import { import { DEFAULT_VERSION } from "../packages/excalidraw/constants"; import { t } from "../packages/excalidraw/i18n"; import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard"; -import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types"; -import { UIAppState } from "../packages/excalidraw/types"; +import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types"; +import type { UIAppState } from "../packages/excalidraw/types"; type StorageSizes = { scene: number; total: number }; diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index 3402bf106..f4b56496d 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -39,10 +39,14 @@ export const STORAGE_KEYS = { LOCAL_STORAGE_ELEMENTS: "excalidraw", LOCAL_STORAGE_APP_STATE: "excalidraw-state", LOCAL_STORAGE_COLLAB: "excalidraw-collab", - LOCAL_STORAGE_LIBRARY: "excalidraw-library", LOCAL_STORAGE_THEME: "excalidraw-theme", VERSION_DATA_STATE: "version-dataState", VERSION_FILES: "version-files", + + IDB_LIBRARY: "excalidraw-library", + + // do not use apart from migrations + __LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library", } as const; export const COOKIES = { diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 14538b674..7059a67c5 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -1,22 +1,25 @@ import throttle from "lodash.throttle"; import { PureComponent } from "react"; -import { +import type { ExcalidrawImperativeAPI, SocketId, } from "../../packages/excalidraw/types"; import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog"; import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants"; -import { ImportedDataState } from "../../packages/excalidraw/data/types"; -import { +import type { ImportedDataState } from "../../packages/excalidraw/data/types"; +import type { ExcalidrawElement, InitializedExcalidrawImageElement, + OrderedExcalidrawElement, } from "../../packages/excalidraw/element/types"; import { + StoreAction, getSceneVersion, restoreElements, zoomToFitBounds, -} from "../../packages/excalidraw/index"; -import { Collaborator, Gesture } from "../../packages/excalidraw/types"; + reconcileElements, +} from "../../packages/excalidraw"; +import type { Collaborator, Gesture } from "../../packages/excalidraw/types"; import { assertNever, preventUnload, @@ -33,12 +36,14 @@ import { SYNC_FULL_SCENE_INTERVAL_MS, WS_EVENTS, } from "../app_constants"; +import type { + SocketUpdateDataSource, + SyncableExcalidrawElement, +} from "../data"; import { generateCollaborationLinkData, getCollaborationLink, getSyncableElements, - SocketUpdateDataSource, - SyncableExcalidrawElement, } from "../data"; import { isSavedToFirebase, @@ -69,18 +74,19 @@ import { isInitializedImageElement, } from "../../packages/excalidraw/element/typeChecks"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; -import { - ReconciledElements, - reconcileElements as _reconcileElements, -} from "./reconciliation"; import { decryptData } from "../../packages/excalidraw/data/encryption"; import { resetBrowserStateVersions } from "../data/tabSync"; import { LocalData } from "../data/LocalData"; import { atom } from "jotai"; import { appJotaiStore } from "../app-jotai"; -import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; +import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils"; +import { collabErrorIndicatorAtom } from "./CollabError"; +import type { + ReconciledExcalidrawElement, + RemoteExcalidrawElement, +} from "../../packages/excalidraw/data/reconcile"; export const collabAPIAtom = atom(null); export const isCollaboratingAtom = atom(false); @@ -88,6 +94,8 @@ export const isOfflineAtom = atom(false); interface CollabState { errorMessage: string | null; + /** errors related to saving */ + dialogNotifiedErrors: Record; username: string; activeRoomLink: string | null; } @@ -107,7 +115,7 @@ export interface CollabAPI { setUsername: CollabInstance["setUsername"]; getUsername: CollabInstance["getUsername"]; getActiveRoomLink: CollabInstance["getActiveRoomLink"]; - setErrorMessage: CollabInstance["setErrorMessage"]; + setCollabError: CollabInstance["setErrorDialog"]; } interface CollabProps { @@ -129,6 +137,7 @@ class Collab extends PureComponent { super(props); this.state = { errorMessage: null, + dialogNotifiedErrors: {}, username: importUsernameFromLocalStorage() || "", activeRoomLink: null, }; @@ -197,7 +206,7 @@ class Collab extends PureComponent { setUsername: this.setUsername, getUsername: this.getUsername, getActiveRoomLink: this.getActiveRoomLink, - setErrorMessage: this.setErrorMessage, + setCollabError: this.setErrorDialog, }; appJotaiStore.set(collabAPIAtom, collabAPI); @@ -270,24 +279,39 @@ class Collab extends PureComponent { syncableElements: readonly SyncableExcalidrawElement[], ) => { try { - const savedData = await saveToFirebase( + const storedElements = await saveToFirebase( this.portal, syncableElements, this.excalidrawAPI.getAppState(), ); - if (this.isCollaborating() && savedData && savedData.reconciledElements) { - this.handleRemoteSceneUpdate( - this.reconcileElements(savedData.reconciledElements), - ); + this.resetErrorIndicator(); + + if (this.isCollaborating() && storedElements) { + this.handleRemoteSceneUpdate(this._reconcileElements(storedElements)); } } catch (error: any) { - this.setState({ - // firestore doesn't return a specific error code when size exceeded - errorMessage: /is longer than.*?bytes/.test(error.message) - ? t("errors.collabSaveFailed_sizeExceeded") - : t("errors.collabSaveFailed"), - }); + const errorMessage = /is longer than.*?bytes/.test(error.message) + ? t("errors.collabSaveFailed_sizeExceeded") + : t("errors.collabSaveFailed"); + + if ( + !this.state.dialogNotifiedErrors[errorMessage] || + !this.isCollaborating() + ) { + this.setErrorDialog(errorMessage); + this.setState({ + dialogNotifiedErrors: { + ...this.state.dialogNotifiedErrors, + [errorMessage]: true, + }, + }); + } + + if (this.isCollaborating()) { + this.setErrorIndicator(errorMessage); + } + console.error(error); } }; @@ -296,6 +320,7 @@ class Collab extends PureComponent { this.queueBroadcastAllElements.cancel(); this.queueSaveToFirebase.cancel(); this.loadImageFiles.cancel(); + this.resetErrorIndicator(true); this.saveCollabRoomToFirebase( getSyncableElements( @@ -334,7 +359,7 @@ class Collab extends PureComponent { this.excalidrawAPI.updateScene({ elements, - commitToHistory: false, + storeAction: StoreAction.UPDATE, }); } }; @@ -407,7 +432,7 @@ class Collab extends PureComponent { startCollaboration = async ( existingRoomLinkData: null | { roomId: string; roomKey: string }, - ): Promise => { + ) => { if (!this.state.username) { import("@excalidraw/random-username").then(({ getRandomUsername }) => { const username = getRandomUsername(); @@ -433,7 +458,11 @@ class Collab extends PureComponent { ); } - const scenePromise = resolvablePromise(); + // TODO: `ImportedDataState` type here seems abused + const scenePromise = resolvablePromise< + | (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] }) + | null + >(); this.setIsCollaborating(true); LocalData.pauseSave("collaboration"); @@ -464,7 +493,7 @@ class Collab extends PureComponent { this.portal.socket.once("connect_error", fallbackInitializationHandler); } catch (error: any) { console.error(error); - this.setState({ errorMessage: error.message }); + this.setErrorDialog(error.message); return null; } @@ -475,14 +504,13 @@ class Collab extends PureComponent { } return element; }); - // remove deleted elements from elements array & history to ensure we don't + // remove deleted elements from elements array to ensure we don't // expose potentially sensitive user data in case user manually deletes // existing elements (or clears scene), which would otherwise be persisted // to database even if deleted before creating the room. - this.excalidrawAPI.history.clear(); this.excalidrawAPI.updateScene({ elements, - commitToHistory: true, + storeAction: StoreAction.UPDATE, }); this.saveCollabRoomToFirebase(getSyncableElements(elements)); @@ -516,10 +544,9 @@ class Collab extends PureComponent { if (!this.portal.socketInitialized) { this.initializeRoom({ fetchScene: false }); const remoteElements = decryptedData.payload.elements; - const reconciledElements = this.reconcileElements(remoteElements); - this.handleRemoteSceneUpdate(reconciledElements, { - init: true, - }); + const reconciledElements = + this._reconcileElements(remoteElements); + this.handleRemoteSceneUpdate(reconciledElements); // noop if already resolved via init from firebase scenePromise.resolve({ elements: reconciledElements, @@ -530,7 +557,7 @@ class Collab extends PureComponent { } case WS_SUBTYPES.UPDATE: this.handleRemoteSceneUpdate( - this.reconcileElements(decryptedData.payload.elements), + this._reconcileElements(decryptedData.payload.elements), ); break; case WS_SUBTYPES.MOUSE_LOCATION: { @@ -678,17 +705,15 @@ class Collab extends PureComponent { return null; }; - private reconcileElements = ( + private _reconcileElements = ( remoteElements: readonly ExcalidrawElement[], - ): ReconciledElements => { + ): ReconciledExcalidrawElement[] => { const localElements = this.getSceneElementsIncludingDeleted(); const appState = this.excalidrawAPI.getAppState(); - - remoteElements = restoreElements(remoteElements, null); - - const reconciledElements = _reconcileElements( + const restoredRemoteElements = restoreElements(remoteElements, null); + const reconciledElements = reconcileElements( localElements, - remoteElements, + restoredRemoteElements as RemoteExcalidrawElement[], appState, ); @@ -719,20 +744,13 @@ class Collab extends PureComponent { }, LOAD_IMAGES_TIMEOUT); private handleRemoteSceneUpdate = ( - elements: ReconciledElements, - { init = false }: { init?: boolean } = {}, + elements: ReconciledExcalidrawElement[], ) => { this.excalidrawAPI.updateScene({ elements, - commitToHistory: !!init, + storeAction: StoreAction.UPDATE, }); - // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack - // when we receive any messages from another peer. This UX can be pretty rough -- if you - // undo, a user makes a change, and then try to redo, your element(s) will be lost. However, - // right now we think this is the right tradeoff. - this.excalidrawAPI.history.clear(); - this.loadImageFiles(); }; @@ -865,7 +883,7 @@ class Collab extends PureComponent { this.portal.broadcastIdleChange(userState); }; - broadcastElements = (elements: readonly ExcalidrawElement[]) => { + broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => { if ( getSceneVersion(elements) > this.getLastBroadcastedOrReceivedSceneVersion() @@ -876,7 +894,7 @@ class Collab extends PureComponent { } }; - syncElements = (elements: readonly ExcalidrawElement[]) => { + syncElements = (elements: readonly OrderedExcalidrawElement[]) => { this.broadcastElements(elements); this.queueSaveToFirebase(); }; @@ -923,8 +941,26 @@ class Collab extends PureComponent { getActiveRoomLink = () => this.state.activeRoomLink; - setErrorMessage = (errorMessage: string | null) => { - this.setState({ errorMessage }); + setErrorIndicator = (errorMessage: string | null) => { + appJotaiStore.set(collabErrorIndicatorAtom, { + message: errorMessage, + nonce: Date.now(), + }); + }; + + resetErrorIndicator = (resetDialogNotifiedErrors = false) => { + appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 }); + if (resetDialogNotifiedErrors) { + this.setState({ + dialogNotifiedErrors: {}, + }); + } + }; + + setErrorDialog = (errorMessage: string | null) => { + this.setState({ + errorMessage, + }); }; render() { @@ -933,7 +969,7 @@ class Collab extends PureComponent { return ( <> {errorMessage != null && ( - this.setState({ errorMessage: null })}> + this.setErrorDialog(null)}> {errorMessage} )} diff --git a/excalidraw-app/collab/CollabError.scss b/excalidraw-app/collab/CollabError.scss new file mode 100644 index 000000000..bb774d00a --- /dev/null +++ b/excalidraw-app/collab/CollabError.scss @@ -0,0 +1,35 @@ +@import "../../packages/excalidraw/css/variables.module.scss"; + +.excalidraw { + .collab-errors-button { + width: 26px; + height: 26px; + margin-inline-end: 1rem; + + color: var(--color-danger); + + flex-shrink: 0; + } + + .collab-errors-button-shake { + animation: strong-shake 0.15s 6; + } + + @keyframes strong-shake { + 0% { + transform: rotate(0deg); + } + 25% { + transform: rotate(10deg); + } + 50% { + transform: rotate(0deg); + } + 75% { + transform: rotate(-10deg); + } + 100% { + transform: rotate(0deg); + } + } +} diff --git a/excalidraw-app/collab/CollabError.tsx b/excalidraw-app/collab/CollabError.tsx new file mode 100644 index 000000000..45a98ac8d --- /dev/null +++ b/excalidraw-app/collab/CollabError.tsx @@ -0,0 +1,54 @@ +import { Tooltip } from "../../packages/excalidraw/components/Tooltip"; +import { warning } from "../../packages/excalidraw/components/icons"; +import clsx from "clsx"; +import { useEffect, useRef, useState } from "react"; + +import "./CollabError.scss"; +import { atom } from "jotai"; + +type ErrorIndicator = { + message: string | null; + /** used to rerun the useEffect responsible for animation */ + nonce: number; +}; + +export const collabErrorIndicatorAtom = atom({ + message: null, + nonce: 0, +}); + +const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => { + const [isAnimating, setIsAnimating] = useState(false); + const clearAnimationRef = useRef(); + + useEffect(() => { + setIsAnimating(true); + clearAnimationRef.current = setTimeout(() => { + setIsAnimating(false); + }, 1000); + + return () => { + clearTimeout(clearAnimationRef.current); + }; + }, [collabError.message, collabError.nonce]); + + if (!collabError.message) { + return null; + } + + return ( + +
+ {warning} +
+
+ ); +}; + +CollabError.displayName = "CollabError"; + +export default CollabError; diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx index bf8ffa5de..e9a4b5bf0 100644 --- a/excalidraw-app/collab/Portal.tsx +++ b/excalidraw-app/collab/Portal.tsx @@ -1,14 +1,15 @@ -import { - isSyncableElement, +import type { SocketUpdateData, SocketUpdateDataSource, + SyncableExcalidrawElement, } from "../data"; +import { isSyncableElement } from "../data"; -import { TCollabClass } from "./Collab"; +import type { TCollabClass } from "./Collab"; -import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; +import type { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types"; import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; -import { +import type { OnUserFollowedPayload, SocketId, UserIdleState, @@ -16,10 +17,9 @@ import { import { trackEvent } from "../../packages/excalidraw/analytics"; import throttle from "lodash.throttle"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; -import { BroadcastedExcalidrawElement } from "./reconciliation"; import { encryptData } from "../../packages/excalidraw/data/encryption"; -import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants"; import type { Socket } from "socket.io-client"; +import { StoreAction } from "../../packages/excalidraw"; class Portal { collab: TCollabClass; @@ -128,12 +128,13 @@ class Portal { } return element; }), + storeAction: StoreAction.UPDATE, }); }, FILE_UPLOAD_TIMEOUT); broadcastScene = async ( updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE, - allElements: readonly ExcalidrawElement[], + elements: readonly OrderedExcalidrawElement[], syncAll: boolean, ) => { if (updateType === WS_SUBTYPES.INIT && !syncAll) { @@ -143,25 +144,17 @@ class Portal { // sync out only the elements we think we need to to save bandwidth. // periodically we'll resync the whole thing to make sure no one diverges // due to a dropped message (server goes down etc). - const syncableElements = allElements.reduce( - (acc, element: BroadcastedExcalidrawElement, idx, elements) => { - if ( - (syncAll || - !this.broadcastedElementVersions.has(element.id) || - element.version > - this.broadcastedElementVersions.get(element.id)!) && - isSyncableElement(element) - ) { - acc.push({ - ...element, - // z-index info for the reconciler - [PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id, - }); - } - return acc; - }, - [] as BroadcastedExcalidrawElement[], - ); + const syncableElements = elements.reduce((acc, element) => { + if ( + (syncAll || + !this.broadcastedElementVersions.has(element.id) || + element.version > this.broadcastedElementVersions.get(element.id)!) && + isSyncableElement(element) + ) { + acc.push(element); + } + return acc; + }, [] as SyncableExcalidrawElement[]); const data: SocketUpdateDataSource[typeof updateType] = { type: updateType, diff --git a/excalidraw-app/collab/RoomDialog.tsx b/excalidraw-app/collab/RoomDialog.tsx index f2614674d..74266d3d9 100644 --- a/excalidraw-app/collab/RoomDialog.tsx +++ b/excalidraw-app/collab/RoomDialog.tsx @@ -65,19 +65,18 @@ export const RoomModal = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(activeRoomLink); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - setErrorMessage(error.message); + } catch (e) { + setErrorMessage(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); ref.current?.select(); }; diff --git a/excalidraw-app/collab/reconciliation.ts b/excalidraw-app/collab/reconciliation.ts deleted file mode 100644 index 15e17ed42..000000000 --- a/excalidraw-app/collab/reconciliation.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants"; -import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; -import { AppState } from "../../packages/excalidraw/types"; -import { arrayToMapWithIndex } from "../../packages/excalidraw/utils"; - -export type ReconciledElements = readonly ExcalidrawElement[] & { - _brand: "reconciledElements"; -}; - -export type BroadcastedExcalidrawElement = ExcalidrawElement & { - [PRECEDING_ELEMENT_KEY]?: string; -}; - -const shouldDiscardRemoteElement = ( - localAppState: AppState, - local: ExcalidrawElement | undefined, - remote: BroadcastedExcalidrawElement, -): boolean => { - if ( - local && - // local element is being edited - (local.id === localAppState.editingElement?.id || - local.id === localAppState.resizingElement?.id || - local.id === localAppState.draggingElement?.id || - // local element is newer - local.version > remote.version || - // resolve conflicting edits deterministically by taking the one with - // the lowest versionNonce - (local.version === remote.version && - local.versionNonce < remote.versionNonce)) - ) { - return true; - } - return false; -}; - -export const reconcileElements = ( - localElements: readonly ExcalidrawElement[], - remoteElements: readonly BroadcastedExcalidrawElement[], - localAppState: AppState, -): ReconciledElements => { - const localElementsData = - arrayToMapWithIndex(localElements); - - const reconciledElements: ExcalidrawElement[] = localElements.slice(); - - const duplicates = new WeakMap(); - - let cursor = 0; - let offset = 0; - - let remoteElementIdx = -1; - for (const remoteElement of remoteElements) { - remoteElementIdx++; - - const local = localElementsData.get(remoteElement.id); - - if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) { - if (remoteElement[PRECEDING_ELEMENT_KEY]) { - delete remoteElement[PRECEDING_ELEMENT_KEY]; - } - - continue; - } - - // Mark duplicate for removal as it'll be replaced with the remote element - if (local) { - // Unless the remote and local elements are the same element in which case - // we need to keep it as we'd otherwise discard it from the resulting - // array. - if (local[0] === remoteElement) { - continue; - } - duplicates.set(local[0], true); - } - - // parent may not be defined in case the remote client is running an older - // excalidraw version - const parent = - remoteElement[PRECEDING_ELEMENT_KEY] || - remoteElements[remoteElementIdx - 1]?.id || - null; - - if (parent != null) { - delete remoteElement[PRECEDING_ELEMENT_KEY]; - - // ^ indicates the element is the first in elements array - if (parent === "^") { - offset++; - if (cursor === 0) { - reconciledElements.unshift(remoteElement); - localElementsData.set(remoteElement.id, [ - remoteElement, - cursor - offset, - ]); - } else { - reconciledElements.splice(cursor + 1, 0, remoteElement); - localElementsData.set(remoteElement.id, [ - remoteElement, - cursor + 1 - offset, - ]); - cursor++; - } - } else { - let idx = localElementsData.has(parent) - ? localElementsData.get(parent)![1] - : null; - if (idx != null) { - idx += offset; - } - if (idx != null && idx >= cursor) { - reconciledElements.splice(idx + 1, 0, remoteElement); - offset++; - localElementsData.set(remoteElement.id, [ - remoteElement, - idx + 1 - offset, - ]); - cursor = idx + 1; - } else if (idx != null) { - reconciledElements.splice(cursor + 1, 0, remoteElement); - offset++; - localElementsData.set(remoteElement.id, [ - remoteElement, - cursor + 1 - offset, - ]); - cursor++; - } else { - reconciledElements.push(remoteElement); - localElementsData.set(remoteElement.id, [ - remoteElement, - reconciledElements.length - 1 - offset, - ]); - } - } - // no parent z-index information, local element exists → replace in place - } else if (local) { - reconciledElements[local[1]] = remoteElement; - localElementsData.set(remoteElement.id, [remoteElement, local[1]]); - // otherwise push to the end - } else { - reconciledElements.push(remoteElement); - localElementsData.set(remoteElement.id, [ - remoteElement, - reconciledElements.length - 1 - offset, - ]); - } - } - - const ret: readonly ExcalidrawElement[] = reconciledElements.filter( - (element) => !duplicates.has(element), - ); - - return ret as ReconciledElements; -}; diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 6806c969c..03c789b40 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -1,12 +1,19 @@ import React from "react"; -import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; +import { + loginIcon, + ExcalLogo, +} from "../../packages/excalidraw/components/icons"; +import type { Theme } from "../../packages/excalidraw/element/types"; import { MainMenu } from "../../packages/excalidraw/index"; +import { isExcalidrawPlusSignedUser } from "../app_constants"; import { LanguageList } from "./LanguageList"; export const AppMainMenu: React.FC<{ onCollabDialogOpen: () => any; isCollaborating: boolean; isCollabEnabled: boolean; + theme: Theme | "system"; + setTheme: (theme: Theme | "system") => void; }> = React.memo((props) => { return ( @@ -20,22 +27,35 @@ export const AppMainMenu: React.FC<{ onSelect={() => props.onCollabDialogOpen()} /> )} - + Excalidraw+ + + {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"} + - + diff --git a/excalidraw-app/components/AppWelcomeScreen.tsx b/excalidraw-app/components/AppWelcomeScreen.tsx index f74bc14e2..94b203994 100644 --- a/excalidraw-app/components/AppWelcomeScreen.tsx +++ b/excalidraw-app/components/AppWelcomeScreen.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; +import { loginIcon } from "../../packages/excalidraw/components/icons"; import { useI18n } from "../../packages/excalidraw/i18n"; import { WelcomeScreen } from "../../packages/excalidraw/index"; import { isExcalidrawPlusSignedUser } from "../app_constants"; @@ -61,9 +61,9 @@ export const AppWelcomeScreen: React.FC<{ import.meta.env.VITE_APP_PLUS_LP }/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`} shortcut={null} - icon={PlusPromoIcon} + icon={loginIcon} > - Try Excalidraw Plus! + Sign up )} diff --git a/excalidraw-app/components/ExportToExcalidrawPlus.tsx b/excalidraw-app/components/ExportToExcalidrawPlus.tsx index 4c566950b..1ba82a0c7 100644 --- a/excalidraw-app/components/ExportToExcalidrawPlus.tsx +++ b/excalidraw-app/components/ExportToExcalidrawPlus.tsx @@ -3,11 +3,11 @@ import { Card } from "../../packages/excalidraw/components/Card"; import { ToolButton } from "../../packages/excalidraw/components/ToolButton"; import { serializeAsJSON } from "../../packages/excalidraw/data/json"; import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase"; -import { +import type { FileId, NonDeletedExcalidrawElement, } from "../../packages/excalidraw/element/types"; -import { +import type { AppState, BinaryFileData, BinaryFiles, @@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async ( elements: readonly NonDeletedExcalidrawElement[], appState: Partial, files: BinaryFiles, + name: string, ) => { const firebase = await loadFirebaseStorage(); @@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async ( .ref(`/migrations/scenes/${id}`) .put(blob, { customMetadata: { - data: JSON.stringify({ version: 2, name: appState.name }), + data: JSON.stringify({ version: 2, name }), created: Date.now().toString(), }, }); @@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{ elements: readonly NonDeletedExcalidrawElement[]; appState: Partial; files: BinaryFiles; + name: string; onError: (error: Error) => void; onSuccess: () => void; -}> = ({ elements, appState, files, onError, onSuccess }) => { +}> = ({ elements, appState, files, name, onError, onSuccess }) => { const { t } = useI18n(); return ( @@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{ onClick={async () => { try { trackEvent("export", "eplus", `ui (${getFrame()})`); - await exportToExcalidrawPlus(elements, appState, files); + await exportToExcalidrawPlus(elements, appState, files, name); onSuccess(); } catch (error: any) { console.error(error); diff --git a/excalidraw-app/components/GitHubCorner.tsx b/excalidraw-app/components/GitHubCorner.tsx index ad343a899..38a7ec896 100644 --- a/excalidraw-app/components/GitHubCorner.tsx +++ b/excalidraw-app/components/GitHubCorner.tsx @@ -1,7 +1,7 @@ import oc from "open-color"; import React from "react"; import { THEME } from "../../packages/excalidraw/constants"; -import { Theme } from "../../packages/excalidraw/element/types"; +import type { Theme } from "../../packages/excalidraw/element/types"; // https://github.com/tholman/github-corners export const GitHubCorner = React.memo( diff --git a/excalidraw-app/components/TopErrorBoundary.tsx b/excalidraw-app/components/TopErrorBoundary.tsx index f796906d6..3dbf12ceb 100644 --- a/excalidraw-app/components/TopErrorBoundary.tsx +++ b/excalidraw-app/components/TopErrorBoundary.tsx @@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component< window.open( `https://github.com/excalidraw/excalidraw/issues/new?body=${body}`, + "_blank", + "noopener noreferrer", ); } diff --git a/excalidraw-app/data/FileManager.ts b/excalidraw-app/data/FileManager.ts index 5a47a4a48..e9b460247 100644 --- a/excalidraw-app/data/FileManager.ts +++ b/excalidraw-app/data/FileManager.ts @@ -1,14 +1,15 @@ +import { StoreAction } from "../../packages/excalidraw"; import { compressData } from "../../packages/excalidraw/data/encode"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; -import { +import type { ExcalidrawElement, ExcalidrawImageElement, FileId, InitializedExcalidrawImageElement, } from "../../packages/excalidraw/element/types"; import { t } from "../../packages/excalidraw/i18n"; -import { +import type { BinaryFileData, BinaryFileMetadata, ExcalidrawImperativeAPI, @@ -238,5 +239,6 @@ export const updateStaleImageStatuses = (params: { } return element; }), + storeAction: StoreAction.UPDATE, }); }; diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index a8a6c41b2..468126b2b 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -10,18 +10,29 @@ * (localStorage, indexedDB). */ -import { createStore, entries, del, getMany, set, setMany } from "idb-keyval"; -import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; -import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { + createStore, + entries, + del, + getMany, + set, + setMany, + get, +} from "idb-keyval"; +import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; +import type { LibraryPersistedData } from "../../packages/excalidraw/data/library"; +import type { ImportedDataState } from "../../packages/excalidraw/data/types"; +import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; +import type { ExcalidrawElement, FileId, } from "../../packages/excalidraw/element/types"; -import { +import type { AppState, BinaryFileData, BinaryFiles, } from "../../packages/excalidraw/types"; +import type { MaybePromise } from "../../packages/excalidraw/utility-types"; import { debounce } from "../../packages/excalidraw/utils"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { FileManager } from "./FileManager"; @@ -183,3 +194,52 @@ export class LocalData { }, }); } +export class LibraryIndexedDBAdapter { + /** IndexedDB database and store name */ + private static idb_name = STORAGE_KEYS.IDB_LIBRARY; + /** library data store key */ + private static key = "libraryData"; + + private static store = createStore( + `${LibraryIndexedDBAdapter.idb_name}-db`, + `${LibraryIndexedDBAdapter.idb_name}-store`, + ); + + static async load() { + const IDBData = await get( + LibraryIndexedDBAdapter.key, + LibraryIndexedDBAdapter.store, + ); + + return IDBData || null; + } + + static save(data: LibraryPersistedData): MaybePromise { + return set( + LibraryIndexedDBAdapter.key, + data, + LibraryIndexedDBAdapter.store, + ); + } +} + +/** LS Adapter used only for migrating LS library data + * to indexedDB */ +export class LibraryLocalStorageMigrationAdapter { + static load() { + const LSData = localStorage.getItem( + STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY, + ); + if (LSData != null) { + const libraryItems: ImportedDataState["libraryItems"] = + JSON.parse(LSData); + if (libraryItems) { + return { libraryItems }; + } + } + return null; + } + static clear() { + localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY); + } +} diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts index f37fbbd81..c73018acf 100644 --- a/excalidraw-app/data/firebase.ts +++ b/excalidraw-app/data/firebase.ts @@ -1,11 +1,13 @@ -import { +import { reconcileElements } from "../../packages/excalidraw"; +import type { ExcalidrawElement, FileId, + OrderedExcalidrawElement, } from "../../packages/excalidraw/element/types"; import { getSceneVersion } from "../../packages/excalidraw/element"; -import Portal from "../collab/Portal"; +import type Portal from "../collab/Portal"; import { restoreElements } from "../../packages/excalidraw/data/restore"; -import { +import type { AppState, BinaryFileData, BinaryFileMetadata, @@ -18,10 +20,11 @@ import { decryptData, } from "../../packages/excalidraw/data/encryption"; import { MIME_TYPES } from "../../packages/excalidraw/constants"; -import { reconcileElements } from "../collab/reconciliation"; -import { getSyncableElements, SyncableExcalidrawElement } from "."; -import { ResolutionType } from "../../packages/excalidraw/utility-types"; +import type { SyncableExcalidrawElement } from "."; +import { getSyncableElements } from "."; +import type { ResolutionType } from "../../packages/excalidraw/utility-types"; import type { Socket } from "socket.io-client"; +import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile"; // private // ----------------------------------------------------------------------------- @@ -230,7 +233,7 @@ export const saveToFirebase = async ( !socket || isSavedToFirebase(portal, elements) ) { - return false; + return null; } const firebase = await loadFirestore(); @@ -238,56 +241,59 @@ export const saveToFirebase = async ( const docRef = firestore.collection("scenes").doc(roomId); - const savedData = await firestore.runTransaction(async (transaction) => { + const storedScene = await firestore.runTransaction(async (transaction) => { const snapshot = await transaction.get(docRef); if (!snapshot.exists) { - const sceneDocument = await createFirebaseSceneDocument( + const storedScene = await createFirebaseSceneDocument( firebase, elements, roomKey, ); - transaction.set(docRef, sceneDocument); + transaction.set(docRef, storedScene); - return { - elements, - reconciledElements: null, - }; + return storedScene; } - const prevDocData = snapshot.data() as FirebaseStoredScene; - const prevElements = getSyncableElements( - await decryptElements(prevDocData, roomKey), + const prevStoredScene = snapshot.data() as FirebaseStoredScene; + const prevStoredElements = getSyncableElements( + restoreElements(await decryptElements(prevStoredScene, roomKey), null), ); - const reconciledElements = getSyncableElements( - reconcileElements(elements, prevElements, appState), + reconcileElements( + elements, + prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[], + appState, + ), ); - const sceneDocument = await createFirebaseSceneDocument( + const storedScene = await createFirebaseSceneDocument( firebase, reconciledElements, roomKey, ); - transaction.update(docRef, sceneDocument); - return { - elements, - reconciledElements, - }; + transaction.update(docRef, storedScene); + + // Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime + return storedScene; }); - FirebaseSceneVersionCache.set(socket, savedData.elements); + const storedElements = getSyncableElements( + restoreElements(await decryptElements(storedScene, roomKey), null), + ); - return { reconciledElements: savedData.reconciledElements }; + FirebaseSceneVersionCache.set(socket, storedElements); + + return storedElements; }; export const loadFromFirebase = async ( roomId: string, roomKey: string, socket: Socket | null, -): Promise => { +): Promise => { const firebase = await loadFirestore(); const db = firebase.firestore(); @@ -298,14 +304,14 @@ export const loadFromFirebase = async ( } const storedScene = doc.data() as FirebaseStoredScene; const elements = getSyncableElements( - await decryptElements(storedScene, roomKey), + restoreElements(await decryptElements(storedScene, roomKey), null), ); if (socket) { FirebaseSceneVersionCache.set(socket, elements); } - return restoreElements(elements, null); + return elements; }; export const loadFilesFromFirebase = async ( diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 5699568b4..ba7df82b2 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -9,38 +9,39 @@ import { } from "../../packages/excalidraw/data/encryption"; import { serializeAsJSON } from "../../packages/excalidraw/data/json"; import { restore } from "../../packages/excalidraw/data/restore"; -import { ImportedDataState } from "../../packages/excalidraw/data/types"; -import { SceneBounds } from "../../packages/excalidraw/element/bounds"; +import type { ImportedDataState } from "../../packages/excalidraw/data/types"; +import type { SceneBounds } from "../../packages/excalidraw/element/bounds"; import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers"; import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; -import { +import type { ExcalidrawElement, FileId, + OrderedExcalidrawElement, } from "../../packages/excalidraw/element/types"; import { t } from "../../packages/excalidraw/i18n"; -import { +import type { AppState, BinaryFileData, BinaryFiles, SocketId, UserIdleState, } from "../../packages/excalidraw/types"; +import type { MakeBrand } from "../../packages/excalidraw/utility-types"; import { bytesToHexString } from "../../packages/excalidraw/utils"; +import type { WS_SUBTYPES } from "../app_constants"; import { DELETED_ELEMENT_TIMEOUT, FILE_UPLOAD_MAX_BYTES, ROOM_ID_BYTES, - WS_SUBTYPES, } from "../app_constants"; import { encodeFilesForUpload } from "./FileManager"; import { saveFilesToFirebase } from "./firebase"; -export type SyncableExcalidrawElement = ExcalidrawElement & { - _brand: "SyncableExcalidrawElement"; -}; +export type SyncableExcalidrawElement = OrderedExcalidrawElement & + MakeBrand<"SyncableExcalidrawElement">; export const isSyncableElement = ( - element: ExcalidrawElement, + element: OrderedExcalidrawElement, ): element is SyncableExcalidrawElement => { if (element.isDeleted) { if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) { @@ -51,7 +52,9 @@ export const isSyncableElement = ( return !isInvisiblySmallElement(element); }; -export const getSyncableElements = (elements: readonly ExcalidrawElement[]) => +export const getSyncableElements = ( + elements: readonly OrderedExcalidrawElement[], +) => elements.filter((element) => isSyncableElement(element), ) as SyncableExcalidrawElement[]; @@ -266,7 +269,6 @@ export const loadScene = async ( // in the scene database/localStorage, and instead fetch them async // from a different database files: data.files, - commitToHistory: false, }; }; diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts index ce4258f4e..1a282e047 100644 --- a/excalidraw-app/data/localStorage.ts +++ b/excalidraw-app/data/localStorage.ts @@ -1,12 +1,11 @@ -import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; -import { AppState } from "../../packages/excalidraw/types"; +import type { ExcalidrawElement } from "../../packages/excalidraw/element/types"; +import type { AppState } from "../../packages/excalidraw/types"; import { clearAppStateForLocalStorage, getDefaultAppState, } from "../../packages/excalidraw/appState"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { STORAGE_KEYS } from "../app_constants"; -import { ImportedDataState } from "../../packages/excalidraw/data/types"; export const saveUsernameToLocalStorage = (username: string) => { try { @@ -88,28 +87,13 @@ export const getTotalStorageSize = () => { try { const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); - const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY); const appStateSize = appState?.length || 0; const collabSize = collab?.length || 0; - const librarySize = library?.length || 0; - return appStateSize + collabSize + librarySize + getElementsStorageSize(); + return appStateSize + collabSize + getElementsStorageSize(); } catch (error: any) { console.error(error); return 0; } }; - -export const getLibraryItemsFromStorage = () => { - try { - const libraryItems: ImportedDataState["libraryItems"] = JSON.parse( - localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string, - ); - - return libraryItems || []; - } catch (error) { - console.error(error); - return []; - } -}; diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 66f3afdab..db5bd6457 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -64,12 +64,30 @@ - <% if ("%PROD%" === "true") { %> + <% if (typeof PROD != 'undefined' && PROD == true) { %> - <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %> - <% } %> diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index d7ab79836..3ca538870 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -4,6 +4,13 @@ &.theme--dark { --color-primary-contrast-offset: #726dff; // to offset Chubb illusion } + + .top-right-ui { + display: flex; + justify-content: center; + align-items: flex-start; + } + .footer-center { justify-content: flex-end; margin-top: auto; @@ -31,8 +38,12 @@ background-color: #ecfdf5; color: #064e3c; } - &.ExcalidrawPlus { + &.highlighted { color: var(--color-promo); + font-weight: 700; + .dropdown-menu-item__icon g { + stroke-width: 2; + } } } } diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json index 7d602d03a..8b82d01ad 100644 --- a/excalidraw-app/package.json +++ b/excalidraw-app/package.json @@ -25,7 +25,9 @@ "engines": { "node": ">=18.0.0" }, - "dependencies": {}, + "dependencies": { + "vite-plugin-html": "3.2.2" + }, "prettier": "@excalidraw/prettier-config", "scripts": { "build-node": "node ./scripts/build-node.js", diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx index 2fa92dff8..6511eec12 100644 --- a/excalidraw-app/share/ShareDialog.tsx +++ b/excalidraw-app/share/ShareDialog.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import * as Popover from "@radix-ui/react-popover"; import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard"; import { trackEvent } from "../../packages/excalidraw/analytics"; @@ -18,10 +18,12 @@ import { } from "../../packages/excalidraw/components/icons"; import { TextField } from "../../packages/excalidraw/components/TextField"; import { FilledButton } from "../../packages/excalidraw/components/FilledButton"; -import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab"; +import type { CollabAPI } from "../collab/Collab"; +import { activeRoomLinkAtom } from "../collab/Collab"; import { atom, useAtom, useAtomValue } from "jotai"; import "./ShareDialog.scss"; +import { useUIAppState } from "../../packages/excalidraw/context/ui-appState"; type OnExportToBackend = () => void; type ShareDialogType = "share" | "collaborationOnly"; @@ -69,20 +71,20 @@ const ActiveRoomDialog = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(activeRoomLink); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - collabAPI.setErrorMessage(error.message); + } catch (e) { + collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); + ref.current?.select(); }; @@ -275,6 +277,14 @@ export const ShareDialog = (props: { }) => { const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom); + const { openDialog } = useUIAppState(); + + useEffect(() => { + if (openDialog) { + setShareDialogState({ isOpen: false }); + } + }, [openDialog, setShareDialogState]); + if (!shareDialogState.isOpen) { return null; } @@ -285,6 +295,6 @@ export const ShareDialog = (props: { collabAPI={props.collabAPI} onExportToBackend={props.onExportToBackend} type={shareDialogState.type} - > + /> ); }; diff --git a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap index ad0c9f0f1..4e526a998 100644 --- a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap +++ b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap @@ -224,24 +224,14 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u fill="none" stroke="none" /> - - @@ -249,7 +239,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
- Try Excalidraw Plus! + Sign up
diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx index c3e94a5ef..f12e944e2 100644 --- a/excalidraw-app/tests/collab.test.tsx +++ b/excalidraw-app/tests/collab.test.tsx @@ -1,12 +1,19 @@ import { vi } from "vitest"; import { + act, render, updateSceneData, waitFor, } from "../../packages/excalidraw/tests/test-utils"; import ExcalidrawApp from "../App"; import { API } from "../../packages/excalidraw/tests/helpers/api"; -import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory"; +import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex"; +import { + createRedoAction, + createUndoAction, +} from "../../packages/excalidraw/actions/actionHistory"; +import { StoreAction, newElementWith } from "../../packages/excalidraw"; + const { h } = window; Object.defineProperty(window, "crypto", { @@ -56,39 +63,190 @@ vi.mock("socket.io-client", () => { }; }); +/** + * These test would deserve to be extended by testing collab with (at least) two clients simultanouesly, + * while having access to both scenes, appstates stores, histories and etc. + * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. + */ describe("collaboration", () => { - it("creating room should reset deleted elements", async () => { + it("should allow to undo / redo even on force-deleted elements", async () => { await render(); - // To update the scene with deleted elements before starting collab + const rect1Props = { + type: "rectangle", + id: "A", + height: 200, + width: 100, + } as const; + + const rect2Props = { + type: "rectangle", + id: "B", + width: 100, + height: 200, + } as const; + + const rect1 = API.createElement({ ...rect1Props }); + const rect2 = API.createElement({ ...rect2Props }); + updateSceneData({ - elements: [ - API.createElement({ type: "rectangle", id: "A" }), - API.createElement({ - type: "rectangle", - id: "B", - isDeleted: true, - }), - ], - }); - await waitFor(() => { - expect(h.elements).toEqual([ - expect.objectContaining({ id: "A" }), - expect.objectContaining({ id: "B", isDeleted: true }), - ]); - expect(API.getStateHistory().length).toBe(1); - }); - window.collab.startCollaboration(null); - await waitFor(() => { - expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); - expect(API.getStateHistory().length).toBe(1); + elements: syncInvalidIndices([rect1, rect2]), + storeAction: StoreAction.CAPTURE, + }); + + updateSceneData({ + elements: syncInvalidIndices([ + rect1, + newElementWith(h.elements[1], { isDeleted: true }), + ]), + storeAction: StoreAction.CAPTURE, }); - const undoAction = createUndoAction(h.history); - // noop - h.app.actionManager.executeAction(undoAction); await waitFor(() => { - expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); - expect(API.getStateHistory().length).toBe(1); + expect(API.getUndoStack().length).toBe(2); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + }); + + // one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server + window.collab.startCollaboration(null); + + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + // we never delete from the local snapshot as it is used for correct diff calculation + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); + }); + + const undoAction = createUndoAction(h.history, h.store); + act(() => h.app.actionManager.executeAction(undoAction)); + + // with explicit undo (as addition) we expect our item to be restored from the snapshot! + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false }), + ]); + }); + + // simulate force deleting the element remotely + updateSceneData({ + elements: syncInvalidIndices([rect1]), + storeAction: StoreAction.UPDATE, + }); + + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); + }); + + const redoAction = createRedoAction(h.history, h.store); + act(() => h.app.actionManager.executeAction(redoAction)); + + // with explicit redo (as removal) we again restore the element from the snapshot! + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + }); + + act(() => h.app.actionManager.executeAction(undoAction)); + + // simulate local update + updateSceneData({ + elements: syncInvalidIndices([ + h.elements[0], + newElementWith(h.elements[1], { x: 100 }), + ]), + storeAction: StoreAction.CAPTURE, + }); + + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), + ]); + }); + + act(() => h.app.actionManager.executeAction(undoAction)); + + // we expect to iterate the stack to the first visible change + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }), + ]); + }); + + // simulate force deleting the element remotely + updateSceneData({ + elements: syncInvalidIndices([rect1]), + storeAction: StoreAction.UPDATE, + }); + + // snapshot was correctly updated and marked the element as deleted + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }), + ]); + expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); + }); + + act(() => h.app.actionManager.executeAction(redoAction)); + + // with explicit redo (as update) we again restored the element from the snapshot! + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining({ id: "A", isDeleted: false }), + expect.objectContaining({ id: "B", isDeleted: true, x: 100 }), + ]); + expect(h.history.isRedoStackEmpty).toBeTruthy(); + expect(h.elements).toEqual([ + expect.objectContaining({ id: "A", isDeleted: false }), + expect.objectContaining({ id: "B", isDeleted: true, x: 100 }), + ]); }); }); }); diff --git a/excalidraw-app/tests/reconciliation.test.ts b/excalidraw-app/tests/reconciliation.test.ts deleted file mode 100644 index 8e395474c..000000000 --- a/excalidraw-app/tests/reconciliation.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { expect } from "chai"; -import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants"; -import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; -import { - BroadcastedExcalidrawElement, - ReconciledElements, - reconcileElements, -} from "../../excalidraw-app/collab/reconciliation"; -import { randomInteger } from "../../packages/excalidraw/random"; -import { AppState } from "../../packages/excalidraw/types"; -import { cloneJSON } from "../../packages/excalidraw/utils"; - -type Id = string; -type ElementLike = { - id: string; - version: number; - versionNonce: number; - [PRECEDING_ELEMENT_KEY]?: string | null; -}; - -type Cache = Record; - -const createElement = (opts: { uid: string } | ElementLike) => { - let uid: string; - let id: string; - let version: number | null; - let parent: string | null = null; - let versionNonce: number | null = null; - if ("uid" in opts) { - const match = opts.uid.match( - /^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/, - )!; - parent = match[1]; - id = match[2]; - version = match[3] ? parseInt(match[3]) : null; - uid = version ? `${id}:${version}` : id; - } else { - ({ id, version, versionNonce } = opts); - parent = parent || null; - uid = id; - } - return { - uid, - id, - version, - versionNonce: versionNonce || randomInteger(), - [PRECEDING_ELEMENT_KEY]: parent || null, - }; -}; - -const idsToElements = ( - ids: (Id | ElementLike)[], - cache: Cache = {}, -): readonly ExcalidrawElement[] => { - return ids.reduce((acc, _uid, idx) => { - const { - uid, - id, - version, - [PRECEDING_ELEMENT_KEY]: parent, - versionNonce, - } = createElement(typeof _uid === "string" ? { uid: _uid } : _uid); - const cached = cache[uid]; - const elem = { - id, - version: version ?? 0, - versionNonce, - ...cached, - [PRECEDING_ELEMENT_KEY]: parent, - } as BroadcastedExcalidrawElement; - // @ts-ignore - cache[uid] = elem; - acc.push(elem); - return acc; - }, [] as ExcalidrawElement[]); -}; - -const addParents = (elements: BroadcastedExcalidrawElement[]) => { - return elements.map((el, idx, els) => { - el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^"; - return el; - }); -}; - -const cleanElements = (elements: ReconciledElements) => { - return elements.map((el) => { - // @ts-ignore - delete el[PRECEDING_ELEMENT_KEY]; - // @ts-ignore - delete el.next; - // @ts-ignore - delete el.prev; - return el; - }); -}; - -const test = ( - local: (Id | ElementLike)[], - remote: (Id | ElementLike)[], - target: U[], - bidirectional = true, -) => { - const cache: Cache = {}; - const _local = idsToElements(local, cache); - const _remote = idsToElements(remote, cache); - const _target = target.map((uid) => { - const [, id, source] = uid.match(/^(\w+):([LR])$/)!; - return (source === "L" ? _local : _remote).find((e) => e.id === id)!; - }) as any as ReconciledElements; - const remoteReconciled = reconcileElements(_local, _remote, {} as AppState); - expect(target.length).equal(remoteReconciled.length); - expect(cleanElements(remoteReconciled)).deep.equal( - cleanElements(_target), - "remote reconciliation", - ); - - const __local = cleanElements(cloneJSON(_remote) as ReconciledElements); - const __remote = addParents(cleanElements(cloneJSON(remoteReconciled))); - if (bidirectional) { - try { - expect( - cleanElements( - reconcileElements( - cloneJSON(__local), - cloneJSON(__remote), - {} as AppState, - ), - ), - ).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation"); - } catch (error: any) { - console.error("local original", __local); - console.error("remote reconciled", __remote); - throw error; - } - } -}; - -export const findIndex = ( - array: readonly T[], - cb: (element: T, index: number, array: readonly T[]) => boolean, - fromIndex: number = 0, -) => { - if (fromIndex < 0) { - fromIndex = array.length + fromIndex; - } - fromIndex = Math.min(array.length, Math.max(fromIndex, 0)); - let index = fromIndex - 1; - while (++index < array.length) { - if (cb(array[index], index, array)) { - return index; - } - } - return -1; -}; - -// ----------------------------------------------------------------------------- - -describe("elements reconciliation", () => { - it("reconcileElements()", () => { - // ------------------------------------------------------------------------- - // - // in following tests, we pass: - // (1) an array of local elements and their version (:1, :2...) - // (2) an array of remote elements and their version (:1, :2...) - // (3) expected reconciled elements - // - // in the reconciled array: - // :L means local element was resolved - // :R means remote element was resolved - // - // if a remote element is prefixed with parentheses, the enclosed string: - // (^) means the element is the first element in the array - // () means the element is preceded by element - // - // if versions are missing, it defaults to version 0 - // ------------------------------------------------------------------------- - - // non-annotated elements - // ------------------------------------------------------------------------- - // usually when we sync elements they should always be annotated with - // their (preceding elements) parents, but let's test a couple of cases when - // they're not for whatever reason (remote clients are on older version...), - // in which case the first synced element either replaces existing element - // or is pushed at the end of the array - - test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]); - test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]); - test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]); - test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]); - test(["A", "B"], ["A:1"], ["A:R", "B:L"]); - test(["A"], ["A", "B"], ["A:L", "B:R"]); - test(["A"], ["A:1", "B"], ["A:R", "B:R"]); - test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]); - test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]); - test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]); - test(["A"], ["A:1"], ["A:R"]); - - // C isn't added to the end because it follows B (even if B was resolved - // to local version) - test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]); - - // some of the following tests are kinda arbitrary and they're less - // likely to happen in real-world cases - - test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]); - test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]); - test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]); - test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]); - test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]); - test( - ["A:2", "B:2", "C"], - ["D", "B:1", "A:3"], - ["B:L", "A:R", "C:L", "D:R"], - ); - test( - ["A:2", "B:2", "C"], - ["D", "B:2", "A:3", "C"], - ["D:R", "B:L", "A:R", "C:L"], - ); - test( - ["A", "B", "C", "D", "E", "F"], - ["A", "B:2", "X", "E:2", "F", "Y"], - ["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"], - ); - - // annotated elements - // ------------------------------------------------------------------------- - - test( - ["A", "B", "C"], - ["(B)X", "(A)Y", "(Y)Z"], - ["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"], - ); - - test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]); - test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]); - - test( - ["A", "B"], - ["(A)C", "(^)D", "F"], - ["A:L", "C:R", "D:R", "F:R", "B:L"], - ); - - test( - ["A", "B", "C", "D"], - ["(B)C:1", "B", "D:1"], - ["A:L", "C:R", "B:L", "D:R"], - ); - - test( - ["A", "B", "C"], - ["(^)X", "(A)Y", "(B)Z"], - ["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"], - ); - - test( - ["B", "A", "C"], - ["(^)X", "(A)Y", "(B)Z"], - ["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"], - ); - - test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]); - - test( - ["A", "B", "C", "D", "E"], - ["(A)X", "(C)Y", "(D)Z"], - ["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"], - ); - - test( - ["X", "Y", "Z"], - ["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"], - ["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"], - ); - - test( - ["A", "B", "C", "D", "E"], - ["(C)X", "(A)Y", "(D)E:1"], - ["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"], - ); - - test( - ["C:1", "B", "D:1"], - ["A", "B", "C:1", "D:1"], - ["A:R", "B:L", "C:L", "D:L"], - ); - - test( - ["A", "B", "C", "D"], - ["(A)C:1", "(C)B", "(B)D:1"], - ["A:L", "C:R", "B:L", "D:R"], - ); - - test( - ["A", "B", "C", "D"], - ["(A)C:1", "(C)B", "(B)D:1"], - ["A:L", "C:R", "B:L", "D:R"], - ); - - test( - ["C:1", "B", "D:1"], - ["(^)A", "(A)B", "(B)C:2", "(C)D:1"], - ["A:R", "B:L", "C:R", "D:L"], - ); - - test( - ["A", "B", "C", "D"], - ["(C)X", "(B)Y", "(A)Z"], - ["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"], - ); - - test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]); - test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]); - test( - ["A", "B", "C", "D"], - ["(A)C:1", "B", "D:1"], - ["A:L", "C:R", "B:L", "D:R"], - ); - - test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]); - test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]); - - test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]); - test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]); - test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]); - test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]); - test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]); - test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]); - test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]); - test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]); - test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]); - }); - - it("test identical elements reconciliation", () => { - const testIdentical = ( - local: ElementLike[], - remote: ElementLike[], - expected: Id[], - ) => { - const ret = reconcileElements( - local as any as ExcalidrawElement[], - remote as any as ExcalidrawElement[], - {} as AppState, - ); - - if (new Set(ret.map((x) => x.id)).size !== ret.length) { - throw new Error("reconcileElements: duplicate elements found"); - } - - expect(ret.map((x) => x.id)).to.deep.equal(expected); - }; - - // identical id/version/versionNonce - // ------------------------------------------------------------------------- - - testIdentical( - [{ id: "A", version: 1, versionNonce: 1 }], - [{ id: "A", version: 1, versionNonce: 1 }], - ["A"], - ); - testIdentical( - [ - { id: "A", version: 1, versionNonce: 1 }, - { id: "B", version: 1, versionNonce: 1 }, - ], - [ - { id: "B", version: 1, versionNonce: 1 }, - { id: "A", version: 1, versionNonce: 1 }, - ], - ["B", "A"], - ); - testIdentical( - [ - { id: "A", version: 1, versionNonce: 1 }, - { id: "B", version: 1, versionNonce: 1 }, - ], - [ - { id: "B", version: 1, versionNonce: 1 }, - { id: "A", version: 1, versionNonce: 1 }, - ], - ["B", "A"], - ); - - // actually identical (arrays and element objects) - // ------------------------------------------------------------------------- - - const elements1 = [ - { - id: "A", - version: 1, - versionNonce: 1, - [PRECEDING_ELEMENT_KEY]: null, - }, - { - id: "B", - version: 1, - versionNonce: 1, - [PRECEDING_ELEMENT_KEY]: null, - }, - ]; - - testIdentical(elements1, elements1, ["A", "B"]); - testIdentical(elements1, elements1.slice(), ["A", "B"]); - testIdentical(elements1.slice(), elements1, ["A", "B"]); - testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]); - - const el1 = { - id: "A", - version: 1, - versionNonce: 1, - [PRECEDING_ELEMENT_KEY]: null, - }; - const el2 = { - id: "B", - version: 1, - versionNonce: 1, - [PRECEDING_ELEMENT_KEY]: null, - }; - testIdentical([el1, el2], [el2, el1], ["A", "B"]); - }); -}); diff --git a/excalidraw-app/useHandleAppTheme.ts b/excalidraw-app/useHandleAppTheme.ts new file mode 100644 index 000000000..7dc45431e --- /dev/null +++ b/excalidraw-app/useHandleAppTheme.ts @@ -0,0 +1,70 @@ +import { atom, useAtom } from "jotai"; +import { useEffect, useLayoutEffect, useState } from "react"; +import { THEME } from "../packages/excalidraw"; +import { EVENT } from "../packages/excalidraw/constants"; +import type { Theme } from "../packages/excalidraw/element/types"; +import { CODES, KEYS } from "../packages/excalidraw/keys"; +import { STORAGE_KEYS } from "./app_constants"; + +export const appThemeAtom = atom( + (localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as + | Theme + | "system" + | null) || THEME.LIGHT, +); + +const getDarkThemeMediaQuery = (): MediaQueryList | undefined => + window.matchMedia?.("(prefers-color-scheme: dark)"); + +export const useHandleAppTheme = () => { + const [appTheme, setAppTheme] = useAtom(appThemeAtom); + const [editorTheme, setEditorTheme] = useState(THEME.LIGHT); + + useEffect(() => { + const mediaQuery = getDarkThemeMediaQuery(); + + const handleChange = (e: MediaQueryListEvent) => { + setEditorTheme(e.matches ? THEME.DARK : THEME.LIGHT); + }; + + if (appTheme === "system") { + mediaQuery?.addEventListener("change", handleChange); + } + + const handleKeydown = (event: KeyboardEvent) => { + if ( + !event[KEYS.CTRL_OR_CMD] && + event.altKey && + event.shiftKey && + event.code === CODES.D + ) { + event.preventDefault(); + event.stopImmediatePropagation(); + setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK); + } + }; + + document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true }); + + return () => { + mediaQuery?.removeEventListener("change", handleChange); + document.removeEventListener(EVENT.KEYDOWN, handleKeydown, { + capture: true, + }); + }; + }, [appTheme, editorTheme, setAppTheme]); + + useLayoutEffect(() => { + localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme); + + if (appTheme === "system") { + setEditorTheme( + getDarkThemeMediaQuery()?.matches ? THEME.DARK : THEME.LIGHT, + ); + } else { + setEditorTheme(appTheme); + } + }, [appTheme]); + + return { editorTheme }; +}; diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index b3714e81e..39417de36 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -4,6 +4,7 @@ import svgrPlugin from "vite-plugin-svgr"; import { ViteEjsPlugin } from "vite-plugin-ejs"; import { VitePWA } from "vite-plugin-pwa"; import checker from "vite-plugin-checker"; +import { createHtmlPlugin } from "vite-plugin-html"; // To load .env.local variables const envVars = loadEnv("", `../`); @@ -189,6 +190,9 @@ export default defineConfig({ ], }, }), + createHtmlPlugin({ + minify: true, + }), ], publicDir: "../public", }); diff --git a/package.json b/package.json index c2030e854..31680edf5 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "@types/chai": "4.3.0", "@types/jest": "27.4.0", "@types/lodash.throttle": "4.1.7", - "@types/react": "18.0.15", - "@types/react-dom": "18.0.6", + "@types/react": "18.2.0", + "@types/react-dom": "18.2.0", "@types/socket.io-client": "3.0.0", "@vitejs/plugin-react": "3.1.0", "@vitest/coverage-v8": "0.33.0", @@ -52,7 +52,7 @@ "vite-plugin-ejs": "1.7.0", "vite-plugin-pwa": "0.17.4", "vite-plugin-svgr": "2.4.0", - "vitest": "1.0.1", + "vitest": "1.5.3", "vitest-canvas-mock": "0.3.2" }, "engines": { @@ -62,9 +62,9 @@ "prettier": "@excalidraw/prettier-config", "scripts": { "build-node": "node ./scripts/build-node.js", - "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build", - "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", - "build:version": "node ./scripts/build-version.js", + "build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker", + "build:app": "yarn --cwd ./excalidraw-app build:app", + "build:version": "yarn --cwd ./excalidraw-app build:version", "build": "yarn --cwd ./excalidraw-app build", "fix:code": "yarn test:code --fix", "fix:other": "yarn prettier --write", diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index e531333a0..6577d04e0 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -15,6 +15,16 @@ Please add the latest change on the top under the correct section. ### Features +- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) + +- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853) + +- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) + +- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655) + +- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) + - Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638). - Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450) @@ -25,8 +35,18 @@ Please add the latest change on the top under the correct section. ### Breaking Changes +- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898) + +| | Before `commitToHistory` | After `storeAction` | Notes | +| --- | --- | --- | --- | +| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. | +| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).

**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. | +| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. | + - `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) +- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties. + - Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed #### Bundler @@ -85,8 +105,6 @@ define: { - Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343) ---- - ## 0.17.0 (2023-11-14) ### Features diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts index 1686554e4..93fddf0c4 100644 --- a/packages/excalidraw/actions/actionAddToLibrary.ts +++ b/packages/excalidraw/actions/actionAddToLibrary.ts @@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement"; import { randomId } from "../random"; import { t } from "../i18n"; import { LIBRARY_DISABLED_TYPES } from "../constants"; +import { StoreAction } from "../store"; export const actionAddToLibrary = register({ name: "addToLibrary", @@ -17,7 +18,7 @@ export const actionAddToLibrary = register({ for (const type of LIBRARY_DISABLED_TYPES) { if (selectedElements.some((element) => element.type === type)) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t(`errors.libraryElementTypeError.${type}`), @@ -41,7 +42,7 @@ export const actionAddToLibrary = register({ }) .then(() => { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, toast: { message: t("toast.addedToLibrary") }, @@ -50,7 +51,7 @@ export const actionAddToLibrary = register({ }) .catch((error) => { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: error.message, @@ -58,5 +59,5 @@ export const actionAddToLibrary = register({ }; }); }, - contextItemLabel: "labels.addToLibrary", + label: "labels.addToLibrary", }); diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index 8d7d36217..6ebf6b7e7 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -1,4 +1,5 @@ -import { alignElements, Alignment } from "../align"; +import type { Alignment } from "../align"; +import { alignElements } from "../align"; import { AlignBottomIcon, AlignLeftIcon, @@ -10,18 +11,19 @@ import { import { ToolButton } from "../components/ToolButton"; import { getNonDeletedElements } from "../element"; import { isFrameLikeElement } from "../element/typeChecks"; -import { ExcalidrawElement } from "../element/types"; +import type { ExcalidrawElement } from "../element/types"; import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; import { KEYS } from "../keys"; import { isSomeElementSelected } from "../scene"; -import { AppClassProperties, AppState } from "../types"; +import { StoreAction } from "../store"; +import type { AppClassProperties, AppState, UIAppState } from "../types"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; const alignActionsPredicate = ( elements: readonly ExcalidrawElement[], - appState: AppState, + appState: UIAppState, _: unknown, app: AppClassProperties, ) => { @@ -59,6 +61,8 @@ const alignSelectedElements = ( export const actionAlignTop = register({ name: "alignTop", + label: "labels.alignTop", + icon: AlignTopIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -68,7 +72,7 @@ export const actionAlignTop = register({ position: "start", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -90,6 +94,8 @@ export const actionAlignTop = register({ export const actionAlignBottom = register({ name: "alignBottom", + label: "labels.alignBottom", + icon: AlignBottomIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -99,7 +105,7 @@ export const actionAlignBottom = register({ position: "end", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -121,6 +127,8 @@ export const actionAlignBottom = register({ export const actionAlignLeft = register({ name: "alignLeft", + label: "labels.alignLeft", + icon: AlignLeftIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -130,7 +138,7 @@ export const actionAlignLeft = register({ position: "start", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -152,6 +160,8 @@ export const actionAlignLeft = register({ export const actionAlignRight = register({ name: "alignRight", + label: "labels.alignRight", + icon: AlignRightIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -161,7 +171,7 @@ export const actionAlignRight = register({ position: "end", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -183,6 +193,8 @@ export const actionAlignRight = register({ export const actionAlignVerticallyCentered = register({ name: "alignVerticallyCentered", + label: "labels.centerVertically", + icon: CenterVerticallyIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -192,7 +204,7 @@ export const actionAlignVerticallyCentered = register({ position: "center", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => ( @@ -210,6 +222,8 @@ export const actionAlignVerticallyCentered = register({ export const actionAlignHorizontallyCentered = register({ name: "alignHorizontallyCentered", + label: "labels.centerHorizontally", + icon: CenterHorizontallyIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -219,7 +233,7 @@ export const actionAlignHorizontallyCentered = register({ position: "center", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => ( diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 5084bd911..1218e4df2 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -23,19 +23,22 @@ import { isTextBindableContainer, isUsingAdaptiveRadius, } from "../element/typeChecks"; -import { +import type { ExcalidrawElement, ExcalidrawLinearElement, ExcalidrawTextContainer, ExcalidrawTextElement, } from "../element/types"; -import { AppState } from "../types"; -import { Mutable } from "../utility-types"; +import type { AppState } from "../types"; +import type { Mutable } from "../utility-types"; +import { arrayToMap } from "../utils"; import { register } from "./register"; +import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; export const actionUnbindText = register({ name: "unbindText", - contextItemLabel: "labels.unbindText", + label: "labels.unbindText", trackEvent: { category: "element" }, predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); @@ -48,22 +51,22 @@ export const actionUnbindText = register({ selectedElements.forEach((element) => { const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { - const { width, height, baseline } = measureTextElement( - boundTextElement, - { - text: boundTextElement.originalText, - }, - ); + const { width, height } = measureTextElement(boundTextElement, { + text: boundTextElement.originalText, + }); const originalContainerHeight = getOriginalContainerHeightFromCache( element.id, ); resetOriginalContainerCache(element.id); - const { x, y } = computeBoundTextPosition(element, boundTextElement); + const { x, y } = computeBoundTextPosition( + element, + boundTextElement, + elementsMap, + ); mutateElement(boundTextElement as ExcalidrawTextElement, { containerId: null, width, height, - baseline, text: boundTextElement.originalText, x, y, @@ -81,14 +84,14 @@ export const actionUnbindText = register({ return { elements, appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); export const actionBindText = register({ name: "bindText", - contextItemLabel: "labels.bindText", + label: "labels.bindText", trackEvent: { category: "element" }, predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); @@ -145,7 +148,11 @@ export const actionBindText = register({ }), }); const originalContainerHeight = container.height; - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); // overwritting the cache with original container height so // it can be restored when unbind updateOriginalContainerCache(container.id, originalContainerHeight); @@ -153,7 +160,7 @@ export const actionBindText = register({ return { elements: pushTextAboveContainer(elements, container, textElement), appState: { ...appState, selectedElementIds: { [container.id]: true } }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); @@ -173,6 +180,8 @@ const pushTextAboveContainer = ( (ele) => ele.id === container.id, ); updatedElements.splice(containerIndex + 1, 0, textElement); + syncMovedIndices(updatedElements, arrayToMap([container, textElement])); + return updatedElements; }; @@ -191,12 +200,14 @@ const pushContainerBelowText = ( (ele) => ele.id === textElement.id, ); updatedElements.splice(textElementIndex, 0, container); + syncMovedIndices(updatedElements, arrayToMap([container, textElement])); + return updatedElements; }; export const actionWrapTextInContainer = register({ name: "wrapTextInContainer", - contextItemLabel: "labels.createContainerFromText", + label: "labels.createContainerFromText", trackEvent: { category: "element" }, predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); @@ -286,13 +297,18 @@ export const actionWrapTextInContainer = register({ }, false, ); - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); updatedElements = pushContainerBelowText( [...updatedElements, container], container, textElement, ); + containerIds[container.id] = true; } } @@ -303,7 +319,7 @@ export const actionWrapTextInContainer = register({ ...appState, selectedElementIds: containerIds, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 73a0dd3bc..e78cdc708 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -1,15 +1,30 @@ import { ColorPicker } from "../components/ColorPicker/ColorPicker"; -import { ZoomInIcon, ZoomOutIcon } from "../components/icons"; +import { + handIcon, + MoonIcon, + SunIcon, + TrashIcon, + zoomAreaIcon, + ZoomInIcon, + ZoomOutIcon, + ZoomResetIcon, +} from "../components/icons"; import { ToolButton } from "../components/ToolButton"; -import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; +import { + CURSOR_TYPE, + MAX_ZOOM, + MIN_ZOOM, + THEME, + ZOOM_STEP, +} from "../constants"; import { getCommonBounds, getNonDeletedElements } from "../element"; -import { ExcalidrawElement } from "../element/types"; +import type { ExcalidrawElement } from "../element/types"; import { t } from "../i18n"; import { CODES, KEYS } from "../keys"; import { getNormalizedZoom } from "../scene"; import { centerScrollOn } from "../scene/scroll"; import { getStateForZoom } from "../scene/zoom"; -import { AppState, NormalizedZoomValue } from "../types"; +import type { AppState, NormalizedZoomValue } from "../types"; import { getShortcutKey, updateActiveTool } from "../utils"; import { register } from "./register"; import { Tooltip } from "../components/Tooltip"; @@ -20,11 +35,14 @@ import { isHandToolActive, } from "../appState"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; -import { SceneBounds } from "../element/bounds"; +import type { SceneBounds } from "../element/bounds"; import { setCursor } from "../cursor"; +import { StoreAction } from "../store"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", + label: "labels.canvasBackground", + paletteName: "Change canvas background color", trackEvent: false, predicate: (elements, appState, props, app) => { return ( @@ -35,7 +53,9 @@ export const actionChangeViewBackgroundColor = register({ perform: (_, appState, value) => { return { appState: { ...appState, ...value }, - commitToHistory: !!value.viewBackgroundColor, + storeAction: !!value.viewBackgroundColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => { @@ -59,6 +79,9 @@ export const actionChangeViewBackgroundColor = register({ export const actionClearCanvas = register({ name: "clearCanvas", + label: "labels.clearCanvas", + paletteName: "Clear canvas", + icon: TrashIcon, trackEvent: { category: "canvas" }, predicate: (elements, appState, props, app) => { return ( @@ -88,14 +111,16 @@ export const actionClearCanvas = register({ ? { ...appState.activeTool, type: "selection" } : appState.activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); export const actionZoomIn = register({ name: "zoomIn", + label: "buttons.zoomIn", viewMode: true, + icon: ZoomInIcon, trackEvent: { category: "canvas" }, perform: (_elements, appState, _, app) => { return { @@ -111,16 +136,17 @@ export const actionZoomIn = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - PanelComponent: ({ updateData }) => ( + PanelComponent: ({ updateData, appState }) => ( = MAX_ZOOM} onClick={() => { updateData(null); }} @@ -133,6 +159,8 @@ export const actionZoomIn = register({ export const actionZoomOut = register({ name: "zoomOut", + label: "buttons.zoomOut", + icon: ZoomOutIcon, viewMode: true, trackEvent: { category: "canvas" }, perform: (_elements, appState, _, app) => { @@ -149,16 +177,17 @@ export const actionZoomOut = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - PanelComponent: ({ updateData }) => ( + PanelComponent: ({ updateData, appState }) => ( { updateData(null); }} @@ -171,6 +200,8 @@ export const actionZoomOut = register({ export const actionResetZoom = register({ name: "resetZoom", + label: "buttons.resetZoom", + icon: ZoomResetIcon, viewMode: true, trackEvent: { category: "canvas" }, perform: (_elements, appState, _, app) => { @@ -187,7 +218,7 @@ export const actionResetZoom = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ updateData, appState }) => ( @@ -262,8 +293,8 @@ export const zoomToFitBounds = ({ // Apply clamping to newZoomValue to be between 10% and 3000% newZoomValue = Math.min( - Math.max(newZoomValue, 0.1), - 30.0, + Math.max(newZoomValue, MIN_ZOOM), + MAX_ZOOM, ) as NormalizedZoomValue; scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX; @@ -294,7 +325,7 @@ export const zoomToFitBounds = ({ scrollY, zoom: { value: newZoomValue }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }; @@ -326,6 +357,8 @@ export const zoomToFit = ({ // size, it won't be zoomed in. export const actionZoomToFitSelectionInViewport = register({ name: "zoomToFitSelectionInViewport", + label: "labels.zoomToFitViewport", + icon: zoomAreaIcon, trackEvent: { category: "canvas" }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); @@ -349,6 +382,8 @@ export const actionZoomToFitSelectionInViewport = register({ export const actionZoomToFitSelection = register({ name: "zoomToFitSelection", + label: "helpDialog.zoomToSelection", + icon: zoomAreaIcon, trackEvent: { category: "canvas" }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); @@ -371,6 +406,8 @@ export const actionZoomToFitSelection = register({ export const actionZoomToFit = register({ name: "zoomToFit", + label: "helpDialog.zoomToFit", + icon: zoomAreaIcon, viewMode: true, trackEvent: { category: "canvas" }, perform: (elements, appState) => @@ -391,6 +428,13 @@ export const actionZoomToFit = register({ export const actionToggleTheme = register({ name: "toggleTheme", + label: (_, appState) => { + return appState.theme === THEME.DARK + ? "buttons.lightMode" + : "buttons.darkMode"; + }, + keywords: ["toggle", "dark", "light", "mode", "theme"], + icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon), viewMode: true, trackEvent: { category: "canvas" }, perform: (_, appState, value) => { @@ -400,7 +444,7 @@ export const actionToggleTheme = register({ theme: value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, @@ -411,6 +455,7 @@ export const actionToggleTheme = register({ export const actionToggleEraserTool = register({ name: "toggleEraserTool", + label: "toolBar.eraser", trackEvent: { category: "toolbar" }, perform: (elements, appState) => { let activeTool: AppState["activeTool"]; @@ -437,7 +482,7 @@ export const actionToggleEraserTool = register({ activeEmbeddable: null, activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event.key === KEYS.E, @@ -445,7 +490,11 @@ export const actionToggleEraserTool = register({ export const actionToggleHandTool = register({ name: "toggleHandTool", + label: "toolBar.hand", + paletteName: "Toggle hand tool", trackEvent: { category: "toolbar" }, + icon: handIcon, + viewMode: false, perform: (elements, appState, _, app) => { let activeTool: AppState["activeTool"]; @@ -472,7 +521,7 @@ export const actionToggleHandTool = register({ activeEmbeddable: null, activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index dadc61013..e4f998d01 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -13,9 +13,13 @@ import { exportCanvas, prepareElementsForExport } from "../data/index"; import { isTextElement } from "../element"; import { t } from "../i18n"; import { isFirefox } from "../constants"; +import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionCopy = register({ name: "copy", + label: "labels.copy", + icon: DuplicateIcon, trackEvent: { category: "element" }, perform: async (elements, appState, event: ClipboardEvent | null, app) => { const elementsToCopy = app.scene.getSelectedElements({ @@ -28,7 +32,7 @@ export const actionCopy = register({ await copyToClipboard(elementsToCopy, app.files, event); } catch (error: any) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: error.message, @@ -37,16 +41,16 @@ export const actionCopy = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - contextItemLabel: "labels.copy", // don't supply a shortcut since we handle this conditionally via onCopy event keyTest: undefined, }); export const actionPaste = register({ name: "paste", + label: "labels.paste", trackEvent: { category: "element" }, perform: async (elements, appState, data, app) => { let types; @@ -63,7 +67,7 @@ export const actionPaste = register({ if (isFirefox) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("hints.firefox_clipboard_write"), @@ -72,7 +76,7 @@ export const actionPaste = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("errors.asyncPasteFailedOnRead"), @@ -85,7 +89,7 @@ export const actionPaste = register({ } catch (error: any) { console.error(error); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("errors.asyncPasteFailedOnParse"), @@ -94,32 +98,34 @@ export const actionPaste = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - contextItemLabel: "labels.paste", // don't supply a shortcut since we handle this conditionally via onCopy event keyTest: undefined, }); export const actionCut = register({ name: "cut", + label: "labels.cut", + icon: cutIcon, trackEvent: { category: "element" }, perform: (elements, appState, event: ClipboardEvent | null, app) => { actionCopy.perform(elements, appState, event, app); - return actionDeleteSelected.perform(elements, appState); + return actionDeleteSelected.perform(elements, appState, null, app); }, - contextItemLabel: "labels.cut", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, }); export const actionCopyAsSvg = register({ name: "copyAsSvg", + label: "labels.copyAsSvg", + icon: svgIcon, trackEvent: { category: "element" }, perform: async (elements, appState, _data, app) => { if (!app.canvas) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } @@ -138,10 +144,11 @@ export const actionCopyAsSvg = register({ { ...appState, exportingFrame, + name: app.getName(), }, ); return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } catch (error: any) { console.error(error); @@ -150,23 +157,25 @@ export const actionCopyAsSvg = register({ ...appState, errorMessage: error.message, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, predicate: (elements) => { return probablySupportsClipboardWriteText && elements.length > 0; }, - contextItemLabel: "labels.copyAsSvg", + keywords: ["svg", "clipboard", "copy"], }); export const actionCopyAsPng = register({ name: "copyAsPng", + label: "labels.copyAsPng", + icon: pngIcon, trackEvent: { category: "element" }, perform: async (elements, appState, _data, app) => { if (!app.canvas) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } const selectedElements = app.scene.getSelectedElements({ @@ -184,6 +193,7 @@ export const actionCopyAsPng = register({ await exportCanvas("clipboard", exportedElements, appState, app.files, { ...appState, exportingFrame, + name: app.getName(), }); return { appState: { @@ -199,7 +209,7 @@ export const actionCopyAsPng = register({ }), }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } catch (error: any) { console.error(error); @@ -208,19 +218,20 @@ export const actionCopyAsPng = register({ ...appState, errorMessage: error.message, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, predicate: (elements) => { return probablySupportsClipboardBlob && elements.length > 0; }, - contextItemLabel: "labels.copyAsPng", keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, + keywords: ["png", "clipboard", "copy"], }); export const copyText = register({ name: "copyText", + label: "labels.copyText", trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements({ @@ -236,9 +247,13 @@ export const copyText = register({ return acc; }, []) .join("\n\n"); - copyTextToSystemClipboard(text); + try { + copyTextToSystemClipboard(text); + } catch (e) { + throw new Error(t("errors.copyToSystemClipboardFailed")); + } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, predicate: (elements, appState, _, app) => { @@ -252,5 +267,5 @@ export const copyText = register({ .some(isTextElement) ); }, - contextItemLabel: "labels.copyText", + keywords: ["text", "clipboard", "copy"], }); diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index de25ed898..311d88970 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -4,8 +4,8 @@ import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; import { register } from "./register"; import { getNonDeletedElements } from "../element"; -import { ExcalidrawElement } from "../element/types"; -import { AppState } from "../types"; +import type { ExcalidrawElement } from "../element/types"; +import type { AppState } from "../types"; import { newElementWith } from "../element/mutateElement"; import { getElementsInGroup } from "../groups"; import { LinearElementEditor } from "../element/linearElementEditor"; @@ -13,6 +13,7 @@ import { fixBindingsAfterDeletion } from "../element/binding"; import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; import { updateActiveTool } from "../utils"; import { TrashIcon } from "../components/icons"; +import { StoreAction } from "../store"; const deleteSelectedElements = ( elements: readonly ExcalidrawElement[], @@ -72,8 +73,10 @@ const handleGroupEditingState = ( export const actionDeleteSelected = register({ name: "deleteSelectedElements", + label: "labels.delete", + icon: TrashIcon, trackEvent: { category: "element", action: "delete" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { if (appState.editingLinearElement) { const { elementId, @@ -81,7 +84,8 @@ export const actionDeleteSelected = register({ startBindingElement, endBindingElement, } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const elementsMap = app.scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } @@ -109,7 +113,7 @@ export const actionDeleteSelected = register({ ...nextAppState, editingLinearElement: null, }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; } @@ -141,7 +145,7 @@ export const actionDeleteSelected = register({ : [0], }, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } let { elements: nextElements, appState: nextAppState } = @@ -161,13 +165,14 @@ export const actionDeleteSelected = register({ multiElement: null, activeEmbeddable: null, }, - commitToHistory: isSomeElementSelected( + storeAction: isSomeElementSelected( getNonDeletedElements(elements), appState, - ), + ) + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, - contextItemLabel: "labels.delete", keyTest: (event, appState, elements) => (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) && !event[KEYS.CTRL_OR_CMD], diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index be48bc870..4b4166a7e 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -3,15 +3,17 @@ import { DistributeVerticallyIcon, } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; -import { distributeElements, Distribution } from "../distribute"; +import type { Distribution } from "../distribute"; +import { distributeElements } from "../distribute"; import { getNonDeletedElements } from "../element"; import { isFrameLikeElement } from "../element/typeChecks"; -import { ExcalidrawElement } from "../element/types"; +import type { ExcalidrawElement } from "../element/types"; import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; import { CODES, KEYS } from "../keys"; import { isSomeElementSelected } from "../scene"; -import { AppClassProperties, AppState } from "../types"; +import { StoreAction } from "../store"; +import type { AppClassProperties, AppState } from "../types"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; @@ -49,6 +51,7 @@ const distributeSelectedElements = ( export const distributeHorizontally = register({ name: "distributeHorizontally", + label: "labels.distributeHorizontally", trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { return { @@ -57,7 +60,7 @@ export const distributeHorizontally = register({ space: "between", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -79,6 +82,7 @@ export const distributeHorizontally = register({ export const distributeVertically = register({ name: "distributeVertically", + label: "labels.distributeVertically", trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { return { @@ -87,7 +91,7 @@ export const distributeVertically = register({ space: "between", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 7126f549e..44c26e226 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -1,6 +1,6 @@ import { KEYS } from "../keys"; import { register } from "./register"; -import { ExcalidrawElement } from "../element/types"; +import type { ExcalidrawElement } from "../element/types"; import { duplicateElement, getNonDeletedElements } from "../element"; import { isSomeElementSelected } from "../scene"; import { ToolButton } from "../components/ToolButton"; @@ -12,9 +12,9 @@ import { getSelectedGroupForElement, getElementsInGroup, } from "../groups"; -import { AppState } from "../types"; +import type { AppState } from "../types"; import { fixBindingsAfterDuplication } from "../element/binding"; -import { ActionResult } from "./types"; +import type { ActionResult } from "./types"; import { GRID_SIZE } from "../constants"; import { bindTextToShapeAfterDuplication, @@ -31,14 +31,22 @@ import { excludeElementsInFramesFromSelection, getSelectedElements, } from "../scene/selection"; +import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; export const actionDuplicateSelection = register({ name: "duplicateSelection", + label: "labels.duplicateSelection", + icon: DuplicateIcon, trackEvent: { category: "element" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { + const elementsMap = app.scene.getNonDeletedElementsMap(); // duplicate selected point(s) if editing a line if (appState.editingLinearElement) { - const ret = LinearElementEditor.duplicateSelectedPoints(appState); + const ret = LinearElementEditor.duplicateSelectedPoints( + appState, + elementsMap, + ); if (!ret) { return false; @@ -47,16 +55,15 @@ export const actionDuplicateSelection = register({ return { elements, appState: ret.appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } return { ...duplicateElements(elements, appState), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, - contextItemLabel: "labels.duplicateSelection", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, PanelComponent: ({ elements, appState, updateData }) => ( (); const duplicateAndOffsetElement = (element: ExcalidrawElement) => { const newElement = duplicateElement( @@ -96,6 +104,7 @@ const duplicateElements = ( y: element.y + GRID_SIZE / 2, }, ); + duplicatedElementsMap.set(newElement.id, newElement); oldIdToDuplicatedId.set(element.id, newElement.id); oldElements.push(element); newElements.push(newElement); @@ -233,8 +242,10 @@ const duplicateElements = ( } // step (3) - - const finalElements = finalElementsReversed.reverse(); + const finalElements = syncMovedIndices( + finalElementsReversed.reverse(), + arrayToMap(newElements), + ); // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts index 164240b29..5e5a91f5d 100644 --- a/packages/excalidraw/actions/actionElementLock.ts +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -1,7 +1,10 @@ +import { LockedIcon, UnlockedIcon } from "../components/icons"; import { newElementWith } from "../element/mutateElement"; import { isFrameLikeElement } from "../element/typeChecks"; -import { ExcalidrawElement } from "../element/types"; +import type { ExcalidrawElement } from "../element/types"; import { KEYS } from "../keys"; +import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; import { arrayToMap } from "../utils"; import { register } from "./register"; @@ -10,11 +13,31 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) => export const actionToggleElementLock = register({ name: "toggleElementLock", + label: (elements, appState, app) => { + const selected = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: false, + }); + if (selected.length === 1 && !isFrameLikeElement(selected[0])) { + return selected[0].locked + ? "labels.elementLock.unlock" + : "labels.elementLock.lock"; + } + + return shouldLock(selected) + ? "labels.elementLock.lockAll" + : "labels.elementLock.unlockAll"; + }, + icon: (appState, elements) => { + const selectedElements = getSelectedElements(elements, appState); + return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon; + }, trackEvent: { category: "element" }, predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); - return !selectedElements.some( - (element) => element.locked && element.frameId, + return ( + selectedElements.length > 0 && + !selectedElements.some((element) => element.locked && element.frameId) ); }, perform: (elements, appState, _, app) => { @@ -44,24 +67,9 @@ export const actionToggleElementLock = register({ ? null : appState.selectedLinearElement, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, - contextItemLabel: (elements, appState, app) => { - const selected = app.scene.getSelectedElements({ - selectedElementIds: appState.selectedElementIds, - includeBoundTextElement: false, - }); - if (selected.length === 1 && !isFrameLikeElement(selected[0])) { - return selected[0].locked - ? "labels.elementLock.unlock" - : "labels.elementLock.lock"; - } - - return shouldLock(selected) - ? "labels.elementLock.lockAll" - : "labels.elementLock.unlockAll"; - }, keyTest: (event, appState, elements, app) => { return ( event.key.toLocaleLowerCase() === KEYS.L && @@ -77,10 +85,16 @@ export const actionToggleElementLock = register({ export const actionUnlockAllElements = register({ name: "unlockAllElements", + paletteName: "Unlock all elements", trackEvent: { category: "canvas" }, viewMode: false, - predicate: (elements) => { - return elements.some((element) => element.locked); + icon: UnlockedIcon, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length === 0 && + elements.some((element) => element.locked) + ); }, perform: (elements, appState) => { const lockedElements = elements.filter((el) => el.locked); @@ -98,8 +112,8 @@ export const actionUnlockAllElements = register({ lockedElements.map((el) => [el.id, true]), ), }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, - contextItemLabel: "labels.elementLock.unlockAll", + label: "labels.elementLock.unlockAll", }); diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 74dff34c8..224edf473 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -1,4 +1,4 @@ -import { questionCircle, saveAs } from "../components/icons"; +import { ExportIcon, questionCircle, saveAs } from "../components/icons"; import { ProjectName } from "../components/ProjectName"; import { ToolButton } from "../components/ToolButton"; import { Tooltip } from "../components/Tooltip"; @@ -16,24 +16,26 @@ import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getNonDeletedElements } from "../element"; import { isImageFileHandle } from "../data/blob"; import { nativeFileSystemSupported } from "../data/filesystem"; -import { Theme } from "../element/types"; +import type { Theme } from "../element/types"; import "../components/ToolIcon.scss"; +import { StoreAction } from "../store"; export const actionChangeProjectName = register({ name: "changeProjectName", + label: "labels.fileTitle", trackEvent: false, perform: (_elements, appState, value) => { - return { appState: { ...appState, name: value }, commitToHistory: false }; + return { + appState: { ...appState, name: value }, + storeAction: StoreAction.NONE, + }; }, - PanelComponent: ({ appState, updateData, appProps, data }) => ( + PanelComponent: ({ appState, updateData, appProps, data, app }) => ( updateData(name)} - isNameEditable={ - typeof appProps.name === "undefined" && !appState.viewModeEnabled - } ignoreFocus={data?.ignoreFocus ?? false} /> ), @@ -41,11 +43,12 @@ export const actionChangeProjectName = register({ export const actionChangeExportScale = register({ name: "changeExportScale", + label: "imageExportDialog.scale", trackEvent: { category: "export", action: "scale" }, perform: (_elements, appState, value) => { return { appState: { ...appState, exportScale: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ elements: allElements, appState, updateData }) => { @@ -90,11 +93,12 @@ export const actionChangeExportScale = register({ export const actionChangeExportBackground = register({ name: "changeExportBackground", + label: "imageExportDialog.label.withBackground", trackEvent: { category: "export", action: "toggleBackground" }, perform: (_elements, appState, value) => { return { appState: { ...appState, exportBackground: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( @@ -109,11 +113,12 @@ export const actionChangeExportBackground = register({ export const actionChangeExportEmbedScene = register({ name: "changeExportEmbedScene", + label: "imageExportDialog.tooltip.embedScene", trackEvent: { category: "export", action: "embedScene" }, perform: (_elements, appState, value) => { return { appState: { ...appState, exportEmbedScene: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( @@ -131,6 +136,8 @@ export const actionChangeExportEmbedScene = register({ export const actionSaveToActiveFile = register({ name: "saveToActiveFile", + label: "buttons.save", + icon: ExportIcon, trackEvent: { category: "export" }, predicate: (elements, appState, props, app) => { return ( @@ -144,11 +151,16 @@ export const actionSaveToActiveFile = register({ try { const { fileHandle } = isImageFileHandle(appState.fileHandle) - ? await resaveAsImageWithScene(elements, appState, app.files) - : await saveAsJSON(elements, appState, app.files); + ? await resaveAsImageWithScene( + elements, + appState, + app.files, + app.getName(), + ) + : await saveAsJSON(elements, appState, app.files, app.getName()); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, fileHandle, @@ -170,7 +182,7 @@ export const actionSaveToActiveFile = register({ } else { console.warn(error); } - return { commitToHistory: false }; + return { storeAction: StoreAction.NONE }; } }, keyTest: (event) => @@ -179,6 +191,8 @@ export const actionSaveToActiveFile = register({ export const actionSaveFileToDisk = register({ name: "saveFileToDisk", + label: "exportDialog.disk_title", + icon: ExportIcon, viewMode: true, trackEvent: { category: "export" }, perform: async (elements, appState, value, app) => { @@ -190,9 +204,10 @@ export const actionSaveFileToDisk = register({ fileHandle: null, }, app.files, + app.getName(), ); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, openDialog: null, @@ -206,7 +221,7 @@ export const actionSaveFileToDisk = register({ } else { console.warn(error); } - return { commitToHistory: false }; + return { storeAction: StoreAction.NONE }; } }, keyTest: (event) => @@ -227,6 +242,7 @@ export const actionSaveFileToDisk = register({ export const actionLoadScene = register({ name: "loadScene", + label: "buttons.load", trackEvent: { category: "export" }, predicate: (elements, appState, props, app) => { return ( @@ -244,7 +260,7 @@ export const actionLoadScene = register({ elements: loadedElements, appState: loadedAppState, files, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } catch (error: any) { if (error?.name === "AbortError") { @@ -255,7 +271,7 @@ export const actionLoadScene = register({ elements, appState: { ...appState, errorMessage: error.message }, files: app.files, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, @@ -264,11 +280,12 @@ export const actionLoadScene = register({ export const actionExportWithDarkMode = register({ name: "exportWithDarkMode", + label: "imageExportDialog.label.darkMode", trackEvent: { category: "export", action: "toggleTheme" }, perform: (_elements, appState, value) => { return { appState: { ...appState, exportWithDarkMode: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index a7c34c5ac..9661154f7 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -1,6 +1,6 @@ import { KEYS } from "../keys"; import { isInvisiblySmallElement } from "../element"; -import { updateActiveTool } from "../utils"; +import { arrayToMap, updateActiveTool } from "../utils"; import { ToolButton } from "../components/ToolButton"; import { done } from "../components/icons"; import { t } from "../i18n"; @@ -8,28 +8,28 @@ import { register } from "./register"; import { mutateElement } from "../element/mutateElement"; import { isPathALoop } from "../math"; import { LinearElementEditor } from "../element/linearElementEditor"; -import Scene from "../scene/Scene"; import { maybeBindLinearElement, bindOrUnbindLinearElement, } from "../element/binding"; import { isBindingElement, isLinearElement } from "../element/typeChecks"; -import { AppState } from "../types"; +import type { AppState } from "../types"; import { resetCursor } from "../cursor"; +import { StoreAction } from "../store"; export const actionFinalize = register({ name: "finalize", + label: "", trackEvent: false, - perform: ( - elements, - appState, - _, - { interactiveCanvas, focusContainer, scene }, - ) => { + perform: (elements, appState, _, app) => { + const { interactiveCanvas, focusContainer, scene } = app; + + const elementsMap = scene.getNonDeletedElementsMap(); + if (appState.editingLinearElement) { const { elementId, startBindingElement, endBindingElement } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (element) { if (isBindingElement(element)) { @@ -37,6 +37,7 @@ export const actionFinalize = register({ element, startBindingElement, endBindingElement, + elementsMap, ); } return { @@ -48,8 +49,9 @@ export const actionFinalize = register({ ...appState, cursorButton: "up", editingLinearElement: null, + selectedLinearElement: null, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } } @@ -90,7 +92,9 @@ export const actionFinalize = register({ }); } } + if (isInvisiblySmallElement(multiPointElement)) { + // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want newElements = newElements.filter( (el) => el.id !== multiPointElement.id, ); @@ -125,13 +129,9 @@ export const actionFinalize = register({ const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( multiPointElement, -1, + arrayToMap(elements), ); - maybeBindLinearElement( - multiPointElement, - appState, - Scene.getScene(multiPointElement)!, - { x, y }, - ); + maybeBindLinearElement(multiPointElement, appState, { x, y }, app); } } @@ -186,11 +186,12 @@ export const actionFinalize = register({ // To select the linear element when user has finished mutipoint editing selectedLinearElement: multiPointElement && isLinearElement(multiPointElement) - ? new LinearElementEditor(multiPointElement, scene) + ? new LinearElementEditor(multiPointElement) : appState.selectedLinearElement, pendingImageElementId: null, }, - commitToHistory: appState.activeTool.type === "freedraw", + // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event, appState) => diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index c760af44d..0aab7f903 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -1,26 +1,29 @@ import { register } from "./register"; import { getSelectedElements } from "../scene"; import { getNonDeletedElements } from "../element"; -import { +import type { ExcalidrawElement, NonDeleted, - NonDeletedElementsMap, NonDeletedSceneElementsMap, } from "../element/types"; import { resizeMultipleElements } from "../element/resizeElements"; -import { AppState } from "../types"; +import type { AppClassProperties, AppState } from "../types"; import { arrayToMap } from "../utils"; import { CODES, KEYS } from "../keys"; import { getCommonBoundingBox } from "../element/bounds"; import { - bindOrUnbindSelectedElements, + bindOrUnbindLinearElements, isBindingEnabled, - unbindLinearElements, } from "../element/binding"; import { updateFrameMembershipOfSelectedElements } from "../frame"; +import { flipHorizontal, flipVertical } from "../components/icons"; +import { StoreAction } from "../store"; +import { isLinearElement } from "../element/typeChecks"; export const actionFlipHorizontal = register({ name: "flipHorizontal", + label: "labels.flipHorizontal", + icon: flipHorizontal, trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { return { @@ -30,20 +33,22 @@ export const actionFlipHorizontal = register({ app.scene.getNonDeletedElementsMap(), appState, "horizontal", + app, ), appState, app, ), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event.shiftKey && event.code === CODES.H, - contextItemLabel: "labels.flipHorizontal", }); export const actionFlipVertical = register({ name: "flipVertical", + label: "labels.flipVertical", + icon: flipVertical, trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { return { @@ -53,24 +58,25 @@ export const actionFlipVertical = register({ app.scene.getNonDeletedElementsMap(), appState, "vertical", + app, ), appState, app, ), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD], - contextItemLabel: "labels.flipVertical", }); const flipSelectedElements = ( elements: readonly ExcalidrawElement[], - elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap, + elementsMap: NonDeletedSceneElementsMap, appState: Readonly, flipDirection: "horizontal" | "vertical", + app: AppClassProperties, ) => { const selectedElements = getSelectedElements( getNonDeletedElements(elements), @@ -86,6 +92,7 @@ const flipSelectedElements = ( elementsMap, appState, flipDirection, + app, ); const updatedElementsMap = arrayToMap(updatedElements); @@ -97,9 +104,10 @@ const flipSelectedElements = ( const flipElements = ( selectedElements: NonDeleted[], - elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap, + elementsMap: NonDeletedSceneElementsMap, appState: AppState, flipDirection: "horizontal" | "vertical", + app: AppClassProperties, ): ExcalidrawElement[] => { const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements); @@ -109,13 +117,17 @@ const flipElements = ( elementsMap, "nw", true, + true, flipDirection === "horizontal" ? maxX : minX, flipDirection === "horizontal" ? minY : maxY, ); - (isBindingEnabled(appState) - ? bindOrUnbindSelectedElements - : unbindLinearElements)(selectedElements); + bindOrUnbindLinearElements( + selectedElements.filter(isLinearElement), + app, + isBindingEnabled(appState), + [], + ); return selectedElements; }; diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 8232db3cd..ffed9197a 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -1,15 +1,20 @@ import { getNonDeletedElements } from "../element"; -import { ExcalidrawElement } from "../element/types"; +import type { ExcalidrawElement } from "../element/types"; import { removeAllElementsFromFrame } from "../frame"; import { getFrameChildren } from "../frame"; import { KEYS } from "../keys"; -import { AppClassProperties, AppState } from "../types"; +import type { AppClassProperties, AppState, UIAppState } from "../types"; import { updateActiveTool } from "../utils"; import { setCursorForShape } from "../cursor"; import { register } from "./register"; import { isFrameLikeElement } from "../element/typeChecks"; +import { frameToolIcon } from "../components/icons"; +import { StoreAction } from "../store"; -const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => { +const isSingleFrameSelected = ( + appState: UIAppState, + app: AppClassProperties, +) => { const selectedElements = app.scene.getSelectedElements(appState); return ( @@ -19,6 +24,7 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => { export const actionSelectAllElementsInFrame = register({ name: "selectAllElementsInFrame", + label: "labels.selectAllElementsInFrame", trackEvent: { category: "canvas" }, perform: (elements, appState, _, app) => { const selectedElement = @@ -39,23 +45,23 @@ export const actionSelectAllElementsInFrame = register({ return acc; }, {} as Record), }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; } return { elements, appState, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - contextItemLabel: "labels.selectAllElementsInFrame", predicate: (elements, appState, _, app) => isSingleFrameSelected(appState, app), }); export const actionRemoveAllElementsFromFrame = register({ name: "removeAllElementsFromFrame", + label: "labels.removeAllElementsFromFrame", trackEvent: { category: "history" }, perform: (elements, appState, _, app) => { const selectedElement = @@ -70,23 +76,23 @@ export const actionRemoveAllElementsFromFrame = register({ [selectedElement.id]: true, }, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } return { elements, appState, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - contextItemLabel: "labels.removeAllElementsFromFrame", predicate: (elements, appState, _, app) => isSingleFrameSelected(appState, app), }); export const actionupdateFrameRendering = register({ name: "updateFrameRendering", + label: "labels.updateFrameRendering", viewMode: true, trackEvent: { category: "canvas" }, perform: (elements, appState) => { @@ -99,16 +105,18 @@ export const actionupdateFrameRendering = register({ enabled: !appState.frameRendering.enabled, }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - contextItemLabel: "labels.updateFrameRendering", checked: (appState: AppState) => appState.frameRendering.enabled, }); export const actionSetFrameAsActiveTool = register({ name: "setFrameAsActiveTool", + label: "toolBar.frame", trackEvent: { category: "toolbar" }, + icon: frameToolIcon, + viewMode: false, perform: (elements, appState, _, app) => { const nextActiveTool = updateActiveTool(appState, { type: "frame", @@ -127,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({ type: "frame", }), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index 44523857a..6a6e735b7 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -17,8 +17,12 @@ import { import { getNonDeletedElements } from "../element"; import { randomId } from "../random"; import { ToolButton } from "../components/ToolButton"; -import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; -import { AppClassProperties, AppState } from "../types"; +import type { + ExcalidrawElement, + ExcalidrawTextElement, + OrderedExcalidrawElement, +} from "../element/types"; +import type { AppClassProperties, AppState } from "../types"; import { isBoundToContainer } from "../element/typeChecks"; import { getElementsInResizingFrame, @@ -27,6 +31,8 @@ import { removeElementsFromFrame, replaceAllElementsInFrame, } from "../frame"; +import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { if (elements.length >= 2) { @@ -61,6 +67,8 @@ const enableActionGroup = ( export const actionGroup = register({ name: "group", + label: "labels.group", + icon: (appState) => , trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements({ @@ -69,7 +77,7 @@ export const actionGroup = register({ }); if (selectedElements.length < 2) { // nothing to group - return { appState, elements, commitToHistory: false }; + return { appState, elements, storeAction: StoreAction.NONE }; } // if everything is already grouped into 1 group, there is nothing to do const selectedGroupIds = getSelectedGroupIds(appState); @@ -89,7 +97,7 @@ export const actionGroup = register({ ]); if (combinedSet.size === elementIdsInGroup.size) { // no incremental ids in the selected ids - return { appState, elements, commitToHistory: false }; + return { appState, elements, storeAction: StoreAction.NONE }; } } @@ -131,18 +139,19 @@ export const actionGroup = register({ // to the z order of the highest element in the layer stack const elementsInGroup = getElementsInGroup(nextElements, newGroupId); const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; - const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup); + const lastGroupElementIndex = nextElements.lastIndexOf( + lastElementInGroup as OrderedExcalidrawElement, + ); const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1); const elementsBeforeGroup = nextElements .slice(0, lastGroupElementIndex) .filter( (updatedElement) => !isElementInGroup(updatedElement, newGroupId), ); - nextElements = [ - ...elementsBeforeGroup, - ...elementsInGroup, - ...elementsAfterGroup, - ]; + const reorderedElements = syncMovedIndices( + [...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup], + arrayToMap(elementsInGroup), + ); return { appState: { @@ -153,11 +162,10 @@ export const actionGroup = register({ getNonDeletedElements(nextElements), ), }, - elements: nextElements, - commitToHistory: true, + elements: reorderedElements, + storeAction: StoreAction.CAPTURE, }; }, - contextItemLabel: "labels.group", predicate: (elements, appState, _, app) => enableActionGroup(elements, appState, app), keyTest: (event) => @@ -177,11 +185,15 @@ export const actionGroup = register({ export const actionUngroup = register({ name: "ungroup", + label: "labels.ungroup", + icon: (appState) => , trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { const groupIds = getSelectedGroupIds(appState); + const elementsMap = arrayToMap(elements); + if (groupIds.length === 0) { - return { appState, elements, commitToHistory: false }; + return { appState, elements, storeAction: StoreAction.NONE }; } let nextElements = [...elements]; @@ -226,7 +238,12 @@ export const actionUngroup = register({ if (frame) { nextElements = replaceAllElementsInFrame( nextElements, - getElementsInResizingFrame(nextElements, frame, appState), + getElementsInResizingFrame( + nextElements, + frame, + appState, + elementsMap, + ), frame, app, ); @@ -249,14 +266,13 @@ export const actionUngroup = register({ return { appState: { ...appState, ...updateAppState }, elements: nextElements, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G.toUpperCase(), - contextItemLabel: "labels.ungroup", predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0, PanelComponent: ({ elements, appState, updateData }) => ( diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index 2e0f4c091..7b4a67f28 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -1,105 +1,120 @@ -import { Action, ActionResult } from "./types"; +import type { Action, ActionResult } from "./types"; import { UndoIcon, RedoIcon } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; -import History, { HistoryEntry } from "../history"; -import { ExcalidrawElement } from "../element/types"; -import { AppState } from "../types"; +import type { History } from "../history"; +import { HistoryChangedEvent } from "../history"; +import type { AppState } from "../types"; import { KEYS } from "../keys"; -import { newElementWith } from "../element/mutateElement"; -import { fixBindingsAfterDeletion } from "../element/binding"; import { arrayToMap } from "../utils"; import { isWindows } from "../constants"; +import type { SceneElementsMap } from "../element/types"; +import type { Store } from "../store"; +import { StoreAction } from "../store"; +import { useEmitter } from "../hooks/useEmitter"; const writeData = ( - prevElements: readonly ExcalidrawElement[], - appState: AppState, - updater: () => HistoryEntry | null, + appState: Readonly, + updater: () => [SceneElementsMap, AppState] | void, ): ActionResult => { - const commitToHistory = false; if ( !appState.multiElement && !appState.resizingElement && !appState.editingElement && !appState.draggingElement ) { - const data = updater(); - if (data === null) { - return { commitToHistory }; + const result = updater(); + + if (!result) { + return { storeAction: StoreAction.NONE }; } - const prevElementMap = arrayToMap(prevElements); - const nextElements = data.elements; - const nextElementMap = arrayToMap(nextElements); - - const deletedElements = prevElements.filter( - (prevElement) => !nextElementMap.has(prevElement.id), - ); - const elements = nextElements - .map((nextElement) => - newElementWith( - prevElementMap.get(nextElement.id) || nextElement, - nextElement, - ), - ) - .concat( - deletedElements.map((prevElement) => - newElementWith(prevElement, { isDeleted: true }), - ), - ); - fixBindingsAfterDeletion(elements, deletedElements); + const [nextElementsMap, nextAppState] = result; + const nextElements = Array.from(nextElementsMap.values()); return { - elements, - appState: { ...appState, ...data.appState }, - commitToHistory, - syncHistory: true, + appState: nextAppState, + elements: nextElements, + storeAction: StoreAction.UPDATE, }; } - return { commitToHistory }; + + return { storeAction: StoreAction.NONE }; }; -type ActionCreator = (history: History) => Action; +type ActionCreator = (history: History, store: Store) => Action; -export const createUndoAction: ActionCreator = (history) => ({ +export const createUndoAction: ActionCreator = (history, store) => ({ name: "undo", + label: "buttons.undo", + icon: UndoIcon, trackEvent: { category: "history" }, + viewMode: false, perform: (elements, appState) => - writeData(elements, appState, () => history.undoOnce()), + writeData(appState, () => + history.undo( + arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` + appState, + store.snapshot, + ), + ), keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.Z && !event.shiftKey, - PanelComponent: ({ updateData, data }) => ( - - ), - commitToHistory: () => false, + PanelComponent: ({ updateData, data }) => { + const { isUndoStackEmpty } = useEmitter( + history.onHistoryChangedEmitter, + new HistoryChangedEvent(), + ); + + return ( + + ); + }, }); -export const createRedoAction: ActionCreator = (history) => ({ +export const createRedoAction: ActionCreator = (history, store) => ({ name: "redo", + label: "buttons.redo", + icon: RedoIcon, trackEvent: { category: "history" }, + viewMode: false, perform: (elements, appState) => - writeData(elements, appState, () => history.redoOnce()), + writeData(appState, () => + history.redo( + arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` + appState, + store.snapshot, + ), + ), keyTest: (event) => (event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key.toLowerCase() === KEYS.Z) || (isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y), - PanelComponent: ({ updateData, data }) => ( - - ), - commitToHistory: () => false, + PanelComponent: ({ updateData, data }) => { + const { isRedoStackEmpty } = useEmitter( + history.onHistoryChangedEmitter, + new HistoryChangedEvent(), + ); + + return ( + + ); + }, }); diff --git a/packages/excalidraw/actions/actionLinearEditor.ts b/packages/excalidraw/actions/actionLinearEditor.ts deleted file mode 100644 index 83611b027..000000000 --- a/packages/excalidraw/actions/actionLinearEditor.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { LinearElementEditor } from "../element/linearElementEditor"; -import { isLinearElement } from "../element/typeChecks"; -import { ExcalidrawLinearElement } from "../element/types"; -import { register } from "./register"; - -export const actionToggleLinearEditor = register({ - name: "toggleLinearEditor", - trackEvent: { - category: "element", - }, - predicate: (elements, appState, _, app) => { - const selectedElements = app.scene.getSelectedElements(appState); - if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { - return true; - } - return false; - }, - perform(elements, appState, _, app) { - const selectedElement = app.scene.getSelectedElements({ - selectedElementIds: appState.selectedElementIds, - includeBoundTextElement: true, - })[0] as ExcalidrawLinearElement; - - const editingLinearElement = - appState.editingLinearElement?.elementId === selectedElement.id - ? null - : new LinearElementEditor(selectedElement, app.scene); - return { - appState: { - ...appState, - editingLinearElement, - }, - commitToHistory: false, - }; - }, - contextItemLabel: (elements, appState, app) => { - const selectedElement = app.scene.getSelectedElements({ - selectedElementIds: appState.selectedElementIds, - includeBoundTextElement: true, - })[0] as ExcalidrawLinearElement; - return appState.editingLinearElement?.elementId === selectedElement.id - ? "labels.lineEditor.exit" - : "labels.lineEditor.edit"; - }, -}); diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx new file mode 100644 index 000000000..12f00c248 --- /dev/null +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -0,0 +1,76 @@ +import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { isLinearElement } from "../element/typeChecks"; +import type { ExcalidrawLinearElement } from "../element/types"; +import { StoreAction } from "../store"; +import { register } from "./register"; +import { ToolButton } from "../components/ToolButton"; +import { t } from "../i18n"; +import { lineEditorIcon } from "../components/icons"; + +export const actionToggleLinearEditor = register({ + name: "toggleLinearEditor", + category: DEFAULT_CATEGORIES.elements, + label: (elements, appState, app) => { + const selectedElement = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + })[0] as ExcalidrawLinearElement | undefined; + + return selectedElement?.type === "arrow" + ? "labels.lineEditor.editArrow" + : "labels.lineEditor.edit"; + }, + keywords: ["line"], + trackEvent: { + category: "element", + }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + if ( + !appState.editingLinearElement && + selectedElements.length === 1 && + isLinearElement(selectedElements[0]) + ) { + return true; + } + return false; + }, + perform(elements, appState, _, app) { + const selectedElement = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + })[0] as ExcalidrawLinearElement; + + const editingLinearElement = + appState.editingLinearElement?.elementId === selectedElement.id + ? null + : new LinearElementEditor(selectedElement); + return { + appState: { + ...appState, + editingLinearElement, + }, + storeAction: StoreAction.CAPTURE, + }; + }, + PanelComponent: ({ appState, updateData, app }) => { + const selectedElement = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + })[0] as ExcalidrawLinearElement; + + const label = t( + selectedElement.type === "arrow" + ? "labels.lineEditor.editArrow" + : "labels.lineEditor.edit", + ); + return ( + updateData(null)} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx new file mode 100644 index 000000000..ae6197486 --- /dev/null +++ b/packages/excalidraw/actions/actionLink.tsx @@ -0,0 +1,55 @@ +import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; +import { LinkIcon } from "../components/icons"; +import { ToolButton } from "../components/ToolButton"; +import { isEmbeddableElement } from "../element/typeChecks"; +import { t } from "../i18n"; +import { KEYS } from "../keys"; +import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; +import { getShortcutKey } from "../utils"; +import { register } from "./register"; + +export const actionLink = register({ + name: "hyperlink", + label: (elements, appState) => getContextMenuLabel(elements, appState), + icon: LinkIcon, + perform: (elements, appState) => { + if (appState.showHyperlinkPopup === "editor") { + return false; + } + + return { + elements, + appState: { + ...appState, + showHyperlinkPopup: "editor", + openMenu: null, + }, + storeAction: StoreAction.CAPTURE, + }; + }, + trackEvent: { category: "hyperlink", action: "click" }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return selectedElements.length === 1; + }, + PanelComponent: ({ elements, appState, updateData }) => { + const selectedElements = getSelectedElements(elements, appState); + + return ( + updateData(null)} + selected={selectedElements.length === 1 && !!selectedElements[0].link} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx index fa8dcbea7..84a5d1be4 100644 --- a/packages/excalidraw/actions/actionMenu.tsx +++ b/packages/excalidraw/actions/actionMenu.tsx @@ -1,19 +1,21 @@ -import { HamburgerMenuIcon, palette } from "../components/icons"; +import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { register } from "./register"; import { KEYS } from "../keys"; +import { StoreAction } from "../store"; export const actionToggleCanvasMenu = register({ name: "toggleCanvasMenu", + label: "buttons.menu", trackEvent: { category: "menu" }, perform: (_, appState) => ({ appState: { ...appState, openMenu: appState.openMenu === "canvas" ? null : "canvas", }, - commitToHistory: false, + storeAction: StoreAction.NONE, }), PanelComponent: ({ appState, updateData }) => ( ({ appState: { ...appState, openMenu: appState.openMenu === "shape" ? null : "shape", }, - commitToHistory: false, + storeAction: StoreAction.NONE, }), PanelComponent: ({ elements, appState, updateData }) => ( { @@ -69,7 +74,7 @@ export const actionShortcuts = register({ name: "help", }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => event.key === KEYS.QUESTION_MARK, diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index ea65584fe..c577e975f 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -1,13 +1,20 @@ import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; -import { GoToCollaboratorComponentProps } from "../components/UserList"; -import { eyeIcon } from "../components/icons"; +import type { GoToCollaboratorComponentProps } from "../components/UserList"; +import { + eyeIcon, + microphoneIcon, + microphoneMutedIcon, +} from "../components/icons"; import { t } from "../i18n"; -import { Collaborator } from "../types"; +import { StoreAction } from "../store"; +import type { Collaborator } from "../types"; import { register } from "./register"; +import clsx from "clsx"; export const actionGoToCollaborator = register({ name: "goToCollaborator", + label: "Go to a collaborator", viewMode: true, trackEvent: { category: "collab" }, perform: (_elements, appState, collaborator: Collaborator) => { @@ -21,7 +28,7 @@ export const actionGoToCollaborator = register({ ...appState, userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } @@ -35,18 +42,49 @@ export const actionGoToCollaborator = register({ // Close mobile menu openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ updateData, data, appState }) => { - const { clientId, collaborator, withName, isBeingFollowed } = + const { socketId, collaborator, withName, isBeingFollowed } = data as GoToCollaboratorComponentProps; - const background = getClientColor(clientId); + const background = getClientColor(socketId, collaborator); + + const statusClassNames = clsx({ + "is-followed": isBeingFollowed, + "is-current-user": collaborator.isCurrentUser === true, + "is-speaking": collaborator.isSpeaking, + "is-in-call": collaborator.isInCall, + "is-muted": collaborator.isMuted, + }); + + const statusIconJSX = collaborator.isInCall ? ( + collaborator.isSpeaking ? ( +
+
+
+
+
+ ) : collaborator.isMuted ? ( +
+ {microphoneMutedIcon} +
+ ) : ( +
{microphoneIcon}
+ ) + ) : null; return withName ? (
updateData(collaborator)} > {}} name={collaborator.username || ""} src={collaborator.avatarUrl} - isBeingFollowed={isBeingFollowed} - isCurrentUser={collaborator.isCurrentUser === true} + className={statusClassNames} />
{collaborator.username}
-
- {eyeIcon} +
+ {isBeingFollowed && ( +
+ {eyeIcon} +
+ )} + {statusIconJSX}
) : ( - { - updateData(collaborator); - }} - name={collaborator.username || ""} - src={collaborator.avatarUrl} - isBeingFollowed={isBeingFollowed} - isCurrentUser={collaborator.isCurrentUser === true} - /> +
+ { + updateData(collaborator); + }} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + className={statusClassNames} + /> + {statusIconJSX && ( +
+ {statusIconJSX} +
+ )} +
); }, }); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 79e50aa68..b26e12de0 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,4 +1,4 @@ -import { AppClassProperties, AppState, Primitive } from "../types"; +import type { AppClassProperties, AppState, Primitive } from "../types"; import { DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS, @@ -49,6 +49,7 @@ import { ArrowheadCircleOutlineIcon, ArrowheadDiamondIcon, ArrowheadDiamondOutlineIcon, + fontSizeIcon, } from "../components/icons"; import { DEFAULT_FONT_FAMILY, @@ -73,7 +74,7 @@ import { isLinearElement, isUsingAdaptiveRadius, } from "../element/typeChecks"; -import { +import type { Arrowhead, ExcalidrawElement, ExcalidrawLinearElement, @@ -95,6 +96,7 @@ import { import { hasStrokeColor } from "../scene/comparisons"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; +import { StoreAction } from "../store"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -209,6 +211,7 @@ const changeFontSize = ( redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); newElement = offsetElementAfterFontResize(oldElement, newElement); @@ -229,7 +232,7 @@ const changeFontSize = ( ? [...newFontSizes][0] : fallbackValue ?? appState.currentItemFontSize, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }; @@ -237,6 +240,7 @@ const changeFontSize = ( export const actionChangeStrokeColor = register({ name: "changeStrokeColor", + label: "labels.stroke", trackEvent: false, perform: (elements, appState, value) => { return { @@ -258,7 +262,9 @@ export const actionChangeStrokeColor = register({ ...appState, ...value, }, - commitToHistory: !!value.currentItemStrokeColor, + storeAction: !!value.currentItemStrokeColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => ( @@ -287,6 +293,7 @@ export const actionChangeStrokeColor = register({ export const actionChangeBackgroundColor = register({ name: "changeBackgroundColor", + label: "labels.changeBackground", trackEvent: false, perform: (elements, appState, value) => { return { @@ -301,7 +308,9 @@ export const actionChangeBackgroundColor = register({ ...appState, ...value, }, - commitToHistory: !!value.currentItemBackgroundColor, + storeAction: !!value.currentItemBackgroundColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => ( @@ -330,6 +339,7 @@ export const actionChangeBackgroundColor = register({ export const actionChangeFillStyle = register({ name: "changeFillStyle", + label: "labels.fill", trackEvent: false, perform: (elements, appState, value, app) => { trackEvent( @@ -344,7 +354,7 @@ export const actionChangeFillStyle = register({ }), ), appState: { ...appState, currentItemFillStyle: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { @@ -407,6 +417,7 @@ export const actionChangeFillStyle = register({ export const actionChangeStrokeWidth = register({ name: "changeStrokeWidth", + label: "labels.strokeWidth", trackEvent: false, perform: (elements, appState, value) => { return { @@ -416,7 +427,7 @@ export const actionChangeStrokeWidth = register({ }), ), appState: { ...appState, currentItemStrokeWidth: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -460,6 +471,7 @@ export const actionChangeStrokeWidth = register({ export const actionChangeSloppiness = register({ name: "changeSloppiness", + label: "labels.sloppiness", trackEvent: false, perform: (elements, appState, value) => { return { @@ -470,7 +482,7 @@ export const actionChangeSloppiness = register({ }), ), appState: { ...appState, currentItemRoughness: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -511,6 +523,7 @@ export const actionChangeSloppiness = register({ export const actionChangeStrokeStyle = register({ name: "changeStrokeStyle", + label: "labels.strokeStyle", trackEvent: false, perform: (elements, appState, value) => { return { @@ -520,7 +533,7 @@ export const actionChangeStrokeStyle = register({ }), ), appState: { ...appState, currentItemStrokeStyle: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -561,6 +574,7 @@ export const actionChangeStrokeStyle = register({ export const actionChangeOpacity = register({ name: "changeOpacity", + label: "labels.opacity", trackEvent: false, perform: (elements, appState, value) => { return { @@ -574,7 +588,7 @@ export const actionChangeOpacity = register({ true, ), appState: { ...appState, currentItemOpacity: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -602,6 +616,7 @@ export const actionChangeOpacity = register({ export const actionChangeFontSize = register({ name: "changeFontSize", + label: "labels.fontSize", trackEvent: false, perform: (elements, appState, value, app) => { return changeFontSize(elements, appState, app, () => value, value); @@ -672,6 +687,8 @@ export const actionChangeFontSize = register({ export const actionDecreaseFontSize = register({ name: "decreaseFontSize", + label: "labels.decreaseFontSize", + icon: fontSizeIcon, trackEvent: false, perform: (elements, appState, value, app) => { return changeFontSize(elements, appState, app, (element) => @@ -694,6 +711,8 @@ export const actionDecreaseFontSize = register({ export const actionIncreaseFontSize = register({ name: "increaseFontSize", + label: "labels.increaseFontSize", + icon: fontSizeIcon, trackEvent: false, perform: (elements, appState, value, app) => { return changeFontSize(elements, appState, app, (element) => @@ -712,6 +731,7 @@ export const actionIncreaseFontSize = register({ export const actionChangeFontFamily = register({ name: "changeFontFamily", + label: "labels.fontFamily", trackEvent: false, perform: (elements, appState, value, app) => { return { @@ -730,6 +750,7 @@ export const actionChangeFontFamily = register({ redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); return newElement; } @@ -742,7 +763,7 @@ export const actionChangeFontFamily = register({ ...appState, currentItemFontFamily: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -814,6 +835,7 @@ export const actionChangeFontFamily = register({ export const actionChangeTextAlign = register({ name: "changeTextAlign", + label: "Change text alignment", trackEvent: false, perform: (elements, appState, value, app) => { return { @@ -829,6 +851,7 @@ export const actionChangeTextAlign = register({ redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); return newElement; } @@ -841,7 +864,7 @@ export const actionChangeTextAlign = register({ ...appState, currentItemTextAlign: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -902,6 +925,7 @@ export const actionChangeTextAlign = register({ export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", + label: "Change vertical alignment", trackEvent: { category: "element" }, perform: (elements, appState, value, app) => { return { @@ -918,6 +942,7 @@ export const actionChangeVerticalAlign = register({ redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); return newElement; } @@ -929,7 +954,7 @@ export const actionChangeVerticalAlign = register({ appState: { ...appState, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -990,6 +1015,7 @@ export const actionChangeVerticalAlign = register({ export const actionChangeRoundness = register({ name: "changeRoundness", + label: "Change edge roundness", trackEvent: false, perform: (elements, appState, value) => { return { @@ -1009,7 +1035,7 @@ export const actionChangeRoundness = register({ ...appState, currentItemRoundness: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { @@ -1128,6 +1154,7 @@ const getArrowheadOptions = (flip: boolean) => { export const actionChangeArrowhead = register({ name: "changeArrowhead", + label: "Change arrowheads", trackEvent: false, perform: ( elements, @@ -1160,7 +1187,7 @@ export const actionChangeArrowhead = register({ ? "currentItemStartArrowhead" : "currentItemEndArrowhead"]: value.type, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index 49f5072ce..e95c5251a 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -2,14 +2,19 @@ import { KEYS } from "../keys"; import { register } from "./register"; import { selectGroupsForSelectedElements } from "../groups"; import { getNonDeletedElements, isTextElement } from "../element"; -import { ExcalidrawElement } from "../element/types"; +import type { ExcalidrawElement } from "../element/types"; import { isLinearElement } from "../element/typeChecks"; import { LinearElementEditor } from "../element/linearElementEditor"; import { excludeElementsInFramesFromSelection } from "../scene/selection"; +import { selectAllIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionSelectAll = register({ name: "selectAll", + label: "labels.selectAll", + icon: selectAllIcon, trackEvent: { category: "canvas" }, + viewMode: false, perform: (elements, appState, value, app) => { if (appState.editingLinearElement) { return false; @@ -43,12 +48,11 @@ export const actionSelectAll = register({ // single linear element selected Object.keys(selectedElementIds).length === 1 && isLinearElement(elements[0]) - ? new LinearElementEditor(elements[0], app.scene) + ? new LinearElementEditor(elements[0]) : null, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, - contextItemLabel: "labels.selectAll", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A, }); diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 25a6baf2a..9483476f8 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -24,13 +24,17 @@ import { isArrowElement, } from "../element/typeChecks"; import { getSelectedElements } from "../scene"; -import { ExcalidrawTextElement } from "../element/types"; +import type { ExcalidrawTextElement } from "../element/types"; +import { paintIcon } from "../components/icons"; +import { StoreAction } from "../store"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; export const actionCopyStyles = register({ name: "copyStyles", + label: "labels.copyStyles", + icon: paintIcon, trackEvent: { category: "element" }, perform: (elements, appState, formData, app) => { const elementsCopied = []; @@ -51,23 +55,24 @@ export const actionCopyStyles = register({ ...appState, toast: { message: t("toast.copyStyles") }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - contextItemLabel: "labels.copyStyles", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C, }); export const actionPasteStyles = register({ name: "pasteStyles", + label: "labels.pasteStyles", + icon: paintIcon, trackEvent: { category: "element" }, perform: (elements, appState, formData, app) => { const elementsCopied = JSON.parse(copiedStyles); const pastedElement = elementsCopied[0]; const boundTextElement = elementsCopied[1]; if (!isExcalidrawElement(pastedElement)) { - return { elements, commitToHistory: false }; + return { elements, storeAction: StoreAction.NONE }; } const selectedElements = getSelectedElements(elements, appState, { @@ -128,7 +133,11 @@ export const actionPasteStyles = register({ element.id === newElement.containerId, ) || null; } - redrawTextBoundingBox(newElement, container); + redrawTextBoundingBox( + newElement, + container, + app.scene.getNonDeletedElementsMap(), + ); } if ( @@ -152,10 +161,9 @@ export const actionPasteStyles = register({ } return element; }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, - contextItemLabel: "labels.pasteStyles", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V, }); diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx index e4f930bff..529489382 100644 --- a/packages/excalidraw/actions/actionToggleGridMode.tsx +++ b/packages/excalidraw/actions/actionToggleGridMode.tsx @@ -1,10 +1,15 @@ import { CODES, KEYS } from "../keys"; import { register } from "./register"; import { GRID_SIZE } from "../constants"; -import { AppState } from "../types"; +import type { AppState } from "../types"; +import { gridIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionToggleGridMode = register({ name: "gridMode", + icon: gridIcon, + keywords: ["snap"], + label: "labels.toggleGrid", viewMode: true, trackEvent: { category: "canvas", @@ -17,13 +22,12 @@ export const actionToggleGridMode = register({ gridSize: this.checked!(appState) ? null : GRID_SIZE, objectsSnapModeEnabled: false, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState: AppState) => appState.gridSize !== null, predicate: (element, appState, props) => { return typeof props.gridModeEnabled === "undefined"; }, - contextItemLabel: "labels.showGrid", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, }); diff --git a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx index 60986137b..586293d08 100644 --- a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx +++ b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx @@ -1,9 +1,13 @@ +import { magnetIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleObjectsSnapMode = register({ name: "objectsSnapMode", - viewMode: true, + label: "buttons.objectsSnapMode", + icon: magnetIcon, + viewMode: false, trackEvent: { category: "canvas", predicate: (appState) => !appState.objectsSnapModeEnabled, @@ -15,14 +19,13 @@ export const actionToggleObjectsSnapMode = register({ objectsSnapModeEnabled: !this.checked!(appState), gridSize: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.objectsSnapModeEnabled, predicate: (elements, appState, appProps) => { return typeof appProps.objectsSnapModeEnabled === "undefined"; }, - contextItemLabel: "buttons.objectsSnapMode", keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S, }); diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index 71ba6bef1..fc1e70a47 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -1,8 +1,13 @@ import { register } from "./register"; import { CODES, KEYS } from "../keys"; +import { abacusIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionToggleStats = register({ name: "stats", + label: "stats.title", + icon: abacusIcon, + paletteName: "Toggle stats", viewMode: true, trackEvent: { category: "menu" }, perform(elements, appState) { @@ -11,11 +16,10 @@ export const actionToggleStats = register({ ...appState, showStats: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.showStats, - contextItemLabel: "stats.title", keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH, }); diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx index dc9db0c37..87dbb94ea 100644 --- a/packages/excalidraw/actions/actionToggleViewMode.tsx +++ b/packages/excalidraw/actions/actionToggleViewMode.tsx @@ -1,8 +1,13 @@ +import { eyeIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleViewMode = register({ name: "viewMode", + label: "labels.viewMode", + paletteName: "Toggle view mode", + icon: eyeIcon, viewMode: true, trackEvent: { category: "canvas", @@ -14,14 +19,13 @@ export const actionToggleViewMode = register({ ...appState, viewModeEnabled: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.viewModeEnabled, predicate: (elements, appState, appProps) => { return typeof appProps.viewModeEnabled === "undefined"; }, - contextItemLabel: "labels.viewMode", keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R, }); diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx index 28956640c..86261443f 100644 --- a/packages/excalidraw/actions/actionToggleZenMode.tsx +++ b/packages/excalidraw/actions/actionToggleZenMode.tsx @@ -1,8 +1,13 @@ +import { coffeeIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleZenMode = register({ name: "zenMode", + label: "buttons.zenMode", + icon: coffeeIcon, + paletteName: "Toggle zen mode", viewMode: true, trackEvent: { category: "canvas", @@ -14,14 +19,13 @@ export const actionToggleZenMode = register({ ...appState, zenModeEnabled: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.zenModeEnabled, predicate: (elements, appState, appProps) => { return typeof appProps.zenModeEnabled === "undefined"; }, - contextItemLabel: "buttons.zenMode", keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z, }); diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx index 17ecde1a6..261b4ab78 100644 --- a/packages/excalidraw/actions/actionZindex.tsx +++ b/packages/excalidraw/actions/actionZindex.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { moveOneLeft, moveOneRight, @@ -16,18 +15,21 @@ import { SendToBackIcon, } from "../components/icons"; import { isDarwin } from "../constants"; +import { StoreAction } from "../store"; export const actionSendBackward = register({ name: "sendBackward", + label: "labels.sendBackward", + keywords: ["move down", "zindex", "layer"], + icon: SendBackwardIcon, trackEvent: { category: "element" }, perform: (elements, appState) => { return { elements: moveOneLeft(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, - contextItemLabel: "labels.sendBackward", keyPriority: 40, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && @@ -47,15 +49,17 @@ export const actionSendBackward = register({ export const actionBringForward = register({ name: "bringForward", + label: "labels.bringForward", + keywords: ["move up", "zindex", "layer"], + icon: BringForwardIcon, trackEvent: { category: "element" }, perform: (elements, appState) => { return { elements: moveOneRight(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, - contextItemLabel: "labels.bringForward", keyPriority: 40, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && @@ -75,15 +79,17 @@ export const actionBringForward = register({ export const actionSendToBack = register({ name: "sendToBack", + label: "labels.sendToBack", + keywords: ["move down", "zindex", "layer"], + icon: SendToBackIcon, trackEvent: { category: "element" }, perform: (elements, appState) => { return { elements: moveAllLeft(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, - contextItemLabel: "labels.sendToBack", keyTest: (event) => isDarwin ? event[KEYS.CTRL_OR_CMD] && @@ -110,16 +116,18 @@ export const actionSendToBack = register({ export const actionBringToFront = register({ name: "bringToFront", + label: "labels.bringToFront", + keywords: ["move up", "zindex", "layer"], + icon: BringToFrontIcon, trackEvent: { category: "element" }, perform: (elements, appState) => { return { elements: moveAllRight(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, - contextItemLabel: "labels.bringToFront", keyTest: (event) => isDarwin ? event[KEYS.CTRL_OR_CMD] && diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index b4551acf5..092060425 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode"; export { actionToggleStats } from "./actionToggleStats"; export { actionUnbindText, actionBindText } from "./actionBoundText"; -export { actionLink } from "../element/Hyperlink"; +export { actionLink } from "./actionLink"; export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx index 6a24cc801..e0457854e 100644 --- a/packages/excalidraw/actions/manager.tsx +++ b/packages/excalidraw/actions/manager.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { +import type { Action, UpdaterFn, ActionName, @@ -8,8 +8,11 @@ import { ActionSource, ActionPredicateFn, } from "./types"; -import { ExcalidrawElement } from "../element/types"; -import { AppClassProperties, AppState } from "../types"; +import type { + ExcalidrawElement, + OrderedExcalidrawElement, +} from "../element/types"; +import type { AppClassProperties, AppState } from "../types"; import { trackEvent } from "../analytics"; import { isPromiseLike } from "../utils"; @@ -48,13 +51,13 @@ export class ActionManager { updater: (actionResult: ActionResult | Promise) => void; getAppState: () => Readonly; - getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; + getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[]; app: AppClassProperties; constructor( updater: UpdaterFn, getAppState: () => AppState, - getElementsIncludingDeleted: () => readonly ExcalidrawElement[], + getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[], app: AppClassProperties, ) { this.updater = (actionResult) => { diff --git a/packages/excalidraw/actions/register.ts b/packages/excalidraw/actions/register.ts index ccc9cdbf9..7c841e3ae 100644 --- a/packages/excalidraw/actions/register.ts +++ b/packages/excalidraw/actions/register.ts @@ -1,4 +1,4 @@ -import { Action } from "./types"; +import type { Action } from "./types"; export let actions: readonly Action[] = []; diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts index 528091f04..f4923b0d7 100644 --- a/packages/excalidraw/actions/shortcuts.ts +++ b/packages/excalidraw/actions/shortcuts.ts @@ -1,8 +1,8 @@ import { isDarwin } from "../constants"; import { t } from "../i18n"; -import { SubtypeOf } from "../utility-types"; +import type { SubtypeOf } from "../utility-types"; import { getShortcutKey } from "../utils"; -import { ActionName, CustomActionName } from "./types"; +import type { ActionName, CustomActionName } from "./types"; export type ShortcutName = | SubtypeOf< @@ -37,9 +37,22 @@ export type ShortcutName = | "flipVertical" | "hyperlink" | "toggleElementLock" + | "resetZoom" + | "zoomOut" + | "zoomIn" + | "zoomToFit" + | "zoomToFitSelectionInViewport" + | "zoomToFitSelection" + | "toggleEraserTool" + | "toggleHandTool" + | "setFrameAsActiveTool" + | "saveFileToDisk" + | "saveToActiveFile" + | "toggleShortcuts" > | "saveScene" - | "imageExport"; + | "imageExport" + | "commandPalette"; export const registerCustomShortcuts = ( shortcuts: Record, @@ -56,6 +69,10 @@ const shortcutMap: Record = { loadScene: [getShortcutKey("CtrlOrCmd+O")], clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")], imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")], + commandPalette: [ + getShortcutKey("CtrlOrCmd+/"), + getShortcutKey("CtrlOrCmd+Shift+P"), + ], cut: [getShortcutKey("CtrlOrCmd+X")], copy: [getShortcutKey("CtrlOrCmd+C")], paste: [getShortcutKey("CtrlOrCmd+V")], @@ -93,10 +110,24 @@ const shortcutMap: Record = { viewMode: [getShortcutKey("Alt+R")], hyperlink: [getShortcutKey("CtrlOrCmd+K")], toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")], + resetZoom: [getShortcutKey("CtrlOrCmd+0")], + zoomOut: [getShortcutKey("CtrlOrCmd+-")], + zoomIn: [getShortcutKey("CtrlOrCmd++")], + zoomToFitSelection: [getShortcutKey("Shift+3")], + zoomToFit: [getShortcutKey("Shift+1")], + zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")], + toggleEraserTool: [getShortcutKey("E")], + toggleHandTool: [getShortcutKey("H")], + setFrameAsActiveTool: [getShortcutKey("F")], + saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")], + saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], + toggleShortcuts: [getShortcutKey("?")], }; -export const getShortcutFromShortcutName = (name: ShortcutName) => { +export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { const shortcuts = shortcutMap[name]; // if multiple shortcuts available, take the first one - return shortcuts && shortcuts.length > 0 ? shortcuts[0] : ""; + return shortcuts && shortcuts.length > 0 + ? shortcuts[idx] || shortcuts[0] + : ""; }; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index b9f5e2b36..b5e8f3d42 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -1,14 +1,24 @@ -import React from "react"; -import { ExcalidrawElement } from "../element/types"; -import { +import type React from "react"; +import type { + ExcalidrawElement, + OrderedExcalidrawElement, +} from "../element/types"; +import type { AppClassProperties, AppState, ExcalidrawProps, BinaryFiles, + UIAppState, } from "../types"; -import { MarkOptional } from "../utility-types"; +import type { MarkOptional } from "../utility-types"; +import type { StoreActionType } from "../store"; -export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api"; +export type ActionSource = + | "ui" + | "keyboard" + | "contextMenu" + | "api" + | "commandPalette"; /** if false, the action should be prevented */ export type ActionResult = @@ -19,14 +29,13 @@ export type ActionResult = "offsetTop" | "offsetLeft" | "width" | "height" > | null; files?: BinaryFiles | null; - commitToHistory: boolean; - syncHistory?: boolean; + storeAction: StoreActionType; replaceFiles?: boolean; } | false; type ActionFn = ( - elements: readonly ExcalidrawElement[], + elements: readonly OrderedExcalidrawElement[], appState: Readonly, formData: any, app: AppClassProperties, @@ -138,7 +147,8 @@ export type ActionName = | "setFrameAsActiveTool" | "setEmbeddableAsActiveTool" | "createContainerFromText" - | "wrapTextInContainer"; + | "wrapTextInContainer" + | "commandPalette"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; @@ -151,6 +161,20 @@ export type PanelComponentProps = { export interface Action { name: ActionName; + label: + | string + | (( + elements: readonly ExcalidrawElement[], + appState: Readonly, + app: AppClassProperties, + ) => string); + keywords?: string[]; + icon?: + | React.ReactNode + | (( + appState: UIAppState, + elements: readonly ExcalidrawElement[], + ) => React.ReactNode); PanelComponent?: React.FC; perform: ActionFn; keyPriority?: number; @@ -160,13 +184,6 @@ export interface Action { elements: readonly ExcalidrawElement[], app: AppClassProperties, ) => boolean; - contextItemLabel?: - | string - | (( - elements: readonly ExcalidrawElement[], - appState: Readonly, - app: AppClassProperties, - ) => string); predicate?: ( elements: readonly ExcalidrawElement[], appState: AppState, diff --git a/packages/excalidraw/align.ts b/packages/excalidraw/align.ts index 90ecabb11..5abebea21 100644 --- a/packages/excalidraw/align.ts +++ b/packages/excalidraw/align.ts @@ -1,6 +1,7 @@ -import { ElementsMap, ExcalidrawElement } from "./element/types"; +import type { ElementsMap, ExcalidrawElement } from "./element/types"; import { newElementWith } from "./element/mutateElement"; -import { BoundingBox, getCommonBoundingBox } from "./element/bounds"; +import type { BoundingBox } from "./element/bounds"; +import { getCommonBoundingBox } from "./element/bounds"; import { getMaximumGroups } from "./groups"; export interface Alignment { diff --git a/packages/excalidraw/analytics.ts b/packages/excalidraw/analytics.ts index 671f59202..bd4b6191e 100644 --- a/packages/excalidraw/analytics.ts +++ b/packages/excalidraw/analytics.ts @@ -1,6 +1,6 @@ // place here categories that you want to track. We want to track just a // small subset of categories at a given time. -const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[]; +const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[]; export const trackEvent = ( category: string, diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animated-trail.ts index de5fd08fd..97a005461 100644 --- a/packages/excalidraw/animated-trail.ts +++ b/packages/excalidraw/animated-trail.ts @@ -1,6 +1,7 @@ -import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer"; -import { AnimationFrameHandler } from "./animation-frame-handler"; -import { AppState } from "./types"; +import type { LaserPointerOptions } from "@excalidraw/laser-pointer"; +import { LaserPointer } from "@excalidraw/laser-pointer"; +import type { AnimationFrameHandler } from "./animation-frame-handler"; +import type { AppState } from "./types"; import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils"; import type App from "./components/App"; import { SVG_NS } from "./constants"; diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index d3359f7f1..ee84554ad 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -7,9 +7,7 @@ import { EXPORT_SCALES, THEME, } from "./constants"; -import { t } from "./i18n"; -import { AppState, NormalizedZoomValue } from "./types"; -import { getDateTime } from "./utils"; +import type { AppState, NormalizedZoomValue } from "./types"; const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) ? devicePixelRatio @@ -65,7 +63,7 @@ export const getDefaultAppState = (): Omit< isRotating: false, lastPointerDownWith: "mouse", multiElement: null, - name: `${t("labels.untitled")}-${getDateTime()}`, + name: null, contextMenu: null, openMenu: null, openPopup: null, diff --git a/packages/excalidraw/change.ts b/packages/excalidraw/change.ts new file mode 100644 index 000000000..8774cfdb9 --- /dev/null +++ b/packages/excalidraw/change.ts @@ -0,0 +1,1525 @@ +import { ENV } from "./constants"; +import type { BindableProp, BindingProp } from "./element/binding"; +import { + BoundElement, + BindableElement, + bindingProperties, + updateBoundElements, +} from "./element/binding"; +import { LinearElementEditor } from "./element/linearElementEditor"; +import type { ElementUpdate } from "./element/mutateElement"; +import { mutateElement, newElementWith } from "./element/mutateElement"; +import { + getBoundTextElementId, + redrawTextBoundingBox, +} from "./element/textElement"; +import { + hasBoundTextElement, + isBindableElement, + isBoundToContainer, + isTextElement, +} from "./element/typeChecks"; +import type { + ExcalidrawElement, + ExcalidrawLinearElement, + ExcalidrawTextElement, + NonDeleted, + OrderedExcalidrawElement, + SceneElementsMap, +} from "./element/types"; +import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; +import { getNonDeletedGroupIds } from "./groups"; +import { getObservedAppState } from "./store"; +import type { + AppState, + ObservedAppState, + ObservedElementsAppState, + ObservedStandaloneAppState, +} from "./types"; +import type { SubtypeOf, ValueOf } from "./utility-types"; +import { + arrayToMap, + arrayToObject, + assertNever, + isShallowEqual, + toBrandedType, +} from "./utils"; + +/** + * Represents the difference between two objects of the same type. + * + * Both `deleted` and `inserted` partials represent the same set of added, removed or updated properties, where: + * - `deleted` is a set of all the deleted values + * - `inserted` is a set of all the inserted (added, updated) values + * + * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load. + */ +class Delta { + private constructor( + public readonly deleted: Partial, + public readonly inserted: Partial, + ) {} + + public static create( + deleted: Partial, + inserted: Partial, + modifier?: (delta: Partial) => Partial, + modifierOptions?: "deleted" | "inserted", + ) { + const modifiedDeleted = + modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted; + const modifiedInserted = + modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted; + + return new Delta(modifiedDeleted, modifiedInserted); + } + + /** + * Calculates the delta between two objects. + * + * @param prevObject - The previous state of the object. + * @param nextObject - The next state of the object. + * + * @returns new delta instance. + */ + public static calculate( + prevObject: T, + nextObject: T, + modifier?: (partial: Partial) => Partial, + postProcess?: ( + deleted: Partial, + inserted: Partial, + ) => [Partial, Partial], + ): Delta { + if (prevObject === nextObject) { + return Delta.empty(); + } + + const deleted = {} as Partial; + const inserted = {} as Partial; + + // O(n^3) here for elements, but it's not as bad as it looks: + // - we do this only on store recordings, not on every frame (not for ephemerals) + // - we do this only on previously detected changed elements + // - we do shallow compare only on the first level of properties (not going any deeper) + // - # of properties is reasonably small + for (const key of this.distinctKeysIterator( + "full", + prevObject, + nextObject, + )) { + deleted[key as keyof T] = prevObject[key]; + inserted[key as keyof T] = nextObject[key]; + } + + const [processedDeleted, processedInserted] = postProcess + ? postProcess(deleted, inserted) + : [deleted, inserted]; + + return Delta.create(processedDeleted, processedInserted, modifier); + } + + public static empty() { + return new Delta({}, {}); + } + + public static isEmpty(delta: Delta): boolean { + return ( + !Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length + ); + } + + /** + * Merges deleted and inserted object partials. + */ + public static mergeObjects( + prev: T, + added: T, + removed: T, + ) { + const cloned = { ...prev }; + + for (const key of Object.keys(removed)) { + delete cloned[key]; + } + + return { ...cloned, ...added }; + } + + /** + * Merges deleted and inserted array partials. + */ + public static mergeArrays( + prev: readonly T[] | null, + added: readonly T[] | null | undefined, + removed: readonly T[] | null | undefined, + predicate?: (value: T) => string, + ) { + return Object.values( + Delta.mergeObjects( + arrayToObject(prev ?? [], predicate), + arrayToObject(added ?? [], predicate), + arrayToObject(removed ?? [], predicate), + ), + ); + } + + /** + * Diff object partials as part of the `postProcess`. + */ + public static diffObjects>( + deleted: Partial, + inserted: Partial, + property: K, + setValue: (prevValue: V | undefined) => V, + ) { + if (!deleted[property] && !inserted[property]) { + return; + } + + if ( + typeof deleted[property] === "object" || + typeof inserted[property] === "object" + ) { + type RecordLike = Record; + + const deletedObject: RecordLike = deleted[property] ?? {}; + const insertedObject: RecordLike = inserted[property] ?? {}; + + const deletedDifferences = Delta.getLeftDifferences( + deletedObject, + insertedObject, + ).reduce((acc, curr) => { + acc[curr] = setValue(deletedObject[curr]); + return acc; + }, {} as RecordLike); + + const insertedDifferences = Delta.getRightDifferences( + deletedObject, + insertedObject, + ).reduce((acc, curr) => { + acc[curr] = setValue(insertedObject[curr]); + return acc; + }, {} as RecordLike); + + if ( + Object.keys(deletedDifferences).length || + Object.keys(insertedDifferences).length + ) { + Reflect.set(deleted, property, deletedDifferences); + Reflect.set(inserted, property, insertedDifferences); + } else { + Reflect.deleteProperty(deleted, property); + Reflect.deleteProperty(inserted, property); + } + } + } + + /** + * Diff array partials as part of the `postProcess`. + */ + public static diffArrays( + deleted: Partial, + inserted: Partial, + property: K, + groupBy: (value: V extends ArrayLike ? T : never) => string, + ) { + if (!deleted[property] && !inserted[property]) { + return; + } + + if (Array.isArray(deleted[property]) || Array.isArray(inserted[property])) { + const deletedArray = ( + Array.isArray(deleted[property]) ? deleted[property] : [] + ) as []; + const insertedArray = ( + Array.isArray(inserted[property]) ? inserted[property] : [] + ) as []; + + const deletedDifferences = arrayToObject( + Delta.getLeftDifferences( + arrayToObject(deletedArray, groupBy), + arrayToObject(insertedArray, groupBy), + ), + ); + const insertedDifferences = arrayToObject( + Delta.getRightDifferences( + arrayToObject(deletedArray, groupBy), + arrayToObject(insertedArray, groupBy), + ), + ); + + if ( + Object.keys(deletedDifferences).length || + Object.keys(insertedDifferences).length + ) { + const deletedValue = deletedArray.filter( + (x) => deletedDifferences[groupBy ? groupBy(x) : String(x)], + ); + const insertedValue = insertedArray.filter( + (x) => insertedDifferences[groupBy ? groupBy(x) : String(x)], + ); + + Reflect.set(deleted, property, deletedValue); + Reflect.set(inserted, property, insertedValue); + } else { + Reflect.deleteProperty(deleted, property); + Reflect.deleteProperty(inserted, property); + } + } + } + + /** + * Compares if object1 contains any different value compared to the object2. + */ + public static isLeftDifferent( + object1: T, + object2: T, + skipShallowCompare = false, + ): boolean { + const anyDistinctKey = this.distinctKeysIterator( + "left", + object1, + object2, + skipShallowCompare, + ).next().value; + + return !!anyDistinctKey; + } + + /** + * Compares if object2 contains any different value compared to the object1. + */ + public static isRightDifferent( + object1: T, + object2: T, + skipShallowCompare = false, + ): boolean { + const anyDistinctKey = this.distinctKeysIterator( + "right", + object1, + object2, + skipShallowCompare, + ).next().value; + + return !!anyDistinctKey; + } + + /** + * Returns all the object1 keys that have distinct values. + */ + public static getLeftDifferences( + object1: T, + object2: T, + skipShallowCompare = false, + ) { + return Array.from( + this.distinctKeysIterator("left", object1, object2, skipShallowCompare), + ); + } + + /** + * Returns all the object2 keys that have distinct values. + */ + public static getRightDifferences( + object1: T, + object2: T, + skipShallowCompare = false, + ) { + return Array.from( + this.distinctKeysIterator("right", object1, object2, skipShallowCompare), + ); + } + + /** + * Iterator comparing values of object properties based on the passed joining strategy. + * + * @yields keys of properties with different values + * + * WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that. + */ + private static *distinctKeysIterator( + join: "left" | "right" | "full", + object1: T, + object2: T, + skipShallowCompare = false, + ) { + if (object1 === object2) { + return; + } + + let keys: string[] = []; + + if (join === "left") { + keys = Object.keys(object1); + } else if (join === "right") { + keys = Object.keys(object2); + } else if (join === "full") { + keys = Array.from( + new Set([...Object.keys(object1), ...Object.keys(object2)]), + ); + } else { + assertNever( + join, + `Unknown distinctKeysIterator's join param "${join}"`, + true, + ); + } + + for (const key of keys) { + const object1Value = object1[key as keyof T]; + const object2Value = object2[key as keyof T]; + + if (object1Value !== object2Value) { + if ( + !skipShallowCompare && + typeof object1Value === "object" && + typeof object2Value === "object" && + object1Value !== null && + object2Value !== null && + isShallowEqual(object1Value, object2Value) + ) { + continue; + } + + yield key; + } + } + } +} + +/** + * Encapsulates the modifications captured as `Delta`/s. + */ +interface Change { + /** + * Inverses the `Delta`s inside while creating a new `Change`. + */ + inverse(): Change; + + /** + * Applies the `Change` to the previous object. + * + * @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change. + */ + applyTo(previous: T, ...options: unknown[]): [T, boolean]; + + /** + * Checks whether there are actually `Delta`s. + */ + isEmpty(): boolean; +} + +export class AppStateChange implements Change { + private constructor(private readonly delta: Delta) {} + + public static calculate( + prevAppState: T, + nextAppState: T, + ): AppStateChange { + const delta = Delta.calculate( + prevAppState, + nextAppState, + undefined, + AppStateChange.postProcess, + ); + + return new AppStateChange(delta); + } + + public static empty() { + return new AppStateChange(Delta.create({}, {})); + } + + public inverse(): AppStateChange { + const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted); + return new AppStateChange(inversedDelta); + } + + public applyTo( + appState: AppState, + nextElements: SceneElementsMap, + ): [AppState, boolean] { + try { + const { + selectedElementIds: removedSelectedElementIds = {}, + selectedGroupIds: removedSelectedGroupIds = {}, + } = this.delta.deleted; + + const { + selectedElementIds: addedSelectedElementIds = {}, + selectedGroupIds: addedSelectedGroupIds = {}, + selectedLinearElementId, + editingLinearElementId, + ...directlyApplicablePartial + } = this.delta.inserted; + + const mergedSelectedElementIds = Delta.mergeObjects( + appState.selectedElementIds, + addedSelectedElementIds, + removedSelectedElementIds, + ); + + const mergedSelectedGroupIds = Delta.mergeObjects( + appState.selectedGroupIds, + addedSelectedGroupIds, + removedSelectedGroupIds, + ); + + const selectedLinearElement = + selectedLinearElementId && nextElements.has(selectedLinearElementId) + ? new LinearElementEditor( + nextElements.get( + selectedLinearElementId, + ) as NonDeleted, + ) + : null; + + const editingLinearElement = + editingLinearElementId && nextElements.has(editingLinearElementId) + ? new LinearElementEditor( + nextElements.get( + editingLinearElementId, + ) as NonDeleted, + ) + : null; + + const nextAppState = { + ...appState, + ...directlyApplicablePartial, + selectedElementIds: mergedSelectedElementIds, + selectedGroupIds: mergedSelectedGroupIds, + selectedLinearElement: + typeof selectedLinearElementId !== "undefined" + ? selectedLinearElement // element was either inserted or deleted + : appState.selectedLinearElement, // otherwise assign what we had before + editingLinearElement: + typeof editingLinearElementId !== "undefined" + ? editingLinearElement // element was either inserted or deleted + : appState.editingLinearElement, // otherwise assign what we had before + }; + + const constainsVisibleChanges = this.filterInvisibleChanges( + appState, + nextAppState, + nextElements, + ); + + return [nextAppState, constainsVisibleChanges]; + } catch (e) { + // shouldn't really happen, but just in case + console.error(`Couldn't apply appstate change`, e); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + + return [appState, false]; + } + } + + public isEmpty(): boolean { + return Delta.isEmpty(this.delta); + } + + /** + * It is necessary to post process the partials in case of reference values, + * for which we need to calculate the real diff between `deleted` and `inserted`. + */ + private static postProcess( + deleted: Partial, + inserted: Partial, + ): [Partial, Partial] { + try { + Delta.diffObjects( + deleted, + inserted, + "selectedElementIds", + // ts language server has a bit trouble resolving this, so we are giving it a little push + (_) => true as ValueOf, + ); + Delta.diffObjects( + deleted, + inserted, + "selectedGroupIds", + (prevValue) => (prevValue ?? false) as ValueOf, + ); + } catch (e) { + // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it + console.error(`Couldn't postprocess appstate change deltas.`); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [deleted, inserted]; + } + } + + /** + * Mutates `nextAppState` be filtering out state related to deleted elements. + * + * @returns `true` if a visible change is found, `false` otherwise. + */ + private filterInvisibleChanges( + prevAppState: AppState, + nextAppState: AppState, + nextElements: SceneElementsMap, + ): boolean { + // TODO: #7348 we could still get an empty undo/redo, as we assume that previous appstate does not contain references to deleted elements + // which is not always true - i.e. now we do cleanup appstate during history, but we do not do it during remote updates + const prevObservedAppState = getObservedAppState(prevAppState); + const nextObservedAppState = getObservedAppState(nextAppState); + + const containsStandaloneDifference = Delta.isRightDifferent( + AppStateChange.stripElementsProps(prevObservedAppState), + AppStateChange.stripElementsProps(nextObservedAppState), + ); + + const containsElementsDifference = Delta.isRightDifferent( + AppStateChange.stripStandaloneProps(prevObservedAppState), + AppStateChange.stripStandaloneProps(nextObservedAppState), + ); + + if (!containsStandaloneDifference && !containsElementsDifference) { + // no change in appstate was detected + return false; + } + + const visibleDifferenceFlag = { + value: containsStandaloneDifference, + }; + + if (containsElementsDifference) { + // filter invisible changes on each iteration + const changedElementsProps = Delta.getRightDifferences( + AppStateChange.stripStandaloneProps(prevObservedAppState), + AppStateChange.stripStandaloneProps(nextObservedAppState), + ) as Array; + + let nonDeletedGroupIds = new Set(); + + if ( + changedElementsProps.includes("editingGroupId") || + changedElementsProps.includes("selectedGroupIds") + ) { + // this one iterates through all the non deleted elements, so make sure it's not done twice + nonDeletedGroupIds = getNonDeletedGroupIds(nextElements); + } + + // check whether delta properties are related to the existing non-deleted elements + for (const key of changedElementsProps) { + switch (key) { + case "selectedElementIds": + nextAppState[key] = AppStateChange.filterSelectedElements( + nextAppState[key], + nextElements, + visibleDifferenceFlag, + ); + + break; + case "selectedGroupIds": + nextAppState[key] = AppStateChange.filterSelectedGroups( + nextAppState[key], + nonDeletedGroupIds, + visibleDifferenceFlag, + ); + + break; + case "editingGroupId": + const editingGroupId = nextAppState[key]; + + if (!editingGroupId) { + // previously there was an editingGroup (assuming visible), now there is none + visibleDifferenceFlag.value = true; + } else if (nonDeletedGroupIds.has(editingGroupId)) { + // previously there wasn't an editingGroup, now there is one which is visible + visibleDifferenceFlag.value = true; + } else { + // there was assigned an editingGroup now, but it's related to deleted element + nextAppState[key] = null; + } + + break; + case "selectedLinearElementId": + case "editingLinearElementId": + const appStateKey = AppStateChange.convertToAppStateKey(key); + const linearElement = nextAppState[appStateKey]; + + if (!linearElement) { + // previously there was a linear element (assuming visible), now there is none + visibleDifferenceFlag.value = true; + } else { + const element = nextElements.get(linearElement.elementId); + + if (element && !element.isDeleted) { + // previously there wasn't a linear element, now there is one which is visible + visibleDifferenceFlag.value = true; + } else { + // there was assigned a linear element now, but it's deleted + nextAppState[appStateKey] = null; + } + } + + break; + default: { + assertNever( + key, + `Unknown ObservedElementsAppState's key "${key}"`, + true, + ); + } + } + } + } + + return visibleDifferenceFlag.value; + } + + private static convertToAppStateKey( + key: keyof Pick< + ObservedElementsAppState, + "selectedLinearElementId" | "editingLinearElementId" + >, + ): keyof Pick { + switch (key) { + case "selectedLinearElementId": + return "selectedLinearElement"; + case "editingLinearElementId": + return "editingLinearElement"; + } + } + + private static filterSelectedElements( + selectedElementIds: AppState["selectedElementIds"], + elements: SceneElementsMap, + visibleDifferenceFlag: { value: boolean }, + ) { + const ids = Object.keys(selectedElementIds); + + if (!ids.length) { + // previously there were ids (assuming related to visible elements), now there are none + visibleDifferenceFlag.value = true; + return selectedElementIds; + } + + const nextSelectedElementIds = { ...selectedElementIds }; + + for (const id of ids) { + const element = elements.get(id); + + if (element && !element.isDeleted) { + // there is a selected element id related to a visible element + visibleDifferenceFlag.value = true; + } else { + delete nextSelectedElementIds[id]; + } + } + + return nextSelectedElementIds; + } + + private static filterSelectedGroups( + selectedGroupIds: AppState["selectedGroupIds"], + nonDeletedGroupIds: Set, + visibleDifferenceFlag: { value: boolean }, + ) { + const ids = Object.keys(selectedGroupIds); + + if (!ids.length) { + // previously there were ids (assuming related to visible groups), now there are none + visibleDifferenceFlag.value = true; + return selectedGroupIds; + } + + const nextSelectedGroupIds = { ...selectedGroupIds }; + + for (const id of Object.keys(nextSelectedGroupIds)) { + if (nonDeletedGroupIds.has(id)) { + // there is a selected group id related to a visible group + visibleDifferenceFlag.value = true; + } else { + delete nextSelectedGroupIds[id]; + } + } + + return nextSelectedGroupIds; + } + + private static stripElementsProps( + delta: Partial, + ): Partial { + // WARN: Do not remove the type-casts as they here to ensure proper type checks + const { + editingGroupId, + selectedGroupIds, + selectedElementIds, + editingLinearElementId, + selectedLinearElementId, + ...standaloneProps + } = delta as ObservedAppState; + + return standaloneProps as SubtypeOf< + typeof standaloneProps, + ObservedStandaloneAppState + >; + } + + private static stripStandaloneProps( + delta: Partial, + ): Partial { + // WARN: Do not remove the type-casts as they here to ensure proper type checks + const { name, viewBackgroundColor, ...elementsProps } = + delta as ObservedAppState; + + return elementsProps as SubtypeOf< + typeof elementsProps, + ObservedElementsAppState + >; + } +} + +type ElementPartial = Omit, "seed">; + +/** + * Elements change is a low level primitive to capture a change between two sets of elements. + * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions. + */ +export class ElementsChange implements Change { + private constructor( + private readonly added: Map>, + private readonly removed: Map>, + private readonly updated: Map>, + ) {} + + public static create( + added: Map>, + removed: Map>, + updated: Map>, + options = { shouldRedistribute: false }, + ) { + let change: ElementsChange; + + if (options.shouldRedistribute) { + const nextAdded = new Map>(); + const nextRemoved = new Map>(); + const nextUpdated = new Map>(); + + const deltas = [...added, ...removed, ...updated]; + + for (const [id, delta] of deltas) { + if (this.satisfiesAddition(delta)) { + nextAdded.set(id, delta); + } else if (this.satisfiesRemoval(delta)) { + nextRemoved.set(id, delta); + } else { + nextUpdated.set(id, delta); + } + } + + change = new ElementsChange(nextAdded, nextRemoved, nextUpdated); + } else { + change = new ElementsChange(added, removed, updated); + } + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + ElementsChange.validate(change, "added", this.satisfiesAddition); + ElementsChange.validate(change, "removed", this.satisfiesRemoval); + ElementsChange.validate(change, "updated", this.satisfiesUpdate); + } + + return change; + } + + private static satisfiesAddition = ({ + deleted, + inserted, + }: Delta) => + // dissallowing added as "deleted", which could cause issues when resolving conflicts + deleted.isDeleted === true && !inserted.isDeleted; + + private static satisfiesRemoval = ({ + deleted, + inserted, + }: Delta) => + !deleted.isDeleted && inserted.isDeleted === true; + + private static satisfiesUpdate = ({ + deleted, + inserted, + }: Delta) => !!deleted.isDeleted === !!inserted.isDeleted; + + private static validate( + change: ElementsChange, + type: "added" | "removed" | "updated", + satifies: (delta: Delta) => boolean, + ) { + for (const [id, delta] of change[type].entries()) { + if (!satifies(delta)) { + console.error( + `Broken invariant for "${type}" delta, element "${id}", delta:`, + delta, + ); + throw new Error(`ElementsChange invariant broken for element "${id}".`); + } + } + } + + /** + * Calculates the `Delta`s between the previous and next set of elements. + * + * @param prevElements - Map representing the previous state of elements. + * @param nextElements - Map representing the next state of elements. + * + * @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements. + */ + public static calculate( + prevElements: Map, + nextElements: Map, + ): ElementsChange { + if (prevElements === nextElements) { + return ElementsChange.empty(); + } + + const added = new Map>(); + const removed = new Map>(); + const updated = new Map>(); + + // this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements + for (const prevElement of prevElements.values()) { + const nextElement = nextElements.get(prevElement.id); + + if (!nextElement) { + const deleted = { ...prevElement, isDeleted: false } as ElementPartial; + const inserted = { isDeleted: true } as ElementPartial; + + const delta = Delta.create( + deleted, + inserted, + ElementsChange.stripIrrelevantProps, + ); + + removed.set(prevElement.id, delta); + } + } + + for (const nextElement of nextElements.values()) { + const prevElement = prevElements.get(nextElement.id); + + if (!prevElement) { + const deleted = { isDeleted: true } as ElementPartial; + const inserted = { + ...nextElement, + isDeleted: false, + } as ElementPartial; + + const delta = Delta.create( + deleted, + inserted, + ElementsChange.stripIrrelevantProps, + ); + + added.set(nextElement.id, delta); + + continue; + } + + if (prevElement.versionNonce !== nextElement.versionNonce) { + const delta = Delta.calculate( + prevElement, + nextElement, + ElementsChange.stripIrrelevantProps, + ElementsChange.postProcess, + ); + + if ( + // making sure we don't get here some non-boolean values (i.e. undefined, null, etc.) + typeof prevElement.isDeleted === "boolean" && + typeof nextElement.isDeleted === "boolean" && + prevElement.isDeleted !== nextElement.isDeleted + ) { + // notice that other props could have been updated as well + if (prevElement.isDeleted && !nextElement.isDeleted) { + added.set(nextElement.id, delta); + } else { + removed.set(nextElement.id, delta); + } + + continue; + } + + // making sure there are at least some changes + if (!Delta.isEmpty(delta)) { + updated.set(nextElement.id, delta); + } + } + } + + return ElementsChange.create(added, removed, updated); + } + + public static empty() { + return ElementsChange.create(new Map(), new Map(), new Map()); + } + + public inverse(): ElementsChange { + const inverseInternal = (deltas: Map>) => { + const inversedDeltas = new Map>(); + + for (const [id, delta] of deltas.entries()) { + inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted)); + } + + return inversedDeltas; + }; + + const added = inverseInternal(this.added); + const removed = inverseInternal(this.removed); + const updated = inverseInternal(this.updated); + + // notice we inverse removed with added not to break the invariants + return ElementsChange.create(removed, added, updated); + } + + public isEmpty(): boolean { + return ( + this.added.size === 0 && + this.removed.size === 0 && + this.updated.size === 0 + ); + } + + /** + * Update delta/s based on the existing elements. + * + * @param elements current elements + * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated + * @returns new instance with modified delta/s + */ + public applyLatestChanges(elements: SceneElementsMap): ElementsChange { + const modifier = + (element: OrderedExcalidrawElement) => (partial: ElementPartial) => { + const latestPartial: { [key: string]: unknown } = {}; + + for (const key of Object.keys(partial) as Array) { + // do not update following props: + // - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys + switch (key) { + case "boundElements": + latestPartial[key] = partial[key]; + break; + default: + latestPartial[key] = element[key]; + } + } + + return latestPartial; + }; + + const applyLatestChangesInternal = ( + deltas: Map>, + ) => { + const modifiedDeltas = new Map>(); + + for (const [id, delta] of deltas.entries()) { + const existingElement = elements.get(id); + + if (existingElement) { + const modifiedDelta = Delta.create( + delta.deleted, + delta.inserted, + modifier(existingElement), + "inserted", + ); + + modifiedDeltas.set(id, modifiedDelta); + } else { + modifiedDeltas.set(id, delta); + } + } + + return modifiedDeltas; + }; + + const added = applyLatestChangesInternal(this.added); + const removed = applyLatestChangesInternal(this.removed); + const updated = applyLatestChangesInternal(this.updated); + + return ElementsChange.create(added, removed, updated, { + shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated + }); + } + + public applyTo( + elements: SceneElementsMap, + snapshot: Map, + ): [SceneElementsMap, boolean] { + let nextElements = toBrandedType(new Map(elements)); + let changedElements: Map; + + const flags = { + containsVisibleDifference: false, + containsZindexDifference: false, + }; + + // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) + try { + const applyDeltas = ElementsChange.createApplier( + nextElements, + snapshot, + flags, + ); + + const addedElements = applyDeltas(this.added); + const removedElements = applyDeltas(this.removed); + const updatedElements = applyDeltas(this.updated); + + const affectedElements = this.resolveConflicts(elements, nextElements); + + // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues + changedElements = new Map([ + ...addedElements, + ...removedElements, + ...updatedElements, + ...affectedElements, + ]); + } catch (e) { + console.error(`Couldn't apply elements change`, e); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + + // should not really happen, but just in case we cannot apply deltas, let's return the previous elements with visible change set to `true` + // even though there is obviously no visible change, returning `false` could be dangerous, as i.e.: + // in the worst case, it could lead into iterating through the whole stack with no possibility to redo + // instead, the worst case when returning `true` is an empty undo / redo + return [elements, true]; + } + + try { + // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state + ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements); + ElementsChange.redrawBoundArrows(nextElements, changedElements); + + // the following reorder performs also mutations, but only on new instances of changed elements + // (unless something goes really bad and it fallbacks to fixing all invalid indices) + nextElements = ElementsChange.reorderElements( + nextElements, + changedElements, + flags, + ); + } catch (e) { + console.error( + `Couldn't mutate elements after applying elements change`, + e, + ); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [nextElements, flags.containsVisibleDifference]; + } + } + + private static createApplier = ( + nextElements: SceneElementsMap, + snapshot: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => { + const getElement = ElementsChange.createGetter( + nextElements, + snapshot, + flags, + ); + + return (deltas: Map>) => + Array.from(deltas.entries()).reduce((acc, [id, delta]) => { + const element = getElement(id, delta.inserted); + + if (element) { + const newElement = ElementsChange.applyDelta(element, delta, flags); + nextElements.set(newElement.id, newElement); + acc.set(newElement.id, newElement); + } + + return acc; + }, new Map()); + }; + + private static createGetter = + ( + elements: SceneElementsMap, + snapshot: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => + (id: string, partial: ElementPartial) => { + let element = elements.get(id); + + if (!element) { + // always fallback to the local snapshot, in cases when we cannot find the element in the elements array + element = snapshot.get(id); + + if (element) { + // as the element was brought from the snapshot, it automatically results in a possible zindex difference + flags.containsZindexDifference = true; + + // as the element was force deleted, we need to check if adding it back results in a visible change + if ( + partial.isDeleted === false || + (partial.isDeleted !== true && element.isDeleted === false) + ) { + flags.containsVisibleDifference = true; + } + } + } + + return element; + }; + + private static applyDelta( + element: OrderedExcalidrawElement, + delta: Delta, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + } = { + // by default we don't care about about the flags + containsVisibleDifference: true, + containsZindexDifference: true, + }, + ) { + const { boundElements, ...directlyApplicablePartial } = delta.inserted; + + if ( + delta.deleted.boundElements?.length || + delta.inserted.boundElements?.length + ) { + const mergedBoundElements = Delta.mergeArrays( + element.boundElements, + delta.inserted.boundElements, + delta.deleted.boundElements, + (x) => x.id, + ); + + Object.assign(directlyApplicablePartial, { + boundElements: mergedBoundElements, + }); + } + + if (!flags.containsVisibleDifference) { + // strip away fractional as even if it would be different, it doesn't have to result in visible change + const { index, ...rest } = directlyApplicablePartial; + const containsVisibleDifference = + ElementsChange.checkForVisibleDifference(element, rest); + + flags.containsVisibleDifference = containsVisibleDifference; + } + + if (!flags.containsZindexDifference) { + flags.containsZindexDifference = + delta.deleted.index !== delta.inserted.index; + } + + return newElementWith(element, directlyApplicablePartial); + } + + /** + * Check for visible changes regardless of whether they were removed, added or updated. + */ + private static checkForVisibleDifference( + element: OrderedExcalidrawElement, + partial: ElementPartial, + ) { + if (element.isDeleted && partial.isDeleted !== false) { + // when it's deleted and partial is not false, it cannot end up with a visible change + return false; + } + + if (element.isDeleted && partial.isDeleted === false) { + // when we add an element, it results in a visible change + return true; + } + + if (element.isDeleted === false && partial.isDeleted) { + // when we remove an element, it results in a visible change + return true; + } + + // check for any difference on a visible element + return Delta.isRightDifferent(element, partial); + } + + /** + * Resolves conflicts for all previously added, removed and updated elements. + * Updates the previous deltas with all the changes after conflict resolution. + * + * @returns all elements affected by the conflict resolution + */ + private resolveConflicts( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + ) { + const nextAffectedElements = new Map(); + const updater = ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => { + const nextElement = nextElements.get(element.id); // only ever modify next element! + if (!nextElement) { + return; + } + + let affectedElement: OrderedExcalidrawElement; + + if (prevElements.get(element.id) === nextElement) { + // create the new element instance in case we didn't modify the element yet + // so that we won't end up in an incosistent state in case we would fail in the middle of mutations + affectedElement = newElementWith( + nextElement, + updates as ElementUpdate, + ); + } else { + affectedElement = mutateElement( + nextElement, + updates as ElementUpdate, + ); + } + + nextAffectedElements.set(affectedElement.id, affectedElement); + nextElements.set(affectedElement.id, affectedElement); + }; + + // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound + for (const [id] of this.removed) { + ElementsChange.unbindAffected(prevElements, nextElements, id, updater); + } + + // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound + for (const [id] of this.added) { + ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + } + + // updated delta is affecting the binding only in case it contains changed binding or bindable property + for (const [id] of Array.from(this.updated).filter(([_, delta]) => + Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) => + bindingProperties.has(prop as BindingProp | BindableProp), + ), + )) { + const updatedElement = nextElements.get(id); + if (!updatedElement || updatedElement.isDeleted) { + // skip fixing bindings for updates on deleted elements + continue; + } + + ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + } + + // filter only previous elements, which were now affected + const prevAffectedElements = new Map( + Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), + ); + + // calculate complete deltas for affected elements, and assign them back to all the deltas + // technically we could do better here if perf. would become an issue + const { added, removed, updated } = ElementsChange.calculate( + prevAffectedElements, + nextAffectedElements, + ); + + for (const [id, delta] of added) { + this.added.set(id, delta); + } + + for (const [id, delta] of removed) { + this.removed.set(id, delta); + } + + for (const [id, delta] of updated) { + this.updated.set(id, delta); + } + + return nextAffectedElements; + } + + /** + * Non deleted affected elements of removed elements (before and after applying delta), + * should be unbound ~ bindings should not point from non deleted into the deleted element/s. + */ + private static unbindAffected( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + id: string, + updater: ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => void, + ) { + // the instance could have been updated, so make sure we are passing the latest element to each function below + const prevElement = () => prevElements.get(id); // element before removal + const nextElement = () => nextElements.get(id); // element after removal + + BoundElement.unbindAffected(nextElements, prevElement(), updater); + BoundElement.unbindAffected(nextElements, nextElement(), updater); + + BindableElement.unbindAffected(nextElements, prevElement(), updater); + BindableElement.unbindAffected(nextElements, nextElement(), updater); + } + + /** + * Non deleted affected elements of added or updated element/s (before and after applying delta), + * should be rebound (if possible) with the current element ~ bindings should be bidirectional. + */ + private static rebindAffected( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + id: string, + updater: ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => void, + ) { + // the instance could have been updated, so make sure we are passing the latest element to each function below + const prevElement = () => prevElements.get(id); // element before addition / update + const nextElement = () => nextElements.get(id); // element after addition / update + + BoundElement.unbindAffected(nextElements, prevElement(), updater); + BoundElement.rebindAffected(nextElements, nextElement(), updater); + + BindableElement.unbindAffected( + nextElements, + prevElement(), + (element, updates) => { + // we cannot rebind arrows with bindable element so we don't unbind them at all during rebind (we still need to unbind them on removal) + // TODO: #7348 add startBinding / endBinding to the `BoundElement` context so that we could rebind arrows and remove this condition + if (isTextElement(element)) { + updater(element, updates); + } + }, + ); + BindableElement.rebindAffected(nextElements, nextElement(), updater); + } + + private static redrawTextBoundingBoxes( + elements: SceneElementsMap, + changed: Map, + ) { + const boxesToRedraw = new Map< + string, + { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement } + >(); + + for (const element of changed.values()) { + if (isBoundToContainer(element)) { + const { containerId } = element as ExcalidrawTextElement; + const container = containerId ? elements.get(containerId) : undefined; + + if (container) { + boxesToRedraw.set(container.id, { + container, + boundText: element as ExcalidrawTextElement, + }); + } + } + + if (hasBoundTextElement(element)) { + const boundTextElementId = getBoundTextElementId(element); + const boundText = boundTextElementId + ? elements.get(boundTextElementId) + : undefined; + + if (boundText) { + boxesToRedraw.set(element.id, { + container: element, + boundText: boundText as ExcalidrawTextElement, + }); + } + } + } + + for (const { container, boundText } of boxesToRedraw.values()) { + if (container.isDeleted || boundText.isDeleted) { + // skip redraw if one of them is deleted, as it would not result in a meaningful redraw + continue; + } + + redrawTextBoundingBox(boundText, container, elements, false); + } + } + + private static redrawBoundArrows( + elements: SceneElementsMap, + changed: Map, + ) { + for (const element of changed.values()) { + if (!element.isDeleted && isBindableElement(element)) { + updateBoundElements(element, elements); + } + } + } + + private static reorderElements( + elements: SceneElementsMap, + changed: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) { + if (!flags.containsZindexDifference) { + return elements; + } + + const previous = Array.from(elements.values()); + const reordered = orderByFractionalIndex([...previous]); + + if ( + !flags.containsVisibleDifference && + Delta.isRightDifferent(previous, reordered, true) + ) { + // we found a difference in order! + flags.containsVisibleDifference = true; + } + + // let's synchronize all invalid indices of moved elements + return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements; + } + + /** + * It is necessary to post process the partials in case of reference values, + * for which we need to calculate the real diff between `deleted` and `inserted`. + */ + private static postProcess( + deleted: ElementPartial, + inserted: ElementPartial, + ): [ElementPartial, ElementPartial] { + try { + Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); + } catch (e) { + // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it + console.error(`Couldn't postprocess elements change deltas.`); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [deleted, inserted]; + } + } + + private static stripIrrelevantProps( + partial: Partial, + ): ElementPartial { + const { id, updated, version, versionNonce, seed, ...strippedPartial } = + partial; + + return strippedPartial; + } +} diff --git a/packages/excalidraw/charts.test.ts b/packages/excalidraw/charts.test.ts index 5c2cce708..fcd8823a9 100644 --- a/packages/excalidraw/charts.test.ts +++ b/packages/excalidraw/charts.test.ts @@ -1,9 +1,5 @@ -import { - Spreadsheet, - tryParseCells, - tryParseNumber, - VALID_SPREADSHEET, -} from "./charts"; +import type { Spreadsheet } from "./charts"; +import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts"; describe("charts", () => { describe("tryParseNumber", () => { diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts index 942cd0df8..b6428b085 100644 --- a/packages/excalidraw/charts.ts +++ b/packages/excalidraw/charts.ts @@ -9,9 +9,9 @@ import { VERTICAL_ALIGN, } from "./constants"; import { newElement, newLinearElement, newTextElement } from "./element"; -import { NonDeletedExcalidrawElement } from "./element/types"; +import type { NonDeletedExcalidrawElement } from "./element/types"; import { randomId } from "./random"; -import { AppState } from "./types"; +import type { AppState } from "./types"; import { selectSubtype } from "./element/subtypes"; export type ChartElements = readonly NonDeletedExcalidrawElement[]; diff --git a/packages/excalidraw/clients.ts b/packages/excalidraw/clients.ts index 354098918..afff1eeb6 100644 --- a/packages/excalidraw/clients.ts +++ b/packages/excalidraw/clients.ts @@ -1,3 +1,18 @@ +import { + COLOR_CHARCOAL_BLACK, + COLOR_VOICE_CALL, + COLOR_WHITE, + THEME, +} from "./constants"; +import { roundRect } from "./renderer/roundRect"; +import type { InteractiveCanvasRenderConfig } from "./scene/types"; +import type { + Collaborator, + InteractiveCanvasAppState, + SocketId, +} from "./types"; +import { UserIdleState } from "./types"; + function hashToInteger(id: string) { let hash = 0; if (id.length === 0) { @@ -11,14 +26,12 @@ function hashToInteger(id: string) { } export const getClientColor = ( - /** - * any uniquely identifying key, such as user id or socket id - */ - id: string, + socketId: SocketId, + collaborator: Collaborator | undefined, ) => { // to get more even distribution in case `id` is not uniformly distributed to // begin with, we hash it - const hash = Math.abs(hashToInteger(id)); + const hash = Math.abs(hashToInteger(collaborator?.id || socketId)); // we want to get a multiple of 10 number in the range of 0-360 (in other // words a hue value of step size 10). There are 37 such values including 0. const hue = (hash % 37) * 10; @@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => { firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?" ).toUpperCase(); }; + +export const renderRemoteCursors = ({ + context, + renderConfig, + appState, + normalizedWidth, + normalizedHeight, +}: { + context: CanvasRenderingContext2D; + renderConfig: InteractiveCanvasRenderConfig; + appState: InteractiveCanvasAppState; + normalizedWidth: number; + normalizedHeight: number; +}) => { + // Paint remote pointers + for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) { + let { x, y } = pointer; + + const collaborator = appState.collaborators.get(socketId); + + x -= appState.offsetLeft; + y -= appState.offsetTop; + + const width = 11; + const height = 14; + + const isOutOfBounds = + x < 0 || + x > normalizedWidth - width || + y < 0 || + y > normalizedHeight - height; + + x = Math.max(x, 0); + x = Math.min(x, normalizedWidth - width); + y = Math.max(y, 0); + y = Math.min(y, normalizedHeight - height); + + const background = getClientColor(socketId, collaborator); + + context.save(); + context.strokeStyle = background; + context.fillStyle = background; + + const userState = renderConfig.remotePointerUserStates.get(socketId); + const isInactive = + isOutOfBounds || + userState === UserIdleState.IDLE || + userState === UserIdleState.AWAY; + + if (isInactive) { + context.globalAlpha = 0.3; + } + + if (renderConfig.remotePointerButton.get(socketId) === "down") { + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 3; + context.strokeStyle = "#ffffff88"; + context.stroke(); + context.closePath(); + + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 1; + context.strokeStyle = background; + context.stroke(); + context.closePath(); + } + + // TODO remove the dark theme color after we stop inverting canvas colors + const IS_SPEAKING_COLOR = + appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL; + + const isSpeaking = collaborator?.isSpeaking; + + if (isSpeaking) { + // cursor outline for currently speaking user + context.fillStyle = IS_SPEAKING_COLOR; + context.strokeStyle = IS_SPEAKING_COLOR; + context.lineWidth = 10; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.stroke(); + context.fill(); + } + + // Background (white outline) for arrow + context.fillStyle = COLOR_WHITE; + context.strokeStyle = COLOR_WHITE; + context.lineWidth = 6; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.stroke(); + context.fill(); + + // Arrow + context.fillStyle = background; + context.strokeStyle = background; + context.lineWidth = 2; + context.lineJoin = "round"; + context.beginPath(); + if (isInactive) { + context.moveTo(x - 1, y - 1); + context.lineTo(x - 1, y + 15); + context.lineTo(x + 5, y + 10); + context.lineTo(x + 12, y + 9); + context.closePath(); + context.fill(); + } else { + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.fill(); + context.stroke(); + } + + const username = renderConfig.remotePointerUsernames.get(socketId) || ""; + + if (!isOutOfBounds && username) { + context.font = "600 12px sans-serif"; // font has to be set before context.measureText() + + const offsetX = (isSpeaking ? x + 0 : x) + width / 2; + const offsetY = (isSpeaking ? y + 0 : y) + height + 2; + const paddingHorizontal = 5; + const paddingVertical = 3; + const measure = context.measureText(username); + const measureHeight = + measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; + const finalHeight = Math.max(measureHeight, 12); + + const boxX = offsetX - 1; + const boxY = offsetY - 1; + const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2; + const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2; + if (context.roundRect) { + context.beginPath(); + context.roundRect(boxX, boxY, boxWidth, boxHeight, 8); + context.fillStyle = background; + context.fill(); + context.strokeStyle = COLOR_WHITE; + context.stroke(); + + if (isSpeaking) { + context.beginPath(); + context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8); + context.strokeStyle = IS_SPEAKING_COLOR; + context.stroke(); + } + } else { + roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE); + } + context.fillStyle = COLOR_CHARCOAL_BLACK; + + context.fillText( + username, + offsetX + paddingHorizontal + 1, + offsetY + + paddingVertical + + measure.actualBoundingBoxAscent + + Math.floor((finalHeight - measureHeight) / 2) + + 2, + ); + + // draw three vertical bars signalling someone is speaking + if (isSpeaking) { + context.fillStyle = IS_SPEAKING_COLOR; + const barheight = 8; + const margin = 8; + const gap = 5; + context.fillRect( + boxX + boxWidth + margin, + boxY + (boxHeight / 2 - barheight / 2), + 2, + barheight, + ); + context.fillRect( + boxX + boxWidth + margin + gap, + boxY + (boxHeight / 2 - (barheight * 2) / 2), + 2, + barheight * 2, + ); + context.fillRect( + boxX + boxWidth + margin + gap * 2, + boxY + (boxHeight / 2 - barheight / 2), + 2, + barheight, + ); + } + } + + context.restore(); + context.closePath(); + } +}; diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index a8bc21562..148258fcb 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -1,9 +1,10 @@ -import { +import type { ExcalidrawElement, NonDeletedExcalidrawElement, } from "./element/types"; -import { AppState, BinaryFiles } from "./types"; -import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; +import type { AppState, BinaryFiles } from "./types"; +import type { Spreadsheet } from "./charts"; +import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; import { ALLOWED_PASTE_MIME_TYPES, EXPORT_DATA_TYPES, @@ -16,8 +17,7 @@ import { import { deepCopyElement } from "./element/newElement"; import { mutateElement } from "./element/mutateElement"; import { getContainingFrame } from "./frame"; -import { isMemberOf, isPromiseLike } from "./utils"; -import { t } from "./i18n"; +import { arrayToMap, isMemberOf, isPromiseLike } from "./utils"; type ElementsClipboard = { type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; @@ -126,6 +126,7 @@ export const serializeAsClipboardJSON = ({ elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles | null; }) => { + const elementsMap = arrayToMap(elements); const framesToCopy = new Set( elements.filter((element) => isFrameLikeElement(element)), ); @@ -152,8 +153,8 @@ export const serializeAsClipboardJSON = ({ type: EXPORT_DATA_TYPES.excalidrawClipboard, elements: elements.map((element) => { if ( - getContainingFrame(element) && - !framesToCopy.has(getContainingFrame(element)!) + getContainingFrame(element, elementsMap) && + !framesToCopy.has(getContainingFrame(element, elementsMap)!) ) { const copiedElement = deepCopyElement(element); mutateElement(copiedElement, { @@ -439,7 +440,7 @@ export const copyTextToSystemClipboard = async ( // (3) if that fails, use document.execCommand if (!copyTextViaExecCommand(text)) { - throw new Error(t("errors.copyToSystemClipboardFailed")); + throw new Error("Error copying to clipboard."); } }; diff --git a/packages/excalidraw/colors.ts b/packages/excalidraw/colors.ts index 905e6cd3f..e4cd67a94 100644 --- a/packages/excalidraw/colors.ts +++ b/packages/excalidraw/colors.ts @@ -1,5 +1,5 @@ import oc from "open-color"; -import { Merge } from "./utility-types"; +import type { Merge } from "./utility-types"; // FIXME can't put to utils.ts rn because of circular dependency const pick = , K extends readonly (keyof R)[]>( diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index df0d73755..5826628de 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -12,6 +12,7 @@ font-size: 0.875rem !important; width: var(--lg-button-size); height: var(--lg-button-size); + svg { width: var(--lg-icon-size) !important; height: var(--lg-icon-size) !important; diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index db8e2d20e..786668f1d 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; -import { ActionManager } from "../actions/manager"; -import { +import type { ActionManager } from "../actions/manager"; +import type { + ExcalidrawElement, ExcalidrawElementType, NonDeletedElementsMap, NonDeletedSceneElementsMap, @@ -16,14 +17,18 @@ import { hasStrokeWidth, } from "../scene"; import { SHAPES } from "../shapes"; -import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types"; +import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types"; import { capitalizeString, isTransparent } from "../utils"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; import { SubtypeShapeActions } from "./Subtypes"; import { hasStrokeColor } from "../scene/comparisons"; import { trackEvent } from "../analytics"; -import { hasBoundTextElement, isTextElement } from "../element/typeChecks"; +import { + hasBoundTextElement, + isLinearElement, + isTextElement, +} from "../element/typeChecks"; import clsx from "clsx"; import { actionToggleZenMode } from "../actions"; import { Tooltip } from "./Tooltip"; @@ -46,6 +51,40 @@ import { import { KEYS } from "../keys"; import { useTunnels } from "../context/tunnels"; +export const canChangeStrokeColor = ( + appState: UIAppState, + targetElements: ExcalidrawElement[], +) => { + let commonSelectedType: ExcalidrawElementType | null = + targetElements[0]?.type || null; + + for (const element of targetElements) { + if (element.type !== commonSelectedType) { + commonSelectedType = null; + break; + } + } + + return ( + (hasStrokeColor(appState.activeTool.type) && + appState.activeTool.type !== "image" && + commonSelectedType !== "image" && + commonSelectedType !== "frame" && + commonSelectedType !== "magicframe") || + targetElements.some((element) => hasStrokeColor(element.type)) + ); +}; + +export const canChangeBackgroundColor = ( + appState: UIAppState, + targetElements: ExcalidrawElement[], +) => { + return ( + hasBackground(appState.activeTool.type) || + targetElements.some((element) => hasBackground(element.type)) + ); +}; + export const SelectedShapeActions = ({ appState, elementsMap, @@ -76,35 +115,22 @@ export const SelectedShapeActions = ({ (element) => hasBackground(element.type) && !isTransparent(element.backgroundColor), ); - const showChangeBackgroundIcons = - hasBackground(appState.activeTool.type) || - targetElements.some((element) => hasBackground(element.type)); const showLinkIcon = targetElements.length === 1 || isSingleElementBoundContainer; - let commonSelectedType: ExcalidrawElementType | null = - targetElements[0]?.type || null; - - for (const element of targetElements) { - if (element.type !== commonSelectedType) { - commonSelectedType = null; - break; - } - } + const showLineEditorAction = + !appState.editingLinearElement && + targetElements.length === 1 && + isLinearElement(targetElements[0]); return (
- {((hasStrokeColor(appState.activeTool.type) && - appState.activeTool.type !== "image" && - commonSelectedType !== "image" && - commonSelectedType !== "frame" && - commonSelectedType !== "magicframe") || - targetElements.some((element) => hasStrokeColor(element.type))) && + {canChangeStrokeColor(appState, targetElements) && renderAction("changeStrokeColor")}
- {showChangeBackgroundIcons && ( + {canChangeBackgroundColor(appState, targetElements) && (
{renderAction("changeBackgroundColor")}
)} @@ -158,8 +184,8 @@ export const SelectedShapeActions = ({
{renderAction("sendToBack")} {renderAction("sendBackward")} - {renderAction("bringToFront")} {renderAction("bringForward")} + {renderAction("bringToFront")}
@@ -214,6 +240,7 @@ export const SelectedShapeActions = ({ {renderAction("group")} {renderAction("ungroup")} {showLinkIcon && renderAction("hyperlink")} + {showLineEditorAction && renderAction("toggleLinearEditor")}
)} @@ -308,6 +335,25 @@ export const ShapesSwitcher = ({ title={t("toolBar.extraTools")} > {extraToolsIcon} + {app.props.aiEnabled !== false && ( +
+ AI +
+ )} setIsExtraToolsMenuOpen(false)} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 33f120ad6..5000cbd54 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1,7 +1,7 @@ import React, { useContext } from "react"; import { flushSync } from "react-dom"; -import { RoughCanvas } from "roughjs/bin/canvas"; +import type { RoughCanvas } from "roughjs/bin/canvas"; import rough from "roughjs/bin/rough"; import clsx from "clsx"; import { nanoid } from "nanoid"; @@ -39,18 +39,16 @@ import { import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { ActionManager } from "../actions/manager"; import { actions } from "../actions/register"; -import { Action, ActionResult } from "../actions/types"; +import type { Action, ActionResult } from "../actions/types"; import { trackEvent } from "../analytics"; import { getDefaultAppState, isEraserActive, isHandToolActive, } from "../appState"; -import { - PastedMixedContent, - copyTextToSystemClipboard, - parseClipboard, -} from "../clipboard"; +import type { PastedMixedContent } from "../clipboard"; +import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; +import type { EXPORT_IMAGE_TYPES } from "../constants"; import { APP_NAME, CURSOR_TYPE, @@ -62,7 +60,6 @@ import { ENV, EVENT, FRAME_STYLE, - EXPORT_IMAGE_TYPES, GRID_SIZE, IMAGE_MIME_TYPES, IMAGE_RENDER_TIMEOUT, @@ -89,8 +86,11 @@ import { TOOL_TYPE, EDITOR_LS_KEYS, isIOS, + supportsResizeObserver, + DEFAULT_COLLISION_THRESHOLD, } from "../constants"; -import { ExportedElements, exportCanvas, loadFromBlob } from "../data"; +import type { ExportedElements } from "../data"; +import { exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { restore, restoreElements } from "../data/restore"; import { @@ -106,8 +106,6 @@ import { getResizeOffsetXY, getLockedLinearCursorAlignSize, getTransformHandleTypeFromCoords, - hitTest, - isHittingElementBoundingBoxWithoutHittingElement, isInvisiblySmallElement, isNonDeletedElement, isTextElement, @@ -118,20 +116,20 @@ import { transformElements, updateTextElement, redrawTextBoundingBox, + getElementAbsoluteCoords, } from "../element"; import { bindOrUnbindLinearElement, - bindOrUnbindSelectedElements, + bindOrUnbindLinearElements, fixBindingsAfterDeletion, fixBindingsAfterDuplication, - getEligibleElementsForBinding, getHoveredElementForBinding, isBindingEnabled, isLinearElementSimpleAndAlreadyBound, maybeBindLinearElement, shouldEnableBindingForPointerEvent, - unbindLinearElements, updateBoundElements, + getSuggestedBindingsForArrows, } from "../element/binding"; import { LinearElementEditor } from "../element/linearElementEditor"; import { mutateElement, newElementWith } from "../element/mutateElement"; @@ -161,8 +159,9 @@ import { isIframeElement, isIframeLikeElement, isMagicFrameElement, + isTextBindableContainer, } from "../element/typeChecks"; -import { +import type { ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFreeDrawElement, @@ -181,6 +180,7 @@ import { IframeData, ExcalidrawIframeElement, ExcalidrawEmbeddableElement, + Ordered, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -192,7 +192,7 @@ import { isSelectedViaGroup, selectGroupsForSelectedElements, } from "../groups"; -import History from "../history"; +import { History } from "../history"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { CODES, @@ -211,7 +211,6 @@ import { } from "../math"; import { calculateScrollCenter, - getElementsAtPosition, getElementsWithinSelection, getNormalizedZoom, getSelectedElements, @@ -219,10 +218,23 @@ import { isSomeElementSelected, } from "../scene"; import Scene from "../scene/Scene"; -import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types"; +import type { + RenderInteractiveSceneCallback, + ScrollBars, +} from "../scene/types"; import { getStateForZoom } from "../scene/zoom"; import { findShapeByKey } from "../shapes"; +import type { GeometricShape } from "../../utils/geometry/shape"; import { + getClosedCurveShape, + getCurveShape, + getEllipseShape, + getFreedrawShape, + getPolygonShape, + getSelectionBoxShape, +} from "../../utils/geometry/shape"; +import { isPointInShape } from "../../utils/collision"; +import type { AppClassProperties, AppProps, AppState, @@ -270,6 +282,9 @@ import { updateStable, addEventListener, normalizeEOL, + getDateTime, + isShallowEqual, + arrayToMap, } from "../utils"; import { createSrcDoc, @@ -277,18 +292,17 @@ import { maybeParseEmbedSrc, getEmbedLink, } from "../element/embeddable"; -import { - ContextMenu, - ContextMenuItems, - CONTEXT_MENU_SEPARATOR, -} from "./ContextMenu"; +import type { ContextMenuItems } from "./ContextMenu"; +import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu"; import LayerUI from "./LayerUI"; import { Toast } from "./Toast"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; -import { +import type { SubtypeLoadedCb, SubtypeRecord, SubtypePrepFn, +} from "../element/subtypes"; +import { checkRefreshOnSubtypeLoad, isSubtypeAction, prepareSubtype, @@ -316,7 +330,8 @@ import { updateImageCache as _updateImageCache, } from "../element/image"; import throttle from "lodash.throttle"; -import { fileOpen, FileSystemHandle } from "../data/filesystem"; +import type { FileSystemHandle } from "../data/filesystem"; +import { fileOpen } from "../data/filesystem"; import { bindTextToShapeAfterDuplication, getApproxMinLineHeight, @@ -326,18 +341,14 @@ import { getContainerElement, getDefaultLineHeight, getLineHeightInPx, - getTextBindableContainerAtPosition, isMeasureTextSupported, isValidTextContainer, } from "../element/textElement"; -import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { showHyperlinkTooltip, hideHyperlinkToolip, Hyperlink, - isPointHittingLink, - isPointHittingLinkIcon, -} from "../element/Hyperlink"; +} from "../components/hyperlink/Hyperlink"; import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; import { shouldShowBoundingBox } from "../element/transformHandles"; import { actionUnlockAllElements } from "../actions/actionElementLock"; @@ -386,11 +397,9 @@ import { import { actionWrapTextInContainer } from "../actions/actionBoundText"; import BraveMeasureTextError from "./BraveMeasureTextError"; import { activeEyeDropperAtom } from "./EyeDropper"; -import { - ExcalidrawElementSkeleton, - convertToExcalidrawElements, -} from "../data/transform"; -import { ValueOf } from "../utility-types"; +import type { ExcalidrawElementSkeleton } from "../data/transform"; +import { convertToExcalidrawElements } from "../data/transform"; +import type { ValueOf } from "../utility-types"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { Renderer } from "../scene/Renderer"; @@ -404,21 +413,34 @@ import { } from "../cursor"; import { Emitter } from "../emitter"; import { ElementCanvasButtons } from "../element/ElementCanvasButtons"; -import { MagicCacheData, diagramToHTML } from "../data/magic"; +import type { MagicCacheData } from "../data/magic"; +import { diagramToHTML } from "../data/magic"; import { exportToBlob } from "../../utils/export"; import { COLOR_PALETTE } from "../colors"; import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; import FollowMode from "./FollowMode/FollowMode"; - +import { Store, StoreAction } from "../store"; import { AnimationFrameHandler } from "../animation-frame-handler"; import { AnimatedTrail } from "../animated-trail"; import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { getRenderOpacity } from "../renderer/renderElement"; +import { + hitElementBoundText, + hitElementBoundingBoxOnly, + hitElementItself, + shouldTestInside, +} from "../element/collision"; import { textWysiwyg } from "../element/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; +import { syncInvalidIndices, syncMovedIndices } from "../fractionalIndex"; +import { + isPointHittingLink, + isPointHittingLinkIcon, +} from "./hyperlink/helpers"; +import { getShortcutFromShortcutName } from "../actions/shortcuts"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -483,9 +505,6 @@ export const useExcalidrawSetAppState = () => export const useExcalidrawActionManager = () => useContext(ExcalidrawActionManagerContext); -const supportsResizeObserver = - typeof window !== "undefined" && "ResizeObserver" in window; - let didTapTwice: boolean = false; let tappedTwiceTimer = 0; let isHoldingSpace: boolean = false; @@ -533,6 +552,7 @@ class App extends React.Component { public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; public id: string; + private store: Store; private history: History; private excalidrawContainerValue: { container: HTMLDivElement | null; @@ -629,7 +649,7 @@ class App extends React.Component { gridModeEnabled = false, objectsSnapModeEnabled = false, theme = defaultAppState.theme, - name = defaultAppState.name, + name = `${t("labels.untitled")}-${getDateTime()}`, } = props; this.state = { ...defaultAppState, @@ -658,6 +678,10 @@ class App extends React.Component { this.canvas = document.createElement("canvas"); this.rc = rough.canvas(this.canvas); this.renderer = new Renderer(this.scene); + + this.store = new Store(); + this.history = new History(); + if (excalidrawAPI) { const api: ExcalidrawImperativeAPI = { updateScene: this.updateScene, @@ -672,6 +696,7 @@ class App extends React.Component { getSceneElements: this.getSceneElements, getAppState: () => this.state, getFiles: () => this.files, + getName: this.getName, registerAction: (action: Action) => { this.actionManager.registerAction(action); }, @@ -707,10 +732,14 @@ class App extends React.Component { onSceneUpdated: this.onSceneUpdated, }); this.history = new History(); - this.actionManager.registerAll(actions); - this.actionManager.registerAction(createUndoAction(this.history)); - this.actionManager.registerAction(createRedoAction(this.history)); + this.actionManager.registerAll(actions); + this.actionManager.registerAction( + createUndoAction(this.history, this.store), + ); + this.actionManager.registerAction( + createRedoAction(this.history, this.store), + ); this.actionManager.registerActionPredicate(subtypeActionPredicate); } @@ -956,7 +985,7 @@ class App extends React.Component { const embeddableElements = this.scene .getNonDeletedElements() .filter( - (el): el is NonDeleted => + (el): el is Ordered> => (isEmbeddableElement(el) && this.embedsValidationStatus.get(el.id) === true) || isIframeElement(el), @@ -975,6 +1004,7 @@ class App extends React.Component { normalizedWidth, normalizedHeight, this.state, + this.scene.getNonDeletedElementsMap(), ); const hasBeenInitialized = this.initializedEmbeds.has(el.id); @@ -1019,7 +1049,7 @@ class App extends React.Component { width: 100%; height: 100%; color: ${ - this.state.theme === "dark" ? "white" : "black" + this.state.theme === THEME.DARK ? "white" : "black" }; } body { @@ -1150,7 +1180,7 @@ class App extends React.Component { display: isVisible ? "block" : "none", opacity: getRenderOpacity( el, - getContainingFrame(el), + getContainingFrame(el, this.scene.getNonDeletedElementsMap()), this.elementsPendingErasure, ), ["--embeddable-radius" as string]: `${getCornerRadius( @@ -1217,7 +1247,9 @@ class App extends React.Component { title="Excalidraw Embedded Content" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen={true} - sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads" + sandbox={`${ + src?.sandbox?.allowSameOrigin ? "allow-same-origin" : "" + } allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`} /> )}
@@ -1286,7 +1318,7 @@ class App extends React.Component { return null; } - const isDarkTheme = this.state.theme === "dark"; + const isDarkTheme = this.state.theme === THEME.DARK; let frameIndex = 0; let magicFrameIndex = 0; @@ -1309,6 +1341,7 @@ class App extends React.Component { scrollY: this.state.scrollY, zoom: this.state.zoom, }, + this.scene.getNonDeletedElementsMap(), ) ) { // if frame not visible, don't render its name @@ -1558,6 +1591,7 @@ class App extends React.Component { { isMagicFrameElement(firstSelectedElement) && ( { ?.status === "done" && ( { } scale={window.devicePixelRatio} appState={this.state} + device={this.device} renderInteractiveSceneCallback={ this.renderInteractiveSceneCallback } @@ -1758,7 +1795,7 @@ class App extends React.Component { this.files, { exportBackground: this.state.exportBackground, - name: this.state.name, + name: this.getName(), viewBackgroundColor: this.state.viewBackgroundColor, exportingFrame: opts.exportingFrame, }, @@ -2059,7 +2096,7 @@ class App extends React.Component { locked: false, }); - this.scene.addNewElement(frame); + this.scene.insertElement(frame); for (const child of selectedElements) { mutateElement(child, { frameId: frame.id }); @@ -2091,12 +2128,12 @@ class App extends React.Component { if (shouldUpdateStrokeColor) { this.syncActionResult({ appState: { ...this.state, currentItemStrokeColor: color }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } else { this.syncActionResult({ appState: { ...this.state, currentItemBackgroundColor: color }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } } else { @@ -2110,6 +2147,7 @@ class App extends React.Component { } return el; }), + storeAction: StoreAction.CAPTURE, }); } }, @@ -2134,10 +2172,14 @@ class App extends React.Component { editingElement = element; } }); - this.scene.replaceAllElements(actionResult.elements); - if (actionResult.commitToHistory) { - this.history.resumeRecording(); + + if (actionResult.storeAction === StoreAction.UPDATE) { + this.store.shouldUpdateSnapshot(); + } else if (actionResult.storeAction === StoreAction.CAPTURE) { + this.store.shouldCaptureIncrement(); } + + this.scene.replaceAllElements(actionResult.elements); } if (actionResult.files) { @@ -2148,8 +2190,10 @@ class App extends React.Component { } if (actionResult.appState || editingElement || this.state.contextMenu) { - if (actionResult.commitToHistory) { - this.history.resumeRecording(); + if (actionResult.storeAction === StoreAction.UPDATE) { + this.store.shouldUpdateSnapshot(); + } else if (actionResult.storeAction === StoreAction.CAPTURE) { + this.store.shouldCaptureIncrement(); } let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false; @@ -2157,7 +2201,7 @@ class App extends React.Component { let gridSize = actionResult?.appState?.gridSize || null; const theme = actionResult?.appState?.theme || this.props.theme || THEME.LIGHT; - let name = actionResult?.appState?.name ?? this.state.name; + const name = actionResult?.appState?.name ?? this.state.name; const errorMessage = actionResult?.appState?.errorMessage ?? this.state.errorMessage; if (typeof this.props.viewModeEnabled !== "undefined") { @@ -2172,10 +2216,6 @@ class App extends React.Component { gridSize = this.props.gridModeEnabled ? GRID_SIZE : null; } - if (typeof this.props.name !== "undefined") { - name = this.props.name; - } - editingElement = editingElement || actionResult.appState?.editingElement || null; @@ -2183,34 +2223,24 @@ class App extends React.Component { editingElement = null; } - this.setState( - (state) => { - // using Object.assign instead of spread to fool TS 4.2.2+ into - // regarding the resulting type as not containing undefined - // (which the following expression will never contain) - return Object.assign(actionResult.appState || {}, { - // NOTE this will prevent opening context menu using an action - // or programmatically from the host, so it will need to be - // rewritten later - contextMenu: null, - editingElement, - viewModeEnabled, - zenModeEnabled, - gridSize, - theme, - name, - errorMessage, - }); - }, - () => { - if (actionResult.syncHistory) { - this.history.setCurrentState( - this.state, - this.scene.getElementsIncludingDeleted(), - ); - } - }, - ); + this.setState((state) => { + // using Object.assign instead of spread to fool TS 4.2.2+ into + // regarding the resulting type as not containing undefined + // (which the following expression will never contain) + return Object.assign(actionResult.appState || {}, { + // NOTE this will prevent opening context menu using an action + // or programmatically from the host, so it will need to be + // rewritten later + contextMenu: null, + editingElement, + viewModeEnabled, + zenModeEnabled, + gridSize, + theme, + name, + errorMessage, + }); + }); } }, ); @@ -2234,6 +2264,10 @@ class App extends React.Component { this.history.clear(); }; + private resetStore = () => { + this.store.clear(); + }; + /** * Resets scene & history. * ! Do not use to clear scene user action ! @@ -2246,6 +2280,7 @@ class App extends React.Component { isLoading: opts?.resetLoadingState ? false : state.isLoading, theme: this.state.theme, })); + this.resetStore(); this.resetHistory(); }, ); @@ -2330,10 +2365,11 @@ class App extends React.Component { // seems faster even in browsers that do fire the loadingdone event. this.fonts.loadFontsForElements(scene.elements); + this.resetStore(); this.resetHistory(); this.syncActionResult({ ...scene, - commitToHistory: true, + storeAction: StoreAction.UPDATE, }); }; @@ -2423,9 +2459,17 @@ class App extends React.Component { configurable: true, value: this.history, }, + store: { + configurable: true, + value: this.store, + }, }); } + this.store.onStoreIncrementEmitter.on((increment) => { + this.history.record(increment.elementsChange, increment.appStateChange); + }); + this.scene.addCallback(this.onSceneUpdated); this.addEventListeners(); @@ -2482,6 +2526,7 @@ class App extends React.Component { this.laserTrails.stop(); this.eraserTrail.stop(); this.onChangeEmitter.clear(); + this.store.onStoreIncrementEmitter.clear(); ShapeCache.destroy(); SnapCache.destroy(); clearTimeout(touchTimeout); @@ -2625,10 +2670,11 @@ class App extends React.Component { componentDidUpdate(prevProps: AppProps, prevState: AppState) { this.updateEmbeddables(); - if ( - !this.state.showWelcomeScreen && - !this.scene.getElementsIncludingDeleted().length - ) { + const elements = this.scene.getElementsIncludingDeleted(); + const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap(); + + if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); } @@ -2733,22 +2779,16 @@ class App extends React.Component { }); } - if (this.props.name && prevProps.name !== this.props.name) { - this.setState({ - name: this.props.name, - }); - } - this.excalidrawContainerRef.current?.classList.toggle( "theme--dark", - this.state.theme === "dark", + this.state.theme === THEME.DARK, ); if ( this.state.editingLinearElement && !this.state.selectedElementIds[this.state.editingLinearElement.elementId] ) { - // defer so that the commitToHistory flag isn't reset via current update + // defer so that the storeAction flag isn't reset via current update setTimeout(() => { // execute only if the condition still holds when the deferred callback // executes (it can be scheduled multiple times depending on how @@ -2783,32 +2823,26 @@ class App extends React.Component { maybeBindLinearElement( multiElement, this.state, - this.scene, tupleToCoors( LinearElementEditor.getPointAtIndexGlobalCoordinates( multiElement, -1, + nonDeletedElementsMap, ), ), + this, ); } - this.history.record(this.state, this.scene.getElementsIncludingDeleted()); + + this.store.commit(elementsMap, this.state); // Do not notify consumers if we're still loading the scene. Among other // potential issues, this fixes a case where the tab isn't focused during // init, which would trigger onChange with empty elements, which would then // override whatever is in localStorage currently. if (!this.state.isLoading) { - this.props.onChange?.( - this.scene.getElementsIncludingDeleted(), - this.state, - this.files, - ); - this.onChangeEmitter.trigger( - this.scene.getElementsIncludingDeleted(), - this.state, - this.files, - ); + this.props.onChange?.(elements, this.state, this.files); + this.onChangeEmitter.trigger(elements, this.state, this.files); } } @@ -3135,10 +3169,10 @@ class App extends React.Component { }, ); - const allElements = [ - ...this.scene.getElementsIncludingDeleted(), - ...newElements, - ]; + const prevElements = this.scene.getElementsIncludingDeleted(); + const nextElements = [...prevElements, ...newElements]; + + syncMovedIndices(nextElements, arrayToMap(newElements)); const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y }); @@ -3147,10 +3181,10 @@ class App extends React.Component { newElements, topLayerFrame, ); - addElementsToFrame(allElements, eligibleElements, topLayerFrame); + addElementsToFrame(nextElements, eligibleElements, topLayerFrame); } - this.scene.replaceAllElements(allElements); + this.scene.replaceAllElements(nextElements); newElements.forEach((newElement) => { if (isTextElement(newElement) && isBoundToContainer(newElement)) { @@ -3158,7 +3192,11 @@ class App extends React.Component { newElement, this.scene.getElementsMapIncludingDeleted(), ); - redrawTextBoundingBox(newElement, container); + redrawTextBoundingBox( + newElement, + container, + this.scene.getElementsMapIncludingDeleted(), + ); } }); @@ -3166,7 +3204,7 @@ class App extends React.Component { this.files = { ...this.files, ...opts.files }; } - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); const nextElementsToSelect = excludeElementsInFramesFromSelection(newElements); @@ -3241,7 +3279,13 @@ class App extends React.Component { try { return { file: await ImageURLToFile(url) }; } catch (error: any) { - return { errorMessage: error.message as string }; + let errorMessage = error.message; + if (error.cause === "FETCH_ERROR") { + errorMessage = t("errors.failedToFetchImage"); + } else if (error.cause === "UNSUPPORTED") { + errorMessage = t("errors.unsupportedFileType"); + } + return { errorMessage }; } }), ); @@ -3372,19 +3416,7 @@ class App extends React.Component { return; } - const frameId = textElements[0].frameId; - - if (frameId) { - this.scene.insertElementsAtIndex( - textElements, - this.scene.getElementIndex(frameId), - ); - } else { - this.scene.replaceAllElements([ - ...this.scene.getElementsIncludingDeleted(), - ...textElements, - ]); - } + this.scene.insertElements(textElements); this.setState({ selectedElementIds: makeNextSelectedElementIds( @@ -3408,7 +3440,7 @@ class App extends React.Component { PLAIN_PASTE_TOAST_SHOWN = true; } - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } setAppState: React.Component["setState"] = ( @@ -3676,10 +3708,39 @@ class App extends React.Component { elements?: SceneData["elements"]; appState?: Pick | null; collaborators?: SceneData["collaborators"]; - commitToHistory?: SceneData["commitToHistory"]; + /** @default StoreAction.CAPTURE */ + storeAction?: SceneData["storeAction"]; }) => { - if (sceneData.commitToHistory) { - this.history.resumeRecording(); + const nextElements = syncInvalidIndices(sceneData.elements ?? []); + + if (sceneData.storeAction && sceneData.storeAction !== StoreAction.NONE) { + const prevCommittedAppState = this.store.snapshot.appState; + const prevCommittedElements = this.store.snapshot.elements; + + const nextCommittedAppState = sceneData.appState + ? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState` + : prevCommittedAppState; + + const nextCommittedElements = sceneData.elements + ? this.store.filterUncomittedElements( + this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements + arrayToMap(nextElements), // We expect all (already reconciled) elements + ) + : prevCommittedElements; + + // WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter + // do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well + if (sceneData.storeAction === StoreAction.CAPTURE) { + this.store.captureIncrement( + nextCommittedElements, + nextCommittedAppState, + ); + } else if (sceneData.storeAction === StoreAction.UPDATE) { + this.store.updateSnapshot( + nextCommittedElements, + nextCommittedAppState, + ); + } } if (sceneData.appState) { @@ -3687,7 +3748,7 @@ class App extends React.Component { } if (sceneData.elements) { - this.scene.replaceAllElements(sceneData.elements); + this.scene.replaceAllElements(nextElements); } if (sceneData.collaborators) { @@ -3708,17 +3769,29 @@ class App extends React.Component { tab, force, }: { - name: SidebarName; + name: SidebarName | null; tab?: SidebarTabName; force?: boolean; }): boolean => { let nextName; if (force === undefined) { - nextName = this.state.openSidebar?.name === name ? null : name; + nextName = + this.state.openSidebar?.name === name && + this.state.openSidebar?.tab === tab + ? null + : name; } else { nextName = force ? name : null; } - this.setState({ openSidebar: nextName ? { name: nextName, tab } : null }); + + const nextState: AppState["openSidebar"] = nextName + ? { name: nextName } + : null; + if (nextState && tab) { + nextState.tab = tab; + } + + this.setState({ openSidebar: nextState }); return !!nextName; }; @@ -3758,6 +3831,21 @@ class App extends React.Component { }); } + if ( + event[KEYS.CTRL_OR_CMD] && + event.key === KEYS.P && + !event.shiftKey && + !event.altKey + ) { + this.setToast({ + message: t("commandPalette.shortcutHint", { + shortcut: getShortcutFromShortcutName("commandPalette"), + }), + }); + event.preventDefault(); + return; + } + if (event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.V) { IS_PLAIN_PASTE = event.shiftKey; clearTimeout(IS_PLAIN_PASTE_TIMER); @@ -3869,12 +3957,17 @@ class App extends React.Component { y: element.y + offsetY, }); - updateBoundElements(element, { + updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { simultaneouslyUpdated: selectedElements, }); }); - this.maybeSuggestBindingForAll(selectedElements); + this.setState({ + suggestedBindings: getSuggestedBindingsForArrows( + selectedElements, + this, + ), + }); event.preventDefault(); } else if (event.key === KEYS.ENTER) { @@ -3888,11 +3981,10 @@ class App extends React.Component { this.state.editingLinearElement.elementId !== selectedElements[0].id ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.setState({ editingLinearElement: new LinearElementEditor( selectedElement, - this.scene, ), }); } @@ -4042,10 +4134,12 @@ class App extends React.Component { this.setState({ isBindingEnabled: true }); } if (isArrowKey(event.key)) { - const selectedElements = this.scene.getSelectedElements(this.state); - isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements(selectedElements) - : unbindLinearElements(selectedElements); + bindOrUnbindLinearElements( + this.scene.getSelectedElements(this.state).filter(isLinearElement), + this, + isBindingEnabled(this.state), + this.state.selectedLinearElement?.selectedPointsIndices ?? [], + ); this.setState({ suggestedBindings: [] }); } }); @@ -4104,6 +4198,11 @@ class App extends React.Component { originSnapOffset: null, activeEmbeddable: null, } as const; + + if (nextActiveTool.type === "freedraw") { + this.store.shouldCaptureIncrement(); + } + if (nextActiveTool.type !== "selection") { return { ...prevState, @@ -4147,6 +4246,14 @@ class App extends React.Component { return gesture.pointers.size >= 2; }; + public getName = () => { + return ( + this.state.name || + this.props.name || + `${t("labels.untitled")}-${getDateTime()}` + ); + }; + // fires only on Safari private onGestureStart = withBatchedUpdates((event: GestureEvent) => { event.preventDefault(); @@ -4218,20 +4325,21 @@ class App extends React.Component { isExistingElement?: boolean; }, ) { + const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const updateElement = ( text: string, originalText: string, isDeleted: boolean, ) => { this.scene.replaceAllElements([ + // Not sure why we include deleted elements as well hence using deleted elements map ...this.scene.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id && isTextElement(_element)) { return updateTextElement( _element, - getContainerElement( - _element, - this.scene.getElementsMapIncludingDeleted(), - ), + getContainerElement(_element, elementsMap), + elementsMap, { text, isDeleted, @@ -4263,7 +4371,7 @@ class App extends React.Component { onChange: withBatchedUpdates((text) => { updateElement(text, text, false); if (isNonDeletedElement(element)) { - updateBoundElements(element); + updateBoundElements(element, elementsMap); } }), onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => { @@ -4291,7 +4399,7 @@ class App extends React.Component { ]); } if (!isDeleted || isExistingElement) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } this.setState({ @@ -4338,12 +4446,88 @@ class App extends React.Component { return null; } + /** + * get the pure geometric shape of an excalidraw element + * which is then used for hit detection + */ + public getElementShape(element: ExcalidrawElement): GeometricShape { + switch (element.type) { + case "rectangle": + case "diamond": + case "frame": + case "magicframe": + case "embeddable": + case "image": + case "iframe": + case "text": + case "selection": + return getPolygonShape(element); + case "arrow": + case "line": { + const roughShape = + ShapeCache.get(element)?.[0] ?? + ShapeCache.generateElementShape(element, null)[0]; + const [, , , , cx, cy] = getElementAbsoluteCoords( + element, + this.scene.getNonDeletedElementsMap(), + ); + + return shouldTestInside(element) + ? getClosedCurveShape( + element, + roughShape, + [element.x, element.y], + element.angle, + [cx, cy], + ) + : getCurveShape(roughShape, [element.x, element.y], element.angle, [ + cx, + cy, + ]); + } + + case "ellipse": + return getEllipseShape(element); + + case "freedraw": { + const [, , , , cx, cy] = getElementAbsoluteCoords( + element, + this.scene.getNonDeletedElementsMap(), + ); + return getFreedrawShape(element, [cx, cy], shouldTestInside(element)); + } + } + } + + private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null { + const boundTextElement = getBoundTextElement( + element, + this.scene.getNonDeletedElementsMap(), + ); + + if (boundTextElement) { + if (element.type === "arrow") { + return this.getElementShape({ + ...boundTextElement, + // arrow's bound text accurate position is not stored in the element's property + // but rather calculated and returned from the following static method + ...LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + this.scene.getNonDeletedElementsMap(), + ), + }); + } + return this.getElementShape(boundTextElement); + } + + return null; + } + private getElementAtPosition( x: number, y: number, opts?: { - /** if true, returns the first selected element (with highest z-index) - of all hit elements */ preferSelected?: boolean; includeBoundTextElement?: boolean; includeLockedElements?: boolean; @@ -4355,6 +4539,7 @@ class App extends React.Component { opts?.includeBoundTextElement, opts?.includeLockedElements, ); + if (allHitElements.length > 1) { if (opts?.preferSelected) { for (let index = allHitElements.length - 1; index > -1; index--) { @@ -4365,22 +4550,28 @@ class App extends React.Component { } const elementWithHighestZIndex = allHitElements[allHitElements.length - 1]; + // If we're hitting element with highest z-index only on its bounding box // while also hitting other element figure, the latter should be considered. - return isHittingElementBoundingBoxWithoutHittingElement( - elementWithHighestZIndex, - this.state, - this.frameNameBoundsCache, + return hitElementItself({ x, y, - this.scene.getNonDeletedElementsMap(), - ) - ? allHitElements[allHitElements.length - 2] - : elementWithHighestZIndex; + element: elementWithHighestZIndex, + shape: this.getElementShape(elementWithHighestZIndex), + // when overlapping, we would like to be more precise + // this also avoids the need to update past tests + threshold: this.getElementHitThreshold() / 2, + frameNameBound: isFrameLikeElement(elementWithHighestZIndex) + ? this.frameNameBoundsCache.get(elementWithHighestZIndex) + : null, + }) + ? elementWithHighestZIndex + : allHitElements[allHitElements.length - 2]; } if (allHitElements.length === 1) { return allHitElements[0]; } + return null; } @@ -4390,7 +4581,11 @@ class App extends React.Component { includeBoundTextElement: boolean = false, includeLockedElements: boolean = false, ): NonDeleted[] { - const elements = + const iframeLikes: Ordered[] = []; + + const elementsMap = this.scene.getNonDeletedElementsMap(); + + const elements = ( includeBoundTextElement && includeLockedElements ? this.scene.getNonDeletedElements() : this.scene @@ -4400,28 +4595,120 @@ class App extends React.Component { (includeLockedElements || !element.locked) && (includeBoundTextElement || !(isTextElement(element) && element.containerId)), - ); + ) + ) + .filter((el) => this.hitElement(x, y, el)) + .filter((element) => { + // hitting a frame's element from outside the frame is not considered a hit + const containingFrame = getContainingFrame(element, elementsMap); + return containingFrame && + this.state.frameRendering.enabled && + this.state.frameRendering.clip + ? isCursorInFrame({ x, y }, containingFrame, elementsMap) + : true; + }) + .filter((el) => { + // The parameter elements comes ordered from lower z-index to higher. + // We want to preserve that order on the returned array. + // Exception being embeddables which should be on top of everything else in + // terms of hit testing. + if (isIframeElement(el)) { + iframeLikes.push(el); + return false; + } + return true; + }) + .concat(iframeLikes) as NonDeleted[]; - return getElementsAtPosition(elements, (element) => - hitTest( + return elements; + } + + private getElementHitThreshold() { + return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value; + } + + private hitElement( + x: number, + y: number, + element: ExcalidrawElement, + considerBoundingBox = true, + ) { + // if the element is selected, then hit test is done against its bounding box + if ( + considerBoundingBox && + this.state.selectedElementIds[element.id] && + shouldShowBoundingBox([element], this.state) + ) { + const selectionShape = getSelectionBoxShape( element, - this.state, - this.frameNameBoundsCache, - x, - y, this.scene.getNonDeletedElementsMap(), - ), - ).filter((element) => { - // hitting a frame's element from outside the frame is not considered a hit - const containingFrame = getContainingFrame(element); - return containingFrame && - this.state.frameRendering.enabled && - this.state.frameRendering.clip - ? isCursorInFrame({ x, y }, containingFrame) - : true; + this.getElementHitThreshold(), + ); + + return isPointInShape([x, y], selectionShape); + } + + // take bound text element into consideration for hit collision as well + const hitBoundTextOfElement = hitElementBoundText( + x, + y, + this.getBoundTextShape(element), + ); + if (hitBoundTextOfElement) { + return true; + } + + return hitElementItself({ + x, + y, + element, + shape: this.getElementShape(element), + threshold: this.getElementHitThreshold(), + frameNameBound: isFrameLikeElement(element) + ? this.frameNameBoundsCache.get(element) + : null, }); } + private getTextBindableContainerAtPosition(x: number, y: number) { + const elements = this.scene.getNonDeletedElements(); + const selectedElements = this.scene.getSelectedElements(this.state); + if (selectedElements.length === 1) { + return isTextBindableContainer(selectedElements[0], false) + ? selectedElements[0] + : null; + } + let hitElement = null; + // We need to do hit testing from front (end of the array) to back (beginning of the array) + for (let index = elements.length - 1; index >= 0; --index) { + if (elements[index].isDeleted) { + continue; + } + const [x1, y1, x2, y2] = getElementAbsoluteCoords( + elements[index], + this.scene.getNonDeletedElementsMap(), + ); + if ( + isArrowElement(elements[index]) && + hitElementItself({ + x, + y, + element: elements[index], + shape: this.getElementShape(elements[index]), + threshold: this.getElementHitThreshold(), + }) + ) { + hitElement = elements[index]; + break; + } else if (x1 < x && x < x2 && y1 < y && y < y2) { + hitElement = elements[index]; + break; + } + } + + return isTextBindableContainer(hitElement, false) ? hitElement : null; + } + private startTextEditing = ({ sceneX, sceneY, @@ -4564,7 +4851,7 @@ class App extends React.Component { const containerIndex = this.scene.getElementIndex(container.id); this.scene.insertElementAtIndex(element, containerIndex + 1); } else { - this.scene.addNewElement(element); + this.scene.insertElement(element); } } @@ -4598,19 +4885,11 @@ class App extends React.Component { (!this.state.editingLinearElement || this.state.editingLinearElement.elementId !== selectedElements[0].id) ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.setState({ - editingLinearElement: new LinearElementEditor( - selectedElements[0], - this.scene, - ), + editingLinearElement: new LinearElementEditor(selectedElements[0]), }); return; - } else if ( - this.state.editingLinearElement && - this.state.editingLinearElement.elementId === selectedElements[0].id - ) { - return; } } @@ -4631,6 +4910,7 @@ class App extends React.Component { getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds); if (selectedGroupId) { + this.store.shouldCaptureIncrement(); this.setState((prevState) => ({ ...prevState, ...selectGroupsForSelectedElements( @@ -4658,23 +4938,19 @@ class App extends React.Component { return; } - const container = getTextBindableContainerAtPosition( - this.scene.getNonDeletedElements(), - this.state, - sceneX, - sceneY, - ); + const container = this.getTextBindableContainerAtPosition(sceneX, sceneY); if (container) { if ( hasBoundTextElement(container) || !isTransparent(container.backgroundColor) || - isHittingElementNotConsideringBoundingBox( - container, - this.state, - this.frameNameBoundsCache, - [sceneX, sceneY], - ) + hitElementItself({ + x: sceneX, + y: sceneY, + element: container, + shape: this.getElementShape(container), + threshold: this.getElementHitThreshold(), + }) ) { const midPoint = getContainerCenter( container, @@ -4714,6 +4990,7 @@ class App extends React.Component { index <= hitElementIndex && isPointHittingLink( element, + this.scene.getNonDeletedElementsMap(), this.state, [scenePointer.x, scenePointer.y], this.device.editor.isMobile, @@ -4744,8 +5021,10 @@ class App extends React.Component { this.lastPointerDownEvent!, this.state, ); + const elementsMap = this.scene.getNonDeletedElementsMap(); const lastPointerDownHittingLinkIcon = isPointHittingLink( this.hitLinkElement, + elementsMap, this.state, [lastPointerDownCoords.x, lastPointerDownCoords.y], this.device.editor.isMobile, @@ -4756,6 +5035,7 @@ class App extends React.Component { ); const lastPointerUpHittingLinkIcon = isPointHittingLink( this.hitLinkElement, + elementsMap, this.state, [lastPointerUpCoords.x, lastPointerUpCoords.y], this.device.editor.isMobile, @@ -4792,10 +5072,11 @@ class App extends React.Component { x: number; y: number; }) => { + const elementsMap = this.scene.getNonDeletedElementsMap(); const frames = this.scene .getNonDeletedFramesLikes() .filter((frame): frame is ExcalidrawFrameLikeElement => - isCursorInFrame(sceneCoords, frame), + isCursorInFrame(sceneCoords, frame, elementsMap), ); return frames.length ? frames[frames.length - 1] : null; @@ -4899,6 +5180,7 @@ class App extends React.Component { y: scenePointerY, }, event, + this.scene.getNonDeletedElementsMap(), ); this.setState((prevState) => { @@ -4938,6 +5220,7 @@ class App extends React.Component { scenePointerX, scenePointerY, this.state, + this.scene.getNonDeletedElementsMap(), ); if ( @@ -5081,23 +5364,41 @@ class App extends React.Component { !isOverScrollBar && !this.state.editingLinearElement ) { - const elementWithTransformHandleType = getElementWithTransformHandleType( - elements, - this.state, - scenePointerX, - scenePointerY, - this.state.zoom, - event.pointerType, - ); - if ( - elementWithTransformHandleType && - elementWithTransformHandleType.transformHandleType - ) { - setCursor( - this.interactiveCanvas, - getCursorForResizingElement(elementWithTransformHandleType), + // for linear elements, we'd like to prioritize point dragging over edge resizing + // therefore, we update and check hovered point index first + if (this.state.selectedLinearElement) { + this.handleHoverSelectedLinearElement( + this.state.selectedLinearElement, + scenePointerX, + scenePointerY, ); - return; + } + + if ( + !this.state.selectedLinearElement || + this.state.selectedLinearElement.hoverPointIndex === -1 + ) { + const elementWithTransformHandleType = + getElementWithTransformHandleType( + elements, + this.state, + scenePointerX, + scenePointerY, + this.state.zoom, + event.pointerType, + this.scene.getNonDeletedElementsMap(), + this.device, + ); + if ( + elementWithTransformHandleType && + elementWithTransformHandleType.transformHandleType + ) { + setCursor( + this.interactiveCanvas, + getCursorForResizingElement(elementWithTransformHandleType), + ); + return; + } } } else if (selectedElements.length > 1 && !isOverScrollBar) { const transformHandleType = getTransformHandleTypeFromCoords( @@ -5106,6 +5407,7 @@ class App extends React.Component { scenePointerY, this.state.zoom, event.pointerType, + this.device, ); if (transformHandleType) { setCursor( @@ -5135,7 +5437,11 @@ class App extends React.Component { !this.state.selectedElementIds[this.hitLinkElement.id] ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - showHyperlinkTooltip(this.hitLinkElement, this.state); + showHyperlinkTooltip( + this.hitLinkElement, + this.state, + this.scene.getNonDeletedElementsMap(), + ); } else { hideHyperlinkToolip(); if ( @@ -5254,7 +5560,7 @@ class App extends React.Component { scenePointer.x, scenePointer.y, ); - const threshold = 10 / this.state.zoom.value; + const threshold = this.getElementHitThreshold(); const point = { ...pointerDownState.lastCoords }; let samplingInterval = 0; while (samplingInterval <= distance) { @@ -5313,11 +5619,12 @@ class App extends React.Component { scenePointerX: number, scenePointerY: number, ) { + const elementsMap = this.scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); - const elementsMap = this.scene.getNonDeletedElementsMap(); - const boundTextElement = getBoundTextElement(element, elementsMap); if (!element) { return; @@ -5326,15 +5633,16 @@ class App extends React.Component { let hoverPointIndex = -1; let segmentMidPointHoveredCoords = null; if ( - isHittingElementNotConsideringBoundingBox( + hitElementItself({ + x: scenePointerX, + y: scenePointerY, element, - this.state, - this.frameNameBoundsCache, - [scenePointerX, scenePointerY], - ) + shape: this.getElementShape(element), + }) ) { hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, + elementsMap, this.state.zoom, scenePointerX, scenePointerY, @@ -5349,32 +5657,10 @@ class App extends React.Component { if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - } else { + } else if (this.hitElement(scenePointerX, scenePointerY, element)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } - } else if ( - shouldShowBoundingBox([element], this.state) && - isHittingElementBoundingBoxWithoutHittingElement( - element, - this.state, - this.frameNameBoundsCache, - scenePointerX, - scenePointerY, - elementsMap, - ) - ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); - } else if ( - boundTextElement && - hitTest( - boundTextElement, - this.state, - this.frameNameBoundsCache, - scenePointerX, - scenePointerY, - this.scene.getNonDeletedElementsMap(), - ) - ) { + } else if (this.hitElement(scenePointerX, scenePointerY, element)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } @@ -5461,6 +5747,7 @@ class App extends React.Component { this.state, ), }, + storeAction: StoreAction.UPDATE, }); return; } @@ -5764,10 +6051,12 @@ class App extends React.Component { if ( clicklength < 300 && isIframeLikeElement(this.hitLinkElement) && - !isPointHittingLinkIcon(this.hitLinkElement, this.state, [ - scenePointer.x, - scenePointer.y, - ]) + !isPointHittingLinkIcon( + this.hitLinkElement, + this.scene.getNonDeletedElementsMap(), + this.state, + [scenePointer.x, scenePointer.y], + ) ) { this.handleEmbeddableCenterClick(this.hitLinkElement); } else { @@ -6065,8 +6354,17 @@ class App extends React.Component { ): boolean => { if (this.state.activeTool.type === "selection") { const elements = this.scene.getNonDeletedElements(); + const elementsMap = this.scene.getNonDeletedElementsMap(); const selectedElements = this.scene.getSelectedElements(this.state); - if (selectedElements.length === 1 && !this.state.editingLinearElement) { + + if ( + selectedElements.length === 1 && + !this.state.editingLinearElement && + !( + this.state.selectedLinearElement && + this.state.selectedLinearElement.hoverPointIndex !== -1 + ) + ) { const elementWithTransformHandleType = getElementWithTransformHandleType( elements, @@ -6075,6 +6373,8 @@ class App extends React.Component { pointerDownState.origin.y, this.state.zoom, event.pointerType, + this.scene.getNonDeletedElementsMap(), + this.device, ); if (elementWithTransformHandleType != null) { this.setState({ @@ -6090,6 +6390,7 @@ class App extends React.Component { pointerDownState.origin.y, this.state.zoom, event.pointerType, + this.device, ); } if (pointerDownState.resize.handleType) { @@ -6098,6 +6399,7 @@ class App extends React.Component { getResizeOffsetXY( pointerDownState.resize.handleType, selectedElements, + elementsMap, pointerDownState.origin.x, pointerDownState.origin.y, ), @@ -6119,10 +6421,10 @@ class App extends React.Component { const ret = LinearElementEditor.handlePointerDown( event, this.state, - this.history, + this.store, pointerDownState.origin, linearElementEditor, - this.scene.getNonDeletedElementsMap(), + this, ); if (ret.hitElement) { pointerDownState.hit.element = ret.hitElement; @@ -6345,7 +6647,7 @@ class App extends React.Component { } // How many pixels off the shape boundary we still consider a hit - const threshold = 10 / this.state.zoom.value; + const threshold = this.getElementHitThreshold(); const [x1, y1, x2, y2] = getCommonBounds(selectedElements); return ( point.x > x1 - threshold && @@ -6373,12 +6675,7 @@ class App extends React.Component { }); // FIXME - let container = getTextBindableContainerAtPosition( - this.scene.getNonDeletedElements(), - this.state, - sceneX, - sceneY, - ); + let container = this.getTextBindableContainerAtPosition(sceneX, sceneY); if (hasBoundTextElement(element)) { container = element as ExcalidrawTextContainer; @@ -6458,9 +6755,9 @@ class App extends React.Component { const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this.scene, + this, ); - this.scene.addNewElement(element); + this.scene.insertElement(element); this.setState({ draggingElement: element, editingElement: element, @@ -6505,10 +6802,7 @@ class App extends React.Component { height, }); - this.scene.replaceAllElements([ - ...this.scene.getElementsIncludingDeleted(), - element, - ]); + this.scene.insertElement(element); return element; }; @@ -6562,10 +6856,7 @@ class App extends React.Component { link, }); - this.scene.replaceAllElements([ - ...this.scene.getElementsIncludingDeleted(), - element, - ]); + this.scene.insertElement(element); return element; }; @@ -6728,10 +7019,10 @@ class App extends React.Component { }); const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this.scene, + this, ); - this.scene.addNewElement(element); + this.scene.insertElement(element); this.setState({ draggingElement: element, editingElement: element, @@ -6811,7 +7102,7 @@ class App extends React.Component { draggingElement: element, }); } else { - this.scene.addNewElement(element); + this.scene.insertElement(element); this.setState({ multiElement: null, draggingElement: element, @@ -6845,10 +7136,7 @@ class App extends React.Component { ? newMagicFrameElement(constructorOpts) : newFrameElement(constructorOpts); - this.scene.replaceAllElements([ - ...this.scene.getElementsIncludingDeleted(), - frame, - ]); + this.scene.insertElement(frame); this.setState({ multiElement: null, @@ -6875,6 +7163,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), selectedElements, this.state, + this.scene.getNonDeletedElementsMap(), ), ); } @@ -6898,6 +7187,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), selectedElements, this.state, + this.scene.getNonDeletedElementsMap(), ), ); } @@ -6997,6 +7287,7 @@ class App extends React.Component { return true; } } + const elementsMap = this.scene.getNonDeletedElementsMap(); if (this.state.selectedLinearElement) { const linearElementEditor = @@ -7007,6 +7298,7 @@ class App extends React.Component { this.state.selectedLinearElement, pointerCoords, this.state, + elementsMap, ) ) { const ret = LinearElementEditor.addMidpoint( @@ -7014,6 +7306,7 @@ class App extends React.Component { pointerCoords, this.state, !event[KEYS.CTRL_OR_CMD], + elementsMap, ); if (!ret) { return; @@ -7172,10 +7465,11 @@ class App extends React.Component { this.maybeCacheReferenceSnapPoints(event, selectedElements); const { snapOffset, snapLines } = snapDraggedElements( - getSelectedElements(originalElements, this.state), + originalElements, dragOffset, this.state, event, + this.scene.getNonDeletedElementsMap(), ); this.setState({ snapLines }); @@ -7193,7 +7487,12 @@ class App extends React.Component { event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, ); - this.maybeSuggestBindingForAll(selectedElements); + this.setState({ + suggestedBindings: getSuggestedBindingsForArrows( + selectedElements, + this, + ), + }); // We duplicate the selected element if alt is pressed on pointer move if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) { @@ -7255,7 +7554,11 @@ class App extends React.Component { nextElements.push(element); } } + const nextSceneElements = [...nextElements, ...elementsToAppend]; + + syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend)); + bindTextToShapeAfterDuplication( nextElements, elementsToAppend, @@ -7272,6 +7575,7 @@ class App extends React.Component { elementsToAppend, oldIdToDuplicatedId, ); + this.scene.replaceAllElements(nextSceneElements); this.maybeCacheVisibleGaps(event, selectedElements, true); this.maybeCacheReferenceSnapPoints(event, selectedElements, true); @@ -7359,6 +7663,7 @@ class App extends React.Component { event, this.state, this.setState.bind(this), + this.scene.getNonDeletedElementsMap(), ); // regular box-select } else { @@ -7389,6 +7694,7 @@ class App extends React.Component { const elementsWithinSelection = getElementsWithinSelection( elements, draggingElement, + this.scene.getNonDeletedElementsMap(), ); this.setState((prevState) => { @@ -7431,10 +7737,7 @@ class App extends React.Component { selectedLinearElement: elementsWithinSelection.length === 1 && isLinearElement(elementsWithinSelection[0]) - ? new LinearElementEditor( - elementsWithinSelection[0], - this.scene, - ) + ? new LinearElementEditor(elementsWithinSelection[0]) : null, showHyperlinkPopup: elementsWithinSelection.length === 1 && @@ -7508,7 +7811,6 @@ class App extends React.Component { ? this.state.editingElement : null, snapLines: updateStable(prevState.snapLines, []), - originSnapOffset: null, })); @@ -7520,7 +7822,7 @@ class App extends React.Component { this.setState({ selectedElementsAreBeingDragged: false, }); - + const elementsMap = this.scene.getNonDeletedElementsMap(); // Handle end of dragging a point of a linear element, might close a loop // and sets binding element if (this.state.editingLinearElement) { @@ -7535,6 +7837,7 @@ class App extends React.Component { childEvent, this.state.editingLinearElement, this.state, + this, ); if (editingLinearElement !== this.state.editingLinearElement) { this.setState({ @@ -7558,6 +7861,7 @@ class App extends React.Component { childEvent, this.state.selectedLinearElement, this.state, + this, ); const { startBindingElement, endBindingElement } = @@ -7568,6 +7872,7 @@ class App extends React.Component { element, startBindingElement, endBindingElement, + elementsMap, ); } @@ -7672,7 +7977,7 @@ class App extends React.Component { if (isLinearElement(draggingElement)) { if (draggingElement!.points.length > 1) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } const pointerCoords = viewportCoordsToSceneCoords( childEvent, @@ -7705,8 +8010,8 @@ class App extends React.Component { maybeBindLinearElement( draggingElement, this.state, - this.scene, pointerCoords, + this, ); } this.setState({ suggestedBindings: [], startBoundElement: null }); @@ -7724,10 +8029,7 @@ class App extends React.Component { }, prevState, ), - selectedLinearElement: new LinearElementEditor( - draggingElement, - this.scene, - ), + selectedLinearElement: new LinearElementEditor(draggingElement), })); } else { this.setState((prevState) => ({ @@ -7744,14 +8046,17 @@ class App extends React.Component { isInvisiblySmallElement(draggingElement) ) { // remove invisible element which was added in onPointerDown - this.scene.replaceAllElements( - this.scene + // update the store snapshot, so that invisible elements are not captured by the store + this.updateScene({ + elements: this.scene .getElementsIncludingDeleted() .filter((el) => el.id !== draggingElement.id), - ); - this.setState({ - draggingElement: null, + appState: { + draggingElement: null, + }, + storeAction: StoreAction.UPDATE, }); + return; } @@ -7774,10 +8079,16 @@ class App extends React.Component { ); if (linearElement?.frameId) { - const frame = getContainingFrame(linearElement); + const frame = getContainingFrame(linearElement, elementsMap); if (frame && linearElement) { - if (!elementOverlapsWithFrame(linearElement, frame)) { + if ( + !elementOverlapsWithFrame( + linearElement, + frame, + this.scene.getNonDeletedElementsMap(), + ) + ) { // remove the linear element from all groups // before removing it from the frame as well mutateElement(linearElement, { @@ -7888,6 +8199,7 @@ class App extends React.Component { const elementsInsideFrame = getElementsInNewFrame( this.scene.getElementsIncludingDeleted(), draggingElement, + this.scene.getNonDeletedElementsMap(), ); this.scene.replaceAllElements( @@ -7906,15 +8218,17 @@ class App extends React.Component { } if (resizingElement) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } if (resizingElement && isInvisiblySmallElement(resizingElement)) { - this.scene.replaceAllElements( - this.scene + // update the store snapshot, so that invisible elements are not captured by the store + this.updateScene({ + elements: this.scene .getElementsIncludingDeleted() .filter((el) => el.id !== resizingElement.id), - ); + storeAction: StoreAction.UPDATE, + }); } // handle frame membership for resizing frames and/or selected elements @@ -7938,6 +8252,7 @@ class App extends React.Component { this.scene.getElementsIncludingDeleted(), frame, this.state, + elementsMap, ), frame, this, @@ -7959,10 +8274,7 @@ class App extends React.Component { // the one we've hit if (selectedELements.length === 1) { this.setState({ - selectedLinearElement: new LinearElementEditor( - hitElement, - this.scene, - ), + selectedLinearElement: new LinearElementEditor(hitElement), }); } } @@ -8075,10 +8387,7 @@ class App extends React.Component { selectedLinearElement: newSelectedElements.length === 1 && isLinearElement(newSelectedElements[0]) - ? new LinearElementEditor( - newSelectedElements[0], - this.scene, - ) + ? new LinearElementEditor(newSelectedElements[0]) : prevState.selectedLinearElement, }; }); @@ -8152,23 +8461,31 @@ class App extends React.Component { // Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1. // Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized prevState.selectedLinearElement?.elementId !== hitElement.id - ? new LinearElementEditor(hitElement, this.scene) + ? new LinearElementEditor(hitElement) : prevState.selectedLinearElement, })); } } if ( + // not dragged !pointerDownState.drag.hasOccurred && + // not resized !this.state.isResizing && + // only hitting the bounding box of the previous hit element ((hitElement && - isHittingElementBoundingBoxWithoutHittingElement( - hitElement, - this.state, - this.frameNameBoundsCache, - pointerDownState.origin.x, - pointerDownState.origin.y, - this.scene.getNonDeletedElementsMap(), + hitElementBoundingBoxOnly( + { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + element: hitElement, + shape: this.getElementShape(hitElement), + threshold: this.getElementHitThreshold(), + frameNameBound: isFrameLikeElement(hitElement) + ? this.frameNameBoundsCache.get(hitElement) + : null, + }, + elementsMap, )) || (!hitElement && pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements)) @@ -8184,6 +8501,8 @@ class App extends React.Component { activeEmbeddable: null, }); } + // reset cursor + setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); return; } @@ -8210,15 +8529,28 @@ class App extends React.Component { if ( activeTool.type !== "selection" || - isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) + isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) || + !isShallowEqual( + this.state.previousSelectedElementIds, + this.state.selectedElementIds, + ) ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { - (isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements - : unbindLinearElements)(this.scene.getSelectedElements(this.state)); + // We only allow binding via linear elements, specifically via dragging + // the endpoints ("start" or "end"). + const linearElements = this.scene + .getSelectedElements(this.state) + .filter(isLinearElement); + + bindOrUnbindLinearElements( + linearElements, + this, + isBindingEnabled(this.state), + this.state.selectedLinearElement?.selectedPointsIndices ?? [], + ); } if (activeTool.type === "laser") { @@ -8284,7 +8616,7 @@ class App extends React.Component { this.elementsPendingErasure = new Set(); if (didChange) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.scene.replaceAllElements(elements); } }; @@ -8430,7 +8762,7 @@ class App extends React.Component { return; } - this.scene.addNewElement(imageElement); + this.scene.insertElement(imageElement); try { return await this.initializeImage({ @@ -8454,10 +8786,18 @@ class App extends React.Component { // mustn't be larger than 128 px // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property const cursorImageSizePx = 96; + let imagePreview; - const imagePreview = await resizeImageFile(imageFile, { - maxWidthOrHeight: cursorImageSizePx, - }); + try { + imagePreview = await resizeImageFile(imageFile, { + maxWidthOrHeight: cursorImageSizePx, + }); + } catch (e: any) { + if (e.cause === "UNSUPPORTED") { + throw new Error(t("errors.unsupportedFileType")); + } + throw e; + } let previewDataURL = await getDataURL(imagePreview); @@ -8695,7 +9035,7 @@ class App extends React.Component { }): void => { const hoveredBindableElement = getHoveredElementForBinding( pointerCoords, - this.scene, + this, ); this.setState({ suggestedBindings: @@ -8722,7 +9062,7 @@ class App extends React.Component { (acc: NonDeleted[], coords) => { const hoveredBindableElement = getHoveredElementForBinding( coords, - this.scene, + this, ); if ( hoveredBindableElement != null && @@ -8742,16 +9082,6 @@ class App extends React.Component { this.setState({ suggestedBindings }); }; - private maybeSuggestBindingForAll( - selectedElements: NonDeleted[], - ): void { - if (selectedElements.length > 50) { - return; - } - const suggestedBindings = getEligibleElementsForBinding(selectedElements); - this.setState({ suggestedBindings }); - } - private clearSelection(hitElement: ExcalidrawElement | null): void { this.setState((prevState) => ({ selectedElementIds: makeNextSelectedElementIds({}, prevState), @@ -8836,12 +9166,13 @@ class App extends React.Component { isLoading: false, }, replaceFiles: true, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); return; } catch (error: any) { + // Don't throw for image scene daa if (error.name !== "EncodingError") { - throw error; + throw new Error(t("alerts.couldNotLoadInvalidFile")); } } } @@ -8915,13 +9246,47 @@ class App extends React.Component { ) => { file = await normalizeFile(file); try { - const ret = await loadSceneOrLibraryFromBlob( - file, - this.state, - this.scene.getElementsIncludingDeleted(), - fileHandle, - ); + const elements = this.scene.getElementsIncludingDeleted(); + let ret; + try { + ret = await loadSceneOrLibraryFromBlob( + file, + this.state, + elements, + fileHandle, + ); + } catch (error: any) { + const imageSceneDataError = error instanceof ImageSceneDataError; + if ( + imageSceneDataError && + error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" && + !this.isToolSupported("image") + ) { + this.setState({ + isLoading: false, + errorMessage: t("errors.imageToolNotSupported"), + }); + return; + } + const errorMessage = imageSceneDataError + ? t("alerts.cannotRestoreFromImage") + : t("alerts.couldNotLoadInvalidFile"); + this.setState({ + isLoading: false, + errorMessage, + }); + } + if (!ret) { + return; + } + if (ret.type === MIME_TYPES.excalidraw) { + // restore the fractional indices by mutating elements + syncInvalidIndices(elements.concat(ret.data.elements)); + + // update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo + this.store.updateSnapshot(arrayToMap(elements), this.state); + this.setState({ isLoading: true }); this.syncActionResult({ ...ret.data, @@ -8930,7 +9295,7 @@ class App extends React.Component { isLoading: false, }, replaceFiles: true, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } else if (ret.type === MIME_TYPES.excalidrawlib) { await this.library @@ -8945,17 +9310,6 @@ class App extends React.Component { }); } } catch (error: any) { - if ( - error instanceof ImageSceneDataError && - error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" && - !this.isToolSupported("image") - ) { - this.setState({ - isLoading: false, - errorMessage: t("errors.imageToolNotSupported"), - }); - return; - } this.setState({ isLoading: false, errorMessage: error.message }); } }; @@ -9015,7 +9369,7 @@ class App extends React.Component { this, ), selectedLinearElement: isLinearElement(element) - ? new LinearElementEditor(element, this.scene) + ? new LinearElementEditor(element) : null, } : this.state), @@ -9087,6 +9441,7 @@ class App extends React.Component { x: gridX - pointerDownState.originInGrid.x, y: gridY - pointerDownState.originInGrid.y, }, + this.scene.getNonDeletedElementsMap(), ); gridX += snapOffset.x; @@ -9113,8 +9468,6 @@ class App extends React.Component { this.state.originSnapOffset, ); - this.maybeSuggestBindingForAll([draggingElement]); - // highlight elements that are to be added to frames on frames creation if ( this.state.activeTool.type === TOOL_TYPE.frame || @@ -9125,6 +9478,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), draggingElement as ExcalidrawFrameLikeElement, this.state, + this.scene.getNonDeletedElementsMap(), ), }); } @@ -9227,7 +9581,7 @@ class App extends React.Component { this.scene.getElementsMapIncludingDeleted(), shouldRotateWithDiscreteAngle(event), shouldResizeFromCenter(event), - selectedElements.length === 1 && isImageElement(selectedElements[0]) + selectedElements.some((element) => isImageElement(element)) ? !shouldMaintainAspectRatio(event) : shouldMaintainAspectRatio(event), resizeX, @@ -9236,7 +9590,10 @@ class App extends React.Component { pointerDownState.resize.center.y, ) ) { - this.maybeSuggestBindingForAll(selectedElements); + const suggestedBindings = getSuggestedBindingsForArrows( + selectedElements, + this, + ); const elementsToHighlight = new Set(); selectedFrames.forEach((frame) => { @@ -9277,11 +9634,13 @@ class App extends React.Component { this.scene.getNonDeletedElements(), frame, this.state, + this.scene.getNonDeletedElementsMap(), ).forEach((element) => elementsToHighlight.add(element)); }); this.setState({ elementsToHighlight: [...elementsToHighlight], + suggestedBindings, }); return true; @@ -9596,7 +9955,6 @@ class App extends React.Component { // ----------------------------------------------------------------------------- // TEST HOOKS // ----------------------------------------------------------------------------- - declare global { interface Window { h: { @@ -9605,24 +9963,30 @@ declare global { setState: React.Component["setState"]; app: InstanceType; history: History; + store: Store; }; } } -if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { - window.h = window.h || ({} as Window["h"]); +export const createTestHook = () => { + if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { + window.h = window.h || ({} as Window["h"]); - Object.defineProperties(window.h, { - elements: { - configurable: true, - get() { - return this.app?.scene.getElementsIncludingDeleted(); + Object.defineProperties(window.h, { + elements: { + configurable: true, + get() { + return this.app?.scene.getElementsIncludingDeleted(); + }, + set(elements: ExcalidrawElement[]) { + return this.app?.scene.replaceAllElements( + syncInvalidIndices(elements), + ); + }, }, - set(elements: ExcalidrawElement[]) { - return this.app?.scene.replaceAllElements(elements); - }, - }, - }); -} + }); + } +}; +createTestHook(); export default App; diff --git a/packages/excalidraw/components/Avatar.tsx b/packages/excalidraw/components/Avatar.tsx index b7b1bf962..9ddc319c6 100644 --- a/packages/excalidraw/components/Avatar.tsx +++ b/packages/excalidraw/components/Avatar.tsx @@ -9,8 +9,7 @@ type AvatarProps = { color: string; name: string; src?: string; - isBeingFollowed?: boolean; - isCurrentUser: boolean; + className?: string; }; export const Avatar = ({ @@ -18,22 +17,14 @@ export const Avatar = ({ onClick, name, src, - isBeingFollowed, - isCurrentUser, + className, }: AvatarProps) => { const shortName = getNameInitial(name); const [error, setError] = useState(false); const loadImg = !error && src; const style = loadImg ? undefined : { background: color }; return ( -
+
{loadImg ? ( (
{children}
diff --git a/packages/excalidraw/components/ColorPicker/ShadeList.tsx b/packages/excalidraw/components/ColorPicker/ShadeList.tsx index 81ddaab24..292457cbc 100644 --- a/packages/excalidraw/components/ColorPicker/ShadeList.tsx +++ b/packages/excalidraw/components/ColorPicker/ShadeList.tsx @@ -7,7 +7,7 @@ import { } from "./colorPickerUtils"; import HotkeyLabel from "./HotkeyLabel"; import { t } from "../../i18n"; -import { ColorPaletteCustom } from "../../colors"; +import type { ColorPaletteCustom } from "../../colors"; interface ShadeListProps { hex: string; diff --git a/packages/excalidraw/components/ColorPicker/TopPicks.tsx b/packages/excalidraw/components/ColorPicker/TopPicks.tsx index 34adbdf49..5c69d1e43 100644 --- a/packages/excalidraw/components/ColorPicker/TopPicks.tsx +++ b/packages/excalidraw/components/ColorPicker/TopPicks.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { ColorPickerType } from "./colorPickerUtils"; +import type { ColorPickerType } from "./colorPickerUtils"; import { DEFAULT_CANVAS_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS, diff --git a/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts b/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts index 37e5c88a6..311f5eba9 100644 --- a/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts +++ b/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts @@ -1,10 +1,7 @@ -import { ExcalidrawElement } from "../../element/types"; +import type { ExcalidrawElement } from "../../element/types"; import { atom } from "jotai"; -import { - ColorPickerColor, - ColorPaletteCustom, - MAX_CUSTOM_COLORS_USED_IN_CANVAS, -} from "../../colors"; +import type { ColorPickerColor, ColorPaletteCustom } from "../../colors"; +import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors"; export const getColorNameAndShadeFromColor = ({ palette, diff --git a/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts b/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts index 95ee7beeb..7767692ed 100644 --- a/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts +++ b/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts @@ -1,14 +1,13 @@ import { KEYS } from "../../keys"; -import { +import type { ColorPickerColor, ColorPalette, ColorPaletteCustom, - COLORS_PER_ROW, - COLOR_PALETTE, } from "../../colors"; -import { ValueOf } from "../../utility-types"; +import { COLORS_PER_ROW, COLOR_PALETTE } from "../../colors"; +import type { ValueOf } from "../../utility-types"; +import type { ActiveColorPickerSectionAtomType } from "./colorPickerUtils"; import { - ActiveColorPickerSectionAtomType, colorPickerHotkeyBindings, getColorNameAndShadeFromColor, } from "./colorPickerUtils"; diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.scss b/packages/excalidraw/components/CommandPalette/CommandPalette.scss new file mode 100644 index 000000000..ebb7e4fa5 --- /dev/null +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.scss @@ -0,0 +1,137 @@ +@import "../../css/variables.module.scss"; + +$verticalBreakpoint: 861px; + +.excalidraw { + .command-palette-dialog { + user-select: none; + + .Modal__content { + height: auto; + max-height: 100%; + + @media screen and (min-width: $verticalBreakpoint) { + max-height: 750px; + height: 100%; + } + + .Island { + height: 100%; + padding: 1.5rem; + } + + .Dialog__content { + height: 100%; + display: flex; + flex-direction: column; + } + } + + .shortcuts-wrapper { + display: flex; + justify-content: center; + align-items: center; + margin-top: 12px; + gap: 1.5rem; + } + + .shortcut { + display: flex; + justify-content: center; + align-items: center; + height: 16px; + font-size: 10px; + gap: 0.25rem; + + .shortcut-wrapper { + display: flex; + } + + .shortcut-plus { + margin: 0px 4px; + } + + .shortcut-key { + padding: 0px 4px; + height: 16px; + border-radius: 4px; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--color-primary-light); + } + + .shortcut-desc { + margin-left: 4px; + color: var(--color-gray-50); + } + } + + .commands { + overflow-y: auto; + box-sizing: border-box; + margin-top: 12px; + color: var(--popup-text-color); + user-select: none; + + .command-category { + display: flex; + flex-direction: column; + padding: 12px 0px; + margin-right: 0.25rem; + } + + .command-category-title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 6px; + display: flex; + align-items: center; + } + + .command-item { + color: var(--popup-text-color); + height: 2.5rem; + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; + padding: 0 0.5rem; + border-radius: var(--border-radius-lg); + cursor: pointer; + + &:active { + background-color: var(--color-surface-low); + } + + .name { + display: flex; + align-items: center; + gap: 0.25rem; + } + } + + .item-selected { + background-color: var(--color-surface-mid); + } + + .item-disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .no-match { + display: flex; + justify-content: center; + align-items: center; + margin-top: 36px; + } + } + + .icon { + width: 16px; + height: 16px; + margin-right: 6px; + } + } +} diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx new file mode 100644 index 000000000..4147ca085 --- /dev/null +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -0,0 +1,934 @@ +import { useEffect, useRef, useState } from "react"; +import { + useApp, + useAppProps, + useExcalidrawActionManager, + useExcalidrawSetAppState, +} from "../App"; +import { KEYS } from "../../keys"; +import { Dialog } from "../Dialog"; +import { TextField } from "../TextField"; +import clsx from "clsx"; +import { getSelectedElements } from "../../scene"; +import type { Action } from "../../actions/types"; +import type { TranslationKeys } from "../../i18n"; +import { t } from "../../i18n"; +import type { ShortcutName } from "../../actions/shortcuts"; +import { getShortcutFromShortcutName } from "../../actions/shortcuts"; +import { DEFAULT_SIDEBAR, EVENT } from "../../constants"; +import { + LockedIcon, + UnlockedIcon, + clockIcon, + searchIcon, + boltIcon, + bucketFillIcon, + ExportImageIcon, + mermaidLogoIcon, + brainIconThin, + LibraryIcon, +} from "../icons"; +import fuzzy from "fuzzy"; +import { useUIAppState } from "../../context/ui-appState"; +import type { AppProps, AppState, UIAppState } from "../../types"; +import { + capitalizeString, + getShortcutKey, + isWritableElement, +} from "../../utils"; +import { atom, useAtom } from "jotai"; +import { deburr } from "../../deburr"; +import type { MarkRequired } from "../../utility-types"; +import { InlineIcon } from "../InlineIcon"; +import { SHAPES } from "../../shapes"; +import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions"; +import { useStableCallback } from "../../hooks/useStableCallback"; +import { actionClearCanvas, actionLink } from "../../actions"; +import { jotaiStore } from "../../jotai"; +import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; +import type { CommandPaletteItem } from "./types"; +import * as defaultItems from "./defaultCommandPaletteItems"; +import { trackEvent } from "../../analytics"; +import { useStable } from "../../hooks/useStable"; + +import "./CommandPalette.scss"; + +const lastUsedPaletteItem = atom(null); + +export const DEFAULT_CATEGORIES = { + app: "App", + export: "Export", + tools: "Tools", + editor: "Editor", + elements: "Elements", + links: "Links", +}; + +const getCategoryOrder = (category: string) => { + switch (category) { + case DEFAULT_CATEGORIES.app: + return 1; + case DEFAULT_CATEGORIES.export: + return 2; + case DEFAULT_CATEGORIES.editor: + return 3; + case DEFAULT_CATEGORIES.tools: + return 4; + case DEFAULT_CATEGORIES.elements: + return 5; + case DEFAULT_CATEGORIES.links: + return 6; + default: + return 10; + } +}; + +const CommandShortcutHint = ({ + shortcut, + className, + children, +}: { + shortcut: string; + className?: string; + children?: React.ReactNode; +}) => { + const shortcuts = shortcut.replace("++", "+$").split("+"); + + return ( +
+ {shortcuts.map((item, idx) => { + return ( +
+
{item === "$" ? "+" : item}
+
+ ); + })} +
{children}
+
+ ); +}; + +const isCommandPaletteToggleShortcut = (event: KeyboardEvent) => { + return ( + !event.altKey && + event[KEYS.CTRL_OR_CMD] && + ((event.shiftKey && event.key.toLowerCase() === KEYS.P) || + event.key === KEYS.SLASH) + ); +}; + +type CommandPaletteProps = { + customCommandPaletteItems?: CommandPaletteItem[]; +}; + +export const CommandPalette = Object.assign( + (props: CommandPaletteProps) => { + const uiAppState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + + useEffect(() => { + const commandPaletteShortcut = (event: KeyboardEvent) => { + if (isCommandPaletteToggleShortcut(event)) { + event.preventDefault(); + event.stopPropagation(); + setAppState((appState) => { + const nextState = + appState.openDialog?.name === "commandPalette" + ? null + : ({ name: "commandPalette" } as const); + + if (nextState) { + trackEvent("command_palette", "open", "shortcut"); + } + + return { + openDialog: nextState, + }; + }); + } + }; + window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, { + capture: true, + }); + return () => + window.removeEventListener(EVENT.KEYDOWN, commandPaletteShortcut, { + capture: true, + }); + }, [setAppState]); + + if (uiAppState.openDialog?.name !== "commandPalette") { + return null; + } + + return ; + }, + { + defaultItems, + }, +); + +function CommandPaletteInner({ + customCommandPaletteItems, +}: CommandPaletteProps) { + const app = useApp(); + const uiAppState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + const appProps = useAppProps(); + const actionManager = useExcalidrawActionManager(); + + const [lastUsed, setLastUsed] = useAtom(lastUsedPaletteItem); + const [allCommands, setAllCommands] = useState< + MarkRequired[] | null + >(null); + + const inputRef = useRef(null); + + const stableDeps = useStable({ + uiAppState, + customCommandPaletteItems, + appProps, + }); + + useEffect(() => { + // these props change often and we don't want them to re-run the effect + // which would renew `allCommands`, cascading down and resetting state. + // + // This means that the commands won't update on appState/appProps changes + // while the command palette is open + const { uiAppState, customCommandPaletteItems, appProps } = stableDeps; + + const getActionLabel = (action: Action) => { + let label = ""; + if (action.label) { + if (typeof action.label === "function") { + label = t( + action.label( + app.scene.getNonDeletedElements(), + uiAppState as AppState, + app, + ) as unknown as TranslationKeys, + ); + } else { + label = t(action.label as unknown as TranslationKeys); + } + } + return label; + }; + + const getActionIcon = (action: Action) => { + if (typeof action.icon === "function") { + return action.icon(uiAppState, app.scene.getNonDeletedElements()); + } + return action.icon; + }; + + let commandsFromActions: CommandPaletteItem[] = []; + + const actionToCommand = ( + action: Action, + category: string, + transformer?: ( + command: CommandPaletteItem, + action: Action, + ) => CommandPaletteItem, + ): CommandPaletteItem => { + const command: CommandPaletteItem = { + label: getActionLabel(action), + icon: getActionIcon(action), + category, + shortcut: getShortcutFromShortcutName(action.name as ShortcutName), + keywords: action.keywords, + predicate: action.predicate, + viewMode: action.viewMode, + perform: () => { + actionManager.executeAction(action, "commandPalette"); + }, + }; + + return transformer ? transformer(command, action) : command; + }; + + if (uiAppState && app.scene && actionManager) { + const elementsCommands: CommandPaletteItem[] = [ + actionManager.actions.group, + actionManager.actions.ungroup, + actionManager.actions.cut, + actionManager.actions.copy, + actionManager.actions.deleteSelectedElements, + actionManager.actions.copyStyles, + actionManager.actions.pasteStyles, + actionManager.actions.bringToFront, + actionManager.actions.bringForward, + actionManager.actions.sendBackward, + actionManager.actions.sendToBack, + actionManager.actions.alignTop, + actionManager.actions.alignBottom, + actionManager.actions.alignLeft, + actionManager.actions.alignRight, + actionManager.actions.alignVerticallyCentered, + actionManager.actions.alignHorizontallyCentered, + actionManager.actions.duplicateSelection, + actionManager.actions.flipHorizontal, + actionManager.actions.flipVertical, + actionManager.actions.zoomToFitSelection, + actionManager.actions.zoomToFitSelectionInViewport, + actionManager.actions.increaseFontSize, + actionManager.actions.decreaseFontSize, + actionManager.actions.toggleLinearEditor, + actionLink, + ].map((action: Action) => + actionToCommand( + action, + DEFAULT_CATEGORIES.elements, + (command, action) => ({ + ...command, + predicate: action.predicate + ? action.predicate + : (elements, appState, appProps, app) => { + const selectedElements = getSelectedElements( + elements, + appState, + ); + return selectedElements.length > 0; + }, + }), + ), + ); + const toolCommands: CommandPaletteItem[] = [ + actionManager.actions.toggleHandTool, + actionManager.actions.setFrameAsActiveTool, + ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools)); + + const editorCommands: CommandPaletteItem[] = [ + actionManager.actions.undo, + actionManager.actions.redo, + actionManager.actions.zoomIn, + actionManager.actions.zoomOut, + actionManager.actions.resetZoom, + actionManager.actions.zoomToFit, + actionManager.actions.zenMode, + actionManager.actions.viewMode, + actionManager.actions.gridMode, + actionManager.actions.objectsSnapMode, + actionManager.actions.toggleShortcuts, + actionManager.actions.selectAll, + actionManager.actions.toggleElementLock, + actionManager.actions.unlockAllElements, + actionManager.actions.stats, + ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.editor)); + + const exportCommands: CommandPaletteItem[] = [ + actionManager.actions.saveToActiveFile, + actionManager.actions.saveFileToDisk, + actionManager.actions.copyAsPng, + actionManager.actions.copyAsSvg, + ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.export)); + + commandsFromActions = [ + ...elementsCommands, + ...editorCommands, + { + label: getActionLabel(actionClearCanvas), + icon: getActionIcon(actionClearCanvas), + shortcut: getShortcutFromShortcutName( + actionClearCanvas.name as ShortcutName, + ), + category: DEFAULT_CATEGORIES.editor, + keywords: ["delete", "destroy"], + viewMode: false, + perform: () => { + jotaiStore.set(activeConfirmDialogAtom, "clearCanvas"); + }, + }, + { + label: t("buttons.exportImage"), + category: DEFAULT_CATEGORIES.export, + icon: ExportImageIcon, + shortcut: getShortcutFromShortcutName("imageExport"), + keywords: [ + "export", + "image", + "png", + "jpeg", + "svg", + "clipboard", + "picture", + ], + perform: () => { + setAppState({ openDialog: { name: "imageExport" } }); + }, + }, + ...exportCommands, + ]; + + const additionalCommands: CommandPaletteItem[] = [ + { + label: t("toolBar.library"), + category: DEFAULT_CATEGORIES.app, + icon: LibraryIcon, + viewMode: false, + perform: () => { + if (uiAppState.openSidebar) { + setAppState({ + openSidebar: null, + }); + } else { + setAppState({ + openSidebar: { + name: DEFAULT_SIDEBAR.name, + tab: DEFAULT_SIDEBAR.defaultTab, + }, + }); + } + }, + }, + { + label: t("labels.changeStroke"), + keywords: ["color", "outline"], + category: DEFAULT_CATEGORIES.elements, + icon: bucketFillIcon, + viewMode: false, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length > 0 && + canChangeStrokeColor(appState, selectedElements) + ); + }, + perform: () => { + setAppState((prevState) => ({ + openMenu: prevState.openMenu === "shape" ? null : "shape", + openPopup: "elementStroke", + })); + }, + }, + { + label: t("labels.changeBackground"), + keywords: ["color", "fill"], + icon: bucketFillIcon, + category: DEFAULT_CATEGORIES.elements, + viewMode: false, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length > 0 && + canChangeBackgroundColor(appState, selectedElements) + ); + }, + perform: () => { + setAppState((prevState) => ({ + openMenu: prevState.openMenu === "shape" ? null : "shape", + openPopup: "elementBackground", + })); + }, + }, + { + label: t("labels.canvasBackground"), + keywords: ["color"], + icon: bucketFillIcon, + category: DEFAULT_CATEGORIES.editor, + viewMode: false, + perform: () => { + setAppState((prevState) => ({ + openMenu: prevState.openMenu === "canvas" ? null : "canvas", + openPopup: "canvasBackground", + })); + }, + }, + ...SHAPES.reduce((acc: CommandPaletteItem[], shape) => { + const { value, icon, key, numericKey } = shape; + + if ( + appProps.UIOptions.tools?.[ + value as Extract< + typeof value, + keyof AppProps["UIOptions"]["tools"] + > + ] === false + ) { + return acc; + } + + const letter = + key && capitalizeString(typeof key === "string" ? key : key[0]); + const shortcut = letter || numericKey; + + const command: CommandPaletteItem = { + label: t(`toolBar.${value}`), + category: DEFAULT_CATEGORIES.tools, + shortcut, + icon, + keywords: ["toolbar"], + viewMode: false, + perform: ({ event }) => { + if (value === "image") { + app.setActiveTool({ + type: value, + insertOnCanvasDirectly: event.type === EVENT.KEYDOWN, + }); + } else { + app.setActiveTool({ type: value }); + } + }, + }; + + acc.push(command); + + return acc; + }, []), + ...toolCommands, + { + label: t("toolBar.lock"), + category: DEFAULT_CATEGORIES.tools, + icon: uiAppState.activeTool.locked ? LockedIcon : UnlockedIcon, + shortcut: KEYS.Q.toLocaleUpperCase(), + viewMode: false, + perform: () => { + app.toggleLock(); + }, + }, + { + label: `${t("labels.textToDiagram")}...`, + category: DEFAULT_CATEGORIES.tools, + icon: brainIconThin, + viewMode: false, + predicate: appProps.aiEnabled, + perform: () => { + setAppState((state) => ({ + ...state, + openDialog: { + name: "ttd", + tab: "text-to-diagram", + }, + })); + }, + }, + { + label: `${t("toolBar.mermaidToExcalidraw")}...`, + category: DEFAULT_CATEGORIES.tools, + icon: mermaidLogoIcon, + viewMode: false, + predicate: appProps.aiEnabled, + perform: () => { + setAppState((state) => ({ + ...state, + openDialog: { + name: "ttd", + tab: "mermaid", + }, + })); + }, + }, + // { + // label: `${t("toolBar.magicframe")}...`, + // category: DEFAULT_CATEGORIES.tools, + // icon: MagicIconThin, + // viewMode: false, + // predicate: appProps.aiEnabled, + // perform: () => { + // app.onMagicframeToolSelect(); + // }, + // }, + ]; + + const allCommands = [ + ...commandsFromActions, + ...additionalCommands, + ...(customCommandPaletteItems || []), + ].map((command) => { + return { + ...command, + icon: command.icon || boltIcon, + order: command.order ?? getCategoryOrder(command.category), + haystack: `${deburr(command.label)} ${ + command.keywords?.join(" ") || "" + }`, + }; + }); + + setAllCommands(allCommands); + setLastUsed( + allCommands.find((command) => command.label === lastUsed?.label) ?? + null, + ); + } + }, [ + stableDeps, + app, + actionManager, + setAllCommands, + lastUsed?.label, + setLastUsed, + setAppState, + ]); + + const [commandSearch, setCommandSearch] = useState(""); + const [currentCommand, setCurrentCommand] = + useState(null); + const [commandsByCategory, setCommandsByCategory] = useState< + Record + >({}); + + const closeCommandPalette = (cb?: () => void) => { + setAppState( + { + openDialog: null, + }, + cb, + ); + setCommandSearch(""); + }; + + const executeCommand = ( + command: CommandPaletteItem, + event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent, + ) => { + if (uiAppState.openDialog?.name === "commandPalette") { + event.stopPropagation(); + event.preventDefault(); + document.body.classList.add("excalidraw-animations-disabled"); + closeCommandPalette(() => { + command.perform({ actionManager, event }); + setLastUsed(command); + + requestAnimationFrame(() => { + document.body.classList.remove("excalidraw-animations-disabled"); + }); + }); + } + }; + + const isCommandAvailable = useStableCallback( + (command: CommandPaletteItem) => { + if (command.viewMode === false && uiAppState.viewModeEnabled) { + return false; + } + + return typeof command.predicate === "function" + ? command.predicate( + app.scene.getNonDeletedElements(), + uiAppState as AppState, + appProps, + app, + ) + : command.predicate === undefined || command.predicate; + }, + ); + + const handleKeyDown = useStableCallback((event: KeyboardEvent) => { + const ignoreAlphanumerics = + isWritableElement(event.target) || + isCommandPaletteToggleShortcut(event) || + event.key === KEYS.ESCAPE; + + if ( + ignoreAlphanumerics && + event.key !== KEYS.ARROW_UP && + event.key !== KEYS.ARROW_DOWN && + event.key !== KEYS.ENTER + ) { + return; + } + + const matchingCommands = Object.values(commandsByCategory).flat(); + const shouldConsiderLastUsed = + lastUsed && !commandSearch && isCommandAvailable(lastUsed); + + if (event.key === KEYS.ARROW_UP) { + event.preventDefault(); + const index = matchingCommands.findIndex( + (item) => item.label === currentCommand?.label, + ); + + if (shouldConsiderLastUsed) { + if (index === 0) { + setCurrentCommand(lastUsed); + return; + } + + if (currentCommand === lastUsed) { + const nextItem = matchingCommands[matchingCommands.length - 1]; + if (nextItem) { + setCurrentCommand(nextItem); + } + return; + } + } + + let nextIndex; + + if (index === -1) { + nextIndex = matchingCommands.length - 1; + } else { + nextIndex = + index === 0 + ? matchingCommands.length - 1 + : (index - 1) % matchingCommands.length; + } + + const nextItem = matchingCommands[nextIndex]; + if (nextItem) { + setCurrentCommand(nextItem); + } + + return; + } + + if (event.key === KEYS.ARROW_DOWN) { + event.preventDefault(); + const index = matchingCommands.findIndex( + (item) => item.label === currentCommand?.label, + ); + + if (shouldConsiderLastUsed) { + if (!currentCommand || index === matchingCommands.length - 1) { + setCurrentCommand(lastUsed); + return; + } + + if (currentCommand === lastUsed) { + const nextItem = matchingCommands[0]; + if (nextItem) { + setCurrentCommand(nextItem); + } + return; + } + } + + const nextIndex = (index + 1) % matchingCommands.length; + const nextItem = matchingCommands[nextIndex]; + if (nextItem) { + setCurrentCommand(nextItem); + } + + return; + } + + if (event.key === KEYS.ENTER) { + if (currentCommand) { + setTimeout(() => { + executeCommand(currentCommand, event); + }); + } + } + + if (ignoreAlphanumerics) { + return; + } + + // prevent regular editor shortcuts + event.stopPropagation(); + + // if alphanumeric keypress and we're not inside the input, focus it + if (/^[a-zA-Z0-9]$/.test(event.key)) { + inputRef?.current?.focus(); + return; + } + + event.preventDefault(); + }); + + useEffect(() => { + window.addEventListener(EVENT.KEYDOWN, handleKeyDown, { + capture: true, + }); + return () => + window.removeEventListener(EVENT.KEYDOWN, handleKeyDown, { + capture: true, + }); + }, [handleKeyDown]); + + useEffect(() => { + if (!allCommands) { + return; + } + + const getNextCommandsByCategory = (commands: CommandPaletteItem[]) => { + const nextCommandsByCategory: Record = {}; + for (const command of commands) { + if (nextCommandsByCategory[command.category]) { + nextCommandsByCategory[command.category].push(command); + } else { + nextCommandsByCategory[command.category] = [command]; + } + } + + return nextCommandsByCategory; + }; + + let matchingCommands = allCommands + .filter(isCommandAvailable) + .sort((a, b) => a.order - b.order); + + const showLastUsed = + !commandSearch && lastUsed && isCommandAvailable(lastUsed); + + if (!commandSearch) { + setCommandsByCategory( + getNextCommandsByCategory( + showLastUsed + ? matchingCommands.filter( + (command) => command.label !== lastUsed?.label, + ) + : matchingCommands, + ), + ); + setCurrentCommand(showLastUsed ? lastUsed : matchingCommands[0] || null); + return; + } + + const _query = deburr(commandSearch.replace(/[<>-_| ]/g, "")); + matchingCommands = fuzzy + .filter(_query, matchingCommands, { + extract: (command) => command.haystack, + }) + .sort((a, b) => b.score - a.score) + .map((item) => item.original); + + setCommandsByCategory(getNextCommandsByCategory(matchingCommands)); + setCurrentCommand(matchingCommands[0] ?? null); + }, [commandSearch, allCommands, isCommandAvailable, lastUsed]); + + return ( + closeCommandPalette()} + closeOnClickOutside + title={false} + size={720} + autofocus + className="command-palette-dialog" + > + { + setCommandSearch(value); + }} + selectOnRender + ref={inputRef} + /> + + {!app.device.viewport.isMobile && ( +
+ + {t("commandPalette.shortcuts.select")} + + + {t("commandPalette.shortcuts.confirm")} + + + {t("commandPalette.shortcuts.close")} + +
+ )} + +
+ {lastUsed && !commandSearch && ( +
+
+ {t("commandPalette.recents")} +
+ {clockIcon} +
+
+ executeCommand(lastUsed, event)} + disabled={!isCommandAvailable(lastUsed)} + onMouseMove={() => setCurrentCommand(lastUsed)} + showShortcut={!app.device.viewport.isMobile} + appState={uiAppState} + /> +
+ )} + + {Object.keys(commandsByCategory).length > 0 ? ( + Object.keys(commandsByCategory).map((category, idx) => { + return ( +
+
{category}
+ {commandsByCategory[category].map((command) => ( + executeCommand(command, event)} + onMouseMove={() => setCurrentCommand(command)} + showShortcut={!app.device.viewport.isMobile} + appState={uiAppState} + /> + ))} +
+ ); + }) + ) : allCommands ? ( +
+
{searchIcon}
{" "} + {t("commandPalette.search.noMatch")} +
+ ) : null} +
+
+ ); +} + +const CommandItem = ({ + command, + isSelected, + disabled, + onMouseMove, + onClick, + showShortcut, + appState, +}: { + command: CommandPaletteItem; + isSelected: boolean; + disabled?: boolean; + onMouseMove: () => void; + onClick: (event: React.MouseEvent) => void; + showShortcut: boolean; + appState: UIAppState; +}) => { + const noop = () => {}; + + return ( +
{ + if (isSelected && !disabled) { + ref?.scrollIntoView?.({ + block: "nearest", + }); + } + }} + onClick={disabled ? noop : onClick} + onMouseMove={disabled ? noop : onMouseMove} + title={disabled ? t("commandPalette.itemNotAvailable") : ""} + > +
+ {command.icon && ( + + )} + {command.label} +
+ {showShortcut && command.shortcut && ( + + )} +
+ ); +}; diff --git a/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts b/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts new file mode 100644 index 000000000..dea14ff26 --- /dev/null +++ b/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts @@ -0,0 +1,11 @@ +import { actionToggleTheme } from "../../actions"; +import type { CommandPaletteItem } from "./types"; + +export const toggleTheme: CommandPaletteItem = { + ...actionToggleTheme, + category: "App", + label: "Toggle theme", + perform: ({ actionManager }) => { + actionManager.executeAction(actionToggleTheme, "commandPalette"); + }, +}; diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts new file mode 100644 index 000000000..957d69927 --- /dev/null +++ b/packages/excalidraw/components/CommandPalette/types.ts @@ -0,0 +1,26 @@ +import type { ActionManager } from "../../actions/manager"; +import type { Action } from "../../actions/types"; +import type { UIAppState } from "../../types"; + +export type CommandPaletteItem = { + label: string; + /** additional keywords to match against + * (appended to haystack, not displayed) */ + keywords?: string[]; + /** + * string we should match against when searching + * (deburred name + keywords) + */ + haystack?: string; + icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode); + category: string; + order?: number; + predicate?: boolean | Action["predicate"]; + shortcut?: string; + /** if false, command will not show while in view mode */ + viewMode?: boolean; + perform: (data: { + actionManager: ActionManager; + event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent; + }) => void; +}; diff --git a/packages/excalidraw/components/ConfirmDialog.tsx b/packages/excalidraw/components/ConfirmDialog.tsx index 9061fefa0..2bda72e2c 100644 --- a/packages/excalidraw/components/ConfirmDialog.tsx +++ b/packages/excalidraw/components/ConfirmDialog.tsx @@ -1,5 +1,6 @@ import { t } from "../i18n"; -import { Dialog, DialogProps } from "./Dialog"; +import type { DialogProps } from "./Dialog"; +import { Dialog } from "./Dialog"; import "./ConfirmDialog.scss"; import DialogActionButton from "./DialogActionButton"; diff --git a/packages/excalidraw/components/ContextMenu.tsx b/packages/excalidraw/components/ContextMenu.tsx index ebabae83b..7353c56c6 100644 --- a/packages/excalidraw/components/ContextMenu.tsx +++ b/packages/excalidraw/components/ContextMenu.tsx @@ -1,14 +1,13 @@ import clsx from "clsx"; import { Popover } from "./Popover"; -import { t, TranslationKeys } from "../i18n"; +import type { TranslationKeys } from "../i18n"; +import { t } from "../i18n"; import "./ContextMenu.scss"; -import { - getShortcutFromShortcutName, - ShortcutName, -} from "../actions/shortcuts"; -import { Action } from "../actions/types"; -import { ActionManager } from "../actions/manager"; +import type { ShortcutName } from "../actions/shortcuts"; +import { getShortcutFromShortcutName } from "../actions/shortcuts"; +import type { Action } from "../actions/types"; +import type { ActionManager } from "../actions/manager"; import { useExcalidrawAppState, useExcalidrawElements } from "./App"; import React from "react"; @@ -78,17 +77,17 @@ export const ContextMenu = React.memo( const actionName = item.name; let label = ""; - if (item.contextItemLabel) { - if (typeof item.contextItemLabel === "function") { + if (item.label) { + if (typeof item.label === "function") { label = t( - item.contextItemLabel( + item.label( elements, appState, actionManager.app, ) as unknown as TranslationKeys, ); } else { - label = t(item.contextItemLabel as unknown as TranslationKeys); + label = t(item.label as unknown as TranslationKeys); } } diff --git a/packages/excalidraw/components/DarkModeToggle.tsx b/packages/excalidraw/components/DarkModeToggle.tsx index 8a4bf261d..6292ba5ed 100644 --- a/packages/excalidraw/components/DarkModeToggle.tsx +++ b/packages/excalidraw/components/DarkModeToggle.tsx @@ -3,7 +3,7 @@ import "./ToolIcon.scss"; import { t } from "../i18n"; import { ToolButton } from "./ToolButton"; import { THEME } from "../constants"; -import { Theme } from "../element/types"; +import type { Theme } from "../element/types"; // We chose to use only explicit toggle and not a third option for system value, // but this could be added in the future. @@ -14,7 +14,9 @@ export const DarkModeToggle = (props: { }) => { const title = props.title || - (props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")); + (props.value === THEME.DARK + ? t("buttons.lightMode") + : t("buttons.darkMode")); return ( { const focusableElements = queryFocusableElements(islandNode); - if (focusableElements.length > 0 && props.autofocus !== false) { - // If there's an element other than close, focus it. - (focusableElements[1] || focusableElements[0]).focus(); - } + setTimeout(() => { + if (focusableElements.length > 0 && props.autofocus !== false) { + // If there's an element other than close, focus it. + (focusableElements[1] || focusableElements[0]).focus(); + } + }); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === KEYS.TAB) { @@ -115,14 +117,16 @@ export const Dialog = (props: DialogProps) => { {props.title} )} - + {isFullscreen && ( + + )}
{props.children}
diff --git a/packages/excalidraw/components/DialogActionButton.tsx b/packages/excalidraw/components/DialogActionButton.tsx index 17f202362..0c4f9d589 100644 --- a/packages/excalidraw/components/DialogActionButton.tsx +++ b/packages/excalidraw/components/DialogActionButton.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import "./DialogActionButton.scss"; import Spinner from "./Spinner"; diff --git a/packages/excalidraw/components/EyeDropper.tsx b/packages/excalidraw/components/EyeDropper.tsx index 9cc3f90f7..f07697662 100644 --- a/packages/excalidraw/components/EyeDropper.tsx +++ b/packages/excalidraw/components/EyeDropper.tsx @@ -12,8 +12,8 @@ import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App"; import { useStable } from "../hooks/useStable"; import "./EyeDropper.scss"; -import { ColorPickerType } from "./ColorPicker/colorPickerUtils"; -import { ExcalidrawElement } from "../element/types"; +import type { ColorPickerType } from "./ColorPicker/colorPickerUtils"; +import type { ExcalidrawElement } from "../element/types"; export type EyeDropperProperties = { keepOpenOnAlt: boolean; diff --git a/packages/excalidraw/components/FilledButton.scss b/packages/excalidraw/components/FilledButton.scss index 70f75cbbb..d23c9d104 100644 --- a/packages/excalidraw/components/FilledButton.scss +++ b/packages/excalidraw/components/FilledButton.scss @@ -10,6 +10,10 @@ background-color: var(--back-color); border-color: var(--border-color); + &:hover { + transition: all 150ms ease-out; + } + .Spinner { --spinner-color: var(--color-surface-lowest); position: absolute; @@ -203,8 +207,6 @@ user-select: none; - transition: all 150ms ease-out; - &--size-large { font-weight: 600; font-size: 0.875rem; diff --git a/packages/excalidraw/components/FollowMode/FollowMode.tsx b/packages/excalidraw/components/FollowMode/FollowMode.tsx index dc1746ca8..302f9d73e 100644 --- a/packages/excalidraw/components/FollowMode/FollowMode.tsx +++ b/packages/excalidraw/components/FollowMode/FollowMode.tsx @@ -1,4 +1,4 @@ -import { UserToFollow } from "../../types"; +import type { UserToFollow } from "../../types"; import { CloseIcon } from "../icons"; import "./FollowMode.scss"; diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index 961158c0c..c362889b3 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -4,9 +4,10 @@ import { KEYS } from "../keys"; import { Dialog } from "./Dialog"; import { getShortcutKey } from "../utils"; import "./HelpDialog.scss"; -import { ExternalLinkIcon } from "./icons"; +import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons"; import { probablySupportsClipboardBlob } from "../clipboard"; import { isDarwin, isFirefox, isWindows } from "../constants"; +import { getShortcutFromShortcutName } from "../actions/shortcuts"; const Header = () => (
@@ -16,8 +17,8 @@ const Header = () => ( target="_blank" rel="noopener noreferrer" > - {t("helpDialog.documentation")}
{ExternalLinkIcon}
+ {t("helpDialog.documentation")} ( target="_blank" rel="noopener noreferrer" > - {t("helpDialog.blog")}
{ExternalLinkIcon}
+ {t("helpDialog.blog")}
( target="_blank" rel="noopener noreferrer" > +
{GithubIcon}
{t("helpDialog.github")} -
{ExternalLinkIcon}
+
+ +
{youtubeIcon}
+ YouTube
); @@ -263,7 +273,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { shortcuts={[getShortcutKey("Alt+S")]} /> void }) => { label={t("stats.title")} shortcuts={[getShortcutKey("Alt+/")]} /> + { const hasSelection = isSomeElementSelected( elementsSnapshot, appStateSnapshot, ); - const appProps = useAppProps(); - const [projectName, setProjectName] = useState(appStateSnapshot.name); + const [projectName, setProjectName] = useState(name); const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection); const [exportWithBackground, setExportWithBackground] = useState( appStateSnapshot.exportBackground, @@ -124,9 +124,16 @@ const ImageExportModal = ({ setRenderError(null); // if converting to blob fails, there's some problem that will // likely prevent preview and export (e.g. canvas too big) - return canvasToBlob(canvas).then(() => { - previewNode.replaceChildren(canvas); - }); + return canvasToBlob(canvas) + .then(() => { + previewNode.replaceChildren(canvas); + }) + .catch((e) => { + if (e.name === "CANVAS_POSSIBLY_TOO_BIG") { + throw new Error(t("canvasError.canvasTooBig")); + } + throw e; + }); }) .catch((error) => { console.error(error); @@ -158,10 +165,6 @@ const ImageExportModal = ({ className="TextInput" value={projectName} style={{ width: "30ch" }} - disabled={ - typeof appProps.name !== "undefined" || - appStateSnapshot.viewModeEnabled - } onChange={(event) => { setProjectName(event.target.value); actionManager.executeAction( @@ -347,6 +350,7 @@ export const ImageExportDialog = ({ actionManager, onExportImage, onCloseRequest, + name, }: { appState: UIAppState; elements: readonly NonDeletedExcalidrawElement[]; @@ -354,6 +358,7 @@ export const ImageExportDialog = ({ actionManager: ActionManager; onExportImage: AppClassProperties["onExportImage"]; onCloseRequest: () => void; + name: string; }) => { // we need to take a snapshot so that the exported state can't be modified // while the dialog is open @@ -372,6 +377,7 @@ export const ImageExportDialog = ({ files={files} actionManager={actionManager} onExportImage={onExportImage} + name={name} /> ); diff --git a/packages/excalidraw/components/InitializeApp.tsx b/packages/excalidraw/components/InitializeApp.tsx index af4961fa1..41f90ceeb 100644 --- a/packages/excalidraw/components/InitializeApp.tsx +++ b/packages/excalidraw/components/InitializeApp.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from "react"; import { LoadingMessage } from "./LoadingMessage"; -import { defaultLang, Language, languages, setLanguage } from "../i18n"; -import { Theme } from "../element/types"; +import type { Language } from "../i18n"; +import { defaultLang, languages, setLanguage } from "../i18n"; +import type { Theme } from "../element/types"; interface Props { langCode: Language["code"]; diff --git a/packages/excalidraw/components/InlineIcon.tsx b/packages/excalidraw/components/InlineIcon.tsx index 7d967232d..75cc29d08 100644 --- a/packages/excalidraw/components/InlineIcon.tsx +++ b/packages/excalidraw/components/InlineIcon.tsx @@ -1,4 +1,4 @@ -export const InlineIcon = ({ icon }: { icon: JSX.Element }) => { +export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => { return ( * { + pointer-events: var(--ui-pointerEvents); + } } &__footer { diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 068c7f93c..54ea9a8f3 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import React from "react"; -import { ActionManager } from "../actions/manager"; +import type { ActionManager } from "../actions/manager"; import { CLASSES, DEFAULT_SIDEBAR, @@ -8,10 +8,11 @@ import { TOOL_TYPE, } from "../constants"; import { showSelectedShapeActions } from "../element"; -import { NonDeletedExcalidrawElement } from "../element/types"; -import { Language, t } from "../i18n"; +import type { NonDeletedExcalidrawElement } from "../element/types"; +import type { Language } from "../i18n"; +import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; -import { +import type { AppProps, AppState, ExcalidrawProps, @@ -196,6 +197,7 @@ const LayerUI = ({ actionManager={actionManager} onExportImage={onExportImage} onCloseRequest={() => setAppState({ openDialog: null })} + name={app.getName()} /> ); }; diff --git a/packages/excalidraw/components/LibraryMenu.tsx b/packages/excalidraw/components/LibraryMenu.tsx index 71e20ff33..6192c7e71 100644 --- a/packages/excalidraw/components/LibraryMenu.tsx +++ b/packages/excalidraw/components/LibraryMenu.tsx @@ -1,11 +1,12 @@ import React, { useState, useCallback, useMemo, useRef } from "react"; -import Library, { +import type Library from "../data/library"; +import { distributeLibraryItemsOnSquareGrid, libraryItemsAtom, } from "../data/library"; import { t } from "../i18n"; import { randomId } from "../random"; -import { +import type { LibraryItems, LibraryItem, ExcalidrawProps, @@ -28,7 +29,7 @@ import { useUIAppState } from "../context/ui-appState"; import "./LibraryMenu.scss"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { isShallowEqual } from "../utils"; -import { NonDeletedExcalidrawElement } from "../element/types"; +import type { NonDeletedExcalidrawElement } from "../element/types"; import { LIBRARY_DISABLED_TYPES } from "../constants"; export const isLibraryMenuOpenAtom = atom(false); diff --git a/packages/excalidraw/components/LibraryMenuBrowseButton.tsx b/packages/excalidraw/components/LibraryMenuBrowseButton.tsx index ab8fa0305..43fbedd77 100644 --- a/packages/excalidraw/components/LibraryMenuBrowseButton.tsx +++ b/packages/excalidraw/components/LibraryMenuBrowseButton.tsx @@ -1,6 +1,6 @@ import { VERSIONS } from "../constants"; import { t } from "../i18n"; -import { ExcalidrawProps, UIAppState } from "../types"; +import type { ExcalidrawProps, UIAppState } from "../types"; const LibraryMenuBrowseButton = ({ theme, diff --git a/packages/excalidraw/components/LibraryMenuControlButtons.tsx b/packages/excalidraw/components/LibraryMenuControlButtons.tsx index 86ac1b635..b467ca39c 100644 --- a/packages/excalidraw/components/LibraryMenuControlButtons.tsx +++ b/packages/excalidraw/components/LibraryMenuControlButtons.tsx @@ -1,4 +1,4 @@ -import { ExcalidrawProps, UIAppState } from "../types"; +import type { ExcalidrawProps, UIAppState } from "../types"; import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton"; import clsx from "clsx"; diff --git a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx index 56c8fe6d0..e26507803 100644 --- a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx +++ b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx @@ -2,10 +2,11 @@ import { useCallback, useState } from "react"; import { t } from "../i18n"; import Trans from "./Trans"; import { jotaiScope } from "../jotai"; -import { LibraryItem, LibraryItems, UIAppState } from "../types"; +import type { LibraryItem, LibraryItems, UIAppState } from "../types"; import { useApp, useExcalidrawSetAppState } from "./App"; import { saveLibraryAsJSON } from "../data/json"; -import Library, { libraryItemsAtom } from "../data/library"; +import type Library from "../data/library"; +import { libraryItemsAtom } from "../data/library"; import { DotsIcon, ExportIcon, diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index ff88e537c..aa2c3e68e 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -7,7 +7,7 @@ import React, { } from "react"; import { serializeLibraryAsJSON } from "../data/json"; import { t } from "../i18n"; -import { +import type { ExcalidrawProps, LibraryItem, LibraryItems, diff --git a/packages/excalidraw/components/LibraryMenuSection.tsx b/packages/excalidraw/components/LibraryMenuSection.tsx index 0e10470fc..b07d6b1cc 100644 --- a/packages/excalidraw/components/LibraryMenuSection.tsx +++ b/packages/excalidraw/components/LibraryMenuSection.tsx @@ -1,8 +1,9 @@ -import React, { memo, ReactNode, useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import React, { memo, useEffect, useState } from "react"; import { EmptyLibraryUnit, LibraryUnit } from "./LibraryUnit"; -import { LibraryItem } from "../types"; -import { ExcalidrawElement, NonDeleted } from "../element/types"; -import { SvgCache } from "../hooks/useLibraryItemSvg"; +import type { LibraryItem } from "../types"; +import type { ExcalidrawElement, NonDeleted } from "../element/types"; +import type { SvgCache } from "../hooks/useLibraryItemSvg"; import { useTransition } from "../hooks/useTransition"; type LibraryOrPendingItem = ( diff --git a/packages/excalidraw/components/LibraryUnit.tsx b/packages/excalidraw/components/LibraryUnit.tsx index 42fb29149..71e1a00f5 100644 --- a/packages/excalidraw/components/LibraryUnit.tsx +++ b/packages/excalidraw/components/LibraryUnit.tsx @@ -1,11 +1,12 @@ import clsx from "clsx"; import { memo, useEffect, useRef, useState } from "react"; import { useDevice } from "./App"; -import { LibraryItem } from "../types"; +import type { LibraryItem } from "../types"; import "./LibraryUnit.scss"; import { CheckboxItem } from "./CheckboxItem"; import { PlusIcon } from "./icons"; -import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; +import type { SvgCache } from "../hooks/useLibraryItemSvg"; +import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; export const LibraryUnit = memo( ({ diff --git a/packages/excalidraw/components/LoadingMessage.tsx b/packages/excalidraw/components/LoadingMessage.tsx index 124396151..b00725257 100644 --- a/packages/excalidraw/components/LoadingMessage.tsx +++ b/packages/excalidraw/components/LoadingMessage.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import Spinner from "./Spinner"; import clsx from "clsx"; import { THEME } from "../constants"; -import { Theme } from "../element/types"; +import type { Theme } from "../element/types"; export const LoadingMessage: React.FC<{ delay?: number; theme?: Theme }> = ({ delay, diff --git a/packages/excalidraw/components/LockButton.tsx b/packages/excalidraw/components/LockButton.tsx index a039a5779..f7913d12d 100644 --- a/packages/excalidraw/components/LockButton.tsx +++ b/packages/excalidraw/components/LockButton.tsx @@ -1,7 +1,7 @@ import "./ToolIcon.scss"; import clsx from "clsx"; -import { ToolButtonSize } from "./ToolButton"; +import type { ToolButtonSize } from "./ToolButton"; import { LockedIcon, UnlockedIcon } from "./icons"; type LockIconProps = { diff --git a/packages/excalidraw/components/MagicButton.tsx b/packages/excalidraw/components/MagicButton.tsx index b8aad5bfd..2a7e834eb 100644 --- a/packages/excalidraw/components/MagicButton.tsx +++ b/packages/excalidraw/components/MagicButton.tsx @@ -1,7 +1,7 @@ import "./ToolIcon.scss"; import clsx from "clsx"; -import { ToolButtonSize } from "./ToolButton"; +import type { ToolButtonSize } from "./ToolButton"; const DEFAULT_SIZE: ToolButtonSize = "small"; diff --git a/packages/excalidraw/components/MagicSettings.tsx b/packages/excalidraw/components/MagicSettings.tsx index af4760376..855ab109d 100644 --- a/packages/excalidraw/components/MagicSettings.tsx +++ b/packages/excalidraw/components/MagicSettings.tsx @@ -53,8 +53,8 @@ export const MagicSettings = (props: { marginLeft: "1rem", fontSize: 14, borderRadius: "12px", - color: "#000", - background: "pink", + background: "var(--color-promo)", + color: "var(--color-surface-lowest)", }} > Experimental diff --git a/packages/excalidraw/components/MobileMenu.tsx b/packages/excalidraw/components/MobileMenu.tsx index c8d0ce5c4..bcb7e3b94 100644 --- a/packages/excalidraw/components/MobileMenu.tsx +++ b/packages/excalidraw/components/MobileMenu.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { +import type { AppClassProperties, AppProps, AppState, @@ -7,11 +7,11 @@ import { ExcalidrawProps, UIAppState, } from "../types"; -import { ActionManager } from "../actions/manager"; +import type { ActionManager } from "../actions/manager"; import { t } from "../i18n"; import Stack from "./Stack"; import { showSelectedShapeActions } from "../element"; -import { NonDeletedExcalidrawElement } from "../element/types"; +import type { NonDeletedExcalidrawElement } from "../element/types"; import { FixedSideContainer } from "./FixedSideContainer"; import { Island } from "./Island"; import { HintViewer } from "./HintViewer"; diff --git a/packages/excalidraw/components/Modal.scss b/packages/excalidraw/components/Modal.scss index a4cf56643..1a355e2e1 100644 --- a/packages/excalidraw/components/Modal.scss +++ b/packages/excalidraw/components/Modal.scss @@ -23,6 +23,20 @@ .Island { padding: 2.5rem; + border: 0; + box-shadow: none; + border-radius: 0; + } + + &.animations-disabled { + .Modal__background { + animation: none; + } + + .Modal__content { + animation: none; + opacity: 1; + } } } @@ -35,7 +49,7 @@ z-index: 1; background-color: rgba(#121212, 0.2); - animation: Modal__background__fade-in 0.125s linear forwards; + animation: Modal__background__fade-in 0.1s linear forwards; } .Modal__content { @@ -47,7 +61,8 @@ opacity: 0; transform: translateY(10px); - animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards; + animation: Modal__content_fade-in 0.025s ease-out 0s forwards; + position: relative; overflow-y: auto; @@ -56,7 +71,7 @@ border: 1px solid var(--dialog-border-color); box-shadow: var(--modal-shadow); - border-radius: 6px; + border-radius: 0.75rem; box-sizing: border-box; &:focus { diff --git a/packages/excalidraw/components/Modal.tsx b/packages/excalidraw/components/Modal.tsx index e02583ef0..e8fff195b 100644 --- a/packages/excalidraw/components/Modal.tsx +++ b/packages/excalidraw/components/Modal.tsx @@ -3,8 +3,9 @@ import "./Modal.scss"; import { createPortal } from "react-dom"; import clsx from "clsx"; import { KEYS } from "../keys"; -import { AppState } from "../types"; +import type { AppState } from "../types"; import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer"; +import { useRef } from "react"; export const Modal: React.FC<{ className?: string; @@ -20,6 +21,10 @@ export const Modal: React.FC<{ className: "excalidraw-modal-container", }); + const animationsDisabledRef = useRef( + document.body.classList.contains("excalidraw-animations-disabled"), + ); + if (!modalRoot) { return null; } @@ -34,7 +39,9 @@ export const Modal: React.FC<{ return createPortal(
isTextElement(el) && - redrawTextBoundingBox(el, getContainerElement(el, elementsMap)), + redrawTextBoundingBox( + el, + getContainerElement(el, elementsMap), + elementsMap, + ), ); setChartElements(elements); }, diff --git a/packages/excalidraw/components/PenModeButton.tsx b/packages/excalidraw/components/PenModeButton.tsx index 7c61d18a2..ca5ceb2b6 100644 --- a/packages/excalidraw/components/PenModeButton.tsx +++ b/packages/excalidraw/components/PenModeButton.tsx @@ -1,7 +1,7 @@ import "./ToolIcon.scss"; import clsx from "clsx"; -import { ToolButtonSize } from "./ToolButton"; +import type { ToolButtonSize } from "./ToolButton"; import { PenModeIcon } from "./icons"; type PenModeIconProps = { diff --git a/packages/excalidraw/components/ProjectName.tsx b/packages/excalidraw/components/ProjectName.tsx index 69ff33527..592961793 100644 --- a/packages/excalidraw/components/ProjectName.tsx +++ b/packages/excalidraw/components/ProjectName.tsx @@ -11,7 +11,6 @@ type Props = { value: string; onChange: (value: string) => void; label: string; - isNameEditable: boolean; ignoreFocus?: boolean; }; @@ -42,23 +41,17 @@ export const ProjectName = (props: Props) => { return (
- {props.isNameEditable ? ( - setFileName(event.target.value)} - /> - ) : ( - - {props.value} - - )} + setFileName(event.target.value)} + />
); }; diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx index 51e14febc..d1cbe63d1 100644 --- a/packages/excalidraw/components/PublishLibrary.tsx +++ b/packages/excalidraw/components/PublishLibrary.tsx @@ -1,11 +1,12 @@ -import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import OpenColor from "open-color"; import { Dialog } from "./Dialog"; import { t } from "../i18n"; import Trans from "./Trans"; -import { LibraryItems, LibraryItem, UIAppState } from "../types"; +import type { LibraryItems, LibraryItem, UIAppState } from "../types"; import { exportToCanvas, exportToSvg } from "../../utils/export"; import { EDITOR_LS_KEYS, @@ -14,7 +15,7 @@ import { MIME_TYPES, VERSIONS, } from "../constants"; -import { ExportedLibraryData } from "../data/types"; +import type { ExportedLibraryData } from "../data/types"; import { canvasToBlob, resizeImageFile } from "../data/blob"; import { chunk } from "../utils"; import DialogActionButton from "./DialogActionButton"; diff --git a/packages/excalidraw/components/RadioGroup.tsx b/packages/excalidraw/components/RadioGroup.tsx index 40c6551f1..64d4d587d 100644 --- a/packages/excalidraw/components/RadioGroup.tsx +++ b/packages/excalidraw/components/RadioGroup.tsx @@ -3,7 +3,8 @@ import "./RadioGroup.scss"; export type RadioGroupChoice = { value: T; - label: string; + label: React.ReactNode; + ariaLabel?: string; }; export type RadioGroupProps = { @@ -26,13 +27,15 @@ export const RadioGroup = function ({ className={clsx("RadioGroup__choice", { active: choice.value === value, })} - key={choice.label} + key={String(choice.value)} + title={choice.ariaLabel} > onChange(choice.value)} + aria-label={choice.ariaLabel} /> {choice.label}
diff --git a/packages/excalidraw/components/SVGLayer.scss b/packages/excalidraw/components/SVGLayer.scss index 5eb0353aa..1ab5a6347 100644 --- a/packages/excalidraw/components/SVGLayer.scss +++ b/packages/excalidraw/components/SVGLayer.scss @@ -1,3 +1,5 @@ +@import "../css/variables.module.scss"; + .excalidraw { .SVGLayer { pointer-events: none; @@ -7,7 +9,7 @@ top: 0; left: 0; - z-index: 2; + z-index: var(--zIndex-svgLayer); & svg { image-rendering: auto; diff --git a/packages/excalidraw/components/SVGLayer.tsx b/packages/excalidraw/components/SVGLayer.tsx index feaebaf94..667b89ab7 100644 --- a/packages/excalidraw/components/SVGLayer.tsx +++ b/packages/excalidraw/components/SVGLayer.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { Trail } from "../animated-trail"; +import type { Trail } from "../animated-trail"; import "./SVGLayer.scss"; diff --git a/packages/excalidraw/components/ShareableLinkDialog.tsx b/packages/excalidraw/components/ShareableLinkDialog.tsx index cb8ba4cef..145cc21b5 100644 --- a/packages/excalidraw/components/ShareableLinkDialog.tsx +++ b/packages/excalidraw/components/ShareableLinkDialog.tsx @@ -31,19 +31,18 @@ export const ShareableLinkDialog = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(link); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - setErrorMessage(error.message); + } catch (e) { + setErrorMessage(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); ref.current?.select(); }; diff --git a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx index 9787f9a73..6b60418b5 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx @@ -85,7 +85,7 @@ describe("Sidebar", () => { }); }); - it("should toggle sidebar using props.toggleMenu()", async () => { + it("should toggle sidebar using excalidrawAPI.toggleSidebar()", async () => { const { container } = await render( @@ -158,6 +158,20 @@ describe("Sidebar", () => { const sidebars = container.querySelectorAll(".sidebar"); expect(sidebars.length).toBe(1); }); + + // closing sidebar using `{ name: null }` + // ------------------------------------------------------------------------- + expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true); + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).not.toBe(null); + }); + + expect(window.h.app.toggleSidebar({ name: null })).toBe(false); + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).toBe(null); + }); }); }); @@ -329,4 +343,70 @@ describe("Sidebar", () => { ); }); }); + + describe("Sidebar.tab", () => { + it("should toggle sidebars tabs correctly", async () => { + const { container } = await render( + + + + Library + Comments + + + , + ); + + await withExcalidrawDimensions( + { width: 1920, height: 1080 }, + async () => { + expect( + container.querySelector( + "[role=tabpanel][data-testid=library]", + ), + ).toBeNull(); + + // open library sidebar + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "library" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=library]", + ), + ).not.toBeNull(); + + // switch to comments tab + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).not.toBeNull(); + + // toggle sidebar closed + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(false); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).toBeNull(); + + // toggle sidebar open + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).not.toBeNull(); + }, + ); + }); + }); }); diff --git a/packages/excalidraw/components/Sidebar/Sidebar.tsx b/packages/excalidraw/components/Sidebar/Sidebar.tsx index ae75f570f..efa6ccbe3 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.tsx @@ -10,11 +10,8 @@ import React, { import { Island } from "../Island"; import { atom, useSetAtom } from "jotai"; import { jotaiScope } from "../../jotai"; -import { - SidebarPropsContext, - SidebarProps, - SidebarPropsContextValue, -} from "./common"; +import type { SidebarProps, SidebarPropsContextValue } from "./common"; +import { SidebarPropsContext } from "./common"; import { SidebarHeader } from "./SidebarHeader"; import clsx from "clsx"; import { useDevice, useExcalidrawSetAppState } from "../App"; diff --git a/packages/excalidraw/components/Sidebar/SidebarTab.tsx b/packages/excalidraw/components/Sidebar/SidebarTab.tsx index 741a69fd1..6fddab0d6 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTab.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTab.tsx @@ -1,5 +1,5 @@ import * as RadixTabs from "@radix-ui/react-tabs"; -import { SidebarTabName } from "../../types"; +import type { SidebarTabName } from "../../types"; export const SidebarTab = ({ tab, @@ -10,7 +10,7 @@ export const SidebarTab = ({ children: React.ReactNode; } & React.HTMLAttributes) => { return ( - + {children} ); diff --git a/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx index cf25f7024..8509ef23d 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx @@ -1,5 +1,5 @@ import * as RadixTabs from "@radix-ui/react-tabs"; -import { SidebarTabName } from "../../types"; +import type { SidebarTabName } from "../../types"; export const SidebarTabTrigger = ({ children, diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx index 889156eba..a26e52d23 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx @@ -1,5 +1,5 @@ import { useExcalidrawSetAppState } from "../App"; -import { SidebarTriggerProps } from "./common"; +import type { SidebarTriggerProps } from "./common"; import { useUIAppState } from "../../context/ui-appState"; import clsx from "clsx"; diff --git a/packages/excalidraw/components/Sidebar/common.ts b/packages/excalidraw/components/Sidebar/common.ts index c7161bd17..05493d44b 100644 --- a/packages/excalidraw/components/Sidebar/common.ts +++ b/packages/excalidraw/components/Sidebar/common.ts @@ -1,5 +1,5 @@ import React from "react"; -import { AppState, SidebarName, SidebarTabName } from "../../types"; +import type { AppState, SidebarName, SidebarTabName } from "../../types"; export type SidebarTriggerProps = { name: SidebarName; diff --git a/packages/excalidraw/components/Stats.tsx b/packages/excalidraw/components/Stats.tsx index eb2b93e7e..a7326f324 100644 --- a/packages/excalidraw/components/Stats.tsx +++ b/packages/excalidraw/components/Stats.tsx @@ -1,9 +1,9 @@ import React from "react"; import { getCommonBounds } from "../element/bounds"; -import { NonDeletedExcalidrawElement } from "../element/types"; +import type { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; import { getTargetElements } from "../scene"; -import { ExcalidrawProps, UIAppState } from "../types"; +import type { ExcalidrawProps, UIAppState } from "../types"; import { CloseIcon } from "./icons"; import { Island } from "./Island"; import "./Stats.scss"; diff --git a/packages/excalidraw/components/Subtypes.tsx b/packages/excalidraw/components/Subtypes.tsx index 09db7f5b7..b2f4be368 100644 --- a/packages/excalidraw/components/Subtypes.tsx +++ b/packages/excalidraw/components/Subtypes.tsx @@ -1,23 +1,23 @@ import { getShortcutKey, updateActiveTool } from "../utils"; import { t } from "../i18n"; -import { Action, makeCustomActionName } from "../actions/types"; +import type { Action } from "../actions/types"; +import { makeCustomActionName } from "../actions/types"; import clsx from "clsx"; +import type { Subtype, SubtypeRecord } from "../element/subtypes"; import { - Subtype, - SubtypeRecord, getSubtypeNames, hasAlwaysEnabledActions, isSubtypeAction, isValidSubtype, subtypeCollides, } from "../element/subtypes"; -import { ExcalidrawElement, Theme } from "../element/types"; +import type { ExcalidrawElement, Theme } from "../element/types"; import { useExcalidrawActionManager, useExcalidrawContainer, useExcalidrawSetAppState, } from "./App"; -import { ContextMenuItems } from "./ContextMenu"; +import type { ContextMenuItems } from "./ContextMenu"; import { Island } from "./Island"; export const SubtypeButton = ( @@ -31,6 +31,7 @@ export const SubtypeButton = ( key !== undefined ? (event) => event.code === `Key${key}` : undefined; const subtypeAction: Action = { name: makeCustomActionName(subtype), + label: t(`toolBar.${subtype}`), trackEvent: false, predicate: (...rest) => rest[4]?.subtype === subtype, perform: (elements, appState) => { @@ -70,7 +71,7 @@ export const SubtypeButton = ( selectedGroupIds, activeTool, }, - commitToHistory: true, + storeAction: "capture", }; }, keyTest, diff --git a/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx index 237cb2606..83fb91d0e 100644 --- a/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx +++ b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx @@ -1,13 +1,13 @@ import { useState, useRef, useEffect, useDeferredValue } from "react"; -import { BinaryFiles } from "../../types"; +import type { BinaryFiles } from "../../types"; import { useApp } from "../App"; -import { NonDeletedExcalidrawElement } from "../../element/types"; +import type { NonDeletedExcalidrawElement } from "../../element/types"; import { ArrowRightIcon } from "../icons"; import "./MermaidToExcalidraw.scss"; import { t } from "../../i18n"; import Trans from "../Trans"; +import type { MermaidToExcalidrawLibProps } from "./common"; import { - MermaidToExcalidrawLibProps, convertMermaidToExcalidraw, insertToEditor, saveMermaidDataToStorage, @@ -18,7 +18,7 @@ import { TTDDialogInput } from "./TTDDialogInput"; import { TTDDialogOutput } from "./TTDDialogOutput"; import { EditorLocalStorage } from "../../data/EditorLocalStorage"; import { EDITOR_LS_KEYS } from "../../constants"; -import { debounce } from "../../utils"; +import { debounce, isDevEnv } from "../../utils"; import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut"; const MERMAID_EXAMPLE = @@ -54,7 +54,11 @@ const MermaidToExcalidraw = ({ mermaidToExcalidrawLib, setError, mermaidDefinition: deferredText, - }).catch(() => {}); + }).catch((err) => { + if (isDevEnv()) { + console.error("Failed to parse mermaid definition", err); + } + }); debouncedSaveMermaidDefinition(deferredText); }, [deferredText, mermaidToExcalidrawLib]); diff --git a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx index c814d3c45..d6192b295 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx @@ -2,7 +2,8 @@ import { Dialog } from "../Dialog"; import { useApp, useExcalidrawSetAppState } from "../App"; import MermaidToExcalidraw from "./MermaidToExcalidraw"; import TTDDialogTabs from "./TTDDialogTabs"; -import { ChangeEventHandler, useEffect, useRef, useState } from "react"; +import type { ChangeEventHandler } from "react"; +import { useEffect, useRef, useState } from "react"; import { useUIAppState } from "../../context/ui-appState"; import { withInternalFallback } from "../hoc/withInternalFallback"; import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers"; @@ -13,14 +14,14 @@ import { TTDDialogInput } from "./TTDDialogInput"; import { TTDDialogOutput } from "./TTDDialogOutput"; import { TTDDialogPanel } from "./TTDDialogPanel"; import { TTDDialogPanels } from "./TTDDialogPanels"; +import type { MermaidToExcalidrawLibProps } from "./common"; import { - MermaidToExcalidrawLibProps, convertMermaidToExcalidraw, insertToEditor, saveMermaidDataToStorage, } from "./common"; -import { NonDeletedExcalidrawElement } from "../../element/types"; -import { BinaryFiles } from "../../types"; +import type { NonDeletedExcalidrawElement } from "../../element/types"; +import type { BinaryFiles } from "../../types"; import { ArrowRightIcon } from "../icons"; import "./TTDDialog.scss"; @@ -253,8 +254,8 @@ export const TTDDialogBase = withInternalFallback( marginLeft: "10px", fontSize: 10, borderRadius: "12px", - background: "pink", - color: "#000", + background: "var(--color-promo)", + color: "var(--color-surface-lowest)", }} > AI Beta diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx index 8ac464f97..e11d0dc3f 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx @@ -1,4 +1,5 @@ -import { ChangeEventHandler, useEffect, useRef } from "react"; +import type { ChangeEventHandler } from "react"; +import { useEffect, useRef } from "react"; import { EVENT } from "../../constants"; import { KEYS } from "../../keys"; diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogPanel.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogPanel.tsx index 5c7fba6da..0a78e4969 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogPanel.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogPanel.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { Button } from "../Button"; import clsx from "clsx"; import Spinner from "../Spinner"; diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogPanels.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogPanels.tsx index 00e573426..0e5e2b5bd 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogPanels.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogPanels.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; export const TTDDialogPanels = ({ children }: { children: ReactNode }) => { return
{children}
; diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx index 324f4e534..30add91e5 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx @@ -1,5 +1,6 @@ import * as RadixTabs from "@radix-ui/react-tabs"; -import { ReactNode, useRef } from "react"; +import type { ReactNode } from "react"; +import { useRef } from "react"; import { useExcalidrawSetAppState } from "../App"; import { isMemberOf } from "../../utils"; diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx index 05bc303d9..033fa8b6c 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { useTunnels } from "../../context/tunnels"; import DropdownMenu from "../dropdownMenu/DropdownMenu"; import { useExcalidrawSetAppState } from "../App"; diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts index 636d160a8..07135afcf 100644 --- a/packages/excalidraw/components/TTDDialog/common.ts +++ b/packages/excalidraw/components/TTDDialog/common.ts @@ -1,15 +1,16 @@ -import { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw"; -import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces"; +import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw"; +import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces"; import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE, EDITOR_LS_KEYS, } from "../../constants"; import { convertToExcalidrawElements, exportToCanvas } from "../../index"; -import { NonDeletedExcalidrawElement } from "../../element/types"; -import { AppClassProperties, BinaryFiles } from "../../types"; +import type { NonDeletedExcalidrawElement } from "../../element/types"; +import type { AppClassProperties, BinaryFiles } from "../../types"; import { canvasToBlob } from "../../data/blob"; import { EditorLocalStorage } from "../../data/EditorLocalStorage"; +import { t } from "../../i18n"; const resetPreview = ({ canvasRef, @@ -108,7 +109,14 @@ export const convertMermaidToExcalidraw = async ({ }); // if converting to blob fails, there's some problem that will // likely prevent preview and export (e.g. canvas too big) - await canvasToBlob(canvas); + try { + await canvasToBlob(canvas); + } catch (e: any) { + if (e.name === "CANVAS_POSSIBLY_TOO_BIG") { + throw new Error(t("canvasError.canvasTooBig")); + } + throw e; + } parent.style.background = "var(--default-bg-color)"; canvasNode.replaceChildren(canvas); } catch (err: any) { diff --git a/packages/excalidraw/components/TextField.tsx b/packages/excalidraw/components/TextField.tsx index 44a7c25ff..463ea2c2d 100644 --- a/packages/excalidraw/components/TextField.tsx +++ b/packages/excalidraw/components/TextField.tsx @@ -1,8 +1,8 @@ +import type { KeyboardEvent } from "react"; import { forwardRef, useRef, useImperativeHandle, - KeyboardEvent, useLayoutEffect, useState, } from "react"; diff --git a/packages/excalidraw/components/Toast.tsx b/packages/excalidraw/components/Toast.tsx index be0c46663..d99c9f648 100644 --- a/packages/excalidraw/components/Toast.tsx +++ b/packages/excalidraw/components/Toast.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from "react"; import { useCallback, useEffect, useRef } from "react"; import { CloseIcon } from "./icons"; import "./Toast.scss"; @@ -11,11 +12,13 @@ export const Toast = ({ closable = false, // To prevent autoclose, pass duration as Infinity duration = DEFAULT_TOAST_TIMEOUT, + style, }: { message: string; onClose: () => void; closable?: boolean; duration?: number; + style?: CSSProperties; }) => { const timerRef = useRef(0); const shouldAutoClose = duration !== Infinity; @@ -43,6 +46,7 @@ export const Toast = ({ className="Toast" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + style={style} >

{message}

{closable && ( diff --git a/packages/excalidraw/components/ToolButton.tsx b/packages/excalidraw/components/ToolButton.tsx index 2dace89d7..30014d4b1 100644 --- a/packages/excalidraw/components/ToolButton.tsx +++ b/packages/excalidraw/components/ToolButton.tsx @@ -1,11 +1,12 @@ import "./ToolIcon.scss"; -import React, { CSSProperties, useEffect, useRef, useState } from "react"; +import type { CSSProperties } from "react"; +import React, { useEffect, useRef, useState } from "react"; import clsx from "clsx"; import { useExcalidrawContainer } from "./App"; import { AbortError } from "../errors"; import Spinner from "./Spinner"; -import { PointerType } from "../element/types"; +import type { PointerType } from "../element/types"; import { isPromiseLike } from "../utils"; export type ToolButtonSize = "small" | "medium"; @@ -25,6 +26,7 @@ type ToolButtonBaseProps = { hidden?: boolean; visible?: boolean; selected?: boolean; + disabled?: boolean; className?: string; style?: CSSProperties; isLoading?: boolean; @@ -124,10 +126,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { type={type} onClick={onClick} ref={innerRef} - disabled={isLoading || props.isLoading} + disabled={isLoading || props.isLoading || !!props.disabled} > {(props.icon || props.label) && ( -