diff --git a/packages/overlay/src/surface.test.ts b/packages/overlay/src/surface.test.ts index 29b0580..1a3c8b6 100644 --- a/packages/overlay/src/surface.test.ts +++ b/packages/overlay/src/surface.test.ts @@ -1,6 +1,13 @@ import { afterEach, describe, expect, it } from "vitest"; +import { type Frame, FrameManager } from "./canvas/frames"; +import type { CanvasViewport } from "./canvas/viewport"; import { cls, PREFIX } from "./dom"; -import { InlineSurface } from "./surface"; +import { + CanvasResolver, + InlineResolver, + InlineSurface, + NestedSurface, +} from "./surface"; /* * Hit-testing through the editor's own overlay. @@ -109,3 +116,148 @@ describe("InlineSurface.elementAtScreen", () => { expect(at()).toBeNull(); }); }); + +// -- nested documents --------------------------------------------------------- +// happy-dom does no layout, so each document's single-element hit test is +// stubbed; what is under test is the descent, the ascent and the composition. + +/** Pin one document's hit test to a fixed answer. */ +function stubHit(doc: Document, node: Element | null): void { + Object.defineProperty(doc, "elementFromPoint", { + configurable: true, + value: () => node, + writable: true, + }); +} + +/** Pin an element's screen rect — see space.test.ts for the same idiom. */ +function stubRect(el: Element, left: number, top: number): void { + (el as HTMLElement).getBoundingClientRect = () => + ({ height: 0, left, top, width: 0 }) as DOMRect; +} + +interface NestedFixture { + frame: Frame; + frames: FrameManager; + nested: HTMLIFrameElement; + nestedDoc: Document; + resolver: CanvasResolver; + target: Element; + world: HTMLElement; +} + +function nestedFixture(scale = 1): NestedFixture { + const world = document.createElement("div"); + document.body.append(world); + const frames = new FrameManager({ + pathname: "/", + storageKey: "__airship-test:surface", + world, + }); + const frame = frames.add({ name: "one" }); + if (!frame?.doc) { + throw new Error("frame did not build"); + } + const nested = frame.doc.createElement("iframe"); + frame.doc.body.append(nested); + const nestedDoc = nested.contentDocument as Document; + const target = nestedDoc.createElement("button"); + nestedDoc.body.append(target); + + stubHit(frame.doc, nested); + stubHit(nestedDoc, target); + + const resolver = new CanvasResolver(frames, { + scale, + } as unknown as CanvasViewport); + return { frame, frames, nested, nestedDoc, resolver, target, world }; +} + +describe("CanvasResolver, over a nested same-origin iframe", () => { + afterEach(() => { + // The fixture's manager owns the global ready-hook; drop it. + for (const child of Array.from(document.body.children)) { + child.remove(); + } + }); + + it("descends to the deepest surface instead of returning the iframe", () => { + const { resolver, nestedDoc, target } = nestedFixture(); + // happy-dom's zero-size rects put {0,0} inside the frame. + const surface = resolver.at({ x: 0, y: 0 }); + + expect(surface).toBeInstanceOf(NestedSurface); + expect(surface?.doc).toBe(nestedDoc); + expect(surface?.elementAtScreen({ x: 0, y: 0 })).toBe(target); + }); + + it("resolves a nested node back to the same nested surface", () => { + const { resolver, nestedDoc, target } = nestedFixture(); + const surface = resolver.of(target); + + expect(surface).toBeInstanceOf(NestedSurface); + expect(surface?.doc).toBe(nestedDoc); + // Identity is stable across calls — the picker compares surfaces by it. + expect(resolver.of(target)).toBe(surface); + expect(resolver.at({ x: 0, y: 0 })).toBe(surface); + }); + + it("keeps the frame surface for the frame's own nodes", () => { + const { resolver, frame } = nestedFixture(); + const own = frame.doc?.body as Element; + + expect(resolver.of(own)).not.toBeInstanceOf(NestedSurface); + expect(resolver.of(own)?.doc).toBe(frame.doc); + }); + + it("composes the nested offset under the canvas scale", () => { + const { resolver, target, frame, nested } = nestedFixture(2); + stubRect(frame.el, 100, 50); + stubRect(nested, 30, 40); + + const surface = resolver.of(target); + const screen = surface?.toScreen({ height: 4, left: 10, top: 5, width: 6 }); + + // left = frame.left + (rect.left + offset.x) * scale = 100 + 40*2. + expect(screen).toEqual({ height: 8, left: 180, top: 140, width: 12 }); + + const back = surface?.toLocal({ x: 180, y: 140 }); + expect(back?.x).toBeCloseTo(10, 6); + expect(back?.y).toBeCloseTo(5, 6); + }); + + it("stops at a boundary whose document is unreachable", () => { + const { resolver, frame, nested } = nestedFixture(); + Object.defineProperty(nested, "contentDocument", { + configurable: true, + get() { + return null; + }, + }); + + const surface = resolver.at({ x: 0, y: 0 }); + expect(surface).not.toBeInstanceOf(NestedSurface); + expect(surface?.doc).toBe(frame.doc); + }); +}); + +describe("InlineResolver, over a nested same-origin iframe", () => { + it("descends and ascends with the page as the root", () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const innerDoc = iframe.contentDocument as Document; + const target = innerDoc.createElement("p"); + innerDoc.body.append(target); + + stack([iframe, document.body]); + stubHit(innerDoc, target); + + const resolver = new InlineResolver(); + const surface = resolver.at({ x: 10, y: 10 }); + expect(surface.doc).toBe(innerDoc); + expect(surface.elementAtScreen({ x: 10, y: 10 })).toBe(target); + expect(resolver.of(target)).toBe(surface); + // The top document keeps the one inline surface. + expect(resolver.of(document.body).doc).toBe(document); + }); +}); diff --git a/packages/overlay/src/surface.ts b/packages/overlay/src/surface.ts index 48e0156..74c34e3 100644 --- a/packages/overlay/src/surface.ts +++ b/packages/overlay/src/surface.ts @@ -4,8 +4,12 @@ import { extractElementInfo } from "@airship/source/browser"; import type { Frame, FrameManager } from "./canvas/frames"; import { clipTo, + frameChain, frameScreenRect, frameToScreen, + intersectRects, + MAX_NEST_DEPTH, + nestOffset, type Point, type Rect, screenToFrame, @@ -65,6 +69,64 @@ export interface SurfaceResolver { of: (node: Node | null) => Surface | null; } +/** + * The app node under a point in one document, looking *through* the editor's + * own chrome. `elementsFromPoint` rather than `elementFromPoint`, because + * chrome that is deliberately opaque to the pointer (the reorder drag proxy, + * see `InlineSurface.elementAtScreen`'s history) must not win the hit; the + * single-element form is the fallback for engines without the stack API — + * which includes happy-dom, where this package's DOM tests run. + */ +function hitTestIn(doc: Document, point: Point): Element | null { + const probe = doc.elementsFromPoint?.bind(doc); + if (!probe) { + return doc.elementFromPoint(point.x, point.y); + } + for (const node of probe(point.x, point.y)) { + if (!isEditorNode(node)) { + return node; + } + } + return null; +} + +/** + * Step from a surface into the same-origin iframe under the point, repeatedly, + * and return the deepest surface. This is what makes a document nested inside + * a frame (Storybook's preview) pickable rather than one opaque `