Fix many syncing issues (#952)

This commit is contained in:
Pete Hunt 2020-03-14 20:46:57 -07:00 committed by GitHub
parent b20d4539c0
commit 3f8144ef85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 176 additions and 100 deletions

2
.gitignore vendored
View File

@ -9,3 +9,5 @@ static
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
yarn.lock yarn.lock
.envrc
firebase/

View File

@ -9,6 +9,7 @@ import { KEYS } from "../keys";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import useIsMobile from "../is-mobile"; import useIsMobile from "../is-mobile";
import { register } from "./register"; import { register } from "./register";
import { newElementWith } from "../element/mutateElement";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@ -33,9 +34,11 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({ export const actionClearCanvas = register({
name: "clearCanvas", name: "clearCanvas",
commitToHistory: () => true, commitToHistory: () => true,
perform: () => { perform: elements => {
return { return {
elements: [], elements: elements.map(element =>
newElementWith(element, { isDeleted: true }),
),
appState: getDefaultAppState(), appState: getDefaultAppState(),
}; };
}, },

View File

@ -7,8 +7,11 @@ import { SceneHistory } from "../history";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement";
const writeData = ( const writeData = (
prevElements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
updater: () => { elements: ExcalidrawElement[]; appState: AppState } | null, updater: () => { elements: ExcalidrawElement[]; appState: AppState } | null,
) => { ) => {
@ -19,13 +22,32 @@ const writeData = (
!appState.draggingElement !appState.draggingElement
) { ) {
const data = updater(); const data = updater();
if (data === null) {
return {};
}
return data === null const prevElementMap = getElementMap(prevElements);
? {} const nextElements = data.elements;
: { const nextElementMap = getElementMap(nextElements);
elements: data.elements, return {
appState: { ...appState, ...data.appState }, elements: nextElements
}; .map(nextElement =>
newElementWith(
prevElementMap[nextElement.id] || nextElement,
nextElement,
),
)
.concat(
prevElements
.filter(
prevElement => !nextElementMap.hasOwnProperty(prevElement.id),
)
.map(prevElement =>
newElementWith(prevElement, { isDeleted: true }),
),
),
appState: { ...appState, ...data.appState },
};
} }
return {}; return {};
}; };
@ -37,7 +59,8 @@ type ActionCreator = (history: SceneHistory) => Action;
export const createUndoAction: ActionCreator = history => ({ export const createUndoAction: ActionCreator = history => ({
name: "undo", name: "undo",
perform: (_, appState) => writeData(appState, () => history.undoOnce()), perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
keyTest: testUndo(false), keyTest: testUndo(false),
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
@ -52,7 +75,8 @@ export const createUndoAction: ActionCreator = history => ({
export const createRedoAction: ActionCreator = history => ({ export const createRedoAction: ActionCreator = history => ({
name: "redo", name: "redo",
perform: (_, appState) => writeData(appState, () => history.redoOnce()), perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
keyTest: testUndo(true), keyTest: testUndo(true),
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton

View File

@ -34,7 +34,6 @@ export function getDefaultAppState(): AppState {
openMenu: null, openMenu: null,
lastPointerDownWith: "mouse", lastPointerDownWith: "mouse",
selectedElementIds: {}, selectedElementIds: {},
deletedIds: {},
collaborators: new Map(), collaborators: new Map(),
}; };
} }

View File

@ -18,6 +18,10 @@ import {
getCursorForResizingElement, getCursorForResizingElement,
getPerfectElementSize, getPerfectElementSize,
normalizeDimensions, normalizeDimensions,
getElementMap,
getDrawingVersion,
getSyncableElements,
hasNonDeletedElements,
} from "../element"; } from "../element";
import { import {
deleteSelectedElements, deleteSelectedElements,
@ -160,6 +164,7 @@ export class App extends React.Component<any, AppState> {
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initalized socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initalized
roomID: string | null = null; roomID: string | null = null;
roomKey: string | null = null; roomKey: string | null = null;
lastBroadcastedOrReceivedSceneVersion: number = -1;
actionManager: ActionManager; actionManager: ActionManager;
canvasOnlyActions = ["selectAll"]; canvasOnlyActions = ["selectAll"];
@ -275,15 +280,11 @@ export class App extends React.Component<any, AppState> {
iv, iv,
); );
let deletedIds = this.state.deletedIds;
switch (decryptedData.type) { switch (decryptedData.type) {
case "INVALID_RESPONSE": case "INVALID_RESPONSE":
return; return;
case "SCENE_UPDATE": case "SCENE_UPDATE":
const { const { elements: remoteElements } = decryptedData.payload;
elements: remoteElements,
appState: remoteAppState,
} = decryptedData.payload;
const restoredState = restore(remoteElements || [], null, { const restoredState = restore(remoteElements || [], null, {
scrollToContent: true, scrollToContent: true,
}); });
@ -295,32 +296,7 @@ export class App extends React.Component<any, AppState> {
} else { } else {
// create a map of ids so we don't have to iterate // create a map of ids so we don't have to iterate
// over the array more than once. // over the array more than once.
const localElementMap = elements.reduce( const localElementMap = getElementMap(elements);
(
acc: { [key: string]: ExcalidrawElement },
element: ExcalidrawElement,
) => {
acc[element.id] = element;
return acc;
},
{},
);
deletedIds = { ...deletedIds };
for (const [id, remoteDeletedEl] of Object.entries(
remoteAppState.deletedIds,
)) {
if (
!localElementMap[id] ||
// don't remove local element if it's newer than the one
// deleted on remote
remoteDeletedEl.version >= localElementMap[id].version
) {
deletedIds[id] = remoteDeletedEl;
delete localElementMap[id];
}
}
// Reconcile // Reconcile
elements = restoredState.elements elements = restoredState.elements
@ -342,17 +318,27 @@ export class App extends React.Component<any, AppState> {
) { ) {
elements.push(localElementMap[element.id]); elements.push(localElementMap[element.id]);
delete localElementMap[element.id]; delete localElementMap[element.id];
} else { } else if (
if (deletedIds.hasOwnProperty(element.id)) { localElementMap.hasOwnProperty(element.id) &&
if (element.version > deletedIds[element.id].version) { localElementMap[element.id].version === element.version &&
elements.push(element); localElementMap[element.id].versionNonce !==
delete deletedIds[element.id]; element.versionNonce
delete localElementMap[element.id]; ) {
} // resolve conflicting edits deterministically by taking the one with the lowest versionNonce
if (
localElementMap[element.id].versionNonce <
element.versionNonce
) {
elements.push(localElementMap[element.id]);
} else { } else {
// it should be highly unlikely that the two versionNonces are the same. if we are
// really worried about this, we can replace the versionNonce with the socket id.
elements.push(element); elements.push(element);
delete localElementMap[element.id];
} }
delete localElementMap[element.id];
} else {
elements.push(element);
delete localElementMap[element.id];
} }
return elements; return elements;
@ -360,9 +346,15 @@ export class App extends React.Component<any, AppState> {
// add local elements that weren't deleted or on remote // add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap)); .concat(...Object.values(localElementMap));
} }
this.setState({ this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion(
deletedIds, elements,
}); );
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
history.clear();
this.setState({});
if (this.socketInitialized === false) { if (this.socketInitialized === false) {
this.socketInitialized = true; this.socketInitialized = true;
} }
@ -370,13 +362,13 @@ export class App extends React.Component<any, AppState> {
case "MOUSE_LOCATION": case "MOUSE_LOCATION":
const { socketID, pointerCoords } = decryptedData.payload; const { socketID, pointerCoords } = decryptedData.payload;
this.setState(state => { this.setState(state => {
if (state.collaborators.has(socketID)) { if (!state.collaborators.has(socketID)) {
const user = state.collaborators.get(socketID)!; state.collaborators.set(socketID, {});
user.pointer = pointerCoords;
state.collaborators.set(socketID, user);
return state;
} }
return null; const user = state.collaborators.get(socketID)!;
user.pointer = pointerCoords;
state.collaborators.set(socketID, user);
return state;
}); });
break; break;
} }
@ -428,24 +420,16 @@ export class App extends React.Component<any, AppState> {
}; };
private broadcastSceneUpdate = () => { private broadcastSceneUpdate = () => {
const deletedIds = { ...this.state.deletedIds };
const _elements = elements.filter(element => {
if (element.id in deletedIds) {
delete deletedIds[element.id];
}
return element.id !== this.state.editingElement?.id;
});
const data: SocketUpdateDataSource["SCENE_UPDATE"] = { const data: SocketUpdateDataSource["SCENE_UPDATE"] = {
type: "SCENE_UPDATE", type: "SCENE_UPDATE",
payload: { payload: {
elements: _elements, elements: getSyncableElements(elements),
appState: {
viewBackgroundColor: this.state.viewBackgroundColor,
name: this.state.name,
deletedIds,
},
}, },
}; };
this.lastBroadcastedOrReceivedSceneVersion = Math.max(
this.lastBroadcastedOrReceivedSceneVersion,
getDrawingVersion(elements),
);
return this._broadcastSocketData( return this._broadcastSocketData(
data as typeof data & { _brand: "socketUpdateData" }, data as typeof data & { _brand: "socketUpdateData" },
); );
@ -840,7 +824,7 @@ export class App extends React.Component<any, AppState> {
action: () => this.pasteFromClipboard(null), action: () => this.pasteFromClipboard(null),
}, },
probablySupportsClipboardBlob && probablySupportsClipboardBlob &&
elements.length > 0 && { hasNonDeletedElements(elements) && {
label: t("labels.copyAsPng"), label: t("labels.copyAsPng"),
action: this.copyToClipboardAsPng, action: this.copyToClipboardAsPng,
}, },
@ -1102,6 +1086,7 @@ export class App extends React.Component<any, AppState> {
const pnt = points[points.length - 1]; const pnt = points[points.length - 1];
pnt[0] = x - originX; pnt[0] = x - originX;
pnt[1] = y - originY; pnt[1] = y - originY;
mutateElement(multiElement);
invalidateShapeForElement(multiElement); invalidateShapeForElement(multiElement);
this.setState({}); this.setState({});
return; return;
@ -1485,6 +1470,7 @@ export class App extends React.Component<any, AppState> {
}, },
})); }));
multiElement.points.push([x - rx, y - ry]); multiElement.points.push([x - rx, y - ry]);
mutateElement(multiElement);
invalidateShapeForElement(multiElement); invalidateShapeForElement(multiElement);
} else { } else {
this.setState(prevState => ({ this.setState(prevState => ({
@ -1494,6 +1480,7 @@ export class App extends React.Component<any, AppState> {
}, },
})); }));
element.points.push([0, 0]); element.points.push([0, 0]);
mutateElement(element);
invalidateShapeForElement(element); invalidateShapeForElement(element);
elements = [...elements, element]; elements = [...elements, element];
this.setState({ this.setState({
@ -1548,20 +1535,19 @@ export class App extends React.Component<any, AppState> {
const dx = element.x + width + p1[0]; const dx = element.x + width + p1[0];
const dy = element.y + height + p1[1]; const dy = element.y + height + p1[1];
p1[0] = absPx - element.x;
p1[1] = absPy - element.y;
mutateElement(element, { mutateElement(element, {
x: dx, x: dx,
y: dy, y: dy,
}); });
p1[0] = absPx - element.x;
p1[1] = absPy - element.y;
} else { } else {
p1[0] -= deltaX;
p1[1] -= deltaY;
mutateElement(element, { mutateElement(element, {
x: element.x + deltaX, x: element.x + deltaX,
y: element.y + deltaY, y: element.y + deltaY,
}); });
p1[0] -= deltaX;
p1[1] -= deltaY;
} }
}; };
@ -1586,6 +1572,7 @@ export class App extends React.Component<any, AppState> {
p1[0] += deltaX; p1[0] += deltaX;
p1[1] += deltaY; p1[1] += deltaY;
} }
mutateElement(element);
}; };
const onPointerMove = (event: PointerEvent) => { const onPointerMove = (event: PointerEvent) => {
@ -1925,6 +1912,8 @@ export class App extends React.Component<any, AppState> {
pnt[0] = dx; pnt[0] = dx;
pnt[1] = dy; pnt[1] = dy;
} }
mutateElement(draggingElement, { points });
} else { } else {
if (event.shiftKey) { if (event.shiftKey) {
({ width, height } = getPerfectElementSize( ({ width, height } = getPerfectElementSize(
@ -2005,6 +1994,7 @@ export class App extends React.Component<any, AppState> {
x - draggingElement.x, x - draggingElement.x,
y - draggingElement.y, y - draggingElement.y,
]); ]);
mutateElement(draggingElement);
invalidateShapeForElement(draggingElement); invalidateShapeForElement(draggingElement);
this.setState({ this.setState({
multiElement: this.state.draggingElement, multiElement: this.state.draggingElement,
@ -2263,13 +2253,20 @@ export class App extends React.Component<any, AppState> {
if (scrollBars) { if (scrollBars) {
currentScrollBars = scrollBars; currentScrollBars = scrollBars;
} }
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0; const scrolledOutside =
!atLeastOneVisibleElement && hasNonDeletedElements(elements);
if (this.state.scrolledOutside !== scrolledOutside) { if (this.state.scrolledOutside !== scrolledOutside) {
this.setState({ scrolledOutside: scrolledOutside }); this.setState({ scrolledOutside: scrolledOutside });
} }
this.saveDebounced(); this.saveDebounced();
if (history.isRecording()) {
if (
getDrawingVersion(elements) > this.lastBroadcastedOrReceivedSceneVersion
) {
this.broadcastSceneUpdate(); this.broadcastSceneUpdate();
}
if (history.isRecording()) {
history.pushEntry(this.state, elements); history.pushEntry(this.state, elements);
history.skipRecording(); history.skipRecording();
} }

View File

@ -13,6 +13,7 @@ import { serializeAsJSON } from "./json";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { restore } from "./restore"; import { restore } from "./restore";
import { restoreFromLocalStorage } from "./localStorage"; import { restoreFromLocalStorage } from "./localStorage";
import { hasNonDeletedElements } from "../element";
export { loadFromBlob } from "./blob"; export { loadFromBlob } from "./blob";
export { saveAsJSON, loadFromJSON } from "./json"; export { saveAsJSON, loadFromJSON } from "./json";
@ -35,7 +36,6 @@ export type SocketUpdateDataSource = {
type: "SCENE_UPDATE"; type: "SCENE_UPDATE";
payload: { payload: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
appState: Pick<AppState, "viewBackgroundColor" | "name" | "deletedIds">;
}; };
}; };
MOUSE_LOCATION: { MOUSE_LOCATION: {
@ -288,7 +288,7 @@ export async function exportCanvas(
scale?: number; scale?: number;
}, },
) { ) {
if (!elements.length) { if (!hasNonDeletedElements(elements)) {
return window.alert(t("alerts.cannotExportEmptyCanvas")); return window.alert(t("alerts.cannotExportEmptyCanvas"));
} }
// calculate smallest area to fit the contents in // calculate smallest area to fit the contents in

View File

@ -52,7 +52,8 @@ export function restore(
return { return {
...element, ...element,
version: element.version || 0, // all elements must have version > 0 so getDrawingVersion() will pick up newly added elements
version: element.version || 1,
id: element.id || nanoid(), id: element.id || nanoid(),
fillStyle: element.fillStyle || "hachure", fillStyle: element.fillStyle || "hachure",
strokeWidth: element.strokeWidth || 1, strokeWidth: element.strokeWidth || 1,

View File

@ -1,3 +1,6 @@
import { ExcalidrawElement } from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers";
export { newElement, newTextElement, duplicateElement } from "./newElement"; export { newElement, newTextElement, duplicateElement } from "./newElement";
export { export {
getElementAbsoluteCoords, getElementAbsoluteCoords,
@ -24,3 +27,28 @@ export {
normalizeDimensions, normalizeDimensions,
} from "./sizeHelpers"; } from "./sizeHelpers";
export { showSelectedShapeActions } from "./showSelectedShapeActions"; export { showSelectedShapeActions } from "./showSelectedShapeActions";
export function getSyncableElements(elements: readonly ExcalidrawElement[]) {
// There are places in Excalidraw where synthetic invisibly small elements are added and removed.
// It's probably best to keep those local otherwise there might be a race condition that
// gets the app into an invalid state. I've never seen it happen but I'm worried about it :)
return elements.filter(el => !isInvisiblySmallElement(el));
}
export function getElementMap(elements: readonly ExcalidrawElement[]) {
return getSyncableElements(elements).reduce(
(acc: { [key: string]: ExcalidrawElement }, element: ExcalidrawElement) => {
acc[element.id] = element;
return acc;
},
{},
);
}
export function getDrawingVersion(elements: readonly ExcalidrawElement[]) {
return elements.reduce((acc, el) => acc + el.version, 0);
}
export function hasNonDeletedElements(elements: readonly ExcalidrawElement[]) {
return elements.some(element => !element.isDeleted);
}

View File

@ -1,4 +1,5 @@
import { ExcalidrawElement, ExcalidrawTextElement } from "./types"; import { ExcalidrawElement, ExcalidrawTextElement } from "./types";
import { randomSeed } from "roughjs/bin/math";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit< type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
@ -10,17 +11,25 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
// the same drawing. // the same drawing.
export function mutateElement( export function mutateElement(
element: ExcalidrawElement, element: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>, updates?: ElementUpdate<ExcalidrawElement>,
) { ) {
Object.assign(element, updates); if (updates) {
Object.assign(element, updates);
}
(element as any).version++; (element as any).version++;
(element as any).versionNonce = randomSeed();
} }
export function newElementWith( export function newElementWith(
element: ExcalidrawElement, element: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>, updates: ElementUpdate<ExcalidrawElement>,
): ExcalidrawElement { ): ExcalidrawElement {
return { ...element, ...updates, version: element.version + 1 }; return {
...element,
...updates,
version: element.version + 1,
versionNonce: randomSeed(),
};
} }
// This function tracks updates of text elements for the purposes for collaboration. // This function tracks updates of text elements for the purposes for collaboration.
@ -32,11 +41,17 @@ export function mutateTextElement(
): void { ): void {
Object.assign(element, updates); Object.assign(element, updates);
(element as any).version++; (element as any).version++;
(element as any).versionNonce = randomSeed();
} }
export function newTextElementWith( export function newTextElementWith(
element: ExcalidrawTextElement, element: ExcalidrawTextElement,
updates: ElementUpdate<ExcalidrawTextElement>, updates: ElementUpdate<ExcalidrawTextElement>,
): ExcalidrawTextElement { ): ExcalidrawTextElement {
return { ...element, ...updates, version: element.version + 1 }; return {
...element,
...updates,
version: element.version + 1,
versionNonce: randomSeed(),
};
} }

View File

@ -34,6 +34,8 @@ export function newElement(
seed: randomSeed(), seed: randomSeed(),
points: [] as Point[], points: [] as Point[],
version: 1, version: 1,
versionNonce: 0,
isDeleted: false,
}; };
return element; return element;
} }

View File

@ -13,6 +13,11 @@ export class SceneHistory {
private stateHistory: string[] = []; private stateHistory: string[] = [];
private redoStack: string[] = []; private redoStack: string[] = [];
clear() {
this.stateHistory.length = 0;
this.redoStack.length = 0;
}
private generateEntry( private generateEntry(
appState: AppState, appState: AppState,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],

View File

@ -24,7 +24,7 @@ function colorForClientId(clientId: string) {
} }
export function renderScene( export function renderScene(
elements: readonly ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
selectionElement: ExcalidrawElement | null, selectionElement: ExcalidrawElement | null,
scale: number, scale: number,
@ -49,6 +49,8 @@ export function renderScene(
return { atLeastOneVisibleElement: false }; return { atLeastOneVisibleElement: false };
} }
const elements = allElements.filter(element => !element.isDeleted);
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
// When doing calculations based on canvas width we should used normalized one // When doing calculations based on canvas width we should used normalized one

View File

@ -25,6 +25,9 @@ export function getElementAtPosition(
let hitElement = null; let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array) // We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let i = elements.length - 1; i >= 0; --i) { for (let i = elements.length - 1; i >= 0; --i) {
if (elements[i].isDeleted) {
continue;
}
if (hitTest(elements[i], appState, x, y, zoom)) { if (hitTest(elements[i], appState, x, y, zoom)) {
hitElement = elements[i]; hitElement = elements[i];
break; break;
@ -42,6 +45,9 @@ export function getElementContainingPosition(
let hitElement = null; let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array) // We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let i = elements.length - 1; i >= 0; --i) { for (let i = elements.length - 1; i >= 0; --i) {
if (elements[i].isDeleted) {
continue;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[i]); const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[i]);
if (x1 < x && x < x2 && y1 < y && y < y2) { if (x1 < x && x < x2 && y1 < y && y < y2) {
hitElement = elements[i]; hitElement = elements[i];

View File

@ -1,6 +1,7 @@
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { getElementAbsoluteCoords } from "../element"; import { getElementAbsoluteCoords } from "../element";
import { AppState } from "../types"; import { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
export function getElementsWithinSelection( export function getElementsWithinSelection(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -34,24 +35,16 @@ export function deleteSelectedElements(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) { ) {
const deletedIds: AppState["deletedIds"] = {};
return { return {
elements: elements.filter(el => { elements: elements.map(el => {
if (appState.selectedElementIds[el.id]) { if (appState.selectedElementIds[el.id]) {
deletedIds[el.id] = { return newElementWith(el, { isDeleted: true });
version: el.version,
};
return false;
} }
return true; return el;
}), }),
appState: { appState: {
...appState, ...appState,
selectedElementIds: {}, selectedElementIds: {},
deletedIds: {
...appState.deletedIds,
...deletedIds,
},
}, },
}; };
} }

View File

@ -34,7 +34,6 @@ export type AppState = {
openMenu: "canvas" | "shape" | null; openMenu: "canvas" | "shape" | null;
lastPointerDownWith: PointerType; lastPointerDownWith: PointerType;
selectedElementIds: { [id: string]: boolean }; selectedElementIds: { [id: string]: boolean };
deletedIds: { [id: string]: { version: ExcalidrawElement["version"] } };
collaborators: Map<string, { pointer?: { x: number; y: number } }>; collaborators: Map<string, { pointer?: { x: number; y: number } }>;
}; };