]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/table/LexicalTableNode.ts
Lexical: Fixed table column resizing changes not appearing
[bookstack] / resources / js / wysiwyg / lexical / table / LexicalTableNode.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import type {TableCellNode} from './LexicalTableCellNode';
10 import {
11   DOMConversionMap,
12   DOMConversionOutput,
13   DOMExportOutput,
14   EditorConfig,
15   LexicalEditor,
16   LexicalNode,
17   NodeKey,
18   Spread,
19 } from 'lexical';
20
21 import {addClassNamesToElement, isHTMLElement} from '@lexical/utils';
22 import {
23   $applyNodeReplacement,
24   $getNearestNodeFromDOMNode,
25
26 } from 'lexical';
27
28 import {$isTableCellNode} from './LexicalTableCellNode';
29 import {TableDOMCell, TableDOMTable} from './LexicalTableObserver';
30 import {getTable} from './LexicalTableSelectionHelpers';
31 import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
32 import {
33   commonPropertiesDifferent, deserializeCommonBlockNode,
34   setCommonBlockPropsFromElement,
35   updateElementWithCommonBlockProps
36 } from "lexical/nodes/common";
37 import {el, extractStyleMapFromElement, StyleMap} from "../../utils/dom";
38 import {getTableColumnWidths} from "../../utils/tables";
39
40 export type SerializedTableNode = Spread<{
41   colWidths: string[];
42   styles: Record<string, string>,
43 }, SerializedCommonBlockNode>
44
45 /** @noInheritDoc */
46 export class TableNode extends CommonBlockNode {
47   __colWidths: string[] = [];
48   __styles: StyleMap = new Map;
49
50   static getType(): string {
51     return 'table';
52   }
53
54   static clone(node: TableNode): TableNode {
55     const newNode = new TableNode(node.__key);
56     copyCommonBlockProperties(node, newNode);
57     newNode.__colWidths = [...node.__colWidths];
58     newNode.__styles = new Map(node.__styles);
59     return newNode;
60   }
61
62   static importDOM(): DOMConversionMap | null {
63     return {
64       table: (_node: Node) => ({
65         conversion: $convertTableElement,
66         priority: 1,
67       }),
68     };
69   }
70
71   static importJSON(_serializedNode: SerializedTableNode): TableNode {
72     const node = $createTableNode();
73     deserializeCommonBlockNode(_serializedNode, node);
74     node.setColWidths(_serializedNode.colWidths);
75     node.setStyles(new Map(Object.entries(_serializedNode.styles)));
76     return node;
77   }
78
79   constructor(key?: NodeKey) {
80     super(key);
81   }
82
83   exportJSON(): SerializedTableNode {
84     return {
85       ...super.exportJSON(),
86       type: 'table',
87       version: 1,
88       colWidths: this.__colWidths,
89       styles: Object.fromEntries(this.__styles),
90     };
91   }
92
93   createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
94     const tableElement = document.createElement('table');
95
96     addClassNamesToElement(tableElement, config.theme.table);
97
98     updateElementWithCommonBlockProps(tableElement, this);
99
100     const colWidths = this.getColWidths();
101     if (colWidths.length > 0) {
102       const colgroup = el('colgroup');
103       for (const width of colWidths) {
104         const col = el('col');
105         if (width) {
106           col.style.width = width;
107         }
108         colgroup.append(col);
109       }
110       tableElement.append(colgroup);
111     }
112
113     for (const [name, value] of this.__styles.entries()) {
114       tableElement.style.setProperty(name, value);
115     }
116
117     return tableElement;
118   }
119
120   updateDOM(_prevNode: TableNode): boolean {
121     return commonPropertiesDifferent(_prevNode, this)
122       || this.__colWidths.join(':') !== _prevNode.__colWidths.join(':')
123       || this.__styles.size !== _prevNode.__styles.size
124       || (Array.from(this.__styles.values()).join(':') !== (Array.from(_prevNode.__styles.values()).join(':')));
125   }
126
127   exportDOM(editor: LexicalEditor): DOMExportOutput {
128     return {
129       ...super.exportDOM(editor),
130       after: (tableElement) => {
131         if (!tableElement) {
132           return;
133         }
134
135         const newElement = tableElement.cloneNode() as ParentNode;
136         const tBody = document.createElement('tbody');
137
138         if (isHTMLElement(tableElement)) {
139           for (const child of Array.from(tableElement.children)) {
140             if (child.nodeName === 'TR') {
141               tBody.append(child);
142             } else if (child.nodeName === 'CAPTION') {
143               newElement.insertBefore(child, newElement.firstChild);
144             } else {
145               newElement.append(child);
146             }
147           }
148         }
149
150         newElement.append(tBody);
151
152         return newElement as HTMLElement;
153       },
154     };
155   }
156
157   canBeEmpty(): false {
158     return false;
159   }
160
161   isShadowRoot(): boolean {
162     return true;
163   }
164
165   setColWidths(widths: string[]) {
166     const self = this.getWritable();
167     self.__colWidths = widths;
168   }
169
170   getColWidths(): string[] {
171     const self = this.getLatest();
172     return [...self.__colWidths];
173   }
174
175   getStyles(): StyleMap {
176     const self = this.getLatest();
177     return new Map(self.__styles);
178   }
179
180   setStyles(styles: StyleMap): void {
181     const self = this.getWritable();
182     self.__styles = new Map(styles);
183   }
184
185   getCordsFromCellNode(
186     tableCellNode: TableCellNode,
187     table: TableDOMTable,
188   ): {x: number; y: number} {
189     const {rows, domRows} = table;
190
191     for (let y = 0; y < rows; y++) {
192       const row = domRows[y];
193
194       if (row == null) {
195         continue;
196       }
197
198       const x = row.findIndex((cell) => {
199         if (!cell) {
200           return;
201         }
202         const {elem} = cell;
203         const cellNode = $getNearestNodeFromDOMNode(elem);
204         return cellNode === tableCellNode;
205       });
206
207       if (x !== -1) {
208         return {x, y};
209       }
210     }
211
212     throw new Error('Cell not found in table.');
213   }
214
215   getDOMCellFromCords(
216     x: number,
217     y: number,
218     table: TableDOMTable,
219   ): null | TableDOMCell {
220     const {domRows} = table;
221
222     const row = domRows[y];
223
224     if (row == null) {
225       return null;
226     }
227
228     const index = x < row.length ? x : row.length - 1;
229
230     const cell = row[index];
231
232     if (cell == null) {
233       return null;
234     }
235
236     return cell;
237   }
238
239   getDOMCellFromCordsOrThrow(
240     x: number,
241     y: number,
242     table: TableDOMTable,
243   ): TableDOMCell {
244     const cell = this.getDOMCellFromCords(x, y, table);
245
246     if (!cell) {
247       throw new Error('Cell not found at cords.');
248     }
249
250     return cell;
251   }
252
253   getCellNodeFromCords(
254     x: number,
255     y: number,
256     table: TableDOMTable,
257   ): null | TableCellNode {
258     const cell = this.getDOMCellFromCords(x, y, table);
259
260     if (cell == null) {
261       return null;
262     }
263
264     const node = $getNearestNodeFromDOMNode(cell.elem);
265
266     if ($isTableCellNode(node)) {
267       return node;
268     }
269
270     return null;
271   }
272
273   getCellNodeFromCordsOrThrow(
274     x: number,
275     y: number,
276     table: TableDOMTable,
277   ): TableCellNode {
278     const node = this.getCellNodeFromCords(x, y, table);
279
280     if (!node) {
281       throw new Error('Node at cords not TableCellNode.');
282     }
283
284     return node;
285   }
286
287   canSelectBefore(): true {
288     return true;
289   }
290
291   canIndent(): false {
292     return false;
293   }
294 }
295
296 export function $getElementForTableNode(
297   editor: LexicalEditor,
298   tableNode: TableNode,
299 ): TableDOMTable {
300   const tableElement = editor.getElementByKey(tableNode.getKey());
301
302   if (tableElement == null) {
303     throw new Error('Table Element Not Found');
304   }
305
306   return getTable(tableElement);
307 }
308
309 export function $convertTableElement(element: HTMLElement): DOMConversionOutput {
310   const node = $createTableNode();
311   setCommonBlockPropsFromElement(element, node);
312
313   const colWidths = getTableColumnWidths(element as HTMLTableElement);
314   node.setColWidths(colWidths);
315   node.setStyles(extractStyleMapFromElement(element));
316
317   return {node};
318 }
319
320 export function $createTableNode(): TableNode {
321   return $applyNodeReplacement(new TableNode());
322 }
323
324 export function $isTableNode(
325   node: LexicalNode | null | undefined,
326 ): node is TableNode {
327   return node instanceof TableNode;
328 }