@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +275 -0
  2. package/LICENSE +22 -0
  3. package/README.md +88 -2
  4. package/astryx.integration.mjs +11 -0
  5. package/blocks/RichTextEditorShowcase.template.mjs +13 -0
  6. package/blocks/RichTextEditorShowcase.tsx +19 -0
  7. package/dist/RichTextEditor.d.ts +282 -0
  8. package/dist/RichTextEditor.d.ts.map +1 -0
  9. package/dist/RichTextEditor.js +657 -0
  10. package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
  11. package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
  12. package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
  13. package/dist/RichTextEditorToolbar.d.ts +186 -0
  14. package/dist/RichTextEditorToolbar.d.ts.map +1 -0
  15. package/dist/RichTextEditorToolbar.js +688 -0
  16. package/dist/RichTextView.d.ts +92 -0
  17. package/dist/RichTextView.d.ts.map +1 -0
  18. package/dist/RichTextView.js +267 -0
  19. package/dist/codeLanguages.d.ts +14 -0
  20. package/dist/codeLanguages.d.ts.map +1 -0
  21. package/dist/codeLanguages.js +34 -0
  22. package/dist/codeLineTokens.d.ts +53 -0
  23. package/dist/codeLineTokens.d.ts.map +1 -0
  24. package/dist/codeLineTokens.js +188 -0
  25. package/dist/editorNodes.d.ts +8 -0
  26. package/dist/editorNodes.d.ts.map +1 -0
  27. package/dist/editorNodes.js +30 -0
  28. package/dist/editorTheme.d.ts +7 -0
  29. package/dist/editorTheme.d.ts.map +1 -0
  30. package/dist/editorTheme.js +328 -0
  31. package/dist/index.d.ts +31 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +28 -0
  34. package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
  35. package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
  36. package/dist/internal/CodeBackspacePlugin.js +70 -0
  37. package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
  38. package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
  39. package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
  40. package/dist/internal/CodePastePlugin.d.ts +5 -0
  41. package/dist/internal/CodePastePlugin.d.ts.map +1 -0
  42. package/dist/internal/CodePastePlugin.js +104 -0
  43. package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
  44. package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
  45. package/dist/internal/CodeSyntaxPlugin.js +397 -0
  46. package/dist/internal/CodeTabPlugin.d.ts +2 -0
  47. package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
  48. package/dist/internal/CodeTabPlugin.js +256 -0
  49. package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
  50. package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
  51. package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
  52. package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
  53. package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
  54. package/dist/internal/ImageSourceCaretPlugin.js +105 -0
  55. package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
  56. package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
  57. package/dist/internal/ListItemShortcutsPlugin.js +314 -0
  58. package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
  59. package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
  60. package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
  61. package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
  62. package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
  63. package/dist/internal/SelectAllScopePlugin.js +67 -0
  64. package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
  65. package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
  66. package/dist/internal/TableColumnFloorPlugin.js +84 -0
  67. package/dist/internal/TablePastePlugin.d.ts +9 -0
  68. package/dist/internal/TablePastePlugin.d.ts.map +1 -0
  69. package/dist/internal/TablePastePlugin.js +183 -0
  70. package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
  71. package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
  72. package/dist/internal/TableScrollRegionPlugin.js +95 -0
  73. package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
  74. package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
  75. package/dist/internal/TaskCheckboxPlugin.js +193 -0
  76. package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
  77. package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
  78. package/dist/internal/ToolbarLinkPopover.js +292 -0
  79. package/dist/linkEditing.d.ts +6 -0
  80. package/dist/linkEditing.d.ts.map +1 -0
  81. package/dist/linkEditing.js +20 -0
  82. package/dist/linkForm.d.ts +28 -0
  83. package/dist/linkForm.d.ts.map +1 -0
  84. package/dist/linkForm.js +137 -0
  85. package/dist/linkUtils.d.ts +27 -0
  86. package/dist/linkUtils.d.ts.map +1 -0
  87. package/dist/linkUtils.js +101 -0
  88. package/dist/markdown.d.ts +17 -0
  89. package/dist/markdown.d.ts.map +1 -0
  90. package/dist/markdown.js +17 -0
  91. package/dist/markdownCharacterReferences.d.ts +135 -0
  92. package/dist/markdownCharacterReferences.d.ts.map +1 -0
  93. package/dist/markdownCharacterReferences.js +1127 -0
  94. package/dist/markdownCodeFence.d.ts +4 -0
  95. package/dist/markdownCodeFence.d.ts.map +1 -0
  96. package/dist/markdownCodeFence.js +167 -0
  97. package/dist/markdownExtensionNode.d.ts +88 -0
  98. package/dist/markdownExtensionNode.d.ts.map +1 -0
  99. package/dist/markdownExtensionNode.js +248 -0
  100. package/dist/markdownExtensions.d.ts +115 -0
  101. package/dist/markdownExtensions.d.ts.map +1 -0
  102. package/dist/markdownExtensions.js +392 -0
  103. package/dist/markdownHardLineBreak.d.ts +25 -0
  104. package/dist/markdownHardLineBreak.d.ts.map +1 -0
  105. package/dist/markdownHardLineBreak.js +59 -0
  106. package/dist/markdownHeading.d.ts +20 -0
  107. package/dist/markdownHeading.d.ts.map +1 -0
  108. package/dist/markdownHeading.js +30 -0
  109. package/dist/markdownLink.d.ts +5 -0
  110. package/dist/markdownLink.d.ts.map +1 -0
  111. package/dist/markdownLink.js +80 -0
  112. package/dist/markdownListExport.d.ts +3 -0
  113. package/dist/markdownListExport.d.ts.map +1 -0
  114. package/dist/markdownListExport.js +68 -0
  115. package/dist/markdownListIndentation.d.ts +8 -0
  116. package/dist/markdownListIndentation.d.ts.map +1 -0
  117. package/dist/markdownListIndentation.js +101 -0
  118. package/dist/markdownOrderedList.d.ts +3 -0
  119. package/dist/markdownOrderedList.d.ts.map +1 -0
  120. package/dist/markdownOrderedList.js +51 -0
  121. package/dist/markdownQuote.d.ts +22 -0
  122. package/dist/markdownQuote.d.ts.map +1 -0
  123. package/dist/markdownQuote.js +32 -0
  124. package/dist/markdownSerializers.d.ts +49 -0
  125. package/dist/markdownSerializers.d.ts.map +1 -0
  126. package/dist/markdownSerializers.js +83 -0
  127. package/dist/markdownSource.d.ts +71 -0
  128. package/dist/markdownSource.d.ts.map +1 -0
  129. package/dist/markdownSource.js +804 -0
  130. package/dist/markdownSpaceRuns.d.ts +24 -0
  131. package/dist/markdownSpaceRuns.d.ts.map +1 -0
  132. package/dist/markdownSpaceRuns.js +147 -0
  133. package/dist/markdownTable.d.ts +35 -0
  134. package/dist/markdownTable.d.ts.map +1 -0
  135. package/dist/markdownTable.js +227 -0
  136. package/dist/markdownTaskList.d.ts +48 -0
  137. package/dist/markdownTaskList.d.ts.map +1 -0
  138. package/dist/markdownTaskList.js +166 -0
  139. package/dist/markdownThematicBreak.d.ts +3 -0
  140. package/dist/markdownThematicBreak.d.ts.map +1 -0
  141. package/dist/markdownThematicBreak.js +55 -0
  142. package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
  143. package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
  144. package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
  145. package/dist/richtext.css +213 -0
  146. package/dist/textSemantics.d.ts +3 -0
  147. package/dist/textSemantics.d.ts.map +1 -0
  148. package/dist/textSemantics.js +87 -0
  149. package/package.json +158 -3
  150. package/src/RichTextEditor.doc.mjs +308 -0
  151. package/src/RichTextEditor.tsx +1109 -0
  152. package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
  153. package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
  154. package/src/RichTextEditorToolbar.doc.mjs +92 -0
  155. package/src/RichTextEditorToolbar.tsx +871 -0
  156. package/src/RichTextView.doc.mjs +101 -0
  157. package/src/RichTextView.tsx +351 -0
  158. package/src/codeLanguages.ts +42 -0
  159. package/src/codeLineTokens.ts +241 -0
  160. package/src/doc-mjs.d.ts +23 -0
  161. package/src/editorNodes.ts +44 -0
  162. package/src/editorTheme.ts +550 -0
  163. package/src/index.ts +59 -0
  164. package/src/internal/CodeBackspacePlugin.tsx +96 -0
  165. package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
  166. package/src/internal/CodePastePlugin.tsx +144 -0
  167. package/src/internal/CodeSyntaxPlugin.tsx +493 -0
  168. package/src/internal/CodeTabPlugin.tsx +298 -0
  169. package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
  170. package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
  171. package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
  172. package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
  173. package/src/internal/SelectAllScopePlugin.tsx +86 -0
  174. package/src/internal/TableColumnFloorPlugin.tsx +99 -0
  175. package/src/internal/TablePastePlugin.tsx +223 -0
  176. package/src/internal/TableScrollRegionPlugin.tsx +107 -0
  177. package/src/internal/TaskCheckboxPlugin.tsx +246 -0
  178. package/src/internal/ToolbarLinkPopover.tsx +392 -0
  179. package/src/linkEditing.ts +20 -0
  180. package/src/linkForm.tsx +156 -0
  181. package/src/linkUtils.ts +106 -0
  182. package/src/markdown.ts +25 -0
  183. package/src/markdownCharacterReferences.ts +1137 -0
  184. package/src/markdownCodeFence.ts +216 -0
  185. package/src/markdownExtensionNode.ts +339 -0
  186. package/src/markdownExtensions.ts +496 -0
  187. package/src/markdownHardLineBreak.ts +73 -0
  188. package/src/markdownHeading.ts +33 -0
  189. package/src/markdownLink.ts +86 -0
  190. package/src/markdownListExport.ts +85 -0
  191. package/src/markdownListIndentation.ts +105 -0
  192. package/src/markdownOrderedList.ts +60 -0
  193. package/src/markdownQuote.ts +35 -0
  194. package/src/markdownSerializers.ts +108 -0
  195. package/src/markdownSource.ts +1045 -0
  196. package/src/markdownSpaceRuns.ts +164 -0
  197. package/src/markdownTable.ts +309 -0
  198. package/src/markdownTaskList.ts +222 -0
  199. package/src/markdownThematicBreak.ts +66 -0
  200. package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
  201. package/src/textSemantics.ts +104 -0
