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