Files
hyperframes/docs/sdk/guides/canvas-integration.mdx
Hblee 9769ba2c7b feat(sdk): expose a paint query for transparent-composition hit-testing (#3070)
* feat(sdk): expose a paint query for transparent-composition hit-testing

A host layering a transparent composition over other content has to know
whether a point carries ink before it decides to swallow a click. The adapter
only answered "what element is here", so AI Studio wrote its own answer and
could not reach the per-pixel alpha the adapter already samples for <img>.

Add PreviewAdapter.paintsAt, plus the pieces it is built from on a new
./adapters/iframe subpath so a host with different hit-test policy can compose
its own walk.

The walk is geometric rather than elementsFromPoint-based: that stack omits
pointer-events:none nodes, and a decorative overlay carrying it still paints,
so a z-stack query would report no ink over visible artwork — the direction
that makes a composition vanish from under the cursor.

fullBleedFraction is an option rather than a constant because "a layer covering
the whole frame is background, not artwork" is host policy, not a fact about
the composition.

* fix(sdk): scope the full-bleed frame to the root under the point

compositionFrameArea took the smallest [data-composition-id] in the whole
document, so an unrelated sub-composition sized the reference frame for points
nowhere near it: a 300x300 badge in a corner made every mid-size painter in a
1920x1080 outer frame read as full-bleed, and the composition went
click-through under artwork the user can plainly see. That is the direction the
fail-safe exists to avoid, and the docs already described the intended
behaviour — the innermost root CONTAINING the point.

Also read the alpha channel instead of matching known transparent spellings.
Only the `transparent` keyword computes to rgba(0, 0, 0, 0); a faded-out white
stays rgba(255, 255, 255, 0), which the set counted as painted. That erred
toward absorbing clicks rather than losing them, so it was a false positive
rather than a hazard, but it is wrong.

Both are pinned by tests that fail when the fix is reverted.

* fix(sdk): stop the paint query answering "no ink" over visible artwork

Four cases where the walk landed on the wrong side of its own fail-safe.

The full-bleed veto tested the winner's border-box area even when the alpha
sampler had just read an opaque pixel there, so a full-frame transparent PNG or
SVG overlay — the case this feature exists for — reported background over
visibly opaque artwork, with no fullBleedFraction that worked. Ink now carries
how it was established, and a measured pixel is never vetoed. An image whose
pixels could NOT be read stays inferred, so a tainted CDN overlay still yields
to the veto rather than absorbing every click.

Composition roots were excluded from candidacy outright, so a root carrying a
background answered false even at fraction 0, where the docs promise every
painting box counts. Roots are candidates now; the veto discounts them without
a special case, since a root's box is the frame.

An <img> with a clear pixel early-returned past its own background, padding
plate and border, which any other element would have counted.

A same-origin iframe mid-navigation exposes a readable but empty document, so
the !doc guard never fired and a loading composition answered a confident
"no ink" — the exact failure the null convention exists to prevent.

Also: the sort comparator's epsilon tie was intransitive, leaving the
smallest-first guarantee (and the lazy single-sample property that rides on it)
engine-dependent; the guide's pass-through recipe called a function that does
not exist and hand-waved the coordinate mapping that makes it correct; and the
reference now states the under-counts alongside the over-counts, the walk's
blindness to runtime-mounted content, and compositionPaintsAt's preconditions.

Each fix is pinned by a test that fails when the fix is reverted.

* refactor(sdk)!: invert the paint query to isProvablyEmptyAt

paintsAt handed callers three falsy bottom values with opposite safe readings:
false meant "no ink, pass the click through", null meant "not knowable, treat as
painted", and undefined from an adapter without the method also meant painted.
The idiomatic `if (!preview.paintsAt?.(x, y)) passThrough()` therefore did the
dangerous thing for two of the three, and the convention needed defending in the
interface docstring, the reference and the guide — plus a dedicated comment and
a pinning test on the headless adapter to stop null regressing to false.

Inverting the polarity collapses the tri-state to a plain boolean and makes the
safe reading structural: true only when the composition was readable and nothing
painted there, so ink, an unreadable or still-loading document, and a missing
implementation all land on "keep the composition clickable". The prose stays as
rationale, but nothing depends on a reader remembering it.

PaintsAtOptions becomes PaintQueryOptions, since it now describes the walk that
both the adapter method and the exported compositionPaintsAt share rather than
one method's arguments. compositionPaintsAt keeps its ink-positive name: it
answers the other question, and its docstring points callers who need the
fail-safe contract at the adapter.

Nothing is released yet, so no consumer is on the old name.
2026-08-06 16:27:38 -07:00

263 lines
14 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Canvas & Preview Integration"
description: "Connect a same-origin composition iframe to the SDK for hit-testing, draft preview, and selection."
---
The SDK's `PreviewAdapter` interface decouples the editing model from the visual surface. For browser-based editors, `createIframePreviewAdapter` bridges the SDK to a same-origin `<iframe>` containing the composition, giving you synchronous hit-testing, 60fps drag preview, and selection management — all without touching the model until the user commits.
<Note>
The iframe must be same-origin (e.g. `srcdoc` or a `blob:` URL). Cross-origin iframe access throws a `DOMException`; the adapter does not guard this, so enforcing same-origin is the caller's responsibility.
</Note>
## Embedding the composition
Render the composition HTML into a same-origin `<iframe>` in your editor shell, then pass that element plus a dispatch callback to `createIframePreviewAdapter`:
```typescript
import { openComposition, createIframePreviewAdapter } from "@hyperframes/sdk";
// Assume `compositionHtml` is the composition's source HTML string.
const iframe = document.querySelector<HTMLIFrameElement>("#composition-frame")!;
// Build the adapter first so you can pass it to openComposition.
// The dispatch callback is called by commitPreview() after a drag completes.
const preview = createIframePreviewAdapter(iframe, (op) => {
comp.dispatch(op);
});
const comp = await openComposition(compositionHtml, { preview });
```
The callback references `comp` before it is declared — that is intentional and safe: the arrow function captures `comp` by closure and is only ever invoked later (by `commitPreview()` on pointer-up), by which point `comp` is assigned. This is the standard way to break the adapter ⇄ session circular dependency.
The `dispatch` callback is optional. Omitting it means `commitPreview()` is a no-op, which is useful if you want to handle op derivation yourself.
## Keeping the preview in sync
Everything above wires up hit-testing and drag — but the iframe still won't reflect edits made any other way (an inspector panel calling `comp.setStyle()` directly, an undo, a collaborator's change replayed via `applyPatches()`). `attachSync` closes that gap: call it once you have both `preview` and `comp`, and every future edit — including undo/redo — mirrors onto the live iframe automatically.
```typescript
const detach = preview.attachSync(comp);
// later, when the editor unmounts or swaps compositions:
detach();
```
`attachSync` does an immediate full sync of `comp`'s current state first (so re-opening a composition with existing overrides isn't a blank iframe), then subscribes to the same `patch` event your other listeners use. You don't need to write your own mirroring code, and you don't need a separate mechanism for undo/redo — both flow through the same subscription. Script-tag edits (GSAP script rewrites) are the one thing it never mirrors, since replaying a live `<script>` tag doesn't re-execute it.
<Note>
Calling `attachSync` again with a different `comp` detaches the previous subscription first — useful if your editor swaps which composition an iframe is bound to without remounting it.
</Note>
## Hit-testing: finding what the user clicked
`preview.elementAtPoint(x, y)` performs a synchronous hit-test at coordinates in the iframe's own coordinate space and returns the nearest `[data-hf-id]` element, or `null` for a transparent hit.
```typescript
iframe.addEventListener("load", () => {
const frameDocument = iframe.contentDocument;
if (!frameDocument) return;
// Events inside an iframe do not bubble to the outer <iframe> element.
frameDocument.addEventListener("click", (event) => {
const hit = preview.elementAtPoint(event.clientX, event.clientY);
if (hit) {
// hit.id — the data-hf-id value
// hit.tag — the lowercased tag name (e.g. "div", "img", "video")
preview.select([hit.id]);
}
});
});
```
The hit-test skips elements whose computed opacity is `0` (including ancestors with `opacity: 0`), and for `<img>` elements it samples the alpha at the clicked pixel using an offscreen canvas — a transparent pixel falls through to the element behind it. Cross-origin images that taint the canvas fall back to treating the pixel as opaque.
The `opts.atTime` parameter is accepted but does not seek the GSAP timeline. It reflects whatever frame the composition is currently paused at in the iframe. Accurate out-of-time-band opacity queries are a future capability.
### Walking a click target to the nearest HF element
If you are working with events on the iframe's `contentDocument` directly (e.g. via a `message` bridge), use the exported `resolveNearestHfElement` function. It walks up the DOM from any node until it finds a `[data-hf-id]` ancestor, skipping the root:
```typescript
import { resolveNearestHfElement } from "@hyperframes/sdk";
// Inside the iframe's own document context:
iframeDoc.addEventListener("click", (e) => {
const result = resolveNearestHfElement(
e.target as Element | null,
(el) => {
// Return false to treat this element as invisible and continue the walk.
const style = el.ownerDocument.defaultView?.getComputedStyle(el);
return style ? parseFloat(style.opacity) !== 0 : true;
},
);
if (result) {
// result.id, result.tag
}
});
```
`resolveNearestHfElement` returns `null` when the walk exits the tree without finding a `[data-hf-id]` node, when the matching node carries `[data-hf-root]` (the root is transparent to selection), or when `isVisible` returns `false` for that node.
## Transparent compositions over other content
A composition authored as an overlay — a small graphic on an otherwise-empty 1080×1920 frame, layered over a video or an avatar — is still a rectangular DOM box covering every pixel of the frame. Without help it swallows every click, and whatever sits beneath it becomes unreachable.
`preview.isProvablyEmptyAt(x, y)` is the question you need answered: is this point provably free of ink, so a click may safely reach what sits beneath? Toggle `pointer-events` on your wrapper from the answer, and let the browser deliver the event to the right target:
```typescript
const wrapper = document.querySelector<HTMLElement>("#composition-wrapper")!;
/**
* Host-page pointer coordinates → the iframe document's own client space, which is what
* the paint query samples against. The iframe renders at the composition's native size and is
* CSS-scaled to fit, so the on-screen scale has to be divided out — skip this and you
* sample the wrong pixel, and pass-through toggles over the wrong regions.
*/
function toCompositionPoint(clientX: number, clientY: number) {
const rect = iframe.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const scaleX = rect.width / compositionNativeWidth;
const scaleY = rect.height / compositionNativeHeight;
if (!scaleX || !scaleY) return null;
return { x: (clientX - rect.left) / scaleX, y: (clientY - rect.top) / scaleY };
}
function updatePassThrough(clientX: number, clientY: number, altKey: boolean) {
const point = toCompositionPoint(clientX, clientY);
// Alt is the escape hatch for grabbing the composition itself in an empty region.
// Every uncertain case — no point, still loading, adapter without the method — is
// falsy here, so the composition stays clickable rather than vanishing.
const passThrough =
!!point && !altKey && !!preview.isProvablyEmptyAt?.(point.x, point.y);
wrapper.style.pointerEvents = passThrough ? "none" : "";
}
```
<Warning>
Listen on the **host document**, not on the wrapper or the iframe. The first time this
sets `pointer-events: none` the wrapper stops receiving events, so a listener attached
there can never turn it back on — the pass-through state sticks.
</Warning>
Three things are easy to get wrong here:
<Steps>
<Step title="Decide before the press, not during it">
The browser picks an event's target before any handler runs, so flipping `pointer-events` inside `mousedown` cannot retarget the click already in flight. Sample the pointer position on `mousemove` and keep the decision current.
</Step>
<Step title="Re-evaluate on every frame, not only on movement">
Animated artwork moves under a stationary cursor. Anything that can change the answer — pointer movement, the Alt key, and the playhead — has to re-run the query from the last known position. Coalesce those triggers into one `requestAnimationFrame` query rather than answering each separately, and short-circuit before the query when the pointer is outside the composition's box: it is a walk over the document, so it does not belong on an ungated per-event path.
</Step>
<Step title="Let the polarity do the work">
`isProvablyEmptyAt` is true only when it has established there is no ink. A document that hasn't loaded, an adapter without the method, and a point you couldn't map all come back falsy — which keeps the composition clickable. Don't invert it into a "does it paint" variable; that reintroduces the bug the polarity removes.
</Step>
</Steps>
Pass `{ fullBleedFraction: 0.9 }` if your editor treats a layer covering nearly the whole frame as background rather than artwork — a common choice, since a full-bleed wrapper is usually scaffolding rather than something the user is pointing at.
Do not reimplement this with `elementsFromPoint`. That stack omits `pointer-events: none` nodes, and a decorative overlay carrying `pointer-events: none` still paints — a z-stack query would report no ink over visible artwork and pass the click through anyway. The paint walk covers element boxes geometrically for that reason.
## Draft loop: 60fps drag without model mutations
The draft loop keeps the model clean during a drag. The SDK is **not** in the 60fps path — you call `preview.applyDraft` on every `pointermove` and `preview.commitPreview` once on `pointerup`. The model sees exactly one `moveElement` op per drag, rather than hundreds.
`applyDraft` sets the element's CSS `translate` directly inside the iframe — the pre-drag value composed with the accumulated delta — so the drag is visible in any composition without composition-side CSS, and works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform instead of being overwritten). Nothing in the SDK model changes. `cancelPreview` restores the pre-drag translate; `commitPreview` derives one `moveElement` op and mirrors the committed position onto the live element.
```typescript
let dragging = false;
let startX = 0;
let startY = 0;
let targetId: string | null = null;
iframe.addEventListener("load", () => {
const frameDocument = iframe.contentDocument;
if (!frameDocument) return;
frameDocument.addEventListener("pointerdown", (event) => {
const hit = preview.elementAtPoint(event.clientX, event.clientY);
if (!hit) return;
dragging = true;
targetId = hit.id;
startX = event.clientX;
startY = event.clientY;
// The target comes from the iframe's realm, so `instanceof Element` against
// this window's constructor is always false and capture would be skipped —
// then a pointer leaving the frame loses `pointerup` and drag state sticks.
if (event.target && "setPointerCapture" in event.target) {
(event.target as Element).setPointerCapture(event.pointerId);
}
});
frameDocument.addEventListener("pointermove", (event) => {
if (!dragging || !targetId) return;
preview.applyDraft(targetId, {
dx: event.clientX - startX,
dy: event.clientY - startY,
});
});
frameDocument.addEventListener("pointerup", () => {
if (!dragging || !targetId) return;
preview.commitPreview();
dragging = false;
targetId = null;
});
frameDocument.addEventListener("pointercancel", () => {
preview.cancelPreview();
dragging = false;
targetId = null;
});
});
```
`DraftProps` accepts `dx`, `dy`, `width`, and `height`. Width and height are accepted by the interface but resize support (mapping to a `setStyle` op) is not yet wired — only `dx`/`dy` drive the draft CSS vars today.
Call `cancelPreview()` instead of `commitPreview()` to discard the drag without emitting any op. The model is never mutated and the CSS vars are cleared.
## Selection
`preview.select(ids, opts?)` sets the selection state and fires the session's `selectionchange` event on any listeners. Pass `{ additive: true }` to extend the current selection rather than replace it.
```typescript
// Replace selection
preview.select(["hf-title"]);
// Extend selection (e.g. shift-click)
preview.select(["hf-logo"], { additive: true });
// Clear selection
preview.select([]);
```
Listen to selection changes on the session via `comp.on("selectionchange", ...)` — the adapter fires that event, not a separate event on the iframe.
## Pairing with embedded override mode
For template-driven products you typically open the composition in embedded override mode and store only the sparse delta, not the full HTML. The preview adapter works identically in that mode — pass it the same way:
```typescript
const comp = await openComposition(templateHtml, {
preview,
overrides: existingOverrides,
history: false,
});
```
See [Embedded Override Mode](/sdk/guides/embedded-override-mode) for the full pattern.
## What to build next
Once hit-testing and drag are working, you can use the affordance resolver to drive a context-aware inspector panel for whatever element is selected. See [Editing Affordances](/sdk/guides/editing-affordances) for how to translate a live element into capability flags and section applicability.
<CardGroup cols={2}>
<Card title="Adapter reference" icon="plug" href="/sdk/reference/adapters">
Full `PreviewAdapter`, `PersistAdapter`, and related type documentation.
</Card>
<Card title="Editing Affordances" icon="sliders" href="/sdk/guides/editing-affordances">
Resolve which edit controls to show for the selected element.
</Card>
</CardGroup>