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