BookStackApp/BookStack

View on GitHub
resources/js/wysiwyg/nodes/code-block.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import {
    DecoratorNode,
    DOMConversion,
    DOMConversionMap,
    DOMConversionOutput, DOMExportOutput,
    LexicalEditor, LexicalNode,
    SerializedLexicalNode,
    Spread
} from "lexical";
import type {EditorConfig} from "lexical/LexicalEditor";
import {EditorDecoratorAdapter} from "../ui/framework/decorator";
import {CodeEditor} from "../../components";
import {el} from "../utils/dom";

export type SerializedCodeBlockNode = Spread<{
    language: string;
    id: string;
    code: string;
}, SerializedLexicalNode>

const getLanguageFromClassList = (classes: string) => {
    const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-'));
    return (langClasses[0] || '').replace('language-', '');
};

export class CodeBlockNode extends DecoratorNode<EditorDecoratorAdapter> {
    __id: string = '';
    __language: string = '';
    __code: string = '';

    static getType(): string {
        return 'code-block';
    }

    static clone(node: CodeBlockNode): CodeBlockNode {
        const newNode = new CodeBlockNode(node.__language, node.__code, node.__key);
        newNode.__id = node.__id;
        return newNode;
    }

    constructor(language: string = '', code: string = '', key?: string) {
        super(key);
        this.__language = language;
        this.__code = code;
    }

    setLanguage(language: string): void {
        const self = this.getWritable();
        self.__language = language;
    }

    getLanguage(): string {
        const self = this.getLatest();
        return self.__language;
    }

    setCode(code: string): void {
        const self = this.getWritable();
        self.__code = code;
    }

    getCode(): string {
        const self = this.getLatest();
        return self.__code;
    }

    setId(id: string) {
        const self = this.getWritable();
        self.__id = id;
    }

    getId(): string {
        const self = this.getLatest();
        return self.__id;
    }

    decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter {
        return {
            type: 'code',
            getNode: () => this,
        };
    }

    isInline(): boolean {
        return false;
    }

    isIsolated() {
        return true;
    }

    createDOM(_config: EditorConfig, _editor: LexicalEditor) {
        const codeBlock = el('pre', {
            id: this.__id || null,
        }, [
            el('code', {
                class: this.__language ? `language-${this.__language}` : null,
            }, [this.__code]),
        ]);

        return el('div', {class: 'editor-code-block-wrap'}, [codeBlock]);
    }

    updateDOM(prevNode: CodeBlockNode, dom: HTMLElement) {
        const code = dom.querySelector('code');
        if (!code) return false;

        if (prevNode.__language !== this.__language) {
            code.className = this.__language ? `language-${this.__language}` : '';
        }

        if (prevNode.__id !== this.__id) {
            dom.setAttribute('id', this.__id);
        }

        if (prevNode.__code !== this.__code) {
            code.textContent = this.__code;
        }

        return false;
    }

    exportDOM(editor: LexicalEditor): DOMExportOutput {
        const dom = this.createDOM(editor._config, editor);
        return {
            element: dom.querySelector('pre') as HTMLElement,
        };
    }

    static importDOM(): DOMConversionMap|null {
        return {
            pre(node: HTMLElement): DOMConversion|null {
                return {
                    conversion: (element: HTMLElement): DOMConversionOutput|null => {

                        const codeEl = element.querySelector('code');
                        const language = getLanguageFromClassList(element.className)
                                        || (codeEl && getLanguageFromClassList(codeEl.className))
                                        || '';

                        const code = codeEl ? (codeEl.textContent || '').trim() : (element.textContent || '').trim();
                        const node = $createCodeBlockNode(language, code);

                        if (element.id) {
                            node.setId(element.id);
                        }

                        return { node };
                    },
                    priority: 3,
                };
            },
        };
    }

    exportJSON(): SerializedCodeBlockNode {
        return {
            type: 'code-block',
            version: 1,
            id: this.__id,
            language: this.__language,
            code: this.__code,
        };
    }

    static importJSON(serializedNode: SerializedCodeBlockNode): CodeBlockNode {
        const node = $createCodeBlockNode(serializedNode.language, serializedNode.code);
        node.setId(serializedNode.id || '');
        return node;
    }
}

export function $createCodeBlockNode(language: string = '', code: string = ''): CodeBlockNode {
    return new CodeBlockNode(language, code);
}

export function $isCodeBlockNode(node: LexicalNode | null | undefined) {
    return node instanceof CodeBlockNode;
}

export function $openCodeEditorForNode(editor: LexicalEditor, node: CodeBlockNode): void {
    const code = node.getCode();
    const language = node.getLanguage();

    // @ts-ignore
    const codeEditor = window.$components.first('code-editor') as CodeEditor;
    // TODO - Handle direction
    codeEditor.open(code, language, 'ltr', (newCode: string, newLang: string) => {
        editor.update(() => {
            node.setCode(newCode);
            node.setLanguage(newLang);
        });
        // TODO - Re-focus
    }, () => {
        // TODO - Re-focus
    });
}