diff --git a/administrator/language/en-GB/plg_editors_tinymce.ini b/administrator/language/en-GB/plg_editors_tinymce.ini index 62b3f80f25246..6c2df01727fa8 100644 --- a/administrator/language/en-GB/plg_editors_tinymce.ini +++ b/administrator/language/en-GB/plg_editors_tinymce.ini @@ -55,6 +55,7 @@ PLG_TINY_FIELD_SKIN_ADMIN_LABEL="Administrator Skin" PLG_TINY_FIELD_SKIN_INFO_DESC="Copy your new skins to: /media/editors/tinymce/skins/ui." PLG_TINY_FIELD_SKIN_INFO_LABEL="For customised skins go to: Skin Creator" PLG_TINY_FIELD_SKIN_LABEL="Site Skin" +PLG_TINY_FIELD_SOURCECODE_LABEL="Source Code Highlighting" PLG_TINY_FIELD_TEXTPATTERN_DESC="Use Markdown syntax to compose content with links, lists, and other styles." ; Do not translate the word Markdown PLG_TINY_FIELD_TEXTPATTERN_LABEL="Markdown" PLG_TINY_FIELD_TOOLBAR_MODE_LABEL="Toolbar Mode" diff --git a/build/build-modules-js/init/exemptions/tinymce.es6.js b/build/build-modules-js/init/exemptions/tinymce.es6.js index 4382d856979c4..ab74b5225ca1c 100644 --- a/build/build-modules-js/init/exemptions/tinymce.es6.js +++ b/build/build-modules-js/init/exemptions/tinymce.es6.js @@ -1,6 +1,10 @@ const { existsSync, copy, readFile, writeFile, mkdir, } = require('fs-extra'); +const CssNano = require('cssnano'); +const Postcss = require('postcss'); +const { minify } = require('terser'); + const { join } = require('path'); const { copyAllFiles } = require('../common/copy-all-files.es6.js'); @@ -65,6 +69,27 @@ module.exports.tinyMCE = async (packageName, version) => { tinyWrongMap = tinyWrongMap.replace('/*# sourceMappingURL=skin.min.css.map */', ''); await writeFile(`${RootPath}/media/vendor/tinymce/skins/ui/oxide/skin.min.css`, tinyWrongMap, { encoding: 'utf8', mode: 0o644 }); + /* Create the Highlighter plugin */ + // Get the css + let cssContent = await readFile('build/media_source/plg_editors_tinymce/js/plugins/highlighter/source.css', { encoding: 'utf8' }); + cssContent = await Postcss([CssNano()]).process(cssContent, { from: undefined }); + // Get the JS + let jsContent = await readFile('build/media_source/plg_editors_tinymce/js/plugins/highlighter/source.es5.js', { encoding: 'utf8' }); + jsContent = await minify(jsContent, { sourceMap: false, format: { comments: false } }); + // Write the HTML file + const htmlContent = ` + + + + + + + + +`; + + await writeFile('media/plg_editors_tinymce/js/plugins/highlighter/source.html', htmlContent, { encoding: 'utf8', mode: 0o644 }); + // Restore our code on the vendor folders await copy(join(RootPath, 'build/media_source/vendor/tinymce/templates'), join(RootPath, 'media/vendor/tinymce/templates'), { preserveTimestamps: true }); }; diff --git a/build/media_source/plg_editors_tinymce/js/plugins/highlighter/plugin.es5.js b/build/media_source/plg_editors_tinymce/js/plugins/highlighter/plugin.es5.js new file mode 100644 index 0000000000000..910370e37e2d7 --- /dev/null +++ b/build/media_source/plg_editors_tinymce/js/plugins/highlighter/plugin.es5.js @@ -0,0 +1,87 @@ +/** + * plugin.js + * + * Original code by Arjan Haverkamp + * Copyright 2013-2015 Arjan Haverkamp (arjan@webgear.nl) + * + * Adapted for use in Joomla by Dimitrios Grammatikogiannis + */ + /* eslint-disable no-undef */ + tinymce.PluginManager.add('highlightPlus', function(editor, url) { + function showSourceEditor() { + editor.focus(); + editor.selection.collapse(true); + + if (!editor.settings.codemirror) editor.settings.codemirror = {}; + + // Insert caret marker + if (editor.settings.codemirror && editor.settings.codemirror.saveCursorPosition) { + editor.selection.setContent(''); + } + + let codemirrorWidth = 800; + if (editor.settings.codemirror.width) { + codemirrorWidth = editor.settings.codemirror.width; + } + + let codemirrorHeight = 550; + if (editor.settings.codemirror.height) { + codemirrorHeight = editor.settings.codemirror.height; + } + + const buttonsConfig = [ + { + type: 'custom', + text: 'Ok', + name: 'codemirrorOk', + primary: true + }, + { + type: 'cancel', + text: 'Cancel', + name: 'codemirrorCancel' + } + ] + + const config = { + title: 'Source code', + url: url + '/source.html', + width: codemirrorWidth, + height: codemirrorHeight, + resizable: true, + maximizable: true, + fullScreen: editor.settings.codemirror.fullscreen, + saveCursorPosition: false, + buttons: buttonsConfig + } + + config.onAction = function (dialogApi, actionData) { + if (actionData.name === 'codemirrorOk') { + const doc = document.querySelectorAll('.tox-dialog__body-iframe iframe')[0]; + doc.contentWindow.tinymceHighlighterSubmit(); + editor.undoManager.add(); + win.close(); + } + } + + const win = editor.windowManager.openUrl(config); + + if (editor.settings.codemirror.fullscreen) { + win.fullscreen(true); + } + } + + editor.ui.registry.addButton('code', { + icon: 'sourcecode', + title: 'Source code+', + tooltip: 'Source code+', + onAction: showSourceEditor + }); + + editor.ui.registry.addMenuItem('code', { + icon: 'sourcecode', + text: 'Source code+', + onAction: showSourceEditor, + context: 'tools' + }); + }); diff --git a/build/media_source/plg_editors_tinymce/js/plugins/highlighter/source.css b/build/media_source/plg_editors_tinymce/js/plugins/highlighter/source.css new file mode 100644 index 0000000000000..084efeda12509 --- /dev/null +++ b/build/media_source/plg_editors_tinymce/js/plugins/highlighter/source.css @@ -0,0 +1,17 @@ +html,body { height:100%; } +body { + margin: 0; +} +.CodeMirror { + height: 100%; + font-size: 12px; + line-height: 18px; +} +.CodeMirror-activeline-background { + background: #e8f2ff !important; +} +.cm-trailingspace { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAYAAAB/qH1jAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QUXCToH00Y1UgAAACFJREFUCNdjPMDBUc/AwNDAAAFMTAwMDA0OP34wQgX/AQBYgwYEx4f9lQAAAABJRU5ErkJggg==); + background-position: bottom left; + background-repeat: repeat-x; +} diff --git a/build/media_source/plg_editors_tinymce/js/plugins/highlighter/source.es5.js b/build/media_source/plg_editors_tinymce/js/plugins/highlighter/source.es5.js new file mode 100644 index 0000000000000..29fa2b4b07fbc --- /dev/null +++ b/build/media_source/plg_editors_tinymce/js/plugins/highlighter/source.es5.js @@ -0,0 +1,279 @@ +/** + * source.js + * + * Original code by Arjan Haverkamp + * Copyright 2013-2015 Arjan Haverkamp (arjan@webgear.nl) + * + * Adapted for use in Joomla by Dimitrios Grammatikogiannis + */ + +// CodeMirror settings +const CMsettings = { + path: '../../../../vendor/codemirror', + indentOnInit: true, + config: { + mode: 'htmlmixed', + theme: 'default', + lineNumbers: true, + lineWrapping: true, + indentUnit: 2, + tabSize: 2, + indentWithTabs: true, + matchBrackets: true, + saveCursorPosition: true, + styleActiveLine: true, + }, + jsFiles: [// Default JS files + 'lib/codemirror.min.js', + 'addon/edit/matchbrackets.min.js', + 'mode/xml/xml.min.js', + 'mode/javascript/javascript.min.js', + 'mode/css/css.min.js', + 'mode/htmlmixed/htmlmixed.min.js', + 'addon/dialog/dialog.min.js', + 'addon/search/searchcursor.min.js', + 'addon/search/search.min.js', + 'addon/selection/active-line.min.js', + ], + cssFiles: [// Default CSS files + 'lib/codemirror.css', + 'addon/dialog/dialog.css', + ], +}; + +// Global vars: +let tinymce; // Reference to TinyMCE +let editor; // Reference to TinyMCE editor +let codemirror; // CodeMirror instance +let chr = 0; // Unused utf-8 character, placeholder for cursor +const isMac = /macintosh|mac os/i.test(navigator.userAgent); + +/** + * Find the depth level + */ +const findDepth = (haystack, needle) => { + const idx = haystack.indexOf(needle); + let depth = 0; + for (let x = idx -1; x >= 0; x--) { + switch(haystack.charAt(x)) { + case '<': depth--; break; + case '>': depth++; break; + case '&': depth++; break; + } + } + return depth; +} + +/** + * This function is called by plugin.js, when user clicks 'Ok' button + */ +window.tinymceHighlighterSubmit = () => { + const cc = '�'; + const isDirty = codemirror.isDirty; + const doc = codemirror.doc; + + if (doc.somethingSelected()) { + // Clear selection: + doc.setCursor(doc.getCursor()); + } + + // Insert cursor placeholder (�) + doc.replaceSelection(cc); + + var pos = codemirror.getCursor(), + curLineHTML = doc.getLine(pos.line); + + if (findDepth(curLineHTML, cc) !== 0) { + // Cursor is inside a , don't set cursor: + curLineHTML = curLineHTML.replace(cc, ''); + doc.replaceRange(curLineHTML, CodeMirror.Pos(pos.line, 0), CodeMirror.Pos(pos.line)); + } + + // Submit HTML to TinyMCE: + // [FIX] Cursor position inside JS, style or &nbps; + // Workaround to fix cursor position if inside script tag + const code = codemirror.getValue(); + + /* Regex to check if inside script or style tags */ + const ccScript = new RegExp("(.*?)" + cc + "(.*?)<\/script>", "ms"); + const ccStyle = new RegExp("(.*?)" + cc + "(.*?)<\/style>", "ms"); + + /* Regex to check if in beginning or end or if between < & > */ + const ccLocationCheck = new RegExp("<[^>]*(" + cc + ").*>|^(" + cc + ")|(" + cc + ")$"); + + if ( + code.search(ccScript) !== -1 || + code.search(ccStyle) !== -1 || + code.search(ccLocationCheck) !== -1 + ){ + editor.setContent(code.replace(cc, '')); + } else { + editor.setContent(code.replace(cc, '')); + } + + editor.isNotDirty = !isDirty; + if (isDirty) { + editor.nodeChanged(); + } + + // Set cursor: + var el = editor.dom.select('span#CmCaReT')[0]; + if (el) { + editor.selection.scrollIntoView(el); + editor.selection.setCursorLocation(el,0); + editor.dom.remove(el); + } +} + +/** + * Append some help text in the modal footer + */ +const start = () => { + // Initialise (on load) + if (typeof(window.CodeMirror) !== 'function') { + throw new Error(`CodeMirror not found in "${CMsettings.path}", aborting...`); + } + + // Create legend for keyboard shortcuts for find & replace: + const head = parent.document.querySelectorAll('.tox-dialog__footer')[0]; + const div = parent.document.createElement('div'); + const td1 = ''; + const td2 = ''; + div.innerHTML = ` + + + ${td1}${(isMac ? '⌘-F' : 'Ctrl-F')}${td2}${tinymce.translate('Start search')} + ${td1}${(isMac ? '⌘-G' : 'Ctrl-G')} + ${td2}${tinymce.translate('Find next')} + ${td1}${(isMac ? '⌘-Alt-F' : 'Shift-Ctrl-F')} + ${td2}${tinymce.translate('Find previous')} + + + ${td1}${(isMac ? '⌘-Alt-F' : 'Shift-Ctrl-F')} + ${td2}${tinymce.translate('Replace')} + ${td1}${(isMac ? 'Shift-⌘-Alt-F' : 'Shift-Ctrl-R')} + ${td2}${tinymce.translate('Replace all')} + +
`; + div.style.position = 'absolute'; + div.style.left = div.style.bottom = '5px'; + head.appendChild(div); + + // Set CodeMirror cursor and bookmark to same position as cursor was in TinyMCE: + let html = editor.getContent({source_view: true}); + + // [FIX] #6 z-index issue with table panel and source code dialog + // editor.selection.getBookmark(); + + html = html.replace(/]*)>([^<]*)<\/span>/gm, String.fromCharCode(chr)); + editor.dom.remove(editor.dom.select('.CmCaReT')); + + // Hide TinyMCE toolbar panels, [FIX] #6 z-index issue with table panel and source code dialog + // https://github.com/christiaan/tinymce-codemirror/issues/6 + tinymce.each(editor.contextToolbars, (toolbar) => { if (toolbar.panel) { toolbar.panel.hide(); } }); + + CodeMirror.defineInitHook((inst) => { + // Move cursor to correct position: + inst.focus(); + const cursor = inst.getSearchCursor(String.fromCharCode(chr), false); + if (cursor.findNext()) { + inst.setCursor(cursor.to()); + cursor.replace(''); + } + + // Indent all code, if so requested: + if (editor.settings.codemirror.indentOnInit) { + const last = inst.lineCount(); + inst.operation(function() { + for (let i = 0; i < last; ++i) { + inst.indentLine(i); + } + }); + } + }); + + CMsettings.config.value = html; + + // Instantiante CodeMirror: + codemirror = CodeMirror(document.body, CMsettings.config); + codemirror.isDirty = false; + codemirror.on('change', (inst) => { + inst.isDirty = true; + }); +} + +/** + * Listen for the escape key and close the modal + * + * @param {Event} evt + */ +document.addEventListener('keydown', (evt) => { + evt = evt || window.event; + let isEscape = false; + if ("key" in evt) + isEscape = (evt.key === "Escape" || evt.key === "Esc"); + else + isEscape = (evt.keyCode === 27); + + if (isEscape) + tinymce.activeEditor.windowManager.close(); +}); + +(() => { + // Initialise (before load) + tinymce = parent.tinymce; + if (!tinymce) { + throw new Error('tinyMCE not found'); + } + + editor = tinymce.activeEditor; + const userSettings = editor.settings.codemirror; + + if (userSettings.fullscreen) { + CMsettings.jsFiles.push('addon/display/fullscreen.min.js'); + CMsettings.cssFiles.push('addon/display/fullscreen.css'); + } + + // Merge config + for (const i in userSettings.config) { + CMsettings.config[i] = userSettings.config[i]; + } + + // Merge jsFiles + for (const i in userSettings.jsFiles) { + if (!CMsettings.jsFiles.includes(userSettings.jsFiles[i])) { + CMsettings.jsFiles.push(userSettings.jsFiles[i]); + } + } + + // Merge cssFiles + for (const i in userSettings.cssFiles) { + if (!CMsettings.cssFiles.includes(userSettings.cssFiles[i])) { + CMsettings.cssFiles.push(userSettings.cssFiles[i]); + } + } + + // Add trailing slash to path + if (!/\/$/.test(CMsettings.path)) { + CMsettings.path += '/'; + } + + // Write stylesheets + for (let i = 0; i < CMsettings.cssFiles.length; i++) { + document.write(''); + } + + // Write JS source files. Needs to be synchronous to ensure the correct order. + for (let i = 0; i < CMsettings.jsFiles.length; i++) { + document.write(''); + } + + // Borrowed from codemirror.js themeChanged function. Sets the theme's class names to the html element. + // Without this, the background color outside of the codemirror wrapper element remains white. + // [TMP] commented temporary, cause JS error: Uncaught TypeError: Cannot read property 'replace' of undefined + if (CMsettings.config.theme) { + document.documentElement.className += CMsettings.config.theme.replace(/(^|\s)\s*/g, ' cm-s-'); + } + + window.onload = start; +})(); diff --git a/plugins/editors/tinymce/forms/setoptions.xml b/plugins/editors/tinymce/forms/setoptions.xml index 3500ce0218347..0c118ea522b2d 100644 --- a/plugins/editors/tinymce/forms/setoptions.xml +++ b/plugins/editors/tinymce/forms/setoptions.xml @@ -298,6 +298,17 @@ + + + + + + >