@dojo-ng/rich-text-criticmarkup 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/format.js +2 -2
- package/dist/grammar.d.ts +23 -0
- package/dist/grammar.js +210 -8
- package/dist/nodes.d.ts +54 -2
- package/dist/nodes.js +62 -9
- package/dist/plugin.js +82 -15
- package/dist/resolution.js +90 -12
- package/dist/transformers.js +10 -6
- package/fixtures/conformance.json +453 -62
- package/package.json +31 -5
package/dist/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
|
-
|
|
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 +=
|
|
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
|
-
/**
|
|
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; }
|