@dojo-ng/rich-text-criticmarkup 0.1.1 → 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 CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { $getRoot, $isElementNode, $isTextNode } from "lexical";
13
13
  import { $convertFromMarkdownString, $convertToMarkdownString } from "@lexical/markdown";
14
- import { parseMarks, tokenizeBlockSpanning, maskNested, unmaskNested, PARAGRAPH_TOKEN } from "./grammar.js";
14
+ import { parseMarks, tokenizeBlockSpanning, maskNested, unmaskNested, unmaskInlineFormat, PARAGRAPH_TOKEN } from "./grammar.js";
15
15
  import { criticMarkupTransformers } from "./transformers.js";
16
16
  /**
17
17
  * Import `data` as CriticMarkup: `tokenizeBlockSpanning`, then `maskNested`, then the markdown
@@ -49,7 +49,7 @@ function refusalsIn(text) {
49
49
  function unmaskTextNodes(node) {
50
50
  if ($isTextNode(node)) {
51
51
  const current = node.getTextContent();
52
- const unmasked = unmaskNested(current);
52
+ const unmasked = unmaskInlineFormat(unmaskNested(current));
53
53
  if (unmasked !== current)
54
54
  node.setTextContent(unmasked);
55
55
  return;
package/dist/grammar.d.ts CHANGED
@@ -120,3 +120,26 @@ export declare function maskNested(text: string): {
120
120
  };
121
121
  /** The exact inverse of `maskNested`: every sentinel code point becomes its real delimiter character. */
122
122
  export declare function unmaskNested(text: string): string;
123
+ /**
124
+ * Mask `*`, `_`, and `` ` `` inside every top-level CriticMarkup mark, for a markdown import that
125
+ * runs `criticMarkupTransformers` alongside `@lexical/markdown`'s text-format transformers. Pass it
126
+ * as `createMarkdownPlugin({ prepareImport: maskInlineFormat })`. Length-preserving; text outside
127
+ * marks, nested marks (which the markdown path cannot pair anyway), and block-spanning marks are
128
+ * left exactly as they were.
129
+ */
130
+ export declare function maskInlineFormat(text: string): string;
131
+ /** The exact inverse of `maskInlineFormat`: every inline sentinel becomes its literal character. */
132
+ export declare function unmaskInlineFormat(text: string): string;
133
+ export interface InlineFormatSegment {
134
+ text: string;
135
+ /** Lexical text-format bits: 1 bold, 2 italic, 16 code. */
136
+ format: number;
137
+ }
138
+ /**
139
+ * Split masked mark content into formatted runs: `*x*` italic, `**x**` bold, `***x***` both, and
140
+ * `` `x` `` code, nesting allowed (`**a *b* c**`). These are the forms `$convertToMarkdownString`
141
+ * writes back, so the round trip is byte-exact. Anything unpaired, and every `_`, stays literal;
142
+ * `_x_` is not rebuilt as italic because the export would rewrite it as `*x*`. Plain strings in
143
+ * and out, so a caller with no editor can use it too.
144
+ */
145
+ export declare function inlineFormatSegments(masked: string, format?: number): InlineFormatSegment[];
package/dist/grammar.js CHANGED
@@ -476,3 +476,126 @@ export function unmaskNested(text) {
476
476
  out += UNMASK_OF.get(ch) ?? ch;
477
477
  return out;
478
478
  }
