diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f4373ee..12dcfb3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -277,6 +277,22 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Verify the vendored external-media artifact
+ # eXeLearning core is canonical (eXe ADR-2199-12): this repo holds the BYTES and
+ # verifies them against the manifest core published, rather than a copy of the
+ # logic that could drift. --build-hash makes it a PROVENANCE check and not merely
+ # an integrity one: without it a locally rebuilt-and-resigned copy would pass.
+ #
+ # If this fails, a vendored file was edited here. Fix it in eXeLearning core and
+ # re-vendor; a local patch is invisible upstream and is overwritten next time.
+ run: |
+ # The expected hash is PINNED HERE, deliberately, and not read from the vendored
+ # manifest. A hash taken from the copy under test cannot say anything about that
+ # copy: file, digest and buildHash can all be rewritten together, and a check that
+ # trusted them would pass a consistent forgery. This line is the out-of-band half.
+ # Bump it in the same commit that re-vendors the artifact.
+ node js/exe_external_media/verify.mjs js/exe_external_media --build-hash "43c776daa551fd770b6dade520629362e5a8ca8745e4245c359447c4689b34d6"
+
- name: JS unit tests with coverage
run: npx vitest run --coverage --reporter=default --reporter=junit --outputFile=junit-js.xml
diff --git a/.gitignore b/.gitignore
index a42a178..88e6968 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,4 +29,8 @@ node_modules/
# JS test artifacts (Vitest)
coverage/
junit-js.xml
+
+# E2E artifacts (Playwright)
+test-results/
+playwright-report/
.env
diff --git a/README.md b/README.md
index a6ca3ed..725a693 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,39 @@ editor remains):
enable/disable the uploaded and built-in styles, and optionally block users
from importing styles bundled inside an `.elpx` — all on this page.
* **xAPI**: master switch for the xAPI-primary grading channel.
+* **Package iframe security mode** (`iframemode`, default **Secure**): in _Secure_
+ mode the eXeLearning package runs in a sandboxed, **opaque-origin** iframe so its
+ JavaScript cannot read or modify the surrounding Moodle page, its cookies or the
+ session; SCORM scoring is relayed to Moodle over a validated `postMessage` bridge,
+ and the package is served via `tokenpluginfile.php` so its assets load without the
+ session cookie (which an opaque iframe never sends). The package only receives a
+ read-only file token — never the `sesskey` — so Secure is strictly safer than
+ Legacy. _Legacy_ keeps the previous same-origin behaviour as an opt-in fallback.
+ Secure mode is **never silently downgraded**: where it cannot render (e.g. a host
+ whose service worker can't serve an opaque iframe, such as a PHP-WASM playground), a
+ "blocked by security configuration" notice is shown instead of falling back to
+ Legacy. See
+ [DEC-80-01](./research/decisiones/adr/DEC-80-01-bridge-scorm-postmessage-origen-opaco.md)
+ and [DEC-80-02](./research/decisiones/adr/DEC-80-02-iframe-seguro-tokenpluginfile.md).
+
+### External embeds (YouTube/Vimeo/PDF) in Secure mode
+
+The opaque-origin sandbox also blanks **embedded YouTube/Vimeo players and PDFs** (the
+sandbox flag propagates to the nested player iframe, and browsers block their PDF viewer
+without `allow-same-origin`). So that authors can still use external media in Secure mode
+**without a separate subdomain**, those embeds are **promoted to the Moodle page and
+rendered inline**: a shim baked into the package (`js/exe_embed_shim.js`, self-activating
+only in the opaque origin) replaces each whitelisted-video / `.pdf` iframe with a
+placeholder and reports its geometry to the parent; a relay on the activity page
+(`js/exe_embed_relay.js`) validates + rebuilds the canonical URL and overlays the real
+player exactly over the placeholder. Local package PDFs always render; any `https` `.pdf`
+renders; a same-origin `.pdf` must belong to the package (served as `application/pdf`).
+This is independent of, and does not affect, the SCORM bridge. See
+[DEC-80-03](./research/decisiones/adr/DEC-80-03-embeds-externos-promote-to-parent.md).
+
+The mechanism has unit tests (`npm run test:js` — Vitest, incl. a SCORM-coexistence
+guard) and a cross-browser end-to-end test in Firefox (`npm run test:e2e:embed` —
+Playwright, loads the real shim/relay against an opaque-origin harness).
## The embedded editor is a release artifact
@@ -199,10 +232,18 @@ attempts, and delete attempts from the teacher report (the grade is
recalculated). Completion can require a passing grade (SCORM-style, see
[DEC-0-10](./research/decisiones/adr/DEC-0-10-finalizacion-estilo-scorm.md)).
-Grading runtime uses a SCORM 1.2 bridge: a small `window.API` shim installed by
-`view.php` accepts `LMSSetValue` calls from the iDevice's bundled pipwerks
-wrapper and forwards them to `track.php`, which calls Moodle's `grade_update()`.
-xAPI support via `core_xapi` is on the roadmap.
+Grading runtime uses a SCORM 1.2 bridge whose isolation depends on the **package
+iframe security mode**
+([DEC-80-01](./research/decisiones/adr/DEC-80-01-bridge-scorm-postmessage-origen-opaco.md)).
+In the default **Secure** mode the package runs in an opaque-origin sandboxed iframe
+served via `tokenpluginfile.php` (so its assets load without the session cookie): a
+`window.API` shim lives _inside_ the iframe and posts buffered scores to the Moodle page
+over a validated `postMessage` channel; the page (which holds the `sesskey`) forwards
+them to `track.php`, which calls Moodle's `grade_update()`. In **Legacy** mode the shim
+is installed by `view.php` in the same-origin parent and the iDevice's bundled pipwerks
+wrapper reaches it directly. See
+[DEC-80-02](./research/decisiones/adr/DEC-80-02-iframe-seguro-tokenpluginfile.md) for the
+secure serving + CSP hardening. xAPI support via `core_xapi` is on the roadmap.
## Web services (Mobile API)
diff --git a/amd/build/editor_modal.min.js b/amd/build/editor_modal.min.js
index adf760f..9ac01d2 100644
--- a/amd/build/editor_modal.min.js
+++ b/amd/build/editor_modal.min.js
@@ -1,3 +1,3 @@
-define("mod_exelearning/editor_modal",["exports","core/str","core/log"],(function(_exports,_str,_log){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.open=_exports.init=_exports.close=void 0,_log=(obj=_log)&&obj.__esModule?obj:{default:obj};let overlay=null,iframe=null,saveBtn=null,loadingModal=null,editorOrigin="*",openRequestSent=!1,openRequestId=null,exportRequestId=null,isSaving=!1,hasUnsavedChanges=!1,session=null,requestCounter=0,openAttemptCount=0,openResponseTimer=null;const nextRequestId=prefix=>(requestCounter+=1,"".concat(prefix,"-").concat(Date.now(),"-").concat(requestCounter)),refreshActivityIframe=revision=>{const activityIframe=document.getElementById("exelearningobject");if(!activityIframe||!activityIframe.src)return;const refreshedSrc=((url,revision)=>{if(!url||!revision)return url;const normalizedRevision=String(revision).replace(/[^0-9]/g,"");if(!normalizedRevision)return url;const updated=url.replace(/(\/mod_exelearning\/content\/)(\d+)(\/)/,"$1".concat(normalizedRevision,"$3"));if(updated===url)return url;const separator=updated.includes("?")?"&":"?";return"".concat(updated).concat(separator,"v=").concat(normalizedRevision,"-").concat(Date.now())})(activityIframe.src,revision);refreshedSrc&&refreshedSrc!==activityIframe.src&&(activityIframe.src=refreshedSrc)},setSaveLabel=async(key,fallback)=>{if(saveBtn)try{const text=await(0,_str.get_string)(key,"closebuttontitle"===key?"core":"mod_exelearning");saveBtn.innerHTML=' '+text}catch{saveBtn.innerHTML=' '+fallback}},showLoadingModal=async()=>{loadingModal||(loadingModal=await(async()=>{const modal=document.createElement("div");modal.className="exeweb-loading-modal",modal.id="exeweb-loading-modal";let savingText="Saving...",waitText="Please wait while the file is being saved.";try{savingText=await(0,_str.get_string)("saving","mod_exelearning"),waitText=await(0,_str.get_string)("savingwait","mod_exelearning")}catch{}return modal.innerHTML='\n
\n
\n
'.concat(savingText,'
\n
').concat(waitText,"
\n
\n "),document.body.appendChild(modal),modal})()),loadingModal.classList.add("is-visible")},hideLoadingModal=()=>{loadingModal&&loadingModal.classList.remove("is-visible")},removeLoadingModal=()=>{loadingModal&&(loadingModal.remove(),loadingModal=null)},postToEditor=(message,transfer)=>{var _iframe;null!==(_iframe=iframe)&&void 0!==_iframe&&_iframe.contentWindow&&(transfer&&transfer.length?iframe.contentWindow.postMessage(message,editorOrigin,transfer):iframe.contentWindow.postMessage(message,editorOrigin))},clearOpenResponseTimer=()=>{openResponseTimer&&(clearTimeout(openResponseTimer),openResponseTimer=null)},scheduleOpenRetry=()=>{openAttemptCount>=3||setTimeout((()=>{openInitialPackage()}),300*openAttemptCount)},setSavingState=async saving=>{isSaving=saving,saveBtn&&(saveBtn.disabled=saving,saving?(await setSaveLabel("saving","Saving..."),await showLoadingModal()):(await setSaveLabel("savetomoodle","Save to Moodle"),hideLoadingModal()))},openInitialPackage=async()=>{var _session,_session2;if((null===(_session=session)||void 0===_session||!_session.skipOpenFileOnInit)&&!openRequestSent&&null!==(_session2=session)&&void 0!==_session2&&_session2.packageUrl){openRequestSent=!0,openAttemptCount+=1;try{const response=await fetch(session.packageUrl,{credentials:"include"});if(!response.ok)throw new Error("HTTP ".concat(response.status,": ").concat(response.statusText));const bytes=await response.arrayBuffer();openRequestId=nextRequestId("open"),postToEditor({type:"OPEN_FILE",requestId:openRequestId,data:{bytes:bytes,filename:"package.elpx"}}),clearOpenResponseTimer(),openResponseTimer=setTimeout((()=>{openRequestSent&&(_log.default.error("[editor_modal] OPEN_FILE timeout waiting for response"),openRequestSent=!1,scheduleOpenRetry())}),3e3)}catch(error){_log.default.error("[editor_modal] Failed to open package:",error),openRequestSent=!1,scheduleOpenRetry()}}},uploadExportedFile=async payload=>{const bytes=null==payload?void 0:payload.bytes;if(!bytes)throw new Error("Missing export bytes");const filename=payload.filename||"package.elpx",mimeType=payload.mimeType||"application/zip",blob=new Blob([bytes],{type:mimeType}),formData=new FormData;formData.append("package",blob,filename),formData.append("format","elpx"),formData.append("cmid",String(session.cmid)),formData.append("sesskey",session.sesskey);const response=await fetch(session.saveUrl,{method:"POST",body:formData,credentials:"include"}),result=await response.json();if(!response.ok||null==result||!result.success)throw new Error((null==result?void 0:result.error)||"Save failed (".concat(response.status,")"));(newUrl=>{if(!newUrl||!session)return;session.packageUrl=newUrl;const selector='[data-action="mod_exelearning/editor-open"][data-cmid="'.concat(String(session.cmid),'"]'),openButton=document.querySelector(selector);null!=openButton&&openButton.dataset&&(openButton.dataset.packageurl=newUrl)})(((url,revision)=>{if(!url||!revision)return url;const normalizedRevision=String(revision).replace(/[^0-9]/g,"");if(!normalizedRevision)return url;const updated=url.replace(/(\/mod_exelearning\/package\/)(\d+)(\/)/,"$1".concat(normalizedRevision,"$3"));if(updated===url)return url;const separator=updated.includes("?")?"&":"?";return"".concat(updated).concat(separator,"v=").concat(normalizedRevision,"-").concat(Date.now())})(session.packageUrl,result.revision)),refreshActivityIframe(result.revision),hasUnsavedChanges=!1,await setSaveLabel("savedsuccess","Saved successfully"),close(!0),window.location.reload()},requestExport=async()=>{var _iframe2;!isSaving&&null!==(_iframe2=iframe)&&void 0!==_iframe2&&_iframe2.contentWindow&&(await setSavingState(!0),exportRequestId=nextRequestId("export"),postToEditor({type:"REQUEST_EXPORT",requestId:exportRequestId,data:{format:"elpx",filename:"package.elpx"}}))},isEditorBridgeMessage=event=>{var _iframe3;return!(null===(_iframe3=iframe)||void 0===_iframe3||!_iframe3.contentWindow||event.source!==iframe.contentWindow||!event.data)&&("*"===editorOrigin||event.origin===editorOrigin)},handleMessage=async event=>{await(async event=>{if(!isEditorBridgeMessage(event))return;const data=event.data;switch(data.type){case"EXELEARNING_READY":postToEditor({type:"CONFIGURE",requestId:nextRequestId("configure"),data:{hideUI:{fileMenu:!0,saveButton:!0,userMenu:!0}}}),openInitialPackage();break;case"DOCUMENT_LOADED":saveBtn&&!isSaving&&(saveBtn.disabled=!1);break;case"DOCUMENT_CHANGED":hasUnsavedChanges=!0;break;case"OPEN_FILE_SUCCESS":data.requestId===openRequestId&&saveBtn&&!isSaving&&(saveBtn.disabled=!1,openRequestSent=!1,openAttemptCount=0,clearOpenResponseTimer());break;case"OPEN_FILE_ERROR":data.requestId===openRequestId&&(_log.default.error("[editor_modal] OPEN_FILE_ERROR:",data.error),openRequestSent=!1,clearOpenResponseTimer(),scheduleOpenRetry());break;case"EXPORT_FILE":if(data.requestId===exportRequestId)try{await uploadExportedFile(data)}catch(error){_log.default.error("[editor_modal] Upload failed:",error),await setSavingState(!1)}break;case"REQUEST_EXPORT_ERROR":data.requestId===exportRequestId&&(_log.default.error("[editor_modal] REQUEST_EXPORT_ERROR:",data.error),await setSavingState(!1))}})(event),await(async event=>{var _data$data;if(!isEditorBridgeMessage(event))return;const data=event.data;if(data&&"exeweb-editor"===data.source){if("editor-ready"===data.type&&null!==(_data$data=data.data)&&void 0!==_data$data&&_data$data.packageUrl){var _session3;const incomingUrl=data.data.packageUrl;incomingUrl!==(null===(_session3=session)||void 0===_session3?void 0:_session3.packageUrl)&&(session.packageUrl=incomingUrl,openRequestSent=!1,openAttemptCount=0),openInitialPackage()}"request-save"===data.type&&await requestExport()}})(event)},handleKeydown=event=>{"Escape"===event.key&&close(!1)},close=async skipConfirm=>{if(!overlay)return;if(!skipConfirm){if(await(async()=>{if(!hasUnsavedChanges)return!1;let message="You have unsaved changes. Are you sure you want to close?";try{message=await(0,_str.get_string)("unsavedchanges","mod_exelearning")}catch{}return!window.confirm(message)})())return}const wasShowingLoader=isSaving||!0===skipConfirm;overlay.remove(),overlay=null,iframe=null,saveBtn=null,session=null,openRequestSent=!1,openRequestId=null,exportRequestId=null,isSaving=!1,hasUnsavedChanges=!1,openAttemptCount=0,clearOpenResponseTimer(),document.body.style.overflow="",window.removeEventListener("message",handleMessage),document.removeEventListener("keydown",handleKeydown),wasShowingLoader?setTimeout((()=>{hideLoadingModal(),removeLoadingModal()}),1500):(hideLoadingModal(),removeLoadingModal())};_exports.close=close;const open=async(cmid,editorUrl,activityName,packageUrl,saveUrl,sesskey)=>{if(_log.default.debug("[editor_modal] Opening editor for cmid:",cmid),overlay)return;editorOrigin=(url=>{try{return new URL(url,window.location.href).origin}catch{return"*"}})(editorUrl),openAttemptCount=0,session={cmid:cmid,editorUrl:editorUrl,packageUrl:packageUrl||"",skipOpenFileOnInit:!!packageUrl,saveUrl:saveUrl,sesskey:sesskey},overlay=document.createElement("div"),overlay.id="exeweb-editor-overlay",overlay.className="exeweb-editor-overlay";const header=document.createElement("div");header.className="exeweb-editor-header";const title=document.createElement("span");title.className="exeweb-editor-title",title.textContent=activityName||"",header.appendChild(title);const buttonGroup=document.createElement("div");buttonGroup.className="exeweb-editor-buttons",saveBtn=document.createElement("button"),saveBtn.className="btn btn-primary mr-2",saveBtn.id="exeweb-editor-save",saveBtn.disabled=!0,await setSaveLabel("savetomoodle","Save to Moodle"),saveBtn.addEventListener("click",requestExport);const closeBtn=document.createElement("button");closeBtn.className="btn btn-secondary",closeBtn.id="exeweb-editor-close";try{closeBtn.textContent=await(0,_str.get_string)("closebuttontitle","core")}catch{closeBtn.textContent="Close"}closeBtn.addEventListener("click",(()=>close(!1))),buttonGroup.appendChild(saveBtn),buttonGroup.appendChild(closeBtn),header.appendChild(buttonGroup),overlay.appendChild(header),iframe=document.createElement("iframe"),iframe.className="exeweb-editor-iframe",iframe.src=editorUrl,iframe.setAttribute("allow","fullscreen"),iframe.setAttribute("frameborder","0"),iframe.addEventListener("load",(()=>{var _session4;!openRequestSent&&null!==(_session4=session)&&void 0!==_session4&&_session4.packageUrl&&openInitialPackage()})),overlay.appendChild(iframe),document.body.appendChild(overlay),document.body.style.overflow="hidden",window.addEventListener("message",handleMessage),document.addEventListener("keydown",handleKeydown)};_exports.open=open;_exports.init=()=>{document.addEventListener("click",(event=>{const button=event.target.closest('[data-action="mod_exelearning/editor-open"]');button&&(event.preventDefault(),open(button.dataset.cmid,button.dataset.editorurl,button.dataset.activityname,button.dataset.packageurl,button.dataset.saveurl,button.dataset.sesskey))}))}}));
+define("mod_exelearning/editor_modal",["exports","core/str","core/log"],(function(_exports,_str,_log){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.open=_exports.init=_exports.close=void 0,_log=(obj=_log)&&obj.__esModule?obj:{default:obj};let overlay=null,iframe=null,saveBtn=null,loadingModal=null,editorOrigin="*",openRequestSent=!1,openRequestId=null,exportRequestId=null,isSaving=!1,hasUnsavedChanges=!1,session=null,requestCounter=0,openAttemptCount=0,openResponseTimer=null;const nextRequestId=prefix=>(requestCounter+=1,"".concat(prefix,"-").concat(Date.now(),"-").concat(requestCounter)),refreshActivityIframe=revision=>{const activityIframe=document.getElementById("exelearningobject");if(!activityIframe||!activityIframe.src)return;const refreshedSrc=((url,revision)=>{if(!url||!revision)return url;const normalizedRevision=String(revision).replace(/[^0-9]/g,"");if(!normalizedRevision)return url;const updated=url.replace(/(\/mod_exelearning\/content\/)(\d+)(\/)/,"$1".concat(normalizedRevision,"$3"));if(updated===url)return url;const separator=updated.includes("?")?"&":"?";return"".concat(updated).concat(separator,"v=").concat(normalizedRevision,"-").concat(Date.now())})(activityIframe.src,revision);refreshedSrc&&refreshedSrc!==activityIframe.src&&(activityIframe.src=refreshedSrc)},setSaveLabel=async(key,fallback)=>{if(saveBtn)try{const text=await(0,_str.get_string)(key,"closebuttontitle"===key?"core":"mod_exelearning");saveBtn.innerHTML=' '+text}catch{saveBtn.innerHTML=' '+fallback}},showLoadingModal=async()=>{loadingModal||(loadingModal=await(async()=>{const modal=document.createElement("div");modal.className="exeweb-loading-modal",modal.id="exeweb-loading-modal";let savingText="Saving...",waitText="Please wait while the file is being saved.";try{savingText=await(0,_str.get_string)("saving","mod_exelearning"),waitText=await(0,_str.get_string)("savingwait","mod_exelearning")}catch{}return modal.innerHTML='\n \n
\n
'.concat(savingText,'
\n
').concat(waitText,"
\n
\n "),document.body.appendChild(modal),modal})()),loadingModal.classList.add("is-visible")},hideLoadingModal=()=>{loadingModal&&loadingModal.classList.remove("is-visible")},removeLoadingModal=()=>{loadingModal&&(loadingModal.remove(),loadingModal=null)},postToEditor=(message,transfer)=>{var _iframe;null!==(_iframe=iframe)&&void 0!==_iframe&&_iframe.contentWindow&&(transfer&&transfer.length?iframe.contentWindow.postMessage(message,editorOrigin,transfer):iframe.contentWindow.postMessage(message,editorOrigin))},clearOpenResponseTimer=()=>{openResponseTimer&&(clearTimeout(openResponseTimer),openResponseTimer=null)},scheduleOpenRetry=()=>{openAttemptCount>=3||setTimeout((()=>{openInitialPackage()}),300*openAttemptCount)},setSavingState=async saving=>{isSaving=saving,saveBtn&&(saveBtn.disabled=saving,saving?(await setSaveLabel("saving","Saving..."),await showLoadingModal()):(await setSaveLabel("savetomoodle","Save to Moodle"),hideLoadingModal()))},openInitialPackage=async()=>{var _session,_session2;if((null===(_session=session)||void 0===_session||!_session.skipOpenFileOnInit)&&!openRequestSent&&null!==(_session2=session)&&void 0!==_session2&&_session2.packageUrl){openRequestSent=!0,openAttemptCount+=1;try{const response=await fetch(session.packageUrl,{credentials:"include"});if(!response.ok)throw new Error("HTTP ".concat(response.status,": ").concat(response.statusText));const bytes=await response.arrayBuffer();openRequestId=nextRequestId("open"),postToEditor({type:"OPEN_FILE",requestId:openRequestId,data:{bytes:bytes,filename:"package.elpx"}}),clearOpenResponseTimer(),openResponseTimer=setTimeout((()=>{openRequestSent&&(_log.default.error("[editor_modal] OPEN_FILE timeout waiting for response"),openRequestSent=!1,scheduleOpenRetry())}),3e3)}catch(error){_log.default.error("[editor_modal] Failed to open package:",error),openRequestSent=!1,scheduleOpenRetry()}}},uploadExportedFile=async payload=>{const bytes=null==payload?void 0:payload.bytes;if(!bytes)throw new Error("Missing export bytes");const filename=payload.filename||"package.elpx",mimeType=payload.mimeType||"application/zip",blob=new Blob([bytes],{type:mimeType}),formData=new FormData;formData.append("package",blob,filename),formData.append("format","elpx"),formData.append("cmid",String(session.cmid)),formData.append("sesskey",session.sesskey);const response=await fetch(session.saveUrl,{method:"POST",body:formData,credentials:"include"}),result=await response.json();if(!response.ok||null==result||!result.success)throw new Error((null==result?void 0:result.error)||"Save failed (".concat(response.status,")"));(newUrl=>{if(!newUrl||!session)return;session.packageUrl=newUrl;const selector='[data-action="mod_exelearning/editor-open"][data-cmid="'.concat(String(session.cmid),'"]'),openButton=document.querySelector(selector);null!=openButton&&openButton.dataset&&(openButton.dataset.packageurl=newUrl)})(((url,revision)=>{if(!url||!revision)return url;const normalizedRevision=String(revision).replace(/[^0-9]/g,"");if(!normalizedRevision)return url;const updated=url.replace(/(\/mod_exelearning\/package\/)(\d+)(\/)/,"$1".concat(normalizedRevision,"$3"));if(updated===url)return url;const separator=updated.includes("?")?"&":"?";return"".concat(updated).concat(separator,"v=").concat(normalizedRevision,"-").concat(Date.now())})(session.packageUrl,result.revision)),refreshActivityIframe(result.revision),hasUnsavedChanges=!1,await setSaveLabel("savedsuccess","Saved successfully"),close(!0),window.location.reload()},requestExport=async()=>{var _iframe2;!isSaving&&null!==(_iframe2=iframe)&&void 0!==_iframe2&&_iframe2.contentWindow&&(await setSavingState(!0),exportRequestId=nextRequestId("export"),postToEditor({type:"REQUEST_EXPORT",requestId:exportRequestId,data:{format:"elpx",filename:"package.elpx"}}))},isEditorBridgeMessage=event=>{var _iframe3;return!(null===(_iframe3=iframe)||void 0===_iframe3||!_iframe3.contentWindow||event.source!==iframe.contentWindow||!event.data)&&("*"===editorOrigin||event.origin===editorOrigin)},handleMessage=async event=>{await(async event=>{if(!isEditorBridgeMessage(event))return;const data=event.data;switch(data.type){case"EXELEARNING_READY":postToEditor({type:"CONFIGURE",requestId:nextRequestId("configure"),data:{hideUI:{fileMenu:!0,saveButton:!0,userMenu:!0}}}),openInitialPackage();break;case"DOCUMENT_LOADED":saveBtn&&!isSaving&&(saveBtn.disabled=!1);break;case"DOCUMENT_CHANGED":hasUnsavedChanges=!0;break;case"OPEN_FILE_SUCCESS":data.requestId===openRequestId&&saveBtn&&!isSaving&&(saveBtn.disabled=!1,openRequestSent=!1,openAttemptCount=0,clearOpenResponseTimer());break;case"OPEN_FILE_ERROR":data.requestId===openRequestId&&(_log.default.error("[editor_modal] OPEN_FILE_ERROR:",data.error),openRequestSent=!1,clearOpenResponseTimer(),scheduleOpenRetry());break;case"EXPORT_FILE":if(data.requestId===exportRequestId)try{await uploadExportedFile(data)}catch(error){_log.default.error("[editor_modal] Upload failed:",error),await setSavingState(!1)}break;case"REQUEST_EXPORT_ERROR":data.requestId===exportRequestId&&(_log.default.error("[editor_modal] REQUEST_EXPORT_ERROR:",data.error),await setSavingState(!1))}})(event),await(async event=>{var _data$data;if(!isEditorBridgeMessage(event))return;const data=event.data;if(data&&"exeweb-editor"===data.source){if("editor-ready"===data.type&&null!==(_data$data=data.data)&&void 0!==_data$data&&_data$data.packageUrl){var _session3;const incomingUrl=data.data.packageUrl;incomingUrl!==(null===(_session3=session)||void 0===_session3?void 0:_session3.packageUrl)&&(session.packageUrl=incomingUrl,openRequestSent=!1,openAttemptCount=0),openInitialPackage()}"request-save"===data.type&&await requestExport()}})(event)},handleKeydown=event=>{"Escape"===event.key&&close(!1)},close=async skipConfirm=>{if(!overlay)return;if(!skipConfirm){if(await(async()=>{if(!hasUnsavedChanges)return!1;let message="You have unsaved changes. Are you sure you want to close?";try{message=await(0,_str.get_string)("unsavedchanges","mod_exelearning")}catch{}return!window.confirm(message)})())return}const wasShowingLoader=isSaving||!0===skipConfirm;overlay.remove(),overlay=null,iframe=null,saveBtn=null,session=null,openRequestSent=!1,openRequestId=null,exportRequestId=null,isSaving=!1,hasUnsavedChanges=!1,openAttemptCount=0,clearOpenResponseTimer(),document.body.style.overflow="",window.removeEventListener("message",handleMessage),document.removeEventListener("keydown",handleKeydown),wasShowingLoader?setTimeout((()=>{hideLoadingModal(),removeLoadingModal()}),1500):(hideLoadingModal(),removeLoadingModal())};_exports.close=close;const open=async(cmid,editorUrl,activityName,packageUrl,saveUrl,sesskey)=>{if(_log.default.debug("[editor_modal] Opening editor for cmid:",cmid),overlay)return;window.exeMediaHost&&"function"==typeof window.exeMediaHost.closeAll&&window.exeMediaHost.closeAll(),editorOrigin=(url=>{try{return new URL(url,window.location.href).origin}catch{return"*"}})(editorUrl),openAttemptCount=0,session={cmid:cmid,editorUrl:editorUrl,packageUrl:packageUrl||"",skipOpenFileOnInit:!!packageUrl,saveUrl:saveUrl,sesskey:sesskey},overlay=document.createElement("div"),overlay.id="exeweb-editor-overlay",overlay.className="exeweb-editor-overlay";const header=document.createElement("div");header.className="exeweb-editor-header";const title=document.createElement("span");title.className="exeweb-editor-title",title.textContent=activityName||"",header.appendChild(title);const buttonGroup=document.createElement("div");buttonGroup.className="exeweb-editor-buttons",saveBtn=document.createElement("button"),saveBtn.className="btn btn-primary mr-2",saveBtn.id="exeweb-editor-save",saveBtn.disabled=!0,await setSaveLabel("savetomoodle","Save to Moodle"),saveBtn.addEventListener("click",requestExport);const closeBtn=document.createElement("button");closeBtn.className="btn btn-secondary",closeBtn.id="exeweb-editor-close";try{closeBtn.textContent=await(0,_str.get_string)("closebuttontitle","core")}catch{closeBtn.textContent="Close"}closeBtn.addEventListener("click",(()=>close(!1))),buttonGroup.appendChild(saveBtn),buttonGroup.appendChild(closeBtn),header.appendChild(buttonGroup),overlay.appendChild(header),iframe=document.createElement("iframe"),iframe.className="exeweb-editor-iframe",iframe.src=editorUrl,iframe.setAttribute("allow","fullscreen"),iframe.setAttribute("frameborder","0"),iframe.addEventListener("load",(()=>{var _session4;!openRequestSent&&null!==(_session4=session)&&void 0!==_session4&&_session4.packageUrl&&openInitialPackage()})),overlay.appendChild(iframe),document.body.appendChild(overlay),document.body.style.overflow="hidden",window.addEventListener("message",handleMessage),document.addEventListener("keydown",handleKeydown)};_exports.open=open;_exports.init=()=>{document.addEventListener("click",(event=>{const button=event.target.closest('[data-action="mod_exelearning/editor-open"]');button&&(event.preventDefault(),open(button.dataset.cmid,button.dataset.editorurl,button.dataset.activityname,button.dataset.packageurl,button.dataset.saveurl,button.dataset.sesskey))}))}}));
//# sourceMappingURL=editor_modal.min.js.map
\ No newline at end of file
diff --git a/amd/build/editor_modal.min.js.map b/amd/build/editor_modal.min.js.map
index f476c36..38f584f 100644
--- a/amd/build/editor_modal.min.js.map
+++ b/amd/build/editor_modal.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"editor_modal.min.js","sources":["../src/editor_modal.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n\nimport {get_string as getString} from 'core/str';\nimport Log from 'core/log';\n\nlet overlay = null;\nlet iframe = null;\nlet saveBtn = null;\nlet loadingModal = null;\nlet editorOrigin = '*';\nlet openRequestSent = false;\nlet openRequestId = null;\nlet exportRequestId = null;\nlet isSaving = false;\nlet hasUnsavedChanges = false;\nlet session = null;\nlet requestCounter = 0;\nlet openAttemptCount = 0;\nlet openResponseTimer = null;\n\nconst MAX_OPEN_ATTEMPTS = 3;\nconst OPEN_RESPONSE_TIMEOUT_MS = 3000;\nconst FIXED_EXPORT_FORMAT = 'elpx';\n\n/**\n * Resolve the origin of a URL, falling back to a wildcard on failure.\n *\n * @param {string} url The URL to inspect.\n * @returns {string} The resolved origin, or '*' if it cannot be parsed.\n */\nconst getOrigin = (url) => {\n try {\n return new URL(url, window.location.href).origin;\n } catch {\n return '*';\n }\n};\n\n/**\n * Generate a unique request identifier for bridge messages.\n *\n * @param {string} prefix A prefix describing the request type.\n * @returns {string} A unique request identifier.\n */\nconst nextRequestId = (prefix) => {\n requestCounter += 1;\n return `${prefix}-${Date.now()}-${requestCounter}`;\n};\n\n/**\n * Rewrite the revision segment of a package URL and add a cache buster.\n *\n * @param {string} url The original package URL.\n * @param {number|string} revision The new revision number.\n * @returns {string} The updated URL, or the original if it cannot be rewritten.\n */\nconst updatePackageUrlRevision = (url, revision) => {\n if (!url || !revision) {\n return url;\n }\n const normalizedRevision = String(revision).replace(/[^0-9]/g, '');\n if (!normalizedRevision) {\n return url;\n }\n const updated = url.replace(/(\\/mod_exelearning\\/package\\/)(\\d+)(\\/)/, `$1${normalizedRevision}$3`);\n if (updated === url) {\n return url;\n }\n const separator = updated.includes('?') ? '&' : '?';\n return `${updated}${separator}v=${normalizedRevision}-${Date.now()}`;\n};\n\n/**\n * Persist an updated package URL on the session and the open button dataset.\n *\n * @param {string} newUrl The new package URL to store.\n * @returns {void}\n */\nconst persistUpdatedPackageUrl = (newUrl) => {\n if (!newUrl || !session) {\n return;\n }\n session.packageUrl = newUrl;\n const selector = `[data-action=\"mod_exelearning/editor-open\"][data-cmid=\"${String(session.cmid)}\"]`;\n const openButton = document.querySelector(selector);\n if (openButton?.dataset) {\n openButton.dataset.packageurl = newUrl;\n }\n};\n\n/**\n * Rewrite the revision segment of a content URL and add a cache buster.\n *\n * @param {string} url The original content URL.\n * @param {number|string} revision The new revision number.\n * @returns {string} The updated URL, or the original if it cannot be rewritten.\n */\nconst updateContentUrlRevision = (url, revision) => {\n if (!url || !revision) {\n return url;\n }\n const normalizedRevision = String(revision).replace(/[^0-9]/g, '');\n if (!normalizedRevision) {\n return url;\n }\n const updated = url.replace(/(\\/mod_exelearning\\/content\\/)(\\d+)(\\/)/, `$1${normalizedRevision}$3`);\n if (updated === url) {\n return url;\n }\n const separator = updated.includes('?') ? '&' : '?';\n return `${updated}${separator}v=${normalizedRevision}-${Date.now()}`;\n};\n\n/**\n * Refresh the activity content iframe so it points at the new revision.\n *\n * @param {number|string} revision The new revision number.\n * @returns {void}\n */\nconst refreshActivityIframe = (revision) => {\n const activityIframe = document.getElementById('exelearningobject');\n if (!activityIframe || !activityIframe.src) {\n return;\n }\n const refreshedSrc = updateContentUrlRevision(activityIframe.src, revision);\n if (refreshedSrc && refreshedSrc !== activityIframe.src) {\n activityIframe.src = refreshedSrc;\n }\n};\n\n/**\n * Set the save button label from a language string, with a fallback.\n *\n * @param {string} key The language string key.\n * @param {string} fallback The fallback text if the string cannot be loaded.\n * @returns {Promise}\n */\nconst setSaveLabel = async(key, fallback) => {\n if (!saveBtn) {\n return;\n }\n try {\n const text = await getString(key, key === 'closebuttontitle' ? 'core' : 'mod_exelearning');\n saveBtn.innerHTML = ' ' + text;\n } catch {\n saveBtn.innerHTML = ' ' + fallback;\n }\n};\n\n/**\n * Build and append the saving loading modal element.\n *\n * @returns {Promise} The created modal element.\n */\nconst createLoadingModal = async() => {\n const modal = document.createElement('div');\n modal.className = 'exeweb-loading-modal';\n modal.id = 'exeweb-loading-modal';\n\n let savingText = 'Saving...';\n let waitText = 'Please wait while the file is being saved.';\n try {\n savingText = await getString('saving', 'mod_exelearning');\n waitText = await getString('savingwait', 'mod_exelearning');\n } catch {\n // Use defaults.\n }\n\n modal.innerHTML = `\n \n
\n
${savingText}
\n
${waitText}
\n
\n `;\n\n document.body.appendChild(modal);\n return modal;\n};\n\n/**\n * Show the saving loading modal, creating it if necessary.\n *\n * @returns {Promise}\n */\nconst showLoadingModal = async() => {\n if (!loadingModal) {\n loadingModal = await createLoadingModal();\n }\n loadingModal.classList.add('is-visible');\n};\n\n/**\n * Hide the saving loading modal if it exists.\n *\n * @returns {void}\n */\nconst hideLoadingModal = () => {\n if (loadingModal) {\n loadingModal.classList.remove('is-visible');\n }\n};\n\n/**\n * Remove the saving loading modal from the DOM.\n *\n * @returns {void}\n */\nconst removeLoadingModal = () => {\n if (loadingModal) {\n loadingModal.remove();\n loadingModal = null;\n }\n};\n\n/**\n * Ask the user to confirm closing when there are unsaved changes.\n *\n * @returns {Promise} True if the close should be cancelled.\n */\nconst checkUnsavedChanges = async() => {\n if (!hasUnsavedChanges) {\n return false;\n }\n let message = 'You have unsaved changes. Are you sure you want to close?';\n try {\n message = await getString('unsavedchanges', 'mod_exelearning');\n } catch {\n // Use default.\n }\n return !window.confirm(message);\n};\n\n/**\n * Post a message to the embedded editor iframe.\n *\n * @param {object} message The message payload to send.\n * @param {Transferable[]} [transfer] Optional transferable objects.\n * @returns {void}\n */\nconst postToEditor = (message, transfer) => {\n if (!iframe?.contentWindow) {\n return;\n }\n if (transfer && transfer.length) {\n iframe.contentWindow.postMessage(message, editorOrigin, transfer);\n } else {\n iframe.contentWindow.postMessage(message, editorOrigin);\n }\n};\n\n/**\n * Clear the pending OPEN_FILE response timeout.\n *\n * @returns {void}\n */\nconst clearOpenResponseTimer = () => {\n if (openResponseTimer) {\n clearTimeout(openResponseTimer);\n openResponseTimer = null;\n }\n};\n\n/**\n * Schedule a retry of the initial package open, with backoff.\n *\n * @returns {void}\n */\nconst scheduleOpenRetry = () => {\n if (openAttemptCount >= MAX_OPEN_ATTEMPTS) {\n return;\n }\n\n setTimeout(() => {\n openInitialPackage();\n }, 300 * openAttemptCount);\n};\n\n/**\n * Arm a timeout that retries the open request if no response arrives.\n *\n * @returns {void}\n */\nconst armOpenResponseTimer = () => {\n clearOpenResponseTimer();\n openResponseTimer = setTimeout(() => {\n if (!openRequestSent) {\n return;\n }\n\n Log.error('[editor_modal] OPEN_FILE timeout waiting for response');\n openRequestSent = false;\n scheduleOpenRetry();\n }, OPEN_RESPONSE_TIMEOUT_MS);\n};\n\n/**\n * Toggle the saving state, updating the button and loading modal.\n *\n * @param {boolean} saving Whether a save is in progress.\n * @returns {Promise}\n */\nconst setSavingState = async(saving) => {\n isSaving = saving;\n if (!saveBtn) {\n return;\n }\n saveBtn.disabled = saving;\n if (saving) {\n await setSaveLabel('saving', 'Saving...');\n await showLoadingModal();\n } else {\n await setSaveLabel('savetomoodle', 'Save to Moodle');\n hideLoadingModal();\n }\n};\n\n/**\n * Fetch the current package and send it to the editor to open.\n *\n * @returns {Promise}\n */\nconst openInitialPackage = async() => {\n if (session?.skipOpenFileOnInit) {\n return;\n }\n if (openRequestSent || !session?.packageUrl) {\n return;\n }\n\n openRequestSent = true;\n openAttemptCount += 1;\n\n try {\n const response = await fetch(session.packageUrl, {credentials: 'include'});\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const bytes = await response.arrayBuffer();\n openRequestId = nextRequestId('open');\n\n postToEditor({\n type: 'OPEN_FILE',\n requestId: openRequestId,\n data: {\n bytes,\n filename: 'package.elpx',\n },\n });\n\n armOpenResponseTimer();\n } catch (error) {\n Log.error('[editor_modal] Failed to open package:', error);\n openRequestSent = false;\n scheduleOpenRetry();\n }\n};\n\n/**\n * Upload an exported package to Moodle and refresh the activity view.\n *\n * @param {object} payload The export payload from the editor.\n * @returns {Promise}\n */\nconst uploadExportedFile = async(payload) => {\n const bytes = payload?.bytes;\n if (!bytes) {\n throw new Error('Missing export bytes');\n }\n\n const filename = payload.filename || 'package.elpx';\n const mimeType = payload.mimeType || 'application/zip';\n\n const blob = new Blob([bytes], {type: mimeType});\n const formData = new FormData();\n formData.append('package', blob, filename);\n formData.append('format', FIXED_EXPORT_FORMAT);\n formData.append('cmid', String(session.cmid));\n formData.append('sesskey', session.sesskey);\n\n const response = await fetch(session.saveUrl, {\n method: 'POST',\n body: formData,\n credentials: 'include',\n });\n\n const result = await response.json();\n if (!response.ok || !result?.success) {\n throw new Error(result?.error || `Save failed (${response.status})`);\n }\n\n const updatedPackageUrl = updatePackageUrlRevision(session.packageUrl, result.revision);\n persistUpdatedPackageUrl(updatedPackageUrl);\n refreshActivityIframe(result.revision);\n\n hasUnsavedChanges = false;\n await setSaveLabel('savedsuccess', 'Saved successfully');\n close(true);\n\n // Reload the activity page so the server-rendered blocks (detected gradable\n // iDevices banner, participation summary, gradebook-driven UI) reflect the\n // re-synced gradebook after save.php re-scanned the package. Refreshing only\n // the content iframe would leave those stale (e.g. a removed trueorfalse\n // would still show in \"Gradable iDevices detected\").\n window.location.reload();\n};\n\n/**\n * Request the editor to export the current document for saving.\n *\n * @returns {Promise}\n */\nconst requestExport = async() => {\n if (isSaving || !iframe?.contentWindow) {\n return;\n }\n\n await setSavingState(true);\n exportRequestId = nextRequestId('export');\n\n postToEditor({\n type: 'REQUEST_EXPORT',\n requestId: exportRequestId,\n data: {\n format: FIXED_EXPORT_FORMAT,\n filename: 'package.elpx',\n },\n });\n};\n\n/**\n * Check that a postMessage event really comes from the embedded editor frame.\n *\n * The legacy eXeWeb bridge is kept for older static editor builds, but it must\n * enforce the same source/origin boundary as the modern bridge (RIE-010).\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {boolean} Whether the event belongs to the active editor iframe.\n */\nconst isEditorBridgeMessage = (event) => {\n if (!iframe?.contentWindow || event.source !== iframe.contentWindow || !event.data) {\n return false;\n }\n if (editorOrigin !== '*' && event.origin !== editorOrigin) {\n return false;\n }\n return true;\n};\n\n/**\n * Handle protocol messages received from the embedded editor bridge.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleBridgeMessage = async(event) => {\n if (!isEditorBridgeMessage(event)) {\n return;\n }\n\n const data = event.data;\n\n switch (data.type) {\n case 'EXELEARNING_READY':\n postToEditor({\n type: 'CONFIGURE',\n requestId: nextRequestId('configure'),\n data: {\n hideUI: {\n fileMenu: true,\n saveButton: true,\n userMenu: true,\n },\n },\n });\n openInitialPackage();\n break;\n\n case 'DOCUMENT_LOADED':\n if (saveBtn && !isSaving) {\n saveBtn.disabled = false;\n }\n break;\n\n case 'DOCUMENT_CHANGED':\n hasUnsavedChanges = true;\n break;\n\n case 'OPEN_FILE_SUCCESS':\n if (data.requestId === openRequestId && saveBtn && !isSaving) {\n saveBtn.disabled = false;\n openRequestSent = false;\n openAttemptCount = 0;\n clearOpenResponseTimer();\n }\n break;\n\n case 'OPEN_FILE_ERROR':\n if (data.requestId === openRequestId) {\n Log.error('[editor_modal] OPEN_FILE_ERROR:', data.error);\n openRequestSent = false;\n clearOpenResponseTimer();\n scheduleOpenRetry();\n }\n break;\n\n case 'EXPORT_FILE':\n if (data.requestId === exportRequestId) {\n try {\n await uploadExportedFile(data);\n } catch (error) {\n Log.error('[editor_modal] Upload failed:', error);\n await setSavingState(false);\n }\n }\n break;\n\n case 'REQUEST_EXPORT_ERROR':\n if (data.requestId === exportRequestId) {\n Log.error('[editor_modal] REQUEST_EXPORT_ERROR:', data.error);\n await setSavingState(false);\n }\n break;\n\n default:\n break;\n }\n};\n\n/**\n * Handle legacy bridge messages from older editor builds.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleLegacyBridgeMessage = async(event) => {\n if (!isEditorBridgeMessage(event)) {\n return;\n }\n\n const data = event.data;\n if (!data || data.source !== 'exeweb-editor') {\n return;\n }\n\n if (data.type === 'editor-ready' && data.data?.packageUrl) {\n const incomingUrl = data.data.packageUrl;\n if (incomingUrl !== session?.packageUrl) {\n session.packageUrl = incomingUrl;\n openRequestSent = false;\n openAttemptCount = 0;\n }\n openInitialPackage();\n }\n\n if (data.type === 'request-save') {\n await requestExport();\n }\n};\n\n/**\n * Dispatch an incoming window message to both bridge handlers.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleMessage = async(event) => {\n await handleBridgeMessage(event);\n await handleLegacyBridgeMessage(event);\n};\n\n/**\n * Close the editor overlay when the Escape key is pressed.\n *\n * @param {KeyboardEvent} event The keydown event.\n * @returns {void}\n */\nconst handleKeydown = (event) => {\n if (event.key === 'Escape') {\n close(false);\n }\n};\n\n/**\n * Close the editor overlay and tear down its state and listeners.\n *\n * @param {boolean} skipConfirm Skip the unsaved-changes confirmation when true.\n * @returns {Promise}\n */\nexport const close = async(skipConfirm) => {\n if (!overlay) {\n return;\n }\n if (!skipConfirm) {\n const shouldCancel = await checkUnsavedChanges();\n if (shouldCancel) {\n return;\n }\n }\n\n const wasShowingLoader = isSaving || (skipConfirm === true);\n\n overlay.remove();\n overlay = null;\n iframe = null;\n saveBtn = null;\n session = null;\n openRequestSent = false;\n openRequestId = null;\n exportRequestId = null;\n isSaving = false;\n hasUnsavedChanges = false;\n openAttemptCount = 0;\n clearOpenResponseTimer();\n\n document.body.style.overflow = '';\n window.removeEventListener('message', handleMessage);\n document.removeEventListener('keydown', handleKeydown);\n\n if (wasShowingLoader) {\n setTimeout(() => {\n hideLoadingModal();\n removeLoadingModal();\n }, 1500);\n } else {\n hideLoadingModal();\n removeLoadingModal();\n }\n};\n\n/**\n * Open the embedded editor overlay for the given activity.\n *\n * @param {number|string} cmid The course module id.\n * @param {string} editorUrl The URL of the embedded editor.\n * @param {string} activityName The activity name shown in the header.\n * @param {string} packageUrl The URL of the current package file.\n * @param {string} saveUrl The endpoint used to save exported packages.\n * @param {string} sesskey The Moodle session key.\n * @returns {Promise}\n */\nexport const open = async(cmid, editorUrl, activityName, packageUrl, saveUrl, sesskey) => {\n Log.debug('[editor_modal] Opening editor for cmid:', cmid);\n\n if (overlay) {\n return;\n }\n\n editorOrigin = getOrigin(editorUrl);\n openAttemptCount = 0;\n session = {\n cmid,\n editorUrl,\n packageUrl: packageUrl || '',\n skipOpenFileOnInit: !!packageUrl,\n saveUrl,\n sesskey,\n };\n\n overlay = document.createElement('div');\n overlay.id = 'exeweb-editor-overlay';\n overlay.className = 'exeweb-editor-overlay';\n\n const header = document.createElement('div');\n header.className = 'exeweb-editor-header';\n\n const title = document.createElement('span');\n title.className = 'exeweb-editor-title';\n title.textContent = activityName || '';\n header.appendChild(title);\n\n const buttonGroup = document.createElement('div');\n buttonGroup.className = 'exeweb-editor-buttons';\n\n saveBtn = document.createElement('button');\n saveBtn.className = 'btn btn-primary mr-2';\n saveBtn.id = 'exeweb-editor-save';\n saveBtn.disabled = true;\n await setSaveLabel('savetomoodle', 'Save to Moodle');\n saveBtn.addEventListener('click', requestExport);\n\n const closeBtn = document.createElement('button');\n closeBtn.className = 'btn btn-secondary';\n closeBtn.id = 'exeweb-editor-close';\n try {\n closeBtn.textContent = await getString('closebuttontitle', 'core');\n } catch {\n closeBtn.textContent = 'Close';\n }\n closeBtn.addEventListener('click', () => close(false));\n\n buttonGroup.appendChild(saveBtn);\n buttonGroup.appendChild(closeBtn);\n header.appendChild(buttonGroup);\n overlay.appendChild(header);\n\n iframe = document.createElement('iframe');\n iframe.className = 'exeweb-editor-iframe';\n iframe.src = editorUrl;\n iframe.setAttribute('allow', 'fullscreen');\n iframe.setAttribute('frameborder', '0');\n iframe.addEventListener('load', () => {\n if (!openRequestSent && session?.packageUrl) {\n openInitialPackage();\n }\n });\n overlay.appendChild(iframe);\n\n document.body.appendChild(overlay);\n document.body.style.overflow = 'hidden';\n\n window.addEventListener('message', handleMessage);\n document.addEventListener('keydown', handleKeydown);\n};\n\n/**\n * Initialise the delegated click handler that opens the editor.\n *\n * @returns {void}\n */\nexport const init = () => {\n document.addEventListener('click', (event) => {\n const button = event.target.closest('[data-action=\"mod_exelearning/editor-open\"]');\n if (!button) {\n return;\n }\n\n event.preventDefault();\n open(\n button.dataset.cmid,\n button.dataset.editorurl,\n button.dataset.activityname,\n button.dataset.packageurl,\n button.dataset.saveurl,\n button.dataset.sesskey\n );\n });\n};\n"],"names":["overlay","iframe","saveBtn","loadingModal","editorOrigin","openRequestSent","openRequestId","exportRequestId","isSaving","hasUnsavedChanges","session","requestCounter","openAttemptCount","openResponseTimer","nextRequestId","prefix","Date","now","refreshActivityIframe","revision","activityIframe","document","getElementById","src","refreshedSrc","url","normalizedRevision","String","replace","updated","separator","includes","updateContentUrlRevision","setSaveLabel","async","key","fallback","text","innerHTML","showLoadingModal","modal","createElement","className","id","savingText","waitText","body","appendChild","createLoadingModal","classList","add","hideLoadingModal","remove","removeLoadingModal","postToEditor","message","transfer","_iframe","contentWindow","length","postMessage","clearOpenResponseTimer","clearTimeout","scheduleOpenRetry","setTimeout","openInitialPackage","setSavingState","saving","disabled","_session","skipOpenFileOnInit","_session2","packageUrl","response","fetch","credentials","ok","Error","status","statusText","bytes","arrayBuffer","type","requestId","data","filename","error","uploadExportedFile","payload","mimeType","blob","Blob","formData","FormData","append","cmid","sesskey","saveUrl","method","result","json","success","newUrl","selector","openButton","querySelector","dataset","packageurl","persistUpdatedPackageUrl","updatePackageUrlRevision","close","window","location","reload","requestExport","_iframe2","format","isEditorBridgeMessage","event","_iframe3","source","origin","handleMessage","hideUI","fileMenu","saveButton","userMenu","handleBridgeMessage","_data$data","incomingUrl","_session3","handleLegacyBridgeMessage","handleKeydown","skipConfirm","confirm","checkUnsavedChanges","wasShowingLoader","style","overflow","removeEventListener","open","editorUrl","activityName","debug","URL","href","getOrigin","header","title","textContent","buttonGroup","addEventListener","closeBtn","setAttribute","_session4","button","target","closest","preventDefault","editorurl","activityname","saveurl"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"editor_modal.min.js","sources":["../src/editor_modal.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n\nimport {get_string as getString} from 'core/str';\nimport Log from 'core/log';\n\nlet overlay = null;\nlet iframe = null;\nlet saveBtn = null;\nlet loadingModal = null;\nlet editorOrigin = '*';\nlet openRequestSent = false;\nlet openRequestId = null;\nlet exportRequestId = null;\nlet isSaving = false;\nlet hasUnsavedChanges = false;\nlet session = null;\nlet requestCounter = 0;\nlet openAttemptCount = 0;\nlet openResponseTimer = null;\n\nconst MAX_OPEN_ATTEMPTS = 3;\nconst OPEN_RESPONSE_TIMEOUT_MS = 3000;\nconst FIXED_EXPORT_FORMAT = 'elpx';\n\n/**\n * Resolve the origin of a URL, falling back to a wildcard on failure.\n *\n * @param {string} url The URL to inspect.\n * @returns {string} The resolved origin, or '*' if it cannot be parsed.\n */\nconst getOrigin = (url) => {\n try {\n return new URL(url, window.location.href).origin;\n } catch {\n return '*';\n }\n};\n\n/**\n * Generate a unique request identifier for bridge messages.\n *\n * @param {string} prefix A prefix describing the request type.\n * @returns {string} A unique request identifier.\n */\nconst nextRequestId = (prefix) => {\n requestCounter += 1;\n return `${prefix}-${Date.now()}-${requestCounter}`;\n};\n\n/**\n * Rewrite the revision segment of a package URL and add a cache buster.\n *\n * @param {string} url The original package URL.\n * @param {number|string} revision The new revision number.\n * @returns {string} The updated URL, or the original if it cannot be rewritten.\n */\nconst updatePackageUrlRevision = (url, revision) => {\n if (!url || !revision) {\n return url;\n }\n const normalizedRevision = String(revision).replace(/[^0-9]/g, '');\n if (!normalizedRevision) {\n return url;\n }\n const updated = url.replace(/(\\/mod_exelearning\\/package\\/)(\\d+)(\\/)/, `$1${normalizedRevision}$3`);\n if (updated === url) {\n return url;\n }\n const separator = updated.includes('?') ? '&' : '?';\n return `${updated}${separator}v=${normalizedRevision}-${Date.now()}`;\n};\n\n/**\n * Persist an updated package URL on the session and the open button dataset.\n *\n * @param {string} newUrl The new package URL to store.\n * @returns {void}\n */\nconst persistUpdatedPackageUrl = (newUrl) => {\n if (!newUrl || !session) {\n return;\n }\n session.packageUrl = newUrl;\n const selector = `[data-action=\"mod_exelearning/editor-open\"][data-cmid=\"${String(session.cmid)}\"]`;\n const openButton = document.querySelector(selector);\n if (openButton?.dataset) {\n openButton.dataset.packageurl = newUrl;\n }\n};\n\n/**\n * Rewrite the revision segment of a content URL and add a cache buster.\n *\n * @param {string} url The original content URL.\n * @param {number|string} revision The new revision number.\n * @returns {string} The updated URL, or the original if it cannot be rewritten.\n */\nconst updateContentUrlRevision = (url, revision) => {\n if (!url || !revision) {\n return url;\n }\n const normalizedRevision = String(revision).replace(/[^0-9]/g, '');\n if (!normalizedRevision) {\n return url;\n }\n const updated = url.replace(/(\\/mod_exelearning\\/content\\/)(\\d+)(\\/)/, `$1${normalizedRevision}$3`);\n if (updated === url) {\n return url;\n }\n const separator = updated.includes('?') ? '&' : '?';\n return `${updated}${separator}v=${normalizedRevision}-${Date.now()}`;\n};\n\n/**\n * Refresh the activity content iframe so it points at the new revision.\n *\n * @param {number|string} revision The new revision number.\n * @returns {void}\n */\nconst refreshActivityIframe = (revision) => {\n const activityIframe = document.getElementById('exelearningobject');\n if (!activityIframe || !activityIframe.src) {\n return;\n }\n const refreshedSrc = updateContentUrlRevision(activityIframe.src, revision);\n if (refreshedSrc && refreshedSrc !== activityIframe.src) {\n activityIframe.src = refreshedSrc;\n }\n};\n\n/**\n * Set the save button label from a language string, with a fallback.\n *\n * @param {string} key The language string key.\n * @param {string} fallback The fallback text if the string cannot be loaded.\n * @returns {Promise}\n */\nconst setSaveLabel = async(key, fallback) => {\n if (!saveBtn) {\n return;\n }\n try {\n const text = await getString(key, key === 'closebuttontitle' ? 'core' : 'mod_exelearning');\n saveBtn.innerHTML = ' ' + text;\n } catch {\n saveBtn.innerHTML = ' ' + fallback;\n }\n};\n\n/**\n * Build and append the saving loading modal element.\n *\n * @returns {Promise} The created modal element.\n */\nconst createLoadingModal = async() => {\n const modal = document.createElement('div');\n modal.className = 'exeweb-loading-modal';\n modal.id = 'exeweb-loading-modal';\n\n let savingText = 'Saving...';\n let waitText = 'Please wait while the file is being saved.';\n try {\n savingText = await getString('saving', 'mod_exelearning');\n waitText = await getString('savingwait', 'mod_exelearning');\n } catch {\n // Use defaults.\n }\n\n modal.innerHTML = `\n \n
\n
${savingText}
\n
${waitText}
\n
\n `;\n\n document.body.appendChild(modal);\n return modal;\n};\n\n/**\n * Show the saving loading modal, creating it if necessary.\n *\n * @returns {Promise}\n */\nconst showLoadingModal = async() => {\n if (!loadingModal) {\n loadingModal = await createLoadingModal();\n }\n loadingModal.classList.add('is-visible');\n};\n\n/**\n * Hide the saving loading modal if it exists.\n *\n * @returns {void}\n */\nconst hideLoadingModal = () => {\n if (loadingModal) {\n loadingModal.classList.remove('is-visible');\n }\n};\n\n/**\n * Remove the saving loading modal from the DOM.\n *\n * @returns {void}\n */\nconst removeLoadingModal = () => {\n if (loadingModal) {\n loadingModal.remove();\n loadingModal = null;\n }\n};\n\n/**\n * Ask the user to confirm closing when there are unsaved changes.\n *\n * @returns {Promise} True if the close should be cancelled.\n */\nconst checkUnsavedChanges = async() => {\n if (!hasUnsavedChanges) {\n return false;\n }\n let message = 'You have unsaved changes. Are you sure you want to close?';\n try {\n message = await getString('unsavedchanges', 'mod_exelearning');\n } catch {\n // Use default.\n }\n return !window.confirm(message);\n};\n\n/**\n * Post a message to the embedded editor iframe.\n *\n * @param {object} message The message payload to send.\n * @param {Transferable[]} [transfer] Optional transferable objects.\n * @returns {void}\n */\nconst postToEditor = (message, transfer) => {\n if (!iframe?.contentWindow) {\n return;\n }\n if (transfer && transfer.length) {\n iframe.contentWindow.postMessage(message, editorOrigin, transfer);\n } else {\n iframe.contentWindow.postMessage(message, editorOrigin);\n }\n};\n\n/**\n * Clear the pending OPEN_FILE response timeout.\n *\n * @returns {void}\n */\nconst clearOpenResponseTimer = () => {\n if (openResponseTimer) {\n clearTimeout(openResponseTimer);\n openResponseTimer = null;\n }\n};\n\n/**\n * Schedule a retry of the initial package open, with backoff.\n *\n * @returns {void}\n */\nconst scheduleOpenRetry = () => {\n if (openAttemptCount >= MAX_OPEN_ATTEMPTS) {\n return;\n }\n\n setTimeout(() => {\n openInitialPackage();\n }, 300 * openAttemptCount);\n};\n\n/**\n * Arm a timeout that retries the open request if no response arrives.\n *\n * @returns {void}\n */\nconst armOpenResponseTimer = () => {\n clearOpenResponseTimer();\n openResponseTimer = setTimeout(() => {\n if (!openRequestSent) {\n return;\n }\n\n Log.error('[editor_modal] OPEN_FILE timeout waiting for response');\n openRequestSent = false;\n scheduleOpenRetry();\n }, OPEN_RESPONSE_TIMEOUT_MS);\n};\n\n/**\n * Toggle the saving state, updating the button and loading modal.\n *\n * @param {boolean} saving Whether a save is in progress.\n * @returns {Promise}\n */\nconst setSavingState = async(saving) => {\n isSaving = saving;\n if (!saveBtn) {\n return;\n }\n saveBtn.disabled = saving;\n if (saving) {\n await setSaveLabel('saving', 'Saving...');\n await showLoadingModal();\n } else {\n await setSaveLabel('savetomoodle', 'Save to Moodle');\n hideLoadingModal();\n }\n};\n\n/**\n * Fetch the current package and send it to the editor to open.\n *\n * @returns {Promise}\n */\nconst openInitialPackage = async() => {\n if (session?.skipOpenFileOnInit) {\n return;\n }\n if (openRequestSent || !session?.packageUrl) {\n return;\n }\n\n openRequestSent = true;\n openAttemptCount += 1;\n\n try {\n const response = await fetch(session.packageUrl, {credentials: 'include'});\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const bytes = await response.arrayBuffer();\n openRequestId = nextRequestId('open');\n\n postToEditor({\n type: 'OPEN_FILE',\n requestId: openRequestId,\n data: {\n bytes,\n filename: 'package.elpx',\n },\n });\n\n armOpenResponseTimer();\n } catch (error) {\n Log.error('[editor_modal] Failed to open package:', error);\n openRequestSent = false;\n scheduleOpenRetry();\n }\n};\n\n/**\n * Upload an exported package to Moodle and refresh the activity view.\n *\n * @param {object} payload The export payload from the editor.\n * @returns {Promise}\n */\nconst uploadExportedFile = async(payload) => {\n const bytes = payload?.bytes;\n if (!bytes) {\n throw new Error('Missing export bytes');\n }\n\n const filename = payload.filename || 'package.elpx';\n const mimeType = payload.mimeType || 'application/zip';\n\n const blob = new Blob([bytes], {type: mimeType});\n const formData = new FormData();\n formData.append('package', blob, filename);\n formData.append('format', FIXED_EXPORT_FORMAT);\n formData.append('cmid', String(session.cmid));\n formData.append('sesskey', session.sesskey);\n\n const response = await fetch(session.saveUrl, {\n method: 'POST',\n body: formData,\n credentials: 'include',\n });\n\n const result = await response.json();\n if (!response.ok || !result?.success) {\n throw new Error(result?.error || `Save failed (${response.status})`);\n }\n\n const updatedPackageUrl = updatePackageUrlRevision(session.packageUrl, result.revision);\n persistUpdatedPackageUrl(updatedPackageUrl);\n refreshActivityIframe(result.revision);\n\n hasUnsavedChanges = false;\n await setSaveLabel('savedsuccess', 'Saved successfully');\n close(true);\n\n // Reload the activity page so the server-rendered blocks (detected gradable\n // iDevices banner, participation summary, gradebook-driven UI) reflect the\n // re-synced gradebook after save.php re-scanned the package. Refreshing only\n // the content iframe would leave those stale (e.g. a removed trueorfalse\n // would still show in \"Gradable iDevices detected\").\n window.location.reload();\n};\n\n/**\n * Request the editor to export the current document for saving.\n *\n * @returns {Promise}\n */\nconst requestExport = async() => {\n if (isSaving || !iframe?.contentWindow) {\n return;\n }\n\n await setSavingState(true);\n exportRequestId = nextRequestId('export');\n\n postToEditor({\n type: 'REQUEST_EXPORT',\n requestId: exportRequestId,\n data: {\n format: FIXED_EXPORT_FORMAT,\n filename: 'package.elpx',\n },\n });\n};\n\n/**\n * Check that a postMessage event really comes from the embedded editor frame.\n *\n * The legacy eXeWeb bridge is kept for older static editor builds, but it must\n * enforce the same source/origin boundary as the modern bridge (RIE-010).\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {boolean} Whether the event belongs to the active editor iframe.\n */\nconst isEditorBridgeMessage = (event) => {\n if (!iframe?.contentWindow || event.source !== iframe.contentWindow || !event.data) {\n return false;\n }\n if (editorOrigin !== '*' && event.origin !== editorOrigin) {\n return false;\n }\n return true;\n};\n\n/**\n * Handle protocol messages received from the embedded editor bridge.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleBridgeMessage = async(event) => {\n if (!isEditorBridgeMessage(event)) {\n return;\n }\n\n const data = event.data;\n\n switch (data.type) {\n case 'EXELEARNING_READY':\n postToEditor({\n type: 'CONFIGURE',\n requestId: nextRequestId('configure'),\n data: {\n hideUI: {\n fileMenu: true,\n saveButton: true,\n userMenu: true,\n },\n },\n });\n openInitialPackage();\n break;\n\n case 'DOCUMENT_LOADED':\n if (saveBtn && !isSaving) {\n saveBtn.disabled = false;\n }\n break;\n\n case 'DOCUMENT_CHANGED':\n hasUnsavedChanges = true;\n break;\n\n case 'OPEN_FILE_SUCCESS':\n if (data.requestId === openRequestId && saveBtn && !isSaving) {\n saveBtn.disabled = false;\n openRequestSent = false;\n openAttemptCount = 0;\n clearOpenResponseTimer();\n }\n break;\n\n case 'OPEN_FILE_ERROR':\n if (data.requestId === openRequestId) {\n Log.error('[editor_modal] OPEN_FILE_ERROR:', data.error);\n openRequestSent = false;\n clearOpenResponseTimer();\n scheduleOpenRetry();\n }\n break;\n\n case 'EXPORT_FILE':\n if (data.requestId === exportRequestId) {\n try {\n await uploadExportedFile(data);\n } catch (error) {\n Log.error('[editor_modal] Upload failed:', error);\n await setSavingState(false);\n }\n }\n break;\n\n case 'REQUEST_EXPORT_ERROR':\n if (data.requestId === exportRequestId) {\n Log.error('[editor_modal] REQUEST_EXPORT_ERROR:', data.error);\n await setSavingState(false);\n }\n break;\n\n default:\n break;\n }\n};\n\n/**\n * Handle legacy bridge messages from older editor builds.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleLegacyBridgeMessage = async(event) => {\n if (!isEditorBridgeMessage(event)) {\n return;\n }\n\n const data = event.data;\n if (!data || data.source !== 'exeweb-editor') {\n return;\n }\n\n if (data.type === 'editor-ready' && data.data?.packageUrl) {\n const incomingUrl = data.data.packageUrl;\n if (incomingUrl !== session?.packageUrl) {\n session.packageUrl = incomingUrl;\n openRequestSent = false;\n openAttemptCount = 0;\n }\n openInitialPackage();\n }\n\n if (data.type === 'request-save') {\n await requestExport();\n }\n};\n\n/**\n * Dispatch an incoming window message to both bridge handlers.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleMessage = async(event) => {\n await handleBridgeMessage(event);\n await handleLegacyBridgeMessage(event);\n};\n\n/**\n * Close the editor overlay when the Escape key is pressed.\n *\n * @param {KeyboardEvent} event The keydown event.\n * @returns {void}\n */\nconst handleKeydown = (event) => {\n if (event.key === 'Escape') {\n close(false);\n }\n};\n\n/**\n * Close the editor overlay and tear down its state and listeners.\n *\n * @param {boolean} skipConfirm Skip the unsaved-changes confirmation when true.\n * @returns {Promise}\n */\nexport const close = async(skipConfirm) => {\n if (!overlay) {\n return;\n }\n if (!skipConfirm) {\n const shouldCancel = await checkUnsavedChanges();\n if (shouldCancel) {\n return;\n }\n }\n\n const wasShowingLoader = isSaving || (skipConfirm === true);\n\n overlay.remove();\n overlay = null;\n iframe = null;\n saveBtn = null;\n session = null;\n openRequestSent = false;\n openRequestId = null;\n exportRequestId = null;\n isSaving = false;\n hasUnsavedChanges = false;\n openAttemptCount = 0;\n clearOpenResponseTimer();\n\n document.body.style.overflow = '';\n window.removeEventListener('message', handleMessage);\n document.removeEventListener('keydown', handleKeydown);\n\n if (wasShowingLoader) {\n setTimeout(() => {\n hideLoadingModal();\n removeLoadingModal();\n }, 1500);\n } else {\n hideLoadingModal();\n removeLoadingModal();\n }\n};\n\n/**\n * Open the embedded editor overlay for the given activity.\n *\n * @param {number|string} cmid The course module id.\n * @param {string} editorUrl The URL of the embedded editor.\n * @param {string} activityName The activity name shown in the header.\n * @param {string} packageUrl The URL of the current package file.\n * @param {string} saveUrl The endpoint used to save exported packages.\n * @param {string} sesskey The Moodle session key.\n * @returns {Promise}\n */\nexport const open = async(cmid, editorUrl, activityName, packageUrl, saveUrl, sesskey) => {\n Log.debug('[editor_modal] Opening editor for cmid:', cmid);\n\n if (overlay) {\n return;\n }\n\n // Close any promoted media player floating on the trusted page. The\n // external-media relay opens YouTube/Vimeo in a top-layer