@astryxdesign/richtext 0.7.15 → 0.7.16-canary.g9b5ad42

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.
@@ -1,2 +1,5 @@
1
+ import { type BaseSelection, type RangeSelection } from 'lexical';
2
+ /** Whether content inserted at the selection lands in a code block. */
3
+ export declare function $startsInCodeBlock(selection: BaseSelection | null): selection is RangeSelection;
1
4
  export declare function CodePastePlugin(): null;
2
5
  //# sourceMappingURL=CodePastePlugin.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"CodePastePlugin.d.ts","sourceRoot":"","sources":["../src/CodePastePlugin.tsx"],"names":[],"mappings":"AAuFA,wBAAgB,eAAe,IAAI,IAAI,CAuDtC"}
1
+ {"version":3,"file":"CodePastePlugin.d.ts","sourceRoot":"","sources":["../src/CodePastePlugin.tsx"],"names":[],"mappings":"AAmBA,OAAO,EAaL,KAAK,aAAa,EAElB,KAAK,cAAc,EACpB,MAAM,SAAS,CAAC;AAKjB,uEAAuE;AACvE,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,aAAa,GAAG,IAAI,GAC9B,SAAS,IAAI,cAAc,CAQ7B;AAqCD,wBAAgB,eAAe,IAAI,IAAI,CAuDtC"}
@@ -7,7 +7,8 @@
7
7
  * @input Uses @lexical/react (composer context), @lexical/code (CodeNode), and
8
8
  * lexical.
9
9
  * @output Exports CodePastePlugin, which makes rich content pasted or dropped
10
- * into a code block go in as plain lines of code.
10
+ * into a code block go in as plain lines of code, and $startsInCodeBlock,
11
+ * which TablePastePlugin uses to leave such a paste to it.
11
12
  * @position Rendered by RichTextEditor; always on; not exported. A paste
12
13
  * whose selection starts in a code block goes in as plain text: the
13
14
  * clipboard's plain text, or else one line per pasted block.
