/** * Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection) + * WS-A2 (applyDraft / commitPreview / cancelPreview → moveElement) + * WS-G (image-alpha hit-test, phase 1). * * Requirements: * - The iframe MUST be same-origin (srcdoc / blob URL). Cross-origin access to * contentDocument throws a DOMException; this adapter does not guard that — * the caller is responsible for ensuring same-origin. * * Image-alpha (phase 1): * - Replaces elementFromPoint with elementsFromPoint (z-stack) so transparent * image hits fall through to the element behind. * - For hits, maps the client point to the natural-pixel coordinate * (object-fit/object-position aware), draws to an offscreen canvas (cached * by src + natural dimensions, so a srcset re-render gets a fresh canvas), * samples alpha. Transparent pixel → miss, continue the stack. * - Cross-origin images taint the canvas → getImageData throws SecurityError * → falls back to treating the pixel as OPAQUE (never drop an unverifiable * hit) and warns once per src. Callers must ensure CORS or accept the fallback. * - A CSS rotation/skew on the image or an ancestor also falls back to opaque * (the axis-aligned rect mapping can't sample a rotated image correctly); * transform-inverse mapping is phase 2. * - Images above a pixel budget skip alpha-testing (opaque) to bound canvas * memory. Limitation: animated (gif) or src swaps invalidate the cache * only when currentSrc/dimensions change. Phase 1 is optimized for static images. * - Phase 2 (full per-pixel alpha via drawElement rasterization) is NOT built * here — gated on a perf spike. * * Paint query (isProvablyEmptyAt): * - Answers "is this point provably free of ink", which is a different question from * "what element is here" and needs a different traversal. elementsFromPoint omits * `pointer-events: none` nodes; those still paint, so a paint query built on the * z-stack reports no ink over visible artwork. The walk therefore covers element * BOXES geometrically and is blind to pointer-events and z-index. * - Ink itself is a computed-style heuristic (background, border, own text, intrinsic * media) EXCEPT for , which routes through the alpha sampler above — so a * transparent PNG only paints where its pixels do. */ import { EDIT_BASE_X_ATTR, EDIT_BASE_Y_ATTR, EDIT_ORIGINAL_TRANSLATE_ATTR, applyPositionEditToElement, composeTranslate, readCurrentTranslate, } from "@hyperframes/core/runtime/position-edits"; import type { PreviewAdapter, ElementAtPointResult, DraftProps, PaintQueryOptions, } from "./types.js"; import type { EditOp, Composition } from "../types.js"; import { applyPatchesToDocument, applyOverrideSet } from "../engine/apply-patches.js"; // ─── Pure resolver (testable without a browser) ─────────────────────────────── /** * Walk from `el` upward through parentElement, looking for the nearest node * that carries `[data-hf-id]` and is NOT `[data-hf-root]`. * * Returns null when: * - The walk exits the tree without finding `[data-hf-id]` * - The matching node is `[data-hf-root]` (transparent to hit-testing) * - `isVisible(node)` returns false for the matching node * * Keeping this a pure function (no elementFromPoint, no window access) makes * it unit-testable in a plain Node environment. */ export function resolveNearestHfElement( el: Element | null, isVisible: (el: Element) => boolean, ): ElementAtPointResult | null { let node = el; while (node !== null) { const id = node.getAttribute("data-hf-id"); if (id !== null) { if (node.hasAttribute("data-hf-root")) return null; if (!isVisible(node)) return null; return { id, tag: node.tagName.toLowerCase() }; } node = node.parentElement; } return null; } // ─── Draft position math (pure — testable without a browser) ───────────────── /** * Compute the new absolute x/y for a moveElement op given: * - the element's current `data-x` / `data-y` string values (may be null) * - the accumulated drag delta (dx, dy) from applyDraft calls * * `data-x` / `data-y` default to 0 when absent or non-numeric. */ export function computeDraftPosition( dataX: string | null, dataY: string | null, dx: number, dy: number, ): { x: number; y: number } { const baseX = parseFloat(dataX ?? "0") || 0; const baseY = parseFloat(dataY ?? "0") || 0; return { x: baseX + dx, y: baseY + dy }; } // ─── Image-alpha pure helpers (WS-G phase 1) ────────────────────────────────── /** * Returns true when the first pixel in `imageData` has alpha >= threshold. * * Pure — no DOM access; unit-testable with a plain Uint8ClampedArray. * threshold defaults to 1 so a fully-transparent pixel (a=0) is a miss. */ export function alphaIsOpaque(imageData: ImageData, threshold = 1): boolean { // ImageData.data is [R, G, B, A, R, G, B, A, ...] const alpha = imageData.data[3] ?? 0; return alpha >= threshold; } /** * Map a client-space point to the natural-pixel coordinates of the image. * * Handles object-fit: fill | cover | contain (default=fill when unset). * object-position is parsed as two percentage/px values (default "50% 50%"). * * Returns null when the point falls outside the rendered image area (e.g. * the letterbox region of a contain-fitted image). A null result means the * image does not own this pixel — the caller should continue the z-stack. * * Pure — no DOM/window access; unit-testable with plain objects. */ // fallow-ignore-next-line complexity export function mapPointToImagePixel( rect: { left: number; top: number; width: number; height: number }, natural: { width: number; height: number }, objectFit: string, objectPosition: string, point: { x: number; y: number }, ): { px: number; py: number } | null { // Local coords within the CSS box const lx = point.x - rect.left; const ly = point.y - rect.top; if (lx < 0 || ly < 0 || lx > rect.width || ly > rect.height) return null; const fit = objectFit || "fill"; // For fill (or unrecognized values): the natural image is stretched to the // box; direct linear mapping. if (fit !== "cover" && fit !== "contain" && fit !== "none") { if (rect.width === 0 || rect.height === 0) return null; const px = Math.floor((lx / rect.width) * natural.width); const py = Math.floor((ly / rect.height) * natural.height); return { px: clamp(px, 0, natural.width - 1), py: clamp(py, 0, natural.height - 1) }; } // For none: image is drawn at its natural size; no scaling. if (fit === "none") { const pos = parseObjectPosition(objectPosition, rect, natural); const ox = pos.x; const oy = pos.y; const px = Math.floor(lx - ox); const py = Math.floor(ly - oy); if (px < 0 || py < 0 || px >= natural.width || py >= natural.height) return null; return { px, py }; } // cover: scale uniformly so the image covers the box; may clip edges. // contain: scale uniformly so the image fits within the box; may letterbox. if (natural.width === 0 || natural.height === 0) return null; const scaleX = rect.width / natural.width; const scaleY = rect.height / natural.height; const scale = fit === "cover" ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY); const renderedW = natural.width * scale; const renderedH = natural.height * scale; const pos = parseObjectPosition(objectPosition, rect, { width: renderedW, height: renderedH, }); // Offset of the rendered image's top-left within the CSS box const imgLeft = pos.x; const imgTop = pos.y; // Local coords relative to the rendered image's top-left const rx = lx - imgLeft; const ry = ly - imgTop; if (rx < 0 || ry < 0 || rx > renderedW || ry > renderedH) return null; if (scale === 0) return null; const px = Math.floor(rx / scale); const py = Math.floor(ry / scale); return { px: clamp(px, 0, natural.width - 1), py: clamp(py, 0, natural.height - 1) }; } // ─── object-position parser (pure) ─────────────────────────────────────────── /** * Parse a CSS object-position value into x/y offsets (top-left of the * rendered content relative to the CSS box top-left). * * Supports the common subset: keyword pairs, percentage pairs, pixel pairs, * and single-value shorthand. Mixed units (e.g. "50% 10px") are supported. * * Pure — no DOM access. */ function parseObjectPosition( objectPosition: string, box: { width: number; height: number }, content: { width: number; height: number }, ): { x: number; y: number } { const raw = (objectPosition || "50% 50%").trim(); const parts = raw.split(/\s+/); // Resolve a single token into a pixel offset along the given axis. // `available` is the "slack" (box dimension - content dimension). // fallow-ignore-next-line complexity function resolveToken(token: string, available: number): number { if (token === "left" || token === "top") return 0; if (token === "right" || token === "bottom") return available; if (token === "center") return available / 2; if (token.endsWith("%")) { const pct = parseFloat(token) / 100; return isNaN(pct) ? available / 2 : pct * available; } if (token.endsWith("px")) { const px = parseFloat(token); return isNaN(px) ? available / 2 : px; } // Bare number — treat as px const n = parseFloat(token); return isNaN(n) ? available / 2 : n; } const availX = box.width - content.width; const availY = box.height - content.height; const isVert = (t: string) => t === "top" || t === "bottom"; const isHoriz = (t: string) => t === "left" || t === "right"; if (parts.length === 1) { const tokenX = parts[0] ?? "50%"; // Single value: if it's a vertical keyword the x defaults to center if (isVert(tokenX)) { return { x: availX / 2, y: resolveToken(tokenX, availY) }; } return { x: resolveToken(tokenX, availX), y: availY / 2 }; } // Keyword pairs may be given vertical-first ("bottom left"); normalize so the // first token addresses the x-axis and the second the y-axis. let xToken = parts[0] ?? "50%"; let yToken = parts[1] ?? "50%"; if (isVert(xToken) || isHoriz(yToken)) { [xToken, yToken] = [yToken, xToken]; } return { x: resolveToken(xToken, availX), y: resolveToken(yToken, availY), }; } function clamp(v: number, min: number, max: number): number { return v < min ? min : v > max ? max : v; } // ─── Visibility check ───────────────────────────────────────────────────────── /** * Returns true when no element in the ancestor chain (inclusive) has * computed opacity === 0. Checks ancestors because a parent at opacity:0 * makes the child invisible even if the child's own opacity is 1. * * This reflects the current GSAP timeline state (whatever the player has * seeked to). For atTime values matching the live playhead this is always * accurate. For speculative times this is NOT seeked — WS-A1 does not mutate * the timeline; accurate out-of-band opacity queries are WS-G follow-on. */ function isOpacityVisible(el: Element, win: Window & typeof globalThis): boolean { let node: Element | null = el; while (node !== null) { const style = win.getComputedStyle(node); if (parseFloat(style.opacity) === 0) return false; node = node.parentElement; } return true; } // ─── Image-alpha canvas cache (WS-G phase 1) ───────────────────────────────── /** * Cache of offscreen canvases keyed by image currentSrc. * * Canvases are drawn once; the same canvas is reused across hit-tests. * Animated images (gif) or dynamic src swaps are NOT tracked — this is a * phase-1 static-image optimization. A tainted entry stores null to record * that the image is cross-origin and all pixels should be treated as opaque. * * Exported for tests that need to reset the cache between runs. */ export const _imgCanvasCache = new Map(); /** * Bounded cap so a long session can't accumulate one full-resolution * OffscreenCanvas per image src indefinitely. * ponytail: FIFO eviction, upgrade to LRU if cache hit-rate matters. */ const _IMG_CANVAS_CACHE_MAX = 64; function cacheCanvas(key: string, value: OffscreenCanvas | null): void { if (!_imgCanvasCache.has(key) && _imgCanvasCache.size >= _IMG_CANVAS_CACHE_MAX) { const oldest = _imgCanvasCache.keys().next().value; if (oldest !== undefined) _imgCanvasCache.delete(oldest); } _imgCanvasCache.set(key, value); } /** * Skip alpha-testing images above this pixel budget — allocating a full-res * OffscreenCanvas for one hit-test is memory-prohibitive (a 4000×3000 image is * ~46 MB). Above the cap we fail safe to opaque. * ponytail: per-image pixel cap; a byte-budget cache is phase 2. */ const _MAX_ALPHA_TEST_PIXELS = 16_000_000; /** * Srcs we've already warned about taint for — keeps the cross-origin warning to * once per image instead of once per hit-test. Cleared with `_imgCanvasCache` * is not necessary; suppressing duplicate warnings across resets is harmless. */ const _warnedTaintSrcs = new Set(); function warnTaintOnce(src: string): void { if (_warnedTaintSrcs.has(src)) return; _warnedTaintSrcs.add(src); // Visibility for the silent-failure path: a cross-origin / uncorsed image // taints the canvas, so alpha hit-test is unavailable and we fall back to // opaque. Without this, the fall-back is invisible ("hit-test feels wrong"). console.warn( `[hyperframes] image-alpha hit-test unavailable for cross-origin/tainted image; treating as opaque: ${src}`, ); } /** * True when the element or any ancestor carries a CSS rotation or skew. Such a * transform makes getBoundingClientRect() return the axis-aligned bounding box, * so the rect→natural-pixel mapping would sample the wrong pixel. Pure translate * / scale keep the matrix b and c components at 0 and map correctly. No-op * (returns false) when DOMMatrix is unavailable (e.g. the test env), preserving * existing behavior there. */ function hasRotationOrSkew(el: Element | null, win: Window & typeof globalThis): boolean { if (typeof win.DOMMatrix !== "function") return false; for (let node: Element | null = el; node; node = node.parentElement) { const t = win.getComputedStyle(node).transform; if (!t || t === "none") continue; try { const m = new win.DOMMatrix(t); if (Math.abs(m.b) > 1e-6 || Math.abs(m.c) > 1e-6) return true; } catch { return true; // unparseable transform — fail safe (treat as non-axis-aligned) } } return false; } /** * Sample the alpha at (clientX, clientY) for an element. * * Returns true (opaque) when: * - The image has not finished loading (naturalWidth/naturalHeight === 0) * - The point maps outside the rendered image area (not this image's pixel) * - The canvas is tainted (cross-origin, SecurityError) — fallback: opaque * - Alpha >= 1 * * Returns false (transparent/miss) only when the canvas is readable AND the * alpha at the mapped pixel is 0. A click on the element's border/padding maps * outside the content box → also false (the click falls through to the layer * behind), since border/padding pixels aren't part of the image — intentional. * * `win` is the iframe's contentWindow, used to call getComputedStyle on the * element which lives in the iframe's document. */ // fallow-ignore-next-line complexity export function imageAlphaOpaqueAt( img: HTMLImageElement, clientX: number, clientY: number, win: Window & typeof globalThis, /** Set to true only when pixels were actually read (or the point provably missed the * rendered image). Lets callers tell a measured answer from a fail-safe assumption. */ probe?: { sampled: boolean }, ): boolean { // Not loaded yet — treat as opaque (safe fallback) if (img.naturalWidth === 0 || img.naturalHeight === 0) return true; const src = img.currentSrc || img.src; if (!src) return true; // CSS rotation/skew on the image or an ancestor breaks the axis-aligned // rect→natural-pixel mapping below (getBoundingClientRect returns the AABB), // so we'd sample the wrong pixel. Fail safe to opaque rather than guess. // Full transform-inverse mapping is phase 2. if (hasRotationOrSkew(img, win)) return true; // Pathological-size guard: don't allocate a huge canvas for one hit-test. if (img.naturalWidth * img.naturalHeight > _MAX_ALPHA_TEST_PIXELS) return true; // object-fit/object-position lay the image out within the CONTENT box, not // the border box that getBoundingClientRect() returns. Inset by border + // padding so the mapping is correct for an that has a border or padding. const rect = img.getBoundingClientRect(); const style = win.getComputedStyle(img); const borderL = parseFloat(style.borderLeftWidth) || 0; const borderT = parseFloat(style.borderTopWidth) || 0; const borderR = parseFloat(style.borderRightWidth) || 0; const borderB = parseFloat(style.borderBottomWidth) || 0; const padL = parseFloat(style.paddingLeft) || 0; const padT = parseFloat(style.paddingTop) || 0; const padR = parseFloat(style.paddingRight) || 0; const padB = parseFloat(style.paddingBottom) || 0; const objectFit = style.objectFit || "fill"; const objectPosition = style.objectPosition || "50% 50%"; const mapped = mapPointToImagePixel( { left: rect.left + borderL + padL, top: rect.top + borderT + padT, width: rect.width - borderL - borderR - padL - padR, height: rect.height - borderT - borderB - padT - padB, }, { width: img.naturalWidth, height: img.naturalHeight }, objectFit, objectPosition, { x: clientX, y: clientY }, ); // Point is outside the rendered image area — not this image's pixel. // Continue the z-stack (return false = miss on this element). Geometric certainty, // so it counts as sampled. if (mapped === null) { if (probe) probe.sampled = true; return false; } // Retrieve or build the offscreen canvas. Key on src + natural dimensions: a // srcset/responsive layout can serve the same URL at a different natural size, // and keying on src alone would reuse a canvas drawn at the prior dimensions. const cacheKey = `${src}@${img.naturalWidth}x${img.naturalHeight}`; let canvas: OffscreenCanvas | null | undefined = _imgCanvasCache.get(cacheKey); if (canvas === undefined) { // First time: draw to an offscreen canvas and cache. try { const oc = new OffscreenCanvas(img.naturalWidth, img.naturalHeight); const ctx = oc.getContext("2d"); if (!ctx) { // OffscreenCanvas 2D unavailable — treat as opaque. cacheCanvas(cacheKey, null); return true; } ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight); cacheCanvas(cacheKey, oc); canvas = oc; } catch { // SecurityError from tainted canvas — record null and fall back opaque. warnTaintOnce(src); cacheCanvas(cacheKey, null); return true; } } // null means we already know this src is tainted — treat as opaque. if (canvas === null) return true; try { const ctx = canvas.getContext("2d"); if (!ctx) return true; // The mapped-pixel read also surfaces lazy canvas taint (SecurityError), // so no separate taint probe is needed. const data = ctx.getImageData(mapped.px, mapped.py, 1, 1); if (probe) probe.sampled = true; return alphaIsOpaque(data); } catch { // Taint discovered on getImageData — update cache and fall back opaque. warnTaintOnce(src); cacheCanvas(cacheKey, null); return true; } } // ─── Paint query ────────────────────────────────────────────────────────────── /** * Elements that paint by virtue of what they are, whatever their CSS says. * * `img` and `picture` look redundant against the branches above them in `elementPaintsInk`, * and are not: those branches need a `point` to sample alpha against, so a point-free call * ("could this paint at all?") falls through to this set — as does any environment without * `window.HTMLImageElement`. The set is also exported, where it reads as a description of * intrinsic painters rather than a switch in one function. */ export const INTRINSIC_PAINT_TAGS: ReadonlySet = new Set([ "img", "picture", "video", "canvas", "svg", ]); /** * Does this computed colour put down no ink? * * The `transparent` keyword computes to `rgba(0, 0, 0, 0)`, but ANY colour can carry a zero * alpha — `rgba(255, 255, 255, 0)` is exactly as invisible and is what you get from fading a * white background out. Matching known spellings misses those, so the alpha is read instead. */ function isTransparentColor(value: string): boolean { if (!value || value === "transparent") return true; // rgb/hsl and their -a forms all carry alpha as the fourth component. Computed // background-color is serialized to rgb() by every engine we target, but matching both // costs one alternation and removes the dependency on that. const inner = /^(?:rgba?|hsla?)\(([^)]*)\)$/.exec(value)?.[1]; if (inner === undefined) return false; // Handles both the legacy comma form and the `rgb(r g b / a)` slash form. const parts = inner.split(/[\s,/]+/).filter(Boolean); // An rgb() with no alpha component is fully opaque. const alpha = parts[3]; return alpha !== undefined && Number.parseFloat(alpha) === 0; } const BORDER_SIDES = ["top", "right", "bottom", "left"] as const; /** * The element's OWN non-whitespace text — a direct child text node, not text that * lives in a descendant. A wrapper around a caption doesn't paint; the caption does. * Without this, every ancestor of a text node would count and the whole frame would * read as painted. */ function hasOwnText(el: Element): boolean { for (const node of Array.from(el.childNodes)) { if (node.nodeType === 3 /* TEXT_NODE */ && (node.textContent ?? "").trim() !== "") return true; } return false; } /** * Does this element put ink on the frame, and — when `point` is supplied and the * element is an image — ink at that specific point? * * Mostly a heuristic over computed style, because a composition's ordinary elements * cannot be sampled without rasterizing them. `` is the exception: the alpha * sampler above reads the actual pixel, so a transparent PNG paints only where its * pixels do. `` defers to the `` it wraps, since the wrapper itself * paints nothing. * * KNOWN OVER-COUNT: a `background-image` that is itself mostly transparent reads as * painting across its whole box. Narrowing it needs a second sampling path (resolve * the `url()`, load it, map through background-size/position). Erring toward "paints" * is the safe direction — the cost is a click that selects the composition, not a * click that vanishes. * * Transparency by CSS (`display: none`, `visibility: hidden`, `opacity: 0`) is NOT * handled here; `compositionPaintsAt` filters those before asking. */ /** * How an element's ink was established. * * `"verified"` means pixels were read. `"inferred"` means computed style says something * paints across the box — true of a `background-image` that is mostly transparent, and of * an image whose pixels could not be sampled. The distinction is what lets the full-bleed * veto discount assumed box-filling paint without discarding a measured pixel. */ type InkKind = "none" | "verified" | "inferred"; /** Any side with a drawn border — a style that renders and a non-zero width. */ function hasVisibleBorder(cs: CSSStyleDeclaration): boolean { for (const side of BORDER_SIDES) { const style = cs.getPropertyValue(`border-${side}-style`); const width = Number.parseFloat(cs.getPropertyValue(`border-${side}-width`) || "0"); if (style && style !== "none" && style !== "hidden" && width > 0) return true; } return false; } /** Computed-style ink: background, border, or the element's own text. */ function styleInk(el: Element, win: Window & typeof globalThis): InkKind { const cs = win.getComputedStyle(el); if (!isTransparentColor(cs.backgroundColor)) return "inferred"; if (cs.backgroundImage && cs.backgroundImage !== "none") return "inferred"; if (hasVisibleBorder(cs)) return "inferred"; return hasOwnText(el) ? "inferred" : "none"; } function inkAt( el: Element, win: Window & typeof globalThis, point?: { x: number; y: number }, ): InkKind { const tag = el.tagName.toLowerCase(); if (tag === "picture") { const img = el.querySelector("img"); return img ? inkAt(img, win, point) : "inferred"; } if (point && win.HTMLImageElement && el instanceof win.HTMLImageElement) { const probe = { sampled: false }; if (imageAlphaOpaqueAt(el, point.x, point.y, win, probe)) { // An unsampled "opaque" is the fail-safe, not a measurement, so it stays inferred. return probe.sampled ? "verified" : "inferred"; } // A clear pixel does not settle the element: its own background plate, padding and // border still paint, and the point may have landed on them rather than the bitmap. return styleInk(el, win); } if (INTRINSIC_PAINT_TAGS.has(tag)) return "inferred"; return styleInk(el, win); } export function elementPaintsInk( el: Element, win: Window & typeof globalThis, point?: { x: number; y: number }, ): boolean { return inkAt(el, win, point) !== "none"; } interface PaintCandidate { el: Element; area: number; depth: number; } /** * Area of the composition frame the point sits in — what a full-bleed layer is measured * against. Nested sub-compositions carry `data-composition-id` too, so the innermost * root CONTAINING the point wins. * * Containment is the load-bearing part. Taking the smallest root in the document * regardless of where the point falls lets an unrelated sub-composition elsewhere on the * frame shrink the reference: a 300x300 badge in a corner would make every mid-size * painter in the 1920x1080 outer frame read as full-bleed, and the composition would go * click-through under artwork the user can plainly see. * * Infinity when no root contains the point, which disables the full-bleed rule rather * than guessing at a frame. */ function compositionFrameArea(doc: Document, x: number, y: number): number { let smallest = Infinity; doc.querySelectorAll("[data-composition-id]").forEach((root) => { const rect = root.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; if (!boxContains(rect, x, y)) return; smallest = Math.min(smallest, rect.width * rect.height); }); return smallest; } function boxContains(rect: DOMRect, x: number, y: number): boolean { return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; } /** * Is the element rendered at all? * * `display: none` is checked belt-and-braces: a real engine collapses the box and the area * guard already rejects it, so the clause only earns its place against hosts that report a * box anyway. `visibility` and `opacity` genuinely need it, and opacity has to be read up * the ancestor chain — a fully opaque child inside a fade-in wrapper that has not started * yet is invisible, and getComputedStyle does not multiply the cascade for us. */ function isRenderedVisible(el: Element, win: Window & typeof globalThis): boolean { const cs = win.getComputedStyle(el); if (cs.display === "none" || cs.visibility === "hidden") return false; return isOpacityVisible(el, win); } function depthOf(el: Element): number { let depth = 0; for (let p = el.parentElement; p; p = p.parentElement) depth++; return depth; } /** * `el` as a paint candidate for the point, or null when it cannot contribute ink * there. Ink itself is NOT tested here — that is the expensive part, deferred until * the candidates are ordered. */ function paintCandidateAt( el: Element, win: Window & typeof globalThis, x: number, y: number, ): PaintCandidate | null { // Composition roots are candidates like anything else: a root carrying a background is // painting, and at fullBleedFraction 0 the literal ink question has to say so. They are // not excluded here because the veto already handles them — a root's box IS the frame, // so any non-zero fraction discounts it as background without a special case. const rect = el.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return null; if (!boxContains(rect, x, y)) return null; if (!isRenderedVisible(el, win)) return null; return { el, area: rect.width * rect.height, depth: depthOf(el) }; } /** * Does the composition in `doc` put ink at (x, y)? * * A GEOMETRIC walk, not a z-stack one: every element whose border box contains the * point is a candidate, regardless of pointer-events, z-index, or what paints over * it. That is what makes the answer "is there artwork here" rather than "what would * receive this click" — an author's `pointer-events: none` overlay still paints. * * Candidates are ordered smallest-box-first (deeper node wins a near-tie, matching * the way a host picks the most specific element) and ink is evaluated lazily down * that order, so the common case costs at most one alpha sample rather than one per * candidate. * * Exported so a host whose hit-test policy differs can reuse the ink test without * taking the adapter with it. Two preconditions come with that: * * - `doc` must be hf-id-stamped (what `openComposition` produces). Against an unstamped * document the default walk matches nothing and every point answers a hard `false`, * indistinguishable from genuinely empty — pass `addressableOnly: false` there. * - `x`/`y` are the DOCUMENT's own client coordinates, not the host page's. A host with a * CSS-scaled iframe has to divide out that scale first, or it samples the wrong pixel. * * Note the polarity: this answers "does it paint", the positive question, and cannot say * "not knowable". A caller that wants the fail-safe contract — every uncertainty resolving * toward keeping the composition clickable — wants `PreviewAdapter.isProvablyEmptyAt`. */ export function compositionPaintsAt( doc: Document, win: Window & typeof globalThis, x: number, y: number, opts?: PaintQueryOptions, ): boolean { const selector = (opts?.addressableOnly ?? true) ? "[data-hf-id]" : "*"; const candidates: PaintCandidate[] = []; doc.querySelectorAll(selector).forEach((el) => { const candidate = paintCandidateAt(el, win, x, y); if (candidate) candidates.push(candidate); }); // Smallest area first, deeper element first on an exact tie. Compared exactly rather // than within an epsilon: a "close enough" tie relation is intransitive, which makes // Array.sort's output implementation-defined and the smallest-first guarantee (and the // lazy single-sample property that rides on it) engine-dependent. candidates.sort((a, b) => a.area - b.area || b.depth - a.depth); let winner: PaintCandidate | undefined; let winnerInk: InkKind = "none"; for (const candidate of candidates) { const ink = inkAt(candidate.el, win, { x, y }); if (ink === "none") continue; winner = candidate; winnerInk = ink; break; } if (!winner) return false; const fullBleed = opts?.fullBleedFraction ?? 0; if (fullBleed <= 0) return true; // Never veto a measured pixel. The rule exists to discount paint ASSUMED to fill a box // — a full-frame wrapper with a background-image — and a full-bleed transparent PNG or // SVG overlay is precisely the case where that assumption is wrong: box area is not ink // area, and answering "background" there makes visible artwork unclickable. if (winnerInk === "verified") return true; const frameArea = compositionFrameArea(doc, x, y); return frameArea === Infinity || winner.area < fullBleed * frameArea; } // ─── IframePreviewAdapter ───────────────────────────────────────────────────── /** * The hit-test z-stack at (x, y): the full elementsFromPoint stack, or a * single-element fallback for hosts that lack elementsFromPoint. */ function hitStack(doc: Document, x: number, y: number): Element[] { if (typeof doc.elementsFromPoint === "function") return doc.elementsFromPoint(x, y); const top = doc.elementFromPoint(x, y); return top ? [top] : []; } type SelectionHandler = (ids: string[]) => void; class IframePreviewAdapter implements PreviewAdapter { private readonly iframe: HTMLIFrameElement; private readonly _dispatch: ((op: EditOp) => void) | undefined; private _selection: string[] = []; private _handlers: SelectionHandler[] = []; /** Tracked id and element for the in-progress drag. */ private _draftId: string | null = null; private _draftEl: HTMLElement | null = null; /** Accumulated drag deltas from applyDraft calls. */ private _draftDx = 0; private _draftDy = 0; /** * The element's effective `translate` when the drag started (inline value, * or computed when no inline one was set; "" = none). Drafts compose onto * this. */ private _draftPrevTranslate: string | null = null; /** * The element's raw INLINE `translate` when the drag started ("" = not * inline). Reverts restore exactly this, so a stylesheet-authored translate * is never promoted to a permanent inline style. */ private _draftPrevInlineTranslate: string | null = null; /** Unsubscribe for the current attachSync subscription, if any. */ private _syncDetach: (() => void) | null = null; constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) { this.iframe = iframe; this._dispatch = dispatch; } /** * Synchronous hit-test. Returns the nearest `[data-hf-id]` element under * (x, y) in the iframe's coordinate space, or null for a transparent hit * (root, opacity-0, nothing at all, or a transparent image pixel). * * WS-G phase 1: uses elementsFromPoint (z-stack) so a transparent-image hit * falls through to the layer behind. For elements, the alpha at the * mapped natural pixel is sampled from an offscreen canvas. Cross-origin * images that taint the canvas are treated as opaque (safe fallback). * * atTime: reflects the GSAP state at the playhead when this is called. * Seeking to a different time to check visibility is WS-G follow-on. */ elementAtPoint(x: number, y: number, _opts?: { atTime?: number }): ElementAtPointResult | null { const doc = this.iframe.contentDocument; if (!doc) return null; const win = this.iframe.contentWindow as (Window & typeof globalThis) | null; if (!win) return null; const stack = hitStack(doc, x, y); for (const candidate of stack) { // One opacity walk per candidate (candidate → root). An opacity:0 element // is skipped, so the click falls through to the layer painted behind it. if (!isOpacityVisible(candidate, win)) continue; // Image-alpha check: if this is an , verify the pixel is opaque. if (win.HTMLImageElement && candidate instanceof win.HTMLImageElement) { if (!imageAlphaOpaqueAt(candidate, x, y, win)) { // Transparent pixel — fall through to the next element in the stack. continue; } } // The candidate's whole ancestor chain is already known visible (the walk // above covers it, and the hf node is on that chain), so the resolver // needs no second visibility walk. const result = resolveNearestHfElement(candidate, () => true); if (result !== null) return result; } return null; } /** * Is (x, y) provably free of ink? See PreviewAdapter.isProvablyEmptyAt. * * Every uncertain path answers false rather than claiming emptiness: an unreachable or * cross-origin document, and a same-origin iframe mid-navigation — srcdoc and blob: * assignment are async navigations, so `contentDocument` is a READABLE but empty * document while the composition is arriving. Walking that would find no ink and hand * back a confident "safe to click through" under a composition about to appear. */ isProvablyEmptyAt(x: number, y: number, opts?: PaintQueryOptions): boolean { let doc: Document | null; let win: (Window & typeof globalThis) | null; try { doc = this.iframe.contentDocument; win = this.iframe.contentWindow as (Window & typeof globalThis) | null; } catch { return false; // Cross-origin access throws. Unknowable is not empty. } if (!doc || !win) return false; if (doc.readyState === "loading") return false; if (!doc.querySelector("[data-hf-id]") && !doc.querySelector("[data-composition-id]")) { return false; } return !compositionPaintsAt(doc, win, x, y, opts); } /** * Visually translate the target element inside the iframe at 60fps without * touching the model: sets the element's `translate` to its pre-drag value * composed with the accumulated delta. `translate` set after GSAP's first * parse is untouched by seeks, so this renders correctly on animated * elements too. (The `--hf-studio-dx/dy` custom properties are no longer * written — compositions with the authored Studio drag-bridge CSS would * move by twice the delta if both channels applied.) * * Calling applyDraft with a new id switches the tracked element, reverting * the previous element's draft translate first. * * width/height in DraftProps are not yet wired (resize → setStyle, future op). */ applyDraft(id: string, props: DraftProps): void { const el = this._resolveDraftElement(id); if (!el) return; if (props.dx !== undefined) this._draftDx = props.dx; if (props.dy !== undefined) this._draftDy = props.dy; el.style.setProperty( "translate", composeTranslate(this._draftPrevTranslate ?? "", `${this._draftDx}px`, `${this._draftDy}px`), ); } /** * Resolve and track the drag target. Reuses the tracked element across the * 60fps drag; only re-queries when the id changes or the cached node * detached (e.g. an iframe reload mid-drag). Switching to a different * element reverts the previous one's draft first, then captures the new * element's pre-drag translate. */ private _resolveDraftElement(id: string): HTMLElement | null { const doc = this.iframe.contentDocument; if (!doc) return null; const cached = id === this._draftId && this._draftEl?.isConnected ? this._draftEl : null; const el = cached ?? doc.querySelector( `[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`, ); if (!el) return null; if (el !== this._draftEl) { // Abandoning a prior target mid-drag must not leave it displaced. this._revertDraftTranslate(); this._draftDx = 0; this._draftDy = 0; this._draftPrevTranslate = readCurrentTranslate(el); const inline = el.style.getPropertyValue("translate").trim(); this._draftPrevInlineTranslate = inline === "none" ? "" : inline; } this._draftId = id; this._draftEl = el; return el; } /** * Read the accumulated draft deltas, derive a moveElement op, dispatch it, * then clear the draft state. * * No-ops (reverting any draft translate) when: * - No applyDraft was called (nothing to commit) * - No dispatch callback was provided at construction * * If dispatch throws (e.g. the model no longer has the element), the draft * translate is reverted and the error propagates — the element is never * left displaced by an uncommitted draft. */ commitPreview(): void { if (!this._draftId || !this._draftEl || !this._dispatch) { this._revertDraftTranslate(); this._clearDraft(); return; } const el = this._draftEl; const dataX = el.getAttribute("data-x"); const dataY = el.getAttribute("data-y"); const { x, y } = computeDraftPosition(dataX, dataY, this._draftDx, this._draftDy); try { this._dispatch({ type: "moveElement", target: this._draftId, x, y }); } catch (err) { this._revertDraftTranslate(); this._clearDraft(); throw err; } this._mirrorCommittedMove(el, dataX, dataY, x, y); this._clearDraft(); } /** * Mirror a committed move onto the live element so the position holds * without a document reload — same attributes handleMoveElement writes * into the model, rendered by the runtime's position-edit translate. * * The pre-edit translate is stamped from the value captured at drag start * (the element's current inline translate is the draft-composed one, which * must not be mistaken for the original), then the final translate is * recomputed the same way the runtime does at bind time. */ private _mirrorCommittedMove( el: HTMLElement, dataX: string | null, dataY: string | null, x: number, y: number, ): void { if (el.getAttribute(EDIT_BASE_X_ATTR) === null) { el.setAttribute(EDIT_BASE_X_ATTR, dataX ?? "0"); } if (el.getAttribute(EDIT_BASE_Y_ATTR) === null) { el.setAttribute(EDIT_BASE_Y_ATTR, dataY ?? "0"); } if (el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) === null) { el.setAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR, this._draftPrevTranslate ?? ""); } el.setAttribute("data-x", String(x)); el.setAttribute("data-y", String(y)); applyPositionEditToElement(el, { force: true }); } /** Revert the draft translate without dispatching any op. */ cancelPreview(): void { this._revertDraftTranslate(); this._clearDraft(); } /** * Restore the element's pre-drag INLINE `translate` (removing it when there * was none, so a stylesheet-authored translate is never promoted to inline). * NOT called on a successful commit — the committed position-edit translate * is recomputed onto the element by _mirrorCommittedMove. */ private _revertDraftTranslate(): void { if (!this._draftEl || this._draftPrevInlineTranslate === null) return; if (this._draftPrevInlineTranslate === "") { this._draftEl.style.removeProperty("translate"); } else { this._draftEl.style.setProperty("translate", this._draftPrevInlineTranslate); } } private _clearDraft(): void { this._draftId = null; this._draftEl = null; this._draftDx = 0; this._draftDy = 0; this._draftPrevTranslate = null; this._draftPrevInlineTranslate = null; } // Selection ----------------------------------------------------------------- select(ids: string[], opts?: { additive?: boolean }): void { if (opts?.additive) { const merged = new Set([...this._selection, ...ids]); this._selection = [...merged]; } else { this._selection = [...ids]; } this._emit(); } on(event: "selection", handler: SelectionHandler): () => void { if (event !== "selection") return () => {}; this._handlers.push(handler); return () => { this._handlers = this._handlers.filter((h) => h !== handler); }; } private _emit(): void { const ids = [...this._selection]; for (const h of this._handlers) h(ids); } /** * Mirror `comp`'s edits onto this.iframe.contentDocument. See the * PreviewAdapter interface doc for the full contract. */ attachSync(comp: Composition): () => void { this._syncDetach?.(); const syncOverrides = (): void => { const doc = this.iframe.contentDocument; if (!doc) return; try { applyOverrideSet({ document: doc, wrapped: false, stamped: "" }, comp.getOverrides()); } catch (err) { // Don't let a bad snapshot prevent the ongoing subscription below // from attaching — future patches should still mirror even if this // composition's current overrides couldn't be applied. console.warn("[hyperframes] attachSync: override sync failed:", err); } }; // Immediate snapshot for the current document… syncOverrides(); // …and again on every iframe `load`: assigning srcdoc/src is an ASYNC // navigation, so an attach in the same tick snapshots the OUTGOING // document, and patches committed during the load window mirror into it // and die with it. Re-syncing on load converges the new document with // the composition state regardless of attach/navigation ordering. this.iframe.addEventListener("load", syncOverrides); const rawUnsubscribe = comp.on("patch", ({ patches }) => { const liveDoc = this.iframe.contentDocument; if (!liveDoc) return; applyPatchesToDocument( { document: liveDoc, wrapped: false, stamped: "" }, // "Never mirror script-tag rewrites" is the documented contract, not // just today's one known path — startsWith so a future script kind // (e.g. "/script/label") is covered by the same intent, not just an // exact string this filter happens to know about today. patches.filter((p) => !p.path.startsWith("/script/")), ); }); const detach = (): void => { this.iframe.removeEventListener("load", syncOverrides); rawUnsubscribe(); if (this._syncDetach === detach) this._syncDetach = null; }; this._syncDetach = detach; return detach; } } export function createIframePreviewAdapter( iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void, ): PreviewAdapter { return new IframePreviewAdapter(iframe, dispatch); }