Files
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

204 lines
9.6 KiB
Plaintext

---
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("click", (e) => {
// e.clientX / e.clientY are in the outer frame's space.
// If the iframe is positioned, convert to iframe-local coords.
const rect = iframe.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const hit = preview.elementAtPoint(x, y);
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.
## 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("pointerdown", (e) => {
const rect = iframe.getBoundingClientRect();
const hit = preview.elementAtPoint(e.clientX - rect.left, e.clientY - rect.top);
if (!hit) return;
dragging = true;
targetId = hit.id;
startX = e.clientX;
startY = e.clientY;
iframe.setPointerCapture(e.pointerId);
});
iframe.addEventListener("pointermove", (e) => {
if (!dragging || !targetId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// applyDraft at 60fps — no model mutation, no patch event
preview.applyDraft(targetId, { dx, dy });
});
iframe.addEventListener("pointerup", () => {
if (!dragging || !targetId) return;
// Derives a moveElement op from the accumulated dx/dy, dispatches it
// through the callback you passed to createIframePreviewAdapter, then
// clears the CSS vars and internal draft state.
preview.commitPreview();
dragging = false;
targetId = null;
});
iframe.addEventListener("pointercancel", () => {
// Clears the CSS vars. Model is never touched.
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>