@llui/lexical-loro 0.1.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -0
  3. package/dist/agent-write.d.ts +123 -0
  4. package/dist/agent-write.d.ts.map +1 -0
  5. package/dist/agent-write.js +499 -0
  6. package/dist/agent-write.js.map +1 -0
  7. package/dist/binding.d.ts +122 -0
  8. package/dist/binding.d.ts.map +1 -0
  9. package/dist/binding.js +114 -0
  10. package/dist/binding.js.map +1 -0
  11. package/dist/index.d.ts +69 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +69 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/mapping.d.ts +115 -0
  16. package/dist/mapping.d.ts.map +1 -0
  17. package/dist/mapping.js +181 -0
  18. package/dist/mapping.js.map +1 -0
  19. package/dist/order.d.ts +124 -0
  20. package/dist/order.d.ts.map +1 -0
  21. package/dist/order.js +187 -0
  22. package/dist/order.js.map +1 -0
  23. package/dist/schema.d.ts +343 -0
  24. package/dist/schema.d.ts.map +1 -0
  25. package/dist/schema.js +363 -0
  26. package/dist/schema.js.map +1 -0
  27. package/dist/seed.d.ts +72 -0
  28. package/dist/seed.d.ts.map +1 -0
  29. package/dist/seed.js +72 -0
  30. package/dist/seed.js.map +1 -0
  31. package/dist/text.d.ts +167 -0
  32. package/dist/text.d.ts.map +1 -0
  33. package/dist/text.js +289 -0
  34. package/dist/text.js.map +1 -0
  35. package/dist/to-lexical.d.ts +119 -0
  36. package/dist/to-lexical.d.ts.map +1 -0
  37. package/dist/to-lexical.js +636 -0
  38. package/dist/to-lexical.js.map +1 -0
  39. package/dist/to-loro.d.ts +154 -0
  40. package/dist/to-loro.d.ts.map +1 -0
  41. package/dist/to-loro.js +718 -0
  42. package/dist/to-loro.js.map +1 -0
  43. package/dist/undo.d.ts +94 -0
  44. package/dist/undo.d.ts.map +1 -0
  45. package/dist/undo.js +200 -0
  46. package/dist/undo.js.map +1 -0
  47. package/package.json +64 -0
  48. package/src/agent-write.ts +613 -0
  49. package/src/binding.ts +185 -0
  50. package/src/index.ts +176 -0
  51. package/src/mapping.ts +206 -0
  52. package/src/order.ts +205 -0
  53. package/src/schema.ts +509 -0
  54. package/src/seed.ts +112 -0
  55. package/src/text.ts +357 -0
  56. package/src/to-lexical.ts +792 -0
  57. package/src/to-loro.ts +914 -0
  58. package/src/undo.ts +269 -0
