From: Dan Brown Date: Wed, 9 Feb 2022 19:24:27 +0000 (+0000) Subject: Revamped workings of WYSIWYG code blocks X-Git-Tag: v22.02~1^2~12^2 X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/refs/pull/3260/head?ds=sidebyside Revamped workings of WYSIWYG code blocks Code blocks in tinymce could sometimes end up exploded into the sub elements of the codemirror display. This changes the strategy to render codemirror within the shadow dom of a custom element while preserving the normal pre/code DOM structure. Still a little instability when moving/adding code blocks within details blocks but much harder to break things now. --- diff --git a/resources/js/code.mjs b/resources/js/code.mjs index 3a7706573..8e2ed72c8 100644 --- a/resources/js/code.mjs +++ b/resources/js/code.mjs @@ -204,56 +204,22 @@ function getTheme() { /** * Create a CodeMirror instance for showing inside the WYSIWYG editor. * Manages a textarea element to hold code content. - * @param {HTMLElement} elem + * @param {HTMLElement} cmContainer + * @param {String} content + * @param {String} language * @returns {{wrap: Element, editor: *}} */ -export function wysiwygView(elem) { - const doc = elem.ownerDocument; - const codeElem = elem.querySelector('code'); - - let lang = getLanguageFromCssClasses(elem.className || ''); - if (!lang && codeElem) { - lang = getLanguageFromCssClasses(codeElem.className || ''); - } - - elem.innerHTML = elem.innerHTML.replace(//gi ,'\n'); - const content = elem.textContent; - const newWrap = doc.createElement('div'); - const newTextArea = doc.createElement('textarea'); - - newWrap.className = 'CodeMirrorContainer'; - newWrap.setAttribute('data-lang', lang); - newWrap.setAttribute('dir', 'ltr'); - newTextArea.style.display = 'none'; - elem.parentNode.replaceChild(newWrap, elem); - - newWrap.appendChild(newTextArea); - newWrap.contentEditable = 'false'; - newTextArea.textContent = content; - - let cm = CodeMirror(function(elt) { - newWrap.appendChild(elt); - }, { +export function wysiwygView(cmContainer, content, language) { + return CodeMirror(cmContainer, { value: content, - mode: getMode(lang, content), + mode: getMode(language, content), lineNumbers: true, lineWrapping: false, theme: getTheme(), readOnly: true }); - - return {wrap: newWrap, editor: cm}; } -/** - * Get the code language from the given css classes. - * @param {String} classes - * @return {String} - */ -function getLanguageFromCssClasses(classes) { - const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-')); - return (langClasses[0] || '').replace('language-', ''); -} /** * Create a CodeMirror instance to show in the WYSIWYG pop-up editor diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index 7fa3b0f26..1b3b6e7b5 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -210,16 +210,6 @@ body { }`.trim().replace('\n', ''); } -// Custom "Document Root" element, a custom element to identify/define -// block that may act as another "editable body". -// Using a custom node means we can identify and add/remove these as desired -// without affecting user content. -class DocRootElement extends HTMLDivElement { - constructor() { - super(); - } -} - /** * @param {WysiwygConfigOptions} options * @return {Object} @@ -230,8 +220,6 @@ export function build(options) { window.tinymce.addI18n(options.language, options.translationMap); // Build toolbar content const {toolbar, groupButtons: toolBarGroupButtons} = buildToolbar(options); - // Define our custom root node - customElements.define('doc-root', DocRootElement, {extends: 'div'}); // Return config object return { @@ -254,10 +242,17 @@ export function build(options) { statusbar: false, menubar: false, paste_data_images: false, - extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],doc-root', + extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],div[*]', automatic_uploads: false, - custom_elements: 'doc-root', - valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote|div],+div[pre],+div[img],+doc-root[p|h1|h2|h3|h4|h5|h6|blockquote|pre|img|ul|ol],-doc-root[doc-root|#text]", + custom_elements: 'doc-root,code-block', + valid_children: [ + "-div[p|h1|h2|h3|h4|h5|h6|blockquote|code-block]", + "+div[pre|img]", + "-doc-root[doc-root|#text]", + "-li[details]", + "+code-block[pre]", + "+doc-root[code-block]" + ].join(','), plugins: gatherPlugins(options), imagetools_toolbar: 'imageoptions', contextmenu: false, diff --git a/resources/js/wysiwyg/plugin-codeeditor.js b/resources/js/wysiwyg/plugin-codeeditor.js index 0d591217a..12b2c25fb 100644 --- a/resources/js/wysiwyg/plugin-codeeditor.js +++ b/resources/js/wysiwyg/plugin-codeeditor.js @@ -1,56 +1,108 @@ 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; + + class CodeBlockElement extends win.HTMLElement { + constructor() { + super(); + this.attachShadow({mode: 'open'}); + const linkElem = document.createElement('link'); + linkElem.setAttribute('rel', 'stylesheet'); + linkElem.setAttribute('href', window.baseUrl('/dist/styles.css')); + + const cmContainer = document.createElement('div'); + cmContainer.style.pointerEvents = 'none'; + cmContainer.contentEditable = 'false'; + cmContainer.classList.add('CodeMirrorContainer'); + + this.shadowRoot.append(linkElem, cmContainer); + } - const lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : ''; - const currentCode = selectedNode.querySelector('textarea').textContent; + getLanguage() { + const getLanguageFromClassList = (classes) => { + const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-')); + return (langClasses[0] || '').replace('language-', ''); + }; - 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 code = this.querySelector('code'); + const pre = this.querySelector('pre'); + return getLanguageFromClassList(pre.className) || (code && getLanguageFromClassList(code.className)) || ''; } - const textArea = selectedNode.querySelector('textarea'); - if (textArea) textArea.textContent = code; - selectedNode.setAttribute('data-lang', lang); - editor.focus() - }); -} + 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().replace(//gi ,'\n').replace(/\ufeff/g, ''); + return tempEl.textContent; + } - codeMirrorContainer.removeAttribute('contentEditable'); - const pre = document.createElement('pre'); - const codeElem = document.createElement('code'); - codeElem.classList.add(`language-${lang}`); - codeElem.textContent = code; - pre.appendChild(codeElem); + connectedCallback() { + if (this.cm) { + return; + } + + const container = this.shadowRoot.querySelector('.CodeMirrorContainer'); + importVersioned('code').then(Code => { + this.cm = Code.wysiwygView(container, this.getContent(), this.getLanguage()); + }); + } + } - codeMirrorContainer.parentElement.replaceChild(pre, codeMirrorContainer); + win.customElements.define('code-block', CodeBlockElement); } @@ -60,8 +112,6 @@ function codeMirrorContainerToPre(codeMirrorContainer) { */ function register(editor, url) { - const $ = editor.$; - editor.ui.registry.addIcon('codeblock', '') editor.ui.registry.addButton('codeeditor', { @@ -73,54 +123,64 @@ function register(editor, url) { }); editor.addCommand('codeeditor', () => { - showPopup(editor); - }); - - // Convert - editor.on('PreProcess', function (e) { - $('div.CodeMirrorContainer', e.node).each((index, elem) => { - codeMirrorContainerToPre(elem); - }); + 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 wrap = doc.createElement('code-block'); + const pre = doc.createElement('pre'); + const code = doc.createElement('code'); + code.classList.add(`language-${newLang}`); + code.innerText = newCode; + pre.append(code); + wrap.append(pre); + + editor.insertContent(wrap.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 = new 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('content-editable', '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.on('PreInit', () => { + defineCodeBlockCustomElement(editor); }); } diff --git a/resources/js/wysiwyg/plugins-details.js b/resources/js/wysiwyg/plugins-details.js index 0f089fc8e..9b5287947 100644 --- a/resources/js/wysiwyg/plugins-details.js +++ b/resources/js/wysiwyg/plugins-details.js @@ -29,12 +29,15 @@ function register(editor, url) { icon: 'togglelabel', tooltip: 'Edit label', onAction() { - const details = getSelectedDetailsBlock(editor); - const dialog = editor.windowManager.open(detailsDialog(editor)); - dialog.setData({summary: getSummaryTextFromDetails(details)}); + showDetailLabelEditWindow(editor); } }); + editor.on('dblclick', event => { + if (!getSelectedDetailsBlock(editor) || event.target.closest('doc-root')) return; + showDetailLabelEditWindow(editor); + }); + editor.ui.registry.addButton('toggledetails', { icon: 'togglefold', tooltip: 'Toggle open/closed', @@ -46,13 +49,29 @@ function register(editor, url) { }); editor.addCommand('InsertDetailsBlock', function () { - const content = editor.selection.getContent({format: 'html'}); + let content = editor.selection.getContent({format: 'html'}); const details = document.createElement('details'); const summary = document.createElement('summary'); + const id = 'details-' + Date.now(); + details.setAttribute('data-id', id) details.appendChild(summary); - details.innerHTML += content; + if (!content) { + content = '


'; + } + + details.innerHTML += content; editor.insertContent(details.outerHTML); + editor.focus(); + + const domDetails = editor.dom.$(`[data-id="${id}"]`); + if (domDetails) { + const firstChild = domDetails.find('doc-root > *'); + if (firstChild) { + firstChild[0].focus(); + } + domDetails.removeAttr('data-id'); + } }); editor.ui.registry.addContextToolbar('details', { @@ -69,6 +88,15 @@ function register(editor, url) { }); } +/** + * @param {Editor} editor + */ +function showDetailLabelEditWindow(editor) { + const details = getSelectedDetailsBlock(editor); + const dialog = editor.windowManager.open(detailsDialog(editor)); + dialog.setData({summary: getSummaryTextFromDetails(details)}); +} + /** * @param {Editor} editor */ @@ -99,7 +127,7 @@ function detailsDialog(editor) { { type: 'input', name: 'summary', - label: 'Toggle label text', + label: 'Toggle label', }, ], }, @@ -141,14 +169,13 @@ function setSummary(editor, summaryContent) { */ function unwrapDetailsInSelection(editor) { const details = editor.selection.getNode().closest('details'); + if (details) { - const summary = details.querySelector('summary'); + const elements = details.querySelectorAll('details > *:not(summary, doc-root), doc-root > *'); + editor.undoManager.transact(() => { - if (summary) { - summary.remove(); - } - while (details.firstChild) { - details.parentNode.insertBefore(details.firstChild, details); + for (const element of elements) { + details.parentNode.insertBefore(element, details); } details.remove(); }); @@ -172,6 +199,12 @@ function setupElementFilters(editor) { el.attr('open', null); } }); + + editor.serializer.addNodeFilter('doc-root', function(elms) { + for (const el of elms) { + el.unwrap(); + } + }); } /** diff --git a/resources/lang/en/editor.php b/resources/lang/en/editor.php index 2b1d1a519..76a9f7fca 100644 --- a/resources/lang/en/editor.php +++ b/resources/lang/en/editor.php @@ -136,6 +136,7 @@ return [ 'edit_label' => 'Edit label', 'toggle_open_closed' => 'Toggle open/closed', 'collapsible_edit' => 'Edit collapsible block', + 'toggle_label' => 'Toggle label', // About view 'about_title' => 'About the WYSIWYG Editor', diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 4c54c1045..af5bea0f1 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -158,6 +158,11 @@ body.tox-fullscreen, body.markdown-fullscreen { details > summary + * { margin-top: .2em; } + details:after { + content: ''; + display: block; + clear: both; + } &.page-revision { pre code { diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index ecb258a53..6add27f45 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -21,6 +21,9 @@ .page-content.mce-content-body doc-root { display: block; } +.page-content.mce-content-body code-block { + display: block; +} // In editor line height override .page-content.mce-content-body p { @@ -38,9 +41,12 @@ body.page-content.mce-content-body { } // Prevent scroll jumps on codemirror clicks -.page-content.mce-content-body .CodeMirror { +.page-content.mce-content-body code-block > * { pointer-events: none; } +.page-content.mce-content-body code-block pre { + display: none; +} // Details/summary editor usability .page-content.mce-content-body details summary { @@ -51,6 +57,8 @@ body.page-content.mce-content-body { margin-left: (2px - $-s); margin-right: (2px - $-s); margin-bottom: (2px - $-s); + margin-top: (2px - $-s); + overflow: hidden; } /**