From 90c40fba5d8df7f325d03c1404a50381dd2a4f0e Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Thu, 21 Jul 2022 15:09:52 +0800 Subject: [PATCH] MDL-75271 editor_tiny: Add a cache-busting loader for TinyMCE Part of MDL-75966 This commit adds a cache-busting loader API for use in the TinyMCE plugin. This is not for use in any TinyMCE subplugins at this time as we have no use-case outside of AMD modules. This loader ensures that only files within the js/tiny directory are loaded, and it only supports either .js or .css files at this time. The client-side of the loader makes use of the jsrevision as a cache-buster, including for CSS files included with TinyMCE. If the revision is negative, then files are not cached. If the revision is positive, then the requested file is cached in a candidate file and served using aggressive cache headers. --- lib/editor/tiny/amd/build/editor.min.js | 2 +- lib/editor/tiny/amd/build/editor.min.js.map | 2 +- lib/editor/tiny/amd/build/loader.min.js | 4 +- lib/editor/tiny/amd/build/loader.min.js.map | 2 +- lib/editor/tiny/amd/src/editor.js | 3 + lib/editor/tiny/amd/src/loader.js | 6 +- lib/editor/tiny/loader.php | 331 ++++++++++++++++++++ 7 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 lib/editor/tiny/loader.php 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();