package/src/undo.ts ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * CRDT-aware undo/redo: Loro's `UndoManager` wired to Lexical's undo commands.
3
+ *
4
+ * ── Why not `@lexical/history` ─────────────────────────────────────────────
5
+ *
6
+ * Lexical's built-in history is SNAPSHOT-based and knows nothing about who made
7
+ * a change. Under collaboration it records our inbound writeback as though the
8
+ * local user had typed it, so undoing after a peer's edit re-applies a snapshot
9
+ * taken BEFORE that edit — reverting the REMOTE change, and (because the
10
+ * outbound sync replays the snapshot faithfully) removing it for every peer.
11
+ * No update tag avoids that; it is a property of snapshot undo.
12
+ *
13
+ * Loro's `UndoManager` is operation-based and bound to a PeerID: it records the
14
+ * commits made by this peer and ignores everything imported from others. Undo
15
+ * therefore reverts exactly the local user's own last change, wherever it now
16
+ * sits in a document other peers have moved on from — including a same-parent
17
+ * block move, which is a single LWW write to a carrier's `pos` and so undoes to
18
+ * the previous fractional index rather than to a whole-document snapshot.
19
+ *
20
+ * ── The echo seam ──────────────────────────────────────────────────────────
21
+ *
22
+ * `manager.undo()` mutates the shared document from THIS peer, so Loro reports
23
+ * the result as a `by: 'local'` batch — which echo layer (a) in `to-lexical.ts`
24
+ * drops as "our own outbound write coming back round". It is not: nothing wrote
25
+ * it to the editor, and dropping it leaves the editor permanently behind the
26
+ * document it mirrors. Loro stamps those batches with the origin
27
+ * {@link LORO_UNDO_ORIGIN} (both undo AND redo use it), and the binding lists
28
+ * {@link UNDO_ORIGINS} in `InboundTarget.localOrigins` so exactly those local
29
+ * batches are applied. The editor update the writeback performs carries
30
+ * `COLLABORATION_TAG`, so echo layer (b) keeps it from bouncing back out.
31
+ *
32
+ * That is also why {@link UNDO_ORIGINS} is passed UNCONDITIONALLY by the
33
+ * binding, whether or not this module was registered: only an `UndoManager`
34
+ * produces that origin, so allowing it costs nothing when there is none, and
35
+ * makes the inbound path independent of registration order.
36
+ *
37
+ * ── Selection ──────────────────────────────────────────────────────────────
38
+ *
39
+ * The inbound mirror mutates nodes in place and keeps NodeKeys stable, so a
40
+ * caret outside the undone region needs no help and one inside the undone text
41
+ * run is transformed by the same diff that rewrites the run. The remaining case
42
+ * is a caret in a node the undo DELETES (undoing an insert): Lexical is left
43
+ * with a selection pointing at a detached node. So the selection is snapshotted
44
+ * before the pop and restored — clamped, and only when the recorded points still
45
+ * resolve — if the post-undo selection is gone or dangling. Restoring is a
46
+ * selection-only update: it dirties no node, so the outbound listener commits
47
+ * nothing.
48
+ */
49
+
50
+ import {
51
+ $createRangeSelection,
52
+ $getNodeByKey,
53
+ $getSelection,
54
+ $isElementNode,
55
+ $isRangeSelection,
56
+ $isTextNode,
57
+ $setSelection,
58
+ CAN_REDO_COMMAND,
59
+ CAN_UNDO_COMMAND,
60
+ COLLABORATION_TAG,
61
+ COMMAND_PRIORITY_EDITOR,
62
+ REDO_COMMAND,
63
+ SKIP_SCROLL_INTO_VIEW_TAG,
64
+ UNDO_COMMAND,
65
+ type LexicalEditor,
66
+ type NodeKey,
67
+ } from 'lexical'
68
+ import { UndoManager, type LoroDoc } from 'loro-crdt'
69
+
70
+ /**
71
+ * The commit origin Loro stamps on the batches produced by `UndoManager#undo`
72
+ * and `#redo`. Both use `'undo'`; there is no separate redo origin.
73
+ */
74
+ export const LORO_UNDO_ORIGIN = 'undo'
75
+
76
+ /** Local commit origins the inbound path must apply rather than treat as echo. */
77
+ export const UNDO_ORIGINS: readonly string[] = [LORO_UNDO_ORIGIN]
78
+
79
+ /**
80
+ * Milliseconds within which consecutive local commits merge into ONE undo step.
81
+ * Matches `@lexical/history`'s delay, so typing undoes in the chunks a user of
82
+ * the non-collaborative editor already expects. `0` disables merging.
83
+ */
84
+ export const DEFAULT_MERGE_INTERVAL = 1000
85
+
86
+ /** Undo steps retained before the oldest is dropped. Loro's own default. */
87
+ export const DEFAULT_MAX_UNDO_STEPS = 100
88
+
89
+ /** Tuning for {@link registerLoroUndo}. */
90
+ export interface LoroUndoOptions {
91
+ /** The shared document. */
92
+ readonly doc: LoroDoc
93
+ /** See {@link DEFAULT_MERGE_INTERVAL}. */
94
+ readonly mergeInterval?: number
95
+ /** See {@link DEFAULT_MAX_UNDO_STEPS}. */
96
+ readonly maxUndoSteps?: number
97
+ /**
98
+ * Local commit origins EXCLUDED from the undo stack, by prefix. Use this for
99
+ * machine-generated writes a user should never be able to undo into.
100
+ */
101
+ readonly excludeOriginPrefixes?: readonly string[]
102
+ }
103
+
104
+ /** Compose teardown functions into one disposer (last-registered first). */
105
+ function mergeDisposers(disposers: ReadonlyArray<() => void>): () => void {
106
+ return () => {
107
+ for (let index = disposers.length - 1; index >= 0; index--) disposers[index]!()
108
+ }
109
+ }
110
+
111
+ /** A serializable point: NodeKey plus offset. Keys are stable for the session. */
112
+ interface PointSnapshot {
113
+ readonly key: NodeKey
114
+ readonly offset: number
115
+ readonly type: 'text' | 'element'
116
+ }
117
+
118
+ interface SelectionSnapshot {
119
+ readonly anchor: PointSnapshot
120
+ readonly focus: PointSnapshot
121
+ }
122
+
123
+ /** Snapshot the current range selection, or `null` if there isn't one. */
124
+ function $snapshotSelection(): SelectionSnapshot | null {
125
+ const selection = $getSelection()
126
+ if (!$isRangeSelection(selection)) return null
127
+ return {
128
+ anchor: {
129
+ key: selection.anchor.key,
130
+ offset: selection.anchor.offset,
131
+ type: selection.anchor.type,
132
+ },
133
+ focus: { key: selection.focus.key, offset: selection.focus.offset, type: selection.focus.type },
134
+ }
135
+ }
136
+
137
+ /** The largest offset a point may hold in the node it addresses, or `null` if
138
+ * the node is gone or no longer of the recorded kind. */
139
+ function $pointCeiling(point: PointSnapshot): number | null {
140
+ const node = $getNodeByKey(point.key)
141
+ if (node === null) return null
142
+ if (point.type === 'text') return $isTextNode(node) ? node.getTextContentSize() : null
143
+ return $isElementNode(node) ? node.getChildrenSize() : null
144
+ }
145
+
146
+ /** Whether the editor's CURRENT selection still addresses live nodes. */
147
+ function $selectionIsLive(): boolean {
148
+ const selection = $getSelection()
149
+ if (!$isRangeSelection(selection)) return false
150
+ for (const point of [selection.anchor, selection.focus]) {
151
+ const node = $getNodeByKey(point.key)
152
+ if (node === null || !node.isAttached()) return false
153
+ }
154
+ return true
155
+ }
156
+
157
+ /**
158
+ * Re-apply a snapshotted selection, clamped to the nodes as they now are.
159
+ * Returns whether it could be applied — a snapshot whose nodes the undo removed
160
+ * is dropped rather than forced.
161
+ */
162
+ function $restoreSelection(snapshot: SelectionSnapshot): boolean {
163
+ const anchorCeiling = $pointCeiling(snapshot.anchor)
164
+ const focusCeiling = $pointCeiling(snapshot.focus)
165
+ if (anchorCeiling === null || focusCeiling === null) return false
166
+ const selection = $createRangeSelection()
167
+ selection.anchor.set(
168
+ snapshot.anchor.key,
169
+ Math.min(snapshot.anchor.offset, anchorCeiling),
170
+ snapshot.anchor.type,
171
+ )
172
+ selection.focus.set(
173
+ snapshot.focus.key,
174
+ Math.min(snapshot.focus.offset, focusCeiling),
175
+ snapshot.focus.type,
176
+ )
177
+ $setSelection(selection)
178
+ return true
179
+ }
180
+
181
+ /**
182
+ * Register Loro-backed undo/redo on an editor.
183
+ *
184
+ * Hand this to `lexicalForeign({ externalUndo })` — which forces the built-in
185
+ * `@lexical/history` stack off, so the two can never both be live. The returned
186
+ * disposer unregisters the commands and frees the manager.
187
+ *
188
+ * The manager is constructed HERE rather than at `loroCollab()` time on purpose:
189
+ * `lexicalForeign` calls `register` (which bootstraps the document) before
190
+ * `externalUndo`, so the boot-time seed is already committed and is NOT on the
191
+ * undo stack. A user's first undo can therefore never empty a freshly seeded
192
+ * document.
193
+ */
194
+ export function registerLoroUndo(options: LoroUndoOptions, editor: LexicalEditor): () => void {
195
+ const manager = new UndoManager(options.doc, {
196
+ mergeInterval: options.mergeInterval ?? DEFAULT_MERGE_INTERVAL,
197
+ maxUndoSteps: options.maxUndoSteps ?? DEFAULT_MAX_UNDO_STEPS,
198
+ excludeOriginPrefixes: [...(options.excludeOriginPrefixes ?? [])],
199
+ })
200
+
201
+ const pushStackState = (): void => {
202
+ editor.dispatchCommand(CAN_UNDO_COMMAND, manager.canUndo())
203
+ editor.dispatchCommand(CAN_REDO_COMMAND, manager.canRedo())
204
+ }
205
+
206
+ // Every batch — local commit, remote import, or our own undo — can change what
207
+ // is undoable, so the stack state is re-published on all of them rather than
208
+ // only on the paths this module drives.
209
+ const unsubscribe = options.doc.subscribe(() => {
210
+ pushStackState()
211
+ })
212
+
213
+ /** Pop one step, then repair the selection if the writeback orphaned it. */
214
+ const pop = (step: () => boolean): void => {
215
+ let snapshot: SelectionSnapshot | null = null
216
+ editor.getEditorState().read(() => {
217
+ snapshot = $snapshotSelection()
218
+ })
219
+ // The writeback into the editor happens synchronously, inside the document
220
+ // subscription this call triggers.
221
+ const applied = step()
222
+ pushStackState()
223
+ if (!applied || snapshot === null) return
224
+ const recorded: SelectionSnapshot = snapshot
225
+ let needsRepair = false
226
+ editor.getEditorState().read(() => {
227
+ needsRepair = !$selectionIsLive()
228
+ })
229
+ if (!needsRepair) return
230
+ editor.update(
231
+ () => {
232
+ $restoreSelection(recorded)
233
+ },
234
+ // Selection-only: tagged so the outbound listener skips it outright rather
235
+ // than relying on it happening to dirty no node.
236
+ { tag: [COLLABORATION_TAG, SKIP_SCROLL_INTO_VIEW_TAG] },
237
+ )
238
+ }
239
+
240
+ const disposeCommands = mergeDisposers([
241
+ editor.registerCommand(
242
+ UNDO_COMMAND,
243
+ () => {
244
+ pop(() => manager.undo())
245
+ return true
246
+ },
247
+ COMMAND_PRIORITY_EDITOR,
248
+ ),
249
+ editor.registerCommand(
250
+ REDO_COMMAND,
251
+ () => {
252
+ pop(() => manager.redo())
253
+ return true
254
+ },
255
+ COMMAND_PRIORITY_EDITOR,
256
+ ),
257
+ ])
258
+
259
+ // Publish the initial (empty) stack state so a toolbar rendered before the
260
+ // first edit shows undo/redo disabled rather than in whatever state it
261
+ // defaulted to.
262
+ pushStackState()
263
+
264
+ return () => {
265
+ disposeCommands()
266
+ unsubscribe()
267
+ manager.free()
268
+ }
269
+ }