diff --git a/lib/editor/tiny/amd/build/editor.min.js b/lib/editor/tiny/amd/build/editor.min.js
index 60ebfb3a3e1..3403c3fb522 100644
--- a/lib/editor/tiny/amd/build/editor.min.js
+++ b/lib/editor/tiny/amd/build/editor.min.js
@@ -1,3 +1,3 @@
-define("editor_tiny/editor",["exports","./loader","core/pending"],(function(_exports,_loader,_pending){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setupForTarget=_exports.setupForElementId=_exports.getInstanceForElementId=_exports.getInstanceForElement=_exports.getAllInstances=_exports.configureDefaultEditor=void 0,_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};const instanceMap=new Map;let defaultOptions={};const importPluginList=async pluginList=>{const pluginHandlers=await Promise.all(pluginList.map((pluginPath=>-1===pluginPath.indexOf("/")?Promise.resolve(pluginPath):"function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([pluginPath],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(pluginPath)):Promise.resolve(_systemImportTransformerGlobalIdentifier[pluginPath])))),pluginNames=pluginHandlers.map((pluginConfig=>"string"==typeof pluginConfig?pluginConfig:Array.isArray(pluginConfig)?pluginConfig[0]:null)).filter((value=>value));return{pluginNames:pluginNames,pluginConfig:pluginHandlers.map((pluginConfig=>Array.isArray(pluginConfig)?pluginConfig[1]:null)).filter((value=>value))}};_exports.getAllInstances=()=>new Map(instanceMap.entries());_exports.getInstanceForElementId=elementId=>getInstanceForElement(document.getElementById(elementId));const getInstanceForElement=element=>{const instance=instanceMap.get(element);if(!instance||!instance.removed)return instance;instanceMap.remove(element)};_exports.getInstanceForElement=getInstanceForElement;_exports.setupForElementId=_ref=>{let{elementId:elementId,options:options}=_ref;const target=document.getElementById(elementId);return setupForTarget(target,options)};(async()=>{const lang=document.querySelector("html").lang,[tinyMCE,langData]=await Promise.all([(0,_loader.getTinyMCE)(),(language=lang,fetch("".concat(M.cfg.wwwroot,"/lib/editor/tiny/lang.php/").concat(M.cfg.langrev,"/").concat(language)).then((response=>response.json())))]);var language;tinyMCE.addI18n(lang,langData)})();const getPlugins=options=>options.plugins?options.plugins:defaultOptions.plugins?defaultOptions.plugins:{},getStandardConfig=(target,tinyMCE,options,plugins)=>({target:target,language:document.querySelector("html").lang,content_css:[options.css],convert_urls:!1,a11y_advanced_options:!0,toolbar_mode:"sliding",toolbar:[{name:"history",items:["undo","redo"]},{name:"styles",items:["styles"]},{name:"formatting",items:["bold","italic"]},{name:"alignment",items:["alignleft","aligncenter","alignright","alignjustify"]},{name:"indentation",items:["outdent","indent"]},{name:"comments",items:["addcomment"]}],menu:{},plugins:[...plugins],skin:"oxide",promotion:!1}),setupForTarget=async function(target){let options=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const instance=getInstanceForElement(target);if(instance)return Promise.resolve(instance);const pendingPromise=new _pending.default("editor_tiny/editor:setupForTarget"),plugins=getPlugins(options),[tinyMCE,pluginValues]=await Promise.all([(0,_loader.getTinyMCE)(),importPluginList(Object.keys(plugins))]),{pluginNames:pluginNames,pluginConfig:pluginConfig}=pluginValues,instanceConfig=getStandardConfig(target,0,options,pluginNames);pluginConfig.forEach((pluginConfig=>{"function"==typeof pluginConfig.configure&&Object.assign(instanceConfig,pluginConfig.configure(instanceConfig))}));const[editor]=await tinyMCE.init(instanceConfig);return instanceMap.set(target,editor),editor.on("remove",(_ref2=>{let{target:target}=_ref2;instanceMap.delete(target.targetElm)})),editor.moodleOptions=options,pendingPromise.resolve(),editor};_exports.setupForTarget=setupForTarget;_exports.configureDefaultEditor=function(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};defaultOptions=options}}));
+define("editor_tiny/editor",["exports","./loader","core/pending"],(function(_exports,_loader,_pending){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setupForTarget=_exports.setupForElementId=_exports.getInstanceForElementId=_exports.getInstanceForElement=_exports.getAllInstances=_exports.configureDefaultEditor=void 0,_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};const instanceMap=new Map;let defaultOptions={};const importPluginList=async pluginList=>{const pluginHandlers=await Promise.all(pluginList.map((pluginPath=>-1===pluginPath.indexOf("/")?Promise.resolve(pluginPath):"function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([pluginPath],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(pluginPath)):Promise.resolve(_systemImportTransformerGlobalIdentifier[pluginPath])))),pluginNames=pluginHandlers.map((pluginConfig=>"string"==typeof pluginConfig?pluginConfig:Array.isArray(pluginConfig)?pluginConfig[0]:null)).filter((value=>value));return{pluginNames:pluginNames,pluginConfig:pluginHandlers.map((pluginConfig=>Array.isArray(pluginConfig)?pluginConfig[1]:null)).filter((value=>value))}};_exports.getAllInstances=()=>new Map(instanceMap.entries());_exports.getInstanceForElementId=elementId=>getInstanceForElement(document.getElementById(elementId));const getInstanceForElement=element=>{const instance=instanceMap.get(element);if(!instance||!instance.removed)return instance;instanceMap.remove(element)};_exports.getInstanceForElement=getInstanceForElement;_exports.setupForElementId=_ref=>{let{elementId:elementId,options:options}=_ref;const target=document.getElementById(elementId);return setupForTarget(target,options)};(async()=>{const lang=document.querySelector("html").lang,[tinyMCE,langData]=await Promise.all([(0,_loader.getTinyMCE)(),(language=lang,fetch("".concat(M.cfg.wwwroot,"/lib/editor/tiny/lang.php/").concat(M.cfg.langrev,"/").concat(language)).then((response=>response.json())))]);var language;tinyMCE.addI18n(lang,langData)})();const getPlugins=options=>options.plugins?options.plugins:defaultOptions.plugins?defaultOptions.plugins:{},getStandardConfig=(target,tinyMCE,options,plugins)=>{const lang=document.querySelector("html").lang;return{base_url:_loader.baseUrl,target:target,language:lang,content_css:[options.css],convert_urls:!1,a11y_advanced_options:!0,toolbar_mode:"sliding",toolbar:[{name:"history",items:["undo","redo"]},{name:"styles",items:["styles"]},{name:"formatting",items:["bold","italic"]},{name:"alignment",items:["alignleft","aligncenter","alignright","alignjustify"]},{name:"indentation",items:["outdent","indent"]},{name:"comments",items:["addcomment"]}],menu:{},plugins:[...plugins],skin:"oxide",promotion:!1}},setupForTarget=async function(target){let options=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const instance=getInstanceForElement(target);if(instance)return Promise.resolve(instance);const pendingPromise=new _pending.default("editor_tiny/editor:setupForTarget"),plugins=getPlugins(options),[tinyMCE,pluginValues]=await Promise.all([(0,_loader.getTinyMCE)(),importPluginList(Object.keys(plugins))]),{pluginNames:pluginNames,pluginConfig:pluginConfig}=pluginValues,instanceConfig=getStandardConfig(target,0,options,pluginNames);pluginConfig.forEach((pluginConfig=>{"function"==typeof pluginConfig.configure&&Object.assign(instanceConfig,pluginConfig.configure(instanceConfig))}));const[editor]=await tinyMCE.init(instanceConfig);return instanceMap.set(target,editor),editor.on("remove",(_ref2=>{let{target:target}=_ref2;instanceMap.delete(target.targetElm)})),editor.moodleOptions=options,pendingPromise.resolve(),editor};_exports.setupForTarget=setupForTarget;_exports.configureDefaultEditor=function(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};defaultOptions=options}}));
//# sourceMappingURL=editor.min.js.map
\ No newline at end of file
diff --git a/lib/editor/tiny/amd/build/editor.min.js.map b/lib/editor/tiny/amd/build/editor.min.js.map
index 99d01e2c6e6..58fe0a3123e 100644
--- a/lib/editor/tiny/amd/build/editor.min.js.map
+++ b/lib/editor/tiny/amd/build/editor.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"editor.min.js","sources":["../src/editor.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Utility functions.\n *\n * @module editor_tiny/editor\n * @copyright 2022 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport {\n getTinyMCE,\n} from './loader';\nimport Pending from 'core/pending';\n\n/**\n * Storage for the TinyMCE instances on the page.\n * @type {Map}\n */\nconst instanceMap = new Map();\n\n/**\n * The default editor configuration.\n * @type {Object}\n */\nlet defaultOptions = {};\n\n/**\n * Require the modules for the named set of TinyMCE plugins.\n *\n * @param {string[]} pluginList The list of plugins\n * @return {Promise[]} A matching set of Promises relating to the requested plugins\n */\nconst importPluginList = async(pluginList) => {\n const pluginHandlers = await Promise.all(pluginList.map(pluginPath => {\n if (pluginPath.indexOf('/') === -1) {\n // A standard TinyMCE Plugin.\n return Promise.resolve(pluginPath);\n }\n\n return import(pluginPath);\n }));\n\n const pluginNames = pluginHandlers.map((pluginConfig) => {\n if (typeof pluginConfig === 'string') {\n return pluginConfig;\n }\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[0];\n }\n return null;\n }).filter((value) => value);\n\n const pluginConfig = pluginHandlers.map((pluginConfig) => {\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[1];\n }\n return null;\n }).filter((value) => value);\n\n return {\n pluginNames,\n pluginConfig,\n };\n};\n\nconst fetchLanguage = (language) => fetch(\n `${M.cfg.wwwroot}/lib/editor/tiny/lang.php/${M.cfg.langrev}/${language}`\n).then(response => response.json());\n\nexport const getAllInstances = () => new Map(instanceMap.entries());\n\n/**\n * Get the TinyMCE instance for the specified Node ID.\n *\n * @param {string} elementId\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElementId = elementId => getInstanceForElement(document.getElementById(elementId));\n\n/*\n * Get the TinyMCE instance for the specified HTMLElement.\n *\n * @param {HTMLElement} element\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElement = element => {\n const instance = instanceMap.get(element);\n if (instance && instance.removed) {\n instanceMap.remove(element);\n return undefined;\n }\n return instance;\n};\n\n/**\n * Set up TinyMCE for the selector at the specified HTML Node id.\n *\n * @param {object} config The configuration required to setup the editor\n * @param {string} config.elementId The HTML Node ID\n * @param {Object} config.options The editor plugin configuration\n * @return {Promise} The TinyMCE instance\n */\nexport const setupForElementId = ({elementId, options}) => {\n const target = document.getElementById(elementId);\n return setupForTarget(target, options);\n};\n\nconst initialisePage = async() => {\n const lang = document.querySelector('html').lang;\n\n const [tinyMCE, langData] = await Promise.all([getTinyMCE(), fetchLanguage(lang)]);\n tinyMCE.addI18n(lang, langData);\n};\ninitialisePage();\n\nconst getPlugins = (options) => {\n if (options.plugins) {\n return options.plugins;\n }\n\n if (defaultOptions.plugins) {\n return defaultOptions.plugins;\n }\n\n return {};\n};\n\nconst getStandardConfig = (target, tinyMCE, options, plugins) => {\n const lang = document.querySelector('html').lang;\n return {\n // Set the editor target.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#target\n target,\n\n // Set the language.\n // https://www.tiny.cloud/docs/tinymce/6/ui-localization/#language\n language: lang,\n\n // Load the editor stylesheet into the editor iframe.\n // https://www.tiny.cloud/docs/tinymce/6/add-css-options/\n content_css: [\n options.css,\n ],\n\n // Do not convert URLs to relative URLs.\n // https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls\n // eslint-disable-next-line camelcase\n convert_urls: false,\n\n // Enabled 'advanced' a11y options.\n // This includes allowing role=\"presentation\" from the image uploader.\n // https://www.tiny.cloud/docs/tinymce/6/accessibility/\n // eslint-disable-next-line camelcase\n a11y_advanced_options: true,\n\n // Toolbar configuration.\n // https://www.tiny.cloud/docs/tinymce/6/toolbar-configuration-options/\n // TODO: Move this configuration to a passed-in option.\n // eslint-disable-next-line camelcase\n toolbar_mode: 'sliding',\n toolbar: [\n {\n name: 'history',\n items: [\n 'undo',\n 'redo'\n ]\n },\n {\n name: 'styles',\n items: ['styles']\n },\n {\n name: 'formatting',\n items: [\n 'bold',\n 'italic'\n ]\n },\n {\n name: 'alignment',\n items: [\n 'alignleft',\n 'aligncenter',\n 'alignright',\n 'alignjustify'\n ]\n },\n {\n name: 'indentation',\n items: [\n 'outdent',\n 'indent'\n ]\n },\n {\n name: 'comments',\n items: ['addcomment']\n },\n ],\n\n // Menu configuration.\n // https://www.tiny.cloud/docs/tinymce/6/menus-configuration-options/\n // TODO: Move this configuration to a passed-in option.\n menu: {\n },\n\n // The list of plugins to include in the instance.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#plugins\n plugins: [\n ...plugins,\n ],\n\n // TODO Add mobile configuration.\n // Mobile configuration.\n // https://www.tiny.cloud/docs/tinymce/6/tinymce-for-mobile/\n // This will include mobile-specific toolbar, and menu options.\n\n // Skins\n skin: 'oxide',\n\n // Remove the \"Upgrade\" link for Tiny.\n // https://www.tiny.cloud/docs/tinymce/6/editor-premium-upgrade-promotion/\n promotion: false,\n };\n};\n\n/**\n * Set up TinyMCE for the HTML Element.\n *\n * @param {HTMLElement} target\n * @param {Object} options The editor plugin configuration\n * @return {Promise} The TinyMCE instance\n */\nexport const setupForTarget = async(target, options = {}) => {\n const instance = getInstanceForElement(target);\n if (instance) {\n return Promise.resolve(instance);\n }\n\n const pendingPromise = new Pending('editor_tiny/editor:setupForTarget');\n\n const plugins = getPlugins(options);\n const [tinyMCE, pluginValues] = await Promise.all([\n getTinyMCE(),\n importPluginList(Object.keys(plugins)),\n ]);\n const {pluginNames, pluginConfig} = pluginValues;\n\n const instanceConfig = getStandardConfig(target, tinyMCE, options, pluginNames);\n pluginConfig.forEach((pluginConfig) => {\n if (typeof pluginConfig.configure === 'function') {\n Object.assign(instanceConfig, pluginConfig.configure(instanceConfig));\n }\n });\n const [editor] = await tinyMCE.init(instanceConfig);\n\n // Store the editor instance in the instanceMap and register its removal to remove it.\n instanceMap.set(target, editor);\n editor.on('remove', ({target}) => {\n // Handle removal of the editor from the map on destruction.\n instanceMap.delete(target.targetElm);\n });\n\n // Store the Moodle-specific options in the TinyMCE instance.\n // TODO: See if there is a more appropriate location for this config.\n // TinyMCE does support custom configuration options in its EditorOptions but these must be registered and spec'd.\n editor.moodleOptions = options;\n\n pendingPromise.resolve();\n return editor;\n};\n\nexport const configureDefaultEditor = (options = {}) => {\n defaultOptions = options;\n};\n"],"names":["instanceMap","Map","defaultOptions","importPluginList","async","pluginHandlers","Promise","all","pluginList","map","pluginPath","indexOf","resolve","pluginNames","pluginConfig","Array","isArray","filter","value","entries","elementId","getInstanceForElement","document","getElementById","element","instance","get","removed","remove","_ref","options","target","setupForTarget","lang","querySelector","tinyMCE","langData","language","fetch","M","cfg","wwwroot","langrev","then","response","json","addI18n","initialisePage","getPlugins","plugins","getStandardConfig","content_css","css","convert_urls","a11y_advanced_options","toolbar_mode","toolbar","name","items","menu","skin","promotion","pendingPromise","Pending","pluginValues","Object","keys","instanceConfig","forEach","configure","assign","editor","init","set","on","_ref2","delete","targetElm","moodleOptions"],"mappings":"4iBA+BMA,YAAc,IAAIC,QAMpBC,eAAiB,SAQfC,iBAAmBC,MAAAA,mBACfC,qBAAuBC,QAAQC,IAAIC,WAAWC,KAAIC,aACnB,IAA7BA,WAAWC,QAAQ,KAEZL,QAAQM,QAAQF,4NAGbA,4WAAAA,gBAGZG,YAAcR,eAAeI,KAAKK,cACR,iBAAjBA,aACAA,aAEPC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,cASd,CACHL,YAAAA,YACAC,aATiBT,eAAeI,KAAKK,cACjCC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,mCAYM,IAAM,IAAIjB,IAAID,YAAYmB,4CAQlBC,WAAaC,sBAAsBC,SAASC,eAAeH,kBAQrFC,sBAAwBG,gBAC3BC,SAAWzB,YAAY0B,IAAIF,aAC7BC,WAAYA,SAASE,eAIlBF,SAHHzB,YAAY4B,OAAOJ,0FAcMK,WAACT,UAACA,UAADU,QAAYA,oBACpCC,OAAST,SAASC,eAAeH,kBAChCY,eAAeD,OAAQD,UAGX1B,iBACb6B,KAAOX,SAASY,cAAc,QAAQD,MAErCE,QAASC,gBAAkB9B,QAAQC,IAAI,EAAC,yBA7C5B8B,SA6CwDJ,KA7C3CK,gBAC7BC,EAAEC,IAAIC,6CAAoCF,EAAEC,IAAIE,oBAAWL,WAChEM,MAAKC,UAAYA,SAASC,YAFLR,IAAAA,SA8CnBF,QAAQW,QAAQb,KAAMG,WAE1BW,SAEMC,WAAclB,SACZA,QAAQmB,QACDnB,QAAQmB,QAGf/C,eAAe+C,QACR/C,eAAe+C,QAGnB,GAGLC,kBAAoB,CAACnB,OAAQI,QAASL,QAASmB,WAE1C,CAGHlB,OAAAA,OAIAM,SARSf,SAASY,cAAc,QAAQD,KAYxCkB,YAAa,CACTrB,QAAQsB,KAMZC,cAAc,EAMdC,uBAAuB,EAMvBC,aAAc,UACdC,QAAS,CACL,CACIC,KAAM,UACNC,MAAO,CACH,OACA,SAGR,CACID,KAAM,SACNC,MAAO,CAAC,WAEZ,CACID,KAAM,aACNC,MAAO,CACH,OACA,WAGR,CACID,KAAM,YACNC,MAAO,CACH,YACA,cACA,aACA,iBAGR,CACID,KAAM,cACNC,MAAO,CACH,UACA,WAGR,CACID,KAAM,WACNC,MAAO,CAAC,gBAOhBC,KAAM,GAKNV,QAAS,IACFA,SASPW,KAAM,QAINC,WAAW,IAWN7B,eAAiB5B,eAAM2B,YAAQD,+DAAU,SAC5CL,SAAWJ,sBAAsBU,WACnCN,gBACOnB,QAAQM,QAAQa,gBAGrBqC,eAAiB,IAAIC,iBAAQ,qCAE7Bd,QAAUD,WAAWlB,UACpBK,QAAS6B,oBAAsB1D,QAAQC,IAAI,EAC9C,wBACAJ,iBAAiB8D,OAAOC,KAAKjB,aAE3BpC,YAACA,YAADC,aAAcA,cAAgBkD,aAE9BG,eAAiBjB,kBAAkBnB,OAAQI,EAASL,QAASjB,aACnEC,aAAasD,SAAStD,eACoB,mBAA3BA,aAAauD,WACpBJ,OAAOK,OAAOH,eAAgBrD,aAAauD,UAAUF,0BAGtDI,cAAgBpC,QAAQqC,KAAKL,uBAGpCnE,YAAYyE,IAAI1C,OAAQwC,QACxBA,OAAOG,GAAG,UAAUC,YAAC5C,OAACA,cAElB/B,YAAY4E,OAAO7C,OAAO8C,cAM9BN,OAAOO,cAAgBhD,QAEvBgC,eAAelD,UACR2D,+EAG2B,eAACzC,+DAAU,GAC7C5B,eAAiB4B"}
\ No newline at end of file
+{"version":3,"file":"editor.min.js","sources":["../src/editor.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Utility functions.\n *\n * @module editor_tiny/editor\n * @copyright 2022 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport {\n getTinyMCE,\n baseUrl,\n} from './loader';\nimport Pending from 'core/pending';\n\n/**\n * Storage for the TinyMCE instances on the page.\n * @type {Map}\n */\nconst instanceMap = new Map();\n\n/**\n * The default editor configuration.\n * @type {Object}\n */\nlet defaultOptions = {};\n\n/**\n * Require the modules for the named set of TinyMCE plugins.\n *\n * @param {string[]} pluginList The list of plugins\n * @return {Promise[]} A matching set of Promises relating to the requested plugins\n */\nconst importPluginList = async(pluginList) => {\n const pluginHandlers = await Promise.all(pluginList.map(pluginPath => {\n if (pluginPath.indexOf('/') === -1) {\n // A standard TinyMCE Plugin.\n return Promise.resolve(pluginPath);\n }\n\n return import(pluginPath);\n }));\n\n const pluginNames = pluginHandlers.map((pluginConfig) => {\n if (typeof pluginConfig === 'string') {\n return pluginConfig;\n }\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[0];\n }\n return null;\n }).filter((value) => value);\n\n const pluginConfig = pluginHandlers.map((pluginConfig) => {\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[1];\n }\n return null;\n }).filter((value) => value);\n\n return {\n pluginNames,\n pluginConfig,\n };\n};\n\nconst fetchLanguage = (language) => fetch(\n `${M.cfg.wwwroot}/lib/editor/tiny/lang.php/${M.cfg.langrev}/${language}`\n).then(response => response.json());\n\nexport const getAllInstances = () => new Map(instanceMap.entries());\n\n/**\n * Get the TinyMCE instance for the specified Node ID.\n *\n * @param {string} elementId\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElementId = elementId => getInstanceForElement(document.getElementById(elementId));\n\n/*\n * Get the TinyMCE instance for the specified HTMLElement.\n *\n * @param {HTMLElement} element\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElement = element => {\n const instance = instanceMap.get(element);\n if (instance && instance.removed) {\n instanceMap.remove(element);\n return undefined;\n }\n return instance;\n};\n\n/**\n * Set up TinyMCE for the selector at the specified HTML Node id.\n *\n * @param {object} config The configuration required to setup the editor\n * @param {string} config.elementId The HTML Node ID\n * @param {Object} config.options The editor plugin configuration\n * @return {Promise} The TinyMCE instance\n */\nexport const setupForElementId = ({elementId, options}) => {\n const target = document.getElementById(elementId);\n return setupForTarget(target, options);\n};\n\nconst initialisePage = async() => {\n const lang = document.querySelector('html').lang;\n\n const [tinyMCE, langData] = await Promise.all([getTinyMCE(), fetchLanguage(lang)]);\n tinyMCE.addI18n(lang, langData);\n};\ninitialisePage();\n\nconst getPlugins = (options) => {\n if (options.plugins) {\n return options.plugins;\n }\n\n if (defaultOptions.plugins) {\n return defaultOptions.plugins;\n }\n\n return {};\n};\n\nconst getStandardConfig = (target, tinyMCE, options, plugins) => {\n const lang = document.querySelector('html').lang;\n return {\n base_url: baseUrl,\n\n // Set the editor target.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#target\n target,\n\n // Set the language.\n // https://www.tiny.cloud/docs/tinymce/6/ui-localization/#language\n language: lang,\n\n // Load the editor stylesheet into the editor iframe.\n // https://www.tiny.cloud/docs/tinymce/6/add-css-options/\n content_css: [\n options.css,\n ],\n\n // Do not convert URLs to relative URLs.\n // https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls\n // eslint-disable-next-line camelcase\n convert_urls: false,\n\n // Enabled 'advanced' a11y options.\n // This includes allowing role=\"presentation\" from the image uploader.\n // https://www.tiny.cloud/docs/tinymce/6/accessibility/\n // eslint-disable-next-line camelcase\n a11y_advanced_options: true,\n\n // Toolbar configuration.\n // https://www.tiny.cloud/docs/tinymce/6/toolbar-configuration-options/\n // TODO: Move this configuration to a passed-in option.\n // eslint-disable-next-line camelcase\n toolbar_mode: 'sliding',\n toolbar: [\n {\n name: 'history',\n items: [\n 'undo',\n 'redo'\n ]\n },\n {\n name: 'styles',\n items: ['styles']\n },\n {\n name: 'formatting',\n items: [\n 'bold',\n 'italic'\n ]\n },\n {\n name: 'alignment',\n items: [\n 'alignleft',\n 'aligncenter',\n 'alignright',\n 'alignjustify'\n ]\n },\n {\n name: 'indentation',\n items: [\n 'outdent',\n 'indent'\n ]\n },\n {\n name: 'comments',\n items: ['addcomment']\n },\n ],\n\n // Menu configuration.\n // https://www.tiny.cloud/docs/tinymce/6/menus-configuration-options/\n // TODO: Move this configuration to a passed-in option.\n menu: {\n },\n\n // The list of plugins to include in the instance.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#plugins\n plugins: [\n ...plugins,\n ],\n\n // TODO Add mobile configuration.\n // Mobile configuration.\n // https://www.tiny.cloud/docs/tinymce/6/tinymce-for-mobile/\n // This will include mobile-specific toolbar, and menu options.\n\n // Skins\n skin: 'oxide',\n\n // Remove the \"Upgrade\" link for Tiny.\n // https://www.tiny.cloud/docs/tinymce/6/editor-premium-upgrade-promotion/\n promotion: false,\n };\n};\n\n/**\n * Set up TinyMCE for the HTML Element.\n *\n * @param {HTMLElement} target\n * @param {Object} options The editor plugin configuration\n * @return {Promise} The TinyMCE instance\n */\nexport const setupForTarget = async(target, options = {}) => {\n const instance = getInstanceForElement(target);\n if (instance) {\n return Promise.resolve(instance);\n }\n\n const pendingPromise = new Pending('editor_tiny/editor:setupForTarget');\n\n const plugins = getPlugins(options);\n const [tinyMCE, pluginValues] = await Promise.all([\n getTinyMCE(),\n importPluginList(Object.keys(plugins)),\n ]);\n const {pluginNames, pluginConfig} = pluginValues;\n\n const instanceConfig = getStandardConfig(target, tinyMCE, options, pluginNames);\n pluginConfig.forEach((pluginConfig) => {\n if (typeof pluginConfig.configure === 'function') {\n Object.assign(instanceConfig, pluginConfig.configure(instanceConfig));\n }\n });\n const [editor] = await tinyMCE.init(instanceConfig);\n\n // Store the editor instance in the instanceMap and register its removal to remove it.\n instanceMap.set(target, editor);\n editor.on('remove', ({target}) => {\n // Handle removal of the editor from the map on destruction.\n instanceMap.delete(target.targetElm);\n });\n\n // Store the Moodle-specific options in the TinyMCE instance.\n // TODO: See if there is a more appropriate location for this config.\n // TinyMCE does support custom configuration options in its EditorOptions but these must be registered and spec'd.\n editor.moodleOptions = options;\n\n pendingPromise.resolve();\n return editor;\n};\n\nexport const configureDefaultEditor = (options = {}) => {\n defaultOptions = options;\n};\n"],"names":["instanceMap","Map","defaultOptions","importPluginList","async","pluginHandlers","Promise","all","pluginList","map","pluginPath","indexOf","resolve","pluginNames","pluginConfig","Array","isArray","filter","value","entries","elementId","getInstanceForElement","document","getElementById","element","instance","get","removed","remove","_ref","options","target","setupForTarget","lang","querySelector","tinyMCE","langData","language","fetch","M","cfg","wwwroot","langrev","then","response","json","addI18n","initialisePage","getPlugins","plugins","getStandardConfig","base_url","baseUrl","content_css","css","convert_urls","a11y_advanced_options","toolbar_mode","toolbar","name","items","menu","skin","promotion","pendingPromise","Pending","pluginValues","Object","keys","instanceConfig","forEach","configure","assign","editor","init","set","on","_ref2","delete","targetElm","moodleOptions"],"mappings":"4iBAgCMA,YAAc,IAAIC,QAMpBC,eAAiB,SAQfC,iBAAmBC,MAAAA,mBACfC,qBAAuBC,QAAQC,IAAIC,WAAWC,KAAIC,aACnB,IAA7BA,WAAWC,QAAQ,KAEZL,QAAQM,QAAQF,4NAGbA,4WAAAA,gBAGZG,YAAcR,eAAeI,KAAKK,cACR,iBAAjBA,aACAA,aAEPC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,cASd,CACHL,YAAAA,YACAC,aATiBT,eAAeI,KAAKK,cACjCC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,mCAYM,IAAM,IAAIjB,IAAID,YAAYmB,4CAQlBC,WAAaC,sBAAsBC,SAASC,eAAeH,kBAQrFC,sBAAwBG,gBAC3BC,SAAWzB,YAAY0B,IAAIF,aAC7BC,WAAYA,SAASE,eAIlBF,SAHHzB,YAAY4B,OAAOJ,0FAcMK,WAACT,UAACA,UAADU,QAAYA,oBACpCC,OAAST,SAASC,eAAeH,kBAChCY,eAAeD,OAAQD,UAGX1B,iBACb6B,KAAOX,SAASY,cAAc,QAAQD,MAErCE,QAASC,gBAAkB9B,QAAQC,IAAI,EAAC,yBA7C5B8B,SA6CwDJ,KA7C3CK,gBAC7BC,EAAEC,IAAIC,6CAAoCF,EAAEC,IAAIE,oBAAWL,WAChEM,MAAKC,UAAYA,SAASC,YAFLR,IAAAA,SA8CnBF,QAAQW,QAAQb,KAAMG,WAE1BW,SAEMC,WAAclB,SACZA,QAAQmB,QACDnB,QAAQmB,QAGf/C,eAAe+C,QACR/C,eAAe+C,QAGnB,GAGLC,kBAAoB,CAACnB,OAAQI,QAASL,QAASmB,iBAC3ChB,KAAOX,SAASY,cAAc,QAAQD,WACrC,CACHkB,SAAUC,gBAIVrB,OAAAA,OAIAM,SAAUJ,KAIVoB,YAAa,CACTvB,QAAQwB,KAMZC,cAAc,EAMdC,uBAAuB,EAMvBC,aAAc,UACdC,QAAS,CACL,CACIC,KAAM,UACNC,MAAO,CACH,OACA,SAGR,CACID,KAAM,SACNC,MAAO,CAAC,WAEZ,CACID,KAAM,aACNC,MAAO,CACH,OACA,WAGR,CACID,KAAM,YACNC,MAAO,CACH,YACA,cACA,aACA,iBAGR,CACID,KAAM,cACNC,MAAO,CACH,UACA,WAGR,CACID,KAAM,WACNC,MAAO,CAAC,gBAOhBC,KAAM,GAKNZ,QAAS,IACFA,SASPa,KAAM,QAINC,WAAW,IAWN/B,eAAiB5B,eAAM2B,YAAQD,+DAAU,SAC5CL,SAAWJ,sBAAsBU,WACnCN,gBACOnB,QAAQM,QAAQa,gBAGrBuC,eAAiB,IAAIC,iBAAQ,qCAE7BhB,QAAUD,WAAWlB,UACpBK,QAAS+B,oBAAsB5D,QAAQC,IAAI,EAC9C,wBACAJ,iBAAiBgE,OAAOC,KAAKnB,aAE3BpC,YAACA,YAADC,aAAcA,cAAgBoD,aAE9BG,eAAiBnB,kBAAkBnB,OAAQI,EAASL,QAASjB,aACnEC,aAAawD,SAASxD,eACoB,mBAA3BA,aAAayD,WACpBJ,OAAOK,OAAOH,eAAgBvD,aAAayD,UAAUF,0BAGtDI,cAAgBtC,QAAQuC,KAAKL,uBAGpCrE,YAAY2E,IAAI5C,OAAQ0C,QACxBA,OAAOG,GAAG,UAAUC,YAAC9C,OAACA,cAElB/B,YAAY8E,OAAO/C,OAAOgD,cAM9BN,OAAOO,cAAgBlD,QAEvBkC,eAAepD,UACR6D,+EAG2B,eAAC3C,+DAAU,GAC7C5B,eAAiB4B"}
\ No newline at end of file
diff --git a/lib/editor/tiny/amd/build/loader.min.js b/lib/editor/tiny/amd/build/loader.min.js
index 05073c95565..63ead9d8fe2 100644
--- a/lib/editor/tiny/amd/build/loader.min.js
+++ b/lib/editor/tiny/amd/build/loader.min.js
@@ -1,4 +1,4 @@
-define("editor_tiny/loader",["exports"],(function(_exports){
+define("editor_tiny/loader",["exports","core/config"],(function(_exports,Config){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}
/**
* Tiny Loader for Moodle
*
@@ -6,6 +6,6 @@ define("editor_tiny/loader",["exports"],(function(_exports){
* @copyright 2022 Andrew Lyons
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-let tinyMCEPromise;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.getTinyMCE=void 0;_exports.getTinyMCE=()=>tinyMCEPromise||(tinyMCEPromise=new Promise(((resolve,reject)=>{const head=document.querySelector("head");let script=head.querySelector('script[data-tinymce="tinymce"]');script&&resolve(window.tinyMCE),script=document.createElement("script"),script.dataset.tinymce="tinymce",script.src="".concat(M.cfg.wwwroot,"/lib/editor/tiny/js/tinymce/tinymce.js"),script.async=!0,script.addEventListener("load",(()=>{resolve(window.tinyMCE)}),!1),script.addEventListener("error",(err=>{reject(err)}),!1),head.append(script)})),tinyMCEPromise)}));
+let tinyMCEPromise;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.getTinyMCE=_exports.baseUrl=void 0,Config=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Config);const baseUrl="".concat(Config.wwwroot,"/lib/editor/tiny/loader.php/").concat(M.cfg.jsrev);_exports.baseUrl=baseUrl;_exports.getTinyMCE=()=>tinyMCEPromise||(tinyMCEPromise=new Promise(((resolve,reject)=>{const head=document.querySelector("head");let script=head.querySelector('script[data-tinymce="tinymce"]');script&&resolve(window.tinyMCE),script=document.createElement("script"),script.dataset.tinymce="tinymce",script.src="".concat(baseUrl,"/tinymce.js"),script.async=!0,script.addEventListener("load",(()=>{resolve(window.tinyMCE)}),!1),script.addEventListener("error",(err=>{reject(err)}),!1),head.append(script)})),tinyMCEPromise)}));
//# sourceMappingURL=loader.min.js.map
\ No newline at end of file
diff --git a/lib/editor/tiny/amd/build/loader.min.js.map b/lib/editor/tiny/amd/build/loader.min.js.map
index 79a60c007b6..51abb7d6739 100644
--- a/lib/editor/tiny/amd/build/loader.min.js.map
+++ b/lib/editor/tiny/amd/build/loader.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"loader.min.js","sources":["../src/loader.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Tiny Loader for Moodle\n *\n * @module editor_tiny/loader\n * @copyright 2022 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nlet tinyMCEPromise;\n\n/**\n * Get the TinyMCE API Object.\n *\n * @returns {Promise} The TinyMCE API Object\n */\nexport const getTinyMCE = () => {\n if (tinyMCEPromise) {\n return tinyMCEPromise;\n }\n\n tinyMCEPromise = new Promise((resolve, reject) => {\n const head = document.querySelector('head');\n let script = head.querySelector('script[data-tinymce=\"tinymce\"]');\n if (script) {\n resolve(window.tinyMCE);\n }\n\n script = document.createElement('script');\n script.dataset.tinymce = 'tinymce';\n script.src = `${M.cfg.wwwroot}/lib/editor/tiny/js/tinymce/tinymce.js`;\n script.async = true;\n\n script.addEventListener('load', () => {\n resolve(window.tinyMCE);\n }, false);\n\n script.addEventListener('error', (err) => {\n reject(err);\n }, false);\n\n head.append(script);\n });\n\n return tinyMCEPromise;\n};\n"],"names":["tinyMCEPromise","Promise","resolve","reject","head","document","querySelector","script","window","tinyMCE","createElement","dataset","tinymce","src","M","cfg","wwwroot","async","addEventListener","err","append"],"mappings":";;;;;;;;IAuBIA,sHAOsB,IAClBA,iBAIJA,eAAiB,IAAIC,SAAQ,CAACC,QAASC,gBAC7BC,KAAOC,SAASC,cAAc,YAChCC,OAASH,KAAKE,cAAc,kCAC5BC,QACAL,QAAQM,OAAOC,SAGnBF,OAASF,SAASK,cAAc,UAChCH,OAAOI,QAAQC,QAAU,UACzBL,OAAOM,cAASC,EAAEC,IAAIC,kDACtBT,OAAOU,OAAQ,EAEfV,OAAOW,iBAAiB,QAAQ,KAC5BhB,QAAQM,OAAOC,YAChB,GAEHF,OAAOW,iBAAiB,SAAUC,MAC9BhB,OAAOgB,QACR,GAEHf,KAAKgB,OAAOb,WAGTP"}
\ No newline at end of file
+{"version":3,"file":"loader.min.js","sources":["../src/loader.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Tiny Loader for Moodle\n *\n * @module editor_tiny/loader\n * @copyright 2022 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nlet tinyMCEPromise;\n\nimport * as Config from 'core/config';\n\nexport const baseUrl = `${Config.wwwroot}/lib/editor/tiny/loader.php/${M.cfg.jsrev}`;\n\n/**\n * Get the TinyMCE API Object.\n *\n * @returns {Promise} The TinyMCE API Object\n */\nexport const getTinyMCE = () => {\n if (tinyMCEPromise) {\n return tinyMCEPromise;\n }\n\n tinyMCEPromise = new Promise((resolve, reject) => {\n const head = document.querySelector('head');\n let script = head.querySelector('script[data-tinymce=\"tinymce\"]');\n if (script) {\n resolve(window.tinyMCE);\n }\n\n script = document.createElement('script');\n script.dataset.tinymce = 'tinymce';\n script.src = `${baseUrl}/tinymce.js`;\n script.async = true;\n\n script.addEventListener('load', () => {\n resolve(window.tinyMCE);\n }, false);\n\n script.addEventListener('error', (err) => {\n reject(err);\n }, false);\n\n head.append(script);\n });\n\n return tinyMCEPromise;\n};\n"],"names":["tinyMCEPromise","baseUrl","Config","wwwroot","M","cfg","jsrev","Promise","resolve","reject","head","document","querySelector","script","window","tinyMCE","createElement","dataset","tinymce","src","async","addEventListener","err","append"],"mappings":";;;;;;;;IAuBIA,qxBAISC,kBAAaC,OAAOC,+CAAsCC,EAAEC,IAAIC,oDAOnD,IAClBN,iBAIJA,eAAiB,IAAIO,SAAQ,CAACC,QAASC,gBAC7BC,KAAOC,SAASC,cAAc,YAChCC,OAASH,KAAKE,cAAc,kCAC5BC,QACAL,QAAQM,OAAOC,SAGnBF,OAASF,SAASK,cAAc,UAChCH,OAAOI,QAAQC,QAAU,UACzBL,OAAOM,cAASlB,uBAChBY,OAAOO,OAAQ,EAEfP,OAAOQ,iBAAiB,QAAQ,KAC5Bb,QAAQM,OAAOC,YAChB,GAEHF,OAAOQ,iBAAiB,SAAUC,MAC9Bb,OAAOa,QACR,GAEHZ,KAAKa,OAAOV,WAGTb"}
\ No newline at end of file
diff --git a/lib/editor/tiny/amd/src/editor.js b/lib/editor/tiny/amd/src/editor.js
index 65044bd23d4..cce0fbf9bb7 100644
--- a/lib/editor/tiny/amd/src/editor.js
+++ b/lib/editor/tiny/amd/src/editor.js
@@ -22,6 +22,7 @@
*/
import {
getTinyMCE,
+ baseUrl,
} from './loader';
import Pending from 'core/pending';
@@ -141,6 +142,8 @@ const getPlugins = (options) => {
const getStandardConfig = (target, tinyMCE, options, plugins) => {
const lang = document.querySelector('html').lang;
return {
+ base_url: baseUrl,
+
// Set the editor target.
// https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#target
target,
diff --git a/lib/editor/tiny/amd/src/loader.js b/lib/editor/tiny/amd/src/loader.js
index d72e7c6bf55..29f456137aa 100644
--- a/lib/editor/tiny/amd/src/loader.js
+++ b/lib/editor/tiny/amd/src/loader.js
@@ -23,6 +23,10 @@
let tinyMCEPromise;
+import * as Config from 'core/config';
+
+export const baseUrl = `${Config.wwwroot}/lib/editor/tiny/loader.php/${M.cfg.jsrev}`;
+
/**
* Get the TinyMCE API Object.
*
@@ -42,7 +46,7 @@ export const getTinyMCE = () => {
script = document.createElement('script');
script.dataset.tinymce = 'tinymce';
- script.src = `${M.cfg.wwwroot}/lib/editor/tiny/js/tinymce/tinymce.js`;
+ script.src = `${baseUrl}/tinymce.js`;
script.async = true;
script.addEventListener('load', () => {
diff --git a/lib/editor/tiny/loader.php b/lib/editor/tiny/loader.php
new file mode 100644
index 00000000000..5bcf26a5294
--- /dev/null
+++ b/lib/editor/tiny/loader.php
@@ -0,0 +1,331 @@
+.
+
+/**
+ * Tiny text editor integration - TinyMCE Loader.
+ *
+ * @package editor_tiny
+ * @copyright 2022 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace editor_tiny;
+
+// Disable moodle specific debug messages and any errors in output.
+define('NO_DEBUG_DISPLAY', true);
+
+// We need just the values from config.php and minlib.php.
+define('ABORT_AFTER_CONFIG', true);
+
+// This stops immediately at the beginning of lib/setup.php.
+require('../../../config.php');
+
+/**
+ * An anonymous class to handle loading and serving TinyMCE JavaScript.
+ *
+ * @copyright 2021 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class loader {
+ /** @var string The filepath requested */
+ protected $filepath;
+
+ /** @var int The revision requested */
+ protected $rev;
+
+ /** @var string The mimetype to send */
+ protected $mimetype = null;
+
+ /**
+ * Initialise the class, parse the request and serve the content.
+ */
+ public function __construct() {
+ $this->parse_file_information_from_url();
+ $this->serve_file();
+ }
+
+ /**
+ * Parse the file information from the URL.
+ */
+ protected function parse_file_information_from_url(): void {
+ global $CFG;
+
+ // The URL format is /[revision]/[filepath].
+ // The revision is an integer with negative values meaning the file is not cached.
+ // The filepath is a child of the TinyMCE js/tinymce directory containing all upstream code.
+ // The filepath is cleaned using the SAFEPATH option, which does not allow directory traversal.
+ if ($slashargument = min_get_slash_argument()) {
+ $slashargument = ltrim($slashargument, '/');
+ if (substr_count($slashargument, '/') < 1) {
+ $this->send_not_found();
+ }
+
+ [$rev, $filepath] = explode('/', $slashargument, 2);
+ $this->rev = min_clean_param($rev, 'RAW');
+ $this->filepath = min_clean_param($filepath, 'SAFEPATH');
+ } else {
+ $this->rev = min_optional_param('rev', 0, 'RAW');
+ $this->filepath = min_optional_param('filepath', 'standard', 'SAFEPATH');
+ }
+
+ $extension = pathinfo($this->filepath, PATHINFO_EXTENSION);
+ if ($extension === 'css') {
+ $this->mimetype = 'text/css';
+ } else if ($extension === 'js') {
+ $this->mimetype = 'application/javascript';
+ } else if ($extension === 'map') {
+ $this->mimetype = 'application/json';
+ } else {
+ $this->send_not_found();
+ }
+
+ $filepathhash = sha1("{$this->filepath}");
+ if (preg_match('/^plugins\/tiny_/', $this->filepath)) {
+ $parts = explode('/', $this->filepath);
+ array_shift($parts);
+ $component = array_shift($parts);
+ $this->component = preg_replace('/^tiny_/', '', $component);
+ $this->filepath = implode('/', $parts);
+ }
+ $this->candidatefile = "{$CFG->localcachedir}/editor_tiny/{$this->rev}/{$filepathhash}";
+ }
+
+ /**
+ * Serve the requested file from the most appropriate location, caching if possible.
+ */
+ public function serve_file(): void {
+ // Attempt to send the cached filepathpack.
+ if ($this->rev > 0) {
+ if ($this->is_candidate_file_available()) {
+ // The send_cached_file_if_available function will exit if successful.
+ // In theory the file could become unavailable after checking that the file exists.
+ // Whilst this is unlikely, fall back to caching the content below.
+ $this->send_cached_file_if_available();
+ }
+
+ // The file isn't cached yet.
+ // Store it in the cache and serve it.
+ $this->store_filepath_file();
+ $this->send_cached();
+ } else {
+ // If the revision is less than 0, then do not cache anything.
+ // Moodle is configured to not cache javascript or css.
+ $this->send_uncached_from_dirroot();
+ }
+ }
+
+ /**
+ * Get the full filepath to the requested file.
+ *
+ * @return string
+ */
+ protected function get_filepath_from_dirroot(): ?string {
+ global $CFG;
+
+ $rootdir = "{$CFG->dirroot}/lib/editor/tiny";
+ if ($this->component) {
+ $rootdir .= "/plugins/{$this->component}/js";
+ } else {
+ $rootdir .= "/js/tinymce";
+ }
+
+ $filepath = "{$rootdir}/{$this->filepath}";
+ if (file_exists($filepath)) {
+ return $filepath;
+ }
+
+ return null;
+ }
+
+ /**
+ * Load the file content from the dirroot.
+ *
+ * @return string
+ */
+ protected function load_content_from_dirroot(): ?string {
+ if ($filepath = $this->get_filepath_from_dirroot()) {
+ return file_get_contents($filepath);
+ }
+
+ return null;
+ }
+
+ /**
+ * Send the file content from the dirroot.
+ *
+ * If the file is not found, send the 404 response instead.
+ */
+ protected function send_uncached_from_dirroot(): void {
+ if ($filepath = $this->get_filepath_from_dirroot()) {
+ $this->send_uncached_file($filepath);
+ }
+
+ $this->send_not_found();
+ }
+
+ /**
+ * Check whether the candidate file exists.
+ *
+ * @return bool
+ */
+ protected function is_candidate_file_available(): bool {
+ return file_exists($this->candidatefile);
+ }
+
+ /**
+ * Send the candidate file.
+ */
+ protected function send_cached_file_if_available(): void {
+ global $_SERVER;
+
+ if (file_exists($this->candidatefile)) {
+ // The candidate file exists so will be sent regardless.
+
+ if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) || !empty($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
+ // The browser sent headers to check if the file has changed.
+ // We do not actually need to verify the eTag value or compare modification headers because our files
+ // never change in cache. When changes are made we increment the revision counter.
+ $this->send_unmodified_headers(filemtime($this->candidatefile));
+ }
+
+ // No modification headers were sent so simply serve the file from cache.
+ $this->send_cached($this->candidatefile);
+ }
+ }
+
+ /**
+ * Store the file content in the candidate file.
+ */
+ protected function store_filepath_file(): void {
+ global $CFG;
+
+ clearstatcache();
+ if (!file_exists(dirname($this->candidatefile))) {
+ @mkdir(dirname($this->candidatefile), $CFG->directorypermissions, true);
+ }
+
+ // Prevent serving of incomplete file from concurrent request,
+ // the rename() should be more atomic than fwrite().
+ ignore_user_abort(true);
+
+ $filename = $this->candidatefile;
+ if ($fp = fopen($filename . '.tmp', 'xb')) {
+ $content = $this->load_content_from_dirroot();
+ fwrite($fp, $content);
+ fclose($fp);
+ rename($filename . '.tmp', $filename);
+ @chmod($filename, $CFG->filepermissions);
+ @unlink($filename . '.tmp'); // Just in case anything fails.
+ }
+
+ ignore_user_abort(false);
+ if (connection_aborted()) {
+ die;
+ }
+ }
+
+ /**
+ * Get the eTag for the candidate file.
+ *
+ * This is a unique hash based on the file arguments.
+ * It does not need to consider the file content because we use a cache busting URL.
+ *
+ * @return string The eTag content
+ */
+ protected function get_etag(): string {
+ $etag = [
+ $this->filepath,
+ $this->rev,
+ ];
+
+ return sha1(implode('/', $etag));
+ }
+
+ /**
+ * Send the candidate file, with aggressive cachign headers.
+ *
+ * This includdes eTags, a last-modified, and expiry approximately 90 days in the future.
+ */
+ protected function send_cached(): void {
+ $path = $this->candidatefile;
+
+ // 90 days only - based on Moodle point release cadence being every 3 months.
+ $lifetime = 60 * 60 * 24 * 90;
+
+ header('Etag: "' . $this->get_etag() . '"');
+ header('Content-Disposition: inline; filename="filepath.php"');
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT');
+ header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
+ header('Pragma: ');
+ header('Cache-Control: public, max-age=' . $lifetime . ', immutable');
+ header('Accept-Ranges: none');
+ header("Content-Type: {$this->mimetype}; charset=utf-8");
+ if (!min_enable_zlib_compression()) {
+ header('Content-Length: ' . filesize($path));
+ }
+
+ readfile($path);
+ die;
+ }
+
+ /**
+ * Sends the content directly without caching it.
+ *
+ * No aggressive caching is used, and the expiry is set to the current time.
+ *
+ * @param string $filepath
+ */
+ protected function send_uncached_file(string $filepath): void {
+ header('Content-Disposition: inline; filename="styles_debug.php"');
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
+ header('Expires: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
+ header('Pragma: ');
+ header('Accept-Ranges: none');
+ header("Content-Type: {$this->mimetype}; charset=utf-8");
+
+ readfile($filepath);
+ die;
+ }
+
+ /**
+ * Send headers to indicate that the file has not been modified at all
+ *
+ * @param int $lastmodified
+ */
+ protected function send_unmodified_headers(int $lastmodified): void {
+ // 90 days only - based on Moodle point release cadence being every 3 months.
+ $lifetime = 60 * 60 * 24 * 90;
+ header('HTTP/1.1 304 Not Modified');
+ header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
+ header('Cache-Control: public, max-age=' . $lifetime);
+ header("Content-Type: {$this->mimetype}; charset=utf-8");
+ header('Etag: "' . $this->get_etag() . '"');
+ if ($lastmodified) {
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastmodified) . ' GMT');
+ }
+ die;
+ }
+
+ /**
+ * Sends a 404 message to indicate that the content was not found.
+ */
+ protected function send_not_found(): void {
+ header('HTTP/1.0 404 not found');
+ die('TinyMCE file was not found, sorry.');
+ }
+}
+
+new loader();