479
+ // --- inline formatting inside a mark (markdown import) ------------------------------------------
480
+ // `@lexical/markdown`'s `$importBlocks` runs text-FORMAT transformers (`*`, `**`, `_`, `` ` ``)
481
+ // before text-MATCH transformers, a fixed call order no transformer list can change. So a mark
482
+ // whose content carries emphasis, `{--he thought *what?* and shook--}`, is split into three text
483
+ // nodes by the italic transformer before any CriticMarkup transformer sees it, and none of the
484
+ // three pieces holds a whole mark: the braces stay behind as literal text, the mark cannot be
485
+ // accepted or rejected, and nothing reports a refusal. Found in NovelMaker, whose editor loads
486
+ // chapters through the markdown format with `criticMarkupTransformers` in the transformer set.
487
+ //
488
+ // The fix is a masking pass in the same spirit as `maskNested`: before the markdown import, hide
489
+ // the inline-format characters inside each mark behind private-use sentinels (U+E020 block, clear
490
+ // of `maskNested`'s U+E000 block and `resolve`'s seam sentinels), so the format transformers see
491
+ // nothing to claim there and the text-match transformers get whole marks. The mark transformers
492
+ // then rebuild the formatting from the sentinels (`inlineFormatSegments`). `unmaskInlineFormat`
493
+ // is the exact inverse, used as a safety net on any text a mark transformer did not consume.
494
+ const INLINE_FORMAT_CHARS = ["*", "_", "`"];
495
+ const INLINE_MASK_OF = new Map(INLINE_FORMAT_CHARS.map((ch, i) => [ch, String.fromCodePoint(0xe020 + i)]));
496
+ const INLINE_UNMASK_OF = new Map(INLINE_FORMAT_CHARS.map((ch, i) => [String.fromCodePoint(0xe020 + i), ch]));
497
+ const STAR = INLINE_MASK_OF.get("*");
498
+ const BACKTICK = INLINE_MASK_OF.get("`");
499
+ /** Lexical's text-format bits, repeated here so this module stays free of a Lexical import. */
500
+ const FORMAT_BOLD = 1;
501
+ const FORMAT_ITALIC = 2;
502
+ const FORMAT_CODE = 16;
503
+ /**
504
+ * Mask `*`, `_`, and `` ` `` inside every top-level CriticMarkup mark, for a markdown import that
505
+ * runs `criticMarkupTransformers` alongside `@lexical/markdown`'s text-format transformers. Pass it
506
+ * as `createMarkdownPlugin({ prepareImport: maskInlineFormat })`. Length-preserving; text outside
507
+ * marks, nested marks (which the markdown path cannot pair anyway), and block-spanning marks are
508
+ * left exactly as they were.
509
+ */
510
+ export function maskInlineFormat(text) {
511
+ const marks = parseMarks(text).filter((m, _, all) => !m.nested && !m.spansBlock && !all.some((p) => p !== m && p.start <= m.start && m.end <= p.end));
512
+ if (!marks.length)
513
+ return text;
514
+ const units = text.split("");
515
+ for (const mark of marks) {
516
+ for (let i = mark.start + TOKEN_LEN; i < mark.end - TOKEN_LEN; i++) {
517
+ const masked = INLINE_MASK_OF.get(units[i]);
518
+ if (masked)
519
+ units[i] = masked;
520
+ }
521
+ }
522
+ return units.join("");
523
+ }
524
+ /** The exact inverse of `maskInlineFormat`: every inline sentinel becomes its literal character. */
525
+ export function unmaskInlineFormat(text) {
526
+ let out = "";
527
+ for (const ch of text)
528
+ out += INLINE_UNMASK_OF.get(ch) ?? ch;
529
+ return out;
530
+ }
531
+ /**
532
+ * Split masked mark content into formatted runs: `*x*` italic, `**x**` bold, `***x***` both, and
533
+ * `` `x` `` code, nesting allowed (`**a *b* c**`). These are the forms `$convertToMarkdownString`
534
+ * writes back, so the round trip is byte-exact. Anything unpaired, and every `_`, stays literal;
535
+ * `_x_` is not rebuilt as italic because the export would rewrite it as `*x*`. Plain strings in
536
+ * and out, so a caller with no editor can use it too.
537
+ */
538
+ export function inlineFormatSegments(masked, format = 0) {
539
+ const out = [];
540
+ let literal = "";
541
+ const flush = () => {
542
+ if (literal !== "")
543
+ out.push({ text: literal, format });
544
+ literal = "";
545
+ };
546
+ let i = 0;
547
+ while (i < masked.length) {
548
+ const ch = masked[i];
549
+ if (ch === BACKTICK) {
550
+ const close = masked.indexOf(BACKTICK, i + 1);
551
+ if (close > i + 1) {
552
+ flush();
553
+ out.push({ text: unmaskInlineFormat(masked.slice(i + 1, close)), format: format | FORMAT_CODE });
554
+ i = close + 1;
555
+ continue;
556
+ }
557
+ }
558
+ else if (ch === STAR) {
559
+ const run = runLength(masked, i);
560
+ const close = run <= 3 ? closingRun(masked, i + run, run) : -1;
561
+ if (close > 0) {
562
+ flush();
563
+ const bits = run === 1 ? FORMAT_ITALIC : run === 2 ? FORMAT_BOLD : FORMAT_BOLD | FORMAT_ITALIC;
564
+ out.push(...inlineFormatSegments(masked.slice(i + run, close), format | bits));
565
+ i = close + run;
566
+ continue;
567
+ }
568
+ literal += "*".repeat(run);
569
+ i += run;
570
+ continue;
571
+ }
572
+ literal += INLINE_UNMASK_OF.get(ch) ?? ch;
573
+ i++;
574
+ }
575
+ flush();
576
+ return out;
577
+ }
578
+ function runLength(text, at) {
579
+ let n = 0;
580
+ while (text[at + n] === STAR)
581
+ n++;
582
+ return n;
583
+ }
584
+ /** Index of the first star run of exactly `size` that can close an emphasis opened just before
585
+ * `from`: content must not start or end with whitespace, and must not be empty. -1 if none. */
586
+ function closingRun(text, from, size) {
587
+ if (from >= text.length || /\s/.test(text[from]))
588
+ return -1;
589
+ let i = from;
590
+ while (i < text.length) {
591
+ if (text[i] === STAR) {
592
+ const run = runLength(text, i);
593
+ if (run === size && i > from && !/\s/.test(text[i - 1]))
594
+ return i;
595
+ i += run;
596
+ continue;
597
+ }
598
+ i++;
599
+ }
600
+ return -1;
601
+ }
package/dist/nodes.d.ts CHANGED
@@ -95,7 +95,36 @@ export declare class CommentNode extends DecoratorNode<HTMLElement> {
95
95
  createDOM(): HTMLElement;
96
96
  updateDOM(): boolean;
97
97
  exportDOM(): DOMExportOutput;
98
- /** The button the container mounts; accessible name is the comment text itself. */
98
+ /**
99
+ * The button `createDOM()`'s container span mounts; accessible name is the comment text
100
+ * itself, carried as `aria-label` rather than as DOM content. It was a text node once
101
+ * (`button.textContent = this.__text`, no other child) — the button is a fixed `1.1em`
102
+ * icon (its glyph comes from `::before` in `CONTENT_CSS`) with no `overflow: hidden`, so a
103
+ * raw text node long enough to exceed that width wrapped inside the flex box one-or-two
104
+ * characters per line, stacking real (if visually blank, white-on-blue) layout geometry
105
+ * down the column. A consumer building a `Range` over this node's rendered element —
106
+ * NovelMaker's `rangeForMark`, `range.selectNodeContents(editor.getElementByKey(key))`,
107
+ * for its reveal-on-scroll highlight — measured that geometry instead of the icon: 47
108
+ * client rects for a ~54-character comment, one wrapped line per character, and a
109
+ * `getBoundingClientRect()` union hundreds of pixels tall and off by hundreds of pixels
110
+ * vertically from the icon's own position (confirmed live: a bare comment's reveal in
111
+ * NovelMaker's Edits drawer was landing ~230px off from the icon, well over half the
112
+ * visible editor height, before this fix). A `.dj-cm-sr-only` child — the pattern
113
+ * `iconLabel()` above uses for `dj-button`'s shadow-DOM buttons — only trades that for a
114
+ * smaller but still-wrong box: `clip: rect(0,0,0,0)` hides it from *paint*, not from
115
+ * layout, and its `white-space: nowrap` keeps the text on one line but at its own full
116
+ * natural width, so a Range over it still measures a stray sliver next to the icon.
117
+ * `aria-label` gives the button its accessible name with no DOM content at all, so
118
+ * `getElementByKey(key)`'s only child (this button) is itself a childless leaf — verified
119
+ * live that `range.selectNodeContents(theContainerSpan)` then reports exactly ONE rect,
120
+ * equal to `button.getBoundingClientRect()`: browsers fall back to an empty element's own
121
+ * border box as the Range's content when there's nothing inside it left to fragment into
122
+ * text runs. This button is a plain native `<button>`, not a custom-element host —
123
+ * `iconLabel()`'s own docstring is about `aria-prohibited-attr`/`button-name` axe failures
124
+ * specific to a role-less custom-element host and a shadow button whose only slotted
125
+ * content was an `aria-hidden` icon; neither applies to a real `<button>`, which has an
126
+ * implicit role and where `aria-label` is standard and axe-clean.
127
+ */
99
128
  decorate(_editor: LexicalEditor): HTMLElement;
100
129
  getText(): string;
101
130
  setText(text: string): this;
@@ -132,6 +161,29 @@ export declare function $isCriticMark(node: LexicalNode | null | undefined): nod
132
161
  * so every mark stays distinguishable when backgrounds flatten. Injected once via
133
162
  * `ensureEditorStyles("dj-rich-text-criticmarkup", CONTENT_CSS)`, called from the plugin's own
134
163
  * `setup()` (Track T), not from this module — node files don't touch the DOM at import time.
164
+ *
165
+ * **Dark-mode fix, found in a real consumer's browser pass (NovelMaker, Q1.7 of its
166
+ * `search-and-edits-spec.md`, 2026-09-27) and worth stating so it is not reintroduced.** The first
167
+ * version of this CSS painted insertion/deletion/highlight backgrounds with `success-100`/
168
+ * `danger-100`/`warning-100` — pale tints that `theme.css` only ever defines under `:root` and
169
+ * never redefines for `.dark`/`prefers-color-scheme: dark`, so in dark mode the background stayed
170
+ * pale while the ambient text color (`color: inherit`, or no override at all) correctly turned
171
+ * near-white — pale-on-near-white, unreadable. This is the exact failure a consumer of this
172
+ * package (NovelMaker) had already hit and fixed once for its own now-retired local plugin: "a pale
173
+ * mint or pale yellow background stays pale while the text drawn on it turns near-white ... backwards
174
+ * contrast." The same version also leaned on FOUR bare tokens — `--dj-color-success`,
175
+ * `--dj-color-danger`, `--dj-color-primary`, `--dj-color-on-primary` — that this package's own
176
+ * `theme.css` never defines at all (only the numbered shades exist), so every `var(--dj-color-X,
177
+ * fallback)` using them silently and permanently resolved to its hardcoded fallback, theme or no
178
+ * theme — invisible unless someone actually diffed light against dark, which is exactly how this
179
+ * shipped unnoticed. **The fix, and the rule for the next person editing this block: use a token
180
+ * that is redefined on BOTH sides of the `.dark` block in `theme.css`, never a bare
181
+ * `--dj-color-<hue>` with no shade number (none exist), and never a `-100` tint alone for
182
+ * anything a reader has to read text through** — `neutral-100`/`-200` (confirmed to invert:
183
+ * `#f3f4f6`/`#e5e7eb` light, `#1f2937`/`#374151` dark) carry the backgrounds now, and the `-600`
184
+ * semantic shades (all three of `success`/`warning`/`danger` are redefined for dark, confirmed by
185
+ * reading `theme.css` directly rather than assumed) carry the decorative accent color, which only
186
+ * has to read as a thin line, not as body text.
135
187
  */
