@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,298 @@
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
+
25
+ import {useEffect} from 'react';
26
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
27
+ import {$isCodeNode, type CodeNode} from '@lexical/code';
28
+ import {
29
+ $addUpdateTag,
30
+ $createTextNode,
31
+ $getSelection,
32
+ $isRangeSelection,
33
+ $isTabNode,
34
+ $isTextNode,
35
+ COMMAND_PRIORITY_BEFORE_EDITOR,
36
+ HISTORY_PUSH_TAG,
37
+ KEY_TAB_COMMAND,
38
+ type LexicalNode,
39
+ type PointType,
40
+ type RangeSelection,
41
+ type TextNode,
42
+ } from 'lexical';
43
+
44
+ /** The narrowest space indent a block uses, and the widest. */
45
+ const MIN_SPACE_INDENT = 2;
46
+ const MAX_SPACE_INDENT = 8;
47
+
48
+ function leadingSpaces(line: string): number {
49
+ return /^ */.exec(line)?.[0].length ?? 0;
50
+ }
51
+
52
+ /**
53
+ * The indent a code block's lines use: a tab when any line starts with one;
54
+ * otherwise the fewest spaces an indented line starts with, kept between two
55
+ * and eight; otherwise two spaces.
56
+ */
57
+ function codeIndentUnit(lines: ReadonlyArray<string>): string {
58
+ if (lines.some(line => line.startsWith('\t'))) {
59
+ return '\t';
60
+ }
61
+ const widths = lines.map(leadingSpaces).filter(width => width > 0);
62
+ const width = widths.length === 0 ? MIN_SPACE_INDENT : Math.min(...widths);
63
+ return ' '.repeat(
64
+ Math.min(MAX_SPACE_INDENT, Math.max(MIN_SPACE_INDENT, width)),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * A code block's children, the offset in the block's text where each one
70
+ * starts, and that text. A line ends at a line break node or at a newline
71
+ * inside a text node: imported code holds newlines, and Enter adds line
72
+ * break nodes.
73
+ */
74
+ interface CodeLayout {
75
+ readonly children: ReadonlyArray<LexicalNode>;
76
+ readonly starts: ReadonlyArray<number>;
77
+ readonly text: string;
78
+ }
79
+
80
+ function $layoutOf(code: CodeNode): CodeLayout {
81
+ const children = code.getChildren();
82
+ const starts: number[] = [];
83
+ let text = '';
84
+ for (const child of children) {
85
+ starts.push(text.length);
86
+ text += child.getTextContent();
87
+ }
88
+ return {children, starts, text};
89
+ }
90
+
91
+ /** A text node that can take a caret and grow: any but a tab node. */
92
+ function $isPlainText(node: LexicalNode): node is TextNode {
93
+ return $isTextNode(node) && !$isTabNode(node);
94
+ }
95
+
96
+ /** The code block that holds both ends of the selection, or null. */
97
+ function $selectionCodeBlock(selection: RangeSelection): CodeNode | null {
98
+ const blockOf = (point: PointType) => {
99
+ const node = point.getNode();
100
+ return $isCodeNode(node) ? node : node.getParent();
101
+ };
102
+ const block = blockOf(selection.anchor);
103
+ return $isCodeNode(block) && block.is(blockOf(selection.focus))
104
+ ? block
105
+ : null;
106
+ }
107
+
108
+ /** Where a point of the block's selection falls in the block's text. */
109
+ function $offsetOf(
110
+ code: CodeNode,
111
+ layout: CodeLayout,
112
+ point: PointType,
113
+ ): number {
114
+ if (point.key === code.getKey()) {
115
+ return point.offset < layout.children.length
116
+ ? layout.starts[point.offset]
117
+ : layout.text.length;
118
+ }
119
+ const index = layout.children.findIndex(
120
+ child => child.getKey() === point.key,
121
+ );
122
+ return index < 0 ? 0 : layout.starts[index] + point.offset;
123
+ }
124
+
125
+ /**
126
+ * Moves a selection point to an offset in the block's text: into the text
127
+ * node there, or between children where none holds it (beside a tab or a
128
+ * line break, or on an empty line).
129
+ */
130
+ function $placePoint(code: CodeNode, point: PointType, offset: number): void {
131
+ const children = code.getChildren();
132
+ let start = 0;
133
+ for (let index = 0; index < children.length; index++) {
134
+ const child = children[index];
135
+ const size = child.getTextContentSize();
136
+ if ($isPlainText(child) && offset >= start && offset <= start + size) {
137
+ point.set(child.getKey(), offset - start, 'text');
138
+ return;
139
+ }
140
+ if (offset <= start) {
141
+ point.set(code.getKey(), index, 'element');
142
+ return;
143
+ }
144
+ start += size;
145
+ }
146
+ point.set(code.getKey(), children.length, 'element');
147
+ }
148
+
149
+ /** Inserts text at an offset in the block's text. */
150
+ function $insertAt(code: CodeNode, offset: number, text: string): void {
151
+ const {children, starts} = $layoutOf(code);
152
+ for (let index = 0; index < children.length; index++) {
153
+ const child = children[index];
154
+ const start = starts[index];
155
+ if (
156
+ $isPlainText(child) &&
157
+ offset >= start &&
158
+ offset <= start + child.getTextContentSize()
159
+ ) {
160
+ const content = child.getTextContent();
161
+ const at = offset - start;
162
+ child.setTextContent(content.slice(0, at) + text + content.slice(at));
163
+ return;
164
+ }
165
+ if (offset <= start) {
166
+ child.insertBefore($createTextNode(text));
167
+ return;
168
+ }
169
+ }
170
+ code.append($createTextNode(text));
171
+ }
172
+
173
+ /** Removes `count` characters at an offset in the block's text. */
174
+ function $removeAt(code: CodeNode, offset: number, count: number): void {
175
+ const {children, starts} = $layoutOf(code);
176
+ let left = count;
177
+ for (let index = 0; index < children.length && left > 0; index++) {
178
+ const child = children[index];
179
+ const size = child.getTextContentSize();
180
+ if (!$isTextNode(child) || starts[index] + size <= offset) {
181
+ continue;
182
+ }
183
+ const from = Math.max(0, offset - starts[index]);
184
+ const removed = Math.min(left, size - from);
185
+ const content = child.getTextContent();
186
+ const rest = content.slice(0, from) + content.slice(from + removed);
187
+ if (rest === '') {
188
+ child.remove();
189
+ } else {
190
+ child.setTextContent(rest);
191
+ }
192
+ left -= removed;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Indents, or outdents, every line the selection touches by the block's own
198
+ * indent, and puts the selection back on the characters it held.
199
+ */
200
+ function $shiftLines(
201
+ code: CodeNode,
202
+ selection: RangeSelection,
203
+ isOutdent: boolean,
204
+ ): void {
205
+ const layout = $layoutOf(code);
206
+ const lines = layout.text.split('\n');
207
+ const lineStarts: number[] = [];
208
+ let lineStart = 0;
209
+ for (const line of lines) {
210
+ lineStarts.push(lineStart);
211
+ lineStart += line.length + 1;
212
+ }
213
+ const lineAt = (offset: number) => {
214
+ let line = 0;
215
+ while (line + 1 < lineStarts.length && lineStarts[line + 1] <= offset) {
216
+ line++;
217
+ }
218
+ return line;
219
+ };
220
+ const unit = codeIndentUnit(lines);
221
+ // A tab-indented block outdents a space-indented line by the narrowest
222
+ // space indent.
223
+ const spaceUnit = unit === '\t' ? MIN_SPACE_INDENT : unit.length;
224
+ const anchor = $offsetOf(code, layout, selection.anchor);
225
+ const focus = $offsetOf(code, layout, selection.focus);
226
+ const start = Math.min(anchor, focus);
227
+ const end = Math.max(anchor, focus);
228
+ const first = lineAt(start);
229
+ let last = lineAt(end);
230
+ // A selection that ends where a line begins does not reach into that line.
231
+ if (last > first && lineStarts[last] === end) {
232
+ last--;
233
+ }
234
+ // Each edit as [offset, characters added (negative when removed)], made
235
+ // from the last line up so the offsets of the lines above stay true.
236
+ const edits: Array<readonly [number, number]> = [];
237
+ for (let line = last; line >= first; line--) {
238
+ const at = lineStarts[line];
239
+ if (!isOutdent) {
240
+ $insertAt(code, at, unit);
241
+ edits.push([at, unit.length]);
242
+ continue;
243
+ }
244
+ const removed = lines[line].startsWith('\t')
245
+ ? 1
246
+ : Math.min(leadingSpaces(lines[line]), spaceUnit);
247
+ if (removed > 0) {
248
+ $removeAt(code, at, removed);
249
+ edits.push([at, -removed]);
250
+ }
251
+ }
252
+ // A point keeps its character: text added at or before it pushes it on,
253
+ // and a point inside removed indentation moves to its line's start.
254
+ const shifted = (offset: number) =>
255
+ edits.reduce((moved, [at, delta]) => {
256
+ if (delta > 0) {
257
+ return offset >= at ? moved + delta : moved;
258
+ }
259
+ return offset > at ? moved - Math.min(-delta, offset - at) : moved;
260
+ }, offset);
261
+ $placePoint(code, selection.anchor, shifted(anchor));
262
+ $placePoint(code, selection.focus, shifted(focus));
263
+ }
264
+
265
+ export function CodeTabPlugin(): null {
266
+ const [editor] = useLexicalComposerContext();
267
+ useEffect(
268
+ () =>
269
+ editor.registerCommand(
270
+ KEY_TAB_COMMAND,
271
+ event => {
272
+ const selection = $getSelection();
273
+ if (!editor.isEditable() || !$isRangeSelection(selection)) {
274
+ return false;
275
+ }
276
+ const code = $selectionCodeBlock(selection);
277
+ if (code == null) {
278
+ return false;
279
+ }
280
+ event.preventDefault();
281
+ // One undo step per press, even when a press adds or removes a
282
+ // single character beside the caret. The push tag starts a step
283
+ // before the press. A dirty block makes history class the press
284
+ // as its own change, so typing after it starts another step.
285
+ $addUpdateTag(HISTORY_PUSH_TAG);
286
+ $shiftLines(code, selection, event.shiftKey);
287
+ code.markDirty();
288
+ return true;
289
+ },
290
+ // After TabFocusEscapePlugin's low-priority listener, which lets an
291
+ // armed Tab move focus, and before TabIndentationPlugin's; see the
292
+ // file header.
293
+ COMMAND_PRIORITY_BEFORE_EDITOR,
294
+ ),
295
+ [editor],
296
+ );
297
+ return null;
298
+ }
@@ -0,0 +1,93 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file DisabledLinkGuardPlugin.tsx
7
+ * @input The composer's editor, and whether RichTextEditor is disabled
8
+ * @output DisabledLinkGuardPlugin (internal; not re-exported from index.ts):
9
+ * while the editor is disabled, its links add no tab stop and never navigate
10
+ * @position Rendered by RichTextEditor inside its LexicalExtensionComposer
11
+ *
12
+ * A disabled editor is not editable, so the browser treats each link in it as
13
+ * a live link: a tab stop that navigates on a click or on Enter. A disabled
14
+ * control must not navigate, and it keeps the focus order it has when enabled,
15
+ * where its links are not tab stops.
16
+ */
17
+
18
+ import {useEffect} from 'react';
19
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
20
+
21
+ // Enter on a focused link and a screen reader's activation arrive as a click.
22
+ // A middle click arrives as auxclick.
23
+ function preventLinkActivation(event: MouseEvent) {
24
+ if (event.target instanceof Element && event.target.closest('a[href]')) {
25
+ event.preventDefault();
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Guards the links under one root element. It records the links it changes,
31
+ * so it restores only those.
32
+ */
33
+ function createLinkGuard(root: HTMLElement) {
34
+ const guarded = new Set<Element>();
35
+ const guard = () => {
36
+ for (const link of root.querySelectorAll('a[href]')) {
37
+ if (!guarded.has(link)) {
38
+ guarded.add(link);
39
+ link.setAttribute('tabindex', '-1');
40
+ // Assistive technology announces each link as disabled. Only the
41
+ // link ignores the pointer, so a wide table still scrolls.
42
+ link.setAttribute('aria-disabled', 'true');
43
+ if (link instanceof HTMLElement) {
44
+ link.style.pointerEvents = 'none';
45
+ }
46
+ }
47
+ }
48
+ };
49
+ guard();
50
+ root.addEventListener('click', preventLinkActivation, true);
51
+ root.addEventListener('auxclick', preventLinkActivation, true);
52
+ return {
53
+ guard,
54
+ release() {
55
+ for (const link of guarded) {
56
+ link.removeAttribute('tabindex');
57
+ link.removeAttribute('aria-disabled');
58
+ if (link instanceof HTMLElement) {
59
+ link.style.removeProperty('pointer-events');
60
+ }
61
+ }
62
+ guarded.clear();
63
+ root.removeEventListener('click', preventLinkActivation, true);
64
+ root.removeEventListener('auxclick', preventLinkActivation, true);
65
+ },
66
+ };
67
+ }
68
+
69
+ export function DisabledLinkGuardPlugin({isDisabled}: {isDisabled: boolean}) {
70
+ const [editor] = useLexicalComposerContext();
71
+ // Synchronizes the editor's link elements, which Lexical renders and owns,
72
+ // with the disabled state: guarded while disabled, restored when enabled.
73
+ useEffect(() => {
74
+ if (!isDisabled) {
75
+ return undefined;
76
+ }
77
+ let linkGuard: ReturnType<typeof createLinkGuard> | null = null;
78
+ const unregisterRoot = editor.registerRootListener(next => {
79
+ linkGuard?.release();
80
+ linkGuard = next == null ? null : createLinkGuard(next);
81
+ });
82
+ // A new or changed document renders new link elements.
83
+ const unregisterUpdate = editor.registerUpdateListener(() => {
84
+ linkGuard?.guard();
85
+ });
86
+ return () => {
87
+ unregisterUpdate();
88
+ unregisterRoot();
89
+ linkGuard?.release();
90
+ };
91
+ }, [editor, isDisabled]);
92
+ return null;
93
+ }
@@ -0,0 +1,159 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file ImageSourceCaretPlugin.tsx
7
+ * @input Uses @lexical/react (composer context), lexical (selection, the
8
+ * selection-change and text-insertion commands, the root element), and
9
+ * $isImageSource from markdownCharacterReferences.ts.
10
+ * @output Exports ImageSourceCaretPlugin.
11
+ * @position Rendered by RichTextEditor, always on. Lexical replaces a whole
12
+ * token with text typed inside it. So a caret inside an image's source
13
+ * moves to an edge: past the image for an arrow key, to the nearer edge
14
+ * for a click or any other move.
15
+ */
16
+
17
+ import {useEffect} from 'react';
18
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
19
+ import {mergeRegister} from '@lexical/utils';
20
+ import {
21
+ $getNodeByKey,
22
+ $getSelection,
23
+ $isRangeSelection,
24
+ COMMAND_PRIORITY_CRITICAL,
25
+ CONTROLLED_TEXT_INSERTION_COMMAND,
26
+ SELECTION_CHANGE_COMMAND,
27
+ type LexicalEditor,
28
+ type NodeKey,
29
+ type TextNode,
30
+ } from 'lexical';
31
+ import {$isImageSource} from '../markdownCharacterReferences';
32
+
33
+ interface CaretPoint {
34
+ readonly key: NodeKey;
35
+ readonly offset: number;
36
+ }
37
+
38
+ /** The collapsed caret, when it is in text. */
39
+ function $caretPoint(): CaretPoint | null {
40
+ const selection = $getSelection();
41
+ return $isRangeSelection(selection) &&
42
+ selection.isCollapsed() &&
43
+ selection.anchor.type === 'text'
44
+ ? {key: selection.anchor.key, offset: selection.anchor.offset}
45
+ : null;
46
+ }
47
+
48
+ /**
49
+ * The edge of `token` a caret at `offset` inside it moves to. An arrow key
50
+ * that moved the caret in from one edge, or from the text beside it, moves
51
+ * it on to the other edge.
52
+ */
53
+ function $edgeFor(
54
+ token: TextNode,
55
+ offset: number,
56
+ arrowFrom: CaretPoint | null,
57
+ ): number {
58
+ const size = token.getTextContentSize();
59
+ const nearer = offset < size / 2 ? 0 : size;
60
+ if (arrowFrom == null) {
61
+ return nearer;
62
+ }
63
+ if (arrowFrom.key === token.getKey()) {
64
+ return arrowFrom.offset === 0
65
+ ? size
66
+ : arrowFrom.offset === size
67
+ ? 0
68
+ : nearer;
69
+ }
70
+ const before = token.getPreviousSibling();
71
+ if (
72
+ before != null &&
73
+ before.getKey() === arrowFrom.key &&
74
+ arrowFrom.offset === before.getTextContentSize()
75
+ ) {
76
+ return size;
77
+ }
78
+ const after = token.getNextSibling();
79
+ if (
80
+ after != null &&
81
+ after.getKey() === arrowFrom.key &&
82
+ arrowFrom.offset === 0
83
+ ) {
84
+ return 0;
85
+ }
86
+ return nearer;
87
+ }
88
+
89
+ /** Moves a collapsed caret inside an image's source to an edge of it. */
90
+ function $moveCaretOutOfImage(arrowFrom: CaretPoint | null): void {
91
+ const caret = $caretPoint();
92
+ if (caret == null) {
93
+ return;
94
+ }
95
+ const node = $getNodeByKey<TextNode>(caret.key);
96
+ if (
97
+ node == null ||
98
+ !$isImageSource(node) ||
99
+ caret.offset === 0 ||
100
+ caret.offset === node.getTextContentSize()
101
+ ) {
102
+ return;
103
+ }
104
+ const edge = $edgeFor(node, caret.offset, arrowFrom);
105
+ const selection = $getSelection();
106
+ if ($isRangeSelection(selection)) {
107
+ selection.anchor.set(caret.key, edge, 'text');
108
+ selection.focus.set(caret.key, edge, 'text');
109
+ }
110
+ }
111
+
112
+ function registerImageSourceCaret(editor: LexicalEditor): () => void {
113
+ // Where the caret was when an arrow key went down; the next selection
114
+ // change reads it once. A press is a click, never an arrow key.
115
+ let arrowFrom: CaretPoint | null = null;
116
+ const onKeyDown = (event: KeyboardEvent) => {
117
+ arrowFrom =
118
+ event.key === 'ArrowLeft' || event.key === 'ArrowRight'
119
+ ? editor.getEditorState().read($caretPoint)
120
+ : null;
121
+ };
122
+ const onPointerDown = () => {
123
+ arrowFrom = null;
124
+ };
125
+ return mergeRegister(
126
+ editor.registerRootListener((root, previousRoot) => {
127
+ previousRoot?.removeEventListener('keydown', onKeyDown, true);
128
+ previousRoot?.removeEventListener('pointerdown', onPointerDown, true);
129
+ root?.addEventListener('keydown', onKeyDown, true);
130
+ root?.addEventListener('pointerdown', onPointerDown, true);
131
+ }),
132
+ editor.registerCommand(
133
+ SELECTION_CHANGE_COMMAND,
134
+ () => {
135
+ $moveCaretOutOfImage(arrowFrom);
136
+ arrowFrom = null;
137
+ return false;
138
+ },
139
+ COMMAND_PRIORITY_CRITICAL,
140
+ ),
141
+ // A key typed before the browser reports a caret move reaches the editor
142
+ // with the caret still inside the source.
143
+ editor.registerCommand(
144
+ CONTROLLED_TEXT_INSERTION_COMMAND,
145
+ () => {
146
+ $moveCaretOutOfImage(null);
147
+ return false;
148
+ },
149
+ COMMAND_PRIORITY_CRITICAL,
150
+ ),
151
+ );
152
+ }
153
+
154
+ /** Keeps a collapsed caret out of the text holding an image's source. */
155
+ export function ImageSourceCaretPlugin(): null {
156
+ const [editor] = useLexicalComposerContext();
157
+ useEffect(() => registerImageSourceCaret(editor), [editor]);
158
+ return null;
159
+ }