@@ -0,0 +1,397 @@
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
+ import { useEffect, useState } from 'react';
28
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
29
+ import { $isCodeNode, CodeNode } from '@lexical/code';
30
+ import { $getNodeByKey, $isTextNode } from 'lexical';
31
+ import { ensureHighlightStyles, TOKEN_TYPES } from '@blue-patterns/core/CodeBlock';
32
+ import { listedLanguage, sameLanguages } from "../codeLanguages.js";
33
+ import { CodeLineTokens } from "../codeLineTokens.js";
34
+
35
+ /** The class the theme adds to every code block (editorTheme.ts). */
36
+ export const CODE_SYNTAX_CLASS = 'astryx-richtext-code';
37
+ let hasInjectedRules = false;
38
+
39
+ /**
40
+ * The highlight rules for code blocks, injected once: each token type takes
41
+ * the same syntax color token core CodeBlock's rules use.
42
+ */
43
+ function ensureRules() {
44
+ if (hasInjectedRules || typeof document === 'undefined') {
45
+ return;
46
+ }
47
+ hasInjectedRules = true;
48
+ const style = document.createElement('style');
49
+ style.setAttribute('data-astryx-richtext-code-syntax', '');
50
+ style.textContent = TOKEN_TYPES.map(type => `.${CODE_SYNTAX_CLASS}::highlight(astryx-${type}) { color: var(--color-syntax-${type}); }`).join('\n');
51
+ document.head.append(style);
52
+ }
53
+ function supportsHighlights() {
54
+ return typeof CSS !== 'undefined' && 'highlights' in CSS && typeof Highlight !== 'undefined';
55
+ }
56
+
57
+ /** A colored token: its range, and the highlight that holds it. */
58
+
59
+ /**
60
+ * A colored code block, with what an edit needs to recolor part of it.
61
+ * Offsets in the rendered text are offsets in the block's DOM text nodes
62
+ * laid end to end, which is the code itself when its line breaks render as
63
+ * text, and the code without them when each renders as an element (`flat`).
64
+ */
65
+
66
+ function highlightFor(type) {
67
+ const name = `astryx-${type}`;
68
+ let highlight = CSS.highlights.get(name);
69
+ if (highlight == null) {
70
+ highlight = new Highlight();
71
+ CSS.highlights.set(name, highlight);
72
+ }
73
+ return highlight;
74
+ }
75
+
76
+ /**
77
+ * The DOM position of `offset` in a block's rendered text: in the text node
78
+ * that holds the character there when `inside`, else in the first text node
79
+ * that reaches it. Null past the end of the text.
80
+ */
81
+ function pointAt(block, offset, inside) {
82
+ const {
83
+ texts,
84
+ starts,
85
+ length
86
+ } = block;
87
+ if (texts.length === 0 || offset > length) {
88
+ return null;
89
+ }
90
+ let low = 0;
91
+ let high = texts.length - 1;
92
+ while (low < high) {
93
+ const mid = low + high >>> 1;
94
+ const end = mid + 1 < texts.length ? starts[mid + 1] : length;
95
+ if (inside ? end > offset : end >= offset) {
96
+ high = mid;
97
+ } else {
98
+ low = mid + 1;
99
+ }
100
+ }
101
+ return [texts[low], offset - starts[low]];
102
+ }
103
+
104
+ /** Colors one line's tokens. */
105
+ function paintLine(block, line) {
106
+ // A flat block's rendered text has no line breaks before this line.
107
+ const base = block.lines.starts[line] - (block.flat ? line : 0);
108
+ const painted = [];
109
+ for (const token of block.lines.tokens[line] ?? []) {
110
+ const start = base + token.start;
111
+ const end = base + token.end;
112
+ const from = pointAt(block, start, start < end);
113
+ const to = pointAt(block, end, false);
114
+ if (from == null || to == null) {
115
+ continue;
116
+ }
117
+ const highlight = highlightFor(token.type);
118
+ try {
119
+ const range = new Range();
120
+ range.setStart(...from);
121
+ range.setEnd(...to);
122
+ highlight.add(range);
123
+ painted.push({
124
+ range,
125
+ highlight
126
+ });
127
+ } catch {
128
+ // A range the DOM refuses colors nothing.
129
+ }
130
+ }
131
+ block.ranges[line] = painted;
132
+ }
133
+ function unpaintLine(block, line) {
134
+ for (const {
135
+ range,
136
+ highlight
137
+ } of block.ranges[line] ?? []) {
138
+ highlight.delete(range);
139
+ }
140
+ block.ranges[line] = [];
141
+ }
142
+ function unpaintAll(block) {
143
+ for (let line = 0; line < block.ranges.length; line++) {
144
+ unpaintLine(block, line);
145
+ }
146
+ block.ranges = [];
147
+ }
148
+ function repaintAll(block) {
149
+ unpaintAll(block);
150
+ for (let line = 0; line < block.lines.tokens.length; line++) {
151
+ paintLine(block, line);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Lists a block's DOM text nodes in order with their offsets, and the
157
+ * Lexical text nodes that render as exactly one of them.
158
+ */
159
+ function $indexText(editor, node, block) {
160
+ const walker = document.createTreeWalker(block.element, NodeFilter.SHOW_TEXT);
161
+ while (walker.nextNode() != null) {
162
+ const text = walker.currentNode;
163
+ block.texts.push(text);
164
+ block.starts.push(block.length);
165
+ block.codeStarts.push(NaN);
166
+ block.length += text.length;
167
+ }
168
+ const indexes = new Map(block.texts.map((text, index) => [text, index]));
169
+ let codeStart = 0;
170
+ for (const child of node.getChildren()) {
171
+ const dom = $isTextNode(child) ? editor.getElementByKey(child.getKey()) : null;
172
+ const index = dom?.childNodes.length === 1 ? indexes.get(dom.firstChild) : undefined;
173
+ if (index != null) {
174
+ block.leaves.set(child.getKey(), {
175
+ index,
176
+ text: child.getTextContent()
177
+ });
178
+ block.codeStarts[index] = codeStart;
179
+ }
180
+ codeStart += child.getTextContentSize();
181
+ }
182
+ }
183
+
184
+ /** The key of the code block that holds the node `key`, if any. */
185
+ function $codeBlockOf(key) {
186
+ let node = $getNodeByKey(key)?.getParent() ?? null;
187
+ while (node != null && !$isCodeNode(node)) {
188
+ node = node.getParent();
189
+ }
190
+ return node?.getKey() ?? null;
191
+ }
192
+ export function CodeSyntaxPlugin({
193
+ codeLanguages
194
+ } = {}) {
195
+ const [editor] = useLexicalComposerContext();
196
+ // Keep one list while it names the same values, so an inline
197
+ // `codeLanguages={[…]}` does not re-colour every block on each parent
198
+ // render (adjusting state during render, not in an Effect).
199
+ const [languages, setLanguages] = useState(codeLanguages);
200
+ if (!sameLanguages(languages, codeLanguages)) {
201
+ setLanguages(codeLanguages);
202
+ }
203
+
204
+ // Synchronizes the editor's code blocks with the CSS highlight registry:
205
+ // Lexical mutation and update listeners colour each block, and the cleanup
206
+ // removes the listeners and every highlight range they added.
207
+ useEffect(() => {
208
+ if (!supportsHighlights()) {
209
+ return undefined;
210
+ }
211
+ ensureRules();
212
+ // Every code block in the editor, the ones colored, and the ones colored
213
+ // in full during the current update.
214
+ const blocks = new Set();
215
+ const colored = new Map();
216
+ const repainted = new Set();
217
+ const uncolor = key => {
218
+ const block = colored.get(key);
219
+ if (block != null) {
220
+ unpaintAll(block);
221
+ colored.delete(key);
222
+ }
223
+ };
224
+ // Colors a block in full, unless `force` is false and its element,
225
+ // language, and code are as last colored.
226
+ const $color = (key, force) => {
227
+ const node = $getNodeByKey(key);
228
+ const element = editor.getElementByKey(key);
229
+ if (!$isCodeNode(node) || element == null) {
230
+ uncolor(key);
231
+ return;
232
+ }
233
+ const code = node.getTextContent();
234
+ const language = node.getLanguage() ?? '';
235
+ const previous = colored.get(key);
236
+ if (!force && previous != null && previous.element === element && previous.language === language && previous.lines.text === code) {
237
+ return;
238
+ }
239
+ uncolor(key);
240
+ const rendered = element.textContent ?? '';
241
+ if (rendered !== code && rendered !== code.replaceAll('\n', '')) {
242
+ // Not rendered yet; the block's next change tries again.
243
+ return;
244
+ }
245
+ ensureHighlightStyles();
246
+ const block = {
247
+ element,
248
+ language,
249
+ lines: new CodeLineTokens(code, listedLanguage(languages, language) ?? language),
250
+ flat: rendered !== code,
251
+ texts: [],
252
+ starts: [],
253
+ codeStarts: [],
254
+ length: 0,
255
+ leaves: new Map(),
256
+ ranges: []
257
+ };
258
+ $indexText(editor, node, block);
259
+ repaintAll(block);
260
+ colored.set(key, block);
261
+ repainted.add(key);
262
+ };
263
+ // Recolors a block after an update that rewrote the text of its leaf
264
+ // `leafKey` and changed nothing else in it. False when the block must
265
+ // be colored in full instead.
266
+ const $recolorLeaf = (key, leafKey) => {
267
+ const block = colored.get(key);
268
+ const leaf = block?.leaves.get(leafKey);
269
+ const node = $getNodeByKey(leafKey);
270
+ const parent = node?.getParent();
271
+ if (block == null || leaf == null || !$isTextNode(node) || !$isCodeNode(parent) || parent.getKey() !== key || (parent.getLanguage() ?? '') !== block.language || editor.getElementByKey(key) !== block.element) {
272
+ return false;
273
+ }
274
+ const text = block.texts[leaf.index];
275
+ const dom = editor.getElementByKey(leafKey);
276
+ const after = node.getTextContent();
277
+ if (dom?.childNodes.length !== 1 || dom.firstChild !== text || text.data !== after) {
278
+ return false;
279
+ }
280
+ const before = leaf.text;
281
+ if (after === before) {
282
+ return true;
283
+ }
284
+ let first = 0;
285
+ while (first < before.length && first < after.length && before[first] === after[first]) {
286
+ first++;
287
+ }
288
+ let beforeEnd = before.length;
289
+ let afterEnd = after.length;
290
+ while (beforeEnd > first && afterEnd > first && before[beforeEnd - 1] === after[afterEnd - 1]) {
291
+ beforeEnd--;
292
+ afterEnd--;
293
+ }
294
+ const inserted = after.slice(first, afterEnd);
295
+ // A flat block's text holds no line breaks; one typed in would no
296
+ // longer lay the code over its rendered text.
297
+ if (block.flat && inserted.includes('\n')) {
298
+ return false;
299
+ }
300
+ const leafStart = block.codeStarts[leaf.index];
301
+ const lines = block.lines.replace(leafStart + first, beforeEnd - first, inserted);
302
+ const delta = after.length - before.length;
303
+ for (let i = leaf.index + 1; i < block.texts.length; i++) {
304
+ block.starts[i] += delta;
305
+ block.codeStarts[i] += delta;
306
+ }
307
+ block.length += delta;
308
+ leaf.text = after;
309
+ if (lines == null) {
310
+ repaintAll(block);
311
+ return true;
312
+ }
313
+ // Lexical rewrites a changed DOM text node whole, which leaves every
314
+ // range in it stale: recolor each line the node holds, and any other
315
+ // line the edit re-tokenized. Every other range sits in a text node of
316
+ // its own line and is still right.
317
+ const recolor = new Set(lines);
318
+ const last = block.lines.lineAt(leafStart + Math.max(after.length - 1, 0));
319
+ for (let i = block.lines.lineAt(leafStart); i <= last; i++) {
320
+ recolor.add(i);
321
+ }
322
+ for (const i of recolor) {
323
+ unpaintLine(block, i);
324
+ paintLine(block, i);
325
+ }
326
+ return true;
327
+ };
328
+ const unregisterMutations = editor.registerMutationListener(CodeNode, mutations => {
329
+ editor.getEditorState().read(() => {
330
+ for (const [key, mutation] of mutations) {
331
+ if (mutation === 'destroyed') {
332
+ blocks.delete(key);
333
+ uncolor(key);
334
+ } else {
335
+ blocks.add(key);
336
+ $color(key, false);
337
+ }
338
+ }
339
+ });
340
+ }, {
341
+ skipInitialization: false
342
+ });
343
+ repainted.clear();
344
+ // Typing in a block changes its text nodes, not the block itself: after
345
+ // each update, recolor the blocks whose text nodes changed. A block whose
346
+ // only change is one leaf's text takes the partial path; any other
347
+ // change colors it in full, unless the block listener just did.
348
+ const unregisterUpdates = editor.registerUpdateListener(({
349
+ editorState,
350
+ mutatedNodes,
351
+ prevEditorState
352
+ }) => {
353
+ if (mutatedNodes != null && blocks.size > 0) {
354
+ const mutations = [];
355
+ for (const nodes of mutatedNodes.values()) {
356
+ for (const mutation of nodes) {
357
+ mutations.push(mutation);
358
+ }
359
+ }
360
+ const before = prevEditorState.read(() => mutations.map(([key, mutation]) => mutation === 'created' ? null : $codeBlockOf(key)));
361
+ editorState.read(() => {
362
+ // Each changed block, with the one leaf whose text an update
363
+ // rewrote, or null when anything else in it changed.
364
+ const changed = new Map();
365
+ const note = (key, leafKey) => {
366
+ if (key != null && blocks.has(key)) {
367
+ changed.set(key, changed.has(key) ? null : leafKey);
368
+ }
369
+ };
370
+ mutations.forEach(([key, mutation], i) => {
371
+ const after = mutation === 'destroyed' ? null : $codeBlockOf(key);
372
+ const rewritten = mutation === 'updated' && after === before[i] && $isTextNode($getNodeByKey(key));
373
+ note(after, rewritten ? key : null);
374
+ if (before[i] !== after) {
375
+ note(before[i], null);
376
+ }
377
+ });
378
+ for (const [key, leafKey] of changed) {
379
+ if (!repainted.has(key) && (leafKey == null || !$recolorLeaf(key, leafKey))) {
380
+ $color(key, true);
381
+ }
382
+ }
383
+ });
384
+ }
385
+ repainted.clear();
386
+ });
387
+ return () => {
388
+ unregisterMutations();
389
+ unregisterUpdates();
390
+ for (const key of [...colored.keys()]) {
391
+ uncolor(key);
392
+ }
393
+ blocks.clear();
394
+ };
395
+ }, [editor, languages]);
396
+ return null;
397
+ }
@@ -0,0 +1,2 @@
1
+ export declare function CodeTabPlugin(): null;
2
+ //# sourceMappingURL=CodeTabPlugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CodeTabPlugin.d.ts","sourceRoot":"","sources":["../../src/internal/CodeTabPlugin.tsx"],"names":[],"mappings":"AAwQA,wBAAgB,aAAa,IAAI,IAAI,CAiCpC"}
@@ -0,0 +1,256 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file CodeTabPlugin.tsx
7
+ * @input Uses @lexical/react (composer context), @lexical/code (CodeNode), and
8
+ * lexical.
9
+ * @output Exports CodeTabPlugin, which makes Tab and Shift+Tab in a code block
10
+ * indent and outdent every line the selection touches, by the indent the
11
+ * block's own lines use.
12
+ * @position Rendered by RichTextEditor; always on, and not exported from the
13
+ * package. A press indents by the block's own indent, so edited code keeps
14
+ * the indentation its author chose; the selection stays on the same
15
+ * characters, and each press is one undo step. It replaces only what Tab
16
+ * did in a code block before: TabIndentationPlugin put a tab character in
17
+ * place of the selection. Its listener sits at the front of the
18
+ * editor-priority queue, ahead of TabIndentationPlugin and behind every
19
+ * low-priority listener, so Escape then Tab still moves focus out of the
20
+ * editor (TabFocusEscapePlugin, WCAG 2.1.2). A selection that reaches
21
+ * outside the code block keeps TabIndentationPlugin's meaning. Read-only
22
+ * and disabled editors are unchanged.
23
+ */
24
+ import { useEffect } from 'react';
25
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
26
+ import { $isCodeNode } from '@lexical/code';
27
+ import { $addUpdateTag, $createTextNode, $getSelection, $isRangeSelection, $isTabNode, $isTextNode, COMMAND_PRIORITY_BEFORE_EDITOR, HISTORY_PUSH_TAG, KEY_TAB_COMMAND } from 'lexical';
28
+
29
+ /** The narrowest space indent a block uses, and the widest. */
30
+ const MIN_SPACE_INDENT = 2;
31
+ const MAX_SPACE_INDENT = 8;
32
+ function leadingSpaces(line) {
33
+ return /^ */.exec(line)?.[0].length ?? 0;
34
+ }
35
+
36
+ /**
37
+ * The indent a code block's lines use: a tab when any line starts with one;
38
+ * otherwise the fewest spaces an indented line starts with, kept between two
39
+ * and eight; otherwise two spaces.
40
+ */
41
+ function codeIndentUnit(lines) {
42
+ if (lines.some(line => line.startsWith('\t'))) {
43
+ return '\t';
44
+ }
45
+ const widths = lines.map(leadingSpaces).filter(width => width > 0);
46
+ const width = widths.length === 0 ? MIN_SPACE_INDENT : Math.min(...widths);
47
+ return ' '.repeat(Math.min(MAX_SPACE_INDENT, Math.max(MIN_SPACE_INDENT, width)));
48
+ }
49
+
50
+ /**
51
+ * A code block's children, the offset in the block's text where each one
52
+ * starts, and that text. A line ends at a line break node or at a newline
53
+ * inside a text node: imported code holds newlines, and Enter adds line
54
+ * break nodes.
55
+ */
56
+
57
+ function $layoutOf(code) {
58
+ const children = code.getChildren();
59
+ const starts = [];
60
+ let text = '';
61
+ for (const child of children) {
62
+ starts.push(text.length);
63
+ text += child.getTextContent();
64
+ }
65
+ return {
66
+ children,
67
+ starts,
68
+ text
69
+ };
70
+ }
71
+
72
+ /** A text node that can take a caret and grow: any but a tab node. */
73
+ function $isPlainText(node) {
74
+ return $isTextNode(node) && !$isTabNode(node);
75
+ }
76
+
77
+ /** The code block that holds both ends of the selection, or null. */
78
+ function $selectionCodeBlock(selection) {
79
+ const blockOf = point => {
80
+ const node = point.getNode();
81
+ return $isCodeNode(node) ? node : node.getParent();
82
+ };
83
+ const block = blockOf(selection.anchor);
84
+ return $isCodeNode(block) && block.is(blockOf(selection.focus)) ? block : null;
85
+ }
86
+
87
+ /** Where a point of the block's selection falls in the block's text. */
88
+ function $offsetOf(code, layout, point) {
89
+ if (point.key === code.getKey()) {
90
+ return point.offset < layout.children.length ? layout.starts[point.offset] : layout.text.length;
91
+ }
92
+ const index = layout.children.findIndex(child => child.getKey() === point.key);
93
+ return index < 0 ? 0 : layout.starts[index] + point.offset;
94
+ }
95
+
96
+ /**
97
+ * Moves a selection point to an offset in the block's text: into the text
98
+ * node there, or between children where none holds it (beside a tab or a
99
+ * line break, or on an empty line).
100
+ */
101
+ function $placePoint(code, point, offset) {
102
+ const children = code.getChildren();
103
+ let start = 0;
104
+ for (let index = 0; index < children.length; index++) {
105
+ const child = children[index];
106
+ const size = child.getTextContentSize();
107
+ if ($isPlainText(child) && offset >= start && offset <= start + size) {
108
+ point.set(child.getKey(), offset - start, 'text');
109
+ return;
110
+ }
111
+ if (offset <= start) {
112
+ point.set(code.getKey(), index, 'element');
113
+ return;
114
+ }
115
+ start += size;
116
+ }
117
+ point.set(code.getKey(), children.length, 'element');
118
+ }
119
+
120
+ /** Inserts text at an offset in the block's text. */
121
+ function $insertAt(code, offset, text) {
122
+ const {
123
+ children,
124
+ starts
125
+ } = $layoutOf(code);
126
+ for (let index = 0; index < children.length; index++) {
127
+ const child = children[index];
128
+ const start = starts[index];
129
+ if ($isPlainText(child) && offset >= start && offset <= start + child.getTextContentSize()) {
130
+ const content = child.getTextContent();
131
+ const at = offset - start;
132
+ child.setTextContent(content.slice(0, at) + text + content.slice(at));
133
+ return;
134
+ }
135
+ if (offset <= start) {
136
+ child.insertBefore($createTextNode(text));
137
+ return;
138
+ }
139
+ }
140
+ code.append($createTextNode(text));
141
+ }
142
+
143
+ /** Removes `count` characters at an offset in the block's text. */
144
+ function $removeAt(code, offset, count) {
145
+ const {
146
+ children,
147
+ starts
148
+ } = $layoutOf(code);
149
+ let left = count;
150
+ for (let index = 0; index < children.length && left > 0; index++) {
151
+ const child = children[index];
152
+ const size = child.getTextContentSize();
153
+ if (!$isTextNode(child) || starts[index] + size <= offset) {
154
+ continue;
155
+ }
156
+ const from = Math.max(0, offset - starts[index]);
157
+ const removed = Math.min(left, size - from);
158
+ const content = child.getTextContent();
159
+ const rest = content.slice(0, from) + content.slice(from + removed);
160
+ if (rest === '') {
161
+ child.remove();
162
+ } else {
163
+ child.setTextContent(rest);
164
+ }
165
+ left -= removed;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Indents, or outdents, every line the selection touches by the block's own
171
+ * indent, and puts the selection back on the characters it held.
172
+ */
173
+ function $shiftLines(code, selection, isOutdent) {
174
+ const layout = $layoutOf(code);
175
+ const lines = layout.text.split('\n');
176
+ const lineStarts = [];
177
+ let lineStart = 0;
178
+ for (const line of lines) {
179
+ lineStarts.push(lineStart);
180
+ lineStart += line.length + 1;
181
+ }
182
+ const lineAt = offset => {
183
+ let line = 0;
184
+ while (line + 1 < lineStarts.length && lineStarts[line + 1] <= offset) {
185
+ line++;
186
+ }
187
+ return line;
188
+ };
189
+ const unit = codeIndentUnit(lines);
190
+ // A tab-indented block outdents a space-indented line by the narrowest
191
+ // space indent.
192
+ const spaceUnit = unit === '\t' ? MIN_SPACE_INDENT : unit.length;
193
+ const anchor = $offsetOf(code, layout, selection.anchor);
194
+ const focus = $offsetOf(code, layout, selection.focus);
195
+ const start = Math.min(anchor, focus);
196
+ const end = Math.max(anchor, focus);
197
+ const first = lineAt(start);
198
+ let last = lineAt(end);
199
+ // A selection that ends where a line begins does not reach into that line.
200
+ if (last > first && lineStarts[last] === end) {
201
+ last--;
202
+ }
203
+ // Each edit as [offset, characters added (negative when removed)], made
204
+ // from the last line up so the offsets of the lines above stay true.
205
+ const edits = [];
206
+ for (let line = last; line >= first; line--) {
207
+ const at = lineStarts[line];
208
+ if (!isOutdent) {
209
+ $insertAt(code, at, unit);
210
+ edits.push([at, unit.length]);
211
+ continue;
212
+ }
213
+ const removed = lines[line].startsWith('\t') ? 1 : Math.min(leadingSpaces(lines[line]), spaceUnit);
214
+ if (removed > 0) {
215
+ $removeAt(code, at, removed);
216
+ edits.push([at, -removed]);
217
+ }
218
+ }
219
+ // A point keeps its character: text added at or before it pushes it on,
220
+ // and a point inside removed indentation moves to its line's start.
221
+ const shifted = offset => edits.reduce((moved, [at, delta]) => {
222
+ if (delta > 0) {
223
+ return offset >= at ? moved + delta : moved;
224
+ }
225
+ return offset > at ? moved - Math.min(-delta, offset - at) : moved;
226
+ }, offset);
227
+ $placePoint(code, selection.anchor, shifted(anchor));
228
+ $placePoint(code, selection.focus, shifted(focus));
229
+ }
230
+ export function CodeTabPlugin() {
231
+ const [editor] = useLexicalComposerContext();
232
+ useEffect(() => editor.registerCommand(KEY_TAB_COMMAND, event => {
233
+ const selection = $getSelection();
234
+ if (!editor.isEditable() || !$isRangeSelection(selection)) {
235
+ return false;
236
+ }
237
+ const code = $selectionCodeBlock(selection);
238
+ if (code == null) {
239
+ return false;
240
+ }
241
+ event.preventDefault();
242
+ // One undo step per press, even when a press adds or removes a
243
+ // single character beside the caret. The push tag starts a step
244
+ // before the press. A dirty block makes history class the press
245
+ // as its own change, so typing after it starts another step.
246
+ $addUpdateTag(HISTORY_PUSH_TAG);
247
+ $shiftLines(code, selection, event.shiftKey);
248
+ code.markDirty();
249
+ return true;
250
+ },
251
+ // After TabFocusEscapePlugin's low-priority listener, which lets an
252
+ // armed Tab move focus, and before TabIndentationPlugin's; see the
253
+ // file header.
254
+ COMMAND_PRIORITY_BEFORE_EDITOR), [editor]);
255
+ return null;
256
+ }
@@ -0,0 +1,4 @@
1
+ export declare function DisabledLinkGuardPlugin({ isDisabled }: {
2
+ isDisabled: boolean;
3
+ }): null;
4
+ //# sourceMappingURL=DisabledLinkGuardPlugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DisabledLinkGuardPlugin.d.ts","sourceRoot":"","sources":["../../src/internal/DisabledLinkGuardPlugin.tsx"],"names":[],"mappings":"AAoEA,wBAAgB,uBAAuB,CAAC,EAAC,UAAU,EAAC,EAAE;IAAC,UAAU,EAAE,OAAO,CAAA;CAAC,QAwB1E"}