MDL-75078 editor_tiny: Improve configuration specification

Part of MDL-75966
This commit is contained in:
Andrew Nicols 2022-08-09 15:02:15 +08:00
parent 05000e3e8b
commit 603c50e1be
16 changed files with 527 additions and 114 deletions

View File

@ -0,0 +1,11 @@
define("editor_tiny/defaults",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.getDefaultToolbar=_exports.getDefaultQuickbarsSelectionToolbar=_exports.getDefaultQuickbarsInsertToolbar=_exports.getDefaultQuickbarsImageToolbar=_exports.getDefaultMenu=_exports.getDefaultConfiguration=void 0;
/**
* TinyMCE Editor Upstream defaults.
*
* @module editor_tiny/editor
* @copyright 2022 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
const getDefaultMenu=()=>({file:{title:"File",items:"newdocument restoredraft | preview | export print | deleteallconversations"},edit:{title:"Edit",items:"undo redo | cut copy paste pastetext | selectall | searchreplace"},view:{title:"View",items:"code | visualaid visualchars visualblocks | spellchecker | preview fullscreen | showcomments"},insert:{title:"Insert",items:"image link media addcomment pageembed template codesample inserttable | charmap emoticons hr | pagebreak nonbreaking anchor tableofcontents | insertdatetime"},format:{title:"Format",items:"bold italic underline strikethrough superscript subscript codeformat | styles blocks fontfamily fontsize align lineheight | forecolor backcolor | language | removeformat"},tools:{title:"Tools",items:"spellchecker spellcheckerlanguage | a11ycheck code wordcount"},table:{title:"Table",items:"inserttable | cell row column | advtablesort | tableprops deletetable"},help:{title:"Help",items:"help"}});_exports.getDefaultMenu=getDefaultMenu;const getDefaultToolbar=()=>[{name:"content",items:[]},{name:"styles",items:["styles"]},{name:"formatting",items:["bold","italic"]},{name:"history",items:["undo","redo"]},{name:"alignment",items:["alignleft","aligncenter","alignright","alignjustify"]},{name:"indentation",items:["outdent","indent"]},{name:"comments",items:["addcomment"]}];_exports.getDefaultToolbar=getDefaultToolbar;const getDefaultQuickbarsSelectionToolbar=()=>"bold italic | quicklink h2 h3 blockquote";_exports.getDefaultQuickbarsSelectionToolbar=getDefaultQuickbarsSelectionToolbar;const getDefaultQuickbarsInsertToolbar=()=>"quickimage quicktable";_exports.getDefaultQuickbarsInsertToolbar=getDefaultQuickbarsInsertToolbar;const getDefaultQuickbarsImageToolbar=()=>"alignleft aligncenter alignright";_exports.getDefaultQuickbarsImageToolbar=getDefaultQuickbarsImageToolbar;_exports.getDefaultConfiguration=()=>({toolbar_mode:"sliding",toolbar:[{name:"content",items:[]},{name:"styles",items:["styles"]},{name:"formatting",items:["bold","italic"]},{name:"history",items:["undo","redo"]},{name:"alignment",items:["alignleft","aligncenter","alignright","alignjustify"]},{name:"indentation",items:["outdent","indent"]},{name:"comments",items:["addcomment"]}],quickbars_selection_toolbar:"bold italic | quicklink h2 h3 blockquote",quickbars_insert_toolbar:"quickimage quicktable",quickbars_image_toolbar:"alignleft aligncenter alignright",menu:{file:{title:"File",items:"newdocument restoredraft | preview | export print | deleteallconversations"},edit:{title:"Edit",items:"undo redo | cut copy paste pastetext | selectall | searchreplace"},view:{title:"View",items:"code | visualaid visualchars visualblocks | spellchecker | preview fullscreen | showcomments"},insert:{title:"Insert",items:"image link media addcomment pageembed template codesample inserttable | charmap emoticons hr | pagebreak nonbreaking anchor tableofcontents | insertdatetime"},format:{title:"Format",items:"bold italic underline strikethrough superscript subscript codeformat | styles blocks fontfamily fontsize align lineheight | forecolor backcolor | language | removeformat"},tools:{title:"Tools",items:"spellchecker spellcheckerlanguage | a11ycheck code wordcount"},table:{title:"Table",items:"inserttable | cell row column | advtablesort | tableprops deletetable"},help:{title:"Help",items:"help"}},skin:"oxide"})}));
//# sourceMappingURL=defaults.min.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
define("editor_tiny/options",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.register=_exports.getPluginOptionName=_exports.getMoodleLang=_exports.getInitialPluginConfiguration=_exports.getFilepickers=_exports.getFilePicker=_exports.getDraftItemId=_exports.getCurrentLanguage=_exports.getContextId=void 0;_exports.register=(editor,options)=>{const registerOption=editor.options.register,setOption=editor.options.set;registerOption("moodle:contextid",{processor:"number",default:0}),setOption("moodle:contextid",options.context),registerOption("moodle:filepickers",{processor:"object",default:{}}),setOption("moodle:filepickers",options.filepicker),registerOption("moodle:draftitemid",{processor:"number",default:0}),setOption("moodle:draftitemid",options.draftitemid),registerOption("moodle:currentLanguage",{processor:"string",default:"en"}),setOption("moodle:currentLanguage",options.currentLanguage),registerOption("moodle:language",{processor:"object",default:{}}),setOption("moodle:language",options.language)};_exports.getContextId=editor=>editor.options.get("moodle:contextid");_exports.getDraftItemId=editor=>editor.options.get("moodle:draftitemid");const getFilepickers=editor=>editor.options.get("moodle:filepickers");_exports.getFilepickers=getFilepickers;_exports.getFilePicker=(editor,type)=>getFilepickers(editor)[type];_exports.getMoodleLang=editor=>editor.options.get("moodle:language");_exports.getCurrentLanguage=editor=>editor.options.get("moodle:currentLanguage");_exports.getInitialPluginConfiguration=options=>{const config={};return Object.entries(options.plugins).forEach((_ref=>{var _pluginConfig$config;let[pluginName,pluginConfig]=_ref;Object.entries(null!==(_pluginConfig$config=pluginConfig.config)&&void 0!==_pluginConfig$config?_pluginConfig$config:{}).forEach((_ref2=>{let[optionName,value]=_ref2;config[getPluginOptionName(pluginName,optionName)]=value}))})),config};const getPluginOptionName=(pluginName,optionName)=>"".concat(pluginName,":").concat(optionName);_exports.getPluginOptionName=getPluginOptionName}));
//# sourceMappingURL=options.min.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"options.min.js","sources":["../src/options.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 <http://www.gnu.org/licenses/>.\n\n/**\n * Option helper for TinyMCE Editor Manager.\n *\n * @module editor_tiny/options\n * @copyright 2022 Andrew Lyons <andrew@nicols.co.uk>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst optionContextId = 'moodle:contextid';\nconst optionDraftItemId = 'moodle:draftitemid';\nconst filePickers = 'moodle:filepickers';\nconst optionsMoodleLang = 'moodle:language';\nconst currentLanguage = 'moodle:currentLanguage';\n\nexport const register = (editor, options) => {\n const registerOption = editor.options.register;\n const setOption = editor.options.set;\n\n registerOption(optionContextId, {\n processor: 'number',\n \"default\": 0,\n });\n setOption(optionContextId, options.context);\n\n registerOption(filePickers, {\n processor: 'object',\n \"default\": {},\n });\n setOption(filePickers, options.filepicker);\n\n registerOption(optionDraftItemId, {\n processor: 'number',\n \"default\": 0,\n });\n setOption(optionDraftItemId, options.draftitemid);\n\n registerOption(currentLanguage, {\n processor: 'string',\n \"default\": 'en',\n });\n setOption(currentLanguage, options.currentLanguage);\n\n // This is primarily used by the media plugin, but it may be re-used elsewhere so is included here as it is large.\n registerOption(optionsMoodleLang, {\n processor: 'object',\n \"default\": {},\n });\n setOption(optionsMoodleLang, options.language);\n};\n\nexport const getContextId = (editor) => editor.options.get(optionContextId);\nexport const getDraftItemId = (editor) => editor.options.get(optionDraftItemId);\nexport const getFilepickers = (editor) => editor.options.get(filePickers);\nexport const getFilePicker = (editor, type) => getFilepickers(editor)[type];\nexport const getMoodleLang = (editor) => editor.options.get(optionsMoodleLang);\nexport const getCurrentLanguage = (editor) => editor.options.get(currentLanguage);\n\n/**\n * Get a set of namespaced options for all defined plugins.\n *\n * @param {object} options\n * @returns {object}\n */\nexport const getInitialPluginConfiguration = (options) => {\n const config = {};\n\n Object.entries(options.plugins).forEach(([pluginName, pluginConfig]) => {\n const values = Object.entries(pluginConfig.config ?? {});\n values.forEach(([optionName, value]) => {\n config[getPluginOptionName(pluginName, optionName)] = value;\n });\n });\n\n return config;\n};\n\n/**\n * Get the namespaced option name for a plugin.\n *\n * @param {string} pluginName\n * @param {string} optionName\n * @returns {string}\n */\nexport const getPluginOptionName = (pluginName, optionName) => `${pluginName}:${optionName}`;\n"],"names":["editor","options","registerOption","register","setOption","set","processor","context","filepicker","draftitemid","currentLanguage","language","get","getFilepickers","type","config","Object","entries","plugins","forEach","_ref","pluginName","pluginConfig","_ref2","optionName","value","getPluginOptionName"],"mappings":"oXA6BwB,CAACA,OAAQC,iBACvBC,eAAiBF,OAAOC,QAAQE,SAChCC,UAAYJ,OAAOC,QAAQI,IAEjCH,eAVoB,mBAUY,CAC5BI,UAAW,iBACA,IAEfF,UAdoB,mBAcOH,QAAQM,SAEnCL,eAdgB,qBAcY,CACxBI,UAAW,iBACA,KAEfF,UAlBgB,qBAkBOH,QAAQO,YAE/BN,eArBsB,qBAqBY,CAC9BI,UAAW,iBACA,IAEfF,UAzBsB,qBAyBOH,QAAQQ,aAErCP,eAxBoB,yBAwBY,CAC5BI,UAAW,iBACA,OAEfF,UA5BoB,yBA4BOH,QAAQS,iBAGnCR,eAhCsB,kBAgCY,CAC9BI,UAAW,iBACA,KAEfF,UApCsB,kBAoCOH,QAAQU,iCAGZX,QAAWA,OAAOC,QAAQW,IA1C/B,4CA2COZ,QAAWA,OAAOC,QAAQW,IA1C/B,4BA2CbC,eAAkBb,QAAWA,OAAOC,QAAQW,IA1CrC,oFA2CS,CAACZ,OAAQc,OAASD,eAAeb,QAAQc,6BACxCd,QAAWA,OAAOC,QAAQW,IA3C9B,+CA4CSZ,QAAWA,OAAOC,QAAQW,IA3CrC,iEAmDsBX,gBACpCc,OAAS,UAEfC,OAAOC,QAAQhB,QAAQiB,SAASC,SAAQC,oCAAEC,WAAYC,mBACnCN,OAAOC,qCAAQK,aAAaP,4DAAU,IAC9CI,SAAQI,YAAEC,WAAYC,aACzBV,OAAOW,oBAAoBL,WAAYG,aAAeC,YAIvDV,cAUEW,oBAAsB,CAACL,WAAYG,uBAAkBH,uBAAcG"}

View File

@ -1,3 +1,3 @@
define("editor_tiny/utils",["exports","core/templates"],(function(_exports,_templates){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.getPluginConfiguration=_exports.getImagePath=_exports.getButtonImage=_exports.displayFilepicker=void 0;const getImagePath=function(identifier){let component=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"editor_tiny";return Promise.resolve(M.util.image_url(identifier,component))};_exports.getImagePath=getImagePath;_exports.getButtonImage=async function(identifier){let component=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"editor_tiny";return(0,_templates.renderForPromise)("editor_tiny/toolbar_button",{image:await getImagePath(identifier,component)})};_exports.getPluginConfiguration=(editor,plugin)=>{var _editor$moodleOptions;const config=null===(_editor$moodleOptions=editor.moodleOptions.plugins["tiny_".concat(plugin,"/plugin")])||void 0===_editor$moodleOptions?void 0:_editor$moodleOptions.config;return config||{}};_exports.displayFilepicker=(editor,filetype)=>new Promise(((resolve,reject)=>{if(editor.moodleOptions.filepicker[filetype]){const options={...editor.moodleOptions.filepicker[filetype],formcallback:resolve};M.core_filepicker.show(Y,options)}else reject("Unknown filetype ".concat(filetype))}))}));
define("editor_tiny/utils",["exports","core/templates","./options"],(function(_exports,_templates,_options){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.getImagePath=_exports.getButtonImage=_exports.displayFilepicker=_exports.addToolbarButton=_exports.addMenubarItem=_exports.addContextmenuItem=void 0;const getImagePath=function(identifier){let component=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"editor_tiny";return Promise.resolve(M.util.image_url(identifier,component))};_exports.getImagePath=getImagePath;_exports.getButtonImage=async function(identifier){let component=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"editor_tiny";return(0,_templates.renderForPromise)("editor_tiny/toolbar_button",{image:await getImagePath(identifier,component)})};_exports.displayFilepicker=(editor,filetype)=>new Promise(((resolve,reject)=>{const configuration=(0,_options.getFilePicker)(editor,filetype);if(configuration){const options={...configuration,formcallback:resolve};M.core_filepicker.show(Y,options)}else reject("Unknown filetype ".concat(filetype))}));_exports.addToolbarButton=(toolbar,section,button)=>{if(!toolbar)return[{name:section,items:[button]}];return JSON.parse(JSON.stringify(toolbar)).map((item=>(item.name===section&&item.items.push(button),item)))};_exports.addMenubarItem=(menubar,section,menuitem)=>{if(!menubar){({})[section]={title:section,items:menuitem}}const mutatedMenubar=JSON.parse(JSON.stringify(menubar));return Array.from(Object.entries(mutatedMenubar)).forEach((_ref=>{let[name,menu]=_ref;name===section&&(menu.items="".concat(menu.items," ").concat(menuitem))})),mutatedMenubar};_exports.addContextmenuItem=function(contextmenu){const contextmenuItems=(null!=contextmenu?contextmenu:"").split(" ");for(var _len=arguments.length,menuitems=new Array(_len>1?_len-1:0),_key=1;_key<_len;_key++)menuitems[_key-1]=arguments[_key];return contextmenuItems.concat(menuitems).filter((item=>""!==item)).join(" ")}}));
//# sourceMappingURL=utils.min.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,177 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/* eslint-disable max-len, */
/**
* TinyMCE Editor Upstream defaults.
*
* @module editor_tiny/editor
* @copyright 2022 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* The upstream defaults for the TinyMCE Menu.
*
* This value is defined in the TinyMCE documentation, but not exported anywhere useful.
* https://www.tiny.cloud/docs/tinymce/6/menus-configuration-options/#menu
*
* @returns {Object}
*/
export const getDefaultMenu = () => {
return {
file: {title: 'File', items: 'newdocument restoredraft | preview | export print | deleteallconversations'},
edit: {title: 'Edit', items: 'undo redo | cut copy paste pastetext | selectall | searchreplace'},
view: {title: 'View', items: 'code | visualaid visualchars visualblocks | spellchecker | preview fullscreen | showcomments'},
insert: {title: 'Insert', items: 'image link media addcomment pageembed template codesample inserttable | charmap emoticons hr | pagebreak nonbreaking anchor tableofcontents | insertdatetime'},
format: {title: 'Format', items: 'bold italic underline strikethrough superscript subscript codeformat | styles blocks fontfamily fontsize align lineheight | forecolor backcolor | language | removeformat'},
tools: {title: 'Tools', items: 'spellchecker spellcheckerlanguage | a11ycheck code wordcount'},
table: {title: 'Table', items: 'inserttable | cell row column | advtablesort | tableprops deletetable'},
help: {title: 'Help', items: 'help'}
};
};
/**
* The default toolbar configuration to use.
*
* This is based upon the default value used if no toolbar is specified.
*
* https://www.tiny.cloud/docs/tinymce/6/menus-configuration-options/#menu
*
* @returns {Object}
*/
export const getDefaultToolbar = () => {
return [
{
name: 'content',
items: [],
},
{
name: 'styles',
items: ['styles']
},
{
name: 'formatting',
items: [
'bold',
'italic'
]
},
{
name: 'history',
items: [
'undo',
'redo'
]
},
{
name: 'alignment',
items: [
'alignleft',
'aligncenter',
'alignright',
'alignjustify'
]
},
{
name: 'indentation',
items: [
'outdent',
'indent'
]
},
{
name: 'comments',
items: ['addcomment']
},
];
};
/**
* The default quickbars_insert_toolbar configuration to use.
*
* This is based upon the default value used if no toolbar is specified.
*
* https://www.tiny.cloud/docs/tinymce/6/quickbars/#quickbars_selection_toolbar
*
* @returns {string}
*/
export const getDefaultQuickbarsSelectionToolbar = () => 'bold italic | quicklink h2 h3 blockquote';
/**
* The default quickbars_insert_toolbar configuration to use.
*
* This is based upon the default value used if no toolbar is specified.
*
* https://www.tiny.cloud/docs/tinymce/6/quickbars/#quickbars_insert_toolbar
*
* @returns {string}
*/
export const getDefaultQuickbarsInsertToolbar = () => 'quickimage quicktable';
/**
* The default quickbars_insert_toolbar configuration to use.
*
* This is based upon the default value used if no toolbar is specified.
*
* https://www.tiny.cloud/docs/tinymce/6/quickbars/#quickbars_image_toolbar
*
* @returns {string}
*/
export const getDefaultQuickbarsImageToolbar = () => 'alignleft aligncenter alignright';
/**
* Get the default configuration provided by TinyMCE.
*
* @returns {object}
*/
export const getDefaultConfiguration = () => ({
// Toolbar configuration.
// https://www.tiny.cloud/docs/tinymce/6/toolbar-configuration-options/
// TODO: Move this configuration to a passed-in option.
// eslint-disable-next-line camelcase
toolbar_mode: 'sliding',
toolbar: getDefaultToolbar(),
// Quickbars Selection Toolbar configuration.
// https://www.tiny.cloud/docs/tinymce/6/quickbars/#quickbars_selection_toolbar
// eslint-disable-next-line camelcase
quickbars_selection_toolbar: getDefaultQuickbarsSelectionToolbar(),
// Quickbars Select Toolbar configuration.
// https://www.tiny.cloud/docs/tinymce/6/quickbars/#quickbars_insert_toolbar
// eslint-disable-next-line camelcase
quickbars_insert_toolbar: getDefaultQuickbarsInsertToolbar(),
// Quickbars Image Toolbar configuration.
// https://www.tiny.cloud/docs/tinymce/6/quickbars/#quickbars_image_toolbar
// eslint-disable-next-line camelcase
quickbars_image_toolbar: getDefaultQuickbarsImageToolbar(),
// Menu configuration.
// https://www.tiny.cloud/docs/tinymce/6/menus-configuration-options/
// TODO: Move this configuration to a passed-in option.
menu: getDefaultMenu(),
// TODO Add mobile configuration.
// Mobile configuration.
// https://www.tiny.cloud/docs/tinymce/6/tinymce-for-mobile/
// This will include mobile-specific toolbar, and menu options.
// Skins
skin: 'oxide',
});

View File

@ -14,17 +14,17 @@
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Utility functions.
* TinyMCE Editor Manager.
*
* @module editor_tiny/editor
* @copyright 2022 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {
getTinyMCE,
baseUrl,
} from './loader';
import Pending from 'core/pending';
import {getDefaultConfiguration} from './defaults';
import {getTinyMCE, baseUrl} from './loader';
import * as Options from './options';
/**
* Storage for the TinyMCE instances on the page.
@ -45,6 +45,8 @@ let defaultOptions = {};
* @return {Promise[]} A matching set of Promises relating to the requested plugins
*/
const importPluginList = async(pluginList) => {
// Fetch all of the plugins from the list of plugins.
// If a plugin contains a '/' then it is assumed to be a Moodle AMD module to import.
const pluginHandlers = await Promise.all(pluginList.map(pluginPath => {
if (pluginPath.indexOf('/') === -1) {
// A standard TinyMCE Plugin.
@ -54,6 +56,10 @@ const importPluginList = async(pluginList) => {
return import(pluginPath);
}));
// Normalise the plugin data to a list of plugin names.
// Two formats are supported:
// - a string; and
// - an array whose first element is the plugin name, and the second element is the plugin configuration.
const pluginNames = pluginHandlers.map((pluginConfig) => {
if (typeof pluginConfig === 'string') {
return pluginConfig;
@ -64,6 +70,7 @@ const importPluginList = async(pluginList) => {
return null;
}).filter((value) => value);
// Fetch the list of pluginConfig handlers.
const pluginConfig = pluginHandlers.map((pluginConfig) => {
if (Array.isArray(pluginConfig)) {
return pluginConfig[1];
@ -77,10 +84,21 @@ const importPluginList = async(pluginList) => {
};
};
/**
* Fetch the language data for the specified language.
*
* @param {string} language The language identifier
* @returns {object}
*/
const fetchLanguage = (language) => fetch(
`${M.cfg.wwwroot}/lib/editor/tiny/lang.php/${M.cfg.langrev}/${language}`
).then(response => response.json());
/**
* Get a list of all Editors in a Map, keyed by the DOM Node that the Editor is associated with.
*
* @returns {Map<Node, Editor>}
*/
export const getAllInstances = () => new Map(instanceMap.entries());
/**
@ -119,6 +137,11 @@ export const setupForElementId = ({elementId, options}) => {
return setupForTarget(target, options);
};
/**
* Initialise the page with standard TinyMCE requirements.
*
* Currently this includes the language taken from the HTML lang property.
*/
const initialisePage = async() => {
const lang = document.querySelector('html').lang;
@ -127,9 +150,18 @@ const initialisePage = async() => {
};
initialisePage();
const getPlugins = (options) => {
if (options.plugins) {
return options.plugins;
/**
* Get the list of plugins to load for the specified configuration.
*
* If the specified configuration does not include a plugin configuration, then return the default configuration.
*
* @param {object} options
* @param {array} [options.plugins=null] The plugin list
* @returns {object}
*/
const getPlugins = ({plugins = null} = {}) => {
if (plugins) {
return plugins;
}
if (defaultOptions.plugins) {
@ -139,9 +171,20 @@ const getPlugins = (options) => {
return {};
};
/**
* Get the standard configuration for the specified options.
*
* @param {Node} target
* @param {tinyMCE} tinyMCE
* @param {object} options
* @param {Array} plugins
* @returns {object}
*/
const getStandardConfig = (target, tinyMCE, options, plugins) => {
const lang = document.querySelector('html').lang;
return {
return Object.assign({}, getDefaultConfiguration(), {
// eslint-disable-next-line camelcase
base_url: baseUrl,
// Set the editor target.
@ -150,10 +193,12 @@ const getStandardConfig = (target, tinyMCE, options, plugins) => {
// Set the language.
// https://www.tiny.cloud/docs/tinymce/6/ui-localization/#language
// eslint-disable-next-line camelcase
language: lang,
// Load the editor stylesheet into the editor iframe.
// https://www.tiny.cloud/docs/tinymce/6/add-css-options/
// eslint-disable-next-line camelcase
content_css: [
options.css,
],
@ -169,58 +214,6 @@ const getStandardConfig = (target, tinyMCE, options, plugins) => {
// eslint-disable-next-line camelcase
a11y_advanced_options: true,
// Toolbar configuration.
// https://www.tiny.cloud/docs/tinymce/6/toolbar-configuration-options/
// TODO: Move this configuration to a passed-in option.
// eslint-disable-next-line camelcase
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 configuration.
// https://www.tiny.cloud/docs/tinymce/6/menus-configuration-options/
// TODO: Move this configuration to a passed-in option.
menu: {
},
// The list of plugins to include in the instance.
// https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#plugins
plugins: [
@ -238,14 +231,18 @@ const getStandardConfig = (target, tinyMCE, options, plugins) => {
// Remove the "Upgrade" link for Tiny.
// https://www.tiny.cloud/docs/tinymce/6/editor-premium-upgrade-promotion/
promotion: false,
};
setup: (editor) => {
Options.register(editor, options);
},
});
};
/**
* Set up TinyMCE for the HTML Element.
*
* @param {HTMLElement} target
* @param {Object} options The editor plugin configuration
* @param {Object} [options={}] The editor plugin configuration
* @return {Promise<TinyMCE>} The TinyMCE instance
*/
export const setupForTarget = async(target, options = {}) => {
@ -254,21 +251,29 @@ export const setupForTarget = async(target, options = {}) => {
return Promise.resolve(instance);
}
// Register a new pending promise to ensure that Behat waits for the editor setup to complete before continuing.
const pendingPromise = new Pending('editor_tiny/editor:setupForTarget');
// Get the list of plugins.
const plugins = getPlugins(options);
// Fetch the tinyMCE API, and instantiate the plugins.
const [tinyMCE, pluginValues] = await Promise.all([
getTinyMCE(),
importPluginList(Object.keys(plugins)),
]);
const {pluginNames, pluginConfig} = pluginValues;
// Allow plugins to modify the configuration.
const instanceConfig = getStandardConfig(target, tinyMCE, options, pluginNames);
pluginConfig.forEach((pluginConfig) => {
if (typeof pluginConfig.configure === 'function') {
Object.assign(instanceConfig, pluginConfig.configure(instanceConfig));
Object.assign(instanceConfig, pluginConfig.configure(instanceConfig, options));
}
});
Object.assign(instanceConfig, Options.getInitialPluginConfiguration(options));
// Initialise the editor instance for the given configuration.
const [editor] = await tinyMCE.init(instanceConfig);
// Store the editor instance in the instanceMap and register its removal to remove it.
@ -278,15 +283,17 @@ export const setupForTarget = async(target, options = {}) => {
instanceMap.delete(target.targetElm);
});
// Store the Moodle-specific options in the TinyMCE instance.
// TODO: See if there is a more appropriate location for this config.
// TinyMCE does support custom configuration options in its EditorOptions but these must be registered and spec'd.
editor.moodleOptions = options;
pendingPromise.resolve();
return editor;
};
/**
* Set the default editor configuration.
*
* This configuration is used when an editor is initialised without any configuration.
*
* @param {object} [options={}]
*/
export const configureDefaultEditor = (options = {}) => {
defaultOptions = options;
};

View File

@ -0,0 +1,99 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Option helper for TinyMCE Editor Manager.
*
* @module editor_tiny/options
* @copyright 2022 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
const optionContextId = 'moodle:contextid';
const optionDraftItemId = 'moodle:draftitemid';
const filePickers = 'moodle:filepickers';
const optionsMoodleLang = 'moodle:language';
const currentLanguage = 'moodle:currentLanguage';
export const register = (editor, options) => {
const registerOption = editor.options.register;
const setOption = editor.options.set;
registerOption(optionContextId, {
processor: 'number',
"default": 0,
});
setOption(optionContextId, options.context);
registerOption(filePickers, {
processor: 'object',
"default": {},
});
setOption(filePickers, options.filepicker);
registerOption(optionDraftItemId, {
processor: 'number',
"default": 0,
});
setOption(optionDraftItemId, options.draftitemid);
registerOption(currentLanguage, {
processor: 'string',
"default": 'en',
});
setOption(currentLanguage, options.currentLanguage);
// This is primarily used by the media plugin, but it may be re-used elsewhere so is included here as it is large.
registerOption(optionsMoodleLang, {
processor: 'object',
"default": {},
});
setOption(optionsMoodleLang, options.language);
};
export const getContextId = (editor) => editor.options.get(optionContextId);
export const getDraftItemId = (editor) => editor.options.get(optionDraftItemId);
export const getFilepickers = (editor) => editor.options.get(filePickers);
export const getFilePicker = (editor, type) => getFilepickers(editor)[type];
export const getMoodleLang = (editor) => editor.options.get(optionsMoodleLang);
export const getCurrentLanguage = (editor) => editor.options.get(currentLanguage);
/**
* Get a set of namespaced options for all defined plugins.
*
* @param {object} options
* @returns {object}
*/
export const getInitialPluginConfiguration = (options) => {
const config = {};
Object.entries(options.plugins).forEach(([pluginName, pluginConfig]) => {
const values = Object.entries(pluginConfig.config ?? {});
values.forEach(([optionName, value]) => {
config[getPluginOptionName(pluginName, optionName)] = value;
});
});
return config;
};
/**
* Get the namespaced option name for a plugin.
*
* @param {string} pluginName
* @param {string} optionName
* @returns {string}
*/
export const getPluginOptionName = (pluginName, optionName) => `${pluginName}:${optionName}`;

View File

@ -14,6 +14,7 @@
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
import {renderForPromise} from 'core/templates';
import {getFilePicker} from './options';
/**
* Get the image path for the specified image.
@ -28,23 +29,6 @@ export const getButtonImage = async(identifier, component = 'editor_tiny') => re
image: await getImagePath(identifier, component),
});
/**
* Get the plugin configuration for the specified plugin.
*
* @param {TinyMCE} editor
* @param {string} plugin
* @returns {object} The plugin configuration
*/
export const getPluginConfiguration = (editor, plugin) => {
const config = editor.moodleOptions.plugins[`tiny_${plugin}/plugin`]?.config;
if (!config) {
return {};
}
return config;
};
/**
* Helper to display a filepicker and return a Promise.
*
@ -55,9 +39,10 @@ export const getPluginConfiguration = (editor, plugin) => {
* @returns {Promise<object>} The file object returned by the filepicker
*/
export const displayFilepicker = (editor, filetype) => new Promise((resolve, reject) => {
if (editor.moodleOptions.filepicker[filetype]) {
const configuration = getFilePicker(editor, filetype);
if (configuration) {
const options = {
...editor.moodleOptions.filepicker[filetype],
...configuration,
formcallback: resolve,
};
M.core_filepicker.show(Y, options);
@ -65,3 +50,72 @@ export const displayFilepicker = (editor, filetype) => new Promise((resolve, rej
}
reject(`Unknown filetype ${filetype}`);
});
/**
* Given a TinyMCE Toolbar configuration, add the specified button to the named section.
*
* @param {object} toolbar
* @param {string} section
* @param {string} button
* @returns {object} The toolbar configuration
*/
export const addToolbarButton = (toolbar, section, button) => {
if (!toolbar) {
return [{
name: section,
items: [button],
}];
}
const mutatedToolbar = JSON.parse(JSON.stringify(toolbar));
return mutatedToolbar.map((item) => {
if (item.name === section) {
item.items.push(button);
}
return item;
});
};
/**
* Given a TinyMCE Menubar configuration, add the specified button to the named section.
*
* @param {object} menubar
* @param {string} section
* @param {string} menuitem
* @returns {object}
*/
export const addMenubarItem = (menubar, section, menuitem) => {
if (!menubar) {
const emptyMenubar = {};
emptyMenubar[section] = {
title: section,
items: menuitem,
};
}
const mutatedMenubar = JSON.parse(JSON.stringify(menubar));
Array.from(Object.entries(mutatedMenubar)).forEach(([name, menu]) => {
if (name === section) {
menu.items = `${menu.items} ${menuitem}`;
}
});
return mutatedMenubar;
};
/**
* Given a TinyMCE contextmenu configuration, add the specified button to the end.
*
* @param {string} contextmenu
* @param {string[]} menuitems
* @returns {string}
*/
export const addContextmenuItem = (contextmenu, ...menuitems) => {
const contextmenuItems = (contextmenu ?? '').split(' ');
return contextmenuItems
.concat(menuitems)
.filter((item) => item !== '')
.join(' ');
};

View File

@ -171,10 +171,28 @@ class editor extends \texteditor {
// File picker options.
'filepicker' => $fpoptions,
'currentLanguage' => current_language(),
// Language options.
'language' => [
'currentlang' => current_language(),
'installed' => get_string_manager()->get_list_of_translations(true),
'available' => get_string_manager()->get_list_of_languages()
],
// Plugin configuration.
'plugins' => $this->manager->get_plugin_configuration($context, $options, $fpoptions),
'plugins' => $this->manager->get_plugin_configuration($context, $options, $fpoptions, $this),
];
foreach ($fpoptions as $fp) {
// Guess the draftitemid for the editor.
// Note: This is the best we can do at the moment.
if (!empty($fp->itemid)) {
$config->draftitemid = $fp->itemid;
break;
}
}
$configoptions = json_encode(convert_to_array($config));
// Note: This is not ideal but the editor does not have control over any HTML output.

View File

@ -27,46 +27,62 @@ use context;
*/
class manager {
/**
* Get the configuration for all plugins.
*
* @param context $context The context that the editor is used within
* @param array $options The options passed in when requesting the editor
* @param array $fpoptions The filepicker options passed in when requesting the editor
* @param editor $editor The editor instance in which the plugin is initialised
*/
public function get_plugin_configuration(
context $context,
array $options = [],
array $fpoptions = []
array $fpoptions = [],
?editor $editor = null
): array {
$disabledplugins = $this->get_disabled_plugins();
// Get the list of plugins.
// Note: Disabled plugins are already removed from this list.
$plugins = $this->get_shipped_plugins();
// Fetch configuration for Moodle plugins.
$moodleplugins = \core_component::get_plugin_list_with_class('tiny', 'plugininfo');
foreach ($moodleplugins as $plugin => $classname) {
if (in_array($plugin, $disabledplugins)) {
if (in_array($plugin, $disabledplugins) || in_array("{$plugin}/plugin", $disabledplugins)) {
// Skip getting data for disabled plugins.
continue;
}
if (!is_a($classname, plugin::class, true)) {
// Skip plugins that do not implement the plugin interface.
debugging("Plugin {$plugin} does not implement the plugin interface", DEBUG_DEVELOPER);
continue;
}
$plugininfo = $classname::get_plugin_info();
if (!$classname::is_enabled($context, $options, $fpoptions, $editor)) {
// This plugin has disabled itself for some reason.
// This is typical for media plugins where there is no file storage.
continue;
}
$config = $classname::get_plugin_configuration_for_context(
// Get the plugin information, which includes the list of buttons, menu items, and configuration.
$plugininfo = $classname::get_plugin_info(
$context,
$options,
$fpoptions
$fpoptions,
$editor
);
if (!empty($config)) {
$plugininfo['config'] = $config;
}
// We suffix the plugin name for Moodle plugins with /plugin to avoid conflicts with Tiny plugins.
$plugins["{$plugin}/plugin"] = $plugininfo;
}
$plugins = array_filter($plugins, function ($plugin) use ($disabledplugins) {
return !in_array($plugin, $disabledplugins);
}, ARRAY_FILTER_USE_KEY);
return $plugins;
}

View File

@ -33,7 +33,39 @@ use context;
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class plugin {
public static function get_plugin_info(): array {
/**
* Whether the plugin is enabled
*
* @param context $context The context that the editor is used within
* @param array $options The options passed in when requesting the editor
* @param array $fpoptions The filepicker options passed in when requesting the editor
* @param editor $editor The editor instance in which the plugin is initialised
* @return boolean
*/
public static function is_enabled(
context $context,
array $options,
array $fpoptions,
?editor $editor = null
): bool {
return true;
}
/**
* Get the plugin information for the plugin.
*
* @param context $context The context that the editor is used within
* @param array $options The options passed in when requesting the editor
* @param array $fpoptions The filepicker options passed in when requesting the editor
* @param editor $editor The editor instance in which the plugin is initialised
* @return array
*/
final public static function get_plugin_info(
context $context,
array $options,
array $fpoptions,
?editor $editor = null
): array {
$plugindata = [];
if (is_a(static::class, plugin_with_buttons::class, true)) {
@ -44,18 +76,10 @@ abstract class plugin {
$plugindata['menuitems'] = static::get_available_menuitems();
}
return $plugindata;
}
public static function get_plugin_configuration_for_context(
context $context,
array $options,
array $fpoptions
): array {
if (is_a(static::class, plugin_with_configuration::class, true)) {
return static::get_plugin_configuration_for_context($context, $options, $fpoptions);
$plugindata['config'] = static::get_plugin_configuration_for_context($context, $options, $fpoptions, $editor);
}
return [];
return $plugindata;
}
}

View File

@ -32,11 +32,13 @@ interface plugin_with_configuration {
* @param context $context The context that the editor is used within
* @param array $options The options passed in when requesting the editor
* @param array $fpoptions The filepicker options passed in when requesting the editor
* @param editor $editor The editor instance in which the plugin is initialised
* @return array
*/
public static function get_plugin_configuration_for_context(
context $context,
array $options,
array $fpoptions
array $fpoptions,
?editor $editor = null
): array;
}