diff --git a/packages/doenetml/src/index.ts b/packages/doenetml/src/index.ts index c64a8f4a1..0257df563 100644 --- a/packages/doenetml/src/index.ts +++ b/packages/doenetml/src/index.ts @@ -1,4 +1,5 @@ export { DoenetViewer, DoenetEditor } from "./doenetml"; +export type { DoenetMLFlags } from "./doenetml"; export { mathjaxConfig, diff --git a/packages/standalone/COORDINATION.md b/packages/standalone/COORDINATION.md new file mode 100644 index 000000000..e05d261b2 --- /dev/null +++ b/packages/standalone/COORDINATION.md @@ -0,0 +1,173 @@ +# Cross-Iframe Coordination + +When loading multiple DoenetML documents in separate iframes, the parent window can coordinate initialization to prevent performance issues caused by simultaneous rendering. + +## Quick Start + +### Parent Page (with iframes) + +```html + + + + + + +

DoenetML Documents

+ + + + + + + + +``` + +### Child Page (each iframe) + +```html + + + + + + +
+ +
+ + + + +``` + +## Strategies + +### dom-order (default) + +Initializes iframes in DOM order (1st iframe first, then 2nd, then 3rd, etc.). + +```javascript +initializeDoenetParentCoordinator({ + strategy: "dom-order" +}); +``` + +### viewport-first + +Prioritizes visible iframes, initializing those in viewport first (sorted by DOM order), then initializes remaining iframes in DOM order. + +```javascript +initializeDoenetParentCoordinator({ + strategy: "viewport-first" +}); +``` + +Perfect for pages where users may scroll to see content - visible content initializes first for a responsive feel. + +**Note**: Visibility is detected using an IntersectionObserver with a rootMargin +(default: 600px). Iframes are considered "visible" when they're within that margin +of the viewport edges. This is configurable via `visibilityRootMargin`. + +## Script Placement + +The coordinator should be initialized before or immediately after creating iframes. +This ensures the parent is listening for `DOENET_REGISTER` messages when child frames load. + +## How It Works + +1. **Child Registration**: When a child iframe with `data-doenet-enable-parent-coordination="true"` loads, it registers with the parent after `registrationDelayMs` (default: 100ms). Visibility changes are reported separately. + +2. **Initial Wait**: The parent waits `initialWaitMs` (default: 300ms) to collect registrations from all iframes. This ensures: + - All DOM positions are captured + - Selection logic can make informed decisions + +3. **Selective Permission**: The parent grants initialization permission to one iframe at a time based on strategy: + - **dom-order**: Next iframe in DOM order + - **viewport-first**: Visible iframe (by DOM order), or if none visible, next in DOM order + +4. **Serialized Rendering**: Each iframe waits for permission before rendering. Only one iframe renders at a time. + +5. **Continued Selection**: When an iframe completes, the parent selects and grants permission to the next iframe. + +## Configuration Options + +```javascript +// Parent coordinator options +initializeDoenetParentCoordinator({ + // Initialization strategy (default: "dom-order") + strategy: "dom-order" | "viewport-first", + + // Maximum time to wait for iframe to complete initialization (default: 30000ms) + // If exceeded, automatically proceeds to next iframe + timeoutMs: 30000, + + // Time to wait for all iframes to register before granting (default: 300ms) + // Should be substantially larger (2-3x) than child registrationDelayMs + initialWaitMs: 300 +}); + +// Child registration options (per iframe) +// The source argument is optional; when omitted/undefined the DoenetML is read +// from a + + + + + + +
+ +
+ + + + ``` -in your webpage. Then you can call the globally-exported function `renderDoenetToContainer`, which expects -a `
` element containing a `` as a child. - -For example +### Multiple Documents in Separate Iframes +**Parent page:** ```html - + + + +``` -
+**Each iframe (doc1.html, doc2.html, etc.):** +```html + +
+ ``` -To pass attributes to the DoenetML react component, you may write them in kebob-case prefixed with `data-doenet`. -For example, +## API Reference + +### `renderDoenetViewerToContainer(container, doenetMLSource?, options?)` + +Renders a DoenetML viewer to a specific container element. + +**Parameters:** +- `container`: DOM element to render into +- `doenetMLSource`: (optional) DoenetML source code. If omitted, reads from ` +
+
``` +See [COORDINATION.md](./COORDINATION.md) for detailed documentation on cross-iframe coordination. + +### Coordination Strategies + +These are configured on the parent coordinator via `initializeDoenetParentCoordinator()`: + +- **`dom-order`** (default): Initialize iframes in DOM order +- **`viewport-first`**: Prioritize visible iframes, then initialize remaining in DOM order + ## Development Run @@ -57,6 +124,4 @@ Run npm run dev ``` -to start a `vite` dev server that serves the test viewer and navigate to the indicated URL. By default -`index.html` is served. You can instead navigate to `index-inline-worker.html` to view the same page but -with the inlined version of the DoenetML web worker. +to start a `vite` dev server that serves the test viewer and navigate to the indicated URL. diff --git a/packages/standalone/iframe-child.html b/packages/standalone/iframe-child.html new file mode 100644 index 000000000..cabd6d4d7 --- /dev/null +++ b/packages/standalone/iframe-child.html @@ -0,0 +1,36 @@ + + + + + + + DoenetML Document + + + +
+ +
+ + + diff --git a/packages/standalone/iframe-parent-viewport-first.html b/packages/standalone/iframe-parent-viewport-first.html new file mode 100644 index 000000000..598ecbe99 --- /dev/null +++ b/packages/standalone/iframe-parent-viewport-first.html @@ -0,0 +1,114 @@ + + + + + + DoenetML Cross-Iframe Test (Viewport-First) + + + +

🧪 DoenetML Viewport-First Strategy Test

+ +
+ Test Purpose: Verify that DoenetML documents prioritize visible iframes + when using the "viewport-first" strategy.

+ Expected Behavior: Scroll down to see out-of-viewport iframes. + The visible iframe should initialize first, even if others registered earlier. + Open browser console to see initialization sequence. +
+ +
+
+

Document 1 (Visible)

+ +
+
+

Document 2 (Visible)

+ +
+
+ +
+ ⬇️ Scroll down to see more documents ⬇️ +
+ +
+
+

Document 3 (Below Fold)

+ +
+
+

Document 4 (Below Fold)

+ +
+
+ +
+ ⬇️ Keep scrolling ⬇️ +
+ +
+
+

Document 5 (Far Below)

+ +
+
+

Document 6 (Far Below)

+ +
+
+ + + + diff --git a/packages/standalone/iframe-parent.html b/packages/standalone/iframe-parent.html new file mode 100644 index 000000000..ac9bcf33c --- /dev/null +++ b/packages/standalone/iframe-parent.html @@ -0,0 +1,78 @@ + + + + + + DoenetML Cross-Iframe Test + + + +

🧪 DoenetML Cross-Iframe Coordination Test

+ +
+ Test Purpose: Verify that multiple DoenetML documents in separate iframes + initialize serially (one at a time) using a parent-window coordinator.

+ Expected Behavior: Iframes will initialize one at a time in DOM order. + Open browser console to see initialization sequence. +
+ +
+ +
+ + + + diff --git a/packages/standalone/index.html b/packages/standalone/index.html index bee77c21e..84905b505 100644 --- a/packages/standalone/index.html +++ b/packages/standalone/index.html @@ -25,7 +25,7 @@
-
+
-
+
+ * + * + * + * + * ``` + */ +export function initializeDoenetParentCoordinator( + options?: CoordinatorOptions, +) { + const strategy = options?.strategy ?? "dom-order"; + const timeoutMs = options?.timeoutMs ?? 30000; + const initialWaitMs = options?.initialWaitMs ?? 300; + + type ChildInfo = { + window: Window; + domOrder: number; + }; + + const registeredChildren = new Map(); + const registrationQueue: string[] = []; + const visibleSet = new Set(); + let activeChild: string | null = null; + let activeTimeoutId: number | null = null; + + // Block granting temporarily to gather initial registrations + let awaitingInitialData = true; + let initialWaitScheduled = false; + + // Helper function to find DOM order of an iframe by its window + const findDomOrder = (iframeWindow: Window): number => { + const iframes = document.querySelectorAll("iframe"); + for (let i = 0; i < iframes.length; i++) { + if (iframes[i].contentWindow === iframeWindow) { + return i; + } + } + return -1; + }; + + // Helper to sort iframes by DOM order + const sortByDomOrder = (ids: string[]): string[] => { + return ids.slice().sort((idA, idB) => { + const orderA = registeredChildren.get(idA)?.domOrder ?? Infinity; + const orderB = registeredChildren.get(idB)?.domOrder ?? Infinity; + return orderA - orderB; + }); + }; + + /** + * Attempts to grant initialization permission to the next iframe in queue. + * Applies initial wait delays to ensure all iframes register their DOM position, + * then selects next iframe based on strategy (viewport-first or dom-order). + */ + const tryGrantNext = () => { + // Delay all grants until initial registrations are collected + if (awaitingInitialData) { + if (!initialWaitScheduled) { + initialWaitScheduled = true; + window.setTimeout(() => { + awaitingInitialData = false; + tryGrantNext(); + }, initialWaitMs); + } + return; + } + + // Don't grant if someone is already active or queue is empty + if (activeChild !== null || registrationQueue.length === 0) { + return; + } + + let nextChildId: string | null = null; + + if (strategy === "viewport-first") { + // For viewport-first: prioritize visible iframes, sorted by DOM order + const visibleIds = registrationQueue.filter((id) => { + return visibleSet.has(id); + }); + + if (visibleIds.length > 0) { + const sortedVisible = sortByDomOrder(visibleIds); + nextChildId = sortedVisible[0]; + } + } + + // Fallback: select next by DOM order + if (!nextChildId) { + const sorted = sortByDomOrder(registrationQueue); + nextChildId = sorted[0] ?? null; + } + + if (!nextChildId) { + return; + } + + // Remove from queue + const queueIndex = registrationQueue.indexOf(nextChildId); + if (queueIndex !== -1) { + registrationQueue.splice(queueIndex, 1); + } + + const childInfo = registeredChildren.get(nextChildId); + if (!childInfo) { + // Child info not found, try next + tryGrantNext(); + return; + } + + activeChild = nextChildId; + // Notify child that it has been granted + + // Set timeout to handle stuck iframes + activeTimeoutId = window.setTimeout(() => { + if (activeChild === nextChildId) { + activeChild = null; + activeTimeoutId = null; + tryGrantNext(); + } + }, timeoutMs); + + childInfo.window.postMessage( + { type: "DOENET_GRANT", iframeId: nextChildId }, + window.location.origin, + ); + }; + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) { + return; + } + if (!event.data || typeof event.data !== "object") { + return; + } + + const { type, iframeId, visible } = event.data; + const hasVisibleFlag = typeof visible === "boolean"; + if (!iframeId) { + return; + } + + if (type === "DOENET_REGISTER") { + // Store child info with document number and DOM order + if (!registeredChildren.has(iframeId)) { + const iframeWindow = event.source as Window; + const domOrder = findDomOrder(iframeWindow); + registeredChildren.set(iframeId, { + window: iframeWindow, + domOrder: domOrder, + }); + // Child registered + } + + // Store initial visibility state from registration message + if (hasVisibleFlag) { + if (visible) { + visibleSet.add(iframeId); + } else { + visibleSet.delete(iframeId); + } + } + + // Add to registration queue if not already there + if ( + !registrationQueue.includes(iframeId) && + iframeId !== activeChild + ) { + registrationQueue.push(iframeId); + // Child queued + } + + tryGrantNext(); + } else if (type === "DOENET_VISIBILITY_CHANGED") { + if (!hasVisibleFlag) { + return; + } + if (visible) { + visibleSet.add(iframeId); + } else { + visibleSet.delete(iframeId); + } + // Visibility updated + + // If strategy is viewport-first, re-evaluate queue + if (strategy === "viewport-first") { + tryGrantNext(); + } + } else if (type === "DOENET_COMPLETE") { + // Clear timeout if this is the active child + if (activeChild === iframeId) { + if (activeTimeoutId !== null) { + window.clearTimeout(activeTimeoutId); + activeTimeoutId = null; + } + activeChild = null; + // Active child completed + } + + // Remove from queue if somehow still there + const index = registrationQueue.indexOf(iframeId); + if (index !== -1) { + registrationQueue.splice(index, 1); + } + + // Completion processed + + tryGrantNext(); + } + }; + + const globalAny = window as any; + if (typeof globalAny.__doenetParentCoordinatorCleanup === "function") { + globalAny.__doenetParentCoordinatorCleanup(); + } + + const cleanup = () => { + window.removeEventListener("message", handleMessage); + if (activeTimeoutId !== null) { + window.clearTimeout(activeTimeoutId); + activeTimeoutId = null; + } + if (globalAny.__doenetParentCoordinatorCleanup === cleanup) { + globalAny.__doenetParentCoordinatorCleanup = null; + } + }; + + globalAny.__doenetParentCoordinatorCleanup = cleanup; + // Listen for coordination messages from child iframes + window.addEventListener("message", handleMessage); + + return cleanup; +} + +// Expose all public functions on the global object for CDN usage // @ts-ignore window.renderDoenetViewerToContainer = renderDoenetViewerToContainer; // @ts-ignore window.renderDoenetEditorToContainer = renderDoenetEditorToContainer; +// @ts-ignore +window.initializeDoenetParentCoordinator = initializeDoenetParentCoordinator; diff --git a/packages/test-cypress/cypress/e2e/standalone/coordination-dom-order.cy.js b/packages/test-cypress/cypress/e2e/standalone/coordination-dom-order.cy.js new file mode 100644 index 000000000..a621af158 --- /dev/null +++ b/packages/test-cypress/cypress/e2e/standalone/coordination-dom-order.cy.js @@ -0,0 +1,56 @@ +describe( + "Standalone parent coordination dom-order", + { tags: ["@group5"] }, + () => { + const scrollToFrame = (frameIndex) => { + cy.get(`#frame-${frameIndex}`).scrollIntoView({ + block: "center", + inline: "center", + }); + }; + + const waitForReady = (frameIndex) => { + scrollToFrame(frameIndex); + cy.getIframeBody(`#frame-${frameIndex}`, "#hello") + .find("#hello") + .should("contain", "Hello world"); + }; + + const expectNotReady = (frameIndex) => { + scrollToFrame(frameIndex); + cy.getIframeBody(`#frame-${frameIndex}`) + .find("#hello") + .should("not.exist"); + }; + + it("initializes iframes strictly in DOM order with DOM probes", () => { + cy.visit("/coordination-dom-order.html"); + + // DOM probe: verify ordering constraint is maintained + waitForReady(1); + for (let i = 2; i <= 6; i++) { + expectNotReady(i); + } + + waitForReady(2); + for (let i = 3; i <= 6; i++) { + expectNotReady(i); + } + + waitForReady(3); + for (let i = 4; i <= 6; i++) { + expectNotReady(i); + } + + waitForReady(4); + for (let i = 5; i <= 6; i++) { + expectNotReady(i); + } + + waitForReady(5); + expectNotReady(6); + + waitForReady(6); + }); + }, +); diff --git a/packages/test-cypress/cypress/e2e/standalone/coordination-viewport-first.cy.js b/packages/test-cypress/cypress/e2e/standalone/coordination-viewport-first.cy.js new file mode 100644 index 000000000..cb4c7bf58 --- /dev/null +++ b/packages/test-cypress/cypress/e2e/standalone/coordination-viewport-first.cy.js @@ -0,0 +1,66 @@ +describe( + "Standalone parent coordination viewport-first", + { tags: ["@group5"] }, + () => { + const scrollToFrame = (frameIndex) => { + cy.get(`#frame-${frameIndex}`).scrollIntoView({ + block: "center", + inline: "center", + }); + }; + + it("prioritizes visible iframes when starting at top", () => { + cy.visit("/coordination-viewport-first.html"); + + // Wait for frame 1 to initialize + cy.getIframeBody("#frame-1", "#hello") + .find("#hello") + .should("contain", "Hello world"); + + // Check that frame 2 has NOT initialized yet + cy.getIframeBody("#frame-2").find("#hello").should("not.exist"); + + // Wait for frame 2 to initialize + cy.getIframeBody("#frame-2", "#hello") + .find("#hello") + .should("contain", "Hello world"); + + // Check that bottom frames have NOT initialized yet + // Must scroll them into view first, otherwise they won't render at all + scrollToFrame(5); + cy.wait(500); // Give already-granted frames time to render + cy.getIframeBody("#frame-5").find("#hello").should("not.exist"); + + scrollToFrame(6); + cy.wait(500); // Give already-granted frames time to render + cy.getIframeBody("#frame-6").find("#hello").should("not.exist"); + }); + + it("prioritizes visible iframes when starting at bottom", () => { + cy.visit("/coordination-viewport-first.html?start=bottom"); + + // Wait for frame 5 to initialize + cy.getIframeBody("#frame-5", "#hello") + .find("#hello") + .should("contain", "Hello world"); + + // Check that frame 6 has NOT initialized yet + cy.getIframeBody("#frame-6").find("#hello").should("not.exist"); + + // Wait for frame 6 to initialize + cy.getIframeBody("#frame-6", "#hello") + .find("#hello") + .should("contain", "Hello world"); + + // Check that top frames have NOT initialized yet + // Must scroll them into view first, otherwise they won't render at all + scrollToFrame(1); + cy.wait(500); // Give already-granted frames time to render + cy.getIframeBody("#frame-1").find("#hello").should("not.exist"); + + scrollToFrame(2); + cy.wait(500); // Give already-granted frames time to render + cy.getIframeBody("#frame-2").find("#hello").should("not.exist"); + }); + }, +); diff --git a/packages/test-cypress/cypress/e2e/standalone/uncoordinated-iframes.cy.js b/packages/test-cypress/cypress/e2e/standalone/uncoordinated-iframes.cy.js new file mode 100644 index 000000000..d0cd395fb --- /dev/null +++ b/packages/test-cypress/cypress/e2e/standalone/uncoordinated-iframes.cy.js @@ -0,0 +1,19 @@ +describe("Standalone uncoordinated iframes", { tags: ["@group5"] }, () => { + it("renders all iframe documents without parent coordinator", () => { + const scrollToFrame = (frameIndex) => { + cy.get(`#frame-${frameIndex}`).scrollIntoView({ + block: "center", + inline: "center", + }); + }; + + cy.visit("/uncoordinated-iframes.html"); + + for (let i = 1; i <= 6; i++) { + scrollToFrame(i); + cy.getIframeBody(`#frame-${i}`) + .find('[id$="hello"]') + .should("contain", "Hello world"); + } + }); +}); diff --git a/packages/test-cypress/cypress/e2e/standalone/uncoordinated-same-page.cy.js b/packages/test-cypress/cypress/e2e/standalone/uncoordinated-same-page.cy.js new file mode 100644 index 000000000..173c0d120 --- /dev/null +++ b/packages/test-cypress/cypress/e2e/standalone/uncoordinated-same-page.cy.js @@ -0,0 +1,21 @@ +describe( + "Standalone uncoordinated same-page viewers", + { tags: ["@group5"] }, + () => { + it("renders multiple documents on one page without iframes", () => { + cy.visit("/uncoordinated-same-page.html"); + + // Verify all viewers have rendered and contain the named paragraph + // Use [id$="hello"] to match IDs ending with "hello" (auto-generated prefix) + cy.get("#viewer-1") + .find('[id$="hello"]') + .should("contain", "Hello world"); + cy.get("#viewer-2") + .find('[id$="hello"]') + .should("contain", "Hello world"); + cy.get("#viewer-3") + .find('[id$="hello"]') + .should("contain", "Hello world"); + }); + }, +); diff --git a/packages/test-cypress/cypress/support/commands.js b/packages/test-cypress/cypress/support/commands.js index bf22c3a6c..bbfd22d15 100644 --- a/packages/test-cypress/cypress/support/commands.js +++ b/packages/test-cypress/cypress/support/commands.js @@ -30,3 +30,29 @@ import { clear as idb_clear } from "idb-keyval"; Cypress.Commands.add("clearIndexedDB", () => { return idb_clear(); }); + +/** + * Get the body element of an iframe, optionally waiting for a specific child element. + * @param {string} iframeSelector - CSS selector to find the iframe + * @param {string} [waitSelector=null] - Optional CSS selector for an element that must exist in the iframe before returning + * @returns The iframe body element wrapped for Cypress chaining + */ +Cypress.Commands.add("getIframeBody", (iframeSelector, waitSelector = null) => { + return cy + .get(iframeSelector, { log: false }) + .should(($iframe) => { + const $body = $iframe.contents().find("body"); + + if ($body.length === 0) { + throw new Error("Iframe body is empty or not yet loaded"); + } + + if (waitSelector && $body.find(waitSelector).length === 0) { + throw new Error( + `Element "${waitSelector}" not yet found in iframe`, + ); + } + }) + .its("0.contentDocument.body", { log: false }) + .then(cy.wrap); +}); diff --git a/packages/test-cypress/package.json b/packages/test-cypress/package.json index 9dcfc5a31..bb16b732a 100644 --- a/packages/test-cypress/package.json +++ b/packages/test-cypress/package.json @@ -32,6 +32,7 @@ "src/**/*.ts", "src/**/*.tsx", "src/**/*.jsx", + "public/**/*", "tsconfig.json", "vite.config.ts" ], @@ -43,7 +44,8 @@ "dependencies": [ "../doenetml-prototype:build", "../doenetml:build", - "../ui-components:build" + "../ui-components:build", + "../standalone:build" ] } }, diff --git a/packages/test-cypress/public/coordination-child.html b/packages/test-cypress/public/coordination-child.html new file mode 100644 index 000000000..f88643732 --- /dev/null +++ b/packages/test-cypress/public/coordination-child.html @@ -0,0 +1,46 @@ + + + + + + Coordinated Child + + + +
+ +
+ + + + diff --git a/packages/test-cypress/public/coordination-dom-order.html b/packages/test-cypress/public/coordination-dom-order.html new file mode 100644 index 000000000..58ff971e5 --- /dev/null +++ b/packages/test-cypress/public/coordination-dom-order.html @@ -0,0 +1,102 @@ + + + + + + Coordination DOM Order + + + +

Parent Coordination: dom-order

+
+
+

Document 1

+ +
+
+

Document 2

+ +
+
+

Document 3

+ +
+
+

Document 4

+ +
+
+

Document 5

+ +
+
+

Document 6

+ +
+
+ + + + diff --git a/packages/test-cypress/public/coordination-viewport-first.html b/packages/test-cypress/public/coordination-viewport-first.html new file mode 100644 index 000000000..b8c48f558 --- /dev/null +++ b/packages/test-cypress/public/coordination-viewport-first.html @@ -0,0 +1,151 @@ + + + + + + Coordination Viewport First + + + +

Parent Coordination: viewport-first

+

+ Add ?start=bottom to URL to auto-scroll before iframe + initialization. +

+ +
+

Top Section

+
+
+

Document 1

+ +
+
+

Document 2

+ +
+
+
+ +
Spacer 1
+ +
+

Middle Section

+
+
+

Document 3

+ +
+
+

Document 4

+ +
+
+
+ +
Spacer 2
+ +
+

Bottom Section

+
+
+

Document 5

+ +
+
+

Document 6

+ +
+
+
+ + + + diff --git a/packages/test-cypress/public/standaloneBlobUrls.js b/packages/test-cypress/public/standaloneBlobUrls.js new file mode 100644 index 000000000..b903dc1a6 --- /dev/null +++ b/packages/test-cypress/public/standaloneBlobUrls.js @@ -0,0 +1,74 @@ +let cachedUrls = null; + +/** + * Fetch and cache the standalone script and CSS as blob URLs. + * Returns an object with `scriptUrl` and `cssUrl` properties. + */ +export async function getStandaloneBlobUrls() { + if (!cachedUrls) { + const [scriptSource, cssSource] = await Promise.all([ + fetch("/standalone/doenet-standalone.js").then((r) => r.text()), + fetch("/standalone/style.css").then((r) => r.text()), + ]); + + cachedUrls = { + scriptUrl: URL.createObjectURL( + new Blob([scriptSource], { + type: "application/javascript", + }), + ), + cssUrl: URL.createObjectURL( + new Blob([cssSource], { type: "text/css" }), + ), + }; + } + + return cachedUrls; +} + +/** + * Inject the standalone CSS into the document head. + * Optionally specify a target document (defaults to current document). + */ +export async function injectStandaloneCss(targetDocument = document) { + const existing = targetDocument.getElementById("doenet-standalone-css"); + if (existing) { + return; + } + + const { cssUrl } = await getStandaloneBlobUrls(); + const link = targetDocument.createElement("link"); + link.id = "doenet-standalone-css"; + link.rel = "stylesheet"; + link.href = cssUrl; + targetDocument.head.appendChild(link); +} + +/** + * Load and execute the standalone script in the document. + * Optionally specify a target document (defaults to current document). + */ +export async function loadStandaloneScript(targetDocument = document) { + if (typeof window.renderDoenetViewerToContainer === "function") { + return; + } + + const existing = targetDocument.getElementById("doenet-standalone-js"); + if (existing) { + await new Promise((resolve) => { + existing.addEventListener("load", resolve, { once: true }); + }); + return; + } + + const { scriptUrl } = await getStandaloneBlobUrls(); + + await new Promise((resolve) => { + const script = targetDocument.createElement("script"); + script.id = "doenet-standalone-js"; + script.type = "module"; + script.src = scriptUrl; + script.onload = resolve; + targetDocument.head.appendChild(script); + }); +} diff --git a/packages/test-cypress/public/uncoordinated-child.html b/packages/test-cypress/public/uncoordinated-child.html new file mode 100644 index 000000000..8abffc550 --- /dev/null +++ b/packages/test-cypress/public/uncoordinated-child.html @@ -0,0 +1,43 @@ + + + + + + Uncoordinated Child + + + +
+ +
+ + + + diff --git a/packages/test-cypress/public/uncoordinated-iframes.html b/packages/test-cypress/public/uncoordinated-iframes.html new file mode 100644 index 000000000..e5af0de0f --- /dev/null +++ b/packages/test-cypress/public/uncoordinated-iframes.html @@ -0,0 +1,83 @@ + + + + + + Uncoordinated Iframes + + + +

Uncoordinated Iframes

+
+
+

Document 1

+ +
+
+

Document 2

+ +
+
+

Document 3

+ +
+
+

Document 4

+ +
+
+

Document 5

+ +
+
+

Document 6

+ +
+
+ + diff --git a/packages/test-cypress/public/uncoordinated-same-page.html b/packages/test-cypress/public/uncoordinated-same-page.html new file mode 100644 index 000000000..65bf31b5c --- /dev/null +++ b/packages/test-cypress/public/uncoordinated-same-page.html @@ -0,0 +1,79 @@ + + + + + + Uncoordinated Same Page + + + +

Uncoordinated Same Page

+ +
+
+ +
+ +
+ +
+ +
+ +
+
+ + + + diff --git a/packages/test-cypress/vite.config.ts b/packages/test-cypress/vite.config.ts index b9e238280..decd676b9 100644 --- a/packages/test-cypress/vite.config.ts +++ b/packages/test-cypress/vite.config.ts @@ -3,7 +3,9 @@ import { defineConfig } from "vite"; import { viteStaticCopy } from "vite-plugin-static-copy"; import path from "node:path"; import { createRequire } from "module"; +import { fileURLToPath } from "node:url"; const require = createRequire(import.meta.url); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // https://vitejs.dev/config/ export default defineConfig({ @@ -26,6 +28,17 @@ export default defineConfig({ ), dest: "fonts/", }, + { + src: path.join( + __dirname, + "../standalone/dist/doenet-standalone.js", + ), + dest: "standalone/", + }, + { + src: path.join(__dirname, "../standalone/dist/style.css"), + dest: "standalone/", + }, ], }), ],