]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/utils/tables.ts
ed947ddcdcbce26fa1e4454a805e1a721f1d7816
[bookstack] / resources / js / wysiwyg / utils / tables.ts
1 import {BaseSelection, LexicalEditor} from "lexical";
2 import {
3     $isTableCellNode,
4     $isTableNode,
5     $isTableRowNode,
6     $isTableSelection, TableCellNode, TableNode,
7     TableRowNode,
8     TableSelection,
9 } from "@lexical/table";
10 import {$getParentOfType} from "./nodes";
11 import {$getNodeFromSelection} from "./selection";
12 import {formatSizeValue} from "./dom";
13 import {TableMap} from "./table-map";
14
15 function $getTableFromCell(cell: TableCellNode): TableNode|null {
16     return $getParentOfType(cell, $isTableNode) as TableNode|null;
17 }
18
19 export function getTableColumnWidths(table: HTMLTableElement): string[] {
20     const maxColRow = getMaxColRowFromTable(table);
21
22     const colGroup = table.querySelector('colgroup');
23     let widths: string[] = [];
24     if (colGroup && (colGroup.childElementCount === maxColRow?.childElementCount || !maxColRow)) {
25         widths = extractWidthsFromRow(colGroup);
26     }
27     if (widths.filter(Boolean).length === 0 && maxColRow) {
28         widths = extractWidthsFromRow(maxColRow);
29     }
30
31     return widths;
32 }
33
34 function getMaxColRowFromTable(table: HTMLTableElement): HTMLTableRowElement | null {
35     const rows = table.querySelectorAll('tr');
36     let maxColCount: number = 0;
37     let maxColRow: HTMLTableRowElement | null = null;
38
39     for (const row of rows) {
40         if (row.childElementCount > maxColCount) {
41             maxColRow = row;
42             maxColCount = row.childElementCount;
43         }
44     }
45
46     return maxColRow;
47 }
48
49 function extractWidthsFromRow(row: HTMLTableRowElement | HTMLTableColElement) {
50     return [...row.children].map(child => extractWidthFromElement(child as HTMLElement))
51 }
52
53 function extractWidthFromElement(element: HTMLElement): string {
54     let width = element.style.width || element.getAttribute('width');
55     if (width && !Number.isNaN(Number(width))) {
56         width = width + 'px';
57     }
58
59     return width || '';
60 }
61
62 export function $setTableColumnWidth(node: TableNode, columnIndex: number, width: number|string): void {
63     const rows = node.getChildren() as TableRowNode[];
64     let maxCols = 0;
65     for (const row of rows) {
66         const cellCount = row.getChildren().length;
67         if (cellCount > maxCols) {
68             maxCols = cellCount;
69         }
70     }
71
72     let colWidths = node.getColWidths();
73     if (colWidths.length === 0 || colWidths.length < maxCols) {
74         colWidths = Array(maxCols).fill('');
75     }
76
77     if (columnIndex + 1 > colWidths.length) {
78         console.error(`Attempted to set table column width for column [${columnIndex}] but only ${colWidths.length} columns found`);
79     }
80
81     colWidths[columnIndex] = formatSizeValue(width);
82     node.setColWidths(colWidths);
83 }
84
85 export function $getTableColumnWidth(editor: LexicalEditor, node: TableNode, columnIndex: number): number {
86     const colWidths = node.getColWidths();
87     if (colWidths.length > columnIndex && colWidths[columnIndex].endsWith('px')) {
88         return Number(colWidths[columnIndex].replace('px', ''));
89     }
90
91     // Otherwise, get from table element
92     const table = editor.getElementByKey(node.__key) as HTMLTableElement | null;
93     if (table) {
94         const maxColRow = getMaxColRowFromTable(table);
95         if (maxColRow && maxColRow.children.length > columnIndex) {
96             const cell = maxColRow.children[columnIndex];
97             return cell.clientWidth;
98         }
99     }
100
101     return 0;
102 }
103
104 function $getCellColumnIndex(node: TableCellNode): number {
105     const row = node.getParent();
106     if (!$isTableRowNode(row)) {
107         return -1;
108     }
109
110     let index = 0;
111     const cells = row.getChildren<TableCellNode>();
112     for (const cell of cells) {
113         let colSpan = cell.getColSpan() || 1;
114         index += colSpan;
115         if (cell.getKey() === node.getKey()) {
116             break;
117         }
118     }
119
120     return index - 1;
121 }
122
123 export function $setTableCellColumnWidth(cell: TableCellNode, width: string): void {
124     const table = $getTableFromCell(cell)
125     const index = $getCellColumnIndex(cell);
126
127     if (table && index >= 0) {
128         $setTableColumnWidth(table, index, width);
129     }
130 }
131
132 export function $getTableCellColumnWidth(editor: LexicalEditor, cell: TableCellNode): string {
133     const table = $getTableFromCell(cell)
134     const index = $getCellColumnIndex(cell);
135     if (!table) {
136         return '';
137     }
138
139     const widths = table.getColWidths();
140     return (widths.length > index) ? widths[index] : '';
141 }
142
143 export function $getTableCellsFromSelection(selection: BaseSelection|null): TableCellNode[]  {
144     if ($isTableSelection(selection)) {
145         const nodes = selection.getNodes();
146         return nodes.filter(n => $isTableCellNode(n));
147     }
148
149     const cell = $getNodeFromSelection(selection, $isTableCellNode) as TableCellNode;
150     return cell ? [cell] : [];
151 }
152
153 export function $mergeTableCellsInSelection(selection: TableSelection): void {
154     const selectionShape = selection.getShape();
155     const cells = $getTableCellsFromSelection(selection);
156     if (cells.length === 0) {
157         return;
158     }
159
160     const table = $getTableFromCell(cells[0]);
161     if (!table) {
162         return;
163     }
164
165     const tableMap = new TableMap(table);
166     const headCell = tableMap.getCellAtPosition(selectionShape.toX, selectionShape.toY);
167     if (!headCell) {
168         return;
169     }
170
171     // We have to adjust the shape since it won't take into account spans for the head corner position.
172     const fixedToX = selectionShape.toX + ((headCell.getColSpan() || 1) - 1);
173     const fixedToY = selectionShape.toY + ((headCell.getRowSpan() || 1) - 1);
174
175     const mergeCells = tableMap.getCellsInRange({
176         fromX: selectionShape.fromX,
177         fromY: selectionShape.fromY,
178         toX: fixedToX,
179         toY: fixedToY,
180     });
181
182     if (mergeCells.length === 0) {
183         return;
184     }
185
186     const firstCell = mergeCells[0];
187     const newWidth = Math.abs(selectionShape.fromX - fixedToX) + 1;
188     const newHeight = Math.abs(selectionShape.fromY - fixedToY) + 1;
189
190     for (let i = 1; i < mergeCells.length; i++) {
191         const mergeCell = mergeCells[i];
192         firstCell.append(...mergeCell.getChildren());
193         mergeCell.remove();
194     }
195
196     firstCell.setColSpan(newWidth);
197     firstCell.setRowSpan(newHeight);
198 }
199
200 export function $getTableRowsFromSelection(selection: BaseSelection|null): TableRowNode[] {
201     const cells = $getTableCellsFromSelection(selection);
202     const rowsByKey: Record<string, TableRowNode> = {};
203     for (const cell of cells) {
204         const row = cell.getParent();
205         if ($isTableRowNode(row)) {
206             rowsByKey[row.getKey()] = row;
207         }
208     }
209
210     return Object.values(rowsByKey);
211 }
212
213 export function $getTableFromSelection(selection: BaseSelection|null): TableNode|null {
214     const cells = $getTableCellsFromSelection(selection);
215     if (cells.length === 0) {
216         return null;
217     }
218
219     const table = $getParentOfType(cells[0], $isTableNode);
220     if ($isTableNode(table)) {
221         return table;
222     }
223
224     return null;
225 }
226
227 export function $clearTableSizes(table: TableNode): void {
228     table.setColWidths([]);
229
230     // TODO - Extra form things once table properties and extra things
231     //   are supported
232
233     for (const row of table.getChildren()) {
234         if (!$isTableRowNode(row)) {
235             continue;
236         }
237
238         const rowStyles = row.getStyles();
239         rowStyles.delete('height');
240         rowStyles.delete('width');
241         row.setStyles(rowStyles);
242
243         const cells = row.getChildren().filter(c => $isTableCellNode(c));
244         for (const cell of cells) {
245             const cellStyles = cell.getStyles();
246             cellStyles.delete('height');
247             cellStyles.delete('width');
248             cell.setStyles(cellStyles);
249             cell.clearWidth();
250         }
251     }
252 }
253
254 export function $clearTableFormatting(table: TableNode): void {
255     table.setColWidths([]);
256     table.setStyles(new Map);
257
258     for (const row of table.getChildren()) {
259         if (!$isTableRowNode(row)) {
260             continue;
261         }
262
263         row.setStyles(new Map);
264
265         const cells = row.getChildren().filter(c => $isTableCellNode(c));
266         for (const cell of cells) {
267             cell.setStyles(new Map);
268             cell.clearWidth();
269         }
270     }
271 }
272
273 /**
274  * Perform the given callback for each cell in the given table.
275  * Returning false from the callback stops the function early.
276  */
277 export function $forEachTableCell(table: TableNode, callback: (c: TableCellNode) => void|false): void {
278     outer: for (const row of table.getChildren()) {
279         if (!$isTableRowNode(row)) {
280             continue;
281         }
282         const cells = row.getChildren();
283         for (const cell of cells) {
284             if (!$isTableCellNode(cell)) {
285                 return;
286             }
287             const result = callback(cell);
288             if (result === false) {
289                 break outer;
290             }
291         }
292     }
293 }
294
295 export function $getCellPaddingForTable(table: TableNode): string {
296     let padding: string|null = null;
297
298     $forEachTableCell(table, (cell: TableCellNode) => {
299         const cellPadding = cell.getStyles().get('padding') || ''
300         if (padding === null) {
301             padding = cellPadding;
302         }
303
304         if (cellPadding !== padding) {
305             padding = null;
306             return false;
307         }
308     });
309
310     return padding || '';
311 }
312
313
314
315
316
317
318
319