use deletedIds map to sync deletions (#936)
* use deletedIds map for sync deletions * refactor how we create data for syncing * fix comments * streamline broadcast API * split broadcast methods
This commit is contained in:
parent
ead6a083d4
commit
b9c75b5bc4
@ -34,6 +34,7 @@ export function getDefaultAppState(): AppState {
|
|||||||
openMenu: null,
|
openMenu: null,
|
||||||
lastPointerDownWith: "mouse",
|
lastPointerDownWith: "mouse",
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
|
deletedIds: {},
|
||||||
collaborators: new Map(),
|
collaborators: new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -37,7 +37,7 @@ import {
|
|||||||
loadScene,
|
loadScene,
|
||||||
loadFromBlob,
|
loadFromBlob,
|
||||||
SOCKET_SERVER,
|
SOCKET_SERVER,
|
||||||
SocketUpdateData,
|
SocketUpdateDataSource,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
import { restore } from "../data/restore";
|
import { restore } from "../data/restore";
|
||||||
|
|
||||||
@ -270,19 +270,18 @@ 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: sceneElements,
|
elements: remoteElements,
|
||||||
appState: sceneAppState,
|
appState: remoteAppState,
|
||||||
} = decryptedData.payload;
|
} = decryptedData.payload;
|
||||||
const restoredState = restore(
|
const restoredState = restore(remoteElements || [], null, {
|
||||||
sceneElements || [],
|
scrollToContent: true,
|
||||||
sceneAppState || getDefaultAppState(),
|
});
|
||||||
{ scrollToContent: true },
|
|
||||||
);
|
|
||||||
// Perform reconciliation - in collaboration, if we encounter
|
// Perform reconciliation - in collaboration, if we encounter
|
||||||
// elements with more staler versions than ours, ignore them
|
// elements with more staler versions than ours, ignore them
|
||||||
// and keep ours.
|
// and keep ours.
|
||||||
@ -301,6 +300,23 @@ export class App extends React.Component<any, AppState> {
|
|||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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
|
||||||
.reduce((elements, element) => {
|
.reduce((elements, element) => {
|
||||||
@ -320,26 +336,28 @@ export class App extends React.Component<any, AppState> {
|
|||||||
localElementMap[element.id].version > element.version
|
localElementMap[element.id].version > element.version
|
||||||
) {
|
) {
|
||||||
elements.push(localElementMap[element.id]);
|
elements.push(localElementMap[element.id]);
|
||||||
|
delete localElementMap[element.id];
|
||||||
|
} else {
|
||||||
|
if (deletedIds.hasOwnProperty(element.id)) {
|
||||||
|
if (element.version > deletedIds[element.id].version) {
|
||||||
|
elements.push(element);
|
||||||
|
delete deletedIds[element.id];
|
||||||
|
delete localElementMap[element.id];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
elements.push(element);
|
elements.push(element);
|
||||||
|
delete localElementMap[element.id];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
}, [] as any)
|
}, [] as any)
|
||||||
// add local elements that are currently being edited
|
// add local elements that weren't deleted or on remote
|
||||||
// (can't be done in the step above because the elements may
|
.concat(...Object.values(localElementMap));
|
||||||
// not exist on remote at all)
|
|
||||||
.concat(
|
|
||||||
elements.filter(element => {
|
|
||||||
return (
|
|
||||||
element.id === this.state.editingElement?.id ||
|
|
||||||
element.id === this.state.resizingElement?.id ||
|
|
||||||
element.id === this.state.draggingElement?.id
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
this.setState({});
|
this.setState({
|
||||||
|
deletedIds,
|
||||||
|
});
|
||||||
if (this.socketInitialized === false) {
|
if (this.socketInitialized === false) {
|
||||||
this.socketInitialized = true;
|
this.socketInitialized = true;
|
||||||
}
|
}
|
||||||
@ -382,20 +400,58 @@ export class App extends React.Component<any, AppState> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.socket.on("new-user", async (socketID: string) => {
|
this.socket.on("new-user", async (socketID: string) => {
|
||||||
this.broadcastSocketData({
|
this.broadcastSceneUpdate();
|
||||||
type: "SCENE_UPDATE",
|
|
||||||
payload: {
|
|
||||||
elements: elements.filter(element => {
|
|
||||||
return element.id !== this.state.editingElement?.id;
|
|
||||||
}),
|
|
||||||
appState: this.state,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private broadcastSocketData = async (data: SocketUpdateData) => {
|
private broadcastMouseLocation = (payload: {
|
||||||
|
pointerCoords: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointerCoords"];
|
||||||
|
}) => {
|
||||||
|
if (this.socket?.id) {
|
||||||
|
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
|
||||||
|
type: "MOUSE_LOCATION",
|
||||||
|
payload: {
|
||||||
|
socketID: this.socket.id,
|
||||||
|
pointerCoords: payload.pointerCoords,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this._broadcastSocketData(
|
||||||
|
data as typeof data & { _brand: "socketUpdateData" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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"] = {
|
||||||
|
type: "SCENE_UPDATE",
|
||||||
|
payload: {
|
||||||
|
elements: _elements,
|
||||||
|
appState: {
|
||||||
|
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||||
|
name: this.state.name,
|
||||||
|
deletedIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this._broadcastSocketData(
|
||||||
|
data as typeof data & { _brand: "socketUpdateData" },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Low-level. Use type-specific broadcast* method.
|
||||||
|
private async _broadcastSocketData(
|
||||||
|
data: SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
|
||||||
|
_brand: "socketUpdateData";
|
||||||
|
},
|
||||||
|
) {
|
||||||
if (this.socketInitialized && this.socket && this.roomID && this.roomKey) {
|
if (this.socketInitialized && this.socket && this.roomID && this.roomKey) {
|
||||||
const json = JSON.stringify(data);
|
const json = JSON.stringify(data);
|
||||||
const encoded = new TextEncoder().encode(json);
|
const encoded = new TextEncoder().encode(json);
|
||||||
@ -407,7 +463,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
encrypted.iv,
|
encrypted.iv,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
private unmounted = false;
|
private unmounted = false;
|
||||||
public async componentDidMount() {
|
public async componentDidMount() {
|
||||||
@ -2128,14 +2184,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
// sometimes the pointer goes off screen
|
// sometimes the pointer goes off screen
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.socket &&
|
this.socket && this.broadcastMouseLocation({ pointerCoords });
|
||||||
this.broadcastSocketData({
|
|
||||||
type: "MOUSE_LOCATION",
|
|
||||||
payload: {
|
|
||||||
socketID: this.socket.id,
|
|
||||||
pointerCoords,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private saveDebounced = debounce(() => {
|
private saveDebounced = debounce(() => {
|
||||||
@ -2188,15 +2237,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
}
|
}
|
||||||
this.saveDebounced();
|
this.saveDebounced();
|
||||||
if (history.isRecording()) {
|
if (history.isRecording()) {
|
||||||
this.broadcastSocketData({
|
this.broadcastSceneUpdate();
|
||||||
type: "SCENE_UPDATE",
|
|
||||||
payload: {
|
|
||||||
elements: elements.filter(element => {
|
|
||||||
return element.id !== this.state.editingElement?.id;
|
|
||||||
}),
|
|
||||||
appState: this.state,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
history.pushEntry(this.state, elements);
|
history.pushEntry(this.state, elements);
|
||||||
history.skipRecording();
|
history.skipRecording();
|
||||||
}
|
}
|
||||||
|
@ -30,21 +30,25 @@ export type EncryptedData = {
|
|||||||
iv: Uint8Array;
|
iv: Uint8Array;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SocketUpdateData =
|
export type SocketUpdateDataSource = {
|
||||||
| {
|
SCENE_UPDATE: {
|
||||||
type: "SCENE_UPDATE";
|
type: "SCENE_UPDATE";
|
||||||
payload: {
|
payload: {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState: AppState | null;
|
appState: Pick<AppState, "viewBackgroundColor" | "name" | "deletedIds">;
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
| {
|
MOUSE_LOCATION: {
|
||||||
type: "MOUSE_LOCATION";
|
type: "MOUSE_LOCATION";
|
||||||
payload: {
|
payload: {
|
||||||
socketID: string;
|
socketID: string;
|
||||||
pointerCoords: { x: number; y: number };
|
pointerCoords: { x: number; y: number };
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SocketUpdateDataIncoming =
|
||||||
|
| SocketUpdateDataSource[keyof SocketUpdateDataSource]
|
||||||
| {
|
| {
|
||||||
type: "INVALID_RESPONSE";
|
type: "INVALID_RESPONSE";
|
||||||
};
|
};
|
||||||
@ -137,7 +141,7 @@ export async function decryptAESGEM(
|
|||||||
data: ArrayBuffer,
|
data: ArrayBuffer,
|
||||||
key: string,
|
key: string,
|
||||||
iv: Uint8Array,
|
iv: Uint8Array,
|
||||||
): Promise<SocketUpdateData> {
|
): Promise<SocketUpdateDataIncoming> {
|
||||||
try {
|
try {
|
||||||
const importedKey = await getImportedKey(key, "decrypt");
|
const importedKey = await getImportedKey(key, "decrypt");
|
||||||
const decrypted = await window.crypto.subtle.decrypt(
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
@ -52,7 +52,7 @@ export function restore(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
version: element.id ? element.version + 1 : element.version || 0,
|
version: element.version || 0,
|
||||||
id: element.id || nanoid(),
|
id: element.id || nanoid(),
|
||||||
fillStyle: element.fillStyle || "hachure",
|
fillStyle: element.fillStyle || "hachure",
|
||||||
strokeWidth: element.strokeWidth || 1,
|
strokeWidth: element.strokeWidth || 1,
|
||||||
|
@ -34,11 +34,24 @@ export function deleteSelectedElements(
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) {
|
) {
|
||||||
|
const deletedIds: AppState["deletedIds"] = {};
|
||||||
return {
|
return {
|
||||||
elements: elements.filter(el => !appState.selectedElementIds[el.id]),
|
elements: elements.filter(el => {
|
||||||
|
if (appState.selectedElementIds[el.id]) {
|
||||||
|
deletedIds[el.id] = {
|
||||||
|
version: el.version,
|
||||||
|
};
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
|
deletedIds: {
|
||||||
|
...appState.deletedIds,
|
||||||
|
...deletedIds,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -34,6 +34,7 @@ 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 } }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user