@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.
- package/README.md +44 -0
- package/dist/comment-authoring.d.ts +21 -0
- package/dist/comment-authoring.js +79 -0
- package/dist/comment-popup.d.ts +29 -0
- package/dist/comment-popup.js +120 -0
- package/dist/format.d.ts +42 -0
- package/dist/format.js +65 -0
- package/dist/grammar.d.ts +122 -0
- package/dist/grammar.js +399 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/nodes.d.ts +137 -0
- package/dist/nodes.js +325 -0
- package/dist/plugin.d.ts +27 -0
- package/dist/plugin.js +288 -0
- package/dist/resolution.d.ts +34 -0
- package/dist/resolution.js +207 -0
- package/dist/suggestion-mode.d.ts +29 -0
- package/dist/suggestion-mode.js +417 -0
- package/dist/transformers.d.ts +26 -0
- package/dist/transformers.js +187 -0
- package/fixtures/conformance.json +67 -0
- package/package.json +5 -0
|
@@ -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[];
|