From 530617be90df8d41f93c7c885d3097565c4c7f6d Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Wed, 17 Apr 2024 13:01:24 +0100 Subject: [PATCH] feat: multiplayer undo / redo (#7348) --- .../excalidraw/api/props/excalidraw-api.mdx | 4 +- excalidraw-app/App.tsx | 2 +- excalidraw-app/collab/Collab.tsx | 17 +- excalidraw-app/data/index.ts | 2 +- excalidraw-app/tests/collab.test.tsx | 192 +- packages/excalidraw/CHANGELOG.md | 11 +- .../excalidraw/actions/actionAddToLibrary.ts | 7 +- packages/excalidraw/actions/actionAlign.tsx | 13 +- .../excalidraw/actions/actionBoundText.tsx | 7 +- packages/excalidraw/actions/actionCanvas.tsx | 39 +- .../excalidraw/actions/actionClipboard.tsx | 27 +- .../actions/actionDeleteSelected.tsx | 11 +- .../excalidraw/actions/actionDistribute.tsx | 5 +- .../actions/actionDuplicateSelection.tsx | 12 +- .../excalidraw/actions/actionElementLock.ts | 5 +- packages/excalidraw/actions/actionExport.tsx | 26 +- .../excalidraw/actions/actionFinalize.tsx | 9 +- packages/excalidraw/actions/actionFlip.ts | 5 +- packages/excalidraw/actions/actionFrame.ts | 13 +- packages/excalidraw/actions/actionGroup.tsx | 31 +- packages/excalidraw/actions/actionHistory.tsx | 132 +- .../excalidraw/actions/actionLinearEditor.ts | 3 +- packages/excalidraw/actions/actionLink.tsx | 3 +- packages/excalidraw/actions/actionMenu.tsx | 7 +- .../excalidraw/actions/actionNavigate.tsx | 5 +- .../excalidraw/actions/actionProperties.tsx | 31 +- .../excalidraw/actions/actionSelectAll.ts | 3 +- packages/excalidraw/actions/actionStyles.ts | 7 +- .../actions/actionToggleGridMode.tsx | 3 +- .../actions/actionToggleObjectsSnapMode.tsx | 3 +- .../excalidraw/actions/actionToggleStats.tsx | 3 +- .../actions/actionToggleViewMode.tsx | 3 +- .../actions/actionToggleZenMode.tsx | 3 +- packages/excalidraw/actions/actionZindex.tsx | 9 +- packages/excalidraw/actions/manager.tsx | 6 +- packages/excalidraw/actions/types.ts | 8 +- packages/excalidraw/change.ts | 1529 ++ packages/excalidraw/components/Actions.scss | 1 + packages/excalidraw/components/App.tsx | 226 +- packages/excalidraw/components/ToolButton.tsx | 9 +- packages/excalidraw/components/ToolIcon.scss | 19 +- packages/excalidraw/constants.ts | 1 + packages/excalidraw/css/theme.scss | 4 + packages/excalidraw/css/variables.module.scss | 9 + packages/excalidraw/data/reconcile.ts | 2 +- packages/excalidraw/data/restore.ts | 2 +- packages/excalidraw/element/binding.ts | 497 +- packages/excalidraw/element/embeddable.ts | 3 +- .../excalidraw/element/linearElementEditor.ts | 6 +- packages/excalidraw/element/mutateElement.ts | 5 +- packages/excalidraw/element/sizeHelpers.ts | 3 + packages/excalidraw/element/textElement.ts | 8 +- packages/excalidraw/element/typeChecks.ts | 3 +- packages/excalidraw/element/types.ts | 12 +- packages/excalidraw/groups.ts | 18 + packages/excalidraw/history.ts | 377 +- packages/excalidraw/hooks/useEmitter.ts | 21 + packages/excalidraw/scene/Scene.ts | 8 +- packages/excalidraw/store.ts | 332 + .../__snapshots__/contextmenu.test.tsx.snap | 4594 ++-- .../tests/__snapshots__/history.test.tsx.snap | 18005 ++++++++++++++ .../tests/__snapshots__/move.test.tsx.snap | 10 +- .../regressionTests.test.tsx.snap | 19768 +++++++--------- .../excalidraw/tests/contextmenu.test.tsx | 2 +- packages/excalidraw/tests/helpers/api.ts | 13 +- packages/excalidraw/tests/helpers/ui.ts | 12 + packages/excalidraw/tests/history.test.tsx | 4625 +++- packages/excalidraw/tests/move.test.tsx | 20 +- .../excalidraw/tests/regressionTests.test.tsx | 19 +- packages/excalidraw/types.ts | 20 +- packages/excalidraw/utils.ts | 12 + 71 files changed, 35435 insertions(+), 15427 deletions(-) create mode 100644 packages/excalidraw/change.ts create mode 100644 packages/excalidraw/hooks/useEmitter.ts create mode 100644 packages/excalidraw/store.ts create mode 100644 packages/excalidraw/tests/__snapshots__/history.test.tsx.snap diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx index ffff19fb09..9f12c115d9 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx @@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git | API | Signature | Usage | | --- | --- | --- | | [updateScene](#updatescene) | `function` | updates the scene with the sceneData | -| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData | +| [updateLibrary](#updatelibrary) | `function` | updates the library | | [addFiles](#addfiles) | `function` | add files data to the appState | | [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. | | [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene | @@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the | `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene | | `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. | | `collaborators` | MapCollaborator> | The list of collaborators to be updated in the scene. | -| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. | +| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. | ```jsx live function App() { diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 90127d9873..7987915910 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -438,7 +438,7 @@ const ExcalidrawWrapper = () => { excalidrawAPI.updateScene({ ...data.scene, ...restore(data.scene, null, null, { repairBindings: true }), - commitToHistory: true, + commitToStore: true, }); } }); diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index def3810f90..1bf83da5e3 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -356,7 +356,6 @@ class Collab extends PureComponent { this.excalidrawAPI.updateScene({ elements, - commitToHistory: false, }); } }; @@ -501,14 +500,12 @@ class Collab extends PureComponent { } return element; }); - // remove deleted elements from elements array & history to ensure we don't + // remove deleted elements from elements array to ensure we don't // expose potentially sensitive user data in case user manually deletes // existing elements (or clears scene), which would otherwise be persisted // to database even if deleted before creating the room. - this.excalidrawAPI.history.clear(); this.excalidrawAPI.updateScene({ elements, - commitToHistory: true, }); this.saveCollabRoomToFirebase(getSyncableElements(elements)); @@ -544,9 +541,7 @@ class Collab extends PureComponent { const remoteElements = decryptedData.payload.elements; const reconciledElements = this._reconcileElements(remoteElements); - this.handleRemoteSceneUpdate(reconciledElements, { - init: true, - }); + this.handleRemoteSceneUpdate(reconciledElements); // noop if already resolved via init from firebase scenePromise.resolve({ elements: reconciledElements, @@ -745,19 +740,11 @@ class Collab extends PureComponent { private handleRemoteSceneUpdate = ( elements: ReconciledExcalidrawElement[], - { init = false }: { init?: boolean } = {}, ) => { this.excalidrawAPI.updateScene({ elements, - commitToHistory: !!init, }); - // 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(); }; diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 10c97fd205..6cf575412a 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -269,7 +269,7 @@ export const loadScene = async ( // in the scene database/localStorage, and instead fetch them async // from a different database files: data.files, - commitToHistory: false, + commitToStore: false, }; }; diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx index 2e2f1332a1..1fd8ecdbc6 100644 --- a/excalidraw-app/tests/collab.test.tsx +++ b/excalidraw-app/tests/collab.test.tsx @@ -1,13 +1,18 @@ 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 { newElementWith } from "../../packages/excalidraw"; const { h } = window; @@ -58,39 +63,188 @@ vi.mock("socket.io-client", () => { }; }); +/** + * These test would deserve to be extended by testing collab with (at least) two clients simultanouesly, + * while having access to both scenes, appstates stores, histories and etc. + * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. + */ describe("collaboration", () => { - it("creating room should reset deleted elements", async () => { + it("should allow to undo / redo even on force-deleted elements", async () => { await render(); - // To update the scene with deleted elements before starting collab + const rect1Props = { + type: "rectangle", + id: "A", + height: 200, + width: 100, + } as const; + + const rect2Props = { + type: "rectangle", + id: "B", + width: 100, + height: 200, + } as const; + + const rect1 = API.createElement({ ...rect1Props }); + const rect2 = API.createElement({ ...rect2Props }); + + updateSceneData({ + elements: syncInvalidIndices([rect1, rect2]), + commitToStore: true, + }); + updateSceneData({ elements: syncInvalidIndices([ - API.createElement({ type: "rectangle", id: "A" }), - API.createElement({ - type: "rectangle", - id: "B", - isDeleted: true, - }), + rect1, + newElementWith(h.elements[1], { isDeleted: true }), ]), + commitToStore: true, }); + 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(h.elements).toEqual([expect.objectContaining({ id: "A" })]); - expect(API.getStateHistory().length).toBe(1); + 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]), + }); + + 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 undoAction = createUndoAction(h.history); - // noop - h.app.actionManager.executeAction(undoAction); + 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 }), + ]), + commitToStore: true, + }); + + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), + ]); + }); + + act(() => h.app.actionManager.executeAction(undoAction)); + + // we expect to iterate the stack to the first visible change + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }), + ]); + }); + + // simulate force deleting the element remotely + updateSceneData({ + elements: syncInvalidIndices([rect1]), + }); + + // snapshot was correctly updated and marked the element as deleted + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }), + ]); + expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); + }); + + act(() => h.app.actionManager.executeAction(redoAction)); + + // with explicit redo (as update) we again restored the element from the snapshot! + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining({ id: "A", isDeleted: false }), + expect.objectContaining({ id: "B", isDeleted: true, x: 100 }), + ]); + expect(h.history.isRedoStackEmpty).toBeTruthy(); + expect(h.elements).toEqual([ + expect.objectContaining({ id: "A", isDeleted: false }), + expect.objectContaining({ id: "B", isDeleted: true, x: 100 }), + ]); }); }); }); diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index b6fcd36fa7..9c5d4f5642 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -15,9 +15,14 @@ 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). @@ -30,6 +35,10 @@ Please add the latest change on the top under the correct section. ### Breaking Changes +- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348). + +### Breaking Changes + - `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. @@ -92,8 +101,6 @@ define: { - Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343) ---- - ## 0.17.0 (2023-11-14) ### Features diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts index ccb7fad629..93fddf0c42 100644 --- a/packages/excalidraw/actions/actionAddToLibrary.ts +++ b/packages/excalidraw/actions/actionAddToLibrary.ts @@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement"; import { randomId } from "../random"; import { t } from "../i18n"; import { LIBRARY_DISABLED_TYPES } from "../constants"; +import { StoreAction } from "../store"; export const actionAddToLibrary = register({ name: "addToLibrary", @@ -17,7 +18,7 @@ export const actionAddToLibrary = register({ for (const type of LIBRARY_DISABLED_TYPES) { if (selectedElements.some((element) => element.type === type)) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t(`errors.libraryElementTypeError.${type}`), @@ -41,7 +42,7 @@ export const actionAddToLibrary = register({ }) .then(() => { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, toast: { message: t("toast.addedToLibrary") }, @@ -50,7 +51,7 @@ export const actionAddToLibrary = register({ }) .catch((error) => { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: error.message, diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index ddcb1415f3..179b3e1381 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -15,6 +15,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; import { KEYS } from "../keys"; import { isSomeElementSelected } from "../scene"; +import { StoreAction } from "../store"; import { AppClassProperties, AppState, UIAppState } from "../types"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; @@ -70,7 +71,7 @@ export const actionAlignTop = register({ position: "start", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -103,7 +104,7 @@ export const actionAlignBottom = register({ position: "end", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -136,7 +137,7 @@ export const actionAlignLeft = register({ position: "start", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -169,7 +170,7 @@ export const actionAlignRight = register({ position: "end", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -202,7 +203,7 @@ export const actionAlignVerticallyCentered = register({ position: "center", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => ( @@ -231,7 +232,7 @@ export const actionAlignHorizontallyCentered = register({ position: "center", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => ( diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 1fcf80fd02..7d04b1afa7 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -34,6 +34,7 @@ import { Mutable } from "../utility-types"; import { arrayToMap, getFontString } from "../utils"; import { register } from "./register"; import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; export const actionUnbindText = register({ name: "unbindText", @@ -85,7 +86,7 @@ export const actionUnbindText = register({ return { elements, appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); @@ -161,7 +162,7 @@ export const actionBindText = register({ return { elements: pushTextAboveContainer(elements, container, textElement), appState: { ...appState, selectedElementIds: { [container.id]: true } }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); @@ -320,7 +321,7 @@ export const actionWrapTextInContainer = register({ ...appState, selectedElementIds: containerIds, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 90492b321f..0503e50f75 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -10,7 +10,13 @@ import { 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 { t } from "../i18n"; @@ -31,6 +37,7 @@ import { import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { SceneBounds } from "../element/bounds"; import { setCursor } from "../cursor"; +import { StoreAction } from "../store"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", @@ -46,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 }) => { @@ -102,7 +111,7 @@ export const actionClearCanvas = register({ ? { ...appState.activeTool, type: "selection" } : appState.activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); @@ -127,16 +136,17 @@ export const actionZoomIn = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - PanelComponent: ({ updateData }) => ( + PanelComponent: ({ updateData, appState }) => ( = MAX_ZOOM} onClick={() => { updateData(null); }} @@ -167,16 +177,17 @@ export const actionZoomOut = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - PanelComponent: ({ updateData }) => ( + PanelComponent: ({ updateData, appState }) => ( { updateData(null); }} @@ -207,7 +218,7 @@ export const actionResetZoom = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ updateData, appState }) => ( @@ -282,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; let appStateWidth = appState.width; @@ -328,7 +339,7 @@ export const zoomToFitBounds = ({ scrollY, zoom: { value: newZoomValue }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }; @@ -447,7 +458,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, @@ -485,7 +496,7 @@ export const actionToggleEraserTool = register({ activeEmbeddable: null, activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event.key === KEYS.E, @@ -524,7 +535,7 @@ export const actionToggleHandTool = register({ activeEmbeddable: null, activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index bb488245c5..e4f998d016 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -14,6 +14,7 @@ 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", @@ -31,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, @@ -40,7 +41,7 @@ export const actionCopy = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, // don't supply a shortcut since we handle this conditionally via onCopy event @@ -66,7 +67,7 @@ export const actionPaste = register({ if (isFirefox) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("hints.firefox_clipboard_write"), @@ -75,7 +76,7 @@ export const actionPaste = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("errors.asyncPasteFailedOnRead"), @@ -88,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"), @@ -97,7 +98,7 @@ export const actionPaste = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, // don't supply a shortcut since we handle this conditionally via onCopy event @@ -124,7 +125,7 @@ export const actionCopyAsSvg = register({ perform: async (elements, appState, _data, app) => { if (!app.canvas) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } @@ -147,7 +148,7 @@ export const actionCopyAsSvg = register({ }, ); return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } catch (error: any) { console.error(error); @@ -156,7 +157,7 @@ export const actionCopyAsSvg = register({ ...appState, errorMessage: error.message, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, @@ -174,7 +175,7 @@ export const actionCopyAsPng = register({ perform: async (elements, appState, _data, app) => { if (!app.canvas) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } const selectedElements = app.scene.getSelectedElements({ @@ -208,7 +209,7 @@ export const actionCopyAsPng = register({ }), }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } catch (error: any) { console.error(error); @@ -217,7 +218,7 @@ export const actionCopyAsPng = register({ ...appState, errorMessage: error.message, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, @@ -252,7 +253,7 @@ export const copyText = register({ throw new Error(t("errors.copyToSystemClipboardFailed")); } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, predicate: (elements, appState, _, app) => { diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 602d737250..4ab6fa4115 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -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[], @@ -112,7 +113,7 @@ export const actionDeleteSelected = register({ ...nextAppState, editingLinearElement: null, }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; } @@ -144,7 +145,7 @@ export const actionDeleteSelected = register({ : [0], }, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } let { elements: nextElements, appState: nextAppState } = @@ -164,10 +165,12 @@ export const actionDeleteSelected = register({ multiElement: null, activeEmbeddable: null, }, - commitToHistory: isSomeElementSelected( + storeAction: isSomeElementSelected( getNonDeletedElements(elements), appState, - ), + ) + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, keyTest: (event, appState, elements) => diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index f3075e5a3c..522fbb305b 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -11,6 +11,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; import { CODES, KEYS } from "../keys"; import { isSomeElementSelected } from "../scene"; +import { StoreAction } from "../store"; import { AppClassProperties, AppState } from "../types"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; @@ -58,7 +59,7 @@ export const distributeHorizontally = register({ space: "between", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -89,7 +90,7 @@ export const distributeVertically = register({ space: "between", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 46d021a21e..0b4957f592 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -32,6 +32,7 @@ import { getSelectedElements, } from "../scene/selection"; import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; export const actionDuplicateSelection = register({ name: "duplicateSelection", @@ -54,13 +55,13 @@ export const actionDuplicateSelection = register({ return { elements, appState: ret.appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } return { ...duplicateElements(elements, appState), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, @@ -241,9 +242,10 @@ const duplicateElements = ( } // step (3) - const finalElements = finalElementsReversed.reverse(); - - syncMovedIndices(finalElements, arrayToMap([...oldElements, ...newElements])); + const finalElements = syncMovedIndices( + finalElementsReversed.reverse(), + arrayToMap(newElements), + ); // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts index 7200dca211..83600871eb 100644 --- a/packages/excalidraw/actions/actionElementLock.ts +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -4,6 +4,7 @@ import { isFrameLikeElement } from "../element/typeChecks"; import { ExcalidrawElement } from "../element/types"; import { KEYS } from "../keys"; import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; import { arrayToMap } from "../utils"; import { register } from "./register"; @@ -66,7 +67,7 @@ export const actionToggleElementLock = register({ ? null : appState.selectedLinearElement, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event, appState, elements, app) => { @@ -111,7 +112,7 @@ export const actionUnlockAllElements = register({ lockedElements.map((el) => [el.id, true]), ), }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, label: "labels.elementLock.unlockAll", diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index eaa1d514fd..7b767ecb82 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -19,13 +19,17 @@ import { nativeFileSystemSupported } from "../data/filesystem"; import { 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, app }) => ( { return { appState: { ...appState, exportScale: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ elements: allElements, appState, updateData }) => { @@ -94,7 +98,7 @@ export const actionChangeExportBackground = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, exportBackground: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( @@ -114,7 +118,7 @@ export const actionChangeExportEmbedScene = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, exportEmbedScene: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( @@ -156,7 +160,7 @@ export const actionSaveToActiveFile = register({ : await saveAsJSON(elements, appState, app.files, app.getName()); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, fileHandle, @@ -178,7 +182,7 @@ export const actionSaveToActiveFile = register({ } else { console.warn(error); } - return { commitToHistory: false }; + return { storeAction: StoreAction.NONE }; } }, keyTest: (event) => @@ -203,7 +207,7 @@ export const actionSaveFileToDisk = register({ app.getName(), ); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, openDialog: null, @@ -217,7 +221,7 @@ export const actionSaveFileToDisk = register({ } else { console.warn(error); } - return { commitToHistory: false }; + return { storeAction: StoreAction.NONE }; } }, keyTest: (event) => @@ -256,7 +260,7 @@ export const actionLoadScene = register({ elements: loadedElements, appState: loadedAppState, files, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } catch (error: any) { if (error?.name === "AbortError") { @@ -267,7 +271,7 @@ export const actionLoadScene = register({ elements, appState: { ...appState, errorMessage: error.message }, files: app.files, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, @@ -281,7 +285,7 @@ export const actionExportWithDarkMode = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, exportWithDarkMode: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 88ff366b64..e4b0861a6d 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -15,6 +15,7 @@ import { import { isBindingElement, isLinearElement } from "../element/typeChecks"; import { AppState } from "../types"; import { resetCursor } from "../cursor"; +import { StoreAction } from "../store"; export const actionFinalize = register({ name: "finalize", @@ -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, ); @@ -186,7 +190,8 @@ export const actionFinalize = register({ : appState.selectedLinearElement, pendingImageElementId: null, }, - commitToHistory: appState.activeTool.type === "freedraw", + // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event, appState) => diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index d821b200d9..565756f942 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -18,6 +18,7 @@ import { } from "../element/binding"; import { updateFrameMembershipOfSelectedElements } from "../frame"; import { flipHorizontal, flipVertical } from "../components/icons"; +import { StoreAction } from "../store"; export const actionFlipHorizontal = register({ name: "flipHorizontal", @@ -38,7 +39,7 @@ export const actionFlipHorizontal = register({ app, ), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event.shiftKey && event.code === CODES.H, @@ -63,7 +64,7 @@ export const actionFlipVertical = register({ app, ), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 019533c599..3471ed5b57 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -9,6 +9,7 @@ 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: UIAppState, @@ -44,14 +45,14 @@ export const actionSelectAllElementsInFrame = register({ return acc; }, {} as Record), }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; } return { elements, appState, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, predicate: (elements, appState, _, app) => @@ -75,14 +76,14 @@ export const actionRemoveAllElementsFromFrame = register({ [selectedElement.id]: true, }, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } return { elements, appState, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, predicate: (elements, appState, _, app) => @@ -104,7 +105,7 @@ export const actionupdateFrameRendering = register({ enabled: !appState.frameRendering.enabled, }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState: AppState) => appState.frameRendering.enabled, @@ -134,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({ type: "frame", }), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index cda66ae5aa..51f49cceab 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -17,7 +17,11 @@ import { import { getNonDeletedElements } from "../element"; import { randomId } from "../random"; import { ToolButton } from "../components/ToolButton"; -import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; +import { + ExcalidrawElement, + ExcalidrawTextElement, + OrderedExcalidrawElement, +} from "../element/types"; import { AppClassProperties, AppState } from "../types"; import { isBoundToContainer } from "../element/typeChecks"; import { @@ -28,6 +32,7 @@ import { replaceAllElementsInFrame, } from "../frame"; import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { if (elements.length >= 2) { @@ -72,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); @@ -92,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 }; } } @@ -134,19 +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), ); - const reorderedElements = [ - ...elementsBeforeGroup, - ...elementsInGroup, - ...elementsAfterGroup, - ]; - syncMovedIndices(reorderedElements, arrayToMap(elementsInGroup)); + const reorderedElements = syncMovedIndices( + [...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup], + arrayToMap(elementsInGroup), + ); return { appState: { @@ -158,7 +163,7 @@ export const actionGroup = register({ ), }, elements: reorderedElements, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, predicate: (elements, appState, _, app) => @@ -188,7 +193,7 @@ export const actionUngroup = register({ const elementsMap = arrayToMap(elements); if (groupIds.length === 0) { - return { appState, elements, commitToHistory: false }; + return { appState, elements, storeAction: StoreAction.NONE }; } let nextElements = [...elements]; @@ -261,7 +266,7 @@ export const actionUngroup = register({ return { appState: { ...appState, ...updateAppState }, elements: nextElements, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index fad4590034..05c832fd27 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -2,113 +2,117 @@ import { 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 { History, HistoryChangedEvent } from "../history"; import { 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 { syncInvalidIndices } from "../fractionalIndex"; +import { SceneElementsMap } from "../element/types"; +import { IStore, StoreAction } from "../store"; +import { useEmitter } from "../hooks/useEmitter"; const writeData = ( - prevElements: readonly ExcalidrawElement[], - appState: AppState, - updater: () => HistoryEntry | null, + appState: Readonly, + updater: () => [SceneElementsMap, AppState] | void, ): ActionResult => { - const commitToHistory = false; if ( !appState.multiElement && !appState.resizingElement && !appState.editingElement && !appState.draggingElement ) { - const data = updater(); - if (data === null) { - return { commitToHistory }; - } + const result = updater(); - 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); - // TODO: will be replaced in #7348 - syncInvalidIndices(elements); + 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: IStore) => Action; -export const createUndoAction: ActionCreator = (history) => ({ +export const createUndoAction: ActionCreator = (history, store) => ({ name: "undo", label: "buttons.undo", icon: UndoIcon, trackEvent: { category: "history" }, viewMode: false, perform: (elements, appState) => - writeData(elements, appState, () => history.undoOnce()), + writeData(appState, () => + history.undo( + arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` + appState, + store.snapshot, + ), + ), keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.Z && !event.shiftKey, - PanelComponent: ({ updateData, data }) => ( - - ), - commitToHistory: () => false, + PanelComponent: ({ updateData, data }) => { + const { isUndoStackEmpty } = useEmitter( + history.onHistoryChangedEmitter, + new HistoryChangedEvent(), + ); + + return ( + + ); + }, }); -export const createRedoAction: ActionCreator = (history) => ({ +export const createRedoAction: ActionCreator = (history, store) => ({ name: "redo", label: "buttons.redo", icon: RedoIcon, trackEvent: { category: "history" }, viewMode: false, perform: (elements, appState) => - writeData(elements, appState, () => history.redoOnce()), + writeData(appState, () => + history.redo( + arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` + appState, + store.snapshot, + ), + ), keyTest: (event) => (event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key.toLowerCase() === KEYS.Z) || (isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y), - PanelComponent: ({ updateData, data }) => ( - - ), - commitToHistory: () => false, + PanelComponent: ({ updateData, data }) => { + const { isRedoStackEmpty } = useEmitter( + history.onHistoryChangedEmitter, + new HistoryChangedEvent(), + ); + + return ( + + ); + }, }); diff --git a/packages/excalidraw/actions/actionLinearEditor.ts b/packages/excalidraw/actions/actionLinearEditor.ts index 5b76868f67..020df8b6f6 100644 --- a/packages/excalidraw/actions/actionLinearEditor.ts +++ b/packages/excalidraw/actions/actionLinearEditor.ts @@ -2,6 +2,7 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette" import { LinearElementEditor } from "../element/linearElementEditor"; import { isLinearElement } from "../element/typeChecks"; import { ExcalidrawLinearElement } from "../element/types"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleLinearEditor = register({ @@ -41,7 +42,7 @@ export const actionToggleLinearEditor = register({ ...appState, editingLinearElement, }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; }, }); diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx index 21e3a4e1a2..ae61974862 100644 --- a/packages/excalidraw/actions/actionLink.tsx +++ b/packages/excalidraw/actions/actionLink.tsx @@ -5,6 +5,7 @@ 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"; @@ -24,7 +25,7 @@ export const actionLink = register({ showHyperlinkPopup: "editor", openMenu: null, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, trackEvent: { category: "hyperlink", action: "click" }, diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx index 45a97eeba5..84a5d1be4a 100644 --- a/packages/excalidraw/actions/actionMenu.tsx +++ b/packages/excalidraw/actions/actionMenu.tsx @@ -4,6 +4,7 @@ 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", @@ -14,7 +15,7 @@ export const actionToggleCanvasMenu = register({ ...appState, openMenu: appState.openMenu === "canvas" ? null : "canvas", }, - commitToHistory: false, + storeAction: StoreAction.NONE, }), PanelComponent: ({ appState, updateData }) => ( ( event.key === KEYS.QUESTION_MARK, diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index c601856571..9e401f4e2e 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -7,6 +7,7 @@ import { microphoneMutedIcon, } from "../components/icons"; import { t } from "../i18n"; +import { StoreAction } from "../store"; import { Collaborator } from "../types"; import { register } from "./register"; import clsx from "clsx"; @@ -27,7 +28,7 @@ export const actionGoToCollaborator = register({ ...appState, userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } @@ -41,7 +42,7 @@ export const actionGoToCollaborator = register({ // Close mobile menu openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ updateData, data, appState }) => { diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 562f04b35a..8ff2b40e73 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -96,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; @@ -231,7 +232,7 @@ const changeFontSize = ( ? [...newFontSizes][0] : fallbackValue ?? appState.currentItemFontSize, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }; @@ -261,7 +262,9 @@ export const actionChangeStrokeColor = register({ ...appState, ...value, }, - commitToHistory: !!value.currentItemStrokeColor, + storeAction: !!value.currentItemStrokeColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => ( @@ -305,7 +308,9 @@ export const actionChangeBackgroundColor = register({ ...appState, ...value, }, - commitToHistory: !!value.currentItemBackgroundColor, + storeAction: !!value.currentItemBackgroundColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => ( @@ -349,7 +354,7 @@ export const actionChangeFillStyle = register({ }), ), appState: { ...appState, currentItemFillStyle: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { @@ -422,7 +427,7 @@ export const actionChangeStrokeWidth = register({ }), ), appState: { ...appState, currentItemStrokeWidth: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -477,7 +482,7 @@ export const actionChangeSloppiness = register({ }), ), appState: { ...appState, currentItemRoughness: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -528,7 +533,7 @@ export const actionChangeStrokeStyle = register({ }), ), appState: { ...appState, currentItemStrokeStyle: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -583,7 +588,7 @@ export const actionChangeOpacity = register({ true, ), appState: { ...appState, currentItemOpacity: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -758,7 +763,7 @@ export const actionChangeFontFamily = register({ ...appState, currentItemFontFamily: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -859,7 +864,7 @@ export const actionChangeTextAlign = register({ ...appState, currentItemTextAlign: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -949,7 +954,7 @@ export const actionChangeVerticalAlign = register({ appState: { ...appState, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -1030,7 +1035,7 @@ export const actionChangeRoundness = register({ ...appState, currentItemRoundness: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { @@ -1182,7 +1187,7 @@ export const actionChangeArrowhead = register({ ? "currentItemStartArrowhead" : "currentItemEndArrowhead"]: value.type, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index 2d682166f0..7cc7a0e28f 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -7,6 +7,7 @@ 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", @@ -50,7 +51,7 @@ export const actionSelectAll = register({ ? new LinearElementEditor(elements[0]) : null, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A, diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 8c0bc5370e..fa8c6b9a31 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -26,6 +26,7 @@ import { import { getSelectedElements } from "../scene"; import { ExcalidrawTextElement } from "../element/types"; import { paintIcon } from "../components/icons"; +import { StoreAction } from "../store"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; @@ -54,7 +55,7 @@ export const actionCopyStyles = register({ ...appState, toast: { message: t("toast.copyStyles") }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => @@ -71,7 +72,7 @@ export const actionPasteStyles = register({ 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, { @@ -160,7 +161,7 @@ export const actionPasteStyles = register({ } return element; }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx index 46e1879d96..da5ab6b440 100644 --- a/packages/excalidraw/actions/actionToggleGridMode.tsx +++ b/packages/excalidraw/actions/actionToggleGridMode.tsx @@ -3,6 +3,7 @@ import { register } from "./register"; import { GRID_SIZE } from "../constants"; import { AppState } from "../types"; import { gridIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionToggleGridMode = register({ name: "gridMode", @@ -21,7 +22,7 @@ export const actionToggleGridMode = register({ gridSize: this.checked!(appState) ? null : GRID_SIZE, objectsSnapModeEnabled: false, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState: AppState) => appState.gridSize !== null, diff --git a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx index 2f9a148c0b..586293d08b 100644 --- a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx +++ b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx @@ -1,5 +1,6 @@ import { magnetIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleObjectsSnapMode = register({ @@ -18,7 +19,7 @@ export const actionToggleObjectsSnapMode = register({ objectsSnapModeEnabled: !this.checked!(appState), gridSize: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.objectsSnapModeEnabled, diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index 74d0e0410e..fc1e70a47c 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -1,6 +1,7 @@ import { register } from "./register"; import { CODES, KEYS } from "../keys"; import { abacusIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionToggleStats = register({ name: "stats", @@ -15,7 +16,7 @@ export const actionToggleStats = register({ ...appState, showStats: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.showStats, diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx index f3c5e4da64..87dbb94ea8 100644 --- a/packages/excalidraw/actions/actionToggleViewMode.tsx +++ b/packages/excalidraw/actions/actionToggleViewMode.tsx @@ -1,5 +1,6 @@ import { eyeIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleViewMode = register({ @@ -18,7 +19,7 @@ export const actionToggleViewMode = register({ ...appState, viewModeEnabled: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.viewModeEnabled, diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx index fd397582a6..86261443f9 100644 --- a/packages/excalidraw/actions/actionToggleZenMode.tsx +++ b/packages/excalidraw/actions/actionToggleZenMode.tsx @@ -1,5 +1,6 @@ import { coffeeIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleZenMode = register({ @@ -18,7 +19,7 @@ export const actionToggleZenMode = register({ ...appState, zenModeEnabled: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.zenModeEnabled, diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx index 7b68a00d5e..7166888117 100644 --- a/packages/excalidraw/actions/actionZindex.tsx +++ b/packages/excalidraw/actions/actionZindex.tsx @@ -15,6 +15,7 @@ import { SendToBackIcon, } from "../components/icons"; import { isDarwin } from "../constants"; +import { StoreAction } from "../store"; export const actionSendBackward = register({ name: "sendBackward", @@ -25,7 +26,7 @@ export const actionSendBackward = register({ return { elements: moveOneLeft(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyPriority: 40, @@ -54,7 +55,7 @@ export const actionBringForward = register({ return { elements: moveOneRight(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyPriority: 40, @@ -83,7 +84,7 @@ export const actionSendToBack = register({ return { elements: moveAllLeft(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -120,7 +121,7 @@ export const actionBringToFront = register({ return { elements: moveAllRight(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx index 90dfe6088b..b5e36e855f 100644 --- a/packages/excalidraw/actions/manager.tsx +++ b/packages/excalidraw/actions/manager.tsx @@ -7,7 +7,7 @@ import { PanelComponentProps, ActionSource, } from "./types"; -import { ExcalidrawElement } from "../element/types"; +import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types"; import { AppClassProperties, AppState } from "../types"; import { trackEvent } from "../analytics"; import { isPromiseLike } from "../utils"; @@ -46,13 +46,13 @@ export class ActionManager { updater: (actionResult: ActionResult | Promise) => void; getAppState: () => Readonly; - getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; + getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[]; app: AppClassProperties; constructor( updater: UpdaterFn, getAppState: () => AppState, - getElementsIncludingDeleted: () => readonly ExcalidrawElement[], + getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[], app: AppClassProperties, ) { this.updater = (actionResult) => { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 18503363f7..e904bfa022 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -1,5 +1,5 @@ import React from "react"; -import { ExcalidrawElement } from "../element/types"; +import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types"; import { AppClassProperties, AppState, @@ -8,6 +8,7 @@ import { UIAppState, } from "../types"; import { MarkOptional } from "../utility-types"; +import { StoreAction } from "../store"; export type ActionSource = | "ui" @@ -25,14 +26,13 @@ export type ActionResult = "offsetTop" | "offsetLeft" | "width" | "height" > | null; files?: BinaryFiles | null; - commitToHistory: boolean; - syncHistory?: boolean; + storeAction: keyof typeof StoreAction; replaceFiles?: boolean; } | false; type ActionFn = ( - elements: readonly ExcalidrawElement[], + elements: readonly OrderedExcalidrawElement[], appState: Readonly, formData: any, app: AppClassProperties, diff --git a/packages/excalidraw/change.ts b/packages/excalidraw/change.ts new file mode 100644 index 0000000000..b8c88f54f7 --- /dev/null +++ b/packages/excalidraw/change.ts @@ -0,0 +1,1529 @@ +import { ENV } from "./constants"; +import { + BoundElement, + BindableElement, + BindableProp, + BindingProp, + bindingProperties, + updateBoundElements, +} from "./element/binding"; +import { LinearElementEditor } from "./element/linearElementEditor"; +import { + ElementUpdate, + mutateElement, + newElementWith, +} from "./element/mutateElement"; +import { + getBoundTextElementId, + redrawTextBoundingBox, +} from "./element/textElement"; +import { + hasBoundTextElement, + isBindableElement, + isBoundToContainer, + isTextElement, +} from "./element/typeChecks"; +import { + ExcalidrawElement, + ExcalidrawLinearElement, + ExcalidrawTextElement, + NonDeleted, + OrderedExcalidrawElement, + SceneElementsMap, +} from "./element/types"; +import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; +import { getNonDeletedGroupIds } from "./groups"; +import { getObservedAppState } from "./store"; +import { + AppState, + ObservedAppState, + ObservedElementsAppState, + ObservedStandaloneAppState, +} from "./types"; +import { SubtypeOf, ValueOf } from "./utility-types"; +import { + arrayToMap, + arrayToObject, + assertNever, + isShallowEqual, + toBrandedType, +} from "./utils"; + +/** + * Represents the difference between two objects of the same type. + * + * Both `deleted` and `inserted` partials represent the same set of added, removed or updated properties, where: + * - `deleted` is a set of all the deleted values + * - `inserted` is a set of all the inserted (added, updated) values + * + * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load. + */ +class Delta { + private constructor( + public readonly deleted: Partial, + public readonly inserted: Partial, + ) {} + + public static create( + deleted: Partial, + inserted: Partial, + modifier?: (delta: Partial) => Partial, + modifierOptions?: "deleted" | "inserted", + ) { + const modifiedDeleted = + modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted; + const modifiedInserted = + modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted; + + return new Delta(modifiedDeleted, modifiedInserted); + } + + /** + * Calculates the delta between two objects. + * + * @param prevObject - The previous state of the object. + * @param nextObject - The next state of the object. + * + * @returns new delta instance. + */ + public static calculate( + prevObject: T, + nextObject: T, + modifier?: (partial: Partial) => Partial, + postProcess?: ( + deleted: Partial, + inserted: Partial, + ) => [Partial, Partial], + ): Delta { + if (prevObject === nextObject) { + return Delta.empty(); + } + + const deleted = {} as Partial; + const inserted = {} as Partial; + + // O(n^3) here for elements, but it's not as bad as it looks: + // - we do this only on store recordings, not on every frame (not for ephemerals) + // - we do this only on previously detected changed elements + // - we do shallow compare only on the first level of properties (not going any deeper) + // - # of properties is reasonably small + for (const key of this.distinctKeysIterator( + "full", + prevObject, + nextObject, + )) { + deleted[key as keyof T] = prevObject[key]; + inserted[key as keyof T] = nextObject[key]; + } + + const [processedDeleted, processedInserted] = postProcess + ? postProcess(deleted, inserted) + : [deleted, inserted]; + + return Delta.create(processedDeleted, processedInserted, modifier); + } + + public static empty() { + return new Delta({}, {}); + } + + public static isEmpty(delta: Delta): boolean { + return ( + !Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length + ); + } + + /** + * Merges deleted and inserted object partials. + */ + public static mergeObjects( + prev: T, + added: T, + removed: T, + ) { + const cloned = { ...prev }; + + for (const key of Object.keys(removed)) { + delete cloned[key]; + } + + return { ...cloned, ...added }; + } + + /** + * Merges deleted and inserted array partials. + */ + public static mergeArrays( + prev: readonly T[] | null, + added: readonly T[] | null | undefined, + removed: readonly T[] | null | undefined, + predicate?: (value: T) => string, + ) { + return Object.values( + Delta.mergeObjects( + arrayToObject(prev ?? [], predicate), + arrayToObject(added ?? [], predicate), + arrayToObject(removed ?? [], predicate), + ), + ); + } + + /** + * Diff object partials as part of the `postProcess`. + */ + public static diffObjects>( + deleted: Partial, + inserted: Partial, + property: K, + setValue: (prevValue: V | undefined) => V, + ) { + if (!deleted[property] && !inserted[property]) { + return; + } + + if ( + typeof deleted[property] === "object" || + typeof inserted[property] === "object" + ) { + type RecordLike = Record; + + const deletedObject: RecordLike = deleted[property] ?? {}; + const insertedObject: RecordLike = inserted[property] ?? {}; + + const deletedDifferences = Delta.getLeftDifferences( + deletedObject, + insertedObject, + ).reduce((acc, curr) => { + acc[curr] = setValue(deletedObject[curr]); + return acc; + }, {} as RecordLike); + + const insertedDifferences = Delta.getRightDifferences( + deletedObject, + insertedObject, + ).reduce((acc, curr) => { + acc[curr] = setValue(insertedObject[curr]); + return acc; + }, {} as RecordLike); + + if ( + Object.keys(deletedDifferences).length || + Object.keys(insertedDifferences).length + ) { + Reflect.set(deleted, property, deletedDifferences); + Reflect.set(inserted, property, insertedDifferences); + } else { + Reflect.deleteProperty(deleted, property); + Reflect.deleteProperty(inserted, property); + } + } + } + + /** + * Diff array partials as part of the `postProcess`. + */ + public static diffArrays( + deleted: Partial, + inserted: Partial, + property: K, + groupBy: (value: V extends ArrayLike ? T : never) => string, + ) { + if (!deleted[property] && !inserted[property]) { + return; + } + + if (Array.isArray(deleted[property]) || Array.isArray(inserted[property])) { + const deletedArray = ( + Array.isArray(deleted[property]) ? deleted[property] : [] + ) as []; + const insertedArray = ( + Array.isArray(inserted[property]) ? inserted[property] : [] + ) as []; + + const deletedDifferences = arrayToObject( + Delta.getLeftDifferences( + arrayToObject(deletedArray, groupBy), + arrayToObject(insertedArray, groupBy), + ), + ); + const insertedDifferences = arrayToObject( + Delta.getRightDifferences( + arrayToObject(deletedArray, groupBy), + arrayToObject(insertedArray, groupBy), + ), + ); + + if ( + Object.keys(deletedDifferences).length || + Object.keys(insertedDifferences).length + ) { + const deletedValue = deletedArray.filter( + (x) => deletedDifferences[groupBy ? groupBy(x) : String(x)], + ); + const insertedValue = insertedArray.filter( + (x) => insertedDifferences[groupBy ? groupBy(x) : String(x)], + ); + + Reflect.set(deleted, property, deletedValue); + Reflect.set(inserted, property, insertedValue); + } else { + Reflect.deleteProperty(deleted, property); + Reflect.deleteProperty(inserted, property); + } + } + } + + /** + * Compares if object1 contains any different value compared to the object2. + */ + public static isLeftDifferent( + object1: T, + object2: T, + skipShallowCompare = false, + ): boolean { + const anyDistinctKey = this.distinctKeysIterator( + "left", + object1, + object2, + skipShallowCompare, + ).next().value; + + return !!anyDistinctKey; + } + + /** + * Compares if object2 contains any different value compared to the object1. + */ + public static isRightDifferent( + object1: T, + object2: T, + skipShallowCompare = false, + ): boolean { + const anyDistinctKey = this.distinctKeysIterator( + "right", + object1, + object2, + skipShallowCompare, + ).next().value; + + return !!anyDistinctKey; + } + + /** + * Returns all the object1 keys that have distinct values. + */ + public static getLeftDifferences( + object1: T, + object2: T, + skipShallowCompare = false, + ) { + return Array.from( + this.distinctKeysIterator("left", object1, object2, skipShallowCompare), + ); + } + + /** + * Returns all the object2 keys that have distinct values. + */ + public static getRightDifferences( + object1: T, + object2: T, + skipShallowCompare = false, + ) { + return Array.from( + this.distinctKeysIterator("right", object1, object2, skipShallowCompare), + ); + } + + /** + * Iterator comparing values of object properties based on the passed joining strategy. + * + * @yields keys of properties with different values + * + * WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that. + */ + private static *distinctKeysIterator( + join: "left" | "right" | "full", + object1: T, + object2: T, + skipShallowCompare = false, + ) { + if (object1 === object2) { + return; + } + + let keys: string[] = []; + + if (join === "left") { + keys = Object.keys(object1); + } else if (join === "right") { + keys = Object.keys(object2); + } else if (join === "full") { + keys = Array.from( + new Set([...Object.keys(object1), ...Object.keys(object2)]), + ); + } else { + assertNever( + join, + `Unknown distinctKeysIterator's join param "${join}"`, + true, + ); + } + + for (const key of keys) { + const object1Value = object1[key as keyof T]; + const object2Value = object2[key as keyof T]; + + if (object1Value !== object2Value) { + if ( + !skipShallowCompare && + typeof object1Value === "object" && + typeof object2Value === "object" && + object1Value !== null && + object2Value !== null && + isShallowEqual(object1Value, object2Value) + ) { + continue; + } + + yield key; + } + } + } +} + +/** + * Encapsulates the modifications captured as `Delta`/s. + */ +interface Change { + /** + * Inverses the `Delta`s inside while creating a new `Change`. + */ + inverse(): Change; + + /** + * Applies the `Change` to the previous object. + * + * @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change. + */ + applyTo(previous: T, ...options: unknown[]): [T, boolean]; + + /** + * Checks whether there are actually `Delta`s. + */ + isEmpty(): boolean; +} + +export class AppStateChange implements Change { + private constructor(private readonly delta: Delta) {} + + public static calculate( + prevAppState: T, + nextAppState: T, + ): AppStateChange { + const delta = Delta.calculate( + prevAppState, + nextAppState, + undefined, + AppStateChange.postProcess, + ); + + return new AppStateChange(delta); + } + + public static empty() { + return new AppStateChange(Delta.create({}, {})); + } + + public inverse(): AppStateChange { + const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted); + return new AppStateChange(inversedDelta); + } + + public applyTo( + appState: AppState, + nextElements: SceneElementsMap, + ): [AppState, boolean] { + try { + const { + selectedElementIds: removedSelectedElementIds = {}, + selectedGroupIds: removedSelectedGroupIds = {}, + } = this.delta.deleted; + + const { + selectedElementIds: addedSelectedElementIds = {}, + selectedGroupIds: addedSelectedGroupIds = {}, + selectedLinearElementId, + editingLinearElementId, + ...directlyApplicablePartial + } = this.delta.inserted; + + const mergedSelectedElementIds = Delta.mergeObjects( + appState.selectedElementIds, + addedSelectedElementIds, + removedSelectedElementIds, + ); + + const mergedSelectedGroupIds = Delta.mergeObjects( + appState.selectedGroupIds, + addedSelectedGroupIds, + removedSelectedGroupIds, + ); + + const selectedLinearElement = + selectedLinearElementId && nextElements.has(selectedLinearElementId) + ? new LinearElementEditor( + nextElements.get( + selectedLinearElementId, + ) as NonDeleted, + ) + : null; + + const editingLinearElement = + editingLinearElementId && nextElements.has(editingLinearElementId) + ? new LinearElementEditor( + nextElements.get( + editingLinearElementId, + ) as NonDeleted, + ) + : null; + + const nextAppState = { + ...appState, + ...directlyApplicablePartial, + selectedElementIds: mergedSelectedElementIds, + selectedGroupIds: mergedSelectedGroupIds, + selectedLinearElement: + typeof selectedLinearElementId !== "undefined" + ? selectedLinearElement // element was either inserted or deleted + : appState.selectedLinearElement, // otherwise assign what we had before + editingLinearElement: + typeof editingLinearElementId !== "undefined" + ? editingLinearElement // element was either inserted or deleted + : appState.editingLinearElement, // otherwise assign what we had before + }; + + const constainsVisibleChanges = this.filterInvisibleChanges( + appState, + nextAppState, + nextElements, + ); + + return [nextAppState, constainsVisibleChanges]; + } catch (e) { + // shouldn't really happen, but just in case + console.error(`Couldn't apply appstate change`, e); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + + return [appState, false]; + } + } + + public isEmpty(): boolean { + return Delta.isEmpty(this.delta); + } + + /** + * It is necessary to post process the partials in case of reference values, + * for which we need to calculate the real diff between `deleted` and `inserted`. + */ + private static postProcess( + deleted: Partial, + inserted: Partial, + ): [Partial, Partial] { + try { + Delta.diffObjects( + deleted, + inserted, + "selectedElementIds", + // ts language server has a bit trouble resolving this, so we are giving it a little push + (_) => true as ValueOf, + ); + Delta.diffObjects( + deleted, + inserted, + "selectedGroupIds", + (prevValue) => (prevValue ?? false) as ValueOf, + ); + } catch (e) { + // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it + console.error(`Couldn't postprocess appstate change deltas.`); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [deleted, inserted]; + } + } + + /** + * Mutates `nextAppState` be filtering out state related to deleted elements. + * + * @returns `true` if a visible change is found, `false` otherwise. + */ + private filterInvisibleChanges( + prevAppState: AppState, + nextAppState: AppState, + nextElements: SceneElementsMap, + ): boolean { + // TODO: #7348 we could still get an empty undo/redo, as we assume that previous appstate does not contain references to deleted elements + // which is not always true - i.e. now we do cleanup appstate during history, but we do not do it during remote updates + const prevObservedAppState = getObservedAppState(prevAppState); + const nextObservedAppState = getObservedAppState(nextAppState); + + const containsStandaloneDifference = Delta.isRightDifferent( + AppStateChange.stripElementsProps(prevObservedAppState), + AppStateChange.stripElementsProps(nextObservedAppState), + ); + + const containsElementsDifference = Delta.isRightDifferent( + AppStateChange.stripStandaloneProps(prevObservedAppState), + AppStateChange.stripStandaloneProps(nextObservedAppState), + ); + + if (!containsStandaloneDifference && !containsElementsDifference) { + // no change in appstate was detected + return false; + } + + const visibleDifferenceFlag = { + value: containsStandaloneDifference, + }; + + if (containsElementsDifference) { + // filter invisible changes on each iteration + const changedElementsProps = Delta.getRightDifferences( + AppStateChange.stripStandaloneProps(prevObservedAppState), + AppStateChange.stripStandaloneProps(nextObservedAppState), + ) as Array; + + let nonDeletedGroupIds = new Set(); + + if ( + changedElementsProps.includes("editingGroupId") || + changedElementsProps.includes("selectedGroupIds") + ) { + // this one iterates through all the non deleted elements, so make sure it's not done twice + nonDeletedGroupIds = getNonDeletedGroupIds(nextElements); + } + + // check whether delta properties are related to the existing non-deleted elements + for (const key of changedElementsProps) { + switch (key) { + case "selectedElementIds": + nextAppState[key] = AppStateChange.filterSelectedElements( + nextAppState[key], + nextElements, + visibleDifferenceFlag, + ); + + break; + case "selectedGroupIds": + nextAppState[key] = AppStateChange.filterSelectedGroups( + nextAppState[key], + nonDeletedGroupIds, + visibleDifferenceFlag, + ); + + break; + case "editingGroupId": + const editingGroupId = nextAppState[key]; + + if (!editingGroupId) { + // previously there was an editingGroup (assuming visible), now there is none + visibleDifferenceFlag.value = true; + } else if (nonDeletedGroupIds.has(editingGroupId)) { + // previously there wasn't an editingGroup, now there is one which is visible + visibleDifferenceFlag.value = true; + } else { + // there was assigned an editingGroup now, but it's related to deleted element + nextAppState[key] = null; + } + + break; + case "selectedLinearElementId": + case "editingLinearElementId": + const appStateKey = AppStateChange.convertToAppStateKey(key); + const linearElement = nextAppState[appStateKey]; + + if (!linearElement) { + // previously there was a linear element (assuming visible), now there is none + visibleDifferenceFlag.value = true; + } else { + const element = nextElements.get(linearElement.elementId); + + if (element && !element.isDeleted) { + // previously there wasn't a linear element, now there is one which is visible + visibleDifferenceFlag.value = true; + } else { + // there was assigned a linear element now, but it's deleted + nextAppState[appStateKey] = null; + } + } + + break; + default: { + assertNever( + key, + `Unknown ObservedElementsAppState's key "${key}"`, + true, + ); + } + } + } + } + + return visibleDifferenceFlag.value; + } + + private static convertToAppStateKey( + key: keyof Pick< + ObservedElementsAppState, + "selectedLinearElementId" | "editingLinearElementId" + >, + ): keyof Pick { + switch (key) { + case "selectedLinearElementId": + return "selectedLinearElement"; + case "editingLinearElementId": + return "editingLinearElement"; + } + } + + private static filterSelectedElements( + selectedElementIds: AppState["selectedElementIds"], + elements: SceneElementsMap, + visibleDifferenceFlag: { value: boolean }, + ) { + const ids = Object.keys(selectedElementIds); + + if (!ids.length) { + // previously there were ids (assuming related to visible elements), now there are none + visibleDifferenceFlag.value = true; + return selectedElementIds; + } + + const nextSelectedElementIds = { ...selectedElementIds }; + + for (const id of ids) { + const element = elements.get(id); + + if (element && !element.isDeleted) { + // there is a selected element id related to a visible element + visibleDifferenceFlag.value = true; + } else { + delete nextSelectedElementIds[id]; + } + } + + return nextSelectedElementIds; + } + + private static filterSelectedGroups( + selectedGroupIds: AppState["selectedGroupIds"], + nonDeletedGroupIds: Set, + visibleDifferenceFlag: { value: boolean }, + ) { + const ids = Object.keys(selectedGroupIds); + + if (!ids.length) { + // previously there were ids (assuming related to visible groups), now there are none + visibleDifferenceFlag.value = true; + return selectedGroupIds; + } + + const nextSelectedGroupIds = { ...selectedGroupIds }; + + for (const id of Object.keys(nextSelectedGroupIds)) { + if (nonDeletedGroupIds.has(id)) { + // there is a selected group id related to a visible group + visibleDifferenceFlag.value = true; + } else { + delete nextSelectedGroupIds[id]; + } + } + + return nextSelectedGroupIds; + } + + private static stripElementsProps( + delta: Partial, + ): Partial { + // WARN: Do not remove the type-casts as they here to ensure proper type checks + const { + editingGroupId, + selectedGroupIds, + selectedElementIds, + editingLinearElementId, + selectedLinearElementId, + ...standaloneProps + } = delta as ObservedAppState; + + return standaloneProps as SubtypeOf< + typeof standaloneProps, + ObservedStandaloneAppState + >; + } + + private static stripStandaloneProps( + delta: Partial, + ): Partial { + // WARN: Do not remove the type-casts as they here to ensure proper type checks + const { name, viewBackgroundColor, ...elementsProps } = + delta as ObservedAppState; + + return elementsProps as SubtypeOf< + typeof elementsProps, + ObservedElementsAppState + >; + } +} + +type ElementPartial = Omit, "seed">; + +/** + * Elements change is a low level primitive to capture a change between two sets of elements. + * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions. + */ +export class ElementsChange implements Change { + private constructor( + private readonly added: Map>, + private readonly removed: Map>, + private readonly updated: Map>, + ) {} + + public static create( + added: Map>, + removed: Map>, + updated: Map>, + options = { shouldRedistribute: false }, + ) { + let change: ElementsChange; + + if (options.shouldRedistribute) { + const nextAdded = new Map>(); + const nextRemoved = new Map>(); + const nextUpdated = new Map>(); + + const deltas = [...added, ...removed, ...updated]; + + for (const [id, delta] of deltas) { + if (this.satisfiesAddition(delta)) { + nextAdded.set(id, delta); + } else if (this.satisfiesRemoval(delta)) { + nextRemoved.set(id, delta); + } else { + nextUpdated.set(id, delta); + } + } + + change = new ElementsChange(nextAdded, nextRemoved, nextUpdated); + } else { + change = new ElementsChange(added, removed, updated); + } + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + ElementsChange.validate(change, "added", this.satisfiesAddition); + ElementsChange.validate(change, "removed", this.satisfiesRemoval); + ElementsChange.validate(change, "updated", this.satisfiesUpdate); + } + + return change; + } + + private static satisfiesAddition = ({ + deleted, + inserted, + }: Delta) => + // dissallowing added as "deleted", which could cause issues when resolving conflicts + deleted.isDeleted === true && !inserted.isDeleted; + + private static satisfiesRemoval = ({ + deleted, + inserted, + }: Delta) => + !deleted.isDeleted && inserted.isDeleted === true; + + private static satisfiesUpdate = ({ + deleted, + inserted, + }: Delta) => !!deleted.isDeleted === !!inserted.isDeleted; + + private static validate( + change: ElementsChange, + type: "added" | "removed" | "updated", + satifies: (delta: Delta) => boolean, + ) { + for (const [id, delta] of change[type].entries()) { + if (!satifies(delta)) { + console.error( + `Broken invariant for "${type}" delta, element "${id}", delta:`, + delta, + ); + throw new Error(`ElementsChange invariant broken for element "${id}".`); + } + } + } + + /** + * Calculates the `Delta`s between the previous and next set of elements. + * + * @param prevElements - Map representing the previous state of elements. + * @param nextElements - Map representing the next state of elements. + * + * @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements. + */ + public static calculate( + prevElements: Map, + nextElements: Map, + ): ElementsChange { + if (prevElements === nextElements) { + return ElementsChange.empty(); + } + + const added = new Map>(); + const removed = new Map>(); + const updated = new Map>(); + + // this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements + for (const prevElement of prevElements.values()) { + const nextElement = nextElements.get(prevElement.id); + + if (!nextElement) { + const deleted = { ...prevElement, isDeleted: false } as ElementPartial; + const inserted = { isDeleted: true } as ElementPartial; + + const delta = Delta.create( + deleted, + inserted, + ElementsChange.stripIrrelevantProps, + ); + + removed.set(prevElement.id, delta); + } + } + + for (const nextElement of nextElements.values()) { + const prevElement = prevElements.get(nextElement.id); + + if (!prevElement) { + const deleted = { isDeleted: true } as ElementPartial; + const inserted = { + ...nextElement, + isDeleted: false, + } as ElementPartial; + + const delta = Delta.create( + deleted, + inserted, + ElementsChange.stripIrrelevantProps, + ); + + added.set(nextElement.id, delta); + + continue; + } + + if (prevElement.versionNonce !== nextElement.versionNonce) { + const delta = Delta.calculate( + prevElement, + nextElement, + ElementsChange.stripIrrelevantProps, + ElementsChange.postProcess, + ); + + if ( + // making sure we don't get here some non-boolean values (i.e. undefined, null, etc.) + typeof prevElement.isDeleted === "boolean" && + typeof nextElement.isDeleted === "boolean" && + prevElement.isDeleted !== nextElement.isDeleted + ) { + // notice that other props could have been updated as well + if (prevElement.isDeleted && !nextElement.isDeleted) { + added.set(nextElement.id, delta); + } else { + removed.set(nextElement.id, delta); + } + + continue; + } + + // making sure there are at least some changes + if (!Delta.isEmpty(delta)) { + updated.set(nextElement.id, delta); + } + } + } + + return ElementsChange.create(added, removed, updated); + } + + public static empty() { + return ElementsChange.create(new Map(), new Map(), new Map()); + } + + public inverse(): ElementsChange { + const inverseInternal = (deltas: Map>) => { + const inversedDeltas = new Map>(); + + for (const [id, delta] of deltas.entries()) { + inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted)); + } + + return inversedDeltas; + }; + + const added = inverseInternal(this.added); + const removed = inverseInternal(this.removed); + const updated = inverseInternal(this.updated); + + // notice we inverse removed with added not to break the invariants + return ElementsChange.create(removed, added, updated); + } + + public isEmpty(): boolean { + return ( + this.added.size === 0 && + this.removed.size === 0 && + this.updated.size === 0 + ); + } + + /** + * Update delta/s based on the existing elements. + * + * @param elements current elements + * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated + * @returns new instance with modified delta/s + */ + public applyLatestChanges(elements: SceneElementsMap): ElementsChange { + const modifier = + (element: OrderedExcalidrawElement) => (partial: ElementPartial) => { + const latestPartial: { [key: string]: unknown } = {}; + + for (const key of Object.keys(partial) as Array) { + // do not update following props: + // - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys + switch (key) { + case "boundElements": + latestPartial[key] = partial[key]; + break; + default: + latestPartial[key] = element[key]; + } + } + + return latestPartial; + }; + + const applyLatestChangesInternal = ( + deltas: Map>, + ) => { + const modifiedDeltas = new Map>(); + + for (const [id, delta] of deltas.entries()) { + const existingElement = elements.get(id); + + if (existingElement) { + const modifiedDelta = Delta.create( + delta.deleted, + delta.inserted, + modifier(existingElement), + "inserted", + ); + + modifiedDeltas.set(id, modifiedDelta); + } else { + modifiedDeltas.set(id, delta); + } + } + + return modifiedDeltas; + }; + + const added = applyLatestChangesInternal(this.added); + const removed = applyLatestChangesInternal(this.removed); + const updated = applyLatestChangesInternal(this.updated); + + return ElementsChange.create(added, removed, updated, { + shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated + }); + } + + public applyTo( + elements: SceneElementsMap, + snapshot: Map, + ): [SceneElementsMap, boolean] { + let nextElements = toBrandedType(new Map(elements)); + let changedElements: Map; + + const flags = { + containsVisibleDifference: false, + containsZindexDifference: false, + }; + + // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) + try { + const applyDeltas = ElementsChange.createApplier( + nextElements, + snapshot, + flags, + ); + + const addedElements = applyDeltas(this.added); + const removedElements = applyDeltas(this.removed); + const updatedElements = applyDeltas(this.updated); + + const affectedElements = this.resolveConflicts(elements, nextElements); + + // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues + changedElements = new Map([ + ...addedElements, + ...removedElements, + ...updatedElements, + ...affectedElements, + ]); + } catch (e) { + console.error(`Couldn't apply elements change`, e); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + + // should not really happen, but just in case we cannot apply deltas, let's return the previous elements with visible change set to `true` + // even though there is obviously no visible change, returning `false` could be dangerous, as i.e.: + // in the worst case, it could lead into iterating through the whole stack with no possibility to redo + // instead, the worst case when returning `true` is an empty undo / redo + return [elements, true]; + } + + try { + // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state + ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements); + ElementsChange.redrawBoundArrows(nextElements, changedElements); + + // the following reorder performs also mutations, but only on new instances of changed elements + // (unless something goes really bad and it fallbacks to fixing all invalid indices) + nextElements = ElementsChange.reorderElements( + nextElements, + changedElements, + flags, + ); + } catch (e) { + console.error( + `Couldn't mutate elements after applying elements change`, + e, + ); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [nextElements, flags.containsVisibleDifference]; + } + } + + private static createApplier = ( + nextElements: SceneElementsMap, + snapshot: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => { + const getElement = ElementsChange.createGetter( + nextElements, + snapshot, + flags, + ); + + return (deltas: Map>) => + Array.from(deltas.entries()).reduce((acc, [id, delta]) => { + const element = getElement(id, delta.inserted); + + if (element) { + const newElement = ElementsChange.applyDelta(element, delta, flags); + nextElements.set(newElement.id, newElement); + acc.set(newElement.id, newElement); + } + + return acc; + }, new Map()); + }; + + private static createGetter = + ( + elements: SceneElementsMap, + snapshot: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => + (id: string, partial: ElementPartial) => { + let element = elements.get(id); + + if (!element) { + // always fallback to the local snapshot, in cases when we cannot find the element in the elements array + element = snapshot.get(id); + + if (element) { + // as the element was brought from the snapshot, it automatically results in a possible zindex difference + flags.containsZindexDifference = true; + + // as the element was force deleted, we need to check if adding it back results in a visible change + if ( + partial.isDeleted === false || + (partial.isDeleted !== true && element.isDeleted === false) + ) { + flags.containsVisibleDifference = true; + } + } + } + + return element; + }; + + private static applyDelta( + element: OrderedExcalidrawElement, + delta: Delta, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + } = { + // by default we don't care about about the flags + containsVisibleDifference: true, + containsZindexDifference: true, + }, + ) { + const { boundElements, ...directlyApplicablePartial } = delta.inserted; + + if ( + delta.deleted.boundElements?.length || + delta.inserted.boundElements?.length + ) { + const mergedBoundElements = Delta.mergeArrays( + element.boundElements, + delta.inserted.boundElements, + delta.deleted.boundElements, + (x) => x.id, + ); + + Object.assign(directlyApplicablePartial, { + boundElements: mergedBoundElements, + }); + } + + if (!flags.containsVisibleDifference) { + // strip away fractional as even if it would be different, it doesn't have to result in visible change + const { index, ...rest } = directlyApplicablePartial; + const containsVisibleDifference = + ElementsChange.checkForVisibleDifference(element, rest); + + flags.containsVisibleDifference = containsVisibleDifference; + } + + if (!flags.containsZindexDifference) { + flags.containsZindexDifference = + delta.deleted.index !== delta.inserted.index; + } + + return newElementWith(element, directlyApplicablePartial); + } + + /** + * Check for visible changes regardless of whether they were removed, added or updated. + */ + private static checkForVisibleDifference( + element: OrderedExcalidrawElement, + partial: ElementPartial, + ) { + if (element.isDeleted && partial.isDeleted !== false) { + // when it's deleted and partial is not false, it cannot end up with a visible change + return false; + } + + if (element.isDeleted && partial.isDeleted === false) { + // when we add an element, it results in a visible change + return true; + } + + if (element.isDeleted === false && partial.isDeleted) { + // when we remove an element, it results in a visible change + return true; + } + + // check for any difference on a visible element + return Delta.isRightDifferent(element, partial); + } + + /** + * Resolves conflicts for all previously added, removed and updated elements. + * Updates the previous deltas with all the changes after conflict resolution. + * + * @returns all elements affected by the conflict resolution + */ + private resolveConflicts( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + ) { + const nextAffectedElements = new Map(); + const updater = ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => { + const nextElement = nextElements.get(element.id); // only ever modify next element! + if (!nextElement) { + return; + } + + let affectedElement: OrderedExcalidrawElement; + + if (prevElements.get(element.id) === nextElement) { + // create the new element instance in case we didn't modify the element yet + // so that we won't end up in an incosistent state in case we would fail in the middle of mutations + affectedElement = newElementWith( + nextElement, + updates as ElementUpdate, + ); + } else { + affectedElement = mutateElement( + nextElement, + updates as ElementUpdate, + ); + } + + nextAffectedElements.set(affectedElement.id, affectedElement); + nextElements.set(affectedElement.id, affectedElement); + }; + + // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound + for (const [id] of this.removed) { + ElementsChange.unbindAffected(prevElements, nextElements, id, updater); + } + + // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound + for (const [id] of this.added) { + ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + } + + // updated delta is affecting the binding only in case it contains changed binding or bindable property + for (const [id] of Array.from(this.updated).filter(([_, delta]) => + Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) => + bindingProperties.has(prop as BindingProp | BindableProp), + ), + )) { + const updatedElement = nextElements.get(id); + if (!updatedElement || updatedElement.isDeleted) { + // skip fixing bindings for updates on deleted elements + continue; + } + + ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + } + + // filter only previous elements, which were now affected + const prevAffectedElements = new Map( + Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), + ); + + // calculate complete deltas for affected elements, and assign them back to all the deltas + // technically we could do better here if perf. would become an issue + const { added, removed, updated } = ElementsChange.calculate( + prevAffectedElements, + nextAffectedElements, + ); + + for (const [id, delta] of added) { + this.added.set(id, delta); + } + + for (const [id, delta] of removed) { + this.removed.set(id, delta); + } + + for (const [id, delta] of updated) { + this.updated.set(id, delta); + } + + return nextAffectedElements; + } + + /** + * Non deleted affected elements of removed elements (before and after applying delta), + * should be unbound ~ bindings should not point from non deleted into the deleted element/s. + */ + private static unbindAffected( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + id: string, + updater: ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => void, + ) { + // the instance could have been updated, so make sure we are passing the latest element to each function below + const prevElement = () => prevElements.get(id); // element before removal + const nextElement = () => nextElements.get(id); // element after removal + + BoundElement.unbindAffected(nextElements, prevElement(), updater); + BoundElement.unbindAffected(nextElements, nextElement(), updater); + + BindableElement.unbindAffected(nextElements, prevElement(), updater); + BindableElement.unbindAffected(nextElements, nextElement(), updater); + } + + /** + * Non deleted affected elements of added or updated element/s (before and after applying delta), + * should be rebound (if possible) with the current element ~ bindings should be bidirectional. + */ + private static rebindAffected( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + id: string, + updater: ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => void, + ) { + // the instance could have been updated, so make sure we are passing the latest element to each function below + const prevElement = () => prevElements.get(id); // element before addition / update + const nextElement = () => nextElements.get(id); // element after addition / update + + BoundElement.unbindAffected(nextElements, prevElement(), updater); + BoundElement.rebindAffected(nextElements, nextElement(), updater); + + BindableElement.unbindAffected( + nextElements, + prevElement(), + (element, updates) => { + // we cannot rebind arrows with bindable element so we don't unbind them at all during rebind (we still need to unbind them on removal) + // TODO: #7348 add startBinding / endBinding to the `BoundElement` context so that we could rebind arrows and remove this condition + if (isTextElement(element)) { + updater(element, updates); + } + }, + ); + BindableElement.rebindAffected(nextElements, nextElement(), updater); + } + + private static redrawTextBoundingBoxes( + elements: SceneElementsMap, + changed: Map, + ) { + const boxesToRedraw = new Map< + string, + { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement } + >(); + + for (const element of changed.values()) { + if (isBoundToContainer(element)) { + const { containerId } = element as ExcalidrawTextElement; + const container = containerId ? elements.get(containerId) : undefined; + + if (container) { + boxesToRedraw.set(container.id, { + container, + boundText: element as ExcalidrawTextElement, + }); + } + } + + if (hasBoundTextElement(element)) { + const boundTextElementId = getBoundTextElementId(element); + const boundText = boundTextElementId + ? elements.get(boundTextElementId) + : undefined; + + if (boundText) { + boxesToRedraw.set(element.id, { + container: element, + boundText: boundText as ExcalidrawTextElement, + }); + } + } + } + + for (const { container, boundText } of boxesToRedraw.values()) { + if (container.isDeleted || boundText.isDeleted) { + // skip redraw if one of them is deleted, as it would not result in a meaningful redraw + continue; + } + + redrawTextBoundingBox(boundText, container, elements, false); + } + } + + private static redrawBoundArrows( + elements: SceneElementsMap, + changed: Map, + ) { + for (const element of changed.values()) { + if (!element.isDeleted && isBindableElement(element)) { + updateBoundElements(element, elements); + } + } + } + + private static reorderElements( + elements: SceneElementsMap, + changed: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) { + if (!flags.containsZindexDifference) { + return elements; + } + + const previous = Array.from(elements.values()); + const reordered = orderByFractionalIndex([...previous]); + + if ( + !flags.containsVisibleDifference && + Delta.isRightDifferent(previous, reordered, true) + ) { + // we found a difference in order! + flags.containsVisibleDifference = true; + } + + // let's synchronize all invalid indices of moved elements + return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements; + } + + /** + * It is necessary to post process the partials in case of reference values, + * for which we need to calculate the real diff between `deleted` and `inserted`. + */ + private static postProcess( + deleted: ElementPartial, + inserted: ElementPartial, + ): [ElementPartial, ElementPartial] { + try { + Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); + } catch (e) { + // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it + console.error(`Couldn't postprocess elements change deltas.`); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [deleted, inserted]; + } + } + + private static stripIrrelevantProps( + partial: Partial, + ): ElementPartial { + const { id, updated, version, versionNonce, seed, ...strippedPartial } = + partial; + + return strippedPartial; + } +} diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index df0d73755d..5826628de1 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -12,6 +12,7 @@ font-size: 0.875rem !important; width: var(--lg-button-size); height: var(--lg-button-size); + svg { width: var(--lg-icon-size) !important; height: var(--lg-icon-size) !important; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 8ceb362a5e..f9c41074b7 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -183,6 +183,7 @@ import { ExcalidrawIframeElement, ExcalidrawEmbeddableElement, Ordered, + OrderedExcalidrawElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -194,7 +195,7 @@ import { isSelectedViaGroup, selectGroupsForSelectedElements, } from "../groups"; -import History from "../history"; +import { History } from "../history"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { CODES, @@ -278,11 +279,12 @@ import { muteFSAbortError, isTestEnv, easeOut, - arrayToMap, updateStable, addEventListener, normalizeEOL, getDateTime, + isShallowEqual, + arrayToMap, } from "../utils"; import { createSrcDoc, @@ -410,6 +412,7 @@ import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; import FollowMode from "./FollowMode/FollowMode"; +import { IStore, Store, StoreAction } from "../store"; import { AnimationFrameHandler } from "../animation-frame-handler"; import { AnimatedTrail } from "../animated-trail"; import { LaserTrails } from "../laser-trails"; @@ -540,6 +543,7 @@ class App extends React.Component { public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; public id: string; + private store: IStore; private history: History; private excalidrawContainerValue: { container: HTMLDivElement | null; @@ -665,6 +669,10 @@ class App extends React.Component { this.canvas = document.createElement("canvas"); this.rc = rough.canvas(this.canvas); this.renderer = new Renderer(this.scene); + + this.store = new Store(); + this.history = new History(); + if (excalidrawAPI) { const api: ExcalidrawImperativeAPI = { updateScene: this.updateScene, @@ -714,10 +722,14 @@ class App extends React.Component { onSceneUpdated: this.onSceneUpdated, }); this.history = new History(); - this.actionManager.registerAll(actions); - this.actionManager.registerAction(createUndoAction(this.history)); - this.actionManager.registerAction(createRedoAction(this.history)); + this.actionManager.registerAll(actions); + this.actionManager.registerAction( + createUndoAction(this.history, this.store), + ); + this.actionManager.registerAction( + createRedoAction(this.history, this.store), + ); } private onWindowMessage(event: MessageEvent) { @@ -2092,12 +2104,12 @@ class App extends React.Component { if (shouldUpdateStrokeColor) { this.syncActionResult({ appState: { ...this.state, currentItemStrokeColor: color }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } else { this.syncActionResult({ appState: { ...this.state, currentItemBackgroundColor: color }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } } else { @@ -2111,6 +2123,7 @@ class App extends React.Component { } return el; }), + commitToStore: true, }); } }, @@ -2135,10 +2148,14 @@ class App extends React.Component { editingElement = element; } }); - this.scene.replaceAllElements(actionResult.elements); - if (actionResult.commitToHistory) { - this.history.resumeRecording(); + + if (actionResult.storeAction === StoreAction.UPDATE) { + this.store.shouldUpdateSnapshot(); + } else if (actionResult.storeAction === StoreAction.CAPTURE) { + this.store.shouldCaptureIncrement(); } + + this.scene.replaceAllElements(actionResult.elements); } if (actionResult.files) { @@ -2149,8 +2166,10 @@ class App extends React.Component { } if (actionResult.appState || editingElement || this.state.contextMenu) { - if (actionResult.commitToHistory) { - this.history.resumeRecording(); + if (actionResult.storeAction === StoreAction.UPDATE) { + this.store.shouldUpdateSnapshot(); + } else if (actionResult.storeAction === StoreAction.CAPTURE) { + this.store.shouldCaptureIncrement(); } let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false; @@ -2180,34 +2199,24 @@ class App extends React.Component { editingElement = null; } - this.setState( - (state) => { - // using Object.assign instead of spread to fool TS 4.2.2+ into - // regarding the resulting type as not containing undefined - // (which the following expression will never contain) - return Object.assign(actionResult.appState || {}, { - // NOTE this will prevent opening context menu using an action - // or programmatically from the host, so it will need to be - // rewritten later - contextMenu: null, - editingElement, - viewModeEnabled, - zenModeEnabled, - gridSize, - theme, - name, - errorMessage, - }); - }, - () => { - if (actionResult.syncHistory) { - this.history.setCurrentState( - this.state, - this.scene.getElementsIncludingDeleted(), - ); - } - }, - ); + this.setState((state) => { + // using Object.assign instead of spread to fool TS 4.2.2+ into + // regarding the resulting type as not containing undefined + // (which the following expression will never contain) + return Object.assign(actionResult.appState || {}, { + // NOTE this will prevent opening context menu using an action + // or programmatically from the host, so it will need to be + // rewritten later + contextMenu: null, + editingElement, + viewModeEnabled, + zenModeEnabled, + gridSize, + theme, + name, + errorMessage, + }); + }); } }, ); @@ -2231,6 +2240,10 @@ class App extends React.Component { this.history.clear(); }; + private resetStore = () => { + this.store.clear(); + }; + /** * Resets scene & history. * ! Do not use to clear scene user action ! @@ -2243,6 +2256,7 @@ class App extends React.Component { isLoading: opts?.resetLoadingState ? false : state.isLoading, theme: this.state.theme, })); + this.resetStore(); this.resetHistory(); }, ); @@ -2327,10 +2341,11 @@ class App extends React.Component { // seems faster even in browsers that do fire the loadingdone event. this.fonts.loadFontsForElements(scene.elements); + this.resetStore(); this.resetHistory(); this.syncActionResult({ ...scene, - commitToHistory: true, + storeAction: StoreAction.UPDATE, }); }; @@ -2420,9 +2435,17 @@ class App extends React.Component { configurable: true, value: this.history, }, + store: { + configurable: true, + value: this.store, + }, }); } + this.store.onStoreIncrementEmitter.on((increment) => { + this.history.record(increment.elementsChange, increment.appStateChange); + }); + this.scene.addCallback(this.onSceneUpdated); this.addEventListeners(); @@ -2479,6 +2502,7 @@ class App extends React.Component { this.laserTrails.stop(); this.eraserTrail.stop(); this.onChangeEmitter.clear(); + this.store.onStoreIncrementEmitter.clear(); ShapeCache.destroy(); SnapCache.destroy(); clearTimeout(touchTimeout); @@ -2623,7 +2647,8 @@ class App extends React.Component { componentDidUpdate(prevProps: AppProps, prevState: AppState) { this.updateEmbeddables(); const elements = this.scene.getElementsIncludingDeleted(); - const elementsMap = this.scene.getNonDeletedElementsMap(); + const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap(); if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); @@ -2739,7 +2764,7 @@ class App extends React.Component { this.state.editingLinearElement && !this.state.selectedElementIds[this.state.editingLinearElement.elementId] ) { - // defer so that the commitToHistory flag isn't reset via current update + // defer so that the storeAction flag isn't reset via current update setTimeout(() => { // execute only if the condition still holds when the deferred callback // executes (it can be scheduled multiple times depending on how @@ -2778,13 +2803,14 @@ class App extends React.Component { LinearElementEditor.getPointAtIndexGlobalCoordinates( multiElement, -1, - elementsMap, + nonDeletedElementsMap, ), ), this, ); } - this.history.record(this.state, elements); + + this.store.capture(elementsMap, this.state); // Do not notify consumers if we're still loading the scene. Among other // potential issues, this fixes a case where the tab isn't focused during @@ -3154,7 +3180,7 @@ class App extends React.Component { this.files = { ...this.files, ...opts.files }; } - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); const nextElementsToSelect = excludeElementsInFramesFromSelection(newElements); @@ -3389,7 +3415,7 @@ class App extends React.Component { PLAIN_PASTE_TOAST_SHOWN = true; } - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } setAppState: React.Component["setState"] = ( @@ -3657,10 +3683,51 @@ class App extends React.Component { elements?: SceneData["elements"]; appState?: Pick | null; collaborators?: SceneData["collaborators"]; - commitToHistory?: SceneData["commitToHistory"]; + commitToStore?: SceneData["commitToStore"]; }) => { - if (sceneData.commitToHistory) { - this.history.resumeRecording(); + const nextElements = syncInvalidIndices(sceneData.elements ?? []); + + if (sceneData.commitToStore) { + this.store.shouldCaptureIncrement(); + } + + if (sceneData.elements || sceneData.appState) { + let nextCommittedAppState = this.state; + let nextCommittedElements: Map; + + if (sceneData.appState) { + nextCommittedAppState = { + ...this.state, + ...sceneData.appState, // Here we expect just partial appState + }; + } + + const prevElements = this.scene.getElementsIncludingDeleted(); + + if (sceneData.elements) { + /** + * We need to schedule a snapshot update, as in case `commitToStore` is false (i.e. remote update), + * as it's essential for computing local changes after the async action is completed (i.e. not to include remote changes in the diff). + * + * This is also a breaking change for all local `updateScene` calls without set `commitToStore` to true, + * as it makes such updates impossible to undo (previously they were undone coincidentally with the switch to the whole snapshot captured by the history). + * + * WARN: be careful here as moving it elsewhere could break the history for remote client without noticing + * - we need to find a way to test two concurrent client updates simultaneously, while having access to both stores & histories. + */ + this.store.shouldUpdateSnapshot(); + + // TODO#7348: deprecate once exchanging just store increments between clients + nextCommittedElements = this.store.ignoreUncomittedElements( + arrayToMap(prevElements), + arrayToMap(nextElements), + ); + } else { + nextCommittedElements = arrayToMap(prevElements); + } + + // WARN: Performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter + this.store.capture(nextCommittedElements, nextCommittedAppState); } if (sceneData.appState) { @@ -3668,7 +3735,7 @@ class App extends React.Component { } if (sceneData.elements) { - this.scene.replaceAllElements(sceneData.elements); + this.scene.replaceAllElements(nextElements); } if (sceneData.collaborators) { @@ -3896,7 +3963,7 @@ class App extends React.Component { this.state.editingLinearElement.elementId !== selectedElements[0].id ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.setState({ editingLinearElement: new LinearElementEditor( selectedElement, @@ -4308,7 +4375,7 @@ class App extends React.Component { ]); } if (!isDeleted || isExistingElement) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } this.setState({ @@ -4793,7 +4860,7 @@ class App extends React.Component { (!this.state.editingLinearElement || this.state.editingLinearElement.elementId !== selectedElements[0].id) ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.setState({ editingLinearElement: new LinearElementEditor(selectedElements[0]), }); @@ -4818,6 +4885,7 @@ class App extends React.Component { getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds); if (selectedGroupId) { + this.store.shouldCaptureIncrement(); this.setState((prevState) => ({ ...prevState, ...selectGroupsForSelectedElements( @@ -6300,7 +6368,7 @@ class App extends React.Component { const ret = LinearElementEditor.handlePointerDown( event, this.state, - this.history, + this.store, pointerDownState.origin, linearElementEditor, this, @@ -7848,7 +7916,7 @@ class App extends React.Component { if (isLinearElement(draggingElement)) { if (draggingElement!.points.length > 1) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } const pointerCoords = viewportCoordsToSceneCoords( childEvent, @@ -7917,14 +7985,16 @@ class App extends React.Component { isInvisiblySmallElement(draggingElement) ) { // remove invisible element which was added in onPointerDown - this.scene.replaceAllElements( - this.scene + // update the store snapshot, so that invisible elements are not captured by the store + this.updateScene({ + elements: this.scene .getElementsIncludingDeleted() .filter((el) => el.id !== draggingElement.id), - ); - this.setState({ - draggingElement: null, + appState: { + draggingElement: null, + }, }); + return; } @@ -8086,15 +8156,16 @@ class App extends React.Component { } if (resizingElement) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } if (resizingElement && isInvisiblySmallElement(resizingElement)) { - this.scene.replaceAllElements( - this.scene + // update the store snapshot, so that invisible elements are not captured by the store + this.updateScene({ + elements: this.scene .getElementsIncludingDeleted() .filter((el) => el.id !== resizingElement.id), - ); + }); } // handle frame membership for resizing frames and/or selected elements @@ -8395,9 +8466,13 @@ class App extends React.Component { if ( activeTool.type !== "selection" || - isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) + isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) || + !isShallowEqual( + this.state.previousSelectedElementIds, + this.state.selectedElementIds, + ) ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { @@ -8475,7 +8550,7 @@ class App extends React.Component { this.elementsPendingErasure = new Set(); if (didChange) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.scene.replaceAllElements(elements); } }; @@ -9038,7 +9113,7 @@ class App extends React.Component { isLoading: false, }, replaceFiles: true, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); return; } catch (error: any) { @@ -9118,12 +9193,13 @@ class App extends React.Component { ) => { file = await normalizeFile(file); try { + const elements = this.scene.getElementsIncludingDeleted(); let ret; try { ret = await loadSceneOrLibraryFromBlob( file, this.state, - this.scene.getElementsIncludingDeleted(), + elements, fileHandle, ); } catch (error: any) { @@ -9152,6 +9228,13 @@ class App extends React.Component { } if (ret.type === MIME_TYPES.excalidraw) { + // Restore the fractional indices by mutating elements and update the + // store snapshot, otherwise we would end up with duplicate indices + syncInvalidIndices(elements.concat(ret.data.elements)); + this.store.snapshot = this.store.snapshot.clone( + arrayToMap(elements), + this.state, + ); this.setState({ isLoading: true }); this.syncActionResult({ ...ret.data, @@ -9160,7 +9243,7 @@ class App extends React.Component { isLoading: false, }, replaceFiles: true, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } else if (ret.type === MIME_TYPES.excalidrawlib) { await this.library @@ -9770,6 +9853,7 @@ declare global { setState: React.Component["setState"]; app: InstanceType; history: History; + store: Store; }; } } diff --git a/packages/excalidraw/components/ToolButton.tsx b/packages/excalidraw/components/ToolButton.tsx index 2dace89d7b..e6d14ba08a 100644 --- a/packages/excalidraw/components/ToolButton.tsx +++ b/packages/excalidraw/components/ToolButton.tsx @@ -25,6 +25,7 @@ type ToolButtonBaseProps = { hidden?: boolean; visible?: boolean; selected?: boolean; + disabled?: boolean; className?: string; style?: CSSProperties; isLoading?: boolean; @@ -124,10 +125,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { type={type} onClick={onClick} ref={innerRef} - disabled={isLoading || props.isLoading} + disabled={isLoading || props.isLoading || !!props.disabled} > {(props.icon || props.label) && ( -