From 921131f99949960f448b2cfbb0ad78edc8d4f24a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 5 Feb 2022 23:15:58 +0000 Subject: [PATCH] Modularised our tinymce config and plugins - Split everything into specific plugin/concern files to make things more managable. Means original component file is now simple and much of the core config is focused in one place. --- resources/js/components/wysiwyg-editor.js | 733 +------------------ resources/js/wysiwyg/common-events.js | 32 + resources/js/wysiwyg/config.js | 232 ++++++ resources/js/wysiwyg/drop-paste-handling.js | 165 +++++ resources/js/wysiwyg/plugin-codeeditor.js | 136 ++++ resources/js/wysiwyg/plugin-drawio.js | 145 ++++ resources/js/wysiwyg/plugins-customhr.js | 38 + resources/js/wysiwyg/plugins-imagemanager.js | 31 + resources/js/wysiwyg/plugins-stub.js | 16 + resources/js/wysiwyg/scrolling.js | 44 ++ resources/js/wysiwyg/shortcuts.js | 42 ++ 11 files changed, 896 insertions(+), 718 deletions(-) create mode 100644 resources/js/wysiwyg/common-events.js create mode 100644 resources/js/wysiwyg/config.js create mode 100644 resources/js/wysiwyg/drop-paste-handling.js create mode 100644 resources/js/wysiwyg/plugin-codeeditor.js create mode 100644 resources/js/wysiwyg/plugin-drawio.js create mode 100644 resources/js/wysiwyg/plugins-customhr.js create mode 100644 resources/js/wysiwyg/plugins-imagemanager.js create mode 100644 resources/js/wysiwyg/plugins-stub.js create mode 100644 resources/js/wysiwyg/scrolling.js create mode 100644 resources/js/wysiwyg/shortcuts.js diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index 100a70ff4..f6ecb368a 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -1,450 +1,4 @@ -import Code from "../services/code"; -import DrawIO from "../services/drawio"; -import Clipboard from "../services/clipboard"; - -/** - * Handle pasting images from clipboard. - * @param {ClipboardEvent} event - * @param {WysiwygEditor} wysiwygComponent - * @param editor - */ -function editorPaste(event, editor, wysiwygComponent) { - const clipboard = new Clipboard(event.clipboardData || event.dataTransfer); - - // Don't handle the event ourselves if no items exist of contains table-looking data - if (!clipboard.hasItems() || clipboard.containsTabularData()) { - return; - } - - const images = clipboard.getImages(); - for (const imageFile of images) { - - const id = "image-" + Math.random().toString(16).slice(2); - const loadingImage = window.baseUrl('/loading.gif'); - event.preventDefault(); - - setTimeout(() => { - editor.insertContent(`

`); - - uploadImageFile(imageFile, wysiwygComponent).then(resp => { - const safeName = resp.name.replace(/"/g, ''); - const newImageHtml = `${safeName}`; - - const newEl = editor.dom.create('a', { - target: '_blank', - href: resp.url, - }, newImageHtml); - - editor.dom.replace(newEl, id); - }).catch(err => { - editor.dom.remove(id); - window.$events.emit('error', wysiwygComponent.imageUploadErrorText); - console.log(err); - }); - }, 10); - } -} - -/** - * Upload an image file to the server - * @param {File} file - * @param {WysiwygEditor} wysiwygComponent - */ -async function uploadImageFile(file, wysiwygComponent) { - if (file === null || file.type.indexOf('image') !== 0) { - throw new Error(`Not an image file`); - } - - let ext = 'png'; - if (file.name) { - let fileNameMatches = file.name.match(/\.(.+)$/); - if (fileNameMatches.length > 1) ext = fileNameMatches[1]; - } - - const remoteFilename = "image-" + Date.now() + "." + ext; - const formData = new FormData(); - formData.append('file', file, remoteFilename); - formData.append('uploaded_to', wysiwygComponent.pageId); - - const resp = await window.$http.post(window.baseUrl('/images/gallery'), formData); - return resp.data; -} - -function registerEditorShortcuts(editor) { - // Headers - for (let i = 1; i < 5; i++) { - editor.shortcuts.add('meta+' + i, '', ['FormatBlock', false, 'h' + (i+1)]); - } - - // Other block shortcuts - editor.shortcuts.add('meta+5', '', ['FormatBlock', false, 'p']); - editor.shortcuts.add('meta+d', '', ['FormatBlock', false, 'p']); - editor.shortcuts.add('meta+6', '', ['FormatBlock', false, 'blockquote']); - editor.shortcuts.add('meta+q', '', ['FormatBlock', false, 'blockquote']); - editor.shortcuts.add('meta+7', '', ['codeeditor', false, 'pre']); - editor.shortcuts.add('meta+e', '', ['codeeditor', false, 'pre']); - editor.shortcuts.add('meta+8', '', ['FormatBlock', false, 'code']); - editor.shortcuts.add('meta+shift+E', '', ['FormatBlock', false, 'code']); - - // Save draft shortcut - editor.shortcuts.add('meta+S', '', () => { - window.$events.emit('editor-save-draft'); - }); - - // Save page shortcut - editor.shortcuts.add('meta+13', '', () => { - window.$events.emit('editor-save-page'); - }); - - // Loop through callout styles - editor.shortcuts.add('meta+9', '', function() { - const selectedNode = editor.selection.getNode(); - const callout = selectedNode ? selectedNode.closest('.callout') : null; - - const formats = ['info', 'success', 'warning', 'danger']; - const currentFormatIndex = formats.findIndex(format => callout && callout.classList.contains(format)); - const newFormatIndex = (currentFormatIndex + 1) % formats.length; - const newFormat = formats[newFormatIndex]; - - editor.formatter.apply('callout' + newFormat); - }); - -} - -/** - * Load custom HTML head content from the settings into the editor. - * @param editor - */ -function loadCustomHeadContent(editor) { - window.$http.get(window.baseUrl('/custom-head-content')).then(resp => { - if (!resp.data) return; - let head = editor.getDoc().querySelector('head'); - head.innerHTML += resp.data; - }); -} - -/** - * Create and enable our custom code plugin - */ -function codePlugin() { - - function elemIsCodeBlock(elem) { - return elem.className === 'CodeMirrorContainer'; - } - - function showPopup(editor) { - const selectedNode = editor.selection.getNode(); - - if (!elemIsCodeBlock(selectedNode)) { - const providedCode = editor.selection.getNode().textContent; - window.components.first('code-editor').open(providedCode, '', (code, lang) => { - const wrap = document.createElement('div'); - wrap.innerHTML = `
`; - wrap.querySelector('code').innerText = code; - - editor.formatter.toggle('pre'); - const node = editor.selection.getNode(); - editor.dom.setHTML(node, wrap.querySelector('pre').innerHTML); - editor.fire('SetContent'); - - 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) { - 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() - }); - } - - function codeMirrorContainerToPre(codeMirrorContainer) { - const textArea = codeMirrorContainer.querySelector('textarea'); - const code = textArea.textContent; - const lang = codeMirrorContainer.getAttribute('data-lang'); - - codeMirrorContainer.removeAttribute('contentEditable'); - const pre = document.createElement('pre'); - const codeElem = document.createElement('code'); - codeElem.classList.add(`language-${lang}`); - codeElem.textContent = code; - pre.appendChild(codeElem); - - codeMirrorContainer.parentElement.replaceChild(pre, codeMirrorContainer); - } - - window.tinymce.PluginManager.add('codeeditor', function(editor, url) { - - const $ = editor.$; - - editor.ui.registry.addIcon('codeblock', '') - - editor.ui.registry.addButton('codeeditor', { - title: 'Insert code block', - icon: 'codeblock', - onAction() { - editor.execCommand('codeeditor'); - } - }); - - editor.addCommand('codeeditor', () => { - showPopup(editor); - }); - - // Convert - editor.on('PreProcess', function (e) { - $('div.CodeMirrorContainer', e.node).each((index, elem) => { - codeMirrorContainerToPre(elem); - }); - }); - - editor.on('dblclick', event => { - let selectedNode = editor.selection.getNode(); - if (!elemIsCodeBlock(selectedNode)) return; - showPopup(editor); - }); - - function parseCodeMirrorInstances() { - - // 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', function() { - // 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(); - }); - // 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, 100); - }); - }, 200); - }); - - }); -} - -function drawIoPlugin(drawioUrl, isDarkMode, pageId, wysiwygComponent) { - - let pageEditor = null; - let currentNode = null; - - function isDrawing(node) { - return node.hasAttribute('drawio-diagram'); - } - - function showDrawingManager(mceEditor, selectedNode = null) { - pageEditor = mceEditor; - currentNode = selectedNode; - // Show image manager - window.ImageManager.show(function (image) { - if (selectedNode) { - let imgElem = selectedNode.querySelector('img'); - pageEditor.dom.setAttrib(imgElem, 'src', image.url); - pageEditor.dom.setAttrib(selectedNode, 'drawio-diagram', image.id); - } else { - let imgHTML = `
`; - pageEditor.insertContent(imgHTML); - } - }, 'drawio'); - } - - function showDrawingEditor(mceEditor, selectedNode = null) { - pageEditor = mceEditor; - currentNode = selectedNode; - DrawIO.show(drawioUrl, drawingInit, updateContent); - } - - async function updateContent(pngData) { - const id = "image-" + Math.random().toString(16).slice(2); - const loadingImage = window.baseUrl('/loading.gif'); - - const handleUploadError = (error) => { - if (error.status === 413) { - window.$events.emit('error', wysiwygComponent.serverUploadLimitText); - } else { - window.$events.emit('error', wysiwygComponent.imageUploadErrorText); - } - console.log(error); - }; - - // Handle updating an existing image - if (currentNode) { - DrawIO.close(); - let imgElem = currentNode.querySelector('img'); - try { - const img = await DrawIO.upload(pngData, pageId); - pageEditor.dom.setAttrib(imgElem, 'src', img.url); - pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id); - } catch (err) { - handleUploadError(err); - } - return; - } - - setTimeout(async () => { - pageEditor.insertContent(`
`); - DrawIO.close(); - try { - const img = await DrawIO.upload(pngData, pageId); - pageEditor.dom.setAttrib(id, 'src', img.url); - pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id); - } catch (err) { - pageEditor.dom.remove(id); - handleUploadError(err); - } - }, 5); - } - - - function drawingInit() { - if (!currentNode) { - return Promise.resolve(''); - } - - let drawingId = currentNode.getAttribute('drawio-diagram'); - return DrawIO.load(drawingId); - } - - window.tinymce.PluginManager.add('drawio', function(editor, url) { - - editor.addCommand('drawio', () => { - const selectedNode = editor.selection.getNode(); - showDrawingEditor(editor, isDrawing(selectedNode) ? selectedNode : null); - }); - - // editor.ui.registry.addIcon('diagram', ``) - editor.ui.registry.addIcon('diagram', ``) - - editor.ui.registry.addSplitButton('drawio', { - tooltip: 'Drawing', - icon: 'diagram', - onAction() { - editor.execCommand('drawio'); - }, - fetch(callback) { - callback([ - { - type: 'choiceitem', - text: 'Drawing Manager', - value: 'drawing-manager', - } - ]); - }, - onItemAction(api, value) { - if (value === 'drawing-manager') { - const selectedNode = editor.selection.getNode(); - showDrawingManager(editor, isDrawing(selectedNode) ? selectedNode : null); - } - } - }); - - editor.on('dblclick', event => { - let selectedNode = editor.selection.getNode(); - if (!isDrawing(selectedNode)) return; - showDrawingEditor(editor, selectedNode); - }); - - editor.on('SetContent', function () { - const drawings = editor.$('body > div[drawio-diagram]'); - if (!drawings.length) return; - - editor.undoManager.transact(function () { - drawings.each((index, elem) => { - elem.setAttribute('contenteditable', 'false'); - }); - }); - }); - - }); -} - -function customHrPlugin() { - window.tinymce.PluginManager.add('customhr', function (editor) { - editor.addCommand('InsertHorizontalRule', function () { - let hrElem = document.createElement('hr'); - let cNode = editor.selection.getNode(); - let parentNode = cNode.parentNode; - parentNode.insertBefore(hrElem, cNode); - }); - - editor.ui.registry.addButton('hr', { - icon: 'horizontal-rule', - tooltip: 'Horizontal line', - onAction() { - editor.execCommand('InsertHorizontalRule'); - } - }); - - editor.ui.registry.addMenuItem('hr', { - icon: 'horizontal-rule', - text: 'Horizontal line', - context: 'insert', - onAction() { - editor.execCommand('InsertHorizontalRule'); - } - }); - }); -} - - -function listenForBookStackEditorEvents(editor) { - - // Replace editor content - window.$events.listen('editor::replace', ({html}) => { - editor.setContent(html); - }); - - // Append editor content - window.$events.listen('editor::append', ({html}) => { - const content = editor.getContent() + html; - editor.setContent(content); - }); - - // Prepend editor content - window.$events.listen('editor::prepend', ({html}) => { - const content = html + editor.getContent(); - editor.setContent(content); - }); - - // Insert editor content at the current location - window.$events.listen('editor::insert', ({html}) => { - editor.insertContent(html); - }); - - // Focus on the editor - window.$events.listen('editor::focus', () => { - editor.focus(); - }); -} +import {build as buildEditorConfig} from "../wysiwyg/config"; class WysiwygEditor { @@ -453,287 +7,30 @@ class WysiwygEditor { this.pageId = this.$opts.pageId; this.textDirection = this.$opts.textDirection; - this.imageUploadErrorText = this.$opts.imageUploadErrorText; - this.serverUploadLimitText = this.$opts.serverUploadLimitText; this.isDarkMode = document.documentElement.classList.contains('dark-mode'); - this.plugins = "image imagetools table paste link autolink fullscreen code customhr autosave lists codeeditor media"; - this.loadPlugins(); + this.tinyMceConfig = buildEditorConfig({ + containerElement: this.elem, + darkMode: this.isDarkMode, + textDirection: this.textDirection, + drawioUrl: this.getDrawIoUrl(), + pageId: Number(this.pageId), + translations: { + imageUploadErrorText: this.$opts.imageUploadErrorText, + serverUploadLimitText: this.$opts.serverUploadLimitText, + } + }); - this.tinyMceConfig = this.getTinyMceConfig(); window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig}); window.tinymce.init(this.tinyMceConfig); } - loadPlugins() { - codePlugin(); - customHrPlugin(); - + getDrawIoUrl() { const drawioUrlElem = document.querySelector('[drawio-url]'); if (drawioUrlElem) { - const url = drawioUrlElem.getAttribute('drawio-url'); - drawIoPlugin(url, this.isDarkMode, this.pageId, this); - this.plugins += ' drawio'; + return drawioUrlElem.getAttribute('drawio-url'); } - - if (this.textDirection === 'rtl') { - this.plugins += ' directionality' - } - } - - getToolBar() { - const textDirPlugins = this.textDirection === 'rtl' ? 'ltr rtl' : ''; - return `undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr codeeditor drawio media | removeformat code ${textDirPlugins} fullscreen` - } - - getTinyMceConfig() { - - const context = this; - - return { - width: '100%', - height: '100%', - selector: '#html-editor', - content_css: [ - window.baseUrl('/dist/styles.css'), - ], - branding: false, - skin: this.isDarkMode ? 'oxide-dark' : 'oxide', - body_class: 'page-content', - browser_spellcheck: true, - relative_urls: false, - directionality : this.textDirection, - remove_script_host: false, - document_base_url: window.baseUrl('/'), - end_container_on_empty_block: true, - statusbar: false, - menubar: false, - paste_data_images: false, - extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram]', - automatic_uploads: false, - valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre],+div[img]", - plugins: this.plugins, - imagetools_toolbar: 'imageoptions', - contextmenu: false, - toolbar: this.getToolBar(), - content_style: `html, body, html.dark-mode {background: ${this.isDarkMode ? '#222' : '#fff'};} body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}`, - style_formats: [ - {title: "Header Large", format: "h2", preview: 'color: blue;'}, - {title: "Header Medium", format: "h3"}, - {title: "Header Small", format: "h4"}, - {title: "Header Tiny", format: "h5"}, - {title: "Paragraph", format: "p", exact: true, classes: ''}, - {title: "Blockquote", format: "blockquote"}, - {title: "Inline Code", inline: "code"}, - {title: "Callouts", items: [ - {title: "Info", format: 'calloutinfo'}, - {title: "Success", format: 'calloutsuccess'}, - {title: "Warning", format: 'calloutwarning'}, - {title: "Danger", format: 'calloutdanger'} - ]}, - ], - style_formats_merge: false, - media_alt_source: false, - media_poster: false, - 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'}, - calloutsuccess: {block: 'p', exact: true, attributes: {class: 'callout success'}}, - calloutinfo: {block: 'p', exact: true, attributes: {class: 'callout info'}}, - calloutwarning: {block: 'p', exact: true, attributes: {class: 'callout warning'}}, - calloutdanger: {block: 'p', exact: true, attributes: {class: 'callout danger'}} - }, - file_picker_types: 'file image', - file_picker_callback(callback, value, meta) { - - // field_name, url, type, win - if (meta.filetype === 'file') { - window.EntitySelectorPopup.show(entity => { - callback(entity.link, { - text: entity.name, - title: entity.name, - }); - }); - } - - if (meta.filetype === 'image') { - // Show image manager - window.ImageManager.show(function (image) { - callback(image.url, {alt: image.name}); - }, 'gallery'); - } - - }, - paste_preprocess(plugin, args) { - let content = args.content; - if (content.indexOf('`; - html += `${image.name}`; - html += ''; - editor.execCommand('mceInsertContent', false, html); - }, 'gallery'); - } - }); - - // Paste image-uploads - editor.on('paste', event => editorPaste(event, editor, context)); - - // Custom handler hook - window.$events.emitPublic(context.elem, 'editor-tinymce::setup', {editor}); - } - }; + return ''; } } diff --git a/resources/js/wysiwyg/common-events.js b/resources/js/wysiwyg/common-events.js new file mode 100644 index 000000000..7d3f1113e --- /dev/null +++ b/resources/js/wysiwyg/common-events.js @@ -0,0 +1,32 @@ +/** + * @param {Editor} editor + */ +export function listen(editor) { + + // Replace editor content + window.$events.listen('editor::replace', ({html}) => { + editor.setContent(html); + }); + + // Append editor content + window.$events.listen('editor::append', ({html}) => { + const content = editor.getContent() + html; + editor.setContent(content); + }); + + // Prepend editor content + window.$events.listen('editor::prepend', ({html}) => { + const content = html + editor.getContent(); + editor.setContent(content); + }); + + // Insert editor content at the current location + window.$events.listen('editor::insert', ({html}) => { + editor.insertContent(html); + }); + + // Focus on the editor + window.$events.listen('editor::focus', () => { + editor.focus(); + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js new file mode 100644 index 000000000..e1f2fb6e5 --- /dev/null +++ b/resources/js/wysiwyg/config.js @@ -0,0 +1,232 @@ +import {register as registerShortcuts} from "./shortcuts"; +import {listen as listenForCommonEvents} from "./common-events"; +import {scrollToQueryString, fixScrollForMobile} from "./scrolling"; +import {listenForDragAndPaste} from "./drop-paste-handling"; + +import {getPlugin as getCodeeditorPlugin} from "./plugin-codeeditor"; +import {getPlugin as getDrawioPlugin} from "./plugin-drawio"; +import {getPlugin as getCustomhrPlugin} from "./plugins-customhr"; +import {getPlugin as getImagemanagerPlugin} from "./plugins-imagemanager"; + +const style_formats = [ + {title: "Header Large", format: "h2", preview: 'color: blue;'}, + {title: "Header Medium", format: "h3"}, + {title: "Header Small", format: "h4"}, + {title: "Header Tiny", format: "h5"}, + {title: "Paragraph", format: "p", exact: true, classes: ''}, + {title: "Blockquote", format: "blockquote"}, + {title: "Inline Code", inline: "code"}, + { + title: "Callouts", items: [ + {title: "Info", format: 'calloutinfo'}, + {title: "Success", format: 'calloutsuccess'}, + {title: "Warning", format: 'calloutwarning'}, + {title: "Danger", format: 'calloutdanger'} + ] + }, +]; + +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'}, + calloutsuccess: {block: 'p', exact: true, attributes: {class: 'callout success'}}, + calloutinfo: {block: 'p', exact: true, attributes: {class: 'callout info'}}, + calloutwarning: {block: 'p', exact: true, attributes: {class: 'callout warning'}}, + calloutdanger: {block: 'p', exact: true, attributes: {class: 'callout danger'}} +}; + +function file_picker_callback(callback, value, meta) { + + // field_name, url, type, win + if (meta.filetype === 'file') { + window.EntitySelectorPopup.show(entity => { + callback(entity.link, { + text: entity.name, + title: entity.name, + }); + }); + } + + if (meta.filetype === 'image') { + // Show image manager + window.ImageManager.show(function (image) { + callback(image.url, {alt: image.name}); + }, 'gallery'); + } + +} + +/** + * @param {WysiwygConfigOptions} options + * @return {string} + */ +function buildToolbar(options) { + const textDirPlugins = options.textDirection === 'rtl' ? 'ltr rtl' : ''; + + const toolbar = [ + 'undo redo', + 'styleselect', + 'bold italic underline strikethrough superscript subscript', + 'forecolor backcolor', + 'alignleft aligncenter alignright alignjustify', + 'bullist numlist outdent indent', + textDirPlugins, + 'table imagemanager-insert link hr codeeditor drawio media', + 'removeformat code ${textDirPlugins} fullscreen' + ]; + + return toolbar.filter(row => Boolean(row)).join(' | '); +} + +/** + * @param {WysiwygConfigOptions} options + * @return {string} + */ +function gatherPlugins(options) { + const plugins = [ + "image", + "imagetools", + "table", + "paste", + "link", + "autolink", + "fullscreen", + "code", + "customhr", + "autosave", + "lists", + "codeeditor", + "media", + "imagemanager", + options.textDirection === 'rtl' ? 'directionality' : '', + ]; + + window.tinymce.PluginManager.add('codeeditor', getCodeeditorPlugin(options)); + window.tinymce.PluginManager.add('customhr', getCustomhrPlugin(options)); + window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin(options)); + + if (options.drawioUrl) { + window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options)); + plugins.push('drawio'); + } + + return plugins.filter(plugin => Boolean(plugin)).join(' '); +} + +/** + * Load custom HTML head content from the settings into the editor. + * TODO: We should be able to get this from current parent page? + * @param {Editor} editor + */ +function loadCustomHeadContent(editor) { + window.$http.get(window.baseUrl('/custom-head-content')).then(resp => { + if (!resp.data) return; + let head = editor.getDoc().querySelector('head'); + head.innerHTML += resp.data; + }); +} + +/** + * @param {WysiwygConfigOptions} options + * @return {function(Editor)} + */ +function getSetupCallback(options) { + return function(editor) { + editor.on('ExecCommand change input NodeChange ObjectResized', editorChange); + listenForCommonEvents(editor); + registerShortcuts(editor); + listenForDragAndPaste(editor, options); + + editor.on('init', () => { + editorChange(); + scrollToQueryString(editor); + fixScrollForMobile(editor); + window.editor = editor; + }); + + function editorChange() { + const content = editor.getContent(); + if (options.darkMode) { + editor.contentDocument.documentElement.classList.add('dark-mode'); + } + window.$events.emit('editor-html-change', content); + } + + // TODO - Update to standardise across both editors + // Use events within listenForBookStackEditorEvents instead (Different event signature) + window.$events.listen('editor-html-update', html => { + editor.setContent(html); + editor.selection.select(editor.getBody(), true); + editor.selection.collapse(false); + editorChange(html); + }); + + // Custom handler hook + window.$events.emitPublic(options.containerElement, 'editor-tinymce::setup', {editor}); + } +} + +/** + * @param {WysiwygConfigOptions} options + * @return {Object} + */ +export function build(options) { + + return { + width: '100%', + height: '100%', + selector: '#html-editor', + content_css: [ + window.baseUrl('/dist/styles.css'), + ], + branding: false, + skin: options.darkMode ? 'oxide-dark' : 'oxide', + body_class: 'page-content', + browser_spellcheck: true, + relative_urls: false, + directionality: options.textDirection, + remove_script_host: false, + document_base_url: window.baseUrl('/'), + end_container_on_empty_block: true, + statusbar: false, + menubar: false, + paste_data_images: false, + extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram]', + automatic_uploads: false, + valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre],+div[img]", + plugins: gatherPlugins(options), + imagetools_toolbar: 'imageoptions', + contextmenu: false, + toolbar: buildToolbar(options), + content_style: `html, body, html.dark-mode {background: ${options.darkMode ? '#222' : '#fff'};} body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}`, + style_formats, + style_formats_merge: false, + media_alt_source: false, + media_poster: false, + formats, + file_picker_types: 'file image', + file_picker_callback, + paste_preprocess(plugin, args) { + let content = args.content; + if (content.indexOf(' { + editor.insertContent(`

