plugin.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  1. /*
  2. * Supercode TinyMCE Plugin
  3. * 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.
  4. *
  5. * Repository: https://github.com/prathamVaidya/supercode-tinymce-plugin
  6. * Author: Pratham Vaidya
  7. * License: GPL-3.0
  8. * Version: 1.2.1
  9. *
  10. * Released under the GPL-3.0 License.
  11. */
  12. !(function () {
  13. 'use strict';
  14. /**
  15. modal ->
  16. {
  17. element : Modal Container Ref
  18. editor : Ace Editor
  19. }
  20. */
  21. const MODAL_HTML = `
  22. <div id="supercode-backdrop"></div>
  23. <div id="supercode-modal">
  24. <div id="supercode-header">
  25. <h1>Source Code Editor</h1>
  26. <button id="supercode-close-btn">
  27. Close
  28. </button>
  29. </div>
  30. <div id="supercode-editor"></div>
  31. <div id="supercode-footer">
  32. <button id="supercode-cancel-btn">
  33. Cancel
  34. </button>
  35. <button id="supercode-save-btn">
  36. Save
  37. </button>
  38. </div>
  39. </div>
  40. `;
  41. const MODAL_CSS = `
  42. :root{
  43. --supercode-modal-primary: #ffffff;
  44. --supercode-modal-secondary: #222f3e;
  45. --supercode-modal-border: rgba(0, 0, 0, 0.1);
  46. }
  47. /* Media query for mobile devices */
  48. @media only screen and (max-width: 767px) {
  49. #supercode-modal {
  50. width: 100% !important;
  51. height: 100% !important;
  52. border-radius: 0 !important;
  53. }
  54. }
  55. .disable-scroll {
  56. overflow: hidden;
  57. }
  58. #supercode-modal-container {
  59. position: fixed;
  60. top: 0;
  61. left: 0;
  62. width: 100%;
  63. height: 100%;
  64. display: flex;
  65. align-items: center;
  66. justify-content: center;
  67. z-index: 990;
  68. display: none;
  69. opacity: 0;
  70. transition: opacity 0.1s linear;
  71. }
  72. #supercode-backdrop {
  73. position: absolute;
  74. top: 0;
  75. left: 0;
  76. background: black;
  77. opacity: 0.7;
  78. width: 100%;
  79. height: 100%;
  80. z-index: 1;
  81. }
  82. #supercode-modal {
  83. width: 90%;
  84. height: 80%;
  85. max-width: 1200px;
  86. z-index: 2;
  87. overflow: hidden;
  88. border-radius: 10px;
  89. display: flex;
  90. flex-direction: column;
  91. background: var(--supercode-modal-primary);
  92. }
  93. #supercode-header {
  94. display: flex;
  95. padding: 0.5rem 1rem;
  96. border-bottom: 1px solid var(--supercode-modal-border);
  97. color: var(--supercode-modal-secondary);
  98. }
  99. #supercode-modal h1 {
  100. flex-grow: 1;
  101. margin: auto;
  102. font-size: 14px;
  103. }
  104. #supercode-close-btn {
  105. background: none;
  106. border: none;
  107. padding: 0;
  108. height: 100%;
  109. cursor: pointer;
  110. fill: var(--supercode-modal-secondary);
  111. }
  112. #supercode-editor {
  113. width: 100%;
  114. height: 100%;
  115. position: relative;
  116. }
  117. #supercode-footer {
  118. padding: 0.5rem 1rem;
  119. display: flex;
  120. justify-content: end;
  121. gap: 1rem;
  122. border-top: 1px solid var(--supercode-modal-border);
  123. }
  124. #supercode-footer button {
  125. padding: 0.5rem 1rem;
  126. border-radius: 5px;
  127. font-weight: bold;
  128. border: none;
  129. cursor: pointer;
  130. min-width: 5rem;
  131. transition: opacity 0.1s linear;
  132. }
  133. #supercode-footer button:hover {
  134. opacity: 0.8;
  135. }
  136. #supercode-cancel-btn {
  137. background: transparent;
  138. color: var(--supercode-modal-secondary);
  139. }
  140. #supercode-save-btn {
  141. background: var(--supercode-modal-secondary);
  142. color: var(--supercode-modal-primary);
  143. }
  144. `;
  145. const CLOSE_ICON_FALLBACK = `<svg width="24" height="24"><path d="M17.3 8.2 13.4 12l3.9 3.8a1 1 0 0 1-1.5 1.5L12 13.4l-3.8 3.9a1 1 0 0 1-1.5-1.5l3.9-3.8-3.9-3.8a1 1 0 0 1 1.5-1.5l3.8 3.9 3.8-3.9a1 1 0 0 1 1.5 1.5Z" fill-rule="evenodd"></path></svg>`;
  146. const CODE_ICON_FALLBACK = `<svg width="24" height="24" focusable="false"><g fill-rule="nonzero"><path d="M9.8 15.7c.3.3.3.8 0 1-.3.4-.9.4-1.2 0l-4.4-4.1a.8.8 0 0 1 0-1.2l4.4-4.2c.3-.3.9-.3 1.2 0 .3.3.3.8 0 1.1L6 12l3.8 3.7ZM14.2 15.7c-.3.3-.3.8 0 1 .4.4.9.4 1.2 0l4.4-4.1c.3-.3.3-.9 0-1.2l-4.4-4.2a.8.8 0 0 0-1.2 0c-.3.3-.3.8 0 1.1L18 12l-3.8 3.7Z"></path></g></svg>`;
  147. let modal = null;
  148. const initDependencies = (config) => {
  149. const scripts = {
  150. 'ace-default': {
  151. url: 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.9.6/ace.js',
  152. loaded: false,
  153. required: true,
  154. },
  155. 'beautify-html': {
  156. url: 'https://cdnjs.cloudflare.com/ajax/libs/js-beautify/1.15.1/beautify-html.min.js',
  157. loaded: false,
  158. required: true,
  159. },
  160. 'ace-autocomplete': {
  161. url: 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.9.6/ext-language_tools.min.js',
  162. loaded: false,
  163. required: false,
  164. },
  165. };
  166. if (config.autocomplete) {
  167. scripts['ace-autocomplete'].required = true;
  168. }
  169. Object.values(scripts).forEach((script) => {
  170. if (script.loaded) return;
  171. let element = document.createElement('script');
  172. element.src = script.url;
  173. element.type = 'text/javascript';
  174. document.body.appendChild(element);
  175. });
  176. };
  177. const injectCSS = (css) => {
  178. const style = document.createElement('style');
  179. style.innerHTML = css;
  180. document.head.append(style);
  181. };
  182. const debounce = (func, delay) => {
  183. let timeoutId;
  184. return (...args) => {
  185. clearTimeout(timeoutId);
  186. timeoutId = setTimeout(() => func.apply(null, args), delay);
  187. };
  188. };
  189. // on plugin load
  190. const mainPlugin = function (editor) {
  191. let editorWidth = 0,
  192. originalHeader,
  193. isScreenSizeChanged = false,
  194. session,
  195. aceEditor;
  196. const Config = {
  197. theme: 'chrome',
  198. fontSize: 14, // in px
  199. wrap: true,
  200. icon: undefined, // auto set during config
  201. iconName: 'sourcecode',
  202. autocomplete: false,
  203. language: 'html',
  204. renderer: null,
  205. parser: null,
  206. shortcut: true,
  207. aceCss: null,
  208. fontFamily: null,
  209. fallbackModal: false, // enabled in cases like inline, or versions where `CustomView` is not supported.
  210. modalPrimaryColor: '#ffffff',
  211. modalSecondaryColor: '#222f3e',
  212. dark: false,
  213. debug: true,
  214. };
  215. const debug = (msg) => {
  216. if (Config.debug) {
  217. console.warn(`${msg} \n\nUse debug:false option to disable this warning`);
  218. }
  219. };
  220. // Get Configurations
  221. const setConfig = (editor) => {
  222. const supercodeOptions = editor.getParam('supercode');
  223. if (supercodeOptions && typeof supercodeOptions === 'object') {
  224. for (const key in supercodeOptions) {
  225. if (supercodeOptions.hasOwnProperty(key)) {
  226. const value = supercodeOptions[key];
  227. switch (key) {
  228. case 'theme':
  229. case 'language':
  230. case 'iconName':
  231. case 'aceCss':
  232. case 'fontFamily':
  233. case 'modalPrimaryColor':
  234. case 'modalSecondaryColor':
  235. if (typeof value === 'string') {
  236. Config[key] = value;
  237. }
  238. break;
  239. case 'fontSize':
  240. if (typeof value === 'number' && value > 0) {
  241. Config.fontSize = parseInt(value);
  242. }
  243. break;
  244. case 'wrap':
  245. case 'autocomplete':
  246. case 'shortcut':
  247. case 'fallbackModal':
  248. case 'dark':
  249. case 'debug':
  250. if (typeof value === 'boolean') {
  251. Config[key] = value;
  252. }
  253. break;
  254. case 'parser':
  255. case 'renderer':
  256. if (typeof value === 'function') {
  257. Config[key] = value;
  258. }
  259. break;
  260. default:
  261. // Ignore unrecognized options
  262. break;
  263. }
  264. }
  265. }
  266. }
  267. // Set plugin icon
  268. Config.icon = editor.ui.registry.getAll?.().icons?.[Config.iconName];
  269. if (!Config.icon) {
  270. Config.icon = CODE_ICON_FALLBACK;
  271. debug(
  272. 'Supercode Icon name is invalid or you are using older versions of tinyMCE. The icon is set to default fallback code icon.'
  273. );
  274. }
  275. // Detect and set fallback model if its required
  276. if (!Config.fallbackModal) {
  277. // case: inline mode, < v5
  278. if (editor.getParam('inline') === true || tinymce.majorVersion <= 5) {
  279. Config.fallbackModal = true;
  280. }
  281. }
  282. };
  283. const setAceOptions = () => {
  284. const options = {};
  285. if (Config.autocomplete) {
  286. options.enableLiveAutocompletion = true;
  287. }
  288. if (Config.fontFamily) {
  289. options.fontFamily = Config.fontFamily;
  290. }
  291. aceEditor.setOptions(options);
  292. aceEditor.setTheme(`ace/theme/${Config.theme}`);
  293. aceEditor.setFontSize(Config.fontSize);
  294. aceEditor.setShowPrintMargin(false);
  295. };
  296. // Builds ace editor only on the first run
  297. const buildAceEditor = (view) => {
  298. // Attach Ace Editor to shadow dom to prevent tinymce css affecting it
  299. view.attachShadow({ mode: 'open' });
  300. if (Config.aceCss) {
  301. const sheet = new CSSStyleSheet();
  302. sheet.replaceSync(Config.aceCss);
  303. view.shadowRoot.adoptedStyleSheets.push(sheet);
  304. }
  305. view.shadowRoot.innerHTML = `<div class="supercode-editor" style="width: 100%; height: 100%; position: absolute; left:0; top:0"></div>`;
  306. const editorElement = view.shadowRoot.querySelector('.supercode-editor');
  307. editorElement.style.width = '100%';
  308. editorElement.style.height = '100%';
  309. aceEditor = ace.edit(editorElement);
  310. // https://github.com/josdejong/jsoneditor/issues/742#issuecomment-698449020
  311. aceEditor.renderer.attachToShadowRoot();
  312. setAceOptions();
  313. };
  314. const setHeader = (view, originalHeader) => {
  315. // add a copy of original header to give original header look
  316. const newHeader = originalHeader.cloneNode(true);
  317. newHeader.style.position = 'relative';
  318. // If menu-bar exists utilize the space to show Title "Source Code Editor"
  319. const menubar = newHeader.querySelector('.tox-menubar');
  320. if (menubar) {
  321. menubar.innerHTML = `<b style='font-size: 14px; font-weight: bold; padding: 11px 9px;'>Source Code Editor</b>`;
  322. }
  323. // disable all the buttons except supercode button, attach event listener
  324. let overflowButton = null,
  325. isPluginButton = false;
  326. newHeader.querySelectorAll('.tox-tbtn, .tox-split-button').forEach((btn) => {
  327. if (btn.getAttribute('data-mce-name') != 'supercode') {
  328. // remove overflow button to make space for code button
  329. if (btn.getAttribute('data-mce-name') === 'overflow-button') {
  330. overflowButton = btn;
  331. }
  332. btn.classList.remove('tox-tbtn--enabled');
  333. btn.classList.add('tox-tbtn--disabled');
  334. btn.removeAttribute('data-mce-name');
  335. } else {
  336. isPluginButton = true;
  337. btn.setAttribute('data-mce-name', 'supercode-toggle');
  338. btn.classList.add('tox-tbtn--enabled');
  339. btn.onclick = onSaveHandler;
  340. }
  341. });
  342. // in case of overflow, replace the overflow button with code button
  343. if (!isPluginButton && overflowButton) {
  344. overflowButton.classList = 'tox-tbtn tox-tbtn--enabled';
  345. overflowButton.innerHTML = `<span class="tox-icon tox-tbtn__icon-wrap">${Config.icon}</span>`;
  346. overflowButton.onclick = onSaveHandler;
  347. }
  348. view.innerHTML = ''; // delete any existing header
  349. view.append(newHeader);
  350. };
  351. const setMainView = (view, width) => {
  352. // configure body of view to look similar to tinymce body, adds ace editor
  353. view.style.width = width + 'px';
  354. view.style.height = '100%';
  355. view.style.position = 'relative';
  356. buildAceEditor(view);
  357. };
  358. setConfig(editor);
  359. initDependencies(Config);
  360. const modalKeydownListener = (e) => {
  361. if (e.key === 'Escape') {
  362. hideModal();
  363. }
  364. };
  365. const showModal = () => {
  366. if (!modal) {
  367. // Build Modal
  368. const modalContainer = document.createElement('div');
  369. modalContainer.id = 'supercode-modal-container';
  370. modalContainer.innerHTML = MODAL_HTML;
  371. injectCSS(MODAL_CSS);
  372. document.body.append(modalContainer);
  373. modal = {
  374. element: modalContainer,
  375. editor: ace.edit(modalContainer.querySelector('#supercode-editor')),
  376. };
  377. }
  378. // transfer global editor in active scope (All methods work on local scope and need aceEditor)
  379. aceEditor = modal.editor;
  380. setAceOptions(); // update ace options according to current editor configs
  381. /* Update Event Listeners */
  382. modal.element.querySelector('#supercode-backdrop').onclick = hideModal;
  383. modal.element.querySelector('#supercode-close-btn').onclick = hideModal;
  384. modal.element.querySelector('#supercode-cancel-btn').onclick = hideModal;
  385. modal.element.querySelector('#supercode-save-btn').onclick = () => {
  386. onSaveHandler();
  387. hideModal();
  388. };
  389. if (Config.shortcut) {
  390. modal.element
  391. .querySelector('#supercode-editor')
  392. .addEventListener('keydown', modalKeydownListener);
  393. }
  394. /* Update Modal based on editor's theme */
  395. document.querySelector('body').classList.add('disable-scroll');
  396. document.body.style.setProperty('--supercode-modal-primary', Config.modalPrimaryColor);
  397. document.body.style.setProperty('--supercode-modal-secondary', Config.modalSecondaryColor);
  398. if (Config.dark) {
  399. document.body.style.setProperty('--supercode-modal-border', 'rgba(255, 255, 255, 0.1)');
  400. }
  401. modal.element.querySelector('#supercode-close-btn').innerHTML =
  402. editor.ui.registry.getAll?.().icons?.['close'] ?? CLOSE_ICON_FALLBACK;
  403. modal.element.style.display = 'flex';
  404. setTimeout(() => {
  405. modal.element.style.opacity = 1;
  406. }, 10);
  407. /* Load current editor's ace session into aceEditor */
  408. loadAceSession();
  409. };
  410. const hideModal = () => {
  411. if (Config.shortcut) {
  412. removeEventListener('keydown', modalKeydownListener);
  413. }
  414. document.querySelector('body').classList.remove('disable-scroll');
  415. modal.element.style.opacity = 0;
  416. editor.focus();
  417. setTimeout(() => {
  418. modal.element.style.display = 'none';
  419. }, 10);
  420. };
  421. // Save editor content to tinymce editor on ace editor change
  422. const liveSave = () => {
  423. editor.undoManager.transact(function () {
  424. let value = aceEditor.getValue();
  425. if (Config.renderer) {
  426. value = Config.renderer(value);
  427. }
  428. editor.setContent(value);
  429. });
  430. editor.nodeChanged();
  431. };
  432. const onSaveHandler = () => {
  433. editor.focus();
  434. if (Config.fallbackModal) {
  435. editor.undoManager.transact(function () {
  436. let value = aceEditor.getValue();
  437. if (Config.renderer) {
  438. value = Config.renderer(value);
  439. }
  440. editor.setContent(value);
  441. });
  442. editor.selection.setCursorLocation();
  443. editor.nodeChanged();
  444. }
  445. editor.execCommand('ToggleView', false, 'supercode');
  446. };
  447. const onKeyDownHandler = (e) => {
  448. if ((e.key === ' ' && e.ctrlKey) || e.key === 'Escape') {
  449. onSaveHandler();
  450. }
  451. };
  452. const getSourceCode = (value) => {
  453. if (Config.parser) {
  454. return Config.parser(value);
  455. }
  456. return html_beautify(value);
  457. };
  458. const loadAceSession = () => {
  459. // Load or build new ace session from editor
  460. let content = getSourceCode(editor.getContent());
  461. if (!session) {
  462. session = ace.createEditSession(content, `ace/mode/${Config.language}`);
  463. session.setUseWrapMode(Config.wrap);
  464. // Attach live save to ace editor for in-editor source view
  465. if (!Config.fallbackModal) {
  466. const debouncedSave = debounce(liveSave, 300);
  467. session.on('change', debouncedSave);
  468. }
  469. }
  470. aceEditor.setSession(session);
  471. session.setValue(content);
  472. aceEditor.gotoLine(Infinity);
  473. aceEditor.focus();
  474. };
  475. const startPlugin = function () {
  476. if (Config.fallbackModal) {
  477. showModal();
  478. } else {
  479. const container = editor.getContainer();
  480. if (editorWidth) {
  481. isScreenSizeChanged = editorWidth != container.clientWidth;
  482. }
  483. editorWidth = container.clientWidth;
  484. if (isScreenSizeChanged || !originalHeader) {
  485. originalHeader = container.querySelector('.tox-editor-header');
  486. }
  487. editor.execCommand('ToggleView', false, 'supercode');
  488. }
  489. };
  490. if (!Config.fallbackModal) {
  491. const CodeView = {
  492. onShow: (api) => {
  493. const codeView = api.getContainer();
  494. // On tinymce size change => resize code view
  495. if (isScreenSizeChanged) {
  496. setHeader(codeView.querySelector('.supercode-header'), originalHeader);
  497. codeView.querySelector('.supercode-body ').style.width = editorWidth + 'px';
  498. aceEditor.resize();
  499. }
  500. // Only on First time plugin opened => mount view
  501. if (codeView.childElementCount === 0) {
  502. codeView.style.padding = 0;
  503. codeView.style.display = 'flex';
  504. codeView.style.flexDirection = 'column';
  505. codeView.innerHTML = `<div class="supercode-header"></div><div class="supercode-body"></div>`;
  506. // Ctrl + Space Toggle Shortcut, Escape to Exit Source Code Mode
  507. if (Config.shortcut) {
  508. codeView.addEventListener('keydown', onKeyDownHandler);
  509. }
  510. // configure header
  511. setHeader(codeView.querySelector('.supercode-header'), originalHeader);
  512. // configure main code view to look same
  513. setMainView(codeView.querySelector('.supercode-body '), editorWidth);
  514. }
  515. loadAceSession();
  516. },
  517. onHide: () => {
  518. if (Config.shortcut) {
  519. removeEventListener('keydown', onKeyDownHandler);
  520. }
  521. },
  522. };
  523. editor.ui.registry.addView('supercode', CodeView);
  524. }
  525. editor.ui.registry.addButton('supercode', {
  526. icon: Config.iconName,
  527. tooltip: 'Source Code Editor (Ctrl + space)',
  528. onAction: startPlugin,
  529. });
  530. editor.ui.registry.addMenuItem('supercode', {
  531. icon: Config.iconName,
  532. text: 'Source Code',
  533. onAction: startPlugin,
  534. });
  535. editor.ui.registry.addContextMenu('supercode', {
  536. update: (element) => {
  537. return 'supercode';
  538. },
  539. });
  540. // Ctrl + Space Toggle Shortcut
  541. if (Config.shortcut) {
  542. editor.shortcuts.add('ctrl+32', 'Toggles Source Code Editing Mode', startPlugin);
  543. }
  544. return {
  545. getMetadata: function () {
  546. return {
  547. name: 'Supercode',
  548. url: 'https://github.com/prathamVaidya/supercode-tinymce-plugin',
  549. };
  550. },
  551. };
  552. };
  553. // On Script Load, the plugin will be loaded
  554. tinymce.PluginManager.add('supercode', mainPlugin);
  555. })();