|
@@ -18,6 +18,10 @@ import {
|
18
|
18
|
getCursorForResizingElement,
|
19
|
19
|
getPerfectElementSize,
|
20
|
20
|
normalizeDimensions,
|
|
21
|
+ getElementMap,
|
|
22
|
+ getDrawingVersion,
|
|
23
|
+ getSyncableElements,
|
|
24
|
+ hasNonDeletedElements,
|
21
|
25
|
} from "../element";
|
22
|
26
|
import {
|
23
|
27
|
deleteSelectedElements,
|
|
@@ -160,6 +164,7 @@ export class App extends React.Component<any, AppState> {
|
160
|
164
|
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initalized
|
161
|
165
|
roomID: string | null = null;
|
162
|
166
|
roomKey: string | null = null;
|
|
167
|
+ lastBroadcastedOrReceivedSceneVersion: number = -1;
|
163
|
168
|
|
164
|
169
|
actionManager: ActionManager;
|
165
|
170
|
canvasOnlyActions = ["selectAll"];
|
|
@@ -275,15 +280,11 @@ export class App extends React.Component<any, AppState> {
|
275
|
280
|
iv,
|
276
|
281
|
);
|
277
|
282
|
|
278
|
|
- let deletedIds = this.state.deletedIds;
|
279
|
283
|
switch (decryptedData.type) {
|
280
|
284
|
case "INVALID_RESPONSE":
|
281
|
285
|
return;
|
282
|
286
|
case "SCENE_UPDATE":
|
283
|
|
- const {
|
284
|
|
- elements: remoteElements,
|
285
|
|
- appState: remoteAppState,
|
286
|
|
- } = decryptedData.payload;
|
|
287
|
+ const { elements: remoteElements } = decryptedData.payload;
|
287
|
288
|
const restoredState = restore(remoteElements || [], null, {
|
288
|
289
|
scrollToContent: true,
|
289
|
290
|
});
|
|
@@ -295,32 +296,7 @@ export class App extends React.Component<any, AppState> {
|
295
|
296
|
} else {
|
296
|
297
|
// create a map of ids so we don't have to iterate
|
297
|
298
|
// over the array more than once.
|
298
|
|
- const localElementMap = elements.reduce(
|
299
|
|
- (
|
300
|
|
- acc: { [key: string]: ExcalidrawElement },
|
301
|
|
- element: ExcalidrawElement,
|
302
|
|
- ) => {
|
303
|
|
- acc[element.id] = element;
|
304
|
|
- return acc;
|
305
|
|
- },
|
306
|
|
- {},
|
307
|
|
- );
|
308
|
|
-
|
309
|
|
- deletedIds = { ...deletedIds };
|
310
|
|
-
|
311
|
|
- for (const [id, remoteDeletedEl] of Object.entries(
|
312
|
|
- remoteAppState.deletedIds,
|
313
|
|
- )) {
|
314
|
|
- if (
|
315
|
|
- !localElementMap[id] ||
|
316
|
|
- // don't remove local element if it's newer than the one
|
317
|
|
- // deleted on remote
|
318
|
|
- remoteDeletedEl.version >= localElementMap[id].version
|
319
|
|
- ) {
|
320
|
|
- deletedIds[id] = remoteDeletedEl;
|
321
|
|
- delete localElementMap[id];
|
322
|
|
- }
|
323
|
|
- }
|
|
299
|
+ const localElementMap = getElementMap(elements);
|
324
|
300
|
|
325
|
301
|
// Reconcile
|
326
|
302
|
elements = restoredState.elements
|
|
@@ -342,17 +318,27 @@ export class App extends React.Component<any, AppState> {
|
342
|
318
|
) {
|
343
|
319
|
elements.push(localElementMap[element.id]);
|
344
|
320
|
delete localElementMap[element.id];
|
345
|
|
- } else {
|
346
|
|
- if (deletedIds.hasOwnProperty(element.id)) {
|
347
|
|
- if (element.version > deletedIds[element.id].version) {
|
348
|
|
- elements.push(element);
|
349
|
|
- delete deletedIds[element.id];
|
350
|
|
- delete localElementMap[element.id];
|
351
|
|
- }
|
|
321
|
+ } else if (
|
|
322
|
+ localElementMap.hasOwnProperty(element.id) &&
|
|
323
|
+ localElementMap[element.id].version === element.version &&
|
|
324
|
+ localElementMap[element.id].versionNonce !==
|
|
325
|
+ element.versionNonce
|
|
326
|
+ ) {
|
|
327
|
+ // resolve conflicting edits deterministically by taking the one with the lowest versionNonce
|
|
328
|
+ if (
|
|
329
|
+ localElementMap[element.id].versionNonce <
|
|
330
|
+ element.versionNonce
|
|
331
|
+ ) {
|
|
332
|
+ elements.push(localElementMap[element.id]);
|
352
|
333
|
} else {
|
|
334
|
+ // it should be highly unlikely that the two versionNonces are the same. if we are
|
|
335
|
+ // really worried about this, we can replace the versionNonce with the socket id.
|
353
|
336
|
elements.push(element);
|
354
|
|
- delete localElementMap[element.id];
|
355
|
337
|
}
|
|
338
|
+ delete localElementMap[element.id];
|
|
339
|
+ } else {
|
|
340
|
+ elements.push(element);
|
|
341
|
+ delete localElementMap[element.id];
|
356
|
342
|
}
|
357
|
343
|
|
358
|
344
|
return elements;
|
|
@@ -360,9 +346,15 @@ export class App extends React.Component<any, AppState> {
|
360
|
346
|
// add local elements that weren't deleted or on remote
|
361
|
347
|
.concat(...Object.values(localElementMap));
|
362
|
348
|
}
|
363
|
|
- this.setState({
|
364
|
|
- deletedIds,
|
365
|
|
- });
|
|
349
|
+ this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion(
|
|
350
|
+ elements,
|
|
351
|
+ );
|
|
352
|
+ // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
|
|
353
|
+ // when we receive any messages from another peer. This UX can be pretty rough -- if you
|
|
354
|
+ // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
|
355
|
+ // right now we think this is the right tradeoff.
|
|
356
|
+ history.clear();
|
|
357
|
+ this.setState({});
|
366
|
358
|
if (this.socketInitialized === false) {
|
367
|
359
|
this.socketInitialized = true;
|
368
|
360
|
}
|
|
@@ -370,13 +362,13 @@ export class App extends React.Component<any, AppState> {
|
370
|
362
|
case "MOUSE_LOCATION":
|
371
|
363
|
const { socketID, pointerCoords } = decryptedData.payload;
|
372
|
364
|
this.setState(state => {
|
373
|
|
- if (state.collaborators.has(socketID)) {
|
374
|
|
- const user = state.collaborators.get(socketID)!;
|
375
|
|
- user.pointer = pointerCoords;
|
376
|
|
- state.collaborators.set(socketID, user);
|
377
|
|
- return state;
|
|
365
|
+ if (!state.collaborators.has(socketID)) {
|
|
366
|
+ state.collaborators.set(socketID, {});
|
378
|
367
|
}
|
379
|
|
- return null;
|
|
368
|
+ const user = state.collaborators.get(socketID)!;
|
|
369
|
+ user.pointer = pointerCoords;
|
|
370
|
+ state.collaborators.set(socketID, user);
|
|
371
|
+ return state;
|
380
|
372
|
});
|
381
|
373
|
break;
|
382
|
374
|
}
|
|
@@ -428,24 +420,16 @@ export class App extends React.Component<any, AppState> {
|
428
|
420
|
};
|
429
|
421
|
|
430
|
422
|
private broadcastSceneUpdate = () => {
|
431
|
|
- const deletedIds = { ...this.state.deletedIds };
|
432
|
|
- const _elements = elements.filter(element => {
|
433
|
|
- if (element.id in deletedIds) {
|
434
|
|
- delete deletedIds[element.id];
|
435
|
|
- }
|
436
|
|
- return element.id !== this.state.editingElement?.id;
|
437
|
|
- });
|
438
|
423
|
const data: SocketUpdateDataSource["SCENE_UPDATE"] = {
|
439
|
424
|
type: "SCENE_UPDATE",
|
440
|
425
|
payload: {
|
441
|
|
- elements: _elements,
|
442
|
|
- appState: {
|
443
|
|
- viewBackgroundColor: this.state.viewBackgroundColor,
|
444
|
|
- name: this.state.name,
|
445
|
|
- deletedIds,
|
446
|
|
- },
|
|
426
|
+ elements: getSyncableElements(elements),
|
447
|
427
|
},
|
448
|
428
|
};
|
|
429
|
+ this.lastBroadcastedOrReceivedSceneVersion = Math.max(
|
|
430
|
+ this.lastBroadcastedOrReceivedSceneVersion,
|
|
431
|
+ getDrawingVersion(elements),
|
|
432
|
+ );
|
449
|
433
|
return this._broadcastSocketData(
|
450
|
434
|
data as typeof data & { _brand: "socketUpdateData" },
|
451
|
435
|
);
|
|
@@ -840,7 +824,7 @@ export class App extends React.Component<any, AppState> {
|
840
|
824
|
action: () => this.pasteFromClipboard(null),
|
841
|
825
|
},
|
842
|
826
|
probablySupportsClipboardBlob &&
|
843
|
|
- elements.length > 0 && {
|
|
827
|
+ hasNonDeletedElements(elements) && {
|
844
|
828
|
label: t("labels.copyAsPng"),
|
845
|
829
|
action: this.copyToClipboardAsPng,
|
846
|
830
|
},
|
|
@@ -1102,6 +1086,7 @@ export class App extends React.Component<any, AppState> {
|
1102
|
1086
|
const pnt = points[points.length - 1];
|
1103
|
1087
|
pnt[0] = x - originX;
|
1104
|
1088
|
pnt[1] = y - originY;
|
|
1089
|
+ mutateElement(multiElement);
|
1105
|
1090
|
invalidateShapeForElement(multiElement);
|
1106
|
1091
|
this.setState({});
|
1107
|
1092
|
return;
|
|
@@ -1485,6 +1470,7 @@ export class App extends React.Component<any, AppState> {
|
1485
|
1470
|
},
|
1486
|
1471
|
}));
|
1487
|
1472
|
multiElement.points.push([x - rx, y - ry]);
|
|
1473
|
+ mutateElement(multiElement);
|
1488
|
1474
|
invalidateShapeForElement(multiElement);
|
1489
|
1475
|
} else {
|
1490
|
1476
|
this.setState(prevState => ({
|
|
@@ -1494,6 +1480,7 @@ export class App extends React.Component<any, AppState> {
|
1494
|
1480
|
},
|
1495
|
1481
|
}));
|
1496
|
1482
|
element.points.push([0, 0]);
|
|
1483
|
+ mutateElement(element);
|
1497
|
1484
|
invalidateShapeForElement(element);
|
1498
|
1485
|
elements = [...elements, element];
|
1499
|
1486
|
this.setState({
|
|
@@ -1548,20 +1535,19 @@ export class App extends React.Component<any, AppState> {
|
1548
|
1535
|
|
1549
|
1536
|
const dx = element.x + width + p1[0];
|
1550
|
1537
|
const dy = element.y + height + p1[1];
|
|
1538
|
+ p1[0] = absPx - element.x;
|
|
1539
|
+ p1[1] = absPy - element.y;
|
1551
|
1540
|
mutateElement(element, {
|
1552
|
1541
|
x: dx,
|
1553
|
1542
|
y: dy,
|
1554
|
1543
|
});
|
1555
|
|
- p1[0] = absPx - element.x;
|
1556
|
|
- p1[1] = absPy - element.y;
|
1557
|
1544
|
} else {
|
|
1545
|
+ p1[0] -= deltaX;
|
|
1546
|
+ p1[1] -= deltaY;
|
1558
|
1547
|
mutateElement(element, {
|
1559
|
1548
|
x: element.x + deltaX,
|
1560
|
1549
|
y: element.y + deltaY,
|
1561
|
1550
|
});
|
1562
|
|
-
|
1563
|
|
- p1[0] -= deltaX;
|
1564
|
|
- p1[1] -= deltaY;
|
1565
|
1551
|
}
|
1566
|
1552
|
};
|
1567
|
1553
|
|
|
@@ -1586,6 +1572,7 @@ export class App extends React.Component<any, AppState> {
|
1586
|
1572
|
p1[0] += deltaX;
|
1587
|
1573
|
p1[1] += deltaY;
|
1588
|
1574
|
}
|
|
1575
|
+ mutateElement(element);
|
1589
|
1576
|
};
|
1590
|
1577
|
|
1591
|
1578
|
const onPointerMove = (event: PointerEvent) => {
|
|
@@ -1925,6 +1912,8 @@ export class App extends React.Component<any, AppState> {
|
1925
|
1912
|
pnt[0] = dx;
|
1926
|
1913
|
pnt[1] = dy;
|
1927
|
1914
|
}
|
|
1915
|
+
|
|
1916
|
+ mutateElement(draggingElement, { points });
|
1928
|
1917
|
} else {
|
1929
|
1918
|
if (event.shiftKey) {
|
1930
|
1919
|
({ width, height } = getPerfectElementSize(
|
|
@@ -2005,6 +1994,7 @@ export class App extends React.Component<any, AppState> {
|
2005
|
1994
|
x - draggingElement.x,
|
2006
|
1995
|
y - draggingElement.y,
|
2007
|
1996
|
]);
|
|
1997
|
+ mutateElement(draggingElement);
|
2008
|
1998
|
invalidateShapeForElement(draggingElement);
|
2009
|
1999
|
this.setState({
|
2010
|
2000
|
multiElement: this.state.draggingElement,
|
|
@@ -2263,13 +2253,20 @@ export class App extends React.Component<any, AppState> {
|
2263
|
2253
|
if (scrollBars) {
|
2264
|
2254
|
currentScrollBars = scrollBars;
|
2265
|
2255
|
}
|
2266
|
|
- const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
|
|
2256
|
+ const scrolledOutside =
|
|
2257
|
+ !atLeastOneVisibleElement && hasNonDeletedElements(elements);
|
2267
|
2258
|
if (this.state.scrolledOutside !== scrolledOutside) {
|
2268
|
2259
|
this.setState({ scrolledOutside: scrolledOutside });
|
2269
|
2260
|
}
|
2270
|
2261
|
this.saveDebounced();
|
2271
|
|
- if (history.isRecording()) {
|
|
2262
|
+
|
|
2263
|
+ if (
|
|
2264
|
+ getDrawingVersion(elements) > this.lastBroadcastedOrReceivedSceneVersion
|
|
2265
|
+ ) {
|
2272
|
2266
|
this.broadcastSceneUpdate();
|
|
2267
|
+ }
|
|
2268
|
+
|
|
2269
|
+ if (history.isRecording()) {
|
2273
|
2270
|
history.pushEntry(this.state, elements);
|
2274
|
2271
|
history.skipRecording();
|
2275
|
2272
|
}
|