@loro-extended/change 2.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -1
- package/dist/index.d.ts +89 -14
- package/dist/index.js +480 -156
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/overlay.ts +62 -3
- package/src/shape.ts +69 -8
- package/src/typed-doc.ts +1 -0
- package/src/typed-refs/base.ts +7 -18
- package/src/typed-refs/counter.ts +0 -2
- package/src/typed-refs/doc.ts +3 -21
- package/src/typed-refs/list-base.ts +28 -29
- package/src/typed-refs/list-value-updates.test.ts +213 -0
- package/src/typed-refs/movable-list.ts +0 -2
- package/src/typed-refs/record-value-updates.test.ts +214 -0
- package/src/typed-refs/record.ts +48 -51
- package/src/typed-refs/struct-value-updates.test.ts +200 -0
- package/src/typed-refs/struct.ts +39 -44
- package/src/typed-refs/text.ts +0 -6
- package/src/typed-refs/tree-node-value-updates.test.ts +234 -0
- package/src/typed-refs/tree-node.ts +236 -0
- package/src/typed-refs/tree.test.ts +384 -0
- package/src/typed-refs/tree.ts +252 -24
- package/src/typed-refs/utils.ts +30 -7
- package/src/types.ts +36 -1
- package/src/utils/type-guards.ts +1 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { change } from "../functional-helpers.js"
|
|
3
|
+
import { Shape } from "../shape.js"
|
|
4
|
+
import { createTypedDoc } from "../typed-doc.js"
|
|
5
|
+
|
|
6
|
+
// Define the state machine schema from the plan
|
|
7
|
+
const StateNodeDataShape = Shape.struct({
|
|
8
|
+
name: Shape.text(),
|
|
9
|
+
facts: Shape.record(Shape.plain.any()),
|
|
10
|
+
rules: Shape.list(
|
|
11
|
+
Shape.plain.struct({
|
|
12
|
+
name: Shape.plain.string(),
|
|
13
|
+
rego: Shape.plain.string(),
|
|
14
|
+
description: Shape.plain.string().nullable(),
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const ResmSchema = Shape.doc({
|
|
20
|
+
states: Shape.tree(StateNodeDataShape),
|
|
21
|
+
currentPath: Shape.list(Shape.plain.string()),
|
|
22
|
+
input: Shape.record(Shape.plain.any()),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe("TreeRef", () => {
|
|
26
|
+
describe("basic operations", () => {
|
|
27
|
+
it("should create a root node with typed data", () => {
|
|
28
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
29
|
+
|
|
30
|
+
change(typedDoc, draft => {
|
|
31
|
+
const root = draft.states.createNode()
|
|
32
|
+
expect(root).toBeDefined()
|
|
33
|
+
expect(root.id).toBeDefined()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("should create a root node and set data", () => {
|
|
38
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
39
|
+
|
|
40
|
+
change(typedDoc, draft => {
|
|
41
|
+
const root = draft.states.createNode()
|
|
42
|
+
// Set data after creation
|
|
43
|
+
root.data.name.insert(0, "idle")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Verify the data was set
|
|
47
|
+
const json = typedDoc.toJSON()
|
|
48
|
+
expect(json.states).toHaveLength(1)
|
|
49
|
+
expect(json.states[0].data.name).toBe("idle")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("should create child nodes", () => {
|
|
53
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
54
|
+
|
|
55
|
+
change(typedDoc, draft => {
|
|
56
|
+
const root = draft.states.createNode()
|
|
57
|
+
root.data.name.insert(0, "idle")
|
|
58
|
+
|
|
59
|
+
const child = root.createNode()
|
|
60
|
+
child.data.name.insert(0, "running")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const json = typedDoc.toJSON()
|
|
64
|
+
expect(json.states).toHaveLength(1)
|
|
65
|
+
expect(json.states[0].data.name).toBe("idle")
|
|
66
|
+
expect(json.states[0].children).toHaveLength(1)
|
|
67
|
+
expect(json.states[0].children[0].data.name).toBe("running")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("should access node.data with type safety", () => {
|
|
71
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
72
|
+
|
|
73
|
+
change(typedDoc, draft => {
|
|
74
|
+
const root = draft.states.createNode()
|
|
75
|
+
root.data.name.insert(0, "idle")
|
|
76
|
+
|
|
77
|
+
// Access nested list container
|
|
78
|
+
root.data.rules.push({
|
|
79
|
+
name: "rule1",
|
|
80
|
+
rego: "package test",
|
|
81
|
+
description: null,
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const json = typedDoc.toJSON()
|
|
86
|
+
expect(json.states[0].data.name).toBe("idle")
|
|
87
|
+
expect(json.states[0].data.rules).toEqual([
|
|
88
|
+
{ name: "rule1", rego: "package test", description: null },
|
|
89
|
+
])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("should access record containers in node.data", () => {
|
|
93
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
94
|
+
|
|
95
|
+
change(typedDoc, draft => {
|
|
96
|
+
const root = draft.states.createNode()
|
|
97
|
+
root.data.name.insert(0, "idle")
|
|
98
|
+
|
|
99
|
+
// Access nested record container - use set method
|
|
100
|
+
const facts = root.data.facts
|
|
101
|
+
facts.set("key1", "value1")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const json = typedDoc.toJSON()
|
|
105
|
+
expect(json.states[0].data.name).toBe("idle")
|
|
106
|
+
expect(json.states[0].data.facts).toEqual({ key1: "value1" })
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe("tree navigation", () => {
|
|
111
|
+
it("should get roots", () => {
|
|
112
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
113
|
+
|
|
114
|
+
change(typedDoc, draft => {
|
|
115
|
+
draft.states.createNode()
|
|
116
|
+
draft.states.createNode()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
change(typedDoc, draft => {
|
|
120
|
+
const roots = draft.states.roots()
|
|
121
|
+
expect(roots).toHaveLength(2)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("should get all nodes", () => {
|
|
126
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
127
|
+
|
|
128
|
+
change(typedDoc, draft => {
|
|
129
|
+
const root = draft.states.createNode()
|
|
130
|
+
root.createNode()
|
|
131
|
+
root.createNode()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
change(typedDoc, draft => {
|
|
135
|
+
const nodes = draft.states.nodes()
|
|
136
|
+
expect(nodes).toHaveLength(3) // 1 root + 2 children
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("should navigate parent/children relationships", () => {
|
|
141
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
142
|
+
|
|
143
|
+
let rootId: string | undefined
|
|
144
|
+
let childId: string | undefined
|
|
145
|
+
|
|
146
|
+
change(typedDoc, draft => {
|
|
147
|
+
const root = draft.states.createNode()
|
|
148
|
+
root.data.name.insert(0, "root")
|
|
149
|
+
rootId = root.id
|
|
150
|
+
|
|
151
|
+
const child = root.createNode()
|
|
152
|
+
child.data.name.insert(0, "child")
|
|
153
|
+
childId = child.id
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
change(typedDoc, draft => {
|
|
157
|
+
const root = draft.states.getNodeByID(rootId as string)
|
|
158
|
+
expect(root).toBeDefined()
|
|
159
|
+
expect(root?.children()).toHaveLength(1)
|
|
160
|
+
|
|
161
|
+
const child = draft.states.getNodeByID(childId as string)
|
|
162
|
+
expect(child).toBeDefined()
|
|
163
|
+
expect(child?.parent()?.id).toBe(rootId)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("should get node by ID", () => {
|
|
168
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
169
|
+
|
|
170
|
+
let nodeId: string | undefined
|
|
171
|
+
|
|
172
|
+
change(typedDoc, draft => {
|
|
173
|
+
const root = draft.states.createNode()
|
|
174
|
+
root.data.name.insert(0, "test")
|
|
175
|
+
nodeId = root.id
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
change(typedDoc, draft => {
|
|
179
|
+
const node = draft.states.getNodeByID(nodeId as string)
|
|
180
|
+
expect(node).toBeDefined()
|
|
181
|
+
expect(node?.data.name.toString()).toBe("test")
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("should check if node exists with has()", () => {
|
|
186
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
187
|
+
|
|
188
|
+
let nodeId: string | undefined
|
|
189
|
+
|
|
190
|
+
change(typedDoc, draft => {
|
|
191
|
+
const root = draft.states.createNode()
|
|
192
|
+
nodeId = root.id
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
change(typedDoc, draft => {
|
|
196
|
+
expect(draft.states.has(nodeId as string)).toBe(true)
|
|
197
|
+
expect(draft.states.has("0@999" as any)).toBe(false)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe("tree mutations", () => {
|
|
203
|
+
it("should delete a node", () => {
|
|
204
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
205
|
+
|
|
206
|
+
let nodeId: string | undefined
|
|
207
|
+
|
|
208
|
+
change(typedDoc, draft => {
|
|
209
|
+
const root = draft.states.createNode()
|
|
210
|
+
nodeId = root.id
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
change(typedDoc, draft => {
|
|
214
|
+
draft.states.delete(nodeId as string)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const json = typedDoc.toJSON()
|
|
218
|
+
expect(json.states).toHaveLength(0)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("should move nodes between parents", () => {
|
|
222
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
223
|
+
|
|
224
|
+
let root1Id: string | undefined
|
|
225
|
+
let root2Id: string | undefined
|
|
226
|
+
let childId: string | undefined
|
|
227
|
+
|
|
228
|
+
change(typedDoc, draft => {
|
|
229
|
+
const root1 = draft.states.createNode()
|
|
230
|
+
root1.data.name.insert(0, "root1")
|
|
231
|
+
root1Id = root1.id
|
|
232
|
+
|
|
233
|
+
const root2 = draft.states.createNode()
|
|
234
|
+
root2.data.name.insert(0, "root2")
|
|
235
|
+
root2Id = root2.id
|
|
236
|
+
|
|
237
|
+
const child = root1.createNode()
|
|
238
|
+
child.data.name.insert(0, "child")
|
|
239
|
+
childId = child.id
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
// Move child from root1 to root2
|
|
243
|
+
change(typedDoc, draft => {
|
|
244
|
+
const child = draft.states.getNodeByID(childId as string)
|
|
245
|
+
const root2 = draft.states.getNodeByID(root2Id as string)
|
|
246
|
+
if (child && root2) {
|
|
247
|
+
child.move(root2)
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
change(typedDoc, draft => {
|
|
252
|
+
const root1 = draft.states.getNodeByID(root1Id as string)
|
|
253
|
+
const root2 = draft.states.getNodeByID(root2Id as string)
|
|
254
|
+
|
|
255
|
+
expect(root1?.children()).toHaveLength(0)
|
|
256
|
+
expect(root2?.children()).toHaveLength(1)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe("serialization", () => {
|
|
262
|
+
it("should serialize tree to nested JSON with toJSON()", () => {
|
|
263
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
264
|
+
|
|
265
|
+
change(typedDoc, draft => {
|
|
266
|
+
const root = draft.states.createNode()
|
|
267
|
+
root.data.name.insert(0, "root")
|
|
268
|
+
|
|
269
|
+
const child1 = root.createNode()
|
|
270
|
+
child1.data.name.insert(0, "child1")
|
|
271
|
+
|
|
272
|
+
const child2 = root.createNode()
|
|
273
|
+
child2.data.name.insert(0, "child2")
|
|
274
|
+
|
|
275
|
+
const grandchild = child1.createNode()
|
|
276
|
+
grandchild.data.name.insert(0, "grandchild")
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const json = typedDoc.toJSON()
|
|
280
|
+
expect(json.states).toHaveLength(1)
|
|
281
|
+
expect(json.states[0].data.name).toBe("root")
|
|
282
|
+
expect(json.states[0].children).toHaveLength(2)
|
|
283
|
+
expect(json.states[0].children[0].children).toHaveLength(1)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it("should serialize tree to flat array with toArray()", () => {
|
|
287
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
288
|
+
|
|
289
|
+
change(typedDoc, draft => {
|
|
290
|
+
const root = draft.states.createNode()
|
|
291
|
+
root.data.name.insert(0, "root")
|
|
292
|
+
|
|
293
|
+
const child = root.createNode()
|
|
294
|
+
child.data.name.insert(0, "child")
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
change(typedDoc, draft => {
|
|
298
|
+
const array = draft.states.toArray()
|
|
299
|
+
expect(array).toHaveLength(2)
|
|
300
|
+
// Each item should have id, parent, index, fractionalIndex, data
|
|
301
|
+
expect(array[0]).toHaveProperty("id")
|
|
302
|
+
expect(array[0]).toHaveProperty("parent")
|
|
303
|
+
expect(array[0]).toHaveProperty("index")
|
|
304
|
+
expect(array[0]).toHaveProperty("data")
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
describe("fractional index", () => {
|
|
310
|
+
it("should enable fractional index", () => {
|
|
311
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
312
|
+
|
|
313
|
+
change(typedDoc, draft => {
|
|
314
|
+
draft.states.enableFractionalIndex(8)
|
|
315
|
+
const root = draft.states.createNode()
|
|
316
|
+
expect(root.fractionalIndex()).toBeDefined()
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("should get node index among siblings", () => {
|
|
321
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
322
|
+
|
|
323
|
+
change(typedDoc, draft => {
|
|
324
|
+
const root = draft.states.createNode()
|
|
325
|
+
const child1 = root.createNode()
|
|
326
|
+
const child2 = root.createNode()
|
|
327
|
+
|
|
328
|
+
expect(child1.index()).toBe(0)
|
|
329
|
+
expect(child2.index()).toBe(1)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe("absorbPlainValues", () => {
|
|
335
|
+
it("should propagate absorbPlainValues to all nodes", () => {
|
|
336
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
337
|
+
|
|
338
|
+
change(typedDoc, draft => {
|
|
339
|
+
const root = draft.states.createNode()
|
|
340
|
+
root.data.name.insert(0, "test")
|
|
341
|
+
root.data.rules.push({
|
|
342
|
+
name: "rule1",
|
|
343
|
+
rego: "package test",
|
|
344
|
+
description: null,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
// absorbPlainValues is called automatically at the end of change()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// Verify data was persisted
|
|
351
|
+
const json = typedDoc.toJSON()
|
|
352
|
+
expect(json.states[0].data.name).toBe("test")
|
|
353
|
+
expect(json.states[0].data.rules).toEqual([
|
|
354
|
+
{ name: "rule1", rego: "package test", description: null },
|
|
355
|
+
])
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
describe("node deletion tracking", () => {
|
|
360
|
+
it("should track deleted nodes with isDeleted()", () => {
|
|
361
|
+
const typedDoc = createTypedDoc(ResmSchema)
|
|
362
|
+
|
|
363
|
+
let nodeId: string | undefined
|
|
364
|
+
|
|
365
|
+
change(typedDoc, draft => {
|
|
366
|
+
const root = draft.states.createNode()
|
|
367
|
+
nodeId = root.id
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
change(typedDoc, draft => {
|
|
371
|
+
const node = draft.states.getNodeByID(nodeId as string)
|
|
372
|
+
if (node) {
|
|
373
|
+
expect(node.isDeleted()).toBe(false)
|
|
374
|
+
draft.states.delete(nodeId as string)
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// After deletion, Loro's has() still returns true (node exists in history)
|
|
379
|
+
// but isDeleted() returns true and the node won't appear in toJSON()
|
|
380
|
+
const json = typedDoc.toJSON()
|
|
381
|
+
expect(json.states).toHaveLength(0)
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
})
|
package/src/typed-refs/tree.ts
CHANGED
|
@@ -1,40 +1,268 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { LoroDoc, LoroTree, LoroTreeNode, TreeID } from "loro-crdt"
|
|
2
|
+
import type {
|
|
3
|
+
StructContainerShape,
|
|
4
|
+
TreeContainerShape,
|
|
5
|
+
TreeNodeJSON,
|
|
6
|
+
} from "../shape.js"
|
|
2
7
|
import type { Infer } from "../types.js"
|
|
3
|
-
import {
|
|
8
|
+
import { TreeNodeRef } from "./tree-node.js"
|
|
4
9
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Parameters for creating a TreeRef.
|
|
12
|
+
*/
|
|
13
|
+
export interface TreeRefParams<DataShape extends StructContainerShape> {
|
|
14
|
+
shape: TreeContainerShape<DataShape>
|
|
15
|
+
placeholder?: never[]
|
|
16
|
+
getContainer: () => LoroTree
|
|
17
|
+
autoCommit?: boolean
|
|
18
|
+
batchedMutation?: boolean
|
|
19
|
+
getDoc?: () => LoroDoc
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Typed ref for tree (forest) containers.
|
|
24
|
+
* Wraps LoroTree with type-safe access to node metadata.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const StateNodeDataShape = Shape.struct({
|
|
29
|
+
* name: Shape.text(),
|
|
30
|
+
* facts: Shape.record(Shape.plain.any()),
|
|
31
|
+
* })
|
|
32
|
+
*
|
|
33
|
+
* doc.$.change(draft => {
|
|
34
|
+
* const root = draft.states.createNode({ name: "idle", facts: {} })
|
|
35
|
+
* const child = root.createNode({ name: "running", facts: {} })
|
|
36
|
+
* child.data.name = "active"
|
|
37
|
+
* })
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export class TreeRef<DataShape extends StructContainerShape> {
|
|
41
|
+
private nodeCache = new Map<TreeID, TreeNodeRef<DataShape>>()
|
|
42
|
+
private _cachedContainer?: LoroTree
|
|
43
|
+
protected _params: TreeRefParams<DataShape>
|
|
44
|
+
|
|
45
|
+
constructor(params: TreeRefParams<DataShape>) {
|
|
46
|
+
this._params = params
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
protected get shape(): TreeContainerShape<DataShape> {
|
|
50
|
+
return this._params.shape
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
protected get container(): LoroTree {
|
|
54
|
+
if (!this._cachedContainer) {
|
|
55
|
+
this._cachedContainer = this._params.getContainer()
|
|
56
|
+
}
|
|
57
|
+
return this._cachedContainer
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
protected get dataShape(): DataShape {
|
|
61
|
+
return this.shape.shape
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected get autoCommit(): boolean {
|
|
65
|
+
return !!this._params.autoCommit
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
protected get batchedMutation(): boolean {
|
|
69
|
+
return !!this._params.batchedMutation
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
protected get doc(): LoroDoc | undefined {
|
|
73
|
+
return this._params.getDoc?.()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Commits changes if autoCommit is enabled.
|
|
78
|
+
*/
|
|
79
|
+
protected commitIfAuto(): void {
|
|
80
|
+
if (this.autoCommit && this.doc) {
|
|
81
|
+
this.doc.commit()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Absorb plain values from all cached nodes.
|
|
87
|
+
* Called before committing changes to ensure all pending values are written.
|
|
88
|
+
*/
|
|
89
|
+
absorbPlainValues(): void {
|
|
90
|
+
for (const nodeRef of this.nodeCache.values()) {
|
|
91
|
+
nodeRef.absorbPlainValues()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get or create a TreeNodeRef for the given LoroTreeNode.
|
|
97
|
+
* Uses caching to ensure the same TreeNodeRef is returned for the same node.
|
|
98
|
+
*/
|
|
99
|
+
getOrCreateNodeRef(node: LoroTreeNode): TreeNodeRef<DataShape> {
|
|
100
|
+
const id = node.id
|
|
101
|
+
let nodeRef = this.nodeCache.get(id)
|
|
102
|
+
if (!nodeRef) {
|
|
103
|
+
nodeRef = new TreeNodeRef({
|
|
104
|
+
node,
|
|
105
|
+
dataShape: this.dataShape,
|
|
106
|
+
treeRef: this,
|
|
107
|
+
autoCommit: this.autoCommit,
|
|
108
|
+
batchedMutation: this.batchedMutation,
|
|
109
|
+
getDoc: this._params.getDoc,
|
|
110
|
+
})
|
|
111
|
+
this.nodeCache.set(id, nodeRef)
|
|
112
|
+
}
|
|
113
|
+
return nodeRef
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a new root node with optional initial data.
|
|
118
|
+
*
|
|
119
|
+
* @param initialData - Optional partial data to initialize the node with
|
|
120
|
+
* @returns The created TreeNodeRef
|
|
121
|
+
*/
|
|
122
|
+
createNode(initialData?: Partial<Infer<DataShape>>): TreeNodeRef<DataShape> {
|
|
123
|
+
const loroNode = this.container.createNode()
|
|
124
|
+
const nodeRef = this.getOrCreateNodeRef(loroNode)
|
|
125
|
+
|
|
126
|
+
// Initialize data if provided
|
|
127
|
+
if (initialData) {
|
|
128
|
+
for (const [key, value] of Object.entries(initialData)) {
|
|
129
|
+
;(nodeRef.data as any)[key] = value
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.commitIfAuto()
|
|
134
|
+
return nodeRef
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get all root nodes (nodes without parents).
|
|
139
|
+
* Returns nodes in their fractional index order.
|
|
140
|
+
*/
|
|
141
|
+
roots(): TreeNodeRef<DataShape>[] {
|
|
142
|
+
return this.container.roots().map(node => this.getOrCreateNodeRef(node))
|
|
9
143
|
}
|
|
10
144
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Get all nodes in the tree (unordered).
|
|
147
|
+
* Includes all nodes, not just roots.
|
|
148
|
+
*/
|
|
149
|
+
nodes(): TreeNodeRef<DataShape>[] {
|
|
150
|
+
return this.container.nodes().map(node => this.getOrCreateNodeRef(node))
|
|
14
151
|
}
|
|
15
152
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Get a node by its TreeID.
|
|
155
|
+
*
|
|
156
|
+
* @param id - The TreeID of the node to find
|
|
157
|
+
* @returns The TreeNodeRef if found, undefined otherwise
|
|
158
|
+
*/
|
|
159
|
+
getNodeByID(id: TreeID): TreeNodeRef<DataShape> | undefined {
|
|
160
|
+
// Check cache first
|
|
161
|
+
const cached = this.nodeCache.get(id)
|
|
162
|
+
if (cached) return cached
|
|
163
|
+
|
|
164
|
+
// Check if node exists in tree
|
|
165
|
+
if (!this.container.has(id)) return undefined
|
|
166
|
+
|
|
167
|
+
// Find the node in the tree's nodes
|
|
168
|
+
const nodes = this.container.nodes()
|
|
169
|
+
const node = nodes.find(n => n.id === id)
|
|
170
|
+
if (!node) return undefined
|
|
171
|
+
|
|
172
|
+
return this.getOrCreateNodeRef(node)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Check if a node with the given ID exists in the tree.
|
|
177
|
+
*/
|
|
178
|
+
has(id: TreeID): boolean {
|
|
179
|
+
return this.container.has(id)
|
|
19
180
|
}
|
|
20
181
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Delete a node and all its descendants.
|
|
184
|
+
* Also removes the node from the cache.
|
|
185
|
+
*
|
|
186
|
+
* @param target - The TreeID or TreeNodeRef to delete
|
|
187
|
+
*/
|
|
188
|
+
delete(target: TreeID | TreeNodeRef<DataShape>): void {
|
|
189
|
+
const id = typeof target === "string" ? target : target.id
|
|
190
|
+
this.container.delete(id)
|
|
191
|
+
// Remove from cache
|
|
192
|
+
this.nodeCache.delete(id)
|
|
193
|
+
this.commitIfAuto()
|
|
24
194
|
}
|
|
25
195
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Enable fractional index generation for ordering.
|
|
198
|
+
*
|
|
199
|
+
* @param jitter - Optional jitter value to avoid conflicts (0 = no jitter)
|
|
200
|
+
*/
|
|
201
|
+
enableFractionalIndex(jitter = 0): void {
|
|
202
|
+
this.container.enableFractionalIndex(jitter)
|
|
29
203
|
}
|
|
30
204
|
|
|
31
|
-
|
|
32
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Serialize the tree to a nested JSON structure.
|
|
207
|
+
* Each node includes its data and children recursively.
|
|
208
|
+
*/
|
|
209
|
+
toJSON(): TreeNodeJSON<DataShape>[] {
|
|
210
|
+
// Use Loro's native toJSON which returns nested structure
|
|
211
|
+
const nativeJson = this.container.toJSON() as any[]
|
|
212
|
+
return this.transformNativeJson(nativeJson)
|
|
33
213
|
}
|
|
34
214
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Transform Loro's native JSON format to our typed format.
|
|
217
|
+
*/
|
|
218
|
+
private transformNativeJson(nodes: any[]): TreeNodeJSON<DataShape>[] {
|
|
219
|
+
return nodes.map(node => ({
|
|
220
|
+
id: node.id as TreeID,
|
|
221
|
+
parent: node.parent as TreeID | null,
|
|
222
|
+
index: node.index as number,
|
|
223
|
+
fractionalIndex: node.fractional_index as string,
|
|
224
|
+
data: node.meta as Infer<DataShape>,
|
|
225
|
+
children: this.transformNativeJson(node.children || []),
|
|
226
|
+
}))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get a flat array representation of all nodes.
|
|
231
|
+
* Flattens the nested tree structure into a single array.
|
|
232
|
+
*/
|
|
233
|
+
toArray(): Array<{
|
|
234
|
+
id: TreeID
|
|
235
|
+
parent: TreeID | null
|
|
236
|
+
index: number
|
|
237
|
+
fractionalIndex: string
|
|
238
|
+
data: Infer<DataShape>
|
|
239
|
+
}> {
|
|
240
|
+
const result: Array<{
|
|
241
|
+
id: TreeID
|
|
242
|
+
parent: TreeID | null
|
|
243
|
+
index: number
|
|
244
|
+
fractionalIndex: string
|
|
245
|
+
data: Infer<DataShape>
|
|
246
|
+
}> = []
|
|
247
|
+
|
|
248
|
+
// Flatten the nested structure
|
|
249
|
+
const flattenNodes = (nodes: any[]) => {
|
|
250
|
+
for (const node of nodes) {
|
|
251
|
+
result.push({
|
|
252
|
+
id: node.id as TreeID,
|
|
253
|
+
parent: node.parent as TreeID | null,
|
|
254
|
+
index: node.index as number,
|
|
255
|
+
fractionalIndex: node.fractional_index as string,
|
|
256
|
+
data: node.meta as Infer<DataShape>,
|
|
257
|
+
})
|
|
258
|
+
if (node.children && node.children.length > 0) {
|
|
259
|
+
flattenNodes(node.children)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const nativeJson = this.container.toJSON() as any[]
|
|
265
|
+
flattenNodes(nativeJson)
|
|
266
|
+
return result
|
|
39
267
|
}
|
|
40
268
|
}
|