@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.
- package/dist/CodePastePlugin.d.ts +3 -0
- package/dist/CodePastePlugin.d.ts.map +1 -1
- package/dist/CodePastePlugin.js +3 -2
- package/dist/RichTextEditor.d.ts.map +1 -1
- package/dist/RichTextEditor.js +2 -1
- package/dist/TablePastePlugin.d.ts +9 -0
- package/dist/TablePastePlugin.d.ts.map +1 -0
- package/dist/TablePastePlugin.js +183 -0
- package/package.json +5 -5
- package/src/CodePastePlugin.tsx +3 -2
- package/src/RichTextEditor.doc.mjs +1 -1
- package/src/RichTextEditor.tsx +2 -0
- package/src/TablePastePlugin.tsx +223 -0
|
@@ -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":"
|
|
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"}
|
package/dist/CodePastePlugin.js
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"RichTextEditor.d.ts","sourceRoot":"","sources":["../src/RichTextEditor.tsx"],"names":[],"mappings":"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"}
|
package/dist/RichTextEditor.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|
package/src/CodePastePlugin.tsx
CHANGED
|
@@ -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,
|
package/src/RichTextEditor.tsx
CHANGED
|
@@ -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
|
+
}
|