diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2ddcc0..b90f342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,16 +31,16 @@ jobs: matrix: include: - php: '8.2' - moodle-branch: 'master' + moodle-branch: 'main' database: 'pgsql' - php: '8.1' - moodle-branch: 'MOODLE_402_STABLE' + moodle-branch: 'MOODLE_403_STABLE' database: 'mariadb' - php: '8.0' - moodle-branch: 'MOODLE_401_STABLE' + moodle-branch: 'MOODLE_402_STABLE' database: 'pgsql' - php: '7.4' - moodle-branch: 'MOODLE_400_STABLE' + moodle-branch: 'MOODLE_401_STABLE' database: 'mariadb' steps: diff --git a/amd/build/commands.min.js b/amd/build/commands.min.js new file mode 100644 index 0000000..e98ee99 --- /dev/null +++ b/amd/build/commands.min.js @@ -0,0 +1,3 @@ +define("tiny_embedquestion/commands",["exports","editor_tiny/utils","core/str","./common","./dialogue_manager"],(function(_exports,_utils,_str,_common,_dialogue_manager){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.getSetup=void 0;_exports.getSetup=async()=>{const[buttonText,buttonImage]=await Promise.all([(0,_str.get_string)("pluginname",_common.component),(0,_utils.getButtonImage)("icon",_common.component)]);return async editor=>{registerManagerCommand(editor,buttonText,buttonImage)}};const registerManagerCommand=async(editor,buttonText,buttonImage)=>{const handleDialogManager=async()=>{const dialog=new _dialogue_manager.DialogManager(editor);await dialog.displayDialogue()};editor.ui.registry.addIcon(_common.icon,buttonImage.html),editor.ui.registry.addButton(_common.buttonName,{icon:_common.icon,tooltip:buttonText,onAction:async()=>{await handleDialogManager()}}),editor.ui.registry.addMenuItem(_common.buttonName,{icon:_common.icon,text:buttonText,onAction:async()=>{await handleDialogManager()}}),editor.ui.registry.addToggleButton(_common.buttonName,{icon:_common.icon,tooltip:buttonText,onAction:async()=>{await handleDialogManager()},onSetup:api=>{editor.on("NodeChange",(()=>{const dialog=new _dialogue_manager.DialogManager(editor);api.setActive(!!dialog.getEmbedCodeFromTextSelection(editor))}))}})}})); + +//# sourceMappingURL=commands.min.js.map \ No newline at end of file diff --git a/amd/build/commands.min.js.map b/amd/build/commands.min.js.map new file mode 100644 index 0000000..c258afa --- /dev/null +++ b/amd/build/commands.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"commands.min.js","sources":["../src/commands.js"],"sourcesContent":["// This file is part of Moodle - https://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 * Commands helper for the Moodle tiny_embedquestion plugin.\n *\n * @module tiny_embedquestion/commands\n * @copyright 2024 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getButtonImage} from 'editor_tiny/utils';\nimport {get_string as getString} from 'core/str';\nimport {\n component,\n buttonName,\n icon\n} from './common';\nimport {DialogManager} from \"./dialogue_manager\";\n\n/**\n * Get the setup function for the buttons.\n *\n * This is performed in an async function which ultimately returns the registration function as the\n * Tiny.AddOnManager.Add() function does not support async functions.\n *\n * @returns {function} The registration function to call within the Plugin.add function.\n */\nexport const getSetup = async() => {\n const [\n buttonText,\n buttonImage,\n ] = await Promise.all([\n getString('pluginname', component),\n getButtonImage('icon', component),\n ]);\n\n return async(editor) => {\n registerManagerCommand(editor, buttonText, buttonImage);\n };\n};\n\n/**\n * Registers a custom command for embed question in the editor.\n *\n * @async\n * @param {Object} editor - The editor instance.\n * @param {string} buttonText - The text to display as a tooltip for the button.\n * @param {Object} buttonImage - The image to be displayed on the button.\n */\nconst registerManagerCommand = async(editor, buttonText, buttonImage) => {\n const handleDialogManager = async() => {\n const dialog = new DialogManager(editor);\n await dialog.displayDialogue();\n };\n\n editor.ui.registry.addIcon(icon, buttonImage.html);\n editor.ui.registry.addButton(buttonName, {\n icon: icon,\n tooltip: buttonText,\n onAction: async() => {\n await handleDialogManager();\n }\n });\n\n editor.ui.registry.addMenuItem(buttonName, {\n icon: icon,\n text: buttonText,\n onAction: async() => {\n await handleDialogManager();\n }\n });\n\n // Register the Menu Button as a toggle.\n editor.ui.registry.addToggleButton(buttonName, {\n icon: icon,\n tooltip: buttonText,\n onAction: async() => {\n await handleDialogManager();\n },\n onSetup: (api) => {\n editor.on('NodeChange', () => {\n const dialog = new DialogManager(editor);\n // Set the button to be active if the current selection matches the embed question code format.\n api.setActive(!!dialog.getEmbedCodeFromTextSelection(editor));\n });\n },\n });\n};\n"],"names":["async","buttonText","buttonImage","Promise","all","component","registerManagerCommand","editor","handleDialogManager","dialog","DialogManager","displayDialogue","ui","registry","addIcon","icon","html","addButton","buttonName","tooltip","onAction","addMenuItem","text","addToggleButton","onSetup","api","on","setActive","getEmbedCodeFromTextSelection"],"mappings":"6QAwCwBA,gBAEhBC,WACAC,mBACMC,QAAQC,IAAI,EAClB,mBAAU,aAAcC,oBACxB,yBAAe,OAAQA,4BAGpBL,MAAAA,SACHM,uBAAuBC,OAAQN,WAAYC,qBAY7CI,uBAAyBN,MAAMO,OAAQN,WAAYC,qBAC/CM,oBAAsBR,gBAClBS,OAAS,IAAIC,gCAAcH,cAC3BE,OAAOE,mBAGjBJ,OAAOK,GAAGC,SAASC,QAAQC,aAAMb,YAAYc,MAC7CT,OAAOK,GAAGC,SAASI,UAAUC,mBAAY,CACrCH,KAAMA,aACNI,QAASlB,WACTmB,SAAUpB,gBACAQ,yBAIdD,OAAOK,GAAGC,SAASQ,YAAYH,mBAAY,CACvCH,KAAMA,aACNO,KAAMrB,WACNmB,SAAUpB,gBACAQ,yBAKdD,OAAOK,GAAGC,SAASU,gBAAgBL,mBAAY,CAC3CH,KAAMA,aACNI,QAASlB,WACTmB,SAAUpB,gBACAQ,uBAEVgB,QAAUC,MACNlB,OAAOmB,GAAG,cAAc,WACdjB,OAAS,IAAIC,gCAAcH,QAEjCkB,IAAIE,YAAYlB,OAAOmB,8BAA8BrB"} \ No newline at end of file diff --git a/amd/build/common.min.js b/amd/build/common.min.js new file mode 100644 index 0000000..81ff963 --- /dev/null +++ b/amd/build/common.min.js @@ -0,0 +1,11 @@ +define("tiny_embedquestion/common",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0; +/** + * Common values helper for the embed question plugin. + * + * @module tiny_embedquestion/common + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +const component="tiny_embedquestion";var _default={pluginName:"".concat(component,"/plugin"),component:"".concat(component),buttonName:"".concat(component),icon:"".concat(component)};return _exports.default=_default,_exports.default})); + +//# sourceMappingURL=common.min.js.map \ No newline at end of file diff --git a/amd/build/common.min.js.map b/amd/build/common.min.js.map new file mode 100644 index 0000000..2af1848 --- /dev/null +++ b/amd/build/common.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"common.min.js","sources":["../src/common.js"],"sourcesContent":["// This file is part of Moodle - https://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 * Common values helper for the embed question plugin.\n *\n * @module tiny_embedquestion/common\n * @copyright 2024 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst component = 'tiny_embedquestion';\n\nexport default {\n pluginName: `${component}/plugin`,\n component: `${component}`,\n buttonName: `${component}`,\n icon: `${component}`,\n};\n"],"names":["component","pluginName","buttonName","icon"],"mappings":";;;;;;;;MAuBMA,UAAY,kCAEH,CACXC,qBAAeD,qBACfA,oBAAcA,WACdE,qBAAeF,WACfG,eAASH"} \ No newline at end of file diff --git a/amd/build/configuration.min.js b/amd/build/configuration.min.js new file mode 100644 index 0000000..c75846e --- /dev/null +++ b/amd/build/configuration.min.js @@ -0,0 +1,3 @@ +define("tiny_embedquestion/configuration",["exports","./common","editor_tiny/utils"],(function(_exports,_common,_utils){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.configure=void 0;_exports.configure=instanceConfig=>{return{toolbar:(toolbar=instanceConfig.toolbar,toolbar=(0,_utils.addToolbarButton)(toolbar,"formatting",_common.buttonName)),menu:(0,_utils.addMenubarItem)(instanceConfig.menu,"insert",_common.buttonName)};var toolbar}})); + +//# sourceMappingURL=configuration.min.js.map \ No newline at end of file diff --git a/amd/build/configuration.min.js.map b/amd/build/configuration.min.js.map new file mode 100644 index 0000000..7a30a1e --- /dev/null +++ b/amd/build/configuration.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"configuration.min.js","sources":["../src/configuration.js"],"sourcesContent":["// This file is part of Moodle - https://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 embed question configuration for Moodle.\n *\n * @module tiny_embedquestion/configuration\n * @copyright 2024 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {buttonName} from './common';\nimport {addMenubarItem, addToolbarButton} from 'editor_tiny/utils';\n\n/**\n * This function control where the button is display in the editor.\n *\n * @param {Object} toolbar\n * @returns {addToolbarButton}\n */\nconst configureToolbar = (toolbar) => {\n toolbar = addToolbarButton(toolbar, 'formatting', buttonName);\n return toolbar;\n};\n\n/**\n * This function control where the button is display in the menu bar of the editor.\n *\n * @param {Object} instanceConfig\n * @returns {Object}\n */\nexport const configure = (instanceConfig) => {\n return {\n toolbar: configureToolbar(instanceConfig.toolbar),\n menu: addMenubarItem(instanceConfig.menu, 'insert', buttonName),\n };\n};\n"],"names":["instanceConfig","toolbar","buttonName","menu"],"mappings":"6NA2C0BA,uBACf,CACHC,SAbkBA,QAaQD,eAAeC,QAZ7CA,SAAU,2BAAiBA,QAAS,aAAcC,qBAa9CC,MAAM,yBAAeH,eAAeG,KAAM,SAAUD,qBAdlCD,IAAAA"} \ No newline at end of file diff --git a/amd/build/dialogue_manager.min.js b/amd/build/dialogue_manager.min.js new file mode 100644 index 0000000..0a35424 --- /dev/null +++ b/amd/build/dialogue_manager.min.js @@ -0,0 +1,10 @@ +define("tiny_embedquestion/dialogue_manager",["exports","core/templates","core/str","core/modal","core/modal_factory","core/pending","core/loadingicon","./options","core/ajax","core/notification","core/fragment"],(function(_exports,_templates,_str,_modal,_modal_factory,_pending,_loadingicon,_options,_ajax,_notification,_fragment){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj} +/** + * Manages the embed question dialog. + * + * @module tiny_embedquestion/dialogue_manager + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.DialogManager=void 0,_templates=_interopRequireDefault(_templates),_modal=_interopRequireDefault(_modal),_modal_factory=_interopRequireDefault(_modal_factory),_pending=_interopRequireDefault(_pending),_notification=_interopRequireDefault(_notification),_fragment=_interopRequireDefault(_fragment);_exports.DialogManager=class{constructor(_editor){_defineProperty(this,"editor",null),_defineProperty(this,"currentModal",null),_defineProperty(this,"displayDialogue",(async()=>{void 0!==_modal.default.create?this.currentModal=await _modal.default.create({large:!0,title:(0,_str.get_string)("pluginname","tiny_embedquestion"),body:'
',show:!0,removeOnClose:!0}):(this.currentModal=await _modal_factory.default.create({title:(0,_str.get_string)("pluginname","tiny_embedquestion"),body:'
',large:!0,removeOnClose:!0}),this.currentModal.show());const pendingModalReady=new _pending.default("tiny_embedquestion/displayDialogue"),body=this.currentModal.getBody()[0];(0,_loadingicon.addIconToContainerRemoveOnCompletion)(body,pendingModalReady);let existingCode=this.getEmbedCodeFromTextSelection(this.editor);existingCode&&(existingCode=existingCode.embedCode);const dialogManager=this;_fragment.default.loadFragment("tiny_embedquestion","questionselector",(0,_options.getRelevantContextId)(this.editor),{contextId:(0,_options.getRelevantContextId)(this.editor),embedCode:existingCode}).then((function(html,js){return _templates.default.replaceNodeContents(body,html,js),body.querySelector("#embedqform #id_submitbutton").addEventListener("click",dialogManager.getEmbedCode),pendingModalReady.resolve(),dialogManager.currentModal})).catch(_notification.default.exception)})),_defineProperty(this,"getEmbedCode",(e=>{e.preventDefault();const iframeDescription=document.getElementById("id_iframedescription").value,questionIdnumber=document.getElementById("id_questionidnumber").value,dialogManager=this;questionIdnumber&&(iframeDescription.length&&(iframeDescription.length<3||iframeDescription.length>100)||dialogManager.getEmbedCodeCall(iframeDescription,questionIdnumber).then((function(embedCode){return dialogManager.insertEmbedCode(embedCode),dialogManager})).catch(_notification.default.exception))})),_defineProperty(this,"getEmbedCodeCall",((iframeDescription,questionIdnumber)=>{var _document$getElementB,_document$getElementB2,_document$getElementB3,_document$getElementB4,_document$getElementB5,_document$getElementB6,_document$getElementB7,_document$getElementB8,_document$getElementB9,_document$getElementB10,_document$getElementB11;return(0,_ajax.call)([{methodname:"filter_embedquestion_get_embed_code",args:{courseid:document.querySelector("input[name=courseid]").value,categoryidnumber:document.getElementById("id_categoryidnumber").value,questionidnumber:questionIdnumber,iframedescription:iframeDescription,behaviour:(null===(_document$getElementB=document.getElementById("id_behaviour"))||void 0===_document$getElementB?void 0:_document$getElementB.value)||"",maxmark:(null===(_document$getElementB2=document.getElementById("id_maxmark"))||void 0===_document$getElementB2?void 0:_document$getElementB2.value)||"",variant:(null===(_document$getElementB3=document.getElementById("id_variant"))||void 0===_document$getElementB3?void 0:_document$getElementB3.value)||"",correctness:(null===(_document$getElementB4=document.getElementById("id_correctness"))||void 0===_document$getElementB4?void 0:_document$getElementB4.value)||"",marks:(null===(_document$getElementB5=document.getElementById("id_marks"))||void 0===_document$getElementB5?void 0:_document$getElementB5.value)||"",markdp:(null===(_document$getElementB6=document.getElementById("id_markdp"))||void 0===_document$getElementB6?void 0:_document$getElementB6.value)||"",feedback:(null===(_document$getElementB7=document.getElementById("id_feedback"))||void 0===_document$getElementB7?void 0:_document$getElementB7.value)||"",generalfeedback:(null===(_document$getElementB8=document.getElementById("id_generalfeedback"))||void 0===_document$getElementB8?void 0:_document$getElementB8.value)||"",rightanswer:(null===(_document$getElementB9=document.getElementById("id_rightanswer"))||void 0===_document$getElementB9?void 0:_document$getElementB9.value)||"",history:(null===(_document$getElementB10=document.getElementById("id_history"))||void 0===_document$getElementB10?void 0:_document$getElementB10.value)||"",forcedlanguage:(null===(_document$getElementB11=document.getElementById("id_forcedlanguage"))||void 0===_document$getElementB11?void 0:_document$getElementB11.value)||""}}])[0]})),_defineProperty(this,"insertEmbedCode",(embedCode=>{const existingCode=this.getEmbedCodeFromTextSelection(this.editor);if(existingCode){const parent=this.editor.selection.getNode(),text=parent.textContent;parent.textContent=text.slice(0,existingCode.start)+embedCode+text.slice(existingCode.end)}else this.editor.insertContent(embedCode);this.currentModal.destroy()})),_defineProperty(this,"getEmbedCodeFromTextSelection",(editor=>{const selection=editor.selection.getSel(),selectedNode=editor.selection.getNode();let text,patternMatches,returnValue=!1;if(!selection)return!1;if(!(selection.rangeCount?selection.getRangeAt(0):null))return!1;if(text=selectedNode.textContent,patternMatches=text.match(/\{Q\{(?:(?!\}Q\}).)*\}Q\}/g),!patternMatches||!patternMatches.length)return!1;for(let i=0;i=start&&selection.anchorOffsetstart,reserveStartMatches=selection.anchorOffset<=end&&selection.anchorOffset>start,reserveEndMatches=selection.focusOffset>=start&&selection.focusOffset.\n\nimport Templates from 'core/templates';\nimport {get_string as getString} from 'core/str';\nimport Modal from 'core/modal';\nimport ModalFactory from 'core/modal_factory';\nimport Pending from 'core/pending';\nimport {addIconToContainerRemoveOnCompletion} from 'core/loadingicon';\nimport {getRelevantContextId} from './options';\nimport {call as fetchMany} from 'core/ajax';\nimport Notification from 'core/notification';\nimport Fragment from 'core/fragment';\n\n/**\n * Manages the embed question dialog.\n *\n * @module tiny_embedquestion/dialogue_manager\n * @copyright 2024 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport const DialogManager = class {\n\n /** @property {Object} current Tiny MCE editor instance */\n editor = null;\n\n /** @property {Object} current display dialog */\n currentModal = null;\n\n /**\n * Dialog constructor.\n *\n * @constructor\n * @param {Object} editor current editor instance.\n */\n constructor(editor) {\n this.editor = editor;\n }\n\n /**\n * Displays a modal dialogue for managing embed question.\n *\n * @async\n */\n displayDialogue = async() => {\n if (typeof Modal.create !== \"undefined\") {\n this.currentModal = await Modal.create({\n large: true,\n title: getString('pluginname', 'tiny_embedquestion'),\n body: '
',\n show: true,\n removeOnClose: true\n });\n } else {\n // TODO Need to be remove after we no longer support 4.2 and below.\n this.currentModal = await ModalFactory.create({\n title: getString('pluginname', 'tiny_embedquestion'),\n body: '
',\n large: true,\n removeOnClose: true\n });\n this.currentModal.show();\n }\n\n const pendingModalReady = new Pending('tiny_embedquestion/displayDialogue');\n const body = this.currentModal.getBody()[0];\n addIconToContainerRemoveOnCompletion(\n body, pendingModalReady\n );\n\n let existingCode = this.getEmbedCodeFromTextSelection(this.editor);\n if (existingCode) {\n existingCode = existingCode.embedCode;\n }\n const dialogManager = this;\n Fragment.loadFragment('tiny_embedquestion', 'questionselector', getRelevantContextId(this.editor),\n {contextId: getRelevantContextId(this.editor), embedCode: existingCode}).then(function(html, js) {\n\n Templates.replaceNodeContents(body, html, js);\n body.querySelector('#embedqform #id_submitbutton').addEventListener('click', dialogManager.getEmbedCode);\n pendingModalReady.resolve();\n return dialogManager.currentModal;\n }).catch(Notification.exception);\n };\n\n /**\n * Handler for when the form button is clicked.\n * Make an AJAX request to the server to get the embed code.\n *\n * @param {Event} e - the click event.\n */\n getEmbedCode = (e) => {\n e.preventDefault();\n const iframeDescription = document.getElementById('id_iframedescription').value;\n const questionIdnumber = document.getElementById('id_questionidnumber').value;\n const dialogManager = this;\n // Required value of questionidnumber.\n // Note that the form also validates this, and deals with displaying a message to the user.\n if (!questionIdnumber) {\n return;\n }\n\n // Validate iframedescription.\n // If it is present, then it must have at least 3 characters and a maximum of 100 characters.\n // (It can be left blank to get the default description.)\n // Note that the form also validates this, and deals with displaying a message to the user.\n if (iframeDescription.length && (iframeDescription.length < 3 || iframeDescription.length > 100)) {\n return;\n }\n\n dialogManager.getEmbedCodeCall(iframeDescription, questionIdnumber).then(function(embedCode) {\n dialogManager.insertEmbedCode(embedCode);\n return dialogManager;\n }).catch(Notification.exception);\n };\n\n /**\n * Ajax call to get the embed code from back end.\n *\n * @param {String} iframeDescription - Description for the the embed code\n * @param {Number} questionIdnumber - question id number.\n * @returns {Promise}\n */\n getEmbedCodeCall = (iframeDescription, questionIdnumber) => {\n return fetchMany([{\n methodname: 'filter_embedquestion_get_embed_code',\n args: {\n courseid: document.querySelector('input[name=courseid]').value,\n categoryidnumber: document.getElementById('id_categoryidnumber').value,\n questionidnumber: questionIdnumber,\n iframedescription: iframeDescription,\n behaviour: document.getElementById('id_behaviour')?.value || '',\n maxmark: document.getElementById('id_maxmark')?.value || '',\n variant: document.getElementById('id_variant')?.value || '',\n correctness: document.getElementById('id_correctness')?.value || '',\n marks: document.getElementById('id_marks')?.value || '',\n markdp: document.getElementById('id_markdp')?.value || '',\n feedback: document.getElementById('id_feedback')?.value || '',\n generalfeedback: document.getElementById('id_generalfeedback')?.value || '',\n rightanswer: document.getElementById('id_rightanswer')?.value || '',\n history: document.getElementById('id_history')?.value || '',\n forcedlanguage: document.getElementById('id_forcedlanguage')?.value || ''\n }\n }])[0];\n };\n\n /**\n * Handles when we get the embed code from the AJAX request.\n *\n * @param {String} embedCode - the embed code to insert.\n */\n insertEmbedCode = (embedCode) => {\n const existingCode = this.getEmbedCodeFromTextSelection(this.editor);\n if (existingCode) {\n // Replace the existing code.\n const parent = this.editor.selection.getNode();\n const text = parent.textContent;\n parent.textContent = text.slice(0, existingCode.start) +\n embedCode + text.slice(existingCode.end);\n } else {\n this.editor.insertContent(embedCode);\n }\n this.currentModal.destroy();\n };\n\n /**\n * Get the embed code of the current selected text,\n *\n * @param {TinyMCE} editor\n * @returns {boolean|Object}\n * return false if we can't find the match pattern.\n * return Object {start: start position of the text, end: end position of the text, embedCode: embed code of the string}\n */\n getEmbedCodeFromTextSelection = (editor) => {\n\n // Find the embed code in the surrounding text.\n const selection = editor.selection.getSel(),\n selectedNode = editor.selection.getNode(),\n pattern = /\\{Q\\{(?:(?!\\}Q\\}).)*\\}Q\\}/g;\n let text,\n returnValue = false,\n patternMatches;\n\n if (!selection) {\n return false;\n }\n const range = selection.rangeCount ? selection.getRangeAt(0) : null;\n if (!range) {\n return false;\n }\n text = selectedNode.textContent;\n patternMatches = text.match(pattern);\n\n if (!patternMatches || !patternMatches.length) {\n return false;\n }\n // This pattern matches at least once. See if this pattern matches our current position.\n // Note: We return here to break the Y.Array.find loop - any truthy return will stop any subsequent\n // searches which is the required behaviour of this function.\n for (let i = 0; i < patternMatches.length; ++i) {\n let startIndex = 0;\n while (text.indexOf(patternMatches[i], startIndex) !== -1) {\n // Determine whether the cursor is in the current occurrence of this string.\n // Note: We do not support a selection exceeding the bounds of an equation.\n const start = text.indexOf(patternMatches[i], startIndex),\n end = start + patternMatches[i].length,\n startMatches = (selection.anchorOffset >= start && selection.anchorOffset < end),\n endMatches = (selection.focusOffset <= end && selection.focusOffset > start),\n reserveStartMatches = (selection.anchorOffset <= end && selection.anchorOffset > start),\n reserveEndMatches = (selection.focusOffset >= start && selection.focusOffset < end);\n if ((startMatches && endMatches) || (reserveStartMatches && reserveEndMatches)) {\n // Save all data for later.\n returnValue = {\n // Outer match data.\n start: start,\n end: end,\n embedCode: patternMatches[i]\n };\n\n // This breaks out the loop\n break;\n }\n\n // Update the startIndex to match the end of the current match so that we can continue hunting\n // for further matches.\n startIndex = end;\n }\n }\n return returnValue;\n };\n};\n"],"names":["constructor","editor","async","Modal","create","currentModal","large","title","body","show","removeOnClose","ModalFactory","pendingModalReady","Pending","this","getBody","existingCode","getEmbedCodeFromTextSelection","embedCode","dialogManager","loadFragment","contextId","then","html","js","replaceNodeContents","querySelector","addEventListener","getEmbedCode","resolve","catch","Notification","exception","e","preventDefault","iframeDescription","document","getElementById","value","questionIdnumber","length","getEmbedCodeCall","insertEmbedCode","methodname","args","courseid","categoryidnumber","questionidnumber","iframedescription","behaviour","maxmark","variant","correctness","marks","markdp","feedback","generalfeedback","rightanswer","history","forcedlanguage","parent","selection","getNode","text","textContent","slice","start","end","insertContent","destroy","getSel","selectedNode","patternMatches","returnValue","rangeCount","getRangeAt","match","i","startIndex","indexOf","startMatches","anchorOffset","endMatches","focusOffset","reserveStartMatches","reserveEndMatches"],"mappings":";;;;;;;sYAiC6B,MAczBA,YAAYC,uCAXH,0CAGM,8CAiBGC,eACc,IAAjBC,eAAMC,YACRC,mBAAqBF,eAAMC,OAAO,CACnCE,OAAO,EACPC,OAAO,mBAAU,aAAc,sBAC/BC,KAAM,8CACNC,MAAM,EACNC,eAAe,UAIdL,mBAAqBM,uBAAaP,OAAO,CAC1CG,OAAO,mBAAU,aAAc,sBAC/BC,KAAM,8CACNF,OAAO,EACPI,eAAe,SAEdL,aAAaI,cAGhBG,kBAAoB,IAAIC,iBAAQ,sCAChCL,KAAOM,KAAKT,aAAaU,UAAU,yDAErCP,KAAMI,uBAGNI,aAAeF,KAAKG,8BAA8BH,KAAKb,QACvDe,eACAA,aAAeA,aAAaE,iBAE1BC,cAAgBL,uBACbM,aAAa,qBAAsB,oBAAoB,iCAAqBN,KAAKb,QACtF,CAACoB,WAAW,iCAAqBP,KAAKb,QAASiB,UAAWF,eAAeM,MAAK,SAASC,KAAMC,8BAEnFC,oBAAoBjB,KAAMe,KAAMC,IAC1ChB,KAAKkB,cAAc,gCAAgCC,iBAAiB,QAASR,cAAcS,cAC3FhB,kBAAkBiB,UACXV,cAAcd,gBACtByB,MAAMC,sBAAaC,mDASVC,IACZA,EAAEC,uBACIC,kBAAoBC,SAASC,eAAe,wBAAwBC,MACpEC,iBAAmBH,SAASC,eAAe,uBAAuBC,MAClEnB,cAAgBL,KAGjByB,mBAQDJ,kBAAkBK,SAAWL,kBAAkBK,OAAS,GAAKL,kBAAkBK,OAAS,MAI5FrB,cAAcsB,iBAAiBN,kBAAmBI,kBAAkBjB,MAAK,SAASJ,kBAC9EC,cAAcuB,gBAAgBxB,WACvBC,iBACRW,MAAMC,sBAAaC,wDAUP,CAACG,kBAAmBI,6RAC5B,cAAU,CAAC,CACdI,WAAY,sCACZC,KAAM,CACFC,SAAUT,SAASV,cAAc,wBAAwBY,MACzDQ,iBAAkBV,SAASC,eAAe,uBAAuBC,MACjES,iBAAkBR,iBAClBS,kBAAmBb,kBACnBc,yCAAWb,SAASC,eAAe,8EAAiBC,QAAS,GAC7DY,wCAASd,SAASC,eAAe,8EAAeC,QAAS,GACzDa,wCAASf,SAASC,eAAe,8EAAeC,QAAS,GACzDc,4CAAahB,SAASC,eAAe,kFAAmBC,QAAS,GACjEe,sCAAOjB,SAASC,eAAe,4EAAaC,QAAS,GACrDgB,uCAAQlB,SAASC,eAAe,6EAAcC,QAAS,GACvDiB,yCAAUnB,SAASC,eAAe,+EAAgBC,QAAS,GAC3DkB,gDAAiBpB,SAASC,eAAe,sFAAuBC,QAAS,GACzEmB,4CAAarB,SAASC,eAAe,kFAAmBC,QAAS,GACjEoB,yCAAStB,SAASC,eAAe,gFAAeC,QAAS,GACzDqB,gDAAgBvB,SAASC,eAAe,uFAAsBC,QAAS,OAE3E,8CAQWpB,kBACTF,aAAeF,KAAKG,8BAA8BH,KAAKb,WACzDe,aAAc,OAER4C,OAAS9C,KAAKb,OAAO4D,UAAUC,UAC/BC,KAAOH,OAAOI,YACpBJ,OAAOI,YAAcD,KAAKE,MAAM,EAAGjD,aAAakD,OAC5ChD,UAAY6C,KAAKE,MAAMjD,aAAamD,eAEnClE,OAAOmE,cAAclD,gBAEzBb,aAAagE,mEAWWpE,eAGvB4D,UAAY5D,OAAO4D,UAAUS,SAC/BC,aAAetE,OAAO4D,UAAUC,cAEhCC,KAEAS,eADAC,aAAc,MAGbZ,iBACM,OAEGA,UAAUa,WAAab,UAAUc,WAAW,GAAK,aAEpD,KAEXZ,KAAOQ,aAAaP,YACpBQ,eAAiBT,KAAKa,MAbR,+BAeTJ,iBAAmBA,eAAehC,cAC5B,MAKN,IAAIqC,EAAI,EAAGA,EAAIL,eAAehC,SAAUqC,EAAG,KACxCC,WAAa,QACuC,IAAjDf,KAAKgB,QAAQP,eAAeK,GAAIC,aAAoB,OAGjDZ,MAAQH,KAAKgB,QAAQP,eAAeK,GAAIC,YAC1CX,IAAMD,MAAQM,eAAeK,GAAGrC,OAChCwC,aAAgBnB,UAAUoB,cAAgBf,OAASL,UAAUoB,aAAed,IAC5Ee,WAAcrB,UAAUsB,aAAehB,KAAON,UAAUsB,YAAcjB,MACtEkB,oBAAuBvB,UAAUoB,cAAgBd,KAAON,UAAUoB,aAAef,MACjFmB,kBAAqBxB,UAAUsB,aAAejB,OAASL,UAAUsB,YAAchB,OAC9Ea,cAAgBE,YAAgBE,qBAAuBC,kBAAoB,CAE5EZ,YAAc,CAEVP,MAAOA,MACPC,IAAKA,IACLjD,UAAWsD,eAAeK,UASlCC,WAAaX,YAGdM,oBAhMFxE,OAASA"} \ No newline at end of file diff --git a/amd/build/options.min.js b/amd/build/options.min.js new file mode 100644 index 0000000..9f6d3fd --- /dev/null +++ b/amd/build/options.min.js @@ -0,0 +1,11 @@ +define("tiny_embedquestion/options",["exports","editor_tiny/options","./common"],(function(_exports,_options,_common){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.register=_exports.getRelevantContextId=void 0; +/** + * Option helper for the Moodle tiny_embedquestion plugin. + * + * @module tiny_embedquestion/options + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +const relevantContextId=(0,_options.getPluginOptionName)(_common.pluginName,"relevantContextId");_exports.register=editor=>{(0,editor.options.register)(relevantContextId,{processor:"number"})};_exports.getRelevantContextId=editor=>editor.options.get(relevantContextId)})); + +//# sourceMappingURL=options.min.js.map \ No newline at end of file diff --git a/amd/build/options.min.js.map b/amd/build/options.min.js.map new file mode 100644 index 0000000..f532b19 --- /dev/null +++ b/amd/build/options.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"options.min.js","sources":["../src/options.js"],"sourcesContent":["// This file is part of Moodle - https://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 * Option helper for the Moodle tiny_embedquestion plugin.\n *\n * @module tiny_embedquestion/options\n * @copyright 2024 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport {getPluginOptionName} from 'editor_tiny/options';\nimport {pluginName} from './common';\nconst relevantContextId = getPluginOptionName(pluginName, 'relevantContextId');\n\n/**\n * Register the options for the Tiny Equation plugin.\n *\n * @param {TinyMCE} editor\n */\nexport const register = (editor) => {\n const registerOption = editor.options.register;\n\n registerOption(relevantContextId, {\n processor: 'number',\n });\n\n};\n\n/**\n * Get the context id for the Tiny embed question plugin.\n *\n * @param {TinyMCE} editor\n * @returns {Number} - context id\n */\nexport const getRelevantContextId = (editor) => editor.options.get(relevantContextId);\n"],"names":["relevantContextId","pluginName","editor","registerOption","options","register","processor","get"],"mappings":";;;;;;;;MAwBMA,mBAAoB,gCAAoBC,mBAAY,uCAOjCC,UAGrBC,EAFuBD,OAAOE,QAAQC,UAEvBL,kBAAmB,CAC9BM,UAAW,0CAWkBJ,QAAWA,OAAOE,QAAQG,IAAIP"} \ No newline at end of file diff --git a/amd/build/plugin.min.js b/amd/build/plugin.min.js new file mode 100644 index 0000000..555e272 --- /dev/null +++ b/amd/build/plugin.min.js @@ -0,0 +1,10 @@ +define("tiny_embedquestion/plugin",["exports","editor_tiny/loader","editor_tiny/utils","./common","./commands","./configuration","./options"],(function(_exports,_loader,_utils,_common,_commands,Configuration,Options){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)}function _interopRequireWildcard(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]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj} +/** + * Embed question plugin for TinyMCE. + * + * @module tiny_embedquestion + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,Configuration=_interopRequireWildcard(Configuration),Options=_interopRequireWildcard(Options);var _default=new Promise((async resolve=>{const[tinyMCE,pluginMetadata,setupCommands]=await Promise.all([(0,_loader.getTinyMCE)(),(0,_utils.getPluginMetadata)(_common.component,_common.pluginName),(0,_commands.getSetup)()]);tinyMCE.PluginManager.add(_common.pluginName,(editor=>(Options.register(editor),setupCommands(editor),pluginMetadata))),resolve([_common.pluginName,Configuration])}));return _exports.default=_default,_exports.default})); + +//# sourceMappingURL=plugin.min.js.map \ No newline at end of file diff --git a/amd/build/plugin.min.js.map b/amd/build/plugin.min.js.map new file mode 100644 index 0000000..4202849 --- /dev/null +++ b/amd/build/plugin.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"plugin.min.js","sources":["../src/plugin.js"],"sourcesContent":["// This file is part of Moodle - https://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 * Embed question plugin for TinyMCE.\n *\n * @module tiny_embedquestion\n * @copyright 2024 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getTinyMCE} from 'editor_tiny/loader';\nimport {getPluginMetadata} from 'editor_tiny/utils';\nimport {component, pluginName} from './common';\nimport {getSetup as getCommandSetup} from './commands';\nimport * as Configuration from './configuration';\nimport * as Options from \"./options\";\n\n// Setup the tiny_embedquestion Plugin.\n// eslint-disable-next-line no-async-promise-executor\nexport default new Promise(async(resolve) => {\n // Note: The PluginManager.add function does not support asynchronous configuration.\n // Perform any asynchronous configuration here, and then call the PluginManager.add function.\n const [\n tinyMCE,\n pluginMetadata,\n setupCommands,\n ] = await Promise.all([\n getTinyMCE(),\n getPluginMetadata(component, pluginName),\n getCommandSetup(),\n ]);\n\n // Reminder: Any asynchronous code must be run before this point.\n tinyMCE.PluginManager.add(pluginName, (editor) => {\n\n // Register any options that your plugin has\n Options.register(editor);\n // Setup any commands such as buttons, menu items, and so on.\n setupCommands(editor);\n\n // Return the pluginMetadata object. This is used by TinyMCE to display a help link for your plugin.\n return pluginMetadata;\n });\n\n resolve([pluginName, Configuration]);\n});\n"],"names":["Promise","async","tinyMCE","pluginMetadata","setupCommands","all","component","pluginName","PluginManager","add","editor","Options","register","resolve","Configuration"],"mappings":";;;;;;;gMAgCe,IAAIA,SAAQC,MAAAA,gBAInBC,QACAC,eACAC,qBACMJ,QAAQK,IAAI,EAClB,yBACA,4BAAkBC,kBAAWC,qBAC7B,0BAIJL,QAAQM,cAAcC,IAAIF,oBAAaG,SAGnCC,QAAQC,SAASF,QAEjBN,cAAcM,QAGPP,kBAGXU,QAAQ,CAACN,mBAAYO"} \ No newline at end of file diff --git a/amd/src/commands.js b/amd/src/commands.js new file mode 100644 index 0000000..ee9de85 --- /dev/null +++ b/amd/src/commands.js @@ -0,0 +1,101 @@ +// This file is part of Moodle - https://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 . + +/** + * Commands helper for the Moodle tiny_embedquestion plugin. + * + * @module tiny_embedquestion/commands + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import {getButtonImage} from 'editor_tiny/utils'; +import {get_string as getString} from 'core/str'; +import { + component, + buttonName, + icon +} from './common'; +import {DialogManager} from "./dialogue_manager"; + +/** + * Get the setup function for the buttons. + * + * This is performed in an async function which ultimately returns the registration function as the + * Tiny.AddOnManager.Add() function does not support async functions. + * + * @returns {function} The registration function to call within the Plugin.add function. + */ +export const getSetup = async() => { + const [ + buttonText, + buttonImage, + ] = await Promise.all([ + getString('pluginname', component), + getButtonImage('icon', component), + ]); + + return async(editor) => { + registerManagerCommand(editor, buttonText, buttonImage); + }; +}; + +/** + * Registers a custom command for embed question in the editor. + * + * @async + * @param {Object} editor - The editor instance. + * @param {string} buttonText - The text to display as a tooltip for the button. + * @param {Object} buttonImage - The image to be displayed on the button. + */ +const registerManagerCommand = async(editor, buttonText, buttonImage) => { + const handleDialogManager = async() => { + const dialog = new DialogManager(editor); + await dialog.displayDialogue(); + }; + + editor.ui.registry.addIcon(icon, buttonImage.html); + editor.ui.registry.addButton(buttonName, { + icon: icon, + tooltip: buttonText, + onAction: async() => { + await handleDialogManager(); + } + }); + + editor.ui.registry.addMenuItem(buttonName, { + icon: icon, + text: buttonText, + onAction: async() => { + await handleDialogManager(); + } + }); + + // Register the Menu Button as a toggle. + editor.ui.registry.addToggleButton(buttonName, { + icon: icon, + tooltip: buttonText, + onAction: async() => { + await handleDialogManager(); + }, + onSetup: (api) => { + editor.on('NodeChange', () => { + const dialog = new DialogManager(editor); + // Set the button to be active if the current selection matches the embed question code format. + api.setActive(!!dialog.getEmbedCodeFromTextSelection(editor)); + }); + }, + }); +}; diff --git a/amd/src/common.js b/amd/src/common.js index 228b622..d98b884 100644 --- a/amd/src/common.js +++ b/amd/src/common.js @@ -14,16 +14,18 @@ // along with Moodle. If not, see . /** - * Common values helper for the Moodle tiny_embedquestion plugin. + * Common values helper for the embed question plugin. * - * @module plugintype_pluginname/common - * @copyright 2023 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @module tiny_embedquestion/common + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const component = 'tiny_embedquestion'; export default { - component, pluginName: `${component}/plugin`, + component: `${component}`, + buttonName: `${component}`, + icon: `${component}`, }; diff --git a/amd/src/configuration.js b/amd/src/configuration.js new file mode 100644 index 0000000..4f8e8b1 --- /dev/null +++ b/amd/src/configuration.js @@ -0,0 +1,49 @@ +// This file is part of Moodle - https://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 . + +/** + * Tiny embed question configuration for Moodle. + * + * @module tiny_embedquestion/configuration + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import {buttonName} from './common'; +import {addMenubarItem, addToolbarButton} from 'editor_tiny/utils'; + +/** + * This function control where the button is display in the editor. + * + * @param {Object} toolbar + * @returns {addToolbarButton} + */ +const configureToolbar = (toolbar) => { + toolbar = addToolbarButton(toolbar, 'formatting', buttonName); + return toolbar; +}; + +/** + * This function control where the button is display in the menu bar of the editor. + * + * @param {Object} instanceConfig + * @returns {Object} + */ +export const configure = (instanceConfig) => { + return { + toolbar: configureToolbar(instanceConfig.toolbar), + menu: addMenubarItem(instanceConfig.menu, 'insert', buttonName), + }; +}; diff --git a/amd/src/dialogue_manager.js b/amd/src/dialogue_manager.js new file mode 100644 index 0000000..dd4888f --- /dev/null +++ b/amd/src/dialogue_manager.js @@ -0,0 +1,243 @@ +// 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 . + +import Templates from 'core/templates'; +import {get_string as getString} from 'core/str'; +import Modal from 'core/modal'; +import ModalFactory from 'core/modal_factory'; +import Pending from 'core/pending'; +import {addIconToContainerRemoveOnCompletion} from 'core/loadingicon'; +import {getRelevantContextId} from './options'; +import {call as fetchMany} from 'core/ajax'; +import Notification from 'core/notification'; +import Fragment from 'core/fragment'; + +/** + * Manages the embed question dialog. + * + * @module tiny_embedquestion/dialogue_manager + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +export const DialogManager = class { + + /** @property {Object} current Tiny MCE editor instance */ + editor = null; + + /** @property {Object} current display dialog */ + currentModal = null; + + /** + * Dialog constructor. + * + * @constructor + * @param {Object} editor current editor instance. + */ + constructor(editor) { + this.editor = editor; + } + + /** + * Displays a modal dialogue for managing embed question. + * + * @async + */ + displayDialogue = async() => { + if (typeof Modal.create !== "undefined") { + this.currentModal = await Modal.create({ + large: true, + title: getString('pluginname', 'tiny_embedquestion'), + body: '
', + show: true, + removeOnClose: true + }); + } else { + // TODO Need to be remove after we no longer support 4.2 and below. + this.currentModal = await ModalFactory.create({ + title: getString('pluginname', 'tiny_embedquestion'), + body: '
', + large: true, + removeOnClose: true + }); + this.currentModal.show(); + } + + const pendingModalReady = new Pending('tiny_embedquestion/displayDialogue'); + const body = this.currentModal.getBody()[0]; + addIconToContainerRemoveOnCompletion( + body, pendingModalReady + ); + + let existingCode = this.getEmbedCodeFromTextSelection(this.editor); + if (existingCode) { + existingCode = existingCode.embedCode; + } + const dialogManager = this; + Fragment.loadFragment('tiny_embedquestion', 'questionselector', getRelevantContextId(this.editor), + {contextId: getRelevantContextId(this.editor), embedCode: existingCode}).then(function(html, js) { + + Templates.replaceNodeContents(body, html, js); + body.querySelector('#embedqform #id_submitbutton').addEventListener('click', dialogManager.getEmbedCode); + pendingModalReady.resolve(); + return dialogManager.currentModal; + }).catch(Notification.exception); + }; + + /** + * Handler for when the form button is clicked. + * Make an AJAX request to the server to get the embed code. + * + * @param {Event} e - the click event. + */ + getEmbedCode = (e) => { + e.preventDefault(); + const iframeDescription = document.getElementById('id_iframedescription').value; + const questionIdnumber = document.getElementById('id_questionidnumber').value; + const dialogManager = this; + // Required value of questionidnumber. + // Note that the form also validates this, and deals with displaying a message to the user. + if (!questionIdnumber) { + return; + } + + // Validate iframedescription. + // If it is present, then it must have at least 3 characters and a maximum of 100 characters. + // (It can be left blank to get the default description.) + // Note that the form also validates this, and deals with displaying a message to the user. + if (iframeDescription.length && (iframeDescription.length < 3 || iframeDescription.length > 100)) { + return; + } + + dialogManager.getEmbedCodeCall(iframeDescription, questionIdnumber).then(function(embedCode) { + dialogManager.insertEmbedCode(embedCode); + return dialogManager; + }).catch(Notification.exception); + }; + + /** + * Ajax call to get the embed code from back end. + * + * @param {String} iframeDescription - Description for the the embed code + * @param {Number} questionIdnumber - question id number. + * @returns {Promise} + */ + getEmbedCodeCall = (iframeDescription, questionIdnumber) => { + return fetchMany([{ + methodname: 'filter_embedquestion_get_embed_code', + args: { + courseid: document.querySelector('input[name=courseid]').value, + categoryidnumber: document.getElementById('id_categoryidnumber').value, + questionidnumber: questionIdnumber, + iframedescription: iframeDescription, + behaviour: document.getElementById('id_behaviour')?.value || '', + maxmark: document.getElementById('id_maxmark')?.value || '', + variant: document.getElementById('id_variant')?.value || '', + correctness: document.getElementById('id_correctness')?.value || '', + marks: document.getElementById('id_marks')?.value || '', + markdp: document.getElementById('id_markdp')?.value || '', + feedback: document.getElementById('id_feedback')?.value || '', + generalfeedback: document.getElementById('id_generalfeedback')?.value || '', + rightanswer: document.getElementById('id_rightanswer')?.value || '', + history: document.getElementById('id_history')?.value || '', + forcedlanguage: document.getElementById('id_forcedlanguage')?.value || '' + } + }])[0]; + }; + + /** + * Handles when we get the embed code from the AJAX request. + * + * @param {String} embedCode - the embed code to insert. + */ + insertEmbedCode = (embedCode) => { + const existingCode = this.getEmbedCodeFromTextSelection(this.editor); + if (existingCode) { + // Replace the existing code. + const parent = this.editor.selection.getNode(); + const text = parent.textContent; + parent.textContent = text.slice(0, existingCode.start) + + embedCode + text.slice(existingCode.end); + } else { + this.editor.insertContent(embedCode); + } + this.currentModal.destroy(); + }; + + /** + * Get the embed code of the current selected text, + * + * @param {TinyMCE} editor + * @returns {boolean|Object} + * return false if we can't find the match pattern. + * return Object {start: start position of the text, end: end position of the text, embedCode: embed code of the string} + */ + getEmbedCodeFromTextSelection = (editor) => { + + // Find the embed code in the surrounding text. + const selection = editor.selection.getSel(), + selectedNode = editor.selection.getNode(), + pattern = /\{Q\{(?:(?!\}Q\}).)*\}Q\}/g; + let text, + returnValue = false, + patternMatches; + + if (!selection) { + return false; + } + const range = selection.rangeCount ? selection.getRangeAt(0) : null; + if (!range) { + return false; + } + text = selectedNode.textContent; + patternMatches = text.match(pattern); + + if (!patternMatches || !patternMatches.length) { + return false; + } + // This pattern matches at least once. See if this pattern matches our current position. + // Note: We return here to break the Y.Array.find loop - any truthy return will stop any subsequent + // searches which is the required behaviour of this function. + for (let i = 0; i < patternMatches.length; ++i) { + let startIndex = 0; + while (text.indexOf(patternMatches[i], startIndex) !== -1) { + // Determine whether the cursor is in the current occurrence of this string. + // Note: We do not support a selection exceeding the bounds of an equation. + const start = text.indexOf(patternMatches[i], startIndex), + end = start + patternMatches[i].length, + startMatches = (selection.anchorOffset >= start && selection.anchorOffset < end), + endMatches = (selection.focusOffset <= end && selection.focusOffset > start), + reserveStartMatches = (selection.anchorOffset <= end && selection.anchorOffset > start), + reserveEndMatches = (selection.focusOffset >= start && selection.focusOffset < end); + if ((startMatches && endMatches) || (reserveStartMatches && reserveEndMatches)) { + // Save all data for later. + returnValue = { + // Outer match data. + start: start, + end: end, + embedCode: patternMatches[i] + }; + + // This breaks out the loop + break; + } + + // Update the startIndex to match the end of the current match so that we can continue hunting + // for further matches. + startIndex = end; + } + } + return returnValue; + }; +}; diff --git a/amd/src/options.js b/amd/src/options.js new file mode 100644 index 0000000..183da2a --- /dev/null +++ b/amd/src/options.js @@ -0,0 +1,47 @@ +// This file is part of Moodle - https://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 . + +/** + * Option helper for the Moodle tiny_embedquestion plugin. + * + * @module tiny_embedquestion/options + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +import {getPluginOptionName} from 'editor_tiny/options'; +import {pluginName} from './common'; +const relevantContextId = getPluginOptionName(pluginName, 'relevantContextId'); + +/** + * Register the options for the Tiny Equation plugin. + * + * @param {TinyMCE} editor + */ +export const register = (editor) => { + const registerOption = editor.options.register; + + registerOption(relevantContextId, { + processor: 'number', + }); + +}; + +/** + * Get the context id for the Tiny embed question plugin. + * + * @param {TinyMCE} editor + * @returns {Number} - context id + */ +export const getRelevantContextId = (editor) => editor.options.get(relevantContextId); diff --git a/amd/src/plugin.js b/amd/src/plugin.js index 20eed13..938ae24 100644 --- a/amd/src/plugin.js +++ b/amd/src/plugin.js @@ -14,35 +14,46 @@ // along with Moodle. If not, see . /** - * Tiny tiny_embedquestion for Moodle. + * Embed question plugin for TinyMCE. * - * @module plugintype_pluginname/plugin - * @copyright 2023 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @module tiny_embedquestion + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import {getTinyMCE} from 'editor_tiny/loader'; import {getPluginMetadata} from 'editor_tiny/utils'; - import {component, pluginName} from './common'; +import {getSetup as getCommandSetup} from './commands'; +import * as Configuration from './configuration'; +import * as Options from "./options"; // Setup the tiny_embedquestion Plugin. +// eslint-disable-next-line no-async-promise-executor export default new Promise(async(resolve) => { // Note: The PluginManager.add function does not support asynchronous configuration. // Perform any asynchronous configuration here, and then call the PluginManager.add function. const [ tinyMCE, pluginMetadata, + setupCommands, ] = await Promise.all([ getTinyMCE(), getPluginMetadata(component, pluginName), + getCommandSetup(), ]); // Reminder: Any asynchronous code must be run before this point. tinyMCE.PluginManager.add(pluginName, (editor) => { + + // Register any options that your plugin has + Options.register(editor); + // Setup any commands such as buttons, menu items, and so on. + setupCommands(editor); + // Return the pluginMetadata object. This is used by TinyMCE to display a help link for your plugin. return pluginMetadata; }); - resolve(pluginName); + resolve([pluginName, Configuration]); }); diff --git a/changes.md b/changes.md new file mode 100644 index 0000000..4f61683 --- /dev/null +++ b/changes.md @@ -0,0 +1,3 @@ +## Changes in 1.0 + +* Initial release. diff --git a/classes/plugininfo.php b/classes/plugininfo.php index 99ed05f..ad9f916 100644 --- a/classes/plugininfo.php +++ b/classes/plugininfo.php @@ -18,13 +18,44 @@ use context; use editor_tiny\plugin; +use editor_tiny\plugin_with_buttons; +use context_course; +use editor_tiny\plugin_with_configuration; /** - * Tiny Tiny plugin for Moodle. + * Tiny Embed question plugin for Moodle. * - * @package tiny_embedquestion - * @copyright 2023 The Open University - * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package tiny_embedquestion + * @copyright 2024 The Open University + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class plugininfo extends plugin { +class plugininfo extends plugin implements plugin_with_buttons, plugin_with_configuration { + + public static function is_enabled(context $context, array $options, array $fpoptions, + ?\editor_tiny\editor $editor = null): bool { + // Users must have permission to embed content. + // Get the course context, this is the only context we use. + $context = context_course::instance(\filter_embedquestion\utils::get_relevant_courseid($context)); + return has_any_capability(['moodle/question:useall', 'moodle/question:usemine'], $context); + } + + public static function get_available_buttons(): array { + return [ + 'tiny_embedquestion', + ]; + } + + public static function get_plugin_configuration_for_context( + context $context, + array $options, + array $fpoptions, + ?\editor_tiny\editor $editor = null + ): array { + // Get the course context, this is the only context we use. + $context = \context_course::instance( + \filter_embedquestion\utils::get_relevant_courseid($context)); + return [ + 'relevantContextId' => $context->id, + ]; + } } diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index 526b013..0ee10b6 100644 --- a/classes/privacy/provider.php +++ b/classes/privacy/provider.php @@ -1,5 +1,5 @@ . +// along with Moodle. If not, see . namespace tiny_embedquestion\privacy; -use core_privacy\local\metadata\null_provider; - /** - * Privacy Subsystem implementation for tiny_embedquestion. + * Privacy Subsystem implementation for the Tiny embed question for TinyMCE. * - * @package tiny_embedquestion - * @copyright 2023 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package tiny_embedquestion + * @copyright 2024 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class provider implements null_provider { - public static function get_reason() : string { +class provider implements \core_privacy\local\metadata\null_provider { + + public static function get_reason(): string { return 'privacy:metadata'; } } diff --git a/lang/en/tiny_embedquestion.php b/lang/en/tiny_embedquestion.php index 6aabcec..0e9b0b6 100644 --- a/lang/en/tiny_embedquestion.php +++ b/lang/en/tiny_embedquestion.php @@ -17,15 +17,12 @@ /** * Plugin strings are defined here. * - * @package tiny_embedquestion - * @category string - * @copyright 2023 The Open University - * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package tiny_embedquestion + * @copyright 2024 The Open University + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -defined('MOODLE_INTERNAL') || die(); - $string['embedqcode'] = 'Embed question code'; $string['loading'] = 'Loading...'; $string['pluginname'] = 'Embed question'; -$string['privacy:metadata'] = 'The TinyMCE embed question plugin does not store any personal data.'; +$string['privacy:metadata'] = 'The Tiny Embed question plugin does not store any personal data.'; diff --git a/lib.php b/lib.php new file mode 100644 index 0000000..ce3c301 --- /dev/null +++ b/lib.php @@ -0,0 +1,58 @@ +. + +/** + * Tiny text editor library file. + * + * @package tiny_embedquestion + * @copyright 2024 The Open University + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use filter_embedquestion\form\embed_options_form; + +/** + * Server side controller used by core Fragment javascript to return a moodle form html. + * This is used for the question selection form displayed in the embedquestion atto dialogue. + * Reference https://docs.moodle.org/dev/Fragment. + * Based on similar function in mod/assign/lib.php. + * + * @param array $args Must contain contextid + * @return string + */ +function tiny_embedquestion_output_fragment_questionselector(array $args): string { + global $CFG; + require_once($CFG->dirroot . '/filter/embedquestion/filter.php'); + $context = context::instance_by_id($args['contextId']); + $mform = new embed_options_form(null, ['context' => $context]); + + $currentvalue = $args['embedCode']; + if ($currentvalue && preg_match(filter_embedquestion::get_filter_regexp(), $currentvalue, $matches)) { + + [$embedid, $toform] = filter_embedquestion::parse_embed_code($matches[1]); + if ($embedid !== null) { + $toform['questionidnumber'] = $embedid->questionidnumber; + $toform['categoryidnumber'] = $embedid->categoryidnumber; + // Decode iframedescription data to form. + if (isset($toform['iframedescription'])) { + $toform['iframedescription'] = base64_decode($toform['iframedescription']); + } + $mform->set_data($toform); + } + } + + return $mform->render(); +} diff --git a/pix/icon.svg b/pix/icon.svg new file mode 100644 index 0000000..7ad28ce --- /dev/null +++ b/pix/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/behat/embedquestion.feature b/tests/behat/embedquestion.feature new file mode 100644 index 0000000..de08e78 --- /dev/null +++ b/tests/behat/embedquestion.feature @@ -0,0 +1,39 @@ +@ou @ou_vle @editor @tiny @editor_tiny @tiny_embedquestion @filter_embedquestion +Feature: Embed question in the Tiny editor + In order to encourage students interacting with ativity and learning from it + As a teacher + I need to insert interactive questions in my content + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher | Terry | Teacher | teacher@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher | C1 | editingteacher | + And the following "question categories" exist: + | contextlevel | reference | name | idnumber | + | Course | C1 | Test questions | embed | + And the following "questions" exist: + | questioncategory | qtype | name | idnumber | + | Test questions | truefalse | First question | test1 | + And the "embedquestion" filter is "on" + + @javascript + Scenario: Test using 'Embed question' button + Given I am on the "Course 1" course page logged in as teacher + And I turn editing mode on + When I add a page activity to course "Course 1" section "1" + And I set the field "Name" to "Test page 01" + And I set the field "Description" to "Test page description" + And I set the field "content" to "Test page content" + And I click on "Embed question" "button" + And I set the field "Question category" to "Test questions [embed] (1)" + And I set the field "id_questionidnumber" to "First question [test1]" + And I click on "Embed question" "button" in the "Embed question" "dialogue" + And I switch to the "Description" TinyMCE editor iframe + Then I should see "{Q{embed/test1|" + And I should see "}Q}Test page description" diff --git a/version.php b/version.php index 408f627..0c12436 100644 --- a/version.php +++ b/version.php @@ -17,19 +17,18 @@ /** * Plugin version and other meta-data are defined here. * - * @package tiny_embedquestion - * @copyright 2023 The Open University - * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package tiny_embedquestion + * @copyright 2024 The Open University + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'tiny_embedquestion'; -$plugin->release = '0.1.0'; -$plugin->version = 2023091800; -$plugin->requires = 2022112800; -$plugin->maturity = MATURITY_ALPHA; - -$plugin->dependencies = ['filter_embedquestion' => 2022032900]; - -$plugin->outestssufficient = true; +$plugin->release = '1.0'; +$plugin->version = 2024011100; +$plugin->requires = 2020061500; +$plugin->maturity = MATURITY_STABLE; +$plugin->dependencies = [ + 'filter_embedquestion' => 2022032900, +];