Skip to content

Commit e40c6c7

Browse files
committed
Add tldraw undo/redo example
1 parent 1594345 commit e40c6c7

File tree

3 files changed

+231
-27
lines changed

3 files changed

+231
-27
lines changed

examples/react-tldraw/src/hooks/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@ export type YorkieDocType = {
1313
assets: JSONObject<Record<string, JSONObject<TDAsset>>>;
1414
};
1515

16+
export type TlType = {
17+
shapes: Record<string, JSONObject<TDShape>>;
18+
bindings: Record<string, JSONObject<TDBinding>>;
19+
assets: Record<string, JSONObject<TDAsset>>;
20+
};
21+
22+
export type HistoryType = {
23+
undoStack: Array<CommandType>;
24+
redoStack: Array<CommandType>;
25+
};
26+
27+
export type CommandType = {
28+
snapshot: TlType;
29+
undo: () => void;
30+
redo: () => void;
31+
};
32+
1633
export type YorkiePresenceType = {
1734
tdUser: TDUser;
1835
};

examples/react-tldraw/src/hooks/useMultiplayerState.ts

Lines changed: 177 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@ import * as yorkie from 'yorkie-js-sdk';
1313
import randomColor from 'randomcolor';
1414
import { uniqueNamesGenerator, names } from 'unique-names-generator';
1515
import _ from 'lodash';
16+
import useUndoRedo from './useUndoRedo';
1617

17-
import type { Options, YorkieDocType, YorkiePresenceType } from './types';
18+
import type {
19+
Options,
20+
YorkieDocType,
21+
YorkiePresenceType,
22+
TlType,
23+
} from './types';
1824

1925
// Yorkie Client declaration
2026
let client: yorkie.Client;
@@ -25,6 +31,7 @@ let doc: yorkie.Document<YorkieDocType, YorkiePresenceType>;
2531
export function useMultiplayerState(roomId: string) {
2632
const [app, setApp] = useState<TldrawApp>();
2733
const [loading, setLoading] = useState(true);
34+
const { push, undo, redo } = useUndoRedo();
2835

2936
// Callbacks --------------
3037

@@ -55,6 +62,37 @@ export function useMultiplayerState(roomId: string) {
5562
[roomId],
5663
);
5764

65+
// undo
66+
67+
const onUndo = useCallback(() => {
68+
undo();
69+
}, [roomId]);
70+
71+
// redo
72+
73+
const onRedo = useCallback(() => {
74+
redo();
75+
}, [roomId]);
76+
77+
// Subscribe to changes
78+
function handleChanges() {
79+
const root = doc.getRoot();
80+
81+
// Parse proxy object to record
82+
const shapeRecord: Record<string, TDShape> = JSON.parse(
83+
root.shapes.toJSON!(),
84+
);
85+
const bindingRecord: Record<string, TDBinding> = JSON.parse(
86+
root.bindings.toJSON!(),
87+
);
88+
const assetRecord: Record<string, TDAsset> = JSON.parse(
89+
root.assets.toJSON!(),
90+
);
91+
92+
// Replace page content with changed(propagated) records
93+
app?.replacePageContent(shapeRecord, bindingRecord, assetRecord);
94+
}
95+
5896
// Update Yorkie doc when the app's shapes change.
5997
// Prevent overloading yorkie update api call by throttle
6098
const onChangePage = useThrottleCallback(
@@ -65,6 +103,13 @@ export function useMultiplayerState(roomId: string) {
65103
) => {
66104
if (!app || client === undefined || doc === undefined) return;
67105

106+
// Object that stores the latest state value of yorkie doc before the client changes
107+
const currentYorkieDocSnapshot: TlType = {
108+
shapes: {},
109+
bindings: {},
110+
assets: {},
111+
};
112+
68113
const getUpdatedPropertyList = <T extends object>(
69114
source: T,
70115
target: T,
@@ -76,18 +121,26 @@ export function useMultiplayerState(roomId: string) {
76121

77122
Object.entries(shapes).forEach(([id, shape]) => {
78123
doc.update((root) => {
124+
const rootShapesToJS = root.shapes.toJS!();
79125
if (!shape) {
126+
currentYorkieDocSnapshot.shapes[id] = rootShapesToJS[id];
80127
delete root.shapes[id];
81128
} else if (!root.shapes[id]) {
129+
currentYorkieDocSnapshot.shapes[id] = undefined!;
82130
root.shapes[id] = shape;
83131
} else {
84132
const updatedPropertyList = getUpdatedPropertyList(
85133
shape,
86-
root.shapes[id]!.toJS!(),
134+
rootShapesToJS[id],
87135
);
88-
136+
currentYorkieDocSnapshot.shapes[id] =
137+
{} as yorkie.JSONObject<TDShape>;
89138
updatedPropertyList.forEach((key) => {
90139
const newValue = shape[key];
140+
const snapshotValue = rootShapesToJS[id][key];
141+
(currentYorkieDocSnapshot.shapes[id][
142+
key
143+
] as typeof snapshotValue) = snapshotValue;
91144
(root.shapes[id][key] as typeof newValue) = newValue;
92145
});
93146
}
@@ -96,18 +149,26 @@ export function useMultiplayerState(roomId: string) {
96149

97150
Object.entries(bindings).forEach(([id, binding]) => {
98151
doc.update((root) => {
152+
const rootBindingsToJS = root.bindings.toJS!();
99153
if (!binding) {
154+
currentYorkieDocSnapshot.bindings[id] = rootBindingsToJS[id];
100155
delete root.bindings[id];
101156
} else if (!root.bindings[id]) {
157+
currentYorkieDocSnapshot.bindings[id] = undefined!;
102158
root.bindings[id] = binding;
103159
} else {
104160
const updatedPropertyList = getUpdatedPropertyList(
105161
binding,
106-
root.bindings[id]!.toJS!(),
162+
rootBindingsToJS[id],
107163
);
108-
164+
currentYorkieDocSnapshot.bindings[id] =
165+
{} as yorkie.JSONObject<TDBinding>;
109166
updatedPropertyList.forEach((key) => {
110167
const newValue = binding[key];
168+
const snapshotValue = rootBindingsToJS[id][key];
169+
(currentYorkieDocSnapshot.bindings[id][
170+
key
171+
] as typeof snapshotValue) = snapshotValue;
111172
(root.bindings[id][key] as typeof newValue) = newValue;
112173
});
113174
}
@@ -118,14 +179,16 @@ export function useMultiplayerState(roomId: string) {
118179
// Document key for assets should be asset.id (string), not index
119180
Object.entries(app.assets).forEach(([, asset]) => {
120181
doc.update((root) => {
182+
const rootAssetsToJS = root.assets.toJS!();
183+
currentYorkieDocSnapshot.assets[asset.id] = rootAssetsToJS[asset.id];
121184
if (!asset.id) {
122185
delete root.assets[asset.id];
123-
} else if (root.assets[asset.id]) {
186+
} else if (!root.assets[asset.id]) {
124187
root.assets[asset.id] = asset;
125188
} else {
126189
const updatedPropertyList = getUpdatedPropertyList(
127190
asset,
128-
root.assets[asset.id]!.toJS!(),
191+
rootAssetsToJS[asset.id],
129192
);
130193

131194
updatedPropertyList.forEach((key) => {
@@ -135,8 +198,112 @@ export function useMultiplayerState(roomId: string) {
135198
}
136199
});
137200
});
201+
202+
// Command object for action
203+
// Undo, redo work the same way
204+
// undo(): Save yorkie doc's state before returning
205+
// redo(): Save yorkie doc's state before moving forward
206+
const command = {
207+
snapshot: currentYorkieDocSnapshot,
208+
undo: () => {
209+
const currentYorkieDocSnapshot: TlType = {
210+
shapes: {},
211+
bindings: {},
212+
assets: {},
213+
};
214+
const snapshot = command.snapshot;
215+
Object.entries(snapshot.shapes).forEach(([id, shape]) => {
216+
doc.update((root) => {
217+
const rootShapesToJS = root.shapes.toJS!();
218+
if (!shape) {
219+
currentYorkieDocSnapshot.shapes[id] = rootShapesToJS[id];
220+
delete root.shapes[id];
221+
} else if (!root.shapes.toJS!()[id]) {
222+
currentYorkieDocSnapshot.shapes[id] = undefined!;
223+
if (shape.id) root.shapes[id] = shape;
224+
} else {
225+
currentYorkieDocSnapshot.shapes[id] =
226+
{} as yorkie.JSONObject<TDShape>;
227+
(
228+
Object.keys(snapshot.shapes[id]) as Array<keyof TDShape>
229+
).forEach((key) => {
230+
const snapshotValue = snapshot.shapes[id][key];
231+
const newSnapshotValue = rootShapesToJS[id][key];
232+
233+
(currentYorkieDocSnapshot.shapes[id][
234+
key
235+
] as typeof newSnapshotValue) = newSnapshotValue;
236+
(root.shapes[id][key] as typeof snapshotValue) =
237+
snapshotValue;
238+
});
239+
}
240+
});
241+
});
242+
243+
Object.entries(snapshot.bindings).forEach(([id, binding]) => {
244+
doc.update((root) => {
245+
const rootBindingsToJs = root.bindings.toJS!();
246+
if (!binding) {
247+
currentYorkieDocSnapshot.bindings[id] = rootBindingsToJs[id];
248+
delete root.bindings[id];
249+
} else if (!root.bindings.toJS!()[id]) {
250+
currentYorkieDocSnapshot.bindings[id] = undefined!;
251+
if (binding.id) root.bindings[id] = binding;
252+
} else {
253+
currentYorkieDocSnapshot.bindings[id] =
254+
{} as yorkie.JSONObject<TDBinding>;
255+
(
256+
Object.keys(snapshot.bindings[id]) as Array<keyof TDBinding>
257+
).forEach((key) => {
258+
const snapshotValue = snapshot.bindings[id][key];
259+
const newSnapshotValue = rootBindingsToJs[id][key];
260+
261+
(currentYorkieDocSnapshot.bindings[id][
262+
key
263+
] as typeof newSnapshotValue) = newSnapshotValue;
264+
(root.bindings[id][key] as typeof snapshotValue) =
265+
snapshotValue;
266+
});
267+
}
268+
});
269+
});
270+
271+
Object.entries(snapshot.assets).forEach(([, asset]) => {
272+
doc.update((root) => {
273+
const rootAssetsToJs = root.assets.toJS!();
274+
currentYorkieDocSnapshot.assets[asset.id] =
275+
rootAssetsToJs[asset.id];
276+
if (!asset.id) {
277+
delete root.assets[asset.id];
278+
} else if (!root.assets.toJS!()[asset.id]) {
279+
root.assets[asset.id] = asset;
280+
} else {
281+
const updatedPropertyList = getUpdatedPropertyList(
282+
asset,
283+
rootAssetsToJs[asset.id],
284+
);
285+
286+
updatedPropertyList.forEach((key) => {
287+
const newValue = asset[key];
288+
(root.assets[asset.id][key] as typeof newValue) = newValue;
289+
});
290+
}
291+
});
292+
});
293+
command.snapshot = currentYorkieDocSnapshot;
294+
// Reflect changes locally
295+
handleChanges();
296+
},
297+
redo: () => {
298+
command.undo();
299+
handleChanges();
300+
},
301+
};
302+
303+
// Create History
304+
push(command);
138305
},
139-
60,
306+
20,
140307
false,
141308
);
142309

@@ -168,25 +335,6 @@ export function useMultiplayerState(roomId: string) {
168335

169336
window.addEventListener('beforeunload', handleDisconnect);
170337

171-
// Subscribe to changes
172-
function handleChanges() {
173-
const root = doc.getRoot();
174-
175-
// Parse proxy object to record
176-
const shapeRecord: Record<string, TDShape> = JSON.parse(
177-
root.shapes.toJSON!(),
178-
);
179-
const bindingRecord: Record<string, TDBinding> = JSON.parse(
180-
root.bindings.toJSON!(),
181-
);
182-
const assetRecord: Record<string, TDAsset> = JSON.parse(
183-
root.assets.toJSON!(),
184-
);
185-
186-
// Replace page content with changed(propagated) records
187-
app?.replacePageContent(shapeRecord, bindingRecord, assetRecord);
188-
}
189-
190338
let stillAlive = true;
191339

192340
// Setup the document's storage and subscriptions
@@ -294,5 +442,7 @@ export function useMultiplayerState(roomId: string) {
294442
onChangePage,
295443
loading,
296444
onChangePresence,
445+
onUndo,
446+
onRedo,
297447
};
298448
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { CommandType, HistoryType } from './types';
2+
3+
const history: HistoryType = {
4+
undoStack: [],
5+
redoStack: [],
6+
};
7+
8+
const useUndoRedo = () => {
9+
const { undoStack, redoStack } = history;
10+
11+
const push = (command: CommandType) => {
12+
undoStack.push(command);
13+
redoStack.length = 0;
14+
};
15+
16+
const undo = () => {
17+
if (undoStack.length === 0) return;
18+
const command: CommandType | undefined = undoStack.pop();
19+
if (command) {
20+
command.undo();
21+
redoStack.push(command);
22+
}
23+
};
24+
25+
const redo = () => {
26+
if (redoStack.length === 0) return;
27+
const command: CommandType | undefined = redoStack.pop();
28+
if (command) {
29+
command.redo();
30+
undoStack.push(command);
31+
}
32+
};
33+
34+
return { push, undo, redo };
35+
};
36+
37+
export default useUndoRedo;

0 commit comments

Comments
 (0)