@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/binding.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loroCollab(config)` — the binding that drives both directions.
|
|
3
|
+
*
|
|
4
|
+
* It composes `to-loro.ts` (Lexical → Loro), `to-lexical.ts` (Loro → Lexical)
|
|
5
|
+
* and `seed.ts` (boot) into one `register(editor)` step you hand to
|
|
6
|
+
* `lexicalForeign({ history: false, seedMode: 'deferred', register })`. The
|
|
7
|
+
* returned handle satisfies `@llui/markdown-editor`'s `CollabBinding`
|
|
8
|
+
* structurally, so that package needs no Loro dependency of its own.
|
|
9
|
+
*
|
|
10
|
+
* ── Transport ──────────────────────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* There is none, deliberately. `LoroDoc` already exposes the whole wire surface
|
|
13
|
+
* (`subscribeLocalUpdates` / `import` / `export`), so a transport is a dozen
|
|
14
|
+
* lines the consumer writes against their own websocket, WebRTC channel or
|
|
15
|
+
* `loro-websocket` provider — and baking one in would make this package own a
|
|
16
|
+
* connection lifecycle it has no way to test honestly. Pass the same `LoroDoc`
|
|
17
|
+
* to your provider and to `loroCollab`.
|
|
18
|
+
*
|
|
19
|
+
* ── The three echo layers, and where each one lives ────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* All three are required; any one missing produces an infinite loop or, worse,
|
|
22
|
+
* a silent data-loss bug:
|
|
23
|
+
*
|
|
24
|
+
* a. Loro → us. A LOCAL Loro batch is our own outbound write completing its
|
|
25
|
+
* commit. `applyLoroToLexical` drops it (`to-lexical.ts`). Without this the
|
|
26
|
+
* binding re-enters `editor.update` from inside a Lexical update listener.
|
|
27
|
+
* b. us → Lexical → us. Our inbound writeback is tagged `COLLABORATION_TAG`,
|
|
28
|
+
* which `syncLexicalToLoro` skips (`to-loro.ts`). Without this every remote
|
|
29
|
+
* keystroke is echoed straight back to its sender.
|
|
30
|
+
* c. THE SEAM. This binding NEVER emits `PROGRAMMATIC_TAG`.
|
|
31
|
+
* `packages/lexical/src/foreign.ts` treats that tag as "the host pushed new
|
|
32
|
+
* content — cancel pending outbound work and rebase", so a remote writeback
|
|
33
|
+
* carrying it would cancel the local user's in-flight debounced `onChange`
|
|
34
|
+
* and the host's persistence would go dark whenever a peer types. There is
|
|
35
|
+
* no code here to enforce this; there is simply no line that emits it, and
|
|
36
|
+
* `test/to-lexical.test.ts` pins that.
|
|
37
|
+
*
|
|
38
|
+
* ── Undo ───────────────────────────────────────────────────────────────────
|
|
39
|
+
*
|
|
40
|
+
* This binding OWNS undo, via `externalUndo` (`undo.ts`, a Loro `UndoManager`
|
|
41
|
+
* scoped to this peer). Hosts must therefore turn their built-in
|
|
42
|
+
* `@lexical/history` stack off — `lexicalForeign` does that for you the moment
|
|
43
|
+
* `externalUndo` is present, so the two can never both be live.
|
|
44
|
+
*
|
|
45
|
+
* Loro's manager is operation-based and PeerID-scoped: undo reverts exactly the
|
|
46
|
+
* local user's own last change and never a peer's, which snapshot-based history
|
|
47
|
+
* cannot do at any tag setting. See `undo.ts` for why, and for the echo seam the
|
|
48
|
+
* undo batches travel through.
|
|
49
|
+
*/
|
|
50
|
+
import { LoroDoc } from 'loro-crdt';
|
|
51
|
+
import { AGENT_WRITE_ORIGIN } from './agent-write.js';
|
|
52
|
+
import { ContainerNodeMap } from './mapping.js';
|
|
53
|
+
import { bootstrapDocument } from './seed.js';
|
|
54
|
+
import { initDoc } from './schema.js';
|
|
55
|
+
import { LORO_TEXT_FORMATS } from './text.js';
|
|
56
|
+
import { applyLoroToLexical } from './to-lexical.js';
|
|
57
|
+
import { OUTBOUND_ORIGIN, syncLexicalToLoro } from './to-loro.js';
|
|
58
|
+
import { registerLoroUndo, UNDO_ORIGINS } from './undo.js';
|
|
59
|
+
/**
|
|
60
|
+
* Build a collaborative-editing binding over a Loro document.
|
|
61
|
+
*
|
|
62
|
+
* The document is configured for this package's schema (`initDoc`) immediately,
|
|
63
|
+
* not at `register` time, so a transport may be attached to `collab.doc` before
|
|
64
|
+
* any editor exists.
|
|
65
|
+
*/
|
|
66
|
+
export function loroCollab(config = {}) {
|
|
67
|
+
const doc = config.doc ?? new LoroDoc();
|
|
68
|
+
// Must run on EVERY peer: `configTextStyle` is local configuration, not
|
|
69
|
+
// replicated state, and a peer that skips it resolves marks under different
|
|
70
|
+
// expand rules and diverges. Idempotent, so a shared doc may be passed to
|
|
71
|
+
// more than one binding.
|
|
72
|
+
const root = initDoc(doc, LORO_TEXT_FORMATS);
|
|
73
|
+
const mapping = new ContainerNodeMap();
|
|
74
|
+
const origin = config.origin ?? OUTBOUND_ORIGIN;
|
|
75
|
+
const bootstrap = (editor) => {
|
|
76
|
+
const outcome = bootstrapDocument({
|
|
77
|
+
doc,
|
|
78
|
+
root,
|
|
79
|
+
mapping,
|
|
80
|
+
editor,
|
|
81
|
+
seed: config.seed,
|
|
82
|
+
shouldBootstrap: config.shouldBootstrap,
|
|
83
|
+
});
|
|
84
|
+
config.onBootstrap?.(outcome);
|
|
85
|
+
return outcome;
|
|
86
|
+
};
|
|
87
|
+
const register = (editor) => {
|
|
88
|
+
const outboundTarget = { doc, root, mapping, origin };
|
|
89
|
+
// `localOrigins` are the local batches the inbound path must still APPLY
|
|
90
|
+
// rather than drop as an echo (see `to-lexical.ts`). Both are passed
|
|
91
|
+
// unconditionally: only a Loro `UndoManager` produces `UNDO_ORIGINS` and only
|
|
92
|
+
// `reconcileTargetIntoLoro` produces `AGENT_WRITE_ORIGIN`, so listing them is
|
|
93
|
+
// inert until one occurs — and the inbound path stays independent of whether
|
|
94
|
+
// `externalUndo` was registered or an agent write ever happens.
|
|
95
|
+
const localOrigins = [...UNDO_ORIGINS, AGENT_WRITE_ORIGIN];
|
|
96
|
+
const inboundTarget = { doc, root, mapping, editor, localOrigins };
|
|
97
|
+
// Inbound BEFORE outbound: the subscription must be live before the
|
|
98
|
+
// bootstrap writes anything, or a document arriving mid-boot is missed.
|
|
99
|
+
const unsubscribe = doc.subscribe((batch) => {
|
|
100
|
+
applyLoroToLexical(inboundTarget, batch);
|
|
101
|
+
});
|
|
102
|
+
const unregister = editor.registerUpdateListener((payload) => {
|
|
103
|
+
syncLexicalToLoro(outboundTarget, payload);
|
|
104
|
+
});
|
|
105
|
+
bootstrap(editor);
|
|
106
|
+
return () => {
|
|
107
|
+
unregister();
|
|
108
|
+
unsubscribe();
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
const externalUndo = (editor) => registerLoroUndo({ doc, ...config.undo }, editor);
|
|
112
|
+
return { register, doc, root, mapping, bootstrap, externalUndo };
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=binding.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"binding.js","sourceRoot":"","sources":["../src/binding.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@llui/lexical-loro` — Loro CRDT binding for the LLui ↔ Lexical editor.
|
|
3
|
+
*
|
|
4
|
+
* Start at {@link loroCollab}: it composes everything below into a
|
|
5
|
+
* `register(editor)` plus an `externalUndo(editor)` you hand to
|
|
6
|
+
* `lexicalForeign({ seedMode: 'deferred', register, externalUndo })`, and it
|
|
7
|
+
* satisfies `@llui/markdown-editor`'s `CollabBinding` structurally.
|
|
8
|
+
*
|
|
9
|
+
* - `schema.ts` — the Loro container shape mirroring a Lexical EditorState
|
|
10
|
+
* - `order.ts` — fractional indexing: sibling order as a sortable property
|
|
11
|
+
* - `mapping.ts` — the ContainerID ↔ NodeKey registry (the core invariant)
|
|
12
|
+
* - `text.ts` — format-bitmask ↔ named-mark conversion, the run diff, and
|
|
13
|
+
* the cursor-biased text diff
|
|
14
|
+
* - `to-loro.ts` — Lexical → Loro: the update-listener-driven mirror
|
|
15
|
+
* - `to-lexical.ts` — Loro → Lexical: the persistent, in-place mirror
|
|
16
|
+
* - `seed.ts` — boot: seed an empty shared document, or adopt a populated one
|
|
17
|
+
* - `undo.ts` — peer-scoped, CRDT-aware undo/redo over Loro's `UndoManager`
|
|
18
|
+
* - `binding.ts` — `loroCollab`: both directions plus the echo guards
|
|
19
|
+
*
|
|
20
|
+
* ── Scope ──────────────────────────────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* DOCUMENT SYNC plus LOCAL-ONLY UNDO. Undo is owned by this binding
|
|
23
|
+
* (`LoroCollab.externalUndo`), so a host MUST disable `@lexical/history` —
|
|
24
|
+
* `lexicalForeign` does that automatically once `externalUndo` is passed. Still
|
|
25
|
+
* out of scope: presence and remote cursors (additive, over Loro's
|
|
26
|
+
* `EphemeralStore`), and text-node `style` / `mode` / `detail` — the run model
|
|
27
|
+
* is `{text, format}`.
|
|
28
|
+
*
|
|
29
|
+
* ── Echo suppression ───────────────────────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* Three independent layers are ALL required; see `binding.ts` for where each
|
|
32
|
+
* one lives and what breaks without it. The one with no code to enforce it:
|
|
33
|
+
* this binding NEVER emits `PROGRAMMATIC_TAG`, because
|
|
34
|
+
* `packages/lexical/src/foreign.ts` reads that tag as "the host pushed content —
|
|
35
|
+
* cancel pending outbound work", which would make the host's persistence go dark
|
|
36
|
+
* whenever a peer types.
|
|
37
|
+
*
|
|
38
|
+
* `test/expand-semantics.test.ts` (51 tests) is the specification for the
|
|
39
|
+
* text-format half; read it before touching `text.ts`. `test/network.ts` is the
|
|
40
|
+
* multi-peer convergence harness, driven by `test/convergence.test.ts`.
|
|
41
|
+
*/
|
|
42
|
+
export { ROOT_CONTAINER, KEY_TYPE, KEY_PROPS, KEY_CHILDREN, KEY_UUID, KEY_POS, KEY_KIND, KEY_TEXT, DECORATOR_TYPE, KEY_BRIDGE_TYPE, KEY_DATA, ROOT_TYPE, TEXT_MARK_EXPAND, initDoc, newUuid, createElementChild, createTextChild, setChildPosition, deleteChild, orderedChildren, childCount, elementType, elementProps, elementChildren, isElementContainer, isTextContainer, isDecoratorElement, containerId, containerIsLive, type PropValue, type PropsContainer, type ElementContainer, type ElementShape, type ChildrenContainer, type ChildContainer, type ChildCarrier, type CarrierShape, type TextCarrier, type TextCarrierShape, type ChildEntry, type ChildKind, type ExpandType, } from './schema.js';
|
|
43
|
+
export { DIGITS, between, allocate, allocateAt, jitterFor, comparePositions } from './order.js';
|
|
44
|
+
export { ContainerNodeMap, type MappingEntry, type LivenessProbe } from './mapping.js';
|
|
45
|
+
export { LORO_TEXT_FORMATS, FORMAT_BITS, KNOWN_FORMAT_MASK, formatBit, formatsFromBitmask, bitmaskFromFormats, bitmaskFromAttributes, runsText, normalizeRuns, runsFromDelta, runsFromText, diffRunFormats, applyMarkOps, diffTextWithCursor, diffText, applyTextDiff, type LoroTextFormat, type TextRun, type TextDeltaItem, type MarkOp, type TextDiff, } from './text.js';
|
|
46
|
+
export { OUTBOUND_ORIGIN, OUTBOUND_SKIP_TAGS, syncLexicalToLoro, seedLoroFromLexical, longestIncreasingSubsequence, type OutboundTarget, type OutboundUpdate, } from './to-loro.js';
|
|
47
|
+
export { INBOUND_TAGS, applyLoroToLexical, adoptLoroDocument, type InboundTarget, } from './to-lexical.js';
|
|
48
|
+
export { bootstrapDocument, isSharedDocumentEmpty, type BootstrapOutcome, type BootstrapTarget, } from './seed.js';
|
|
49
|
+
export { LORO_UNDO_ORIGIN, UNDO_ORIGINS, DEFAULT_MERGE_INTERVAL, DEFAULT_MAX_UNDO_STEPS, registerLoroUndo, type LoroUndoOptions, } from './undo.js';
|
|
50
|
+
export { loroCollab, type LoroCollab, type LoroCollabConfig } from './binding.js';
|
|
51
|
+
/**
|
|
52
|
+
* Agent-write: apply an LLM's full-document markdown REWRITE to the shared
|
|
53
|
+
* document by CONTENT, so unchanged blocks keep their `ContainerID`s (hence their
|
|
54
|
+
* `NodeKey`s and mounted decorator sub-apps, and any concurrent peer edit into
|
|
55
|
+
* them) instead of being recreated wholesale.
|
|
56
|
+
*
|
|
57
|
+
* `reconcileTargetIntoLoro` is a SIBLING to `syncLexicalToLoro`: it writes Loro
|
|
58
|
+
* directly under {@link AGENT_WRITE_ORIGIN} and does NOT consult the
|
|
59
|
+
* `ContainerNodeMap` (content matching is the point; the mapping self-heals on
|
|
60
|
+
* the inbound bounce into any live editor). The caller owns the markdown → target
|
|
61
|
+
* parse with its own `@lexical/markdown` transformer set — this package never
|
|
62
|
+
* depends on `@lexical/markdown` — and projects the parsed editor state with
|
|
63
|
+
* {@link targetFromEditorState} / {@link projectTarget}.
|
|
64
|
+
*
|
|
65
|
+
* Guarantees and the documented DUPLICATE-BLOCK caveat live on the functions and
|
|
66
|
+
* in the README.
|
|
67
|
+
*/
|
|
68
|
+
export { AGENT_WRITE_ORIGIN, reconcileTargetIntoLoro, projectTarget, targetFromEditorState, type TargetElement, type TargetText, type TargetChild, } from './agent-write.js';
|
|
69
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,EACL,cAAc,EACd,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,cAAc,EACd,eAAe,EACf,QAAQ,EACR,SAAS,EACT,gBAAgB,EAChB,OAAO,EACP,OAAO,EACP,kBAAkB,EAClB,eAAe,EACf,gBAAgB,EAChB,WAAW,EACX,eAAe,EACf,UAAU,EACV,WAAW,EACX,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,eAAe,EACf,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,SAAS,EACd,KAAK,UAAU,GAChB,MAAM,aAAa,CAAA;AAEpB,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAE/F,OAAO,EAAE,gBAAgB,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,cAAc,CAAA;AAEtF,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,iBAAiB,EACjB,SAAS,EACT,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,QAAQ,EACR,aAAa,EACb,aAAa,EACb,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,kBAAkB,EAClB,QAAQ,EACR,aAAa,EACb,KAAK,cAAc,EACnB,KAAK,OAAO,EACZ,KAAK,aAAa,EAClB,KAAK,MAAM,EACX,KAAK,QAAQ,GACd,MAAM,WAAW,CAAA;AAElB,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,4BAA4B,EAC5B,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,EACjB,KAAK,aAAa,GACnB,MAAM,iBAAiB,CAAA;AAExB,OAAO,EACL,iBAAiB,EACjB,qBAAqB,EACrB,KAAK,gBAAgB,EACrB,KAAK,eAAe,GACrB,MAAM,WAAW,CAAA;AAElB,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,gBAAgB,EAChB,KAAK,eAAe,GACrB,MAAM,WAAW,CAAA;AAElB,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAA;AAEjF;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EACL,kBAAkB,EAClB,uBAAuB,EACvB,aAAa,EACb,qBAAqB,EACrB,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,WAAW,GACjB,MAAM,kBAAkB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@llui/lexical-loro` — Loro CRDT binding for the LLui ↔ Lexical editor.
|
|
3
|
+
*
|
|
4
|
+
* Start at {@link loroCollab}: it composes everything below into a
|
|
5
|
+
* `register(editor)` plus an `externalUndo(editor)` you hand to
|
|
6
|
+
* `lexicalForeign({ seedMode: 'deferred', register, externalUndo })`, and it
|
|
7
|
+
* satisfies `@llui/markdown-editor`'s `CollabBinding` structurally.
|
|
8
|
+
*
|
|
9
|
+
* - `schema.ts` — the Loro container shape mirroring a Lexical EditorState
|
|
10
|
+
* - `order.ts` — fractional indexing: sibling order as a sortable property
|
|
11
|
+
* - `mapping.ts` — the ContainerID ↔ NodeKey registry (the core invariant)
|
|
12
|
+
* - `text.ts` — format-bitmask ↔ named-mark conversion, the run diff, and
|
|
13
|
+
* the cursor-biased text diff
|
|
14
|
+
* - `to-loro.ts` — Lexical → Loro: the update-listener-driven mirror
|
|
15
|
+
* - `to-lexical.ts` — Loro → Lexical: the persistent, in-place mirror
|
|
16
|
+
* - `seed.ts` — boot: seed an empty shared document, or adopt a populated one
|
|
17
|
+
* - `undo.ts` — peer-scoped, CRDT-aware undo/redo over Loro's `UndoManager`
|
|
18
|
+
* - `binding.ts` — `loroCollab`: both directions plus the echo guards
|
|
19
|
+
*
|
|
20
|
+
* ── Scope ──────────────────────────────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* DOCUMENT SYNC plus LOCAL-ONLY UNDO. Undo is owned by this binding
|
|
23
|
+
* (`LoroCollab.externalUndo`), so a host MUST disable `@lexical/history` —
|
|
24
|
+
* `lexicalForeign` does that automatically once `externalUndo` is passed. Still
|
|
25
|
+
* out of scope: presence and remote cursors (additive, over Loro's
|
|
26
|
+
* `EphemeralStore`), and text-node `style` / `mode` / `detail` — the run model
|
|
27
|
+
* is `{text, format}`.
|
|
28
|
+
*
|
|
29
|
+
* ── Echo suppression ───────────────────────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* Three independent layers are ALL required; see `binding.ts` for where each
|
|
32
|
+
* one lives and what breaks without it. The one with no code to enforce it:
|
|
33
|
+
* this binding NEVER emits `PROGRAMMATIC_TAG`, because
|
|
34
|
+
* `packages/lexical/src/foreign.ts` reads that tag as "the host pushed content —
|
|
35
|
+
* cancel pending outbound work", which would make the host's persistence go dark
|
|
36
|
+
* whenever a peer types.
|
|
37
|
+
*
|
|
38
|
+
* `test/expand-semantics.test.ts` (51 tests) is the specification for the
|
|
39
|
+
* text-format half; read it before touching `text.ts`. `test/network.ts` is the
|
|
40
|
+
* multi-peer convergence harness, driven by `test/convergence.test.ts`.
|
|
41
|
+
*/
|
|
42
|
+
export { ROOT_CONTAINER, KEY_TYPE, KEY_PROPS, KEY_CHILDREN, KEY_UUID, KEY_POS, KEY_KIND, KEY_TEXT, DECORATOR_TYPE, KEY_BRIDGE_TYPE, KEY_DATA, ROOT_TYPE, TEXT_MARK_EXPAND, initDoc, newUuid, createElementChild, createTextChild, setChildPosition, deleteChild, orderedChildren, childCount, elementType, elementProps, elementChildren, isElementContainer, isTextContainer, isDecoratorElement, containerId, containerIsLive, } from './schema.js';
|
|
43
|
+
export { DIGITS, between, allocate, allocateAt, jitterFor, comparePositions } from './order.js';
|
|
44
|
+
export { ContainerNodeMap } from './mapping.js';
|
|
45
|
+
export { LORO_TEXT_FORMATS, FORMAT_BITS, KNOWN_FORMAT_MASK, formatBit, formatsFromBitmask, bitmaskFromFormats, bitmaskFromAttributes, runsText, normalizeRuns, runsFromDelta, runsFromText, diffRunFormats, applyMarkOps, diffTextWithCursor, diffText, applyTextDiff, } from './text.js';
|
|
46
|
+
export { OUTBOUND_ORIGIN, OUTBOUND_SKIP_TAGS, syncLexicalToLoro, seedLoroFromLexical, longestIncreasingSubsequence, } from './to-loro.js';
|
|
47
|
+
export { INBOUND_TAGS, applyLoroToLexical, adoptLoroDocument, } from './to-lexical.js';
|
|
48
|
+
export { bootstrapDocument, isSharedDocumentEmpty, } from './seed.js';
|
|
49
|
+
export { LORO_UNDO_ORIGIN, UNDO_ORIGINS, DEFAULT_MERGE_INTERVAL, DEFAULT_MAX_UNDO_STEPS, registerLoroUndo, } from './undo.js';
|
|
50
|
+
export { loroCollab } from './binding.js';
|
|
51
|
+
/**
|
|
52
|
+
* Agent-write: apply an LLM's full-document markdown REWRITE to the shared
|
|
53
|
+
* document by CONTENT, so unchanged blocks keep their `ContainerID`s (hence their
|
|
54
|
+
* `NodeKey`s and mounted decorator sub-apps, and any concurrent peer edit into
|
|
55
|
+
* them) instead of being recreated wholesale.
|
|
56
|
+
*
|
|
57
|
+
* `reconcileTargetIntoLoro` is a SIBLING to `syncLexicalToLoro`: it writes Loro
|
|
58
|
+
* directly under {@link AGENT_WRITE_ORIGIN} and does NOT consult the
|
|
59
|
+
* `ContainerNodeMap` (content matching is the point; the mapping self-heals on
|
|
60
|
+
* the inbound bounce into any live editor). The caller owns the markdown → target
|
|
61
|
+
* parse with its own `@lexical/markdown` transformer set — this package never
|
|
62
|
+
* depends on `@lexical/markdown` — and projects the parsed editor state with
|
|
63
|
+
* {@link targetFromEditorState} / {@link projectTarget}.
|
|
64
|
+
*
|
|
65
|
+
* Guarantees and the documented DUPLICATE-BLOCK caveat live on the functions and
|
|
66
|
+
* in the README.
|
|
67
|
+
*/
|
|
68
|
+
export { AGENT_WRITE_ORIGIN, reconcileTargetIntoLoro, projectTarget, targetFromEditorState, } from './agent-write.js';
|
|
69
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,EACL,cAAc,EACd,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,cAAc,EACd,eAAe,EACf,QAAQ,EACR,SAAS,EACT,gBAAgB,EAChB,OAAO,EACP,OAAO,EACP,kBAAkB,EAClB,eAAe,EACf,gBAAgB,EAChB,WAAW,EACX,eAAe,EACf,UAAU,EACV,WAAW,EACX,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,eAAe,EACf,kBAAkB,EAClB,WAAW,EACX,eAAe,GAchB,MAAM,aAAa,CAAA;AAEpB,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAE/F,OAAO,EAAE,gBAAgB,EAAyC,MAAM,cAAc,CAAA;AAEtF,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,iBAAiB,EACjB,SAAS,EACT,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,QAAQ,EACR,aAAa,EACb,aAAa,EACb,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,kBAAkB,EAClB,QAAQ,EACR,aAAa,GAMd,MAAM,WAAW,CAAA;AAElB,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,4BAA4B,GAG7B,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,GAElB,MAAM,iBAAiB,CAAA;AAExB,OAAO,EACL,iBAAiB,EACjB,qBAAqB,GAGtB,MAAM,WAAW,CAAA;AAElB,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,gBAAgB,GAEjB,MAAM,WAAW,CAAA;AAElB,OAAO,EAAE,UAAU,EAA0C,MAAM,cAAc,CAAA;AAEjF;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EACL,kBAAkB,EAClB,uBAAuB,EACvB,aAAa,EACb,qBAAqB,GAItB,MAAM,kBAAkB,CAAA"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ContainerID ↔ NodeKey registry — the core invariant of this package.
|
|
3
|
+
*
|
|
4
|
+
* ── Why it exists ──────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Lexical's `NodeKey` is a bare per-session counter
|
|
7
|
+
* (`LexicalUtils.ts`: `return '' + keyCounter++`). It is meaningless to a peer
|
|
8
|
+
* and unstable across reloads. Loro's `ContainerID` is the stable, replicated
|
|
9
|
+
* address. Every sync operation is therefore a translation between the two, and
|
|
10
|
+
* this registry is the only place that translation is allowed to happen.
|
|
11
|
+
*
|
|
12
|
+
* There is also NO stable node-object identity to key off: Lexical CLONES a node
|
|
13
|
+
* on every write, so `node === node` fails across an update. The NodeKey string
|
|
14
|
+
* survives cloning; the object does not. Hence a key↔id map, never a WeakMap.
|
|
15
|
+
*
|
|
16
|
+
* ── THE INVARIANT ──────────────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* The two internal maps are always exact inverses of each other: a BIJECTION.
|
|
19
|
+
*
|
|
20
|
+
* byContainer.get(id) === key ⟺ byNode.get(key) === id
|
|
21
|
+
*
|
|
22
|
+
* No ContainerID ever addresses two NodeKeys, and no NodeKey ever addresses two
|
|
23
|
+
* ContainerIDs. `link()` enforces this by evicting BOTH stale directions before
|
|
24
|
+
* writing, so a re-link can never leave a half-entry behind. A violated
|
|
25
|
+
* bijection is the failure mode that silently corrupts a document: a stale
|
|
26
|
+
* reverse entry makes an inbound remote edit land on the wrong Lexical node.
|
|
27
|
+
*
|
|
28
|
+
* ── When it must be updated ────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* - local create/delete — the user edited; mirror the change outbound
|
|
31
|
+
* - remote create/delete — an inbound event created/removed a container
|
|
32
|
+
* - TEXT NORMALIZATION — Lexical silently merges/splits adjacent TextNodes
|
|
33
|
+
* and reports it via `normalizedNodes`. This is the easiest one to forget and
|
|
34
|
+
* the one that corrupts the document: the surviving node keeps ONE key and the
|
|
35
|
+
* others are discarded, so their entries must be re-linked or dropped. This is
|
|
36
|
+
* also why inbound application runs `discrete: true` — without a synchronous
|
|
37
|
+
* flush the normalization happens after we have already read back keys, and
|
|
38
|
+
* the mapping drifts.
|
|
39
|
+
*
|
|
40
|
+
* Entries are never implicitly expired. Use `sweep()` after a structural change
|
|
41
|
+
* to drop entries whose container or node no longer exists.
|
|
42
|
+
*/
|
|
43
|
+
import type { ContainerID } from 'loro-crdt';
|
|
44
|
+
import type { NodeKey } from 'lexical';
|
|
45
|
+
/** One direction of a mapping entry, as reported by iteration and sweeps. */
|
|
46
|
+
export interface MappingEntry {
|
|
47
|
+
readonly id: ContainerID;
|
|
48
|
+
readonly key: NodeKey;
|
|
49
|
+
}
|
|
50
|
+
/** Liveness probes used by {@link ContainerNodeMap.sweep}. */
|
|
51
|
+
export interface LivenessProbe {
|
|
52
|
+
/** True when the container still exists in the Loro document. */
|
|
53
|
+
readonly hasContainer: (id: ContainerID) => boolean;
|
|
54
|
+
/** True when the node still exists in the Lexical editor state. */
|
|
55
|
+
readonly hasNode: (key: NodeKey) => boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A bijective, explicitly-invalidated registry mapping Loro `ContainerID`s to
|
|
59
|
+
* Lexical `NodeKey`s. See the file header for the invariant it maintains.
|
|
60
|
+
*/
|
|
61
|
+
export declare class ContainerNodeMap {
|
|
62
|
+
#private;
|
|
63
|
+
/** Number of live entries. Both directions always agree on this. */
|
|
64
|
+
get size(): number;
|
|
65
|
+
/**
|
|
66
|
+
* Link a container to a node, replacing any previous link on EITHER side.
|
|
67
|
+
*
|
|
68
|
+
* Evicting both directions first is what preserves the bijection: linking
|
|
69
|
+
* `id → k2` when `id → k1` existed must also drop `k1 → id`, or `k1` keeps
|
|
70
|
+
* resolving to a container it no longer owns.
|
|
71
|
+
*/
|
|
72
|
+
link(id: ContainerID, key: NodeKey): void;
|
|
73
|
+
/** The NodeKey addressed by a container, or `undefined` if unmapped. */
|
|
74
|
+
nodeKey(id: ContainerID): NodeKey | undefined;
|
|
75
|
+
/** The ContainerID addressing a node, or `undefined` if unmapped. */
|
|
76
|
+
containerId(key: NodeKey): ContainerID | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* The NodeKey addressed by a container. Throws when unmapped.
|
|
79
|
+
*
|
|
80
|
+
* Prefer this on paths where a missing entry means the registry is already
|
|
81
|
+
* corrupt — failing loudly beats writing to the wrong node.
|
|
82
|
+
*/
|
|
83
|
+
expectNodeKey(id: ContainerID): NodeKey;
|
|
84
|
+
/** The ContainerID addressing a node. Throws when unmapped. */
|
|
85
|
+
expectContainerId(key: NodeKey): ContainerID;
|
|
86
|
+
hasContainer(id: ContainerID): boolean;
|
|
87
|
+
hasNode(key: NodeKey): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Move a container's link to a different NodeKey, keeping the bijection.
|
|
90
|
+
*
|
|
91
|
+
* This is the TEXT-NORMALIZATION path: Lexical merged our run's node into a
|
|
92
|
+
* sibling, so the container must now address the survivor. Returns false when
|
|
93
|
+
* the container was not mapped (nothing to move).
|
|
94
|
+
*/
|
|
95
|
+
rekey(id: ContainerID, key: NodeKey): boolean;
|
|
96
|
+
/** Drop the entry for a container. Returns whether one existed. */
|
|
97
|
+
unlinkContainer(id: ContainerID): boolean;
|
|
98
|
+
/** Drop the entry for a node. Returns whether one existed. */
|
|
99
|
+
unlinkNode(key: NodeKey): boolean;
|
|
100
|
+
/** Every live entry, in insertion order. */
|
|
101
|
+
entries(): MappingEntry[];
|
|
102
|
+
/**
|
|
103
|
+
* Drop every entry whose container or node no longer exists, and return the
|
|
104
|
+
* entries removed.
|
|
105
|
+
*
|
|
106
|
+
* Deleting a subtree removes many containers at once; rather than making every
|
|
107
|
+
* caller walk the removed subtree, sweep after the structural change. An entry
|
|
108
|
+
* is stale if EITHER side is gone — a half-live entry is exactly the corrupt
|
|
109
|
+
* state the invariant forbids.
|
|
110
|
+
*/
|
|
111
|
+
sweep(probe: LivenessProbe): MappingEntry[];
|
|
112
|
+
/** Drop every entry. Used when the document is re-seeded from scratch. */
|
|
113
|
+
clear(): void;
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=mapping.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mapping.d.ts","sourceRoot":"","sources":["../src/mapping.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAC5C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AAEtC,6EAA6E;AAC7E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAA;IACxB,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAA;CACtB;AAED,8DAA8D;AAC9D,MAAM,WAAW,aAAa;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,YAAY,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAA;IACnD,mEAAmE;IACnE,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAA;CAC5C;AAED;;;GAGG;AACH,qBAAa,gBAAgB;;IAI3B,oEAAoE;IACpE,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,OAAO,GAAG,IAAI;IASzC,wEAAwE;IACxE,OAAO,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,GAAG,SAAS;IAI7C,qEAAqE;IACrE,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,WAAW,GAAG,SAAS;IAIlD;;;;;OAKG;IACH,aAAa,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO;IAMvC,+DAA+D;IAC/D,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,WAAW;IAM5C,YAAY,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO;IAItC,OAAO,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO;IAI9B;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO;IAM7C,mEAAmE;IACnE,eAAe,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO;IAQzC,8DAA8D;IAC9D,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO;IAQjC,4CAA4C;IAC5C,OAAO,IAAI,YAAY,EAAE;IAIzB;;;;;;;;OAQG;IACH,KAAK,CAAC,KAAK,EAAE,aAAa,GAAG,YAAY,EAAE;IAU3C,0EAA0E;IAC1E,KAAK,IAAI,IAAI;CAuBd"}
|
package/dist/mapping.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ContainerID ↔ NodeKey registry — the core invariant of this package.
|
|
3
|
+
*
|
|
4
|
+
* ── Why it exists ──────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Lexical's `NodeKey` is a bare per-session counter
|
|
7
|
+
* (`LexicalUtils.ts`: `return '' + keyCounter++`). It is meaningless to a peer
|
|
8
|
+
* and unstable across reloads. Loro's `ContainerID` is the stable, replicated
|
|
9
|
+
* address. Every sync operation is therefore a translation between the two, and
|
|
10
|
+
* this registry is the only place that translation is allowed to happen.
|
|
11
|
+
*
|
|
12
|
+
* There is also NO stable node-object identity to key off: Lexical CLONES a node
|
|
13
|
+
* on every write, so `node === node` fails across an update. The NodeKey string
|
|
14
|
+
* survives cloning; the object does not. Hence a key↔id map, never a WeakMap.
|
|
15
|
+
*
|
|
16
|
+
* ── THE INVARIANT ──────────────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* The two internal maps are always exact inverses of each other: a BIJECTION.
|
|
19
|
+
*
|
|
20
|
+
* byContainer.get(id) === key ⟺ byNode.get(key) === id
|
|
21
|
+
*
|
|
22
|
+
* No ContainerID ever addresses two NodeKeys, and no NodeKey ever addresses two
|
|
23
|
+
* ContainerIDs. `link()` enforces this by evicting BOTH stale directions before
|
|
24
|
+
* writing, so a re-link can never leave a half-entry behind. A violated
|
|
25
|
+
* bijection is the failure mode that silently corrupts a document: a stale
|
|
26
|
+
* reverse entry makes an inbound remote edit land on the wrong Lexical node.
|
|
27
|
+
*
|
|
28
|
+
* ── When it must be updated ────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* - local create/delete — the user edited; mirror the change outbound
|
|
31
|
+
* - remote create/delete — an inbound event created/removed a container
|
|
32
|
+
* - TEXT NORMALIZATION — Lexical silently merges/splits adjacent TextNodes
|
|
33
|
+
* and reports it via `normalizedNodes`. This is the easiest one to forget and
|
|
34
|
+
* the one that corrupts the document: the surviving node keeps ONE key and the
|
|
35
|
+
* others are discarded, so their entries must be re-linked or dropped. This is
|
|
36
|
+
* also why inbound application runs `discrete: true` — without a synchronous
|
|
37
|
+
* flush the normalization happens after we have already read back keys, and
|
|
38
|
+
* the mapping drifts.
|
|
39
|
+
*
|
|
40
|
+
* Entries are never implicitly expired. Use `sweep()` after a structural change
|
|
41
|
+
* to drop entries whose container or node no longer exists.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* A bijective, explicitly-invalidated registry mapping Loro `ContainerID`s to
|
|
45
|
+
* Lexical `NodeKey`s. See the file header for the invariant it maintains.
|
|
46
|
+
*/
|
|
47
|
+
export class ContainerNodeMap {
|
|
48
|
+
#byContainer = new Map();
|
|
49
|
+
#byNode = new Map();
|
|
50
|
+
/** Number of live entries. Both directions always agree on this. */
|
|
51
|
+
get size() {
|
|
52
|
+
return this.#byContainer.size;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Link a container to a node, replacing any previous link on EITHER side.
|
|
56
|
+
*
|
|
57
|
+
* Evicting both directions first is what preserves the bijection: linking
|
|
58
|
+
* `id → k2` when `id → k1` existed must also drop `k1 → id`, or `k1` keeps
|
|
59
|
+
* resolving to a container it no longer owns.
|
|
60
|
+
*/
|
|
61
|
+
link(id, key) {
|
|
62
|
+
const previousKey = this.#byContainer.get(id);
|
|
63
|
+
if (previousKey !== undefined && previousKey !== key)
|
|
64
|
+
this.#byNode.delete(previousKey);
|
|
65
|
+
const previousId = this.#byNode.get(key);
|
|
66
|
+
if (previousId !== undefined && previousId !== id)
|
|
67
|
+
this.#byContainer.delete(previousId);
|
|
68
|
+
this.#byContainer.set(id, key);
|
|
69
|
+
this.#byNode.set(key, id);
|
|
70
|
+
}
|
|
71
|
+
/** The NodeKey addressed by a container, or `undefined` if unmapped. */
|
|
72
|
+
nodeKey(id) {
|
|
73
|
+
return this.#byContainer.get(id);
|
|
74
|
+
}
|
|
75
|
+
/** The ContainerID addressing a node, or `undefined` if unmapped. */
|
|
76
|
+
containerId(key) {
|
|
77
|
+
return this.#byNode.get(key);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The NodeKey addressed by a container. Throws when unmapped.
|
|
81
|
+
*
|
|
82
|
+
* Prefer this on paths where a missing entry means the registry is already
|
|
83
|
+
* corrupt — failing loudly beats writing to the wrong node.
|
|
84
|
+
*/
|
|
85
|
+
expectNodeKey(id) {
|
|
86
|
+
const key = this.#byContainer.get(id);
|
|
87
|
+
if (key === undefined)
|
|
88
|
+
throw new Error(`lexical-loro: no NodeKey mapped for container ${id}`);
|
|
89
|
+
return key;
|
|
90
|
+
}
|
|
91
|
+
/** The ContainerID addressing a node. Throws when unmapped. */
|
|
92
|
+
expectContainerId(key) {
|
|
93
|
+
const id = this.#byNode.get(key);
|
|
94
|
+
if (id === undefined)
|
|
95
|
+
throw new Error(`lexical-loro: no ContainerID mapped for node ${key}`);
|
|
96
|
+
return id;
|
|
97
|
+
}
|
|
98
|
+
hasContainer(id) {
|
|
99
|
+
return this.#byContainer.has(id);
|
|
100
|
+
}
|
|
101
|
+
hasNode(key) {
|
|
102
|
+
return this.#byNode.has(key);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Move a container's link to a different NodeKey, keeping the bijection.
|
|
106
|
+
*
|
|
107
|
+
* This is the TEXT-NORMALIZATION path: Lexical merged our run's node into a
|
|
108
|
+
* sibling, so the container must now address the survivor. Returns false when
|
|
109
|
+
* the container was not mapped (nothing to move).
|
|
110
|
+
*/
|
|
111
|
+
rekey(id, key) {
|
|
112
|
+
if (!this.#byContainer.has(id))
|
|
113
|
+
return false;
|
|
114
|
+
this.link(id, key);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
/** Drop the entry for a container. Returns whether one existed. */
|
|
118
|
+
unlinkContainer(id) {
|
|
119
|
+
const key = this.#byContainer.get(id);
|
|
120
|
+
if (key === undefined)
|
|
121
|
+
return false;
|
|
122
|
+
this.#byContainer.delete(id);
|
|
123
|
+
this.#byNode.delete(key);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
/** Drop the entry for a node. Returns whether one existed. */
|
|
127
|
+
unlinkNode(key) {
|
|
128
|
+
const id = this.#byNode.get(key);
|
|
129
|
+
if (id === undefined)
|
|
130
|
+
return false;
|
|
131
|
+
this.#byNode.delete(key);
|
|
132
|
+
this.#byContainer.delete(id);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
/** Every live entry, in insertion order. */
|
|
136
|
+
entries() {
|
|
137
|
+
return [...this.#byContainer].map(([id, key]) => ({ id, key }));
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Drop every entry whose container or node no longer exists, and return the
|
|
141
|
+
* entries removed.
|
|
142
|
+
*
|
|
143
|
+
* Deleting a subtree removes many containers at once; rather than making every
|
|
144
|
+
* caller walk the removed subtree, sweep after the structural change. An entry
|
|
145
|
+
* is stale if EITHER side is gone — a half-live entry is exactly the corrupt
|
|
146
|
+
* state the invariant forbids.
|
|
147
|
+
*/
|
|
148
|
+
sweep(probe) {
|
|
149
|
+
const removed = [];
|
|
150
|
+
for (const [id, key] of this.#byContainer) {
|
|
151
|
+
if (probe.hasContainer(id) && probe.hasNode(key))
|
|
152
|
+
continue;
|
|
153
|
+
removed.push({ id, key });
|
|
154
|
+
}
|
|
155
|
+
for (const { id } of removed)
|
|
156
|
+
this.unlinkContainer(id);
|
|
157
|
+
return removed;
|
|
158
|
+
}
|
|
159
|
+
/** Drop every entry. Used when the document is re-seeded from scratch. */
|
|
160
|
+
clear() {
|
|
161
|
+
this.#byContainer.clear();
|
|
162
|
+
this.#byNode.clear();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Assert the bijection holds. A debugging/test aid: production paths maintain
|
|
166
|
+
* it structurally, so a failure here means a bug in this class, not a caller.
|
|
167
|
+
*
|
|
168
|
+
* @internal
|
|
169
|
+
*/
|
|
170
|
+
assertBijective() {
|
|
171
|
+
if (this.#byContainer.size !== this.#byNode.size) {
|
|
172
|
+
throw new Error(`lexical-loro: mapping is not bijective — ${this.#byContainer.size} containers vs ${this.#byNode.size} nodes`);
|
|
173
|
+
}
|
|
174
|
+
for (const [id, key] of this.#byContainer) {
|
|
175
|
+
if (this.#byNode.get(key) !== id) {
|
|
176
|
+
throw new Error(`lexical-loro: mapping is not bijective at ${id} ↔ ${key}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=mapping.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mapping.js","sourceRoot":"","sources":["../src/mapping.ts"],"names":[],"mappings":"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"}
|