mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
* fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate A committed moveElement wrote data-x/data-y but nothing rendered them: hosts shimmed CSS translate, which GSAP folds into the cached transform at first parse and then discards on the animated axis at every seek — dragging an animated element kept only the un-animated axis. Spike-proven on GSAP 3.15: a translate set AFTER GSAP's first parse is never read, folded, or cleared across seeks and composes natively with the animated transform. So: - moveElement captures the pre-edit baseline once (data-hf-edit-base-x/y) - the runtime (new core runtime/positionEdits.ts, applied at timeline bind — after GSAP parse) renders translate = (data-x − base), a pure delta that composes with GSAP tweens, tl.set positions, and CSS alike - applyDraft now drives the drag preview through the same translate channel (the --hf-studio-dx/dy vars had no consumer outside authored Studio bridges), and commitPreview mirrors the committed move onto the live element so it holds without an srcdoc reload Acceptance: packages/engine/scripts/test-runtime-position-edits-browser.ts (real Chrome + GSAP + runtime IIFE, no Studio shell) — X-animated, Y-animated, and static elements hold both edited axes across the full seek range. New subpath export @hyperframes/core/runtime/position-edits. Known limitation (documented): a tween created lazily at runtime that first-parses a marked element after apply folds the edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): harden position-edit rendering and the drag draft channel Fixes six issues from adversarial review of the moveElement stack: - Runtime: apply position edits at init as well as at timeline bind, so committed moves render in compositions with no usable GSAP timeline (CSS/WAAPI-animated or fully static) — previously the apply was unreachable outside the boundDuration > 0 bind branch and the edit silently vanished from reloads and renders. - Runtime: guard bind-path re-apply against post-fold double-apply — if the previously written translate was consumed externally (a lazily created tween folding it into GSAP's cached transform), skip instead of re-setting it on top ({force} escape hatch for editor commits). - Adapter: stop writing the --hf-studio-dx/dy custom properties during drags — compositions with the documented var-consuming drag-bridge CSS moved by twice the pointer delta (var transform + new inline translate). The inline translate is now the only draft channel; deltas accumulate in adapter fields. Docs updated to match. - Adapter: switching applyDraft to a new id reverts the abandoned element's draft translate instead of leaving it displaced with no op. - Adapter: cancelPreview restores the raw inline translate (removing it when there was none), so a stylesheet-authored translate is never promoted to a permanent inline style. - Adapter: commitPreview reverts the draft and clears state when dispatch throws, instead of leaving the element shifted by an uncommitted draft. Cleanups: reuse readCurrentTranslate from the core module (was a verbatim copy), drop the dead __hfApplyPositionEdits window hook. Browser acceptance test now also covers the GSAP-free composition path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): prime GSAP transform cache before position-edit apply; add fold-loss telemetry Addresses PR #1875 review feedback (Rames, Miga): - Prime the element's GSAP transform parse (gsap.getProperty) before the first translate apply — positioned tl.set()s and tweens that first RENDER after the apply now reuse the cache instead of folding the edit. This closes the lazy-first-parse fold-loss for any page where GSAP is loaded at apply time; the residual limitation is GSAP itself loading after the apply. Proven by the extended browser acceptance test. - Emit position_edit_fold_skipped analytics at the fold-guard skip site so the residual degradation is observable instead of silent. - Browser acceptance test: add a both-axis-animated element (the shape that originated the per-axis loss) and a positioned tl.set() element, asserted across the full seek range. - Simplify the num() null guard (review nit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
187 lines
8.4 KiB
Plaintext
187 lines
8.4 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.
|
|
|
|
## 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>
|
|
|