@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 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
@@ -167,7 +167,14 @@ function resolve(text, mark, side) {
167
167
  return text;
168
168
  }
169
169
  const end = mark.kind === "highlight" ? highlightEnd(text, mark) : mark.end;
170
- return text.slice(0, mark.start) + keptText(mark, side) + text.slice(end);
170
+ // Decision 18: only an ACCEPT adjusts whitespace at a paragraph-break seam. A decline restores
171
+ // the original text verbatim — `run{++¶++}together` declines back to `runtogether`, not to
172
+ // `run together` — and a highlight is kept unchanged in both directions, so neither touches the
173
+ // seam. `settleSeams` is a no-op on a string with no sentinel in it, which is every other case.
174
+ const adjusts = side === "new" && mark.kind !== "highlight";
175
+ const kept = keptText(mark, side, adjusts ? BREAK_SEAM : PARAGRAPH_BREAK);
176
+ const dropped = adjusts && kept === "" && hasStructuralToken(droppedText(mark, side));
177
+ return settleSeams(text.slice(0, mark.start) + (dropped ? JOIN_SEAM : kept) + text.slice(end));
171
178
  }
172
179
  /**
173
180
  * `mark`'s own `end`, extended to swallow an immediately adjacent `{>>...<<}` — CriticMarkup's
@@ -183,20 +190,34 @@ function highlightEnd(text, mark) {
183
190
  }
184
191
  return mark.end;
185
192
  }
186
- function keptText(mark, side) {
193
+ function keptText(mark, side, breakAs = PARAGRAPH_BREAK) {
187
194
  switch (mark.kind) {
188
195
  case "highlight":
189
- return resolveParagraphTokens(mark.text);
196
+ return resolveParagraphTokens(mark.text, PARAGRAPH_TOKEN, breakAs);
190
197
  case "substitution":
191
- return resolveParagraphTokens(side === "new" ? mark.new : mark.old);
198
+ return resolveParagraphTokens(side === "new" ? mark.new : mark.old, PARAGRAPH_TOKEN, breakAs);
192
199
  case "insertion":
193
- return side === "new" ? resolveParagraphTokens(mark.text) : "";
200
+ return side === "new" ? resolveParagraphTokens(mark.text, PARAGRAPH_TOKEN, breakAs) : "";
194
201
  case "deletion":
195
- return side === "new" ? "" : resolveParagraphTokens(mark.text);
202
+ return side === "new" ? "" : resolveParagraphTokens(mark.text, PARAGRAPH_TOKEN, breakAs);
196
203
  default:
197
204
  throw new Error(`unknown mark kind ${mark.kind}`);
198
205
  }
199
206
  }
207
+ /** The text this resolution DISCARDS — the mirror of `keptText`, and the only place a break that is
208
+ * about to disappear can still be seen. */
209
+ function droppedText(mark, side) {
210
+ switch (mark.kind) {
211
+ case "insertion":
212
+ return side === "new" ? "" : mark.text;
213
+ case "deletion":
214
+ return side === "new" ? mark.text : "";
215
+ case "substitution":
216
+ return side === "new" ? mark.old : mark.new;
217
+ default:
218
+ return "";
219
+ }
220
+ }
200
221
  /**
201
222
  * Resolve every mark as `accept` would, nested ones included. Idempotent: a body with no marks is
202
223
  * returned unchanged.
@@ -291,7 +312,7 @@ export function unescapeToken(text, token = PARAGRAPH_TOKEN) {
291
312
  * and a doubled (escaped) token becomes the single literal character it stands for. Scans
292
313
  * left-to-right so a doubled pair is consumed as a unit and never mistaken for two lone tokens.
293
314
  */
