]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/utils/table-copy-paste.ts
Opensearch: Fixed XML declaration when php short tags enabled
[bookstack] / resources / js / wysiwyg / utils / table-copy-paste.ts
1 import {NodeClipboard} from "./node-clipboard";
2 import {$getTableCellsFromSelection, $getTableFromSelection, $getTableRowsFromSelection} from "./tables";
3 import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
4 import {TableMap} from "./table-map";
5 import {
6     $createTableCellNode,
7     $isTableCellNode,
8     $isTableSelection,
9     TableCellNode,
10     TableNode,
11     TableRowNode
12 } from "@lexical/table";
13 import {$getNodeFromSelection} from "./selection";
14
15 const rowClipboard: NodeClipboard<TableRowNode> = new NodeClipboard<TableRowNode>();
16
17 export function isRowClipboardEmpty(): boolean {
18     return rowClipboard.size() === 0;
19 }
20
21 export function validateRowsToCopy(rows: TableRowNode[]): void {
22     let commonRowSize: number|null = null;
23
24     for (const row of rows) {
25         const cells = row.getChildren().filter(n => $isTableCellNode(n));
26         let rowSize = 0;
27         for (const cell of cells) {
28             rowSize += cell.getColSpan() || 1;
29             if (cell.getRowSpan() > 1) {
30                 throw Error('Cannot copy rows with merged cells');
31             }
32         }
33
34         if (commonRowSize === null) {
35             commonRowSize = rowSize;
36         } else if (commonRowSize !== rowSize) {
37             throw Error('Cannot copy rows with inconsistent sizes');
38         }
39     }
40 }
41
42 export function validateRowsToPaste(rows: TableRowNode[], targetTable: TableNode): void {
43     const tableColCount = (new TableMap(targetTable)).columnCount;
44     for (const row of rows) {
45         const cells = row.getChildren().filter(n => $isTableCellNode(n));
46         let rowSize = 0;
47         for (const cell of cells) {
48             rowSize += cell.getColSpan() || 1;
49         }
50
51         if (rowSize > tableColCount) {
52             throw Error('Cannot paste rows that are wider than target table');
53         }
54
55         while (rowSize < tableColCount) {
56             row.append($createTableCellNode());
57             rowSize++;
58         }
59     }
60 }
61
62 export function $cutSelectedRowsToClipboard(): void {
63     const rows = $getTableRowsFromSelection($getSelection());
64     validateRowsToCopy(rows);
65     rowClipboard.set(...rows);
66     for (const row of rows) {
67         row.remove();
68     }
69 }
70
71 export function $copySelectedRowsToClipboard(): void {
72     const rows = $getTableRowsFromSelection($getSelection());
73     validateRowsToCopy(rows);
74     rowClipboard.set(...rows);
75 }
76
77 export function $pasteClipboardRowsBefore(editor: LexicalEditor): void {
78     const selection = $getSelection();
79     const rows = $getTableRowsFromSelection(selection);
80     const table = $getTableFromSelection(selection);
81     const lastRow = rows[rows.length - 1];
82     if (lastRow && table) {
83         const clipboardRows = rowClipboard.get(editor);
84         validateRowsToPaste(clipboardRows, table);
85         for (const row of clipboardRows) {
86             lastRow.insertBefore(row);
87         }
88     }
89 }
90
91 export function $pasteClipboardRowsAfter(editor: LexicalEditor): void {
92     const selection = $getSelection();
93     const rows = $getTableRowsFromSelection(selection);
94     const table = $getTableFromSelection(selection);
95     const lastRow = rows[rows.length - 1];
96     if (lastRow && table) {
97         const clipboardRows = rowClipboard.get(editor).reverse();
98         validateRowsToPaste(clipboardRows, table);
99         for (const row of clipboardRows) {
100             lastRow.insertAfter(row);
101         }
102     }
103 }
104
105 const columnClipboard: NodeClipboard<TableCellNode>[] = [];
106
107 function setColumnClipboard(columns: TableCellNode[][]): void {
108     const newClipboards = columns.map(cells => {
109         const clipboard = new NodeClipboard<TableCellNode>();
110         clipboard.set(...cells);
111         return clipboard;
112     });
113
114     columnClipboard.splice(0, columnClipboard.length, ...newClipboards);
115 }
116
117 type TableRange = {from: number, to: number};
118
119 export function isColumnClipboardEmpty(): boolean {
120     return columnClipboard.length === 0;
121 }
122
123 function $getSelectionColumnRange(selection: BaseSelection|null): TableRange|null {
124     if ($isTableSelection(selection)) {
125         const shape = selection.getShape()
126         return {from: shape.fromX, to: shape.toX};
127     }
128
129     const cell = $getNodeFromSelection(selection, $isTableCellNode);
130     const table = $getTableFromSelection(selection);
131     if (!$isTableCellNode(cell) || !table) {
132         return null;
133     }
134
135     const map = new TableMap(table);
136     const range = map.getRangeForCell(cell);
137     if (!range) {
138         return null;
139     }
140
141     return {from: range.fromX, to: range.toX};
142 }
143
144 function $getTableColumnCellsFromSelection(range: TableRange, table: TableNode): TableCellNode[][] {
145     const map = new TableMap(table);
146     const columns = [];
147     for (let x = range.from; x <= range.to; x++) {
148         const cells = map.getCellsInColumn(x);
149         columns.push(cells);
150     }
151
152     return columns;
153 }
154
155 function validateColumnsToCopy(columns: TableCellNode[][]): void {
156     let commonColSize: number|null = null;
157
158     for (const cells of columns) {
159         let colSize = 0;
160         for (const cell of cells) {
161             colSize += cell.getRowSpan() || 1;
162             if (cell.getColSpan() > 1) {
163                 throw Error('Cannot copy columns with merged cells');
164             }
165         }
166
167         if (commonColSize === null) {
168             commonColSize = colSize;
169         } else if (commonColSize !== colSize) {
170             throw Error('Cannot copy columns with inconsistent sizes');
171         }
172     }
173 }
174
175 export function $cutSelectedColumnsToClipboard(): void {
176     const selection = $getSelection();
177     const range = $getSelectionColumnRange(selection);
178     const table = $getTableFromSelection(selection);
179     if (!range || !table) {
180         return;
181     }
182
183     const colWidths = table.getColWidths();
184     const columns = $getTableColumnCellsFromSelection(range, table);
185     validateColumnsToCopy(columns);
186     setColumnClipboard(columns);
187     for (const cells of columns) {
188         for (const cell of cells) {
189             cell.remove();
190         }
191     }
192
193     const newWidths = [...colWidths].splice(range.from, (range.to - range.from) + 1);
194     table.setColWidths(newWidths);
195 }
196
197 export function $copySelectedColumnsToClipboard(): void {
198     const selection = $getSelection();
199     const range = $getSelectionColumnRange(selection);
200     const table = $getTableFromSelection(selection);
201     if (!range || !table) {
202         return;
203     }
204
205     const columns = $getTableColumnCellsFromSelection(range, table);
206     validateColumnsToCopy(columns);
207     setColumnClipboard(columns);
208 }
209
210 function validateColumnsToPaste(columns: TableCellNode[][], targetTable: TableNode) {
211     const tableRowCount = (new TableMap(targetTable)).rowCount;
212     for (const cells of columns) {
213         let colSize = 0;
214         for (const cell of cells) {
215             colSize += cell.getRowSpan() || 1;
216         }
217
218         if (colSize > tableRowCount) {
219             throw Error('Cannot paste columns that are taller than target table');
220         }
221
222         while (colSize < tableRowCount) {
223             cells.push($createTableCellNode());
224             colSize++;
225         }
226     }
227 }
228
229 function $pasteClipboardColumns(editor: LexicalEditor, isBefore: boolean): void {
230     const selection = $getSelection();
231     const table = $getTableFromSelection(selection);
232     const cells = $getTableCellsFromSelection(selection);
233     const referenceCell = cells[isBefore ? 0 : cells.length - 1];
234     if (!table || !referenceCell) {
235         return;
236     }
237
238     const clipboardCols = columnClipboard.map(cb => cb.get(editor));
239     if (!isBefore) {
240         clipboardCols.reverse();
241     }
242
243     validateColumnsToPaste(clipboardCols, table);
244     const map = new TableMap(table);
245     const cellRange = map.getRangeForCell(referenceCell);
246     if (!cellRange) {
247         return;
248     }
249
250     const colIndex = isBefore ? cellRange.fromX : cellRange.toX;
251     const colWidths = table.getColWidths();
252
253     for (let y = 0; y < map.rowCount; y++) {
254         const relCell = map.getCellAtPosition(colIndex, y);
255         for (const cells of clipboardCols) {
256             const newCell = cells[y];
257             if (isBefore) {
258                 relCell.insertBefore(newCell);
259             } else {
260                 relCell.insertAfter(newCell);
261             }
262         }
263     }
264
265     const refWidth = colWidths[colIndex];
266     const addedWidths = clipboardCols.map(_ => refWidth);
267     colWidths.splice(isBefore ? colIndex : colIndex + 1, 0, ...addedWidths);
268 }
269
270 export function $pasteClipboardColumnsBefore(editor: LexicalEditor): void {
271     $pasteClipboardColumns(editor, true);
272 }
273
274 export function $pasteClipboardColumnsAfter(editor: LexicalEditor): void {
275     $pasteClipboardColumns(editor, false);
276 }