BookStack/resources/js/wysiwyg/plugin-codeeditor.js
Dan Brown 2b3726702d
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.
2022-02-09 19:24:27 +00:00

193 lines
6.5 KiB
JavaScript

function elemIsCodeBlock(elem) {
return elem.tagName.toLowerCase() === 'code-block';
}
/**
* @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()
});
}
/**
* @param {Editor} editor
* @param {CodeBlockElement} codeBlock
*/
function showPopupForCodeBlock(editor, codeBlock) {
showPopup(editor, codeBlock.getContent(), codeBlock.getLanguage(), (newCode, newLang) => {
codeBlock.setContent(newCode, newLang);
});
}
/**
* 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;
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);
}
/**
* @param {Editor} editor
* @param {String} url
*/
function register(editor, url) {
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', {
tooltip: 'Insert code block',
icon: 'codeblock',
onAction() {
editor.execCommand('codeeditor');
}
});
editor.addCommand('codeeditor', () => {
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);
editor.insertContent(wrap.outerHTML);
});
}
});
editor.on('dblclick', event => {
let selectedNode = editor.selection.getNode();
if (elemIsCodeBlock(selectedNode)) {
showPopupForCodeBlock(editor, selectedNode);
}
});
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',
});
const spans = el.getAll('span');
for (const span of spans) {
span.unwrap();
}
el.attr('style', null);
el.wrap(wrapper);
}
});
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);
});
}
/**
* @param {WysiwygConfigOptions} options
* @return {register}
*/
export function getPlugin(options) {
return register;
}