@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/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
|
+
}
|