/* * Supercode TinyMCE Plugin * Supercode is an enhanced source code editor plugin for TinyMCE, the popular web-based WYSIWYG editor. This plugin provides users with a seamless experience for editing and displaying source code within the TinyMCE editor environment. * * Repository: https://github.com/prathamVaidya/supercode-tinymce-plugin * Author: Pratham Vaidya * License: GPL-3.0 * Version: 1.2.1 * * Released under the GPL-3.0 License. */ !(function () { 'use strict'; /** modal -> { element : Modal Container Ref editor : Ace Editor } */ const MODAL_HTML = `

Source Code Editor

`; const MODAL_CSS = ` :root{ --supercode-modal-primary: #ffffff; --supercode-modal-secondary: #222f3e; --supercode-modal-border: rgba(0, 0, 0, 0.1); } /* Media query for mobile devices */ @media only screen and (max-width: 767px) { #supercode-modal { width: 100% !important; height: 100% !important; border-radius: 0 !important; } } .disable-scroll { overflow: hidden; } #supercode-modal-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; z-index: 990; display: none; opacity: 0; transition: opacity 0.1s linear; } #supercode-backdrop { position: absolute; top: 0; left: 0; background: black; opacity: 0.7; width: 100%; height: 100%; z-index: 1; } #supercode-modal { width: 90%; height: 80%; max-width: 1200px; z-index: 2; overflow: hidden; border-radius: 10px; display: flex; flex-direction: column; background: var(--supercode-modal-primary); } #supercode-header { display: flex; padding: 0.5rem 1rem; border-bottom: 1px solid var(--supercode-modal-border); color: var(--supercode-modal-secondary); } #supercode-modal h1 { flex-grow: 1; margin: auto; font-size: 14px; } #supercode-close-btn { background: none; border: none; padding: 0; height: 100%; cursor: pointer; fill: var(--supercode-modal-secondary); } #supercode-editor { width: 100%; height: 100%; position: relative; } #supercode-footer { padding: 0.5rem 1rem; display: flex; justify-content: end; gap: 1rem; border-top: 1px solid var(--supercode-modal-border); } #supercode-footer button { padding: 0.5rem 1rem; border-radius: 5px; font-weight: bold; border: none; cursor: pointer; min-width: 5rem; transition: opacity 0.1s linear; } #supercode-footer button:hover { opacity: 0.8; } #supercode-cancel-btn { background: transparent; color: var(--supercode-modal-secondary); } #supercode-save-btn { background: var(--supercode-modal-secondary); color: var(--supercode-modal-primary); } `; const CLOSE_ICON_FALLBACK = ``; const CODE_ICON_FALLBACK = ``; let modal = null; const initDependencies = (config) => { const scripts = { 'ace-default': { url: 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.9.6/ace.js', loaded: false, required: true, }, 'beautify-html': { url: 'https://cdnjs.cloudflare.com/ajax/libs/js-beautify/1.15.1/beautify-html.min.js', loaded: false, required: true, }, 'ace-autocomplete': { url: 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.9.6/ext-language_tools.min.js', loaded: false, required: false, }, }; if (config.autocomplete) { scripts['ace-autocomplete'].required = true; } Object.values(scripts).forEach((script) => { if (script.loaded) return; let element = document.createElement('script'); element.src = script.url; element.type = 'text/javascript'; document.body.appendChild(element); }); }; const injectCSS = (css) => { const style = document.createElement('style'); style.innerHTML = css; document.head.append(style); }; const debounce = (func, delay) => { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => func.apply(null, args), delay); }; }; // on plugin load const mainPlugin = function (editor) { let editorWidth = 0, originalHeader, isScreenSizeChanged = false, session, aceEditor; const Config = { theme: 'chrome', fontSize: 14, // in px wrap: true, icon: undefined, // auto set during config iconName: 'sourcecode', autocomplete: false, language: 'html', renderer: null, parser: null, shortcut: true, aceCss: null, fontFamily: null, fallbackModal: false, // enabled in cases like inline, or versions where `CustomView` is not supported. modalPrimaryColor: '#ffffff', modalSecondaryColor: '#222f3e', dark: false, debug: true, }; const debug = (msg) => { if (Config.debug) { console.warn(`${msg} \n\nUse debug:false option to disable this warning`); } }; // Get Configurations const setConfig = (editor) => { const supercodeOptions = editor.getParam('supercode'); if (supercodeOptions && typeof supercodeOptions === 'object') { for (const key in supercodeOptions) { if (supercodeOptions.hasOwnProperty(key)) { const value = supercodeOptions[key]; switch (key) { case 'theme': case 'language': case 'iconName': case 'aceCss': case 'fontFamily': case 'modalPrimaryColor': case 'modalSecondaryColor': if (typeof value === 'string') { Config[key] = value; } break; case 'fontSize': if (typeof value === 'number' && value > 0) { Config.fontSize = parseInt(value); } break; case 'wrap': case 'autocomplete': case 'shortcut': case 'fallbackModal': case 'dark': case 'debug': if (typeof value === 'boolean') { Config[key] = value; } break; case 'parser': case 'renderer': if (typeof value === 'function') { Config[key] = value; } break; default: // Ignore unrecognized options break; } } } } // Set plugin icon Config.icon = editor.ui.registry.getAll?.().icons?.[Config.iconName]; if (!Config.icon) { Config.icon = CODE_ICON_FALLBACK; debug( 'Supercode Icon name is invalid or you are using older versions of tinyMCE. The icon is set to default fallback code icon.' ); } // Detect and set fallback model if its required if (!Config.fallbackModal) { // case: inline mode, < v5 if (editor.getParam('inline') === true || tinymce.majorVersion <= 5) { Config.fallbackModal = true; } } }; const setAceOptions = () => { const options = {}; if (Config.autocomplete) { options.enableLiveAutocompletion = true; } if (Config.fontFamily) { options.fontFamily = Config.fontFamily; } aceEditor.setOptions(options); aceEditor.setTheme(`ace/theme/${Config.theme}`); aceEditor.setFontSize(Config.fontSize); aceEditor.setShowPrintMargin(false); }; // Builds ace editor only on the first run const buildAceEditor = (view) => { // Attach Ace Editor to shadow dom to prevent tinymce css affecting it view.attachShadow({ mode: 'open' }); if (Config.aceCss) { const sheet = new CSSStyleSheet(); sheet.replaceSync(Config.aceCss); view.shadowRoot.adoptedStyleSheets.push(sheet); } view.shadowRoot.innerHTML = `
`; const editorElement = view.shadowRoot.querySelector('.supercode-editor'); editorElement.style.width = '100%'; editorElement.style.height = '100%'; aceEditor = ace.edit(editorElement); // https://github.com/josdejong/jsoneditor/issues/742#issuecomment-698449020 aceEditor.renderer.attachToShadowRoot(); setAceOptions(); }; const setHeader = (view, originalHeader) => { // add a copy of original header to give original header look const newHeader = originalHeader.cloneNode(true); newHeader.style.position = 'relative'; // If menu-bar exists utilize the space to show Title "Source Code Editor" const menubar = newHeader.querySelector('.tox-menubar'); if (menubar) { menubar.innerHTML = `Source Code Editor`; } // disable all the buttons except supercode button, attach event listener let overflowButton = null, isPluginButton = false; newHeader.querySelectorAll('.tox-tbtn, .tox-split-button').forEach((btn) => { if (btn.getAttribute('data-mce-name') != 'supercode') { // remove overflow button to make space for code button if (btn.getAttribute('data-mce-name') === 'overflow-button') { overflowButton = btn; } btn.classList.remove('tox-tbtn--enabled'); btn.classList.add('tox-tbtn--disabled'); btn.removeAttribute('data-mce-name'); } else { isPluginButton = true; btn.setAttribute('data-mce-name', 'supercode-toggle'); btn.classList.add('tox-tbtn--enabled'); btn.onclick = onSaveHandler; } }); // in case of overflow, replace the overflow button with code button if (!isPluginButton && overflowButton) { overflowButton.classList = 'tox-tbtn tox-tbtn--enabled'; overflowButton.innerHTML = `${Config.icon}`; overflowButton.onclick = onSaveHandler; } view.innerHTML = ''; // delete any existing header view.append(newHeader); }; const setMainView = (view, width) => { // configure body of view to look similar to tinymce body, adds ace editor view.style.width = width + 'px'; view.style.height = '100%'; view.style.position = 'relative'; buildAceEditor(view); }; setConfig(editor); initDependencies(Config); const modalKeydownListener = (e) => { if (e.key === 'Escape') { hideModal(); } }; const showModal = () => { if (!modal) { // Build Modal const modalContainer = document.createElement('div'); modalContainer.id = 'supercode-modal-container'; modalContainer.innerHTML = MODAL_HTML; injectCSS(MODAL_CSS); document.body.append(modalContainer); modal = { element: modalContainer, editor: ace.edit(modalContainer.querySelector('#supercode-editor')), }; } // transfer global editor in active scope (All methods work on local scope and need aceEditor) aceEditor = modal.editor; setAceOptions(); // update ace options according to current editor configs /* Update Event Listeners */ modal.element.querySelector('#supercode-backdrop').onclick = hideModal; modal.element.querySelector('#supercode-close-btn').onclick = hideModal; modal.element.querySelector('#supercode-cancel-btn').onclick = hideModal; modal.element.querySelector('#supercode-save-btn').onclick = () => { onSaveHandler(); hideModal(); }; if (Config.shortcut) { modal.element .querySelector('#supercode-editor') .addEventListener('keydown', modalKeydownListener); } /* Update Modal based on editor's theme */ document.querySelector('body').classList.add('disable-scroll'); document.body.style.setProperty('--supercode-modal-primary', Config.modalPrimaryColor); document.body.style.setProperty('--supercode-modal-secondary', Config.modalSecondaryColor); if (Config.dark) { document.body.style.setProperty('--supercode-modal-border', 'rgba(255, 255, 255, 0.1)'); } modal.element.querySelector('#supercode-close-btn').innerHTML = editor.ui.registry.getAll?.().icons?.['close'] ?? CLOSE_ICON_FALLBACK; modal.element.style.display = 'flex'; setTimeout(() => { modal.element.style.opacity = 1; }, 10); /* Load current editor's ace session into aceEditor */ loadAceSession(); }; const hideModal = () => { if (Config.shortcut) { removeEventListener('keydown', modalKeydownListener); } document.querySelector('body').classList.remove('disable-scroll'); modal.element.style.opacity = 0; editor.focus(); setTimeout(() => { modal.element.style.display = 'none'; }, 10); }; // Save editor content to tinymce editor on ace editor change const liveSave = () => { editor.undoManager.transact(function () { let value = aceEditor.getValue(); if (Config.renderer) { value = Config.renderer(value); } editor.setContent(value); }); editor.nodeChanged(); }; const onSaveHandler = () => { editor.focus(); if (Config.fallbackModal) { editor.undoManager.transact(function () { let value = aceEditor.getValue(); if (Config.renderer) { value = Config.renderer(value); } editor.setContent(value); }); editor.selection.setCursorLocation(); editor.nodeChanged(); } editor.execCommand('ToggleView', false, 'supercode'); }; const onKeyDownHandler = (e) => { if ((e.key === ' ' && e.ctrlKey) || e.key === 'Escape') { onSaveHandler(); } }; const getSourceCode = (value) => { if (Config.parser) { return Config.parser(value); } return html_beautify(value); }; const loadAceSession = () => { // Load or build new ace session from editor let content = getSourceCode(editor.getContent()); if (!session) { session = ace.createEditSession(content, `ace/mode/${Config.language}`); session.setUseWrapMode(Config.wrap); // Attach live save to ace editor for in-editor source view if (!Config.fallbackModal) { const debouncedSave = debounce(liveSave, 300); session.on('change', debouncedSave); } } aceEditor.setSession(session); session.setValue(content); aceEditor.gotoLine(Infinity); aceEditor.focus(); }; const startPlugin = function () { if (Config.fallbackModal) { showModal(); } else { const container = editor.getContainer(); if (editorWidth) { isScreenSizeChanged = editorWidth != container.clientWidth; } editorWidth = container.clientWidth; if (isScreenSizeChanged || !originalHeader) { originalHeader = container.querySelector('.tox-editor-header'); } editor.execCommand('ToggleView', false, 'supercode'); } }; if (!Config.fallbackModal) { const CodeView = { onShow: (api) => { const codeView = api.getContainer(); // On tinymce size change => resize code view if (isScreenSizeChanged) { setHeader(codeView.querySelector('.supercode-header'), originalHeader); codeView.querySelector('.supercode-body ').style.width = editorWidth + 'px'; aceEditor.resize(); } // Only on First time plugin opened => mount view if (codeView.childElementCount === 0) { codeView.style.padding = 0; codeView.style.display = 'flex'; codeView.style.flexDirection = 'column'; codeView.innerHTML = `
`; // Ctrl + Space Toggle Shortcut, Escape to Exit Source Code Mode if (Config.shortcut) { codeView.addEventListener('keydown', onKeyDownHandler); } // configure header setHeader(codeView.querySelector('.supercode-header'), originalHeader); // configure main code view to look same setMainView(codeView.querySelector('.supercode-body '), editorWidth); } loadAceSession(); }, onHide: () => { if (Config.shortcut) { removeEventListener('keydown', onKeyDownHandler); } }, }; editor.ui.registry.addView('supercode', CodeView); } editor.ui.registry.addButton('supercode', { icon: Config.iconName, tooltip: 'Source Code Editor (Ctrl + space)', onAction: startPlugin, }); editor.ui.registry.addMenuItem('supercode', { icon: Config.iconName, text: 'Source Code', onAction: startPlugin, }); editor.ui.registry.addContextMenu('supercode', { update: (element) => { return 'supercode'; }, }); // Ctrl + Space Toggle Shortcut if (Config.shortcut) { editor.shortcuts.add('ctrl+32', 'Toggles Source Code Editing Mode', startPlugin); } return { getMetadata: function () { return { name: 'Supercode', url: 'https://github.com/prathamVaidya/supercode-tinymce-plugin', }; }, }; }; // On Script Load, the plugin will be loaded tinymce.PluginManager.add('supercode', mainPlugin); })();