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(/<br\s*[\/]?>/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(); - - 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; - - editor.insertContent(wrap.innerHTML); - editor.focus(); - }); - return; - } - - const lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : ''; - const currentCode = selectedNode.querySelector('textarea').textContent; - - 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 textArea = selectedNode.querySelector('textarea'); - if (textArea) textArea.textContent = code; - selectedNode.setAttribute('data-lang', lang); - +/** + * @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() }); } -function codeMirrorContainerToPre(codeMirrorContainer) { - const textArea = codeMirrorContainer.querySelector('textarea'); - const code = textArea.textContent; - const lang = codeMirrorContainer.getAttribute('data-lang'); +/** + * @param {Editor} editor + * @param {CodeBlockElement} codeBlock + */ +function showPopupForCodeBlock(editor, codeBlock) { + showPopup(editor, codeBlock.getContent(), codeBlock.getLanguage(), (newCode, newLang) => { + codeBlock.setContent(newCode, newLang); + }); +} - codeMirrorContainer.removeAttribute('contentEditable'); - const pre = document.createElement('pre'); - const codeElem = document.createElement('code'); - codeElem.classList.add(`language-${lang}`); - codeElem.textContent = code; - pre.appendChild(codeElem); +/** + * 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; - codeMirrorContainer.parentElement.replaceChild(pre, codeMirrorContainer); + 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); + } + + 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}`; + } + + getContent() { + const code = this.querySelector('code') || this.querySelector('pre'); + const tempEl = document.createElement('pre'); + tempEl.innerHTML = code.innerHTML.replace().replace(/<br\s*[\/]?>/gi ,'\n').replace(/\ufeff/g, ''); + return tempEl.textContent; + } + + connectedCallback() { + if (this.cm) { + return; + } + + const container = this.shadowRoot.querySelector('.CodeMirrorContainer'); + importVersioned('code').then(Code => { + this.cm = Code.wysiwygView(container, this.getContent(), this.getLanguage()); + }); + } + } + + win.customElements.define('code-block', CodeBlockElement); } @@ -60,8 +112,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', { @@ -73,54 +123,64 @@ function register(editor, url) { }); editor.addCommand('codeeditor', () => { - showPopup(editor); - }); + 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); - // Convert - editor.on('PreProcess', function (e) { - $('div.CodeMirrorContainer', e.node).each((index, elem) => { - codeMirrorContainerToPre(elem); - }); + 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) { + 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', + }); - // Recover broken codemirror instances - $('.CodeMirrorContainer').filter((index ,elem) => { - return typeof elem.querySelector('.CodeMirror').CodeMirror === 'undefined'; - }).each((index, elem) => { - codeMirrorContainerToPre(elem); + 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 = '<p><br></p>'; + } + + 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; } /**