@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 +2 -2
- package/dist/grammar.d.ts +23 -0
- package/dist/grammar.js +123 -0
- package/dist/nodes.d.ts +54 -2
- package/dist/nodes.js +62 -9
- package/dist/plugin.js +82 -15
- package/dist/resolution.js +22 -3
- package/dist/transformers.js +10 -6
- package/package.json +2 -2
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
|
-
/**
|
|
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-
|
|
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
|
-
/**
|
|
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").
|
|
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.
|
|
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-
|
|
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-
|
|
313
|
-
dj-rich-text mark.dj-cm-highlight { background: var(--dj-color-
|
|
314
|
-
dj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: inset 0 -2px 0 var(--dj-color-warning, #
|
|
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-
|
|
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, #
|
|
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
|
-
|
|
185
|
-
|
|
215
|
+
//
|
|
216
|
+
// `kind` switches to `"outlined"` while active, found missing in a real consumer's
|
|
217
|
+
// browser pass (NovelMaker, Q1.7, 2026-09-27): `aria-pressed` alone changes nothing a
|
|
218
|
+
// sighted mouse user can see — `dj-button` has no CSS reacting to it at all, the exact
|
|
219
|
+
// gap this project's own T10 track hit once before with a different attribute. Switching
|
|
220
|
+
// `kind` reuses `dj-button`'s OWN existing, already-themed outlined style (a persistent
|
|
221
|
+
// border in `--dj-color-primary-600`, confirmed to invert for dark mode by the same
|
|
222
|
+
// theme.css read that fixed `nodes.ts`'s `CONTENT_CSS`) rather than adding new CSS here
|
|
223
|
+
// that would need its own dark-mode proof.
|
|
224
|
+
render: (c) => {
|
|
225
|
+
const active = isSuggestionMode(c.editor);
|
|
226
|
+
return html `<dj-button
|
|
227
|
+
kind=${active ? "outlined" : "text"}
|
|
186
228
|
title=${msg(ctx, "suggestEdits")}
|
|
187
|
-
aria-pressed=${
|
|
188
|
-
@click=${() =>
|
|
189
|
-
|
|
190
|
-
|
|
229
|
+
aria-pressed=${active}
|
|
230
|
+
@click=${() => {
|
|
231
|
+
setSuggestionMode(c.editor, !isSuggestionMode(c.editor));
|
|
232
|
+
// isSuggestionMode is a WeakMap, entirely outside Lexical's own editor
|
|
233
|
+
// state — no document mutation, no selection change, so NEITHER of the
|
|
234
|
+
// core's two built-in re-render triggers (registerUpdateListener,
|
|
235
|
+
// onSelectionChange) ever fires from this click. Without this the click
|
|
236
|
+
// genuinely works (confirmed: isSuggestionMode(editor) flips) but the
|
|
237
|
+
// DOM — aria-pressed, the kind="outlined" border decision 18's own fix
|
|
238
|
+
// above depends on — stays stale until some UNRELATED later action
|
|
239
|
+
// happens to re-render the toolbar, which is exactly the false-positive
|
|
240
|
+
// shape that let this ship once already: a manual browser check that
|
|
241
|
+
// clicked the button and then did something else right after read the
|
|
242
|
+
// SECOND action's incidental re-render as proof the click worked. Found
|
|
243
|
+
// by checking isSuggestionMode(editor) directly against the DOM
|
|
244
|
+
// attribute after a click and nothing else — the same gap, and the
|
|
245
|
+
// same fix (`ctx.host.requestUpdate()`), NovelMaker's own retired local
|
|
246
|
+
// plugin had already hit for this exact button before this package
|
|
247
|
+
// existed; `RichTextContext.host`'s own doc comment even names
|
|
248
|
+
// requestUpdate as a reason a plugin holds a host reference.
|
|
249
|
+
// `RichTextContext.host` is typed as the narrower `HTMLElement` for the
|
|
250
|
+
// public API surface; it is always a `dj-rich-text`, a real
|
|
251
|
+
// ReactiveElement, at runtime — the same cast `RichTextContext.host`'s
|
|
252
|
+
// own doc comment invites by naming requestUpdate as a reason to hold it.
|
|
253
|
+
c.host.requestUpdate();
|
|
254
|
+
}}
|
|
255
|
+
>${iconLabel("✎", msg(ctx, "suggestEdits"))}</dj-button
|
|
256
|
+
>`;
|
|
257
|
+
},
|
|
191
258
|
},
|
|
192
259
|
{
|
|
193
260
|
id: "criticmarkup-accept",
|
|
@@ -211,7 +278,7 @@ function toolbarItems(ctx, confirmBulk) {
|
|
|
211
278
|
if (node)
|
|
212
279
|
acceptMark(c.editor, node);
|
|
213
280
|
}}
|
|
214
|
-
>${msg(ctx, "acceptMark")}</dj-button
|
|
281
|
+
>${iconLabel("✓", msg(ctx, "acceptMark"))}</dj-button
|
|
215
282
|
>`;
|
|
216
283
|
},
|
|
217
284
|
},
|
|
@@ -237,7 +304,7 @@ function toolbarItems(ctx, confirmBulk) {
|
|
|
237
304
|
if (node)
|
|
238
305
|
declineMark(c.editor, node);
|
|
239
306
|
}}
|
|
240
|
-
>${msg(ctx, "rejectMark")}</dj-button
|
|
307
|
+
>${iconLabel("✗", msg(ctx, "rejectMark"))}</dj-button
|
|
241
308
|
>`;
|
|
242
309
|
},
|
|
243
310
|
},
|
|
@@ -272,7 +339,7 @@ function toolbarItems(ctx, confirmBulk) {
|
|
|
272
339
|
const collapsed = isSelectionCollapsed(ctx.editor);
|
|
273
340
|
popup.open({ anchor, mode: collapsed ? "insert-bare" : "insert-anchored", text: "" });
|
|
274
341
|
}}
|
|
275
|
-
>${msg(ctx, "addComment")}</dj-button
|
|
342
|
+
>${iconLabel("\u{1F4AC}", msg(ctx, "addComment"))}</dj-button
|
|
276
343
|
>`,
|
|
277
344
|
},
|
|
278
345
|
];
|
package/dist/resolution.js
CHANGED
|
@@ -48,6 +48,24 @@ export function acceptAllMarks(editor) {
|
|
|
48
48
|
export function declineAllMarks(editor) {
|
|
49
49
|
resolveAllMarks(editor, "old");
|
|
50
50
|
}
|
|
51
|
+
// Lexical's own reconciler (`updateDOMSelection`, read directly from the vendored
|
|
52
|
+
// `Lexical.dev.mjs` rather than assumed) scrolls a COLLAPSED selection into view
|
|
53
|
+
// whenever an update leaves the editor root focused and the browser's own native
|
|
54
|
+
// selection no longer matches Lexical's internal one — which is exactly the shape
|
|
55
|
+
// every caller here has: a toolbar or drawer BUTTON click, never a caret move, so
|
|
56
|
+
// the native selection has almost always drifted to wherever the mouse last did
|
|
57
|
+
// something selectable, while Lexical's own last-known selection is still sitting
|
|
58
|
+
// wherever the author was last actually typing. Left untagged, resolving a mark
|
|
59
|
+
// pulls the view back to THAT old typing position instead of staying on the mark
|
|
60
|
+
// just resolved — reported against NovelMaker's Edits drawer 2026-09-27 ("hard to
|
|
61
|
+
// double-check a change after accepting it"), but the mechanism is Lexical's own
|
|
62
|
+
// reconciliation, not anything the drawer does, so every caller of `acceptMark`/
|
|
63
|
+
// `declineMark`/`acceptAllMarks`/`declineAllMarks` — the toolbar's own accept/
|
|
64
|
+
// reject buttons included — had the identical risk. `"skip-scroll-into-view"` is
|
|
65
|
+
// not exported as a named constant anywhere in Lexical's own public API, but it is
|
|
66
|
+
// the literal tag string its reconciler checks (`Lexical.dev.mjs`'s own
|
|
67
|
+
// `updateDOMSelection`), matching how Lexical's own examples use it directly.
|
|
68
|
+
const SKIP_SCROLL_TAG = "skip-scroll-into-view";
|
|
51
69
|
function resolveMark(editor, node, side) {
|
|
52
70
|
editor.update(() => {
|
|
53
71
|
const fresh = $getNodeByKey(node.getKey());
|
|
@@ -59,8 +77,9 @@ function resolveMark(editor, node, side) {
|
|
|
59
77
|
// applied synchronously, not silently batched to a later microtask. Tagged with SKIP_TAG so
|
|
60
78
|
// suggestion mode's own diff-and-wrap listener, if still on, does not see the flattened text
|
|
61
79
|
// this resolution just changed (an accepted deletion, a declined insertion, a split/merge just
|
|
62
|
-
// made real) and re-wrap it right back up as a brand-new suggestion.
|
|
63
|
-
|
|
80
|
+
// made real) and re-wrap it right back up as a brand-new suggestion. SKIP_SCROLL_TAG is the
|
|
81
|
+
// unwanted-scroll fix above.
|
|
82
|
+
{ discrete: true, tag: [SKIP_TAG, SKIP_SCROLL_TAG] });
|
|
64
83
|
}
|
|
65
84
|
function resolveAllMarks(editor, side) {
|
|
66
85
|
editor.update(() => {
|
|
@@ -71,7 +90,7 @@ function resolveAllMarks(editor, side) {
|
|
|
71
90
|
continue;
|
|
72
91
|
resolveOne(editor, node, side, resolved);
|
|
73
92
|
}
|
|
74
|
-
}, { discrete: true, tag: SKIP_TAG });
|
|
93
|
+
}, { discrete: true, tag: [SKIP_TAG, SKIP_SCROLL_TAG] });
|
|
75
94
|
}
|
|
76
95
|
/** Resolves `node` (and, if it is half of a substitution pair, its partner too), then emits
|
|
77
96
|
* `dj-criticmarkup-change`. `resolved` (bulk resolution only) records both halves of a pair so the
|
package/dist/transformers.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* these patterns look for.
|
|
18
18
|
*/
|
|
19
19
|
import { $createTextNode, $isTextNode } from "lexical";
|
|
20
|
-
import { escapeToken, unescapeToken, PARAGRAPH_TOKEN } from "./grammar.js";
|
|
20
|
+
import { escapeToken, unescapeToken, inlineFormatSegments, unmaskInlineFormat, PARAGRAPH_TOKEN } from "./grammar.js";
|
|
21
21
|
import { $createBreakNode, $isBreakNode, $createCommentNode, $isCommentNode, $createDeletionNode, $isDeletionNode, DeletionNode, $createHighlightNode, $isHighlightNode, HighlightNode, $createInsertionNode, $isInsertionNode, InsertionNode, CommentNode, } from "./nodes.js";
|
|
22
22
|
/**
|
|
23
23
|
* Split raw mark content around paragraph-token breaks (decision 16) into `TextNode`/`BreakNode`
|
|
@@ -31,9 +31,13 @@ function appendTokenSegments(parent, content, format) {
|
|
|
31
31
|
const flush = () => {
|
|
32
32
|
if (buffer === "")
|
|
33
33
|
return;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
// `maskInlineFormat` may have hidden emphasis inside the mark from the markdown import's
|
|
35
|
+
// text-format pass; rebuild it here as formatted runs. Unmasked content yields one run.
|
|
36
|
+
for (const segment of inlineFormatSegments(buffer, format)) {
|
|
37
|
+
const t = $createTextNode(unescapeToken(segment.text));
|
|
38
|
+
t.setFormat(segment.format);
|
|
39
|
+
parent.append(t);
|
|
40
|
+
}
|
|
37
41
|
buffer = "";
|
|
38
42
|
};
|
|
39
43
|
while (i < content.length) {
|
|
@@ -149,7 +153,7 @@ export const HIGHLIGHT_TRANSFORMER = {
|
|
|
149
153
|
const node = $createHighlightNode();
|
|
150
154
|
appendTokenSegments(node, raw, textNode.getFormat());
|
|
151
155
|
if (commentRaw !== undefined)
|
|
152
|
-
node.setComment(unescapeToken(commentRaw));
|
|
156
|
+
node.setComment(unescapeToken(unmaskInlineFormat(commentRaw)));
|
|
153
157
|
textNode.replace(node);
|
|
154
158
|
},
|
|
155
159
|
export: (node, _exportChildren, exportFormat) => {
|
|
@@ -168,7 +172,7 @@ export const COMMENT_TRANSFORMER = {
|
|
|
168
172
|
regExp: /\{>>(.*?)<<\}$/,
|
|
169
173
|
replace: (textNode, match) => {
|
|
170
174
|
const [, raw] = match;
|
|
171
|
-
textNode.replace($createCommentNode(unescapeToken(raw)));
|
|
175
|
+
textNode.replace($createCommentNode(unescapeToken(unmaskInlineFormat(raw))));
|
|
172
176
|
},
|
|
173
177
|
export: (node) => {
|
|
174
178
|
if (!$isCommentNode(node))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dojo-ng/rich-text-criticmarkup",
|
|
3
|
-
"version": "0.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.
|
|
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",
|