@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,86 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file SelectAllScopePlugin.tsx
7
+ * @input Uses @lexical/react (composer context), @lexical/code (CodeNode),
8
+ * @lexical/table (TableCellNode), and lexical (the select-all command and
9
+ * selection).
10
+ * @output Exports SelectAllScopePlugin, which makes Cmd+A (Ctrl+A off Apple
11
+ * platforms) select the content of the code block or table cell holding the
12
+ * caret first, and the whole document on the next press.
13
+ * @position Rendered by RichTextEditor, always on; a read-only or disabled
14
+ * editor keeps the browser's and Lexical's select all. Elsewhere, and when
15
+ * the selection already covers its code block or cell or runs outside it,
16
+ * the press falls through to Lexical's own select all, which takes the
17
+ * whole document.
18
+ */
19
+
20
+ import {useEffect} from 'react';
21
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
22
+ import {$isCodeNode} from '@lexical/code';
23
+ import {$isTableCellNode} from '@lexical/table';
24
+ import {
25
+ $getSelection,
26
+ $isBlockFullySelected,
27
+ $isRangeSelection,
28
+ COMMAND_PRIORITY_NORMAL,
29
+ SELECT_ALL_COMMAND,
30
+ type ElementNode,
31
+ type LexicalEditor,
32
+ type LexicalNode,
33
+ } from 'lexical';
34
+
35
+ /** Whether select all stops at `node` before taking the whole document. */
36
+ function $isScope(node: LexicalNode): node is ElementNode {
37
+ return $isCodeNode(node) || $isTableCellNode(node);
38
+ }
39
+
40
+ /** The scopes holding `node`, innermost first. */
41
+ function $scopesOf(node: LexicalNode): Array<ElementNode> {
42
+ const scopes: Array<ElementNode> = [];
43
+ for (let at: LexicalNode | null = node; at !== null; at = at.getParent()) {
44
+ if ($isScope(at)) {
45
+ scopes.push(at);
46
+ }
47
+ }
48
+ return scopes;
49
+ }
50
+
51
+ /**
52
+ * Selects the content of the innermost scope that holds the whole selection
53
+ * and is not yet fully selected; false, to let the document be selected,
54
+ * when there is none.
55
+ */
56
+ function $selectScope(): boolean {
57
+ const selection = $getSelection();
58
+ if (!$isRangeSelection(selection)) {
59
+ return false;
60
+ }
61
+ const focusScopes = $scopesOf(selection.focus.getNode());
62
+ for (const scope of $scopesOf(selection.anchor.getNode())) {
63
+ if (
64
+ focusScopes.some(each => each.is(scope)) &&
65
+ !$isBlockFullySelected(scope, selection)
66
+ ) {
67
+ scope.select(0, scope.getChildrenSize());
68
+ return true;
69
+ }
70
+ }
71
+ return false;
72
+ }
73
+
74
+ function registerSelectAllScope(editor: LexicalEditor): () => void {
75
+ return editor.registerCommand(
76
+ SELECT_ALL_COMMAND,
77
+ () => editor.isEditable() && $selectScope(),
78
+ COMMAND_PRIORITY_NORMAL,
79
+ );
80
+ }
81
+
82
+ export function SelectAllScopePlugin(): null {
83
+ const [editor] = useLexicalComposerContext();
84
+ useEffect(() => registerSelectAllScope(editor), [editor]);
85
+ return null;
86
+ }
@@ -0,0 +1,99 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file TableColumnFloorPlugin.tsx
7
+ * @input Uses @lexical/react (composer context), @lexical/table, and lexical.
8
+ * @output Exports TableColumnFloorPlugin, which gives each table column core
9
+ * Markdown's readable width floor, and tableColumnFloors, the rule itself.
10
+ * @position Rendered by RichTextEditor and RichTextView. Matches core
11
+ * Markdown's table column floors (spec:AST-061 FR4): automatic table layout
12
+ * already keeps every column at least as wide as its longest word, but a
13
+ * prose column's longest word is short, so a narrow table still wraps one
14
+ * word per line. The floor lets a column's longest cell wrap to about two
15
+ * lines instead, keeps a header label on one line up to a cap, and stays
16
+ * bounded so a table of short columns still fits; a wider table scrolls
17
+ * inside its wrapper. The floors are `ch` on content-box cells, so they
18
+ * follow the reader's font size and cell padding does not eat into them.
19
+ */
20
+
21
+ import {useEffect} from 'react';
22
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
23
+ import {$isTableCellNode, $isTableNode, $isTableRowNode} from '@lexical/table';
24
+ import {$getRoot} from 'lexical';
25
+
26
+ const TABLE_COLUMN_MIN_CH = 4;
27
+ const TABLE_COLUMN_MAX_CH = 24;
28
+ const TABLE_COLUMN_TARGET_LINES = 2;
29
+ const TABLE_HEADER_ONE_LINE_MAX_CH = 20;
30
+
31
+ /**
32
+ * Each column's floor in `ch`, from the text length of every cell, header row
33
+ * first: the longest cell wraps to about two lines, the header reads on one
34
+ * line up to its cap, and every floor stays between the bounds.
35
+ */
36
+ export function tableColumnFloors(
37
+ rows: ReadonlyArray<ReadonlyArray<number>>,
38
+ ): Array<number> {
39
+ const [header, ...body] = rows;
40
+ if (header == null) {
41
+ return [];
42
+ }
43
+ return header.map((headerLength, column) => {
44
+ const longest = Math.max(
45
+ headerLength,
46
+ ...body.map(row => row[column] ?? 0),
47
+ );
48
+ const bodyFloor = Math.ceil(longest / TABLE_COLUMN_TARGET_LINES);
49
+ const headerFloor = Math.min(headerLength, TABLE_HEADER_ONE_LINE_MAX_CH);
50
+ return Math.min(
51
+ TABLE_COLUMN_MAX_CH,
52
+ Math.max(TABLE_COLUMN_MIN_CH, bodyFloor, headerFloor),
53
+ );
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Sets each top-level table's header cells to their column's floor after
59
+ * every update, so the floors follow edits.
60
+ */
61
+ export function TableColumnFloorPlugin(): null {
62
+ const [editor] = useLexicalComposerContext();
63
+ useEffect(() => {
64
+ let frame = 0;
65
+ const apply = () => {
66
+ frame = 0;
67
+ editor.getEditorState().read(() => {
68
+ for (const table of $getRoot().getChildren().filter($isTableNode)) {
69
+ const rows = table
70
+ .getChildren()
71
+ .filter($isTableRowNode)
72
+ .map(row => row.getChildren().filter($isTableCellNode));
73
+ const floors = tableColumnFloors(
74
+ rows.map(cells => cells.map(cell => cell.getTextContentSize())),
75
+ );
76
+ rows[0]?.forEach((cell, column) => {
77
+ const element = editor.getElementByKey(cell.getKey());
78
+ const floor = `${floors[column]}ch`;
79
+ if (element != null && element.style.minWidth !== floor) {
80
+ element.style.minWidth = floor;
81
+ }
82
+ });
83
+ }
84
+ });
85
+ };
86
+ const schedule = () => {
87
+ if (frame === 0) {
88
+ frame = requestAnimationFrame(apply);
89
+ }
90
+ };
91
+ const unregister = editor.registerUpdateListener(schedule);
92
+ schedule();
93
+ return () => {
94
+ cancelAnimationFrame(frame);
95
+ unregister();
96
+ };
97
+ }, [editor]);
98
+ return null;
99
+ }
@@ -0,0 +1,223 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file TablePastePlugin.tsx
7
+ * @input Uses @lexical/react (composer context), @lexical/table,
8
+ * @lexical/utils, and lexical.
9
+ * @output Exports TablePastePlugin, which pastes into a table's cells without
10
+ * dropping any of the paste or emptying a cell.
11
+ * @position Rendered by RichTextEditor beside TablePlugin. It answers a paste
12
+ * that holds a table, made with the selection in one table's cells, ahead of
13
+ * TablePlugin's own handler. That handler refuses a table that comes with
14
+ * other content, and fits a pasted merged cell by merging the cells under
15
+ * it, which drops their text.
16
+ */
17
+
18
+ import {useEffect} from 'react';
19
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
20
+ import {
21
+ $computeTableCellRectBoundary,
22
+ $computeTableMap,
23
+ $computeTableMapSkipCellCheck,
24
+ $insertTableColumnAtNode,
25
+ $insertTableRowAtNode,
26
+ $isTableCellNode,
27
+ $isTableNode,
28
+ $isTableSelection,
29
+ type TableCellNode,
30
+ type TableNode,
31
+ type TableSelection,
32
+ } from '@lexical/table';
33
+ import {$dfs, $findMatchingParent} from '@lexical/utils';
34
+ import {$startsInCodeBlock} from './CodePastePlugin';
35
+ import {
36
+ $createParagraphNode,
37
+ $createTabNode,
38
+ $createTextNode,
39
+ $isRangeSelection,
40
+ COMMAND_PRIORITY_LOW,
41
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
42
+ type BaseSelection,
43
+ type LexicalNode,
44
+ type ParagraphNode,
45
+ type RangeSelection,
46
+ } from 'lexical';
47
+
48
+ /** A selection that starts and ends in cells of one table. */
49
+ interface CellSelection {
50
+ readonly selection: RangeSelection | TableSelection;
51
+ readonly table: TableNode;
52
+ readonly anchorCell: TableCellNode;
53
+ readonly focusCell: TableCellNode;
54
+ }
55
+
56
+ function $cellSelection(selection: BaseSelection): CellSelection | null {
57
+ if (!$isRangeSelection(selection) && !$isTableSelection(selection)) {
58
+ return null;
59
+ }
60
+ const points = selection.getStartEndPoints();
61
+ if (points == null) {
62
+ return null;
63
+ }
64
+ const anchorCell = $findMatchingParent(points[0].getNode(), $isTableCellNode);
65
+ const focusCell = $findMatchingParent(points[1].getNode(), $isTableCellNode);
66
+ if (anchorCell == null || focusCell == null) {
67
+ return null;
68
+ }
69
+ const table = $findMatchingParent(anchorCell, $isTableNode);
70
+ if (table == null || !table.isParentOf(focusCell)) {
71
+ return null;
72
+ }
73
+ return {selection, table, anchorCell, focusCell};
74
+ }
75
+
76
+ /**
77
+ * Writes a pasted table into the table cell by cell, starting at the
78
+ * top-left selected cell and adding the rows and columns it reaches past the
79
+ * table's edge; selected cells bound it instead. Each pasted cell replaces
80
+ * what its cell held. A merged pasted cell fills only the cell where it
81
+ * starts: the rest of its span, like the missing end of a short row, leaves
82
+ * the cells under it as they were, so no cell is merged or emptied.
83
+ */
84
+ function $pasteTableIntoCells(
85
+ pasted: TableNode,
86
+ {selection, table, anchorCell, focusCell}: CellSelection,
87
+ ): void {
88
+ const [grid, anchorEntry, focusEntry] = $computeTableMap(
89
+ table,
90
+ anchorCell,
91
+ focusCell,
92
+ );
93
+ const {minRow, minColumn, maxRow, maxColumn} = $computeTableCellRectBoundary(
94
+ grid,
95
+ anchorEntry,
96
+ focusEntry,
97
+ );
98
+ const [source] = $computeTableMapSkipCellCheck(pasted, null, null);
99
+ // Pasted rows can differ in length, so the widest one sets the width.
100
+ let rows = source.length;
101
+ let columns = Math.max(0, ...source.map(row => row.length));
102
+ if ($isTableSelection(selection)) {
103
+ rows = Math.min(rows, maxRow - minRow + 1);
104
+ columns = Math.min(columns, maxColumn - minColumn + 1);
105
+ }
106
+ for (let count = grid.length; count < minRow + rows; count++) {
107
+ const [current] = $computeTableMapSkipCellCheck(table, null, null);
108
+ $insertTableRowAtNode(current[current.length - 1][0].cell);
109
+ }
110
+ for (let count = grid[0].length; count < minColumn + columns; count++) {
111
+ const [current] = $computeTableMapSkipCellCheck(table, null, null);
112
+ $insertTableColumnAtNode(
113
+ current[0][current[0].length - 1].cell,
114
+ true,
115
+ false,
116
+ );
117
+ }
118
+ const [target] = $computeTableMapSkipCellCheck(table, null, null);
119
+ for (let row = 0; row < rows; row++) {
120
+ for (let column = 0; column < columns; column++) {
121
+ const entry = source[row][column];
122
+ if (
123
+ entry == null ||
124
+ entry.startRow !== row ||
125
+ entry.startColumn !== column
126
+ ) {
127
+ continue;
128
+ }
129
+ const cell = target[minRow + row][minColumn + column].cell;
130
+ const replaced = cell.getChildren();
131
+ cell.append(...entry.cell.getChildren());
132
+ for (const node of replaced) {
133
+ node.remove();
134
+ }
135
+ }
136
+ }
137
+ target[minRow][minColumn].cell.selectEnd();
138
+ }
139
+
140
+ /**
141
+ * A table as text a cell can hold: a paragraph per row, with the text of the
142
+ * row's cells between tabs. A merged cell's text appears once, where the
143
+ * cell starts.
144
+ */
145
+ function $tableAsParagraphs(table: TableNode): Array<ParagraphNode> {
146
+ const [grid] = $computeTableMapSkipCellCheck(table, null, null);
147
+ return grid.map((row, rowIndex) => {
148
+ const paragraph = $createParagraphNode();
149
+ row.forEach((entry, column) => {
150
+ if (column > 0) {
151
+ paragraph.append($createTabNode());
152
+ }
153
+ if (entry.startRow !== rowIndex || entry.startColumn !== column) {
154
+ return;
155
+ }
156
+ const text = entry.cell.getTextContent().replace(/\s+/g, ' ').trim();
157
+ if (text !== '') {
158
+ paragraph.append($createTextNode(text));
159
+ }
160
+ });
161
+ return paragraph;
162
+ });
163
+ }
164
+
165
+ /** `node` with every table in it, or the node itself if a table, as text. */
166
+ function $withTablesAsText(node: LexicalNode): Array<LexicalNode> {
167
+ if ($isTableNode(node)) {
168
+ return $tableAsParagraphs(node);
169
+ }
170
+ for (const {node: inner} of $dfs(node)) {
171
+ if ($isTableNode(inner)) {
172
+ for (const paragraph of $tableAsParagraphs(inner)) {
173
+ inner.insertBefore(paragraph);
174
+ }
175
+ inner.remove();
176
+ }
177
+ }
178
+ return [node];
179
+ }
180
+
181
+ /**
182
+ * Pastes a table into a table's cells. A table alone goes in cell by cell
183
+ * (`$pasteTableIntoCells`). A table that comes with other content, such as
184
+ * the paragraph and table a web page copies, goes into the cell with each
185
+ * table as text, since a cell holds no table. Any other paste is left to
186
+ * the editor's own handlers.
187
+ */
188
+ export function TablePastePlugin(): null {
189
+ const [editor] = useLexicalComposerContext();
190
+ useEffect(
191
+ () =>
192
+ editor.registerCommand(
193
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
194
+ ({nodes, selection}, dispatchEditor) => {
195
+ // A paste into a code block, even one in a cell, belongs to
196
+ // CodePastePlugin, which adds plain code lines.
197
+ if (
198
+ dispatchEditor !== editor ||
199
+ $startsInCodeBlock(selection) ||
200
+ !nodes.some(node =>
201
+ $dfs(node).some(({node: inner}) => $isTableNode(inner)),
202
+ )
203
+ ) {
204
+ return false;
205
+ }
206
+ const cells = $cellSelection(selection);
207
+ if (cells == null) {
208
+ return false;
209
+ }
210
+ if (nodes.length === 1 && $isTableNode(nodes[0])) {
211
+ $pasteTableIntoCells(nodes[0], cells);
212
+ } else {
213
+ cells.selection.insertNodes(nodes.flatMap($withTablesAsText));
214
+ }
215
+ return true;
216
+ },
217
+ // Ahead of TablePlugin's handler, which runs at the editor priority.
218
+ COMMAND_PRIORITY_LOW,
219
+ ),
220
+ [editor],
221
+ );
222
+ return null;
223
+ }
@@ -0,0 +1,107 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file TableScrollRegionPlugin.tsx
7
+ * @input Lexical's TableNode mutations, the scroll wrapper that TablePlugin's
8
+ * horizontal-scroll mode renders around each table, and core's translator
9
+ * for the region's name.
10
+ * @output TableScrollRegionPlugin: gives each table's scroll wrapper the
11
+ * semantics of core Table's scroll region.
12
+ * @position Internal to @blue-patterns/richtext; rendered by RichTextView only.
13
+ * The editor needs none of this: its caret scrolls a wide table, and a tab
14
+ * stop inside the editable surface would break Escape-then-Tab.
15
+ */
16
+
17
+ import {useEffect} from 'react';
18
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
19
+ import {TableNode} from '@lexical/table';
20
+ import {useTranslator} from '@blue-patterns/core/i18n';
21
+
22
+ interface TrackedRegion {
23
+ readonly wrapper: HTMLElement;
24
+ readonly observer: ResizeObserver | null;
25
+ }
26
+
27
+ /** A tab stop only while the table is wider than its wrapper. */
28
+ function syncTabStop(wrapper: HTMLElement): void {
29
+ if (wrapper.scrollWidth > wrapper.clientWidth) {
30
+ wrapper.tabIndex = 0;
31
+ } else {
32
+ wrapper.removeAttribute('tabindex');
33
+ }
34
+ }
35
+
36
+ /**
37
+ * A read-only view has no caret, so nothing inside a wide table can scroll it
38
+ * from the keyboard. Like core Table's scroll region, each table's wrapper is
39
+ * a group named "Table" that takes a tab stop only while the table overflows
40
+ * it; a table that fits adds no stop. The tab stop is explicit, so browsers
41
+ * that do not make scroll containers focusable on their own still reach it.
42
+ */
43
+ export function TableScrollRegionPlugin(): null {
44
+ const [editor] = useLexicalComposerContext();
45
+ const t = useTranslator();
46
+ const label = t('@astryx.table.label');
47
+
48
+ useEffect(() => {
49
+ const regions = new Map<string, TrackedRegion>();
50
+
51
+ const release = (key: string) => {
52
+ regions.get(key)?.observer?.disconnect();
53
+ regions.delete(key);
54
+ };
55
+
56
+ const track = (key: string) => {
57
+ // With horizontal scroll active, a table node's DOM is its wrapper.
58
+ const wrapper = editor.getElementByKey(key);
59
+ if (!(wrapper instanceof HTMLDivElement)) {
60
+ release(key);
61
+ return;
62
+ }
63
+ if (regions.get(key)?.wrapper === wrapper) {
64
+ syncTabStop(wrapper);
65
+ return;
66
+ }
67
+ release(key);
68
+ wrapper.setAttribute('role', 'group');
69
+ wrapper.setAttribute('aria-label', label);
70
+ let observer: ResizeObserver | null = null;
71
+ if (typeof ResizeObserver !== 'undefined') {
72
+ observer = new ResizeObserver(() => syncTabStop(wrapper));
73
+ // The wrapper resizes with its container, the table with its content.
74
+ observer.observe(wrapper);
75
+ const table = wrapper.querySelector(':scope > table');
76
+ if (table != null) {
77
+ observer.observe(table);
78
+ }
79
+ }
80
+ regions.set(key, {wrapper, observer});
81
+ syncTabStop(wrapper);
82
+ };
83
+
84
+ const unregister = editor.registerMutationListener(
85
+ TableNode,
86
+ mutations => {
87
+ for (const [key, mutation] of mutations) {
88
+ if (mutation === 'destroyed') {
89
+ release(key);
90
+ } else {
91
+ track(key);
92
+ }
93
+ }
94
+ },
95
+ {skipInitialization: false},
96
+ );
97
+
98
+ return () => {
99
+ unregister();
100
+ for (const key of [...regions.keys()]) {
101
+ release(key);
102
+ }
103
+ };
104
+ }, [editor, label]);
105
+
106
+ return null;
107
+ }