Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

178 lines
7.8 KiB
TypeScript

'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { useWidgetIframeStore } from '@/lib/store/widget-iframe';
import {
useInteractiveIframePool,
type IframePoolEntry,
} from '@/lib/store/interactive-iframe-pool';
import { useSceneRuntimeErrors } from '@/lib/store/scene-runtime-errors';
/**
* Stable host for interactive scene iframes (#619).
*
* Mounted once at the `Stage` root — outside the mode-swap / scene subtree that
* unmounts and remounts — so the iframe elements it renders survive Pro mode
* toggles, scene switches, and any PlaybackChromeRoot remount. The in-tree
* `InteractiveRenderer` is only a placeholder that registers content and reports
* the on-screen rect; the actual iframes live here, portaled into a stable host
* node and positioned over each scene's rect via `position: fixed`.
*
* Portal target follows `document.fullscreenElement` so the iframe stays inside
* the fullscreen subtree during presentation mode (which calls requestFullscreen
* on the playback stage, not on body); otherwise it lives on `document.body`.
* A low z-index keeps it under Radix dialogs (e.g. the scene-switch confirm)
* while still covering the canvas box during interactive playback AND Pro-mode
* editing — the editor agent fixes interactive HTML, so the teacher must see the
* live page while editing. Visibility is driven by the placeholder's ownership
* (gone → hidden, never unmounted), so the document is preserved for a
* zero-reload return.
*/
export function InteractiveIframeHost() {
const entries = useInteractiveIframePool((s) => s.entries);
const activeSceneId = useInteractiveIframePool((s) => s.activeSceneId);
const reset = useInteractiveIframePool((s) => s.reset);
const setActiveScene = useWidgetIframeStore((s) => s.setActiveScene);
// Portal into the fullscreen element when one is active (presentation mode
// fullscreens the stage, and a body-portaled iframe would not be part of that
// subtree, so it would vanish). Falls back to body otherwise.
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
useEffect(() => {
const sync = () => setPortalTarget(document.fullscreenElement ?? document.body);
sync();
document.addEventListener('fullscreenchange', sync);
return () => document.removeEventListener('fullscreenchange', sync);
}, []);
// Keep the messaging store's active scene in lock-step (its legacy fallback
// path resolves the current widget by active scene when no id is passed).
useEffect(() => {
setActiveScene(activeSceneId);
}, [activeSceneId, setActiveScene]);
// The host is mounted once per classroom (inside Stage). When it unmounts —
// e.g. on classroom switch — drop the pool so a new classroom doesn't briefly
// render the previous one's stale iframes.
useEffect(() => reset, [reset]);
if (!portalTarget) return null;
return createPortal(
<>
{Object.entries(entries).map(([sceneId, entry]) => (
<PooledIframe
key={sceneId}
sceneId={sceneId}
entry={entry}
visible={entry.owner !== null && sceneId === activeSceneId}
/>
))}
</>,
portalTarget,
);
}
interface PooledIframeProps {
readonly sceneId: string;
readonly entry: IframePoolEntry;
readonly visible: boolean;
}
/**
* One persisted iframe. Stays mounted as long as its pool entry exists (only
* evicted by LRU), so its document is preserved across scene/mode changes.
* `srcDoc` / `src` come straight from the entry and only change when the
* content changes — that is the single intended reload path.
*
* Security: the sandbox intentionally omits `allow-same-origin`.
* Combining `allow-scripts` with `allow-same-origin` on a srcDoc iframe
* effectively negates sandbox protections — the embedded document is treated
* as same-origin with the parent and can access cookies, localStorage, and
* the parent DOM. Since the HTML may originate from LLM output or imported
* classroom JSON, keeping the iframe in a unique (null) origin prevents
* any embedded script from reaching the host application's state.
* postMessage communication (the only parent↔iframe channel used here)
* works correctly with a null origin because the host sends with
* targetOrigin='*'.
*/
function PooledIframe({ sceneId, entry, visible }: PooledIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const registerIframe = useWidgetIframeStore((s) => s.registerIframe);
// Register the postMessage callback for this scene (moved here from the
// placeholder, since the iframe now lives in the host). Stable per scene:
// the callback reads contentWindow lazily at send time.
useEffect(() => {
const send = (type: string, payload: Record<string, unknown>) => {
iframeRef.current?.contentWindow?.postMessage({ type, ...payload }, '*');
};
registerIframe(sceneId, send);
return () => registerIframe(sceneId, null);
}, [sceneId, registerIframe]);
// Capture runtime errors the iframe's error shim posts out (see iframe.ts), so
// the editor agent can diagnose a blank/broken page. Matched to THIS iframe by
// event.source (sandboxed null-origin iframes still postMessage to the parent).
//
// The errors that matter most (a JSON.parse that aborts setup) fire while srcDoc
// parses — possibly BEFORE this passive effect subscribes. The shim buffers every
// error and re-emits it on request, so after subscribing we ask for a replay to
// recover anything posted pre-subscription. Re-subscribed per document version
// (entry.srcDoc) so each fresh page gets its own replay request; addError dedups
// the live + replayed copies. iframeRef is read lazily, so the handler is stable.
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
const d = e.data as
| { __maicInteractive?: boolean; kind?: string; errorKind?: string; message?: unknown }
| undefined;
if (!d || d.__maicInteractive !== true || d.kind !== 'runtime-error') return;
const kind = typeof d.errorKind === 'string' ? d.errorKind : 'error';
const msg = typeof d.message === 'string' ? d.message : String(d.message ?? '');
useSceneRuntimeErrors.getState().addError(sceneId, `[${kind}] ${msg}`);
};
window.addEventListener('message', onMessage);
iframeRef.current?.contentWindow?.postMessage({ __maicErrorReplayRequest: true }, '*');
return () => window.removeEventListener('message', onMessage);
}, [sceneId, entry.srcDoc]);
// A content change reloads the iframe; drop the previous render's errors so the
// captured set reflects the CURRENT page (e.g. after the agent applies a fix).
useEffect(() => {
useSceneRuntimeErrors.getState().clearScene(sceneId);
}, [sceneId, entry.srcDoc]);
const rect = entry.rect;
// Require a real measured box before showing — a null or zero-size rect means
// the slot hasn't laid out yet; showing then would flash a 0x0 iframe pinned
// at the viewport origin.
const shown = visible && rect !== null && rect.width > 0 && rect.height > 0;
const style: CSSProperties = {
position: 'fixed',
left: rect?.left ?? 0,
top: rect?.top ?? 0,
width: rect?.width ?? 0,
height: rect?.height ?? 0,
border: 0,
borderRadius: '0.5rem', // matches the canvas box's rounded-lg
overflow: 'hidden',
zIndex: 1,
// visibility (not display) — display:none can drop the document on re-show.
visibility: shown ? 'visible' : 'hidden',
pointerEvents: shown ? 'auto' : 'none',
};
return (
<iframe
ref={iframeRef}
srcDoc={entry.srcDoc}
src={entry.srcDoc ? undefined : entry.src}
style={style}
title={`Interactive Scene ${sceneId}`}
sandbox="allow-scripts allow-forms allow-popups"
/>
);
}