@@ -21,7 +22,7 @@ import { $addUpdateTag, $findMatchingParent, $getSelection, $isElementNode, $isR
21
22
  const RICH_TYPES = ['text/html', 'application/x-lexical-editor'];
22
23
 
23
24
  /** Whether content inserted at the selection lands in a code block. */
24
- function $startsInCodeBlock(selection) {
25
+ export function $startsInCodeBlock(selection) {
25
26
  if (!$isRangeSelection(selection)) {
26
27
  return false;
27
28
  }
@@ -1 +1 @@
1
- {"version":3,"file":"RichTextEditor.d.ts","sourceRoot":"","sources":["../src/RichTextEditor.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAOL,KAAK,SAAS,EACd,KAAK,GAAG,EACT,MAAM,OAAO,CAAC;AASf,OAAO,EAML,KAAK,kBAAkB,EACxB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAIlD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,0BAA0B,CAAC;AAwBlC,OAAO,KAAK,EAAC,yBAAyB,EAAC,MAAM,sBAAsB,CAAC;AACpE,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,8BAA8B,CAAC;AAK/D,OAAO,EAAC,KAAK,WAAW,EAAC,MAAM,mBAAmB,CAAC;AAKnD,OAAO,EASL,KAAK,WAAW,EAChB,KAAK,KAAK,EACV,KAAK,aAAa,EAClB,KAAK,WAAW,EAEjB,MAAM,SAAS,CAAC;AAyJjB,MAAM,MAAM,wBAAwB,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,CAAC;AAEvE,MAAM,MAAM,kBAAkB,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAEpD,MAAM,WAAW,oBAAoB;IACnC,qCAAqC;IACrC,IAAI,EAAE,wBAAwB,CAAC;IAC/B,oDAAoD;IACpD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;;OAGG;IACH,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,6EAA6E;IAC7E,cAAc,EAAE,MAAM,WAAW,CAAC;IAClC;;;;;;;OAOG;IACH,WAAW,EAAE,MAAM,MAAM,CAAC;IAC1B;;;;OAIG;IACH,OAAO,EAAE,MAAM,MAAM,CAAC;IACtB;;;OAGG;IACH,SAAS,EAAE,MAAM,aAAa,CAAC;CAChC;AAED,MAAM,WAAW,mBAAoB,SAAQ,IAAI,CAC/C,SAAS,EACT,UAAU,GAAG,cAAc,CAC5B;IACC;;;;OAIG;IACH,GAAG,CAAC,EAAE,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAC7B,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,KAAK,IAAI,CAAC;IACrE,uDAAuD;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,kBAAkB,GAAG,SAAS,CAAC;IAC/C;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,IAAI,CAAC,EAAE,kBAAkB,CAAC;IAC1B;;;;OAIG;IACH,KAAK,CAAC,EAAE,aAAa,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC;IAC1C;;;;;OAKG;IACH,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;;;;;OAiBG;IACH,YAAY,CAAC,EAAE,aAAa,CAAC,WAAW,CAAC,CAAC;IAC1C;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,aAAa,CAAC,yBAAyB,CAAC,CAAC;IAC9D;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC;IAC5C,yEAAyE;IACzE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,cAAc;2ZAiCxB,mBAAmB;;CAgQrB,CAAC"}
1
+ {"version":3,"file":"RichTextEditor.d.ts","sourceRoot":"","sources":["../src/RichTextEditor.tsx"],"names":[],"mappings":"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"}
@@ -49,6 +49,7 @@ import { TablePlugin } from '@lexical/react/LexicalTablePlugin';
49
49
  import { HorizontalRuleExtension } from '@lexical/extension';
50
50
  import { TextSemanticsExtension } from "./textSemantics.js";
51
51
  import { TableColumnFloorPlugin } from "./TableColumnFloorPlugin.js";
52
+ import { TablePastePlugin } from "./TablePastePlugin.js";
52
53
  import { CodeBackspacePlugin } from "./CodeBackspacePlugin.js";
53
54
  import { CodeBlockHeaderPlugin } from "./CodeBlockHeaderPlugin.js";
54
55
  import { CodePastePlugin } from "./CodePastePlugin.js";
@@ -364,7 +365,7 @@ export const RichTextEditor = function RichTextEditor({
364
365
  hasCellBackgroundColor: false,
365
366
  hasTabHandler: false,
366
367
  hasHorizontalScroll: true
367
- }), /*#__PURE__*/_jsx(TableColumnFloorPlugin, {}), /*#__PURE__*/_jsx(CodeBlockHeaderPlugin, {
368
+ }), /*#__PURE__*/_jsx(TableColumnFloorPlugin, {}), /*#__PURE__*/_jsx(TablePastePlugin, {}), /*#__PURE__*/_jsx(CodeBlockHeaderPlugin, {
368
369
  codeLanguages: codeLanguages
369
370
  }), /*#__PURE__*/_jsx(CodeSyntaxPlugin, {
370
371
  codeLanguages: codeLanguages
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Pastes a table into a table's cells. A table alone goes in cell by cell
3
+ * (`$pasteTableIntoCells`). A table that comes with other content, such as
4
+ * the paragraph and table a web page copies, goes into the cell with each
5
+ * table as text, since a cell holds no table. Any other paste is left to
6
+ * the editor's own handlers.
7
+ */
8
+ export declare function TablePastePlugin(): null;
9
+ //# sourceMappingURL=TablePastePlugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TablePastePlugin.d.ts","sourceRoot":"","sources":["../src/TablePastePlugin.tsx"],"names":[],"mappings":"AAoLA;;;;;;GAMG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAmCvC"}
@@ -0,0 +1,183 @@
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
+ import { useEffect } from 'react';
18
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
19
+ import { $computeTableCellRectBoundary, $computeTableMap, $computeTableMapSkipCellCheck, $insertTableColumnAtNode, $insertTableRowAtNode, $isTableCellNode, $isTableNode, $isTableSelection } from '@lexical/table';
20
+ import { $dfs, $findMatchingParent } from '@lexical/utils';
21
+ import { $startsInCodeBlock } from "./CodePastePlugin.js";
22
+ import { $createParagraphNode, $createTabNode, $createTextNode, $isRangeSelection, COMMAND_PRIORITY_LOW, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND } from 'lexical';
23
+
24
+ /** A selection that starts and ends in cells of one table. */
25
+
26
+ function $cellSelection(selection) {
27
+ if (!$isRangeSelection(selection) && !$isTableSelection(selection)) {
28
+ return null;
29
+ }
30
+ const points = selection.getStartEndPoints();
31
+ if (points == null) {
32
+ return null;
33
+ }
34
+ const anchorCell = $findMatchingParent(points[0].getNode(), $isTableCellNode);
35
+ const focusCell = $findMatchingParent(points[1].getNode(), $isTableCellNode);
36
+ if (anchorCell == null || focusCell == null) {
37
+ return null;
38
+ }
39
+ const table = $findMatchingParent(anchorCell, $isTableNode);
40
+ if (table == null || !table.isParentOf(focusCell)) {
41
+ return null;
42
+ }
43
+ return {
44
+ selection,
45
+ table,
46
+ anchorCell,
47
+ focusCell
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Writes a pasted table into the table cell by cell, starting at the
53
+ * top-left selected cell and adding the rows and columns it reaches past the
54
+ * table's edge; selected cells bound it instead. Each pasted cell replaces
55
+ * what its cell held. A merged pasted cell fills only the cell where it
56
+ * starts: the rest of its span, like the missing end of a short row, leaves
57
+ * the cells under it as they were, so no cell is merged or emptied.
58
+ */
59
+ function $pasteTableIntoCells(pasted, {
60
+ selection,
61
+ table,
62
+ anchorCell,
63
+ focusCell
64
+ }) {
65
+ const [grid, anchorEntry, focusEntry] = $computeTableMap(table, anchorCell, focusCell);
66
+ const {
67
+ minRow,
68
+ minColumn,
69
+ maxRow,
70
+ maxColumn
71
+ } = $computeTableCellRectBoundary(grid, anchorEntry, focusEntry);
72
+ const [source] = $computeTableMapSkipCellCheck(pasted, null, null);
73
+ // Pasted rows can differ in length, so the widest one sets the width.
74
+ let rows = source.length;
75
+ let columns = Math.max(0, ...source.map(row => row.length));
76
+ if ($isTableSelection(selection)) {
77
+ rows = Math.min(rows, maxRow - minRow + 1);
78
+ columns = Math.min(columns, maxColumn - minColumn + 1);
79
+ }
80
+ for (let count = grid.length; count < minRow + rows; count++) {
81
+ const [current] = $computeTableMapSkipCellCheck(table, null, null);
82
+ $insertTableRowAtNode(current[current.length - 1][0].cell);
83
+ }
84
+ for (let count = grid[0].length; count < minColumn + columns; count++) {
85
+ const [current] = $computeTableMapSkipCellCheck(table, null, null);
86
+ $insertTableColumnAtNode(current[0][current[0].length - 1].cell, true, false);
87
+ }
88
+ const [target] = $computeTableMapSkipCellCheck(table, null, null);
89
+ for (let row = 0; row < rows; row++) {
90
+ for (let column = 0; column < columns; column++) {
91
+ const entry = source[row][column];
92
+ if (entry == null || entry.startRow !== row || entry.startColumn !== column) {
93
+ continue;
94
+ }
95
+ const cell = target[minRow + row][minColumn + column].cell;
96
+ const replaced = cell.getChildren();
97
+ cell.append(...entry.cell.getChildren());
98
+ for (const node of replaced) {
99
+ node.remove();
100
+ }
101
+ }
102
+ }
103
+ target[minRow][minColumn].cell.selectEnd();
104
+ }
105
+
106
+ /**
107
+ * A table as text a cell can hold: a paragraph per row, with the text of the
108
+ * row's cells between tabs. A merged cell's text appears once, where the
109
+ * cell starts.
110
+ */
111
+ function $tableAsParagraphs(table) {
112
+ const [grid] = $computeTableMapSkipCellCheck(table, null, null);
113
+ return grid.map((row, rowIndex) => {
114
+ const paragraph = $createParagraphNode();
115
+ row.forEach((entry, column) => {
116
+ if (column > 0) {
117
+ paragraph.append($createTabNode());
118
+ }
119
+ if (entry.startRow !== rowIndex || entry.startColumn !== column) {
120
+ return;
121
+ }
122
+ const text = entry.cell.getTextContent().replace(/\s+/g, ' ').trim();
123
+ if (text !== '') {
124
+ paragraph.append($createTextNode(text));
125
+ }
126
+ });
127
+ return paragraph;
128
+ });
129
+ }
130
+
131
+ /** `node` with every table in it, or the node itself if a table, as text. */
132
+ function $withTablesAsText(node) {
133
+ if ($isTableNode(node)) {
134
+ return $tableAsParagraphs(node);
135
+ }
136
+ for (const {
137
+ node: inner
138
+ } of $dfs(node)) {
139
+ if ($isTableNode(inner)) {
140
+ for (const paragraph of $tableAsParagraphs(inner)) {
141
+ inner.insertBefore(paragraph);
142
+ }
143
+ inner.remove();
144
+ }
145
+ }
146
+ return [node];
147
+ }
148
+
149
+ /**
150
+ * Pastes a table into a table's cells. A table alone goes in cell by cell
151
+ * (`$pasteTableIntoCells`). A table that comes with other content, such as
152
+ * the paragraph and table a web page copies, goes into the cell with each
153
+ * table as text, since a cell holds no table. Any other paste is left to
154
+ * the editor's own handlers.
155
+ */
156
+ export function TablePastePlugin() {
157
+ const [editor] = useLexicalComposerContext();
158
+ useEffect(() => editor.registerCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, ({
159
+ nodes,
160
+ selection
161
+ }, dispatchEditor) => {
162
+ // A paste into a code block, even one in a cell, belongs to
163
+ // CodePastePlugin, which adds plain code lines.
164
+ if (dispatchEditor !== editor || $startsInCodeBlock(selection) || !nodes.some(node => $dfs(node).some(({
165
+ node: inner
166
+ }) => $isTableNode(inner)))) {
167
+ return false;
168
+ }
169
+ const cells = $cellSelection(selection);
170
+ if (cells == null) {
171
+ return false;
172
+ }
173
+ if (nodes.length === 1 && $isTableNode(nodes[0])) {
174
+ $pasteTableIntoCells(nodes[0], cells);
175
+ } else {
176
+ cells.selection.insertNodes(nodes.flatMap($withTablesAsText));
177
+ }
178
+ return true;
179
+ },
180
+ // Ahead of TablePlugin's handler, which runs at the editor priority.
181
+ COMMAND_PRIORITY_LOW), [editor]);
182
+ return null;
183
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@astryxdesign/richtext",
3
3
  "displayName": "Rich Text",
4
- "version": "0.7.15",
4
+ "version": "0.7.16-canary.g9b5ad42",
5
5
  "description": "Astryx rich text — a Lexical-based rich text editor and viewer, styled with Astryx design tokens.",
6
6
  "author": "Meta Open Source",
7
7
  "license": "MIT",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "peerDependencies": {
68
68
  "@stylexjs/stylex": ">=0.10.0",
69
- "@astryxdesign/core": "0.7.15",
69
+ "@astryxdesign/core": "0.7.16-canary.g9b5ad42",
70
70
  "react": ">=19.0.0",
71
71
  "react-dom": ">=19.0.0",
72
72
  "lexical": "^0.52.0",
@@ -125,6 +125,8 @@
125
125
  }
126
126
  },
127
127
  "devDependencies": {
128
+ "@astryxdesign/a11y-spec": "0.7.16-canary.g9b5ad42",
129
+ "@astryxdesign/cli": "0.7.16-canary.g9b5ad42",
128
130
  "@babel/cli": "^7.29.7",
129
131
  "@babel/core": "^7.29.7",
130
132
  "@babel/preset-react": "^7.29.7",
@@ -143,9 +145,7 @@
143
145
  "@lexical/utils": "^0.52.0",
144
146
  "@stylexjs/babel-plugin": "^0.19.1",
145
147
  "@testing-library/react": "^16.3.3",
146
- "lexical": "^0.52.0",
147
- "@astryxdesign/a11y-spec": "0.0.0",
148
- "@astryxdesign/cli": "0.7.15"
148
+ "lexical": "^0.52.0"
149
149
  },
150
150
  "scripts": {
151
151
  "build": "node ../../scripts/clean-dist.mjs && pnpm build:esm && tsc --project tsconfig.build.json && node ../../scripts/add-declaration-extensions.mjs && pnpm build:css && node ../../scripts/check-no-dev-jsx.mjs && node ../../scripts/check-fully-specified.mjs",
@@ -7,7 +7,8 @@
7
7
  * @input Uses @lexical/react (composer context), @lexical/code (CodeNode), and
8
8
  * lexical.
9
9
  * @output Exports CodePastePlugin, which makes rich content pasted or dropped
10
- * into a code block go in as plain lines of code.
10
+ * into a code block go in as plain lines of code, and $startsInCodeBlock,
11
+ * which TablePastePlugin uses to leave such a paste to it.
11
12
  * @position Rendered by RichTextEditor; always on; not exported. A paste
12
13
  * whose selection starts in a code block goes in as plain text: the
13
14
  * clipboard's plain text, or else one line per pasted block.
@@ -38,7 +39,7 @@ import {
38
39
  const RICH_TYPES = ['text/html', 'application/x-lexical-editor'];
39
40
 
40
41
  /** Whether content inserted at the selection lands in a code block. */
41
- function $startsInCodeBlock(
42
+ export function $startsInCodeBlock(
42
43
  selection: BaseSelection | null,
43
44
  ): selection is RangeSelection {
44
45
  if (!$isRangeSelection(selection)) {
@@ -236,7 +236,7 @@ export const docs = {
236
236
  {
237
237
  guidance: true,
238
238
  description:
239
- 'GFM pipe tables import, edit, and export by default. The first row renders as header cells, delimiter-row colons set each column alignment, rows shorter than the widest row gain empty cells, and a wide table scrolls inside its own wrapper. Arrow keys move between cells; Tab keeps its editor meaning, so Escape then Tab still leaves the editor. Tables export in the canonical form: outer pipes and three-dash delimiters. RichTextView renders the same tables. Install @lexical/table with the other lexical peers.',
239
+ 'GFM pipe tables import, edit, and export by default. The first row renders as header cells, delimiter-row colons set each column alignment, rows shorter than the widest row gain empty cells, and a wide table scrolls inside its own wrapper. Arrow keys move between cells; Tab keeps its editor meaning, so Escape then Tab still leaves the editor. Pasting a table into a table fills its cells one by one from the cell holding the caret, or within the selected cells, adding rows and columns as needed; a cell the paste does not fill keeps its text, and a table pasted with other content goes into the cell as text. Tables export in the canonical form: outer pipes and three-dash delimiters. RichTextView renders the same tables. Install @lexical/table with the other lexical peers.',
240
240
  },
241
241
  {
242
242
  guidance: true,
@@ -77,6 +77,7 @@ import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
77
77
  import {HorizontalRuleExtension} from '@lexical/extension';
78
78
  import {TextSemanticsExtension} from './textSemantics';
79
79
  import {TableColumnFloorPlugin} from './TableColumnFloorPlugin';
80
+ import {TablePastePlugin} from './TablePastePlugin';
80
81
  import {CodeBackspacePlugin} from './CodeBackspacePlugin';
81
82
  import {CodeBlockHeaderPlugin} from './CodeBlockHeaderPlugin';
82
83
  import {CodePastePlugin} from './CodePastePlugin';
@@ -730,6 +731,7 @@ export const RichTextEditor = function RichTextEditor({
730
731
  hasHorizontalScroll
731
732
  />
732
733
  <TableColumnFloorPlugin />
734
+ <TablePastePlugin />
733
735
  <CodeBlockHeaderPlugin codeLanguages={codeLanguages} />
734
736
  <CodeSyntaxPlugin codeLanguages={codeLanguages} />
735
737
  {/* Backspace at the very start of a code block makes it a
@@ -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
+ }