1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2024-10-30 07:32:39 +01:00

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.
This commit is contained in:
Dan Brown 2022-02-09 19:24:27 +00:00
parent 2b46b00f29
commit 2b3726702d
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
7 changed files with 216 additions and 148 deletions

View File

@ -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

View File

@ -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,

View File

@ -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);
});
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);
});
if (elemIsCodeBlock(selectedNode)) {
showPopupForCodeBlock(editor, selectedNode);
}
});
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);
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',
});
// 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);
const spans = el.getAll('span');
for (const span of spans) {
span.unwrap();
}
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

@ -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();
}
});
}
/**

View File

@ -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',

View File

@ -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 {

View File

@ -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;
}
/**