@dojo-ng/rich-text-criticmarkup 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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Resolution (Track T1/T2): `markAtSelection`, `acceptMark`/`declineMark` (one mark), and
3
+ * `acceptAllMarks`/`declineAllMarks` (the whole document). Decision 10's rules, applied directly to
4
+ * the live tree rather than round-tripping through markdown — the two paths are cross-checked in the
5
+ * test file against `grammar.accept`/`decline` on the same starting `value`, which is the point of
6
+ * having one grammar.
7
+ *
8
+ * A substitution is never resolved as a lone deletion or insertion: `acceptMark`/`declineMark` on
9
+ * EITHER half of an adjacent deletion-then-insertion pair (decision 4's shape) resolves the whole
10
+ * pair. A kept insertion/deletion holding a `BreakNode` (decision 16 — a proposed paragraph
11
+ * split/merge) performs the real block split/merge at that point when kept, mirroring exactly what
12
+ * `grammar.accept`/`decline` do to the paragraph token at the string level.
13
+ *
14
+ * No confirmation here — `dj-popup-confirmation` gating `confirmBulk` is a toolbar-level concern
15
+ * (Track T3), not baked into these functions themselves.
16
+ */
17
+ import { $createParagraphNode, $getNodeByKey, $getRoot, $getSelection, $isElementNode, $isRangeSelection, $isRootOrShadowRoot } from "lexical";
18
+ import { $isBreakNode, $isCommentNode, $isCriticMark, $isDeletionNode, $isHighlightNode, $isInsertionNode, } from "./nodes.js";
19
+ import { SKIP_TAG } from "./suggestion-mode.js";
20
+ /** The nearest CriticMarkup mark (any of the four kinds) containing the selection's anchor, or
21
+ * `null` if the caret sits in plain prose or there is no range selection. */
22
+ export function markAtSelection(editor) {
23
+ let result = null;
24
+ editor.getEditorState().read(() => {
25
+ const selection = $getSelection();
26
+ if (!$isRangeSelection(selection))
27
+ return;
28
+ let node = selection.anchor.getNode();
29
+ while (node) {
30
+ if ($isCriticMark(node)) {
31
+ result = node;
32
+ return;
33
+ }
34
+ node = node.getParent();
35
+ }
36
+ });
37
+ return result;
38
+ }
39
+ export function acceptMark(editor, node) {
40
+ resolveMark(editor, node, "new");
41
+ }
42
+ export function declineMark(editor, node) {
43
+ resolveMark(editor, node, "old");
44
+ }
45
+ export function acceptAllMarks(editor) {
46
+ resolveAllMarks(editor, "new");
47
+ }
48
+ export function declineAllMarks(editor) {
49
+ resolveAllMarks(editor, "old");
50
+ }
51
+ function resolveMark(editor, node, side) {
52
+ editor.update(() => {
53
+ const fresh = $getNodeByKey(node.getKey());
54
+ if (!fresh || !$isCriticMark(fresh))
55
+ return;
56
+ resolveOne(editor, fresh, side);
57
+ },
58
+ // `discrete: true` so a toolbar click (or a caller reading `value` right after) sees this
59
+ // applied synchronously, not silently batched to a later microtask. Tagged with SKIP_TAG so
60
+ // suggestion mode's own diff-and-wrap listener, if still on, does not see the flattened text
61
+ // this resolution just changed (an accepted deletion, a declined insertion, a split/merge just
62
+ // made real) and re-wrap it right back up as a brand-new suggestion.
63
+ { discrete: true, tag: SKIP_TAG });
64
+ }
65
+ function resolveAllMarks(editor, side) {
66
+ editor.update(() => {
67
+ const marks = collectMarks($getRoot());
68
+ const resolved = new Set();
69
+ for (const node of marks) {
70
+ if (resolved.has(node.getKey()))
71
+ continue;
72
+ resolveOne(editor, node, side, resolved);
73
+ }
74
+ }, { discrete: true, tag: SKIP_TAG });
75
+ }
76
+ /** Resolves `node` (and, if it is half of a substitution pair, its partner too), then emits
77
+ * `dj-criticmarkup-change`. `resolved` (bulk resolution only) records both halves of a pair so the
78
+ * bulk walk's own snapshot does not try to resolve the second half a second time. */
79
+ function resolveOne(editor, node, side, resolved) {
80
+ const pair = substitutionPairOf(node);
81
+ if (pair) {
82
+ resolved?.add(pair.deletion.getKey());
83
+ resolved?.add(pair.insertion.getKey());
84
+ if (side === "new") {
85
+ pair.deletion.remove();
86
+ unwrapAndResolveBreaks(pair.insertion);
87
+ }
88
+ else {
89
+ pair.insertion.remove();
90
+ unwrapAndResolveBreaks(pair.deletion);
91
+ }
92
+ dispatchChange(editor, "substitution", side === "new" ? "accept" : "decline");
93
+ return;
94
+ }
95
+ if ($isCommentNode(node))
96
+ return; // decision 10: a bare comment resolves neither way
97
+ if (!$isInsertionNode(node) && !$isDeletionNode(node) && !$isHighlightNode(node))
98
+ return; // exhaustive; unreachable
99
+ const kind = kindOf(node);
100
+ if ($isHighlightNode(node)) {
101
+ // Kept either way — its anchored comment, if any, is state on the node, not a sibling, so it
102
+ // simply goes with it. No BreakNode ever lives inside a highlight (decision, N1).
103
+ unwrap(node);
104
+ }
105
+ else {
106
+ const keep = $isInsertionNode(node) ? side === "new" : side === "old";
107
+ if (keep)
108
+ unwrapAndResolveBreaks(node);
109
+ else
110
+ node.remove();
111
+ }
112
+ dispatchChange(editor, kind, side === "new" ? "accept" : "decline");
113
+ }
114
+ function kindOf(node) {
115
+ if ($isInsertionNode(node))
116
+ return "insertion";
117
+ if ($isDeletionNode(node))
118
+ return "deletion";
119
+ if ($isHighlightNode(node))
120
+ return "highlight";
121
+ return "comment";
122
+ }
123
+ /** `node` is one half of a substitution (decision 4's shape: a `DeletionNode` immediately followed
124
+ * by an `InsertionNode`) if it is either half of such an adjacent pair. */
125
+ function substitutionPairOf(node) {
126
+ if ($isDeletionNode(node)) {
127
+ const next = node.getNextSibling();
128
+ if ($isInsertionNode(next))
129
+ return { deletion: node, insertion: next };
130
+ }
131
+ if ($isInsertionNode(node)) {
132
+ const prev = node.getPreviousSibling();
133
+ if ($isDeletionNode(prev))
134
+ return { deletion: prev, insertion: node };
135
+ }
136
+ return null;
137
+ }
138
+ /** Replace `node` with its own children, in place — the standard Lexical "unlink" shape
139
+ * (`@lexical/link`'s own `$toggleLink` does the same to remove a `LinkNode`). Returns the children,
140
+ * now live at `node`'s old position, for the caller to inspect. */
141
+ function unwrap(node) {
142
+ const children = node.getChildren();
143
+ for (const child of children)
144
+ node.insertBefore(child);
145
+ node.remove();
146
+ return children;
147
+ }
148
+ /** Unwrap `node`, then perform the real block split/merge for any `BreakNode` among its now-freed
149
+ * children — a kept paragraph-break proposal (decision 16) stops being a decorator and becomes an
150
+ * actual block boundary, mirroring what `grammar.accept`/`decline` do to the token at the string
151
+ * level. */
152
+ function unwrapAndResolveBreaks(node) {
153
+ const children = unwrap(node);
154
+ for (const child of children) {
155
+ if ($isBreakNode(child))
156
+ splitBlockAtBreak(child);
157
+ }
158
+ }
159
+ function splitBlockAtBreak(breakNode) {
160
+ const block = topLevelBlockOf(breakNode);
161
+ if (!block) {
162
+ breakNode.remove();
163
+ return;
164
+ }
165
+ const newBlock = $createParagraphNode();
166
+ let sibling = breakNode.getNextSibling();
167
+ while (sibling) {
168
+ const next = sibling.getNextSibling();
169
+ newBlock.append(sibling);
170
+ sibling = next;
171
+ }
172
+ block.insertAfter(newBlock);
173
+ breakNode.remove();
174
+ }
175
+ function topLevelBlockOf(node) {
176
+ let current = node;
177
+ while (current) {
178
+ const parent = current.getParent();
179
+ if (parent === null)
180
+ return null;
181
+ if ($isRootOrShadowRoot(parent))
182
+ return $isElementNode(current) ? current : null;
183
+ current = parent;
184
+ }
185
+ return null;
186
+ }
187
+ /** Every CriticMarkup mark in `root`, in document order. Marks never nest as real nodes (decision
188
+ * 11), so finding one ends that branch of the walk. */
189
+ function collectMarks(root) {
190
+ const marks = [];
191
+ const walk = (node) => {
192
+ if ($isCriticMark(node)) {
193
+ marks.push(node);
194
+ return;
195
+ }
196
+ if ($isElementNode(node))
197
+ for (const child of node.getChildren())
198
+ walk(child);
199
+ };
200
+ for (const child of root.getChildren())
201
+ walk(child);
202
+ return marks;
203
+ }
204
+ function dispatchChange(editor, kind, action) {
205
+ const root = editor.getRootElement();
206
+ root?.dispatchEvent(new CustomEvent("dj-criticmarkup-change", { detail: { kind, action }, bubbles: true, composed: true }));
207
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Suggestion mode (Track S): per-editor state, a block-diff listener that wraps typed/deleted text
3
+ * as marks, and structural-edit handling for a paragraph split or merge (decisions 14, 16).
4
+ *
5
+ * `setSuggestionMode`/`isSuggestionMode` are the frozen, editor-only API. `configureSuggestionMode`
6
+ * is this track's own addition — the frozen surface has no way to pass `structuralEdits` into a bare
7
+ * `(editor, enabled)` call, and the plugin-level `CriticMarkupOptions.structuralEdits` has to reach
8
+ * this module somehow. Whichever track assembles the full plugin object calls it once from `setup()`
9
+ * with the resolved option; `setSuggestionMode` alone still works standalone, registering with the
10
+ * `"mark"` default the first time it is called.
11
+ */
12
+ import { type LexicalEditor } from "lexical";
13
+ /** What suggestion mode does with a block-structure change (decisions 14, 16). */
14
+ export type StructuralPolicy = "mark" | "annotate" | "block" | "apply";
15
+ export interface SuggestionModeOptions {
16
+ /** Default `"mark"`. */
17
+ structuralEdits?: StructuralPolicy;
18
+ }
19
+ /** The update tag the diff-and-wrap listener skips (S1/S3's own structural edits use it on
20
+ * themselves already). Exported so `resolution.ts` can tag its own mutations the same way — a mark
21
+ * resolution changes a block's flattened text too (an accepted deletion, a declined insertion, a
22
+ * split/merge just made real), and without this tag the listener re-diffs that change and wraps it
23
+ * right back up as a brand-new suggestion, undoing the resolution it was just asked to perform. */
24
+ export declare const SKIP_TAG = "dj-criticmarkup-suggestion";
25
+ export declare function isSuggestionMode(editor: LexicalEditor): boolean;
26
+ /** Turn suggestion mode on or off. Registers the listener and command handlers once, lazily. */
27
+ export declare function setSuggestionMode(editor: LexicalEditor, enabled: boolean): void;
28
+ /** Set `structuralEdits` before (or after) `setSuggestionMode` is ever called on `editor`. */
29
+ export declare function configureSuggestionMode(editor: LexicalEditor, options: SuggestionModeOptions): void;
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Suggestion mode (Track S): per-editor state, a block-diff listener that wraps typed/deleted text
3
+ * as marks, and structural-edit handling for a paragraph split or merge (decisions 14, 16).
4
+ *
5
+ * `setSuggestionMode`/`isSuggestionMode` are the frozen, editor-only API. `configureSuggestionMode`
6
+ * is this track's own addition — the frozen surface has no way to pass `structuralEdits` into a bare
7
+ * `(editor, enabled)` call, and the plugin-level `CriticMarkupOptions.structuralEdits` has to reach
8
+ * this module somehow. Whichever track assembles the full plugin object calls it once from `setup()`
9
+ * with the resolved option; `setSuggestionMode` alone still works standalone, registering with the
10
+ * `"mark"` default the first time it is called.
11
+ */
12
+ import { $addUpdateTag, $createTextNode, $getNodeByKey, $getSelection, $isElementNode, $isRangeSelection, $isRootOrShadowRoot, $isTextNode, COMMAND_PRIORITY_CRITICAL, DELETE_CHARACTER_COMMAND, INSERT_PARAGRAPH_COMMAND, } from "lexical";
13
+ import { getDefaultLocale, messages, registerDefaults } from "@dojo-ng/i18n";
14
+ import { $createBreakNode, $createCommentNode, $createDeletionNode, $createInsertionNode, $isInsertionNode, } from "./nodes.js";
15
+ const EN = {
16
+ structuralSplit: "paragraph split, not tracked",
17
+ structuralMerge: "paragraph merge, not tracked",
18
+ };
19
+ registerDefaults("dj", EN);
20
+ function msg(editor, key) {
21
+ const locale = editor.getRootElement()?.closest("[lang]")?.getAttribute("lang") || getDefaultLocale();
22
+ return messages.resolve("dj", locale, key) ?? EN[key] ?? key;
23
+ }
24
+ const STATE = new WeakMap();
25
+ /** The update tag the diff-and-wrap listener skips (S1/S3's own structural edits use it on
26
+ * themselves already). Exported so `resolution.ts` can tag its own mutations the same way — a mark
27
+ * resolution changes a block's flattened text too (an accepted deletion, a declined insertion, a
28
+ * split/merge just made real), and without this tag the listener re-diffs that change and wraps it
29
+ * right back up as a brand-new suggestion, undoing the resolution it was just asked to perform. */
30
+ export const SKIP_TAG = "dj-criticmarkup-suggestion";
31
+ function ensureState(editor) {
32
+ let state = STATE.get(editor);
33
+ if (!state) {
34
+ state = { enabled: false, structuralEdits: "mark", registered: false };
35
+ STATE.set(editor, state);
36
+ }
37
+ return state;
38
+ }
39
+ export function isSuggestionMode(editor) {
40
+ return STATE.get(editor)?.enabled ?? false;
41
+ }
42
+ /** Turn suggestion mode on or off. Registers the listener and command handlers once, lazily. */
43
+ export function setSuggestionMode(editor, enabled) {
44
+ const state = ensureState(editor);
45
+ state.enabled = enabled;
46
+ if (!state.registered) {
47
+ state.registered = true;
48
+ registerSuggestionMode(editor, state);
49
+ }
50
+ }
51
+ /** Set `structuralEdits` before (or after) `setSuggestionMode` is ever called on `editor`. */
52
+ export function configureSuggestionMode(editor, options) {
53
+ const state = ensureState(editor);
54
+ if (options.structuralEdits !== undefined)
55
+ state.structuralEdits = options.structuralEdits;
56
+ if (!state.registered) {
57
+ state.registered = true;
58
+ registerSuggestionMode(editor, state);
59
+ }
60
+ }
61
+ function dispatchStructural(editor, operation, policy) {
62
+ const root = editor.getRootElement();
63
+ root?.dispatchEvent(new CustomEvent("dj-criticmarkup-structural", { detail: { operation, policy }, bubbles: true, composed: true }));
64
+ }
65
+ function registerSuggestionMode(editor, state) {
66
+ const disposers = [
67
+ editor.registerCommand(INSERT_PARAGRAPH_COMMAND, () => handleInsertParagraph(editor, state), COMMAND_PRIORITY_CRITICAL),
68
+ editor.registerCommand(DELETE_CHARACTER_COMMAND, (isBackward) => handleDeleteCharacter(editor, state, isBackward), COMMAND_PRIORITY_CRITICAL),
69
+ editor.registerUpdateListener(({ editorState, prevEditorState, dirtyElements, dirtyLeaves, tags }) => {
70
+ if (!state.enabled)
71
+ return;
72
+ if (tags.has(SKIP_TAG))
73
+ return;
74
+ if (dirtyElements.size === 0 && dirtyLeaves.size === 0)
75
+ return;
76
+ // `dirtyElements` includes "root" itself whenever any child changed — root is not a
77
+ // "block" (its flattened text joins paragraphs with "\n\n", not prose), and diffing it
78
+ // as one corrupts the very first paragraph. Only genuine top-level blocks (root's direct
79
+ // children) are ever diffed or checked for a structural change.
80
+ const keys = topLevelDirtyKeys(prevEditorState, editorState, dirtyElements);
81
+ if (keys.length === 0)
82
+ return;
83
+ const change = findStructuralChange(prevEditorState, editorState, keys);
84
+ if (change) {
85
+ handleStructuralGuard(editor, state, change);
86
+ return; // suppress the per-block diff entirely for this update
87
+ }
88
+ for (const key of keys)
89
+ diffBlock(editor, key, prevEditorState, editorState);
90
+ }),
91
+ ];
92
+ return () => disposers.forEach((d) => d());
93
+ }
94
+ // --- S1: the per-block diff and marking -----------------------------------------------------------
95
+ function diffBlock(editor, key, prevEditorState, editorState) {
96
+ let oldText = null;
97
+ let newText = null;
98
+ prevEditorState.read(() => {
99
+ const node = $getNodeByKey(key);
100
+ if ($isElementNode(node))
101
+ oldText = node.getTextContent();
102
+ });
103
+ editorState.read(() => {
104
+ const node = $getNodeByKey(key);
105
+ if ($isElementNode(node))
106
+ newText = node.getTextContent();
107
+ });
108
+ if (oldText === null || newText === null || oldText === newText)
109
+ return;
110
+ const { prefix, removed, inserted } = diffStrings(oldText, newText);
111
+ if (!removed && !inserted)
112
+ return;
113
+ // Deleting from inside an insertion is the author withdrawing their own unaccepted suggestion,
114
+ // not proposing a deletion of it — Lexical already shrank (or, via canBeEmpty()=false, removed)
115
+ // the insertion; nothing more to mark. Checked against the OLD tree, since the removed text is
116
+ // already gone from the new one.
117
+ let removedWasInsertion = false;
118
+ if (removed) {
119
+ prevEditorState.read(() => {
120
+ const node = $getNodeByKey(key);
121
+ if (!$isElementNode(node))
122
+ return;
123
+ const loc = locateOffset(node, prefix);
124
+ removedWasInsertion = !!loc && $isInsertionNode(loc.node.getParent());
125
+ });
126
+ }
127
+ editor.update(() => {
128
+ const node = $getNodeByKey(key);
129
+ if (!$isElementNode(node))
130
+ return;
131
+ if (inserted)
132
+ wrapInsertionIfNeeded(node, prefix, inserted);
133
+ if (removed && !removedWasInsertion) {
134
+ const insertAt = prefix + (inserted ? inserted.length : 0);
135
+ insertDeletionAt(node, insertAt, removed);
136
+ }
137
+ }, { tag: SKIP_TAG, discrete: true });
138
+ }
139
+ function diffStrings(oldText, newText) {
140
+ const maxPrefix = Math.min(oldText.length, newText.length);
141
+ let prefix = 0;
142
+ while (prefix < maxPrefix && oldText[prefix] === newText[prefix])
143
+ prefix++;
144
+ const maxSuffix = Math.min(oldText.length - prefix, newText.length - prefix);
145
+ let suffix = 0;
146
+ while (suffix < maxSuffix && oldText[oldText.length - 1 - suffix] === newText[newText.length - 1 - suffix])
147
+ suffix++;
148
+ return {
149
+ prefix,
150
+ removed: oldText.slice(prefix, oldText.length - suffix),
151
+ inserted: newText.slice(prefix, newText.length - suffix),
152
+ };
153
+ }
154
+ /** The (TextNode, local offset) at character `offset` into `block`'s flattened text, recursing into
155
+ * an existing mark's own text children so an edit inside one is still located precisely. */
156
+ function locateOffset(block, offset) {
157
+ const children = block.getChildren();
158
+ let acc = 0;
159
+ for (let i = 0; i < children.length; i++) {
160
+ const child = children[i];
161
+ const len = child.getTextContent().length;
162
+ const isLast = i === children.length - 1;
163
+ // At an EXACT boundary between two children, prefer the START of the FOLLOWING one rather
164
+ // than the end of the one before it — otherwise a position "between two marks" (or between a
165
+ // mark and plain text) resolves backward into whichever mark happens to sit first, which is
166
+ // exactly wrong when that mark is a DeletionNode (canInsertTextAfter() false): newly typed
167
+ // text landing just after it must be located in what follows, not inside it.
168
+ if (offset < acc + len || (isLast && offset === acc + len)) {
169
+ const local = offset - acc;
170
+ if ($isTextNode(child))
171
+ return { node: child, offset: local };
172
+ if ($isElementNode(child))
173
+ return locateOffset(child, local);
174
+ return null; // a decorator (BreakNode/CommentNode): no text position to split
175
+ }
176
+ acc += len;
177
+ }
178
+ return null;
179
+ }
180
+ /** Wrap the just-typed `text` at `offset` in a new `InsertionNode` — unless Lexical already placed
181
+ * it inside an EXISTING one (decision 6's `canInsertTextAfter/Before` doing exactly what it exists
182
+ * for), in which case there is nothing to do. */
183
+ function wrapInsertionIfNeeded(block, offset, text) {
184
+ const loc = locateOffset(block, offset);
185
+ if (!loc)
186
+ return;
187
+ if ($isInsertionNode(loc.node.getParent()))
188
+ return;
189
+ const full = loc.node.getTextContent();
190
+ const end = Math.min(loc.offset + text.length, full.length);
191
+ const expected = full.slice(loc.offset, end);
192
+ // `splitText` OMITS a zero-length piece rather than returning an empty node for it, so when
193
+ // `loc.offset` is 0 (or `end` is the node's own length) the "middle" piece is not reliably at a
194
+ // fixed array position — find it by its own text content instead.
195
+ const pieces = loc.node.splitText(loc.offset, end);
196
+ const target = pieces.find((p) => p.getTextContent() === expected) ?? pieces[0] ?? loc.node;
197
+ const insertionNode = $createInsertionNode();
198
+ target.replace(insertionNode);
199
+ insertionNode.append(target);
200
+ }
201
+ /** Insert a new `DeletionNode` holding the just-removed `text` back at `offset`. */
202
+ function insertDeletionAt(block, offset, text) {
203
+ const deletionNode = $createDeletionNode();
204
+ deletionNode.append($createTextNode(text));
205
+ const loc = locateOffset(block, offset);
206
+ if (!loc) {
207
+ block.append(deletionNode);
208
+ deletionNode.selectEnd();
209
+ return;
210
+ }
211
+ if (loc.offset === 0) {
212
+ loc.node.insertBefore(deletionNode);
213
+ deletionNode.selectNext(0, 0); // S2: the caret lands right after the new deletion, not inside it
214
+ return;
215
+ }
216
+ if (loc.offset >= loc.node.getTextContent().length) {
217
+ loc.node.insertAfter(deletionNode);
218
+ deletionNode.selectNext(0, 0);
219
+ return;
220
+ }
221
+ const [, after] = loc.node.splitText(loc.offset);
222
+ (after ?? loc.node).insertBefore(deletionNode);
223
+ deletionNode.selectNext(0, 0);
224
+ }
225
+ // --- S3: structural edits (paragraph split / merge) ------------------------------------------------
226
+ function topLevelBlockOf(node) {
227
+ let current = node;
228
+ while (current) {
229
+ const parent = current.getParent();
230
+ if (parent === null)
231
+ return null;
232
+ if ($isRootOrShadowRoot(parent))
233
+ return $isElementNode(current) ? current : null;
234
+ current = parent;
235
+ }
236
+ return null;
237
+ }
238
+ /** Whether the collapsed selection `point` sits at the very first character of `block`. */
239
+ function isAtBlockStart(block, point) {
240
+ if (point.type !== "text" || point.offset !== 0)
241
+ return false;
242
+ let n = block;
243
+ while (n && !$isTextNode(n)) {
244
+ if (!$isElementNode(n) || n.getChildrenSize() === 0)
245
+ return false;
246
+ n = n.getFirstChild();
247
+ }
248
+ return n !== null && n.is(point.getNode());
249
+ }
250
+ function handleInsertParagraph(editor, state) {
251
+ if (!state.enabled)
252
+ return false;
253
+ if (state.structuralEdits !== "mark" && state.structuralEdits !== "block")
254
+ return false;
255
+ const selection = $getSelection();
256
+ if (!$isRangeSelection(selection) || !selection.isCollapsed())
257
+ return false;
258
+ if (state.structuralEdits === "block") {
259
+ dispatchStructural(editor, "split", "block");
260
+ return true;
261
+ }
262
+ // "mark": a proposed break, not a real split — the block set never changes, so decision 14's
263
+ // per-block-diff failure mode is unreachable on this path rather than merely guarded against.
264
+ // Tagged so the SAME update does not also get run through the ordinary per-block diff below,
265
+ // which would otherwise see this paragraph's text change and wrap it a second time.
266
+ $addUpdateTag(SKIP_TAG);
267
+ const anchorNode = selection.anchor.getNode();
268
+ const enclosingInsertion = $isTextNode(anchorNode) ? anchorNode.getParent() : null;
269
+ if ($isTextNode(anchorNode) && $isInsertionNode(enclosingInsertion)) {
270
+ // Splitting INSIDE an existing, not-yet-resolved insertion (Bill's harder placement): grow
271
+ // that SAME insertion with a break rather than wrapping a brand-new one around it — using
272
+ // `selection.insertNodes()` here would split the existing insertion in two around the new
273
+ // one, turning one mark into three.
274
+ const offset = selection.anchor.offset;
275
+ const full = anchorNode.getTextContent();
276
+ if (offset === 0) {
277
+ anchorNode.insertBefore($createBreakNode());
278
+ }
279
+ else if (offset >= full.length) {
280
+ anchorNode.insertAfter($createBreakNode());
281
+ }
282
+ else {
283
+ // `splitText` omits a zero-length piece rather than a fixed-position array, so find the
284
+ // "before" piece by its own text rather than assuming index 0.
285
+ const pieces = anchorNode.splitText(offset);
286
+ const before = pieces.find((p) => p.getTextContent() === full.slice(0, offset)) ?? pieces[0];
287
+ before.insertAfter($createBreakNode());
288
+ }
289
+ }
290
+ else {
291
+ const breakNode = $createBreakNode();
292
+ const insertionNode = $createInsertionNode();
293
+ insertionNode.append(breakNode);
294
+ selection.insertNodes([insertionNode]);
295
+ }
296
+ dispatchStructural(editor, "split", "mark");
297
+ return true;
298
+ }
299
+ function handleDeleteCharacter(editor, state, isBackward) {
300
+ if (!state.enabled || !isBackward)
301
+ return false;
302
+ if (state.structuralEdits !== "mark" && state.structuralEdits !== "block")
303
+ return false;
304
+ const selection = $getSelection();
305
+ if (!$isRangeSelection(selection) || !selection.isCollapsed())
306
+ return false;
307
+ const block = topLevelBlockOf(selection.anchor.getNode());
308
+ if (!block || !isAtBlockStart(block, selection.anchor))
309
+ return false;
310
+ const prevBlock = block.getPreviousSibling();
311
+ if (!$isElementNode(prevBlock))
312
+ return false;
313
+ if (state.structuralEdits === "block") {
314
+ dispatchStructural(editor, "merge", "block");
315
+ return true;
316
+ }
317
+ // "mark": join the blocks, with a DeletionNode wrapping a BreakNode where the break was — the
318
+ // merge case does join two blocks into one (the mark is inline, it has to), unlike the split case.
319
+ // Tagged for the same reason as the split path above.
320
+ $addUpdateTag(SKIP_TAG);
321
+ const breakNode = $createBreakNode();
322
+ const deletionNode = $createDeletionNode();
323
+ deletionNode.append(breakNode);
324
+ prevBlock.append(deletionNode);
325
+ const moved = block.getChildren();
326
+ for (const child of moved)
327
+ prevBlock.append(child);
328
+ block.remove();
329
+ deletionNode.selectEnd();
330
+ dispatchStructural(editor, "merge", "mark");
331
+ return true;
332
+ }
333
+ /** `dirtyElements` includes "root" itself whenever any descendant changed, and root is not a block —
334
+ * its flattened text joins paragraphs with `"\n\n"`, not prose, and diffing it as one would corrupt
335
+ * the first paragraph. Keeps only keys whose node is (or, for a just-removed block, WAS) a direct
336
+ * child of root-or-shadow-root, in EITHER state, so a genuinely new or genuinely removed top-level
337
+ * block is still included — only root itself, and any non-top-level dirty element, is dropped. */
338
+ function topLevelDirtyKeys(prevEditorState, editorState, dirtyElements) {
339
+ const keys = [];
340
+ for (const key of dirtyElements.keys()) {
341
+ let isTopLevel = false;
342
+ editorState.read(() => {
343
+ const node = $getNodeByKey(key);
344
+ if (node)
345
+ isTopLevel = $isElementNode(node) && $isRootOrShadowRoot(node.getParent());
346
+ });
347
+ if (!isTopLevel) {
348
+ prevEditorState.read(() => {
349
+ const node = $getNodeByKey(key);
350
+ if (node)
351
+ isTopLevel = $isElementNode(node) && $isRootOrShadowRoot(node.getParent());
352
+ });
353
+ }
354
+ if (isTopLevel)
355
+ keys.push(key);
356
+ }
357
+ return keys;
358
+ }
359
+ /** A dirty block with no previous state, or a previously existing block now gone, is a structural
360
+ * change (decision 14) — the guard for anything that reaches block structure by a route the two
361
+ * command handlers above don't cover (paste, drag, an IME commit). Under normal typed Enter/Backspace
362
+ * with `structuralEdits: "mark"` or `"block"`, this never fires: the command handlers already
363
+ * intercepted before any such update could land. */
364
+ function findStructuralChange(prevEditorState, editorState, keys) {
365
+ let split = false;
366
+ let merged = false;
367
+ for (const key of keys) {
368
+ let inPrev = false;
369
+ let inCurrent = false;
370
+ prevEditorState.read(() => {
371
+ inPrev = $getNodeByKey(key) !== null;
372
+ });
373
+ editorState.read(() => {
374
+ inCurrent = $getNodeByKey(key) !== null;
375
+ });
376
+ if (inPrev && !inCurrent)
377
+ merged = true;
378
+ if (!inPrev && inCurrent)
379
+ split = true;
380
+ }
381
+ if (split && !merged)
382
+ return { operation: "split" };
383
+ if (merged && !split)
384
+ return { operation: "merge" };
385
+ if (split || merged)
386
+ return { operation: "other" };
387
+ return null;
388
+ }
389
+ /** The aftermath for a structural change NOT already fully handled by a command interception —
390
+ * `"mark"`/`"block"` only reach here via a non-command route, and both fall back to the same
391
+ * best-effort marker `"annotate"` uses: there is no tree to retroactively un-split or un-merge
392
+ * through this path, only the choice of whether to leave a visible note about it. */
393
+ function handleStructuralGuard(editor, state, change) {
394
+ const policy = state.structuralEdits;
395
+ if (policy === "apply") {
396
+ dispatchStructural(editor, change.operation, policy);
397
+ return;
398
+ }
399
+ if (policy === "block") {
400
+ dispatchStructural(editor, change.operation, policy);
401
+ return;
402
+ }
403
+ // "annotate", and "mark" falling back to it (see the doc comment above). Best effort on WHERE:
404
+ // there is no single well-defined "boundary" node across an arbitrary non-command structural
405
+ // change (paste, drag, IME), so the note lands on whichever top-level block the selection sits
406
+ // in once the change has already landed — visible near the change, which is the most this path
407
+ // can honestly offer.
408
+ editor.update(() => {
409
+ const text = msg(editor, change.operation === "merge" ? "structuralMerge" : "structuralSplit");
410
+ const comment = $createCommentNode(text);
411
+ const selection = $getSelection();
412
+ const anchorNode = $isRangeSelection(selection) ? selection.anchor.getNode() : null;
413
+ const target = anchorNode ? topLevelBlockOf(anchorNode) : null;
414
+ target?.append(comment);
415
+ }, { tag: SKIP_TAG, discrete: true });
416
+ dispatchStructural(editor, change.operation, policy === "mark" ? "mark" : "annotate");
417
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The five CriticMarkup text-match transformers (Track N2). Order is substitution, deletion,
3
+ * insertion, highlight, comment — decision 7: `@lexical/markdown`'s import loop tries transformers
4
+ * in LIST order against the full remaining text and takes the first whose regex matches ANYWHERE in
5
+ * it, not the one that would have matched earliest. Highlight before comment is load-bearing: an
6
+ * anchored `{==t==}{>>n<<}` must let the highlight's own regex (which carries the optional trailing
7
+ * comment group) claim the comment text before a bare-comment transformer listed earlier could steal
8
+ * it from a highlight elsewhere on the same line.
9
+ *
10
+ * No `trigger` on any of these: CriticMarkup marks are authored through toolbar actions and
11
+ * suggestion mode (Track S/T), not typed markdown shortcuts, and a stray `}` should never silently
12
+ * spawn a mark.
13
+ *
14
+ * These regexes assume the text hitting them has already been through decision 16's pipeline
15
+ * (`tokenizeBlockSpanning` then `maskNested`, Track N3) — a mark never spans a block by the time a
16
+ * transformer sees it, and a nested mark's own delimiters are sentinels, not the literal characters
17
+ * these patterns look for.
18
+ */
19
+ import type { Transformer } from "@lexical/markdown";
20
+ export declare const SUBSTITUTION_TRANSFORMER: Transformer;
21
+ export declare const DELETION_TRANSFORMER: Transformer;
22
+ export declare const INSERTION_TRANSFORMER: Transformer;
23
+ export declare const HIGHLIGHT_TRANSFORMER: Transformer;
24
+ export declare const COMMENT_TRANSFORMER: Transformer;
25
+ /** The five transformers in decision 7's required order. */
26
+ export declare const criticMarkupTransformers: Transformer[];