mobx-keystone-loro 1.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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/mobx-keystone-loro.esm.js +957 -0
- package/dist/mobx-keystone-loro.esm.mjs +957 -0
- package/dist/mobx-keystone-loro.umd.js +957 -0
- package/dist/types/binding/LoroTextModel.d.ts +72 -0
- package/dist/types/binding/applyLoroEventToMobx.d.ts +9 -0
- package/dist/types/binding/applyMobxChangeToLoroObject.d.ts +7 -0
- package/dist/types/binding/bindLoroToMobxKeystone.d.ts +33 -0
- package/dist/types/binding/convertJsonToLoroData.d.ts +51 -0
- package/dist/types/binding/convertLoroDataToJson.d.ts +11 -0
- package/dist/types/binding/loroBindingContext.d.ts +39 -0
- package/dist/types/binding/loroSnapshotTracking.d.ts +26 -0
- package/dist/types/binding/moveWithinArray.d.ts +36 -0
- package/dist/types/binding/resolveLoroPath.d.ts +11 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/plainTypes.d.ts +6 -0
- package/dist/types/utils/error.d.ts +4 -0
- package/dist/types/utils/getOrCreateLoroCollectionAtom.d.ts +7 -0
- package/dist/types/utils/isBindableLoroContainer.d.ts +9 -0
- package/package.json +92 -0
- package/src/binding/LoroTextModel.ts +211 -0
- package/src/binding/applyLoroEventToMobx.ts +280 -0
- package/src/binding/applyMobxChangeToLoroObject.ts +182 -0
- package/src/binding/bindLoroToMobxKeystone.ts +353 -0
- package/src/binding/convertJsonToLoroData.ts +315 -0
- package/src/binding/convertLoroDataToJson.ts +68 -0
- package/src/binding/loroBindingContext.ts +46 -0
- package/src/binding/loroSnapshotTracking.ts +36 -0
- package/src/binding/moveWithinArray.ts +112 -0
- package/src/binding/resolveLoroPath.ts +37 -0
- package/src/index.ts +16 -0
- package/src/plainTypes.ts +7 -0
- package/src/utils/error.ts +12 -0
- package/src/utils/getOrCreateLoroCollectionAtom.ts +17 -0
- package/src/utils/isBindableLoroContainer.ts +13 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ContainerID,
|
|
3
|
+
type LoroDoc,
|
|
4
|
+
type LoroEventBatch,
|
|
5
|
+
LoroMap,
|
|
6
|
+
LoroMovableList,
|
|
7
|
+
LoroText,
|
|
8
|
+
} from "loro-crdt"
|
|
9
|
+
import { action } from "mobx"
|
|
10
|
+
import {
|
|
11
|
+
AnyDataModel,
|
|
12
|
+
AnyModel,
|
|
13
|
+
AnyStandardType,
|
|
14
|
+
DeepChange,
|
|
15
|
+
DeepChangeType,
|
|
16
|
+
fromSnapshot,
|
|
17
|
+
getParentToChildPath,
|
|
18
|
+
getSnapshot,
|
|
19
|
+
isTreeNode,
|
|
20
|
+
ModelClass,
|
|
21
|
+
onDeepChange,
|
|
22
|
+
onGlobalDeepChange,
|
|
23
|
+
onSnapshot,
|
|
24
|
+
resolvePath,
|
|
25
|
+
SnapshotOutOf,
|
|
26
|
+
TypeToData,
|
|
27
|
+
} from "mobx-keystone"
|
|
28
|
+
import { nanoid } from "nanoid"
|
|
29
|
+
import type { PlainArray, PlainObject } from "../plainTypes"
|
|
30
|
+
import { getOrCreateLoroCollectionAtom } from "../utils/getOrCreateLoroCollectionAtom"
|
|
31
|
+
import type { BindableLoroContainer } from "../utils/isBindableLoroContainer"
|
|
32
|
+
|
|
33
|
+
import { applyLoroEventToMobx, ReconciliationMap } from "./applyLoroEventToMobx"
|
|
34
|
+
import { applyMobxChangeToLoroObject } from "./applyMobxChangeToLoroObject"
|
|
35
|
+
import {
|
|
36
|
+
applyDeltaToLoroText,
|
|
37
|
+
applyJsonArrayToLoroMovableList,
|
|
38
|
+
applyJsonObjectToLoroMap,
|
|
39
|
+
extractTextDeltaFromSnapshot,
|
|
40
|
+
} from "./convertJsonToLoroData"
|
|
41
|
+
import { convertLoroDataToJson } from "./convertLoroDataToJson"
|
|
42
|
+
import { loroTextModelId } from "./LoroTextModel"
|
|
43
|
+
import { type LoroBindingContext, loroBindingContext } from "./loroBindingContext"
|
|
44
|
+
import { setLoroContainerSnapshot } from "./loroSnapshotTracking"
|
|
45
|
+
import {
|
|
46
|
+
type ArrayMoveChange,
|
|
47
|
+
isInMoveContextForArray,
|
|
48
|
+
processChangeForMove,
|
|
49
|
+
} from "./moveWithinArray"
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Captures snapshots of tree nodes in a DeepChange.
|
|
53
|
+
* This ensures values are captured at change time, not at apply time,
|
|
54
|
+
* preventing issues when values are mutated after being added to a collection.
|
|
55
|
+
*/
|
|
56
|
+
function captureChangeSnapshots(change: DeepChange): DeepChange {
|
|
57
|
+
if (change.type === DeepChangeType.ArraySplice && change.addedValues.length > 0) {
|
|
58
|
+
const snapshots = change.addedValues.map((v) => (isTreeNode(v) ? getSnapshot(v) : v))
|
|
59
|
+
return { ...change, addedValues: snapshots }
|
|
60
|
+
} else if (change.type === DeepChangeType.ArrayUpdate) {
|
|
61
|
+
const snapshot = isTreeNode(change.newValue) ? getSnapshot(change.newValue) : change.newValue
|
|
62
|
+
return { ...change, newValue: snapshot }
|
|
63
|
+
} else if (
|
|
64
|
+
change.type === DeepChangeType.ObjectAdd ||
|
|
65
|
+
change.type === DeepChangeType.ObjectUpdate
|
|
66
|
+
) {
|
|
67
|
+
const snapshot = isTreeNode(change.newValue) ? getSnapshot(change.newValue) : change.newValue
|
|
68
|
+
return { ...change, newValue: snapshot }
|
|
69
|
+
}
|
|
70
|
+
return change
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Creates a bidirectional binding between a Loro data structure and a mobx-keystone model.
|
|
75
|
+
*/
|
|
76
|
+
export function bindLoroToMobxKeystone<
|
|
77
|
+
TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>,
|
|
78
|
+
>({
|
|
79
|
+
loroDoc,
|
|
80
|
+
loroObject,
|
|
81
|
+
mobxKeystoneType,
|
|
82
|
+
}: {
|
|
83
|
+
/**
|
|
84
|
+
* The Loro document.
|
|
85
|
+
*/
|
|
86
|
+
loroDoc: LoroDoc
|
|
87
|
+
/**
|
|
88
|
+
* The bound Loro data structure.
|
|
89
|
+
*/
|
|
90
|
+
loroObject: BindableLoroContainer
|
|
91
|
+
/**
|
|
92
|
+
* The mobx-keystone model type.
|
|
93
|
+
*/
|
|
94
|
+
mobxKeystoneType: TType
|
|
95
|
+
}): {
|
|
96
|
+
/**
|
|
97
|
+
* The bound mobx-keystone instance.
|
|
98
|
+
*/
|
|
99
|
+
boundObject: TypeToData<TType>
|
|
100
|
+
/**
|
|
101
|
+
* Disposes the binding.
|
|
102
|
+
*/
|
|
103
|
+
dispose: () => void
|
|
104
|
+
/**
|
|
105
|
+
* The Loro origin string used for binding transactions.
|
|
106
|
+
*/
|
|
107
|
+
loroOrigin: string
|
|
108
|
+
} {
|
|
109
|
+
const loroOrigin = `bindLoroToMobxKeystoneTransactionOrigin-${nanoid()}`
|
|
110
|
+
|
|
111
|
+
let applyingLoroChangesToMobxKeystone = 0
|
|
112
|
+
|
|
113
|
+
const bindingContext: LoroBindingContext = {
|
|
114
|
+
loroDoc,
|
|
115
|
+
loroObject,
|
|
116
|
+
mobxKeystoneType,
|
|
117
|
+
loroOrigin,
|
|
118
|
+
boundObject: undefined, // not yet created
|
|
119
|
+
|
|
120
|
+
get isApplyingLoroChangesToMobxKeystone() {
|
|
121
|
+
return applyingLoroChangesToMobxKeystone > 0
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const loroJson = convertLoroDataToJson(loroObject) as SnapshotOutOf<TypeToData<TType>>
|
|
126
|
+
|
|
127
|
+
let boundObject: TypeToData<TType>
|
|
128
|
+
|
|
129
|
+
// Track if any init changes occurred during fromSnapshot
|
|
130
|
+
// If they did, we need to sync the model snapshot to the CRDT
|
|
131
|
+
let hasInitChanges = false
|
|
132
|
+
|
|
133
|
+
const createBoundObject = () => {
|
|
134
|
+
// Set up a temporary global listener to detect init changes during fromSnapshot
|
|
135
|
+
const disposeGlobalListener = onGlobalDeepChange((_target, change) => {
|
|
136
|
+
if (change.isInit) {
|
|
137
|
+
hasInitChanges = true
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const result = loroBindingContext.apply(
|
|
143
|
+
() => fromSnapshot(mobxKeystoneType, loroJson),
|
|
144
|
+
bindingContext
|
|
145
|
+
)
|
|
146
|
+
loroBindingContext.set(result, { ...bindingContext, boundObject: result })
|
|
147
|
+
return result
|
|
148
|
+
} finally {
|
|
149
|
+
disposeGlobalListener()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
boundObject = createBoundObject()
|
|
154
|
+
|
|
155
|
+
// Get the path to the root Loro object for path resolution
|
|
156
|
+
const rootLoroPath = loroDoc.getPathToContainer(loroObject.id) ?? []
|
|
157
|
+
|
|
158
|
+
// bind any changes from Loro to mobx-keystone
|
|
159
|
+
const loroSubscribeCb = action((eventBatch: LoroEventBatch) => {
|
|
160
|
+
// Skip changes that originated from this binding
|
|
161
|
+
if (eventBatch.origin === loroOrigin) {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Track newly inserted containers to avoid double-processing their events
|
|
166
|
+
const newlyInsertedContainers = new Set<ContainerID>()
|
|
167
|
+
|
|
168
|
+
// Create a map to store reconciliation data for this batch
|
|
169
|
+
const reconciliationMap: ReconciliationMap = new Map()
|
|
170
|
+
|
|
171
|
+
// Collect init changes that occur during event application
|
|
172
|
+
// (e.g., fromSnapshot calls that trigger onInit hooks)
|
|
173
|
+
// We store both target and change so we can compute the correct path later
|
|
174
|
+
// Snapshots are captured immediately to preserve values at init time
|
|
175
|
+
const initChanges: { target: object; change: DeepChange }[] = []
|
|
176
|
+
const disposeGlobalListener = onGlobalDeepChange((target, change) => {
|
|
177
|
+
if (change.isInit) {
|
|
178
|
+
initChanges.push({ target, change: captureChangeSnapshots(change) })
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
applyingLoroChangesToMobxKeystone++
|
|
183
|
+
try {
|
|
184
|
+
try {
|
|
185
|
+
for (const event of eventBatch.events) {
|
|
186
|
+
applyLoroEventToMobx(
|
|
187
|
+
event,
|
|
188
|
+
loroDoc,
|
|
189
|
+
boundObject,
|
|
190
|
+
rootLoroPath,
|
|
191
|
+
reconciliationMap,
|
|
192
|
+
newlyInsertedContainers
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
disposeGlobalListener()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Sync back any init-time mutations from fromSnapshot calls
|
|
200
|
+
// (e.g., onInit hooks that modify the model)
|
|
201
|
+
// This is needed because init changes during Loro event handling are not
|
|
202
|
+
// captured by the main onDeepChange (it skips changes when applyingLoroChangesToMobxKeystone > 0)
|
|
203
|
+
if (initChanges.length > 0) {
|
|
204
|
+
loroDoc.setNextCommitOrigin(loroOrigin)
|
|
205
|
+
|
|
206
|
+
for (const { target, change } of initChanges) {
|
|
207
|
+
// Compute the path from boundObject to the target
|
|
208
|
+
const pathToTarget = getParentToChildPath(boundObject, target)
|
|
209
|
+
if (pathToTarget !== undefined) {
|
|
210
|
+
// Create a new change with the correct path from the root
|
|
211
|
+
const changeWithCorrectPath: DeepChange = {
|
|
212
|
+
...change,
|
|
213
|
+
path: [...pathToTarget, ...change.path],
|
|
214
|
+
}
|
|
215
|
+
applyMobxChangeToLoroObject(changeWithCorrectPath, loroObject)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
loroDoc.commit()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Update snapshot tracking: the Loro container is now in sync with the current MobX snapshot
|
|
223
|
+
// This enables the merge optimization to skip unchanged subtrees during reconciliation
|
|
224
|
+
if (loroObject instanceof LoroMap || loroObject instanceof LoroMovableList) {
|
|
225
|
+
setLoroContainerSnapshot(loroObject, getSnapshot(boundObject))
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
applyingLoroChangesToMobxKeystone--
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
const loroUnsubscribe = loroDoc.subscribe(loroSubscribeCb)
|
|
232
|
+
|
|
233
|
+
// bind any changes from mobx-keystone to Loro
|
|
234
|
+
// Collect changes during an action and apply them after the action completes
|
|
235
|
+
let pendingChanges: (DeepChange | ArrayMoveChange)[] = []
|
|
236
|
+
|
|
237
|
+
const disposeOnDeepChange = onDeepChange(boundObject, (change) => {
|
|
238
|
+
// Skip if we're currently applying Loro changes to MobX
|
|
239
|
+
if (bindingContext.isApplyingLoroChangesToMobxKeystone) {
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Skip init changes - they are handled by the getSnapshot + merge at the end of binding
|
|
244
|
+
if (change.isInit) {
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Check if this is part of a moveWithinArray operation
|
|
249
|
+
if (change.type === DeepChangeType.ArraySplice) {
|
|
250
|
+
const resolved = resolvePath(boundObject, change.path)
|
|
251
|
+
if (resolved.resolved && isInMoveContextForArray(resolved.value as unknown[])) {
|
|
252
|
+
const moveResult = processChangeForMove(change)
|
|
253
|
+
|
|
254
|
+
if (moveResult === undefined) {
|
|
255
|
+
// Intercepted but move not complete - skip this change
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Move complete - add the move change instead
|
|
260
|
+
pendingChanges.push(moveResult)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Capture snapshots now before the values can be mutated within the same transaction.
|
|
266
|
+
// This is necessary because changes are collected and applied after the action completes,
|
|
267
|
+
// by which time the original values may have been modified.
|
|
268
|
+
// Example: `obj.items = [a, b]; obj.items.splice(0, 1)` - without early capture,
|
|
269
|
+
// the ObjectUpdate for `items` would get the post-splice array state.
|
|
270
|
+
pendingChanges.push(captureChangeSnapshots(change))
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// Apply collected changes when snapshot changes (i.e., after action completes)
|
|
274
|
+
// Also notify that the loro container atoms have been updated
|
|
275
|
+
const disposeOnSnapshot = onSnapshot(boundObject, () => {
|
|
276
|
+
if (pendingChanges.length === 0) {
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const changesToApply = pendingChanges
|
|
281
|
+
pendingChanges = []
|
|
282
|
+
|
|
283
|
+
// Skip if we're currently applying Loro changes to MobX
|
|
284
|
+
if (bindingContext.isApplyingLoroChangesToMobxKeystone) {
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
loroDoc.setNextCommitOrigin(loroOrigin)
|
|
289
|
+
|
|
290
|
+
// Apply all changes directly to Loro
|
|
291
|
+
for (const change of changesToApply) {
|
|
292
|
+
applyMobxChangeToLoroObject(change, loroObject)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
loroDoc.commit()
|
|
296
|
+
|
|
297
|
+
// Update snapshot tracking: the Loro container is now in sync with the current MobX snapshot
|
|
298
|
+
if (loroObject instanceof LoroMap || loroObject instanceof LoroMovableList) {
|
|
299
|
+
setLoroContainerSnapshot(loroObject, getSnapshot(boundObject))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Notify MobX that the Loro container has been updated
|
|
303
|
+
getOrCreateLoroCollectionAtom(loroObject).reportChanged()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// Sync the model snapshot to the CRDT if any init changes occurred.
|
|
307
|
+
// Init changes include: defaults being applied, onInit hooks mutating the model.
|
|
308
|
+
// This is an optimization: if no init changes occurred, we skip the sync entirely.
|
|
309
|
+
const finalSnapshot = getSnapshot(boundObject)
|
|
310
|
+
|
|
311
|
+
if (hasInitChanges) {
|
|
312
|
+
loroDoc.setNextCommitOrigin(loroOrigin)
|
|
313
|
+
|
|
314
|
+
if (loroObject instanceof LoroMap) {
|
|
315
|
+
applyJsonObjectToLoroMap(loroObject, finalSnapshot as PlainObject, { mode: "merge" })
|
|
316
|
+
} else if (loroObject instanceof LoroMovableList) {
|
|
317
|
+
applyJsonArrayToLoroMovableList(loroObject, finalSnapshot as PlainArray, { mode: "merge" })
|
|
318
|
+
} else if (loroObject instanceof LoroText) {
|
|
319
|
+
// For LoroText, we need to handle LoroTextModel snapshot
|
|
320
|
+
const snapshot = finalSnapshot as Record<string, unknown>
|
|
321
|
+
if (snapshot.$modelType === loroTextModelId) {
|
|
322
|
+
// Clear existing content and apply deltas
|
|
323
|
+
if (loroObject.length > 0) {
|
|
324
|
+
loroObject.delete(0, loroObject.length)
|
|
325
|
+
}
|
|
326
|
+
const deltas = extractTextDeltaFromSnapshot(snapshot.deltaList)
|
|
327
|
+
if (deltas.length > 0) {
|
|
328
|
+
applyDeltaToLoroText(loroObject, deltas)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
loroDoc.commit()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Always update snapshot tracking after binding initialization
|
|
337
|
+
// This ensures the merge optimization can skip unchanged subtrees in future reconciliations
|
|
338
|
+
if (loroObject instanceof LoroMap || loroObject instanceof LoroMovableList) {
|
|
339
|
+
setLoroContainerSnapshot(loroObject, finalSnapshot)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const dispose = () => {
|
|
343
|
+
loroUnsubscribe()
|
|
344
|
+
disposeOnDeepChange()
|
|
345
|
+
disposeOnSnapshot()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
boundObject,
|
|
350
|
+
dispose,
|
|
351
|
+
loroOrigin,
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import type { Delta } from "loro-crdt"
|
|
2
|
+
import { LoroMap, LoroMovableList, LoroText } from "loro-crdt"
|
|
3
|
+
import { frozenKey, isFrozenSnapshot } from "mobx-keystone"
|
|
4
|
+
import type { PlainArray, PlainObject, PlainPrimitive, PlainValue } from "../plainTypes"
|
|
5
|
+
import {
|
|
6
|
+
type BindableLoroContainer,
|
|
7
|
+
isBindableLoroContainer,
|
|
8
|
+
} from "../utils/isBindableLoroContainer"
|
|
9
|
+
import { isLoroTextModelSnapshot } from "./LoroTextModel"
|
|
10
|
+
import { isLoroContainerUpToDate, setLoroContainerSnapshot } from "./loroSnapshotTracking"
|
|
11
|
+
|
|
12
|
+
type LoroValue = BindableLoroContainer | PlainValue
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Options for applying JSON data to Loro data structures.
|
|
16
|
+
*/
|
|
17
|
+
export interface ApplyJsonToLoroOptions {
|
|
18
|
+
/**
|
|
19
|
+
* The mode to use when applying JSON data to Loro data structures.
|
|
20
|
+
* - `add`: Creates new Loro containers for objects/arrays (default, backwards compatible)
|
|
21
|
+
* - `merge`: Recursively merges values, preserving existing container references where possible
|
|
22
|
+
*/
|
|
23
|
+
mode?: "add" | "merge"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isPlainPrimitive(v: PlainValue): v is PlainPrimitive {
|
|
27
|
+
const t = typeof v
|
|
28
|
+
return t === "string" || t === "number" || t === "boolean" || v === null || v === undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPlainArray(v: PlainValue): v is PlainArray {
|
|
32
|
+
return Array.isArray(v)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPlainObject(v: PlainValue): v is PlainObject {
|
|
36
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extracts delta array from a LoroTextModel snapshot's delta field.
|
|
41
|
+
* The delta field is a frozen Delta<string>[] (array of delta operations).
|
|
42
|
+
*/
|
|
43
|
+
export function extractTextDeltaFromSnapshot(delta: unknown): Delta<string>[] {
|
|
44
|
+
// The delta field is frozen, so we need to extract it
|
|
45
|
+
if (isFrozenSnapshot<Delta<string>[]>(delta)) {
|
|
46
|
+
const data = delta.data
|
|
47
|
+
if (Array.isArray(data)) {
|
|
48
|
+
return data
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Handle plain delta array (not wrapped in frozen)
|
|
53
|
+
if (Array.isArray(delta)) {
|
|
54
|
+
return delta as Delta<string>[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Applies delta operations to a LoroText using insert/mark APIs.
|
|
62
|
+
* This works on both attached and detached containers.
|
|
63
|
+
*
|
|
64
|
+
* Strategy: Insert all text first, then apply marks. This avoids mark inheritance
|
|
65
|
+
* issues when inserting at the boundary of a marked region.
|
|
66
|
+
*/
|
|
67
|
+
export function applyDeltaToLoroText(text: LoroText, deltas: Delta<string>[]): void {
|
|
68
|
+
// Phase 1: Insert all text content
|
|
69
|
+
let position = 0
|
|
70
|
+
const markOperations: Array<{
|
|
71
|
+
start: number
|
|
72
|
+
end: number
|
|
73
|
+
attributes: Record<string, unknown>
|
|
74
|
+
}> = []
|
|
75
|
+
|
|
76
|
+
for (const delta of deltas) {
|
|
77
|
+
if (delta.insert !== undefined) {
|
|
78
|
+
const content = delta.insert
|
|
79
|
+
text.insert(position, content)
|
|
80
|
+
|
|
81
|
+
// Collect mark operations to apply later
|
|
82
|
+
if (delta.attributes && Object.keys(delta.attributes).length > 0) {
|
|
83
|
+
markOperations.push({
|
|
84
|
+
start: position,
|
|
85
|
+
end: position + content.length,
|
|
86
|
+
attributes: delta.attributes,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
position += content.length
|
|
91
|
+
} else if (delta.retain) {
|
|
92
|
+
position += delta.retain
|
|
93
|
+
} else if (delta.delete) {
|
|
94
|
+
text.delete(position, delta.delete)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Phase 2: Apply all marks after text is inserted
|
|
99
|
+
for (const op of markOperations) {
|
|
100
|
+
for (const [key, value] of Object.entries(op.attributes)) {
|
|
101
|
+
text.mark({ start: op.start, end: op.end }, key, value)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Converts a plain value to a Loro data structure.
|
|
108
|
+
* Objects are converted to LoroMaps, arrays to LoroMovableLists, primitives are untouched.
|
|
109
|
+
* Frozen values are a special case and they are kept as immutable plain values.
|
|
110
|
+
*/
|
|
111
|
+
export function convertJsonToLoroData(v: PlainValue): LoroValue {
|
|
112
|
+
if (isPlainPrimitive(v)) {
|
|
113
|
+
return v
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isPlainArray(v)) {
|
|
117
|
+
const list = new LoroMovableList()
|
|
118
|
+
applyJsonArrayToLoroMovableList(list, v)
|
|
119
|
+
return list
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isPlainObject(v)) {
|
|
123
|
+
if (v[frozenKey] === true) {
|
|
124
|
+
// frozen value with explicit $frozen marker (shouldn't reach here after above check)
|
|
125
|
+
return v
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (isLoroTextModelSnapshot(v)) {
|
|
129
|
+
const text = new LoroText()
|
|
130
|
+
// Extract delta from the snapshot and apply using insert/mark APIs
|
|
131
|
+
// (applyDelta doesn't work on detached containers, but insert/mark do)
|
|
132
|
+
const deltas = extractTextDeltaFromSnapshot(v.deltaList)
|
|
133
|
+
if (deltas.length > 0) {
|
|
134
|
+
applyDeltaToLoroText(text, deltas)
|
|
135
|
+
}
|
|
136
|
+
return text
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const map = new LoroMap()
|
|
140
|
+
applyJsonObjectToLoroMap(map, v)
|
|
141
|
+
return map
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throw new Error(`unsupported value type: ${v}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Applies a JSON array to a LoroMovableList, using convertJsonToLoroData to convert the values.
|
|
149
|
+
*
|
|
150
|
+
* @param dest The destination LoroMovableList.
|
|
151
|
+
* @param source The source JSON array.
|
|
152
|
+
* @param options Options for applying the JSON data.
|
|
153
|
+
*/
|
|
154
|
+
export const applyJsonArrayToLoroMovableList = (
|
|
155
|
+
dest: LoroMovableList,
|
|
156
|
+
source: PlainArray,
|
|
157
|
+
options: ApplyJsonToLoroOptions = {}
|
|
158
|
+
) => {
|
|
159
|
+
const { mode = "add" } = options
|
|
160
|
+
|
|
161
|
+
if (mode === "add") {
|
|
162
|
+
// Add mode: just push all items to the end
|
|
163
|
+
for (const item of source) {
|
|
164
|
+
const converted = convertJsonToLoroData(item)
|
|
165
|
+
if (isBindableLoroContainer(converted)) {
|
|
166
|
+
dest.pushContainer(converted)
|
|
167
|
+
} else {
|
|
168
|
+
dest.push(converted)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Merge mode: recursively merge values, preserving existing container references
|
|
175
|
+
// In merge mode, check if the container is already up-to-date with this snapshot
|
|
176
|
+
if (isLoroContainerUpToDate(dest, source)) {
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Remove extra items from the end
|
|
181
|
+
const destLen = dest.length
|
|
182
|
+
const srcLen = source.length
|
|
183
|
+
if (destLen > srcLen) {
|
|
184
|
+
dest.delete(srcLen, destLen - srcLen)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Update existing items
|
|
188
|
+
const minLen = Math.min(destLen, srcLen)
|
|
189
|
+
for (let i = 0; i < minLen; i++) {
|
|
190
|
+
const srcItem = source[i]
|
|
191
|
+
const destItem = dest.get(i)
|
|
192
|
+
|
|
193
|
+
// If both are objects, merge recursively
|
|
194
|
+
if (isPlainObject(srcItem) && destItem instanceof LoroMap) {
|
|
195
|
+
applyJsonObjectToLoroMap(destItem, srcItem, options)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// If both are arrays, merge recursively
|
|
200
|
+
if (isPlainArray(srcItem) && destItem instanceof LoroMovableList) {
|
|
201
|
+
applyJsonArrayToLoroMovableList(destItem, srcItem, options)
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Skip if primitive value is unchanged (optimization)
|
|
206
|
+
if (isPlainPrimitive(srcItem) && destItem === srcItem) {
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Otherwise, replace the item
|
|
211
|
+
dest.delete(i, 1)
|
|
212
|
+
const converted = convertJsonToLoroData(srcItem)
|
|
213
|
+
if (isBindableLoroContainer(converted)) {
|
|
214
|
+
dest.insertContainer(i, converted)
|
|
215
|
+
} else {
|
|
216
|
+
dest.insert(i, converted)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Add new items at the end
|
|
221
|
+
for (let i = destLen; i < srcLen; i++) {
|
|
222
|
+
const converted = convertJsonToLoroData(source[i])
|
|
223
|
+
if (isBindableLoroContainer(converted)) {
|
|
224
|
+
dest.pushContainer(converted)
|
|
225
|
+
} else {
|
|
226
|
+
dest.push(converted)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Update snapshot tracking after successful merge
|
|
231
|
+
setLoroContainerSnapshot(dest, source)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Applies a JSON object to a LoroMap, using convertJsonToLoroData to convert the values.
|
|
236
|
+
*
|
|
237
|
+
* @param dest The destination LoroMap.
|
|
238
|
+
* @param source The source JSON object.
|
|
239
|
+
* @param options Options for applying the JSON data.
|
|
240
|
+
*/
|
|
241
|
+
export const applyJsonObjectToLoroMap = (
|
|
242
|
+
dest: LoroMap,
|
|
243
|
+
source: PlainObject,
|
|
244
|
+
options: ApplyJsonToLoroOptions = {}
|
|
245
|
+
) => {
|
|
246
|
+
const { mode = "add" } = options
|
|
247
|
+
|
|
248
|
+
if (mode === "add") {
|
|
249
|
+
// Add mode: just set all values
|
|
250
|
+
for (const k of Object.keys(source)) {
|
|
251
|
+
const v = source[k]
|
|
252
|
+
if (v !== undefined) {
|
|
253
|
+
const converted = convertJsonToLoroData(v)
|
|
254
|
+
if (isBindableLoroContainer(converted)) {
|
|
255
|
+
dest.setContainer(k, converted)
|
|
256
|
+
} else {
|
|
257
|
+
dest.set(k, converted)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Merge mode: recursively merge values, preserving existing container references
|
|
265
|
+
// In merge mode, check if the container is already up-to-date with this snapshot
|
|
266
|
+
if (isLoroContainerUpToDate(dest, source)) {
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Delete keys that are not present in source (or have undefined value)
|
|
271
|
+
const sourceKeysWithValues = new Set(Object.keys(source).filter((k) => source[k] !== undefined))
|
|
272
|
+
for (const key of dest.keys()) {
|
|
273
|
+
if (!sourceKeysWithValues.has(key)) {
|
|
274
|
+
dest.delete(key)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const k of Object.keys(source)) {
|
|
279
|
+
const v = source[k]
|
|
280
|
+
// Skip undefined values - Loro maps cannot store undefined
|
|
281
|
+
if (v === undefined) {
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const existing = dest.get(k)
|
|
286
|
+
|
|
287
|
+
// If source is an object and dest has a LoroMap, merge recursively
|
|
288
|
+
if (isPlainObject(v) && existing instanceof LoroMap) {
|
|
289
|
+
applyJsonObjectToLoroMap(existing, v, options)
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// If source is an array and dest has a LoroMovableList, merge recursively
|
|
294
|
+
if (isPlainArray(v) && existing instanceof LoroMovableList) {
|
|
295
|
+
applyJsonArrayToLoroMovableList(existing, v, options)
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Skip if primitive value is unchanged (optimization)
|
|
300
|
+
if (isPlainPrimitive(v) && existing === v) {
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Otherwise, convert and set the value (this creates new containers if needed)
|
|
305
|
+
const converted = convertJsonToLoroData(v)
|
|
306
|
+
if (isBindableLoroContainer(converted)) {
|
|
307
|
+
dest.setContainer(k, converted)
|
|
308
|
+
} else {
|
|
309
|
+
dest.set(k, converted)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Update snapshot tracking after successful merge
|
|
314
|
+
setLoroContainerSnapshot(dest, source)
|
|
315
|
+
}
|