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

View file

@ -18,6 +18,10 @@ import {
getCursorForResizingElement,
getPerfectElementSize,
normalizeDimensions,
getElementMap,
getDrawingVersion,
getSyncableElements,
hasNonDeletedElements,
} from "../element";
import {
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
roomID: string | null = null;
roomKey: string | null = null;
lastBroadcastedOrReceivedSceneVersion: number = -1;
actionManager: ActionManager;
canvasOnlyActions = ["selectAll"];
@ -275,15 +280,11 @@ export class App extends React.Component<any, AppState> {
iv,
);
let deletedIds = this.state.deletedIds;
switch (decryptedData.type) {
case "INVALID_RESPONSE":
return;
case "SCENE_UPDATE":
const {
elements: remoteElements,
appState: remoteAppState,
} = decryptedData.payload;
const { elements: remoteElements } = decryptedData.payload;
const restoredState = restore(remoteElements || [], null, {
scrollToContent: true,
});
@ -295,32 +296,7 @@ export class App extends React.Component<any, AppState> {
} else {
// create a map of ids so we don't have to iterate
// over the array more than once.
const localElementMap = elements.reduce(
(
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];
}
}
const localElementMap = getElementMap(elements);
// Reconcile
elements = restoredState.elements
@ -342,17 +318,27 @@ export class App extends React.Component<any, AppState> {
) {
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 if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version === element.version &&
localElementMap[element.id].versionNonce !==
element.versionNonce
) {
// 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 {
// 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);
delete localElementMap[element.id];
}
delete localElementMap[element.id];
} else {
elements.push(element);
delete localElementMap[element.id];
}
return elements;
@ -360,9 +346,15 @@ export class App extends React.Component<any, AppState> {
// add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap));
}
this.setState({
deletedIds,
});
this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion(
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) {
this.socketInitialized = true;
}
@ -370,13 +362,13 @@ export class App extends React.Component<any, AppState> {
case "MOUSE_LOCATION":
const { socketID, pointerCoords } = decryptedData.payload;
this.setState(state => {
if (state.collaborators.has(socketID)) {
const user = state.collaborators.get(socketID)!;
user.pointer = pointerCoords;
state.collaborators.set(socketID, user);
return state;
if (!state.collaborators.has(socketID)) {
state.collaborators.set(socketID, {});
}
return null;
const user = state.collaborators.get(socketID)!;
user.pointer = pointerCoords;
state.collaborators.set(socketID, user);
return state;
});
break;
}
@ -428,24 +420,16 @@ export class App extends React.Component<any, AppState> {
};
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,
},
elements: getSyncableElements(elements),
},
};
this.lastBroadcastedOrReceivedSceneVersion = Math.max(
this.lastBroadcastedOrReceivedSceneVersion,
getDrawingVersion(elements),
);
return this._broadcastSocketData(
data as typeof data & { _brand: "socketUpdateData" },
);
@ -840,7 +824,7 @@ export class App extends React.Component<any, AppState> {
action: () => this.pasteFromClipboard(null),
},
probablySupportsClipboardBlob &&
elements.length > 0 && {
hasNonDeletedElements(elements) && {
label: t("labels.copyAsPng"),
action: this.copyToClipboardAsPng,
},
@ -1102,6 +1086,7 @@ export class App extends React.Component<any, AppState> {
const pnt = points[points.length - 1];
pnt[0] = x - originX;
pnt[1] = y - originY;
mutateElement(multiElement);
invalidateShapeForElement(multiElement);
this.setState({});
return;
@ -1485,6 +1470,7 @@ export class App extends React.Component<any, AppState> {
},
}));
multiElement.points.push([x - rx, y - ry]);
mutateElement(multiElement);
invalidateShapeForElement(multiElement);
} else {
this.setState(prevState => ({
@ -1494,6 +1480,7 @@ export class App extends React.Component<any, AppState> {
},
}));
element.points.push([0, 0]);
mutateElement(element);
invalidateShapeForElement(element);
elements = [...elements, element];
this.setState({
@ -1548,20 +1535,19 @@ export class App extends React.Component<any, AppState> {
const dx = element.x + width + p1[0];
const dy = element.y + height + p1[1];
p1[0] = absPx - element.x;
p1[1] = absPy - element.y;
mutateElement(element, {
x: dx,
y: dy,
});
p1[0] = absPx - element.x;
p1[1] = absPy - element.y;
} else {
p1[0] -= deltaX;
p1[1] -= deltaY;
mutateElement(element, {
x: element.x + deltaX,
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[1] += deltaY;
}
mutateElement(element);
};
const onPointerMove = (event: PointerEvent) => {
@ -1925,6 +1912,8 @@ export class App extends React.Component<any, AppState> {
pnt[0] = dx;
pnt[1] = dy;
}
mutateElement(draggingElement, { points });
} else {
if (event.shiftKey) {
({ width, height } = getPerfectElementSize(
@ -2005,6 +1994,7 @@ export class App extends React.Component<any, AppState> {
x - draggingElement.x,
y - draggingElement.y,
]);
mutateElement(draggingElement);
invalidateShapeForElement(draggingElement);
this.setState({
multiElement: this.state.draggingElement,
@ -2263,13 +2253,20 @@ export class App extends React.Component<any, AppState> {
if (scrollBars) {
currentScrollBars = scrollBars;
}
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
const scrolledOutside =
!atLeastOneVisibleElement && hasNonDeletedElements(elements);
if (this.state.scrolledOutside !== scrolledOutside) {
this.setState({ scrolledOutside: scrolledOutside });
}
this.saveDebounced();
if (history.isRecording()) {
if (
getDrawingVersion(elements) > this.lastBroadcastedOrReceivedSceneVersion
) {
this.broadcastSceneUpdate();
}
if (history.isRecording()) {
history.pushEntry(this.state, elements);
history.skipRecording();
}