@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/text.ts ADDED
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Text: format-bitmask ↔ named-Loro-mark conversion, the run diff that replays
3
+ * Lexical's resulting state, and a cursor-biased string diff.
4
+ *
5
+ * ── Why formats are DECOMPOSED into named marks ────────────────────────────
6
+ *
7
+ * `TextNode.__format` is a bitmask. Storing it as ONE Loro value makes two peers
8
+ * concurrently toggling bold and italic on overlapping ranges a last-writer-wins
9
+ * conflict that SILENTLY DROPS one of them. Stored as independent named marks
10
+ * (`bold`, `italic`, …) the same edit converges to the union. This is measured
11
+ * in `test/expand-semantics.test.ts` §4, both the working and the broken form.
12
+ *
13
+ * The bit values are derived from Lexical's `TEXT_TYPE_TO_FORMAT` at runtime —
14
+ * never hardcoded, so a Lexical release that renumbers or adds a format is a
15
+ * compile/boot-time failure here rather than silent corruption.
16
+ *
17
+ * ── Why the outbound direction replays STATE, not keystrokes ───────────────
18
+ *
19
+ * Loro's `expand` rule cannot reproduce Lexical's boundary behaviour: no uniform
20
+ * table works, and no PER-FORMAT table can either, because the divergence set is
21
+ * identical for all 11 formats — Lexical has no per-format inclusivity, its
22
+ * caret is uniformly left-biased (`LexicalSelection.ts:3164-3204`). Three cases
23
+ * are unreachable by any table:
24
+ *
25
+ * 1. typing at index 0 of a formatted first run
26
+ * 2. a format toggled ON at a collapsed caret
27
+ * 3. a format toggled OFF at a collapsed caret
28
+ *
29
+ * So after each Lexical update we diff the RESULTING TextNode runs against what
30
+ * Loro currently holds and emit explicit `mark`/`unmark` ops for every format
31
+ * bit that differs — {@link diffRunFormats}. `expand` then governs only what
32
+ * happens to text a REMOTE peer inserts concurrently at a mark boundary.
33
+ *
34
+ * ── Index units ────────────────────────────────────────────────────────────
35
+ *
36
+ * All offsets here are UTF-16 code units: JavaScript string indices, Lexical
37
+ * offsets, and loro-crdt's JS text indices all agree (pinned in
38
+ * `test/schema.test.ts`). No conversion at this seam.
39
+ */
40
+
41
+ import { TEXT_TYPE_TO_FORMAT, type TextFormatType } from 'lexical'
42
+ import type { LoroText } from 'loro-crdt'
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Format vocabulary
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * The Lexical text formats a Loro binding represents, in bit order (see
50
+ * Lexical's `LexicalConstants.ts`). Each becomes an INDEPENDENT named mark.
51
+ */
52
+ export const LORO_TEXT_FORMATS = [
53
+ 'bold',
54
+ 'italic',
55
+ 'strikethrough',
56
+ 'underline',
57
+ 'code',
58
+ 'subscript',
59
+ 'superscript',
60
+ 'highlight',
61
+ 'lowercase',
62
+ 'uppercase',
63
+ 'capitalize',
64
+ ] as const
65
+
66
+ export type LoroTextFormat = (typeof LORO_TEXT_FORMATS)[number]
67
+
68
+ /**
69
+ * Mark name → bit value, derived from Lexical rather than hardcoded.
70
+ *
71
+ * Built eagerly so a format this package names but Lexical does not define
72
+ * fails at module load — a loud boot error instead of a format that silently
73
+ * never round-trips.
74
+ */
75
+ export const FORMAT_BITS: Readonly<Record<LoroTextFormat, number>> = Object.freeze(
76
+ Object.fromEntries(
77
+ LORO_TEXT_FORMATS.map((format) => {
78
+ const bit = TEXT_TYPE_TO_FORMAT[format as TextFormatType]
79
+ if (bit === undefined) {
80
+ throw new Error(
81
+ `lexical-loro: Lexical does not define the text format '${format}' — ` +
82
+ 'LORO_TEXT_FORMATS is out of sync with TEXT_TYPE_TO_FORMAT',
83
+ )
84
+ }
85
+ return [format, bit]
86
+ }),
87
+ ) as Record<LoroTextFormat, number>,
88
+ )
89
+
90
+ /** Every bit this binding knows how to represent. */
91
+ export const KNOWN_FORMAT_MASK: number = LORO_TEXT_FORMATS.reduce(
92
+ (mask, format) => mask | FORMAT_BITS[format],
93
+ 0,
94
+ )
95
+
96
+ /** The bit value of a named format. */
97
+ export function formatBit(format: LoroTextFormat): number {
98
+ return FORMAT_BITS[format]
99
+ }
100
+
101
+ /** Decompose a Lexical bitmask into the named marks it sets, in bit order. */
102
+ export function formatsFromBitmask(bitmask: number): LoroTextFormat[] {
103
+ return LORO_TEXT_FORMATS.filter((format) => (bitmask & FORMAT_BITS[format]) !== 0)
104
+ }
105
+
106
+ /** Recompose named marks into a Lexical bitmask. */
107
+ export function bitmaskFromFormats(formats: Iterable<LoroTextFormat>): number {
108
+ let bitmask = 0
109
+ for (const format of formats) bitmask |= FORMAT_BITS[format]
110
+ return bitmask
111
+ }
112
+
113
+ /**
114
+ * Read a Loro delta's attribute bag as a Lexical bitmask.
115
+ *
116
+ * Only `true` counts as set: Loro represents an unmark as an explicit `null`
117
+ * attribute in the delta, which must read as OFF, not as "present".
118
+ */
119
+ export function bitmaskFromAttributes(
120
+ attributes: Readonly<Record<string, unknown>> | undefined,
121
+ ): number {
122
+ if (attributes === undefined) return 0
123
+ let bitmask = 0
124
+ for (const format of LORO_TEXT_FORMATS) {
125
+ if (attributes[format] === true) bitmask |= FORMAT_BITS[format]
126
+ }
127
+ return bitmask
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Runs
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /** A maximal stretch of text sharing one Lexical format bitmask. */
135
+ export interface TextRun {
136
+ readonly text: string
137
+ readonly format: number
138
+ }
139
+
140
+ /** Concatenated text of a run list. */
141
+ export function runsText(runs: readonly TextRun[]): string {
142
+ return runs.map((run) => run.text).join('')
143
+ }
144
+
145
+ /**
146
+ * Coalesce adjacent equal-format runs and drop empty ones, so two run lists
147
+ * describing the same content compare structurally equal.
148
+ *
149
+ * Necessary because Lexical's node boundaries are a rendering detail: `ab`+`c`
150
+ * and `abc` at the same format are the same document.
151
+ */
152
+ export function normalizeRuns(runs: readonly TextRun[]): TextRun[] {
153
+ const out: TextRun[] = []
154
+ for (const run of runs) {
155
+ if (run.text === '') continue
156
+ const last = out[out.length - 1]
157
+ if (last !== undefined && last.format === run.format) {
158
+ out[out.length - 1] = { text: last.text + run.text, format: last.format }
159
+ } else {
160
+ out.push({ text: run.text, format: run.format })
161
+ }
162
+ }
163
+ return out
164
+ }
165
+
166
+ /** A Loro text delta item, as returned by `LoroText#toDelta`. */
167
+ export interface TextDeltaItem {
168
+ readonly insert?: unknown
169
+ readonly attributes?: Readonly<Record<string, unknown>>
170
+ }
171
+
172
+ /** Project a `LoroText`'s delta into normalized Lexical-shaped runs. */
173
+ export function runsFromDelta(delta: readonly TextDeltaItem[]): TextRun[] {
174
+ return normalizeRuns(
175
+ delta.map((item) => ({
176
+ text: String(item.insert ?? ''),
177
+ format: bitmaskFromAttributes(item.attributes),
178
+ })),
179
+ )
180
+ }
181
+
182
+ /** Project a live `LoroText` into normalized Lexical-shaped runs. */
183
+ export function runsFromText(text: LoroText): TextRun[] {
184
+ return runsFromDelta(text.toDelta() as readonly TextDeltaItem[])
185
+ }
186
+
187
+ /**
188
+ * Expand runs into a per-character bitmask array.
189
+ *
190
+ * Per-character is what makes the run diff correct without special-casing:
191
+ * the two sides may be segmented completely differently (Lexical split a node
192
+ * where Loro did not), and comparing per character makes segmentation moot.
193
+ */
194
+ function bitmaskPerCharacter(runs: readonly TextRun[]): number[] {
195
+ const out: number[] = []
196
+ for (const run of runs) {
197
+ for (let i = 0; i < run.text.length; i++) out.push(run.format)
198
+ }
199
+ return out
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Run-format diff — the outbound replay
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /** An explicit format operation to apply to a `LoroText`. */
207
+ export interface MarkOp {
208
+ readonly kind: 'mark' | 'unmark'
209
+ /** Inclusive UTF-16 start offset. */
210
+ readonly start: number
211
+ /** Exclusive UTF-16 end offset. */
212
+ readonly end: number
213
+ readonly format: LoroTextFormat
214
+ }
215
+
216
+ /**
217
+ * Diff two run lists into the minimal explicit `mark`/`unmark` ops that turn
218
+ * `current` into `target`.
219
+ *
220
+ * `current` is what Loro holds after the text edit landed (so `expand` has
221
+ * already had its say); `target` is the runs Lexical actually produced. Both
222
+ * MUST describe the same character count — call this only after the text
223
+ * content has been reconciled.
224
+ *
225
+ * Each format is diffed INDEPENDENTLY (that is the whole point of decomposing
226
+ * the bitmask) and differing characters are coalesced into maximal ranges, so a
227
+ * whole-paragraph bolding is one op, not one per character.
228
+ */
229
+ export function diffRunFormats(current: readonly TextRun[], target: readonly TextRun[]): MarkOp[] {
230
+ const from = bitmaskPerCharacter(current)
231
+ const to = bitmaskPerCharacter(target)
232
+ if (from.length !== to.length) {
233
+ throw new Error(
234
+ `lexical-loro: diffRunFormats requires equal-length runs — ` +
235
+ `current has ${from.length} chars, target has ${to.length}; ` +
236
+ 'reconcile the text content before diffing formats',
237
+ )
238
+ }
239
+
240
+ const ops: MarkOp[] = []
241
+ for (const format of LORO_TEXT_FORMATS) {
242
+ const bit = FORMAT_BITS[format]
243
+ let start = -1
244
+ let openKind: MarkOp['kind'] = 'mark'
245
+ // Iterate one PAST the end so a range still open at the last character is
246
+ // closed by the same branch as any other, with no duplicated tail logic.
247
+ for (let i = 0; i <= from.length; i++) {
248
+ let kind: MarkOp['kind'] | undefined
249
+ if (i < from.length) {
250
+ const wanted = (to[i]! & bit) !== 0
251
+ const has = (from[i]! & bit) !== 0
252
+ if (wanted !== has) kind = wanted ? 'mark' : 'unmark'
253
+ }
254
+ if (start !== -1 && kind !== openKind) {
255
+ ops.push({ kind: openKind, start, end: i, format })
256
+ start = -1
257
+ }
258
+ if (kind !== undefined && start === -1) {
259
+ start = i
260
+ openKind = kind
261
+ }
262
+ }
263
+ }
264
+ return ops
265
+ }
266
+
267
+ /**
268
+ * Apply {@link MarkOp}s to a `LoroText`. The caller owns the surrounding
269
+ * transaction (`doc.commit`), so a whole Lexical update lands as one Loro
270
+ * change and therefore as one remote event batch.
271
+ */
272
+ export function applyMarkOps(text: LoroText, ops: readonly MarkOp[]): void {
273
+ for (const op of ops) {
274
+ if (op.kind === 'mark') text.mark({ start: op.start, end: op.end }, op.format, true)
275
+ else text.unmark({ start: op.start, end: op.end }, op.format)
276
+ }
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Cursor-biased string diff
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /** A single-region string edit: delete `remove` chars at `index`, insert `insert`. */
284
+ export interface TextDiff {
285
+ /** UTF-16 offset at which the change applies. */
286
+ readonly index: number
287
+ /** Number of UTF-16 code units to delete at `index`. */
288
+ readonly remove: number
289
+ /** Text to insert at `index` after the deletion. */
290
+ readonly insert: string
291
+ }
292
+
293
+ const HIGH_SURROGATE = /[\uD800-\uDBFF]/
294
+ const LOW_SURROGATE = /[\uDC00-\uDFFF]/
295
+
296
+ /**
297
+ * Diff `a` → `b`, biased to place the change at `cursor`.
298
+ *
299
+ * A plain "common prefix / common suffix" diff is ambiguous whenever the edit
300
+ * sits next to repeated characters: typing `o` in `foo` could be described as an
301
+ * insert at index 1, 2 or 3, and the plain diff always picks the leftmost. Every
302
+ * peer then sees the character inserted at the wrong place, which drags remote
303
+ * carets and (through Loro's `expand`) can even attach the wrong formatting.
304
+ *
305
+ * Biasing the prefix scan to stop AT the cursor resolves the ambiguity in favour
306
+ * of where the user actually typed. `@lexical/yjs` uses `simpleDiffWithCursor`
307
+ * for exactly this reason; this is that algorithm (lib0's
308
+ * `simpleDiffStringWithCursor`), including its surrogate-pair rollbacks, ported
309
+ * so the package carries no lib0 dependency.
310
+ *
311
+ * Surrogate handling: the scans never stop between the halves of a surrogate
312
+ * pair, so an astral character is always inserted or deleted whole.
313
+ *
314
+ * @param cursor UTF-16 offset of the caret in `b` (the new string).
315
+ */
316
+ export function diffTextWithCursor(a: string, b: string, cursor: number): TextDiff {
317
+ let left = 0
318
+ let right = 0
319
+ // Scan the common prefix, but stop at the cursor: the user's caret marks
320
+ // where the change really is.
321
+ while (left < a.length && left < b.length && a[left] === b[left] && left < cursor) left++
322
+ if (left > 0 && HIGH_SURROGATE.test(a[left - 1]!)) left--
323
+ // Scan the common suffix.
324
+ while (
325
+ right + left < a.length &&
326
+ right + left < b.length &&
327
+ a[a.length - right - 1] === b[b.length - right - 1]
328
+ )
329
+ right++
330
+ if (right > 0 && LOW_SURROGATE.test(a[a.length - right]!)) right--
331
+ // Resume the prefix scan past the cursor — anything still common there is not
332
+ // part of the edit, and leaving it in would make the diff larger than needed.
333
+ while (right + left < a.length && right + left < b.length && a[left] === b[left]) left++
334
+ if (left > 0 && HIGH_SURROGATE.test(a[left - 1]!)) left--
335
+ return { index: left, remove: a.length - left - right, insert: b.slice(left, b.length - right) }
336
+ }
337
+
338
+ /**
339
+ * Cursor-free variant: the change is placed as far LEFT as possible.
340
+ *
341
+ * Equivalent to lib0's `simpleDiffString`. Use it only where no caret is known
342
+ * (a programmatic document change); prefer {@link diffTextWithCursor} on any
343
+ * user-typing path, where the leftmost placement is exactly the wrong guess.
344
+ */
345
+ export function diffText(a: string, b: string): TextDiff {
346
+ return diffTextWithCursor(a, b, 0)
347
+ }
348
+
349
+ /**
350
+ * Apply a {@link TextDiff} to a `LoroText`. A no-op diff writes nothing, so a
351
+ * Lexical update that changed only formatting produces no text ops (and so no
352
+ * spurious remote text event).
353
+ */
354
+ export function applyTextDiff(text: LoroText, diff: TextDiff): void {
355
+ if (diff.remove > 0) text.delete(diff.index, diff.remove)
356
+ if (diff.insert !== '') text.insert(diff.index, diff.insert)
357
+ }