chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
---
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
title: "Editing Affordances"
|
||||
description: "Resolve capability flags and inspector sections for a selected element so your editor panel is element-aware."
|
||||
---
|
||||
|
||||
`resolveElementAffordances` answers the question "what can the user do with this element right now?" given a live DOM element and its SDK model entry. It returns two objects: `capabilities` (boolean flags for each edit action) and `sections` (which inspector panels apply). You use these to conditionally render controls in your editor's detail panel rather than showing a fixed field set for all elements.
|
||||
|
||||
<Note>
|
||||
This API lives on the `@hyperframes/sdk/editing` subpath, available since `@hyperframes/sdk@0.7.22`. If the import fails to resolve, upgrade: `npm install @hyperframes/sdk@latest`.
|
||||
</Note>
|
||||
|
||||
## Import
|
||||
|
||||
```typescript
|
||||
import { resolveElementAffordances } from "@hyperframes/sdk/editing";
|
||||
```
|
||||
|
||||
## Signature
|
||||
|
||||
```typescript
|
||||
function resolveElementAffordances(
|
||||
liveEl: HTMLElement,
|
||||
modelEl: Pick<HyperFramesElement, "text" | "animationIds" | "start"> | null,
|
||||
ctx?: AffordanceContext,
|
||||
): EditingAffordances
|
||||
```
|
||||
|
||||
- `liveEl` — a live, laid-out `HTMLElement` from the composition iframe. The resolver calls `getComputedStyle` on it, so it must be attached to a rendered document.
|
||||
- `modelEl` — the SDK model element (`comp.getElement(id)`), or `null` when the element exists in the live DOM but not in the SDK source model (generated elements). Passing `null` restricts affordances: style editing is disabled and `reasonIfDisabled` is set.
|
||||
- `ctx` — optional context for studio-specific concepts. For most custom editors, omit it or leave all flags at their defaults (`false`).
|
||||
|
||||
### `AffordanceContext`
|
||||
|
||||
```typescript
|
||||
interface AffordanceContext {
|
||||
isCompositionHost?: boolean; // default false
|
||||
isCompositionRoot?: boolean; // default false
|
||||
isInsideLockedComposition?: boolean; // default false
|
||||
isMasterView?: boolean; // default false
|
||||
}
|
||||
```
|
||||
|
||||
These flags are studio-specific. In a standalone custom editor you can omit `ctx` entirely.
|
||||
|
||||
## Return value
|
||||
|
||||
```typescript
|
||||
interface EditingAffordances {
|
||||
capabilities: DomEditCapabilities;
|
||||
sections: EditingSectionApplicability;
|
||||
}
|
||||
```
|
||||
|
||||
### `capabilities`
|
||||
|
||||
```typescript
|
||||
interface DomEditCapabilities {
|
||||
canSelect: boolean;
|
||||
canEditStyles: boolean;
|
||||
/** Directly editable authored left/top style fields. */
|
||||
canMove: boolean;
|
||||
/** Directly editable authored width/height style fields. */
|
||||
canResize: boolean;
|
||||
/** Canvas translate-drag maps here, not to canMove. */
|
||||
canApplyManualOffset: boolean;
|
||||
canApplyManualSize: boolean;
|
||||
canApplyManualRotation: boolean;
|
||||
/** Set when canEditStyles / canMove / canResize is false; explains why. */
|
||||
reasonIfDisabled?: string;
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`canMove` and `canResize` indicate that the element has authored `left`/`top`/`width`/`height` style values that can be directly edited as fields. A canvas drag operation maps to `canApplyManualOffset`, not `canMove`. Do not gate drag handles on `canMove`.
|
||||
</Warning>
|
||||
|
||||
### `sections`
|
||||
|
||||
```typescript
|
||||
interface EditingSectionApplicability {
|
||||
text: boolean; // true when the element has editable text content
|
||||
media: boolean; // true for <video> and <audio>
|
||||
colorGrading: boolean; // true for <video> and <img> — element-level only
|
||||
timing: boolean; // true when data-start is present or animationCount > 0
|
||||
animation: boolean; // true when animationCount > 0
|
||||
}
|
||||
```
|
||||
|
||||
`sections.colorGrading` is element-level only: it tells you this particular element supports color grading controls. Studio shows the color grading panel for supported media elements.
|
||||
|
||||
## End-to-end example
|
||||
|
||||
```typescript
|
||||
import { openComposition, createIframePreviewAdapter } from "@hyperframes/sdk";
|
||||
import { resolveElementAffordances } from "@hyperframes/sdk/editing";
|
||||
|
||||
const iframe = document.querySelector<HTMLIFrameElement>("#composition-frame")!;
|
||||
const preview = createIframePreviewAdapter(iframe, (op) => comp.dispatch(op));
|
||||
const comp = await openComposition(compositionHtml, { preview });
|
||||
|
||||
function onElementSelected(hfId: string) {
|
||||
// 1. Get the live DOM element from the iframe.
|
||||
const liveEl = iframe.contentDocument?.querySelector<HTMLElement>(
|
||||
`[data-hf-id="${hfId}"]`,
|
||||
);
|
||||
if (!liveEl) return;
|
||||
|
||||
// 2. Get the SDK model element — null for script-generated elements.
|
||||
const modelEl = comp.getElement(hfId);
|
||||
|
||||
// 3. Resolve affordances.
|
||||
const { capabilities, sections } = resolveElementAffordances(liveEl, modelEl);
|
||||
|
||||
// 4. Render controls conditionally.
|
||||
renderInspector({ capabilities, sections, hfId });
|
||||
}
|
||||
|
||||
function renderInspector({
|
||||
capabilities,
|
||||
sections,
|
||||
hfId,
|
||||
}: {
|
||||
capabilities: ReturnType<typeof resolveElementAffordances>["capabilities"];
|
||||
sections: ReturnType<typeof resolveElementAffordances>["sections"];
|
||||
hfId: string;
|
||||
}) {
|
||||
// Show a disabled-state tooltip when editing is locked.
|
||||
if (!capabilities.canEditStyles && capabilities.reasonIfDisabled) {
|
||||
showDisabledBanner(capabilities.reasonIfDisabled);
|
||||
return;
|
||||
}
|
||||
|
||||
// Style panel — always show when editing is allowed.
|
||||
if (capabilities.canEditStyles) {
|
||||
showStylePanel(hfId);
|
||||
}
|
||||
|
||||
// Position/size fields — only for absolutely positioned elements with px values.
|
||||
if (capabilities.canMove) {
|
||||
showPositionFields(hfId);
|
||||
}
|
||||
if (capabilities.canResize) {
|
||||
showSizeFields(hfId);
|
||||
}
|
||||
|
||||
// Canvas drag handles — use canApplyManualOffset, NOT canMove.
|
||||
if (capabilities.canApplyManualOffset) {
|
||||
showDragHandles(hfId);
|
||||
}
|
||||
|
||||
// Section panels.
|
||||
if (sections.text) showTextPanel(hfId);
|
||||
if (sections.media) showMediaPanel(hfId);
|
||||
if (sections.colorGrading && myFeatureFlags.colorGrading) showColorGradingPanel(hfId);
|
||||
if (sections.timing) showTimingPanel(hfId);
|
||||
if (sections.animation) showAnimationPanel(hfId);
|
||||
}
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
**Browser-only.** `resolveElementAffordances` calls `getComputedStyle` internally. It must not be imported or called in Node, a server action, or any non-browser path. The SDK adapter (`@hyperframes/sdk/editing`) is separate from the pure core resolver precisely to keep the Node SDK path free of browser globals.
|
||||
|
||||
**Needs a laid-out DOM.** `canMove` and `canResize` require a live computed `position`, `left`, and `top`. If you call `resolveElementAffordances` before the iframe finishes layout (e.g. synchronously after `srcdoc` assignment), both flags will be `false`. Wait for the iframe's `load` event or the composition's first rendered frame before resolving affordances.
|
||||
|
||||
**`null` model means select-only.** When `modelEl` is `null` (the element is generated at runtime and has no source entry), the resolver sets `existsInSource: false`. This gives `canSelect: true` but `canEditStyles: false`, `canMove: false`, and all manual-geometry flags `false`, with `reasonIfDisabled` set to a human-readable explanation. You can still show the selection highlight; just hide or disable all edit controls.
|
||||
|
||||
**`canApplyManualOffset` vs `canMove`.** `canMove` is true only when the element has absolute/fixed positioning AND authored `left`/`top` px values AND no transform-driven geometry. `canApplyManualOffset` is true for all non-root non-host elements regardless of positioning. A translate-based canvas drag (the `applyDraft` / `commitPreview` flow in [Canvas Integration](/sdk/guides/canvas-integration)) uses `canApplyManualOffset` as its gate — it does not require `canMove`.
|
||||
|
||||
**`colorGrading` is element-level, not feature-level.** `sections.colorGrading` tells you the element type supports grading (`<video>` or `<img>`). Your own feature flag is a separate AND condition. Never replace your feature flag with this field.
|
||||
|
||||
## Type reference
|
||||
|
||||
The full type definitions — `EditingAffordances`, `DomEditCapabilities`, `EditingSectionApplicability`, `AffordanceContext`, `HyperFramesElement` — are documented at [`/sdk/reference/types`](/sdk/reference/types).
|
||||
|
||||
The pure DOM-free resolver (`resolveEditingAffordances`, `EditableElementFacts`) lives in `@hyperframes/core` and is documented at [`/packages/core`](/packages/core).
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Canvas Integration" icon="frame" href="/sdk/guides/canvas-integration">
|
||||
Get the live element and selection via hit-testing and the iframe adapter.
|
||||
</Card>
|
||||
<Card title="Types reference" icon="brackets-curly" href="/sdk/reference/types">
|
||||
Complete type definitions for the SDK public surface.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
title: "Embedded Override Mode"
|
||||
description: "Layer a sparse delta on top of a reusable base composition so the host stores only what changed."
|
||||
---
|
||||
|
||||
Embedded override mode is the pattern for template-driven products: you maintain one base composition HTML file and store only the per-instance _delta_ (the `OverrideSet`) alongside it. When a user edits their instance, the SDK accumulates further changes into that same delta — the base stays untouched.
|
||||
|
||||
This is exactly how a canvas embedder (for example, AI Studio) persists in-composition edits as a stable, reopenable delta.
|
||||
|
||||
## Opening with overrides
|
||||
|
||||
Pass the stored delta as `overrides` when you call `openComposition`. The SDK replays the override set onto the base template in one pass, so the session exposes the user's exact edited state immediately:
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
// Stored delta — loaded from your database or storage layer.
|
||||
const storedOverrides = {
|
||||
"hf-title.text": "Acme Corp Launch",
|
||||
"hf-logo.attr.src": "/customers/acme/logo.png",
|
||||
"hf-subtitle.style.color": "#0EA5E9",
|
||||
};
|
||||
|
||||
const comp = await openComposition(templateHtml, {
|
||||
overrides: storedOverrides,
|
||||
history: false, // host owns undo — see below
|
||||
});
|
||||
```
|
||||
|
||||
After `openComposition` returns, `comp.getElements()` reflects the overridden state. Any further edits accumulate into the same delta automatically.
|
||||
|
||||
## Reading the current delta
|
||||
|
||||
Call `getOverrides()` to retrieve the current delta for storage. It returns a shallow copy of the internal override set — safe to serialize and stash:
|
||||
|
||||
```typescript
|
||||
comp.setText("hf-cta", "Get started free");
|
||||
comp.setStyle("hf-cta", { backgroundColor: "#22C55E" });
|
||||
|
||||
const nextOverrides = comp.getOverrides();
|
||||
// { "hf-title.text": "Acme Corp Launch", ..., "hf-cta.text": "Get started free", ... }
|
||||
|
||||
await db.saveOverrides(sessionId, nextOverrides);
|
||||
```
|
||||
|
||||
On the next session open, pass `nextOverrides` as `overrides` again — the user sees their exact state.
|
||||
|
||||
## Override set key format
|
||||
|
||||
Keys follow the pattern `hfId.prop.path`. The common forms are:
|
||||
|
||||
| Key | Meaning |
|
||||
|-----|---------|
|
||||
| `"hf-title.text"` | Inner text of element `hf-title` |
|
||||
| `"hf-logo.attr.src"` | `src` attribute on `hf-logo` |
|
||||
| `"hf-x.style.fontSize"` | Inline `fontSize` style on `hf-x` |
|
||||
| `"hf-card"` with value `null` | Removal marker — element was deleted |
|
||||
|
||||
A `null` value is a removal marker. When the SDK serializes the composition it omits that element entirely. This lets the host distinguish "never touched" (key absent) from "user deleted" (key present, value `null`).
|
||||
|
||||
Sub-composition elements use scoped IDs — `"hf-host/hf-leaf.text"` — where the host and leaf are separated by `/`.
|
||||
|
||||
### Font and image variable overrides
|
||||
|
||||
Variable overrides (from `setVariableValue`) live under the `var.{id}` key. Font and image values are objects, not strings:
|
||||
|
||||
```typescript
|
||||
import type { FontValue, ImageValue } from "@hyperframes/sdk";
|
||||
|
||||
// Font variable — name is the CSS font-family, source is the stylesheet URL.
|
||||
const fontOverride: FontValue = {
|
||||
name: "Inter",
|
||||
source: "https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap",
|
||||
};
|
||||
|
||||
// Image variable — url is the src; alt and fit are optional.
|
||||
const imageOverride: ImageValue = {
|
||||
url: "/customers/acme/hero.jpg",
|
||||
alt: "Acme hero image",
|
||||
fit: "cover",
|
||||
};
|
||||
|
||||
comp.setVariableValue("brand-font", fontOverride);
|
||||
comp.setVariableValue("hero-image", imageOverride);
|
||||
|
||||
const overrides = comp.getOverrides();
|
||||
// {
|
||||
// "var.brand-font": { name: "Inter", source: "https://…" },
|
||||
// "var.hero-image": { url: "/customers/acme/hero.jpg", alt: "…", fit: "cover" },
|
||||
// }
|
||||
```
|
||||
|
||||
When you read an `OverrideSet` value at a `var.*` key, narrow before treating it as a scalar — the type is `string | number | boolean | Record<string, unknown> | null`.
|
||||
|
||||
## Disabling SDK undo
|
||||
|
||||
In embedded mode the host typically owns the undo stack. The SDK already leaves history **off by default** in embedded mode (any session opened with `overrides`), so you usually don't need to do anything. Pass `history: false` explicitly only to make that intent obvious in standalone code paths, or as a guard if a call site may or may not supply `overrides`:
|
||||
|
||||
```typescript
|
||||
const comp = await openComposition(templateHtml, {
|
||||
overrides: storedOverrides,
|
||||
history: false,
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
`history: false` only disables the SDK's internal undo stack. Persistence (the `persist` adapter, if you supply one) is independent — omitting it does not affect autosave. In embedded mode, however, the host typically owns both undo and persistence, so you usually pass neither.
|
||||
</Note>
|
||||
|
||||
## Host-owned undo via `applyPatches()`
|
||||
|
||||
When the host pops an undo entry and needs to replay the inverse patches back into the SDK, use `applyPatches()`. The SDK applies the patches to the live document, updates the internal override set, and emits a `patch` event tagged with `ORIGIN_APPLY_PATCHES`.
|
||||
|
||||
```typescript
|
||||
import { ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";
|
||||
|
||||
// Host undo stack entry — inversePatches came from a prior 'patch' event.
|
||||
function hostUndo(inversePatches: JsonPatchOp[]) {
|
||||
comp.applyPatches(inversePatches);
|
||||
}
|
||||
|
||||
// Guard against undo loops — skip ORIGIN_APPLY_PATCHES events in the patch listener.
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return; // already from host undo — do not re-push
|
||||
hostHistory.push({ patches, inversePatches });
|
||||
});
|
||||
```
|
||||
|
||||
`applyPatches()` accepts an optional `opts.origin` override if you want to tag the event with a different sentinel, but the default `ORIGIN_APPLY_PATCHES` is the expected value for host undo/redo flows.
|
||||
|
||||
See [Undo, Redo, and Patches](/sdk/guides/undo-redo-and-patches) for the full patch event contract and inverse patch handling.
|
||||
|
||||
## Full example
|
||||
|
||||
```typescript
|
||||
import { openComposition, ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";
|
||||
import type { JsonPatchOp, OverrideSet } from "@hyperframes/sdk";
|
||||
|
||||
async function openUserSession(
|
||||
templateHtml: string,
|
||||
storedOverrides: OverrideSet,
|
||||
) {
|
||||
const hostHistory: Array<{ patches: readonly JsonPatchOp[]; inversePatches: readonly JsonPatchOp[] }> = [];
|
||||
let historyIndex = hostHistory.length;
|
||||
|
||||
const comp = await openComposition(templateHtml, {
|
||||
overrides: storedOverrides,
|
||||
history: false,
|
||||
});
|
||||
|
||||
// Record every user edit in the host undo stack.
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return;
|
||||
// Discard any redo entries ahead of the cursor.
|
||||
hostHistory.splice(historyIndex);
|
||||
hostHistory.push({ patches, inversePatches });
|
||||
historyIndex = hostHistory.length;
|
||||
});
|
||||
|
||||
return {
|
||||
comp,
|
||||
|
||||
undo() {
|
||||
if (historyIndex === 0) return;
|
||||
const { inversePatches } = hostHistory[--historyIndex];
|
||||
comp.applyPatches([...inversePatches]);
|
||||
},
|
||||
|
||||
redo() {
|
||||
if (historyIndex >= hostHistory.length) return;
|
||||
const { patches } = hostHistory[historyIndex++];
|
||||
comp.applyPatches([...patches]);
|
||||
},
|
||||
|
||||
async save() {
|
||||
// Only store the delta — not the full HTML.
|
||||
return comp.getOverrides();
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="openComposition reference" icon="code" href="/sdk/reference/open-composition">
|
||||
Full `OpenCompositionOptions` including `overrides`, `history`, and `persist`.
|
||||
</Card>
|
||||
<Card title="Types reference" icon="shapes" href="/sdk/reference/types">
|
||||
`OverrideSet`, `FontValue`, `ImageValue`, `PatchEvent`, and `ORIGIN_APPLY_PATCHES`.
|
||||
</Card>
|
||||
<Card title="Undo, Redo, and Patches" icon="clock-rotate-left" href="/sdk/guides/undo-redo-and-patches">
|
||||
Patch event format, inverse patch contract, and undo loop prevention.
|
||||
</Card>
|
||||
<Card title="Canvas integration" icon="browsers" href="/sdk/guides/canvas-integration">
|
||||
Wiring the SDK to a live preview iframe inside an editor canvas.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
title: "Persistence"
|
||||
description: "Autosave composition edits through pluggable adapters — filesystem, memory, or your own storage backend."
|
||||
---
|
||||
|
||||
When you pass a `persist` adapter to `openComposition`, the SDK subscribes to every `change` event and schedules an async write automatically. You never call a save method after each edit — the adapter handles it.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
||||
|
||||
const adapter = createFsAdapter({ root: "./project" });
|
||||
|
||||
const comp = await openComposition(html, {
|
||||
persist: adapter,
|
||||
persistPath: "composition.html", // default; can be omitted
|
||||
});
|
||||
|
||||
comp.setText("hf-title", "Draft title");
|
||||
// Autosaved on the next tick — no explicit save call needed.
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
<ParamField path="persist" type="PersistAdapter">
|
||||
The storage adapter. The SDK calls `adapter.write(persistPath, html)` after every change. Omit to skip persistence (headless / agent use).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="persistPath" type="string">
|
||||
The path key passed to the adapter on every write. Default: `"composition.html"`. Immutable for the session lifetime — changing the path mid-session is not supported.
|
||||
</ParamField>
|
||||
|
||||
## Flushing pending writes
|
||||
|
||||
The persist queue coalesces rapid edits, so the last write may be deferred when your application closes. Call `flush()` to drain any pending write before exit:
|
||||
|
||||
```typescript
|
||||
// Ensure the latest state is committed before the process exits.
|
||||
await comp.flush();
|
||||
comp.dispose();
|
||||
```
|
||||
|
||||
`flush()` resolves when the in-flight write completes. If there is nothing pending it resolves immediately. It is a no-op when no `persist` adapter was provided.
|
||||
|
||||
## Handling write failures
|
||||
|
||||
Write errors are emitted as events rather than thrown exceptions — a failed autosave must not crash an interactive session. Subscribe with `comp.on('persist:error', …)` to handle them:
|
||||
|
||||
```typescript
|
||||
comp.on("persist:error", ({ error }) => {
|
||||
console.error("Autosave failed:", error.message, error.cause);
|
||||
showToast("Could not save — retrying next change.");
|
||||
});
|
||||
```
|
||||
|
||||
The event carries `{ error: { message: string; hint?: string; cause?: unknown } }`. The SDK retries on the next `change` event, so transient errors recover automatically.
|
||||
|
||||
## Shipped adapters
|
||||
|
||||
### `createMemoryAdapter()` — tests and demos
|
||||
|
||||
An in-process store — no file I/O, no Node.js required. Use it in unit tests, demos, or browser sessions where you want version history without disk access.
|
||||
|
||||
```typescript
|
||||
import { createMemoryAdapter } from "@hyperframes/sdk";
|
||||
|
||||
const adapter = createMemoryAdapter();
|
||||
|
||||
const comp = await openComposition(html, {
|
||||
persist: adapter,
|
||||
});
|
||||
|
||||
comp.setText("hf-title", "v1");
|
||||
await comp.flush();
|
||||
comp.setText("hf-title", "v2");
|
||||
await comp.flush();
|
||||
|
||||
const versions = await adapter.listVersions("composition.html");
|
||||
// versions[0] is newest — key "v2", versions[1] is "v1"
|
||||
|
||||
const v1Html = await adapter.loadFrom("composition.html", versions[1].key);
|
||||
```
|
||||
|
||||
`createMemoryAdapter()` also exposes `injectFault(message)` — a test helper that makes the next write fire `persist:error` instead of committing. This lets you exercise your error-handling path without real I/O failures.
|
||||
|
||||
### `createFsAdapter(opts)` — Node.js local dev
|
||||
|
||||
Writes to the filesystem and keeps a rolling version history under `.hf-versions/`. Node.js only — do not use in browser builds.
|
||||
|
||||
<Note>
|
||||
`createFsAdapter` is imported from the `@hyperframes/sdk/adapters/fs` **subpath**, not the main barrel like `createMemoryAdapter`. The fs adapter pulls in Node's `fs` module, so keeping it on a separate subpath lets browser bundles tree-shake it away — importing it from the root would drag `node:fs` into a browser build.
|
||||
</Note>
|
||||
|
||||
```typescript
|
||||
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
||||
|
||||
const adapter = createFsAdapter({
|
||||
root: "./project", // required — directory for composition files
|
||||
maxVersions: 20, // optional — versions to retain per file (default: 20)
|
||||
});
|
||||
```
|
||||
|
||||
<ParamField path="root" type="string">
|
||||
Root directory. The adapter writes `{root}/{persistPath}` and keeps version files under `{root}/.hf-versions/{persistPath}/`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="maxVersions" type="number">
|
||||
Maximum version snapshots to retain per file. Oldest are pruned automatically. Default: `20`.
|
||||
</ParamField>
|
||||
|
||||
**Listing and restoring versions:**
|
||||
|
||||
```typescript
|
||||
const versions = await adapter.listVersions("composition.html");
|
||||
// [ { key: "1751234567890-0003", timestamp: 1751234567890 }, ... ]
|
||||
// Entries are newest-first.
|
||||
|
||||
const olderHtml = await adapter.loadFrom("composition.html", versions[2].key);
|
||||
if (olderHtml) {
|
||||
// Re-open the old snapshot.
|
||||
const restored = await openComposition(olderHtml, { persist: adapter });
|
||||
}
|
||||
```
|
||||
|
||||
Version keys are `{timestamp}-{counter}` strings. `listVersions` returns entries newest-first; `timestamp` is the Unix milliseconds at the time of the write.
|
||||
|
||||
## Implementing a custom adapter
|
||||
|
||||
Any object that satisfies the `PersistAdapter` interface works as a drop-in. The interface is:
|
||||
|
||||
```typescript
|
||||
export interface PersistAdapter {
|
||||
read(path: string): Promise<string | undefined>;
|
||||
write(path: string, content: string): Promise<void>;
|
||||
flush(): Promise<void>;
|
||||
listVersions(path: string): Promise<PersistVersionEntry[]>;
|
||||
loadFrom(path: string, versionKey: string): Promise<string | undefined>;
|
||||
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Contract:**
|
||||
|
||||
- `read()` returns `undefined` for a path that has never been written.
|
||||
- `write()` is idempotent — a second write to the same path overwrites.
|
||||
- `flush()` resolves when any queued writes are committed. Return `Promise.resolve()` if writes are synchronous.
|
||||
- `listVersions()` returns entries newest-first. Entries without stored `content` are valid — `loadFrom` is called lazily by the consumer.
|
||||
- `loadFrom()` returns `undefined` when the version key is not found.
|
||||
- `on('persist:error', handler)` registers an error listener. Errors **must not** propagate as thrown exceptions — surface them through the event instead. Returns an unsubscribe function.
|
||||
|
||||
**Minimal example — an HTTP / S3-style adapter:**
|
||||
|
||||
```typescript
|
||||
import type { PersistAdapter, PersistVersionEntry, PersistErrorEvent } from "@hyperframes/sdk";
|
||||
|
||||
export function createHttpAdapter(baseUrl: string): PersistAdapter {
|
||||
const errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
|
||||
|
||||
return {
|
||||
async read(path) {
|
||||
const res = await fetch(`${baseUrl}/${path}`);
|
||||
if (res.status === 404) return undefined;
|
||||
return res.text();
|
||||
},
|
||||
|
||||
async write(path, content) {
|
||||
try {
|
||||
await fetch(`${baseUrl}/${path}`, {
|
||||
method: "PUT",
|
||||
body: content,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
errorHandlers.forEach((h) => h({ error: { message, cause: err } }));
|
||||
}
|
||||
},
|
||||
|
||||
async flush() {
|
||||
// No queuing — writes are immediate.
|
||||
},
|
||||
|
||||
async listVersions(path): Promise<PersistVersionEntry[]> {
|
||||
const res = await fetch(`${baseUrl}/${path}/versions`);
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<PersistVersionEntry[]>;
|
||||
},
|
||||
|
||||
async loadFrom(path, versionKey) {
|
||||
const res = await fetch(`${baseUrl}/${path}/versions/${versionKey}`);
|
||||
if (!res.ok) return undefined;
|
||||
return res.text();
|
||||
},
|
||||
|
||||
on(event, handler) {
|
||||
if (event !== "persist:error") return () => {};
|
||||
errorHandlers.push(handler);
|
||||
return () => {
|
||||
const i = errorHandlers.indexOf(handler);
|
||||
if (i !== -1) errorHandlers.splice(i, 1);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
`write()` must never throw — catch errors and emit them through `on('persist:error', …)` instead. The SDK trusts that writes are fire-and-forget from its side; thrown exceptions break the internal persist queue.
|
||||
</Note>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Adapter reference" icon="plug" href="/sdk/reference/adapters">
|
||||
Full `PersistAdapter`, `PersistVersionEntry`, and `PreviewAdapter` type definitions.
|
||||
</Card>
|
||||
<Card title="openComposition reference" icon="code" href="/sdk/reference/open-composition">
|
||||
All `OpenCompositionOptions` fields including `persist`, `persistPath`, `preview`, and `overrides`.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
title: "Querying & Editing Elements"
|
||||
description: "Find elements by property, make typed mutations, group edits with batch, and work with element handles and selection."
|
||||
---
|
||||
|
||||
The SDK exposes two layers for working with composition elements: a **query API** for reading the current document state, and a **mutation API** of typed methods (plus `dispatch()`) for making changes. Every query and every edit operates on stable `hf-id` strings — there is no cursor, no "current selection required" invariant, and no DOM reference that can go stale.
|
||||
|
||||
## Query API
|
||||
|
||||
### `getElements()`
|
||||
|
||||
Returns a flat array of `ElementSnapshot` objects representing every element in the composition — including elements nested inside sub-compositions — in document order.
|
||||
|
||||
```typescript
|
||||
const elements = comp.getElements();
|
||||
|
||||
// Filter in userland
|
||||
const images = elements.filter((el) => el.tag === "img");
|
||||
const timed = elements.filter((el) => el.start !== null);
|
||||
```
|
||||
|
||||
Each `ElementSnapshot` (aliased from `HyperFramesElement`) carries:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `id` | `string` | Leaf `hf-id` — unique within its sub-composition scope |
|
||||
| `scopedId` | `string` | Canonical dispatch target (see below) |
|
||||
| `tag` | `string` | Lowercase HTML tag name |
|
||||
| `text` | `string \| null` | Direct text content of the element |
|
||||
| `inlineStyles` | `Record<string, string>` | camelCase property names |
|
||||
| `attributes` | `Record<string, string>` | All attributes except `style`, `class`, and `data-hf-*` |
|
||||
| `classNames` | `string[]` | |
|
||||
| `start` | `number \| null` | Seconds — null when `data-start` is absent |
|
||||
| `duration` | `number \| null` | Seconds — null when neither `data-duration` nor `data-end` is present |
|
||||
| `trackIndex` | `number \| null` | |
|
||||
| `animationIds` | `string[]` | GSAP tween IDs targeting this element |
|
||||
|
||||
### `getElement(id)`
|
||||
|
||||
Fetches a single element snapshot by `id` or `scopedId`. Returns `null` when no element matches.
|
||||
|
||||
```typescript
|
||||
const el = comp.getElement("hf-title");
|
||||
if (el) {
|
||||
console.log(el.text, el.inlineStyles.color);
|
||||
}
|
||||
```
|
||||
|
||||
Both bare ids (top-level elements) and scoped ids (sub-composition elements) are accepted. A bare id resolves only against top-level elements: a leaf that exists **only** inside a sub-composition returns `null` even if its id is unique in the document — use `find()` to discover its scoped form (`"hf-HOST/hf-LEAF"`), then pass that to `getElement`.
|
||||
|
||||
### `find(query)`
|
||||
|
||||
Returns an array of `scopedId` strings for every element that matches all supplied `FindQuery` fields. All fields are optional; an empty query matches everything (equivalent to `getElements().map(el => el.scopedId)`).
|
||||
|
||||
```typescript
|
||||
import type { FindQuery } from "@hyperframes/sdk";
|
||||
|
||||
// By tag
|
||||
const imgIds = comp.find({ tag: "img" });
|
||||
|
||||
// By text content (substring match)
|
||||
const headlineIds = comp.find({ text: "Launch" });
|
||||
|
||||
// By data-name attribute
|
||||
const logoIds = comp.find({ name: "brand-logo" });
|
||||
|
||||
// By track index
|
||||
const track1Ids = comp.find({ track: 1 });
|
||||
|
||||
// Filter to elements inside a specific sub-composition (by host hf-id)
|
||||
const innerIds = comp.find({ composition: "hf-intro-scene" });
|
||||
|
||||
// Combine fields — all must match
|
||||
const [targetId] = comp.find({ tag: "h1", track: 0 });
|
||||
```
|
||||
|
||||
`FindQuery` fields:
|
||||
|
||||
| Field | Type | Matches |
|
||||
|-------|------|---------|
|
||||
| `tag` | `string` | Exact tag name |
|
||||
| `text` | `string` | Substring of `el.text` |
|
||||
| `name` | `string` | Exact value of the `data-name` attribute |
|
||||
| `track` | `number` | Exact `trackIndex` |
|
||||
| `composition` | `string` | Elements whose `scopedId` starts with `"<host-id>/"` |
|
||||
|
||||
### `scopedId` for sub-composition elements
|
||||
|
||||
When a composition embeds another composition as a sub-clip, the inner elements are addressable with a scoped id of the form `"hf-HOST/hf-LEAF"` (arbitrary depth: `"hf-A/hf-B/hf-C"`). Always use the `scopedId` as the dispatch target for sub-composition elements — passing a bare leaf id to `setText` or `setStyle` will not resolve correctly when the same leaf id appears in multiple nested scopes.
|
||||
|
||||
```typescript
|
||||
// Wrong — bare id for a sub-composition element
|
||||
comp.setText("hf-inner-title", "New text"); // may silently no-op
|
||||
|
||||
// Correct — use the scopedId returned by find() or getElements()
|
||||
const [id] = comp.find({ name: "inner-title" }); // returns "hf-scene/hf-inner-title"
|
||||
if (id) comp.setText(id, "New text");
|
||||
```
|
||||
|
||||
## Editing with typed methods
|
||||
|
||||
Typed methods are the primary editing surface. Each one dispatches a single `EditOp` and returns immediately. The change is visible in the next `getElements()` call.
|
||||
|
||||
### `setText(id, value)`
|
||||
|
||||
Sets the direct text content of an element.
|
||||
|
||||
```typescript
|
||||
comp.setText("hf-title", "Shipped.");
|
||||
comp.setText("hf-subtitle", "Available now in all regions.");
|
||||
```
|
||||
|
||||
### `setStyle(id, styles)`
|
||||
|
||||
Merges inline styles. Property names are camelCase. Pass `null` for a property to remove it.
|
||||
|
||||
```typescript
|
||||
comp.setStyle("hf-card", {
|
||||
backgroundColor: "#1A1A2E",
|
||||
borderRadius: "16px",
|
||||
opacity: "0.9",
|
||||
});
|
||||
|
||||
// Remove a previously-set inline style
|
||||
comp.setStyle("hf-card", { opacity: null });
|
||||
```
|
||||
|
||||
### `setAttribute(id, name, value)`
|
||||
|
||||
Sets an HTML attribute. Pass `null` to remove it.
|
||||
|
||||
```typescript
|
||||
comp.setAttribute("hf-hero-img", "src", "/assets/hero-v2.jpg");
|
||||
comp.setAttribute("hf-hero-img", "alt", "Product screenshot");
|
||||
|
||||
// Remove an attribute
|
||||
comp.setAttribute("hf-hero-img", "loading", null);
|
||||
```
|
||||
|
||||
### `removeElement(id)`
|
||||
|
||||
Removes an element and all its children from the composition.
|
||||
|
||||
```typescript
|
||||
comp.removeElement("hf-old-badge");
|
||||
```
|
||||
|
||||
### `addElement(parent, index, html)`
|
||||
|
||||
Inserts an HTML fragment as a child of `parent` at zero-based sibling position `index`. Pass `null` for `parent` to insert at the document body root. Returns the minted `hf-id` of the inserted root element.
|
||||
|
||||
```typescript
|
||||
const newId = comp.addElement("hf-card", 0, `<span class="badge">New</span>`);
|
||||
// newId is a fresh stable hf-id, e.g. "hf-a3k7"
|
||||
|
||||
// Append at the end (index >= child count)
|
||||
comp.addElement("hf-card", 999, `<div class="footer-note">v2.0</div>`);
|
||||
```
|
||||
|
||||
The inserted HTML must be a single-root fragment and must not contain `<script>` tags.
|
||||
|
||||
### `setVariableValue(id, value)`
|
||||
|
||||
Sets a composition variable by its variable id.
|
||||
|
||||
```typescript
|
||||
// String variable
|
||||
comp.setVariableValue("tagline", "The fast path to production.");
|
||||
|
||||
// Font variable
|
||||
comp.setVariableValue("headingFont", {
|
||||
name: "Inter",
|
||||
source: "https://fonts.googleapis.com/css2?family=Inter:wght@400;700",
|
||||
});
|
||||
|
||||
// Image variable
|
||||
comp.setVariableValue("heroBg", {
|
||||
url: "/assets/hero.jpg",
|
||||
fit: "cover",
|
||||
});
|
||||
```
|
||||
|
||||
## `batch()` — group edits into one step
|
||||
|
||||
Wrap related mutations in `batch()` to coalesce them into a single undo entry, a single persist write, and a single `change` event. This is the right tool any time two or more edits are logically inseparable.
|
||||
|
||||
```typescript
|
||||
comp.batch(() => {
|
||||
comp.setText("hf-title", "Summer Drop");
|
||||
comp.setStyle("hf-title", { color: "#FF6B35", fontSize: "112px" });
|
||||
comp.setTiming("hf-title", { start: 0, duration: 3.5 });
|
||||
comp.setAttribute("hf-logo", "src", "/assets/logo-summer.svg");
|
||||
});
|
||||
// One undo entry. One disk write. One change event.
|
||||
```
|
||||
|
||||
Batches are transactional: if the callback throws, all DOM mutations from that batch are rolled back and the composition is restored to its pre-batch state.
|
||||
|
||||
Batches can nest — only the outermost boundary emits events and triggers a persist write.
|
||||
|
||||
## Ergonomic handles
|
||||
|
||||
### `comp.element(id)` → `ElementHandle`
|
||||
|
||||
Returns a curried handle that holds the `id` string and exposes the same mutation methods as the top-level `comp.*` methods. Useful when you are making several edits to the same element.
|
||||
|
||||
```typescript
|
||||
const title = comp.element("hf-title");
|
||||
|
||||
title.setText("Shipped.");
|
||||
title.setStyle({ color: "#FFD60A", letterSpacing: "-0.02em" });
|
||||
title.setTiming({ start: 0.5, duration: 3 });
|
||||
```
|
||||
|
||||
The handle holds only the `id` string — there is no stale DOM reference hazard. Calling methods on it after `dispose()` will silently no-op via the underlying dispatch path.
|
||||
|
||||
### `comp.selection()` → `SelectionProxy`
|
||||
|
||||
Returns a proxy that resolves the *current* selection at call time and applies mutations to every selected id in one batch.
|
||||
|
||||
```typescript
|
||||
comp.setSelection(["hf-title", "hf-subtitle"]);
|
||||
|
||||
const sel = comp.selection();
|
||||
console.log(sel.ids); // ["hf-title", "hf-subtitle"]
|
||||
|
||||
// Applies setStyle to both ids as a single batch
|
||||
sel.setStyle({ opacity: "0.5" });
|
||||
```
|
||||
|
||||
The selection is a runtime concept — it does not persist and is not included in patches or the override set. Use `getSelection()` to read the current selection, and `setSelection(ids)` to change it programmatically. Pass an empty array to clear.
|
||||
|
||||
```typescript
|
||||
const current = comp.getSelection();
|
||||
comp.setSelection(["hf-logo"]);
|
||||
comp.setSelection([]); // clear
|
||||
```
|
||||
|
||||
<Note>
|
||||
`SelectionProxy` and `ElementHandle` expose the same five methods: `setStyle`, `setText`, `setAttribute`, `setTiming`, and `removeElement`. They are intentionally symmetric — switch between them freely without rethinking your call site.
|
||||
</Note>
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Composition reference" icon="cube" href="/sdk/reference/composition">
|
||||
Full method signatures for every Composition method.
|
||||
</Card>
|
||||
<Card title="Types reference" icon="brackets-curly" href="/sdk/reference/types">
|
||||
HyperFramesElement, FindQuery, ElementHandle, SelectionProxy, and more.
|
||||
</Card>
|
||||
<Card title="Edit Operations reference" icon="pen-to-square" href="/sdk/reference/edit-operations">
|
||||
Every EditOp variant — for the dispatch() and can() layers.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
---
|
||||
title: "Timing & Animation"
|
||||
description: "Set clip timing, elastic holds, GSAP tweens, and keyframed animations on composition elements."
|
||||
---
|
||||
|
||||
Every clip in a HyperFrames composition has a position on the timeline (`data-start`, `data-duration`) and an optional animation attached to it. The SDK exposes typed helpers for both: the timing API controls *when* an element appears and how long it stays on screen; the animation API controls *how* it moves through that window.
|
||||
|
||||
## Clip Timing
|
||||
|
||||
### Reading timings
|
||||
|
||||
`getElementTimings()` returns a `Record<HfId, ElementTimingSnapshot>` — one entry per element that carries timing data. Values are derived from the live DOM on every call (never cached in the snapshot). Keys are `scopedId`: for top-level elements this equals the bare `id`, but for elements inside a sub-composition it is the scoped form `"hf-HOST/hf-LEAF"`.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
const comp = await openComposition(html);
|
||||
const timings = comp.getElementTimings();
|
||||
|
||||
for (const [id, t] of Object.entries(timings)) {
|
||||
console.log(id, t.enterAt, "→", t.exitAt, "labels:", t.labels);
|
||||
}
|
||||
```
|
||||
|
||||
Each `ElementTimingSnapshot` contains:
|
||||
|
||||
<ResponseField name="enterAt" type="number">
|
||||
Absolute timeline position (seconds) at which the element enters.
|
||||
</ResponseField>
|
||||
<ResponseField name="exitAt" type="number">
|
||||
Absolute timeline position (seconds) at which the element exits.
|
||||
</ResponseField>
|
||||
<ResponseField name="labels" type="string[]">
|
||||
GSAP timeline label names whose numeric position falls within `[enterAt, exitAt]`. Parsed fresh from the GSAP script on every `getElementTimings()` call — never stale.
|
||||
</ResponseField>
|
||||
|
||||
<Note>
|
||||
`getElementTimings()` only includes elements that have `data-start` and either `data-duration` or `data-end` attributes. Untimed elements are omitted. The method prefers `data-duration` over `data-end − data-start` when both exist, matching the behavior of `setTiming`.
|
||||
</Note>
|
||||
|
||||
### Setting timing on one element
|
||||
|
||||
```typescript
|
||||
// Move hf-title to start at 1.0 s, last 2.5 s, on track 0
|
||||
comp.setTiming("hf-title", { start: 1.0, duration: 2.5, trackIndex: 0 });
|
||||
```
|
||||
|
||||
All three fields are optional — pass only what you want to change:
|
||||
|
||||
<ParamField path="start" type="number">
|
||||
Clip start time in seconds.
|
||||
</ParamField>
|
||||
<ParamField path="duration" type="number">
|
||||
Clip duration in seconds.
|
||||
</ParamField>
|
||||
<ParamField path="trackIndex" type="number">
|
||||
Zero-based track index.
|
||||
</ParamField>
|
||||
|
||||
### Updating multiple elements in one batch
|
||||
|
||||
`setElementTiming(map)` dispatches one `setTiming` op per entry inside a single batch, so the history records one undo step and patch listeners see one event:
|
||||
|
||||
```typescript
|
||||
comp.setElementTiming({
|
||||
"hf-title": { start: 0.5, duration: 2.0 },
|
||||
"hf-logo": { start: 0.0, duration: 5.0, trackIndex: 1 },
|
||||
"hf-cta": { start: 3.0, duration: 2.5 },
|
||||
});
|
||||
```
|
||||
|
||||
Unknown ids are silently skipped.
|
||||
|
||||
### Elastic holds
|
||||
|
||||
An elastic hold freezes or loops a portion of an element's timeline window. Use it to hold a static frame between two animated segments without changing clip duration.
|
||||
|
||||
```typescript
|
||||
comp.setHold("hf-card", {
|
||||
start: 1.5, // hold begins at this time within the composition
|
||||
end: 4.0, // hold ends at this time
|
||||
fill: "freeze", // "freeze" | "loop"
|
||||
});
|
||||
```
|
||||
|
||||
`ElasticHold` fields:
|
||||
|
||||
<ParamField path="start" type="number">
|
||||
Absolute composition time at which the hold begins.
|
||||
</ParamField>
|
||||
<ParamField path="end" type="number">
|
||||
Absolute composition time at which the hold ends.
|
||||
</ParamField>
|
||||
<ParamField path="fill" type='"freeze" | "loop"'>
|
||||
`"freeze"` holds the last frame until `end`; `"loop"` repeats the segment from `start` back to `start`.
|
||||
</ParamField>
|
||||
|
||||
---
|
||||
|
||||
## GSAP Tweens
|
||||
|
||||
GSAP tweens are the primary animation primitive. The SDK reads tween IDs from `element.animationIds` (returned by the query API) and writes through `addGsapTween`, `setGsapTween`, and `removeGsapTween`.
|
||||
|
||||
### Adding a tween
|
||||
|
||||
```typescript
|
||||
const animId = comp.addGsapTween("hf-title", {
|
||||
method: "from",
|
||||
position: 0.5,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
fromProperties: { opacity: 0, y: 40 },
|
||||
});
|
||||
// animId is the newly-assigned animation ID — store it for subsequent edits
|
||||
```
|
||||
|
||||
`addGsapTween` returns the newly-assigned animation ID as a `string`.
|
||||
|
||||
### GsapTweenSpec fields
|
||||
|
||||
<ParamField path="method" type='"from" | "to" | "fromTo" | "set"'>
|
||||
The GSAP timeline method to call.
|
||||
</ParamField>
|
||||
<ParamField path="position" type="number | string">
|
||||
Timeline position: a number (seconds) or a label-relative string (e.g. `"intro+=0.3"`). Number-only is required for `addWithKeyframes` / `replaceWithKeyframes` — see [Keyframes](#keyframes) below.
|
||||
</ParamField>
|
||||
<ParamField path="duration" type="number">
|
||||
Tween duration in seconds.
|
||||
</ParamField>
|
||||
<ParamField path="ease" type="string">
|
||||
GSAP ease string, e.g. `"power2.inOut"`.
|
||||
</ParamField>
|
||||
<ParamField path="fromProperties" type="Record<string, unknown>">
|
||||
Starting properties — used with `"from"` and `"fromTo"`.
|
||||
</ParamField>
|
||||
<ParamField path="toProperties" type="Record<string, unknown>">
|
||||
Ending properties — used with `"fromTo"`.
|
||||
</ParamField>
|
||||
<ParamField path="properties" type="Record<string, unknown>">
|
||||
Animation target properties — used with `"to"` tweens.
|
||||
</ParamField>
|
||||
<ParamField path="repeat" type="number">
|
||||
Number of repeats (`-1` = infinite). Maps to GSAP's `repeat` option.
|
||||
</ParamField>
|
||||
<ParamField path="yoyo" type="boolean">
|
||||
When `true`, alternates direction on each repeat.
|
||||
</ParamField>
|
||||
<ParamField path="stagger" type="number | Record<string, unknown>">
|
||||
GSAP stagger amount (seconds) or a full stagger config object.
|
||||
</ParamField>
|
||||
|
||||
### Modifying a tween
|
||||
|
||||
```typescript
|
||||
// Change ease and duration — all other fields stay as-is
|
||||
comp.setGsapTween(animId, { ease: "elastic.out(1, 0.5)", duration: 0.9 });
|
||||
```
|
||||
|
||||
`setGsapTween` takes the animation ID and a `Partial<GsapTweenSpec>` — only the keys you pass are updated.
|
||||
|
||||
### Removing a tween
|
||||
|
||||
```typescript
|
||||
comp.removeGsapTween(animId);
|
||||
```
|
||||
|
||||
### Looking up animation IDs
|
||||
|
||||
Query the element snapshot to find animation IDs attached to a given element:
|
||||
|
||||
```typescript
|
||||
const el = comp.getElement("hf-title");
|
||||
const [firstAnimId] = el?.animationIds ?? [];
|
||||
if (firstAnimId) {
|
||||
comp.setGsapTween(firstAnimId, { ease: "none" });
|
||||
}
|
||||
```
|
||||
|
||||
### Feature-detecting advanced ops
|
||||
|
||||
Some GSAP operations (such as `setGsapKeyframe`, arc paths, and label ops) require the parser engine, which ships in a later phase. Use `can()` to gate them before dispatching:
|
||||
|
||||
```typescript
|
||||
const check = comp.can({
|
||||
type: "setGsapTween",
|
||||
animationId: firstAnimId,
|
||||
properties: { ease: "power3.inOut" },
|
||||
});
|
||||
|
||||
if (!check.ok) {
|
||||
if (check.code === "E_NO_GSAP_TIMELINE") {
|
||||
// Parser engine not yet available — show a disabled state or skip
|
||||
console.warn("GSAP timeline not available:", check.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
comp.dispatch({ type: "setGsapTween", animationId: firstAnimId, properties: { ease: "power3.inOut" } });
|
||||
```
|
||||
|
||||
Stable error codes returned by `can()`:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `E_TARGET_NOT_FOUND` | The target `HfId` does not exist in the document. |
|
||||
| `E_NO_ROOT` | The document has no root element. |
|
||||
| `E_NO_GSAP_TIMELINE` | Op requires the GSAP parser engine, which is not yet available. |
|
||||
| `E_NO_GSAP_SCRIPT` | The composition has no embedded GSAP script. |
|
||||
|
||||
---
|
||||
|
||||
## Keyframes
|
||||
|
||||
For keyframe-based animations, use `addWithKeyframes` and `replaceWithKeyframes`. Both operate on the GSAP CSS-keyframes layer and return the minted animation ID.
|
||||
|
||||
### Adding a keyframed tween
|
||||
|
||||
```typescript
|
||||
const animId = comp.addWithKeyframes(
|
||||
"#hf-badge", // CSS selector targeting the element
|
||||
1.0, // timeline position in seconds (number only)
|
||||
0.8, // duration in seconds
|
||||
[
|
||||
{ percentage: 0, properties: { opacity: 0, scale: 0.8 } },
|
||||
{ percentage: 60, properties: { opacity: 1, scale: 1.05 }, ease: "power2.out" },
|
||||
{ percentage: 100, properties: { scale: 1 } },
|
||||
],
|
||||
"power2.inOut", // optional overall ease
|
||||
);
|
||||
```
|
||||
|
||||
Returns the new animation ID string, or `""` if the op was rejected.
|
||||
|
||||
### Replacing an existing keyframed tween
|
||||
|
||||
```typescript
|
||||
const newAnimId = comp.replaceWithKeyframes(
|
||||
oldAnimId,
|
||||
"#hf-badge",
|
||||
1.0,
|
||||
1.2,
|
||||
[
|
||||
{ percentage: 0, properties: { x: -60, opacity: 0 } },
|
||||
{ percentage: 100, properties: { x: 0, opacity: 1 }, ease: "back.out(1.7)" },
|
||||
],
|
||||
);
|
||||
// newAnimId !== oldAnimId — position-derived IDs renumber after the remove
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`replaceWithKeyframes` is equivalent to `removeGsapTween` + `addWithKeyframes` in one atomic op. Because position-derived tween IDs renumber after the removal step, the returned ID is always a **new** ID and must not be assumed equal to the input `animationId`. Re-query `element.animationIds` after a replace to get the current set.
|
||||
</Warning>
|
||||
|
||||
### KeyframeSpec fields
|
||||
|
||||
<ParamField path="percentage" type="number">
|
||||
Position within the tween as a percentage (0–100).
|
||||
</ParamField>
|
||||
<ParamField path="properties" type="Record<string, number | string>">
|
||||
CSS / GSAP properties at this keyframe.
|
||||
</ParamField>
|
||||
<ParamField path="ease" type="string">
|
||||
Per-keyframe ease applied *from* this keyframe to the next.
|
||||
</ParamField>
|
||||
<ParamField path="auto" type="boolean">
|
||||
GSAP endpoint flag — when `true`, this keyframe picks up the element's current value automatically.
|
||||
</ParamField>
|
||||
|
||||
### Lower-level keyframe and label ops
|
||||
|
||||
For lower-level operations — individual keyframe mutations (`addGsapKeyframe`, `setGsapKeyframe`, `removeGsapKeyframe`), property removal, arc paths, label insertion, and animation splitting — use `dispatch()` directly with the corresponding `EditOp` types. See the [Edit Operations reference](/sdk/reference/edit-operations) for the full op union.
|
||||
|
||||
---
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Edit Operations" icon="code" href="/sdk/reference/edit-operations">
|
||||
Full reference for every op type in the `EditOp` union, including lower-level keyframe and arc ops.
|
||||
</Card>
|
||||
<Card title="Types" icon="brackets-curly" href="/sdk/reference/types">
|
||||
`GsapTweenSpec`, `KeyframeSpec`, `ElasticHold`, `ElementTimingSnapshot`, and all related types.
|
||||
</Card>
|
||||
<Card title="GSAP Animation Guide" icon="wand-magic-sparkles" href="/guides/gsap-animation">
|
||||
Authoring GSAP timelines in composition HTML.
|
||||
</Card>
|
||||
<Card title="Keyframes Guide" icon="film" href="/guides/keyframes">
|
||||
Keyframe authoring patterns and best practices.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
---
|
||||
title: "Undo, Redo & Patches"
|
||||
description: "Use the SDK's built-in undo stack, subscribe to patch events, and integrate with a host application's own history."
|
||||
---
|
||||
|
||||
The SDK ships undo/redo by default for standalone sessions and emits RFC 6902 JSON patches on every change. Patch events are the integration seam for host application history, collaborative editing, audit logs, and embedded override mode.
|
||||
|
||||
## Built-in Undo and Redo
|
||||
|
||||
Standalone sessions (those without an `overrides` option) automatically attach a history module. You call `undo()` and `redo()` directly on the composition, and guard UI state with `canUndo()` and `canRedo()`.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
const comp = await openComposition(html);
|
||||
|
||||
comp.setText("hf-title", "Draft A");
|
||||
comp.setText("hf-title", "Draft B");
|
||||
|
||||
comp.canUndo(); // true
|
||||
comp.undo(); // reverts "Draft B" → "Draft A"
|
||||
comp.redo(); // reapplies "Draft B"
|
||||
comp.canRedo(); // false — already at tip
|
||||
```
|
||||
|
||||
<Tip>
|
||||
`canUndo()` and `canRedo()` return `false` when the stack is empty and also when `history: false` was passed to `openComposition`. Check them before showing undo/redo controls.
|
||||
</Tip>
|
||||
|
||||
### Opting out of the built-in history
|
||||
|
||||
Pass `history: false` when the host application owns the undo stack and you want the SDK to emit patches without building a parallel internal stack:
|
||||
|
||||
```typescript
|
||||
const comp = await openComposition(html, {
|
||||
overrides: storedOverrides,
|
||||
history: false,
|
||||
});
|
||||
// comp.undo() / comp.redo() are no-ops — host drives history via applyPatches()
|
||||
```
|
||||
|
||||
<Note>
|
||||
`history: false` only disables the SDK undo stack. Persistence (auto-save) is independent — passing `history: false` does not suppress disk writes.
|
||||
</Note>
|
||||
|
||||
### Coalescing rapid edits
|
||||
|
||||
The history module coalesces patch events into one undo entry when two conditions are met: the same op types and same element paths are touched within a configurable window. This prevents a slider drag from flooding the stack with hundreds of individual entries.
|
||||
|
||||
The default coalesce window is 300 ms. Override it in `openComposition`:
|
||||
|
||||
```typescript
|
||||
const comp = await openComposition(html, {
|
||||
coalesceMs: 500, // ms — increase for slower interactions
|
||||
});
|
||||
```
|
||||
|
||||
For more on the coalesce contract and the `HistoryOptions` type, see [Utilities](/sdk/reference/utilities).
|
||||
|
||||
---
|
||||
|
||||
## Patch Events
|
||||
|
||||
Every committed change emits a `patch` event. Subscribe to mirror SDK edits into your host state, audit log, or collaboration channel.
|
||||
|
||||
```typescript
|
||||
const off = comp.on("patch", ({ formatVersion, patches, inversePatches, origin, opTypes }) => {
|
||||
if (formatVersion !== 1) throw new Error("Unexpected patch format version");
|
||||
saveToDB({ patches, inversePatches, origin, opTypes });
|
||||
});
|
||||
|
||||
// Unsubscribe when done
|
||||
off();
|
||||
```
|
||||
|
||||
### PatchEvent fields
|
||||
|
||||
<ResponseField name="formatVersion" type="1">
|
||||
Always `1` in the current SDK. Check this and reject unknown versions — a bump means a breaking change.
|
||||
</ResponseField>
|
||||
<ResponseField name="patches" type="JsonPatchOp[]">
|
||||
Forward patches that were applied: the add/remove/replace ops describing how the document changed.
|
||||
</ResponseField>
|
||||
<ResponseField name="inversePatches" type="JsonPatchOp[]">
|
||||
Inverse patches that undo this change. Store these alongside `patches` to support host-owned undo.
|
||||
</ResponseField>
|
||||
<ResponseField name="origin" type="unknown">
|
||||
The origin tag that was passed to `dispatch()` or `batch()`. Use it to route or filter events.
|
||||
</ResponseField>
|
||||
<ResponseField name="opTypes" type="string[]">
|
||||
Semantic names of the operations that produced this event (e.g. `["setStyle"]`). Useful for analytics and history labels. Not versioned — treat as informational.
|
||||
</ResponseField>
|
||||
|
||||
### JsonPatchOp
|
||||
|
||||
The SDK emits and accepts only the add/remove/replace subset of RFC 6902:
|
||||
|
||||
```typescript
|
||||
interface JsonPatchOp {
|
||||
op: "add" | "remove" | "replace";
|
||||
path: string; // JSON Pointer (RFC 6901), e.g. "/elements/hf-title/style/color"
|
||||
value?: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
The SDK never emits `move`, `copy`, or `test` ops, and `applyPatches()` ignores them if passed.
|
||||
|
||||
---
|
||||
|
||||
## Origin Model
|
||||
|
||||
Every mutation is tagged with an origin string. The SDK defines two built-in origins:
|
||||
|
||||
| Constant | Value | When used |
|
||||
|----------|-------|-----------|
|
||||
| `ORIGIN_LOCAL` | `"local"` | Default for all `dispatch()` and `batch()` calls |
|
||||
| `ORIGIN_APPLY_PATCHES` | `"@hyperframes/sdk:applyPatches"` | Automatically applied by `applyPatches()` |
|
||||
|
||||
Both are exported from `@hyperframes/sdk`.
|
||||
|
||||
### Setting a custom origin
|
||||
|
||||
Pass `origin` in the options to `dispatch()` or `batch()` to tag the event for downstream routing:
|
||||
|
||||
```typescript
|
||||
import { ORIGIN_LOCAL } from "@hyperframes/sdk";
|
||||
|
||||
comp.dispatch(
|
||||
{ type: "setStyle", target: "hf-title", styles: { color: "#0EA5E9" } },
|
||||
{ origin: "ui:color-picker" },
|
||||
);
|
||||
|
||||
comp.batch(() => {
|
||||
comp.setText("hf-cta", "Buy Now");
|
||||
comp.setStyle("hf-cta", { backgroundColor: "#22C55E" });
|
||||
}, { origin: "template-wizard" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Host-Owned History with applyPatches
|
||||
|
||||
When `history: false`, the host maintains its own undo stack from the patch events the SDK emits. To replay an undo step back into the SDK, call `applyPatches()` with the inverse patches from that stack entry:
|
||||
|
||||
```typescript
|
||||
// Host undo stack entry (built from PatchEvent)
|
||||
type HostEntry = {
|
||||
patches: JsonPatchOp[];
|
||||
inversePatches: JsonPatchOp[];
|
||||
};
|
||||
|
||||
let history: HostEntry[] = [];
|
||||
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
// Skip — this is the SDK replaying a host-initiated undo
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return;
|
||||
history.push({ patches, inversePatches });
|
||||
});
|
||||
|
||||
function hostUndo() {
|
||||
const entry = history.pop();
|
||||
if (!entry) return;
|
||||
comp.applyPatches(entry.inversePatches);
|
||||
}
|
||||
```
|
||||
|
||||
### Preventing undo loops
|
||||
|
||||
`applyPatches()` auto-tags its patch event with `ORIGIN_APPLY_PATCHES`. Your listener **must** skip that origin or you create an infinite loop:
|
||||
|
||||
```typescript
|
||||
import { ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";
|
||||
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return; // <-- required guard
|
||||
history.push({ patches, inversePatches });
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Omitting the `ORIGIN_APPLY_PATCHES` guard causes every `applyPatches()` call to push a new entry onto the host stack, which triggers another `applyPatches()`, and so on. Always check `origin` first.
|
||||
</Warning>
|
||||
|
||||
`ORIGIN_APPLY_PATCHES` is a namespaced string (`"@hyperframes/sdk:applyPatches"`) rather than a `Symbol` so it survives `postMessage`, structured clone, and JSON round-trips — important when the host forwards patch events across frames or workers.
|
||||
|
||||
### applyPatches with a custom origin
|
||||
|
||||
If you want downstream listeners to receive an explicit marker different from the default:
|
||||
|
||||
```typescript
|
||||
comp.applyPatches(entry.inversePatches, { origin: "host:undo" });
|
||||
```
|
||||
|
||||
Any origin other than `ORIGIN_APPLY_PATCHES` will enter the history module if `history` is still attached. Use `history: false` or a `trackedOrigins` filter when running host-owned history to avoid double-stacking.
|
||||
|
||||
---
|
||||
|
||||
## Embedded Override Mode Integration
|
||||
|
||||
In embedded (T3) mode, the host stores only the sparse override delta for each user. Pair `history: false` with `applyPatches()` to let the host drive undo while the SDK updates the composition state:
|
||||
|
||||
```typescript
|
||||
import { openComposition, ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";
|
||||
|
||||
const comp = await openComposition(baseTemplateHtml, {
|
||||
overrides: storedUserOverrides,
|
||||
history: false,
|
||||
});
|
||||
|
||||
let hostStack: HostEntry[] = [];
|
||||
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return;
|
||||
hostStack.push({ patches, inversePatches });
|
||||
// Persist the updated override set
|
||||
saveOverrides(comp.getOverrides());
|
||||
});
|
||||
|
||||
function undo() {
|
||||
const entry = hostStack.pop();
|
||||
if (entry) comp.applyPatches(entry.inversePatches);
|
||||
}
|
||||
```
|
||||
|
||||
See [Embedded Override Mode](/sdk/guides/embedded-override-mode) for the full T3 integration pattern.
|
||||
|
||||
---
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Composition API" icon="code" href="/sdk/reference/composition">
|
||||
Full method signatures for `undo`, `redo`, `canUndo`, `canRedo`, `on`, `applyPatches`, `dispatch`, and `batch`.
|
||||
</Card>
|
||||
<Card title="Types" icon="brackets-curly" href="/sdk/reference/types">
|
||||
`PatchEvent`, `JsonPatchOp`, `ORIGIN_APPLY_PATCHES`, `ORIGIN_LOCAL`, and related types.
|
||||
</Card>
|
||||
<Card title="Utilities" icon="wrench" href="/sdk/reference/utilities">
|
||||
`HistoryOptions`, `coalesceMs`, `trackedOrigins`, and `createHistory` internals.
|
||||
</Card>
|
||||
<Card title="Embedded Override Mode" icon="layer-group" href="/sdk/guides/embedded-override-mode">
|
||||
Full T3 embedded integration with host-owned history.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Reference in New Issue
Block a user