Prechádzať zdrojové kódy

Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage

Daniel J. Geiger 1 rok pred
rodič
commit
81e3dd5406
100 zmenil súbory, kde vykonal 5170 pridanie a 1589 odobranie
  1. 7 0
      .dockerignore
  2. 2 1
      .eslintrc.json
  3. 2 1
      .gitignore
  4. 7 5
      Dockerfile
  5. 4 4
      dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx
  6. 2 2
      dev-docs/docs/@excalidraw/excalidraw/integration.mdx
  7. 1 1
      examples/excalidraw/components/App.tsx
  8. 1 1
      examples/excalidraw/components/MobileFooter.tsx
  9. 1 1
      examples/excalidraw/utils.ts
  10. 303 80
      excalidraw-app/App.tsx
  11. 2 2
      excalidraw-app/CustomStats.tsx
  12. 5 1
      excalidraw-app/app_constants.ts
  13. 94 58
      excalidraw-app/collab/Collab.tsx
  14. 35 0
      excalidraw-app/collab/CollabError.scss
  15. 54 0
      excalidraw-app/collab/CollabError.tsx
  16. 20 27
      excalidraw-app/collab/Portal.tsx
  17. 10 11
      excalidraw-app/collab/RoomDialog.tsx
  18. 0 154
      excalidraw-app/collab/reconciliation.ts
  19. 26 6
      excalidraw-app/components/AppMainMenu.tsx
  20. 3 3
      excalidraw-app/components/AppWelcomeScreen.tsx
  21. 7 5
      excalidraw-app/components/ExportToExcalidrawPlus.tsx
  22. 1 1
      excalidraw-app/components/GitHubCorner.tsx
  23. 2 0
      excalidraw-app/components/TopErrorBoundary.tsx
  24. 4 2
      excalidraw-app/data/FileManager.ts
  25. 63 3
      excalidraw-app/data/LocalData.ts
  26. 36 30
      excalidraw-app/data/firebase.ts
  27. 13 11
      excalidraw-app/data/index.ts
  28. 3 19
      excalidraw-app/data/localStorage.ts
  29. 26 9
      excalidraw-app/index.html
  30. 12 1
      excalidraw-app/index.scss
  31. 3 1
      excalidraw-app/package.json
  32. 23 13
      excalidraw-app/share/ShareDialog.tsx
  33. 5 15
      excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap
  34. 179 21
      excalidraw-app/tests/collab.test.tsx
  35. 0 421
      excalidraw-app/tests/reconciliation.test.ts
  36. 70 0
      excalidraw-app/useHandleAppTheme.ts
  37. 4 0
      excalidraw-app/vite.config.mts
  38. 6 6
      package.json
  39. 20 2
      packages/excalidraw/CHANGELOG.md
  40. 5 4
      packages/excalidraw/actions/actionAddToLibrary.ts
  41. 24 10
      packages/excalidraw/actions/actionAlign.tsx
  42. 35 19
      packages/excalidraw/actions/actionBoundText.tsx
  43. 67 18
      packages/excalidraw/actions/actionCanvas.tsx
  44. 36 21
      packages/excalidraw/actions/actionClipboard.tsx
  45. 14 9
      packages/excalidraw/actions/actionDeleteSelected.tsx
  46. 9 5
      packages/excalidraw/actions/actionDistribute.tsx
  47. 21 10
      packages/excalidraw/actions/actionDuplicateSelection.tsx
  48. 37 23
      packages/excalidraw/actions/actionElementLock.ts
  49. 37 20
      packages/excalidraw/actions/actionExport.tsx
  50. 20 19
      packages/excalidraw/actions/actionFinalize.tsx
  51. 26 14
      packages/excalidraw/actions/actionFlip.ts
  52. 20 12
      packages/excalidraw/actions/actionFrame.ts
  53. 33 17
      packages/excalidraw/actions/actionGroup.tsx
  54. 78 63
      packages/excalidraw/actions/actionHistory.tsx
  55. 0 45
      packages/excalidraw/actions/actionLinearEditor.ts
  56. 76 0
      packages/excalidraw/actions/actionLinearEditor.tsx
  57. 55 0
      packages/excalidraw/actions/actionLink.tsx
  58. 9 4
      packages/excalidraw/actions/actionMenu.tsx
  59. 75 27
      packages/excalidraw/actions/actionNavigate.tsx
  60. 42 15
      packages/excalidraw/actions/actionProperties.tsx
  61. 8 4
      packages/excalidraw/actions/actionSelectAll.ts
  62. 15 7
      packages/excalidraw/actions/actionStyles.ts
  63. 7 3
      packages/excalidraw/actions/actionToggleGridMode.tsx
  64. 6 3
      packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
  65. 6 2
      packages/excalidraw/actions/actionToggleStats.tsx
  66. 6 2
      packages/excalidraw/actions/actionToggleViewMode.tsx
  67. 6 2
      packages/excalidraw/actions/actionToggleZenMode.tsx
  68. 17 9
      packages/excalidraw/actions/actionZindex.tsx
  69. 1 1
      packages/excalidraw/actions/index.ts
  70. 8 5
      packages/excalidraw/actions/manager.tsx
  71. 1 1
      packages/excalidraw/actions/register.ts
  72. 36 5
      packages/excalidraw/actions/shortcuts.ts
  73. 33 16
      packages/excalidraw/actions/types.ts
  74. 3 2
      packages/excalidraw/align.ts
  75. 1 1
      packages/excalidraw/analytics.ts
  76. 4 3
      packages/excalidraw/animated-trail.ts
  77. 2 4
      packages/excalidraw/appState.ts
  78. 1525 0
      packages/excalidraw/change.ts
  79. 2 6
      packages/excalidraw/charts.test.ts
  80. 2 2
      packages/excalidraw/charts.ts
  81. 224 5
      packages/excalidraw/clients.ts
  82. 9 8
      packages/excalidraw/clipboard.ts
  83. 1 1
      packages/excalidraw/colors.ts
  84. 1 0
      packages/excalidraw/components/Actions.scss
  85. 70 24
      packages/excalidraw/components/Actions.tsx
  86. 266 158
      packages/excalidraw/components/App.tsx
  87. 3 12
      packages/excalidraw/components/Avatar.tsx
  88. 2 4
      packages/excalidraw/components/ColorPicker/ColorInput.tsx
  89. 6 7
      packages/excalidraw/components/ColorPicker/ColorPicker.tsx
  90. 3 3
      packages/excalidraw/components/ColorPicker/Picker.tsx
  91. 3 2
      packages/excalidraw/components/ColorPicker/PickerColorList.tsx
  92. 1 1
      packages/excalidraw/components/ColorPicker/PickerHeading.tsx
  93. 1 1
      packages/excalidraw/components/ColorPicker/ShadeList.tsx
  94. 1 1
      packages/excalidraw/components/ColorPicker/TopPicks.tsx
  95. 3 6
      packages/excalidraw/components/ColorPicker/colorPickerUtils.ts
  96. 4 5
      packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts
  97. 137 0
      packages/excalidraw/components/CommandPalette/CommandPalette.scss
  98. 934 0
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  99. 11 0
      packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts
  100. 26 0
      packages/excalidraw/components/CommandPalette/types.ts

