@blue-patterns/richtext 0.0.0-stage → 0.7.18
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/CHANGELOG.md +275 -0
- package/LICENSE +22 -0
- package/README.md +88 -2
- package/astryx.integration.mjs +11 -0
- package/blocks/RichTextEditorShowcase.template.mjs +13 -0
- package/blocks/RichTextEditorShowcase.tsx +19 -0
- package/dist/RichTextEditor.d.ts +282 -0
- package/dist/RichTextEditor.d.ts.map +1 -0
- package/dist/RichTextEditor.js +657 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
- package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
- package/dist/RichTextEditorToolbar.d.ts +186 -0
- package/dist/RichTextEditorToolbar.d.ts.map +1 -0
- package/dist/RichTextEditorToolbar.js +688 -0
- package/dist/RichTextView.d.ts +92 -0
- package/dist/RichTextView.d.ts.map +1 -0
- package/dist/RichTextView.js +267 -0
- package/dist/codeLanguages.d.ts +14 -0
- package/dist/codeLanguages.d.ts.map +1 -0
- package/dist/codeLanguages.js +34 -0
- package/dist/codeLineTokens.d.ts +53 -0
- package/dist/codeLineTokens.d.ts.map +1 -0
- package/dist/codeLineTokens.js +188 -0
- package/dist/editorNodes.d.ts +8 -0
- package/dist/editorNodes.d.ts.map +1 -0
- package/dist/editorNodes.js +30 -0
- package/dist/editorTheme.d.ts +7 -0
- package/dist/editorTheme.d.ts.map +1 -0
- package/dist/editorTheme.js +328 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
- package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
- package/dist/internal/CodeBackspacePlugin.js +70 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
- package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
- package/dist/internal/CodePastePlugin.d.ts +5 -0
- package/dist/internal/CodePastePlugin.d.ts.map +1 -0
- package/dist/internal/CodePastePlugin.js +104 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
- package/dist/internal/CodeSyntaxPlugin.js +397 -0
- package/dist/internal/CodeTabPlugin.d.ts +2 -0
- package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
- package/dist/internal/CodeTabPlugin.js +256 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
- package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
- package/dist/internal/ImageSourceCaretPlugin.js +105 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
- package/dist/internal/ListItemShortcutsPlugin.js +314 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
- package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
- package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
- package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
- package/dist/internal/SelectAllScopePlugin.js +67 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
- package/dist/internal/TableColumnFloorPlugin.js +84 -0
- package/dist/internal/TablePastePlugin.d.ts +9 -0
- package/dist/internal/TablePastePlugin.d.ts.map +1 -0
- package/dist/internal/TablePastePlugin.js +183 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
- package/dist/internal/TableScrollRegionPlugin.js +95 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
- package/dist/internal/TaskCheckboxPlugin.js +193 -0
- package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
- package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
- package/dist/internal/ToolbarLinkPopover.js +292 -0
- package/dist/linkEditing.d.ts +6 -0
- package/dist/linkEditing.d.ts.map +1 -0
- package/dist/linkEditing.js +20 -0
- package/dist/linkForm.d.ts +28 -0
- package/dist/linkForm.d.ts.map +1 -0
- package/dist/linkForm.js +137 -0
- package/dist/linkUtils.d.ts +27 -0
- package/dist/linkUtils.d.ts.map +1 -0
- package/dist/linkUtils.js +101 -0
- package/dist/markdown.d.ts +17 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +17 -0
- package/dist/markdownCharacterReferences.d.ts +135 -0
- package/dist/markdownCharacterReferences.d.ts.map +1 -0
- package/dist/markdownCharacterReferences.js +1127 -0
- package/dist/markdownCodeFence.d.ts +4 -0
- package/dist/markdownCodeFence.d.ts.map +1 -0
- package/dist/markdownCodeFence.js +167 -0
- package/dist/markdownExtensionNode.d.ts +88 -0
- package/dist/markdownExtensionNode.d.ts.map +1 -0
- package/dist/markdownExtensionNode.js +248 -0
- package/dist/markdownExtensions.d.ts +115 -0
- package/dist/markdownExtensions.d.ts.map +1 -0
- package/dist/markdownExtensions.js +392 -0
- package/dist/markdownHardLineBreak.d.ts +25 -0
- package/dist/markdownHardLineBreak.d.ts.map +1 -0
- package/dist/markdownHardLineBreak.js +59 -0
- package/dist/markdownHeading.d.ts +20 -0
- package/dist/markdownHeading.d.ts.map +1 -0
- package/dist/markdownHeading.js +30 -0
- package/dist/markdownLink.d.ts +5 -0
- package/dist/markdownLink.d.ts.map +1 -0
- package/dist/markdownLink.js +80 -0
- package/dist/markdownListExport.d.ts +3 -0
- package/dist/markdownListExport.d.ts.map +1 -0
- package/dist/markdownListExport.js +68 -0
- package/dist/markdownListIndentation.d.ts +8 -0
- package/dist/markdownListIndentation.d.ts.map +1 -0
- package/dist/markdownListIndentation.js +101 -0
- package/dist/markdownOrderedList.d.ts +3 -0
- package/dist/markdownOrderedList.d.ts.map +1 -0
- package/dist/markdownOrderedList.js +51 -0
- package/dist/markdownQuote.d.ts +22 -0
- package/dist/markdownQuote.d.ts.map +1 -0
- package/dist/markdownQuote.js +32 -0
- package/dist/markdownSerializers.d.ts +49 -0
- package/dist/markdownSerializers.d.ts.map +1 -0
- package/dist/markdownSerializers.js +83 -0
- package/dist/markdownSource.d.ts +71 -0
- package/dist/markdownSource.d.ts.map +1 -0
- package/dist/markdownSource.js +804 -0
- package/dist/markdownSpaceRuns.d.ts +24 -0
- package/dist/markdownSpaceRuns.d.ts.map +1 -0
- package/dist/markdownSpaceRuns.js +147 -0
- package/dist/markdownTable.d.ts +35 -0
- package/dist/markdownTable.d.ts.map +1 -0
- package/dist/markdownTable.js +227 -0
- package/dist/markdownTaskList.d.ts +48 -0
- package/dist/markdownTaskList.d.ts.map +1 -0
- package/dist/markdownTaskList.js +166 -0
- package/dist/markdownThematicBreak.d.ts +3 -0
- package/dist/markdownThematicBreak.d.ts.map +1 -0
- package/dist/markdownThematicBreak.js +55 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
- package/dist/richtext.css +213 -0
- package/dist/textSemantics.d.ts +3 -0
- package/dist/textSemantics.d.ts.map +1 -0
- package/dist/textSemantics.js +87 -0
- package/package.json +158 -3
- package/src/RichTextEditor.doc.mjs +308 -0
- package/src/RichTextEditor.tsx +1109 -0
- package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
- package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
- package/src/RichTextEditorToolbar.doc.mjs +92 -0
- package/src/RichTextEditorToolbar.tsx +871 -0
- package/src/RichTextView.doc.mjs +101 -0
- package/src/RichTextView.tsx +351 -0
- package/src/codeLanguages.ts +42 -0
- package/src/codeLineTokens.ts +241 -0
- package/src/doc-mjs.d.ts +23 -0
- package/src/editorNodes.ts +44 -0
- package/src/editorTheme.ts +550 -0
- package/src/index.ts +59 -0
- package/src/internal/CodeBackspacePlugin.tsx +96 -0
- package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
- package/src/internal/CodePastePlugin.tsx +144 -0
- package/src/internal/CodeSyntaxPlugin.tsx +493 -0
- package/src/internal/CodeTabPlugin.tsx +298 -0
- package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
- package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
- package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
- package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
- package/src/internal/SelectAllScopePlugin.tsx +86 -0
- package/src/internal/TableColumnFloorPlugin.tsx +99 -0
- package/src/internal/TablePastePlugin.tsx +223 -0
- package/src/internal/TableScrollRegionPlugin.tsx +107 -0
- package/src/internal/TaskCheckboxPlugin.tsx +246 -0
- package/src/internal/ToolbarLinkPopover.tsx +392 -0
- package/src/linkEditing.ts +20 -0
- package/src/linkForm.tsx +156 -0
- package/src/linkUtils.ts +106 -0
- package/src/markdown.ts +25 -0
- package/src/markdownCharacterReferences.ts +1137 -0
- package/src/markdownCodeFence.ts +216 -0
- package/src/markdownExtensionNode.ts +339 -0
- package/src/markdownExtensions.ts +496 -0
- package/src/markdownHardLineBreak.ts +73 -0
- package/src/markdownHeading.ts +33 -0
- package/src/markdownLink.ts +86 -0
- package/src/markdownListExport.ts +85 -0
- package/src/markdownListIndentation.ts +105 -0
- package/src/markdownOrderedList.ts +60 -0
- package/src/markdownQuote.ts +35 -0
- package/src/markdownSerializers.ts +108 -0
- package/src/markdownSource.ts +1045 -0
- package/src/markdownSpaceRuns.ts +164 -0
- package/src/markdownTable.ts +309 -0
- package/src/markdownTaskList.ts +222 -0
- package/src/markdownThematicBreak.ts +66 -0
- package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
- package/src/textSemantics.ts +104 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file CodeSyntaxPlugin.tsx
|
|
7
|
+
* @input Uses @lexical/react (composer context), @lexical/code (CodeNode),
|
|
8
|
+
* lexical, core CodeBlock's highlight styles and token types, and
|
|
9
|
+
* CodeLineTokens (codeLineTokens.ts), core CodeBlock's tokens kept line by
|
|
10
|
+
* line.
|
|
11
|
+
* @output Exports CodeSyntaxPlugin, which colors the code in every fenced code
|
|
12
|
+
* block with core CodeBlock's tokenizer and syntax color tokens; and
|
|
13
|
+
* CODE_SYNTAX_CLASS, the class the theme gives code blocks for it.
|
|
14
|
+
* @position Rendered by RichTextEditor and RichTextView. A fenced code block
|
|
15
|
+
* with a language core CodeBlock knows is colored as core Markdown colors
|
|
16
|
+
* it: the same tokenizer finds the same tokens, and each token type takes
|
|
17
|
+
* its `--color-syntax-*` token (spec:AST-061 FR1) — no palette of its own.
|
|
18
|
+
* The colors are CSS Custom Highlight ranges over the text Lexical renders,
|
|
19
|
+
* the way core CodeBlock colors editable code, so the document and its DOM
|
|
20
|
+
* are untouched. A block is colored in full when it appears, or when its
|
|
21
|
+
* language, its element, or its set of text nodes changes. An edit that
|
|
22
|
+
* only rewrites one text node's text re-tokenizes just the lines
|
|
23
|
+
* CodeLineTokens says it can change, and replaces only the ranges in that
|
|
24
|
+
* text node, reading no other. Browsers without the Highlight API show
|
|
25
|
+
* plain code.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {useEffect, useState} from 'react';
|
|
29
|
+
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
|
30
|
+
import {$isCodeNode, CodeNode} from '@lexical/code';
|
|
31
|
+
import {
|
|
32
|
+
$getNodeByKey,
|
|
33
|
+
$isTextNode,
|
|
34
|
+
type LexicalEditor,
|
|
35
|
+
type NodeKey,
|
|
36
|
+
type NodeMutation,
|
|
37
|
+
} from 'lexical';
|
|
38
|
+
import {
|
|
39
|
+
ensureHighlightStyles,
|
|
40
|
+
TOKEN_TYPES,
|
|
41
|
+
type CodeLanguage,
|
|
42
|
+
} from '@blue-patterns/core/CodeBlock';
|
|
43
|
+
import {listedLanguage, sameLanguages} from '../codeLanguages';
|
|
44
|
+
import {CodeLineTokens} from '../codeLineTokens';
|
|
45
|
+
|
|
46
|
+
/** The class the theme adds to every code block (editorTheme.ts). */
|
|
47
|
+
export const CODE_SYNTAX_CLASS = 'astryx-richtext-code';
|
|
48
|
+
|
|
49
|
+
let hasInjectedRules = false;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The highlight rules for code blocks, injected once: each token type takes
|
|
53
|
+
* the same syntax color token core CodeBlock's rules use.
|
|
54
|
+
*/
|
|
55
|
+
function ensureRules(): void {
|
|
56
|
+
if (hasInjectedRules || typeof document === 'undefined') {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
hasInjectedRules = true;
|
|
60
|
+
const style = document.createElement('style');
|
|
61
|
+
style.setAttribute('data-astryx-richtext-code-syntax', '');
|
|
62
|
+
style.textContent = TOKEN_TYPES.map(
|
|
63
|
+
type =>
|
|
64
|
+
`.${CODE_SYNTAX_CLASS}::highlight(astryx-${type}) { color: var(--color-syntax-${type}); }`,
|
|
65
|
+
).join('\n');
|
|
66
|
+
document.head.append(style);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function supportsHighlights(): boolean {
|
|
70
|
+
return (
|
|
71
|
+
typeof CSS !== 'undefined' &&
|
|
72
|
+
'highlights' in CSS &&
|
|
73
|
+
typeof Highlight !== 'undefined'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A colored token: its range, and the highlight that holds it. */
|
|
78
|
+
interface Painted {
|
|
79
|
+
readonly range: Range;
|
|
80
|
+
readonly highlight: Highlight;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A colored code block, with what an edit needs to recolor part of it.
|
|
85
|
+
* Offsets in the rendered text are offsets in the block's DOM text nodes
|
|
86
|
+
* laid end to end, which is the code itself when its line breaks render as
|
|
87
|
+
* text, and the code without them when each renders as an element (`flat`).
|
|
88
|
+
*/
|
|
89
|
+
interface Colored {
|
|
90
|
+
readonly element: HTMLElement;
|
|
91
|
+
readonly language: string;
|
|
92
|
+
readonly lines: CodeLineTokens;
|
|
93
|
+
readonly flat: boolean;
|
|
94
|
+
/** The block's DOM text nodes in order. */
|
|
95
|
+
readonly texts: Text[];
|
|
96
|
+
/** Where each DOM text node starts in the rendered text. */
|
|
97
|
+
readonly starts: number[];
|
|
98
|
+
/** Where each leaf's DOM text node starts in the code (NaN for others). */
|
|
99
|
+
readonly codeStarts: number[];
|
|
100
|
+
/** The rendered text's length. */
|
|
101
|
+
length: number;
|
|
102
|
+
/** The Lexical text nodes that render as one DOM text node each, by key:
|
|
103
|
+
* that node's index in `texts`, and the text last colored. */
|
|
104
|
+
readonly leaves: Map<NodeKey, {readonly index: number; text: string}>;
|
|
105
|
+
/** Each line's colored tokens. */
|
|
106
|
+
ranges: Painted[][];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function highlightFor(type: string): Highlight {
|
|
110
|
+
const name = `astryx-${type}`;
|
|
111
|
+
let highlight = CSS.highlights.get(name);
|
|
112
|
+
if (highlight == null) {
|
|
113
|
+
highlight = new Highlight();
|
|
114
|
+
CSS.highlights.set(name, highlight);
|
|
115
|
+
}
|
|
116
|
+
return highlight;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The DOM position of `offset` in a block's rendered text: in the text node
|
|
121
|
+
* that holds the character there when `inside`, else in the first text node
|
|
122
|
+
* that reaches it. Null past the end of the text.
|
|
123
|
+
*/
|
|
124
|
+
function pointAt(
|
|
125
|
+
block: Colored,
|
|
126
|
+
offset: number,
|
|
127
|
+
inside: boolean,
|
|
128
|
+
): [Text, number] | null {
|
|
129
|
+
const {texts, starts, length} = block;
|
|
130
|
+
if (texts.length === 0 || offset > length) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
let low = 0;
|
|
134
|
+
let high = texts.length - 1;
|
|
135
|
+
while (low < high) {
|
|
136
|
+
const mid = (low + high) >>> 1;
|
|
137
|
+
const end = mid + 1 < texts.length ? starts[mid + 1] : length;
|
|
138
|
+
if (inside ? end > offset : end >= offset) {
|
|
139
|
+
high = mid;
|
|
140
|
+
} else {
|
|
141
|
+
low = mid + 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return [texts[low], offset - starts[low]];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Colors one line's tokens. */
|
|
148
|
+
function paintLine(block: Colored, line: number): void {
|
|
149
|
+
// A flat block's rendered text has no line breaks before this line.
|
|
150
|
+
const base = block.lines.starts[line] - (block.flat ? line : 0);
|
|
151
|
+
const painted: Painted[] = [];
|
|
152
|
+
for (const token of block.lines.tokens[line] ?? []) {
|
|
153
|
+
const start = base + token.start;
|
|
154
|
+
const end = base + token.end;
|
|
155
|
+
const from = pointAt(block, start, start < end);
|
|
156
|
+
const to = pointAt(block, end, false);
|
|
157
|
+
if (from == null || to == null) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const highlight = highlightFor(token.type);
|
|
161
|
+
try {
|
|
162
|
+
const range = new Range();
|
|
163
|
+
range.setStart(...from);
|
|
164
|
+
range.setEnd(...to);
|
|
165
|
+
highlight.add(range);
|
|
166
|
+
painted.push({range, highlight});
|
|
167
|
+
} catch {
|
|
168
|
+
// A range the DOM refuses colors nothing.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
block.ranges[line] = painted;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function unpaintLine(block: Colored, line: number): void {
|
|
175
|
+
for (const {range, highlight} of block.ranges[line] ?? []) {
|
|
176
|
+
highlight.delete(range);
|
|
177
|
+
}
|
|
178
|
+
block.ranges[line] = [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function unpaintAll(block: Colored): void {
|
|
182
|
+
for (let line = 0; line < block.ranges.length; line++) {
|
|
183
|
+
unpaintLine(block, line);
|
|
184
|
+
}
|
|
185
|
+
block.ranges = [];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function repaintAll(block: Colored): void {
|
|
189
|
+
unpaintAll(block);
|
|
190
|
+
for (let line = 0; line < block.lines.tokens.length; line++) {
|
|
191
|
+
paintLine(block, line);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Lists a block's DOM text nodes in order with their offsets, and the
|
|
197
|
+
* Lexical text nodes that render as exactly one of them.
|
|
198
|
+
*/
|
|
199
|
+
function $indexText(
|
|
200
|
+
editor: LexicalEditor,
|
|
201
|
+
node: CodeNode,
|
|
202
|
+
block: Colored,
|
|
203
|
+
): void {
|
|
204
|
+
const walker = document.createTreeWalker(block.element, NodeFilter.SHOW_TEXT);
|
|
205
|
+
while (walker.nextNode() != null) {
|
|
206
|
+
const text = walker.currentNode as Text;
|
|
207
|
+
block.texts.push(text);
|
|
208
|
+
block.starts.push(block.length);
|
|
209
|
+
block.codeStarts.push(NaN);
|
|
210
|
+
block.length += text.length;
|
|
211
|
+
}
|
|
212
|
+
const indexes = new Map(block.texts.map((text, index) => [text, index]));
|
|
213
|
+
let codeStart = 0;
|
|
214
|
+
for (const child of node.getChildren()) {
|
|
215
|
+
const dom = $isTextNode(child)
|
|
216
|
+
? editor.getElementByKey(child.getKey())
|
|
217
|
+
: null;
|
|
218
|
+
const index =
|
|
219
|
+
dom?.childNodes.length === 1
|
|
220
|
+
? indexes.get(dom.firstChild as Text)
|
|
221
|
+
: undefined;
|
|
222
|
+
if (index != null) {
|
|
223
|
+
block.leaves.set(child.getKey(), {index, text: child.getTextContent()});
|
|
224
|
+
block.codeStarts[index] = codeStart;
|
|
225
|
+
}
|
|
226
|
+
codeStart += child.getTextContentSize();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The key of the code block that holds the node `key`, if any. */
|
|
231
|
+
function $codeBlockOf(key: NodeKey): NodeKey | null {
|
|
232
|
+
let node = $getNodeByKey(key)?.getParent() ?? null;
|
|
233
|
+
while (node != null && !$isCodeNode(node)) {
|
|
234
|
+
node = node.getParent();
|
|
235
|
+
}
|
|
236
|
+
return node?.getKey() ?? null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function CodeSyntaxPlugin({
|
|
240
|
+
codeLanguages,
|
|
241
|
+
}: {
|
|
242
|
+
/** Language values a fence may name (component:Markdown FR30). */
|
|
243
|
+
codeLanguages?: ReadonlyArray<CodeLanguage>;
|
|
244
|
+
} = {}): null {
|
|
245
|
+
const [editor] = useLexicalComposerContext();
|
|
246
|
+
// Keep one list while it names the same values, so an inline
|
|
247
|
+
// `codeLanguages={[…]}` does not re-colour every block on each parent
|
|
248
|
+
// render (adjusting state during render, not in an Effect).
|
|
249
|
+
const [languages, setLanguages] = useState(codeLanguages);
|
|
250
|
+
if (!sameLanguages(languages, codeLanguages)) {
|
|
251
|
+
setLanguages(codeLanguages);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Synchronizes the editor's code blocks with the CSS highlight registry:
|
|
255
|
+
// Lexical mutation and update listeners colour each block, and the cleanup
|
|
256
|
+
// removes the listeners and every highlight range they added.
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (!supportsHighlights()) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
ensureRules();
|
|
262
|
+
// Every code block in the editor, the ones colored, and the ones colored
|
|
263
|
+
// in full during the current update.
|
|
264
|
+
const blocks = new Set<NodeKey>();
|
|
265
|
+
const colored = new Map<NodeKey, Colored>();
|
|
266
|
+
const repainted = new Set<NodeKey>();
|
|
267
|
+
const uncolor = (key: NodeKey) => {
|
|
268
|
+
const block = colored.get(key);
|
|
269
|
+
if (block != null) {
|
|
270
|
+
unpaintAll(block);
|
|
271
|
+
colored.delete(key);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
// Colors a block in full, unless `force` is false and its element,
|
|
275
|
+
// language, and code are as last colored.
|
|
276
|
+
const $color = (key: NodeKey, force: boolean) => {
|
|
277
|
+
const node = $getNodeByKey(key);
|
|
278
|
+
const element = editor.getElementByKey(key);
|
|
279
|
+
if (!$isCodeNode(node) || element == null) {
|
|
280
|
+
uncolor(key);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const code = node.getTextContent();
|
|
284
|
+
const language = node.getLanguage() ?? '';
|
|
285
|
+
const previous = colored.get(key);
|
|
286
|
+
if (
|
|
287
|
+
!force &&
|
|
288
|
+
previous != null &&
|
|
289
|
+
previous.element === element &&
|
|
290
|
+
previous.language === language &&
|
|
291
|
+
previous.lines.text === code
|
|
292
|
+
) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
uncolor(key);
|
|
296
|
+
const rendered = element.textContent ?? '';
|
|
297
|
+
if (rendered !== code && rendered !== code.replaceAll('\n', '')) {
|
|
298
|
+
// Not rendered yet; the block's next change tries again.
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
ensureHighlightStyles();
|
|
302
|
+
const block: Colored = {
|
|
303
|
+
element,
|
|
304
|
+
language,
|
|
305
|
+
lines: new CodeLineTokens(
|
|
306
|
+
code,
|
|
307
|
+
listedLanguage(languages, language) ?? language,
|
|
308
|
+
),
|
|
309
|
+
flat: rendered !== code,
|
|
310
|
+
texts: [],
|
|
311
|
+
starts: [],
|
|
312
|
+
codeStarts: [],
|
|
313
|
+
length: 0,
|
|
314
|
+
leaves: new Map(),
|
|
315
|
+
ranges: [],
|
|
316
|
+
};
|
|
317
|
+
$indexText(editor, node, block);
|
|
318
|
+
repaintAll(block);
|
|
319
|
+
colored.set(key, block);
|
|
320
|
+
repainted.add(key);
|
|
321
|
+
};
|
|
322
|
+
// Recolors a block after an update that rewrote the text of its leaf
|
|
323
|
+
// `leafKey` and changed nothing else in it. False when the block must
|
|
324
|
+
// be colored in full instead.
|
|
325
|
+
const $recolorLeaf = (key: NodeKey, leafKey: NodeKey): boolean => {
|
|
326
|
+
const block = colored.get(key);
|
|
327
|
+
const leaf = block?.leaves.get(leafKey);
|
|
328
|
+
const node = $getNodeByKey(leafKey);
|
|
329
|
+
const parent = node?.getParent();
|
|
330
|
+
if (
|
|
331
|
+
block == null ||
|
|
332
|
+
leaf == null ||
|
|
333
|
+
!$isTextNode(node) ||
|
|
334
|
+
!$isCodeNode(parent) ||
|
|
335
|
+
parent.getKey() !== key ||
|
|
336
|
+
(parent.getLanguage() ?? '') !== block.language ||
|
|
337
|
+
editor.getElementByKey(key) !== block.element
|
|
338
|
+
) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
const text = block.texts[leaf.index];
|
|
342
|
+
const dom = editor.getElementByKey(leafKey);
|
|
343
|
+
const after = node.getTextContent();
|
|
344
|
+
if (
|
|
345
|
+
dom?.childNodes.length !== 1 ||
|
|
346
|
+
dom.firstChild !== text ||
|
|
347
|
+
text.data !== after
|
|
348
|
+
) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
const before = leaf.text;
|
|
352
|
+
if (after === before) {
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
let first = 0;
|
|
356
|
+
while (
|
|
357
|
+
first < before.length &&
|
|
358
|
+
first < after.length &&
|
|
359
|
+
before[first] === after[first]
|
|
360
|
+
) {
|
|
361
|
+
first++;
|
|
362
|
+
}
|
|
363
|
+
let beforeEnd = before.length;
|
|
364
|
+
let afterEnd = after.length;
|
|
365
|
+
while (
|
|
366
|
+
beforeEnd > first &&
|
|
367
|
+
afterEnd > first &&
|
|
368
|
+
before[beforeEnd - 1] === after[afterEnd - 1]
|
|
369
|
+
) {
|
|
370
|
+
beforeEnd--;
|
|
371
|
+
afterEnd--;
|
|
372
|
+
}
|
|
373
|
+
const inserted = after.slice(first, afterEnd);
|
|
374
|
+
// A flat block's text holds no line breaks; one typed in would no
|
|
375
|
+
// longer lay the code over its rendered text.
|
|
376
|
+
if (block.flat && inserted.includes('\n')) {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
const leafStart = block.codeStarts[leaf.index];
|
|
380
|
+
const lines = block.lines.replace(
|
|
381
|
+
leafStart + first,
|
|
382
|
+
beforeEnd - first,
|
|
383
|
+
inserted,
|
|
384
|
+
);
|
|
385
|
+
const delta = after.length - before.length;
|
|
386
|
+
for (let i = leaf.index + 1; i < block.texts.length; i++) {
|
|
387
|
+
block.starts[i] += delta;
|
|
388
|
+
block.codeStarts[i] += delta;
|
|
389
|
+
}
|
|
390
|
+
block.length += delta;
|
|
391
|
+
leaf.text = after;
|
|
392
|
+
if (lines == null) {
|
|
393
|
+
repaintAll(block);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
// Lexical rewrites a changed DOM text node whole, which leaves every
|
|
397
|
+
// range in it stale: recolor each line the node holds, and any other
|
|
398
|
+
// line the edit re-tokenized. Every other range sits in a text node of
|
|
399
|
+
// its own line and is still right.
|
|
400
|
+
const recolor = new Set(lines);
|
|
401
|
+
const last = block.lines.lineAt(
|
|
402
|
+
leafStart + Math.max(after.length - 1, 0),
|
|
403
|
+
);
|
|
404
|
+
for (let i = block.lines.lineAt(leafStart); i <= last; i++) {
|
|
405
|
+
recolor.add(i);
|
|
406
|
+
}
|
|
407
|
+
for (const i of recolor) {
|
|
408
|
+
unpaintLine(block, i);
|
|
409
|
+
paintLine(block, i);
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
};
|
|
413
|
+
const unregisterMutations = editor.registerMutationListener(
|
|
414
|
+
CodeNode,
|
|
415
|
+
mutations => {
|
|
416
|
+
editor.getEditorState().read(() => {
|
|
417
|
+
for (const [key, mutation] of mutations) {
|
|
418
|
+
if (mutation === 'destroyed') {
|
|
419
|
+
blocks.delete(key);
|
|
420
|
+
uncolor(key);
|
|
421
|
+
} else {
|
|
422
|
+
blocks.add(key);
|
|
423
|
+
$color(key, false);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
{skipInitialization: false},
|
|
429
|
+
);
|
|
430
|
+
repainted.clear();
|
|
431
|
+
// Typing in a block changes its text nodes, not the block itself: after
|
|
432
|
+
// each update, recolor the blocks whose text nodes changed. A block whose
|
|
433
|
+
// only change is one leaf's text takes the partial path; any other
|
|
434
|
+
// change colors it in full, unless the block listener just did.
|
|
435
|
+
const unregisterUpdates = editor.registerUpdateListener(
|
|
436
|
+
({editorState, mutatedNodes, prevEditorState}) => {
|
|
437
|
+
if (mutatedNodes != null && blocks.size > 0) {
|
|
438
|
+
const mutations: Array<[NodeKey, NodeMutation]> = [];
|
|
439
|
+
for (const nodes of mutatedNodes.values()) {
|
|
440
|
+
for (const mutation of nodes) {
|
|
441
|
+
mutations.push(mutation);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const before = prevEditorState.read(() =>
|
|
445
|
+
mutations.map(([key, mutation]) =>
|
|
446
|
+
mutation === 'created' ? null : $codeBlockOf(key),
|
|
447
|
+
),
|
|
448
|
+
);
|
|
449
|
+
editorState.read(() => {
|
|
450
|
+
// Each changed block, with the one leaf whose text an update
|
|
451
|
+
// rewrote, or null when anything else in it changed.
|
|
452
|
+
const changed = new Map<NodeKey, NodeKey | null>();
|
|
453
|
+
const note = (key: NodeKey | null, leafKey: NodeKey | null) => {
|
|
454
|
+
if (key != null && blocks.has(key)) {
|
|
455
|
+
changed.set(key, changed.has(key) ? null : leafKey);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
mutations.forEach(([key, mutation], i) => {
|
|
459
|
+
const after = mutation === 'destroyed' ? null : $codeBlockOf(key);
|
|
460
|
+
const rewritten =
|
|
461
|
+
mutation === 'updated' &&
|
|
462
|
+
after === before[i] &&
|
|
463
|
+
$isTextNode($getNodeByKey(key));
|
|
464
|
+
note(after, rewritten ? key : null);
|
|
465
|
+
if (before[i] !== after) {
|
|
466
|
+
note(before[i], null);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
for (const [key, leafKey] of changed) {
|
|
470
|
+
if (
|
|
471
|
+
!repainted.has(key) &&
|
|
472
|
+
(leafKey == null || !$recolorLeaf(key, leafKey))
|
|
473
|
+
) {
|
|
474
|
+
$color(key, true);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
repainted.clear();
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
return () => {
|
|
483
|
+
unregisterMutations();
|
|
484
|
+
unregisterUpdates();
|
|
485
|
+
for (const key of [...colored.keys()]) {
|
|
486
|
+
uncolor(key);
|
|
487
|
+
}
|
|
488
|
+
blocks.clear();
|
|
489
|
+
};
|
|
490
|
+
}, [editor, languages]);
|
|
491
|
+
|
|
492
|
+
return null;
|
|
493
|
+
}
|