294
- function resolveParagraphTokens(text, token = PARAGRAPH_TOKEN) {
315
+ function resolveParagraphTokens(text, token = PARAGRAPH_TOKEN, breakAs = PARAGRAPH_BREAK) {
295
316
  if (token === "")
296
317
  return text;
297
318
  let out = "";
@@ -303,7 +324,7 @@ function resolveParagraphTokens(text, token = PARAGRAPH_TOKEN) {
303
324
  i += token.length * 2;
304
325
  }
305
326
  else {
306
- out += "\n\n"; // unpaired: a structural break
327
+ out += breakAs; // unpaired: a structural break
307
328
  i += token.length;
308
329
  }
309
330
  }
@@ -354,6 +375,64 @@ export function tokenizeBlockSpanning(text, token = PARAGRAPH_TOKEN) {
354
375
  export function toPortableCriticMarkup(text, token = PARAGRAPH_TOKEN) {
355
376
  return normalizeBlockSpanning(resolveParagraphTokens(text, token));
356
377
  }
378
+ // --- decision 18: whitespace at a paragraph-break seam ------------------------------------------
379
+ /** A real paragraph break, as this grammar spells one. */
380
+ const PARAGRAPH_BREAK = "\n\n";
381
+ // Two sentinels, placed by `resolve` and consumed by `settleSeams` within that same call. They exist
382
+ // so a seam adjustment can see the characters on BOTH sides of the mark — which a rule written over
383
+ // the mark's own span cannot — without a regex sweep that would touch whitespace elsewhere in the
384
+ // document that this resolution does not own (G2's "leaving all other text untouched"). Deliberately
385
+ // outside the U+E000..U+E007 block `maskNested` uses; nothing outside this file ever sees one.
386
+ const BREAK_SEAM = "\uE010"; // a break this resolution CREATES
387
+ const JOIN_SEAM = "\uE011"; // a break this resolution REMOVES, with nothing kept in its place
388
+ /** Horizontal whitespace only: a seam adjustment never eats a neighbouring line's newline. */
389
+ const HORIZONTAL_WS = /[ \t]*/;
390
+ /** True if `text` holds at least one UNPAIRED token — a structural break rather than a doubled
391
+ * literal. Scans in `resolveParagraphTokens`' left-to-right order so the two always agree on which
392
+ * tokens are structural. */
393
+ function hasStructuralToken(text, token = PARAGRAPH_TOKEN) {
394
+ if (token === "")
395
+ return false;
396
+ let i = 0;
397
+ while (i < text.length) {
398
+ if (text.startsWith(token, i)) {
399
+ if (text.startsWith(token, i + token.length)) {
400
+ i += token.length * 2;
401
+ continue;
402
+ }
403
+ return true;
404
+ }
405
+ i++;
406
+ }
407
+ return false;
408
+ }
409
+ /**
410
+ * Turn the seam sentinels into real text, which is where decision 18's two rules actually live:
411
+ *
412
+ * - A break being CREATED absorbs the horizontal whitespace on either side of it, so accepting
413
+ * `here. {++¶++}The` does not leave a space stranded at the end of the first paragraph.
414
+ * - A break being REMOVED becomes a single space, so accepting `here.{--¶--}The` reads
415
+ * `here. The` rather than `here.The` — but only when real text sits hard against both sides, so a
416
+ * merge into text that already has whitespace (or at the very start or end of the body) adds
417
+ * nothing.
418
+ */
419
+ function settleSeams(text) {
420
+ if (!text.includes(BREAK_SEAM) && !text.includes(JOIN_SEAM))
421
+ return text;
422
+ const broken = text.replace(new RegExp(`${HORIZONTAL_WS.source}${BREAK_SEAM}${HORIZONTAL_WS.source}`, "g"), PARAGRAPH_BREAK);
423
+ return settleJoinSeam(broken);
424
+ }
425
+ /** One join seam at a time, by index rather than by regex, so a character outside the Basic
426
+ * Multilingual Plane on either side of the seam is never split. */
427
+ function settleJoinSeam(text) {
428
+ const at = text.indexOf(JOIN_SEAM);
429
+ if (at < 0)
430
+ return text;
431
+ const before = text.slice(0, at);
432
+ const after = text.slice(at + JOIN_SEAM.length);
433
+ const needsSpace = before !== "" && after !== "" && !/\s$/.test(before) && !/^\s/.test(after);
434
+ return settleJoinSeam(before + (needsSpace ? " " : "") + after);
435
+ }
357
436
  // --- decision 11: nesting is detected and masked, not mangled ----------------------------------
358
437
  // One private-use sentinel per CriticMarkup delimiter character, so masking is a plain,
359
438
  // length-preserving character substitution with a trivial exact inverse.
@@ -397,3 +476,126 @@ export function unmaskNested(text) {
397
476
  out += UNMASK_OF.get(ch) ?? ch;
398
477
  return out;
399
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; }