+ 7 - 0
.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

+ 2 - 1
.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" }]
   }
 }

+ 2 - 1
.gitignore

@@ -25,4 +25,5 @@ packages/excalidraw/types
 coverage
 dev-dist
 html
-examples/**/bundle.*
+examples/**/bundle.*
+meta*.json

+ 7 - 5
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

+ 4 - 4
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` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | 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() {
       <button className="custom-button" onClick={updateScene}>
         Update Scene
       </button>
-      <Excalidraw ref={(api) => setExcalidrawAPI(api)} />
+      <Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
     </div>
   );
 }
@@ -188,7 +188,7 @@ function App() {
         Update Library
       </button>
       <Excalidraw
-        ref={(api) => 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,

+ 2 - 2
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 (
-      <div style={{height:"500px", width:"500px"}}  
+      <div style={{height:"500px", width:"500px"}}>  
         <Excalidraw />
       </div> 
     );

+ 1 - 1
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,

+ 1 - 1
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";
 

+ 1 - 1
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<keyof typeof MIME_TYPES, "binary">;

+ 303 - 80
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<Theme>(
-    () =>
-      (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: <div style={{ width: 14 }}>{ExcalLogo}</div>,
+    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: <div style={{ width: 14 }}>{ExcalLogo}</div>,
+    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 (
     <div
       style={{ height: "100%" }}
@@ -712,27 +752,30 @@ const ExcalidrawWrapper = () => {
             toggleTheme: true,
             export: {
               onExportToBackend,
-              renderCustomUI: (elements, appState, files) => {
-                return (
-                  <ExportToExcalidrawPlus
-                    elements={elements}
-                    appState={appState}
-                    files={files}
-                    onError={(error) => {
-                      excalidrawAPI?.updateScene({
-                        appState: {
-                          errorMessage: error.message,
-                        },
-                      });
-                    }}
-                    onSuccess={() => {
-                      excalidrawAPI?.updateScene({
-                        appState: { openDialog: null },
-                      });
-                    }}
-                  />
-                );
-              },
+              renderCustomUI: excalidrawAPI
+                ? (elements, appState, files) => {
+                    return (
+                      <ExportToExcalidrawPlus
+                        elements={elements}
+                        appState={appState}
+                        files={files}
+                        name={excalidrawAPI.getName()}
+                        onError={(error) => {
+                          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 (
-            <LiveCollaborationTrigger
-              isCollaborating={isCollaborating}
-              onSelect={() =>
-                setShareDialogState({ isOpen: true, type: "share" })
-              }
-            />
+            <div className="top-right-ui">
+              {collabError.message && <CollabError collabError={collabError} />}
+              <LiveCollaborationTrigger
+                isCollaborating={isCollaborating}
+                onSelect={() =>
+                  setShareDialogState({ isOpen: true, type: "share" })
+                }
+              />
+            </div>
           );
         }}
       >
@@ -761,6 +806,8 @@ const ExcalidrawWrapper = () => {
           onCollabDialogOpen={onCollabDialogOpen}
           isCollaborating={isCollaborating}
           isCollabEnabled={!isCollabDisabled}
+          theme={appTheme}
+          setTheme={(theme) => setAppTheme(theme)}
         />
         <AppWelcomeScreen
           onCollabDialogOpen={onCollabDialogOpen}
@@ -778,6 +825,7 @@ const ExcalidrawWrapper = () => {
                   excalidrawAPI.getSceneElements(),
                   excalidrawAPI.getAppState(),
                   excalidrawAPI.getFiles(),
+                  excalidrawAPI.getName(),
                 );
               }}
             >
@@ -882,6 +930,181 @@ const ExcalidrawWrapper = () => {
             {errorMessage}
           </ErrorDialog>
         )}
+
+        <CommandPalette
+          customCommandPaletteItems={[
+            {
+              label: t("labels.liveCollaboration"),
+              category: DEFAULT_CATEGORIES.app,
+              keywords: [
+                "team",
+                "multiplayer",
+                "share",
+                "public",
+                "session",
+                "invite",
+              ],
+              icon: usersIcon,
+              perform: () => {
+                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,
+                );
+              },
+            },
+          ]}
+        />
       </Excalidraw>
     </div>
   );

+ 2 - 2
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 };
 

+ 5 - 1
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 = {

+ 94 - 58
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<CollabAPI | null>(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<string, boolean>;
   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<CollabProps, CollabState> {
     super(props);
     this.state = {
       errorMessage: null,
+      dialogNotifiedErrors: {},
       username: importUsernameFromLocalStorage() || "",
       activeRoomLink: null,
     };
@@ -197,7 +206,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
       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<CollabProps, CollabState> {
     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<CollabProps, CollabState> {
     this.queueBroadcastAllElements.cancel();
     this.queueSaveToFirebase.cancel();
     this.loadImageFiles.cancel();
+    this.resetErrorIndicator(true);
 
     this.saveCollabRoomToFirebase(
       getSyncableElements(
@@ -334,7 +359,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
 
       this.excalidrawAPI.updateScene({
         elements,
-        commitToHistory: false,
+        storeAction: StoreAction.UPDATE,
       });
     }
   };
@@ -407,7 +432,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
 
   startCollaboration = async (
     existingRoomLinkData: null | { roomId: string; roomKey: string },
-  ): Promise<ImportedDataState | null> => {
+  ) => {
     if (!this.state.username) {
       import("@excalidraw/random-username").then(({ getRandomUsername }) => {
         const username = getRandomUsername();
@@ -433,7 +458,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
       );
     }
 
-    const scenePromise = resolvablePromise<ImportedDataState | null>();
+    // 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<CollabProps, CollabState> {
       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<CollabProps, CollabState> {
         }
         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<CollabProps, CollabState> {
             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<CollabProps, CollabState> {
           }
           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<CollabProps, CollabState> {
     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<CollabProps, CollabState> {
   }, 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<CollabProps, CollabState> {
     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<CollabProps, CollabState> {
     }
   };
 
-  syncElements = (elements: readonly ExcalidrawElement[]) => {
+  syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
     this.broadcastElements(elements);
     this.queueSaveToFirebase();
   };
@@ -923,8 +941,26 @@ class Collab extends PureComponent<CollabProps, CollabState> {
 
   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<CollabProps, CollabState> {
     return (
       <>
         {errorMessage != null && (
-          <ErrorDialog onClose={() => this.setState({ errorMessage: null })}>
+          <ErrorDialog onClose={() => this.setErrorDialog(null)}>
             {errorMessage}
           </ErrorDialog>
         )}

+ 35 - 0
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);
+    }
+  }
+}

+ 54 - 0
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<ErrorIndicator>({
+  message: null,
+  nonce: 0,
+});
+
+const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
+  const [isAnimating, setIsAnimating] = useState(false);
+  const clearAnimationRef = useRef<string | number | NodeJS.Timeout>();
+
+  useEffect(() => {
+    setIsAnimating(true);
+    clearAnimationRef.current = setTimeout(() => {
+      setIsAnimating(false);
+    }, 1000);
+
+    return () => {
+      clearTimeout(clearAnimationRef.current);
+    };
+  }, [collabError.message, collabError.nonce]);
+
+  if (!collabError.message) {
+    return null;
+  }
+
+  return (
+    <Tooltip label={collabError.message} long={true}>
+      <div
+        className={clsx("collab-errors-button", {
+          "collab-errors-button-shake": isAnimating,
+        })}
+      >
+        {warning}
+      </div>
+    </Tooltip>
+  );
+};
+
+CollabError.displayName = "CollabError";
+
+export default CollabError;

+ 20 - 27
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,

+ 10 - 11
excalidraw-app/collab/RoomDialog.tsx

@@ -65,20 +65,19 @@ export const RoomModal = ({
   const copyRoomLink = async () => {
     try {
       await copyTextToSystemClipboard(activeRoomLink);
+    } catch (e) {
+      setErrorMessage(t("errors.copyToSystemClipboardFailed"));
+    }
+    setJustCopied(true);
 
-      setJustCopied(true);
-
-      if (timerRef.current) {
-        window.clearTimeout(timerRef.current);
-      }
-
-      timerRef.current = window.setTimeout(() => {
-        setJustCopied(false);
-      }, 3000);
-    } catch (error: any) {
-      setErrorMessage(error.message);
+    if (timerRef.current) {
+      window.clearTimeout(timerRef.current);
     }
 
+    timerRef.current = window.setTimeout(() => {
+      setJustCopied(false);
+    }, 3000);
+
     ref.current?.select();
   };
 

+ 0 - 154
excalidraw-app/collab/reconciliation.ts

@@ -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<ExcalidrawElement>(localElements);
-
-  const reconciledElements: ExcalidrawElement[] = localElements.slice();
-
-  const duplicates = new WeakMap<ExcalidrawElement, true>();
-
-  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;
-};

+ 26 - 6
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 (
     <MainMenu>
@@ -20,22 +27,35 @@ export const AppMainMenu: React.FC<{
           onSelect={() => props.onCollabDialogOpen()}
         />
       )}
-
+      <MainMenu.DefaultItems.CommandPalette className="highlighted" />
       <MainMenu.DefaultItems.Help />
       <MainMenu.DefaultItems.ClearCanvas />
       <MainMenu.Separator />
       <MainMenu.ItemLink
-        icon={PlusPromoIcon}
+        icon={ExcalLogo}
         href={`${
-          import.meta.env.VITE_APP_PLUS_LP
+          import.meta.env.VITE_APP_PLUS_APP
         }/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
