diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Entities/Tools/ExportFormatter.php index f993d332d..5617db692 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Entities/Tools/ExportFormatter.php @@ -147,10 +147,31 @@ class ExportFormatter { $html = $this->containHtml($html); $html = $this->replaceIframesWithLinks($html); + $html = $this->openDetailElements($html); return $this->pdfGenerator->fromHtml($html); } + /** + * Within the given HTML content, Open any detail blocks + */ + protected function openDetailElements(string $html): string + { + libxml_use_internal_errors(true); + + $doc = new DOMDocument(); + $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + $xPath = new DOMXPath($doc); + + $details = $xPath->query('//details'); + /** @var DOMElement $detail */ + foreach ($details as $detail) { + $detail->setAttribute('open', 'open'); + } + + return $doc->saveHTML(); + } + /** * Within the given HTML content, replace any iframe elements * with anchor links within paragraph blocks. 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 8e7669acc..1b3b6e7b5 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -8,6 +8,7 @@ import {getPlugin as getDrawioPlugin} from "./plugin-drawio"; import {getPlugin as getCustomhrPlugin} from "./plugins-customhr"; import {getPlugin as getImagemanagerPlugin} from "./plugins-imagemanager"; import {getPlugin as getAboutPlugin} from "./plugins-about"; +import {getPlugin as getDetailsPlugin} from "./plugins-details"; const style_formats = [ {title: "Large Header", format: "h2", preview: 'color: blue;'}, @@ -27,7 +28,6 @@ const style_formats = [ ]; const formats = { - codeeditor: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div'}, alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'}, aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'}, alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'}, @@ -79,7 +79,7 @@ function buildToolbar(options) { insertoverflow: { icon: 'more-drawer', tooltip: 'More', - items: 'hr codeeditor drawio media' + items: 'hr codeeditor drawio media details' } }; @@ -121,6 +121,7 @@ function gatherPlugins(options) { "media", "imagemanager", "about", + "details", options.textDirection === 'rtl' ? 'directionality' : '', ]; @@ -128,6 +129,7 @@ function gatherPlugins(options) { window.tinymce.PluginManager.add('customhr', getCustomhrPlugin(options)); window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin(options)); window.tinymce.PluginManager.add('about', getAboutPlugin(options)); + window.tinymce.PluginManager.add('details', getDetailsPlugin(options)); if (options.drawioUrl) { window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options)); @@ -216,7 +218,7 @@ export function build(options) { // Set language window.tinymce.addI18n(options.language, options.translationMap); - + // Build toolbar content const {toolbar, groupButtons: toolBarGroupButtons} = buildToolbar(options); // Return config object @@ -240,9 +242,17 @@ export function build(options) { statusbar: false, menubar: false, paste_data_images: false, - extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram]', + extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],div[*]', automatic_uploads: false, - valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre],+div[img]", + 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 = `
`; - 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(//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', '') 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 new file mode 100644 index 000000000..9b5287947 --- /dev/null +++ b/resources/js/wysiwyg/plugins-details.js @@ -0,0 +1,252 @@ +/** + * @param {Editor} editor + * @param {String} url + */ + +function register(editor, url) { + + editor.ui.registry.addIcon('details', ''); + editor.ui.registry.addIcon('togglefold', ''); + editor.ui.registry.addIcon('togglelabel', ''); + + editor.ui.registry.addButton('details', { + icon: 'details', + tooltip: 'Insert collapsible block', + onAction() { + editor.execCommand('InsertDetailsBlock'); + } + }); + + editor.ui.registry.addButton('removedetails', { + icon: 'table-delete-table', + tooltip: 'Unwrap', + onAction() { + unwrapDetailsInSelection(editor) + } + }); + + editor.ui.registry.addButton('editdetials', { + icon: 'togglelabel', + tooltip: 'Edit label', + onAction() { + 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', + onAction() { + const details = getSelectedDetailsBlock(editor); + details.toggleAttribute('open'); + editor.focus(); + } + }); + + editor.addCommand('InsertDetailsBlock', function () { + 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); + + 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', { + predicate: function (node) { + return node.nodeName.toLowerCase() === 'details'; + }, + items: 'editdetials toggledetails removedetails', + position: 'node', + scope: 'node' + }); + + editor.on('PreInit', () => { + setupElementFilters(editor); + }); +} + +/** + * @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 + */ +function getSelectedDetailsBlock(editor) { + return editor.selection.getNode().closest('details'); +} + +/** + * @param {Element} element + */ +function getSummaryTextFromDetails(element) { + const summary = element.querySelector('summary'); + if (!summary) { + return ''; + } + return summary.textContent; +} + +/** + * @param {Editor} editor + */ +function detailsDialog(editor) { + return { + title: 'Edit collapsible block', + body: { + type: 'panel', + items: [ + { + type: 'input', + name: 'summary', + label: 'Toggle label', + }, + ], + }, + buttons: [ + { + type: 'cancel', + text: 'Cancel' + }, + { + type: 'submit', + text: 'Save', + primary: true, + } + ], + onSubmit(api) { + const {summary} = api.getData(); + setSummary(editor, summary); + api.close(); + } + } +} + +function setSummary(editor, summaryContent) { + const details = getSelectedDetailsBlock(editor); + if (!details) return; + + editor.undoManager.transact(() => { + let summary = details.querySelector('summary'); + if (!summary) { + summary = document.createElement('summary'); + details.prepend(summary); + } + summary.textContent = summaryContent; + }); +} + +/** + * @param {Editor} editor + */ +function unwrapDetailsInSelection(editor) { + const details = editor.selection.getNode().closest('details'); + + if (details) { + const elements = details.querySelectorAll('details > *:not(summary, doc-root), doc-root > *'); + + editor.undoManager.transact(() => { + for (const element of elements) { + details.parentNode.insertBefore(element, details); + } + details.remove(); + }); + } + editor.focus(); +} + +/** + * @param {Editor} editor + */ +function setupElementFilters(editor) { + editor.parser.addNodeFilter('details', function(elms) { + for (const el of elms) { + ensureDetailsWrappedInEditable(el); + } + }); + + editor.serializer.addNodeFilter('details', function(elms) { + for (const el of elms) { + unwrapDetailsEditable(el); + el.attr('open', null); + } + }); + + editor.serializer.addNodeFilter('doc-root', function(elms) { + for (const el of elms) { + el.unwrap(); + } + }); +} + +/** + * @param {tinymce.html.Node} detailsEl + */ +function ensureDetailsWrappedInEditable(detailsEl) { + unwrapDetailsEditable(detailsEl); + + detailsEl.attr('contenteditable', 'false'); + const wrap = tinymce.html.Node.create('doc-root', {contenteditable: 'true'}); + for (const child of detailsEl.children()) { + if (child.name !== 'summary') { + wrap.append(child); + } + } + + detailsEl.append(wrap); +} + +/** + * @param {tinymce.html.Node} detailsEl + */ +function unwrapDetailsEditable(detailsEl) { + detailsEl.attr('contenteditable', null); + let madeUnwrap = false; + for (const child of detailsEl.children()) { + if (child.name === 'doc-root') { + child.unwrap(); + madeUnwrap = true; + } + } + + if (madeUnwrap) { + unwrapDetailsEditable(detailsEl); + } +} + + +/** + * @param {WysiwygConfigOptions} options + * @return {register} + */ +export function getPlugin(options) { + return register; +} \ No newline at end of file diff --git a/resources/lang/en/editor.php b/resources/lang/en/editor.php index e84d60a7d..76a9f7fca 100644 --- a/resources/lang/en/editor.php +++ b/resources/lang/en/editor.php @@ -120,7 +120,7 @@ return [ 'show_caption' => 'Show caption', 'constrain' => 'Constrain proportions', - // Images, links & embed + // Images, links, details/summary & embed 'source' => 'Source', 'alt_desc' => 'Alternative description', 'embed' => 'Embed', @@ -131,6 +131,12 @@ return [ 'open_link' => 'Open link in...', 'open_link_current' => 'Current window', 'open_link_new' => 'New window', + 'insert_collapsible' => 'Insert collapsible block', + 'collapsible_unwrap' => 'Unwrap', + '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 23f5150a7..af5bea0f1 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -135,6 +135,35 @@ body.tox-fullscreen, body.markdown-fullscreen { background: #FFECEC; } + details { + border: 1px solid; + @include lightDark(border-color, #DDD, #555); + margin-bottom: 1em; + padding: $-s; + } + details > summary { + margin-top: -$-s; + margin-left: -$-s; + margin-right: -$-s; + margin-bottom: -$-s; + font-weight: bold; + @include lightDark(background-color, #EEE, #333); + padding: $-xs $-s; + } + details[open] > summary { + margin-bottom: $-s; + border-bottom: 1px solid; + @include lightDark(border-color, #DDD, #555); + } + details > summary + * { + margin-top: .2em; + } + details:after { + content: ''; + display: block; + clear: both; + } + &.page-revision { pre code { white-space: pre-wrap; diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index e846b138f..6add27f45 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -17,6 +17,14 @@ display: block; } +// Default styles for our custom root nodes +.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 { line-height: 1.6; @@ -33,9 +41,25 @@ 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 { + pointer-events: none; +} +.page-content.mce-content-body details doc-root { + padding: $-s; + margin-left: (2px - $-s); + margin-right: (2px - $-s); + margin-bottom: (2px - $-s); + margin-top: (2px - $-s); + overflow: hidden; +} /** * Dark Mode Overrides diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index fc6b74088..445cd24f3 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -309,6 +309,24 @@ class ExportTest extends TestCase $this->assertStringContainsString('

https://www.youtube.com/embed/ShqUjt33uOs

', $pdfHtml); } + public function test_page_pdf_export_opens_details_blocks() + { + $page = Page::query()->first()->forceFill([ + 'html' => '
Hello

Content!

', + ]); + $page->save(); + + $pdfHtml = ''; + $mockPdfGenerator = $this->mock(PdfGenerator::class); + $mockPdfGenerator->shouldReceive('fromHtml') + ->with(\Mockery::capture($pdfHtml)) + ->andReturn(''); + $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); + + $this->asEditor()->get($page->getUrl('/export/pdf')); + $this->assertStringContainsString('
first();