@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/dist/text.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
import type { LoroText } from 'loro-crdt';
|
|
41
|
+
/**
|
|
42
|
+
* The Lexical text formats a Loro binding represents, in bit order (see
|
|
43
|
+
* Lexical's `LexicalConstants.ts`). Each becomes an INDEPENDENT named mark.
|
|
44
|
+
*/
|
|
45
|
+
export declare const LORO_TEXT_FORMATS: readonly ["bold", "italic", "strikethrough", "underline", "code", "subscript", "superscript", "highlight", "lowercase", "uppercase", "capitalize"];
|
|
46
|
+
export type LoroTextFormat = (typeof LORO_TEXT_FORMATS)[number];
|
|
47
|
+
/**
|
|
48
|
+
* Mark name → bit value, derived from Lexical rather than hardcoded.
|
|
49
|
+
*
|
|
50
|
+
* Built eagerly so a format this package names but Lexical does not define
|
|
51
|
+
* fails at module load — a loud boot error instead of a format that silently
|
|
52
|
+
* never round-trips.
|
|
53
|
+
*/
|
|
54
|
+
export declare const FORMAT_BITS: Readonly<Record<LoroTextFormat, number>>;
|
|
55
|
+
/** Every bit this binding knows how to represent. */
|
|
56
|
+
export declare const KNOWN_FORMAT_MASK: number;
|
|
57
|
+
/** The bit value of a named format. */
|
|
58
|
+
export declare function formatBit(format: LoroTextFormat): number;
|
|
59
|
+
/** Decompose a Lexical bitmask into the named marks it sets, in bit order. */
|
|
60
|
+
export declare function formatsFromBitmask(bitmask: number): LoroTextFormat[];
|
|
61
|
+
/** Recompose named marks into a Lexical bitmask. */
|
|
62
|
+
export declare function bitmaskFromFormats(formats: Iterable<LoroTextFormat>): number;
|
|
63
|
+
/**
|
|
64
|
+
* Read a Loro delta's attribute bag as a Lexical bitmask.
|
|
65
|
+
*
|
|
66
|
+
* Only `true` counts as set: Loro represents an unmark as an explicit `null`
|
|
67
|
+
* attribute in the delta, which must read as OFF, not as "present".
|
|
68
|
+
*/
|
|
69
|
+
export declare function bitmaskFromAttributes(attributes: Readonly<Record<string, unknown>> | undefined): number;
|
|
70
|
+
/** A maximal stretch of text sharing one Lexical format bitmask. */
|
|
71
|
+
export interface TextRun {
|
|
72
|
+
readonly text: string;
|
|
73
|
+
readonly format: number;
|
|
74
|
+
}
|
|
75
|
+
/** Concatenated text of a run list. */
|
|
76
|
+
export declare function runsText(runs: readonly TextRun[]): string;
|
|
77
|
+
/**
|
|
78
|
+
* Coalesce adjacent equal-format runs and drop empty ones, so two run lists
|
|
79
|
+
* describing the same content compare structurally equal.
|
|
80
|
+
*
|
|
81
|
+
* Necessary because Lexical's node boundaries are a rendering detail: `ab`+`c`
|
|
82
|
+
* and `abc` at the same format are the same document.
|
|
83
|
+
*/
|
|
84
|
+
export declare function normalizeRuns(runs: readonly TextRun[]): TextRun[];
|
|
85
|
+
/** A Loro text delta item, as returned by `LoroText#toDelta`. */
|
|
86
|
+
export interface TextDeltaItem {
|
|
87
|
+
readonly insert?: unknown;
|
|
88
|
+
readonly attributes?: Readonly<Record<string, unknown>>;
|
|
89
|
+
}
|
|
90
|
+
/** Project a `LoroText`'s delta into normalized Lexical-shaped runs. */
|
|
91
|
+
export declare function runsFromDelta(delta: readonly TextDeltaItem[]): TextRun[];
|
|
92
|
+
/** Project a live `LoroText` into normalized Lexical-shaped runs. */
|
|
93
|
+
export declare function runsFromText(text: LoroText): TextRun[];
|
|
94
|
+
/** An explicit format operation to apply to a `LoroText`. */
|
|
95
|
+
export interface MarkOp {
|
|
96
|
+
readonly kind: 'mark' | 'unmark';
|
|
97
|
+
/** Inclusive UTF-16 start offset. */
|
|
98
|
+
readonly start: number;
|
|
99
|
+
/** Exclusive UTF-16 end offset. */
|
|
100
|
+
readonly end: number;
|
|
101
|
+
readonly format: LoroTextFormat;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Diff two run lists into the minimal explicit `mark`/`unmark` ops that turn
|
|
105
|
+
* `current` into `target`.
|
|
106
|
+
*
|
|
107
|
+
* `current` is what Loro holds after the text edit landed (so `expand` has
|
|
108
|
+
* already had its say); `target` is the runs Lexical actually produced. Both
|
|
109
|
+
* MUST describe the same character count — call this only after the text
|
|
110
|
+
* content has been reconciled.
|
|
111
|
+
*
|
|
112
|
+
* Each format is diffed INDEPENDENTLY (that is the whole point of decomposing
|
|
113
|
+
* the bitmask) and differing characters are coalesced into maximal ranges, so a
|
|
114
|
+
* whole-paragraph bolding is one op, not one per character.
|
|
115
|
+
*/
|
|
116
|
+
export declare function diffRunFormats(current: readonly TextRun[], target: readonly TextRun[]): MarkOp[];
|
|
117
|
+
/**
|
|
118
|
+
* Apply {@link MarkOp}s to a `LoroText`. The caller owns the surrounding
|
|
119
|
+
* transaction (`doc.commit`), so a whole Lexical update lands as one Loro
|
|
120
|
+
* change and therefore as one remote event batch.
|
|
121
|
+
*/
|
|
122
|
+
export declare function applyMarkOps(text: LoroText, ops: readonly MarkOp[]): void;
|
|
123
|
+
/** A single-region string edit: delete `remove` chars at `index`, insert `insert`. */
|
|
124
|
+
export interface TextDiff {
|
|
125
|
+
/** UTF-16 offset at which the change applies. */
|
|
126
|
+
readonly index: number;
|
|
127
|
+
/** Number of UTF-16 code units to delete at `index`. */
|
|
128
|
+
readonly remove: number;
|
|
129
|
+
/** Text to insert at `index` after the deletion. */
|
|
130
|
+
readonly insert: string;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Diff `a` → `b`, biased to place the change at `cursor`.
|
|
134
|
+
*
|
|
135
|
+
* A plain "common prefix / common suffix" diff is ambiguous whenever the edit
|
|
136
|
+
* sits next to repeated characters: typing `o` in `foo` could be described as an
|
|
137
|
+
* insert at index 1, 2 or 3, and the plain diff always picks the leftmost. Every
|
|
138
|
+
* peer then sees the character inserted at the wrong place, which drags remote
|
|
139
|
+
* carets and (through Loro's `expand`) can even attach the wrong formatting.
|
|
140
|
+
*
|
|
141
|
+
* Biasing the prefix scan to stop AT the cursor resolves the ambiguity in favour
|
|
142
|
+
* of where the user actually typed. `@lexical/yjs` uses `simpleDiffWithCursor`
|
|
143
|
+
* for exactly this reason; this is that algorithm (lib0's
|
|
144
|
+
* `simpleDiffStringWithCursor`), including its surrogate-pair rollbacks, ported
|
|
145
|
+
* so the package carries no lib0 dependency.
|
|
146
|
+
*
|
|
147
|
+
* Surrogate handling: the scans never stop between the halves of a surrogate
|
|
148
|
+
* pair, so an astral character is always inserted or deleted whole.
|
|
149
|
+
*
|
|
150
|
+
* @param cursor UTF-16 offset of the caret in `b` (the new string).
|
|
151
|
+
*/
|
|
152
|
+
export declare function diffTextWithCursor(a: string, b: string, cursor: number): TextDiff;
|
|
153
|
+
/**
|
|
154
|
+
* Cursor-free variant: the change is placed as far LEFT as possible.
|
|
155
|
+
*
|
|
156
|
+
* Equivalent to lib0's `simpleDiffString`. Use it only where no caret is known
|
|
157
|
+
* (a programmatic document change); prefer {@link diffTextWithCursor} on any
|
|
158
|
+
* user-typing path, where the leftmost placement is exactly the wrong guess.
|
|
159
|
+
*/
|
|
160
|
+
export declare function diffText(a: string, b: string): TextDiff;
|
|
161
|
+
/**
|
|
162
|
+
* Apply a {@link TextDiff} to a `LoroText`. A no-op diff writes nothing, so a
|
|
163
|
+
* Lexical update that changed only formatting produces no text ops (and so no
|
|
164
|
+
* spurious remote text event).
|
|
165
|
+
*/
|
|
166
|
+
export declare function applyTextDiff(text: LoroText, diff: TextDiff): void;
|
|
167
|
+
//# sourceMappingURL=text.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"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"}
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
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
|
+
import { TEXT_TYPE_TO_FORMAT } from 'lexical';
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Format vocabulary
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/**
|
|
45
|
+
* The Lexical text formats a Loro binding represents, in bit order (see
|
|
46
|
+
* Lexical's `LexicalConstants.ts`). Each becomes an INDEPENDENT named mark.
|
|
47
|
+
*/
|
|
48
|
+
export const LORO_TEXT_FORMATS = [
|
|
49
|
+
'bold',
|
|
50
|
+
'italic',
|
|
51
|
+
'strikethrough',
|
|
52
|
+
'underline',
|
|
53
|
+
'code',
|
|
54
|
+
'subscript',
|
|
55
|
+
'superscript',
|
|
56
|
+
'highlight',
|
|
57
|
+
'lowercase',
|
|
58
|
+
'uppercase',
|
|
59
|
+
'capitalize',
|
|
60
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* Mark name → bit value, derived from Lexical rather than hardcoded.
|
|
63
|
+
*
|
|
64
|
+
* Built eagerly so a format this package names but Lexical does not define
|
|
65
|
+
* fails at module load — a loud boot error instead of a format that silently
|
|
66
|
+
* never round-trips.
|
|
67
|
+
*/
|
|
68
|
+
export const FORMAT_BITS = Object.freeze(Object.fromEntries(LORO_TEXT_FORMATS.map((format) => {
|
|
69
|
+
const bit = TEXT_TYPE_TO_FORMAT[format];
|
|
70
|
+
if (bit === undefined) {
|
|
71
|
+
throw new Error(`lexical-loro: Lexical does not define the text format '${format}' — ` +
|
|
72
|
+
'LORO_TEXT_FORMATS is out of sync with TEXT_TYPE_TO_FORMAT');
|
|
73
|
+
}
|
|
74
|
+
return [format, bit];
|
|
75
|
+
})));
|
|
76
|
+
/** Every bit this binding knows how to represent. */
|
|
77
|
+
export const KNOWN_FORMAT_MASK = LORO_TEXT_FORMATS.reduce((mask, format) => mask | FORMAT_BITS[format], 0);
|
|
78
|
+
/** The bit value of a named format. */
|
|
79
|
+
export function formatBit(format) {
|
|
80
|
+
return FORMAT_BITS[format];
|
|
81
|
+
}
|
|
82
|
+
/** Decompose a Lexical bitmask into the named marks it sets, in bit order. */
|
|
83
|
+
export function formatsFromBitmask(bitmask) {
|
|
84
|
+
return LORO_TEXT_FORMATS.filter((format) => (bitmask & FORMAT_BITS[format]) !== 0);
|
|
85
|
+
}
|
|
86
|
+
/** Recompose named marks into a Lexical bitmask. */
|
|
87
|
+
export function bitmaskFromFormats(formats) {
|
|
88
|
+
let bitmask = 0;
|
|
89
|
+
for (const format of formats)
|
|
90
|
+
bitmask |= FORMAT_BITS[format];
|
|
91
|
+
return bitmask;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read a Loro delta's attribute bag as a Lexical bitmask.
|
|
95
|
+
*
|
|
96
|
+
* Only `true` counts as set: Loro represents an unmark as an explicit `null`
|
|
97
|
+
* attribute in the delta, which must read as OFF, not as "present".
|
|
98
|
+
*/
|
|
99
|
+
export function bitmaskFromAttributes(attributes) {
|
|
100
|
+
if (attributes === undefined)
|
|
101
|
+
return 0;
|
|
102
|
+
let bitmask = 0;
|
|
103
|
+
for (const format of LORO_TEXT_FORMATS) {
|
|
104
|
+
if (attributes[format] === true)
|
|
105
|
+
bitmask |= FORMAT_BITS[format];
|
|
106
|
+
}
|
|
107
|
+
return bitmask;
|
|
108
|
+
}
|
|
109
|
+
/** Concatenated text of a run list. */
|
|
110
|
+
export function runsText(runs) {
|
|
111
|
+
return runs.map((run) => run.text).join('');
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Coalesce adjacent equal-format runs and drop empty ones, so two run lists
|
|
115
|
+
* describing the same content compare structurally equal.
|
|
116
|
+
*
|
|
117
|
+
* Necessary because Lexical's node boundaries are a rendering detail: `ab`+`c`
|
|
118
|
+
* and `abc` at the same format are the same document.
|
|
119
|
+
*/
|
|
120
|
+
export function normalizeRuns(runs) {
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const run of runs) {
|
|
123
|
+
if (run.text === '')
|
|
124
|
+
continue;
|
|
125
|
+
const last = out[out.length - 1];
|
|
126
|
+
if (last !== undefined && last.format === run.format) {
|
|
127
|
+
out[out.length - 1] = { text: last.text + run.text, format: last.format };
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
out.push({ text: run.text, format: run.format });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/** Project a `LoroText`'s delta into normalized Lexical-shaped runs. */
|
|
136
|
+
export function runsFromDelta(delta) {
|
|
137
|
+
return normalizeRuns(delta.map((item) => ({
|
|
138
|
+
text: String(item.insert ?? ''),
|
|
139
|
+
format: bitmaskFromAttributes(item.attributes),
|
|
140
|
+
})));
|
|
141
|
+
}
|
|
142
|
+
/** Project a live `LoroText` into normalized Lexical-shaped runs. */
|
|
143
|
+
export function runsFromText(text) {
|
|
144
|
+
return runsFromDelta(text.toDelta());
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Expand runs into a per-character bitmask array.
|
|
148
|
+
*
|
|
149
|
+
* Per-character is what makes the run diff correct without special-casing:
|
|
150
|
+
* the two sides may be segmented completely differently (Lexical split a node
|
|
151
|
+
* where Loro did not), and comparing per character makes segmentation moot.
|
|
152
|
+
*/
|
|
153
|
+
function bitmaskPerCharacter(runs) {
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const run of runs) {
|
|
156
|
+
for (let i = 0; i < run.text.length; i++)
|
|
157
|
+
out.push(run.format);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Diff two run lists into the minimal explicit `mark`/`unmark` ops that turn
|
|
163
|
+
* `current` into `target`.
|
|
164
|
+
*
|
|
165
|
+
* `current` is what Loro holds after the text edit landed (so `expand` has
|
|
166
|
+
* already had its say); `target` is the runs Lexical actually produced. Both
|
|
167
|
+
* MUST describe the same character count — call this only after the text
|
|
168
|
+
* content has been reconciled.
|
|
169
|
+
*
|
|
170
|
+
* Each format is diffed INDEPENDENTLY (that is the whole point of decomposing
|
|
171
|
+
* the bitmask) and differing characters are coalesced into maximal ranges, so a
|
|
172
|
+
* whole-paragraph bolding is one op, not one per character.
|
|
173
|
+
*/
|
|
174
|
+
export function diffRunFormats(current, target) {
|
|
175
|
+
const from = bitmaskPerCharacter(current);
|
|
176
|
+
const to = bitmaskPerCharacter(target);
|
|
177
|
+
if (from.length !== to.length) {
|
|
178
|
+
throw new Error(`lexical-loro: diffRunFormats requires equal-length runs — ` +
|
|
179
|
+
`current has ${from.length} chars, target has ${to.length}; ` +
|
|
180
|
+
'reconcile the text content before diffing formats');
|
|
181
|
+
}
|
|
182
|
+
const ops = [];
|
|
183
|
+
for (const format of LORO_TEXT_FORMATS) {
|
|
184
|
+
const bit = FORMAT_BITS[format];
|
|
185
|
+
let start = -1;
|
|
186
|
+
let openKind = 'mark';
|
|
187
|
+
// Iterate one PAST the end so a range still open at the last character is
|
|
188
|
+
// closed by the same branch as any other, with no duplicated tail logic.
|
|
189
|
+
for (let i = 0; i <= from.length; i++) {
|
|
190
|
+
let kind;
|
|
191
|
+
if (i < from.length) {
|
|
192
|
+
const wanted = (to[i] & bit) !== 0;
|
|
193
|
+
const has = (from[i] & bit) !== 0;
|
|
194
|
+
if (wanted !== has)
|
|
195
|
+
kind = wanted ? 'mark' : 'unmark';
|
|
196
|
+
}
|
|
197
|
+
if (start !== -1 && kind !== openKind) {
|
|
198
|
+
ops.push({ kind: openKind, start, end: i, format });
|
|
199
|
+
start = -1;
|
|
200
|
+
}
|
|
201
|
+
if (kind !== undefined && start === -1) {
|
|
202
|
+
start = i;
|
|
203
|
+
openKind = kind;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return ops;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Apply {@link MarkOp}s to a `LoroText`. The caller owns the surrounding
|
|
211
|
+
* transaction (`doc.commit`), so a whole Lexical update lands as one Loro
|
|
212
|
+
* change and therefore as one remote event batch.
|
|
213
|
+
*/
|
|
214
|
+
export function applyMarkOps(text, ops) {
|
|
215
|
+
for (const op of ops) {
|
|
216
|
+
if (op.kind === 'mark')
|
|
217
|
+
text.mark({ start: op.start, end: op.end }, op.format, true);
|
|
218
|
+
else
|
|
219
|
+
text.unmark({ start: op.start, end: op.end }, op.format);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const HIGH_SURROGATE = /[\uD800-\uDBFF]/;
|
|
223
|
+
const LOW_SURROGATE = /[\uDC00-\uDFFF]/;
|
|
224
|
+
/**
|
|
225
|
+
* Diff `a` → `b`, biased to place the change at `cursor`.
|
|
226
|
+
*
|
|
227
|
+
* A plain "common prefix / common suffix" diff is ambiguous whenever the edit
|
|
228
|
+
* sits next to repeated characters: typing `o` in `foo` could be described as an
|
|
229
|
+
* insert at index 1, 2 or 3, and the plain diff always picks the leftmost. Every
|
|
230
|
+
* peer then sees the character inserted at the wrong place, which drags remote
|
|
231
|
+
* carets and (through Loro's `expand`) can even attach the wrong formatting.
|
|
232
|
+
*
|
|
233
|
+
* Biasing the prefix scan to stop AT the cursor resolves the ambiguity in favour
|
|
234
|
+
* of where the user actually typed. `@lexical/yjs` uses `simpleDiffWithCursor`
|
|
235
|
+
* for exactly this reason; this is that algorithm (lib0's
|
|
236
|
+
* `simpleDiffStringWithCursor`), including its surrogate-pair rollbacks, ported
|
|
237
|
+
* so the package carries no lib0 dependency.
|
|
238
|
+
*
|
|
239
|
+
* Surrogate handling: the scans never stop between the halves of a surrogate
|
|
240
|
+
* pair, so an astral character is always inserted or deleted whole.
|
|
241
|
+
*
|
|
242
|
+
* @param cursor UTF-16 offset of the caret in `b` (the new string).
|
|
243
|
+
*/
|
|
244
|
+
export function diffTextWithCursor(a, b, cursor) {
|
|
245
|
+
let left = 0;
|
|
246
|
+
let right = 0;
|
|
247
|
+
// Scan the common prefix, but stop at the cursor: the user's caret marks
|
|
248
|
+
// where the change really is.
|
|
249
|
+
while (left < a.length && left < b.length && a[left] === b[left] && left < cursor)
|
|
250
|
+
left++;
|
|
251
|
+
if (left > 0 && HIGH_SURROGATE.test(a[left - 1]))
|
|
252
|
+
left--;
|
|
253
|
+
// Scan the common suffix.
|
|
254
|
+
while (right + left < a.length &&
|
|
255
|
+
right + left < b.length &&
|
|
256
|
+
a[a.length - right - 1] === b[b.length - right - 1])
|
|
257
|
+
right++;
|
|
258
|
+
if (right > 0 && LOW_SURROGATE.test(a[a.length - right]))
|
|
259
|
+
right--;
|
|
260
|
+
// Resume the prefix scan past the cursor — anything still common there is not
|
|
261
|
+
// part of the edit, and leaving it in would make the diff larger than needed.
|
|
262
|
+
while (right + left < a.length && right + left < b.length && a[left] === b[left])
|
|
263
|
+
left++;
|
|
264
|
+
if (left > 0 && HIGH_SURROGATE.test(a[left - 1]))
|
|
265
|
+
left--;
|
|
266
|
+
return { index: left, remove: a.length - left - right, insert: b.slice(left, b.length - right) };
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Cursor-free variant: the change is placed as far LEFT as possible.
|
|
270
|
+
*
|
|
271
|
+
* Equivalent to lib0's `simpleDiffString`. Use it only where no caret is known
|
|
272
|
+
* (a programmatic document change); prefer {@link diffTextWithCursor} on any
|
|
273
|
+
* user-typing path, where the leftmost placement is exactly the wrong guess.
|
|
274
|
+
*/
|
|
275
|
+
export function diffText(a, b) {
|
|
276
|
+
return diffTextWithCursor(a, b, 0);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Apply a {@link TextDiff} to a `LoroText`. A no-op diff writes nothing, so a
|
|
280
|
+
* Lexical update that changed only formatting produces no text ops (and so no
|
|
281
|
+
* spurious remote text event).
|
|
282
|
+
*/
|
|
283
|
+
export function applyTextDiff(text, diff) {
|
|
284
|
+
if (diff.remove > 0)
|
|
285
|
+
text.delete(diff.index, diff.remove);
|
|
286
|
+
if (diff.insert !== '')
|
|
287
|
+
text.insert(diff.index, diff.insert);
|
|
288
|
+
}
|
|
289
|
+
//# sourceMappingURL=text.js.map
|
package/dist/text.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"text.js","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound sync: the Loro mirror → a Lexical `EditorState`.
|
|
3
|
+
*
|
|
4
|
+
* ── The one thing this must not do ─────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* loro-prosemirror's inbound path replaces the whole document on every remote
|
|
7
|
+
* event (`sync-plugin.ts`). ProseMirror tolerates that. LEXICAL CANNOT: its
|
|
8
|
+
* `NodeKey` is a bare counter (`LexicalUtils.ts`: `'' + keyCounter++`), so a
|
|
9
|
+
* rebuild mints a fresh key for every node, which tears down all DOM, destroys
|
|
10
|
+
* the selection and IME composition, and — decisively for this stack — destroys
|
|
11
|
+
* and remounts EVERY `LLuiDecoratorNode`, because `packages/lexical/decorator.ts`
|
|
12
|
+
* disposes the mounted LLui sub-app on the 'destroyed' mutation.
|
|
13
|
+
*
|
|
14
|
+
* So this module is a PERSISTENT MIRROR: it resolves each remote change to an
|
|
15
|
+
* existing `NodeKey` through the registry in `mapping.ts` and MUTATES THAT NODE
|
|
16
|
+
* IN PLACE. Nodes nothing touched are never written, so their keys — and
|
|
17
|
+
* therefore their DOM, their mounts, and any selection inside them — survive by
|
|
18
|
+
* construction. That is the model `@lexical/yjs`'s `SyncEditorStates.ts` uses,
|
|
19
|
+
* and it is the only one that works here.
|
|
20
|
+
*
|
|
21
|
+
* ── Why it reconciles from STATE, not from the deltas ──────────────────────
|
|
22
|
+
*
|
|
23
|
+
* The events say WHERE the document changed; this module then reconciles those
|
|
24
|
+
* containers against Loro's CURRENT state rather than replaying each delta. Four
|
|
25
|
+
* reasons, all learned from the event shapes loro-crdt actually emits:
|
|
26
|
+
*
|
|
27
|
+
* 1. A batch that creates a subtree emits the parent's list insert AND a
|
|
28
|
+
* separate event for every descendant container. Replaying all of them
|
|
29
|
+
* double-applies; reconciling from state is idempotent, so the duplicate
|
|
30
|
+
* descriptions of one change collapse to one reconciliation.
|
|
31
|
+
* 2. A `LoroMovableList#move` arrives as an insert plus a delete carrying the
|
|
32
|
+
* SAME `ContainerID`. Reconciling by identity recognises that as a move —
|
|
33
|
+
* the whole point of the movable-list schema — where a delta replay would
|
|
34
|
+
* see a delete and rebuild the subtree.
|
|
35
|
+
* 3. A map deletion is reported as an `undefined` value in `MapDiff.updated`,
|
|
36
|
+
* which is indistinguishable from an absent key in most JS handling. Reading
|
|
37
|
+
* the map is unambiguous.
|
|
38
|
+
* 4. It is the mirror image of `to-loro.ts`, so both directions share one
|
|
39
|
+
* notion of "these two trees agree" (`text.ts`'s normalized runs) and cannot
|
|
40
|
+
* drift apart in their idea of what a difference is.
|
|
41
|
+
*
|
|
42
|
+
* The events are therefore used as a DIRTY SET, exactly as `to-loro.ts` uses
|
|
43
|
+
* Lexical's `dirtyElements`/`dirtyLeaves`: every element on the path from the
|
|
44
|
+
* root to a changed container is marked, and the walk descends only into marked
|
|
45
|
+
* children. A remote keystroke costs O(tree depth), not O(document).
|
|
46
|
+
*
|
|
47
|
+
* ── Selection ──────────────────────────────────────────────────────────────
|
|
48
|
+
*
|
|
49
|
+
* Untouched nodes keep their keys, so a caret outside the changed run needs no
|
|
50
|
+
* help at all. Only the case where a remote change lands INSIDE the exact text
|
|
51
|
+
* run holding the caret needs work: there the caret's offset is transformed
|
|
52
|
+
* through the same single-region diff used to update the run (a caret at or
|
|
53
|
+
* before the edit point does not move; one after it shifts by
|
|
54
|
+
* `insert.length - remove`; one inside a deleted span clamps to the edit point).
|
|
55
|
+
* That is deliberately narrow — reaching for cursors more broadly would mean
|
|
56
|
+
* writing selection on every remote event, which is its own source of jitter.
|
|
57
|
+
*/
|
|
58
|
+
import { type LexicalEditor } from 'lexical';
|
|
59
|
+
import { type LoroDoc, type LoroEventBatch } from 'loro-crdt';
|
|
60
|
+
import { ContainerNodeMap } from './mapping.js';
|
|
61
|
+
import { type ElementContainer } from './schema.js';
|
|
62
|
+
/**
|
|
63
|
+
* Tags stamped on every inbound writeback.
|
|
64
|
+
*
|
|
65
|
+
* `COLLABORATION_TAG` is echo layer (b): `to-loro.ts` skips updates carrying it,
|
|
66
|
+
* so our own writeback cannot bounce back into the shared document.
|
|
67
|
+
* `SKIP_SCROLL_INTO_VIEW_TAG` stops a peer's edit from yanking the local
|
|
68
|
+
* viewport.
|
|
69
|
+
*
|
|
70
|
+
* `PROGRAMMATIC_TAG` is deliberately ABSENT and must stay that way — echo layer
|
|
71
|
+
* (c). `packages/lexical/src/foreign.ts` treats that tag as "the host pushed new
|
|
72
|
+
* content: cancel pending outbound work and rebase", so a remote writeback
|
|
73
|
+
* carrying it would silently cancel the local user's in-flight debounced
|
|
74
|
+
* `onChange` and the host's persistence would go dark whenever a peer types.
|
|
75
|
+
*/
|
|
76
|
+
export declare const INBOUND_TAGS: readonly string[];
|
|
77
|
+
/** The Lexical side of the binding, plus the shared document it mirrors. */
|
|
78
|
+
export interface InboundTarget {
|
|
79
|
+
readonly doc: LoroDoc;
|
|
80
|
+
/** The root element container, as returned by `initDoc`. */
|
|
81
|
+
readonly root: ElementContainer;
|
|
82
|
+
/** The ContainerID ↔ NodeKey registry, owned by the binding and mutated here. */
|
|
83
|
+
readonly mapping: ContainerNodeMap;
|
|
84
|
+
readonly editor: LexicalEditor;
|
|
85
|
+
/**
|
|
86
|
+
* Commit origins whose LOCAL batches must still be applied.
|
|
87
|
+
*
|
|
88
|
+
* Echo layer (a) drops `by: 'local'` batches — they are this peer's own
|
|
89
|
+
* outbound writes coming back round. But not every local batch is an echo:
|
|
90
|
+
*
|
|
91
|
+
* - the CRDT-aware undo manager (`undo.ts`) produces LOCAL batches from
|
|
92
|
+
* `undo()`/`redo()` (`UNDO_ORIGINS`), and
|
|
93
|
+
* - the agent-write reconciler (`agent-write.ts`) writes the document directly
|
|
94
|
+
* under {@link import('./agent-write.js').AGENT_WRITE_ORIGIN},
|
|
95
|
+
*
|
|
96
|
+
* neither of which came FROM the editor, so both MUST be applied to bounce into
|
|
97
|
+
* it (preserving `NodeKey`s and decorator mounts). `binding.ts` passes those
|
|
98
|
+
* origins here. A local batch whose origin is on this list is applied; every
|
|
99
|
+
* other local batch is still dropped as an echo.
|
|
100
|
+
*/
|
|
101
|
+
readonly localOrigins?: readonly string[];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Apply one Loro event batch to the editor.
|
|
105
|
+
*
|
|
106
|
+
* @returns whether anything was applied. `false` means the batch was an echo of
|
|
107
|
+
* our own write, or described no change the editor could see.
|
|
108
|
+
*/
|
|
109
|
+
export declare function applyLoroToLexical(target: InboundTarget, batch: LoroEventBatch): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Reconcile the ENTIRE shared document into the editor, with no dirty gate.
|
|
112
|
+
*
|
|
113
|
+
* Used at boot by a peer adopting a document it has no event history for (see
|
|
114
|
+
* `seed.ts`), and as the fallback whenever an event's container ancestry cannot
|
|
115
|
+
* be resolved. Full-fidelity and identity-preserving: adopting a document the
|
|
116
|
+
* editor already matches writes nothing and churns no NodeKeys.
|
|
117
|
+
*/
|
|
118
|
+
export declare function adoptLoroDocument(target: InboundTarget): boolean;
|
|
119
|
+
//# sourceMappingURL=to-lexical.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"to-lexical.d.ts","sourceRoot":"","sources":["../src/to-lexical.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAYL,KAAK,aAAa,EAMnB,MAAM,SAAS,CAAA;AAChB,OAAO,EAIL,KAAK,OAAO,EACZ,KAAK,cAAc,EACpB,MAAM,WAAW,CAAA;AAElB,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EASL,KAAK,gBAAgB,EAEtB,MAAM,aAAa,CAAA;AAOpB;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAAmD,CAAA;AAE7F,4EAA4E;AAC5E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAA;IACrB,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAA;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAA;IAClC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;IAC9B;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC1C;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,cAAc,GAAG,OAAO,CAaxF;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAEhE"}
|