@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.
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/dist/agent-write.d.ts +123 -0
- package/dist/agent-write.d.ts.map +1 -0
- package/dist/agent-write.js +499 -0
- package/dist/agent-write.js.map +1 -0
- package/dist/binding.d.ts +122 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +114 -0
- package/dist/binding.js.map +1 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/mapping.d.ts +115 -0
- package/dist/mapping.d.ts.map +1 -0
- package/dist/mapping.js +181 -0
- package/dist/mapping.js.map +1 -0
- package/dist/order.d.ts +124 -0
- package/dist/order.d.ts.map +1 -0
- package/dist/order.js +187 -0
- package/dist/order.js.map +1 -0
- package/dist/schema.d.ts +343 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +363 -0
- package/dist/schema.js.map +1 -0
- package/dist/seed.d.ts +72 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +72 -0
- package/dist/seed.js.map +1 -0
- package/dist/text.d.ts +167 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +289 -0
- package/dist/text.js.map +1 -0
- package/dist/to-lexical.d.ts +119 -0
- package/dist/to-lexical.d.ts.map +1 -0
- package/dist/to-lexical.js +636 -0
- package/dist/to-lexical.js.map +1 -0
- package/dist/to-loro.d.ts +154 -0
- package/dist/to-loro.d.ts.map +1 -0
- package/dist/to-loro.js +718 -0
- package/dist/to-loro.js.map +1 -0
- package/dist/undo.d.ts +94 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +200 -0
- package/dist/undo.js.map +1 -0
- package/package.json +64 -0
- package/src/agent-write.ts +613 -0
- package/src/binding.ts +185 -0
- package/src/index.ts +176 -0
- package/src/mapping.ts +206 -0
- package/src/order.ts +205 -0
- package/src/schema.ts +509 -0
- package/src/seed.ts +112 -0
- package/src/text.ts +357 -0
- package/src/to-lexical.ts +792 -0
- package/src/to-loro.ts +914 -0
- 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
|
+
}
|