`); + + uploadImageFile(imageFile, options.pageId).then(resp => { + const safeName = resp.name.replace(/"/g, ''); + const newImageHtml = `${safeName}`; + + const newEl = editor.dom.create('a', { + target: '_blank', + href: resp.url, + }, newImageHtml); + + editor.dom.replace(newEl, id); + }).catch(err => { + editor.dom.remove(id); + // TODO - Check we have this translation + window.$events.emit('error', options.translations.imageUploadErrorText); + console.log(err); + }); + }, 10); + } +} + +/** + * Upload an image file to the server + * @param {File} file + * @param {int} pageId + */ +async function uploadImageFile(file, pageId) { + if (file === null || file.type.indexOf('image') !== 0) { + throw new Error(`Not an image file`); + } + + let ext = 'png'; + if (file.name) { + let fileNameMatches = file.name.match(/\.(.+)$/); + if (fileNameMatches.length > 1) ext = fileNameMatches[1]; + } + + const remoteFilename = "image-" + Date.now() + "." + ext; + const formData = new FormData(); + formData.append('file', file, remoteFilename); + formData.append('uploaded_to', pageId); + + const resp = await window.$http.post(window.baseUrl('/images/gallery'), formData); + return resp.data; +} + +/** + * @param {Editor} editor + * @param {WysiwygConfigOptions} options + */ +function dragStart(editor, options) { + let node = editor.selection.getNode(); + + if (node.nodeName === 'IMG') { + wrap = editor.dom.getParent(node, '.mceTemp'); + + if (!wrap && node.parentNode.nodeName === 'A' && !hasTextContent(node.parentNode)) { + wrap = node.parentNode; + } + } + + // Track dragged contenteditable blocks + if (node.hasAttribute('contenteditable') && node.getAttribute('contenteditable') === 'false') { + draggedContentEditable = node; + } +} + +/** + * @param {Editor} editor + * @param {WysiwygConfigOptions} options + * @param {DragEvent} event + */ +function drop(editor, options, event) { + let dom = editor.dom, + rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc()); + + // Template insertion + const templateId = event.dataTransfer && event.dataTransfer.getData('bookstack/template'); + if (templateId) { + event.preventDefault(); + window.$http.get(`/templates/${templateId}`).then(resp => { + editor.selection.setRng(rng); + editor.undoManager.transact(function () { + editor.execCommand('mceInsertContent', false, resp.data.html); + }); + }); + } + + // Don't allow anything to be dropped in a captioned image. + if (dom.getParent(rng.startContainer, '.mceTemp')) { + event.preventDefault(); + } else if (wrap) { + event.preventDefault(); + + editor.undoManager.transact(function () { + editor.selection.setRng(rng); + editor.selection.setNode(wrap); + dom.remove(wrap); + }); + } + + // Handle contenteditable section drop + if (!event.isDefaultPrevented() && draggedContentEditable) { + event.preventDefault(); + editor.undoManager.transact(function () { + const selectedNode = editor.selection.getNode(); + const range = editor.selection.getRng(); + const selectedNodeRoot = selectedNode.closest('body > *'); + if (range.startOffset > (range.startContainer.length / 2)) { + editor.$(selectedNodeRoot).after(draggedContentEditable); + } else { + editor.$(selectedNodeRoot).before(draggedContentEditable); + } + }); + } + + // Handle image insert + if (!event.isDefaultPrevented()) { + paste(editor, options, event); + } + + wrap = null; +} + +/** + * @param {Editor} editor + * @param {WysiwygConfigOptions} options + */ +export function listenForDragAndPaste(editor, options) { + editor.on('dragstart', () => dragStart(editor, options)); + editor.on('drop', event => drop(editor, options, event)); + editor.on('paste', event => paste(editor, options, event)); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/plugin-codeeditor.js b/resources/js/wysiwyg/plugin-codeeditor.js new file mode 100644 index 000000000..97bfebf9a --- /dev/null +++ b/resources/js/wysiwyg/plugin-codeeditor.js @@ -0,0 +1,136 @@ +import Code from "../services/code"; + +function elemIsCodeBlock(elem) { + return elem.className === 'CodeMirrorContainer'; +} + +function showPopup(editor) { + const selectedNode = editor.selection.getNode(); + + if (!elemIsCodeBlock(selectedNode)) { + const providedCode = editor.selection.getNode().textContent; + window.components.first('code-editor').open(providedCode, '', (code, lang) => { + const wrap = document.createElement('div'); + wrap.innerHTML = `
`; + wrap.querySelector('code').innerText = code; + + editor.formatter.toggle('pre'); + const node = editor.selection.getNode(); + editor.dom.setHTML(node, wrap.querySelector('pre').innerHTML); + editor.fire('SetContent'); + + 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) { + 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() + }); +} + +function codeMirrorContainerToPre(codeMirrorContainer) { + const textArea = codeMirrorContainer.querySelector('textarea'); + const code = textArea.textContent; + const lang = codeMirrorContainer.getAttribute('data-lang'); + + codeMirrorContainer.removeAttribute('contentEditable'); + const pre = document.createElement('pre'); + const codeElem = document.createElement('code'); + codeElem.classList.add(`language-${lang}`); + codeElem.textContent = code; + pre.appendChild(codeElem); + + codeMirrorContainer.parentElement.replaceChild(pre, codeMirrorContainer); +} + + +/** + * @param {Editor} editor + * @param {String} url + */ +function register(editor, url) { + + const $ = editor.$; + + editor.ui.registry.addIcon('codeblock', '') + + editor.ui.registry.addButton('codeeditor', { + title: 'Insert code block', + icon: 'codeblock', + onAction() { + editor.execCommand('codeeditor'); + } + }); + + editor.addCommand('codeeditor', () => { + showPopup(editor); + }); + + // Convert + editor.on('PreProcess', function (e) { + $('div.CodeMirrorContainer', e.node).each((index, elem) => { + codeMirrorContainerToPre(elem); + }); + }); + + editor.on('dblclick', event => { + let selectedNode = editor.selection.getNode(); + if (!elemIsCodeBlock(selectedNode)) return; + showPopup(editor); + }); + + function parseCodeMirrorInstances() { + + // 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', function() { + // 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(); + }); + // 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, 100); + }); + }, 200); + }); +} + +/** + * @param {WysiwygConfigOptions} options + * @return {register} + */ +export function getPlugin(options) { + return register; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/plugin-drawio.js b/resources/js/wysiwyg/plugin-drawio.js new file mode 100644 index 000000000..b6b768c0a --- /dev/null +++ b/resources/js/wysiwyg/plugin-drawio.js @@ -0,0 +1,145 @@ +import DrawIO from "../services/drawio"; + +let pageEditor = null; +let currentNode = null; + +/** + * @type {WysiwygConfigOptions} + */ +let options = {}; + +function isDrawing(node) { + return node.hasAttribute('drawio-diagram'); +} + +function showDrawingManager(mceEditor, selectedNode = null) { + pageEditor = mceEditor; + currentNode = selectedNode; + // Show image manager + window.ImageManager.show(function (image) { + if (selectedNode) { + let imgElem = selectedNode.querySelector('img'); + pageEditor.dom.setAttrib(imgElem, 'src', image.url); + pageEditor.dom.setAttrib(selectedNode, 'drawio-diagram', image.id); + } else { + let imgHTML = `
`; + pageEditor.insertContent(imgHTML); + } + }, 'drawio'); +} + +function showDrawingEditor(mceEditor, selectedNode = null) { + pageEditor = mceEditor; + currentNode = selectedNode; + DrawIO.show(options.drawioUrl, drawingInit, updateContent); +} + +async function updateContent(pngData) { + const id = "image-" + Math.random().toString(16).slice(2); + const loadingImage = window.baseUrl('/loading.gif'); + + const handleUploadError = (error) => { + if (error.status === 413) { + window.$events.emit('error', options.translations.serverUploadLimitText); + } else { + window.$events.emit('error', options.translations.imageUploadErrorText); + } + console.log(error); + }; + + // Handle updating an existing image + if (currentNode) { + DrawIO.close(); + let imgElem = currentNode.querySelector('img'); + try { + const img = await DrawIO.upload(pngData, options.pageId); + pageEditor.dom.setAttrib(imgElem, 'src', img.url); + pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id); + } catch (err) { + handleUploadError(err); + } + return; + } + + setTimeout(async () => { + pageEditor.insertContent(`
`); + DrawIO.close(); + try { + const img = await DrawIO.upload(pngData, options.pageId); + pageEditor.dom.setAttrib(id, 'src', img.url); + pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id); + } catch (err) { + pageEditor.dom.remove(id); + handleUploadError(err); + } + }, 5); +} + + +function drawingInit() { + if (!currentNode) { + return Promise.resolve(''); + } + + let drawingId = currentNode.getAttribute('drawio-diagram'); + return DrawIO.load(drawingId); +} + +/** + * + * @param {WysiwygConfigOptions} providedOptions + * @return {function(Editor, string)} + */ +export function getPlugin(providedOptions) { + options = providedOptions; + return function(editor, url) { + + editor.addCommand('drawio', () => { + const selectedNode = editor.selection.getNode(); + showDrawingEditor(editor, isDrawing(selectedNode) ? selectedNode : null); + }); + + editor.ui.registry.addIcon('diagram', ``) + + editor.ui.registry.addSplitButton('drawio', { + tooltip: 'Drawing', + icon: 'diagram', + onAction() { + editor.execCommand('drawio'); + }, + fetch(callback) { + callback([ + { + type: 'choiceitem', + text: 'Drawing Manager', + value: 'drawing-manager', + } + ]); + }, + onItemAction(api, value) { + if (value === 'drawing-manager') { + const selectedNode = editor.selection.getNode(); + showDrawingManager(editor, isDrawing(selectedNode) ? selectedNode : null); + } + } + }); + + editor.on('dblclick', event => { + let selectedNode = editor.selection.getNode(); + if (!isDrawing(selectedNode)) return; + showDrawingEditor(editor, selectedNode); + }); + + editor.on('SetContent', function () { + const drawings = editor.$('body > div[drawio-diagram]'); + if (!drawings.length) return; + + editor.undoManager.transact(function () { + drawings.each((index, elem) => { + elem.setAttribute('contenteditable', 'false'); + }); + }); + }); + + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/plugins-customhr.js b/resources/js/wysiwyg/plugins-customhr.js new file mode 100644 index 000000000..0744f113f --- /dev/null +++ b/resources/js/wysiwyg/plugins-customhr.js @@ -0,0 +1,38 @@ +/** + * @param {Editor} editor + * @param {String} url + */ +function register(editor, url) { + editor.addCommand('InsertHorizontalRule', function () { + let hrElem = document.createElement('hr'); + let cNode = editor.selection.getNode(); + let parentNode = cNode.parentNode; + parentNode.insertBefore(hrElem, cNode); + }); + + editor.ui.registry.addButton('hr', { + icon: 'horizontal-rule', + tooltip: 'Horizontal line', + onAction() { + editor.execCommand('InsertHorizontalRule'); + } + }); + + editor.ui.registry.addMenuItem('hr', { + icon: 'horizontal-rule', + text: 'Horizontal line', + context: 'insert', + onAction() { + editor.execCommand('InsertHorizontalRule'); + } + }); +} + + +/** + * @param {WysiwygConfigOptions} options + * @return {register} + */ +export function getPlugin(options) { + return register; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/plugins-imagemanager.js b/resources/js/wysiwyg/plugins-imagemanager.js new file mode 100644 index 000000000..d3b03e38c --- /dev/null +++ b/resources/js/wysiwyg/plugins-imagemanager.js @@ -0,0 +1,31 @@ +/** + * @param {Editor} editor + * @param {String} url + */ +function register(editor, url) { + + // Custom Image picker button + editor.ui.registry.addButton('imagemanager-insert', { + title: 'Insert an image', + icon: 'image', + tooltip: 'Insert an image', + onAction() { + window.ImageManager.show(function (image) { + const imageUrl = image.thumbs.display || image.url; + let html = ``; + html += `${image.name}`; + html += ''; + editor.execCommand('mceInsertContent', false, html); + }, 'gallery'); + } + }); +} + + +/** + * @param {WysiwygConfigOptions} options + * @return {register} + */ +export function getPlugin(options) { + return register; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/plugins-stub.js b/resources/js/wysiwyg/plugins-stub.js new file mode 100644 index 000000000..d220ac02d --- /dev/null +++ b/resources/js/wysiwyg/plugins-stub.js @@ -0,0 +1,16 @@ +/** + * @param {Editor} editor + * @param {String} url + */ +function register(editor, url) { + +} + + +/** + * @param {WysiwygConfigOptions} options + * @return {register} + */ +export function getPlugin(options) { + return register; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/scrolling.js b/resources/js/wysiwyg/scrolling.js new file mode 100644 index 000000000..360d18381 --- /dev/null +++ b/resources/js/wysiwyg/scrolling.js @@ -0,0 +1,44 @@ +/** + * Scroll to a section dictated by the current URL query string, if present. + * Used when directly editing a specific section of the page. + * @param {Editor} editor + */ +export function scrollToQueryString(editor) { + const queryParams = (new URL(window.location)).searchParams; + const scrollId = queryParams.get('content-id'); + if (scrollId) { + scrollToText(editor, scrollId); + } +} + +/** + * Override for touch events to allow scrolling on mobile devices. + * TODO - Check if still needed or if needs editing. + * @param {Editor} editor + */ +export function fixScrollForMobile(editor) { + const container = editor.getContainer(); + const toolbarButtons = container.querySelectorAll('.mce-btn'); + for (let button of toolbarButtons) { + button.addEventListener('touchstart', event => { + event.stopPropagation(); + }); + } +} + +/** + * @param {Editor} editor + * @param {String} scrollId + */ +function scrollToText(editor, scrollId) { + const element = editor.dom.get(encodeURIComponent(scrollId).replace(/!/g, '%21')); + if (!element) { + return; + } + + // scroll the element into the view and put the cursor at the end. + element.scrollIntoView(); + editor.selection.select(element, true); + editor.selection.collapse(false); + editor.focus(); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/shortcuts.js b/resources/js/wysiwyg/shortcuts.js new file mode 100644 index 000000000..7b7af41ec --- /dev/null +++ b/resources/js/wysiwyg/shortcuts.js @@ -0,0 +1,42 @@ +/** + * @param {Editor} editor + */ +export function register(editor) { + // Headers + for (let i = 1; i < 5; i++) { + editor.shortcuts.add('meta+' + i, '', ['FormatBlock', false, 'h' + (i+1)]); + } + + // Other block shortcuts + editor.shortcuts.add('meta+5', '', ['FormatBlock', false, 'p']); + editor.shortcuts.add('meta+d', '', ['FormatBlock', false, 'p']); + editor.shortcuts.add('meta+6', '', ['FormatBlock', false, 'blockquote']); + editor.shortcuts.add('meta+q', '', ['FormatBlock', false, 'blockquote']); + editor.shortcuts.add('meta+7', '', ['codeeditor', false, 'pre']); + editor.shortcuts.add('meta+e', '', ['codeeditor', false, 'pre']); + editor.shortcuts.add('meta+8', '', ['FormatBlock', false, 'code']); + editor.shortcuts.add('meta+shift+E', '', ['FormatBlock', false, 'code']); + + // Save draft shortcut + editor.shortcuts.add('meta+S', '', () => { + window.$events.emit('editor-save-draft'); + }); + + // Save page shortcut + editor.shortcuts.add('meta+13', '', () => { + window.$events.emit('editor-save-page'); + }); + + // Loop through callout styles + editor.shortcuts.add('meta+9', '', function() { + const selectedNode = editor.selection.getNode(); + const callout = selectedNode ? selectedNode.closest('.callout') : null; + + const formats = ['info', 'success', 'warning', 'danger']; + const currentFormatIndex = formats.findIndex(format => callout && callout.classList.contains(format)); + const newFormatIndex = (currentFormatIndex + 1) % formats.length; + const newFormat = formats[newFormatIndex]; + + editor.formatter.apply('callout' + newFormat); + }); +} \ No newline at end of file