@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/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
- render: (c) => html `<dj-button
185
- kind="text"
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=${isSuggestionMode(c.editor)}
188
- @click=${() => setSuggestionMode(c.editor, !isSuggestionMode(c.editor))}
189
- >${msg(ctx, "suggestEdits")}</dj-button
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
  ];
@@ -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
- { discrete: true, tag: SKIP_TAG });
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
- else
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;
@@ -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
- const t = $createTextNode(unescapeToken(buffer));
35
- t.setFormat(format);
36
- parent.append(t);
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))