Skip to content

Commit 5015f99

Browse files
committed
chore: update history section in lesson19
1 parent c8b2413 commit 5015f99

File tree

7 files changed

+397
-55
lines changed

7 files changed

+397
-55
lines changed

packages/site/docs/.vitepress/config/zh.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export const zh = defineConfig({
5858
{ text: '课程16 - 文本的高级特性', link: 'lesson-016' },
5959
{ text: '课程17 - 渐变和重复图案', link: 'lesson-017' },
6060
{ text: '课程18 - 使用 ECS 重构', link: 'lesson-018' },
61-
{ text: '课程19 - 协同', link: 'lesson-019' },
61+
{ text: '课程19 - 历史记录与协同', link: 'lesson-019' },
6262
],
6363
},
6464
],
+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script setup lang="ts">
2+
import {
3+
App,
4+
Pen,
5+
DefaultPlugins,
6+
} from '@infinite-canvas-tutorial/ecs';
7+
import { ref, onMounted, onUnmounted } from 'vue';
8+
9+
const wrapper = ref<HTMLElement | null>(null);
10+
let api: any | undefined;
11+
let onReady: ((api: CustomEvent<any>) => void) | undefined;
12+
13+
onMounted(async () => {
14+
const canvas = wrapper.value;
15+
if (!canvas) {
16+
return;
17+
}
18+
19+
const { Event, UIPlugin } = await import('@infinite-canvas-tutorial/webcomponents');
20+
await import('@infinite-canvas-tutorial/webcomponents/spectrum');
21+
22+
onReady = (e) => {
23+
api = e.detail;
24+
25+
const node = {
26+
type: 'rect',
27+
id: '0',
28+
fill: 'red',
29+
stroke: 'black',
30+
x: 100,
31+
y: 100,
32+
width: 100,
33+
height: 100,
34+
};
35+
36+
api.setPen(Pen.SELECT);
37+
api.setTaskbars(['show-layers-panel']);
38+
39+
api.updateNodes([node]);
40+
api.updateNode(node, {
41+
fill: 'blue',
42+
});
43+
};
44+
45+
canvas.addEventListener(Event.READY, onReady);
46+
47+
// App only runs once
48+
if (!(window as any).worldInited) {
49+
new App().addPlugins(...DefaultPlugins, UIPlugin).run();
50+
(window as any).worldInited = true;
51+
}
52+
});
53+
54+
onUnmounted(async () => {
55+
const canvas = wrapper.value;
56+
if (!canvas) {
57+
return;
58+
}
59+
60+
if (onReady) {
61+
// canvas.removeEventListener(Event.READY, onReady);
62+
}
63+
64+
api?.destroy();
65+
});
66+
</script>
67+
68+
<template>
69+
<div>
70+
<ic-spectrum-canvas ref="wrapper" style="width: 100%; height: 400px"></ic-spectrum-canvas>
71+
</div>
72+
</template>