-        className="ExcalidrawPlus"
+        className=""
       >
         Excalidraw+
       </MainMenu.ItemLink>
       <MainMenu.DefaultItems.Socials />
+      <MainMenu.ItemLink
+        icon={loginIcon}
+        href={`${import.meta.env.VITE_APP_PLUS_APP}${
+          isExcalidrawPlusSignedUser ? "" : "/sign-up"
+        }?utm_source=signin&utm_medium=app&utm_content=hamburger`}
+        className="highlighted"
+      >
+        {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
+      </MainMenu.ItemLink>
       <MainMenu.Separator />
-      <MainMenu.DefaultItems.ToggleTheme />
+      <MainMenu.DefaultItems.ToggleTheme
+        allowSystemTheme
+        theme={props.theme}
+        onSelect={props.setTheme}
+      />
       <MainMenu.ItemCustom>
         <LanguageList style={{ width: "100%" }} />
       </MainMenu.ItemCustom>

+ 3 - 3
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
             </WelcomeScreen.Center.MenuItemLink>
           )}
         </WelcomeScreen.Center.Menu>

+ 7 - 5
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<AppState>,
   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<AppState>;
   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 (
     <Card color="primary">
@@ -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);

+ 1 - 1
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(

+ 2 - 0
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",
     );
   }
 

+ 4 - 2
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,
   });
 };

+ 63 - 3
excalidraw-app/data/LocalData.ts

@@ -10,18 +10,29 @@
  *   (localStorage, indexedDB).
  */
 
-import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
+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 {
+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<LibraryPersistedData>(
+      LibraryIndexedDBAdapter.key,
+      LibraryIndexedDBAdapter.store,
+    );
+
+    return IDBData || null;
+  }
+
+  static save(data: LibraryPersistedData): MaybePromise<void> {
+    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);
+  }
+}

+ 36 - 30
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),
+  );
+
+  FirebaseSceneVersionCache.set(socket, storedElements);
 
-  return { reconciledElements: savedData.reconciledElements };
+  return storedElements;
 };
 
 export const loadFromFirebase = async (
   roomId: string,
   roomKey: string,
   socket: Socket | null,
-): Promise<readonly ExcalidrawElement[] | null> => {
+): Promise<readonly SyncableExcalidrawElement[] | null> => {
   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 (

+ 13 - 11
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,
   };
 };
 

+ 3 - 19
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 [];
-  }
-};

+ 26 - 9
excalidraw-app/index.html

@@ -64,12 +64,30 @@
     <!--   to minimize white flash on load when user has dark mode enabled   -->
     <script>
       try {
-        //
-        const theme = window.localStorage.getItem("excalidraw-theme");
-        if (theme === "dark") {
-          document.documentElement.classList.add("dark");
+        function setTheme(theme) {
+          if (theme === "dark") {
+            document.documentElement.classList.add("dark");
+          } else {
+            document.documentElement.classList.remove("dark");
+          }
+        }
+
+        function getTheme() {
+          const theme = window.localStorage.getItem("excalidraw-theme");
+
+          if (theme && theme === "system") {
+            return window.matchMedia("(prefers-color-scheme: dark)").matches
+              ? "dark"
+              : "light";
+          } else {
+            return theme || "light";
+          }
         }
-      } catch {}
+
+        setTheme(getTheme());
+      } catch (e) {
+        console.error("Error setting dark mode", e);
+      }
     </script>
     <style>
       html.dark {
@@ -78,7 +96,7 @@
       }
     </style>
     <!------------------------------------------------------------------------->
