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