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