]> BookStack Code Mirror - bookstack/blobdiff - resources/js/wysiwyg/plugin-codeeditor.js
Started refactor and alignment of component system
[bookstack] / resources / js / wysiwyg / plugin-codeeditor.js
index 0d591217a430186fe53611c8d09237ff0080ef40..cd0078b1d914da39ccdf3df993527f44f7ce4626 100644 (file)
 function elemIsCodeBlock(elem) {
-    return elem.className === 'CodeMirrorContainer';
+    return elem.tagName.toLowerCase() === 'code-block';
 }
 
-function showPopup(editor) {
-    const selectedNode = editor.selection.getNode();
+/**
+ * @param {Editor} editor
+ * @param {String} code
+ * @param {String} language
+ * @param {function(string, string)} callback (Receives (code: string,language: string)
+ */
+function showPopup(editor, code, language, callback) {
+    window.$components.first('code-editor').open(code, language, (newCode, newLang) => {
+        callback(newCode, newLang)
+        editor.focus()
+    });
+}
 
-    if (!elemIsCodeBlock(selectedNode)) {
-        const providedCode = editor.selection.getContent({format: 'text'});
-        window.components.first('code-editor').open(providedCode, '', (code, lang) => {
-            const wrap = document.createElement('div');
-            wrap.innerHTML = `<pre><code class="language-${lang}"></code></pre>`;
-            wrap.querySelector('code').innerText = code;
+/**
+ * @param {Editor} editor
+ * @param {CodeBlockElement} codeBlock
+ */
+function showPopupForCodeBlock(editor, codeBlock) {
+    showPopup(editor, codeBlock.getContent(), codeBlock.getLanguage(), (newCode, newLang) => {
+        codeBlock.setContent(newCode, newLang);
+    });
+}
 
-            editor.insertContent(wrap.innerHTML);
-            editor.focus();
-        });
-        return;
-    }
+/**
+ * Define our custom code-block HTML element that we use.
+ * Needs to be delayed since it needs to be defined within the context of the
+ * child editor window and document, hence its definition within a callback.
+ * @param {Editor} editor
+ */
+function defineCodeBlockCustomElement(editor) {
+    const doc = editor.getDoc();
+    const win = doc.defaultView;
 
-    const lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : '';
-    const currentCode = selectedNode.querySelector('textarea').textContent;
+    class CodeBlockElement extends win.HTMLElement {
+        constructor() {
+            super();
+            this.attachShadow({mode: 'open'});
 
-    window.components.first('code-editor').open(currentCode, lang, (code, lang) => {
-        const editorElem = selectedNode.querySelector('.CodeMirror');
-        const cmInstance = editorElem.CodeMirror;
-        if (cmInstance) {
-            window.importVersioned('code').then(Code => {
-                Code.setContent(cmInstance, code);
-                Code.setMode(cmInstance, lang, code);
-            });
+            const stylesToCopy = document.querySelectorAll('link[rel="stylesheet"]:not([media="print"])');
+            const copiedStyles = Array.from(stylesToCopy).map(styleEl => styleEl.cloneNode(false));
+
+            const cmContainer = document.createElement('div');
+            cmContainer.style.pointerEvents = 'none';
+            cmContainer.contentEditable = 'false';
+            cmContainer.classList.add('CodeMirrorContainer');
+
+            this.shadowRoot.append(...copiedStyles, cmContainer);
         }
-        const textArea = selectedNode.querySelector('textarea');
-        if (textArea) textArea.textContent = code;
-        selectedNode.setAttribute('data-lang', lang);
 
-        editor.focus()
-    });
-}
+        getLanguage() {
+            const getLanguageFromClassList = (classes) => {
+                const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-'));
+                return (langClasses[0] || '').replace('language-', '');
+            };
+
+            const code = this.querySelector('code');
+            const pre = this.querySelector('pre');
+            return getLanguageFromClassList(pre.className) || (code && getLanguageFromClassList(code.className)) || '';
+        }
+
+        setContent(content, language) {
+            if (this.cm) {
+                importVersioned('code').then(Code => {
+                    Code.setContent(this.cm, content);
+                    Code.setMode(this.cm, language, content);
+                });
+            }
+
+            let pre = this.querySelector('pre');
+            if (!pre) {
+                pre = doc.createElement('pre');
+                this.append(pre);
+            }
+            pre.innerHTML = '';
+
+            const code = doc.createElement('code');
+            pre.append(code);
+            code.innerText = content;
+            code.className = `language-${language}`;
+        }
 
-function codeMirrorContainerToPre(codeMirrorContainer) {
-    const textArea = codeMirrorContainer.querySelector('textarea');
-    const code = textArea.textContent;
-    const lang = codeMirrorContainer.getAttribute('data-lang');
+        getContent() {
+            const code = this.querySelector('code') || this.querySelector('pre');
+            const tempEl = document.createElement('pre');
+            tempEl.innerHTML = code.innerHTML.replace(/\ufeff/g, '');
 
-    codeMirrorContainer.removeAttribute('contentEditable');
-    const pre = document.createElement('pre');
-    const codeElem = document.createElement('code');
-    codeElem.classList.add(`language-${lang}`);
-    codeElem.textContent = code;
-    pre.appendChild(codeElem);
+            const brs = tempEl.querySelectorAll('br');
+            for (const br of brs) {
+                br.replaceWith('\n');
+            }
 
-    codeMirrorContainer.parentElement.replaceChild(pre, codeMirrorContainer);
+            return tempEl.textContent;
+        }
+
+        connectedCallback() {
+            const connectedTime = Date.now();
+            if (this.cm) {
+                return;
+            }
+
+            this.cleanChildContent();
+            const content = this.getContent();
+            const lines = content.split('\n').length;
+            const height = (lines * 19.2) + 18 + 24;
+            this.style.height = `${height}px`;
+
+            const container = this.shadowRoot.querySelector('.CodeMirrorContainer');
+            const renderCodeMirror = (Code) => {
+                this.cm = Code.wysiwygView(container, content, this.getLanguage());
+                setTimeout(() => Code.updateLayout(this.cm), 10);
+                setTimeout(() => this.style.height = null, 12);
+            };
+
+            window.importVersioned('code').then((Code) => {
+                const timeout = (Date.now() - connectedTime < 20) ? 20 : 0;
+                setTimeout(() => renderCodeMirror(Code), timeout);
+            });
+        }
+
+        cleanChildContent() {
+            const pre = this.querySelector('pre');
+            if (!pre) return;
+
+            for (const preChild of pre.childNodes) {
+                if (preChild.nodeName === '#text' && preChild.textContent === '') {
+                    preChild.remove();
+                }
+            }
+        }
+    }
+
+    win.customElements.define('code-block', CodeBlockElement);
 }
 
 
@@ -60,8 +143,6 @@ function codeMirrorContainerToPre(codeMirrorContainer) {
  */
 function register(editor, url) {
 
-    const $ = editor.$;
-
     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>')
 
     editor.ui.registry.addButton('codeeditor', {
@@ -72,55 +153,80 @@ function register(editor, url) {
         }
     });
 
-    editor.addCommand('codeeditor', () => {
-        showPopup(editor);
+    editor.ui.registry.addButton('editcodeeditor', {
+        tooltip: 'Edit code block',
+        icon: 'edit-block',
+        onAction() {
+            editor.execCommand('codeeditor');
+        }
     });
 
-    // Convert
-    editor.on('PreProcess', function (e) {
-        $('div.CodeMirrorContainer', e.node).each((index, elem) => {
-            codeMirrorContainerToPre(elem);
-        });
+    editor.addCommand('codeeditor', () => {
+        const selectedNode = editor.selection.getNode();
+        const doc = selectedNode.ownerDocument;
+        if (elemIsCodeBlock(selectedNode)) {
+            showPopupForCodeBlock(editor, selectedNode);
+        } else {
+            const textContent = editor.selection.getContent({format: 'text'});
+            showPopup(editor, textContent, '', (newCode, newLang) => {
+                const pre = doc.createElement('pre');
+                const code = doc.createElement('code');
+                code.classList.add(`language-${newLang}`);
+                code.innerText = newCode;
+                pre.append(code);
+
+                editor.insertContent(pre.outerHTML);
+            });
+        }
     });
 
     editor.on('dblclick', event => {
         let selectedNode = editor.selection.getNode();
-        if (!elemIsCodeBlock(selectedNode)) return;
-        showPopup(editor);
+        if (elemIsCodeBlock(selectedNode)) {
+            showPopupForCodeBlock(editor, selectedNode);
+        }
     });
 
-    function parseCodeMirrorInstances(Code) {
-
-        // Recover broken codemirror instances
-        $('.CodeMirrorContainer').filter((index ,elem) => {
-            return typeof elem.querySelector('.CodeMirror').CodeMirror === 'undefined';
-        }).each((index, elem) => {
-            codeMirrorContainerToPre(elem);
+    editor.on('PreInit', () => {
+        editor.parser.addNodeFilter('pre', function(elms) {
+            for (const el of elms) {
+                const wrapper = tinymce.html.Node.create('code-block', {
+                    contenteditable: 'false',
+                });
+
+                const spans = el.getAll('span');
+                for (const span of spans) {
+                    span.unwrap();
+                }
+                el.attr('style', null);
+                el.wrap(wrapper);
+            }
         });
 
-        const codeSamples = $('body > pre').filter((index, elem) => {
-            return elem.contentEditable !== "false";
+        editor.parser.addNodeFilter('code-block', function(elms) {
+            for (const el of elms) {
+                el.attr('contenteditable', 'false');
+            }
         });
 
-        codeSamples.each((index, elem) => {
-            Code.wysiwygView(elem);
+        editor.serializer.addNodeFilter('code-block', function(elms) {
+            for (const el of elms) {
+                el.unwrap();
+            }
         });
-    }
+    });
 
-    editor.on('init', async function() {
-        const Code = await window.importVersioned('code');
-        // Parse code mirror instances on init, but delay a little so this runs after
-        // initial styles are fetched into the editor.
-        editor.undoManager.transact(function () {
-            parseCodeMirrorInstances(Code);
-        });
-        // Parsed code mirror blocks when content is set but wait before setting this handler
-        // to avoid any init 'SetContent' events.
-        setTimeout(() => {
-            editor.on('SetContent', () => {
-                setTimeout(() => parseCodeMirrorInstances(Code), 100);
-            });
-        }, 200);
+    editor.ui.registry.addContextToolbar('codeeditor', {
+        predicate: function (node) {
+            return node.nodeName.toLowerCase() === 'code-block';
+        },
+        items: 'editcodeeditor',
+        position: 'node',
+        scope: 'node'
+    });
+
+    editor.on('PreInit', () => {
+        defineCodeBlockCustomElement(editor);
     });
 }