BookStackApp/BookStack

View on GitHub
resources/js/wysiwyg/nodes/custom-table-cell.ts

Summary

Maintainability
D
2 days
Test Coverage
import {
    $createParagraphNode,
    $isElementNode,
    $isLineBreakNode,
    $isTextNode,
    DOMConversionMap,
    DOMConversionOutput,
    DOMExportOutput,
    EditorConfig,
    LexicalEditor,
    LexicalNode,
    Spread
} from "lexical";

import {
    $createTableCellNode,
    $isTableCellNode,
    SerializedTableCellNode,
    TableCellHeaderStates,
    TableCellNode
} from "@lexical/table";
import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode";
import {extractStyleMapFromElement, StyleMap} from "../utils/dom";
import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common";

export type SerializedCustomTableCellNode = Spread<{
    styles: Record<string, string>;
    alignment: CommonBlockAlignment;
}, SerializedTableCellNode>

export class CustomTableCellNode extends TableCellNode {
    __styles: StyleMap = new Map;
    __alignment: CommonBlockAlignment = '';

    static getType(): string {
        return 'custom-table-cell';
    }

    static clone(node: CustomTableCellNode): CustomTableCellNode {
        const cellNode = new CustomTableCellNode(
            node.__headerState,
            node.__colSpan,
            node.__width,
            node.__key,
        );
        cellNode.__rowSpan = node.__rowSpan;
        cellNode.__styles = new Map(node.__styles);
        cellNode.__alignment = node.__alignment;
        return cellNode;
    }

    clearWidth(): void {
        const self = this.getWritable();
        self.__width = undefined;
    }

    getStyles(): StyleMap {
        const self = this.getLatest();
        return new Map(self.__styles);
    }

    setStyles(styles: StyleMap): void {
        const self = this.getWritable();
        self.__styles = new Map(styles);
    }

    setAlignment(alignment: CommonBlockAlignment) {
        const self = this.getWritable();
        self.__alignment = alignment;
    }

    getAlignment(): CommonBlockAlignment {
        const self = this.getLatest();
        return self.__alignment;
    }

    updateTag(tag: string): void {
        const isHeader = tag.toLowerCase() === 'th';
        const state = isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS;
        const self = this.getWritable();
        self.__headerState = state;
    }

    createDOM(config: EditorConfig): HTMLElement {
        const element = super.createDOM(config);

        for (const [name, value] of this.__styles.entries()) {
            element.style.setProperty(name, value);
        }

        if (this.__alignment) {
            element.classList.add('align-' + this.__alignment);
        }

        return element;
    }

    updateDOM(prevNode: CustomTableCellNode): boolean {
        return super.updateDOM(prevNode)
            || this.__styles !== prevNode.__styles
            || this.__alignment !== prevNode.__alignment;
    }

    static importDOM(): DOMConversionMap | null {
        return {
            td: (node: Node) => ({
                conversion: $convertCustomTableCellNodeElement,
                priority: 0,
            }),
            th: (node: Node) => ({
                conversion: $convertCustomTableCellNodeElement,
                priority: 0,
            }),
        };
    }

    exportDOM(editor: LexicalEditor): DOMExportOutput {
        const element = this.createDOM(editor._config);
        return {
            element
        };
    }

    static importJSON(serializedNode: SerializedCustomTableCellNode): CustomTableCellNode {
        const node = $createCustomTableCellNode(
            serializedNode.headerState,
            serializedNode.colSpan,
            serializedNode.width,
        );

        node.setStyles(new Map(Object.entries(serializedNode.styles)));
        node.setAlignment(serializedNode.alignment);

        return node;
    }

    exportJSON(): SerializedCustomTableCellNode {
        return {
            ...super.exportJSON(),
            type: 'custom-table-cell',
            styles: Object.fromEntries(this.__styles),
            alignment: this.__alignment,
        };
    }
}

function $convertCustomTableCellNodeElement(domNode: Node): DOMConversionOutput {
    const output =  $convertTableCellNodeElement(domNode);

    if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) {
        output.node.setStyles(extractStyleMapFromElement(domNode));
        output.node.setAlignment(extractAlignmentFromElement(domNode));
    }

    return output;
}

/**
 * Function taken from:
 * https://github.com/facebook/lexical/blob/e1881a6e409e1541c10dd0b5378f3a38c9dc8c9e/packages/lexical-table/src/LexicalTableCellNode.ts#L289
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * MIT LICENSE
 * Modified since copy.
 */
export function $convertTableCellNodeElement(
    domNode: Node,
): DOMConversionOutput {
    const domNode_ = domNode as HTMLTableCellElement;
    const nodeName = domNode.nodeName.toLowerCase();

    let width: number | undefined = undefined;


    const PIXEL_VALUE_REG_EXP = /^(\d+(?:\.\d+)?)px$/;
    if (PIXEL_VALUE_REG_EXP.test(domNode_.style.width)) {
        width = parseFloat(domNode_.style.width);
    }

    const tableCellNode = $createTableCellNode(
        nodeName === 'th'
            ? TableCellHeaderStates.ROW
            : TableCellHeaderStates.NO_STATUS,
        domNode_.colSpan,
        width,
    );

    tableCellNode.__rowSpan = domNode_.rowSpan;

    const style = domNode_.style;
    const textDecoration = style.textDecoration.split(' ');
    const hasBoldFontWeight =
        style.fontWeight === '700' || style.fontWeight === 'bold';
    const hasLinethroughTextDecoration = textDecoration.includes('line-through');
    const hasItalicFontStyle = style.fontStyle === 'italic';
    const hasUnderlineTextDecoration = textDecoration.includes('underline');
    return {
        after: (childLexicalNodes) => {
            if (childLexicalNodes.length === 0) {
                childLexicalNodes.push($createParagraphNode());
            }
            return childLexicalNodes;
        },
        forChild: (lexicalNode, parentLexicalNode) => {
            if ($isTableCellNode(parentLexicalNode) && !$isElementNode(lexicalNode)) {
                const paragraphNode = $createParagraphNode();
                if (
                    $isLineBreakNode(lexicalNode) &&
                    lexicalNode.getTextContent() === '\n'
                ) {
                    return null;
                }
                if ($isTextNode(lexicalNode)) {
                    if (hasBoldFontWeight) {
                        lexicalNode.toggleFormat('bold');
                    }
                    if (hasLinethroughTextDecoration) {
                        lexicalNode.toggleFormat('strikethrough');
                    }
                    if (hasItalicFontStyle) {
                        lexicalNode.toggleFormat('italic');
                    }
                    if (hasUnderlineTextDecoration) {
                        lexicalNode.toggleFormat('underline');
                    }
                }
                paragraphNode.append(lexicalNode);
                return paragraphNode;
            }

            return lexicalNode;
        },
        node: tableCellNode,
    };
}


export function $createCustomTableCellNode(
    headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS,
    colSpan = 1,
    width?: number,
): CustomTableCellNode {
    return new CustomTableCellNode(headerState, colSpan, width);
}

export function $isCustomTableCellNode(node: LexicalNode | null | undefined): node is CustomTableCellNode {
    return node instanceof CustomTableCellNode;
}