-    <% if ("%PROD%" === "true") { %>
+    <% if (typeof PROD != 'undefined' && PROD == true) { %>
     <script>
       // Redirect Excalidraw+ users which have auto-redirect enabled.
       //
@@ -122,7 +140,8 @@
     />
 
     <link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
-    <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
+    <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
+    VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
     <script>
       {
         const _WebSocket = window.WebSocket;
@@ -196,7 +215,6 @@
     </header>
     <div id="root"></div>
     <script type="module" src="index.tsx"></script>
-    <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
     <!-- 100% privacy friendly analytics -->
     <script>
       // need to load this script dynamically bcs. of iframe embed tracking
@@ -229,6 +247,5 @@
       }
     </script>
     <!-- end LEGACY GOOGLE ANALYTICS -->
-    <% } %>
   </body>
 </html>

+ 12 - 1
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;
+        }
       }
     }
   }

+ 3 - 1
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",

+ 23 - 13
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);
+    } catch (e) {
+      collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
+    }
 
-      setJustCopied(true);
-
-      if (timerRef.current) {
-        window.clearTimeout(timerRef.current);
-      }
+    setJustCopied(true);
 
-      timerRef.current = window.setTimeout(() => {
-        setJustCopied(false);
-      }, 3000);
-    } catch (error: any) {
-      collabAPI.setErrorMessage(error.message);
+    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}
-    ></ShareDialogInner>
+    />
   );
 };

+ 5 - 15
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"
             />
-            <rect
-              height="4"
-              rx="1"
-              width="18"
-              x="3"
-              y="8"
-            />
-            <line
-              x1="12"
-              x2="12"
-              y1="8"
-              y2="21"
+            <path
+              d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
             />
             <path
-              d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
+              d="M21 12h-13l3 -3"
             />
             <path
-              d="M7.5 8a2.5 2.5 0 0 1 0 -5a4.8 8 0 0 1 4.5 5a4.8 8 0 0 1 4.5 -5a2.5 2.5 0 0 1 0 5"
+              d="M11 15l-3 -3"
             />
           </g>
         </svg>
@@ -249,7 +239,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
       <div
         class="welcome-screen-menu-item__text"
       >
-        Try Excalidraw Plus!
+        Sign up
       </div>
     </a>
   </div>

+ 179 - 21
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(<ExcalidrawApp />);
-    // 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: syncInvalidIndices([rect1, rect2]),
+      storeAction: StoreAction.CAPTURE,
+    });
+
     updateSceneData({
-      elements: [
-        API.createElement({ type: "rectangle", id: "A" }),
-        API.createElement({
-          type: "rectangle",
-          id: "B",
-          isDeleted: true,
-        }),
-      ],
+      elements: syncInvalidIndices([
+        rect1,
+        newElementWith(h.elements[1], { isDeleted: true }),
+      ]),
+      storeAction: StoreAction.CAPTURE,
     });
+
     await waitFor(() => {
+      expect(API.getUndoStack().length).toBe(2);
+      expect(API.getSnapshot()).toEqual([
+        expect.objectContaining(rect1Props),
+        expect.objectContaining({ ...rect2Props, isDeleted: true }),
+      ]);
       expect(h.elements).toEqual([
-        expect.objectContaining({ id: "A" }),
-        expect.objectContaining({ id: "B", isDeleted: true }),
+        expect.objectContaining(rect1Props),
+        expect.objectContaining({ ...rect2Props, isDeleted: true }),
       ]);
-      expect(API.getStateHistory().length).toBe(1);
     });
+
+    // 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(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
-      expect(API.getStateHistory().length).toBe(1);
+      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 }),
+      ]);
     });
 
-    const undoAction = createUndoAction(h.history);
-    // noop
-    h.app.actionManager.executeAction(undoAction);
+    act(() => h.app.actionManager.executeAction(undoAction));
+
+    // we expect to iterate the stack to the first visible change
     await waitFor(() => {
-      expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
-      expect(API.getStateHistory().length).toBe(1);
+      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 }),
+      ]);
     });
   });
 });

+ 0 - 421
excalidraw-app/tests/reconciliation.test.ts

@@ -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<string, ExcalidrawElement | undefined>;
-
-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 = <U extends `${string}:${"L" | "R"}`>(
-  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 = <T>(
-  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
-    //  (<id>) means the element is preceded by <id> 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"]);
-  });
-});

+ 70 - 0
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<Theme | "system">(
+  (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>(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 };
+};

+ 4 - 0
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",
 });

+ 6 - 6
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",