136
- export declare const CONTENT_CSS = "\ndj-rich-text ins.dj-cm-insertion { text-decoration: underline; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-success, #16a34a); text-decoration-skip-ink: none; background: var(--dj-color-success-100, #dcfce7); }\ndj-rich-text del.dj-cm-deletion { text-decoration: line-through; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-danger, #dc2626); background: var(--dj-color-danger-100, #fee2e2); }\ndj-rich-text mark.dj-cm-highlight { background: var(--dj-color-warning-100, #fef3c7); color: inherit; }\ndj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: inset 0 -2px 0 var(--dj-color-warning, #d97706); }\ndj-rich-text .dj-cm-comment-button { display: inline-flex; align-items: center; justify-content: center; width: 1.1em; height: 1.1em; padding: 0; border: none; border-radius: 999px; background: var(--dj-color-primary, #2563eb); color: var(--dj-color-on-primary, #fff); font-size: .75em; line-height: 1; cursor: pointer; }\ndj-rich-text .dj-cm-comment-button::before { content: \"\\1F4AC\"; }\ndj-rich-text .dj-cm-break-pill { display: inline-block; padding: 0 .3em; border-radius: 3px; background: var(--dj-color-primary-100, #dbeafe); color: var(--dj-color-primary, #2563eb); font-size: .85em; }\n@media (forced-colors: active) {\n\tdj-rich-text ins.dj-cm-insertion, dj-rich-text del.dj-cm-deletion { background: transparent; text-decoration-color: CanvasText; }\n\tdj-rich-text mark.dj-cm-highlight { background: Mark; color: MarkText; border: 1px solid CanvasText; }\n\tdj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: none; border-style: dashed; }\n\tdj-rich-text .dj-cm-comment-button { forced-color-adjust: none; background: Highlight; color: HighlightText; border: 1px solid CanvasText; }\n\tdj-rich-text .dj-cm-break-pill { forced-color-adjust: none; background: Canvas; color: CanvasText; border: 1px solid CanvasText; }\n}\n";
188
+ export declare const CONTENT_CSS = "\ndj-rich-text ins.dj-cm-insertion { text-decoration: underline; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-success-600, #16a34a); text-decoration-skip-ink: none; background: var(--dj-color-neutral-100, #f3f4f6); }\ndj-rich-text del.dj-cm-deletion { text-decoration: line-through; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-danger-600, #dc2626); background: var(--dj-color-neutral-100, #f3f4f6); }\ndj-rich-text mark.dj-cm-highlight { background: var(--dj-color-neutral-200, #e5e7eb); color: inherit; }\ndj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: inset 0 -2px 0 var(--dj-color-warning-600, #ca8a04); }\ndj-rich-text .dj-cm-comment-button { display: inline-flex; align-items: center; justify-content: center; width: 1.1em; height: 1.1em; padding: 0; border: none; border-radius: 999px; background: var(--dj-color-primary-600, #2563eb); color: var(--dj-color-neutral-0, #fff); font-size: .75em; line-height: 1; cursor: pointer; }\ndj-rich-text .dj-cm-comment-button::before { content: \"\\1F4AC\"; }\ndj-rich-text .dj-cm-break-pill { display: inline-block; padding: 0 .3em; border-radius: 3px; background: var(--dj-color-primary-100, #dbeafe); color: var(--dj-color-primary-700, #1d4ed8); font-size: .85em; }\n.dj-cm-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }\n@media (forced-colors: active) {\n\tdj-rich-text ins.dj-cm-insertion, dj-rich-text del.dj-cm-deletion { background: transparent; text-decoration-color: CanvasText; }\n\tdj-rich-text mark.dj-cm-highlight { background: Mark; color: MarkText; border: 1px solid CanvasText; }\n\tdj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: none; border-style: dashed; }\n\tdj-rich-text .dj-cm-comment-button { forced-color-adjust: none; background: Highlight; color: HighlightText; border: 1px solid CanvasText; }\n\tdj-rich-text .dj-cm-break-pill { forced-color-adjust: none; background: Canvas; color: CanvasText; border: 1px solid CanvasText; }\n}\n";
137
189
  export {};
