Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[4.1] Tiny source code syntax highlighting #2132

Closed
jgerman-bot opened this issue Nov 24, 2021 · 0 comments · Fixed by #2154
Closed

[4.1] Tiny source code syntax highlighting #2132

jgerman-bot opened this issue Nov 24, 2021 · 0 comments · Fixed by #2154

Comments

@jgerman-bot
Copy link

New language relevant PR in upstream repo: joomla/joomla-cms#35647 Here are the upstream changes:

Click to expand the diff!
diff --git a/administrator/language/en-GB/plg_editors_tinymce.ini b/administrator/language/en-GB/plg_editors_tinymce.ini
index 62b3f80f2524..6c2df01727fa 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: <a href=\"http://skin.tiny.cloud\" target=\"_blank\">Skin Creator</a>"
 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 4382d856979c..ab74b5225ca1 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 = `<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="UTF-8">
+    <script>${jsContent.code}</script>
+    <style type="text/css">${cssContent.css}</style>
+  </head>
+  <body></body>
+</html>
+`;
+
+  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 000000000000..910370e37e2d
--- /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 ([email protected])
+ *
+ * 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('<span style="display: none;" class="CmCaReT">&#x0;</span>');
+      }
+
+      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 000000000000..084efeda1250
--- /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 000000000000..29fa2b4b07fb
--- /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 ([email protected])
+ *
+ * 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 = '&#x0;';
+  const isDirty = codemirror.isDirty;
+  const doc = codemirror.doc;
+
+  if (doc.somethingSelected()) {
+    // Clear selection:
+    doc.setCursor(doc.getCursor());
+  }
+
+  // Insert cursor placeholder (&#x0;)
+  doc.replaceSelection(cc);
+
+  var pos = codemirror.getCursor(),
+  curLineHTML = doc.getLine(pos.line);
+
+  if (findDepth(curLineHTML, cc) !== 0) {
+    // Cursor is inside a <tag>, 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("<script(.*?)>(.*?)" + cc + "(.*?)<\/script>", "ms");
+  const ccStyle = new RegExp("<style(.*?)>(.*?)" + 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, '<span id="CmCaReT"></span>'));
+  }
+
+  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 = '<td style="font-size:11px;background:#777;color:#fff;padding:0 4px">';
+  const td2 = '<td style="font-size:11px;padding-right:5px">';
+  div.innerHTML = `
+<table cellspacing="0" cellpadding="0" style="border-spacing:4px">
+  <tr>
+    ${td1}${(isMac ? '&#8984;-F' : 'Ctrl-F</td>')}${td2}${tinymce.translate('Start search')}</td>
+    ${td1}${(isMac ? '&#8984;-G' : 'Ctrl-G')}</td>
+    ${td2}${tinymce.translate('Find next')}</td>
+    ${td1}${(isMac ? '&#8984;-Alt-F' : 'Shift-Ctrl-F')}</td>
+    ${td2}${tinymce.translate('Find previous')}</td>
+  </tr>
+  <tr>
+    ${td1}${(isMac ? '&#8984;-Alt-F' : 'Shift-Ctrl-F')}</td>
+    ${td2}${tinymce.translate('Replace')}</td>
+    ${td1}${(isMac ? 'Shift-&#8984;-Alt-F' : 'Shift-Ctrl-R')}</td>
+    ${td2}${tinymce.translate('Replace all')}</td>
+  </tr>
+</table>`;
+  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\s+style="display: none;"\s+class="CmCaReT"([^>]*)>([^<]*)<\/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('<li' + 'nk rel="stylesheet" type="text/css" href="' + CMsettings.path + CMsettings.cssFiles[i] + '" />');
+  }
+
+  // Write JS source files. Needs to be synchronous to ensure the correct order.
+  for (let i = 0; i < CMsettings.jsFiles.length; i++) {
+    document.write('<scr' + 'ipt type="text/javascript" src="' + CMsettings.path + CMsettings.jsFiles[i] + '"></scr' + 'ipt>');
+  }
+
+  // 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 3500ce021834..0c118ea522b2 100644
--- a/plugins/editors/tinymce/forms/setoptions.xml
+++ b/plugins/editors/tinymce/forms/setoptions.xml
@@ -298,6 +298,17 @@
 			<option value="1">JON</option>
 		</field>
 
+		<field
+			name="sourcecode"
+			type="radio"
+			label="PLG_TINY_FIELD_SOURCECODE_LABEL"
+			layout="joomla.form.field.radio.switcher"
+			default="1"
+			>
+			<option value="0">JOFF</option>
+			<option value="1">JON</option>
+		</field>
+
 		<field
 			name="content_languages"
 			type="subform"
@@ -307,7 +318,7 @@
 			icon="list"
 			multiple="true"
 			default=''
-		>
+  		>
 			<form hidden="true" name="content_languages" repeat="true">
 				<field
 					name="content_language_name"
diff --git a/plugins/editors/tinymce/tinymce.php b/plugins/editors/tinymce/tinymce.php
index f407ed5d6ca1..fd0744ff9a43 100644
--- a/plugins/editors/tinymce/tinymce.php
+++ b/plugins/editors/tinymce/tinymce.php
@@ -519,6 +519,12 @@ public function onDisplay(
 				$plugins[] = $pName;
 			}
 		}
+		// Use CodeMirror in the code view instead of plain text to provide syntax highlighting
+		$sourcecode = $levelParams->get('highlightPlus', 1);
+		if ($sourcecode)
+		{
+			$externalPlugins['highlightPlus'] = HTMLHelper::_('script', 'plg_editors_tinymce/plugins/highlighter/plugin.min.js', ['relative' => true, 'version' => 'auto', 'pathOnly' => true]);
+		}
 
 		// Drag and drop Images always FALSE, reverting this allows for inlining the images
 		$allowImgPaste = false;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

Successfully merging a pull request may close this issue.

3 participants