+ 20 - 2
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`).<br/><br/>**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

+ 5 - 4
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",
 });

+ 24 - 10
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 }) => (

+ 35 - 19
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,
     };
   },
 });

+ 67 - 18
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 }) => (
     <ToolButton
       type="button"
       className="zoom-in-button zoom-button"
       icon={ZoomInIcon}
       title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
       aria-label={t("buttons.zoomIn")}
+      disabled={appState.zoom.value >= 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 }) => (
     <ToolButton
       type="button"
       className="zoom-out-button zoom-button"
       icon={ZoomOutIcon}
       title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
       aria-label={t("buttons.zoomOut")}
+      disabled={appState.zoom.value <= MIN_ZOOM}
       onClick={() => {
         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) =>

+ 36 - 21
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"],
 });

+ 14 - 9
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],

+ 9 - 5
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) =>

+ 21 - 10
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 }) => (
     <ToolButton
@@ -85,6 +92,7 @@ const duplicateElements = (
   const newElements: ExcalidrawElement[] = [];
   const oldElements: ExcalidrawElement[] = [];
   const oldIdToDuplicatedId = new Map();
+  const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
 
   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),
+  );
 
   // ---------------------------------------------------------------------------
 

+ 37 - 23
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",
 });

+ 37 - 20
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 }) => (
     <ProjectName
       label={t("labels.fileTitle")}
-      value={appState.name || "Unnamed"}
+      value={app.getName()}
       onChange={(name: string) => 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 }) => (

+ 20 - 19
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) =>

+ 26 - 14
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<AppState>,
   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<ExcalidrawElement>[],
-  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;
 };

+ 20 - 12
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<ExcalidrawElement["id"], true>),
         },
-        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) =>

+ 33 - 17
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) => <GroupIcon theme={appState.theme} />,
   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) => <UngroupIcon theme={appState.theme} />,
   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 }) => (

+ 78 - 63
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<AppState>,
+  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();
 
-    const prevElementMap = arrayToMap(prevElements);
-    const nextElements = data.elements;
-    const nextElementMap = arrayToMap(nextElements);
+    if (!result) {
+      return { storeAction: StoreAction.NONE };
+    }
 
-    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 }) => (
-    <ToolButton
-      type="button"
-      icon={UndoIcon}
-      aria-label={t("buttons.undo")}
-      onClick={updateData}
-      size={data?.size || "medium"}
-    />
-  ),
-  commitToHistory: () => false,
+  PanelComponent: ({ updateData, data }) => {
+    const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
+      history.onHistoryChangedEmitter,
+      new HistoryChangedEvent(),
+    );
+
+    return (
+      <ToolButton
+        type="button"
+        icon={UndoIcon}
+        aria-label={t("buttons.undo")}
+        onClick={updateData}
+        size={data?.size || "medium"}
+        disabled={isUndoStackEmpty}
+      />
+    );
+  },
 });
 
-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 }) => (
-    <ToolButton
-      type="button"
-      icon={RedoIcon}
-      aria-label={t("buttons.redo")}
-      onClick={updateData}
-      size={data?.size || "medium"}
-    />
-  ),
-  commitToHistory: () => false,
+  PanelComponent: ({ updateData, data }) => {
+    const { isRedoStackEmpty } = useEmitter(
+      history.onHistoryChangedEmitter,
+      new HistoryChangedEvent(),
+    );
+
+    return (
+      <ToolButton
+        type="button"
+        icon={RedoIcon}
+        aria-label={t("buttons.redo")}
+        onClick={updateData}
+        size={data?.size || "medium"}
+        disabled={isRedoStackEmpty}
+      />
+    );
+  },
 });

+ 0 - 45
packages/excalidraw/actions/actionLinearEditor.ts

@@ -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";
-  },
-});

+ 76 - 0
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 (
+      <ToolButton
+        type="button"
+        icon={lineEditorIcon}
+        title={label}
+        aria-label={label}
+        onClick={() => updateData(null)}
+      />
+    );
+  },
+});

+ 55 - 0
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 (
+      <ToolButton
+        type="button"
+        icon={LinkIcon}
+        aria-label={t(getContextMenuLabel(elements, appState))}
+        title={`${
+          isEmbeddableElement(elements[0])
+            ? t("labels.link.labelEmbed")
+            : t("labels.link.label")
+        } - ${getShortcutKey("CtrlOrCmd+K")}`}
+        onClick={() => updateData(null)}
+        selected={selectedElements.length === 1 && !!selectedElements[0].link}
+      />
+    );
+  },
+});

+ 9 - 4
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 }) => (
     <ToolButton
@@ -28,13 +30,14 @@ export const actionToggleCanvasMenu = register({
 
 export const actionToggleEditMenu = register({
   name: "toggleEditMenu",
+  label: "buttons.edit",
   trackEvent: { category: "menu" },
   perform: (_elements, appState) => ({
     appState: {
       ...appState,
       openMenu: appState.openMenu === "shape" ? null : "shape",
     },
-    commitToHistory: false,
+    storeAction: StoreAction.NONE,
   }),
   PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton
@@ -53,6 +56,8 @@ export const actionToggleEditMenu = register({
 
 export const actionShortcuts = register({
   name: "toggleShortcuts",
+  label: "welcomeScreen.defaults.helpHint",
+  icon: HelpIconThin,
   viewMode: true,
   trackEvent: { category: "menu", action: "toggleHelpDialog" },
   perform: (_elements, appState, _, { focusContainer }) => {
@@ -69,7 +74,7 @@ export const actionShortcuts = register({
                 name: "help",
               },
       },
-      commitToHistory: false,
+      storeAction: StoreAction.NONE,
     };
   },
   keyTest: (event) => event.key === KEYS.QUESTION_MARK,

+ 75 - 27
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 ? (
+        <div
+          className="UserList__collaborator-status-icon-speaking-indicator"
+          title={t("userList.hint.isSpeaking")}
+        >
+          <div />
+          <div />
+          <div />
+        </div>
+      ) : collaborator.isMuted ? (
+        <div
+          className="UserList__collaborator-status-icon-microphone-muted"
+          title={t("userList.hint.micMuted")}
+        >
+          {microphoneMutedIcon}
+        </div>
+      ) : (
+        <div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
+      )
+    ) : null;
 
     return withName ? (
       <div
-        className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
+        className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
+        style={{ [`--avatar-size` as any]: "1.5rem" }}
         onClick={() => updateData<Collaborator>(collaborator)}
       >
         <Avatar
@@ -54,32 +92,42 @@ export const actionGoToCollaborator = register({
           onClick={() => {}}
           name={collaborator.username || ""}
           src={collaborator.avatarUrl}
-          isBeingFollowed={isBeingFollowed}
-          isCurrentUser={collaborator.isCurrentUser === true}
+          className={statusClassNames}
         />
         <div className="UserList__collaborator-name">
           {collaborator.username}
         </div>
-        <div
-          className="UserList__collaborator-follow-status-icon"
-          style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
-          title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
-          aria-hidden
-        >
-          {eyeIcon}
+        <div className="UserList__collaborator-status-icons" aria-hidden>
+          {isBeingFollowed && (
+            <div
+              className="UserList__collaborator-status-icon-is-followed"
+              title={t("userList.hint.followStatus")}
+            >
+              {eyeIcon}
+            </div>
+          )}
+          {statusIconJSX}
         </div>
       </div>
     ) : (
-      <Avatar
-        color={background}
-        onClick={() => {
-          updateData(collaborator);
-        }}
-        name={collaborator.username || ""}
-        src={collaborator.avatarUrl}
-        isBeingFollowed={isBeingFollowed}
-        isCurrentUser={collaborator.isCurrentUser === true}
-      />
+      <div
+        className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
+      >
+        <Avatar
+          color={background}
+          onClick={() => {
+            updateData(collaborator);
+          }}
+          name={collaborator.username || ""}
+          src={collaborator.avatarUrl}
+          className={statusClassNames}
+        />
+        {statusIconJSX && (
+          <div className="UserList__collaborator-status-icon">
+            {statusIconJSX}
+          </div>
+        )}
+      </div>
     );
   },
 });

+ 42 - 15
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 }) => {

+ 8 - 4
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,
 });

+ 15 - 7
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,
 });

+ 7 - 3
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,
 });

+ 6 - 3
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,
 });

+ 6 - 2
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,
 });

+ 6 - 2
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,
 });

+ 6 - 2
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,
 });

+ 17 - 9
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] &&

+ 1 - 1
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";

+ 8 - 5
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<ActionResult>) => void;
 
   getAppState: () => Readonly<AppState>;
-  getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
+  getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[];
   app: AppClassProperties;
 
   constructor(
     updater: UpdaterFn,
     getAppState: () => AppState,
-    getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
+    getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[],
     app: AppClassProperties,
   ) {
     this.updater = (actionResult) => {

+ 1 - 1
packages/excalidraw/actions/register.ts

@@ -1,4 +1,4 @@
-import { Action } from "./types";
+import type { Action } from "./types";
 
 export let actions: readonly Action[] = [];
 

+ 36 - 5
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<CustomActionName, string[]>,
@@ -56,6 +69,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   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<ShortcutName, string[]> = {
   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]
+    : "";
 };

+ 33 - 16
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<AppState>,
   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<AppState>,
+        app: AppClassProperties,
+      ) => string);
+  keywords?: string[];
+  icon?:
+    | React.ReactNode
+    | ((
+        appState: UIAppState,
+        elements: readonly ExcalidrawElement[],
+      ) => React.ReactNode);
   PanelComponent?: React.FC<PanelComponentProps>;
   perform: ActionFn;
   keyPriority?: number;
@@ -160,13 +184,6 @@ export interface Action {
     elements: readonly ExcalidrawElement[],
     app: AppClassProperties,
   ) => boolean;
-  contextItemLabel?:
-    | string
-    | ((
-        elements: readonly ExcalidrawElement[],
-        appState: Readonly<AppState>,
-        app: AppClassProperties,
-      ) => string);
   predicate?: (
     elements: readonly ExcalidrawElement[],
     appState: AppState,

+ 3 - 2
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 {

+ 1 - 1
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,

+ 4 - 3
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";

+ 2 - 4
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,

+ 1525 - 0
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<T> {
+  private constructor(
+    public readonly deleted: Partial<T>,
+    public readonly inserted: Partial<T>,
+  ) {}
+
+  public static create<T>(
+    deleted: Partial<T>,
+    inserted: Partial<T>,
+    modifier?: (delta: Partial<T>) => Partial<T>,
+    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<T extends { [key: string]: any }>(
+    prevObject: T,
+    nextObject: T,
+    modifier?: (partial: Partial<T>) => Partial<T>,
+    postProcess?: (
+      deleted: Partial<T>,
+      inserted: Partial<T>,
+    ) => [Partial<T>, Partial<T>],
+  ): Delta<T> {
+    if (prevObject === nextObject) {
+      return Delta.empty();
+    }
+
+    const deleted = {} as Partial<T>;
+    const inserted = {} as Partial<T>;
+
+    // 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<T>(delta: Delta<T>): boolean {
+    return (
+      !Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length
+    );
+  }
+
+  /**
+   * Merges deleted and inserted object partials.
+   */
+  public static mergeObjects<T extends { [key: string]: unknown }>(
+    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<T>(
+    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<T, K extends keyof T, V extends ValueOf<T[K]>>(
+    deleted: Partial<T>,
+    inserted: Partial<T>,
+    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<string, V | undefined>;
+
+      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<T, K extends keyof T, V extends T[K]>(
+    deleted: Partial<T>,
+    inserted: Partial<T>,
+    property: K,
+    groupBy: (value: V extends ArrayLike<infer T> ? 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<T extends {}>(
+    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<T extends {}>(
+    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<T extends {}>(
+    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<T extends {}>(
+    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<T extends {}>(
+    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<T> {
+  /**
+   * Inverses the `Delta`s inside while creating a new `Change`.
+   */
+  inverse(): Change<T>;
+
+  /**
+   * 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<AppState> {
+  private constructor(private readonly delta: Delta<ObservedAppState>) {}
+
+  public static calculate<T extends ObservedAppState>(
+    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<ExcalidrawLinearElement>,
+            )
+          : null;
+
+      const editingLinearElement =
+        editingLinearElementId && nextElements.has(editingLinearElementId)
+          ? new LinearElementEditor(
+              nextElements.get(
+                editingLinearElementId,
+              ) as NonDeleted<ExcalidrawLinearElement>,
+            )
+          : 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<T extends ObservedAppState>(
+    deleted: Partial<T>,
+    inserted: Partial<T>,
+  ): [Partial<T>, Partial<T>] {
+    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<T["selectedElementIds"]>,
+      );
+      Delta.diffObjects(
+        deleted,
+        inserted,
+        "selectedGroupIds",
+        (prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
+      );
+    } 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<keyof ObservedElementsAppState>;
+
+      let nonDeletedGroupIds = new Set<string>();
+
+      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<AppState, "selectedLinearElement" | "editingLinearElement"> {
+    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<string>,
+    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<ObservedAppState>,
+  ): Partial<ObservedStandaloneAppState> {
+    // 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<ObservedAppState>,
+  ): Partial<ObservedElementsAppState> {
+    // 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<ElementUpdate<OrderedExcalidrawElement>, "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<SceneElementsMap> {
+  private constructor(
+    private readonly added: Map<string, Delta<ElementPartial>>,
+    private readonly removed: Map<string, Delta<ElementPartial>>,
+    private readonly updated: Map<string, Delta<ElementPartial>>,
+  ) {}
+
+  public static create(
+    added: Map<string, Delta<ElementPartial>>,
+    removed: Map<string, Delta<ElementPartial>>,
+    updated: Map<string, Delta<ElementPartial>>,
+    options = { shouldRedistribute: false },
+  ) {
+    let change: ElementsChange;
+
+    if (options.shouldRedistribute) {
+      const nextAdded = new Map<string, Delta<ElementPartial>>();
+      const nextRemoved = new Map<string, Delta<ElementPartial>>();
+      const nextUpdated = new Map<string, Delta<ElementPartial>>();
+
+      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<ElementPartial>) =>
+    // dissallowing added as "deleted", which could cause issues when resolving conflicts
+    deleted.isDeleted === true && !inserted.isDeleted;
+
+  private static satisfiesRemoval = ({
+    deleted,
+    inserted,
+  }: Delta<ElementPartial>) =>
+    !deleted.isDeleted && inserted.isDeleted === true;
+
+  private static satisfiesUpdate = ({
+    deleted,
+    inserted,
+  }: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
+
+  private static validate(
+    change: ElementsChange,
+    type: "added" | "removed" | "updated",
+    satifies: (delta: Delta<ElementPartial>) => 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<T extends OrderedExcalidrawElement>(
+    prevElements: Map<string, T>,
+    nextElements: Map<string, T>,
+  ): ElementsChange {
+    if (prevElements === nextElements) {
+      return ElementsChange.empty();
+    }
+
+    const added = new Map<string, Delta<ElementPartial>>();
+    const removed = new Map<string, Delta<ElementPartial>>();
+    const updated = new Map<string, Delta<ElementPartial>>();
+
+    // 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<ElementPartial>(
+          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<string, Delta<ElementPartial>>) => {
+      const inversedDeltas = new Map<string, Delta<ElementPartial>>();
+
+      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<keyof typeof partial>) {
+          // 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<string, Delta<ElementPartial>>,
+    ) => {
+      const modifiedDeltas = new Map<string, Delta<ElementPartial>>();
+
+      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<string, OrderedExcalidrawElement>,
+  ): [SceneElementsMap, boolean] {
+    let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
+    let changedElements: Map<string, OrderedExcalidrawElement>;
+
+    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<string, OrderedExcalidrawElement>,
+    flags: {
+      containsVisibleDifference: boolean;
+      containsZindexDifference: boolean;
+    },
+  ) => {
+    const getElement = ElementsChange.createGetter(
+      nextElements,
+      snapshot,
+      flags,
+    );
+
+    return (deltas: Map<string, Delta<ElementPartial>>) =>
+      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<string, OrderedExcalidrawElement>());
+  };
+
+  private static createGetter =
+    (
+      elements: SceneElementsMap,
+      snapshot: Map<string, OrderedExcalidrawElement>,
+      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<ElementPartial>,
+    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<string, OrderedExcalidrawElement>();
+    const updater = (
+      element: ExcalidrawElement,
+      updates: ElementUpdate<ExcalidrawElement>,
+    ) => {
+      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<OrderedExcalidrawElement>,
+        );
+      } else {
+        affectedElement = mutateElement(
+          nextElement,
+          updates as ElementUpdate<OrderedExcalidrawElement>,
+        );
+      }
+
+      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<ExcalidrawElement>,
+    ) => 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<ExcalidrawElement>,
+    ) => 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<string, OrderedExcalidrawElement>,
+  ) {
+    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<string, OrderedExcalidrawElement>,
+  ) {
+    for (const element of changed.values()) {
+      if (!element.isDeleted && isBindableElement(element)) {
+        updateBoundElements(element, elements);
+      }
+    }
+  }
+
+  private static reorderElements(
+    elements: SceneElementsMap,
+    changed: Map<string, OrderedExcalidrawElement>,
+    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<OrderedExcalidrawElement>,
+  ): ElementPartial {
+    const { id, updated, version, versionNonce, seed, ...strippedPartial } =
+      partial;
+
+    return strippedPartial;
+  }
+}

+ 2 - 6
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", () => {

+ 2 - 2
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[];

+ 224 - 5
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();
+  }
+};

+ 9 - 8
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.");
   }
 };
 

+ 1 - 1
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 = <R extends Record<string, any>, K extends readonly (keyof R)[]>(

+ 1 - 0
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;

+ 70 - 24
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 (
     <div className="panelColumn">
       <div>
-        {((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")}
       </div>
-      {showChangeBackgroundIcons && (
+      {canChangeBackgroundColor(appState, targetElements) && (
         <div>{renderAction("changeBackgroundColor")}</div>
       )}
       <SubtypeShapeActions elements={targetElements} />
@@ -158,8 +184,8 @@ export const SelectedShapeActions = ({
         <div className="buttonList">
           {renderAction("sendToBack")}
           {renderAction("sendBackward")}
-          {renderAction("bringToFront")}
           {renderAction("bringForward")}
+          {renderAction("bringToFront")}
         </div>
       </fieldset>
 
@@ -214,6 +240,7 @@ export const SelectedShapeActions = ({
             {renderAction("group")}
             {renderAction("ungroup")}
             {showLinkIcon && renderAction("hyperlink")}
+            {showLineEditorAction && renderAction("toggleLinearEditor")}
           </div>
         </fieldset>
       )}
@@ -308,6 +335,25 @@ export const ShapesSwitcher = ({
           title={t("toolBar.extraTools")}
         >
           {extraToolsIcon}
+          {app.props.aiEnabled !== false && (
+            <div
+              style={{
+                display: "inline-flex",
+                marginLeft: "auto",
+                padding: "2px 4px",
+                borderRadius: 6,
+                fontSize: 8,
+                fontFamily: "Cascadia, monospace",
+                position: "absolute",
+                background: "var(--color-promo)",
+                color: "var(--color-surface-lowest)",
+                bottom: 3,
+                right: 4,
+              }}
+            >
+              AI
+            </div>
+          )}
         </DropdownMenu.Trigger>
         <DropdownMenu.Content
           onClickOutside={() => setIsExtraToolsMenuOpen(false)}

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 266 - 158
packages/excalidraw/components/App.tsx


+ 3 - 12
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 (
-    <div
-      className={clsx("Avatar", {
-        "Avatar--is-followed": isBeingFollowed,
-        "Avatar--is-current-user": isCurrentUser,
-      })}
-      style={style}
-      onClick={onClick}
-    >
+    <div className={clsx("Avatar", className)} style={style} onClick={onClick}>
       {loadImg ? (
         <img
           className="Avatar-img"

+ 2 - 4
packages/excalidraw/components/ColorPicker/ColorInput.tsx

@@ -1,10 +1,8 @@
 import { useCallback, useEffect, useRef, useState } from "react";
 import { getColor } from "./ColorPicker";
 import { useAtom } from "jotai";
-import {
-  ColorPickerType,
-  activeColorPickerSectionAtom,
-} from "./colorPickerUtils";
+import type { ColorPickerType } from "./colorPickerUtils";
+import { activeColorPickerSectionAtom } from "./colorPickerUtils";
 import { eyeDropperIcon } from "../icons";
 import { jotaiScope } from "../../jotai";
 import { KEYS } from "../../keys";

+ 6 - 7
packages/excalidraw/components/ColorPicker/ColorPicker.tsx

@@ -1,16 +1,15 @@
 import { isInteractive, isTransparent, isWritableElement } from "../../utils";
-import { ExcalidrawElement } from "../../element/types";
-import { AppState } from "../../types";
+import type { ExcalidrawElement } from "../../element/types";
+import type { AppState } from "../../types";
 import { TopPicks } from "./TopPicks";
 import { Picker } from "./Picker";
 import * as Popover from "@radix-ui/react-popover";
 import { useAtom } from "jotai";
-import {
-  activeColorPickerSectionAtom,
-  ColorPickerType,
-} from "./colorPickerUtils";
+import type { ColorPickerType } from "./colorPickerUtils";
+import { activeColorPickerSectionAtom } from "./colorPickerUtils";
 import { useDevice, useExcalidrawContainer } from "../App";
-import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
+import type { ColorTuple, ColorPaletteCustom } from "../../colors";
+import { COLOR_PALETTE } from "../../colors";
 import PickerHeading from "./PickerHeading";
 import { t } from "../../i18n";
 import clsx from "clsx";

+ 3 - 3
packages/excalidraw/components/ColorPicker/Picker.tsx

@@ -1,7 +1,7 @@
 import React, { useEffect, useState } from "react";
 import { t } from "../../i18n";
 
-import { ExcalidrawElement } from "../../element/types";
+import type { ExcalidrawElement } from "../../element/types";
 import { ShadeList } from "./ShadeList";
 
 import PickerColorList from "./PickerColorList";
@@ -9,15 +9,15 @@ import { useAtom } from "jotai";
 import { CustomColorList } from "./CustomColorList";
 import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
 import PickerHeading from "./PickerHeading";
+import type { ColorPickerType } from "./colorPickerUtils";
 import {
-  ColorPickerType,
   activeColorPickerSectionAtom,
   getColorNameAndShadeFromColor,
   getMostUsedCustomColors,
   isCustomColor,
 } from "./colorPickerUtils";
+import type { ColorPaletteCustom } from "../../colors";
 import {
-  ColorPaletteCustom,
   DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
   DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
 } from "../../colors";

+ 3 - 2
packages/excalidraw/components/ColorPicker/PickerColorList.tsx

@@ -7,8 +7,9 @@ import {
   getColorNameAndShadeFromColor,
 } from "./colorPickerUtils";
 import HotkeyLabel from "./HotkeyLabel";
-import { ColorPaletteCustom } from "../../colors";
-import { TranslationKeys, t } from "../../i18n";
+import type { ColorPaletteCustom } from "../../colors";
+import type { TranslationKeys } from "../../i18n";
+import { t } from "../../i18n";
 
 interface PickerColorListProps {
   palette: ColorPaletteCustom;

+ 1 - 1
packages/excalidraw/components/ColorPicker/PickerHeading.tsx

@@ -1,4 +1,4 @@
-import { ReactNode } from "react";
+import type { ReactNode } from "react";
 
 const PickerHeading = ({ children }: { children: ReactNode }) => (
   <div className="color-picker__heading">{children}</div>

+ 1 - 1
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;

+ 1 - 1
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,

+ 3 - 6
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,

+ 4 - 5
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";

+ 137 - 0
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;
+    }
+  }
+}

+ 934 - 0
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<CommandPaletteItem | null>(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 (
+    <div className={clsx("shortcut", className)}>
+      {shortcuts.map((item, idx) => {
+        return (
+          <div className="shortcut-wrapper" key={item}>
+            <div className="shortcut-key">{item === "$" ? "+" : item}</div>
+          </div>
+        );
+      })}
+      <div className="shortcut-desc">{children}</div>
+    </div>
+  );
+};
+
+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 <CommandPaletteInner {...props} />;
+  },
+  {
+    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<CommandPaletteItem, "haystack" | "order">[] | null
+  >(null);
+
+  const inputRef = useRef<HTMLInputElement>(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<CommandPaletteItem | null>(null);
+  const [commandsByCategory, setCommandsByCategory] = useState<
+    Record<string, CommandPaletteItem[]>
+  >({});
+
+  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<string, CommandPaletteItem[]> = {};
+      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 (
+    <Dialog
+      onCloseRequest={() => closeCommandPalette()}
+      closeOnClickOutside
+      title={false}
+      size={720}
+      autofocus
+      className="command-palette-dialog"
+    >
+      <TextField
+        value={commandSearch}
+        placeholder={t("commandPalette.search.placeholder")}
+        onChange={(value) => {
+          setCommandSearch(value);
+        }}
+        selectOnRender
+        ref={inputRef}
+      />
+
+      {!app.device.viewport.isMobile && (
+        <div className="shortcuts-wrapper">
+          <CommandShortcutHint shortcut="↑↓">
+            {t("commandPalette.shortcuts.select")}
+          </CommandShortcutHint>
+          <CommandShortcutHint shortcut="↵">
+            {t("commandPalette.shortcuts.confirm")}
+          </CommandShortcutHint>
+          <CommandShortcutHint shortcut={getShortcutKey("Esc")}>
+            {t("commandPalette.shortcuts.close")}
+          </CommandShortcutHint>
+        </div>
+      )}
+
+      <div className="commands">
+        {lastUsed && !commandSearch && (
+          <div className="command-category">
+            <div className="command-category-title">
+              {t("commandPalette.recents")}
+              <div
+                className="icon"
+                style={{
+                  marginLeft: "6px",
+                }}
+              >
+                {clockIcon}
+              </div>
+            </div>
+            <CommandItem
+              command={lastUsed}
+              isSelected={lastUsed.label === currentCommand?.label}
+              onClick={(event) => executeCommand(lastUsed, event)}
+              disabled={!isCommandAvailable(lastUsed)}
+              onMouseMove={() => setCurrentCommand(lastUsed)}
+              showShortcut={!app.device.viewport.isMobile}
+              appState={uiAppState}
+            />
+          </div>
+        )}
+
+        {Object.keys(commandsByCategory).length > 0 ? (
+          Object.keys(commandsByCategory).map((category, idx) => {
+            return (
+              <div className="command-category" key={category}>
+                <div className="command-category-title">{category}</div>
+                {commandsByCategory[category].map((command) => (
+                  <CommandItem
+                    key={command.label}
+                    command={command}
+                    isSelected={command.label === currentCommand?.label}
+                    onClick={(event) => executeCommand(command, event)}
+                    onMouseMove={() => setCurrentCommand(command)}
+                    showShortcut={!app.device.viewport.isMobile}
+                    appState={uiAppState}
+                  />
+                ))}
+              </div>
+            );
+          })
+        ) : allCommands ? (
+          <div className="no-match">
+            <div className="icon">{searchIcon}</div>{" "}
+            {t("commandPalette.search.noMatch")}
+          </div>
+        ) : null}
+      </div>
+    </Dialog>
+  );
+}
+
+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 (
+    <div
+      className={clsx("command-item", {
+        "item-selected": isSelected,
+        "item-disabled": disabled,
+      })}
+      ref={(ref) => {
+        if (isSelected && !disabled) {
+          ref?.scrollIntoView?.({
+            block: "nearest",
+          });
+        }
+      }}
+      onClick={disabled ? noop : onClick}
+      onMouseMove={disabled ? noop : onMouseMove}
+      title={disabled ? t("commandPalette.itemNotAvailable") : ""}
+    >
+      <div className="name">
+        {command.icon && (
+          <InlineIcon
+            icon={
+              typeof command.icon === "function"
+                ? command.icon(appState)
+                : command.icon
+            }
+          />
+        )}
+        {command.label}
+      </div>
+      {showShortcut && command.shortcut && (
+        <CommandShortcutHint shortcut={command.shortcut} />
+      )}
+    </div>
+  );
+};

+ 11 - 0
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");
+  },
+};

+ 26 - 0
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;
+};

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov