Skip to content

Commit 238c50b

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

File tree

3 files changed

+237
-27
lines changed

3 files changed

+237
-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: Function;
30+
redo: Function;
31+
};
32+
1633
export type YorkiePresenceType = {
1734
tdUser: TDUser;
1835
};

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

Lines changed: 183 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,43 @@ export function useMultiplayerState(roomId: string) {
5562
[roomId],
5663
);
5764

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

112+
// Object that stores the latest state value of yorkie doc before the client changes
113+
const currentYorkieDocSnapshot: TlType = {
114+
shapes: {},
115+
bindings: {},
116+
assets: {},
117+
};
118+
68119
const getUpdatedPropertyList = <T extends object>(
69120
source: T,
70121
target: T,
@@ -76,18 +127,26 @@ export function useMultiplayerState(roomId: string) {
76127

77128
Object.entries(shapes).forEach(([id, shape]) => {
78129
doc.update((root) => {
130+
const rootShapesToJS = root.shapes.toJS!();
79131
if (!shape) {
132+
currentYorkieDocSnapshot.shapes[id] = rootShapesToJS[id];
80133
delete root.shapes[id];
81134
} else if (!root.shapes[id]) {
135+
currentYorkieDocSnapshot.shapes[id] = undefined!;
82136
root.shapes[id] = shape;
83137
} else {
84138
const updatedPropertyList = getUpdatedPropertyList(
85139
shape,
86-
root.shapes[id]!.toJS!(),
140+
rootShapesToJS[id],
87141
);
88-
142+
currentYorkieDocSnapshot.shapes[id] =
143+
{} as yorkie.JSONObject<TDShape>;
89144
updatedPropertyList.forEach((key) => {
90145
const newValue = shape[key];
146+
const snapshotValue = rootShapesToJS[id][key];
147+
(currentYorkieDocSnapshot.shapes[id][
148+
key
149+
] as typeof snapshotValue) = snapshotValue;
91150
(root.shapes[id][key] as typeof newValue) = newValue;
92151
});
93152
}
@@ -96,18 +155,26 @@ export function useMultiplayerState(roomId: string) {
96155

97156
Object.entries(bindings).forEach(([id, binding]) => {
98157
doc.update((root) => {
158+
const rootBindingsToJS = root.bindings.toJS!();
99159
if (!binding) {
160+
currentYorkieDocSnapshot.bindings[id] = rootBindingsToJS[id];
100161
delete root.bindings[id];
101162
} else if (!root.bindings[id]) {
163+
currentYorkieDocSnapshot.bindings[id] = undefined!;
102164
root.bindings[id] = binding;
103165
} else {
104166
const updatedPropertyList = getUpdatedPropertyList(
105167
binding,
106-
root.bindings[id]!.toJS!(),
168+
rootBindingsToJS[id],
107169
);
108-
170+
currentYorkieDocSnapshot.bindings[id] =
171+
{} as yorkie.JSONObject<TDBinding>;
109172
updatedPropertyList.forEach((key) => {
110173
const newValue = binding[key];
174+
const snapshotValue = rootBindingsToJS[id][key];
175+
(currentYorkieDocSnapshot.bindings[id][
176+
key
177+
] as typeof snapshotValue) = snapshotValue;
111178
(root.bindings[id][key] as typeof newValue) = newValue;
112179
});
113180
}
@@ -118,14 +185,16 @@ export function useMultiplayerState(roomId: string) {
118185
// Document key for assets should be asset.id (string), not index
119186
Object.entries(app.assets).forEach(([, asset]) => {
120187
doc.update((root) => {
188+
const rootAssetsToJS = root.assets.toJS!();
189+
currentYorkieDocSnapshot.assets[asset.id] = rootAssetsToJS[asset.id];
121190
if (!asset.id) {
122191
delete root.assets[asset.id];
123-
} else if (root.assets[asset.id]) {
192+
} else if (!root.assets[asset.id]) {
124193
root.assets[asset.id] = asset;
125194
} else {
126195
const updatedPropertyList = getUpdatedPropertyList(
127196
asset,
128-
root.assets[asset.id]!.toJS!(),
197+
rootAssetsToJS[asset.id],
129198
);
130199

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

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

169342
window.addEventListener('beforeunload', handleDisconnect);
170343

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-
190344
let stillAlive = true;
191345

192346
// Setup the document's storage and subscriptions
@@ -294,5 +448,7 @@ export function useMultiplayerState(roomId: string) {
294448
onChangePage,
295449
loading,
296450
onChangePresence,
451+
onUndo,
452+
onRedo,
297453
};
298454
}
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)