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