]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts
Lexical: Imported core lexical libs
[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 {COLUMN_WIDTH, PIXEL_VALUE_REG_EXP} from './constants';
32
33 export const TableCellHeaderStates = {
34   BOTH: 3,
35   COLUMN: 2,
36   NO_STATUS: 0,
37   ROW: 1,
38 };
39
40 export type TableCellHeaderState =
41   typeof TableCellHeaderStates[keyof typeof TableCellHeaderStates];
42
43 export type SerializedTableCellNode = Spread<
44   {
45     colSpan?: number;
46     rowSpan?: number;
47     headerState: TableCellHeaderState;
48     width?: number;
49     backgroundColor?: null | string;
50   },
51   SerializedElementNode
52 >;
53
54 /** @noInheritDoc */
55 export class TableCellNode extends ElementNode {
56   /** @internal */
57   __colSpan: number;
58   /** @internal */
59   __rowSpan: number;
60   /** @internal */
61   __headerState: TableCellHeaderState;
62   /** @internal */
63   __width?: number;
64   /** @internal */
65   __backgroundColor: null | string;
66
67   static getType(): string {
68     return 'tablecell';
69   }
70
71   static clone(node: TableCellNode): TableCellNode {
72     const cellNode = new TableCellNode(
73       node.__headerState,
74       node.__colSpan,
75       node.__width,
76       node.__key,
77     );
78     cellNode.__rowSpan = node.__rowSpan;
79     cellNode.__backgroundColor = node.__backgroundColor;
80     return cellNode;
81   }
82
83   static importDOM(): DOMConversionMap | null {
84     return {
85       td: (node: Node) => ({
86         conversion: $convertTableCellNodeElement,
87         priority: 0,
88       }),
89       th: (node: Node) => ({
90         conversion: $convertTableCellNodeElement,
91         priority: 0,
92       }),
93     };
94   }
95
96   static importJSON(serializedNode: SerializedTableCellNode): TableCellNode {
97     const colSpan = serializedNode.colSpan || 1;
98     const rowSpan = serializedNode.rowSpan || 1;
99     const cellNode = $createTableCellNode(
100       serializedNode.headerState,
101       colSpan,
102       serializedNode.width || undefined,
103     );
104     cellNode.__rowSpan = rowSpan;
105     cellNode.__backgroundColor = serializedNode.backgroundColor || null;
106     return cellNode;
107   }
108
109   constructor(
110     headerState = TableCellHeaderStates.NO_STATUS,
111     colSpan = 1,
112     width?: number,
113     key?: NodeKey,
114   ) {
115     super(key);
116     this.__colSpan = colSpan;
117     this.__rowSpan = 1;
118     this.__headerState = headerState;
119     this.__width = width;
120     this.__backgroundColor = null;
121   }
122
123   createDOM(config: EditorConfig): HTMLElement {
124     const element = document.createElement(
125       this.getTag(),
126     ) as HTMLTableCellElement;
127
128     if (this.__width) {
129       element.style.width = `${this.__width}px`;
130     }
131     if (this.__colSpan > 1) {
132       element.colSpan = this.__colSpan;
133     }
134     if (this.__rowSpan > 1) {
135       element.rowSpan = this.__rowSpan;
136     }
137     if (this.__backgroundColor !== null) {
138       element.style.backgroundColor = this.__backgroundColor;
139     }
140
141     addClassNamesToElement(
142       element,
143       config.theme.tableCell,
144       this.hasHeader() && config.theme.tableCellHeader,
145     );
146
147     return element;
148   }
149
150   exportDOM(editor: LexicalEditor): DOMExportOutput {
151     const {element} = super.exportDOM(editor);
152
153     if (element) {
154       const element_ = element as HTMLTableCellElement;
155       element_.style.border = '1px solid black';
156       if (this.__colSpan > 1) {
157         element_.colSpan = this.__colSpan;
158       }
159       if (this.__rowSpan > 1) {
160         element_.rowSpan = this.__rowSpan;
161       }
162       element_.style.width = `${this.getWidth() || COLUMN_WIDTH}px`;
163
164       element_.style.verticalAlign = 'top';
165       element_.style.textAlign = 'start';
166
167       const backgroundColor = this.getBackgroundColor();
168       if (backgroundColor !== null) {
169         element_.style.backgroundColor = backgroundColor;
170       } else if (this.hasHeader()) {
171         element_.style.backgroundColor = '#f2f3f5';
172       }
173     }
174
175     return {
176       element,
177     };
178   }
179
180   exportJSON(): SerializedTableCellNode {
181     return {
182       ...super.exportJSON(),
183       backgroundColor: this.getBackgroundColor(),
184       colSpan: this.__colSpan,
185       headerState: this.__headerState,
186       rowSpan: this.__rowSpan,
187       type: 'tablecell',
188       width: this.getWidth(),
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   getBackgroundColor(): null | string {
235     return this.getLatest().__backgroundColor;
236   }
237
238   setBackgroundColor(newBackgroundColor: null | string): void {
239     this.getWritable().__backgroundColor = newBackgroundColor;
240   }
241
242   toggleHeaderStyle(headerStateToToggle: TableCellHeaderState): TableCellNode {
243     const self = this.getWritable();
244
245     if ((self.__headerState & headerStateToToggle) === headerStateToToggle) {
246       self.__headerState -= headerStateToToggle;
247     } else {
248       self.__headerState += headerStateToToggle;
249     }
250
251     return self;
252   }
253
254   hasHeaderState(headerState: TableCellHeaderState): boolean {
255     return (this.getHeaderStyles() & headerState) === headerState;
256   }
257
258   hasHeader(): boolean {
259     return this.getLatest().__headerState !== TableCellHeaderStates.NO_STATUS;
260   }
261
262   updateDOM(prevNode: TableCellNode): boolean {
263     return (
264       prevNode.__headerState !== this.__headerState ||
265       prevNode.__width !== this.__width ||
266       prevNode.__colSpan !== this.__colSpan ||
267       prevNode.__rowSpan !== this.__rowSpan ||
268       prevNode.__backgroundColor !== this.__backgroundColor
269     );
270   }
271
272   isShadowRoot(): boolean {
273     return true;
274   }
275
276   collapseAtStart(): true {
277     return true;
278   }
279
280   canBeEmpty(): false {
281     return false;
282   }
283
284   canIndent(): false {
285     return false;
286   }
287 }
288
289 export function $convertTableCellNodeElement(
290   domNode: Node,
291 ): DOMConversionOutput {
292   const domNode_ = domNode as HTMLTableCellElement;
293   const nodeName = domNode.nodeName.toLowerCase();
294
295   let width: number | undefined = undefined;
296
297   if (PIXEL_VALUE_REG_EXP.test(domNode_.style.width)) {
298     width = parseFloat(domNode_.style.width);
299   }
300
301   const tableCellNode = $createTableCellNode(
302     nodeName === 'th'
303       ? TableCellHeaderStates.ROW
304       : TableCellHeaderStates.NO_STATUS,
305     domNode_.colSpan,
306     width,
307   );
308
309   tableCellNode.__rowSpan = domNode_.rowSpan;
310   const backgroundColor = domNode_.style.backgroundColor;
311   if (backgroundColor !== '') {
312     tableCellNode.__backgroundColor = backgroundColor;
313   }
314
315   const style = domNode_.style;
316   const textDecoration = style.textDecoration.split(' ');
317   const hasBoldFontWeight =
318     style.fontWeight === '700' || style.fontWeight === 'bold';
319   const hasLinethroughTextDecoration = textDecoration.includes('line-through');
320   const hasItalicFontStyle = style.fontStyle === 'italic';
321   const hasUnderlineTextDecoration = textDecoration.includes('underline');
322   return {
323     after: (childLexicalNodes) => {
324       if (childLexicalNodes.length === 0) {
325         childLexicalNodes.push($createParagraphNode());
326       }
327       return childLexicalNodes;
328     },
329     forChild: (lexicalNode, parentLexicalNode) => {
330       if ($isTableCellNode(parentLexicalNode) && !$isElementNode(lexicalNode)) {
331         const paragraphNode = $createParagraphNode();
332         if (
333           $isLineBreakNode(lexicalNode) &&
334           lexicalNode.getTextContent() === '\n'
335         ) {
336           return null;
337         }
338         if ($isTextNode(lexicalNode)) {
339           if (hasBoldFontWeight) {
340             lexicalNode.toggleFormat('bold');
341           }
342           if (hasLinethroughTextDecoration) {
343             lexicalNode.toggleFormat('strikethrough');
344           }
345           if (hasItalicFontStyle) {
346             lexicalNode.toggleFormat('italic');
347           }
348           if (hasUnderlineTextDecoration) {
349             lexicalNode.toggleFormat('underline');
350           }
351         }
352         paragraphNode.append(lexicalNode);
353         return paragraphNode;
354       }
355
356       return lexicalNode;
357     },
358     node: tableCellNode,
359   };
360 }
361
362 export function $createTableCellNode(
363   headerState: TableCellHeaderState,
364   colSpan = 1,
365   width?: number,
366 ): TableCellNode {
367   return $applyNodeReplacement(new TableCellNode(headerState, colSpan, width));
368 }
369
370 export function $isTableCellNode(
371   node: LexicalNode | null | undefined,
372 ): node is TableCellNode {
373   return node instanceof TableCellNode;
374 }