feat: add onIncrement API (#9450)
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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,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]>,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user