package/dist/nodes.js CHANGED
@@ -220,13 +220,42 @@ export class CommentNode extends DecoratorNode {
220
220
  el.textContent = this.getText();
221
221
  return { element: el };
222
222
  }
223
- /** The button the container mounts; accessible name is the comment text itself. */
223
+ /**
224
+ * The button `createDOM()`'s container span mounts; accessible name is the comment text
225
+ * itself, carried as `aria-label` rather than as DOM content. It was a text node once
226
+ * (`button.textContent = this.__text`, no other child) — the button is a fixed `1.1em`
227
+ * icon (its glyph comes from `::before` in `CONTENT_CSS`) with no `overflow: hidden`, so a
228
+ * raw text node long enough to exceed that width wrapped inside the flex box one-or-two
229
+ * characters per line, stacking real (if visually blank, white-on-blue) layout geometry
230
+ * down the column. A consumer building a `Range` over this node's rendered element —
231
+ * NovelMaker's `rangeForMark`, `range.selectNodeContents(editor.getElementByKey(key))`,
232
+ * for its reveal-on-scroll highlight — measured that geometry instead of the icon: 47
233
+ * client rects for a ~54-character comment, one wrapped line per character, and a
234
+ * `getBoundingClientRect()` union hundreds of pixels tall and off by hundreds of pixels
235
+ * vertically from the icon's own position (confirmed live: a bare comment's reveal in
236
+ * NovelMaker's Edits drawer was landing ~230px off from the icon, well over half the
237
+ * visible editor height, before this fix). A `.dj-cm-sr-only` child — the pattern
238
+ * `iconLabel()` above uses for `dj-button`'s shadow-DOM buttons — only trades that for a
239
+ * smaller but still-wrong box: `clip: rect(0,0,0,0)` hides it from *paint*, not from
240
+ * layout, and its `white-space: nowrap` keeps the text on one line but at its own full
241
+ * natural width, so a Range over it still measures a stray sliver next to the icon.
242
+ * `aria-label` gives the button its accessible name with no DOM content at all, so
243
+ * `getElementByKey(key)`'s only child (this button) is itself a childless leaf — verified
244
+ * live that `range.selectNodeContents(theContainerSpan)` then reports exactly ONE rect,
245
+ * equal to `button.getBoundingClientRect()`: browsers fall back to an empty element's own
246
+ * border box as the Range's content when there's nothing inside it left to fragment into
247
+ * text runs. This button is a plain native `<button>`, not a custom-element host —
248
+ * `iconLabel()`'s own docstring is about `aria-prohibited-attr`/`button-name` axe failures
249
+ * specific to a role-less custom-element host and a shadow button whose only slotted
250
+ * content was an `aria-hidden` icon; neither applies to a real `<button>`, which has an
251
+ * implicit role and where `aria-label` is standard and axe-clean.
252
+ */
224
253
  decorate(_editor) {
225
- if (!__classPrivateFieldGet(this, _CommentNode_el, "f") || __classPrivateFieldGet(this, _CommentNode_el, "f").textContent !== this.__text) {
254
+ if (!__classPrivateFieldGet(this, _CommentNode_el, "f") || __classPrivateFieldGet(this, _CommentNode_el, "f").getAttribute("aria-label") !== this.__text) {
226
255
  const button = document.createElement("button");
227
256
  button.type = "button";
228
257
  button.className = "dj-cm-comment-button";
229
- button.textContent = this.__text;
258
+ button.setAttribute("aria-label", this.__text);
230
259
  __classPrivateFieldSet(this, _CommentNode_el, button, "f");
231
260
  }
232
261
  return __classPrivateFieldGet(this, _CommentNode_el, "f");
@@ -306,15 +335,39 @@ export function $isCriticMark(node) {
306
335
  * so every mark stays distinguishable when backgrounds flatten. Injected once via
307
336
  * `ensureEditorStyles("dj-rich-text-criticmarkup", CONTENT_CSS)`, called from the plugin's own
308
337
  * `setup()` (Track T), not from this module — node files don't touch the DOM at import time.
338
+ *
339
+ * **Dark-mode fix, found in a real consumer's browser pass (NovelMaker, Q1.7 of its
340
+ * `search-and-edits-spec.md`, 2026-09-27) and worth stating so it is not reintroduced.** The first
341
+ * version of this CSS painted insertion/deletion/highlight backgrounds with `success-100`/
342
+ * `danger-100`/`warning-100` — pale tints that `theme.css` only ever defines under `:root` and
343
+ * never redefines for `.dark`/`prefers-color-scheme: dark`, so in dark mode the background stayed
344
+ * pale while the ambient text color (`color: inherit`, or no override at all) correctly turned
345
+ * near-white — pale-on-near-white, unreadable. This is the exact failure a consumer of this
346
+ * package (NovelMaker) had already hit and fixed once for its own now-retired local plugin: "a pale
347
+ * mint or pale yellow background stays pale while the text drawn on it turns near-white ... backwards
348
+ * contrast." The same version also leaned on FOUR bare tokens — `--dj-color-success`,
349
+ * `--dj-color-danger`, `--dj-color-primary`, `--dj-color-on-primary` — that this package's own
350
+ * `theme.css` never defines at all (only the numbered shades exist), so every `var(--dj-color-X,
351
+ * fallback)` using them silently and permanently resolved to its hardcoded fallback, theme or no
352
+ * theme — invisible unless someone actually diffed light against dark, which is exactly how this
353
+ * shipped unnoticed. **The fix, and the rule for the next person editing this block: use a token
354
+ * that is redefined on BOTH sides of the `.dark` block in `theme.css`, never a bare
355
+ * `--dj-color-<hue>` with no shade number (none exist), and never a `-100` tint alone for
356
+ * anything a reader has to read text through** — `neutral-100`/`-200` (confirmed to invert:
357
+ * `#f3f4f6`/`#e5e7eb` light, `#1f2937`/`#374151` dark) carry the backgrounds now, and the `-600`
358
+ * semantic shades (all three of `success`/`warning`/`danger` are redefined for dark, confirmed by
359
+ * reading `theme.css` directly rather than assumed) carry the decorative accent color, which only
360
+ * has to read as a thin line, not as body text.
309
361
  */
310
362
  export const CONTENT_CSS = `
311
- dj-rich-text ins.dj-cm-insertion { text-decoration: underline; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-success, #16a34a); text-decoration-skip-ink: none; background: var(--dj-color-success-100, #dcfce7); }
312
- dj-rich-text del.dj-cm-deletion { text-decoration: line-through; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-danger, #dc2626); background: var(--dj-color-danger-100, #fee2e2); }
313
- dj-rich-text mark.dj-cm-highlight { background: var(--dj-color-warning-100, #fef3c7); color: inherit; }
314
- dj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: inset 0 -2px 0 var(--dj-color-warning, #d97706); }
315
- dj-rich-text .dj-cm-comment-button { display: inline-flex; align-items: center; justify-content: center; width: 1.1em; height: 1.1em; padding: 0; border: none; border-radius: 999px; background: var(--dj-color-primary, #2563eb); color: var(--dj-color-on-primary, #fff); font-size: .75em; line-height: 1; cursor: pointer; }
363
+ dj-rich-text ins.dj-cm-insertion { text-decoration: underline; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-success-600, #16a34a); text-decoration-skip-ink: none; background: var(--dj-color-neutral-100, #f3f4f6); }
364
+ dj-rich-text del.dj-cm-deletion { text-decoration: line-through; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-danger-600, #dc2626); background: var(--dj-color-neutral-100, #f3f4f6); }
365
+ dj-rich-text mark.dj-cm-highlight { background: var(--dj-color-neutral-200, #e5e7eb); color: inherit; }
366
+ dj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: inset 0 -2px 0 var(--dj-color-warning-600, #ca8a04); }
367
+ dj-rich-text .dj-cm-comment-button { display: inline-flex; align-items: center; justify-content: center; width: 1.1em; height: 1.1em; padding: 0; border: none; border-radius: 999px; background: var(--dj-color-primary-600, #2563eb); color: var(--dj-color-neutral-0, #fff); font-size: .75em; line-height: 1; cursor: pointer; }
316
368
  dj-rich-text .dj-cm-comment-button::before { content: "\\1F4AC"; }
317
- dj-rich-text .dj-cm-break-pill { display: inline-block; padding: 0 .3em; border-radius: 3px; background: var(--dj-color-primary-100, #dbeafe); color: var(--dj-color-primary, #2563eb); font-size: .85em; }
369
+ dj-rich-text .dj-cm-break-pill { display: inline-block; padding: 0 .3em; border-radius: 3px; background: var(--dj-color-primary-100, #dbeafe); color: var(--dj-color-primary-700, #1d4ed8); font-size: .85em; }
370
+ .dj-cm-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
318
371
  @media (forced-colors: active) {
319
372
  dj-rich-text ins.dj-cm-insertion, dj-rich-text del.dj-cm-deletion { background: transparent; text-decoration-color: CanvasText; }
320
373
  dj-rich-text mark.dj-cm-highlight { background: Mark; color: MarkText; border: 1px solid CanvasText; }
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
  ];
@@ -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
@@ -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))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dojo-ng/rich-text-criticmarkup",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "license": "BSD-3-Clause",
5
5
  "description": "CriticMarkup tracked-changes plugin for @dojo-ng/rich-text",
6
6
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "clean": "rm -rf dist"
18
18
  },
19
19
  "dependencies": {
20
- "@dojo-ng/rich-text": "^0.1.0",
20
+ "@dojo-ng/rich-text": "^0.1.2",
21
21
  "@dojo-ng/i18n": "^0.1.0",
22
22
  "@dojo-ng/popup-confirmation": "^0.1.0",
23
23
  "@dojo-ng/popup": "^0.1.0",