]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts
Lexical: Merged custom table node code
[bookstack] / resources / js / wysiwyg / lexical / table / LexicalTableCellNode.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 {
10   DOMConversionMap,
11   DOMConversionOutput,
12   DOMExportOutput,
13   EditorConfig,
14   LexicalEditor,
15   LexicalNode,
16   NodeKey,
17   SerializedElementNode,
18   Spread,
19 } from 'lexical';
20
21 import {addClassNamesToElement} from '@lexical/utils';
22 import {
23   $applyNodeReplacement,
24   $createParagraphNode,
25   $isElementNode,
26   $isLineBreakNode,
27   $isTextNode,
28   ElementNode,
29 } from 'lexical';
30
31 import {extractStyleMapFromElement, StyleMap} from "../../utils/dom";
32 import {CommonBlockAlignment, extractAlignmentFromElement} from "../../nodes/_common";
33
34 export const TableCellHeaderStates = {
35   BOTH: 3,
36   COLUMN: 2,
37   NO_STATUS: 0,
38   ROW: 1,
39 };
40
41 export type TableCellHeaderState =
42   typeof TableCellHeaderStates[keyof typeof TableCellHeaderStates];
43
44 export type SerializedTableCellNode = Spread<
45   {
46     colSpan?: number;
47     rowSpan?: number;
48     headerState: TableCellHeaderState;
49     width?: number;
50     backgroundColor?: null | string;
51     styles: Record<string, string>;
52     alignment: CommonBlockAlignment;
53   },
54   SerializedElementNode
55 >;
56
57 /** @noInheritDoc */
58 export class TableCellNode extends ElementNode {
59   /** @internal */
60   __colSpan: number;
61   /** @internal */
62   __rowSpan: number;
63   /** @internal */
64   __headerState: TableCellHeaderState;
65   /** @internal */
66   __width?: number;
67   /** @internal */
68   __backgroundColor: null | string;
69   /** @internal */
70   __styles: StyleMap = new Map;
71   /** @internal */
72   __alignment: CommonBlockAlignment = '';
73
74   static getType(): string {
75     return 'tablecell';
76   }
77
78   static clone(node: TableCellNode): TableCellNode {
79     const cellNode = new TableCellNode(
80       node.__headerState,
81       node.__colSpan,
82       node.__width,
83       node.__key,
84     );
85     cellNode.__rowSpan = node.__rowSpan;
86     cellNode.__backgroundColor = node.__backgroundColor;
87     cellNode.__styles = new Map(node.__styles);
88     cellNode.__alignment = node.__alignment;
89     return cellNode;
90   }
91
92   static importDOM(): DOMConversionMap | null {
93     return {
94       td: (node: Node) => ({
95         conversion: $convertTableCellNodeElement,
96         priority: 0,
97       }),
98       th: (node: Node) => ({
99         conversion: $convertTableCellNodeElement,
100         priority: 0,
101       }),
102     };
103   }
104
105   static importJSON(serializedNode: SerializedTableCellNode): TableCellNode {
106     const node = $createTableCellNode(
107         serializedNode.headerState,
108         serializedNode.colSpan,
109         serializedNode.width,
110     );
111
112     if (serializedNode.rowSpan) {
113         node.setRowSpan(serializedNode.rowSpan);
114     }
115
116     node.setStyles(new Map(Object.entries(serializedNode.styles)));
117     node.setAlignment(serializedNode.alignment);
118
119     return node;
120   }
121
122   constructor(
123     headerState = TableCellHeaderStates.NO_STATUS,
124     colSpan = 1,
125     width?: number,
126     key?: NodeKey,
127   ) {
128     super(key);
129     this.__colSpan = colSpan;
130     this.__rowSpan = 1;
131     this.__headerState = headerState;
132     this.__width = width;
133     this.__backgroundColor = null;
134   }
135
136   createDOM(config: EditorConfig): HTMLElement {
137     const element = document.createElement(
138       this.getTag(),
139     ) as HTMLTableCellElement;
140
141     if (this.__width) {
142       element.style.width = `${this.__width}px`;
143     }
144     if (this.__colSpan > 1) {
145       element.colSpan = this.__colSpan;
146     }
147     if (this.__rowSpan > 1) {
148       element.rowSpan = this.__rowSpan;
149     }
150     if (this.__backgroundColor !== null) {
151       element.style.backgroundColor = this.__backgroundColor;
152     }
153
154     addClassNamesToElement(
155       element,
156       config.theme.tableCell,
157       this.hasHeader() && config.theme.tableCellHeader,
158     );
159
160     for (const [name, value] of this.__styles.entries()) {
161       element.style.setProperty(name, value);
162     }
163
164     if (this.__alignment) {
165       element.classList.add('align-' + this.__alignment);
166     }
167
168     return element;
169   }
170
171   exportDOM(editor: LexicalEditor): DOMExportOutput {
172     const {element} = super.exportDOM(editor);
173     return {
174       element,
175     };
176   }
177
178   exportJSON(): SerializedTableCellNode {
179     return {
180       ...super.exportJSON(),
181       backgroundColor: this.getBackgroundColor(),
182       colSpan: this.__colSpan,
183       headerState: this.__headerState,
184       rowSpan: this.__rowSpan,
185       type: 'tablecell',
186       width: this.getWidth(),
187       styles: Object.fromEntries(this.__styles),
188       alignment: this.__alignment,
189     };
190   }
191
192   getColSpan(): number {
193     return this.__colSpan;
194   }
195
196   setColSpan(colSpan: number): this {
197     this.getWritable().__colSpan = colSpan;
198     return this;
199   }
200
201   getRowSpan(): number {
202     return this.__rowSpan;
203   }
204
205   setRowSpan(rowSpan: number): this {
206     this.getWritable().__rowSpan = rowSpan;
207     return this;
208   }
209
210   getTag(): string {
211     return this.hasHeader() ? 'th' : 'td';
212   }
213
214   setHeaderStyles(headerState: TableCellHeaderState): TableCellHeaderState {
215     const self = this.getWritable();
216     self.__headerState = headerState;
217     return this.__headerState;
218   }
219
220   getHeaderStyles(): TableCellHeaderState {
221     return this.getLatest().__headerState;
222   }
223
224   setWidth(width: number): number | null | undefined {
225     const self = this.getWritable();
226     self.__width = width;
227     return this.__width;
228   }
229
230   getWidth(): number | undefined {
231     return this.getLatest().__width;
232   }
233
234   clearWidth(): void {
235     const self = this.getWritable();
236     self.__width = undefined;
237   }
238
239   getStyles(): StyleMap {
240     const self = this.getLatest();
241     return new Map(self.__styles);
242   }
243
244   setStyles(styles: StyleMap): void {
245     const self = this.getWritable();
246     self.__styles = new Map(styles);
247   }
248
249   setAlignment(alignment: CommonBlockAlignment) {
250     const self = this.getWritable();
251     self.__alignment = alignment;
252   }
253
254   getAlignment(): CommonBlockAlignment {
255     const self = this.getLatest();
256     return self.__alignment;
257   }
258
259   updateTag(tag: string): void {
260     const isHeader = tag.toLowerCase() === 'th';
261     const state = isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS;
262     const self = this.getWritable();
263     self.__headerState = state;
264   }
265
266   getBackgroundColor(): null | string {
267     return this.getLatest().__backgroundColor;
268   }
269
270   setBackgroundColor(newBackgroundColor: null | string): void {
271     this.getWritable().__backgroundColor = newBackgroundColor;
272   }
273
274   toggleHeaderStyle(headerStateToToggle: TableCellHeaderState): TableCellNode {
275     const self = this.getWritable();
276
277     if ((self.__headerState & headerStateToToggle) === headerStateToToggle) {
278       self.__headerState -= headerStateToToggle;
279     } else {
280       self.__headerState += headerStateToToggle;
281     }
282
283     return self;
284   }
285
286   hasHeaderState(headerState: TableCellHeaderState): boolean {
287     return (this.getHeaderStyles() & headerState) === headerState;
288   }
289
290   hasHeader(): boolean {
291     return this.getLatest().__headerState !== TableCellHeaderStates.NO_STATUS;
292   }
293
294   updateDOM(prevNode: TableCellNode): boolean {
295     return (
296       prevNode.__headerState !== this.__headerState ||
297       prevNode.__width !== this.__width ||
298       prevNode.__colSpan !== this.__colSpan ||
299       prevNode.__rowSpan !== this.__rowSpan ||
300       prevNode.__backgroundColor !== this.__backgroundColor ||
301       prevNode.__styles !== this.__styles ||
302       prevNode.__alignment !== this.__alignment
303     );
304   }
305
306   isShadowRoot(): boolean {
307     return true;
308   }
309
310   collapseAtStart(): true {
311     return true;
312   }
313
314   canBeEmpty(): false {
315     return false;
316   }
317
318   canIndent(): false {
319     return false;
320   }
321 }
322
323 export function $convertTableCellNodeElement(
324     domNode: Node,
325 ): DOMConversionOutput {
326   const domNode_ = domNode as HTMLTableCellElement;
327   const nodeName = domNode.nodeName.toLowerCase();
328
329   let width: number | undefined = undefined;
330
331
332   const PIXEL_VALUE_REG_EXP = /^(\d+(?:\.\d+)?)px$/;
333   if (PIXEL_VALUE_REG_EXP.test(domNode_.style.width)) {
334     width = parseFloat(domNode_.style.width);
335   }
336
337   const tableCellNode = $createTableCellNode(
338       nodeName === 'th'
339           ? TableCellHeaderStates.ROW
340           : TableCellHeaderStates.NO_STATUS,
341       domNode_.colSpan,
342       width,
343   );
344
345   tableCellNode.__rowSpan = domNode_.rowSpan;
346
347   const style = domNode_.style;
348   const textDecoration = style.textDecoration.split(' ');
349   const hasBoldFontWeight =
350       style.fontWeight === '700' || style.fontWeight === 'bold';
351   const hasLinethroughTextDecoration = textDecoration.includes('line-through');
352   const hasItalicFontStyle = style.fontStyle === 'italic';
353   const hasUnderlineTextDecoration = textDecoration.includes('underline');
354
355   if (domNode instanceof HTMLElement) {
356     tableCellNode.setStyles(extractStyleMapFromElement(domNode));
357     tableCellNode.setAlignment(extractAlignmentFromElement(domNode));
358   }
359
360   return {
361     after: (childLexicalNodes) => {
362       if (childLexicalNodes.length === 0) {
363         childLexicalNodes.push($createParagraphNode());
364       }
365       return childLexicalNodes;
366     },
367     forChild: (lexicalNode, parentLexicalNode) => {
368       if ($isTableCellNode(parentLexicalNode) && !$isElementNode(lexicalNode)) {
369         const paragraphNode = $createParagraphNode();
370         if (
371             $isLineBreakNode(lexicalNode) &&
372             lexicalNode.getTextContent() === '\n'
373         ) {
374           return null;
375         }
376         if ($isTextNode(lexicalNode)) {
377           if (hasBoldFontWeight) {
378             lexicalNode.toggleFormat('bold');
379           }
380           if (hasLinethroughTextDecoration) {
381             lexicalNode.toggleFormat('strikethrough');
382           }
383           if (hasItalicFontStyle) {
384             lexicalNode.toggleFormat('italic');
385           }
386           if (hasUnderlineTextDecoration) {
387             lexicalNode.toggleFormat('underline');
388           }
389         }
390         paragraphNode.append(lexicalNode);
391         return paragraphNode;
392       }
393
394       return lexicalNode;
395     },
396     node: tableCellNode,
397   };
398 }
399
400 export function $createTableCellNode(
401   headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS,
402   colSpan = 1,
403   width?: number,
404 ): TableCellNode {
405   return $applyNodeReplacement(new TableCellNode(headerState, colSpan, width));
406 }
407
408 export function $isTableCellNode(
409   node: LexicalNode | null | undefined,
410 ): node is TableCellNode {
411   return node instanceof TableCellNode;
412 }