From 9e0b8a9fb6b0c96f9fed911544e22d9d56cb74a1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 8 Feb 2022 23:08:00 +0000 Subject: [PATCH 1/5] Started support for WYSIWYG details/summary blocks --- resources/js/wysiwyg/config.js | 8 +- resources/js/wysiwyg/plugins-details.js | 207 ++++++++++++++++++++++++ resources/sass/_pages.scss | 21 +++ resources/sass/_tinymce.scss | 5 + 4 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 resources/js/wysiwyg/plugins-details.js diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index 8e7669acc..13d15e1c5 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)); @@ -240,7 +242,7 @@ 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[*]', automatic_uploads: false, valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre],+div[img]", plugins: gatherPlugins(options), diff --git a/resources/js/wysiwyg/plugins-details.js b/resources/js/wysiwyg/plugins-details.js new file mode 100644 index 000000000..90fdf84ec --- /dev/null +++ b/resources/js/wysiwyg/plugins-details.js @@ -0,0 +1,207 @@ +/** + * @param {Editor} editor + * @param {String} url + */ + +function register(editor, url) { + + editor.ui.registry.addIcon('details', ''); + + 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 collapsible block', + onAction() { + unwrapDetailsInSelection(editor) + } + }); + + editor.ui.registry.addButton('editdetials', { + icon: 'tag', + tooltip: 'Edit label', + onAction() { + const details = getSelectedDetailsBlock(editor); + const dialog = editor.windowManager.open(detailsDialog(editor)); + dialog.setData({summary: getSummaryTextFromDetails(details)}); + } + }); + + editor.ui.registry.addButton('collapsedetails', { + icon: 'action-prev', + tooltip: 'Collapse', + onAction() { + const details = getSelectedDetailsBlock(editor); + details.removeAttribute('open'); + editor.focus(); + } + }); + + editor.ui.registry.addButton('expanddetails', { + icon: 'action-next', + tooltip: 'Expand', + onAction() { + const details = getSelectedDetailsBlock(editor); + details.setAttribute('open', 'open'); + editor.focus(); + } + }); + + editor.addCommand('InsertDetailsBlock', function () { + const content = editor.selection.getContent({format: 'html'}); + const details = document.createElement('details'); + const summary = document.createElement('summary'); + details.appendChild(summary); + details.innerHTML += content; + + editor.insertContent(details.outerHTML); + }); + + editor.ui.registry.addContextToolbar('details', { + predicate: function (node) { + return node.nodeName.toLowerCase() === 'details'; + }, + items: 'removedetails editdetials collapsedetails expanddetails', + position: 'node', + scope: 'node' + }); + + editor.on('PreInit', () => { + setupElementFilters(editor); + }); +} + +/** + * @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 text', + }, + ], + }, + 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.appendChild(summary); + } + summary.textContent = summaryContent; + }); +} + +/** + * @param {Editor} editor + */ +function unwrapDetailsInSelection(editor) { + const details = editor.selection.getNode().closest('details'); + if (details) { + const summary = details.querySelector('summary'); + editor.undoManager.transact(() => { + if (summary) { + summary.remove(); + } + while (details.firstChild) { + details.parentNode.insertBefore(details.firstChild, details); + } + details.remove(); + }); + } + editor.focus(); +} + +/** + * @param {Editor} editor + */ +function setupElementFilters(editor) { + editor.parser.addNodeFilter('details', function(elms) { + for (const el of elms) { + // el.attr('contenteditable', 'false'); + // console.log(el); + // let wrap = el.find('div[detailswrap]'); + // if (!wrap) { + // wrap = document.createElement('div'); + // wrap.setAttribute('detailswrap', 'true'); + // } + // + // for (const child of el.children) { + // if (child.nodeName.toLowerCase() === 'summary' || child.hasAttribute('detailswrap')) { + // continue; + // } + // wrap.appendChild(child); + // } + // + // el.appendChild(wrap); + // wrap.setAttribute('contenteditable', 'true'); + } + }); + + editor.serializer.addNodeFilter('details', function(elms) { + for (const summaryEl of elms) { + summaryEl.attr('contenteditable', null); + } + }); +} + + +/** + * @param {WysiwygConfigOptions} options + * @return {register} + */ +export function getPlugin(options) { + return register; +} \ No newline at end of file diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 23f5150a7..494937299 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -135,6 +135,27 @@ body.tox-fullscreen, body.markdown-fullscreen { background: #FFECEC; } + details { + border: 1px solid #DDD; + margin-bottom: 1em; + padding: $-s; + } + details > summary { + margin-top: -$-s; + margin-left: -$-s; + margin-right: -$-s; + margin-bottom: -$-s; + font-weight: bold; + background-color: #EEEEEE; + padding: $-xs $-s; + } + details[open] > summary { + margin-bottom: 0; + } + details > summary + * { + margin-top: .2em; + } + &.page-revision { pre code { white-space: pre-wrap; diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index e846b138f..c5cc179d0 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -37,6 +37,11 @@ body.page-content.mce-content-body { pointer-events: none; } +// Prevent details summary clicks { +.page-content.mce-content-body details summary { + pointer-events: none; +} + /** * Dark Mode Overrides */ From a318775cfce9155c064900cc71d2a66fd001500d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 9 Feb 2022 10:40:46 +0000 Subject: [PATCH 2/5] Improved wysiwyg details/summary edit controls - Added specific non-editable/editable filtering to make editing within box more reliable. - Updated toolbar icons and controls. --- resources/js/wysiwyg/plugins-details.js | 77 +++++++++++++------------ resources/lang/en/editor.php | 7 ++- resources/sass/_tinymce.scss | 8 ++- 3 files changed, 53 insertions(+), 39 deletions(-) diff --git a/resources/js/wysiwyg/plugins-details.js b/resources/js/wysiwyg/plugins-details.js index 90fdf84ec..83a29a29d 100644 --- a/resources/js/wysiwyg/plugins-details.js +++ b/resources/js/wysiwyg/plugins-details.js @@ -6,6 +6,8 @@ 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', @@ -17,14 +19,14 @@ function register(editor, url) { editor.ui.registry.addButton('removedetails', { icon: 'table-delete-table', - tooltip: 'Unwrap collapsible block', + tooltip: 'Unwrap', onAction() { unwrapDetailsInSelection(editor) } }); editor.ui.registry.addButton('editdetials', { - icon: 'tag', + icon: 'togglelabel', tooltip: 'Edit label', onAction() { const details = getSelectedDetailsBlock(editor); @@ -33,22 +35,12 @@ function register(editor, url) { } }); - editor.ui.registry.addButton('collapsedetails', { - icon: 'action-prev', - tooltip: 'Collapse', + editor.ui.registry.addButton('toggledetails', { + icon: 'togglefold', + tooltip: 'Toggle open/closed', onAction() { const details = getSelectedDetailsBlock(editor); - details.removeAttribute('open'); - editor.focus(); - } - }); - - editor.ui.registry.addButton('expanddetails', { - icon: 'action-next', - tooltip: 'Expand', - onAction() { - const details = getSelectedDetailsBlock(editor); - details.setAttribute('open', 'open'); + details.toggleAttribute('open'); editor.focus(); } }); @@ -67,7 +59,7 @@ function register(editor, url) { predicate: function (node) { return node.nodeName.toLowerCase() === 'details'; }, - items: 'removedetails editdetials collapsedetails expanddetails', + items: 'editdetials toggledetails removedetails', position: 'node', scope: 'node' }); @@ -138,7 +130,7 @@ function setSummary(editor, summaryContent) { let summary = details.querySelector('summary'); if (!summary) { summary = document.createElement('summary'); - details.appendChild(summary); + details.prepend(summary); } summary.textContent = summaryContent; }); @@ -170,33 +162,44 @@ function unwrapDetailsInSelection(editor) { function setupElementFilters(editor) { editor.parser.addNodeFilter('details', function(elms) { for (const el of elms) { - // el.attr('contenteditable', 'false'); - // console.log(el); - // let wrap = el.find('div[detailswrap]'); - // if (!wrap) { - // wrap = document.createElement('div'); - // wrap.setAttribute('detailswrap', 'true'); - // } - // - // for (const child of el.children) { - // if (child.nodeName.toLowerCase() === 'summary' || child.hasAttribute('detailswrap')) { - // continue; - // } - // wrap.appendChild(child); - // } - // - // el.appendChild(wrap); - // wrap.setAttribute('contenteditable', 'true'); + ensureDetailsWrappedInEditable(el); } }); editor.serializer.addNodeFilter('details', function(elms) { - for (const summaryEl of elms) { - summaryEl.attr('contenteditable', null); + for (const el of elms) { + unwrapDetailsEditable(el); + el.attr('open', null); } }); } +/** + * @param {tinymce.html.Node} detailsEl + */ +function ensureDetailsWrappedInEditable(detailsEl) { + detailsEl.attr('contenteditable', 'false'); + const wrap = tinymce.html.Node.create('div', {detailswrap: 'true', 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); + for (const child of detailsEl.children()) { + if (child.attr('detailswrap')) { + child.unwrap(); + } + } +} + /** * @param {WysiwygConfigOptions} options diff --git a/resources/lang/en/editor.php b/resources/lang/en/editor.php index e84d60a7d..2b1d1a519 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,11 @@ 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', // About view 'about_title' => 'About the WYSIWYG Editor', diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index c5cc179d0..57bb69754 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -37,10 +37,16 @@ body.page-content.mce-content-body { pointer-events: none; } -// Prevent details summary clicks { +// Details/summary editor usability .page-content.mce-content-body details summary { pointer-events: none; } +.page-content.mce-content-body details [detailswrap] { + padding: $-s; + margin-left: (2px - $-s); + margin-right: (2px - $-s); + margin-bottom: (2px - $-s); +} /** * Dark Mode Overrides From 536ad142764bd2ddf32e43aa4123f079b5057afb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 9 Feb 2022 11:25:22 +0000 Subject: [PATCH 3/5] WYSIWYG details: Improved usage reliability and dark mdoe styles --- resources/js/wysiwyg/config.js | 19 ++++++++++++++++--- resources/js/wysiwyg/plugins-details.js | 13 +++++++++++-- resources/sass/_pages.scss | 9 ++++++--- resources/sass/_tinymce.scss | 7 ++++++- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index 13d15e1c5..7fa3b0f26 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -210,6 +210,16 @@ 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} @@ -218,8 +228,10 @@ export function build(options) { // Set language 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 { @@ -242,9 +254,10 @@ export function build(options) { statusbar: false, menubar: false, paste_data_images: false, - extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*]', + extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],doc-root', automatic_uploads: false, - valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre],+div[img]", + 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]", plugins: gatherPlugins(options), imagetools_toolbar: 'imageoptions', contextmenu: false, diff --git a/resources/js/wysiwyg/plugins-details.js b/resources/js/wysiwyg/plugins-details.js index 83a29a29d..0f089fc8e 100644 --- a/resources/js/wysiwyg/plugins-details.js +++ b/resources/js/wysiwyg/plugins-details.js @@ -178,13 +178,16 @@ function setupElementFilters(editor) { * @param {tinymce.html.Node} detailsEl */ function ensureDetailsWrappedInEditable(detailsEl) { + unwrapDetailsEditable(detailsEl); + detailsEl.attr('contenteditable', 'false'); - const wrap = tinymce.html.Node.create('div', {detailswrap: 'true', contenteditable: 'true'}); + 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); } @@ -193,11 +196,17 @@ function ensureDetailsWrappedInEditable(detailsEl) { */ function unwrapDetailsEditable(detailsEl) { detailsEl.attr('contenteditable', null); + let madeUnwrap = false; for (const child of detailsEl.children()) { - if (child.attr('detailswrap')) { + if (child.name === 'doc-root') { child.unwrap(); + madeUnwrap = true; } } + + if (madeUnwrap) { + unwrapDetailsEditable(detailsEl); + } } diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 494937299..4c54c1045 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -136,7 +136,8 @@ body.tox-fullscreen, body.markdown-fullscreen { } details { - border: 1px solid #DDD; + border: 1px solid; + @include lightDark(border-color, #DDD, #555); margin-bottom: 1em; padding: $-s; } @@ -146,11 +147,13 @@ body.tox-fullscreen, body.markdown-fullscreen { margin-right: -$-s; margin-bottom: -$-s; font-weight: bold; - background-color: #EEEEEE; + @include lightDark(background-color, #EEE, #333); padding: $-xs $-s; } details[open] > summary { - margin-bottom: 0; + margin-bottom: $-s; + border-bottom: 1px solid; + @include lightDark(border-color, #DDD, #555); } details > summary + * { margin-top: .2em; diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index 57bb69754..ecb258a53 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -17,6 +17,11 @@ display: block; } +// Default styles for our custom root nodes +.page-content.mce-content-body doc-root { + display: block; +} + // In editor line height override .page-content.mce-content-body p { line-height: 1.6; @@ -41,7 +46,7 @@ body.page-content.mce-content-body { .page-content.mce-content-body details summary { pointer-events: none; } -.page-content.mce-content-body details [detailswrap] { +.page-content.mce-content-body details doc-root { padding: $-s; margin-left: (2px - $-s); margin-right: (2px - $-s); From 2b46b00f293f3f9607150671e0d8c3b2ffa6630c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 9 Feb 2022 11:33:23 +0000 Subject: [PATCH 4/5] Updated PDF export to open detail blocks --- app/Entities/Tools/ExportFormatter.php | 21 +++++++++++++++++++++ tests/Entity/ExportTest.php | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+) 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/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(); From 2b3726702d6dc4d0d0262e00f161ca06835cd42d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 9 Feb 2022 19:24:27 +0000 Subject: [PATCH 5/5] 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. --- resources/js/code.mjs | 46 +---- resources/js/wysiwyg/config.js | 25 +-- resources/js/wysiwyg/plugin-codeeditor.js | 220 ++++++++++++++-------- resources/js/wysiwyg/plugins-details.js | 57 ++++-- resources/lang/en/editor.php | 1 + resources/sass/_pages.scss | 5 + resources/sass/_tinymce.scss | 10 +- 7 files changed, 216 insertions(+), 148 deletions(-) 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(); - - 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 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; } /**