@dojo-ng/rich-text-criticmarkup 0.1.0 → 0.1.2
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/dist/format.js +2 -2
- package/dist/grammar.d.ts +23 -0
- package/dist/grammar.js +210 -8
- package/dist/nodes.d.ts +54 -2
- package/dist/nodes.js +62 -9
- package/dist/plugin.js +82 -15
- package/dist/resolution.js +90 -12
- package/dist/transformers.js +10 -6
- package/fixtures/conformance.json +453 -62
- package/package.json +31 -5
package/dist/plugin.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { html } from "lit";
|
|
8
8
|
import { createRef, ref } from "lit/directives/ref.js";
|
|
9
|
-
import { $getNearestNodeFromDOMNode, $getSelection, $isRangeSelection } from "lexical";
|
|
9
|
+
import { $getNearestNodeFromDOMNode, $getSelection, $isRangeSelection, TextNode } from "lexical";
|
|
10
10
|
import { mergeRegister } from "@lexical/utils";
|
|
11
11
|
import { getDefaultLocale, messages, registerDefaults } from "@dojo-ng/i18n";
|
|
12
12
|
import { defineRichTextPlugin, ensureEditorStyles } from "@dojo-ng/rich-text";
|
|
@@ -14,7 +14,7 @@ import "@dojo-ng/popup-confirmation";
|
|
|
14
14
|
import "@dojo-ng/button";
|
|
15
15
|
import { CONTENT_CSS, CommentNode, DeletionNode, HighlightNode, InsertionNode, BreakNode, $isCommentNode, $isHighlightNode, } from "./nodes.js";
|
|
16
16
|
import { deserializeCriticMarkup, serializeCriticMarkup } from "./format.js";
|
|
17
|
-
import { PARAGRAPH_TOKEN } from "./grammar.js";
|
|
17
|
+
import { PARAGRAPH_TOKEN, unmaskInlineFormat } from "./grammar.js";
|
|
18
18
|
import { setSuggestionMode, isSuggestionMode, configureSuggestionMode } from "./suggestion-mode.js";
|
|
19
19
|
import { markAtSelection, acceptMark, declineMark, acceptAllMarks, declineAllMarks } from "./resolution.js";
|
|
20
20
|
import { createCommentPopupController } from "./comment-popup.js";
|
|
@@ -100,6 +100,17 @@ export function createCriticMarkupPlugin(options = {}) {
|
|
|
100
100
|
};
|
|
101
101
|
ctx.host.addEventListener("click", onHostClick);
|
|
102
102
|
return mergeRegister(
|
|
103
|
+
// Safety net for `maskInlineFormat`: a masked character the mark transformers did not
|
|
104
|
+
// consume (a mark the markdown path could not pair, or one inside an inline code span)
|
|
105
|
+
// must never reach the document as a private-use code point. Those sentinels never
|
|
106
|
+
// occur in real text, so turning any that appear back into their literal character is
|
|
107
|
+
// always correct and a no-op otherwise.
|
|
108
|
+
ctx.editor.registerNodeTransform(TextNode, (node) => {
|
|
109
|
+
const current = node.getTextContent();
|
|
110
|
+
const unmasked = unmaskInlineFormat(current);
|
|
111
|
+
if (unmasked !== current)
|
|
112
|
+
node.setTextContent(unmasked);
|
|
113
|
+
}),
|
|
103
114
|
// Mount CommentNode/BreakNode decorate() output (deferred from N1) and wire the
|
|
104
115
|
// comment button to open the popup, once per button — the click handler resolves
|
|
105
116
|
// the CURRENT node fresh each time rather than closing over one, so it never shows
|
|
@@ -149,23 +160,43 @@ export function createCriticMarkupPlugin(options = {}) {
|
|
|
149
160
|
export const criticMarkupPlugin = createCriticMarkupPlugin();
|
|
150
161
|
export default criticMarkupPlugin;
|
|
151
162
|
// --- toolbar ------------------------------------------------------------------------------------
|
|
163
|
+
/**
|
|
164
|
+
* A compact icon plus a name the ACCESSIBLE-NAME ALGORITHM sees but no sighted user does — visually
|
|
165
|
+
* clipped, never `display:none`/`aria-hidden` (either would drop it from the accessibility tree
|
|
166
|
+
* too, defeating the point). Both slot into `dj-button`'s DEFAULT slot alongside the icon slot, so
|
|
167
|
+
* the native `<button>` in its shadow root gets a real accessible name from its own rendered text
|
|
168
|
+
* content — the path `dj-button`'s own docstring calls "the common case," not `aria-label` on the
|
|
169
|
+
* host, which is the path six toolbar items were moved OFF of after a real axe run flagged it
|
|
170
|
+
* (`aria-prohibited-attr` on the role-less host, `button-name` on the shadow button whose only
|
|
171
|
+
* content was an `aria-hidden` icon). Restores the compact layout the visible-text swap gave up —
|
|
172
|
+
* found necessary in the same real consumer pass that found the two fixes above it (NovelMaker,
|
|
173
|
+
* Q1.7, 2026-09-27: "the text runs off the end of the toolbar... what happened to the icons?") —
|
|
174
|
+
* without reopening the violation the text swap was for: the icon is `aria-hidden` (decorative,
|
|
175
|
+
* the name already comes from the sr-only text) and the sr-only text is real, present content, not
|
|
176
|
+
* a host-level attribute a shadow boundary or a role-less element could make axe suspicious of.
|
|
177
|
+
* Verified against a real axe run in `tests/browser/rich-text-criticmarkup.test.js`, not assumed —
|
|
178
|
+
* see that file's own new case.
|
|
179
|
+
*/
|
|
180
|
+
function iconLabel(icon, text) {
|
|
181
|
+
return html `<span slot="icon" aria-hidden="true">${icon}</span><span class="dj-cm-sr-only">${text}</span>`;
|
|
182
|
+
}
|
|
152
183
|
function toolbarItems(ctx, confirmBulk) {
|
|
153
184
|
const addCommentRef = createRef();
|
|
154
185
|
const acceptAllContent = confirmBulk
|
|
155
186
|
? html `<dj-popup-confirmation confirm-label=${msg(ctx, "acceptAll")} @dj-confirm=${() => acceptAllMarks(ctx.editor)}>
|
|
156
187
|
<span slot="content">${msg(ctx, "confirmAcceptAll")}</span>
|
|
157
|
-
<dj-button kind="text" title=${msg(ctx, "acceptAll")}>${msg(ctx, "acceptAll")}</dj-button>
|
|
188
|
+
<dj-button kind="text" title=${msg(ctx, "acceptAll")}>${iconLabel("✓✓", msg(ctx, "acceptAll"))}</dj-button>
|
|
158
189
|
</dj-popup-confirmation>`
|
|
159
190
|
: html `<dj-button kind="text" title=${msg(ctx, "acceptAll")} @click=${() => acceptAllMarks(ctx.editor)}
|
|
160
|
-
>${msg(ctx, "acceptAll")}</dj-button
|
|
191
|
+
>${iconLabel("✓✓", msg(ctx, "acceptAll"))}</dj-button
|
|
161
192
|
>`;
|
|
162
193
|
const rejectAllContent = confirmBulk
|
|
163
194
|
? html `<dj-popup-confirmation confirm-label=${msg(ctx, "rejectAll")} @dj-confirm=${() => declineAllMarks(ctx.editor)}>
|
|
164
195
|
<span slot="content">${msg(ctx, "confirmRejectAll")}</span>
|
|
165
|
-
<dj-button kind="text" title=${msg(ctx, "rejectAll")}>${msg(ctx, "rejectAll")}</dj-button>
|
|
196
|
+
<dj-button kind="text" title=${msg(ctx, "rejectAll")}>${iconLabel("✗✗", msg(ctx, "rejectAll"))}</dj-button>
|
|
166
197
|
</dj-popup-confirmation>`
|
|
167
198
|
: html `<dj-button kind="text" title=${msg(ctx, "rejectAll")} @click=${() => declineAllMarks(ctx.editor)}
|
|
168
|
-
>${msg(ctx, "rejectAll")}</dj-button
|
|
199
|
+
>${iconLabel("✗✗", msg(ctx, "rejectAll"))}</dj-button
|
|
169
200
|
>`;
|
|
170
201
|
return [
|
|
171
202
|
{
|
|
@@ -181,13 +212,49 @@ function toolbarItems(ctx, confirmBulk) {
|
|
|
181
212
|
// already use here is what actually passes. `isActive`/`run` stay set too, so this item's
|
|
182
213
|
// state is still checkable directly (headless tests read `item.isActive(ctx)`), even
|
|
183
214
|
// though the core ignores them once `render` is present.
|
|
184
|
-
|
|
185
|
-
|
|
215
|
+
//
|
|
216
|
+
// `kind` switches to `"outlined"` while active, found missing in a real consumer's
|
|
217
|
+
// browser pass (NovelMaker, Q1.7, 2026-09-27): `aria-pressed` alone changes nothing a
|
|
218
|
+
// sighted mouse user can see — `dj-button` has no CSS reacting to it at all, the exact
|
|
219
|
+
// gap this project's own T10 track hit once before with a different attribute. Switching
|
|
220
|
+
// `kind` reuses `dj-button`'s OWN existing, already-themed outlined style (a persistent
|
|
221
|
+
// border in `--dj-color-primary-600`, confirmed to invert for dark mode by the same
|
|
222
|
+
// theme.css read that fixed `nodes.ts`'s `CONTENT_CSS`) rather than adding new CSS here
|
|
223
|
+
// that would need its own dark-mode proof.
|
|
224
|
+
render: (c) => {
|
|
225
|
+
const active = isSuggestionMode(c.editor);
|
|
226
|
+
return html `<dj-button
|
|
227
|
+
kind=${active ? "outlined" : "text"}
|
|
186
228
|
title=${msg(ctx, "suggestEdits")}
|
|
187
|
-
aria-pressed=${
|
|
188
|
-
@click=${() =>
|
|
189
|
-
|
|
190
|
-
|
|
229
|
+
aria-pressed=${active}
|
|
230
|
+
@click=${() => {
|
|
231
|
+
setSuggestionMode(c.editor, !isSuggestionMode(c.editor));
|
|
232
|
+
// isSuggestionMode is a WeakMap, entirely outside Lexical's own editor
|
|
233
|
+
// state — no document mutation, no selection change, so NEITHER of the
|
|
234
|
+
// core's two built-in re-render triggers (registerUpdateListener,
|
|
235
|
+
// onSelectionChange) ever fires from this click. Without this the click
|
|
236
|
+
// genuinely works (confirmed: isSuggestionMode(editor) flips) but the
|
|
237
|
+
// DOM — aria-pressed, the kind="outlined" border decision 18's own fix
|
|
238
|
+
// above depends on — stays stale until some UNRELATED later action
|
|
239
|
+
// happens to re-render the toolbar, which is exactly the false-positive
|
|
240
|
+
// shape that let this ship once already: a manual browser check that
|
|
241
|
+
// clicked the button and then did something else right after read the
|
|
242
|
+
// SECOND action's incidental re-render as proof the click worked. Found
|
|
243
|
+
// by checking isSuggestionMode(editor) directly against the DOM
|
|
244
|
+
// attribute after a click and nothing else — the same gap, and the
|
|
245
|
+
// same fix (`ctx.host.requestUpdate()`), NovelMaker's own retired local
|
|
246
|
+
// plugin had already hit for this exact button before this package
|
|
247
|
+
// existed; `RichTextContext.host`'s own doc comment even names
|
|
248
|
+
// requestUpdate as a reason a plugin holds a host reference.
|
|
249
|
+
// `RichTextContext.host` is typed as the narrower `HTMLElement` for the
|
|
250
|
+
// public API surface; it is always a `dj-rich-text`, a real
|
|
251
|
+
// ReactiveElement, at runtime — the same cast `RichTextContext.host`'s
|
|
252
|
+
// own doc comment invites by naming requestUpdate as a reason to hold it.
|
|
253
|
+
c.host.requestUpdate();
|
|
254
|
+
}}
|
|
255
|
+
>${iconLabel("✎", msg(ctx, "suggestEdits"))}</dj-button
|
|
256
|
+
>`;
|
|
257
|
+
},
|
|
191
258
|
},
|
|
192
259
|
{
|
|
193
260
|
id: "criticmarkup-accept",
|
|
@@ -211,7 +278,7 @@ function toolbarItems(ctx, confirmBulk) {
|
|
|
211
278
|
if (node)
|
|
212
279
|
acceptMark(c.editor, node);
|
|
213
280
|
}}
|
|
214
|
-
>${msg(ctx, "acceptMark")}</dj-button
|
|
281
|
+
>${iconLabel("✓", msg(ctx, "acceptMark"))}</dj-button
|
|
215
282
|
>`;
|
|
216
283
|
},
|
|
217
284
|
},
|
|
@@ -237,7 +304,7 @@ function toolbarItems(ctx, confirmBulk) {
|
|
|
237
304
|
if (node)
|
|
238
305
|
declineMark(c.editor, node);
|
|
239
306
|
}}
|
|
240
|
-
>${msg(ctx, "rejectMark")}</dj-button
|
|
307
|
+
>${iconLabel("✗", msg(ctx, "rejectMark"))}</dj-button
|
|
241
308
|
>`;
|
|
242
309
|
},
|
|
243
310
|
},
|
|
@@ -272,7 +339,7 @@ function toolbarItems(ctx, confirmBulk) {
|
|
|
272
339
|
const collapsed = isSelectionCollapsed(ctx.editor);
|
|
273
340
|
popup.open({ anchor, mode: collapsed ? "insert-bare" : "insert-anchored", text: "" });
|
|
274
341
|
}}
|
|
275
|
-
>${msg(ctx, "addComment")}</dj-button
|
|
342
|
+
>${iconLabel("\u{1F4AC}", msg(ctx, "addComment"))}</dj-button
|
|
276
343
|
>`,
|
|
277
344
|
},
|
|
278
345
|
];
|
package/dist/resolution.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* No confirmation here — `dj-popup-confirmation` gating `confirmBulk` is a toolbar-level concern
|
|
15
15
|
* (Track T3), not baked into these functions themselves.
|
|
16
16
|
*/
|
|
17
|
-
import { $createParagraphNode, $getNodeByKey, $getRoot, $getSelection, $isElementNode, $isRangeSelection, $isRootOrShadowRoot } from "lexical";
|
|
17
|
+
import { $createParagraphNode, $createTextNode, $getNodeByKey, $getRoot, $getSelection, $isElementNode, $isRangeSelection, $isRootOrShadowRoot, $isTextNode } from "lexical";
|
|
18
18
|
import { $isBreakNode, $isCommentNode, $isCriticMark, $isDeletionNode, $isHighlightNode, $isInsertionNode, } from "./nodes.js";
|
|
19
19
|
import { SKIP_TAG } from "./suggestion-mode.js";
|
|
20
20
|
/** The nearest CriticMarkup mark (any of the four kinds) containing the selection's anchor, or
|
|
@@ -48,6 +48,24 @@ export function acceptAllMarks(editor) {
|
|
|
48
48
|
export function declineAllMarks(editor) {
|
|
49
49
|
resolveAllMarks(editor, "old");
|
|
50
50
|
}
|
|
51
|
+
// Lexical's own reconciler (`updateDOMSelection`, read directly from the vendored
|
|
52
|
+
// `Lexical.dev.mjs` rather than assumed) scrolls a COLLAPSED selection into view
|
|
53
|
+
// whenever an update leaves the editor root focused and the browser's own native
|
|
54
|
+
// selection no longer matches Lexical's internal one — which is exactly the shape
|
|
55
|
+
// every caller here has: a toolbar or drawer BUTTON click, never a caret move, so
|
|
56
|
+
// the native selection has almost always drifted to wherever the mouse last did
|
|
57
|
+
// something selectable, while Lexical's own last-known selection is still sitting
|
|
58
|
+
// wherever the author was last actually typing. Left untagged, resolving a mark
|
|
59
|
+
// pulls the view back to THAT old typing position instead of staying on the mark
|
|
60
|
+
// just resolved — reported against NovelMaker's Edits drawer 2026-09-27 ("hard to
|
|
61
|
+
// double-check a change after accepting it"), but the mechanism is Lexical's own
|
|
62
|
+
// reconciliation, not anything the drawer does, so every caller of `acceptMark`/
|
|
63
|
+
// `declineMark`/`acceptAllMarks`/`declineAllMarks` — the toolbar's own accept/
|
|
64
|
+
// reject buttons included — had the identical risk. `"skip-scroll-into-view"` is
|
|
65
|
+
// not exported as a named constant anywhere in Lexical's own public API, but it is
|
|
66
|
+
// the literal tag string its reconciler checks (`Lexical.dev.mjs`'s own
|
|
67
|
+
// `updateDOMSelection`), matching how Lexical's own examples use it directly.
|
|
68
|
+
const SKIP_SCROLL_TAG = "skip-scroll-into-view";
|
|
51
69
|
function resolveMark(editor, node, side) {
|
|
52
70
|
editor.update(() => {
|
|
53
71
|
const fresh = $getNodeByKey(node.getKey());
|
|
@@ -59,8 +77,9 @@ function resolveMark(editor, node, side) {
|
|
|
59
77
|
// applied synchronously, not silently batched to a later microtask. Tagged with SKIP_TAG so
|
|
60
78
|
// suggestion mode's own diff-and-wrap listener, if still on, does not see the flattened text
|
|
61
79
|
// 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
|
-
|
|
80
|
+
// made real) and re-wrap it right back up as a brand-new suggestion. SKIP_SCROLL_TAG is the
|
|
81
|
+
// unwanted-scroll fix above.
|
|
82
|
+
{ discrete: true, tag: [SKIP_TAG, SKIP_SCROLL_TAG] });
|
|
64
83
|
}
|
|
65
84
|
function resolveAllMarks(editor, side) {
|
|
66
85
|
editor.update(() => {
|
|
@@ -71,7 +90,7 @@ function resolveAllMarks(editor, side) {
|
|
|
71
90
|
continue;
|
|
72
91
|
resolveOne(editor, node, side, resolved);
|
|
73
92
|
}
|
|
74
|
-
}, { discrete: true, tag: SKIP_TAG });
|
|
93
|
+
}, { discrete: true, tag: [SKIP_TAG, SKIP_SCROLL_TAG] });
|
|
75
94
|
}
|
|
76
95
|
/** Resolves `node` (and, if it is half of a substitution pair, its partner too), then emits
|
|
77
96
|
* `dj-criticmarkup-change`. `resolved` (bulk resolution only) records both halves of a pair so the
|
|
@@ -82,12 +101,14 @@ function resolveOne(editor, node, side, resolved) {
|
|
|
82
101
|
resolved?.add(pair.deletion.getKey());
|
|
83
102
|
resolved?.add(pair.insertion.getKey());
|
|
84
103
|
if (side === "new") {
|
|
104
|
+
// No join seam here even when the deletion held a break (decision 18): the insertion's own
|
|
105
|
+
// text lands in its place, so the two sides are not becoming adjacent.
|
|
85
106
|
pair.deletion.remove();
|
|
86
|
-
unwrapAndResolveBreaks(pair.insertion);
|
|
107
|
+
unwrapAndResolveBreaks(pair.insertion, true);
|
|
87
108
|
}
|
|
88
109
|
else {
|
|
89
110
|
pair.insertion.remove();
|
|
90
|
-
unwrapAndResolveBreaks(pair.deletion);
|
|
111
|
+
unwrapAndResolveBreaks(pair.deletion, false);
|
|
91
112
|
}
|
|
92
113
|
dispatchChange(editor, "substitution", side === "new" ? "accept" : "decline");
|
|
93
114
|
return;
|
|
@@ -104,10 +125,17 @@ function resolveOne(editor, node, side, resolved) {
|
|
|
104
125
|
}
|
|
105
126
|
else {
|
|
106
127
|
const keep = $isInsertionNode(node) ? side === "new" : side === "old";
|
|
107
|
-
if (keep)
|
|
108
|
-
unwrapAndResolveBreaks(node);
|
|
109
|
-
|
|
128
|
+
if (keep) {
|
|
129
|
+
unwrapAndResolveBreaks(node, side === "new");
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// Decision 18: accepting a deletion that holds a break closes a real paragraph boundary, so
|
|
133
|
+
// the text on either side needs the space that boundary was providing. Declining an
|
|
134
|
+
// insertion drops a break that never existed in the document, so it changes nothing.
|
|
135
|
+
if (side === "new" && holdsBreak(node))
|
|
136
|
+
joinAtRemovedBreak(node);
|
|
110
137
|
node.remove();
|
|
138
|
+
}
|
|
111
139
|
}
|
|
112
140
|
dispatchChange(editor, kind, side === "new" ? "accept" : "decline");
|
|
113
141
|
}
|
|
@@ -149,14 +177,60 @@ function unwrap(node) {
|
|
|
149
177
|
* children — a kept paragraph-break proposal (decision 16) stops being a decorator and becomes an
|
|
150
178
|
* actual block boundary, mirroring what `grammar.accept`/`decline` do to the token at the string
|
|
151
179
|
* level. */
|
|
152
|
-
function unwrapAndResolveBreaks(node) {
|
|
180
|
+
function unwrapAndResolveBreaks(node, creating) {
|
|
153
181
|
const children = unwrap(node);
|
|
154
182
|
for (const child of children) {
|
|
155
183
|
if ($isBreakNode(child))
|
|
156
|
-
splitBlockAtBreak(child);
|
|
184
|
+
splitBlockAtBreak(child, creating);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** True if `node` holds a `BreakNode` — the tree-side reading of `grammar.hasStructuralToken`. */
|
|
188
|
+
function holdsBreak(node) {
|
|
189
|
+
return node.getChildren().some($isBreakNode);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Decision 18's join rule on the live tree: a single space where the removed break was, but only
|
|
193
|
+
* when real text sits hard against both sides of it. Inserted as its own text node rather than
|
|
194
|
+
* appended to the neighbour, so a formatted run keeps its own format and Lexical's own
|
|
195
|
+
* normalization merges the pair where they match.
|
|
196
|
+
*/
|
|
197
|
+
function joinAtRemovedBreak(node) {
|
|
198
|
+
const before = node.getPreviousSibling()?.getTextContent() ?? "";
|
|
199
|
+
const after = node.getNextSibling()?.getTextContent() ?? "";
|
|
200
|
+
if (before === "" || after === "")
|
|
201
|
+
return;
|
|
202
|
+
if (/\s$/.test(before) || /^\s/.test(after))
|
|
203
|
+
return;
|
|
204
|
+
node.insertBefore($createTextNode(" "));
|
|
205
|
+
}
|
|
206
|
+
/** Decision 18's absorb rule on the live tree: the horizontal whitespace on either side of a break
|
|
207
|
+
* being CREATED goes with it, so an accepted split does not strand a space at the end of the first
|
|
208
|
+
* block or open the second one with one. A break being RESTORED (a declined merge) is left exactly
|
|
209
|
+
* as the document had it. */
|
|
210
|
+
function trimSeam(block, edge) {
|
|
211
|
+
// Walks inward from the edge over a SNAPSHOT of the children, rather than re-reading the edge
|
|
212
|
+
// child on each pass. The loop's progress has to come from the walk itself and never from
|
|
213
|
+
// `remove()` having taken effect: Lexical reinstates an empty text node at a block edge in some
|
|
214
|
+
// reconciliation paths, and a loop that re-read `getLastChild()` would then be handed the same
|
|
215
|
+
// node forever — a hung page rather than a failed assertion, which is the kind of bug a headless
|
|
216
|
+
// test with a simpler tree can miss entirely.
|
|
217
|
+
const children = block.getChildren();
|
|
218
|
+
const ordered = edge === "end" ? [...children].reverse() : children;
|
|
219
|
+
for (const child of ordered) {
|
|
220
|
+
if (!$isTextNode(child))
|
|
221
|
+
return;
|
|
222
|
+
const text = child.getTextContent();
|
|
223
|
+
const trimmed = edge === "end" ? text.replace(/[ \t]+$/, "") : text.replace(/^[ \t]+/, "");
|
|
224
|
+
if (trimmed === text)
|
|
225
|
+
return;
|
|
226
|
+
if (trimmed !== "") {
|
|
227
|
+
child.setTextContent(trimmed);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
child.remove();
|
|
157
231
|
}
|
|
158
232
|
}
|
|
159
|
-
function splitBlockAtBreak(breakNode) {
|
|
233
|
+
function splitBlockAtBreak(breakNode, creating) {
|
|
160
234
|
const block = topLevelBlockOf(breakNode);
|
|
161
235
|
if (!block) {
|
|
162
236
|
breakNode.remove();
|
|
@@ -171,6 +245,10 @@ function splitBlockAtBreak(breakNode) {
|
|
|
171
245
|
}
|
|
172
246
|
block.insertAfter(newBlock);
|
|
173
247
|
breakNode.remove();
|
|
248
|
+
if (creating) {
|
|
249
|
+
trimSeam(block, "end");
|
|
250
|
+
trimSeam(newBlock, "start");
|
|
251
|
+
}
|
|
174
252
|
}
|
|
175
253
|
function topLevelBlockOf(node) {
|
|
176
254
|
let current = node;
|
package/dist/transformers.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* these patterns look for.
|
|
18
18
|
*/
|
|
19
19
|
import { $createTextNode, $isTextNode } from "lexical";
|
|
20
|
-
import { escapeToken, unescapeToken, PARAGRAPH_TOKEN } from "./grammar.js";
|
|
20
|
+
import { escapeToken, unescapeToken, inlineFormatSegments, unmaskInlineFormat, PARAGRAPH_TOKEN } from "./grammar.js";
|
|
21
21
|
import { $createBreakNode, $isBreakNode, $createCommentNode, $isCommentNode, $createDeletionNode, $isDeletionNode, DeletionNode, $createHighlightNode, $isHighlightNode, HighlightNode, $createInsertionNode, $isInsertionNode, InsertionNode, CommentNode, } from "./nodes.js";
|
|
22
22
|
/**
|
|
23
23
|
* Split raw mark content around paragraph-token breaks (decision 16) into `TextNode`/`BreakNode`
|
|
@@ -31,9 +31,13 @@ function appendTokenSegments(parent, content, format) {
|
|
|
31
31
|
const flush = () => {
|
|
32
32
|
if (buffer === "")
|
|
33
33
|
return;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
// `maskInlineFormat` may have hidden emphasis inside the mark from the markdown import's
|
|
35
|
+
// text-format pass; rebuild it here as formatted runs. Unmasked content yields one run.
|
|
36
|
+
for (const segment of inlineFormatSegments(buffer, format)) {
|
|
37
|
+
const t = $createTextNode(unescapeToken(segment.text));
|
|
38
|
+
t.setFormat(segment.format);
|
|
39
|
+
parent.append(t);
|
|
40
|
+
}
|
|
37
41
|
buffer = "";
|
|
38
42
|
};
|
|
39
43
|
while (i < content.length) {
|
|
@@ -149,7 +153,7 @@ export const HIGHLIGHT_TRANSFORMER = {
|
|
|
149
153
|
const node = $createHighlightNode();
|
|
150
154
|
appendTokenSegments(node, raw, textNode.getFormat());
|
|
151
155
|
if (commentRaw !== undefined)
|
|
152
|
-
node.setComment(unescapeToken(commentRaw));
|
|
156
|
+
node.setComment(unescapeToken(unmaskInlineFormat(commentRaw)));
|
|
153
157
|
textNode.replace(node);
|
|
154
158
|
},
|
|
155
159
|
export: (node, _exportChildren, exportFormat) => {
|
|
@@ -168,7 +172,7 @@ export const COMMENT_TRANSFORMER = {
|
|
|
168
172
|
regExp: /\{>>(.*?)<<\}$/,
|
|
169
173
|
replace: (textNode, match) => {
|
|
170
174
|
const [, raw] = match;
|
|
171
|
-
textNode.replace($createCommentNode(unescapeToken(raw)));
|
|
175
|
+
textNode.replace($createCommentNode(unescapeToken(unmaskInlineFormat(raw))));
|
|
172
176
|
},
|
|
173
177
|
export: (node) => {
|
|
174
178
|
if (!$isCommentNode(node))
|