Skip to content

Commit bee6451

Browse files
fix(collab): Fix Duplicate commands make editors become out-of-sync
Duplicate commands, when serialized, did not store enough information to be completely reconstructed
1 parent 56975a1 commit bee6451

File tree

3 files changed

+110
-5
lines changed

3 files changed

+110
-5
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Duplicate from './Duplicate';
2+
import { Color4, Path } from '@js-draw/math';
3+
import { EditorImage, pathToRenderable, SerializableCommand, Stroke } from '../lib';
4+
import createEditor from '../testing/createEditor';
5+
6+
const getAllStrokeIds = (editorImage: EditorImage) => {
7+
const strokes = editorImage.getAllElements().filter((elem) => elem instanceof Stroke);
8+
return strokes.map((stroke) => stroke.getId());
9+
};
10+
11+
describe('Duplicate', () => {
12+
test('deserialized Duplicate commands should create clones with the same IDs', async () => {
13+
const editor = createEditor();
14+
15+
const stroke = new Stroke([
16+
pathToRenderable(Path.fromString('m0,0 l10,10 l-10,0'), { fill: Color4.red }),
17+
]);
18+
await editor.dispatch(editor.image.addElement(stroke));
19+
20+
const command = new Duplicate([stroke]);
21+
command.apply(editor);
22+
23+
// Should have duplicated [element]
24+
const strokes1 = getAllStrokeIds(editor.image);
25+
expect(strokes1).toHaveLength(2);
26+
// Should not have removed the first element
27+
expect(strokes1).toContain(stroke.getId());
28+
// Clone should have a different ID
29+
expect(strokes1.filter((id) => id !== stroke.getId())).toHaveLength(1);
30+
31+
// Apply a deserialized copy of the command
32+
const serialized = command.serialize();
33+
const deserialized = SerializableCommand.deserialize(serialized, editor);
34+
35+
command.unapply(editor);
36+
deserialized.apply(editor);
37+
38+
// The copy should produce a clone with the same ID
39+
const strokes2 = getAllStrokeIds(editor.image);
40+
expect(strokes1).toEqual(strokes2);
41+
42+
// It should be possible to unapply the deserialized command
43+
deserialized.unapply(editor);
44+
45+
expect(getAllStrokeIds(editor.image)).toEqual([stroke.getId()]);
46+
});
47+
});

packages/js-draw/src/commands/Duplicate.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import AbstractComponent from '../components/AbstractComponent';
22
import describeComponentList from '../components/util/describeComponentList';
33
import Editor from '../Editor';
44
import { EditorLocalization } from '../localization';
5+
import { assertIsStringArray } from '../util/assertions';
56
import Erase from './Erase';
67
import SerializableCommand from './SerializableCommand';
78

@@ -32,10 +33,23 @@ export default class Duplicate extends SerializableCommand {
3233
private duplicates: AbstractComponent[];
3334
private reverse: Erase;
3435

35-
public constructor(private toDuplicate: AbstractComponent[]) {
36+
public constructor(
37+
private toDuplicate: AbstractComponent[],
38+
39+
// @internal -- IDs given to the duplicate elements
40+
idsForDuplicates?: string[],
41+
) {
3642
super('duplicate');
3743

38-
this.duplicates = toDuplicate.map((elem) => elem.clone());
44+
this.duplicates = toDuplicate.map((elem, idx) => {
45+
// For collaborative editing, it's important for the clones to have
46+
// the same IDs as the originals
47+
if (idsForDuplicates && idsForDuplicates[idx]) {
48+
return elem.cloneWithId(idsForDuplicates[idx]);
49+
} else {
50+
return elem.clone();
51+
}
52+
});
3953
this.reverse = new Erase(this.duplicates);
4054
}
4155

@@ -63,13 +77,42 @@ export default class Duplicate extends SerializableCommand {
6377
}
6478

6579
protected serializeToJSON() {
66-
return this.toDuplicate.map((elem) => elem.getId());
80+
return {
81+
originalIds: this.toDuplicate.map((elem) => elem.getId()),
82+
cloneIds: this.duplicates.map((elem) => elem.getId()),
83+
};
6784
}
6885

6986
static {
7087
SerializableCommand.register('duplicate', (json: any, editor: Editor) => {
71-
const elems = json.map((id: string) => editor.image.lookupElement(id));
72-
return new Duplicate(elems);
88+
let originalIds;
89+
let cloneIds;
90+
// Compatibility with older editors
91+
if (Array.isArray(json)) {
92+
originalIds = json;
93+
cloneIds = [];
94+
} else {
95+
originalIds = json.originalIds;
96+
cloneIds = json.cloneIds;
97+
}
98+
assertIsStringArray(originalIds);
99+
assertIsStringArray(cloneIds);
100+
101+
// Resolve to elements -- only keep the elements that can be found in the image.
102+
const resolvedElements = [];
103+
const filteredCloneIds = [];
104+
for (let i = 0; i < originalIds.length; i++) {
105+
const originalId = originalIds[i];
106+
const foundElement = editor.image.lookupElement(originalId);
107+
if (!foundElement) {
108+
console.warn('Duplicate command: Could not find element with ID', originalId);
109+
} else {
110+
filteredCloneIds.push(cloneIds[i]);
111+
resolvedElements.push(foundElement);
112+
}
113+
}
114+
115+
return new Duplicate(resolvedElements, filteredCloneIds);
73116
});
74117
}
75118
}

packages/js-draw/src/components/AbstractComponent.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import UnresolvedSerializableCommand from '../commands/UnresolvedCommand';
99
import Viewport from '../Viewport';
1010
import { Point2 } from '@js-draw/math';
1111
import describeTransformation from '../util/describeTransformation';
12+
import { assertIsString } from '../util/assertions';
1213

1314
export type LoadSaveData = string[] | Record<symbol, string | number>;
1415
export type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
@@ -426,6 +427,18 @@ export default abstract class AbstractComponent {
426427
return clone;
427428
}
428429

430+
/**
431+
* Creates a copy of this component with a particular `id`.
432+
* This is used internally by {@link Duplicate} when deserializing.
433+
*
434+
* @internal -- users of the library shouldn't need this.
435+
*/
436+
public cloneWithId(cloneId: string) {
437+
const clone = this.clone();
438+
clone.id = cloneId;
439+
return clone;
440+
}
441+
429442
/**
430443
* **Optional method**: Divides this component into sections roughly along the given path,
431444
* removing parts that are roughly within `shape`.
@@ -494,6 +507,8 @@ export default abstract class AbstractComponent {
494507
throw new Error(`Element with data ${json} cannot be deserialized.`);
495508
}
496509

510+
assertIsString(json.id);
511+
497512
const instance = this.deserializationCallbacks[json.name]!(json.data);
498513
instance.id = json.id;
499514

0 commit comments

Comments
 (0)