]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/nodes/code-block.ts
Lexical: Added single node backspace/delete support
[bookstack] / resources / js / wysiwyg / nodes / code-block.ts
1 import {
2     DecoratorNode,
3     DOMConversion,
4     DOMConversionMap,
5     DOMConversionOutput, DOMExportOutput,
6     LexicalEditor, LexicalNode,
7     SerializedLexicalNode,
8     Spread
9 } from "lexical";
10 import type {EditorConfig} from "lexical/LexicalEditor";
11 import {EditorDecoratorAdapter} from "../ui/framework/decorator";
12 import {CodeEditor} from "../../components";
13 import {el} from "../utils/dom";
14
15 export type SerializedCodeBlockNode = Spread<{
16     language: string;
17     id: string;
18     code: string;
19 }, SerializedLexicalNode>
20
21 const getLanguageFromClassList = (classes: string) => {
22     const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-'));
23     return (langClasses[0] || '').replace('language-', '');
24 };
25
26 export class CodeBlockNode extends DecoratorNode<EditorDecoratorAdapter> {
27     __id: string = '';
28     __language: string = '';
29     __code: string = '';
30
31     static getType(): string {
32         return 'code-block';
33     }
34
35     static clone(node: CodeBlockNode): CodeBlockNode {
36         const newNode = new CodeBlockNode(node.__language, node.__code, node.__key);
37         newNode.__id = node.__id;
38         return newNode;
39     }
40
41     constructor(language: string = '', code: string = '', key?: string) {
42         super(key);
43         this.__language = language;
44         this.__code = code;
45     }
46
47     setLanguage(language: string): void {
48         const self = this.getWritable();
49         self.__language = language;
50     }
51
52     getLanguage(): string {
53         const self = this.getLatest();
54         return self.__language;
55     }
56
57     setCode(code: string): void {
58         const self = this.getWritable();
59         self.__code = code;
60     }
61
62     getCode(): string {
63         const self = this.getLatest();
64         return self.__code;
65     }
66
67     setId(id: string) {
68         const self = this.getWritable();
69         self.__id = id;
70     }
71
72     getId(): string {
73         const self = this.getLatest();
74         return self.__id;
75     }
76
77     decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter {
78         return {
79             type: 'code',
80             getNode: () => this,
81         };
82     }
83
84     isInline(): boolean {
85         return false;
86     }
87
88     isIsolated() {
89         return true;
90     }
91
92     createDOM(_config: EditorConfig, _editor: LexicalEditor) {
93         const codeBlock = el('pre', {
94             id: this.__id || null,
95         }, [
96             el('code', {
97                 class: this.__language ? `language-${this.__language}` : null,
98             }, [this.__code]),
99         ]);
100
101         return el('div', {class: 'editor-code-block-wrap'}, [codeBlock]);
102     }
103
104     updateDOM(prevNode: CodeBlockNode, dom: HTMLElement) {
105         const code = dom.querySelector('code');
106         if (!code) return false;
107
108         if (prevNode.__language !== this.__language) {
109             code.className = this.__language ? `language-${this.__language}` : '';
110         }
111
112         if (prevNode.__id !== this.__id) {
113             dom.setAttribute('id', this.__id);
114         }
115
116         if (prevNode.__code !== this.__code) {
117             code.textContent = this.__code;
118         }
119
120         return false;
121     }
122
123     exportDOM(editor: LexicalEditor): DOMExportOutput {
124         const dom = this.createDOM(editor._config, editor);
125         return {
126             element: dom.querySelector('pre') as HTMLElement,
127         };
128     }
129
130     static importDOM(): DOMConversionMap|null {
131         return {
132             pre(node: HTMLElement): DOMConversion|null {
133                 return {
134                     conversion: (element: HTMLElement): DOMConversionOutput|null => {
135
136                         const codeEl = element.querySelector('code');
137                         const language = getLanguageFromClassList(element.className)
138                                         || (codeEl && getLanguageFromClassList(codeEl.className))
139                                         || '';
140
141                         const code = codeEl ? (codeEl.textContent || '').trim() : (element.textContent || '').trim();
142                         const node = $createCodeBlockNode(language, code);
143
144                         if (element.id) {
145                             node.setId(element.id);
146                         }
147
148                         return { node };
149                     },
150                     priority: 3,
151                 };
152             },
153         };
154     }
155
156     exportJSON(): SerializedCodeBlockNode {
157         return {
158             type: 'code-block',
159             version: 1,
160             id: this.__id,
161             language: this.__language,
162             code: this.__code,
163         };
164     }
165
166     static importJSON(serializedNode: SerializedCodeBlockNode): CodeBlockNode {
167         const node = $createCodeBlockNode(serializedNode.language, serializedNode.code);
168         node.setId(serializedNode.id || '');
169         return node;
170     }
171 }
172
173 export function $createCodeBlockNode(language: string = '', code: string = ''): CodeBlockNode {
174     return new CodeBlockNode(language, code);
175 }
176
177 export function $isCodeBlockNode(node: LexicalNode | null | undefined) {
178     return node instanceof CodeBlockNode;
179 }
180
181 export function $openCodeEditorForNode(editor: LexicalEditor, node: CodeBlockNode): void {
182     const code = node.getCode();
183     const language = node.getLanguage();
184
185     // @ts-ignore
186     const codeEditor = window.$components.first('code-editor') as CodeEditor;
187     // TODO - Handle direction
188     codeEditor.open(code, language, 'ltr', (newCode: string, newLang: string) => {
189         editor.update(() => {
190             node.setCode(newCode);
191             node.setLanguage(newLang);
192         });
193         // TODO - Re-focus
194     }, () => {
195         // TODO - Re-focus
196     });
197 }