diff --git a/packages/overlay/src/canvas/frames.test.ts b/packages/overlay/src/canvas/frames.test.ts index 016eb30..a0d593d 100644 --- a/packages/overlay/src/canvas/frames.test.ts +++ b/packages/overlay/src/canvas/frames.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { FrameAgent } from "../frame-agent"; import { type Frame, FrameManager, MAX_FRAMES } from "./frames"; /* @@ -296,3 +297,106 @@ describe("FrameManager.reorder", () => { expect(stacked()).toEqual(["f1", "f2", "f3"]); }); }); + +describe("nested-document ownership", () => { + /** A real same-origin iframe inside a frame's document. */ + function nestInside(frame: Frame): HTMLIFrameElement { + const { doc } = frame; + if (!doc) { + throw new Error("frame has no document"); + } + const nested = doc.createElement("iframe"); + doc.body.append(nested); + return nested; + } + + function readyHook(): (agent: FrameAgent) => void { + const hook = ( + window as unknown as { + __airshipOnFrameReady?: (agent: FrameAgent) => void; + } + ).__airshipOnFrameReady; + if (!hook) { + throw new Error("no __airshipOnFrameReady installed"); + } + return hook; + } + + it("frameOf resolves a node in a nested document to its frame", () => { + const frame = add("one"); + const nested = nestInside(frame); + const nestedDoc = nested.contentDocument as Document; + const node = nestedDoc.createElement("div"); + nestedDoc.body.append(node); + + expect(frames.frameOf(node)).toBe(frame); + }); + + it("frameOfWindow walks a nested window up to its frame", () => { + const frame = add("one"); + const nested = nestInside(frame); + + expect(frames.frameOfWindow(nested.contentWindow)).toBe(frame); + expect(frames.frameOfWindow(frame.win)).toBe(frame); + expect(frames.frameOfWindow(window)).toBeNull(); + }); + + it("registers a nested agent without clobbering the frame's own", () => { + const frame = add("one"); + const nested = nestInside(frame); + const nestedWin = nested.contentWindow as Window; + const rootAgent = { window: frame.win } as unknown as FrameAgent; + const nestedAgent = { window: nestedWin } as unknown as FrameAgent; + + readyHook()(rootAgent); + readyHook()(nestedAgent); + + expect(frame.agent).toBe(rootAgent); + expect(frame.agents.get(frame.win as Window)).toBe(rootAgent); + expect(frame.agents.get(nestedWin)).toBe(nestedAgent); + }); + + it("drops nested agents when the root realm re-registers", () => { + // An HMR full reload rebuilds the frame's realm; the nested realms died + // with it, so their agents must not linger in the map. + const frame = add("one"); + const nested = nestInside(frame); + const nestedAgent = { + window: nested.contentWindow, + } as unknown as FrameAgent; + const rebuilt = { window: frame.win } as unknown as FrameAgent; + + readyHook()(nestedAgent); + expect(frame.agents.size).toBe(1); + + readyHook()(rebuilt); + expect(frame.agent).toBe(rebuilt); + expect(Array.from(frame.agents.values())).toEqual([rebuilt]); + }); + + it("hands the shell the agent that registered, not just the frame", () => { + const seen: { frame: Frame; agent: FrameAgent }[] = []; + const local = new FrameManager({ + onFrameReady: (frame, agent) => seen.push({ agent, frame }), + pathname: "/", + storageKey: "__airship-test:frames-nested", + world, + }); + try { + const frame = local.add({ name: "one" }); + if (!frame) { + throw new Error("add refused"); + } + const nested = nestInside(frame); + const nestedAgent = { + window: nested.contentWindow, + } as unknown as FrameAgent; + + readyHook()(nestedAgent); + + expect(seen).toEqual([{ agent: nestedAgent, frame }]); + } finally { + local.destroy(); + } + }); +}); diff --git a/packages/overlay/src/canvas/frames.ts b/packages/overlay/src/canvas/frames.ts index 810208e..1a8d092 100644 --- a/packages/overlay/src/canvas/frames.ts +++ b/packages/overlay/src/canvas/frames.ts @@ -1,7 +1,13 @@ import { AIRSHIP_FRAME_NAME, AIRSHIP_MODE_PARAM } from "@airship/protocol"; import { cls, el } from "../dom"; import type { FrameAgent } from "../frame-agent"; -import { frameScreenRect, type Point, type Rect } from "./space"; +import { + frameScreenRect, + MAX_NEST_DEPTH, + type Point, + parentFrameElement, + type Rect, +} from "./space"; /** * Frames — the canvas's unit of content, in the design-tool sense of the word: a named, @@ -228,6 +234,14 @@ export function framePreset( export interface Frame { /** Resolved once the frame's agent has registered itself. */ agent: FrameAgent | null; + /** + * Every agent living inside this frame, keyed by its realm's window — the + * root document's agent plus one per nested same-origin document (a + * Storybook preview, say). `agent` stays the root's, for every existing + * reader; the map is cleared whenever the root realm is rebuilt, because + * the nested realms died with it. + */ + readonly agents: Map; /** The frame's document, or null before it has loaded. */ readonly doc: Document | null; /** The world-space wrapper. Its screen rect is the frame's screen rect. */ @@ -284,8 +298,9 @@ export interface RemovedFrame { export interface FrameManagerDeps { /** Frames were added, removed, moved or resized. */ onChanged?: () => void; - /** A frame's agent registered (initial load, or an HMR full reload). */ - onFrameReady?: (frame: Frame) => void; + /** An agent registered (initial load, or an HMR full reload) — the frame's + * own, or one booted in a nested same-origin document of that frame. */ + onFrameReady?: (frame: Frame, agent: FrameAgent) => void; /** App path every frame loads, carried through from the shell's own URL. */ pathname: string; /** Storage key, so two projects on different ports keep separate layouts. */ @@ -337,12 +352,20 @@ export class FrameManager { * global — see there. */ private readonly onFrameReady = (agent: FrameAgent): void => { - const frame = this.frames.find((f) => f.win === agent.window); + // The chain walk, not an exact match: a nested same-origin document's + // agent belongs to the frame it sits inside, however deep it sits. + const frame = this.frameOfWindow(agent.window); if (!frame) { return; } - frame.agent = agent; - this.deps.onFrameReady?.(frame); + if (frame.win === agent.window) { + // The root realm was (re)built; every nested realm died with it, so + // their agents go too — a nested agent re-registers when it reboots. + frame.agents.clear(); + frame.agent = agent; + } + frame.agents.set(agent.window, agent); + this.deps.onFrameReady?.(frame, agent); }; constructor(deps: FrameManagerDeps) { @@ -391,7 +414,35 @@ export class FrameManager { return null; } const doc = node.ownerDocument ?? (node as Document); - return this.frames.find((f) => f.doc === doc) ?? null; + // The exact match first, so the common single-level path is unchanged. + const exact = this.frames.find((f) => f.doc === doc); + if (exact) { + return exact; + } + // A node in a *nested* same-origin document matches no frame document; + // its frame is up the window chain. + return this.frameOfWindow(doc.defaultView); + } + + /** The frame whose realm contains `win`, walking nested iframes upward. */ + frameOfWindow(win: Window | null): Frame | null { + let current: Window | null = win; + for (let depth = 0; depth <= MAX_NEST_DEPTH && current; depth += 1) { + const target = current; + const frame = this.frames.find((f) => f.win === target); + if (frame) { + return frame; + } + let frameEl: Element | null; + try { + frameEl = parentFrameElement(current); + } catch { + // Cross-origin boundary: nothing above here can be ours. + return null; + } + current = frameEl?.ownerDocument.defaultView ?? null; + } + return null; } /** Topmost frame under a screen point — later frames paint over earlier ones. */ @@ -850,6 +901,7 @@ export class FrameManager { destroy(): void { for (const frame of this.frames) { frame.el.remove(); + frame.agents.clear(); } this.frames.length = 0; // The global goes too. It is a closure over `this`, hung off `window` by @@ -918,6 +970,7 @@ export class FrameManager { const frame: Frame = { agent: null, + agents: new Map(), get doc(): Document | null { return iframe.contentDocument; }, diff --git a/packages/overlay/src/canvas/space.test.ts b/packages/overlay/src/canvas/space.test.ts index 8bb8ccd..ef306b7 100644 --- a/packages/overlay/src/canvas/space.test.ts +++ b/packages/overlay/src/canvas/space.test.ts @@ -18,9 +18,15 @@ import { describe, expect, it } from "vitest"; import { centerAt, fitTo, + frameChain, + frameToScreen, + intersectRects, + MAX_NEST_DEPTH, MIN_SCALE, + nestOffset, projectInto, type Rect, + screenToFrame, screenToWorld, type Viewport, visibleWorldRect, @@ -156,3 +162,150 @@ describe("projectInto", () => { expect(projectInto(bounds, { height: 10, width: 10 }, 8).scale).toBe(1); }); }); + +// -- nested documents -------------------------------------------------------- + +/** Pin an element's screen rect — happy-dom does no layout. */ +function stubRect(el: Element, rect: Partial): void { + (el as HTMLElement).getBoundingClientRect = () => + ({ height: 0, left: 0, top: 0, width: 0, ...rect }) as DOMRect; +} + +function stubBorder(el: Element, left: number, top: number): void { + Object.defineProperty(el, "clientLeft", { value: left }); + Object.defineProperty(el, "clientTop", { value: top }); +} + +/** A real same-origin iframe appended into `doc`, the text-edit.test idiom. */ +function nestedIframe(doc: Document): HTMLIFrameElement { + const iframe = doc.createElement("iframe"); + doc.body.append(iframe); + return iframe; +} + +describe("frameChain", () => { + it("is empty when the window already is the root", () => { + expect(frameChain(window, window)).toEqual([]); + }); + + it("collects the iframes between a nested document and the root, innermost first", () => { + const outer = nestedIframe(document); + const outerDoc = outer.contentDocument as Document; + const inner = nestedIframe(outerDoc); + const innerWin = inner.contentWindow as Window; + + expect(frameChain(innerWin, window)).toEqual([inner, outer]); + outer.remove(); + }); + + it("returns null when the root is not an ancestor", () => { + const stray = nestedIframe(document); + const strayWin = stray.contentWindow as Window; + const other = nestedIframe(document); + const otherWin = other.contentWindow as Window; + + expect(frameChain(strayWin, otherWin)).toBeNull(); + stray.remove(); + other.remove(); + }); + + it("gives up past the depth cap", () => { + let doc = document; + let iframe: HTMLIFrameElement | null = null; + const top = nestedIframe(doc); + doc = top.contentDocument as Document; + for (let i = 0; i < MAX_NEST_DEPTH + 1; i += 1) { + iframe = nestedIframe(doc); + doc = iframe.contentDocument as Document; + } + + expect(frameChain(doc.defaultView as Window, window)).toBeNull(); + top.remove(); + }); + + it("treats a throwing hop as a cross-origin boundary", () => { + const iframe = nestedIframe(document); + const win = iframe.contentWindow as Window; + Object.defineProperty(win, "frameElement", { + get() { + throw new DOMException("cross-origin"); + }, + }); + + expect(frameChain(win, window)).toBeNull(); + iframe.remove(); + }); +}); + +describe("nestOffset", () => { + it("is zero for an empty chain — the single-level path unchanged", () => { + expect(nestOffset([])).toEqual({ x: 0, y: 0 }); + }); + + it("composes positions and borders across levels", () => { + const outer = nestedIframe(document); + const inner = nestedIframe(outer.contentDocument as Document); + stubRect(outer, { left: 200, top: 100 }); + stubBorder(outer, 2, 2); + stubRect(inner, { left: 30, top: 40 }); + stubBorder(inner, 1, 3); + + // Innermost first, the way frameChain hands them over. + expect(nestOffset([inner, outer])).toEqual({ x: 233, y: 145 }); + outer.remove(); + }); +}); + +describe("intersectRects", () => { + it("returns the overlap", () => { + const a: Rect = { height: 100, left: 0, top: 0, width: 100 }; + const b: Rect = { height: 100, left: 60, top: 40, width: 100 }; + expect(intersectRects(a, b)).toEqual({ + height: 60, + left: 60, + top: 40, + width: 40, + }); + }); + + it("collapses to zero size when they miss, never negative", () => { + const a: Rect = { height: 10, left: 0, top: 0, width: 10 }; + const b: Rect = { height: 10, left: 50, top: 50, width: 10 }; + const out = intersectRects(a, b); + expect(out.width).toBe(0); + expect(out.height).toBe(0); + }); +}); + +describe("frame⇄screen with a nested offset", () => { + it("round-trips at a zoom that is not 1×", () => { + // Deliberately not 1: a bug that adds the offset after the scale is + // invisible at 1× and glaring at 2×. + const frameEl = document.createElement("div"); + stubRect(frameEl, { left: 100, top: 50 }); + const offset = { x: 30, y: 20 }; + const rect: Rect = { height: 40, left: 10, top: 5, width: 60 }; + + const screen = frameToScreen(frameEl, rect, 2, offset); + expect(screen).toEqual({ height: 80, left: 180, top: 100, width: 120 }); + + const back = screenToFrame( + frameEl, + { x: screen.left, y: screen.top }, + 2, + offset + ); + expect(back.x).toBeCloseTo(rect.left, 6); + expect(back.y).toBeCloseTo(rect.top, 6); + }); + + it("reduces to the plain conversion when the offset is absent", () => { + const frameEl = document.createElement("div"); + stubRect(frameEl, { left: 100, top: 50 }); + const rect: Rect = { height: 40, left: 10, top: 5, width: 60 }; + + expect(frameToScreen(frameEl, rect, 2)).toEqual( + frameToScreen(frameEl, rect, 2, { x: 0, y: 0 }) + ); + }); +}); diff --git a/packages/overlay/src/canvas/space.ts b/packages/overlay/src/canvas/space.ts index 82876df..39beeee 100644 --- a/packages/overlay/src/canvas/space.ts +++ b/packages/overlay/src/canvas/space.ts @@ -122,31 +122,147 @@ export function frameScreenRect(frameEl: Element): Rect { return { height: r.height, left: r.left, top: r.top, width: r.width }; } -/** A rect measured inside a frame → the shell's screen space. */ +const ORIGIN: Point = { x: 0, y: 0 }; + +/** + * A rect measured inside a frame → the shell's screen space. `offset` is the + * pre-composed translation of a nested document's viewport within the frame + * (`nestOffset`); it defaults to zero so the single-level path is unchanged. + */ export function frameToScreen( frameEl: Element, rect: Rect, - scale: number + scale: number, + offset: Point = ORIGIN ): Rect { const o = frameScreenRect(frameEl); return { height: snap(rect.height * scale), - left: snap(o.left + rect.left * scale), - top: snap(o.top + rect.top * scale), + left: snap(o.left + (rect.left + offset.x) * scale), + top: snap(o.top + (rect.top + offset.y) * scale), width: snap(rect.width * scale), }; } -/** A point in the shell's screen space → that frame's viewport coordinates. */ +/** A point in the shell's screen space → that frame's viewport coordinates — + * or, given a `nestOffset`, a nested document's viewport coordinates. */ export function screenToFrame( frameEl: Element, point: Point, - scale: number + scale: number, + offset: Point = ORIGIN ): Point { const o = frameScreenRect(frameEl); return { - x: (point.x - o.left) / scale, - y: (point.y - o.top) / scale, + x: (point.x - o.left) / scale - offset.x, + y: (point.y - o.top) / scale - offset.y, + }; +} + +// -- nested documents -------------------------------------------------------- +// A same-origin iframe inside a frame (Storybook's preview is the common case) +// holds its own document, whose rects are in its own viewport. Composition is +// translation-only: each iframe's getBoundingClientRect() is already in its +// parent document's coordinates, so the canvas scale applies once, at the +// outermost boundary, and every level below is a plain add — CSS-transformed +// nested iframes are out of scope. + +/** How deep a same-origin iframe chain the editor follows. Storybook needs + * one level; four is generous and stops a self-embedding page from walking + * forever. Shared by every walk, up (agents, ownership) and down (hit + * testing). */ +export const MAX_NEST_DEPTH = 4; + +/** + * The iframe holding `win` in its parent document, or null at a top window + * or a cross-origin boundary. `frameElement` is the direct answer in a real + * browser; happy-dom leaves it `undefined`, so the tests reach the fallback, + * which scans the parent document's iframes for our own window. The + * browser-tier stories cover the direct path. + */ +export function parentFrameElement(win: Window): Element | null { + const direct = win.frameElement; + if (direct !== undefined) { + return direct; + } + const { parent } = win; + if (!parent || parent === win) { + return null; + } + for (const iframe of Array.from(parent.document.querySelectorAll("iframe"))) { + if ((iframe as HTMLIFrameElement).contentWindow === win) { + return iframe; + } + } + return null; +} + +/** + * The iframe elements between `win` and the ancestor `root`, innermost first. + * Empty for `win === root`. `null` when `root` is not an ancestor, the chain + * exceeds `MAX_NEST_DEPTH`, or a hop is cross-origin (property access + * throws) — the caller treats that boundary as an opaque leaf. + */ +export function frameChain( + win: Window, + root: Window +): HTMLIFrameElement[] | null { + const chain: HTMLIFrameElement[] = []; + let current: Window = win; + while (chain.length <= MAX_NEST_DEPTH) { + if (current === root) { + return chain; + } + let frameEl: Element | null; + try { + frameEl = parentFrameElement(current); + } catch { + return null; + } + // A tag check, not instanceof: the element belongs to the parent + // document's realm, and a cross-realm instanceof is always false. + if (frameEl?.tagName !== "IFRAME") { + return null; + } + const parent = frameEl.ownerDocument.defaultView; + if (!parent) { + return null; + } + chain.push(frameEl as HTMLIFrameElement); + current = parent; + } + return null; +} + +/** + * The composed translation from the innermost document's viewport to the + * outermost frame document's viewport: each iframe's border-box position plus + * its own border (`clientLeft`/`clientTop`). `{x:0,y:0}` for an empty chain, + * which is what keeps the non-nested path byte-identical. + */ +export function nestOffset(chain: readonly HTMLIFrameElement[]): Point { + let x = 0; + let y = 0; + for (const iframe of chain) { + const rect = iframe.getBoundingClientRect(); + x += rect.left + iframe.clientLeft; + y += rect.top + iframe.clientTop; + } + return { x, y }; +} + +/** The overlap of two rects; zero-size when they miss, which `clipTo` + * already handles. */ +export function intersectRects(a: Rect, b: Rect): Rect { + const left = Math.max(a.left, b.left); + const top = Math.max(a.top, b.top); + const right = Math.min(a.left + a.width, b.left + b.width); + const bottom = Math.min(a.top + a.height, b.top + b.height); + return { + height: Math.max(0, bottom - top), + left, + top, + width: Math.max(0, right - left), }; }