feat: multiplayer undo / redo (#7348)

This commit is contained in:
Marcel Mraz 2024-04-17 13:01:24 +01:00 committed by GitHub
parent 5211b003b8
commit 530617be90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 34885 additions and 14877 deletions

View file

@ -12,6 +12,7 @@
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size) !important;
height: var(--lg-icon-size) !important;

View file

@ -183,6 +183,7 @@ import {
ExcalidrawIframeElement,
ExcalidrawEmbeddableElement,
Ordered,
OrderedExcalidrawElement,
} from "../element/types";
import { getCenter, getDistance } from "../gesture";
import {
@ -194,7 +195,7 @@ import {
isSelectedViaGroup,
selectGroupsForSelectedElements,
} from "../groups";
import History from "../history";
import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
import {
CODES,
@ -278,11 +279,12 @@ import {
muteFSAbortError,
isTestEnv,
easeOut,
arrayToMap,
updateStable,
addEventListener,
normalizeEOL,
getDateTime,
isShallowEqual,
arrayToMap,
} from "../utils";
import {
createSrcDoc,
@ -410,6 +412,7 @@ import { ElementCanvasButton } from "./MagicButton";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import FollowMode from "./FollowMode/FollowMode";
import { IStore, Store, StoreAction } from "../store";
import { AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail";
import { LaserTrails } from "../laser-trails";
@ -540,6 +543,7 @@ class App extends React.Component<AppProps, AppState> {
public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined;
public id: string;
private store: IStore;
private history: History;
private excalidrawContainerValue: {
container: HTMLDivElement | null;
@ -665,6 +669,10 @@ class App extends React.Component<AppProps, AppState> {
this.canvas = document.createElement("canvas");
this.rc = rough.canvas(this.canvas);
this.renderer = new Renderer(this.scene);
this.store = new Store();
this.history = new History();
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene,
@ -714,10 +722,14 @@ class App extends React.Component<AppProps, AppState> {
onSceneUpdated: this.onSceneUpdated,
});
this.history = new History();
this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
this.actionManager.registerAll(actions);
this.actionManager.registerAction(
createUndoAction(this.history, this.store),
);
this.actionManager.registerAction(
createRedoAction(this.history, this.store),
);
}
private onWindowMessage(event: MessageEvent) {
@ -2092,12 +2104,12 @@ class App extends React.Component<AppProps, AppState> {
if (shouldUpdateStrokeColor) {
this.syncActionResult({
appState: { ...this.state, currentItemStrokeColor: color },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
} else {
this.syncActionResult({
appState: { ...this.state, currentItemBackgroundColor: color },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
}
} else {
@ -2111,6 +2123,7 @@ class App extends React.Component<AppProps, AppState> {
}
return el;
}),
commitToStore: true,
});
}
},
@ -2135,10 +2148,14 @@ class App extends React.Component<AppProps, AppState> {
editingElement = element;
}
});
this.scene.replaceAllElements(actionResult.elements);
if (actionResult.commitToHistory) {
this.history.resumeRecording();
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.shouldCaptureIncrement();
}
this.scene.replaceAllElements(actionResult.elements);
}
if (actionResult.files) {
@ -2149,8 +2166,10 @@ class App extends React.Component<AppProps, AppState> {
}
if (actionResult.appState || editingElement || this.state.contextMenu) {
if (actionResult.commitToHistory) {
this.history.resumeRecording();
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.shouldCaptureIncrement();
}
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
@ -2180,34 +2199,24 @@ class App extends React.Component<AppProps, AppState> {
editingElement = null;
}
this.setState(
(state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
});
},
() => {
if (actionResult.syncHistory) {
this.history.setCurrentState(
this.state,
this.scene.getElementsIncludingDeleted(),
);
}
},
);
this.setState((state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
});
});
}
},
);
@ -2231,6 +2240,10 @@ class App extends React.Component<AppProps, AppState> {
this.history.clear();
};
private resetStore = () => {
this.store.clear();
};
/**
* Resets scene & history.
* ! Do not use to clear scene user action !
@ -2243,6 +2256,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: opts?.resetLoadingState ? false : state.isLoading,
theme: this.state.theme,
}));
this.resetStore();
this.resetHistory();
},
);
@ -2327,10 +2341,11 @@ class App extends React.Component<AppProps, AppState> {
// seems faster even in browsers that do fire the loadingdone event.
this.fonts.loadFontsForElements(scene.elements);
this.resetStore();
this.resetHistory();
this.syncActionResult({
...scene,
commitToHistory: true,
storeAction: StoreAction.UPDATE,
});
};
@ -2420,9 +2435,17 @@ class App extends React.Component<AppProps, AppState> {
configurable: true,
value: this.history,
},
store: {
configurable: true,
value: this.store,
},
});
}
this.store.onStoreIncrementEmitter.on((increment) => {
this.history.record(increment.elementsChange, increment.appStateChange);
});
this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners();
@ -2479,6 +2502,7 @@ class App extends React.Component<AppProps, AppState> {
this.laserTrails.stop();
this.eraserTrail.stop();
this.onChangeEmitter.clear();
this.store.onStoreIncrementEmitter.clear();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
@ -2623,7 +2647,8 @@ class App extends React.Component<AppProps, AppState> {
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getNonDeletedElementsMap();
const elementsMap = this.scene.getElementsMapIncludingDeleted();
const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap();
if (!this.state.showWelcomeScreen && !elements.length) {
this.setState({ showWelcomeScreen: true });
@ -2739,7 +2764,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) {
// defer so that the commitToHistory flag isn't reset via current update
// defer so that the storeAction flag isn't reset via current update
setTimeout(() => {
// execute only if the condition still holds when the deferred callback
// executes (it can be scheduled multiple times depending on how
@ -2778,13 +2803,14 @@ class App extends React.Component<AppProps, AppState> {
LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiElement,
-1,
elementsMap,
nonDeletedElementsMap,
),
),
this,
);
}
this.history.record(this.state, elements);
this.store.capture(elementsMap, this.state);
// Do not notify consumers if we're still loading the scene. Among other
// potential issues, this fixes a case where the tab isn't focused during
@ -3154,7 +3180,7 @@ class App extends React.Component<AppProps, AppState> {
this.files = { ...this.files, ...opts.files };
}
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
@ -3389,7 +3415,7 @@ class App extends React.Component<AppProps, AppState> {
PLAIN_PASTE_TOAST_SHOWN = true;
}
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
}
setAppState: React.Component<any, AppState>["setState"] = (
@ -3657,10 +3683,51 @@ class App extends React.Component<AppProps, AppState> {
elements?: SceneData["elements"];
appState?: Pick<AppState, K> | null;
collaborators?: SceneData["collaborators"];
commitToHistory?: SceneData["commitToHistory"];
commitToStore?: SceneData["commitToStore"];
}) => {
if (sceneData.commitToHistory) {
this.history.resumeRecording();
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
if (sceneData.commitToStore) {
this.store.shouldCaptureIncrement();
}
if (sceneData.elements || sceneData.appState) {
let nextCommittedAppState = this.state;
let nextCommittedElements: Map<string, OrderedExcalidrawElement>;
if (sceneData.appState) {
nextCommittedAppState = {
...this.state,
...sceneData.appState, // Here we expect just partial appState
};
}
const prevElements = this.scene.getElementsIncludingDeleted();
if (sceneData.elements) {
/**
* We need to schedule a snapshot update, as in case `commitToStore` is false (i.e. remote update),
* as it's essential for computing local changes after the async action is completed (i.e. not to include remote changes in the diff).
*
* This is also a breaking change for all local `updateScene` calls without set `commitToStore` to true,
* as it makes such updates impossible to undo (previously they were undone coincidentally with the switch to the whole snapshot captured by the history).
*
* WARN: be careful here as moving it elsewhere could break the history for remote client without noticing
* - we need to find a way to test two concurrent client updates simultaneously, while having access to both stores & histories.
*/
this.store.shouldUpdateSnapshot();
// TODO#7348: deprecate once exchanging just store increments between clients
nextCommittedElements = this.store.ignoreUncomittedElements(
arrayToMap(prevElements),
arrayToMap(nextElements),
);
} else {
nextCommittedElements = arrayToMap(prevElements);
}
// WARN: Performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
this.store.capture(nextCommittedElements, nextCommittedAppState);
}
if (sceneData.appState) {
@ -3668,7 +3735,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (sceneData.elements) {
this.scene.replaceAllElements(sceneData.elements);
this.scene.replaceAllElements(nextElements);
}
if (sceneData.collaborators) {
@ -3896,7 +3963,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement.elementId !==
selectedElements[0].id
) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElement,
@ -4308,7 +4375,7 @@ class App extends React.Component<AppProps, AppState> {
]);
}
if (!isDeleted || isExistingElement) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
}
this.setState({
@ -4793,7 +4860,7 @@ class App extends React.Component<AppProps, AppState> {
(!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !== selectedElements[0].id)
) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
this.setState({
editingLinearElement: new LinearElementEditor(selectedElements[0]),
});
@ -4818,6 +4885,7 @@ class App extends React.Component<AppProps, AppState> {
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
if (selectedGroupId) {
this.store.shouldCaptureIncrement();
this.setState((prevState) => ({
...prevState,
...selectGroupsForSelectedElements(
@ -6300,7 +6368,7 @@ class App extends React.Component<AppProps, AppState> {
const ret = LinearElementEditor.handlePointerDown(
event,
this.state,
this.history,
this.store,
pointerDownState.origin,
linearElementEditor,
this,
@ -7848,7 +7916,7 @@ class App extends React.Component<AppProps, AppState> {
if (isLinearElement(draggingElement)) {
if (draggingElement!.points.length > 1) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
}
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
@ -7917,14 +7985,16 @@ class App extends React.Component<AppProps, AppState> {
isInvisiblySmallElement(draggingElement)
) {
// remove invisible element which was added in onPointerDown
this.scene.replaceAllElements(
this.scene
// update the store snapshot, so that invisible elements are not captured by the store
this.updateScene({
elements: this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== draggingElement.id),
);
this.setState({
draggingElement: null,
appState: {
draggingElement: null,
},
});
return;
}
@ -8086,15 +8156,16 @@ class App extends React.Component<AppProps, AppState> {
}
if (resizingElement) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
}
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
this.scene.replaceAllElements(
this.scene
// update the store snapshot, so that invisible elements are not captured by the store
this.updateScene({
elements: this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== resizingElement.id),
);
});
}
// handle frame membership for resizing frames and/or selected elements
@ -8395,9 +8466,13 @@ class App extends React.Component<AppProps, AppState> {
if (
activeTool.type !== "selection" ||
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state)
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) ||
!isShallowEqual(
this.state.previousSelectedElementIds,
this.state.selectedElementIds,
)
) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
}
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
@ -8475,7 +8550,7 @@ class App extends React.Component<AppProps, AppState> {
this.elementsPendingErasure = new Set();
if (didChange) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
this.scene.replaceAllElements(elements);
}
};
@ -9038,7 +9113,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false,
},
replaceFiles: true,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
return;
} catch (error: any) {
@ -9118,12 +9193,13 @@ class App extends React.Component<AppProps, AppState> {
) => {
file = await normalizeFile(file);
try {
const elements = this.scene.getElementsIncludingDeleted();
let ret;
try {
ret = await loadSceneOrLibraryFromBlob(
file,
this.state,
this.scene.getElementsIncludingDeleted(),
elements,
fileHandle,
);
} catch (error: any) {
@ -9152,6 +9228,13 @@ class App extends React.Component<AppProps, AppState> {
}
if (ret.type === MIME_TYPES.excalidraw) {
// Restore the fractional indices by mutating elements and update the
// store snapshot, otherwise we would end up with duplicate indices
syncInvalidIndices(elements.concat(ret.data.elements));
this.store.snapshot = this.store.snapshot.clone(
arrayToMap(elements),
this.state,
);
this.setState({ isLoading: true });
this.syncActionResult({
...ret.data,
@ -9160,7 +9243,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false,
},
replaceFiles: true,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
} else if (ret.type === MIME_TYPES.excalidrawlib) {
await this.library
@ -9770,6 +9853,7 @@ declare global {
setState: React.Component<any, AppState>["setState"];
app: InstanceType<typeof App>;
history: History;
store: Store;
};
}
}

View file

@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
hidden?: boolean;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
className?: string;
style?: CSSProperties;
isLoading?: boolean;
@ -124,10 +125,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true">
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">

View file

@ -77,8 +77,7 @@
}
.ToolIcon_type_button,
.Modal .ToolIcon_type_button,
.ToolIcon_type_button {
.Modal .ToolIcon_type_button {
padding: 0;
border: none;
margin: 0;
@ -101,6 +100,22 @@
background-color: var(--button-gray-3);
}
&:disabled {
cursor: default;
&:active,
&:focus-visible,
&:hover {
background-color: initial;
border: none;
box-shadow: none;
}
svg {
color: var(--color-disabled);
}
}
&--show {
visibility: visible;
}