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

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({