Merge pull request #3260 from BookStackApp/wysiwyg_details

WYSIWYG details/summary blocks
This commit is contained in:
Dan Brown 2022-02-09 19:33:53 +00:00 committed by GitHub
commit 9806907d53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 513 additions and 127 deletions

View File

@ -147,10 +147,31 @@ class ExportFormatter
{ {
$html = $this->containHtml($html); $html = $this->containHtml($html);
$html = $this->replaceIframesWithLinks($html); $html = $this->replaceIframesWithLinks($html);
$html = $this->openDetailElements($html);
return $this->pdfGenerator->fromHtml($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 * Within the given HTML content, replace any iframe elements
* with anchor links within paragraph blocks. * with anchor links within paragraph blocks.

View File

@ -204,56 +204,22 @@ function getTheme() {
/** /**
* Create a CodeMirror instance for showing inside the WYSIWYG editor. * Create a CodeMirror instance for showing inside the WYSIWYG editor.
* Manages a textarea element to hold code content. * Manages a textarea element to hold code content.
* @param {HTMLElement} elem * @param {HTMLElement} cmContainer
* @param {String} content
* @param {String} language
* @returns {{wrap: Element, editor: *}} * @returns {{wrap: Element, editor: *}}
*/ */
export function wysiwygView(elem) { export function wysiwygView(cmContainer, content, language) {
const doc = elem.ownerDocument; return CodeMirror(cmContainer, {
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);
}, {
value: content, value: content,
mode: getMode(lang, content), mode: getMode(language, content),
lineNumbers: true, lineNumbers: true,
lineWrapping: false, lineWrapping: false,
theme: getTheme(), theme: getTheme(),
readOnly: true 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 * Create a CodeMirror instance to show in the WYSIWYG pop-up editor

View File

@ -8,6 +8,7 @@ import {getPlugin as getDrawioPlugin} from "./plugin-drawio";
import {getPlugin as getCustomhrPlugin} from "./plugins-customhr"; import {getPlugin as getCustomhrPlugin} from "./plugins-customhr";
import {getPlugin as getImagemanagerPlugin} from "./plugins-imagemanager"; import {getPlugin as getImagemanagerPlugin} from "./plugins-imagemanager";
import {getPlugin as getAboutPlugin} from "./plugins-about"; import {getPlugin as getAboutPlugin} from "./plugins-about";
import {getPlugin as getDetailsPlugin} from "./plugins-details";
const style_formats = [ const style_formats = [
{title: "Large Header", format: "h2", preview: 'color: blue;'}, {title: "Large Header", format: "h2", preview: 'color: blue;'},
@ -27,7 +28,6 @@ const style_formats = [
]; ];
const 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'}, 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'}, 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'}, 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: { insertoverflow: {
icon: 'more-drawer', icon: 'more-drawer',
tooltip: 'More', tooltip: 'More',
items: 'hr codeeditor drawio media' items: 'hr codeeditor drawio media details'
} }
}; };
@ -121,6 +121,7 @@ function gatherPlugins(options) {
"media", "media",
"imagemanager", "imagemanager",
"about", "about",
"details",
options.textDirection === 'rtl' ? 'directionality' : '', options.textDirection === 'rtl' ? 'directionality' : '',
]; ];
@ -128,6 +129,7 @@ function gatherPlugins(options) {
window.tinymce.PluginManager.add('customhr', getCustomhrPlugin(options)); window.tinymce.PluginManager.add('customhr', getCustomhrPlugin(options));
window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin(options)); window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin(options));
window.tinymce.PluginManager.add('about', getAboutPlugin(options)); window.tinymce.PluginManager.add('about', getAboutPlugin(options));
window.tinymce.PluginManager.add('details', getDetailsPlugin(options));
if (options.drawioUrl) { if (options.drawioUrl) {
window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options)); window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options));
@ -216,7 +218,7 @@ export function build(options) {
// Set language // Set language
window.tinymce.addI18n(options.language, options.translationMap); window.tinymce.addI18n(options.language, options.translationMap);
// Build toolbar content
const {toolbar, groupButtons: toolBarGroupButtons} = buildToolbar(options); const {toolbar, groupButtons: toolBarGroupButtons} = buildToolbar(options);
// Return config object // Return config object
@ -240,9 +242,17 @@ export function build(options) {
statusbar: false, statusbar: false,
menubar: false, menubar: false,
paste_data_images: 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, 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), plugins: gatherPlugins(options),
imagetools_toolbar: 'imageoptions', imagetools_toolbar: 'imageoptions',
contextmenu: false, contextmenu: false,

View File

@ -1,56 +1,108 @@
function elemIsCodeBlock(elem) { 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
if (!elemIsCodeBlock(selectedNode)) { * @param {String} language
const providedCode = editor.selection.getContent({format: 'text'}); * @param {function(string, string)} callback (Receives (code: string,language: string)
window.components.first('code-editor').open(providedCode, '', (code, lang) => { */
const wrap = document.createElement('div'); function showPopup(editor, code, language, callback) {
wrap.innerHTML = `<pre><code class="language-${lang}"></code></pre>`; window.components.first('code-editor').open(code, language, (newCode, newLang) => {
wrap.querySelector('code').innerText = code; callback(newCode, newLang)
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);
editor.focus() editor.focus()
}); });
} }
function codeMirrorContainerToPre(codeMirrorContainer) { /**
const textArea = codeMirrorContainer.querySelector('textarea'); * @param {Editor} editor
const code = textArea.textContent; * @param {CodeBlockElement} codeBlock
const lang = codeMirrorContainer.getAttribute('data-lang'); */
function showPopupForCodeBlock(editor, codeBlock) {
showPopup(editor, codeBlock.getContent(), codeBlock.getLanguage(), (newCode, newLang) => {
codeBlock.setContent(newCode, newLang);
});
}
codeMirrorContainer.removeAttribute('contentEditable'); /**
const pre = document.createElement('pre'); * Define our custom code-block HTML element that we use.
const codeElem = document.createElement('code'); * Needs to be delayed since it needs to be defined within the context of the
codeElem.classList.add(`language-${lang}`); * child editor window and document, hence its definition within a callback.
codeElem.textContent = code; * @param {Editor} editor
pre.appendChild(codeElem); */
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) { 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.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', { editor.ui.registry.addButton('codeeditor', {
@ -73,54 +123,64 @@ function register(editor, url) {
}); });
editor.addCommand('codeeditor', () => { 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.insertContent(wrap.outerHTML);
editor.on('PreProcess', function (e) {
$('div.CodeMirrorContainer', e.node).each((index, elem) => {
codeMirrorContainerToPre(elem);
}); });
}
}); });
editor.on('dblclick', event => { editor.on('dblclick', event => {
let selectedNode = editor.selection.getNode(); let selectedNode = editor.selection.getNode();
if (!elemIsCodeBlock(selectedNode)) return; if (elemIsCodeBlock(selectedNode)) {
showPopup(editor); 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);
});
const codeSamples = $('body > pre').filter((index, elem) => {
return elem.contentEditable !== "false";
});
codeSamples.each((index, elem) => {
Code.wysiwygView(elem);
});
} }
});
editor.on('init', async function() { editor.on('PreInit', () => {
const Code = await window.importVersioned('code'); editor.parser.addNodeFilter('pre', function(elms) {
// Parse code mirror instances on init, but delay a little so this runs after for (const el of elms) {
// initial styles are fetched into the editor. const wrapper = new tinymce.html.Node.create('code-block', {
editor.undoManager.transact(function () { contenteditable: 'false',
parseCodeMirrorInstances(Code);
}); });
// Parsed code mirror blocks when content is set but wait before setting this handler
// to avoid any init 'SetContent' events. const spans = el.getAll('span');
setTimeout(() => { for (const span of spans) {
editor.on('SetContent', () => { span.unwrap();
setTimeout(() => parseCodeMirrorInstances(Code), 100); }
el.attr('style', null);
el.wrap(wrapper);
}
}); });
}, 200);
editor.parser.addNodeFilter('code-block', function(elms) {
for (const el of elms) {
el.attr('content-editable', 'false');
}
});
editor.serializer.addNodeFilter('code-block', function(elms) {
for (const el of elms) {
el.unwrap();
}
});
});
editor.on('PreInit', () => {
defineCodeBlockCustomElement(editor);
}); });
} }

