X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/c6ad16dba657c82512ae495a4a38b99b8cfa9eeb..refs/pull/3994/head:/resources/js/wysiwyg/plugin-codeeditor.js diff --git a/resources/js/wysiwyg/plugin-codeeditor.js b/resources/js/wysiwyg/plugin-codeeditor.js index 0d591217a..cd0078b1d 100644 --- a/resources/js/wysiwyg/plugin-codeeditor.js +++ b/resources/js/wysiwyg/plugin-codeeditor.js @@ -1,56 +1,139 @@ 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 = `
`; - 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', '') 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); }); }