packages/site/docs/components/Spectrum.vue

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from '@infinite-canvas-tutorial/ecs';
77
import { ref, onMounted, onUnmounted } from 'vue';
88
9-
const wrapper = ref < HTMLElement | null > (null);
9+
const wrapper = ref<HTMLElement | null>(null);
1010
let api: any | undefined;
1111
let onReady: ((api: CustomEvent<any>) => void) | undefined;
1212
@@ -64,6 +64,6 @@ onUnmounted(async () => {
6464

6565
<template>
6666
<div>
67-
<ic-spectrum-canvas ref="wrapper" style="width: 100%; height: 400px"></ic-spectrum-canvas>
67+
<ic-spectrum-canvas ref="wrapper" style="width: 100%; height: 600px"></ic-spectrum-canvas>
6868
</div>
6969
</template>

packages/site/docs/guide/lesson-019.md

+161-22
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,170 @@ publish: false
44
---
55

66
<script setup>
7+
import History from '../components/History.vue';
78
import LoroCRDT from '../components/LoroCRDT.vue';
89
</script>
910

1011
# Lesson 19 - History and collaboration
1112

1213
In this lesson, we'll explore how to implement multi-user collaborative editing functionality. We'll introduce several core concepts and technologies, including history records, Local-first, and CRDT.
1314

15+
## History {#history}
16+
17+
<History />
18+
19+
Whether you are a text or graphical editor, the history and undo redo functionality is a must. As implemented in [JavaScript-Undo-Manager], we can use an undoStack to save each operation and its inverse:
20+
21+
```ts
22+
function createPerson(id, name) {
23+
// first creation
24+
addPerson(id, name);
25+
26+
// make undoable
27+
undoManager.add({
28+
undo: () => removePerson(id),
29+
redo: () => addPerson(id, name),
30+
});
31+
}
32+
```
33+
34+
We can also use two stacks to manage undo and redo operations separately, see: [UI Algorithms: A Tiny Undo Stack]. The last section of [How Figma's multiplayer technology works] introduces Figma's implementation approach:
35+
36+
> This is why in Figma an undo operation modifies redo history at the time of the undo, and likewise a redo operation modifies undo history at the time of the redo.
37+
38+
Referring to [Excalidraw HistoryEntry], we add a History class to manage undo and redo operations.
39+
40+
```ts
41+
export class History {
42+
#undoStack: HistoryStack = [];
43+
#redoStack: HistoryStack = [];
44+
45+
clear() {
46+
this.#undoStack.length = 0;
47+
this.#redoStack.length = 0;
48+
}
49+
}
50+
```
51+
52+
Each entry in the history stack contains two types of modifications to the system state, which we describe below:
53+
54+
```ts
55+
type HistoryStack = HistoryEntry[];
56+
export class HistoryEntry {
57+
private constructor(
58+
public readonly appStateChange: AppStateChange,
59+
public readonly elementsChange: ElementsChange,
60+
) {}
61+
}
62+
```
63+
64+
### Desgin states {#design-states}
65+
66+
Referring to Excalidraw, we split the system state into `AppState` and `Elements`. The former includes the state of the canvas as well as the UI components, such as the current theme, the camera zoom level, the toolbar configurations and selections, etc.
67+
68+
```ts
69+
export interface AppState {
70+
theme: Theme;
71+
checkboardStyle: CheckboardStyle;
72+
cameraZoom: number;
73+
penbarAll: Pen[];
74+
penbarSelected: Pen[];
75+
taskbarAll: Task[];
76+
taskbarSelected: Task[];
77+
layersSelected: SerializedNode['id'][];
78+
propertiesOpened: SerializedNode['id'][];
79+
}
80+
```
81+
82+
As you can see, we prefer to use a flat data structure rather than a nested object structure like `{ penbar: { all: [], selected: [] } }`, in order to allow for quicker and easier state diff considerations that don't require recursion, see: [distinctKeysIterator].
83+
84+
The latter is the array of shapes in the canvas, which we previously covered in [Lesson 10] in the context of serializing shapes. Here we use a flat array instead of a tree structure, move the attributes in the `attributes` object up to the top level, and represent the parent-child relationship slightly differently, using `parentId` to associate the parent node with the `id`. However, we can't just traverse the tree structure and render it, we need to sort the graph array according to some rules, which we'll cover later:
85+
86+
```ts
87+
// before
88+
interface SerializedNode {
89+
id: string;
90+
children: [];
91+
attributes: {
92+
fill: string;
93+
stroke: string;
94+
};
95+
}
96+
97+
// after
98+
interface SerializedNode {
99+
id: string;
100+
parentId?: string;
101+
fill: string;
102+
stroke: string;
103+
}
104+
```
105+
106+
Consider collaboration we'll add more attributes like `version` later on. Let's see how to add a history entry.
107+
108+
### Record a history entry {#record-history-entry}
109+
110+
In the example at the top of this section, we used the API to insert two histories for updating the fill color of the rectangle, which you can do using the undo and redo operations in the top toolbar:
111+
112+
```ts
113+
api.updateNode(node, {
114+
fill: 'red',
115+
});
116+
api.updateNode(node, {
117+
fill: 'blue',
118+
});
119+
```
120+
121+
Each call to `api.updateNode` adds a history record because the state of the graph did change, but it is important to note that only AppState changes should not reset the redoStack. when a change occurs we add the inverse operation of the change to the undoStack:
122+
123+
```ts
124+
export class History {
125+
record(elementsChange: ElementsChange, appStateChange: AppStateChange) {
126+
const entry = HistoryEntry.create(appStateChange, elementsChange);
127+
128+
if (!entry.isEmpty()) {
129+
// 添加逆操作
130+
this.#undoStack.push(entry.inverse());
131+
if (!entry.elementsChange.isEmpty()) {
132+
this.#redoStack.length = 0;
133+
}
134+
}
135+
}
136+
}
137+
```
138+
139+
Now we can look at how to design the `AppStateChange` and `ElementsChange` data structures for `Change`, allowing us to use a generic `entry.inverse()` instead of describing each changeable attribute with `add/removeFill` `add/removeStroke` and so on.
140+
141+
### Desgin change structure {#design-change-structure}
142+
143+
The `Change` interface in Excalidraw is very simple:
144+
145+
```ts
146+
export interface Change<T> {
147+
/**
148+
* Inverses the `Delta`s inside while creating a new `Change`.
149+
*/
150+
inverse(): Change<T>;
151+
152+
/**
153+
* Applies the `Change` to the previous object.
154+
*
155+
* @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change.
156+
*/
157+
applyTo(previous: T, ...options: unknown[]): [T, boolean];
158+
159+
/**
160+
* Checks whether there are actually `Delta`s.
161+
*/
162+
isEmpty(): boolean;
163+
}
164+
```
165+
166+
```ts
167+
class AppStateChange implements Change<AppState> {}
168+
class ElementsChange implements Change<SceneElementsMap> {}
169+
```
170+
14171
## CRDT {#crdt}
15172

16173
What is CRDT? The following introduction comes from [What are CRDTs]. The collaborative features of Google Docs / Figma / Tiptap are all implemented based on it. This article also compares the characteristics of CRDT and OT in detail:
@@ -143,28 +300,6 @@ Referring to [Excalidraw updateScene], we can also provide an `updateScene` meth
143300
canvas.updateScene({ elements });
144301
```
145302

146-
## History {#history}
147-
148-
Referring to [Excalidraw HistoryEntry], we add a History class to manage undo and redo operations.
149-
150-
```ts
151-
export class History {
152-
#undoStack: HistoryStack = [];
153-
#redoStack: HistoryStack = [];
154-
155-
clear() {
156-
this.#undoStack.length = 0;
157-
this.#redoStack.length = 0;
158-
}
159-
}
160-
```
161-
162-
### Undo and Redo {#undo-and-redo}
163-
164-
The last section of [How Figma's multiplayer technology works] introduces Figma's implementation approach:
165-
166-
> This is why in Figma an undo operation modifies redo history at the time of the undo, and likewise a redo operation modifies undo history at the time of the redo.
167-
168303
## Extended Reading {#extended-reading}
169304

170305
- [How Figma's multiplayer technology works]
@@ -198,3 +333,7 @@ The last section of [How Figma's multiplayer technology works] introduces Figma'
198333
[Excalidraw updateScene]: https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#updatescene
199334
[fractional-indexing]: https://github.com/rocicorp/fractional-indexing
200335
[Movable tree CRDTs and Loro's implementation]: https://loro.dev/blog/movable-tree
336+
[UI Algorithms: A Tiny Undo Stack]: https://blog.julik.nl/2025/03/a-tiny-undo-stack
337+
[JavaScript-Undo-Manager]: https://github.com/ArthurClemens/JavaScript-Undo-Manager
338+
[distinctKeysIterator]: https://github.com/excalidraw/excalidraw/blob/dff69e91912507bbfcc68b35277cc6031ce5b437/packages/excalidraw/change.ts#L359
339+
[Lesson 10]: /guide/lesson-010#shape-to-serialized-node

0 commit comments

Comments
 (0)