feat: add onIncrement API (#9450)

This commit is contained in:
Marcel Mraz
2025-05-06 19:23:02 +02:00
committed by GitHub
parent a7c61319dd
commit 3dc54a724a
63 changed files with 20173 additions and 19665 deletions
@@ -1,8 +1,9 @@
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
+2 -1
View File
@@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element/align";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element/align";
@@ -25,7 +27,6 @@ import {
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -33,6 +33,8 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { newElement } from "@excalidraw/element/newElement";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -44,8 +46,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
import type { AppState } from "../types";
+2 -1
View File
@@ -17,6 +17,8 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import {
@@ -44,7 +46,6 @@ import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -3,6 +3,8 @@ import { getTextFromElements } from "@excalidraw/element/textElement";
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import {
copyTextToSystemClipboard,
copyToClipboard,
@@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { register } from "./register";
@@ -1,11 +1,12 @@
import { isImageElement } from "@excalidraw/element/typeChecks";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton";
import { cropIcon } from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -17,11 +17,12 @@ import {
selectGroupsForSelectedElements,
} from "@excalidraw/element/groups";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
@@ -8,6 +8,8 @@ import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/fra
import { distributeElements } from "@excalidraw/element/distribute";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element/distribute";
@@ -21,7 +23,6 @@ import {
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -18,12 +18,13 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { duplicateElements } from "@excalidraw/element/duplicate";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -4,11 +4,12 @@ import {
getLinkIdAndTypeFromSelection,
} from "@excalidraw/element/elementLink";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { copyTextToSystemClipboard } from "../clipboard";
import { copyIcon, elementLinkIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -4,12 +4,13 @@ import { newElementWith } from "@excalidraw/element/mutateElement";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { LockedIcon, UnlockedIcon } from "../components/icons";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -1,7 +1,8 @@
import { updateActiveTool } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { setCursorForShape } from "../cursor";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
+2 -1
View File
@@ -7,6 +7,8 @@ import {
import { getNonDeletedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { Theme } from "@excalidraw/element/types";
import { useDevice } from "../components/App";
@@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getExportSize } from "../scene/export";
import { CaptureUpdateAction } from "../store";
import "../components/ToolIcon.scss";
@@ -16,11 +16,12 @@ import { isPathALoop } from "@excalidraw/element/shapes";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { t } from "../i18n";
import { resetCursor } from "../cursor";
import { done } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
+2 -1
View File
@@ -15,6 +15,8 @@ import {
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
@@ -24,7 +26,6 @@ import type {
} from "@excalidraw/element/types";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { flipHorizontal, flipVertical } from "../components/icons";
+2 -1
View File
@@ -14,12 +14,13 @@ import { getElementsInGroup } from "@excalidraw/element/groups";
import { getCommonBounds } from "@excalidraw/element/bounds";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { setCursorForShape } from "../cursor";
import { frameToolIcon } from "../components/icons";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
+2 -1
View File
@@ -28,6 +28,8 @@ import {
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
@@ -40,7 +42,6 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
+15 -17
View File
@@ -1,5 +1,9 @@
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { orderByFractionalIndex } from "@excalidraw/element/fractionalIndex";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton";
@@ -7,10 +11,8 @@ import { UndoIcon, RedoIcon } from "../components/icons";
import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import type { History } from "../history";
import type { Store } from "../store";
import type { AppClassProperties, AppState } from "../types";
import type { Action, ActionResult } from "./types";
@@ -35,7 +37,11 @@ const executeHistoryAction = (
}
const [nextElementsMap, nextAppState] = result;
const nextElements = Array.from(nextElementsMap.values());
// order by fractional indices in case the map was accidently modified in the meantime
const nextElements = orderByFractionalIndex(
Array.from(nextElementsMap.values()),
);
return {
appState: nextAppState,
@@ -47,9 +53,9 @@ const executeHistoryAction = (
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
};
type ActionCreator = (history: History, store: Store) => Action;
type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history, store) => ({
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
label: "buttons.undo",
icon: UndoIcon,
@@ -57,11 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
viewMode: false,
perform: (elements, appState, value, app) =>
executeHistoryAction(app, appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
history.undo(arrayToMap(elements) as SceneElementsMap, appState),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
@@ -88,19 +90,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({
},
});
export const createRedoAction: ActionCreator = (history, store) => ({
export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
label: "buttons.redo",
icon: RedoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, _, app) =>
perform: (elements, appState, __, app) =>
executeHistoryAction(app, appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
@@ -4,6 +4,8 @@ import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
import { arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
@@ -11,7 +13,6 @@ import { ToolButton } from "../components/ToolButton";
import { lineEditorIcon } from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
+2 -1
View File
@@ -2,13 +2,14 @@ import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
import { KEYS, getShortcutKey } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { ToolButton } from "../components/ToolButton";
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
+2 -2
View File
@@ -4,12 +4,12 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { ToolButton } from "../components/ToolButton";
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
export const actionToggleCanvasMenu = register({
@@ -1,5 +1,7 @@
import clsx from "clsx";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import {
@@ -8,7 +10,6 @@ import {
microphoneMutedIcon,
} from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -54,6 +54,8 @@ import { hasStrokeColor } from "@excalidraw/element/comparisons";
import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { LocalPoint } from "@excalidraw/math";
import type {
@@ -70,6 +72,8 @@ import type {
import type Scene from "@excalidraw/element/Scene";
import type { CaptureUpdateActionType } from "@excalidraw/element/store";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
@@ -131,11 +135,9 @@ import {
getTargetElements,
isSomeElementSelected,
} from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
import type { CaptureUpdateActionType } from "../store";
import type { AppClassProperties, AppState, Primitive } from "../types";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -6,9 +6,9 @@ import { arrayToMap, KEYS } from "@excalidraw/common";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { CaptureUpdateAction } from "../store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { selectAllIcon } from "../components/icons";
+2 -1
View File
@@ -24,13 +24,14 @@ import {
redrawTextBoundingBox,
} from "@excalidraw/element/textElement";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
import { paintIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -5,8 +5,9 @@ import { measureText } from "@excalidraw/element/textMeasurements";
import { isTextElement } from "@excalidraw/element/typeChecks";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -1,7 +1,8 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { gridIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -1,7 +1,8 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { magnetIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -5,8 +5,9 @@ import {
DEFAULT_SIDEBAR,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { searchIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -1,3 +1,5 @@
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import {
@@ -5,7 +7,6 @@ import {
convertElementTypePopupAtom,
} from "../components/ConvertElementTypePopup";
import { editorJotaiStore } from "../editor-jotai";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -1,7 +1,8 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { abacusIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -1,7 +1,8 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { eyeIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@@ -1,7 +1,8 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { coffeeIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
+2 -1
View File
@@ -7,6 +7,8 @@ import {
moveAllRight,
} from "@excalidraw/element/zindex";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import {
BringForwardIcon,
BringToFrontIcon,
@@ -14,7 +16,6 @@ import {
SendToBackIcon,
} from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
+2 -1
View File
@@ -3,7 +3,8 @@ import type {
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type { CaptureUpdateActionType } from "../store";
import type { CaptureUpdateActionType } from "@excalidraw/element/store";
import type {
AppClassProperties,
AppState,
File diff suppressed because it is too large Load Diff
+71 -74
View File
@@ -101,6 +101,7 @@ import {
type EXPORT_IMAGE_TYPES,
randomInteger,
CLASSES,
Emitter,
} from "@excalidraw/common";
import {
@@ -303,6 +304,8 @@ import { isNonDeletedElement } from "@excalidraw/element";
import Scene from "@excalidraw/element/Scene";
import { Store, CaptureUpdateAction } from "@excalidraw/element/store";
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -331,6 +334,7 @@ import type {
ExcalidrawNonSelectionElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
SceneElementsMap,
} from "@excalidraw/element/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -454,9 +458,7 @@ import {
resetCursor,
setCursorForShape,
} from "../cursor";
import { Emitter } from "../emitter";
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { Store, CaptureUpdateAction } from "../store";
import { LaserTrails } from "../laser-trails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
@@ -761,8 +763,8 @@ class App extends React.Component<AppProps, AppState> {
this.renderer = new Renderer(this.scene);
this.visibleElements = [];
this.store = new Store();
this.history = new History();
this.store = new Store(this);
this.history = new History(this.store);
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
@@ -792,6 +794,7 @@ class App extends React.Component<AppProps, AppState> {
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
@@ -810,15 +813,11 @@ class App extends React.Component<AppProps, AppState> {
};
this.fonts = new Fonts(this.scene);
this.history = new History();
this.history = new History(this.store);
this.actionManager.registerAll(actions);
this.actionManager.registerAction(
createUndoAction(this.history, this.store),
);
this.actionManager.registerAction(
createRedoAction(this.history, this.store),
);
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
}
updateEditorAtom = <Value, Args extends unknown[], Result>(
@@ -1899,6 +1898,10 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getElementsIncludingDeleted();
};
public getSceneElementsMapIncludingDeleted = () => {
return this.scene.getElementsMapIncludingDeleted();
};
public getSceneElements = () => {
return this.scene.getNonDeletedElements();
};
@@ -2215,11 +2218,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
this.store.shouldCaptureIncrement();
}
this.store.scheduleAction(actionResult.captureUpdate);
let didUpdate = false;
@@ -2292,10 +2291,7 @@ class App extends React.Component<AppProps, AppState> {
didUpdate = true;
}
if (
!didUpdate &&
actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY
) {
if (!didUpdate) {
this.scene.triggerUpdate();
}
});
@@ -2547,10 +2543,19 @@ class App extends React.Component<AppProps, AppState> {
});
}
this.store.onStoreIncrementEmitter.on((increment) => {
this.history.record(increment.elementsChange, increment.appStateChange);
this.store.onDurableIncrementEmitter.on((increment) => {
this.history.record(increment.delta);
});
const { onIncrement } = this.props;
// per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation
if (onIncrement) {
this.store.onStoreIncrementEmitter.on((increment) => {
onIncrement(increment);
});
}
this.scene.onUpdate(this.triggerRender);
this.addEventListeners();
@@ -2610,6 +2615,7 @@ class App extends React.Component<AppProps, AppState> {
this.eraserTrail.stop();
this.onChangeEmitter.clear();
this.store.onStoreIncrementEmitter.clear();
this.store.onDurableIncrementEmitter.clear();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
@@ -2903,7 +2909,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) {
// defer so that the shouldCaptureIncrement flag isn't reset via current update
// defer so that the scheduleCapture 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
@@ -3358,7 +3364,7 @@ class App extends React.Component<AppProps, AppState> {
this.addMissingFiles(opts.files);
}
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
const nextElementsToSelect =
excludeElementsInFramesFromSelection(duplicatedElements);
@@ -3619,7 +3625,7 @@ class App extends React.Component<AppProps, AppState> {
PLAIN_PASTE_TOAST_SHOWN = true;
}
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
setAppState: React.Component<any, AppState>["setState"] = (
@@ -3975,51 +3981,37 @@ class App extends React.Component<AppProps, AppState> {
*/
captureUpdate?: SceneData["captureUpdate"];
}) => {
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
const { elements, appState, collaborators, captureUpdate } = sceneData;
if (
sceneData.captureUpdate &&
sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY
) {
const prevCommittedAppState = this.store.snapshot.appState;
const prevCommittedElements = this.store.snapshot.elements;
const nextElements = elements ? syncInvalidIndices(elements) : undefined;
const nextCommittedAppState = sceneData.appState
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
: prevCommittedAppState;
if (captureUpdate) {
const nextElementsMap = elements
? (arrayToMap(nextElements ?? []) as SceneElementsMap)
: undefined;
const nextCommittedElements = sceneData.elements
? this.store.filterUncomittedElements(
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
arrayToMap(nextElements), // We expect all (already reconciled) elements
)
: prevCommittedElements;
const nextAppState = appState
? // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
Object.assign({}, this.store.snapshot.appState, appState)
: undefined;
// WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
this.store.captureIncrement(
nextCommittedElements,
nextCommittedAppState,
);
} else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) {
this.store.updateSnapshot(
nextCommittedElements,
nextCommittedAppState,
);
}
this.store.scheduleMicroAction({
action: captureUpdate,
elements: nextElementsMap,
appState: nextAppState,
});
}
if (sceneData.appState) {
this.setState(sceneData.appState);
if (appState) {
this.setState(appState);
}
if (sceneData.elements) {
if (nextElements) {
this.scene.replaceAllElements(nextElements);
}
if (sceneData.collaborators) {
this.setState({ collaborators: sceneData.collaborators });
if (collaborators) {
this.setState({ collaborators });
}
},
);
@@ -4202,7 +4194,7 @@ class App extends React.Component<AppProps, AppState> {
direction: event.shiftKey ? "left" : "right",
})
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
}
if (conversionType) {
@@ -4519,7 +4511,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement.elementId !==
selectedElements[0].id
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
if (!isElbowArrow(selectedElement)) {
this.setState({
editingLinearElement: new LinearElementEditor(
@@ -4845,7 +4837,7 @@ class App extends React.Component<AppProps, AppState> {
} as const;
if (nextActiveTool.type === "freedraw") {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
if (nextActiveTool.type === "lasso") {
@@ -5062,7 +5054,7 @@ class App extends React.Component<AppProps, AppState> {
]);
}
if (!isDeleted || isExistingElement) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
flushSync(() => {
@@ -5475,7 +5467,7 @@ class App extends React.Component<AppProps, AppState> {
};
private startImageCropping = (image: ExcalidrawImageElement) => {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState({
croppingElementId: image.id,
});
@@ -5483,7 +5475,7 @@ class App extends React.Component<AppProps, AppState> {
private finishImageCropping = () => {
if (this.state.croppingElementId) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState({
croppingElementId: null,
});
@@ -5518,7 +5510,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElements[0].id) &&
!isElbowArrow(selectedElements[0])
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElements[0],
@@ -5546,7 +5538,7 @@ class App extends React.Component<AppProps, AppState> {
: -1;
if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
LinearElementEditor.deleteFixedSegment(
selectedElements[0],
this.scene,
@@ -5608,7 +5600,7 @@ class App extends React.Component<AppProps, AppState> {
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
if (selectedGroupId) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState((prevState) => ({
...prevState,
...selectGroupsForSelectedElements(
@@ -9131,7 +9123,7 @@ class App extends React.Component<AppProps, AppState> {
if (isLinearElement(newElement)) {
if (newElement!.points.length > 1) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
@@ -9404,7 +9396,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (resizingElement) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
@@ -9744,7 +9736,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedElementIds,
)
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
if (
@@ -9837,7 +9829,7 @@ class App extends React.Component<AppProps, AppState> {
this.elementsPendingErasure = new Set();
if (didChange) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.scene.replaceAllElements(elements);
}
};
@@ -10517,8 +10509,13 @@ class App extends React.Component<AppProps, AppState> {
// restore the fractional indices by mutating elements
syncInvalidIndices(elements.concat(ret.data.elements));
// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
this.store.updateSnapshot(arrayToMap(elements), this.state);
// don't capture and only update the store snapshot for old elements,
// otherwise we would end up with duplicated fractional indices on undo
this.store.scheduleMicroAction({
action: CaptureUpdateAction.NEVER,
elements: arrayToMap(elements) as SceneElementsMap,
appState: undefined,
});
this.setState({ isLoading: true });
this.syncActionResult({
@@ -5,11 +5,12 @@ import { EVENT, KEYS, cloneJSON } from "@excalidraw/common";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { CaptureUpdateAction } from "../../store";
import { useApp } from "../App";
import { InlineIcon } from "../InlineIcon";
+1 -1
View File
@@ -14,6 +14,7 @@ import {
resolvablePromise,
toValidURL,
Queue,
Emitter,
} from "@excalidraw/common";
import { hashElementsVersion, hashString } from "@excalidraw/element";
@@ -26,7 +27,6 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
import { atom, editorJotaiStore } from "../editor-jotai";
import { Emitter } from "../emitter";
import { AbortError } from "../errors";
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
import { t } from "../i18n";
-51
View File
@@ -1,51 +0,0 @@
import type { UnsubscribeCallback } from "./types";
type Subscriber<T extends any[]> = (...payload: T) => void;
export class Emitter<T extends any[] = []> {
public subscribers: Subscriber<T>[] = [];
/**
* Attaches subscriber
*
* @returns unsubscribe function
*/
on(...handlers: Subscriber<T>[] | Subscriber<T>[][]): UnsubscribeCallback {
const _handlers = handlers
.flat()
.filter((item) => typeof item === "function");
this.subscribers.push(..._handlers);
return () => this.off(_handlers);
}
once(...handlers: Subscriber<T>[] | Subscriber<T>[][]): UnsubscribeCallback {
const _handlers = handlers
.flat()
.filter((item) => typeof item === "function");
_handlers.push(() => detach());
const detach = this.on(..._handlers);
return detach;
}
off(...handlers: Subscriber<T>[] | Subscriber<T>[][]) {
const _handlers = handlers.flat();
this.subscribers = this.subscribers.filter(
(handler) => !_handlers.includes(handler),
);
}
trigger(...payload: T) {
for (const handler of this.subscribers) {
handler(...payload);
}
return this;
}
clear() {
this.subscribers = [];
}
}
+72 -98
View File
@@ -1,12 +1,17 @@
import { Emitter } from "@excalidraw/common";
import {
CaptureUpdateAction,
StoreChange,
StoreDelta,
type Store,
} from "@excalidraw/element/store";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { Emitter } from "./emitter";
import type { AppStateChange, ElementsChange } from "./change";
import type { Snapshot } from "./store";
import type { AppState } from "./types";
type HistoryStack = HistoryEntry[];
class HistoryEntry extends StoreDelta {}
export class HistoryChangedEvent {
constructor(
@@ -20,8 +25,8 @@ export class History {
[HistoryChangedEvent]
>();
private readonly undoStack: HistoryStack = [];
private readonly redoStack: HistoryStack = [];
public readonly undoStack: HistoryEntry[] = [];
public readonly redoStack: HistoryEntry[] = [];
public get isUndoStackEmpty() {
return this.undoStack.length === 0;
@@ -31,60 +36,52 @@ export class History {
return this.redoStack.length === 0;
}
constructor(private readonly store: Store) {}
public clear() {
this.undoStack.length = 0;
this.redoStack.length = 0;
}
/**
* Record a local change which will go into the history
* Record a non-empty local durable increment, which will go into the undo stack..
* Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
*/
public record(
elementsChange: ElementsChange,
appStateChange: AppStateChange,
) {
const entry = HistoryEntry.create(appStateChange, elementsChange);
if (!entry.isEmpty()) {
// we have the latest changes, no need to `applyLatest`, which is done within `History.push`
this.undoStack.push(entry.inverse());
if (!entry.elementsChange.isEmpty()) {
// don't reset redo stack on local appState changes,
// as a simple click (unselect) could lead to losing all the redo entries
// only reset on non empty elements changes!
this.redoStack.length = 0;
}
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
public record(delta: StoreDelta) {
if (delta.isEmpty() || delta instanceof HistoryEntry) {
return;
}
// construct history entry, so once it's emitted, it's not recorded again
const entry = HistoryEntry.inverse(delta);
this.undoStack.push(entry);
if (!entry.elements.isEmpty()) {
// don't reset redo stack on local appState changes,
// as a simple click (unselect) could lead to losing all the redo entries
// only reset on non empty elements changes!
this.redoStack.length = 0;
}
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
}
public undo(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
) {
public undo(elements: SceneElementsMap, appState: AppState) {
return this.perform(
elements,
appState,
snapshot,
() => History.pop(this.undoStack),
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
);
}
public redo(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
) {
public redo(elements: SceneElementsMap, appState: AppState) {
return this.perform(
elements,
appState,
snapshot,
() => History.pop(this.redoStack),
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
);
@@ -93,7 +90,6 @@ export class History {
private perform(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
pop: () => HistoryEntry | null,
push: (entry: HistoryEntry) => void,
): [SceneElementsMap, AppState] | void {
@@ -104,6 +100,10 @@ export class History {
return;
}
const action = CaptureUpdateAction.IMMEDIATELY;
let prevSnapshot = this.store.snapshot;
let nextElements = elements;
let nextAppState = appState;
let containsVisibleChange = false;
@@ -112,9 +112,29 @@ export class History {
while (historyEntry) {
try {
[nextElements, nextAppState, containsVisibleChange] =
historyEntry.applyTo(nextElements, nextAppState, snapshot);
StoreDelta.applyTo(
historyEntry,
nextElements,
nextAppState,
prevSnapshot,
);
const nextSnapshot = prevSnapshot.maybeClone(
action,
nextElements,
nextAppState,
);
// schedule immediate capture, so that it's emitted for the sync purposes
this.store.scheduleMicroAction({
action,
change: StoreChange.create(prevSnapshot, nextSnapshot),
delta: historyEntry,
});
prevSnapshot = nextSnapshot;
} finally {
// make sure to always push / pop, even if the increment is corrupted
// make sure to always push, even if the delta is corrupted
push(historyEntry);
}
@@ -135,7 +155,7 @@ export class History {
}
}
private static pop(stack: HistoryStack): HistoryEntry | null {
private static pop(stack: HistoryEntry[]): HistoryEntry | null {
if (!stack.length) {
return null;
}
@@ -150,63 +170,17 @@ export class History {
}
private static push(
stack: HistoryStack,
stack: HistoryEntry[],
entry: HistoryEntry,
prevElements: SceneElementsMap,
) {
const updatedEntry = entry.inverse().applyLatestChanges(prevElements);
const inversedEntry = HistoryEntry.inverse(entry);
const updatedEntry = HistoryEntry.applyLatestChanges(
inversedEntry,
prevElements,
"inserted",
);
return stack.push(updatedEntry);
}
}
export class HistoryEntry {
private constructor(
public readonly appStateChange: AppStateChange,
public readonly elementsChange: ElementsChange,
) {}
public static create(
appStateChange: AppStateChange,
elementsChange: ElementsChange,
) {
return new HistoryEntry(appStateChange, elementsChange);
}
public inverse(): HistoryEntry {
return new HistoryEntry(
this.appStateChange.inverse(),
this.elementsChange.inverse(),
);
}
public applyTo(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] =
this.elementsChange.applyTo(elements, snapshot.elements);
const [nextAppState, appStateContainsVisibleChange] =
this.appStateChange.applyTo(appState, nextElements);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`.
*/
public applyLatestChanges(elements: SceneElementsMap): HistoryEntry {
const updatedElementsChange =
this.elementsChange.applyLatestChanges(elements);
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
}
public isEmpty(): boolean {
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import type { Emitter } from "../emitter";
import type { Emitter } from "@excalidraw/common";
export const useEmitter = <TEvent extends unknown>(
emitter: Emitter<[TEvent]>,
+3 -1
View File
@@ -23,6 +23,7 @@ polyfill();
const ExcalidrawBase = (props: ExcalidrawProps) => {
const {
onChange,
onIncrement,
initialData,
excalidrawAPI,
isCollaborating = false,
@@ -114,6 +115,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
<InitializeApp langCode={langCode} theme={theme}>
<App
onChange={onChange}
onIncrement={onIncrement}
initialData={initialData}
excalidrawAPI={excalidrawAPI}
isCollaborating={isCollaborating}
@@ -266,7 +268,7 @@ export {
bumpVersion,
} from "@excalidraw/element/mutateElement";
export { CaptureUpdateAction } from "./store";
export { CaptureUpdateAction } from "@excalidraw/element/store";
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
-449
View File
@@ -1,449 +0,0 @@
import { isDevEnv, isShallowEqual, isTestEnv } from "@excalidraw/common";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { newElementWith } from "@excalidraw/element/mutateElement";
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
import type { ValueOf } from "@excalidraw/common/utility-types";
import { getDefaultAppState } from "./appState";
import { AppStateChange, ElementsChange } from "./change";
import { Emitter } from "./emitter";
import type { AppState, ObservedAppState } from "./types";
// hidden non-enumerable property for runtime checks
const hiddenObservedAppStateProp = "__observedAppState";
export const getObservedAppState = (appState: AppState): ObservedAppState => {
const observedAppState = {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
value: true,
enumerable: false,
});
return observedAppState;
};
const isObservedAppState = (
appState: AppState | ObservedAppState,
): appState is ObservedAppState =>
!!Reflect.get(appState, hiddenObservedAppStateProp);
export const CaptureUpdateAction = {
/**
* Immediately undoable.
*
* Use for updates which should be captured.
* Should be used for most of the local updates.
*
* These updates will _immediately_ make it to the local undo / redo stacks.
*/
IMMEDIATELY: "IMMEDIATELY",
/**
* Never undoable.
*
* Use for updates which should never be recorded, such as remote updates
* or scene initialization.
*
* These updates will _never_ make it to the local undo / redo stacks.
*/
NEVER: "NEVER",
/**
* Eventually undoable.
*
* Use for updates which should not be captured immediately - likely
* exceptions which are part of some async multi-step process. Otherwise, all
* such updates would end up being captured with the next
* `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene`
* or internally by the editor.
*
* These updates will _eventually_ make it to the local undo / redo stacks.
*/
EVENTUALLY: "EVENTUALLY",
} as const;
export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
/**
* Represent an increment to the Store.
*/
class StoreIncrementEvent {
constructor(
public readonly elementsChange: ElementsChange,
public readonly appStateChange: AppStateChange,
) {}
}
/**
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
*
* @experimental this interface is experimental and subject to change.
*/
export interface IStore {
onStoreIncrementEmitter: Emitter<[StoreIncrementEvent]>;
get snapshot(): Snapshot;
set snapshot(snapshot: Snapshot);
/**
* Use to schedule update of the snapshot, useful on updates for which we don't need to calculate increments (i.e. remote updates).
*/
shouldUpdateSnapshot(): void;
/**
* Use to schedule calculation of a store increment.
*/
shouldCaptureIncrement(): void;
/**
* Based on the scheduled operation, either only updates store snapshot or also calculates increment and emits the result as a `StoreIncrementEvent`.
*
* @emits StoreIncrementEvent when increment is calculated.
*/
commit(
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
): void;
/**
* Clears the store instance.
*/
clear(): void;
/**
* Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
*
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
*/
filterUncomittedElements(
prevElements: Map<string, OrderedExcalidrawElement>,
nextElements: Map<string, OrderedExcalidrawElement>,
): Map<string, OrderedExcalidrawElement>;
}
export class Store implements IStore {
public readonly onStoreIncrementEmitter = new Emitter<
[StoreIncrementEvent]
>();
private scheduledActions: Set<CaptureUpdateActionType> = new Set();
private _snapshot = Snapshot.empty();
public get snapshot() {
return this._snapshot;
}
public set snapshot(snapshot: Snapshot) {
this._snapshot = snapshot;
}
// TODO: Suspicious that this is called so many places. Seems error-prone.
public shouldCaptureIncrement = () => {
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
};
public shouldUpdateSnapshot = () => {
this.scheduleAction(CaptureUpdateAction.NEVER);
};
private scheduleAction = (action: CaptureUpdateActionType) => {
this.scheduledActions.add(action);
this.satisfiesScheduledActionsInvariant();
};
public commit = (
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
): void => {
try {
// Capture has precedence since it also performs update
if (this.scheduledActions.has(CaptureUpdateAction.IMMEDIATELY)) {
this.captureIncrement(elements, appState);
} else if (this.scheduledActions.has(CaptureUpdateAction.NEVER)) {
this.updateSnapshot(elements, appState);
}
} finally {
this.satisfiesScheduledActionsInvariant();
// Defensively reset all scheduled actions, potentially cleans up other runtime garbage
this.scheduledActions = new Set();
}
};
public captureIncrement = (
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
) => {
const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
// Optimisation, don't continue if nothing has changed
if (prevSnapshot !== nextSnapshot) {
// Calculate and record the changes based on the previous and next snapshot
const elementsChange = nextSnapshot.meta.didElementsChange
? ElementsChange.calculate(prevSnapshot.elements, nextSnapshot.elements)
: ElementsChange.empty();
const appStateChange = nextSnapshot.meta.didAppStateChange
? AppStateChange.calculate(prevSnapshot.appState, nextSnapshot.appState)
: AppStateChange.empty();
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
// Notify listeners with the increment
this.onStoreIncrementEmitter.trigger(
new StoreIncrementEvent(elementsChange, appStateChange),
);
}
// Update snapshot
this.snapshot = nextSnapshot;
}
};
public updateSnapshot = (
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
) => {
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
if (this.snapshot !== nextSnapshot) {
// Update snapshot
this.snapshot = nextSnapshot;
}
};
public filterUncomittedElements = (
prevElements: Map<string, OrderedExcalidrawElement>,
nextElements: Map<string, OrderedExcalidrawElement>,
) => {
for (const [id, prevElement] of prevElements.entries()) {
const nextElement = nextElements.get(id);
if (!nextElement) {
// Nothing to care about here, elements were forcefully deleted
continue;
}
const elementSnapshot = this.snapshot.elements.get(id);
// Checks for in progress async user action
if (!elementSnapshot) {
// Detected yet uncomitted local element
nextElements.delete(id);
} else if (elementSnapshot.version < prevElement.version) {
// Element was already commited, but the snapshot version is lower than current current local version
nextElements.set(id, elementSnapshot);
}
}
return nextElements;
};
public clear = (): void => {
this.snapshot = Snapshot.empty();
this.scheduledActions = new Set();
};
private satisfiesScheduledActionsInvariant = () => {
if (!(this.scheduledActions.size >= 0 && this.scheduledActions.size <= 3)) {
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledActions.size}".`;
console.error(message, this.scheduledActions.values());
if (isTestEnv() || isDevEnv()) {
throw new Error(message);
}
}
};
}
export class Snapshot {
private constructor(
public readonly elements: Map<string, OrderedExcalidrawElement>,
public readonly appState: ObservedAppState,
public readonly meta: {
didElementsChange: boolean;
didAppStateChange: boolean;
isEmpty?: boolean;
} = {
didElementsChange: false,
didAppStateChange: false,
isEmpty: false,
},
) {}
public static empty() {
return new Snapshot(
new Map(),
getObservedAppState(getDefaultAppState() as AppState),
{ didElementsChange: false, didAppStateChange: false, isEmpty: true },
);
}
public isEmpty() {
return this.meta.isEmpty;
}
/**
* Efficiently clone the existing snapshot, only if we detected changes.
*
* @returns same instance if there are no changes detected, new instance otherwise.
*/
public maybeClone(
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
) {
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(elements);
const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(appState);
let didElementsChange = false;
let didAppStateChange = false;
if (this.elements !== nextElementsSnapshot) {
didElementsChange = true;
}
if (this.appState !== nextAppStateSnapshot) {
didAppStateChange = true;
}
if (!didElementsChange && !didAppStateChange) {
return this;
}
const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
didElementsChange,
didAppStateChange,
});
return snapshot;
}
private maybeCreateAppStateSnapshot(
appState: AppState | ObservedAppState | undefined,
) {
if (!appState) {
return this.appState;
}
// Not watching over everything from the app state, just the relevant props
const nextAppStateSnapshot = !isObservedAppState(appState)
? getObservedAppState(appState)
: appState;
const didAppStateChange = this.detectChangedAppState(nextAppStateSnapshot);
if (!didAppStateChange) {
return this.appState;
}
return nextAppStateSnapshot;
}
private detectChangedAppState(nextObservedAppState: ObservedAppState) {
return !isShallowEqual(this.appState, nextObservedAppState, {
selectedElementIds: isShallowEqual,
selectedGroupIds: isShallowEqual,
});
}
private maybeCreateElementsSnapshot(
elements: Map<string, OrderedExcalidrawElement> | undefined,
) {
if (!elements) {
return this.elements;
}
const didElementsChange = this.detectChangedElements(elements);
if (!didElementsChange) {
return this.elements;
}
const elementsSnapshot = this.createElementsSnapshot(elements);
return elementsSnapshot;
}
/**
* Detect if there any changed elements.
*
* NOTE: we shouldn't just use `sceneVersionNonce` instead, as we need to call this before the scene updates.
*/
private detectChangedElements(
nextElements: Map<string, OrderedExcalidrawElement>,
) {
if (this.elements === nextElements) {
return false;
}
if (this.elements.size !== nextElements.size) {
return true;
}
// loop from right to left as changes are likelier to happen on new elements
const keys = Array.from(nextElements.keys());
for (let i = keys.length - 1; i >= 0; i--) {
const prev = this.elements.get(keys[i]);
const next = nextElements.get(keys[i]);
if (
!prev ||
!next ||
prev.id !== next.id ||
prev.versionNonce !== next.versionNonce
) {
return true;
}
}
return false;
}
/**
* Perform structural clone, cloning only elements that changed.
*/
private createElementsSnapshot(
nextElements: Map<string, OrderedExcalidrawElement>,
) {
const clonedElements = new Map();
for (const [id, prevElement] of this.elements.entries()) {
// Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
// i.e. during collab, persist or whenenever isDeleted elements get cleared
if (!nextElements.get(id)) {
// When we cannot find the prev element in the next elements, we mark it as deleted
clonedElements.set(
id,
newElementWith(prevElement, { isDeleted: true }),
);
} else {
clonedElements.set(id, prevElement);
}
}
for (const [id, nextElement] of nextElements.entries()) {
const prevElement = clonedElements.get(id);
// At this point our elements are reconcilled already, meaning the next element is always newer
if (
!prevElement || // element was added
(prevElement && prevElement.versionNonce !== nextElement.versionNonce) // element was updated
) {
clonedElements.set(id, deepCopyElement(nextElement));
}
}
return clonedElements;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -44,7 +44,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"frameId": null,
"groupIds": [],
"height": 50,
"id": "id2",
"id": "id4",
"index": "a1",
"isDeleted": false,
"link": null,
@@ -108,7 +108,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id2",
"id": "id6",
"type": "arrow",
},
],
@@ -147,7 +147,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id2",
"id": "id6",
"type": "arrow",
},
],
@@ -156,7 +156,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"frameId": null,
"groupIds": [],
"height": 300,
"id": "id1",
"id": "id3",
"index": "a1",
"isDeleted": false,
"link": null,
@@ -189,7 +189,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id1",
"elementId": "id3",
"focus": "-0.46667",
"gap": 10,
},
@@ -197,7 +197,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"frameId": null,
"groupIds": [],
"height": "87.29887",
"id": "id2",
"id": "id6",
"index": "a2",
"isDeleted": false,
"lastCommittedPoint": null,
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,7 @@ import {
waitFor,
togglePopover,
unmountComponent,
checkpointHistory,
} from "./test-utils";
import type { ShortcutName } from "../actions/shortcuts";
@@ -33,11 +34,12 @@ const checkpoint = (name: string) => {
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
checkpointHistory(h.history, name);
};
const mouse = new Pointer("mouse");
+19 -17
View File
@@ -23,6 +23,10 @@ import {
import "@excalidraw/utils/test-utils";
import { ElementsDelta, AppStateDelta } from "@excalidraw/element/delta";
import { CaptureUpdateAction, StoreDelta } from "@excalidraw/element/store";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type {
@@ -46,11 +50,8 @@ import {
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import { getDefaultAppState } from "../appState";
import { HistoryEntry } from "../history";
import { Excalidraw } from "../index";
import * as StaticScene from "../renderer/staticScene";
import { Snapshot, CaptureUpdateAction } from "../store";
import { AppStateChange, ElementsChange } from "../change";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
@@ -61,6 +62,7 @@ import {
render,
togglePopover,
getCloneByOrigId,
checkpointHistory,
} from "./test-utils";
import type { AppState } from "../types";
@@ -82,13 +84,15 @@ const checkpoint = (name: string) => {
...strippedAppState
} = h.state;
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
checkpointHistory(h.history, name);
};
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
@@ -116,12 +120,12 @@ describe("history", () => {
API.setElements([rect]);
const corrupedEntry = HistoryEntry.create(
AppStateChange.empty(),
ElementsChange.empty(),
const corrupedEntry = StoreDelta.create(
ElementsDelta.empty(),
AppStateDelta.empty(),
);
vi.spyOn(corrupedEntry, "applyTo").mockImplementation(() => {
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
throw new Error("Oh no, I am corrupted!");
});
@@ -136,7 +140,6 @@ describe("history", () => {
h.history.undo(
arrayToMap(h.elements) as SceneElementsMap,
appState,
Snapshot.empty(),
) as any,
);
} catch (e) {
@@ -157,7 +160,6 @@ describe("history", () => {
h.history.redo(
arrayToMap(h.elements) as SceneElementsMap,
appState,
Snapshot.empty(),
) as any,
);
} catch (e) {
@@ -454,8 +456,8 @@ describe("history", () => {
expect(h.history.isUndoStackEmpty).toBeTruthy();
});
const undoAction = createUndoAction(h.history, h.store);
const redoAction = createRedoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
// noop
API.executeAction(undoAction);
expect(h.elements).toEqual([
@@ -531,8 +533,8 @@ describe("history", () => {
expect.objectContaining({ id: "B", isDeleted: false }),
]);
const undoAction = createUndoAction(h.history, h.store);
const redoAction = createRedoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
API.executeAction(undoAction);
expect(API.getSnapshot()).toEqual([
@@ -1713,8 +1715,8 @@ describe("history", () => {
/>,
);
const undoAction = createUndoAction(h.history, h.store);
const redoAction = createRedoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
@@ -1763,7 +1765,7 @@ describe("history", () => {
/>,
);
const undoAction = createUndoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
@@ -14,6 +14,7 @@ import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import {
assertSelectedElements,
checkpointHistory,
fireEvent,
render,
screen,
@@ -39,11 +40,12 @@ const checkpoint = (name: string) => {
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
checkpointHistory(h.history, name);
};
beforeEach(async () => {
unmountComponent();
+44
View File
@@ -22,6 +22,8 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
import { Pointer, UI } from "./helpers/ui";
import * as toolQueries from "./queries/toolQueries";
import type { History } from "../history";
import type { RenderResult, RenderOptions } from "@testing-library/react";
import type { ImportedDataState } from "../data/types";
@@ -432,3 +434,45 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
};
const stripSeed = (deltas: Record<string, { deleted: any; inserted: any }>) =>
Object.entries(deltas).reduce((acc, curr) => {
const { inserted, deleted, ...rest } = curr[1];
delete inserted.seed;
delete deleted.seed;
acc[curr[0]] = {
inserted,
deleted,
...rest,
};
return acc;
}, {} as Record<string, any>);
export const checkpointHistory = (history: History, name: string) => {
expect(
history.undoStack.map((x) => ({
...x,
elements: {
...x.elements,
added: stripSeed(x.elements.added),
removed: stripSeed(x.elements.removed),
updated: stripSeed(x.elements.updated),
},
})),
).toMatchSnapshot(`[${name}] undo stack`);
expect(
history.redoStack.map((x) => ({
...x,
elements: {
...x.elements,
added: stripSeed(x.elements.added),
removed: stripSeed(x.elements.removed),
updated: stripSeed(x.elements.updated),
},
})),
).toMatchSnapshot(`[${name}] redo stack`);
};
+10 -1
View File
@@ -43,6 +43,12 @@ import type {
MakeBrand,
} from "@excalidraw/common/utility-types";
import type {
CaptureUpdateActionType,
DurableIncrement,
EphemeralIncrement,
} from "@excalidraw/element/store";
import type { Action } from "./actions/types";
import type { Spreadsheet } from "./charts";
import type { ClipboardData } from "./clipboard";
@@ -51,7 +57,6 @@ import type Library from "./data/library";
import type { FileSystemHandle } from "./data/filesystem";
import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping";
import type { CaptureUpdateActionType } from "./store";
import type { ImportedDataState } from "./data/types";
import type { Language } from "./i18n";
@@ -518,6 +523,7 @@ export interface ExcalidrawProps {
appState: AppState,
files: BinaryFiles,
) => void;
onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
initialData?:
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
| MaybePromise<ExcalidrawInitialDataState | null>;
@@ -821,6 +827,9 @@ export interface ExcalidrawImperativeAPI {
files: BinaryFiles,
) => void,
) => UnsubscribeCallback;
onIncrement: (
callback: (event: DurableIncrement | EphemeralIncrement) => void,
) => UnsubscribeCallback;
onPointerDown: (
callback: (
activeTool: AppState["activeTool"],