View File

@ -0,0 +1,252 @@
/**
* @param {Editor} editor
* @param {String} url
*/
function register(editor, url) {
editor.ui.registry.addIcon('details', '<svg width="24" height="24"><path d="M8.2 9a.5.5 0 0 0-.4.8l4 5.6a.5.5 0 0 0 .8 0l4-5.6a.5.5 0 0 0-.4-.8ZM20.122 18.151h-16c-.964 0-.934 2.7 0 2.7h16c1.139 0 1.173-2.7 0-2.7zM20.122 3.042h-16c-.964 0-.934 2.7 0 2.7h16c1.139 0 1.173-2.7 0-2.7z"/></svg>');
editor.ui.registry.addIcon('togglefold', '<svg height="24" width="24"><path d="M8.12 19.3c.39.39 1.02.39 1.41 0L12 16.83l2.47 2.47c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41l-3.17-3.17c-.39-.39-1.02-.39-1.41 0l-3.17 3.17c-.4.38-.4 1.02-.01 1.41zm7.76-14.6c-.39-.39-1.02-.39-1.41 0L12 7.17 9.53 4.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.03 0 1.42l3.17 3.17c.39.39 1.02.39 1.41 0l3.17-3.17c.4-.39.4-1.03.01-1.42z"/></svg>');
editor.ui.registry.addIcon('togglelabel', '<svg height="18" width="18" viewBox="0 0 24 24"><path d="M21.41,11.41l-8.83-8.83C12.21,2.21,11.7,2,11.17,2H4C2.9,2,2,2.9,2,4v7.17c0,0.53,0.21,1.04,0.59,1.41l8.83,8.83 c0.78,0.78,2.05,0.78,2.83,0l7.17-7.17C22.2,13.46,22.2,12.2,21.41,11.41z M6.5,8C5.67,8,5,7.33,5,6.5S5.67,5,6.5,5S8,5.67,8,6.5 S7.33,8,6.5,8z"/></svg>');
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 = '<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', {
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;
}

View File

@ -120,7 +120,7 @@ return [
'show_caption' => 'Show caption', 'show_caption' => 'Show caption',
'constrain' => 'Constrain proportions', 'constrain' => 'Constrain proportions',
// Images, links & embed // Images, links, details/summary & embed
'source' => 'Source', 'source' => 'Source',
'alt_desc' => 'Alternative description', 'alt_desc' => 'Alternative description',
'embed' => 'Embed', 'embed' => 'Embed',
@ -131,6 +131,12 @@ return [
'open_link' => 'Open link in...', 'open_link' => 'Open link in...',
'open_link_current' => 'Current window', 'open_link_current' => 'Current window',
'open_link_new' => 'New 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 view
'about_title' => 'About the WYSIWYG Editor', 'about_title' => 'About the WYSIWYG Editor',

View File

@ -135,6 +135,35 @@ body.tox-fullscreen, body.markdown-fullscreen {
background: #FFECEC; 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 { &.page-revision {
pre code { pre code {
white-space: pre-wrap; white-space: pre-wrap;

View File

@ -17,6 +17,14 @@
display: block; 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 // In editor line height override
.page-content.mce-content-body p { .page-content.mce-content-body p {
line-height: 1.6; line-height: 1.6;
@ -33,9 +41,25 @@ body.page-content.mce-content-body {
} }
// Prevent scroll jumps on codemirror clicks // Prevent scroll jumps on codemirror clicks
.page-content.mce-content-body .CodeMirror { .page-content.mce-content-body code-block > * {
pointer-events: none; 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 * Dark Mode Overrides

View File

@ -309,6 +309,24 @@ class ExportTest extends TestCase
$this->assertStringContainsString('<p><a href="https://www.youtube.com/embed/ShqUjt33uOs">https://www.youtube.com/embed/ShqUjt33uOs</a></p>', $pdfHtml); $this->assertStringContainsString('<p><a href="https://www.youtube.com/embed/ShqUjt33uOs">https://www.youtube.com/embed/ShqUjt33uOs</a></p>', $pdfHtml);
} }
public function test_page_pdf_export_opens_details_blocks()
{
$page = Page::query()->first()->forceFill([
'html' => '<details><summary>Hello</summary><p>Content!</p></details>',
]);
$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('<details open="open"', $pdfHtml);
}
public function test_page_markdown_export() public function test_page_markdown_export()
{ {
$page = Page::query()->first(); $page = Page::query()->first();