]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/plugin-codeeditor.js
9e681486dadf96e7bcc154b561d630d48641d77f
[bookstack] / resources / js / wysiwyg / plugin-codeeditor.js
1 function elemIsCodeBlock(elem) {
2     return elem.tagName.toLowerCase() === 'code-block';
3 }
4
5 /**
6  * @param {Editor} editor
7  * @param {String} code
8  * @param {String} language
9  * @param {function(string, string)} callback (Receives (code: string,language: string)
10  */
11 function showPopup(editor, code, language, callback) {
12     window.$components.first('code-editor').open(code, language, (newCode, newLang) => {
13         callback(newCode, newLang)
14         editor.focus()
15     });
16 }
17
18 /**
19  * @param {Editor} editor
20  * @param {CodeBlockElement} codeBlock
21  */
22 function showPopupForCodeBlock(editor, codeBlock) {
23     showPopup(editor, codeBlock.getContent(), codeBlock.getLanguage(), (newCode, newLang) => {
24         codeBlock.setContent(newCode, newLang);
25     });
26 }
27
28 /**
29  * Define our custom code-block HTML element that we use.
30  * Needs to be delayed since it needs to be defined within the context of the
31  * child editor window and document, hence its definition within a callback.
32  * @param {Editor} editor
33  */
34 function defineCodeBlockCustomElement(editor) {
35     const doc = editor.getDoc();
36     const win = doc.defaultView;
37
38     class CodeBlockElement extends win.HTMLElement {
39
40         /**
41          * @type {?SimpleEditorInterface}
42          */
43         editor = null;
44
45         constructor() {
46             super();
47             this.attachShadow({mode: 'open'});
48
49             const stylesToCopy = document.querySelectorAll('link[rel="stylesheet"]:not([media="print"])');
50             const copiedStyles = Array.from(stylesToCopy).map(styleEl => styleEl.cloneNode(false));
51
52             const cmContainer = document.createElement('div');
53             cmContainer.style.pointerEvents = 'none';
54             cmContainer.contentEditable = 'false';
55             cmContainer.classList.add('CodeMirrorContainer');
56             cmContainer.classList.toggle('dark-mode', document.documentElement.classList.contains('dark-mode'));
57
58             this.shadowRoot.append(...copiedStyles, cmContainer);
59         }
60
61         getLanguage() {
62             const getLanguageFromClassList = (classes) => {
63                 const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-'));
64                 return (langClasses[0] || '').replace('language-', '');
65             };
66
67             const code = this.querySelector('code');
68             const pre = this.querySelector('pre');
69             return getLanguageFromClassList(pre.className) || (code && getLanguageFromClassList(code.className)) || '';
70         }
71
72         setContent(content, language) {
73             if (this.editor) {
74                 this.editor.setContent(content);
75                 this.editor.setMode(language, content);
76             }
77
78             let pre = this.querySelector('pre');
79             if (!pre) {
80                 pre = doc.createElement('pre');
81                 this.append(pre);
82             }
83             pre.innerHTML = '';
84
85             const code = doc.createElement('code');
86             pre.append(code);
87             code.innerText = content;
88             code.className = `language-${language}`;
89         }
90
91         getContent() {
92             const code = this.querySelector('code') || this.querySelector('pre');
93             const tempEl = document.createElement('pre');
94             tempEl.innerHTML = code.innerHTML.replace(/\ufeff/g, '');
95
96             const brs = tempEl.querySelectorAll('br');
97             for (const br of brs) {
98                 br.replaceWith('\n');
99             }
100
101             return tempEl.textContent;
102         }
103
104         connectedCallback() {
105             const connectedTime = Date.now();
106             if (this.editor) {
107                 return;
108             }
109
110             this.cleanChildContent();
111             const content = this.getContent();
112             const lines = content.split('\n').length;
113             const height = (lines * 19.2) + 18 + 24;
114             this.style.height = `${height}px`;
115
116             const container = this.shadowRoot.querySelector('.CodeMirrorContainer');
117             const renderEditor = (Code) => {
118                 this.editor = Code.wysiwygView(container, this.shadowRoot, content, this.getLanguage());
119                 setTimeout(() => this.style.height = null, 12);
120             };
121
122             window.importVersioned('code').then((Code) => {
123                 const timeout = (Date.now() - connectedTime < 20) ? 20 : 0;
124                 setTimeout(() => renderEditor(Code), timeout);
125             });
126         }
127
128         cleanChildContent() {
129             const pre = this.querySelector('pre');
130             if (!pre) return;
131
132             for (const preChild of pre.childNodes) {
133                 if (preChild.nodeName === '#text' && preChild.textContent === '') {
134                     preChild.remove();
135                 }
136             }
137         }
138     }
139
140     win.customElements.define('code-block', CodeBlockElement);
141 }
142
143
144 /**
145  * @param {Editor} editor
146  * @param {String} url
147  */
148 function register(editor, url) {
149
150     editor.ui.registry.addIcon('codeblock', '<svg width="24" height="24"><path d="M4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm1 2v14h14V5Z"/><path d="M11.103 15.423c.277.277.277.738 0 .922a.692.692 0 0 1-1.106 0l-4.057-3.78a.738.738 0 0 1 0-1.107l4.057-3.872c.276-.277.83-.277 1.106 0a.724.724 0 0 1 0 1.014L7.6 12.012ZM12.897 8.577c-.245-.312-.2-.675.08-.955.28-.281.727-.27 1.027.033l4.057 3.78a.738.738 0 0 1 0 1.107l-4.057 3.872c-.277.277-.83.277-1.107 0a.724.724 0 0 1 0-1.014l3.504-3.412z"/></svg>')
151
152     editor.ui.registry.addButton('codeeditor', {
153         tooltip: 'Insert code block',
154         icon: 'codeblock',
155         onAction() {
156             editor.execCommand('codeeditor');
157         }
158     });
159
160     editor.ui.registry.addButton('editcodeeditor', {
161         tooltip: 'Edit code block',
162         icon: 'edit-block',
163         onAction() {
164             editor.execCommand('codeeditor');
165         }
166     });
167
168     editor.addCommand('codeeditor', () => {
169         const selectedNode = editor.selection.getNode();
170         const doc = selectedNode.ownerDocument;
171         if (elemIsCodeBlock(selectedNode)) {
172             showPopupForCodeBlock(editor, selectedNode);
173         } else {
174             const textContent = editor.selection.getContent({format: 'text'});
175             showPopup(editor, textContent, '', (newCode, newLang) => {
176                 const pre = doc.createElement('pre');
177                 const code = doc.createElement('code');
178                 code.classList.add(`language-${newLang}`);
179                 code.innerText = newCode;
180                 pre.append(code);
181
182                 editor.insertContent(pre.outerHTML);
183             });
184         }
185     });
186
187     editor.on('dblclick', event => {
188         let selectedNode = editor.selection.getNode();
189         if (elemIsCodeBlock(selectedNode)) {
190             showPopupForCodeBlock(editor, selectedNode);
191         }
192     });
193
194     editor.on('PreInit', () => {
195         editor.parser.addNodeFilter('pre', function(elms) {
196             for (const el of elms) {
197                 const wrapper = tinymce.html.Node.create('code-block', {
198                     contenteditable: 'false',
199                 });
200
201                 const spans = el.getAll('span');
202                 for (const span of spans) {
203                     span.unwrap();
204                 }
205                 el.attr('style', null);
206                 el.wrap(wrapper);
207             }
208         });
209
210         editor.parser.addNodeFilter('code-block', function(elms) {
211             for (const el of elms) {
212                 el.attr('contenteditable', 'false');
213             }
214         });
215
216         editor.serializer.addNodeFilter('code-block', function(elms) {
217             for (const el of elms) {
218                 el.unwrap();
219             }
220         });
221     });
222
223     editor.ui.registry.addContextToolbar('codeeditor', {
224         predicate: function (node) {
225             return node.nodeName.toLowerCase() === 'code-block';
226         },
227         items: 'editcodeeditor',
228         position: 'node',
229         scope: 'node'
230     });
231
232     editor.on('PreInit', () => {
233         defineCodeBlockCustomElement(editor);
234     });
235 }
236
237 /**
238  * @param {WysiwygConfigOptions} options
239  * @return {register}
240  */
241 export function getPlugin(options) {
242     return register;
243 }