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,217 @@
|
||||
/**
|
||||
* Archetype (c) — Headless agent script
|
||||
*
|
||||
* Shows: no browser, no persist adapter, no preview — pure editing engine.
|
||||
* Agents: batch restyling, localization, A/B variants, programmatic animation.
|
||||
* Explicit-id ops via query API — no selection, no mouse events.
|
||||
*
|
||||
* F1 payoff: headless is possible BECAUSE ops have explicit targets.
|
||||
* Selection-implicit ops (old R0) would break here — no UI → no selection.
|
||||
*/
|
||||
|
||||
import { openComposition } from "../src/index.js";
|
||||
import type { ElementSnapshot } from "../src/index.js";
|
||||
|
||||
// ── Localization agent ────────────────────────────────────────────────────────
|
||||
// Rewrites all text elements to a new locale. No browser, no preview.
|
||||
|
||||
export async function localize(html: string, translations: Map<string, string>): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
const textElements = comp.find({ tag: "div" });
|
||||
|
||||
comp.batch(() => {
|
||||
for (const id of textElements) {
|
||||
const el = comp.getElement(id);
|
||||
if (!el?.text) continue;
|
||||
const translated = translations.get(el.text);
|
||||
if (translated) comp.setText(id, translated);
|
||||
}
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Brand restyle agent ───────────────────────────────────────────────────────
|
||||
// Apply brand colors to all elements with a matching class name.
|
||||
|
||||
export async function applyBrandColors(
|
||||
html: string,
|
||||
brandPrimary: string,
|
||||
brandSecondary: string,
|
||||
): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
// Query: find elements by attribute pattern
|
||||
const brandColorEls = comp
|
||||
.getElements()
|
||||
.filter((el) => el.attributes["data-brand-role"] === "primary");
|
||||
const brandSecondaryEls = comp
|
||||
.getElements()
|
||||
.filter((el) => el.attributes["data-brand-role"] === "secondary");
|
||||
|
||||
comp.batch(() => {
|
||||
for (const el of brandColorEls) {
|
||||
comp.setStyle(el.id, { color: brandPrimary });
|
||||
}
|
||||
for (const el of brandSecondaryEls) {
|
||||
comp.setStyle(el.id, { color: brandSecondary });
|
||||
}
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── A/B variant agent ─────────────────────────────────────────────────────────
|
||||
// Produce two HTML variants from one template.
|
||||
|
||||
export async function createABVariants(
|
||||
html: string,
|
||||
variantB: { headlineId: string; text: string; color: string },
|
||||
): Promise<{ variantA: string; variantB: string }> {
|
||||
const compA = await openComposition(html);
|
||||
const variantAHtml = compA.serialize();
|
||||
compA.dispose();
|
||||
|
||||
const compB = await openComposition(html);
|
||||
compB.setText(variantB.headlineId, variantB.text);
|
||||
compB.setStyle(variantB.headlineId, { color: variantB.color });
|
||||
const variantBHtml = compB.serialize();
|
||||
compB.dispose();
|
||||
|
||||
return { variantA: variantAHtml, variantB: variantBHtml };
|
||||
}
|
||||
|
||||
// ── Asset swap agent ──────────────────────────────────────────────────────────
|
||||
// F3: setAttribute handles img src, href, alt — the full attribute space.
|
||||
|
||||
export async function swapAssets(
|
||||
html: string,
|
||||
swaps: Array<{ id: string; src: string; alt?: string }>,
|
||||
): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
comp.batch(() => {
|
||||
for (const swap of swaps) {
|
||||
comp.setAttribute(swap.id, "src", swap.src);
|
||||
if (swap.alt !== undefined) {
|
||||
comp.setAttribute(swap.id, "alt", swap.alt);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Batch GSAP animation agent ────────────────────────────────────────────────
|
||||
// Add staggered entrance animations to all text elements.
|
||||
|
||||
export async function addStaggeredEntrance(html: string, staggerDelay = 0.15): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
const textEls = comp.find({ tag: "div" });
|
||||
|
||||
// Phase 3b feature-detect: addGsapTween throws UnsupportedOpError until the
|
||||
// parser-backed engine lands — skip animation rather than crash the job.
|
||||
const probeTween = {
|
||||
method: "from",
|
||||
position: 0,
|
||||
duration: 0.5,
|
||||
ease: "power3.out",
|
||||
fromProperties: { opacity: 0, y: 30 },
|
||||
} as const;
|
||||
const first = textEls[0];
|
||||
if (
|
||||
!first ||
|
||||
!comp.can({ type: "addGsapTween", target: first, id: "preflight", tween: probeTween })
|
||||
) {
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
comp.batch(() => {
|
||||
textEls.forEach((id, i) => {
|
||||
comp.addGsapTween(id, { ...probeTween, position: i * staggerDelay });
|
||||
});
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Composition metadata normalization ────────────────────────────────────────
|
||||
|
||||
export async function normalizeToPortrait(html: string): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
comp.dispatch({ type: "setCompositionMetadata", width: 1080, height: 1920 });
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Variable override agent ───────────────────────────────────────────────────
|
||||
// Apply a brand kit as composition variable overrides.
|
||||
|
||||
export async function applyVariableKit(
|
||||
html: string,
|
||||
kit: Record<string, string | number | boolean>,
|
||||
): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
comp.batch(() => {
|
||||
for (const [id, value] of Object.entries(kit)) {
|
||||
comp.setVariableValue(id, value);
|
||||
}
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Inspection utility ────────────────────────────────────────────────────────
|
||||
// Agents need to discover what's in a composition before editing.
|
||||
|
||||
export async function inspectComposition(html: string): Promise<{
|
||||
elementCount: number;
|
||||
textElements: ElementSnapshot[];
|
||||
imageElements: ElementSnapshot[];
|
||||
ids: string[];
|
||||
}> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
const all = comp.getElements();
|
||||
const textElements = all.filter((el) => ["div", "p", "h1", "h2", "h3", "span"].includes(el.tag));
|
||||
const imageElements = all.filter((el) => el.tag === "img");
|
||||
|
||||
comp.dispose();
|
||||
|
||||
return {
|
||||
elementCount: all.length,
|
||||
textElements,
|
||||
imageElements,
|
||||
ids: all.map((el) => el.id),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Timing normalization agent ────────────────────────────────────────────────
|
||||
|
||||
export async function normalizeTiming(html: string, totalDuration: number): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
const timedEls = comp.getElements().filter((el) => el.start !== null && el.duration !== null);
|
||||
|
||||
const lastEnd = timedEls.reduce(
|
||||
(max, el) => Math.max(max, (el.start ?? 0) + (el.duration ?? 0)),
|
||||
0,
|
||||
);
|
||||
if (lastEnd === 0) return comp.serialize();
|
||||
|
||||
const scale = totalDuration / lastEnd;
|
||||
|
||||
comp.batch(() => {
|
||||
for (const el of timedEls) {
|
||||
comp.setTiming(el.id, {
|
||||
start: Math.round((el.start ?? 0) * scale * 100) / 100,
|
||||
duration: Math.round((el.duration ?? 0) * scale * 100) / 100,
|
||||
});
|
||||
}
|
||||
comp.dispatch({ type: "setCompositionMetadata", duration: totalDuration });
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Archetype (a) — React app embedding the SDK (T1 standalone)
|
||||
*
|
||||
* Shows: openComposition, event subscription, typed methods, selection sugar,
|
||||
* batch + brand kit, useSyncExternalStore pattern, undo/redo, export.
|
||||
*
|
||||
* Note: JSX/React not imported here to keep this file framework-agnostic .ts.
|
||||
* In a real React app: wrap createEditorSession in useEffect, subscribe with
|
||||
* useSyncExternalStore (see comment blocks below).
|
||||
*/
|
||||
|
||||
import { openComposition, ORIGIN_APPLY_PATCHES } from "../src/index.js";
|
||||
import { createMemoryAdapter } from "../src/adapters/memory.js";
|
||||
import type { Composition, ElementSnapshot } from "../src/index.js";
|
||||
|
||||
// ── Session factory ───────────────────────────────────────────────────────────
|
||||
// Typically called once in useEffect(() => { createEditorSession(html).then(setComp) }, [])
|
||||
|
||||
export async function createEditorSession(html: string): Promise<Composition> {
|
||||
const persist = createMemoryAdapter();
|
||||
|
||||
const comp = await openComposition(html, { persist });
|
||||
|
||||
// Persist failures surface as events, never fatal exceptions.
|
||||
comp.on("persist:error", ({ error }) => {
|
||||
console.error(`Auto-save failed: ${error.message}`);
|
||||
// In a real app: show a toast notification
|
||||
});
|
||||
|
||||
return comp;
|
||||
}
|
||||
|
||||
// ── useSyncExternalStore integration ─────────────────────────────────────────
|
||||
// React 18+ pattern:
|
||||
//
|
||||
// const selection = useSyncExternalStore(
|
||||
// (cb) => comp.on('selectionchange', cb),
|
||||
// () => comp.getSelection(),
|
||||
// )
|
||||
//
|
||||
// Imperative equivalent for non-React consumers:
|
||||
|
||||
export function subscribeToSelection(
|
||||
comp: Composition,
|
||||
onChange: (ids: string[]) => void,
|
||||
): () => void {
|
||||
return comp.on("selectionchange", onChange);
|
||||
}
|
||||
|
||||
// ── Property panel bindings ───────────────────────────────────────────────────
|
||||
|
||||
export function applyStyle(comp: Composition, id: string, prop: string, value: string): void {
|
||||
// F1: explicit target — panel holds the id when rendering the current element
|
||||
comp.setStyle(id, { [prop]: value });
|
||||
}
|
||||
|
||||
export function applyFontSize(comp: Composition, id: string, px: number): void {
|
||||
comp.setStyle(id, { fontSize: `${px}px` });
|
||||
}
|
||||
|
||||
export function applyTextContent(comp: Composition, id: string, value: string): void {
|
||||
comp.setText(id, value);
|
||||
}
|
||||
|
||||
// Selection sugar — resolves getSelection() → explicit ops at call time.
|
||||
// Equivalent to: ids = comp.getSelection(); comp.setStyle(ids, {...})
|
||||
export function applyColorToSelection(comp: Composition, color: string): void {
|
||||
comp.selection().setStyle({ color });
|
||||
}
|
||||
|
||||
// ── Brand kit (batch) ─────────────────────────────────────────────────────────
|
||||
// One undo entry, one persist write, one change event.
|
||||
|
||||
export function applyBrandKit(comp: Composition, kit: Record<string, string>): void {
|
||||
comp.batch(() => {
|
||||
for (const [variableId, value] of Object.entries(kit)) {
|
||||
comp.setVariableValue(variableId, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Timeline drag ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function onClipDrag(comp: Composition, id: string, start: number, duration: number): void {
|
||||
comp.setTiming(id, { start, duration });
|
||||
}
|
||||
|
||||
// ── GSAP animation panel ──────────────────────────────────────────────────────
|
||||
|
||||
// Phase 3b: GSAP ops throw UnsupportedOpError until the parser-backed engine
|
||||
// lands — feature-detect with can() and disable the panel control if false.
|
||||
|
||||
export function addBounceIn(comp: Composition, targetId: string): string | null {
|
||||
const tween = {
|
||||
method: "from",
|
||||
position: 0,
|
||||
duration: 0.5,
|
||||
ease: "bounce.out",
|
||||
fromProperties: { y: 40, opacity: 0 },
|
||||
} as const;
|
||||
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
|
||||
return comp.addGsapTween(targetId, tween);
|
||||
}
|
||||
|
||||
export function updateEase(comp: Composition, animationId: string, ease: string): void {
|
||||
if (!comp.can({ type: "setGsapTween", animationId, properties: { ease } })) return;
|
||||
comp.setGsapTween(animationId, { ease });
|
||||
}
|
||||
|
||||
// ── Undo / redo ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function undo(comp: Composition): void {
|
||||
comp.undo();
|
||||
}
|
||||
|
||||
export function redo(comp: Composition): void {
|
||||
comp.redo();
|
||||
}
|
||||
|
||||
// ── T3 host undo integration (embedded mode) ─────────────────────────────────
|
||||
// When the SDK is embedded in a host with its own undo timeline:
|
||||
|
||||
export type HostHistoryEntry =
|
||||
| { kind: "sdk"; patches: ReturnType<Composition["getOverrides"]>; inversePatches: unknown[] }
|
||||
| { kind: "native"; data: unknown };
|
||||
|
||||
export function setupHostUndo(
|
||||
comp: Composition,
|
||||
pushToHostHistory: (entry: HostHistoryEntry) => void,
|
||||
): () => void {
|
||||
return comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
// Origin guard: skip re-emissions from applyPatches to avoid undo loops (F4)
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return;
|
||||
|
||||
pushToHostHistory({
|
||||
kind: "sdk",
|
||||
patches: patches as unknown as ReturnType<Composition["getOverrides"]>,
|
||||
inversePatches: [...inversePatches],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportHtml(comp: Composition): string {
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Query API usage ───────────────────────────────────────────────────────────
|
||||
|
||||
export function findTextElements(comp: Composition): ElementSnapshot[] {
|
||||
const ids = comp.find({ tag: "div" });
|
||||
return ids.map((id) => comp.getElement(id)).filter((el): el is ElementSnapshot => el !== null);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Archetype (b) — Vanilla standalone editor (T1)
|
||||
*
|
||||
* Shows: openComposition with fs adapter pattern, typed methods (the docs page one surface),
|
||||
* element handle, batch, dispatch (advanced layer), slider-burst coalescing intent,
|
||||
* sub-composition editing intent, timeline label ops.
|
||||
*
|
||||
* This is the "zero-framework" path: plain TypeScript, no React, no Vue.
|
||||
* Target: a tools developer building a custom editor UI from scratch.
|
||||
*/
|
||||
|
||||
import { openComposition } from "../src/index.js";
|
||||
import { createMemoryAdapter } from "../src/adapters/memory.js";
|
||||
import type { Composition, GsapTweenSpec } from "../src/index.js";
|
||||
|
||||
// ── Initialize ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function initEditor(html: string): Promise<Composition> {
|
||||
// Use createFsAdapter({ root: projectDir }) in production:
|
||||
// import { createFsAdapter } from '@hyperframes/sdk/adapters/fs'
|
||||
const persist = createMemoryAdapter();
|
||||
|
||||
const comp = await openComposition(html, {
|
||||
persist,
|
||||
coalesceMs: 300,
|
||||
});
|
||||
|
||||
comp.on("persist:error", ({ error }) => {
|
||||
showError(`Auto-save failed: ${error.message}${error.hint ? ` — ${error.hint}` : ""}`);
|
||||
});
|
||||
|
||||
return comp;
|
||||
}
|
||||
|
||||
// ── Property panel — typed method layer (F10 docs page one) ──────────────────
|
||||
|
||||
export function setColor(comp: Composition, id: string, color: string): void {
|
||||
comp.setStyle(id, { color });
|
||||
}
|
||||
|
||||
export function setFontFamily(comp: Composition, id: string, family: string): void {
|
||||
comp.setStyle(id, { fontFamily: family });
|
||||
}
|
||||
|
||||
export function swapImage(comp: Composition, id: string, src: string): void {
|
||||
// F3: setAttribute closes the attribute space — handles img src, href, alt, data-*, ARIA
|
||||
comp.setAttribute(id, "src", src);
|
||||
}
|
||||
|
||||
export function setAltText(comp: Composition, id: string, alt: string): void {
|
||||
comp.setAttribute(id, "alt", alt);
|
||||
}
|
||||
|
||||
export function removeElement(comp: Composition, id: string): void {
|
||||
comp.removeElement(id);
|
||||
// Inverse patch carries full serialized subtree — undo restores it.
|
||||
}
|
||||
|
||||
// ── Element handle pattern ────────────────────────────────────────────────────
|
||||
// comp.element(id) — curried handle, no stale-ref hazard
|
||||
|
||||
export function editHeadline(comp: Composition, headlineId: string): void {
|
||||
const h = comp.element(headlineId);
|
||||
h.setText("New headline");
|
||||
h.setStyle({ color: "#FFD60A", fontSize: "96px" });
|
||||
h.setTiming({ start: 0.5, duration: 3 });
|
||||
}
|
||||
|
||||
// ── Slider burst (rapid dispatch — coalesced into one undo entry) ─────────────
|
||||
|
||||
export function onFontSizeSlider(comp: Composition, id: string, px: number): void {
|
||||
// Each input event dispatches setStyle. History coalesces: same op + same target
|
||||
// within coalesceMs → one undo entry (forward keeps latest, inverse keeps first prev).
|
||||
// Persist queue writes once when the burst settles.
|
||||
comp.setStyle(id, { fontSize: `${px}px` });
|
||||
}
|
||||
|
||||
// ── Batch ─────────────────────────────────────────────────────────────────────
|
||||
// One undo entry, one persist write, one subscriber notification.
|
||||
|
||||
export function applyTextPreset(
|
||||
comp: Composition,
|
||||
id: string,
|
||||
preset: { fontSize: string; color: string; fontFamily: string },
|
||||
): void {
|
||||
comp.batch(() => {
|
||||
comp.setStyle(id, {
|
||||
fontSize: preset.fontSize,
|
||||
color: preset.color,
|
||||
fontFamily: preset.fontFamily,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── dispatch() — advanced layer for agents / automation ──────────────────────
|
||||
// Typed methods are sugar; dispatch() remains public for data-shaped op emission.
|
||||
|
||||
export function applyOpFromJson(comp: Composition, opJson: unknown): void {
|
||||
// Agents or automation scripts that emit JSON op objects use dispatch directly.
|
||||
comp.dispatch(opJson as Parameters<Composition["dispatch"]>[0]);
|
||||
}
|
||||
|
||||
// ── GSAP operations ───────────────────────────────────────────────────────────
|
||||
|
||||
// NOTE (Phase 3b): GSAP ops require the parser-backed engine and throw
|
||||
// UnsupportedOpError until it lands. Feature-detect with can() first.
|
||||
|
||||
export function addFadeIn(comp: Composition, targetId: string, delay = 0): string | null {
|
||||
const tween: GsapTweenSpec = {
|
||||
method: "from",
|
||||
position: delay,
|
||||
duration: 0.4,
|
||||
ease: "power2.out",
|
||||
fromProperties: { opacity: 0 },
|
||||
};
|
||||
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
|
||||
return comp.addGsapTween(targetId, tween);
|
||||
}
|
||||
|
||||
export function addBounce(
|
||||
comp: Composition,
|
||||
targetId: string,
|
||||
overrides?: Partial<GsapTweenSpec>,
|
||||
): string | null {
|
||||
const tween: GsapTweenSpec = {
|
||||
method: "from",
|
||||
position: 0,
|
||||
duration: 0.6,
|
||||
ease: "bounce.out",
|
||||
fromProperties: { y: 60, opacity: 0 },
|
||||
...overrides,
|
||||
};
|
||||
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
|
||||
return comp.addGsapTween(targetId, tween);
|
||||
}
|
||||
|
||||
// Keyframe editing (addGsapKeyframe / removeGsapKeyframe — v1, promoted 2026-06-09):
|
||||
export function insertKeyframe(comp: Composition, animationId: string, position: number): void {
|
||||
comp.dispatch({
|
||||
type: "addGsapKeyframe",
|
||||
animationId,
|
||||
position,
|
||||
value: { opacity: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
// Timeline labels
|
||||
export function addLabel(comp: Composition, name: string, position: number): void {
|
||||
comp.dispatch({ type: "addLabel", name, position });
|
||||
}
|
||||
|
||||
// ── Composition metadata ──────────────────────────────────────────────────────
|
||||
|
||||
export function resizeComposition(
|
||||
comp: Composition,
|
||||
width: number,
|
||||
height: number,
|
||||
duration: number,
|
||||
): void {
|
||||
comp.dispatch({ type: "setCompositionMetadata", width, height, duration });
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportComposition(comp: Composition): string {
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Query API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function listAllElementIds(comp: Composition): string[] {
|
||||
return comp.getElements().map((el) => el.id);
|
||||
}
|
||||
|
||||
export function findByText(comp: Composition, text: string): string[] {
|
||||
return comp.find({ text });
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function cleanup(comp: Composition): void {
|
||||
comp.dispose();
|
||||
}
|
||||
|
||||
function showError(msg: string): void {
|
||||
console.error(msg);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "@hyperframes/sdk",
|
||||
"version": "0.7.55",
|
||||
"description": "Headless, framework-neutral HyperFrames composition editing engine",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
"directory": "packages/sdk"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
},
|
||||
"./adapters/memory": {
|
||||
"import": "./src/adapters/memory.ts",
|
||||
"types": "./src/adapters/memory.ts"
|
||||
},
|
||||
"./adapters/fs": {
|
||||
"import": "./src/adapters/fs.ts",
|
||||
"types": "./src/adapters/fs.ts"
|
||||
},
|
||||
"./adapters/headless": {
|
||||
"import": "./src/adapters/headless.ts",
|
||||
"types": "./src/adapters/headless.ts"
|
||||
},
|
||||
"./editing": {
|
||||
"import": "./src/editing/affordances.ts",
|
||||
"types": "./src/editing/affordances.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./adapters/memory": {
|
||||
"import": "./dist/adapters/memory.js",
|
||||
"types": "./dist/adapters/memory.d.ts"
|
||||
},
|
||||
"./adapters/fs": {
|
||||
"import": "./dist/adapters/fs.js",
|
||||
"types": "./dist/adapters/fs.d.ts"
|
||||
},
|
||||
"./adapters/headless": {
|
||||
"import": "./dist/adapters/headless.js",
|
||||
"types": "./dist/adapters/headless.d.ts"
|
||||
},
|
||||
"./editing": {
|
||||
"import": "./dist/editing/affordances.js",
|
||||
"types": "./dist/editing/affordances.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:examples": "tsc --noEmit -p tsconfig.check.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
"linkedom": "^0.18.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"happy-dom": "^20.9.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
export interface FsAdapterOptions {
|
||||
/** Root directory for composition files */
|
||||
root: string;
|
||||
/** Max versions to keep per file. Default: 20 */
|
||||
maxVersions?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_VERSIONS = 20;
|
||||
|
||||
let _versionCounter = 0;
|
||||
|
||||
class FsAdapter implements PersistAdapter {
|
||||
private readonly root: string;
|
||||
private readonly maxVersions: number;
|
||||
private errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
|
||||
private readonly inflightWrites = new Set<Promise<void>>();
|
||||
private _writeLocks = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(opts: FsAdapterOptions) {
|
||||
this.root = opts.root;
|
||||
this.maxVersions = opts.maxVersions ?? DEFAULT_MAX_VERSIONS;
|
||||
}
|
||||
|
||||
async read(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(this.abs(path), "utf8");
|
||||
} catch (err: unknown) {
|
||||
if (isNotFound(err)) return undefined;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async write(path: string, content: string): Promise<void> {
|
||||
const p = this.doWrite(path, content);
|
||||
this.inflightWrites.add(p);
|
||||
try {
|
||||
await p;
|
||||
} finally {
|
||||
this.inflightWrites.delete(p);
|
||||
}
|
||||
}
|
||||
|
||||
private async doWrite(path: string, content: string): Promise<void> {
|
||||
try {
|
||||
const abs = this.abs(path);
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
await writeFile(abs, content, "utf8");
|
||||
await this.appendVersion(path, content);
|
||||
} catch (err) {
|
||||
for (const h of this.errorHandlers) h({ error: { message: String(err), cause: err } });
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
// Promise.all rejects on the first write failure; per-write errors are also
|
||||
// surfaced individually through the persist:error event channel.
|
||||
await Promise.all([...this.inflightWrites]);
|
||||
}
|
||||
|
||||
async listVersions(path: string): Promise<PersistVersionEntry[]> {
|
||||
const dir = this.versionsDir(path);
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
const sorted = entries
|
||||
.filter((f) => f.endsWith(".html"))
|
||||
.sort()
|
||||
.reverse();
|
||||
return Promise.all(
|
||||
sorted.map(async (f) => {
|
||||
const key = f.replace(/\.html$/, "");
|
||||
return {
|
||||
key,
|
||||
content: await readFile(join(dir, f), "utf8"),
|
||||
timestamp: Number(key.split("-")[0]),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async loadFrom(path: string, versionKey: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(join(this.versionsDir(path), `${versionKey}.html`), "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void {
|
||||
if (event !== "persist:error") return () => {};
|
||||
this.errorHandlers.push(handler);
|
||||
return () => {
|
||||
const i = this.errorHandlers.indexOf(handler);
|
||||
if (i !== -1) this.errorHandlers.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
private abs(path: string): string {
|
||||
return join(this.root, path);
|
||||
}
|
||||
|
||||
private versionsDir(path: string): string {
|
||||
return join(this.root, ".hf-versions", path);
|
||||
}
|
||||
|
||||
private async appendVersion(path: string, content: string): Promise<void> {
|
||||
const prior = this._writeLocks.get(path) ?? Promise.resolve();
|
||||
const next = prior.then(() => this._doAppendVersion(path, content));
|
||||
this._writeLocks.set(
|
||||
path,
|
||||
next.catch(() => {}),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
private async _doAppendVersion(path: string, content: string): Promise<void> {
|
||||
const dir = this.versionsDir(path);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const key = `${Date.now()}-${String(++_versionCounter).padStart(4, "0")}`;
|
||||
await writeFile(join(dir, `${key}.html`), content, "utf8");
|
||||
// prune oldest beyond maxVersions
|
||||
const all = (await readdir(dir)).filter((f) => f.endsWith(".html")).sort();
|
||||
const excess = all.length - this.maxVersions;
|
||||
if (excess > 0) {
|
||||
await Promise.all(all.slice(0, excess).map((f) => unlink(join(dir, f)).catch(() => {})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFound(err: unknown): boolean {
|
||||
return (err as NodeJS.ErrnoException)?.code === "ENOENT";
|
||||
}
|
||||
|
||||
export function createFsAdapter(opts: FsAdapterOptions): PersistAdapter {
|
||||
return new FsAdapter(opts);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
|
||||
import type { Composition } from "../types.js";
|
||||
|
||||
/** Null PreviewAdapter for headless use (agents, CI, server-side rendering). */
|
||||
class HeadlessPreviewAdapter implements PreviewAdapter {
|
||||
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
applyDraft(_id: string, _props: DraftProps): void {}
|
||||
|
||||
commitPreview(): void {}
|
||||
|
||||
cancelPreview(): void {}
|
||||
|
||||
select(_ids: string[], _opts?: { additive?: boolean }): void {}
|
||||
|
||||
on(_event: "selection", _handler: (ids: string[]) => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
attachSync(_comp: Composition): () => void {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export function createHeadlessAdapter(): PreviewAdapter {
|
||||
return new HeadlessPreviewAdapter();
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* attachSync mirrors every SDK edit (including undo/redo) onto a real live
|
||||
* document — this file needs happy-dom (the package's default vitest
|
||||
* environment is "node") because it exercises iframe.contentDocument.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createIframePreviewAdapter } from "./iframe.js";
|
||||
import { openComposition } from "../session.js";
|
||||
|
||||
const BASE_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" style="color: #fff; font-size: 64px">Hello World</h1>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
/** A same-origin iframe seeded with the given HTML, ready for contentDocument access. */
|
||||
function mountIframe(html: string): HTMLIFrameElement {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.appendChild(iframe);
|
||||
iframe.contentDocument!.open();
|
||||
iframe.contentDocument!.write(html);
|
||||
iframe.contentDocument!.close();
|
||||
return iframe;
|
||||
}
|
||||
|
||||
describe("IframePreviewAdapter.attachSync", () => {
|
||||
it("mirrors comp.getOverrides() onto the iframe immediately on attach", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#f00" }); // edit BEFORE attaching
|
||||
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
const liveTitle = iframe.contentDocument!.querySelector(
|
||||
'[data-hf-id="hf-title"]',
|
||||
) as HTMLElement;
|
||||
expect(liveTitle.style.getPropertyValue("color")).toBe("#f00");
|
||||
});
|
||||
|
||||
it("mirrors a style edit dispatched AFTER attaching", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
comp.setStyle("hf-title", { fontSize: "96px" });
|
||||
|
||||
const liveTitle = iframe.contentDocument!.querySelector(
|
||||
'[data-hf-id="hf-title"]',
|
||||
) as HTMLElement;
|
||||
expect(liveTitle.style.getPropertyValue("font-size")).toBe("96px");
|
||||
});
|
||||
|
||||
it("mirrors setText, setAttribute, and removeElement", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
const liveDoc = iframe.contentDocument!;
|
||||
|
||||
comp.setText("hf-title", "Goodbye");
|
||||
expect(liveDoc.querySelector('[data-hf-id="hf-title"]')?.textContent).toContain("Goodbye");
|
||||
|
||||
comp.setAttribute("hf-title", "data-test", "1");
|
||||
expect(liveDoc.querySelector('[data-hf-id="hf-title"]')?.getAttribute("data-test")).toBe("1");
|
||||
|
||||
comp.removeElement("hf-title");
|
||||
expect(liveDoc.querySelector('[data-hf-id="hf-title"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("mirrors undo — restores the live DOM to the pre-edit state", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
const liveDoc = iframe.contentDocument!;
|
||||
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
expect(
|
||||
(liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
|
||||
"color",
|
||||
),
|
||||
).toBe("#f00");
|
||||
|
||||
comp.undo();
|
||||
expect(
|
||||
(liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
|
||||
"color",
|
||||
),
|
||||
).toBe("#fff");
|
||||
});
|
||||
|
||||
it("mirrors redo after undo", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
const liveDoc = iframe.contentDocument!;
|
||||
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
comp.undo();
|
||||
comp.redo();
|
||||
expect(
|
||||
(liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
|
||||
"color",
|
||||
),
|
||||
).toBe("#f00");
|
||||
});
|
||||
|
||||
it("does NOT mirror a /script/gsap patch onto the live <script> tag", async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="5">
|
||||
<div data-hf-id="hf-box" style="opacity:0"></div>
|
||||
</div>
|
||||
<script>var tl = gsap.timeline({ paused: true });
|
||||
window.__timelines = { t: tl };</script>
|
||||
</body></html>`;
|
||||
const iframe = mountIframe(html);
|
||||
const liveScriptBefore = iframe.contentDocument!.querySelector("script")!.textContent;
|
||||
|
||||
const comp = await openComposition(html);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
comp.addGsapTween("hf-box", { method: "to", properties: { opacity: 1 }, duration: 1 });
|
||||
|
||||
// The offscreen model's script changed (proves the edit really happened)...
|
||||
expect(comp.serialize()).not.toBe(html);
|
||||
// ...but the LIVE script tag is untouched — script patches are never mirrored.
|
||||
expect(iframe.contentDocument!.querySelector("script")!.textContent).toBe(liveScriptBefore);
|
||||
});
|
||||
|
||||
it("DOES mirror a /style/css (stylesheet) patch onto the live <style> tag", async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="5">
|
||||
<div data-hf-id="hf-box" class="boxy"></div>
|
||||
</div>
|
||||
<style>.boxy { color: blue; }</style>
|
||||
</body></html>`;
|
||||
const iframe = mountIframe(html);
|
||||
const comp = await openComposition(html);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
comp.dispatch({ type: "setClassStyle", selector: ".boxy", styles: { color: "green" } });
|
||||
|
||||
const liveStyle = iframe.contentDocument!.querySelector("style")!.textContent ?? "";
|
||||
expect(liveStyle).toContain("green");
|
||||
});
|
||||
|
||||
it("does not throw when a patch arrives after the iframe is removed from the DOM", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
iframe.remove(); // contentDocument becomes null (or inaccessible) once detached
|
||||
|
||||
expect(() => comp.setStyle("hf-title", { color: "#0f0" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("re-attaching detaches the previous subscription — old comp's edits stop mirroring", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const compA = await openComposition(BASE_HTML);
|
||||
const compB = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
|
||||
adapter.attachSync(compA);
|
||||
adapter.attachSync(compB); // should detach compA's subscription
|
||||
|
||||
compA.setStyle("hf-title", { color: "#f00" }); // must NOT mirror — stale subscription
|
||||
compB.setStyle("hf-title", { fontSize: "10px" }); // must mirror — active subscription
|
||||
|
||||
const liveTitle = iframe.contentDocument!.querySelector(
|
||||
'[data-hf-id="hf-title"]',
|
||||
) as HTMLElement;
|
||||
expect(liveTitle.style.getPropertyValue("color")).not.toBe("#f00");
|
||||
expect(liveTitle.style.getPropertyValue("font-size")).toBe("10px");
|
||||
});
|
||||
|
||||
it("the returned unsubscribe function detaches the subscription", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
const detach = adapter.attachSync(comp);
|
||||
|
||||
detach();
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
|
||||
const liveTitle = iframe.contentDocument!.querySelector(
|
||||
'[data-hf-id="hf-title"]',
|
||||
) as HTMLElement;
|
||||
expect(liveTitle.style.getPropertyValue("color")).not.toBe("#f00");
|
||||
});
|
||||
|
||||
it("mirrors setVariableValue onto the live root's CSS custom property", async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-variables='[{"id":"accent","default":"#fff"}]'>
|
||||
<body>
|
||||
<div data-hf-id="hf-stage" data-hf-root style="--accent: #fff; width: 1280px; height: 720px" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" style="color: var(--accent)">Hello World</h1>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
const iframe = mountIframe(html);
|
||||
const comp = await openComposition(html);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
comp.setVariableValue("accent", "#0f0");
|
||||
|
||||
const liveRoot = iframe.contentDocument!.querySelector(
|
||||
'[data-hf-id="hf-stage"]',
|
||||
) as HTMLElement;
|
||||
expect(liveRoot.style.getPropertyValue("--accent")).toBe("#0f0");
|
||||
});
|
||||
|
||||
it("mirrors declareVariable/removeVariable onto the live document's schema attribute", async () => {
|
||||
// Full document (not a fragment): declareVariable refuses fragment sources.
|
||||
const fullDoc = `<!DOCTYPE html><html><body>${BASE_HTML}</body></html>`;
|
||||
const iframe = mountIframe(fullDoc); // no data-composition-variables at all
|
||||
const comp = await openComposition(fullDoc);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
comp.declareVariable({ id: "accent", type: "string", label: "Accent", default: "#fff" });
|
||||
|
||||
const liveDocEl = iframe.contentDocument!.documentElement;
|
||||
expect(liveDocEl.getAttribute("data-composition-variables")).toContain("accent");
|
||||
|
||||
comp.removeVariable("accent");
|
||||
// Removing the last declaration drops the attribute entirely (null).
|
||||
expect(liveDocEl.getAttribute("data-composition-variables") ?? "").not.toContain("accent");
|
||||
});
|
||||
|
||||
it("mirrors setTiming onto the live element's data-start/data-end attributes", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
comp.setTiming("hf-title", { start: 1, duration: 2 });
|
||||
|
||||
const liveTitle = iframe.contentDocument!.querySelector(
|
||||
'[data-hf-id="hf-title"]',
|
||||
) as HTMLElement;
|
||||
expect(liveTitle.getAttribute("data-start")).toBe("1");
|
||||
expect(liveTitle.getAttribute("data-end")).toBe("3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,898 @@
|
||||
/**
|
||||
* Unit tests for the pure functions in iframe.ts (no browser needed).
|
||||
*
|
||||
* elementFromPoint requires a real layout engine — the adapter's elementAtPoint()
|
||||
* is NOT tested here. Cover it with an integration test mounting a same-origin
|
||||
* iframe (WS-A1 follow-on).
|
||||
*
|
||||
* applyDraft / commitPreview / cancelPreview require HTMLElement.style + querySelector
|
||||
* which are also browser-only. They are tested via a lightweight fake-DOM helper
|
||||
* that simulates style.setProperty / getAttribute / removeProperty.
|
||||
*
|
||||
* WS-G image-alpha tests cover:
|
||||
* - alphaIsOpaque (pure predicate)
|
||||
* - mapPointToImagePixel (pure coordinate mapping)
|
||||
* - z-stack fallthrough via mock elementsFromPoint
|
||||
* - canvas taint → opaque fallback
|
||||
* - non-image regression (WS-A1 opacity behavior unchanged)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
resolveNearestHfElement,
|
||||
computeDraftPosition,
|
||||
createIframePreviewAdapter,
|
||||
alphaIsOpaque,
|
||||
mapPointToImagePixel,
|
||||
_imgCanvasCache,
|
||||
} from "./iframe.js";
|
||||
import type { ElementAtPointResult } from "./types.js";
|
||||
import type { EditOp } from "../types.js";
|
||||
|
||||
// ─── Minimal fake element ────────────────────────────────────────────────────
|
||||
|
||||
interface FakeEl {
|
||||
attrs: Record<string, string>;
|
||||
tagName: string;
|
||||
parentElement: FakeEl | null;
|
||||
getAttribute(name: string): string | null;
|
||||
hasAttribute(name: string): boolean;
|
||||
}
|
||||
|
||||
function fakeEl(
|
||||
attrs: Record<string, string>,
|
||||
tagName: string,
|
||||
parent: FakeEl | null = null,
|
||||
): FakeEl {
|
||||
return {
|
||||
attrs,
|
||||
tagName,
|
||||
parentElement: parent,
|
||||
getAttribute(name) {
|
||||
return Object.prototype.hasOwnProperty.call(this.attrs, name) ? this.attrs[name] : null;
|
||||
},
|
||||
hasAttribute(name) {
|
||||
return Object.prototype.hasOwnProperty.call(this.attrs, name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const visible = () => true;
|
||||
const invisible = () => false;
|
||||
|
||||
// ─── resolveNearestHfElement ──────────────────────────────────────────────────
|
||||
|
||||
describe("resolveNearestHfElement", () => {
|
||||
it("returns null for a null input", () => {
|
||||
expect(resolveNearestHfElement(null, visible)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the element itself when it carries data-hf-id", () => {
|
||||
const el = fakeEl({ "data-hf-id": "hf-abc" }, "div");
|
||||
const result = resolveNearestHfElement(el as unknown as Element, visible);
|
||||
expect(result).toEqual<ElementAtPointResult>({ id: "hf-abc", tag: "div" });
|
||||
});
|
||||
|
||||
it("walks up to a parent that carries data-hf-id", () => {
|
||||
const parent = fakeEl({ "data-hf-id": "hf-parent" }, "section");
|
||||
const child = fakeEl({}, "span", parent);
|
||||
const result = resolveNearestHfElement(child as unknown as Element, visible);
|
||||
expect(result).toEqual<ElementAtPointResult>({ id: "hf-parent", tag: "section" });
|
||||
});
|
||||
|
||||
it("returns null when the nearest data-hf-id node is data-hf-root", () => {
|
||||
const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div");
|
||||
const child = fakeEl({}, "p", root);
|
||||
expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the element itself is data-hf-root", () => {
|
||||
const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div");
|
||||
expect(resolveNearestHfElement(root as unknown as Element, visible)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when isVisible returns false for the matching element", () => {
|
||||
const el = fakeEl({ "data-hf-id": "hf-abc" }, "div");
|
||||
expect(resolveNearestHfElement(el as unknown as Element, invisible)).toBeNull();
|
||||
});
|
||||
|
||||
it("skips an opacity-0 element and returns null (isVisible called on the resolved node)", () => {
|
||||
const parent = fakeEl({ "data-hf-id": "hf-parent" }, "div");
|
||||
const child = fakeEl({}, "span", parent);
|
||||
const isVisible = vi.fn((el: Element) => {
|
||||
const fe = el as unknown as FakeEl;
|
||||
return fe.attrs["data-hf-id"] !== "hf-parent";
|
||||
});
|
||||
expect(resolveNearestHfElement(child as unknown as Element, isVisible)).toBeNull();
|
||||
expect(isVisible).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null when no data-hf-id found in any ancestor", () => {
|
||||
const grandparent = fakeEl({}, "body");
|
||||
const parent = fakeEl({}, "div", grandparent);
|
||||
const child = fakeEl({}, "span", parent);
|
||||
expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull();
|
||||
});
|
||||
|
||||
it("tag is lowercased", () => {
|
||||
const el = fakeEl({ "data-hf-id": "hf-xyz" }, "DIV");
|
||||
const result = resolveNearestHfElement(el as unknown as Element, visible);
|
||||
expect(result?.tag).toBe("div");
|
||||
});
|
||||
|
||||
it("stops at the nearest ancestor — does not continue past first data-hf-id", () => {
|
||||
const outer = fakeEl({ "data-hf-id": "hf-outer" }, "section");
|
||||
const inner = fakeEl({ "data-hf-id": "hf-inner" }, "div", outer);
|
||||
const child = fakeEl({}, "span", inner);
|
||||
const result = resolveNearestHfElement(child as unknown as Element, visible);
|
||||
expect(result?.id).toBe("hf-inner");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── computeDraftPosition ─────────────────────────────────────────────────────
|
||||
|
||||
describe("computeDraftPosition", () => {
|
||||
it("applies delta to base data-x/data-y", () => {
|
||||
expect(computeDraftPosition("100", "200", 30, -10)).toEqual({ x: 130, y: 190 });
|
||||
});
|
||||
|
||||
it("defaults missing data-x/data-y to 0", () => {
|
||||
expect(computeDraftPosition(null, null, 50, 25)).toEqual({ x: 50, y: 25 });
|
||||
});
|
||||
|
||||
it("defaults non-numeric data-x/data-y to 0", () => {
|
||||
expect(computeDraftPosition("abc", "xyz", 10, 5)).toEqual({ x: 10, y: 5 });
|
||||
});
|
||||
|
||||
it("works with zero delta (no-move commit)", () => {
|
||||
expect(computeDraftPosition("40", "80", 0, 0)).toEqual({ x: 40, y: 80 });
|
||||
});
|
||||
|
||||
it("handles negative base positions", () => {
|
||||
expect(computeDraftPosition("-20", "0", 5, 10)).toEqual({ x: -15, y: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IframePreviewAdapter selection ──────────────────────────────────────────
|
||||
|
||||
function stubIframe() {
|
||||
return {} as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
describe("IframePreviewAdapter selection", () => {
|
||||
it("on('selection') fires when select() is called", () => {
|
||||
const adapter = createIframePreviewAdapter(stubIframe());
|
||||
const cb = vi.fn();
|
||||
adapter.on("selection", cb);
|
||||
adapter.select(["hf-abc"]);
|
||||
expect(cb).toHaveBeenCalledWith(["hf-abc"]);
|
||||
});
|
||||
|
||||
it("off unsubscribes the handler", () => {
|
||||
const adapter = createIframePreviewAdapter(stubIframe());
|
||||
const cb = vi.fn();
|
||||
const off = adapter.on("selection", cb);
|
||||
off();
|
||||
adapter.select(["hf-abc"]);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("additive select merges with prior selection", () => {
|
||||
const adapter = createIframePreviewAdapter(stubIframe());
|
||||
const cb = vi.fn();
|
||||
adapter.on("selection", cb);
|
||||
adapter.select(["hf-a"]);
|
||||
adapter.select(["hf-b"], { additive: true });
|
||||
expect(cb).toHaveBeenLastCalledWith(expect.arrayContaining(["hf-a", "hf-b"]));
|
||||
});
|
||||
|
||||
it("non-additive select replaces prior selection", () => {
|
||||
const adapter = createIframePreviewAdapter(stubIframe());
|
||||
const cb = vi.fn();
|
||||
adapter.on("selection", cb);
|
||||
adapter.select(["hf-a"]);
|
||||
adapter.select(["hf-b"]);
|
||||
expect(cb).toHaveBeenLastCalledWith(["hf-b"]);
|
||||
});
|
||||
|
||||
it("multiple handlers all fire", () => {
|
||||
const adapter = createIframePreviewAdapter(stubIframe());
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
adapter.on("selection", cb1);
|
||||
adapter.on("selection", cb2);
|
||||
adapter.select(["hf-abc"]);
|
||||
expect(cb1).toHaveBeenCalledOnce();
|
||||
expect(cb2).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyDraft / commitPreview / cancelPreview ───────────────────────────────
|
||||
// Tests use a fake iframe+element because HTMLElement.style requires a browser.
|
||||
|
||||
interface FakeStyle {
|
||||
_props: Record<string, string>;
|
||||
setProperty(name: string, value: string): void;
|
||||
getPropertyValue(name: string): string;
|
||||
removeProperty(name: string): void;
|
||||
}
|
||||
|
||||
interface FakeDomEl {
|
||||
_attrs: Record<string, string>;
|
||||
style: FakeStyle;
|
||||
isConnected: boolean;
|
||||
getAttribute(name: string): string | null;
|
||||
setAttribute(name: string, value: string): void;
|
||||
hasAttribute(name: string): boolean;
|
||||
querySelector(sel: string): FakeDomEl | null;
|
||||
}
|
||||
|
||||
function fakeDomEl(id: string, dataX: string | null, dataY: string | null): FakeDomEl {
|
||||
const style: FakeStyle = {
|
||||
_props: {},
|
||||
setProperty(name, value) {
|
||||
this._props[name] = value;
|
||||
},
|
||||
getPropertyValue(name) {
|
||||
return this._props[name] ?? "";
|
||||
},
|
||||
removeProperty(name) {
|
||||
delete this._props[name];
|
||||
},
|
||||
};
|
||||
const attrs: Record<string, string> = { "data-hf-id": id };
|
||||
if (dataX !== null) attrs["data-x"] = dataX;
|
||||
if (dataY !== null) attrs["data-y"] = dataY;
|
||||
const el: FakeDomEl = {
|
||||
_attrs: attrs,
|
||||
style,
|
||||
isConnected: true,
|
||||
getAttribute(name) {
|
||||
return this._attrs[name] ?? null;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this._attrs[name] = value;
|
||||
},
|
||||
hasAttribute(name) {
|
||||
return name in this._attrs;
|
||||
},
|
||||
querySelector(_sel: string) {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
function fakeIframe(el: FakeDomEl | null): HTMLIFrameElement {
|
||||
return {
|
||||
contentDocument: {
|
||||
querySelector(_sel: string) {
|
||||
return el;
|
||||
},
|
||||
},
|
||||
} as unknown as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
describe("IframePreviewAdapter draft / commit / cancel", () => {
|
||||
it("commitPreview without applyDraft is a no-op", () => {
|
||||
const dispatch = vi.fn();
|
||||
const adapter = createIframePreviewAdapter(stubIframe(), dispatch);
|
||||
adapter.commitPreview();
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancelPreview without applyDraft is a no-op", () => {
|
||||
const dispatch = vi.fn();
|
||||
const adapter = createIframePreviewAdapter(stubIframe(), dispatch);
|
||||
adapter.cancelPreview();
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commitPreview dispatches moveElement with correct absolute position", () => {
|
||||
const dispatch = vi.fn();
|
||||
const el = fakeDomEl("hf-abc", "100", "200");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
|
||||
adapter.commitPreview();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith<[EditOp]>({
|
||||
type: "moveElement",
|
||||
target: "hf-abc",
|
||||
x: 130,
|
||||
y: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it("commitPreview with missing data-x/data-y defaults base to 0", () => {
|
||||
const dispatch = vi.fn();
|
||||
const el = fakeDomEl("hf-abc", null, null);
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 50, dy: 25 });
|
||||
adapter.commitPreview();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith<[EditOp]>({
|
||||
type: "moveElement",
|
||||
target: "hf-abc",
|
||||
x: 50,
|
||||
y: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("commitPreview mirrors the move onto the live element and applies the translate", () => {
|
||||
const el = fakeDomEl("hf-abc", "100", "200");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
|
||||
adapter.commitPreview();
|
||||
|
||||
expect(el.getAttribute("data-x")).toBe("130");
|
||||
expect(el.getAttribute("data-y")).toBe("180");
|
||||
// Baseline captured from the pre-drag values.
|
||||
expect(el.getAttribute("data-hf-edit-base-x")).toBe("100");
|
||||
expect(el.getAttribute("data-hf-edit-base-y")).toBe("200");
|
||||
// Final translate = delta from the baseline, held without a reload.
|
||||
expect(el.getAttribute("data-hf-edit-original-translate")).toBe("");
|
||||
expect(el.style.getPropertyValue("translate")).toBe("30px -20px");
|
||||
|
||||
// A second drag composes from the committed state and keeps the baseline.
|
||||
adapter.applyDraft("hf-abc", { dx: 10, dy: 10 });
|
||||
expect(el.style.getPropertyValue("translate")).toBe("40px -10px");
|
||||
adapter.commitPreview();
|
||||
expect(el.getAttribute("data-x")).toBe("140");
|
||||
expect(el.getAttribute("data-hf-edit-base-x")).toBe("100");
|
||||
expect(el.style.getPropertyValue("translate")).toBe("40px -10px");
|
||||
});
|
||||
|
||||
it("applyDraft translates the element live and cancelPreview restores it", () => {
|
||||
const el = fakeDomEl("hf-abc", "0", "0");
|
||||
el.style.setProperty("translate", "5px 6px");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
|
||||
expect(el.style.getPropertyValue("translate")).toBe("35px -14px");
|
||||
|
||||
adapter.cancelPreview();
|
||||
expect(el.style.getPropertyValue("translate")).toBe("5px 6px");
|
||||
expect(el.getAttribute("data-hf-edit-base-x")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancelPreview removes a draft translate when there was none before", () => {
|
||||
const el = fakeDomEl("hf-abc", "0", "0");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30 });
|
||||
expect(el.style.getPropertyValue("translate")).toBe("30px 0px");
|
||||
|
||||
adapter.cancelPreview();
|
||||
expect(el.style.getPropertyValue("translate")).toBe("");
|
||||
});
|
||||
|
||||
it("applyDraft reuses the cached element across repeated calls (no re-query)", () => {
|
||||
const el = fakeDomEl("hf-abc", "0", "0");
|
||||
let queryCount = 0;
|
||||
const iframe = {
|
||||
contentDocument: {
|
||||
querySelector(_sel: string) {
|
||||
queryCount++;
|
||||
return el;
|
||||
},
|
||||
},
|
||||
} as unknown as HTMLIFrameElement;
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.applyDraft("hf-abc", { dx: 1, dy: 1 });
|
||||
adapter.applyDraft("hf-abc", { dx: 2, dy: 2 });
|
||||
adapter.applyDraft("hf-abc", { dx: 3, dy: 3 });
|
||||
// Queried once on the first call; the next two reuse the connected cache.
|
||||
expect(queryCount).toBe(1);
|
||||
});
|
||||
|
||||
it("commitPreview without a dispatch callback is a no-op", () => {
|
||||
const el = fakeDomEl("hf-abc", "0", "0");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el));
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 10, dy: 10 });
|
||||
// should not throw
|
||||
adapter.commitPreview();
|
||||
});
|
||||
|
||||
it("cancelPreview reverts the draft translate without dispatching", () => {
|
||||
const dispatch = vi.fn();
|
||||
const el = fakeDomEl("hf-abc", "100", "200");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
|
||||
expect(el.style.getPropertyValue("translate")).toBe("30px 20px");
|
||||
adapter.cancelPreview();
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(el.style.getPropertyValue("translate")).toBe("");
|
||||
});
|
||||
|
||||
it("second commitPreview after first is a no-op (draft cleared)", () => {
|
||||
const dispatch = vi.fn();
|
||||
const el = fakeDomEl("hf-abc", "0", "0");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 10, dy: 5 });
|
||||
adapter.commitPreview();
|
||||
adapter.commitPreview();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("switching applyDraft to a new id reverts the abandoned element", () => {
|
||||
const elA = fakeDomEl("hf-a", "0", "0");
|
||||
const elB = fakeDomEl("hf-b", "0", "0");
|
||||
const iframe = {
|
||||
contentDocument: {
|
||||
querySelector(sel: string) {
|
||||
return sel.includes("hf-a") ? elA : elB;
|
||||
},
|
||||
},
|
||||
} as unknown as HTMLIFrameElement;
|
||||
const adapter = createIframePreviewAdapter(iframe, vi.fn());
|
||||
|
||||
adapter.applyDraft("hf-a", { dx: 80, dy: 0 });
|
||||
expect(elA.style.getPropertyValue("translate")).toBe("80px 0px");
|
||||
|
||||
adapter.applyDraft("hf-b", { dx: 10, dy: 10 });
|
||||
// The abandoned element is restored; the delta does not carry over.
|
||||
expect(elA.style.getPropertyValue("translate")).toBe("");
|
||||
expect(elB.style.getPropertyValue("translate")).toBe("10px 10px");
|
||||
});
|
||||
|
||||
it("commitPreview reverts the draft translate when dispatch throws", () => {
|
||||
const el = fakeDomEl("hf-abc", "0", "0");
|
||||
el.style.setProperty("translate", "5px 6px");
|
||||
const dispatch = vi.fn(() => {
|
||||
throw new Error("element_not_found");
|
||||
});
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
|
||||
expect(() => adapter.commitPreview()).toThrow("element_not_found");
|
||||
expect(el.style.getPropertyValue("translate")).toBe("5px 6px");
|
||||
expect(el.getAttribute("data-hf-edit-base-x")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancelPreview does not promote a computed (stylesheet) translate to inline", () => {
|
||||
const el = fakeDomEl("hf-abc", "0", "0");
|
||||
// Simulate a stylesheet-authored translate visible only via computed style.
|
||||
(el as unknown as { ownerDocument: unknown }).ownerDocument = {
|
||||
defaultView: {
|
||||
getComputedStyle: () => ({ getPropertyValue: () => "-50% -50%" }),
|
||||
},
|
||||
};
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
|
||||
// Draft composes onto the computed baseline (calc for non-px units).
|
||||
expect(el.style.getPropertyValue("translate")).toBe("calc(-50% + 30px) calc(-50% + 20px)");
|
||||
|
||||
adapter.cancelPreview();
|
||||
// Inline translate removed — the stylesheet value stays authoritative.
|
||||
expect(el.style.getPropertyValue("translate")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WS-G: alphaIsOpaque ──────────────────────────────────────────────────────
|
||||
|
||||
describe("alphaIsOpaque", () => {
|
||||
function makeImageData(alpha: number): ImageData {
|
||||
const data = new Uint8ClampedArray([255, 0, 0, alpha]);
|
||||
return { data, width: 1, height: 1, colorSpace: "srgb" } as unknown as ImageData;
|
||||
}
|
||||
|
||||
it("returns false for a fully transparent pixel (a=0)", () => {
|
||||
expect(alphaIsOpaque(makeImageData(0))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a fully opaque pixel (a=255)", () => {
|
||||
expect(alphaIsOpaque(makeImageData(255))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for alpha at default threshold (a=1 >= 1)", () => {
|
||||
expect(alphaIsOpaque(makeImageData(1), 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects a custom threshold: a=100 < threshold=128 → false", () => {
|
||||
expect(alphaIsOpaque(makeImageData(100), 128)).toBe(false);
|
||||
});
|
||||
|
||||
it("respects a custom threshold: a=200 >= threshold=128 → true", () => {
|
||||
expect(alphaIsOpaque(makeImageData(200), 128)).toBe(true);
|
||||
});
|
||||
|
||||
it("threshold edge: a === threshold → true", () => {
|
||||
expect(alphaIsOpaque(makeImageData(64), 64)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WS-G: mapPointToImagePixel ───────────────────────────────────────────────
|
||||
|
||||
describe("mapPointToImagePixel", () => {
|
||||
// A 200×100 CSS box displaying a 400×200 natural image.
|
||||
const rect = { left: 10, top: 20, width: 200, height: 100 };
|
||||
const natural = { width: 400, height: 200 };
|
||||
|
||||
it("fill: maps center of box to center of natural image", () => {
|
||||
const result = mapPointToImagePixel(rect, natural, "fill", "50% 50%", {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
});
|
||||
expect(result).toEqual({ px: 200, py: 100 });
|
||||
});
|
||||
|
||||
it("fill: maps top-left corner to natural (0, 0)", () => {
|
||||
const result = mapPointToImagePixel(rect, natural, "fill", "50% 50%", {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
});
|
||||
expect(result).toEqual({ px: 0, py: 0 });
|
||||
});
|
||||
|
||||
it("fill: maps bottom-right corner to natural (399, 199)", () => {
|
||||
const result = mapPointToImagePixel(rect, natural, "fill", "50% 50%", {
|
||||
x: rect.left + rect.width,
|
||||
y: rect.top + rect.height,
|
||||
});
|
||||
// rect.width maps to naturalWidth → px = floor(200/200 * 400) = 400, clamped to 399
|
||||
expect(result).toEqual({ px: 399, py: 199 });
|
||||
});
|
||||
|
||||
it("returns null when point is outside the CSS box (left)", () => {
|
||||
const result = mapPointToImagePixel(rect, natural, "fill", "50% 50%", {
|
||||
x: rect.left - 1,
|
||||
y: rect.top + 10,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when point is outside the CSS box (above)", () => {
|
||||
const result = mapPointToImagePixel(rect, natural, "fill", "50% 50%", {
|
||||
x: rect.left + 10,
|
||||
y: rect.top - 1,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("cover", () => {
|
||||
// 200×100 box, 100×100 natural → scale = max(2, 1) = 2 (cover clips Y;
|
||||
// the 200×200 rendered image overflows top/bottom — cover never letterboxes)
|
||||
// rendered: 200×200; centered by default (50% 50%)
|
||||
// imgTop = (100 - 200)/2 = -50; imgLeft = (200-200)/2 = 0
|
||||
const coverRect = { left: 0, top: 0, width: 200, height: 100 };
|
||||
const coverNatural = { width: 100, height: 100 };
|
||||
|
||||
it("cover: point in center maps to center of natural image", () => {
|
||||
// x=100, y=50 → rx=100-0=100, ry=50-(-50)=100; px=100/2=50, py=100/2=50
|
||||
const result = mapPointToImagePixel(coverRect, coverNatural, "cover", "50% 50%", {
|
||||
x: 100,
|
||||
y: 50,
|
||||
});
|
||||
expect(result).toEqual({ px: 50, py: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("contain", () => {
|
||||
// 200×100 box, 400×100 natural → scale = min(0.5, 1) = 0.5
|
||||
// rendered: 200×50; centered by default
|
||||
// imgLeft = (200-200)/2 = 0; imgTop = (100-50)/2 = 25
|
||||
const containRect = { left: 0, top: 0, width: 200, height: 100 };
|
||||
const containNatural = { width: 400, height: 100 };
|
||||
|
||||
it("contain: point in rendered area maps to natural pixel", () => {
|
||||
// y=50 → ry = 50-25 = 25; py = 25/0.5 = 50
|
||||
// x=100 → rx = 100-0 = 100; px = 100/0.5 = 200
|
||||
const result = mapPointToImagePixel(containRect, containNatural, "contain", "50% 50%", {
|
||||
x: 100,
|
||||
y: 50,
|
||||
});
|
||||
expect(result).toEqual({ px: 200, py: 50 });
|
||||
});
|
||||
|
||||
it("contain: point in letterbox region (above image) → null", () => {
|
||||
// imgTop = 25; y=10 → ry=10-25 = -15 → null
|
||||
const result = mapPointToImagePixel(containRect, containNatural, "contain", "50% 50%", {
|
||||
x: 100,
|
||||
y: 10,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("contain: point in letterbox region (below image) → null", () => {
|
||||
// imgTop = 25; rendered height = 50 → imgBottom = 75; y=80 > 75 → null
|
||||
const result = mapPointToImagePixel(containRect, containNatural, "contain", "50% 50%", {
|
||||
x: 100,
|
||||
y: 80,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("out-of-box point returns null regardless of object-fit", () => {
|
||||
const r = { left: 50, top: 50, width: 100, height: 100 };
|
||||
const n = { width: 200, height: 200 };
|
||||
// Point to the left of the CSS box
|
||||
expect(mapPointToImagePixel(r, n, "cover", "50% 50%", { x: 49, y: 100 })).toBeNull();
|
||||
});
|
||||
|
||||
describe("none", () => {
|
||||
// object-fit:none draws the image at natural size, positioned in the box.
|
||||
// box 100×100, natural 50×50, "50% 50%" → availX=availY=50, offset=25.
|
||||
const r = { left: 0, top: 0, width: 100, height: 100 };
|
||||
const n = { width: 50, height: 50 };
|
||||
|
||||
it("none: box center maps to image center", () => {
|
||||
// click (50,50): px=floor(50-25)=25, py=25
|
||||
expect(mapPointToImagePixel(r, n, "none", "50% 50%", { x: 50, y: 50 })).toEqual({
|
||||
px: 25,
|
||||
py: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("none: point inside the box but outside the natural-size image → null", () => {
|
||||
// image occupies 25..75; click at (10,10) is left/above it
|
||||
expect(mapPointToImagePixel(r, n, "none", "50% 50%", { x: 10, y: 10 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("object-position", () => {
|
||||
// contain so object-position has an effect on the vertical axis.
|
||||
// box 200×100, natural 400×100 → scale 0.5, rendered 200×50; availX=0, availY=50.
|
||||
const r = { left: 0, top: 0, width: 200, height: 100 };
|
||||
const n = { width: 400, height: 100 };
|
||||
|
||||
it("'left top' aligns the image to the top of the box", () => {
|
||||
expect(mapPointToImagePixel(r, n, "contain", "left top", { x: 0, y: 0 })).toEqual({
|
||||
px: 0,
|
||||
py: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reversed keyword pair 'top left' resolves identically to 'left top'", () => {
|
||||
const reversed = mapPointToImagePixel(r, n, "contain", "top left", { x: 0, y: 0 });
|
||||
const canonical = mapPointToImagePixel(r, n, "contain", "left top", { x: 0, y: 0 });
|
||||
expect(reversed).toEqual(canonical);
|
||||
expect(reversed).toEqual({ px: 0, py: 0 });
|
||||
});
|
||||
|
||||
it("'bottom left' (vertical-first) and 'left bottom' both align bottom-left", () => {
|
||||
// bottom → imgTop=availY=50; bottom edge click y=100 → ry=50 → py=floor(50/0.5)=100→clamp 99
|
||||
const vh = mapPointToImagePixel(r, n, "contain", "bottom left", { x: 0, y: 100 });
|
||||
const hv = mapPointToImagePixel(r, n, "contain", "left bottom", { x: 0, y: 100 });
|
||||
expect(vh).toEqual(hv);
|
||||
expect(vh).toEqual({ px: 0, py: 99 });
|
||||
});
|
||||
|
||||
it("supports pixel object-position values", () => {
|
||||
// "0px 10px" → imgTop=10; click (0,10) → ry=0 → py=0
|
||||
expect(mapPointToImagePixel(r, n, "contain", "0px 10px", { x: 0, y: 10 })).toEqual({
|
||||
px: 0,
|
||||
py: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("cover/contain returns null for a zero-dimension natural image", () => {
|
||||
const r = { left: 0, top: 0, width: 200, height: 100 };
|
||||
expect(
|
||||
mapPointToImagePixel(r, { width: 0, height: 100 }, "cover", "50% 50%", { x: 10, y: 10 }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WS-G: z-stack fallthrough (mock elementsFromPoint) ──────────────────────
|
||||
|
||||
/**
|
||||
* WS-G z-stack tests.
|
||||
*
|
||||
* The adapter's elementAtPoint uses elementsFromPoint (z-stack) and checks
|
||||
* `candidate instanceof win.HTMLImageElement`. Since happy-dom / the Bun test
|
||||
* runner doesn't expose a global HTMLImageElement, we supply a local stub
|
||||
* class and wire it into the fake contentWindow so the instanceof check works.
|
||||
*
|
||||
* Canvas pixel reads (getImageData) are unavailable in the test environment, so
|
||||
* we patch globalThis.OffscreenCanvas to control alpha values:
|
||||
* - opaque: getImageData → alpha=255
|
||||
* - transparent: getImageData → alpha=0
|
||||
* - tainted: getImageData → throws SecurityError
|
||||
*/
|
||||
|
||||
// ─── Stub HTMLImageElement (no global in Node/Bun) ───────────────────────────
|
||||
|
||||
class FakeHTMLImageElement {
|
||||
attrs: Record<string, string>;
|
||||
tagName: string;
|
||||
parentElement: FakeHTMLImageElement | null;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
currentSrc: string;
|
||||
src: string;
|
||||
|
||||
constructor(id: string, parent: FakeHTMLImageElement | null = null) {
|
||||
this.attrs = { "data-hf-id": id };
|
||||
this.tagName = "IMG";
|
||||
this.parentElement = parent;
|
||||
this.naturalWidth = 100;
|
||||
this.naturalHeight = 100;
|
||||
this.currentSrc = `http://example.com/img-${id}.png`;
|
||||
this.src = `http://example.com/img-${id}.png`;
|
||||
}
|
||||
|
||||
getBoundingClientRect(): DOMRect {
|
||||
return { left: 0, top: 0, width: 100, height: 100, right: 100, bottom: 100 } as DOMRect;
|
||||
}
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return Object.prototype.hasOwnProperty.call(this.attrs, name) ? this.attrs[name] : null;
|
||||
}
|
||||
|
||||
hasAttribute(name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(this.attrs, name);
|
||||
}
|
||||
}
|
||||
|
||||
// WS-G tests reuse the existing FakeEl / fakeEl helper from above.
|
||||
// buildFakeIframeWithStack accepts FakeEl or FakeHTMLImageElement.
|
||||
|
||||
function buildFakeIframeWithStack(stack: Array<FakeEl | FakeHTMLImageElement>) {
|
||||
return {
|
||||
contentDocument: {
|
||||
elementsFromPoint(_x: number, _y: number) {
|
||||
return stack;
|
||||
},
|
||||
},
|
||||
contentWindow: {
|
||||
// Supply our stub class so `candidate instanceof win.HTMLImageElement` works
|
||||
HTMLImageElement: FakeHTMLImageElement,
|
||||
getComputedStyle(_el: Element) {
|
||||
return { opacity: "1" } as CSSStyleDeclaration;
|
||||
},
|
||||
},
|
||||
} as unknown as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
// ─── OffscreenCanvas stub helpers ────────────────────────────────────────────
|
||||
|
||||
type CanvasAlphaBehavior = "opaque" | "transparent" | "tainted";
|
||||
|
||||
function stubOffscreenCanvas(behavior: CanvasAlphaBehavior): () => void {
|
||||
const orig = globalThis.OffscreenCanvas as typeof OffscreenCanvas | undefined;
|
||||
globalThis.OffscreenCanvas = class {
|
||||
width: number;
|
||||
height: number;
|
||||
constructor(w: number, h: number) {
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
}
|
||||
getContext(_type: string) {
|
||||
return {
|
||||
drawImage() {},
|
||||
getImageData() {
|
||||
if (behavior === "tainted") {
|
||||
throw new DOMException("Tainted canvases may not be exported.", "SecurityError");
|
||||
}
|
||||
const alpha = behavior === "opaque" ? 255 : 0;
|
||||
return { data: new Uint8ClampedArray([255, 0, 0, alpha]), width: 1, height: 1 };
|
||||
},
|
||||
};
|
||||
}
|
||||
} as unknown as typeof OffscreenCanvas;
|
||||
return () => {
|
||||
if (orig === undefined) {
|
||||
delete (globalThis as Record<string, unknown>).OffscreenCanvas;
|
||||
} else {
|
||||
globalThis.OffscreenCanvas = orig;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a z-stack image-alpha test with a controlled canvas behavior.
|
||||
* Abstracts the restore/try/finally boilerplate shared across multiple tests.
|
||||
*/
|
||||
function withCanvasStub(
|
||||
behavior: CanvasAlphaBehavior,
|
||||
fn: (
|
||||
makeImgStack: (
|
||||
imgId: string,
|
||||
behind?: Array<FakeEl | FakeHTMLImageElement>,
|
||||
) => HTMLIFrameElement,
|
||||
) => void,
|
||||
): void {
|
||||
const restore = stubOffscreenCanvas(behavior);
|
||||
try {
|
||||
fn((imgId, behind = []) => {
|
||||
const img = new FakeHTMLImageElement(imgId);
|
||||
return buildFakeIframeWithStack([img, ...behind]);
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("WS-G: z-stack fallthrough via mock elementsFromPoint", () => {
|
||||
// Clear the module-level canvas cache before each test so stubs from a
|
||||
// previous test don't affect the next one.
|
||||
beforeEach(() => {
|
||||
_imgCanvasCache.clear();
|
||||
});
|
||||
|
||||
it("non-image hit resolves normally (WS-A1 regression)", () => {
|
||||
// No images in the stack — should behave exactly like WS-A1.
|
||||
const div = fakeEl({ "data-hf-id": "hf-div" }, "DIV");
|
||||
const iframe = buildFakeIframeWithStack([div]);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
const result = adapter.elementAtPoint(50, 50);
|
||||
expect(result).toEqual({ id: "hf-div", tag: "div" });
|
||||
});
|
||||
|
||||
it("opaque image hit resolves to the image element", () => {
|
||||
withCanvasStub("opaque", (makeImgStack) => {
|
||||
const iframe = makeImgStack("hf-img", [fakeEl({ "data-hf-id": "hf-behind" }, "DIV")]);
|
||||
const result = createIframePreviewAdapter(iframe).elementAtPoint(50, 50);
|
||||
expect(result).toEqual({ id: "hf-img", tag: "img" });
|
||||
});
|
||||
});
|
||||
|
||||
it("transparent image pixel falls through to the element behind", () => {
|
||||
withCanvasStub("transparent", (makeImgStack) => {
|
||||
const iframe = makeImgStack("hf-img", [fakeEl({ "data-hf-id": "hf-behind" }, "DIV")]);
|
||||
const result = createIframePreviewAdapter(iframe).elementAtPoint(50, 50);
|
||||
expect(result).toEqual({ id: "hf-behind", tag: "div" });
|
||||
});
|
||||
});
|
||||
|
||||
it("tainted canvas (SecurityError) falls back to treating pixel as opaque", () => {
|
||||
// Taint fallback → opaque → hit the image, not the behind-layer element
|
||||
withCanvasStub("tainted", (makeImgStack) => {
|
||||
const iframe = makeImgStack("hf-tainted", [fakeEl({ "data-hf-id": "hf-behind" }, "DIV")]);
|
||||
const result = createIframePreviewAdapter(iframe).elementAtPoint(50, 50);
|
||||
expect(result).toEqual({ id: "hf-tainted", tag: "img" });
|
||||
});
|
||||
});
|
||||
|
||||
it("transparent image over transparent image falls through to the div behind both", () => {
|
||||
// Two consecutive transparent fallthroughs — exercises the loop iterating
|
||||
// past more than one transparent image before hitting an opaque layer.
|
||||
withCanvasStub("transparent", (makeImgStack) => {
|
||||
const img2 = new FakeHTMLImageElement("hf-img2");
|
||||
const iframe = makeImgStack("hf-img1", [img2, fakeEl({ "data-hf-id": "hf-behind" }, "DIV")]);
|
||||
const result = createIframePreviewAdapter(iframe).elementAtPoint(50, 50);
|
||||
expect(result).toEqual({ id: "hf-behind", tag: "div" });
|
||||
});
|
||||
});
|
||||
|
||||
it("transparent image with no element behind returns null", () => {
|
||||
// No behind element in stack — transparent hit returns null.
|
||||
withCanvasStub("transparent", (makeImgStack) => {
|
||||
const iframe = makeImgStack("hf-img");
|
||||
const result = createIframePreviewAdapter(iframe).elementAtPoint(50, 50);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("survives a window without HTMLImageElement and a doc without elementsFromPoint", () => {
|
||||
const div = fakeEl({ "data-hf-id": "hf-div" }, "DIV");
|
||||
const fakeIframe = (doc: unknown, win: unknown) =>
|
||||
({ contentDocument: doc, contentWindow: win }) as unknown as HTMLIFrameElement;
|
||||
const expected = { id: "hf-div", tag: "div" };
|
||||
|
||||
// No HTMLImageElement on the window — `instanceof` must not throw.
|
||||
const noCtor = fakeIframe(
|
||||
{ elementsFromPoint: () => [div] },
|
||||
{ getComputedStyle: () => ({ opacity: "1" }) },
|
||||
);
|
||||
expect(createIframePreviewAdapter(noCtor).elementAtPoint(50, 50)).toEqual(expected);
|
||||
|
||||
// No elementsFromPoint on the doc — fall back to elementFromPoint.
|
||||
const noStack = fakeIframe(
|
||||
{ elementFromPoint: () => div },
|
||||
{ HTMLImageElement: FakeHTMLImageElement, getComputedStyle: () => ({ opacity: "1" }) },
|
||||
);
|
||||
expect(createIframePreviewAdapter(noStack).elementAtPoint(50, 50)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,794 @@
|
||||
/**
|
||||
* Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection) +
|
||||
* WS-A2 (applyDraft / commitPreview / cancelPreview → moveElement) +
|
||||
* WS-G (image-alpha hit-test, phase 1).
|
||||
*
|
||||
* Requirements:
|
||||
* - The iframe MUST be same-origin (srcdoc / blob URL). Cross-origin access to
|
||||
* contentDocument throws a DOMException; this adapter does not guard that —
|
||||
* the caller is responsible for ensuring same-origin.
|
||||
*
|
||||
* Image-alpha (phase 1):
|
||||
* - Replaces elementFromPoint with elementsFromPoint (z-stack) so transparent
|
||||
* image hits fall through to the element behind.
|
||||
* - For <img> hits, maps the client point to the natural-pixel coordinate
|
||||
* (object-fit/object-position aware), draws to an offscreen canvas (cached
|
||||
* by src + natural dimensions, so a srcset re-render gets a fresh canvas),
|
||||
* samples alpha. Transparent pixel → miss, continue the stack.
|
||||
* - Cross-origin images taint the canvas → getImageData throws SecurityError
|
||||
* → falls back to treating the pixel as OPAQUE (never drop an unverifiable
|
||||
* hit) and warns once per src. Callers must ensure CORS or accept the fallback.
|
||||
* - A CSS rotation/skew on the image or an ancestor also falls back to opaque
|
||||
* (the axis-aligned rect mapping can't sample a rotated image correctly);
|
||||
* transform-inverse mapping is phase 2.
|
||||
* - Images above a pixel budget skip alpha-testing (opaque) to bound canvas
|
||||
* memory. Limitation: animated <img> (gif) or src swaps invalidate the cache
|
||||
* only when currentSrc/dimensions change. Phase 1 is optimized for static images.
|
||||
* - Phase 2 (full per-pixel alpha via drawElement rasterization) is NOT built
|
||||
* here — gated on a perf spike.
|
||||
*/
|
||||
|
||||
import {
|
||||
EDIT_BASE_X_ATTR,
|
||||
EDIT_BASE_Y_ATTR,
|
||||
EDIT_ORIGINAL_TRANSLATE_ATTR,
|
||||
applyPositionEditToElement,
|
||||
composeTranslate,
|
||||
readCurrentTranslate,
|
||||
} from "@hyperframes/core/runtime/position-edits";
|
||||
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
|
||||
import type { EditOp, Composition } from "../types.js";
|
||||
import { applyPatchesToDocument, applyOverrideSet } from "../engine/apply-patches.js";
|
||||
|
||||
// ─── Pure resolver (testable without a browser) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk from `el` upward through parentElement, looking for the nearest node
|
||||
* that carries `[data-hf-id]` and is NOT `[data-hf-root]`.
|
||||
*
|
||||
* Returns null when:
|
||||
* - The walk exits the tree without finding `[data-hf-id]`
|
||||
* - The matching node is `[data-hf-root]` (transparent to hit-testing)
|
||||
* - `isVisible(node)` returns false for the matching node
|
||||
*
|
||||
* Keeping this a pure function (no elementFromPoint, no window access) makes
|
||||
* it unit-testable in a plain Node environment.
|
||||
*/
|
||||
export function resolveNearestHfElement(
|
||||
el: Element | null,
|
||||
isVisible: (el: Element) => boolean,
|
||||
): ElementAtPointResult | null {
|
||||
let node = el;
|
||||
while (node !== null) {
|
||||
const id = node.getAttribute("data-hf-id");
|
||||
if (id !== null) {
|
||||
if (node.hasAttribute("data-hf-root")) return null;
|
||||
if (!isVisible(node)) return null;
|
||||
return { id, tag: node.tagName.toLowerCase() };
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Draft position math (pure — testable without a browser) ─────────────────
|
||||
|
||||
/**
|
||||
* Compute the new absolute x/y for a moveElement op given:
|
||||
* - the element's current `data-x` / `data-y` string values (may be null)
|
||||
* - the accumulated drag delta (dx, dy) from applyDraft calls
|
||||
*
|
||||
* `data-x` / `data-y` default to 0 when absent or non-numeric.
|
||||
*/
|
||||
export function computeDraftPosition(
|
||||
dataX: string | null,
|
||||
dataY: string | null,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): { x: number; y: number } {
|
||||
const baseX = parseFloat(dataX ?? "0") || 0;
|
||||
const baseY = parseFloat(dataY ?? "0") || 0;
|
||||
return { x: baseX + dx, y: baseY + dy };
|
||||
}
|
||||
|
||||
// ─── Image-alpha pure helpers (WS-G phase 1) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true when the first pixel in `imageData` has alpha >= threshold.
|
||||
*
|
||||
* Pure — no DOM access; unit-testable with a plain Uint8ClampedArray.
|
||||
* threshold defaults to 1 so a fully-transparent pixel (a=0) is a miss.
|
||||
*/
|
||||
export function alphaIsOpaque(imageData: ImageData, threshold = 1): boolean {
|
||||
// ImageData.data is [R, G, B, A, R, G, B, A, ...]
|
||||
const alpha = imageData.data[3] ?? 0;
|
||||
return alpha >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a client-space point to the natural-pixel coordinates of the image.
|
||||
*
|
||||
* Handles object-fit: fill | cover | contain (default=fill when unset).
|
||||
* object-position is parsed as two percentage/px values (default "50% 50%").
|
||||
*
|
||||
* Returns null when the point falls outside the rendered image area (e.g.
|
||||
* the letterbox region of a contain-fitted image). A null result means the
|
||||
* image does not own this pixel — the caller should continue the z-stack.
|
||||
*
|
||||
* Pure — no DOM/window access; unit-testable with plain objects.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function mapPointToImagePixel(
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
natural: { width: number; height: number },
|
||||
objectFit: string,
|
||||
objectPosition: string,
|
||||
point: { x: number; y: number },
|
||||
): { px: number; py: number } | null {
|
||||
// Local coords within the CSS box
|
||||
const lx = point.x - rect.left;
|
||||
const ly = point.y - rect.top;
|
||||
|
||||
if (lx < 0 || ly < 0 || lx > rect.width || ly > rect.height) return null;
|
||||
|
||||
const fit = objectFit || "fill";
|
||||
|
||||
// For fill (or unrecognized values): the natural image is stretched to the
|
||||
// box; direct linear mapping.
|
||||
if (fit !== "cover" && fit !== "contain" && fit !== "none") {
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
const px = Math.floor((lx / rect.width) * natural.width);
|
||||
const py = Math.floor((ly / rect.height) * natural.height);
|
||||
return { px: clamp(px, 0, natural.width - 1), py: clamp(py, 0, natural.height - 1) };
|
||||
}
|
||||
|
||||
// For none: image is drawn at its natural size; no scaling.
|
||||
if (fit === "none") {
|
||||
const pos = parseObjectPosition(objectPosition, rect, natural);
|
||||
const ox = pos.x;
|
||||
const oy = pos.y;
|
||||
const px = Math.floor(lx - ox);
|
||||
const py = Math.floor(ly - oy);
|
||||
if (px < 0 || py < 0 || px >= natural.width || py >= natural.height) return null;
|
||||
return { px, py };
|
||||
}
|
||||
|
||||
// cover: scale uniformly so the image covers the box; may clip edges.
|
||||
// contain: scale uniformly so the image fits within the box; may letterbox.
|
||||
if (natural.width === 0 || natural.height === 0) return null;
|
||||
const scaleX = rect.width / natural.width;
|
||||
const scaleY = rect.height / natural.height;
|
||||
const scale = fit === "cover" ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY);
|
||||
|
||||
const renderedW = natural.width * scale;
|
||||
const renderedH = natural.height * scale;
|
||||
|
||||
const pos = parseObjectPosition(objectPosition, rect, {
|
||||
width: renderedW,
|
||||
height: renderedH,
|
||||
});
|
||||
|
||||
// Offset of the rendered image's top-left within the CSS box
|
||||
const imgLeft = pos.x;
|
||||
const imgTop = pos.y;
|
||||
|
||||
// Local coords relative to the rendered image's top-left
|
||||
const rx = lx - imgLeft;
|
||||
const ry = ly - imgTop;
|
||||
|
||||
if (rx < 0 || ry < 0 || rx > renderedW || ry > renderedH) return null;
|
||||
|
||||
if (scale === 0) return null;
|
||||
|
||||
const px = Math.floor(rx / scale);
|
||||
const py = Math.floor(ry / scale);
|
||||
return { px: clamp(px, 0, natural.width - 1), py: clamp(py, 0, natural.height - 1) };
|
||||
}
|
||||
|
||||
// ─── object-position parser (pure) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a CSS object-position value into x/y offsets (top-left of the
|
||||
* rendered content relative to the CSS box top-left).
|
||||
*
|
||||
* Supports the common subset: keyword pairs, percentage pairs, pixel pairs,
|
||||
* and single-value shorthand. Mixed units (e.g. "50% 10px") are supported.
|
||||
*
|
||||
* Pure — no DOM access.
|
||||
*/
|
||||
function parseObjectPosition(
|
||||
objectPosition: string,
|
||||
box: { width: number; height: number },
|
||||
content: { width: number; height: number },
|
||||
): { x: number; y: number } {
|
||||
const raw = (objectPosition || "50% 50%").trim();
|
||||
const parts = raw.split(/\s+/);
|
||||
|
||||
// Resolve a single token into a pixel offset along the given axis.
|
||||
// `available` is the "slack" (box dimension - content dimension).
|
||||
// fallow-ignore-next-line complexity
|
||||
function resolveToken(token: string, available: number): number {
|
||||
if (token === "left" || token === "top") return 0;
|
||||
if (token === "right" || token === "bottom") return available;
|
||||
if (token === "center") return available / 2;
|
||||
if (token.endsWith("%")) {
|
||||
const pct = parseFloat(token) / 100;
|
||||
return isNaN(pct) ? available / 2 : pct * available;
|
||||
}
|
||||
if (token.endsWith("px")) {
|
||||
const px = parseFloat(token);
|
||||
return isNaN(px) ? available / 2 : px;
|
||||
}
|
||||
// Bare number — treat as px
|
||||
const n = parseFloat(token);
|
||||
return isNaN(n) ? available / 2 : n;
|
||||
}
|
||||
|
||||
const availX = box.width - content.width;
|
||||
const availY = box.height - content.height;
|
||||
|
||||
const isVert = (t: string) => t === "top" || t === "bottom";
|
||||
const isHoriz = (t: string) => t === "left" || t === "right";
|
||||
|
||||
if (parts.length === 1) {
|
||||
const tokenX = parts[0] ?? "50%";
|
||||
// Single value: if it's a vertical keyword the x defaults to center
|
||||
if (isVert(tokenX)) {
|
||||
return { x: availX / 2, y: resolveToken(tokenX, availY) };
|
||||
}
|
||||
return { x: resolveToken(tokenX, availX), y: availY / 2 };
|
||||
}
|
||||
|
||||
// Keyword pairs may be given vertical-first ("bottom left"); normalize so the
|
||||
// first token addresses the x-axis and the second the y-axis.
|
||||
let xToken = parts[0] ?? "50%";
|
||||
let yToken = parts[1] ?? "50%";
|
||||
if (isVert(xToken) || isHoriz(yToken)) {
|
||||
[xToken, yToken] = [yToken, xToken];
|
||||
}
|
||||
return {
|
||||
x: resolveToken(xToken, availX),
|
||||
y: resolveToken(yToken, availY),
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return v < min ? min : v > max ? max : v;
|
||||
}
|
||||
|
||||
// ─── Visibility check ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true when no element in the ancestor chain (inclusive) has
|
||||
* computed opacity === 0. Checks ancestors because a parent at opacity:0
|
||||
* makes the child invisible even if the child's own opacity is 1.
|
||||
*
|
||||
* This reflects the current GSAP timeline state (whatever the player has
|
||||
* seeked to). For atTime values matching the live playhead this is always
|
||||
* accurate. For speculative times this is NOT seeked — WS-A1 does not mutate
|
||||
* the timeline; accurate out-of-band opacity queries are WS-G follow-on.
|
||||
*/
|
||||
function isOpacityVisible(el: Element, win: Window & typeof globalThis): boolean {
|
||||
let node: Element | null = el;
|
||||
while (node !== null) {
|
||||
const style = win.getComputedStyle(node);
|
||||
if (parseFloat(style.opacity) === 0) return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Image-alpha canvas cache (WS-G phase 1) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cache of offscreen canvases keyed by image currentSrc.
|
||||
*
|
||||
* Canvases are drawn once; the same canvas is reused across hit-tests.
|
||||
* Animated images (gif) or dynamic src swaps are NOT tracked — this is a
|
||||
* phase-1 static-image optimization. A tainted entry stores null to record
|
||||
* that the image is cross-origin and all pixels should be treated as opaque.
|
||||
*
|
||||
* Exported for tests that need to reset the cache between runs.
|
||||
*/
|
||||
export const _imgCanvasCache = new Map<string, OffscreenCanvas | null>();
|
||||
|
||||
/**
|
||||
* Bounded cap so a long session can't accumulate one full-resolution
|
||||
* OffscreenCanvas per image src indefinitely.
|
||||
* ponytail: FIFO eviction, upgrade to LRU if cache hit-rate matters.
|
||||
*/
|
||||
const _IMG_CANVAS_CACHE_MAX = 64;
|
||||
|
||||
function cacheCanvas(key: string, value: OffscreenCanvas | null): void {
|
||||
if (!_imgCanvasCache.has(key) && _imgCanvasCache.size >= _IMG_CANVAS_CACHE_MAX) {
|
||||
const oldest = _imgCanvasCache.keys().next().value;
|
||||
if (oldest !== undefined) _imgCanvasCache.delete(oldest);
|
||||
}
|
||||
_imgCanvasCache.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip alpha-testing images above this pixel budget — allocating a full-res
|
||||
* OffscreenCanvas for one hit-test is memory-prohibitive (a 4000×3000 image is
|
||||
* ~46 MB). Above the cap we fail safe to opaque.
|
||||
* ponytail: per-image pixel cap; a byte-budget cache is phase 2.
|
||||
*/
|
||||
const _MAX_ALPHA_TEST_PIXELS = 16_000_000;
|
||||
|
||||
/**
|
||||
* Srcs we've already warned about taint for — keeps the cross-origin warning to
|
||||
* once per image instead of once per hit-test. Cleared with `_imgCanvasCache`
|
||||
* is not necessary; suppressing duplicate warnings across resets is harmless.
|
||||
*/
|
||||
const _warnedTaintSrcs = new Set<string>();
|
||||
|
||||
function warnTaintOnce(src: string): void {
|
||||
if (_warnedTaintSrcs.has(src)) return;
|
||||
_warnedTaintSrcs.add(src);
|
||||
// Visibility for the silent-failure path: a cross-origin / uncorsed image
|
||||
// taints the canvas, so alpha hit-test is unavailable and we fall back to
|
||||
// opaque. Without this, the fall-back is invisible ("hit-test feels wrong").
|
||||
console.warn(
|
||||
`[hyperframes] image-alpha hit-test unavailable for cross-origin/tainted image; treating as opaque: ${src}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the element or any ancestor carries a CSS rotation or skew. Such a
|
||||
* transform makes getBoundingClientRect() return the axis-aligned bounding box,
|
||||
* so the rect→natural-pixel mapping would sample the wrong pixel. Pure translate
|
||||
* / scale keep the matrix b and c components at 0 and map correctly. No-op
|
||||
* (returns false) when DOMMatrix is unavailable (e.g. the test env), preserving
|
||||
* existing behavior there.
|
||||
*/
|
||||
function hasRotationOrSkew(el: Element | null, win: Window & typeof globalThis): boolean {
|
||||
if (typeof win.DOMMatrix !== "function") return false;
|
||||
for (let node: Element | null = el; node; node = node.parentElement) {
|
||||
const t = win.getComputedStyle(node).transform;
|
||||
if (!t || t === "none") continue;
|
||||
try {
|
||||
const m = new win.DOMMatrix(t);
|
||||
if (Math.abs(m.b) > 1e-6 || Math.abs(m.c) > 1e-6) return true;
|
||||
} catch {
|
||||
return true; // unparseable transform — fail safe (treat as non-axis-aligned)
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the alpha at (clientX, clientY) for an <img> element.
|
||||
*
|
||||
* Returns true (opaque) when:
|
||||
* - The image has not finished loading (naturalWidth/naturalHeight === 0)
|
||||
* - The point maps outside the rendered image area (not this image's pixel)
|
||||
* - The canvas is tainted (cross-origin, SecurityError) — fallback: opaque
|
||||
* - Alpha >= 1
|
||||
*
|
||||
* Returns false (transparent/miss) only when the canvas is readable AND the
|
||||
* alpha at the mapped pixel is 0. A click on the element's border/padding maps
|
||||
* outside the content box → also false (the click falls through to the layer
|
||||
* behind), since border/padding pixels aren't part of the image — intentional.
|
||||
*
|
||||
* `win` is the iframe's contentWindow, used to call getComputedStyle on the
|
||||
* element which lives in the iframe's document.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function imageAlphaOpaqueAt(
|
||||
img: HTMLImageElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
win: Window & typeof globalThis,
|
||||
): boolean {
|
||||
// Not loaded yet — treat as opaque (safe fallback)
|
||||
if (img.naturalWidth === 0 || img.naturalHeight === 0) return true;
|
||||
|
||||
const src = img.currentSrc || img.src;
|
||||
if (!src) return true;
|
||||
|
||||
// CSS rotation/skew on the image or an ancestor breaks the axis-aligned
|
||||
// rect→natural-pixel mapping below (getBoundingClientRect returns the AABB),
|
||||
// so we'd sample the wrong pixel. Fail safe to opaque rather than guess.
|
||||
// Full transform-inverse mapping is phase 2.
|
||||
if (hasRotationOrSkew(img, win)) return true;
|
||||
|
||||
// Pathological-size guard: don't allocate a huge canvas for one hit-test.
|
||||
if (img.naturalWidth * img.naturalHeight > _MAX_ALPHA_TEST_PIXELS) return true;
|
||||
|
||||
// object-fit/object-position lay the image out within the CONTENT box, not
|
||||
// the border box that getBoundingClientRect() returns. Inset by border +
|
||||
// padding so the mapping is correct for an <img> that has a border or padding.
|
||||
const rect = img.getBoundingClientRect();
|
||||
const style = win.getComputedStyle(img);
|
||||
const borderL = parseFloat(style.borderLeftWidth) || 0;
|
||||
const borderT = parseFloat(style.borderTopWidth) || 0;
|
||||
const borderR = parseFloat(style.borderRightWidth) || 0;
|
||||
const borderB = parseFloat(style.borderBottomWidth) || 0;
|
||||
const padL = parseFloat(style.paddingLeft) || 0;
|
||||
const padT = parseFloat(style.paddingTop) || 0;
|
||||
const padR = parseFloat(style.paddingRight) || 0;
|
||||
const padB = parseFloat(style.paddingBottom) || 0;
|
||||
const objectFit = style.objectFit || "fill";
|
||||
const objectPosition = style.objectPosition || "50% 50%";
|
||||
|
||||
const mapped = mapPointToImagePixel(
|
||||
{
|
||||
left: rect.left + borderL + padL,
|
||||
top: rect.top + borderT + padT,
|
||||
width: rect.width - borderL - borderR - padL - padR,
|
||||
height: rect.height - borderT - borderB - padT - padB,
|
||||
},
|
||||
{ width: img.naturalWidth, height: img.naturalHeight },
|
||||
objectFit,
|
||||
objectPosition,
|
||||
{ x: clientX, y: clientY },
|
||||
);
|
||||
|
||||
// Point is outside the rendered image area — not this image's pixel.
|
||||
// Continue the z-stack (return false = miss on this element).
|
||||
if (mapped === null) return false;
|
||||
|
||||
// Retrieve or build the offscreen canvas. Key on src + natural dimensions: a
|
||||
// srcset/responsive layout can serve the same URL at a different natural size,
|
||||
// and keying on src alone would reuse a canvas drawn at the prior dimensions.
|
||||
const cacheKey = `${src}@${img.naturalWidth}x${img.naturalHeight}`;
|
||||
let canvas: OffscreenCanvas | null | undefined = _imgCanvasCache.get(cacheKey);
|
||||
if (canvas === undefined) {
|
||||
// First time: draw to an offscreen canvas and cache.
|
||||
try {
|
||||
const oc = new OffscreenCanvas(img.naturalWidth, img.naturalHeight);
|
||||
const ctx = oc.getContext("2d");
|
||||
if (!ctx) {
|
||||
// OffscreenCanvas 2D unavailable — treat as opaque.
|
||||
cacheCanvas(cacheKey, null);
|
||||
return true;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight);
|
||||
cacheCanvas(cacheKey, oc);
|
||||
canvas = oc;
|
||||
} catch {
|
||||
// SecurityError from tainted canvas — record null and fall back opaque.
|
||||
warnTaintOnce(src);
|
||||
cacheCanvas(cacheKey, null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// null means we already know this src is tainted — treat as opaque.
|
||||
if (canvas === null) return true;
|
||||
|
||||
try {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return true;
|
||||
// The mapped-pixel read also surfaces lazy canvas taint (SecurityError),
|
||||
// so no separate taint probe is needed.
|
||||
const data = ctx.getImageData(mapped.px, mapped.py, 1, 1);
|
||||
return alphaIsOpaque(data);
|
||||
} catch {
|
||||
// Taint discovered on getImageData — update cache and fall back opaque.
|
||||
warnTaintOnce(src);
|
||||
cacheCanvas(cacheKey, null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IframePreviewAdapter ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The hit-test z-stack at (x, y): the full elementsFromPoint stack, or a
|
||||
* single-element fallback for hosts that lack elementsFromPoint.
|
||||
*/
|
||||
function hitStack(doc: Document, x: number, y: number): Element[] {
|
||||
if (typeof doc.elementsFromPoint === "function") return doc.elementsFromPoint(x, y);
|
||||
const top = doc.elementFromPoint(x, y);
|
||||
return top ? [top] : [];
|
||||
}
|
||||
|
||||
type SelectionHandler = (ids: string[]) => void;
|
||||
|
||||
class IframePreviewAdapter implements PreviewAdapter {
|
||||
private readonly iframe: HTMLIFrameElement;
|
||||
private readonly _dispatch: ((op: EditOp) => void) | undefined;
|
||||
|
||||
private _selection: string[] = [];
|
||||
private _handlers: SelectionHandler[] = [];
|
||||
|
||||
/** Tracked id and element for the in-progress drag. */
|
||||
private _draftId: string | null = null;
|
||||
private _draftEl: HTMLElement | null = null;
|
||||
/** Accumulated drag deltas from applyDraft calls. */
|
||||
private _draftDx = 0;
|
||||
private _draftDy = 0;
|
||||
/**
|
||||
* The element's effective `translate` when the drag started (inline value,
|
||||
* or computed when no inline one was set; "" = none). Drafts compose onto
|
||||
* this.
|
||||
*/
|
||||
private _draftPrevTranslate: string | null = null;
|
||||
/**
|
||||
* The element's raw INLINE `translate` when the drag started ("" = not
|
||||
* inline). Reverts restore exactly this, so a stylesheet-authored translate
|
||||
* is never promoted to a permanent inline style.
|
||||
*/
|
||||
private _draftPrevInlineTranslate: string | null = null;
|
||||
|
||||
/** Unsubscribe for the current attachSync subscription, if any. */
|
||||
private _syncDetach: (() => void) | null = null;
|
||||
|
||||
constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) {
|
||||
this.iframe = iframe;
|
||||
this._dispatch = dispatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous hit-test. Returns the nearest `[data-hf-id]` element under
|
||||
* (x, y) in the iframe's coordinate space, or null for a transparent hit
|
||||
* (root, opacity-0, nothing at all, or a transparent image pixel).
|
||||
*
|
||||
* WS-G phase 1: uses elementsFromPoint (z-stack) so a transparent-image hit
|
||||
* falls through to the layer behind. For <img> elements, the alpha at the
|
||||
* mapped natural pixel is sampled from an offscreen canvas. Cross-origin
|
||||
* images that taint the canvas are treated as opaque (safe fallback).
|
||||
*
|
||||
* atTime: reflects the GSAP state at the playhead when this is called.
|
||||
* Seeking to a different time to check visibility is WS-G follow-on.
|
||||
*/
|
||||
elementAtPoint(x: number, y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
||||
const doc = this.iframe.contentDocument;
|
||||
if (!doc) return null;
|
||||
const win = this.iframe.contentWindow as (Window & typeof globalThis) | null;
|
||||
if (!win) return null;
|
||||
|
||||
const stack = hitStack(doc, x, y);
|
||||
|
||||
for (const candidate of stack) {
|
||||
// One opacity walk per candidate (candidate → root). An opacity:0 element
|
||||
// is skipped, so the click falls through to the layer painted behind it.
|
||||
if (!isOpacityVisible(candidate, win)) continue;
|
||||
|
||||
// Image-alpha check: if this is an <img>, verify the pixel is opaque.
|
||||
if (win.HTMLImageElement && candidate instanceof win.HTMLImageElement) {
|
||||
if (!imageAlphaOpaqueAt(candidate, x, y, win)) {
|
||||
// Transparent pixel — fall through to the next element in the stack.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// The candidate's whole ancestor chain is already known visible (the walk
|
||||
// above covers it, and the hf node is on that chain), so the resolver
|
||||
// needs no second visibility walk.
|
||||
const result = resolveNearestHfElement(candidate, () => true);
|
||||
if (result !== null) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visually translate the target element inside the iframe at 60fps without
|
||||
* touching the model: sets the element's `translate` to its pre-drag value
|
||||
* composed with the accumulated delta. `translate` set after GSAP's first
|
||||
* parse is untouched by seeks, so this renders correctly on animated
|
||||
* elements too. (The `--hf-studio-dx/dy` custom properties are no longer
|
||||
* written — compositions with the authored Studio drag-bridge CSS would
|
||||
* move by twice the delta if both channels applied.)
|
||||
*
|
||||
* Calling applyDraft with a new id switches the tracked element, reverting
|
||||
* the previous element's draft translate first.
|
||||
*
|
||||
* width/height in DraftProps are not yet wired (resize → setStyle, future op).
|
||||
*/
|
||||
applyDraft(id: string, props: DraftProps): void {
|
||||
const el = this._resolveDraftElement(id);
|
||||
if (!el) return;
|
||||
|
||||
if (props.dx !== undefined) this._draftDx = props.dx;
|
||||
if (props.dy !== undefined) this._draftDy = props.dy;
|
||||
|
||||
el.style.setProperty(
|
||||
"translate",
|
||||
composeTranslate(this._draftPrevTranslate ?? "", `${this._draftDx}px`, `${this._draftDy}px`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and track the drag target. Reuses the tracked element across the
|
||||
* 60fps drag; only re-queries when the id changes or the cached node
|
||||
* detached (e.g. an iframe reload mid-drag). Switching to a different
|
||||
* element reverts the previous one's draft first, then captures the new
|
||||
* element's pre-drag translate.
|
||||
*/
|
||||
private _resolveDraftElement(id: string): HTMLElement | null {
|
||||
const doc = this.iframe.contentDocument;
|
||||
if (!doc) return null;
|
||||
|
||||
const cached = id === this._draftId && this._draftEl?.isConnected ? this._draftEl : null;
|
||||
const el =
|
||||
cached ??
|
||||
doc.querySelector<HTMLElement>(
|
||||
`[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`,
|
||||
);
|
||||
if (!el) return null;
|
||||
|
||||
if (el !== this._draftEl) {
|
||||
// Abandoning a prior target mid-drag must not leave it displaced.
|
||||
this._revertDraftTranslate();
|
||||
this._draftDx = 0;
|
||||
this._draftDy = 0;
|
||||
this._draftPrevTranslate = readCurrentTranslate(el);
|
||||
const inline = el.style.getPropertyValue("translate").trim();
|
||||
this._draftPrevInlineTranslate = inline === "none" ? "" : inline;
|
||||
}
|
||||
this._draftId = id;
|
||||
this._draftEl = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the accumulated draft deltas, derive a moveElement op, dispatch it,
|
||||
* then clear the draft state.
|
||||
*
|
||||
* No-ops (reverting any draft translate) when:
|
||||
* - No applyDraft was called (nothing to commit)
|
||||
* - No dispatch callback was provided at construction
|
||||
*
|
||||
* If dispatch throws (e.g. the model no longer has the element), the draft
|
||||
* translate is reverted and the error propagates — the element is never
|
||||
* left displaced by an uncommitted draft.
|
||||
*/
|
||||
commitPreview(): void {
|
||||
if (!this._draftId || !this._draftEl || !this._dispatch) {
|
||||
this._revertDraftTranslate();
|
||||
this._clearDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this._draftEl;
|
||||
const dataX = el.getAttribute("data-x");
|
||||
const dataY = el.getAttribute("data-y");
|
||||
const { x, y } = computeDraftPosition(dataX, dataY, this._draftDx, this._draftDy);
|
||||
|
||||
try {
|
||||
this._dispatch({ type: "moveElement", target: this._draftId, x, y });
|
||||
} catch (err) {
|
||||
this._revertDraftTranslate();
|
||||
this._clearDraft();
|
||||
throw err;
|
||||
}
|
||||
this._mirrorCommittedMove(el, dataX, dataY, x, y);
|
||||
this._clearDraft();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a committed move onto the live element so the position holds
|
||||
* without a document reload — same attributes handleMoveElement writes
|
||||
* into the model, rendered by the runtime's position-edit translate.
|
||||
*
|
||||
* The pre-edit translate is stamped from the value captured at drag start
|
||||
* (the element's current inline translate is the draft-composed one, which
|
||||
* must not be mistaken for the original), then the final translate is
|
||||
* recomputed the same way the runtime does at bind time.
|
||||
*/
|
||||
private _mirrorCommittedMove(
|
||||
el: HTMLElement,
|
||||
dataX: string | null,
|
||||
dataY: string | null,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
if (el.getAttribute(EDIT_BASE_X_ATTR) === null) {
|
||||
el.setAttribute(EDIT_BASE_X_ATTR, dataX ?? "0");
|
||||
}
|
||||
if (el.getAttribute(EDIT_BASE_Y_ATTR) === null) {
|
||||
el.setAttribute(EDIT_BASE_Y_ATTR, dataY ?? "0");
|
||||
}
|
||||
if (el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) === null) {
|
||||
el.setAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR, this._draftPrevTranslate ?? "");
|
||||
}
|
||||
el.setAttribute("data-x", String(x));
|
||||
el.setAttribute("data-y", String(y));
|
||||
applyPositionEditToElement(el, { force: true });
|
||||
}
|
||||
|
||||
/** Revert the draft translate without dispatching any op. */
|
||||
cancelPreview(): void {
|
||||
this._revertDraftTranslate();
|
||||
this._clearDraft();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the element's pre-drag INLINE `translate` (removing it when there
|
||||
* was none, so a stylesheet-authored translate is never promoted to inline).
|
||||
* NOT called on a successful commit — the committed position-edit translate
|
||||
* is recomputed onto the element by _mirrorCommittedMove.
|
||||
*/
|
||||
private _revertDraftTranslate(): void {
|
||||
if (!this._draftEl || this._draftPrevInlineTranslate === null) return;
|
||||
if (this._draftPrevInlineTranslate === "") {
|
||||
this._draftEl.style.removeProperty("translate");
|
||||
} else {
|
||||
this._draftEl.style.setProperty("translate", this._draftPrevInlineTranslate);
|
||||
}
|
||||
}
|
||||
|
||||
private _clearDraft(): void {
|
||||
this._draftId = null;
|
||||
this._draftEl = null;
|
||||
this._draftDx = 0;
|
||||
this._draftDy = 0;
|
||||
this._draftPrevTranslate = null;
|
||||
this._draftPrevInlineTranslate = null;
|
||||
}
|
||||
|
||||
// Selection -----------------------------------------------------------------
|
||||
|
||||
select(ids: string[], opts?: { additive?: boolean }): void {
|
||||
if (opts?.additive) {
|
||||
const merged = new Set([...this._selection, ...ids]);
|
||||
this._selection = [...merged];
|
||||
} else {
|
||||
this._selection = [...ids];
|
||||
}
|
||||
this._emit();
|
||||
}
|
||||
|
||||
on(event: "selection", handler: SelectionHandler): () => void {
|
||||
if (event !== "selection") return () => {};
|
||||
this._handlers.push(handler);
|
||||
return () => {
|
||||
this._handlers = this._handlers.filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
private _emit(): void {
|
||||
const ids = [...this._selection];
|
||||
for (const h of this._handlers) h(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror `comp`'s edits onto this.iframe.contentDocument. See the
|
||||
* PreviewAdapter interface doc for the full contract.
|
||||
*/
|
||||
attachSync(comp: Composition): () => void {
|
||||
this._syncDetach?.();
|
||||
|
||||
const doc = this.iframe.contentDocument;
|
||||
if (doc) {
|
||||
try {
|
||||
applyOverrideSet({ document: doc, wrapped: false, stamped: "" }, comp.getOverrides());
|
||||
} catch (err) {
|
||||
// Don't let a bad initial snapshot prevent the ongoing subscription
|
||||
// below from attaching — future patches should still mirror even if
|
||||
// this composition's current overrides couldn't be applied.
|
||||
console.warn("[hyperframes] attachSync: initial override sync failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const rawUnsubscribe = comp.on("patch", ({ patches }) => {
|
||||
const liveDoc = this.iframe.contentDocument;
|
||||
if (!liveDoc) return;
|
||||
applyPatchesToDocument(
|
||||
{ document: liveDoc, wrapped: false, stamped: "" },
|
||||
// "Never mirror script-tag rewrites" is the documented contract, not
|
||||
// just today's one known path — startsWith so a future script kind
|
||||
// (e.g. "/script/label") is covered by the same intent, not just an
|
||||
// exact string this filter happens to know about today.
|
||||
patches.filter((p) => !p.path.startsWith("/script/")),
|
||||
);
|
||||
});
|
||||
|
||||
const detach = (): void => {
|
||||
rawUnsubscribe();
|
||||
if (this._syncDetach === detach) this._syncDetach = null;
|
||||
};
|
||||
this._syncDetach = detach;
|
||||
return detach;
|
||||
}
|
||||
}
|
||||
|
||||
export function createIframePreviewAdapter(
|
||||
iframe: HTMLIFrameElement,
|
||||
dispatch?: (op: EditOp) => void,
|
||||
): PreviewAdapter {
|
||||
return new IframePreviewAdapter(iframe, dispatch);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
|
||||
class MemoryAdapter implements PersistAdapter {
|
||||
private readonly store = new Map<string, string>();
|
||||
private readonly history = new Map<string, PersistVersionEntry[]>();
|
||||
private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = [];
|
||||
private versionCounter = 0;
|
||||
private faultMessage: string | null = null;
|
||||
|
||||
async read(path: string): Promise<string | undefined> {
|
||||
return this.store.get(path);
|
||||
}
|
||||
|
||||
async write(path: string, content: string): Promise<void> {
|
||||
if (this.faultMessage !== null) {
|
||||
const msg = this.faultMessage;
|
||||
this.faultMessage = null;
|
||||
this.errorListeners.forEach((l) => l({ error: { message: msg } }));
|
||||
return;
|
||||
}
|
||||
this.store.set(path, content);
|
||||
const hist = this.history.get(path) ?? [];
|
||||
const entry: PersistVersionEntry = {
|
||||
key: `v${++this.versionCounter}`,
|
||||
content,
|
||||
};
|
||||
hist.unshift(entry);
|
||||
this.history.set(path, hist);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
// Memory adapter writes are synchronous — nothing to drain.
|
||||
}
|
||||
|
||||
async listVersions(path: string): Promise<PersistVersionEntry[]> {
|
||||
return [...(this.history.get(path) ?? [])];
|
||||
}
|
||||
|
||||
async loadFrom(path: string, versionKey: string): Promise<string | undefined> {
|
||||
const hist = this.history.get(path) ?? [];
|
||||
return hist.find((v) => v.key === versionKey)?.content;
|
||||
}
|
||||
|
||||
on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void {
|
||||
if (event !== "persist:error") return () => {};
|
||||
this.errorListeners.push(handler);
|
||||
return () => {
|
||||
const idx = this.errorListeners.indexOf(handler);
|
||||
if (idx !== -1) this.errorListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test helper — next write fires persist:error instead of committing */
|
||||
injectFault(message: string): void {
|
||||
this.faultMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryAdapter(): PersistAdapter & { injectFault(message: string): void } {
|
||||
return new MemoryAdapter();
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* T13 — PersistAdapter contract suite
|
||||
*
|
||||
* Parameterized over adapter implementations. Every adapter (memory, fs, S3, HTTP)
|
||||
* runs the same suite automatically — write once, protect all.
|
||||
*
|
||||
* Run against the memory adapter immediately; future implementations:
|
||||
* runPersistAdapterContract("fs", () => createFsAdapter({ root: tmpDir }))
|
||||
* runPersistAdapterContract("s3", () => createS3Adapter({ bucket, prefix }))
|
||||
*/
|
||||
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createMemoryAdapter } from "./memory.js";
|
||||
import { createFsAdapter } from "./fs.js";
|
||||
import type { PersistAdapter } from "./types.js";
|
||||
|
||||
export function runPersistAdapterContract(
|
||||
label: string,
|
||||
createAdapter: () => PersistAdapter,
|
||||
): void {
|
||||
describe(`PersistAdapter contract — ${label}`, () => {
|
||||
it("read returns undefined for a path never written", async () => {
|
||||
const adapter = createAdapter();
|
||||
expect(await adapter.read("missing.html")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("write then read returns the written content", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "<html></html>");
|
||||
expect(await adapter.read("comp.html")).toBe("<html></html>");
|
||||
});
|
||||
|
||||
it("second write overwrites the first", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "v1");
|
||||
await adapter.write("comp.html", "v2");
|
||||
expect(await adapter.read("comp.html")).toBe("v2");
|
||||
});
|
||||
|
||||
it("flush() returns after any pending writes are committed", async () => {
|
||||
const adapter = createAdapter();
|
||||
// Write without awaiting to exercise the queue path
|
||||
void adapter.write("comp.html", "queued");
|
||||
await adapter.flush();
|
||||
expect(await adapter.read("comp.html")).toBe("queued");
|
||||
});
|
||||
|
||||
it("listVersions returns entries in reverse-chronological order", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "v1");
|
||||
await adapter.write("comp.html", "v2");
|
||||
await adapter.write("comp.html", "v3");
|
||||
const versions = await adapter.listVersions("comp.html");
|
||||
expect(versions.length).toBeGreaterThanOrEqual(3);
|
||||
// Newest first
|
||||
expect(versions[0]?.content).toBe("v3");
|
||||
expect(versions[versions.length - 1]?.content).toBe("v1");
|
||||
});
|
||||
|
||||
it("loadFrom restores the model to that version's content", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "v1");
|
||||
const versions = await adapter.listVersions("comp.html");
|
||||
const firstKey = versions[versions.length - 1]?.key;
|
||||
expect(firstKey).toBeDefined();
|
||||
await adapter.write("comp.html", "v2");
|
||||
const restored = await adapter.loadFrom("comp.html", firstKey!);
|
||||
expect(restored).toBe("v1");
|
||||
});
|
||||
|
||||
it("listVersions returns empty array for a path never written", async () => {
|
||||
const adapter = createAdapter();
|
||||
expect(await adapter.listVersions("missing.html")).toEqual([]);
|
||||
});
|
||||
|
||||
it("loadFrom returns undefined for an unknown version key", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "content");
|
||||
expect(await adapter.loadFrom("comp.html", "nonexistent-key")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("on('persist:error') fires when a write fails; error is not thrown", async () => {
|
||||
// This test uses the injectFault() test helper if available.
|
||||
// For adapters without fault injection, skip with a note.
|
||||
const adapter = createAdapter();
|
||||
const hasInjectFault =
|
||||
"injectFault" in adapter &&
|
||||
typeof (adapter as { injectFault: unknown }).injectFault === "function";
|
||||
|
||||
if (!hasInjectFault) {
|
||||
// Adapter does not expose fault injection — skip execution
|
||||
// (test still runs to document the contract; real adapters must implement this)
|
||||
return;
|
||||
}
|
||||
|
||||
const onError = vi.fn();
|
||||
adapter.on("persist:error", onError);
|
||||
(adapter as { injectFault(m: string): void }).injectFault("network error");
|
||||
|
||||
await adapter.write("comp.html", "content");
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ message: "network error" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("unsubscribe returned by on() removes the listener", async () => {
|
||||
const adapter = createAdapter();
|
||||
const onError = vi.fn();
|
||||
const unsub = adapter.on("persist:error", onError);
|
||||
unsub();
|
||||
|
||||
// Fire an error if possible
|
||||
if (
|
||||
"injectFault" in adapter &&
|
||||
typeof (adapter as { injectFault: unknown }).injectFault === "function"
|
||||
) {
|
||||
(adapter as { injectFault(m: string): void }).injectFault("err");
|
||||
await adapter.write("comp.html", "x");
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run the suite against the memory adapter immediately
|
||||
runPersistAdapterContract("memory", createMemoryAdapter);
|
||||
|
||||
// Run against the fs adapter — each test gets an isolated tmpdir
|
||||
runPersistAdapterContract("fs", () =>
|
||||
createFsAdapter({ root: mkdtempSync(join(tmpdir(), "hf-fs-test-")) }),
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { PersistErrorEvent, Composition } from "../types.js";
|
||||
|
||||
// ─── PersistAdapter ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface PersistVersionEntry {
|
||||
/** Opaque key identifying this version (adapter-defined format) */
|
||||
key: string;
|
||||
/** Full HTML content — may be omitted by adapters that load content lazily via loadFrom() */
|
||||
content?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injectable storage adapter — decouples the SDK from the underlying persistence mechanism.
|
||||
* Implementations: memory (tests/demos), fs (local dev), S3 (cloud), HTTP (Pacific).
|
||||
*
|
||||
* Contract:
|
||||
* - read() returns undefined for a path never written
|
||||
* - write() is idempotent (second write overwrites)
|
||||
* - flush() resolves when any queued writes are committed
|
||||
* - listVersions() returns entries newest-first
|
||||
* - loadFrom() returns content for the given version key (undefined if not found)
|
||||
* - on('persist:error') fires when a write fails; the error must not propagate as a thrown exception
|
||||
*/
|
||||
export interface PersistAdapter {
|
||||
read(path: string): Promise<string | undefined>;
|
||||
write(path: string, content: string): Promise<void>;
|
||||
/** Force all pending writes to commit before returning */
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── PreviewAdapter ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ElementAtPointResult {
|
||||
id: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface DraftProps {
|
||||
dx?: number;
|
||||
dy?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injectable preview adapter — decouples the SDK from the host preview surface.
|
||||
* The null/headless adapter stubs all methods (no browser needed).
|
||||
*
|
||||
* The SDK is NOT in the 60fps draft loop — consumers call applyDraft() directly on
|
||||
* the preview at 60fps; commitPreview() fires once on pointer-up to derive and
|
||||
* dispatch the resulting op.
|
||||
*/
|
||||
export interface PreviewAdapter {
|
||||
/** Sync hit-test at composition coordinates. Requires same-origin iframe. */
|
||||
elementAtPoint(x: number, y: number, opts?: { atTime?: number }): ElementAtPointResult | null;
|
||||
|
||||
/** Apply draft CSS markers to the preview element (60fps, SDK not involved) */
|
||||
applyDraft(id: string, props: DraftProps): void;
|
||||
|
||||
/** Derive op from draft markers, dispatch it, emit patch event, clear markers */
|
||||
commitPreview(): void;
|
||||
|
||||
/** Revert draft markers without committing. Model never changed. */
|
||||
cancelPreview(): void;
|
||||
|
||||
/** Set preview selection; fires selectionchange on the session */
|
||||
select(ids: string[], opts?: { additive?: boolean }): void;
|
||||
|
||||
// Stage 8 prep: fired when the preview host changes selection (e.g. user clicks an element).
|
||||
// Not wired up in stage 7 — callers listen to the session's own selectionchange event instead.
|
||||
on(event: "selection", handler: (ids: string[]) => void): () => void;
|
||||
|
||||
/**
|
||||
* Mirror this composition's edits onto the adapter's own live document —
|
||||
* an immediate full sync of the composition's CURRENT overrides, then a
|
||||
* subscription that replays every future patch (including undo/redo).
|
||||
* `/script/gsap` patches are never mirrored (re-executing a live <script>
|
||||
* tag doesn't work and would conflict with running GSAP state).
|
||||
* Calling this again while already attached detaches the previous
|
||||
* subscription first. Returns an unsubscribe.
|
||||
*/
|
||||
attachSync(comp: Composition): () => void;
|
||||
|
||||
/**
|
||||
* Optional: apply composition-variable values to the preview so it renders
|
||||
* as `window.__hfVariables` injection would at render time (values must be
|
||||
* visible to the runtime BEFORE composition scripts run — typically a
|
||||
* preview reload with injection, not a live poke). Pass null to restore
|
||||
* declared defaults. Values are ephemeral preview state, never persisted.
|
||||
*/
|
||||
setPreviewVariables?(values: Record<string, unknown> | null): void;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* SDK document model — adaptation layer on top of @hyperframes/core.
|
||||
*
|
||||
* F6 decision: SDK builds ON core, no parser duplication.
|
||||
* - ensureHfIds (from core) is the parse entry point: all construction starts here.
|
||||
* - DOMParser is NOT used (browser-only). linkedom is the node-safe primitive.
|
||||
* - ParsedHtml (core) is the Studio timeline view (timed elements only).
|
||||
* HyperFramesElement is the editing view (ALL editable elements, with raw attrs).
|
||||
*/
|
||||
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ensureHfIds, isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
|
||||
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import {
|
||||
findRoot,
|
||||
getElementStyles,
|
||||
getGsapScripts,
|
||||
getOwnText,
|
||||
isNewHostBoundary,
|
||||
querySelectorAllDeep,
|
||||
} from "./engine/model.js";
|
||||
import type { HyperFramesElement, SdkDocument } from "./types.js";
|
||||
|
||||
// Tags that carry no editable content and must not enter the element tree.
|
||||
const EXCLUDED_TAGS = new Set([
|
||||
"script",
|
||||
"style",
|
||||
"template",
|
||||
"meta",
|
||||
"link",
|
||||
"noscript",
|
||||
"base",
|
||||
"head",
|
||||
]);
|
||||
|
||||
// Snapshot text is TRIMMED for display (markup indentation produces noisy
|
||||
// whitespace text nodes). The raw text target is shared with setText so shadow
|
||||
// value checks and dispatch serialization use the same DOM target.
|
||||
function snapshotText(el: Element): string | null {
|
||||
const trimmed = getOwnText(el).trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
// Parsing the GSAP script (acorn AST walk) is the expensive part and depends
|
||||
// only on the script text, so memoize the {tween id, selector} pairs by script.
|
||||
// Selector→hf-id resolution still runs each call — it depends on the live DOM,
|
||||
// which changes on dispatch. Single-entry cache covers the hot path (same comp,
|
||||
// repeated getElements() rebuilds) and stays bounded.
|
||||
let gsapLocatedCacheKey: string | null = null;
|
||||
let gsapLocatedCacheVal: Array<{ id: string; selector: string }> = [];
|
||||
|
||||
function parseLocatedCached(script: string): Array<{ id: string; selector: string }> {
|
||||
if (gsapLocatedCacheKey === script) return gsapLocatedCacheVal;
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
gsapLocatedCacheVal = parsed
|
||||
? parsed.located.map(({ id, animation }) => ({ id, selector: animation.targetSelector }))
|
||||
: [];
|
||||
gsapLocatedCacheKey = script;
|
||||
return gsapLocatedCacheVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each element's data-hf-id → the GSAP tween ids targeting it. Tween ids
|
||||
* come from the acorn parser's stable `targetSelector-method-position` scheme —
|
||||
* the SAME id-space the studio-api read path and the SDK GSAP ops use, so these
|
||||
* ids are dispatchable as-is via setGsapTween/removeGsapTween. Best-effort: a
|
||||
* malformed selector or unparseable script yields no entries (animationIds: []).
|
||||
*/
|
||||
function buildAnimationIdMap(document: Document): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const script of getGsapScripts(document)) {
|
||||
for (const { id, selector } of parseLocatedCached(script)) {
|
||||
appendAnimationIdsForSelector(map, document, id, selector);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function appendAnimationIdsForSelector(
|
||||
map: Map<string, string[]>,
|
||||
document: Document,
|
||||
animationId: string,
|
||||
selector: string,
|
||||
): void {
|
||||
if (!selector) return;
|
||||
|
||||
let matches: Element[];
|
||||
try {
|
||||
matches = querySelectorAllDeep(document, selector);
|
||||
} catch {
|
||||
return; // selector not valid for querySelectorAll — skip
|
||||
}
|
||||
|
||||
for (const el of matches) {
|
||||
const hfId = el.getAttribute("data-hf-id");
|
||||
if (!hfId) continue;
|
||||
const list = map.get(hfId);
|
||||
if (list) list.push(animationId);
|
||||
else map.set(hfId, [animationId]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every GSAP tween id `parseLocatedCached` finds in the script, with no DOM
|
||||
* matching at all — the same id space the server-side script ops
|
||||
* (removeAllKeyframesFromScript et al.) resolve against. Unlike
|
||||
* buildAnimationIdMap's per-element map, this never drops an id just because
|
||||
* its selector doesn't currently CSS-match a live element — that gap is what
|
||||
* caused a false animation_not_found divergence in the resolver-shadow
|
||||
* tripwire (a tween on a renamed/duplicate/scoped selector still resolves on
|
||||
* the server, which reads the script directly).
|
||||
*/
|
||||
export function parsedAnimationIds(script: string): Set<string> {
|
||||
return new Set(parseLocatedCached(script).map(({ id }) => id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the element list for a parent's children, treating a COMPOSITION
|
||||
* template (`<template data-composition-id>`) as a TRANSPARENT container: its
|
||||
* inner elements are spliced in at the template's position, the template
|
||||
* itself gets no node. This mirrors the studio preview, which unwraps exactly
|
||||
* that pattern into the served body — so template-based sub-comps expose the
|
||||
* same elements (and hf-ids) here as the timeline reads from the live preview
|
||||
* DOM. A plain <template> (runtime clone-source) stays fully excluded: its
|
||||
* inert interior is not editable and its content is duplicated at runtime.
|
||||
*/
|
||||
function buildChildren(
|
||||
parent: Element,
|
||||
scopePrefix: string,
|
||||
animationIdsByHfId: Map<string, string[]>,
|
||||
): HyperFramesElement[] {
|
||||
const out: HyperFramesElement[] = [];
|
||||
for (const child of Array.from(parent.children)) {
|
||||
if (child.tagName.toLowerCase() === "template") {
|
||||
if (isCompositionTemplate(child)) {
|
||||
out.push(...buildChildren(child, scopePrefix, animationIdsByHfId));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const built = buildElement(child, scopePrefix, animationIdsByHfId);
|
||||
if (built) out.push(built);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function buildElement(
|
||||
el: Element,
|
||||
scopePrefix: string,
|
||||
animationIdsByHfId: Map<string, string[]>,
|
||||
): HyperFramesElement | null {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (EXCLUDED_TAGS.has(tag)) return null;
|
||||
|
||||
const id = el.getAttribute("data-hf-id") ?? "";
|
||||
if (!id) return null; // should never happen after ensureHfIds, but guard defensively
|
||||
|
||||
// scopedId: if we're inside a sub-comp scope, prefix with "scopePrefix/".
|
||||
// The host element itself is in the PARENT scope (no prefix change for its own id).
|
||||
const scopedId = scopePrefix ? `${scopePrefix}/${id}` : id;
|
||||
|
||||
// Children inherit the scope prefix from their parent.
|
||||
// If this element is a new host boundary (starts a new sub-comp scope), its
|
||||
// children use THIS element's scopedId as their prefix.
|
||||
// Otherwise, children inherit the same prefix that this element used.
|
||||
const childPrefix = isNewHostBoundary(el) ? scopedId : scopePrefix;
|
||||
|
||||
const inlineStyles = getElementStyles(el);
|
||||
|
||||
const classAttr = el.getAttribute("class") ?? "";
|
||||
const classNames = classAttr
|
||||
.split(/\s+/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const attributes: Record<string, string> = {};
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.name === "style" || attr.name === "class" || attr.name.startsWith("data-hf-")) {
|
||||
continue;
|
||||
}
|
||||
attributes[attr.name] = attr.value;
|
||||
}
|
||||
|
||||
const startAttr = el.getAttribute("data-start");
|
||||
const endAttr = el.getAttribute("data-end");
|
||||
const trackAttr = el.getAttribute("data-track-index");
|
||||
|
||||
const start = startAttr !== null ? parseFloat(startAttr) : null;
|
||||
const duration =
|
||||
start !== null && endAttr !== null ? Math.max(0, parseFloat(endAttr) - start) : null;
|
||||
const trackIndex = trackAttr !== null ? parseInt(trackAttr, 10) : null;
|
||||
|
||||
const children = buildChildren(el, childPrefix, animationIdsByHfId);
|
||||
|
||||
return {
|
||||
id,
|
||||
scopedId,
|
||||
tag,
|
||||
children,
|
||||
inlineStyles,
|
||||
classNames,
|
||||
attributes,
|
||||
text: snapshotText(el),
|
||||
start,
|
||||
duration,
|
||||
trackIndex,
|
||||
animationIds: animationIdsByHfId.get(id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function extractGsapScript(doc: Document): string | null {
|
||||
return getGsapScripts(doc)[0] ?? null;
|
||||
}
|
||||
|
||||
function extractStyles(doc: Document): string | null {
|
||||
const styleEl = doc.querySelector("style");
|
||||
return styleEl ? styleEl.textContent : null;
|
||||
}
|
||||
|
||||
// Root resolution delegates to the engine's findRoot so dimension extraction
|
||||
// and mutations agree on which element is the composition root.
|
||||
// fallow-ignore-next-line complexity
|
||||
function extractDimensions(doc: Document): { width: number | null; height: number | null } {
|
||||
const stage = findRoot(doc);
|
||||
if (!stage) return { width: null, height: null };
|
||||
// data-width/data-height are the runtime's forced override — prefer them.
|
||||
const wAttr = stage.getAttribute("data-width");
|
||||
const hAttr = stage.getAttribute("data-height");
|
||||
const style = (stage as HTMLElement).getAttribute?.("style") ?? "";
|
||||
const wm = /width:\s*(\d+)px/.exec(style);
|
||||
const hm = /height:\s*(\d+)px/.exec(style);
|
||||
return {
|
||||
width: wAttr !== null ? parseInt(wAttr, 10) : wm ? parseInt(wm[1] ?? "", 10) : null,
|
||||
height: hAttr !== null ? parseInt(hAttr, 10) : hm ? parseInt(hm[1] ?? "", 10) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractDuration(doc: Document): number | null {
|
||||
const root = findRoot(doc) ?? doc.body;
|
||||
const dur = root?.getAttribute("data-duration");
|
||||
return dur ? parseFloat(dur) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the element tree from an already-parsed (hf-id-stamped) linkedom Document.
|
||||
* Walks the live DOM directly — no serialize/re-parse round trip. This is what
|
||||
* the session's query API uses against its mutable document.
|
||||
*/
|
||||
export function buildRoots(document: Document): HyperFramesElement[] {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
return buildChildren(body, "", buildAnimationIdMap(document));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an HTML string into the SDK document model.
|
||||
* Calls ensureHfIds first so every element has a stable data-hf-id.
|
||||
* Uses linkedom — node-safe (works in agents, CI, server-side).
|
||||
*/
|
||||
export function buildDocument(html: string): SdkDocument {
|
||||
const stamped = ensureHfIds(html);
|
||||
|
||||
const hasShell = /<!doctype|<html[\s>]/i.test(stamped);
|
||||
const wrapped = !hasShell;
|
||||
const { document } = wrapped
|
||||
? parseHTML(`<!DOCTYPE html><html><head></head><body>${stamped}</body></html>`)
|
||||
: parseHTML(stamped);
|
||||
|
||||
const dims = extractDimensions(document);
|
||||
|
||||
return {
|
||||
roots: buildRoots(document),
|
||||
gsapScript: extractGsapScript(document),
|
||||
styles: extractStyles(document),
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
compositionDuration: extractDuration(document),
|
||||
html: stamped,
|
||||
};
|
||||
}
|
||||
|
||||
/** Flat walk of the element tree — returns every element in document order */
|
||||
export function flatElements(roots: readonly HyperFramesElement[]): HyperFramesElement[] {
|
||||
const result: HyperFramesElement[] = [];
|
||||
function walk(el: HyperFramesElement) {
|
||||
result.push(el);
|
||||
for (const child of el.children) walk(child);
|
||||
}
|
||||
for (const root of roots) walk(root);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { Window } from "happy-dom";
|
||||
import { resolveElementAffordances } from "./affordances";
|
||||
|
||||
let doc: Document;
|
||||
|
||||
beforeEach(() => {
|
||||
const win = new Window();
|
||||
doc = win.document as unknown as Document;
|
||||
});
|
||||
|
||||
function el(html: string): HTMLElement {
|
||||
doc.body.innerHTML = html;
|
||||
const node = doc.body.firstElementChild;
|
||||
const view = doc.defaultView;
|
||||
if (!view) throw new Error("no defaultView");
|
||||
if (!(node instanceof view.HTMLElement)) throw new Error("expected HTMLElement");
|
||||
return node as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
describe("resolveElementAffordances (live DOM)", () => {
|
||||
it("video element with model => media + colorGrading, existsInSource", () => {
|
||||
const v = el(`<video data-hf-id="hf-v" style="position:absolute;left:10px;top:20px"></video>`);
|
||||
const a = resolveElementAffordances(v, { text: null, animationIds: [], start: null });
|
||||
expect(a.sections).toMatchObject({ media: true, colorGrading: true });
|
||||
expect(a.capabilities.canSelect).toBe(true);
|
||||
});
|
||||
|
||||
it("absolutely-positioned div with inline left/top => canMove", () => {
|
||||
const d = el(`<div style="position:absolute;left:5px;top:5px"></div>`);
|
||||
const a = resolveElementAffordances(d, { text: null, animationIds: [], start: null });
|
||||
expect(a.capabilities.canMove).toBe(true);
|
||||
});
|
||||
|
||||
it("model text => text section; model animationIds => timing+animation", () => {
|
||||
const d = el(`<div></div>`);
|
||||
const a = resolveElementAffordances(d, { text: "hello", animationIds: ["t1", "t2"], start: 0 });
|
||||
expect(a.sections).toMatchObject({ text: true, timing: true, animation: true });
|
||||
});
|
||||
|
||||
it("null model => existsInSource false (not in model)", () => {
|
||||
const d = el(`<div></div>`);
|
||||
const a = resolveElementAffordances(d, null);
|
||||
expect(a.capabilities.canEditStyles).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Browser-only editing-affordance resolver. Reads live layout (getComputedStyle)
|
||||
* from a rendered element and combines it with the SDK model element to call the
|
||||
* pure core resolver. MUST NOT be imported on the static/Node SDK path — it
|
||||
* touches getComputedStyle and only resolves meaningfully against a laid-out DOM.
|
||||
*/
|
||||
|
||||
import { resolveEditingAffordances, type EditingAffordances } from "@hyperframes/core/editing";
|
||||
import type { HyperFramesElement } from "../types.js";
|
||||
|
||||
export interface AffordanceContext {
|
||||
/** Studio-app concepts; default false for a generic consumer with no such notion. */
|
||||
isCompositionHost?: boolean;
|
||||
isCompositionRoot?: boolean;
|
||||
isInsideLockedComposition?: boolean;
|
||||
isMasterView?: boolean;
|
||||
}
|
||||
|
||||
type ModelFacts = Pick<HyperFramesElement, "text" | "animationIds" | "start">;
|
||||
|
||||
export function resolveElementAffordances(
|
||||
liveEl: HTMLElement,
|
||||
modelEl: ModelFacts | null,
|
||||
ctx: AffordanceContext = {},
|
||||
): EditingAffordances {
|
||||
const view = liveEl.ownerDocument.defaultView;
|
||||
const cs = view ? view.getComputedStyle(liveEl) : null;
|
||||
const computedStyles: Record<string, string> | undefined = cs
|
||||
? {
|
||||
position: cs.getPropertyValue("position"),
|
||||
left: cs.getPropertyValue("left"),
|
||||
top: cs.getPropertyValue("top"),
|
||||
width: cs.getPropertyValue("width"),
|
||||
height: cs.getPropertyValue("height"),
|
||||
transform: cs.getPropertyValue("transform"),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Core reads position only from computedStyles; inlineStyles supplies the
|
||||
// authored left/top/width/height that override the computed layout.
|
||||
const inlineStyles: Record<string, string> = {
|
||||
left: liveEl.style.getPropertyValue("left"),
|
||||
top: liveEl.style.getPropertyValue("top"),
|
||||
width: liveEl.style.getPropertyValue("width"),
|
||||
height: liveEl.style.getPropertyValue("height"),
|
||||
};
|
||||
|
||||
return resolveEditingAffordances({
|
||||
hasStableTarget: true,
|
||||
tag: liveEl.tagName.toLowerCase(),
|
||||
inlineStyles,
|
||||
computedStyles,
|
||||
isCompositionHost: ctx.isCompositionHost ?? false,
|
||||
isCompositionRoot: ctx.isCompositionRoot ?? false,
|
||||
isInsideLockedComposition: ctx.isInsideLockedComposition ?? false,
|
||||
isMasterView: ctx.isMasterView ?? false,
|
||||
existsInSource: modelEl != null,
|
||||
hasEditableText: modelEl?.text != null,
|
||||
hasTimingStart: modelEl ? modelEl.start != null : liveEl.hasAttribute("data-start"),
|
||||
animationCount: modelEl?.animationIds.length ?? 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Bounded RFC 6902 patch applier — handles only the path patterns emitted by mutate.ts.
|
||||
*
|
||||
* Not a general-purpose JSON Patch implementation. Translates the well-defined path
|
||||
* grammar back into DOM mutations. Used by applyPatches() for host undo (T3 mode).
|
||||
*
|
||||
* Supports only the emit subset (add/remove/replace) — move/copy/test ops and
|
||||
* unknown paths are silently ignored, matching the JsonPatchOp contract.
|
||||
*/
|
||||
|
||||
import type { JsonPatchOp, OverrideSet } from "../types.js";
|
||||
import type { ParsedDocument } from "./model.js";
|
||||
import {
|
||||
findById,
|
||||
findRoot,
|
||||
declarationElement,
|
||||
setElementStyles,
|
||||
setOwnText,
|
||||
setGsapScript,
|
||||
setStyleSheet,
|
||||
} from "./model.js";
|
||||
import { keyToPath, stylePath } from "./patches.js";
|
||||
import {
|
||||
writeVariableDefault,
|
||||
clearVariableDefault,
|
||||
writeVariableDeclaration,
|
||||
removeVariableDeclarationEntry,
|
||||
} from "./variableModel.js";
|
||||
|
||||
function isRawDeclarationEntry(value: unknown): value is { id: string } & Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as { id?: unknown }).id === "string"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Path parser ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedPath {
|
||||
type:
|
||||
| "style"
|
||||
| "text"
|
||||
| "attribute"
|
||||
| "timing"
|
||||
| "hold"
|
||||
| "element"
|
||||
| "variable"
|
||||
| "variableDeclaration"
|
||||
| "metadata"
|
||||
| "script"
|
||||
| "stylesheet";
|
||||
id?: string;
|
||||
prop?: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parsePath(path: string): ParsedPath | null {
|
||||
const styleM = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
|
||||
if (styleM) return { type: "style", id: styleM[1], prop: styleM[2] };
|
||||
|
||||
const textM = /^\/elements\/([^/]+)\/text$/.exec(path);
|
||||
if (textM) return { type: "text", id: textM[1] };
|
||||
|
||||
const attrM = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
|
||||
if (attrM)
|
||||
return {
|
||||
type: "attribute",
|
||||
id: attrM[1],
|
||||
prop: attrM[2]?.replace(/~1/g, "/").replace(/~0/g, "~"),
|
||||
};
|
||||
|
||||
const timingM = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path);
|
||||
if (timingM) return { type: "timing", id: timingM[1], field: timingM[2] };
|
||||
|
||||
const holdM = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path);
|
||||
if (holdM) return { type: "hold", id: holdM[1], field: holdM[2] };
|
||||
|
||||
const elemM = /^\/elements\/([^/]+)$/.exec(path);
|
||||
if (elemM) return { type: "element", id: elemM[1] };
|
||||
|
||||
const varDeclM = /^\/variableDeclarations\/(.+)$/.exec(path);
|
||||
if (varDeclM) return { type: "variableDeclaration", id: varDeclM[1] };
|
||||
|
||||
const varM = /^\/variables\/(.+)$/.exec(path);
|
||||
if (varM) return { type: "variable", id: varM[1] };
|
||||
|
||||
const metaM = /^\/metadata\/(.+)$/.exec(path);
|
||||
if (metaM) return { type: "metadata", field: metaM[1] };
|
||||
|
||||
if (path === "/script/gsap") return { type: "script" };
|
||||
if (path === "/style/css") return { type: "stylesheet" };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Variable JSON model helper ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply a variable patch to `data-composition-variables`. A remove op (null)
|
||||
* deletes the declaration's `default` key, restoring its "no authored default"
|
||||
* state — the exact inverse of a first-set that added a default to a
|
||||
* default-less variable, so undo of such a set round-trips. A value op upserts
|
||||
* the matching declaration's `default`. No-ops when the attr/decl is absent.
|
||||
* Shares the model logic with mutate.ts via ./variableModel.ts.
|
||||
*/
|
||||
function applyVariableDefault(declEl: Element | null, id: string, newDefault: unknown): void {
|
||||
if (newDefault === null) {
|
||||
clearVariableDefault(declEl, id);
|
||||
} else {
|
||||
writeVariableDefault(declEl, id, newDefault);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Patch application ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replay a stored override-set onto a freshly-parsed base document (T3 init).
|
||||
* A null value means the property was explicitly deleted — emit a remove patch
|
||||
* so the base document matches the session state. (Removing a non-existent
|
||||
* property is a no-op in applyOne, so this is safe against fresh-base misses.)
|
||||
*/
|
||||
export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void {
|
||||
const patches: JsonPatchOp[] = [];
|
||||
const rootId = findRoot(parsed.document)?.getAttribute("data-hf-id") ?? null;
|
||||
// Whole-declaration snapshots (varDecl.{id}) must replay BEFORE value keys
|
||||
// (var.{id}): a declaration snapshot embeds the default at fold time, while
|
||||
// var.{id} always carries the latest value — insertion order alone would let
|
||||
// an older snapshot clobber a newer value.
|
||||
const entries = Object.entries(overrides).sort(([a], [b]) => {
|
||||
const aVar = a.startsWith("var.");
|
||||
const bVar = b.startsWith("var.");
|
||||
const aDecl = a.startsWith("varDecl.");
|
||||
const bDecl = b.startsWith("varDecl.");
|
||||
if (aVar && bDecl) return 1;
|
||||
if (aDecl && bVar) return -1;
|
||||
return 0; // stable — every other key keeps its insertion order
|
||||
});
|
||||
for (const [key, value] of entries) {
|
||||
const path = keyToPath(key);
|
||||
if (!path) continue;
|
||||
if (value === null) {
|
||||
patches.push({ op: "remove", path });
|
||||
} else {
|
||||
patches.push({ op: "replace", path, value });
|
||||
}
|
||||
// A scalar `var.{id}` override must also restore the `--{id}` CSS custom
|
||||
// prop on the root. Current sessions persist a paired style override, but
|
||||
// sets written before the model/CSS split only carry `var.{id}`; derive the
|
||||
// CSS here so `var(--{id})` bindings rehydrate. Object (font/image) values
|
||||
// are never CSS, so they are skipped.
|
||||
if (rootId && key.startsWith("var.") && value !== null && typeof value !== "object") {
|
||||
const cssPath = stylePath(rootId, `--${key.slice("var.".length)}`);
|
||||
patches.push({ op: "replace", path: cssPath, value: String(value) });
|
||||
} else if (rootId && key.startsWith("var.") && value === null) {
|
||||
patches.push({ op: "remove", path: stylePath(rootId, `--${key.slice("var.".length)}`) });
|
||||
}
|
||||
}
|
||||
applyPatchesToDocument(parsed, patches);
|
||||
}
|
||||
|
||||
export function applyPatchesToDocument(
|
||||
parsed: ParsedDocument,
|
||||
patches: readonly JsonPatchOp[],
|
||||
): void {
|
||||
for (const patch of patches) {
|
||||
const p = parsePath(patch.path);
|
||||
if (!p) continue;
|
||||
applyOne(parsed, patch, p);
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): void {
|
||||
switch (p.type) {
|
||||
case "style": {
|
||||
const el = p.id ? findById(parsed.document, p.id) : null;
|
||||
if (!el || !p.prop) return;
|
||||
if (patch.op === "remove") {
|
||||
setElementStyles(el, { [p.prop]: null });
|
||||
} else {
|
||||
setElementStyles(el, { [p.prop]: String(patch.value) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "text": {
|
||||
const el = p.id ? findById(parsed.document, p.id) : null;
|
||||
if (!el) return;
|
||||
if (patch.op === "remove") {
|
||||
setOwnText(el, "");
|
||||
} else {
|
||||
setOwnText(el, String(patch.value ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "attribute": {
|
||||
const el = p.id ? findById(parsed.document, p.id) : null;
|
||||
if (!el || !p.prop) return;
|
||||
if (patch.op === "remove") {
|
||||
el.removeAttribute(p.prop);
|
||||
} else {
|
||||
el.setAttribute(p.prop, String(patch.value ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "timing": {
|
||||
const el = p.id ? findById(parsed.document, p.id) : null;
|
||||
if (!el || !p.field) return;
|
||||
if (p.field === "start") {
|
||||
if (patch.op === "remove") el.removeAttribute("data-start");
|
||||
else el.setAttribute("data-start", String(patch.value));
|
||||
} else if (p.field === "duration") {
|
||||
// Patch value is the data-duration value — set directly.
|
||||
if (patch.op === "remove") el.removeAttribute("data-duration");
|
||||
else el.setAttribute("data-duration", String(patch.value));
|
||||
} else if (p.field === "end") {
|
||||
// Patch value is the absolute data-end time — set directly, no re-derivation.
|
||||
if (patch.op === "remove") el.removeAttribute("data-end");
|
||||
else el.setAttribute("data-end", String(patch.value));
|
||||
} else if (p.field === "trackIndex") {
|
||||
if (patch.op === "remove") el.removeAttribute("data-track-index");
|
||||
else el.setAttribute("data-track-index", String(patch.value));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "hold": {
|
||||
const el = p.id ? findById(parsed.document, p.id) : null;
|
||||
if (!el || !p.field) return;
|
||||
const attrName = `data-hold-${p.field}`;
|
||||
if (patch.op === "remove") el.removeAttribute(attrName);
|
||||
else el.setAttribute(attrName, String(patch.value));
|
||||
break;
|
||||
}
|
||||
|
||||
case "element": {
|
||||
if (!p.id) return;
|
||||
if (patch.op === "remove") {
|
||||
const el = findById(parsed.document, p.id);
|
||||
el?.remove();
|
||||
} else if (patch.op === "add" && patch.value) {
|
||||
const v = patch.value as { html: string; parentId: string | null; siblingIndex: number };
|
||||
const parent = v.parentId
|
||||
? findById(parsed.document, v.parentId)
|
||||
: ((parsed.document as unknown as { body: Element }).body as unknown as Element);
|
||||
if (!parent) return;
|
||||
// Parse within the target document to avoid cross-document node issues.
|
||||
const tmp = parsed.document.createElement("div");
|
||||
tmp.innerHTML = v.html;
|
||||
const node = tmp.firstElementChild;
|
||||
if (!node) return;
|
||||
const children = Array.from(parent.children);
|
||||
const ref = children[v.siblingIndex] ?? null;
|
||||
parent.insertBefore(node, ref);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "variableDeclaration": {
|
||||
if (!p.id) return;
|
||||
if (patch.op === "remove") {
|
||||
removeVariableDeclarationEntry(declarationElement(parsed.document, parsed.wrapped), p.id);
|
||||
} else if (isRawDeclarationEntry(patch.value)) {
|
||||
// Replay is faithful, not strict: inverse patches capture raw entries
|
||||
// (loose hand-authored declarations included) and undo must restore
|
||||
// them verbatim — gating on isCompositionVariable here would make
|
||||
// undo of a remove/update on a loose entry silently no-op.
|
||||
writeVariableDeclaration(declarationElement(parsed.document, parsed.wrapped), patch.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "variable": {
|
||||
if (!p.id) return;
|
||||
// B1: update the JSON model (data-composition-variables) so
|
||||
// getVariables() returns the correct value in both preview and render.
|
||||
// CSS compat is handled by explicit style-path patches emitted by mutate.ts,
|
||||
// so we do NOT write CSS here — the style case above handles those patches.
|
||||
applyVariableDefault(
|
||||
declarationElement(parsed.document, parsed.wrapped),
|
||||
p.id,
|
||||
patch.op === "remove" ? null : patch.value,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "script": {
|
||||
if (patch.op === "remove") {
|
||||
setGsapScript(parsed.document, "");
|
||||
} else {
|
||||
setGsapScript(parsed.document, String(patch.value ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "stylesheet": {
|
||||
if (patch.op === "remove") {
|
||||
setStyleSheet(parsed.document, "");
|
||||
} else {
|
||||
setStyleSheet(parsed.document, String(patch.value ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "metadata": {
|
||||
const root = findRoot(parsed.document);
|
||||
if (!root || !p.field) return;
|
||||
// Mirror mutate.ts: style always written; the data-* forced-override
|
||||
// attribute is updated only when the composition already carries it.
|
||||
if (p.field === "width") {
|
||||
if (patch.op === "remove") {
|
||||
setElementStyles(root, { width: null });
|
||||
root.removeAttribute("data-width");
|
||||
} else {
|
||||
setElementStyles(root, { width: `${patch.value}px` });
|
||||
if (root.hasAttribute("data-width")) root.setAttribute("data-width", String(patch.value));
|
||||
}
|
||||
} else if (p.field === "height") {
|
||||
if (patch.op === "remove") {
|
||||
setElementStyles(root, { height: null });
|
||||
root.removeAttribute("data-height");
|
||||
} else {
|
||||
setElementStyles(root, { height: `${patch.value}px` });
|
||||
if (root.hasAttribute("data-height")) {
|
||||
root.setAttribute("data-height", String(patch.value));
|
||||
}
|
||||
}
|
||||
} else if (p.field === "duration") {
|
||||
if (patch.op === "remove") root.removeAttribute("data-duration");
|
||||
else root.setAttribute("data-duration", String(patch.value));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Hand-rolled flat-CSS rule editor.
|
||||
*
|
||||
* Handles only simple flat rules (`selector { declarations }`) — no nesting,
|
||||
* no @-rules, no CSS comments. Sufficient for composition <style> blocks
|
||||
* generated by hyperframes, which never contain those constructs.
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CssRule {
|
||||
selector: string;
|
||||
body: string;
|
||||
/** Byte offset of the first char of the selector. */
|
||||
start: number;
|
||||
/** Byte offset after the closing `}`. */
|
||||
end: number;
|
||||
}
|
||||
|
||||
// ─── Parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseCssRules(css: string): CssRule[] {
|
||||
const rules: CssRule[] = [];
|
||||
let i = 0;
|
||||
const len = css.length;
|
||||
while (i < len) {
|
||||
const braceOpen = css.indexOf("{", i);
|
||||
if (braceOpen === -1) break;
|
||||
const selector = css.slice(i, braceOpen).trim();
|
||||
if (!selector) {
|
||||
i = braceOpen + 1;
|
||||
continue;
|
||||
}
|
||||
// skip leading whitespace to get the true selector start
|
||||
let selStart = i;
|
||||
while (selStart < braceOpen && " \t\r\n".includes(css[selStart]!)) selStart++;
|
||||
// find closing }, respecting quoted strings so `content: "}"` doesn't end the rule
|
||||
let j = braceOpen + 1;
|
||||
let quote: string | null = null;
|
||||
while (j < len) {
|
||||
const ch = css[j]!;
|
||||
if (quote) {
|
||||
if (ch === "\\" && j + 1 < len)
|
||||
j++; // skip escaped char
|
||||
else if (ch === quote) quote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
} else if (ch === "}") {
|
||||
break;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
rules.push({ selector, body: css.slice(braceOpen + 1, j), start: selStart, end: j + 1 });
|
||||
i = j + 1;
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseDeclarations(body: string): Record<string, string> {
|
||||
const decls: Record<string, string> = {};
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let start = 0;
|
||||
for (let i = 0; i <= body.length; i++) {
|
||||
const ch = i < body.length ? body[i]! : ";"; // sentinel flush
|
||||
if (quote) {
|
||||
if (ch === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
} // skip escaped char
|
||||
if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
else if (ch === ";" && depth === 0) {
|
||||
const part = body.slice(start, i);
|
||||
const colon = part.indexOf(":");
|
||||
if (colon !== -1) {
|
||||
const prop = part.slice(0, colon).trim();
|
||||
const value = part.slice(colon + 1).trim();
|
||||
if (prop && value) decls[prop] = value;
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
return decls;
|
||||
}
|
||||
|
||||
function serializeDeclarations(decls: Record<string, string>): string {
|
||||
const entries = Object.entries(decls);
|
||||
if (!entries.length) return "";
|
||||
return " " + entries.map(([k, v]) => `${k}: ${v}`).join("; ") + "; ";
|
||||
}
|
||||
|
||||
function normalizeSelector(sel: string): string {
|
||||
return sel.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update or insert a CSS rule.
|
||||
*
|
||||
* Finds the first rule whose selector matches (after whitespace normalization)
|
||||
* and merges the given declarations into it. A null value removes the property.
|
||||
* If no matching rule exists, appends a new one.
|
||||
*
|
||||
* Returns the modified CSS string. Returns `css` unchanged when nothing changed.
|
||||
*/
|
||||
export function upsertCssRule(
|
||||
css: string,
|
||||
selector: string,
|
||||
styles: Record<string, string | null>,
|
||||
): string {
|
||||
const normalized = normalizeSelector(selector);
|
||||
const rules = parseCssRules(css);
|
||||
const idx = rules.findIndex((r) => normalizeSelector(r.selector) === normalized);
|
||||
|
||||
if (idx !== -1) {
|
||||
const rule = rules[idx]!;
|
||||
const decls = parseDeclarations(rule.body);
|
||||
for (const [prop, value] of Object.entries(styles)) {
|
||||
if (value === null) {
|
||||
delete decls[prop];
|
||||
} else {
|
||||
decls[prop] = value;
|
||||
}
|
||||
}
|
||||
const newRuleText = `${rule.selector} {${serializeDeclarations(decls)}}`;
|
||||
return css.slice(0, rule.start) + newRuleText + css.slice(rule.end);
|
||||
}
|
||||
|
||||
// Append new rule — skip if all values are null (nothing to write).
|
||||
const newDecls: Record<string, string> = {};
|
||||
for (const [prop, value] of Object.entries(styles)) {
|
||||
if (value !== null) newDecls[prop] = value;
|
||||
}
|
||||
if (!Object.keys(newDecls).length) return css;
|
||||
|
||||
const newRuleText = `${selector} {${serializeDeclarations(newDecls)}}`;
|
||||
const sep = css.length > 0 && !css.endsWith("\n") ? "\n" : "";
|
||||
return css + sep + newRuleText + "\n";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Backfill defaults for add-keyframe ops.
|
||||
*
|
||||
* When an add-keyframe op introduces a property absent from the other keyframes,
|
||||
* the writer needs a rest value to seed those keyframes with so GSAP interpolates
|
||||
* instead of snapping. The SDK derives the numeric-default set here so the acorn
|
||||
* writer matches the recast writer the server uses.
|
||||
*
|
||||
* Only props with a real numeric default get a backfill value. Defaulting an
|
||||
* unknown or string-valued prop to 0 (e.g. `color: 0`, `filter: 0`) emits invalid
|
||||
* GSAP, so such props are SKIPPED — the writer then leaves them out of the other
|
||||
* keyframes (GSAP reads the rest value from the DOM), matching recast (which skips
|
||||
* any prop whose default is null).
|
||||
*/
|
||||
|
||||
// Numeric rest values for editable transform/style props. Props absent here have
|
||||
// no safe static default and are intentionally omitted from the backfill set.
|
||||
//
|
||||
// KEEP IN SYNC WITH packages/studio/src/hooks/gsapShared.ts:PROPERTY_DEFAULTS —
|
||||
// the studio (recast) and SDK (acorn) paths must derive the same defaults or
|
||||
// SDK-written keyframes drift from server-written ones (the exact bug this fixes).
|
||||
// TODO: lift the canonical table into @hyperframes/core and import from both.
|
||||
const KEYFRAME_PROPERTY_DEFAULTS: Record<string, number> = {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
|
||||
/** Derive the backfillDefaults for an add-keyframe op (numeric-default props only). */
|
||||
export function deriveKeyframeBackfillDefaults(
|
||||
value: Record<string, number | string>,
|
||||
): Record<string, number | string> {
|
||||
const defaults: Record<string, number | string> = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
const def = KEYFRAME_PROPERTY_DEFAULTS[key];
|
||||
if (def !== undefined) defaults[key] = def;
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Mutable document — linkedom Document wrapper for Phase 3 editing.
|
||||
*
|
||||
* The linkedom Document IS the mutable backing store. All dispatch mutations
|
||||
* go here. serialize() walks the live DOM; no separate mutable tree to sync.
|
||||
*/
|
||||
|
||||
import { parseHTML } from "linkedom";
|
||||
import {
|
||||
ensureHfIds,
|
||||
isCompositionTemplate,
|
||||
walkCompositionDescendants,
|
||||
} from "@hyperframes/core/hf-ids";
|
||||
|
||||
export interface ParsedDocument {
|
||||
document: Document;
|
||||
/** True when the input was a fragment (no <html> shell) and was wrapped. */
|
||||
wrapped: boolean;
|
||||
/** ensureHfIds-stamped original HTML — used as fallback / diff base. */
|
||||
stamped: string;
|
||||
}
|
||||
|
||||
export function parseMutable(html: string): ParsedDocument {
|
||||
const stamped = ensureHfIds(html);
|
||||
const hasShell = /<!doctype|<html[\s>]/i.test(stamped);
|
||||
const wrapped = !hasShell;
|
||||
const { document } = wrapped
|
||||
? parseHTML(`<!DOCTYPE html><html><head></head><body>${stamped}</body></html>`)
|
||||
: parseHTML(stamped);
|
||||
return { document: document as unknown as Document, wrapped, stamped };
|
||||
}
|
||||
|
||||
// ─── Element lookup ───────────────────────────────────────────────────────────
|
||||
|
||||
export function findById(document: Document, id: string): Element | null {
|
||||
// Delegate to resolveScoped so patch replay (undo/redo, override-set apply)
|
||||
// resolves an id the SAME way forward dispatch does: canonical-first for an
|
||||
// ambiguous bare id, and scoped-path ("hf-host/hf-leaf") aware. Otherwise the
|
||||
// two paths disagree on which duplicate a bare id targets and undo reverts the
|
||||
// wrong element. (function declaration is hoisted.)
|
||||
return resolveScoped(document, id);
|
||||
}
|
||||
|
||||
export function escapeHfId(id: string): string {
|
||||
return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* querySelectorAll that also descends into COMPOSITION `<template>` subtrees
|
||||
* (`data-composition-id` — the pattern the studio preview unwraps) — linkedom's
|
||||
* querySelectorAll does not, so template-based sub-comp content would be
|
||||
* unreachable for resolution/dispatch even though buildRoots models it.
|
||||
*
|
||||
* Implemented as a document-order DOM walk (not qsa + append) so duplicate-id
|
||||
* tiebreaks resolve in TRUE document order — appending template matches after
|
||||
* all top-level matches would make resolveScoped pick a different duplicate
|
||||
* than the preview's unwrapped DOM does. Plain templates (runtime clone
|
||||
* sources) are skipped, matching buildChildren and ensureHfIds.
|
||||
*
|
||||
* Throws like querySelectorAll on an invalid selector (Element.matches).
|
||||
*/
|
||||
export function querySelectorAllDeep(root: Document | Element, selector: string): Element[] {
|
||||
const out: Element[] = [];
|
||||
const start: Element | null =
|
||||
"body" in root ? ((root as Document).body ?? null) : (root as Element);
|
||||
const walk = (parent: Element): void => {
|
||||
for (const child of Array.from(parent.children)) {
|
||||
if (child.tagName.toLowerCase() === "template") {
|
||||
if (isCompositionTemplate(child)) walk(child);
|
||||
continue;
|
||||
}
|
||||
if (child.matches(selector)) out.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
if (start) {
|
||||
// When rooted at an Element (scoped-path step), the root itself is the
|
||||
// context, not a candidate — only descendants match, like querySelectorAll.
|
||||
walk(start);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an element lives at the top-level (canonical) scope — i.e. its
|
||||
* scopedId equals its bare id because no ancestor opens a sub-composition
|
||||
* boundary. This mirrors document.ts's scopedId construction (childPrefix only
|
||||
* changes at isNewHostBoundary elements) without rebuilding the snapshot tree.
|
||||
*/
|
||||
function isCanonicalScope(el: Element): boolean {
|
||||
for (let cur = el.parentElement; cur; cur = cur.parentElement) {
|
||||
if (isNewHostBoundary(cur)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare or scoped hf-id to its DOM element.
|
||||
*
|
||||
* Bare id ("hf-x"): top-level document search. When the bare id is ambiguous
|
||||
* (duplicated across a sub-composition and the top level), prefer the canonical
|
||||
* (top-level) instance — the one whose scopedId equals the bare id — falling
|
||||
* back to document order when no canonical match exists. This matches
|
||||
* getElement()'s resolution rule so removeElement / getElement agree on which
|
||||
* instance an ambiguous bare id targets.
|
||||
*
|
||||
* Scoped id ("hf-HOST/hf-LEAF", any depth): each segment narrows the search
|
||||
* into the subtree of the previous match. This unambiguously addresses an
|
||||
* element inside a sub-composition even when bare ids collide.
|
||||
*/
|
||||
export function resolveScoped(document: Document, id: string): Element | null {
|
||||
const parts = id.split("/");
|
||||
|
||||
// Bare id: prefer the canonical (top-level) match when one exists, so
|
||||
// resolution agrees with getElement (scopedId === id wins over document order).
|
||||
if (parts.length === 1) {
|
||||
const escaped = escapeHfId(id);
|
||||
const matches = querySelectorAllDeep(document, `[data-hf-id="${escaped}"]`);
|
||||
if (matches.length > 0) {
|
||||
return matches.find((el) => isCanonicalScope(el)) ?? matches[0] ?? null;
|
||||
}
|
||||
// Fall back to a sub-composition ROOT addressed by its composition id. A
|
||||
// host element carries data-hf-id (its own leaf id) AND data-composition-id
|
||||
// (the id studio passes when targeting the sub-comp root). data-hf-id takes
|
||||
// precedence above; only when no hf-id matches do we treat the bare id as a
|
||||
// composition id, making comp-ids first-class resolvable addresses.
|
||||
return querySelectorAllDeep(document, `[data-composition-id="${escaped}"]`)[0] ?? null;
|
||||
}
|
||||
|
||||
let context: Element | Document = document;
|
||||
for (const part of parts) {
|
||||
const escaped = escapeHfId(part);
|
||||
const found: Element | null =
|
||||
querySelectorAllDeep(context, `[data-hf-id="${escaped}"]`)[0] ?? null;
|
||||
if (!found) return null;
|
||||
context = found;
|
||||
}
|
||||
return context as Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare leaf id from a scoped hf-id ("hf-HOST/hf-LEAF" → "hf-LEAF"; a bare id
|
||||
* passes through unchanged). The live DOM's `data-hf-id` attribute never
|
||||
* carries the host-chain prefix, so a consumer holding a scopedId (from
|
||||
* getElements()/getElement()) needs this to query the rendered DOM directly.
|
||||
*/
|
||||
export function bareId(scopedId: string): string {
|
||||
const parts = scopedId.split("/");
|
||||
// split() always returns >=1 element, so this never actually falls through at
|
||||
// runtime — the fallback exists to satisfy noUncheckedIndexedAccess, not as a
|
||||
// reachable safety net.
|
||||
return parts[parts.length - 1] ?? scopedId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when this element starts a new sub-composition scope — i.e. it
|
||||
* is a host element (has data-composition-file) and is NOT the outerHTML
|
||||
* innerRoot of the SAME sub-composition (same dcf value as parent).
|
||||
*
|
||||
* outerHTML case: both host and innerRoot carry data-composition-file="sub.html".
|
||||
* The innerRoot has the SAME value as the host (its parent) → not a new boundary.
|
||||
* A genuine nested host inside a sub-comp has a DIFFERENT dcf value.
|
||||
*/
|
||||
export function isNewHostBoundary(el: Element): boolean {
|
||||
const dcf = el.getAttribute("data-composition-file");
|
||||
if (!dcf) return false;
|
||||
const parentDcf = el.parentElement?.getAttribute("data-composition-file") ?? null;
|
||||
return dcf !== parentDcf;
|
||||
}
|
||||
|
||||
/**
|
||||
* The element that carries composition-level declarations
|
||||
* (`data-composition-variables`). Full-document comps use `<html>`; a wrapped
|
||||
* template/fragment comp has a synthetic `<html>` that serialize() strips, so
|
||||
* its declarations must live on the composition root div (where values/metadata
|
||||
* already live) to survive save.
|
||||
*/
|
||||
export function declarationElement(document: Document, wrapped: boolean): Element | null {
|
||||
if (wrapped) return findRoot(document);
|
||||
return (document as Document & { documentElement?: Element }).documentElement ?? null;
|
||||
}
|
||||
|
||||
export function findRoot(document: Document): Element | null {
|
||||
return (
|
||||
document.querySelector("[data-hf-root]") ??
|
||||
document.getElementById("stage") ??
|
||||
// Descend into a composition <template> so a wrapped template sub-comp
|
||||
// resolves to its inner [data-composition-id] root, not the <template> shell.
|
||||
querySelectorAllDeep(document, "[data-composition-id]")[0] ??
|
||||
document.body?.firstElementChild ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inline style helpers ─────────────────────────────────────────────────────
|
||||
|
||||
export function toCamel(prop: string): string {
|
||||
if (prop.startsWith("--")) return prop;
|
||||
return prop.replace(/-([a-z])/g, (_, c: string) => (c as string).toUpperCase());
|
||||
}
|
||||
|
||||
function toKebab(prop: string): string {
|
||||
if (prop.startsWith("--")) return prop;
|
||||
return prop.replace(/([A-Z])/g, (c) => `-${c.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/** Parse style attribute string → camelCase map (custom props kept as-is). */
|
||||
interface StyleDeclarationScan {
|
||||
depth: number;
|
||||
quote: "'" | '"' | null;
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
function advanceStyleDeclarationScan(scan: StyleDeclarationScan, ch: string, next: string): void {
|
||||
if (scan.quote) {
|
||||
if (ch === "\\" && next) {
|
||||
scan.skip = true;
|
||||
return;
|
||||
}
|
||||
if (ch === scan.quote) scan.quote = null;
|
||||
return;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
scan.quote = ch;
|
||||
return;
|
||||
}
|
||||
if (ch === "(") scan.depth++;
|
||||
else if (ch === ")") scan.depth = Math.max(0, scan.depth - 1);
|
||||
}
|
||||
|
||||
function splitStyleDeclarations(style: string): string[] {
|
||||
const declarations: string[] = [];
|
||||
const scan: StyleDeclarationScan = { depth: 0, quote: null, skip: false };
|
||||
let start = 0;
|
||||
for (let i = 0; i < style.length; i++) {
|
||||
if (scan.skip) {
|
||||
scan.skip = false;
|
||||
continue;
|
||||
}
|
||||
const ch = style[i] ?? "";
|
||||
if (ch === ";" && scan.depth === 0 && scan.quote === null) {
|
||||
declarations.push(style.slice(start, i));
|
||||
start = i + 1;
|
||||
} else {
|
||||
advanceStyleDeclarationScan(scan, ch, style[i + 1] ?? "");
|
||||
}
|
||||
}
|
||||
declarations.push(style.slice(start));
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function parseStyleAttr(styleAttr: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const decl of splitStyleDeclarations(styleAttr)) {
|
||||
const idx = decl.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const rawProp = decl.slice(0, idx).trim();
|
||||
const value = decl.slice(idx + 1).trim();
|
||||
if (!rawProp) continue;
|
||||
result[toCamel(rawProp)] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Serialize camelCase style map → style attribute string. */
|
||||
function serializeStyleAttr(styles: Record<string, string>): string {
|
||||
return Object.entries(styles)
|
||||
.map(([k, v]) => `${toKebab(k)}: ${v}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
export function getElementStyles(el: Element): Record<string, string> {
|
||||
const attr = el.getAttribute("style") ?? "";
|
||||
return parseStyleAttr(attr);
|
||||
}
|
||||
|
||||
export function setElementStyles(el: Element, updates: Record<string, string | null>): void {
|
||||
const current = getElementStyles(el);
|
||||
for (const [prop, value] of Object.entries(updates)) {
|
||||
// Stored map is keyed camelCase (parseStyleAttr); custom props (--foo) stay
|
||||
// verbatim. Normalize the incoming key the same way for both set and delete.
|
||||
const key = toCamel(prop);
|
||||
if (value === null) {
|
||||
delete current[key];
|
||||
} else {
|
||||
current[key] = value;
|
||||
}
|
||||
}
|
||||
const serialized = serializeStyleAttr(current);
|
||||
if (serialized) {
|
||||
el.setAttribute("style", serialized);
|
||||
} else {
|
||||
el.removeAttribute("style");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Text helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function isHTMLElementTarget(el: Element): boolean {
|
||||
const HTMLElementCtor = el.ownerDocument.defaultView?.HTMLElement;
|
||||
if (HTMLElementCtor) return el instanceof HTMLElementCtor;
|
||||
return "style" in el;
|
||||
}
|
||||
|
||||
function resolveSingleChildTextTarget(el: Element): Element | null {
|
||||
const inner = el.children.length === 1 ? el.firstElementChild : null;
|
||||
return inner && isHTMLElementTarget(inner) ? inner : null;
|
||||
}
|
||||
|
||||
/** Read the text target used by SDK setText. */
|
||||
export function getOwnText(el: Element): string {
|
||||
const singleChild = resolveSingleChildTextTarget(el);
|
||||
if (singleChild) return singleChild.textContent ?? "";
|
||||
let text = "";
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Replace the SDK text target without destroying multi-child element structure. */
|
||||
export function setOwnText(el: Element, text: string): void {
|
||||
const singleChild = resolveSingleChildTextTarget(el);
|
||||
if (singleChild) {
|
||||
singleChild.textContent = text;
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = el.ownerDocument;
|
||||
const children = Array.from(el.childNodes);
|
||||
// Track original position of the first text node so we restore there, not at firstChild.
|
||||
let firstTextIdx = -1;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i]?.nodeType === 3) {
|
||||
firstTextIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const child of children) {
|
||||
if (child.nodeType === 3) el.removeChild(child);
|
||||
}
|
||||
if (text) {
|
||||
// No text nodes before firstTextIdx (it's the first one), so index is stable.
|
||||
const current = Array.from(el.childNodes);
|
||||
const ref = firstTextIdx >= 0 ? (current[firstTextIdx] ?? null) : null;
|
||||
el.insertBefore(doc.createTextNode(text), ref);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CSS style helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function findStyleElement(document: Document): Element | null {
|
||||
return document.querySelector("style") as unknown as Element | null;
|
||||
}
|
||||
|
||||
export function getStyleSheet(document: Document): string {
|
||||
return findStyleElement(document)?.textContent ?? "";
|
||||
}
|
||||
|
||||
export function setStyleSheet(document: Document, css: string): void {
|
||||
const existing = findStyleElement(document);
|
||||
if (!css) {
|
||||
existing?.remove();
|
||||
return;
|
||||
}
|
||||
let el = existing;
|
||||
if (!el) {
|
||||
el = document.createElement("style") as unknown as Element;
|
||||
const head =
|
||||
(document.querySelector("head") as unknown as Element | null) ??
|
||||
(document.body as unknown as Element);
|
||||
(head as any).appendChild(el);
|
||||
}
|
||||
el.textContent = css;
|
||||
}
|
||||
|
||||
// ─── GSAP script helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function findScriptElementsDeep(document: Document): Element[] {
|
||||
const scripts: Element[] = [];
|
||||
walkCompositionDescendants(document, (child) => {
|
||||
if (child.tagName.toLowerCase() === "script") scripts.push(child);
|
||||
});
|
||||
return scripts;
|
||||
}
|
||||
|
||||
function isGsapScriptText(text: string): boolean {
|
||||
return text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger");
|
||||
}
|
||||
|
||||
export function getGsapScripts(document: Document): string[] {
|
||||
return findScriptElementsDeep(document)
|
||||
.map((script) => script.textContent ?? "")
|
||||
.filter(isGsapScriptText);
|
||||
}
|
||||
|
||||
function findGsapScriptElement(document: Document): Element | null {
|
||||
for (const script of findScriptElementsDeep(document)) {
|
||||
const text = script.textContent ?? "";
|
||||
if (isGsapScriptText(text)) return script;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getGsapScript(document: Document): string | null {
|
||||
const el = findGsapScriptElement(document);
|
||||
return el ? (el.textContent ?? "") : null;
|
||||
}
|
||||
|
||||
export function setGsapScript(document: Document, newScript: string): void {
|
||||
const existing = findGsapScriptElement(document);
|
||||
if (!newScript) {
|
||||
existing?.remove();
|
||||
return;
|
||||
}
|
||||
let el = existing;
|
||||
if (!el) {
|
||||
el = document.createElement("script") as unknown as Element;
|
||||
const head =
|
||||
(document.querySelector("head") as unknown as Element | null) ??
|
||||
(document.body as unknown as Element);
|
||||
(head as any).appendChild(el);
|
||||
}
|
||||
el.textContent = newScript;
|
||||
}
|
||||
|
||||
// ─── Sibling index ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getSiblingIndex(el: Element): number {
|
||||
const parent = el.parentElement;
|
||||
if (!parent) return 0;
|
||||
return Array.from(parent.children).indexOf(el);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* setClassStyle handler tests — flat CSS rule upsert via <style> element.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseMutable } from "./model.js";
|
||||
import { applyOp, validateOp } from "./mutate.js";
|
||||
import { applyPatchesToDocument } from "./apply-patches.js";
|
||||
import { serializeDocument } from "./serialize.js";
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CSS = `.box { opacity: 0; transform: translateX(-50px); }
|
||||
.title { color: #fff; font-size: 64px; }
|
||||
`;
|
||||
|
||||
function makeHtml(style = CSS) {
|
||||
return `<!DOCTYPE html><html><head><style>${style}</style></head><body>
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||
<div data-hf-id="hf-box" class="box"></div>
|
||||
<h1 data-hf-id="hf-title" class="title">Hello</h1>
|
||||
</div></body></html>`.trim();
|
||||
}
|
||||
|
||||
function fresh(style = CSS) {
|
||||
return parseMutable(makeHtml(style));
|
||||
}
|
||||
|
||||
function getStyleText(parsed: ReturnType<typeof parseMutable>): string {
|
||||
const doc = serializeDocument(parsed);
|
||||
const m = /<style>([\s\S]*?)<\/style>/i.exec(doc);
|
||||
return m ? m[1]! : "";
|
||||
}
|
||||
|
||||
// ─── validateOp ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("validateOp setClassStyle", () => {
|
||||
it("returns ok:true (always valid — creates <style> if absent)", () => {
|
||||
expect(
|
||||
validateOp(fresh(), { type: "setClassStyle", selector: ".box", styles: { opacity: "1" } }).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ok:true even when no <style> element present", () => {
|
||||
const noStyle = parseMutable(
|
||||
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
|
||||
);
|
||||
expect(
|
||||
validateOp(noStyle, { type: "setClassStyle", selector: ".box", styles: { opacity: "1" } }).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setClassStyle: update existing rule ──────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — update existing rule", () => {
|
||||
function applyBoxOpacity1() {
|
||||
const result = applyOp(fresh(), {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1" },
|
||||
});
|
||||
return String(result.forward[0]?.value ?? "");
|
||||
}
|
||||
|
||||
it("adds a new property to an existing rule", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { color: "red" },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
expect(result.forward[0]?.path).toBe("/style/css");
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).toContain("color: red");
|
||||
expect(newCss).toContain("opacity: 0");
|
||||
});
|
||||
|
||||
it("overwrites an existing property value", () => {
|
||||
const newCss = applyBoxOpacity1();
|
||||
expect(newCss).toContain("opacity: 1");
|
||||
expect(newCss).not.toContain("opacity: 0");
|
||||
expect(newCss).toContain("translateX(-50px)");
|
||||
});
|
||||
|
||||
it("removes a property when value is null", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: null },
|
||||
});
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).not.toContain("opacity");
|
||||
expect(newCss).toContain("translateX(-50px)");
|
||||
});
|
||||
|
||||
it("leaves other rules untouched", () => {
|
||||
const newCss = applyBoxOpacity1();
|
||||
expect(newCss).toContain(".title");
|
||||
expect(newCss).toContain("color: #fff");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setClassStyle: insert new rule ──────────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — insert new rule", () => {
|
||||
it("appends a new rule when selector not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".new",
|
||||
styles: { display: "flex", gap: "8px" },
|
||||
});
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).toContain(".new");
|
||||
expect(newCss).toContain("display: flex");
|
||||
expect(newCss).toContain("gap: 8px");
|
||||
expect(newCss).toContain(".box");
|
||||
});
|
||||
|
||||
it("creates <style> element when none exists", () => {
|
||||
const noStyle = parseMutable(
|
||||
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
|
||||
);
|
||||
const result = applyOp(noStyle, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1" },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).toContain(".box");
|
||||
expect(newCss).toContain("opacity: 1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── no-op cases ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — no-ops", () => {
|
||||
it("returns EMPTY when all values are null and selector not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".nonexistent",
|
||||
styles: { opacity: null },
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── inverse restores original ────────────────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — inverse patches", () => {
|
||||
it("inverse restores original CSS", () => {
|
||||
const parsed = fresh();
|
||||
const original = getStyleText(parsed);
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1", color: "blue" },
|
||||
});
|
||||
applyPatchesToDocument(parsed, result.inverse);
|
||||
expect(getStyleText(parsed)).toBe(original);
|
||||
});
|
||||
|
||||
it("undo on style-less composition does not create spurious <style> element", () => {
|
||||
const noStyle = parseMutable(
|
||||
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
|
||||
);
|
||||
const result = applyOp(noStyle, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1" },
|
||||
});
|
||||
applyPatchesToDocument(noStyle, result.inverse);
|
||||
const html = serializeDocument(noStyle);
|
||||
expect(html).not.toContain("<style");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── semicolon-containing CSS values ─────────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — CSS values with semicolons (data URIs)", () => {
|
||||
it("preserves data URI value when updating another property in same rule", () => {
|
||||
const dataUriCss = ".hero { background: url(data:image/png;base64,abc=); color: red; }\n";
|
||||
const parsed = fresh(dataUriCss);
|
||||
const result = applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".hero",
|
||||
styles: { color: "blue" },
|
||||
});
|
||||
const newCss = String(result.forward[0]?.value ?? "");
|
||||
expect(newCss).toContain("url(data:image/png;base64,abc=)");
|
||||
expect(newCss).toContain("color: blue");
|
||||
expect(newCss).not.toContain("color: red");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DOM side-effect ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("setClassStyle — DOM mutation", () => {
|
||||
it("mutates the live <style> element in the document", () => {
|
||||
const parsed = fresh();
|
||||
applyOp(parsed, {
|
||||
type: "setClassStyle",
|
||||
selector: ".box",
|
||||
styles: { opacity: "1" },
|
||||
});
|
||||
expect(getStyleText(parsed)).toContain("opacity: 1");
|
||||
expect(getStyleText(parsed)).not.toContain("opacity: 0");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* RFC 6902 patch path grammar (F2) and override-set key mapping (F2 item 7).
|
||||
*
|
||||
* Path grammar:
|
||||
* /elements/{hfId}/inlineStyles/{camelCaseProp}
|
||||
* /elements/{hfId}/text
|
||||
* /elements/{hfId}/attributes/{name}
|
||||
* /elements/{hfId}/timing/{start|end|duration|trackIndex} ← end = computed absolute data-end
|
||||
* /elements/{hfId}/hold/{start|end|fill}
|
||||
* /elements/{hfId} ← whole subtree (removeElement)
|
||||
* /variables/{variableId} ← declaration's default value
|
||||
* /variableDeclarations/{variableId} ← whole declaration object
|
||||
* /metadata/{width|height|duration}
|
||||
* /script/gsap ← GSAP inline script textContent
|
||||
* /style/css ← <style> element textContent
|
||||
*
|
||||
* Override-set key mapping:
|
||||
* /elements/hf-x/inlineStyles/fontSize → "hf-x.style.fontSize"
|
||||
* /elements/hf-x/text → "hf-x.text"
|
||||
* /elements/hf-x/attributes/src → "hf-x.attr.src"
|
||||
* /elements/hf-x/timing/start → "hf-x.timing.start"
|
||||
* /elements/hf-x/hold/start → "hf-x.hold.start"
|
||||
* /elements/hf-x → "hf-x" (null = removal marker)
|
||||
* /variables/brand-color-primary → "var.brand-color-primary"
|
||||
* /variableDeclarations/brand-color-primary → "varDecl.brand-color-primary"
|
||||
* /metadata/width → "meta.width"
|
||||
* /script/gsap → "script.gsap"
|
||||
* /style/css → "style.css"
|
||||
*/
|
||||
|
||||
import type { JsonPatchOp, PatchEvent } from "../types.js";
|
||||
|
||||
// ─── Path builders ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* RFC 6902 JSON Pointer escaping for an hf-id (bare or scoped).
|
||||
* Scoped ids contain "/" which must be encoded as "~1" in a path segment.
|
||||
* "~" must be encoded as "~0" first (order matters per RFC 6902 §3).
|
||||
*/
|
||||
function escapeIdForPath(id: string): string {
|
||||
return id.replace(/~/g, "~0").replace(/\//g, "~1");
|
||||
}
|
||||
|
||||
/** Decode a path segment that may contain RFC 6902-escaped characters back to an hf-id. */
|
||||
function decodePathSegment(segment: string): string {
|
||||
// RFC 6902 §3: unescape ~1 → /, then ~0 → ~ (reverse order)
|
||||
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
||||
}
|
||||
|
||||
export function stylePath(id: string, prop: string): string {
|
||||
return `/elements/${escapeIdForPath(id)}/inlineStyles/${prop}`;
|
||||
}
|
||||
|
||||
export function textPath(id: string): string {
|
||||
return `/elements/${escapeIdForPath(id)}/text`;
|
||||
}
|
||||
|
||||
export function attrPath(id: string, name: string): string {
|
||||
// RFC 6902 JSON Pointer: ~ → ~0, / → ~1
|
||||
const escapedName = name.replace(/~/g, "~0").replace(/\//g, "~1");
|
||||
return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`;
|
||||
}
|
||||
|
||||
export function timingPath(id: string, field: "start" | "end" | "duration" | "trackIndex"): string {
|
||||
return `/elements/${escapeIdForPath(id)}/timing/${field}`;
|
||||
}
|
||||
|
||||
export function holdPath(id: string, field: "start" | "end" | "fill"): string {
|
||||
return `/elements/${escapeIdForPath(id)}/hold/${field}`;
|
||||
}
|
||||
|
||||
export function elementPath(id: string): string {
|
||||
return `/elements/${escapeIdForPath(id)}`;
|
||||
}
|
||||
|
||||
export function variablePath(id: string): string {
|
||||
return `/variables/${id}`;
|
||||
}
|
||||
|
||||
/** Whole-declaration path — distinct from /variables/{id}, which is the default *value*. */
|
||||
export function variableDeclPath(id: string): string {
|
||||
return `/variableDeclarations/${id}`;
|
||||
}
|
||||
|
||||
export function metaPath(field: "width" | "height" | "duration"): string {
|
||||
return `/metadata/${field}`;
|
||||
}
|
||||
|
||||
export function gsapScriptPath(): string {
|
||||
return "/script/gsap";
|
||||
}
|
||||
|
||||
export function styleSheetPath(): string {
|
||||
return "/style/css";
|
||||
}
|
||||
|
||||
// ─── Override-set key mapping ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps an RFC 6902 patch path to its override-set key.
|
||||
* Returns null for paths that don't correspond to override-set entries.
|
||||
*/
|
||||
export function pathToKey(path: string): string | null {
|
||||
// /elements/{id}/inlineStyles/{prop} → "{id}.style.{prop}"
|
||||
// id segment may contain ~1 (RFC 6902-escaped "/") for scoped ids
|
||||
const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
|
||||
if (styleMatch) return `${decodePathSegment(styleMatch[1]!)}.style.${styleMatch[2]}`;
|
||||
|
||||
// /elements/{id}/text → "{id}.text"
|
||||
const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path);
|
||||
if (textMatch) return `${decodePathSegment(textMatch[1]!)}.text`;
|
||||
|
||||
// /elements/{id}/attributes/{name} → "{id}.attr.{name}"
|
||||
const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
|
||||
if (attrMatch) return `${decodePathSegment(attrMatch[1]!)}.attr.${attrMatch[2]}`;
|
||||
|
||||
// /elements/{id}/timing/{field} → "{id}.timing.{field}"
|
||||
// Note: field "end" maps to the computed data-end attribute value.
|
||||
const timingMatch = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path);
|
||||
if (timingMatch) return `${decodePathSegment(timingMatch[1]!)}.timing.${timingMatch[2]}`;
|
||||
|
||||
// /elements/{id}/hold/{field} → "{id}.hold.{field}"
|
||||
const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path);
|
||||
if (holdMatch) return `${decodePathSegment(holdMatch[1]!)}.hold.${holdMatch[2]}`;
|
||||
|
||||
// /elements/{id} (whole element) → "{id}"
|
||||
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
|
||||
if (elemMatch) return decodePathSegment(elemMatch[1]!);
|
||||
|
||||
// /variableDeclarations/{id} → "varDecl.{id}" (checked before /variables/)
|
||||
const varDeclMatch = /^\/variableDeclarations\/(.+)$/.exec(path);
|
||||
if (varDeclMatch) return `varDecl.${varDeclMatch[1]}`;
|
||||
|
||||
// /variables/{id} → "var.{id}"
|
||||
const varMatch = /^\/variables\/(.+)$/.exec(path);
|
||||
if (varMatch) return `var.${varMatch[1]}`;
|
||||
|
||||
// /metadata/{field} → "meta.{field}"
|
||||
const metaMatch = /^\/metadata\/(.+)$/.exec(path);
|
||||
if (metaMatch) return `meta.${metaMatch[1]}`;
|
||||
|
||||
// /script/gsap → "script.gsap"
|
||||
if (path === "/script/gsap") return "script.gsap";
|
||||
|
||||
// /style/css → "style.css"
|
||||
if (path === "/style/css") return "style.css";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of pathToKey — maps an override-set key back to its RFC 6902 path.
|
||||
* Used to replay a stored override-set onto a fresh base document (T3 init).
|
||||
*/
|
||||
// Exhaustive key-family dispatcher — same shape as apply-patches.ts parsePath.
|
||||
// fallow-ignore-next-line complexity
|
||||
export function keyToPath(key: string): string | null {
|
||||
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
|
||||
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
|
||||
|
||||
const text = /^([^.]+)\.text$/.exec(key);
|
||||
if (text?.[1]) return textPath(text[1]);
|
||||
|
||||
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
|
||||
// The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim).
|
||||
// The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape
|
||||
// the already-encoded attr segment. Reconstruct manually.
|
||||
if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`;
|
||||
|
||||
const timing = /^([^.]+)\.timing\.(start|end|duration|trackIndex)$/.exec(key);
|
||||
if (timing?.[1])
|
||||
return timingPath(timing[1], timing[2] as "start" | "end" | "duration" | "trackIndex");
|
||||
|
||||
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
|
||||
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
|
||||
|
||||
const varDecl = /^varDecl\.(.+)$/.exec(key);
|
||||
if (varDecl?.[1]) return variableDeclPath(varDecl[1]);
|
||||
|
||||
const variable = /^var\.(.+)$/.exec(key);
|
||||
if (variable?.[1]) return variablePath(variable[1]);
|
||||
|
||||
const meta = /^meta\.(width|height|duration)$/.exec(key);
|
||||
if (meta) return metaPath(meta[1] as "width" | "height" | "duration");
|
||||
|
||||
if (key === "script.gsap") return gsapScriptPath();
|
||||
if (key === "style.css") return styleSheetPath();
|
||||
|
||||
// Bare element id — removal marker key.
|
||||
if (!key.includes(".")) return elementPath(key);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Patch event builder ──────────────────────────────────────────────────────
|
||||
|
||||
export function buildPatchEvent(
|
||||
forward: readonly JsonPatchOp[],
|
||||
inverse: readonly JsonPatchOp[],
|
||||
origin: unknown,
|
||||
opTypes: readonly string[],
|
||||
): PatchEvent {
|
||||
return { formatVersion: 1, patches: forward, inversePatches: inverse, origin, opTypes };
|
||||
}
|
||||
|
||||
// ─── Replace/add/remove helpers ───────────────────────────────────────────────
|
||||
|
||||
function patchReplace(path: string, value: unknown): JsonPatchOp {
|
||||
return { op: "replace", path, value };
|
||||
}
|
||||
|
||||
export function patchAdd(path: string, value: unknown): JsonPatchOp {
|
||||
return { op: "add", path, value };
|
||||
}
|
||||
|
||||
export function patchRemove(path: string): JsonPatchOp {
|
||||
return { op: "remove", path };
|
||||
}
|
||||
|
||||
/** Emit forward (replace or add) + inverse (replace or remove) for a scalar change. */
|
||||
export function scalarChange(
|
||||
path: string,
|
||||
oldValue: string | number | boolean | null | undefined,
|
||||
newValue: string | number | boolean,
|
||||
): { forward: JsonPatchOp; inverse: JsonPatchOp } {
|
||||
const forward = oldValue == null ? patchAdd(path, newValue) : patchReplace(path, newValue);
|
||||
const inverse = oldValue == null ? patchRemove(path) : patchReplace(path, oldValue ?? null);
|
||||
return { forward, inverse };
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit forward (replace or add) + inverse (replace or remove) for any JSON-serializable value.
|
||||
* Use instead of scalarChange when the value may be an object (e.g. font/image variable).
|
||||
* The old value is captured whole — no sub-key diffing.
|
||||
*/
|
||||
export function valueChange(
|
||||
path: string,
|
||||
oldValue: unknown,
|
||||
newValue: unknown,
|
||||
): { forward: JsonPatchOp; inverse: JsonPatchOp } {
|
||||
const forward = oldValue == null ? patchAdd(path, newValue) : patchReplace(path, newValue);
|
||||
const inverse = oldValue == null ? patchRemove(path) : patchReplace(path, oldValue);
|
||||
return { forward, inverse };
|
||||
}
|
||||
|
||||
/** Emit forward remove + inverse add for a deletion. */
|
||||
export function scalarDelete(
|
||||
path: string,
|
||||
oldValue: string | number | boolean,
|
||||
): { forward: JsonPatchOp; inverse: JsonPatchOp } {
|
||||
return {
|
||||
forward: patchRemove(path),
|
||||
inverse: patchAdd(path, oldValue),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* HTML serializer — walks the live linkedom Document and generates clean HF HTML.
|
||||
*
|
||||
* Phase 3a: generates from the live DOM. The DOM IS the mutable state.
|
||||
* Phase 3b: GSAP script section will use the meriyah/offset-splice path once available.
|
||||
*/
|
||||
|
||||
import type { ParsedDocument } from "./model.js";
|
||||
|
||||
/**
|
||||
* Serialize the live document back to HTML.
|
||||
*
|
||||
* If the original input was a fragment (wrapped=true), returns only body content.
|
||||
* If the original input had a full HTML shell (wrapped=false), returns the full document.
|
||||
*/
|
||||
export function serializeDocument(parsed: ParsedDocument): string {
|
||||
const doc = parsed.document;
|
||||
if (parsed.wrapped) {
|
||||
return (doc.body as HTMLBodyElement).innerHTML ?? "";
|
||||
}
|
||||
return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Shared helpers for the composition variable JSON model
|
||||
* (`data-composition-variables`). The declaration-carrying element is resolved
|
||||
* by the caller (`declarationElement` in model.ts): `<html>` for full-document
|
||||
* comps, the composition root div for wrapped template/fragment comps.
|
||||
*
|
||||
* Single source for the parse → find-by-id → read/write/clear logic so the
|
||||
* forward-mutation path (engine/mutate.ts) and the patch-replay path
|
||||
* (engine/apply-patches.ts) can never disagree on the model's shape.
|
||||
*/
|
||||
|
||||
// Browser-safe subpath — the core/parsers root entries pull Node-only modules
|
||||
// and would break browser bundles that include the SDK (e.g. Studio).
|
||||
import { parseCompositionVariables } from "@hyperframes/core/variables";
|
||||
import type { CompositionVariable } from "@hyperframes/core/variables";
|
||||
|
||||
// Exported so the SDK index can re-export it (kept from #2098's surface).
|
||||
export type VariableDecl = { id: string; default?: unknown; [key: string]: unknown };
|
||||
|
||||
/** Parse the variable declaration array, or null when absent/invalid. */
|
||||
function readDecls(declEl: Element | null): { declEl: Element; arr: VariableDecl[] } | null {
|
||||
if (!declEl) return null;
|
||||
const raw = declEl.getAttribute("data-composition-variables");
|
||||
if (!raw) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return { declEl, arr: parsed as VariableDecl[] };
|
||||
}
|
||||
|
||||
function indexOfId(arr: VariableDecl[], id: string): number {
|
||||
return arr.findIndex((v) => typeof v === "object" && v !== null && v.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the typed variable declarations from `data-composition-variables`.
|
||||
* Delegates to the canonical parser (same filter the render pipeline uses),
|
||||
* so malformed entries are dropped rather than surfaced. Returns `[]` when
|
||||
* the declaration element is absent or has no declarations.
|
||||
*/
|
||||
export function readVariableDeclarations(declEl: Element | null): CompositionVariable[] {
|
||||
if (!declEl) return [];
|
||||
return parseCompositionVariables(declEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the raw declaration entry for a variable id, verbatim (unvalidated).
|
||||
* Returns undefined when the attribute is absent, the JSON is invalid, or no
|
||||
* entry matches the id.
|
||||
*/
|
||||
export function findVariableDeclaration(
|
||||
declEl: Element | null,
|
||||
id: string,
|
||||
): VariableDecl | undefined {
|
||||
const decls = readDecls(declEl);
|
||||
if (!decls) return undefined;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
return idx < 0 ? undefined : decls.arr[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a whole variable declaration by its id. Creates the
|
||||
* `data-composition-variables` attribute when absent; replaces an unparseable
|
||||
* attribute with a fresh single-entry array (the prior content was invisible
|
||||
* to every reader anyway). Returns false only when there is no declaration
|
||||
* element to carry the attribute.
|
||||
*
|
||||
* Accepts raw (unvalidated) entries as well as typed declarations: the patch
|
||||
* REPLAY path must faithfully restore whatever entry the inverse patch
|
||||
* captured — including loose hand-authored declarations the strict parser
|
||||
* would drop — or undo silently diverges from history.
|
||||
*/
|
||||
export function writeVariableDeclaration(
|
||||
declEl: Element | null,
|
||||
declaration: CompositionVariable | ({ id: string } & Record<string, unknown>),
|
||||
): boolean {
|
||||
if (!declEl) return false;
|
||||
const decls = readDecls(declEl);
|
||||
const arr = decls?.arr ?? [];
|
||||
const idx = indexOfId(arr, declaration.id);
|
||||
const entry: VariableDecl = { ...declaration };
|
||||
if (idx < 0) {
|
||||
arr.push(entry);
|
||||
} else {
|
||||
arr[idx] = entry;
|
||||
}
|
||||
declEl.setAttribute("data-composition-variables", JSON.stringify(arr));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a variable declaration by id. Drops the whole attribute when the
|
||||
* last declaration is removed (an empty `[]` is noise in authored HTML).
|
||||
* No-ops (returns false) when the attribute or the entry is absent.
|
||||
*/
|
||||
export function removeVariableDeclarationEntry(declEl: Element | null, id: string): boolean {
|
||||
const decls = readDecls(declEl);
|
||||
if (!decls) return false;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
if (idx < 0) return false;
|
||||
decls.arr.splice(idx, 1);
|
||||
if (decls.arr.length === 0) {
|
||||
decls.declEl.removeAttribute("data-composition-variables");
|
||||
} else {
|
||||
decls.declEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current `default` value for a variable id. Returns undefined when
|
||||
* the attribute is absent, the JSON is invalid, or no entry matches the id.
|
||||
*/
|
||||
export function readVariableDefault(declEl: Element | null, id: string): unknown {
|
||||
const decls = readDecls(declEl);
|
||||
if (!decls) return undefined;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
return idx < 0 ? undefined : decls.arr[idx]?.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a variable's `default`. No-ops (returns false) when the attribute is
|
||||
* absent or contains no declaration for the id — we never auto-add declarations
|
||||
* for undeclared variables, keeping the schema authoritative. Returns true when
|
||||
* the attribute was updated.
|
||||
*/
|
||||
export function writeVariableDefault(
|
||||
declEl: Element | null,
|
||||
id: string,
|
||||
newDefault: unknown,
|
||||
): boolean {
|
||||
const decls = readDecls(declEl);
|
||||
if (!decls) return false;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
if (idx < 0) return false; // variable not declared — don't auto-add
|
||||
decls.arr[idx] = { ...decls.arr[idx]!, default: newDefault };
|
||||
decls.declEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the `default` key from a variable declaration, restoring its
|
||||
* "no authored default" state. This is the exact inverse of writeVariableDefault
|
||||
* adding a default to a decl that had none, so undo of a first-set on a
|
||||
* default-less variable round-trips. No-ops when the decl or key is absent.
|
||||
* Returns true when the attribute was updated.
|
||||
*/
|
||||
export function clearVariableDefault(declEl: Element | null, id: string): boolean {
|
||||
const decls = readDecls(declEl);
|
||||
if (!decls) return false;
|
||||
const idx = indexOfId(decls.arr, id);
|
||||
if (idx < 0 || !(decls.arr[idx]! && "default" in decls.arr[idx]!)) return false;
|
||||
const { default: _drop, ...rest } = decls.arr[idx]!;
|
||||
decls.arr[idx] = rest as VariableDecl;
|
||||
decls.declEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
|
||||
return true;
|
||||
}
|
||||
|
||||
// NOTE: #2098's Document-based helpers (listVariableDecls / declareVariableDecl /
|
||||
// removeVariableDecl) were removed in the #2098-reconciliation — they duplicated
|
||||
// the canonical declEl-based readVariableDeclarations / writeVariableDeclaration /
|
||||
// removeVariableDeclarationEntry below and predate template/fragment declaration
|
||||
// scope. The session conveniences (listVariables / removeVariable) delegate to
|
||||
// the canonical methods instead.
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Optional history module (F5 layering).
|
||||
*
|
||||
* Wires onto a Composition session via on('patch') and implements undo/redo.
|
||||
* Coalesces same-op+same-targets bursts within coalesceMs into one undo entry.
|
||||
*
|
||||
* Usage (standalone / T1):
|
||||
* const comp = await openComposition(html, { persist });
|
||||
* // openComposition attaches this automatically in non-embedded mode.
|
||||
*
|
||||
* Usage (manual / custom undo timeline):
|
||||
* const history = createHistory(comp, { coalesceMs: 500, trackedOrigins: ['local'] });
|
||||
* // host calls history.undo() / history.redo() instead of comp.undo() / comp.redo()
|
||||
*/
|
||||
|
||||
import type { Composition, JsonPatchOp, PatchEvent } from "./types.js";
|
||||
import { ORIGIN_APPLY_PATCHES } from "./types.js";
|
||||
|
||||
export interface HistoryEntry {
|
||||
readonly patches: readonly JsonPatchOp[];
|
||||
readonly inversePatches: readonly JsonPatchOp[];
|
||||
readonly opTypes: readonly string[];
|
||||
readonly origin: unknown;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
export interface HistoryModule {
|
||||
undo(): boolean;
|
||||
redo(): boolean;
|
||||
canUndo(): boolean;
|
||||
canRedo(): boolean;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface HistoryOptions {
|
||||
/** Only ops with these origins enter the undo stack. Default: all non-ORIGIN_APPLY_PATCHES. */
|
||||
trackedOrigins?: unknown[];
|
||||
/** Coalesce window in ms. Same opTypes + same origin within window → one entry. Default: 300. */
|
||||
coalesceMs?: number;
|
||||
/** Max undo stack depth. Default: 100. */
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
export function createHistory(session: Composition, opts: HistoryOptions = {}): HistoryModule {
|
||||
const coalesceMs = opts.coalesceMs ?? 300;
|
||||
const maxEntries = opts.maxEntries ?? 100;
|
||||
const { trackedOrigins } = opts;
|
||||
|
||||
const undoStack: HistoryEntry[] = [];
|
||||
let redoStack: HistoryEntry[] = [];
|
||||
|
||||
function isTracked(origin: unknown): boolean {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return false;
|
||||
if (!trackedOrigins) return true;
|
||||
return trackedOrigins.includes(origin);
|
||||
}
|
||||
|
||||
function pathsKey(patches: readonly JsonPatchOp[]): string {
|
||||
return patches
|
||||
.map((p) => p.path)
|
||||
.sort()
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function opTypesKey(opTypes: readonly string[]): string {
|
||||
// Sorted: the same op-type SET coalesces regardless of dispatch order.
|
||||
return [...opTypes].sort().join(",");
|
||||
}
|
||||
|
||||
function shouldCoalesce(entry: HistoryEntry, incoming: PatchEvent): boolean {
|
||||
if (coalesceMs <= 0) return false;
|
||||
if (opTypesKey(entry.opTypes) !== opTypesKey(incoming.opTypes)) return false;
|
||||
if (entry.origin !== incoming.origin) return false;
|
||||
// Coalesce only when the SAME paths are touched (e.g. slider drag on one
|
||||
// property). Without this, rapid edits to different elements would merge
|
||||
// into one entry holding the second forward + first inverse — undo would
|
||||
// then revert the wrong element.
|
||||
if (pathsKey(entry.patches) !== pathsKey(incoming.patches)) return false;
|
||||
const now = Date.now();
|
||||
return now - entry.timestamp <= coalesceMs;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const unsubscribe = session.on("patch", (event: PatchEvent) => {
|
||||
if (!isTracked(event.origin)) return;
|
||||
|
||||
const last = undoStack[undoStack.length - 1];
|
||||
if (last && shouldCoalesce(last, event)) {
|
||||
// Coalesce: keep first inverse (original prev), replace forward with latest value.
|
||||
// Slide timestamp forward so rapid-fire edits keep coalescing.
|
||||
const coalesced: HistoryEntry = {
|
||||
patches: event.patches,
|
||||
inversePatches: last.inversePatches,
|
||||
opTypes: last.opTypes,
|
||||
origin: last.origin,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
undoStack[undoStack.length - 1] = coalesced;
|
||||
} else {
|
||||
undoStack.push({
|
||||
patches: event.patches,
|
||||
inversePatches: event.inversePatches,
|
||||
opTypes: event.opTypes,
|
||||
origin: event.origin,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (undoStack.length > maxEntries) undoStack.shift();
|
||||
}
|
||||
|
||||
// Any new op clears the redo stack.
|
||||
redoStack = [];
|
||||
});
|
||||
|
||||
return {
|
||||
undo(): boolean {
|
||||
const entry = undoStack.pop();
|
||||
if (!entry) return false;
|
||||
session.applyPatches(entry.inversePatches, { origin: ORIGIN_APPLY_PATCHES });
|
||||
redoStack.push(entry);
|
||||
return true;
|
||||
},
|
||||
|
||||
redo(): boolean {
|
||||
const entry = redoStack.pop();
|
||||
if (!entry) return false;
|
||||
session.applyPatches(entry.patches, { origin: ORIGIN_APPLY_PATCHES });
|
||||
undoStack.push(entry);
|
||||
return true;
|
||||
},
|
||||
|
||||
canUndo(): boolean {
|
||||
return undoStack.length > 0;
|
||||
},
|
||||
|
||||
canRedo(): boolean {
|
||||
return redoStack.length > 0;
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
unsubscribe();
|
||||
undoStack.length = 0;
|
||||
redoStack.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export type {
|
||||
HyperFramesElement,
|
||||
SdkDocument,
|
||||
OverrideSet,
|
||||
EditOp,
|
||||
ElasticHold,
|
||||
FontValue,
|
||||
ImageValue,
|
||||
GsapTweenSpec,
|
||||
HfId,
|
||||
JsonPatchOp,
|
||||
PatchEvent,
|
||||
PersistErrorEvent,
|
||||
ElementSnapshot,
|
||||
ElementTimingSnapshot,
|
||||
FindQuery,
|
||||
SelectionProxy,
|
||||
ElementHandle,
|
||||
Composition,
|
||||
CanResult,
|
||||
} from "./types.js";
|
||||
|
||||
export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
|
||||
|
||||
// Variable schema types — re-exported so SDK consumers (Studio, embedders)
|
||||
// can type declarations without a direct @hyperframes/core dependency.
|
||||
export type {
|
||||
CompositionVariable,
|
||||
CompositionVariableType,
|
||||
CompositionVariableBase,
|
||||
StringVariable,
|
||||
NumberVariable,
|
||||
ColorVariable,
|
||||
BooleanVariable,
|
||||
EnumVariable,
|
||||
FontVariable,
|
||||
ImageVariable,
|
||||
VariableValidationIssue,
|
||||
VariableUsageScan,
|
||||
} from "@hyperframes/core/variables";
|
||||
export type { VariableUsageReport } from "./types.js";
|
||||
|
||||
export { UnsupportedOpError } from "./engine/mutate.js";
|
||||
|
||||
export { buildDocument, buildRoots, flatElements } from "./document.js";
|
||||
|
||||
export { isNewHostBoundary, bareId, resolveScoped, findById, escapeHfId } from "./engine/model.js";
|
||||
|
||||
export { readVariableDefault } from "./engine/variableModel.js";
|
||||
|
||||
export { openComposition } from "./session.js";
|
||||
export type { OpenCompositionOptions } from "./session.js";
|
||||
|
||||
export { createHistory } from "./history.js";
|
||||
export type { HistoryModule, HistoryOptions, HistoryEntry } from "./history.js";
|
||||
|
||||
export { createPersistQueue } from "./persist-queue.js";
|
||||
export type { PersistQueueModule, PersistQueueOptions } from "./persist-queue.js";
|
||||
|
||||
export type { PersistAdapter, PreviewAdapter, PersistVersionEntry } from "./adapters/types.js";
|
||||
|
||||
// Concrete adapter factories (browser-safe — Node-only fs adapter: @hyperframes/sdk/adapters/fs).
|
||||
export { createMemoryAdapter } from "./adapters/memory.js";
|
||||
export { createHeadlessAdapter } from "./adapters/headless.js";
|
||||
export { createIframePreviewAdapter, resolveNearestHfElement } from "./adapters/iframe.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Optional persist queue module (F5 layering).
|
||||
*
|
||||
* Subscribes to 'change' events and schedules async writes via a PersistAdapter.
|
||||
* One in-flight write at a time; latest state always wins (last-write-wins coalescing).
|
||||
*
|
||||
* Wired automatically by openComposition() in standalone (T1/T2) mode.
|
||||
* T3 (embedded) hosts own persistence — do not use this module.
|
||||
*/
|
||||
|
||||
import type { Composition, PersistErrorEvent } from "./types.js";
|
||||
import type { PersistAdapter } from "./adapters/types.js";
|
||||
|
||||
export interface PersistQueueModule {
|
||||
/** Force an immediate write (e.g. before app close). */
|
||||
flush(): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface PersistQueueOptions {
|
||||
/** Adapter path to write to. Default: "composition.html" */
|
||||
path?: string;
|
||||
/** Called when adapter.write() rejects. */
|
||||
onError?: (e: PersistErrorEvent) => void;
|
||||
}
|
||||
|
||||
export function createPersistQueue(
|
||||
session: Composition,
|
||||
adapter: PersistAdapter,
|
||||
opts: PersistQueueOptions = {},
|
||||
): PersistQueueModule {
|
||||
const path = opts.path ?? "composition.html";
|
||||
let pendingWrite: ReturnType<typeof setTimeout> | null = null;
|
||||
// Promise-chain mutex: each write chains onto the prior, preventing concurrent writes.
|
||||
let writeChain: Promise<void> = Promise.resolve();
|
||||
let disposed = false;
|
||||
|
||||
function scheduleWrite(): void {
|
||||
if (pendingWrite !== null) clearTimeout(pendingWrite);
|
||||
pendingWrite = setTimeout(() => {
|
||||
pendingWrite = null;
|
||||
void doWrite();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function doWrite(): Promise<void> {
|
||||
if (disposed) return Promise.resolve();
|
||||
const content = session.serialize();
|
||||
writeChain = writeChain.then(async () => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
await adapter.write(path, content);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
opts.onError?.({ error: { message, cause: err } });
|
||||
}
|
||||
});
|
||||
return writeChain;
|
||||
}
|
||||
|
||||
const unsubscribe = session.on("change", () => {
|
||||
scheduleWrite();
|
||||
});
|
||||
|
||||
return {
|
||||
async flush(): Promise<void> {
|
||||
if (pendingWrite !== null) {
|
||||
clearTimeout(pendingWrite);
|
||||
pendingWrite = null;
|
||||
}
|
||||
await doWrite();
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
if (pendingWrite !== null) {
|
||||
clearTimeout(pendingWrite);
|
||||
pendingWrite = null;
|
||||
}
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* T4 — dispatch-boundary tests.
|
||||
*
|
||||
* Tests the full pipeline: session.dispatch() → patch event → override-set.
|
||||
* Complements mutate.test.ts (which tests applyOp directly) by verifying
|
||||
* the session wiring layer.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
import type { Composition, PatchEvent } from "./types.js";
|
||||
|
||||
const BASE_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px; background: #000" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" data-start="0" data-end="3" data-track-index="0"
|
||||
style="color: #fff; font-size: 64px">Hello World</h1>
|
||||
<p data-hf-id="hf-sub" style="opacity: 0.5">subtitle</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
const GSAP_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||
<div data-hf-id="hf-box" style="opacity: 0"></div>
|
||||
<script>var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
|
||||
window.__timelines = { t: tl };</script>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
async function withPatch(html: string): Promise<{ comp: Composition; events: PatchEvent[] }> {
|
||||
const comp = await openComposition(html);
|
||||
const events: PatchEvent[] = [];
|
||||
comp.on("patch", (e) => events.push(e));
|
||||
return { comp, events };
|
||||
}
|
||||
|
||||
function expectGsapScriptPatch(id: string, events: PatchEvent[]): void {
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.patches.find((p) => p.path.includes("/script/gsap"))).toBeDefined();
|
||||
}
|
||||
|
||||
// ─── patch event emission ─────────────────────────────────────────────────────
|
||||
|
||||
describe("dispatch emits patch event", () => {
|
||||
it("setStyle emits forward replace + inverse replace", async () => {
|
||||
const { comp, events } = await withPatch(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#e63946" });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.patches[0]).toMatchObject({
|
||||
op: "replace",
|
||||
path: "/elements/hf-title/inlineStyles/color",
|
||||
value: "#e63946",
|
||||
});
|
||||
expect(events[0]!.inversePatches[0]).toMatchObject({
|
||||
op: "replace",
|
||||
path: "/elements/hf-title/inlineStyles/color",
|
||||
value: "#fff",
|
||||
});
|
||||
});
|
||||
|
||||
it("no-op dispatch (same value) fires change; patch may be empty", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const changes: number[] = [];
|
||||
comp.on("change", () => changes.push(1));
|
||||
|
||||
comp.setStyle("hf-title", { color: "#fff" }); // same value already set
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("patch event opTypes reflects dispatched op type", async () => {
|
||||
const { comp, events } = await withPatch(BASE_HTML);
|
||||
comp.setText("hf-sub", "new text");
|
||||
expect(events[0]?.opTypes).toContain("setText");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── override-set accumulation ────────────────────────────────────────────────
|
||||
|
||||
describe("override-set accumulation", () => {
|
||||
it("setStyle dispatch adds key to override-set", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#e63946" });
|
||||
expect(comp.getOverrides()["hf-title.style.color"]).toBe("#e63946");
|
||||
});
|
||||
|
||||
it("setText dispatch adds text key to override-set", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setText("hf-sub", "changed");
|
||||
expect(comp.getOverrides()["hf-sub.text"]).toBe("changed");
|
||||
});
|
||||
|
||||
it("setAttribute dispatch adds attr key", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.dispatch({ type: "setAttribute", target: "hf-title", name: "data-name", value: "hero" });
|
||||
expect(comp.getOverrides()["hf-title.attr.data-name"]).toBe("hero");
|
||||
});
|
||||
|
||||
it("removeElement dispatch sets null removal marker in override-set", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.removeElement("hf-sub");
|
||||
// element path key should map to null marker
|
||||
const overrides = comp.getOverrides();
|
||||
const removedKey = Object.keys(overrides).find((k) => k.startsWith("hf-sub"));
|
||||
expect(removedKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── can() structured result ──────────────────────────────────────────────────
|
||||
|
||||
describe("can() CanResult", () => {
|
||||
it("ok:true for valid setStyle target", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const r = comp.can({ type: "setStyle", target: "hf-title", styles: {} });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("ok:false / E_TARGET_NOT_FOUND for unknown id", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const r = comp.can({ type: "setStyle", target: "hf-missing", styles: {} });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.code).toBe("E_TARGET_NOT_FOUND");
|
||||
expect(r.message).toContain("hf-missing");
|
||||
expect(r.hint).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("ok:false / E_NO_GSAP_SCRIPT for GSAP op on non-GSAP composition", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const r = comp.can({ type: "removeGsapTween", animationId: "tw-1" });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.code).toBe("E_NO_GSAP_SCRIPT");
|
||||
});
|
||||
|
||||
it("ok:true for addGsapTween on GSAP composition", async () => {
|
||||
const comp = await openComposition(GSAP_HTML);
|
||||
const r = comp.can({
|
||||
type: "addGsapTween",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 100 } },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("ok:false / E_NO_GSAP_TIMELINE when script has no timeline var (addLabel path)", async () => {
|
||||
const noTimelineHtml = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<div data-hf-id="hf-box"></div>
|
||||
<script>gsap.defaults({ ease: "power1.out" });
|
||||
window.__timelines = {};</script>
|
||||
</div>`.trim();
|
||||
const comp = await openComposition(noTimelineHtml);
|
||||
const r = comp.can({ type: "addLabel", name: "intro", position: 0 });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.code).toBe("E_NO_GSAP_TIMELINE");
|
||||
});
|
||||
|
||||
it("ok:true for setCompositionMetadata always", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
expect(comp.can({ type: "setCompositionMetadata", width: 1920 }).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batch() emits single patch event ────────────────────────────────────────
|
||||
|
||||
describe("batch() patch event", () => {
|
||||
it("collapses N dispatches into one patch event with all op types", async () => {
|
||||
const { comp, events } = await withPatch(BASE_HTML);
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#111" });
|
||||
comp.setText("hf-sub", "batched");
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.patches.length).toBeGreaterThanOrEqual(2);
|
||||
expect(events[0]!.opTypes).toContain("setStyle");
|
||||
expect(events[0]!.opTypes).toContain("setText");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addGsapTween via session API ─────────────────────────────────────────────
|
||||
|
||||
describe("addGsapTween via session", () => {
|
||||
it("returns animationId and emits GSAP script patch", async () => {
|
||||
const { comp, events } = await withPatch(GSAP_HTML);
|
||||
const id = comp.addGsapTween("hf-box", { method: "to", duration: 0.3, properties: { x: 200 } });
|
||||
|
||||
expectGsapScriptPatch(id, events);
|
||||
});
|
||||
|
||||
it("undo removes the added tween", async () => {
|
||||
const comp = await openComposition(GSAP_HTML);
|
||||
const scriptBefore = comp.serialize();
|
||||
comp.addGsapTween("hf-box", { method: "to", duration: 0.3, properties: { x: 200 } });
|
||||
comp.undo();
|
||||
expect(comp.serialize()).toBe(scriptBefore);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addWithKeyframes / replaceWithKeyframes via session API ──────────────────
|
||||
|
||||
describe("keyframe ops via session", () => {
|
||||
it("addWithKeyframes returns an animationId and emits a GSAP script patch", async () => {
|
||||
const { comp, events } = await withPatch(GSAP_HTML);
|
||||
const id = comp.addWithKeyframes('[data-hf-id="hf-box"]', 0, 0.5, [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
]);
|
||||
|
||||
expectGsapScriptPatch(id, events);
|
||||
});
|
||||
|
||||
it("replaceWithKeyframes returns the replacement id; undo restores the prior script", async () => {
|
||||
const comp = await openComposition(GSAP_HTML);
|
||||
const before = comp.serialize();
|
||||
const addId = comp.addWithKeyframes('[data-hf-id="hf-box"]', 0, 0.5, [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
]);
|
||||
const newId = comp.replaceWithKeyframes(addId, '[data-hf-id="hf-box"]', 0, 0.8, [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
]);
|
||||
|
||||
expect(typeof newId).toBe("string");
|
||||
expect(newId.length).toBeGreaterThan(0);
|
||||
|
||||
comp.undo(); // undo replace
|
||||
comp.undo(); // undo add
|
||||
expect(comp.serialize()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── dispatch with explicit origin ───────────────────────────────────────────
|
||||
|
||||
describe("dispatch origin", () => {
|
||||
it("custom origin is propagated to patch event", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const events: PatchEvent[] = [];
|
||||
comp.on("patch", (e) => events.push(e));
|
||||
|
||||
const MY_ORIGIN = Symbol("ai-agent");
|
||||
comp.dispatch({ type: "setText", target: "hf-title", value: "AI edit" }, { origin: MY_ORIGIN });
|
||||
|
||||
expect(events[0]?.origin).toBe(MY_ORIGIN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* T-contract: sub-composition scoped id suite (Stage 6 / F9).
|
||||
*
|
||||
* All tests use pre-inlined HTML (flat DOM with data-composition-file boundaries)
|
||||
* because the SDK only opens pre-inlined HTML — sub-comp loading is not the SDK's job.
|
||||
*
|
||||
* Boundary detection rule: an element is a host (starts a new scope) when it has
|
||||
* data-composition-file AND its value differs from its parent's data-composition-file.
|
||||
* This correctly handles the outerHTML innerRoot case (same dcf as parent → not a new host)
|
||||
* and nested hosts (different dcf from parent → new host).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ensureHfIds } from "@hyperframes/core/hf-ids";
|
||||
import { RUNTIME_BOOTSTRAP_ATTR } from "@hyperframes/core";
|
||||
import { resolveScoped, findById, isNewHostBoundary, bareId } from "./engine/model.js";
|
||||
import { parseMutable } from "./engine/model.js";
|
||||
import { buildRoots, flatElements } from "./document.js";
|
||||
import { openComposition } from "./session.js";
|
||||
|
||||
// ─── Fixture helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Build a flat inlined HTML string simulating what inlineSubCompositions produces. */
|
||||
function inlinedHtml(inner: string): string {
|
||||
return `<!DOCTYPE html><html><body>${inner}</body></html>`;
|
||||
}
|
||||
|
||||
/** Stamp hf-ids and return a linkedom document (same as parseMutable's path). */
|
||||
function makeDoc(html: string) {
|
||||
const { document } = parseHTML(ensureHfIds(html));
|
||||
return document;
|
||||
}
|
||||
|
||||
// ─── 1. resolveScoped ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveScoped — flat id", () => {
|
||||
it("resolves a bare id at top level (same as findById)", () => {
|
||||
const doc = makeDoc(
|
||||
`<!DOCTYPE html><html><body><div data-hf-id="hf-aaaa">hi</div></body></html>`,
|
||||
);
|
||||
const el = resolveScoped(doc as unknown as Document, "hf-aaaa");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el?.getAttribute("data-hf-id")).toBe("hf-aaaa");
|
||||
});
|
||||
|
||||
it("returns null for a missing bare id", () => {
|
||||
const doc = makeDoc(
|
||||
`<!DOCTYPE html><html><body><div data-hf-id="hf-aaaa"></div></body></html>`,
|
||||
);
|
||||
expect(resolveScoped(doc as unknown as Document, "hf-xxxx")).toBeNull();
|
||||
});
|
||||
|
||||
// A sub-composition ROOT is addressed by its composition id. When no element
|
||||
// carries that as a data-hf-id, fall back to [data-composition-id]: comp-ids
|
||||
// become first-class resolvable addresses (fixes validate / getElement).
|
||||
it("resolves a bare id to a sub-comp root via data-composition-id fallback", () => {
|
||||
const doc = makeDoc(
|
||||
`<!DOCTYPE html><html><body><div data-hf-id="hf-host" data-composition-id="sub-1"></div></body></html>`,
|
||||
) as unknown as Document;
|
||||
const viaComp = resolveScoped(doc, "sub-1");
|
||||
const viaHf = resolveScoped(doc, "hf-host");
|
||||
expect(viaComp).not.toBeNull();
|
||||
expect(viaComp?.getAttribute("data-hf-id")).toBe("hf-host");
|
||||
// Both addresses resolve to the SAME host element.
|
||||
expect(viaComp).toBe(viaHf);
|
||||
});
|
||||
|
||||
// data-hf-id MUST take precedence: a bare id that matches a real data-hf-id
|
||||
// never falls back to data-composition-id, even if some other element carries
|
||||
// that string as its composition id.
|
||||
it("data-hf-id takes precedence over data-composition-id for a bare id", () => {
|
||||
const doc = makeDoc(
|
||||
`<!DOCTYPE html><html><body>
|
||||
<div data-hf-id="dup" class="byHfId"></div>
|
||||
<div data-hf-id="hf-host" data-composition-id="dup" class="byCompId"></div>
|
||||
</body></html>`,
|
||||
) as unknown as Document;
|
||||
const el = resolveScoped(doc, "dup");
|
||||
expect(el?.getAttribute("class")).toBe("byHfId");
|
||||
});
|
||||
|
||||
// Regression: findById is the patch-replay/undo resolver. It must agree with
|
||||
// resolveScoped (forward dispatch) on an ambiguous bare id — both pick the
|
||||
// canonical (top-level) instance — or undo reverts the wrong duplicate.
|
||||
it("findById resolves an ambiguous bare id to the canonical instance (== resolveScoped)", () => {
|
||||
const doc = makeDoc(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-dup" class="inside">inside</p>
|
||||
</div>
|
||||
<p data-hf-id="hf-dup" class="outside">outside</p>
|
||||
`),
|
||||
) as unknown as Document;
|
||||
const viaFind = findById(doc, "hf-dup");
|
||||
const viaResolve = resolveScoped(doc, "hf-dup");
|
||||
expect(viaFind).toBe(viaResolve);
|
||||
expect(viaFind?.getAttribute("class")).toBe("outside");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveScoped — scoped id", () => {
|
||||
it("resolves hf-HOST/hf-LEAF inside the host's subtree", () => {
|
||||
// Simulated post-inline structure: host has data-composition-file
|
||||
const doc = makeDoc(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">text</p>
|
||||
</div>
|
||||
`),
|
||||
);
|
||||
const el = resolveScoped(doc as unknown as Document, "hf-host/hf-leaf");
|
||||
expect(el?.getAttribute("data-hf-id")).toBe("hf-leaf");
|
||||
expect(el?.textContent?.trim()).toBe("text");
|
||||
});
|
||||
|
||||
it("does NOT match a leaf outside the host when ids collide", () => {
|
||||
// Two elements with the same hf-id — one inside host, one outside.
|
||||
// resolveScoped must return the one INSIDE the host.
|
||||
const doc = makeDoc(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-dup" class="inside">inside</p>
|
||||
</div>
|
||||
<p data-hf-id="hf-dup" class="outside">outside</p>
|
||||
`),
|
||||
);
|
||||
const el = resolveScoped(doc as unknown as Document, "hf-host/hf-dup");
|
||||
expect(el?.getAttribute("class")).toBe("inside");
|
||||
});
|
||||
|
||||
it("resolves 3-level nesting hf-H1/hf-H2/hf-leaf", () => {
|
||||
const doc = makeDoc(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-h1" data-composition-file="sub1.html">
|
||||
<div data-hf-id="hf-h2" data-composition-file="sub2.html">
|
||||
<span data-hf-id="hf-leaf">deep</span>
|
||||
</div>
|
||||
</div>
|
||||
`),
|
||||
);
|
||||
const el = resolveScoped(doc as unknown as Document, "hf-h1/hf-h2/hf-leaf");
|
||||
expect(el?.getAttribute("data-hf-id")).toBe("hf-leaf");
|
||||
expect(el?.textContent?.trim()).toBe("deep");
|
||||
});
|
||||
|
||||
it("returns null when the first segment is not found", () => {
|
||||
const doc = makeDoc(
|
||||
inlinedHtml(`<div data-hf-id="hf-other"><p data-hf-id="hf-leaf"></p></div>`),
|
||||
);
|
||||
expect(resolveScoped(doc as unknown as Document, "hf-host/hf-leaf")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the leaf is not found inside the host", () => {
|
||||
const doc = makeDoc(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-other">text</p>
|
||||
</div>
|
||||
`),
|
||||
);
|
||||
expect(resolveScoped(doc as unknown as Document, "hf-host/hf-leaf")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. ElementSnapshot.scopedId via buildRoots ───────────────────────────────
|
||||
|
||||
describe("ElementSnapshot.scopedId", () => {
|
||||
it("top-level element has scopedId equal to its bare id", () => {
|
||||
const parsed = parseMutable(
|
||||
`<div data-hf-id="hf-root" data-hf-root><p data-hf-id="hf-p">hi</p></div>`,
|
||||
);
|
||||
const elements = flatElements(buildRoots(parsed.document));
|
||||
const p = elements.find((e) => e.id === "hf-p");
|
||||
expect(p?.scopedId).toBe("hf-p");
|
||||
});
|
||||
|
||||
it("element inside sub-comp gets hf-HOST/hf-LEAF scopedId", () => {
|
||||
const parsed = parseMutable(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">text</p>
|
||||
</div>
|
||||
</div>
|
||||
`),
|
||||
);
|
||||
const elements = flatElements(buildRoots(parsed.document));
|
||||
const leaf = elements.find((e) => e.id === "hf-leaf");
|
||||
expect(leaf?.scopedId).toBe("hf-host/hf-leaf");
|
||||
});
|
||||
|
||||
it("host element itself has bare scopedId (it lives in parent scope)", () => {
|
||||
const parsed = parseMutable(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">text</p>
|
||||
</div>
|
||||
</div>
|
||||
`),
|
||||
);
|
||||
const elements = flatElements(buildRoots(parsed.document));
|
||||
const host = elements.find((e) => e.id === "hf-host");
|
||||
expect(host?.scopedId).toBe("hf-host");
|
||||
});
|
||||
|
||||
it("3-level nesting produces hf-H1/hf-H2/hf-LEAF", () => {
|
||||
const parsed = parseMutable(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-h1" data-composition-file="sub1.html">
|
||||
<div data-hf-id="hf-h2" data-composition-file="sub2.html">
|
||||
<span data-hf-id="hf-leaf">deep</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`),
|
||||
);
|
||||
const elements = flatElements(buildRoots(parsed.document));
|
||||
const leaf = elements.find((e) => e.id === "hf-leaf");
|
||||
expect(leaf?.scopedId).toBe("hf-h1/hf-h2/hf-leaf");
|
||||
});
|
||||
|
||||
it("same sub-comp mounted twice gets different scopedIds", () => {
|
||||
// hf-x exists in both mounts — different host ids disambiguate
|
||||
const parsed = parseMutable(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-mount-a" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-x" class="in-a">A</p>
|
||||
</div>
|
||||
<div data-hf-id="hf-mount-b" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-x" class="in-b">B</p>
|
||||
</div>
|
||||
</div>
|
||||
`),
|
||||
);
|
||||
const elements = flatElements(buildRoots(parsed.document));
|
||||
const xs = elements.filter((e) => e.id === "hf-x");
|
||||
const scopedIds = xs.map((e) => e.scopedId);
|
||||
expect(scopedIds).toContain("hf-mount-a/hf-x");
|
||||
expect(scopedIds).toContain("hf-mount-b/hf-x");
|
||||
expect(new Set(scopedIds).size).toBe(2);
|
||||
});
|
||||
|
||||
it("outerHTML innerRoot (same dcf as parent) is NOT itself a new host boundary", () => {
|
||||
// outerHTML case: host and innerRoot both get data-composition-file="sub.html"
|
||||
const parsed = parseMutable(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<div data-hf-id="hf-inner" data-composition-id="my-sub" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">text</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`),
|
||||
);
|
||||
const elements = flatElements(buildRoots(parsed.document));
|
||||
const leaf = elements.find((e) => e.id === "hf-leaf");
|
||||
// Leaf should be scoped under hf-host, not hf-host/hf-inner
|
||||
expect(leaf?.scopedId).toBe("hf-host/hf-leaf");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. Dispatch to scoped target ─────────────────────────────────────────────
|
||||
|
||||
describe("dispatch — scoped target", () => {
|
||||
it("setStyle with scoped id mutates the correct element when id collides", async () => {
|
||||
// Both host subtree and sibling have an element hf-x — scoped target must hit the right one
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-x">inside</p>
|
||||
</div>
|
||||
<p data-hf-id="hf-x">outside</p>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
comp.setStyle("hf-host/hf-x", { color: "red" });
|
||||
|
||||
const inside = comp.getElement("hf-host/hf-x");
|
||||
const outside = comp.getElement("hf-x");
|
||||
expect(inside?.inlineStyles.color).toBe("red");
|
||||
// Outside element should be unchanged
|
||||
expect(outside?.inlineStyles.color).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dispatch emits scoped id in patch path", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">text</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
const patches: string[] = [];
|
||||
comp.on("patch", (e) => {
|
||||
patches.push(...e.patches.map((p) => p.path));
|
||||
});
|
||||
comp.setStyle("hf-host/hf-leaf", { color: "blue" });
|
||||
// Patch path should encode the scoped id with RFC 6902 escaping (/ → ~1)
|
||||
expect(patches.some((p) => p.includes("hf-host~1hf-leaf"))).toBe(true);
|
||||
});
|
||||
|
||||
it("getElement by scopedId returns the correct snapshot", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">inside text</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
const el = comp.getElement("hf-host/hf-leaf");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el?.text).toBe("inside text");
|
||||
});
|
||||
|
||||
it("find() returns scopedIds for sub-comp elements", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf" class="target">inside</p>
|
||||
</div>
|
||||
<p data-hf-id="hf-outer" class="target">outside</p>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
const ids = comp.find({ tag: "p" });
|
||||
expect(ids).toContain("hf-host/hf-leaf");
|
||||
expect(ids).toContain("hf-outer");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3b. Comp-root GSAP tween attribution ─────────────────────────────────────
|
||||
|
||||
describe("sub-comp root GSAP tween — canonical hf-id attribution", () => {
|
||||
it("getElement(host).animationIds includes a tween added by comp-id target", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-id="sub-1" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">text</p>
|
||||
</div>
|
||||
<script>var tl = gsap.timeline({ paused: true });
|
||||
window.__timelines = { t: tl };</script>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
// Target the sub-comp ROOT by its composition id.
|
||||
const animId = comp.addGsapTween("sub-1", {
|
||||
method: "to",
|
||||
duration: 0.3,
|
||||
properties: { x: 200 },
|
||||
});
|
||||
// The tween is filed under the host's own data-hf-id (canonical form), so
|
||||
// it surfaces on the host element snapshot.
|
||||
const host = comp.getElement("hf-host");
|
||||
expect(host?.animationIds).toContain(animId);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Override-set keys for scoped ids ──────────────────────────────────────
|
||||
|
||||
describe("override-set — scoped id keys", () => {
|
||||
it("setStyle on scoped id produces scoped key in getOverrides()", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">text</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
comp.setStyle("hf-host/hf-leaf", { color: "green" });
|
||||
const overrides = comp.getOverrides();
|
||||
expect(overrides["hf-host/hf-leaf.style.color"]).toBe("green");
|
||||
});
|
||||
|
||||
it("removeElement on host purges all sub-comp keys from override-set", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">text</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
comp.setStyle("hf-host/hf-leaf", { color: "green" });
|
||||
comp.removeElement("hf-host");
|
||||
const overrides = comp.getOverrides();
|
||||
// Removal marker for host is preserved (null); scoped property sub-keys are purged
|
||||
expect(overrides["hf-host"]).toBeNull();
|
||||
expect(
|
||||
Object.keys(overrides).some((k) => k.startsWith("hf-host/") || k.startsWith("hf-host.")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. find({ composition }) filter ─────────────────────────────────────────
|
||||
|
||||
describe("find({ composition })", () => {
|
||||
it("returns elements inside the named host sub-composition", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">inside</p>
|
||||
</div>
|
||||
<p data-hf-id="hf-outer">outside</p>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
const ids = comp.find({ composition: "hf-host" });
|
||||
expect(ids).toContain("hf-host/hf-leaf");
|
||||
expect(ids).not.toContain("hf-outer");
|
||||
expect(ids).not.toContain("hf-host"); // host itself is in parent scope
|
||||
});
|
||||
|
||||
it("returns empty array for unknown host id", async () => {
|
||||
const html = inlinedHtml(
|
||||
`<div data-hf-id="hf-root" data-hf-root><p data-hf-id="hf-p">x</p></div>`,
|
||||
);
|
||||
const comp = await openComposition(html);
|
||||
expect(comp.find({ composition: "hf-no-such" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("can combine composition filter with other query fields", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-a">match</p>
|
||||
<span data-hf-id="hf-b">no</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
const ids = comp.find({ composition: "hf-host", tag: "p" });
|
||||
expect(ids).toEqual(["hf-host/hf-a"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5b. Ambiguous bare id: removeElement / getElement agreement ──────────────
|
||||
|
||||
describe("ambiguous bare id — removeElement and getElement agree", () => {
|
||||
// Inner sub-comp dup appears FIRST in document order; the canonical top-level
|
||||
// dup appears AFTER it. querySelector document-order would return the inner one,
|
||||
// but getElement prefers the canonical (top-level) match. The two APIs must agree.
|
||||
const ambiguousHtml = () =>
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-dup" class="inner">inner</p>
|
||||
</div>
|
||||
<p data-hf-id="hf-dup" class="outer">outer</p>
|
||||
</div>
|
||||
`);
|
||||
|
||||
it("bare id resolves to the canonical (top-level) instance, matching getElement", async () => {
|
||||
const comp = await openComposition(ambiguousHtml());
|
||||
|
||||
// getElement prefers the canonical match (scopedId === id) → top-level "outer".
|
||||
const got = comp.getElement("hf-dup");
|
||||
expect(got?.scopedId).toBe("hf-dup");
|
||||
expect(got?.classNames).toContain("outer");
|
||||
|
||||
// removeElement(bareId) must remove the SAME instance getElement returned.
|
||||
comp.removeElement("hf-dup");
|
||||
|
||||
// The canonical top-level instance is gone — getElement(bareId) no longer
|
||||
// finds it (and does NOT silently fall through to the inner sub-comp dup).
|
||||
expect(comp.getElement("hf-dup")).toBeNull();
|
||||
|
||||
// The inner instance survives, addressable only via its fully-scoped path.
|
||||
const inner = comp.getElement("hf-host/hf-dup");
|
||||
expect(inner?.classNames).toContain("inner");
|
||||
});
|
||||
|
||||
it("fully-scoped path still targets the inner instance exactly", async () => {
|
||||
const comp = await openComposition(ambiguousHtml());
|
||||
comp.removeElement("hf-host/hf-dup");
|
||||
|
||||
// Inner gone; canonical top-level survives.
|
||||
const inner = comp.getElement("hf-host/hf-dup");
|
||||
expect(inner).toBeNull();
|
||||
const top = comp.getElement("hf-dup");
|
||||
expect(top?.scopedId).toBe("hf-dup");
|
||||
expect(top?.classNames).toContain("outer");
|
||||
});
|
||||
|
||||
it("non-duplicated bare id still resolves and removes normally", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">inside</p>
|
||||
</div>
|
||||
<p data-hf-id="hf-solo">solo</p>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
expect(comp.getElement("hf-solo")?.scopedId).toBe("hf-solo");
|
||||
comp.removeElement("hf-solo");
|
||||
expect(comp.getElement("hf-solo")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. Scoped id stability across serialize ──────────────────────────────────
|
||||
|
||||
describe("scopedId stability across serialize/re-parse", () => {
|
||||
it("scopedId values are identical after serialize + re-open", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">text</p>
|
||||
</div>
|
||||
<p data-hf-id="hf-outer">outer</p>
|
||||
</div>
|
||||
`);
|
||||
const comp1 = await openComposition(html);
|
||||
const serialized = comp1.serialize();
|
||||
const comp2 = await openComposition(serialized);
|
||||
|
||||
const ids1 = comp1
|
||||
.getElements()
|
||||
.map((e) => e.scopedId)
|
||||
.sort();
|
||||
const ids2 = comp2
|
||||
.getElements()
|
||||
.map((e) => e.scopedId)
|
||||
.sort();
|
||||
expect(ids1).toEqual(ids2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 7. isNewHostBoundary ──────────────────────────────────────────────────────
|
||||
|
||||
describe("isNewHostBoundary", () => {
|
||||
it("is true for a host with no ancestor dcf (top-level sub-comp host)", () => {
|
||||
const doc = makeDoc(
|
||||
inlinedHtml(`<div data-hf-id="hf-host" data-composition-file="sub.html"></div>`),
|
||||
) as unknown as Document;
|
||||
const host = doc.querySelector('[data-hf-id="hf-host"]') as unknown as Element;
|
||||
expect(isNewHostBoundary(host)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for an element with no data-composition-file at all", () => {
|
||||
const doc = makeDoc(inlinedHtml(`<div data-hf-id="hf-plain"></div>`)) as unknown as Document;
|
||||
const el = doc.querySelector('[data-hf-id="hf-plain"]') as unknown as Element;
|
||||
expect(isNewHostBoundary(el)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for the outerHTML innerRoot (same dcf value as its host parent)", () => {
|
||||
const doc = makeDoc(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<div data-hf-id="hf-inner" data-composition-file="sub.html"></div>
|
||||
</div>
|
||||
`),
|
||||
) as unknown as Document;
|
||||
const inner = doc.querySelector('[data-hf-id="hf-inner"]') as unknown as Element;
|
||||
expect(isNewHostBoundary(inner)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for a nested host with a DIFFERENT dcf from its parent", () => {
|
||||
const doc = makeDoc(
|
||||
inlinedHtml(`
|
||||
<div data-hf-id="hf-outer" data-composition-file="outer.html">
|
||||
<div data-hf-id="hf-inner-host" data-composition-file="inner.html"></div>
|
||||
</div>
|
||||
`),
|
||||
) as unknown as Document;
|
||||
const innerHost = doc.querySelector('[data-hf-id="hf-inner-host"]') as unknown as Element;
|
||||
expect(isNewHostBoundary(innerHost)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 8. bareId ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bareId", () => {
|
||||
it("returns the leaf segment of a scoped id", () => {
|
||||
expect(bareId("hf-host/hf-leaf")).toBe("hf-leaf");
|
||||
});
|
||||
|
||||
it("returns a deeply nested id's leaf segment", () => {
|
||||
expect(bareId("hf-a/hf-b/hf-c")).toBe("hf-c");
|
||||
});
|
||||
|
||||
it("passes a bare id through unchanged", () => {
|
||||
expect(bareId("hf-solo")).toBe("hf-solo");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 9. getRootElements — no descendant duplication ────────────────────────────
|
||||
|
||||
describe("getRootElements", () => {
|
||||
it("excludes descendants that getElements() also lists as top-level entries", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-panel">
|
||||
<h1 data-hf-id="hf-title">Title</h1>
|
||||
</div>
|
||||
<p data-hf-id="hf-solo">solo</p>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
|
||||
// getElements() is flat: hf-title appears once nested under hf-panel AND
|
||||
// once again as its own top-level entry.
|
||||
const flatIds = comp.getElements().map((e) => e.id);
|
||||
expect(flatIds).toContain("hf-title");
|
||||
expect(flatIds).toContain("hf-panel");
|
||||
|
||||
// getRootElements() only returns true roots — hf-title is not one, since
|
||||
// it's hf-panel's descendant.
|
||||
const rootIds = comp.getRootElements().map((e) => e.id);
|
||||
expect(rootIds).toEqual(["hf-panel", "hf-solo"]);
|
||||
expect(comp.getRootElements().find((e) => e.id === "hf-panel")?.children[0]?.id).toBe(
|
||||
"hf-title",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a sub-composition host as a root even though it has descendants", async () => {
|
||||
const html = inlinedHtml(`
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<p data-hf-id="hf-leaf">inside</p>
|
||||
</div>
|
||||
`);
|
||||
const comp = await openComposition(html);
|
||||
expect(comp.getRootElements().map((e) => e.id)).toEqual(["hf-host"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 10. serialize({ stripRuntime }) ───────────────────────────────────────────
|
||||
|
||||
describe("serialize({ stripRuntime })", () => {
|
||||
const RUNTIME_SCRIPT =
|
||||
'<script data-hyperframes-preview-runtime="1" src="https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js"></script>';
|
||||
|
||||
it("keeps the embedded runtime script by default", async () => {
|
||||
const html = `<!DOCTYPE html><html><head>${RUNTIME_SCRIPT}</head><body><div data-hf-id="hf-a"></div></body></html>`;
|
||||
const comp = await openComposition(html);
|
||||
expect(comp.serialize()).toContain("hyperframe.runtime");
|
||||
});
|
||||
|
||||
it("strips the embedded runtime script when stripRuntime is true", async () => {
|
||||
const html = `<!DOCTYPE html><html><head>${RUNTIME_SCRIPT}</head><body><div data-hf-id="hf-a"></div></body></html>`;
|
||||
const comp = await openComposition(html);
|
||||
const out = comp.serialize({ stripRuntime: true });
|
||||
expect(out).not.toContain("hyperframe.runtime");
|
||||
expect(out).toContain('data-hf-id="hf-a"');
|
||||
});
|
||||
|
||||
it("re-exports RUNTIME_BOOTSTRAP_ATTR from @hyperframes/core, matching the marker generators stamp", async () => {
|
||||
expect(RUNTIME_BOOTSTRAP_ATTR).toBe("data-hyperframes-preview-runtime");
|
||||
// The fixture's marker attribute above is authored by hand — confirm it's not
|
||||
// drifted from the real constant a generator would actually stamp.
|
||||
expect(RUNTIME_SCRIPT).toContain(RUNTIME_BOOTSTRAP_ATTR);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Template-based sub-comp support — SDK must model elements inside <template>.
|
||||
*
|
||||
* The studio preview unwraps <template data-composition-id> content into the
|
||||
* served body, so the timeline hands edits hf-ids that live inside the
|
||||
* template in the raw file. Before this support, buildElement excluded the
|
||||
* whole template subtree: getElements() returned [] for template comps and
|
||||
* every edit produced a false element_not_found resolver divergence.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
|
||||
const TEMPLATE_HTML = `
|
||||
<template data-composition-id="test-minimal">
|
||||
<div data-hf-id="hf-a" class="clip" data-start="0" data-end="3">Hello</div>
|
||||
<div data-hf-id="hf-b" class="clip" data-start="3" data-end="6">World</div>
|
||||
</template>
|
||||
`.trim();
|
||||
|
||||
const TEMPLATE_UNSTAMPED_HTML = `
|
||||
<template data-composition-id="test-minimal">
|
||||
<div class="clip" data-start="0" data-end="3">Hello</div>
|
||||
</template>
|
||||
`.trim();
|
||||
|
||||
describe("template-based sub-comp compositions", () => {
|
||||
it("getElements() models the template's inner elements (template itself absent)", async () => {
|
||||
const comp = await openComposition(TEMPLATE_HTML);
|
||||
const els = comp.getElements();
|
||||
expect(els.map((e) => e.id)).toEqual(["hf-a", "hf-b"]);
|
||||
expect(els.every((e) => e.tag !== "template")).toBe(true);
|
||||
});
|
||||
|
||||
it("mints ids for unstamped template-inner elements on open", async () => {
|
||||
const comp = await openComposition(TEMPLATE_UNSTAMPED_HTML);
|
||||
expect(comp.getElements()).toHaveLength(1);
|
||||
expect(comp.getElements()[0]?.id).toMatch(/^hf-/);
|
||||
});
|
||||
|
||||
it("getElement resolves a template-inner id", async () => {
|
||||
const comp = await openComposition(TEMPLATE_HTML);
|
||||
expect(comp.getElement("hf-a")?.text).toBe("Hello");
|
||||
});
|
||||
|
||||
it("setTiming on a template-inner element mutates and serializes inside the template", async () => {
|
||||
const comp = await openComposition(TEMPLATE_HTML);
|
||||
comp.setTiming("hf-a", { start: 1.5 });
|
||||
const out = comp.serialize();
|
||||
expect(out).toContain("<template");
|
||||
// the mutated start must be INSIDE the template wrapper
|
||||
const tpl = out.slice(out.indexOf("<template"), out.indexOf("</template>"));
|
||||
expect(tpl).toContain('data-start="1.5"');
|
||||
expect(comp.getElement("hf-a")?.start).toBe(1.5);
|
||||
});
|
||||
|
||||
it("timed template-inner elements carry start/duration snapshots", async () => {
|
||||
const comp = await openComposition(TEMPLATE_HTML);
|
||||
const a = comp.getElement("hf-a");
|
||||
expect(a?.start).toBe(0);
|
||||
expect(a?.duration).toBe(3);
|
||||
});
|
||||
|
||||
it("plain <template> (runtime clone-source) stays fully excluded", async () => {
|
||||
const comp = await openComposition(
|
||||
`<div data-hf-id="hf-stage" data-hf-root>x</div><template><li data-hf-id="hf-clone">item</li></template>`,
|
||||
);
|
||||
expect(comp.getElements().map((e) => e.id)).toEqual(["hf-stage"]);
|
||||
expect(comp.getElement("hf-clone")).toBeNull();
|
||||
});
|
||||
|
||||
it("duplicate hf-id resolves in true document order (template-inner first when it comes first)", async () => {
|
||||
// A comp-template-inner element EARLIER in the document and a top-level
|
||||
// element LATER share an id (copy-paste drift). The preview's unwrapped
|
||||
// DOM resolves the first-in-document copy; the SDK must agree.
|
||||
const comp = await openComposition(
|
||||
`<template data-composition-id="t"><div data-hf-id="hf-dup" data-start="1" data-end="2">tpl</div></template><div data-hf-id="hf-dup" data-start="5" data-end="6">top</div>`,
|
||||
);
|
||||
expect(comp.getElement("hf-dup")?.text).toBe("tpl");
|
||||
});
|
||||
|
||||
it("models GSAP animations declared inside a composition template", async () => {
|
||||
const comp = await openComposition(`
|
||||
<template data-composition-id="document-card">
|
||||
<div data-hf-id="hf-line" class="line">line</div>
|
||||
<script>
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-line\\"]", { x: 100, duration: 1 }, 0);
|
||||
</script>
|
||||
</template>
|
||||
`);
|
||||
|
||||
const animationIds = comp.getElement("hf-line")?.animationIds ?? [];
|
||||
expect(animationIds).toHaveLength(1);
|
||||
expect(comp.getAllAnimationIds()).toEqual(new Set(animationIds));
|
||||
});
|
||||
});
|
||||
|
||||
// The authored sub-comp form `hyperframes add` scaffolds: the composition id is
|
||||
// on the wrapped root div, and the <template> is keyed by `id="X-template"`.
|
||||
const AUTHORED_TEMPLATE_HTML = `
|
||||
<template id="card-template">
|
||||
<div data-composition-id="card" data-width="1280" data-height="720" data-duration="5">
|
||||
<h1 class="title" style="color: rgb(255, 0, 0)">Headline</h1>
|
||||
</div>
|
||||
</template>
|
||||
`.trim();
|
||||
|
||||
describe("authored template sub-comps (id on the wrapped root div)", () => {
|
||||
it("enumerates and resolves inner elements", async () => {
|
||||
const comp = await openComposition(AUTHORED_TEMPLATE_HTML);
|
||||
const title = comp.getElements().find((e) => e.classNames.includes("title"));
|
||||
expect(title).toBeTruthy();
|
||||
expect(comp.getElement(title!.id)?.text).toBe("Headline");
|
||||
});
|
||||
|
||||
it("declares a variable on the root div and round-trips through serialize", async () => {
|
||||
const comp = await openComposition(AUTHORED_TEMPLATE_HTML);
|
||||
comp.declareVariable({
|
||||
id: "title-color",
|
||||
type: "color",
|
||||
label: "Title color",
|
||||
default: "#ff0000",
|
||||
});
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title-color"]);
|
||||
const serialized = comp.serialize();
|
||||
expect(serialized).toContain("data-composition-variables");
|
||||
// Survives a re-open (declaration is on the root div, not a stripped <html>).
|
||||
const reopened = await openComposition(serialized);
|
||||
expect(reopened.getVariableDeclarations().map((d) => d.id)).toEqual(["title-color"]);
|
||||
});
|
||||
|
||||
it("does not treat a plain clone-source template as a composition", async () => {
|
||||
const comp = await openComposition(
|
||||
`<div data-composition-id="c" data-width="100" data-height="100" data-duration="1" data-start="0">` +
|
||||
`<template id="particle"><span class="dot">·</span></template></div>`,
|
||||
);
|
||||
// The particle template's inner <span> must NOT be enumerated (it is cloned
|
||||
// N times at runtime; a persisted inner id would duplicate across clones).
|
||||
expect(comp.getElements().some((e) => e.classNames.includes("dot"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* Session-level behavior: history coalescing invariants and T3 override replay.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
import type { DraftProps, ElementAtPointResult, PreviewAdapter } from "./adapters/types.js";
|
||||
|
||||
const BASE_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" data-start="0" data-end="3" style="color: #fff; font-size: 64px">Hello World</h1>
|
||||
<p data-hf-id="hf-sub" style="opacity: 0.5">subtitle</p>
|
||||
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
class TestPreviewAdapter implements PreviewAdapter {
|
||||
private selectionHandlers: Array<(ids: string[]) => void> = [];
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
applyDraft(_id: string, _props: DraftProps): void {
|
||||
// Test adapter tracks selection only.
|
||||
}
|
||||
|
||||
commitPreview(): void {
|
||||
// Test adapter tracks selection only.
|
||||
}
|
||||
|
||||
cancelPreview(): void {
|
||||
// Test adapter tracks selection only.
|
||||
}
|
||||
|
||||
select(ids: string[], _opts?: { additive?: boolean }): void {
|
||||
this.emitSelection(ids);
|
||||
}
|
||||
|
||||
on(_event: "selection", handler: (ids: string[]) => void): () => void {
|
||||
this.selectionHandlers.push(handler);
|
||||
return () => {
|
||||
this.selectionHandlers = this.selectionHandlers.filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
emitSelection(ids: readonly string[]): void {
|
||||
const snapshot = [...ids];
|
||||
for (const handler of this.selectionHandlers) {
|
||||
handler([...snapshot]);
|
||||
}
|
||||
}
|
||||
|
||||
listenerCount(): number {
|
||||
return this.selectionHandlers.length;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Preview selection bridge ────────────────────────────────────────────────
|
||||
|
||||
describe("preview selection bridge", () => {
|
||||
it("mirrors preview selection into session state and notifies subscribers", async () => {
|
||||
const preview = new TestPreviewAdapter();
|
||||
const comp = await openComposition(BASE_HTML, { preview });
|
||||
const events: string[][] = [];
|
||||
|
||||
comp.on("selectionchange", (ids) => events.push([...ids]));
|
||||
preview.select(["hf-title"]);
|
||||
|
||||
expect(comp.getSelection()).toEqual(["hf-title"]);
|
||||
expect(comp.selection().ids).toEqual(["hf-title"]);
|
||||
expect(events).toEqual([["hf-title"]]);
|
||||
});
|
||||
|
||||
it("selection proxy applies edits to ids selected by the preview", async () => {
|
||||
const preview = new TestPreviewAdapter();
|
||||
const comp = await openComposition(BASE_HTML, { preview });
|
||||
|
||||
preview.select(["hf-title", "hf-sub"]);
|
||||
comp.selection().setStyle({ color: "#123456" });
|
||||
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#123456");
|
||||
expect(comp.getElement("hf-sub")?.inlineStyles["color"]).toBe("#123456");
|
||||
});
|
||||
|
||||
it("dispose unsubscribes from preview selection events", async () => {
|
||||
const preview = new TestPreviewAdapter();
|
||||
const comp = await openComposition(BASE_HTML, { preview });
|
||||
|
||||
expect(preview.listenerCount()).toBe(1);
|
||||
comp.dispose();
|
||||
expect(preview.listenerCount()).toBe(0);
|
||||
|
||||
preview.select(["hf-title"]);
|
||||
expect(comp.getSelection()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── History coalescing ───────────────────────────────────────────────────────
|
||||
|
||||
describe("history coalescing", () => {
|
||||
it("rapid edits to the SAME property coalesce into one undo entry", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#111" });
|
||||
comp.setStyle("hf-title", { color: "#222" });
|
||||
comp.setStyle("hf-title", { color: "#333" });
|
||||
|
||||
comp.undo();
|
||||
const el = comp.getElement("hf-title");
|
||||
expect(el?.inlineStyles["color"]).toBe("#fff"); // back to original in ONE step
|
||||
});
|
||||
|
||||
it("rapid edits to DIFFERENT elements do NOT coalesce — undo reverts only the last edit", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#111" });
|
||||
comp.setStyle("hf-sub", { opacity: "1" });
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-sub")?.inlineStyles["opacity"]).toBe("0.5"); // last edit reverted
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111"); // first edit intact
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
});
|
||||
|
||||
it("rapid edits to different properties of the same element do not coalesce", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#111" });
|
||||
comp.setStyle("hf-title", { fontSize: "96px" });
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["fontSize"]).toBe("64px");
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── T3 override replay ───────────────────────────────────────────────────────
|
||||
|
||||
describe("override-set replay on open", () => {
|
||||
it("applies style, text, and attribute overrides to the base document", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: {
|
||||
"hf-title.style.color": "#e63946",
|
||||
"hf-title.text": "Edited headline",
|
||||
"hf-logo.attr.src": "/new-logo.png",
|
||||
},
|
||||
});
|
||||
|
||||
const title = comp.getElement("hf-title");
|
||||
expect(title?.inlineStyles["color"]).toBe("#e63946");
|
||||
expect(title?.text).toBe("Edited headline");
|
||||
expect(comp.getElement("hf-logo")?.attributes["src"]).toBe("/new-logo.png");
|
||||
|
||||
const html = comp.serialize();
|
||||
expect(html).toContain("Edited headline");
|
||||
expect(html).toContain("/new-logo.png");
|
||||
expect(html).toContain("#e63946");
|
||||
});
|
||||
|
||||
it("applies timing overrides (computed absolute end)", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.timing.end": 4.5 },
|
||||
});
|
||||
expect(comp.serialize()).toContain('data-end="4.5"');
|
||||
});
|
||||
|
||||
it("removes elements marked with the null removal marker", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-sub": null },
|
||||
});
|
||||
expect(comp.getElement("hf-sub")).toBeNull();
|
||||
expect(comp.serialize()).not.toContain("subtitle");
|
||||
});
|
||||
|
||||
it("treats property-level null as a deletion marker — removes the property from the base", async () => {
|
||||
// Null in the override-set is emitted only from patchRemove (explicit deletion).
|
||||
// On replay against a base that has the property set, it must be removed.
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.style.color": null },
|
||||
});
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("null removal override on non-existent property is a safe no-op", async () => {
|
||||
// backgroundColor doesn't exist on hf-title in the base; removing it must not throw.
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.style.backgroundColor": null },
|
||||
});
|
||||
expect(comp.getElement("hf-title")).not.toBeNull();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["backgroundColor"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getOverrides returns the set the session was opened with", async () => {
|
||||
const overrides = { "hf-title.style.color": "#e63946" };
|
||||
const comp = await openComposition(BASE_HTML, { overrides });
|
||||
expect(comp.getOverrides()).toEqual(overrides);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batch() transactional rollback ───────────────────────────────────────────
|
||||
|
||||
describe("batch rollback on throw", () => {
|
||||
it("reverts DOM mutations and override-set when the callback throws", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const htmlBefore = comp.serialize();
|
||||
|
||||
expect(() =>
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#e63946" });
|
||||
comp.setText("hf-sub", "changed");
|
||||
throw new Error("user cancelled");
|
||||
}),
|
||||
).toThrowError("user cancelled");
|
||||
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
expect(comp.getElement("hf-sub")?.text).toBe("subtitle");
|
||||
expect(comp.serialize()).toBe(htmlBefore);
|
||||
expect(comp.getOverrides()).toEqual({});
|
||||
});
|
||||
|
||||
it("a throwing batch leaves no history entry — undo is a no-op", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
try {
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#e63946" });
|
||||
throw new Error("boom");
|
||||
});
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canUndo / canRedo ────────────────────────────────────────────────────────
|
||||
|
||||
describe("canUndo / canRedo", () => {
|
||||
it("returns false before any mutation", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
expect(comp.canUndo()).toBe(false);
|
||||
expect(comp.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it("canUndo true after a mutation, false after undoing back to start", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#ff0000" });
|
||||
expect(comp.canUndo()).toBe(true);
|
||||
expect(comp.canRedo()).toBe(false);
|
||||
|
||||
comp.undo();
|
||||
expect(comp.canUndo()).toBe(false);
|
||||
expect(comp.canRedo()).toBe(true);
|
||||
});
|
||||
|
||||
it("canRedo cleared after a new mutation", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#ff0000" });
|
||||
comp.undo();
|
||||
expect(comp.canRedo()).toBe(true);
|
||||
|
||||
comp.setStyle("hf-title", { color: "#00ff00" });
|
||||
expect(comp.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false in embedded (T3) mode — no history", async () => {
|
||||
const comp = await openComposition(BASE_HTML, { overrides: {} });
|
||||
comp.setStyle("hf-title", { color: "#ff0000" });
|
||||
expect(comp.canUndo()).toBe(false);
|
||||
expect(comp.canRedo()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── override-set orphan cleanup ──────────────────────────────────────────────
|
||||
|
||||
describe("override-set orphan cleanup on removeElement", () => {
|
||||
it("purges property keys for removed element from the override-set", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#ff0000", fontSize: "96px" });
|
||||
expect(Object.keys(comp.getOverrides())).toContain("hf-title.style.color");
|
||||
|
||||
comp.removeElement("hf-title");
|
||||
const overrides = comp.getOverrides();
|
||||
// removal marker present
|
||||
expect(overrides["hf-title"]).toBeNull();
|
||||
// orphan property keys gone
|
||||
expect(Object.keys(overrides)).not.toContain("hf-title.style.color");
|
||||
expect(Object.keys(overrides)).not.toContain("hf-title.style.fontSize");
|
||||
});
|
||||
|
||||
it("property keys for other elements are unaffected", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#ff0000" });
|
||||
comp.setStyle("hf-sub", { opacity: "1" });
|
||||
comp.removeElement("hf-title");
|
||||
const overrides = comp.getOverrides();
|
||||
expect(overrides["hf-sub.style.opacity"]).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("single-dispatch undo reverses the inverse patch list", () => {
|
||||
// A single dispatch that emits order-dependent inverse patches (here a nested
|
||||
// parent+child removeElement) must undo in reverse application order. Without
|
||||
// the reverse, undo replays 'add child' before 'add parent' → the child has no
|
||||
// parent to attach to and is dropped.
|
||||
it("removeElement([child, parent]) undo restores both, child included", async () => {
|
||||
const NESTED = `<div data-hf-id="hf-root" data-hf-root data-duration="5">
|
||||
<div data-hf-id="hf-parent"><span data-hf-id="hf-child">x</span></div>
|
||||
</div>`;
|
||||
const comp = await openComposition(NESTED);
|
||||
comp.dispatch({ type: "removeElement", target: ["hf-child", "hf-parent"] });
|
||||
expect(comp.getElement("hf-parent")).toBeNull();
|
||||
expect(comp.getElement("hf-child")).toBeNull();
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-parent")).not.toBeNull();
|
||||
expect(comp.getElement("hf-child")).not.toBeNull();
|
||||
});
|
||||
|
||||
// Defense-in-depth: an aliased multi-target (the same element twice) makes the
|
||||
// 2nd id capture the value the 1st already wrote; undo must replay the inverse
|
||||
// in reverse to land on the ORIGINAL, not the intermediate.
|
||||
it("setStyle with a duplicate target undoes to the original, not the intermediate", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.dispatch({
|
||||
type: "setStyle",
|
||||
target: ["hf-title", "hf-title"],
|
||||
styles: { fontSize: "96px" },
|
||||
});
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.fontSize).toBe("96px");
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.fontSize).toBe("64px");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setSelection / getSelection / selectionchange ───────────────────────────
|
||||
|
||||
describe("setSelection", () => {
|
||||
it("getSelection returns empty array before any setSelection call", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
expect(comp.getSelection()).toEqual([]);
|
||||
});
|
||||
|
||||
it("setSelection updates getSelection", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setSelection(["hf-title"]);
|
||||
expect(comp.getSelection()).toEqual(["hf-title"]);
|
||||
});
|
||||
|
||||
it("setSelection with multiple ids", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setSelection(["hf-title", "hf-sub"]);
|
||||
expect(comp.getSelection()).toEqual(["hf-title", "hf-sub"]);
|
||||
});
|
||||
|
||||
it("setSelection([]) clears selection", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setSelection(["hf-title"]);
|
||||
comp.setSelection([]);
|
||||
expect(comp.getSelection()).toEqual([]);
|
||||
});
|
||||
|
||||
it("setSelection fires selectionchange with new ids", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const calls: string[][] = [];
|
||||
comp.on("selectionchange", (ids) => calls.push(ids));
|
||||
comp.setSelection(["hf-title"]);
|
||||
expect(calls).toEqual([["hf-title"]]);
|
||||
});
|
||||
|
||||
it("setSelection fires selectionchange with empty array when clearing", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setSelection(["hf-title"]);
|
||||
const calls: string[][] = [];
|
||||
comp.on("selectionchange", (ids) => calls.push(ids));
|
||||
comp.setSelection([]);
|
||||
expect(calls).toEqual([[]]);
|
||||
});
|
||||
|
||||
it("selectionchange listener receives a fresh copy each call", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const snapshots: string[][] = [];
|
||||
comp.on("selectionchange", (ids) => snapshots.push(ids));
|
||||
comp.setSelection(["hf-title"]);
|
||||
comp.setSelection(["hf-sub"]);
|
||||
expect(snapshots[0]).toEqual(["hf-title"]);
|
||||
expect(snapshots[1]).toEqual(["hf-sub"]);
|
||||
});
|
||||
|
||||
it("unsubscribed listener does not fire", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const calls: string[][] = [];
|
||||
const off = comp.on("selectionchange", (ids) => calls.push(ids));
|
||||
off();
|
||||
comp.setSelection(["hf-title"]);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("selection() proxy operates on ids at call time", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setSelection(["hf-title"]);
|
||||
const proxy = comp.selection();
|
||||
expect(proxy.ids).toEqual(["hf-title"]);
|
||||
});
|
||||
|
||||
it("setSelection does not affect undo stack", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#ff0000" });
|
||||
comp.setSelection(["hf-sub"]);
|
||||
expect(comp.canUndo()).toBe(true);
|
||||
comp.undo();
|
||||
// selection must not have been pushed to history
|
||||
expect(comp.canUndo()).toBe(false);
|
||||
});
|
||||
|
||||
it("setSelection does not emit a patch event", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const patches: unknown[] = [];
|
||||
comp.on("patch", (e) => patches.push(e));
|
||||
comp.setSelection(["hf-title"]);
|
||||
expect(patches).toHaveLength(0);
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("setSelection with same ids does not fire selectionchange again", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const calls: string[][] = [];
|
||||
comp.on("selectionchange", (ids) => calls.push(ids));
|
||||
comp.setSelection(["hf-title"]);
|
||||
comp.setSelection(["hf-title"]); // same ids — must be a no-op
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("setSelection with same ids in different order fires selectionchange", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const calls: string[][] = [];
|
||||
comp.on("selectionchange", (ids) => calls.push(ids));
|
||||
comp.setSelection(["hf-title", "hf-sub"]);
|
||||
comp.setSelection(["hf-sub", "hf-title"]); // order differs — must fire
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("setSelection de-duplicates repeated ids", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setSelection(["hf-title", "hf-title", "hf-sub", "hf-title"]);
|
||||
expect(comp.getSelection()).toEqual(["hf-title", "hf-sub"]);
|
||||
});
|
||||
|
||||
it("setSelection with duplicates matching stored selection does not fire selectionchange", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const calls: string[][] = [];
|
||||
comp.on("selectionchange", (ids) => calls.push(ids));
|
||||
comp.setSelection(["hf-title"]);
|
||||
comp.setSelection(["hf-title", "hf-title"]); // de-duped = ["hf-title"] — no change
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("animationIds population", () => {
|
||||
const GSAP_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||
<div data-hf-id="hf-box" style="opacity: 0">box</div>
|
||||
<div data-hf-id="hf-plain">plain</div>
|
||||
<script>var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;</script>
|
||||
</div>`.trim();
|
||||
|
||||
it("attaches the parser's stable tween id to the targeted element", async () => {
|
||||
const comp = await openComposition(GSAP_HTML);
|
||||
const box = comp.getElement("hf-box");
|
||||
expect(box?.animationIds.length).toBe(1);
|
||||
// Stable id-space shared with studio-api / GSAP ops: targetSelector-method-position.
|
||||
expect(box?.animationIds[0]).toContain("hf-box");
|
||||
expect(box?.animationIds[0]).toContain("-to-");
|
||||
});
|
||||
|
||||
it("leaves untargeted elements with an empty animationIds", async () => {
|
||||
const comp = await openComposition(GSAP_HTML);
|
||||
expect(comp.getElement("hf-plain")?.animationIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("the populated id is dispatchable as a removeGsapTween target", async () => {
|
||||
const comp = await openComposition(GSAP_HTML);
|
||||
const id = comp.getElement("hf-box")?.animationIds[0];
|
||||
expect(id).toBeDefined();
|
||||
if (id) expect(comp.can({ type: "removeGsapTween", animationId: id }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("attaches multiple distinct tween ids when one element has several tweens", async () => {
|
||||
const html = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||
<div data-hf-id="hf-box" style="opacity: 0">box</div>
|
||||
<script>var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
|
||||
tl.from("[data-hf-id=\\"hf-box\\"]", { x: -100, duration: 0.5 }, 1);
|
||||
window.__timelines["t"] = tl;</script>
|
||||
</div>`.trim();
|
||||
const ids = (await openComposition(html)).getElement("hf-box")?.animationIds ?? [];
|
||||
expect(ids.length).toBe(2);
|
||||
expect(new Set(ids).size).toBe(2); // distinct
|
||||
});
|
||||
|
||||
it("fans a shared-selector tween out to every matched element", async () => {
|
||||
const html = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||
<div data-hf-id="hf-a" class="fade">a</div>
|
||||
<div data-hf-id="hf-b" class="fade">b</div>
|
||||
<script>var tl = gsap.timeline({ paused: true });
|
||||
tl.to(".fade", { opacity: 1, duration: 0.5 }, 0);
|
||||
window.__timelines["t"] = tl;</script>
|
||||
</div>`.trim();
|
||||
const comp = await openComposition(html);
|
||||
const a = comp.getElement("hf-a")?.animationIds ?? [];
|
||||
const b = comp.getElement("hf-b")?.animationIds ?? [];
|
||||
expect(a.length).toBe(1);
|
||||
expect(b).toEqual(a); // same tween id on both matched elements
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllAnimationIds", () => {
|
||||
it("includes a tween id even when its selector matches no live DOM element", async () => {
|
||||
const html = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-box" style="color: red">Hello</div>
|
||||
<script>var tl = gsap.timeline({ paused: true }); tl.to("#does-not-exist", { x: 100, duration: 1 }, 3);</script>
|
||||
</body></html>`;
|
||||
const comp = await openComposition(html);
|
||||
const flatIds = comp.getAllAnimationIds();
|
||||
expect(flatIds.size).toBeGreaterThan(0);
|
||||
const [unmatchedId] = [...flatIds];
|
||||
// Confirms the bug this fixes: no element's animationIds contains this id,
|
||||
// because "#does-not-exist" never CSS-matches anything in the document.
|
||||
expect(comp.getElements().some((el) => el.animationIds.includes(unmatchedId ?? ""))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an empty set when the composition has no GSAP script", async () => {
|
||||
const html = /* html */ `<!DOCTYPE html>
|
||||
<html><body><div data-hf-id="hf-box">Hello</div></body></html>`;
|
||||
const comp = await openComposition(html);
|
||||
expect(comp.getAllAnimationIds().size).toBe(0);
|
||||
});
|
||||
|
||||
it("still includes ids for tweens that DO match a live DOM element", async () => {
|
||||
const html = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-box" style="color: red">Hello</div>
|
||||
<script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0);</script>
|
||||
</body></html>`;
|
||||
const comp = await openComposition(html);
|
||||
const realId = comp.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
|
||||
expect(realId).not.toBe("");
|
||||
expect(comp.getAllAnimationIds().has(realId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getVariableValue / listVariables / declareVariable / removeVariable ──────
|
||||
|
||||
const VARIABLES_HTML = `<!DOCTYPE html>
|
||||
<html data-composition-id="c1" data-composition-duration="5" data-composition-variables='${JSON.stringify(
|
||||
[{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" }],
|
||||
)}'>
|
||||
<body>${BASE_HTML}</body>
|
||||
</html>`;
|
||||
|
||||
describe("variable declarations (Composition API)", () => {
|
||||
it("getVariableValue reads a declared variable's current default", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
expect(comp.getVariableValue("brand-color")).toBe("#0066cc");
|
||||
});
|
||||
|
||||
it("getVariableValue returns undefined for an undeclared id", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
expect(comp.getVariableValue("never-declared")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listVariables returns every declared variable's full schema", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
expect(comp.listVariables()).toEqual([
|
||||
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("listVariables returns [] when the composition declares none", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
expect(comp.listVariables()).toEqual([]);
|
||||
});
|
||||
|
||||
it("declareVariable creates a new declaration a variables panel can list immediately", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
|
||||
expect(comp.getVariableValue("brand-title")).toBe("Hi");
|
||||
expect(comp.listVariables()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("declareVariable can create where setVariableValue's model write silently no-ops", async () => {
|
||||
// Full document (not a fragment): declareVariable refuses fragment sources,
|
||||
// whose synthetic <html> is stripped on serialize. No declarations yet.
|
||||
const comp = await openComposition(`<!DOCTYPE html><html><body>${BASE_HTML}</body></html>`);
|
||||
comp.setVariableValue("never-declared", "x");
|
||||
expect(comp.getVariableValue("never-declared")).toBeUndefined();
|
||||
comp.declareVariable({ id: "never-declared", type: "string", label: "New", default: "x" });
|
||||
expect(comp.getVariableValue("never-declared")).toBe("x");
|
||||
});
|
||||
|
||||
it("removeVariable removes the declaration; listVariables reflects it immediately", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
comp.removeVariable("brand-color");
|
||||
expect(comp.listVariables()).toEqual([]);
|
||||
expect(comp.getVariableValue("brand-color")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("declareVariable / removeVariable both support undo", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
|
||||
expect(comp.listVariables()).toHaveLength(2);
|
||||
comp.undo();
|
||||
expect(comp.listVariables()).toHaveLength(1);
|
||||
|
||||
comp.removeVariable("brand-color");
|
||||
expect(comp.listVariables()).toEqual([]);
|
||||
comp.undo();
|
||||
expect(comp.listVariables()).toEqual([
|
||||
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("declareVariable / removeVariable both support redo after undo", async () => {
|
||||
const comp = await openComposition(VARIABLES_HTML);
|
||||
|
||||
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
|
||||
comp.undo();
|
||||
comp.redo();
|
||||
expect(comp.listVariables()).toEqual([
|
||||
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
|
||||
{ id: "brand-title", type: "string", label: "Title", default: "Hi" },
|
||||
]);
|
||||
|
||||
comp.removeVariable("brand-color");
|
||||
comp.undo();
|
||||
comp.redo();
|
||||
expect(comp.listVariables()).toEqual([
|
||||
{ id: "brand-title", type: "string", label: "Title", default: "Hi" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* WS-C — getElementTimings / setElementTiming / setHold tests.
|
||||
*
|
||||
* Tests the session-layer wiring for the new typed methods.
|
||||
* happy-dom can't do GSAP seek/layout so we test DOM attribute reads and
|
||||
* dispatch behavior directly.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Duration-authored clip (data-duration preferred by handleSetTiming). */
|
||||
const DURATION_AUTHORED_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
|
||||
<h1 data-hf-id="hf-title" data-start="0" data-duration="3">Hello</h1>
|
||||
<p data-hf-id="hf-sub" data-start="2" data-duration="2">World</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
/** End-authored clip (data-end only, no data-duration). */
|
||||
const END_AUTHORED_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
|
||||
<h1 data-hf-id="hf-title" data-start="1" data-end="4">Hello</h1>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
/** Both data-duration and data-end (data-duration wins). */
|
||||
const BOTH_ATTRS_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
|
||||
<h1 data-hf-id="hf-title" data-start="0" data-duration="3" data-end="99">Hello</h1>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
/** Has a GSAP script with addLabel. */
|
||||
const GSAP_LABEL_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<div data-hf-id="hf-box" data-start="0" data-duration="5" style="opacity:0"></div>
|
||||
<script>var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 1 }, 0);
|
||||
tl.addLabel("intro", 0.5);
|
||||
tl.addLabel("outro", 4.0);
|
||||
window.__timelines = { t: tl };</script>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
const GSAP_TEMPLATE_LABEL_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<template data-composition-id="label-sub-comp">
|
||||
<div data-hf-id="hf-box" data-start="0" data-duration="5"></div>
|
||||
<script>
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.addLabel("template-label", 1.5);
|
||||
window.__timelines = { t: tl };
|
||||
</script>
|
||||
</template>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
// ─── getElementTimings — duration-authored clips ──────────────────────────────
|
||||
|
||||
describe("getElementTimings — duration-authored clips", () => {
|
||||
it("reads enterAt = data-start, exitAt = data-start + data-duration", async () => {
|
||||
const comp = await openComposition(DURATION_AUTHORED_HTML);
|
||||
const timings = comp.getElementTimings();
|
||||
|
||||
expect(timings["hf-title"]).toMatchObject({ enterAt: 0, exitAt: 3 });
|
||||
expect(timings["hf-sub"]).toMatchObject({ enterAt: 2, exitAt: 4 });
|
||||
});
|
||||
|
||||
it("returns empty labels array when no GSAP script", async () => {
|
||||
const comp = await openComposition(DURATION_AUTHORED_HTML);
|
||||
const timings = comp.getElementTimings();
|
||||
expect(timings["hf-title"]?.labels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getElementTimings — end-authored clips ───────────────────────────────────
|
||||
|
||||
describe("getElementTimings — end-authored clips", () => {
|
||||
it("falls back to data-end − data-start when no data-duration", async () => {
|
||||
const comp = await openComposition(END_AUTHORED_HTML);
|
||||
const timings = comp.getElementTimings();
|
||||
|
||||
// enterAt = 1, exitAt = 4 (from data-end = 4, data-start = 1, duration = 3)
|
||||
expect(timings["hf-title"]).toMatchObject({ enterAt: 1, exitAt: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getElementTimings — data-duration wins over data-end ────────────────────
|
||||
|
||||
describe("getElementTimings — data-duration wins over data-end", () => {
|
||||
it("uses data-duration when both data-duration and data-end are present", async () => {
|
||||
const comp = await openComposition(BOTH_ATTRS_HTML);
|
||||
const timings = comp.getElementTimings();
|
||||
|
||||
// data-duration=3 wins; exitAt = 0+3=3, NOT from data-end=99
|
||||
expect(timings["hf-title"]).toMatchObject({ enterAt: 0, exitAt: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getElementTimings — labels from GSAP script ─────────────────────────────
|
||||
|
||||
describe("getElementTimings — GSAP labels", () => {
|
||||
it("returns labels whose position falls within [enterAt, exitAt]", async () => {
|
||||
const comp = await openComposition(GSAP_LABEL_HTML);
|
||||
const timings = comp.getElementTimings();
|
||||
|
||||
// hf-box: enterAt=0, exitAt=5; labels "intro"@0.5 and "outro"@4.0 are both in range
|
||||
const box = timings["hf-box"];
|
||||
expect(box?.labels).toContain("intro");
|
||||
expect(box?.labels).toContain("outro");
|
||||
});
|
||||
|
||||
it("parses labels fresh — no stale cache after mutation", async () => {
|
||||
const comp = await openComposition(GSAP_LABEL_HTML);
|
||||
|
||||
const before = comp.getElementTimings()["hf-box"]?.labels ?? [];
|
||||
expect(before).toContain("intro");
|
||||
|
||||
// Move the element so timing changes; labels should still parse fresh
|
||||
comp.setTiming("hf-box", { start: 0, duration: 5 }); // no-op but triggers re-parse
|
||||
const after = comp.getElementTimings()["hf-box"]?.labels ?? [];
|
||||
expect(after).toContain("intro");
|
||||
});
|
||||
|
||||
it("reads labels from GSAP scripts inside composition templates", async () => {
|
||||
const comp = await openComposition(GSAP_TEMPLATE_LABEL_HTML);
|
||||
const labels = comp.getElementTimings()["hf-box"]?.labels ?? [];
|
||||
|
||||
expect(labels).toContain("template-label");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getElementTimings — relative data-start references ──────────────────────
|
||||
|
||||
/** "intro" starts at data-start=1 for 3s (ends at 4). "outro" starts 2s after intro ends. */
|
||||
const RELATIVE_START_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="20">
|
||||
<h1 data-hf-id="hf-intro" data-start="1" data-duration="3">Intro</h1>
|
||||
<p data-hf-id="hf-outro" data-start="hf-intro + 2" data-duration="4">Outro</p>
|
||||
<p data-hf-id="hf-right-after" data-start="hf-intro" data-duration="1">Right after</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
describe("getElementTimings — relative data-start references", () => {
|
||||
it("resolves 'ref + offset' against the referenced element's resolved end", async () => {
|
||||
const comp = await openComposition(RELATIVE_START_HTML);
|
||||
const timings = comp.getElementTimings();
|
||||
|
||||
// hf-intro: enterAt=1, exitAt=4
|
||||
expect(timings["hf-intro"]).toMatchObject({ enterAt: 1, exitAt: 4 });
|
||||
// hf-outro: "hf-intro + 2" = intro's exitAt (4) + 2 = 6
|
||||
expect(timings["hf-outro"]).toMatchObject({ enterAt: 6, exitAt: 10 });
|
||||
});
|
||||
|
||||
it("resolves a bare reference (no offset) to the referenced element's exitAt", async () => {
|
||||
const comp = await openComposition(RELATIVE_START_HTML);
|
||||
const timings = comp.getElementTimings();
|
||||
expect(timings["hf-right-after"]).toMatchObject({ enterAt: 4, exitAt: 5 });
|
||||
});
|
||||
|
||||
it("resolves to 0 (not NaN) when the reference target doesn't exist", async () => {
|
||||
const html = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<p data-hf-id="hf-orphan" data-start="hf-nonexistent + 5" data-duration="2"></p>
|
||||
</div>
|
||||
`.trim();
|
||||
const comp = await openComposition(html);
|
||||
const timings = comp.getElementTimings();
|
||||
expect(timings["hf-orphan"]).toMatchObject({ enterAt: 0, exitAt: 2 });
|
||||
});
|
||||
|
||||
it("resolves a chained reference (A -> B -> C) through the recursive resolver", async () => {
|
||||
const html = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<h1 data-hf-id="hf-a" data-start="0" data-duration="2">A</h1>
|
||||
<p data-hf-id="hf-b" data-start="hf-a" data-duration="3">B</p>
|
||||
<p data-hf-id="hf-c" data-start="hf-b + 1" data-duration="1">C</p>
|
||||
</div>
|
||||
`.trim();
|
||||
const comp = await openComposition(html);
|
||||
const timings = comp.getElementTimings();
|
||||
|
||||
expect(timings["hf-a"]).toMatchObject({ enterAt: 0, exitAt: 2 });
|
||||
// hf-b: "hf-a" (no offset) = a's exitAt (2)
|
||||
expect(timings["hf-b"]).toMatchObject({ enterAt: 2, exitAt: 5 });
|
||||
// hf-c: "hf-b + 1" = b's exitAt (5) + 1 = 6
|
||||
expect(timings["hf-c"]).toMatchObject({ enterAt: 6, exitAt: 7 });
|
||||
});
|
||||
|
||||
it("terminates (not an infinite loop) on a direct self-reference", async () => {
|
||||
const html = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<p data-hf-id="hf-self" data-start="hf-self" data-duration="2"></p>
|
||||
</div>
|
||||
`.trim();
|
||||
const comp = await openComposition(html);
|
||||
const timings = comp.getElementTimings();
|
||||
// The cycle guard fires on the re-entrant call, contributing 0 for the
|
||||
// self-reference's own start — the element's OWN duration (2) still applies on
|
||||
// top of that, so enterAt=2, not 0. The guard's job is termination, not zeroing
|
||||
// the whole chain.
|
||||
expect(timings["hf-self"]).toMatchObject({ enterAt: 2, exitAt: 4 });
|
||||
expect(Number.isFinite(timings["hf-self"]?.enterAt)).toBe(true);
|
||||
});
|
||||
|
||||
it("terminates (not an infinite loop) on a mutual A <-> B reference cycle", async () => {
|
||||
const html = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<p data-hf-id="hf-a" data-start="hf-b" data-duration="2"></p>
|
||||
<p data-hf-id="hf-b" data-start="hf-a" data-duration="3"></p>
|
||||
</div>
|
||||
`.trim();
|
||||
const comp = await openComposition(html);
|
||||
const timings = comp.getElementTimings();
|
||||
// Document order resolves hf-a first: it recurses into hf-b, which recurses back
|
||||
// into hf-a — the guard fires there (returns 0), so hf-b's start = 0 + hf-a's
|
||||
// duration (2) = 2. Back in hf-a's own resolution: start = hf-b's start (2) +
|
||||
// hf-b's duration (3) = 5. Neither number is "correct" for a genuine cycle —
|
||||
// the point is both are finite and the recursion terminates.
|
||||
expect(timings["hf-a"]).toMatchObject({ enterAt: 5, exitAt: 7 });
|
||||
expect(timings["hf-b"]).toMatchObject({ enterAt: 2, exitAt: 5 });
|
||||
expect(Number.isFinite(timings["hf-a"]?.enterAt)).toBe(true);
|
||||
expect(Number.isFinite(timings["hf-b"]?.enterAt)).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves a colliding bare id to the TOP-LEVEL match, not a same-scope sibling", async () => {
|
||||
// Bare ids have no scope syntax — resolveScoped's bare-id rule prefers the
|
||||
// canonical top-level match when one exists, same as the runtime's own (also
|
||||
// global, not scope-aware) resolver. Both the outer document AND the sub-comp
|
||||
// author an element with the SAME bare id "hf-intro" — a genuine collision.
|
||||
const html = `
|
||||
<!DOCTYPE html><html><body>
|
||||
<h1 data-hf-id="hf-intro" data-start="0" data-duration="10">Outer intro</h1>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<h1 data-hf-id="hf-intro" data-start="0" data-duration="1">Inner intro (same bare id)</h1>
|
||||
<p data-hf-id="hf-outro" data-start="hf-intro + 1" data-duration="1">Inner outro</p>
|
||||
</div>
|
||||
</body></html>
|
||||
`.trim();
|
||||
const comp = await openComposition(html);
|
||||
const timings = comp.getElementTimings();
|
||||
// "hf-intro + 1", authored on an element INSIDE the sub-comp, still resolves
|
||||
// against the OUTER hf-intro (exitAt=10) — 10 + 1 = 11 — not the same-scope
|
||||
// inner hf-intro (exitAt=1, which would give 2). This pins current behavior;
|
||||
// it is a real authoring footgun, not a claim that it's the ideal semantics.
|
||||
expect(timings["hf-host/hf-outro"]).toMatchObject({ enterAt: 11, exitAt: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setElementTiming — sparse map + batched dispatch ────────────────────────
|
||||
|
||||
describe("setElementTiming", () => {
|
||||
it("applies sparse timing map to multiple elements", async () => {
|
||||
const comp = await openComposition(DURATION_AUTHORED_HTML);
|
||||
|
||||
comp.setElementTiming({
|
||||
"hf-title": { start: 1, duration: 2 },
|
||||
"hf-sub": { start: 4 },
|
||||
});
|
||||
|
||||
const timings = comp.getElementTimings();
|
||||
expect(timings["hf-title"]).toMatchObject({ enterAt: 1, exitAt: 3 });
|
||||
expect(timings["hf-sub"]).toMatchObject({ enterAt: 4 });
|
||||
});
|
||||
|
||||
it("emits exactly one patch event for multiple entries (batched)", async () => {
|
||||
const comp = await openComposition(DURATION_AUTHORED_HTML);
|
||||
const patches: unknown[] = [];
|
||||
comp.on("patch", (e) => patches.push(e));
|
||||
|
||||
comp.setElementTiming({
|
||||
"hf-title": { start: 0.5 },
|
||||
"hf-sub": { start: 3.0 },
|
||||
});
|
||||
|
||||
// One batch → one patch event
|
||||
expect(patches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is a no-op for empty map", async () => {
|
||||
const comp = await openComposition(DURATION_AUTHORED_HTML);
|
||||
const patches: unknown[] = [];
|
||||
comp.on("patch", (e) => patches.push(e));
|
||||
|
||||
comp.setElementTiming({});
|
||||
expect(patches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("respects data-duration vs data-end preference on write", async () => {
|
||||
const comp = await openComposition(DURATION_AUTHORED_HTML);
|
||||
|
||||
// Before: hf-title has data-duration=3, data-start=0
|
||||
comp.setElementTiming({ "hf-title": { duration: 5 } });
|
||||
|
||||
const timings = comp.getElementTimings();
|
||||
// Should read back the new duration
|
||||
expect(timings["hf-title"]).toMatchObject({ exitAt: 5 });
|
||||
});
|
||||
|
||||
it("can be undone as a single step", async () => {
|
||||
const comp = await openComposition(DURATION_AUTHORED_HTML);
|
||||
|
||||
const before = comp.getElementTimings()["hf-title"];
|
||||
|
||||
comp.setElementTiming({ "hf-title": { start: 2 } });
|
||||
comp.undo();
|
||||
|
||||
const after = comp.getElementTimings()["hf-title"];
|
||||
expect(after?.enterAt).toBe(before?.enterAt);
|
||||
});
|
||||
|
||||
it("setElementTiming inverse restores original timing", async () => {
|
||||
const comp = await openComposition(DURATION_AUTHORED_HTML);
|
||||
const originalTimings = comp.getElementTimings();
|
||||
|
||||
comp.setElementTiming({
|
||||
"hf-title": { start: 10, duration: 1 },
|
||||
"hf-sub": { start: 12, duration: 1 },
|
||||
});
|
||||
comp.undo();
|
||||
|
||||
const restored = comp.getElementTimings();
|
||||
expect(restored["hf-title"]).toEqual(originalTimings["hf-title"]);
|
||||
expect(restored["hf-sub"]).toEqual(originalTimings["hf-sub"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setHold — typed wrapper ──────────────────────────────────────────────────
|
||||
|
||||
describe("setHold — typed method", () => {
|
||||
it("dispatches the setHold op (regression: existing op unchanged)", async () => {
|
||||
const comp = await openComposition(DURATION_AUTHORED_HTML);
|
||||
const patches: unknown[] = [];
|
||||
comp.on("patch", (e) => patches.push(e));
|
||||
|
||||
comp.setHold("hf-title", { start: 0.5, end: 2.5, fill: "freeze" });
|
||||
|
||||
// Should emit a patch
|
||||
expect(patches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("setHold writes data-hold-start / data-hold-end / data-hold-fill attrs", async () => {
|
||||
const comp = await openComposition(DURATION_AUTHORED_HTML);
|
||||
|
||||
comp.setHold("hf-title", { start: 1.0, end: 2.0, fill: "loop" });
|
||||
|
||||
// Verify via serialize (attrs are in the HTML output)
|
||||
const html = comp.serialize();
|
||||
expect(html).toContain('data-hold-start="1"');
|
||||
expect(html).toContain('data-hold-end="2"');
|
||||
expect(html).toContain('data-hold-fill="loop"');
|
||||
});
|
||||
|
||||
it("setHold typed method equals dispatch({type:setHold})", async () => {
|
||||
// Run typed method path
|
||||
const comp1 = await openComposition(DURATION_AUTHORED_HTML);
|
||||
comp1.setHold("hf-title", { start: 0.5, end: 2.5, fill: "freeze" });
|
||||
const html1 = comp1.serialize();
|
||||
|
||||
// Run raw dispatch path
|
||||
const comp2 = await openComposition(DURATION_AUTHORED_HTML);
|
||||
comp2.dispatch({
|
||||
type: "setHold",
|
||||
target: "hf-title",
|
||||
hold: { start: 0.5, end: 2.5, fill: "freeze" },
|
||||
});
|
||||
const html2 = comp2.serialize();
|
||||
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,910 @@
|
||||
/**
|
||||
* Phase 3a — real editing session.
|
||||
*
|
||||
* CompositionImpl: live linkedom document, real dispatch, RFC 6902 patch emission,
|
||||
* override-set accumulation, batch, can(), serialize(), applyPatches().
|
||||
*
|
||||
* openComposition() wires history + persist queue for standalone (T1/T2) mode.
|
||||
* T3 (embedded) callers supply overrides; SDK emits patches only — host owns state.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CanResult,
|
||||
Composition,
|
||||
EditOp,
|
||||
ElementSnapshot,
|
||||
ElementTimingSnapshot,
|
||||
FindQuery,
|
||||
FontValue,
|
||||
GsapTweenSpec,
|
||||
ElasticHold,
|
||||
KeyframeSpec,
|
||||
HfId,
|
||||
ImageValue,
|
||||
JsonPatchOp,
|
||||
OverrideSet,
|
||||
PatchEvent,
|
||||
PersistErrorEvent,
|
||||
SelectionProxy,
|
||||
ElementHandle,
|
||||
VariableUsageReport,
|
||||
} from "./types.js";
|
||||
import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
|
||||
import { buildRoots, flatElements, parsedAnimationIds } from "./document.js";
|
||||
import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
|
||||
import { parseMutable } from "./engine/model.js";
|
||||
import type { ParsedDocument } from "./engine/model.js";
|
||||
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
|
||||
import { getGsapScripts, resolveScoped, declarationElement } from "./engine/model.js";
|
||||
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
|
||||
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
|
||||
import {
|
||||
readDeclaredDefaults,
|
||||
validateVariables,
|
||||
scanVariableUsage,
|
||||
} from "@hyperframes/core/variables";
|
||||
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
|
||||
import { readVariableDeclarations } from "./engine/variableModel.js";
|
||||
import { serializeDocument } from "./engine/serialize.js";
|
||||
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
|
||||
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
|
||||
import { createHistory } from "./history.js";
|
||||
import type { HistoryModule } from "./history.js";
|
||||
import { createPersistQueue } from "./persist-queue.js";
|
||||
import type { PersistQueueModule } from "./persist-queue.js";
|
||||
|
||||
export interface OpenCompositionOptions {
|
||||
persist?: PersistAdapter;
|
||||
/** Adapter path the persist queue writes to. Default: "composition.html". Immutable for the session lifetime. */
|
||||
persistPath?: string;
|
||||
preview?: PreviewAdapter;
|
||||
/** T3 embedded mode: override-set applied on top of the base template. */
|
||||
overrides?: OverrideSet;
|
||||
/** Origins whose mutations enter the undo stack. Default: all non-applyPatches. */
|
||||
trackedOrigins?: unknown[];
|
||||
/** Auto-coalesce window for history entries (ms). Default: 300. */
|
||||
coalesceMs?: number;
|
||||
/**
|
||||
* Pass `false` to skip attaching the history module (undo/redo).
|
||||
* Default: history is attached in standalone (non-embedded) mode.
|
||||
* Use when the host owns the undo stack and SDK undo is dead weight.
|
||||
*/
|
||||
history?: false;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Escape a string for literal use inside a RegExp. */
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
class CompositionImpl implements Composition {
|
||||
private readonly parsed: ParsedDocument;
|
||||
private readonly persist: PersistAdapter | undefined;
|
||||
readonly preview: PreviewAdapter | undefined;
|
||||
|
||||
/** Accumulated override-set — T3 embedded mode fold contract. */
|
||||
private overrides: OverrideSet;
|
||||
|
||||
/** Lazily-built element snapshot, invalidated on every mutation. */
|
||||
private elementsCache: ElementSnapshot[] | null = null;
|
||||
/** Lazily-built root snapshot (getRootElements), invalidated alongside elementsCache. */
|
||||
private rootsCache: ElementSnapshot[] | null = null;
|
||||
|
||||
private currentSelection: string[] = [];
|
||||
|
||||
private changeHandlers: Array<() => void> = [];
|
||||
private selectionHandlers: Array<(ids: string[]) => void> = [];
|
||||
private patchHandlers: Array<(e: PatchEvent) => void> = [];
|
||||
private errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
|
||||
private previewSelectionUnsubscribe: (() => void) | null = null;
|
||||
|
||||
/** Attached by openComposition() for standalone mode. */
|
||||
private historyModule: HistoryModule | null = null;
|
||||
private persistQueueModule: PersistQueueModule | null = null;
|
||||
|
||||
/** Batching state: accumulates patches from multiple dispatches. */
|
||||
private batchDepth = 0;
|
||||
private batchForward: JsonPatchOp[] = [];
|
||||
private batchInverse: JsonPatchOp[] = [];
|
||||
private batchOpTypes: string[] = [];
|
||||
private batchOrigin: unknown = ORIGIN_LOCAL;
|
||||
/** Override-set state at outermost batch entry — restored if the batch throws. */
|
||||
private batchOverridesSnapshot: OverrideSet = {};
|
||||
|
||||
constructor(parsed: ParsedDocument, opts: OpenCompositionOptions) {
|
||||
this.parsed = parsed;
|
||||
this.persist = opts.persist;
|
||||
this.preview = opts.preview;
|
||||
this.overrides = { ...(opts.overrides ?? {}) };
|
||||
this.previewSelectionUnsubscribe =
|
||||
this.preview?.on("selection", (ids) => this.updateSelection(ids)) ?? null;
|
||||
}
|
||||
|
||||
attachHistory(module: HistoryModule): void {
|
||||
this.historyModule = module;
|
||||
}
|
||||
|
||||
attachPersistQueue(module: PersistQueueModule): void {
|
||||
this.persistQueueModule = module;
|
||||
}
|
||||
|
||||
_fireError(e: PersistErrorEvent): void {
|
||||
this.errorHandlers.forEach((h) => h(e));
|
||||
}
|
||||
|
||||
// ── Typed methods (F10 layer 1) ─────────────────────────────────────────────
|
||||
|
||||
setStyle(id: HfId, styles: Record<string, string | null>): void {
|
||||
this.dispatch({ type: "setStyle", target: id, styles });
|
||||
}
|
||||
|
||||
setText(id: HfId, value: string): void {
|
||||
this.dispatch({ type: "setText", target: id, value });
|
||||
}
|
||||
|
||||
setAttribute(id: HfId, name: string, value: string | null): void {
|
||||
this.dispatch({ type: "setAttribute", target: id, name, value });
|
||||
}
|
||||
|
||||
setTiming(id: HfId, timing: { start?: number; duration?: number; trackIndex?: number }): void {
|
||||
this.dispatch({ type: "setTiming", target: id, ...timing });
|
||||
}
|
||||
|
||||
removeElement(id: HfId): void {
|
||||
this.dispatch({ type: "removeElement", target: id });
|
||||
}
|
||||
|
||||
addElement(parent: HfId | null, index: number, html: string): HfId {
|
||||
const result = this._dispatch({ type: "addElement", parent, index, html }, ORIGIN_LOCAL);
|
||||
return result.meta?.newId ?? "";
|
||||
}
|
||||
|
||||
setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void {
|
||||
this.dispatch({ type: "setVariableValue", id, value });
|
||||
}
|
||||
|
||||
// ── #2098 CRUD conveniences — thin aliases over the canonical surface below.
|
||||
// They delegate so the per-file declaration-element scope (template/fragment
|
||||
// sub-comps included) is resolved in exactly one place.
|
||||
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined {
|
||||
return this.getVariableValues()[id] as
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| FontValue
|
||||
| ImageValue
|
||||
| undefined;
|
||||
}
|
||||
|
||||
listVariables(): CompositionVariable[] {
|
||||
return this.getVariableDeclarations();
|
||||
}
|
||||
|
||||
removeVariable(id: string): void {
|
||||
this.dispatch({ type: "removeVariable", id });
|
||||
}
|
||||
|
||||
// ── Canonical declaration edit ops (this stack) ──
|
||||
// declareVariable unified onto the {declaration} op payload (#2098 used
|
||||
// {decl}); the method signature is identical, so #2098 callers are unaffected.
|
||||
declareVariable(declaration: CompositionVariable): void {
|
||||
this.dispatch({ type: "declareVariable", declaration });
|
||||
}
|
||||
|
||||
updateVariableDeclaration(id: string, declaration: CompositionVariable): void {
|
||||
this.dispatch({ type: "updateVariableDeclaration", id, declaration });
|
||||
}
|
||||
|
||||
removeVariableDeclaration(id: string): void {
|
||||
this.dispatch({ type: "removeVariableDeclaration", id });
|
||||
}
|
||||
|
||||
getVariableDeclarations(): CompositionVariable[] {
|
||||
return readVariableDeclarations(declarationElement(this.parsed.document, this.parsed.wrapped));
|
||||
}
|
||||
|
||||
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown> {
|
||||
// THIS composition's own declared defaults (loose extraction: any entry with
|
||||
// a string id + a `default` key, even ones the strict declaration parser
|
||||
// drops) spread under the overrides. Scope note: this reads the composition's
|
||||
// single declaration element only — NOT a union of every `[data-composition-
|
||||
// variables]` in the document. The runtime's getVariables()
|
||||
// (core/runtime/getVariables.ts) additionally walks inlined sub-composition
|
||||
// declarers because it operates on the bundled multi-composition document;
|
||||
// the SDK models one composition file, so per-file scope is intended.
|
||||
const defaults = readDeclaredDefaults(
|
||||
declarationElement(this.parsed.document, this.parsed.wrapped),
|
||||
);
|
||||
return { ...defaults, ...(overrides ?? {}) };
|
||||
}
|
||||
|
||||
validateVariableValues(values: Record<string, unknown>): VariableValidationIssue[] {
|
||||
return validateVariables(values, this.getVariableDeclarations());
|
||||
}
|
||||
|
||||
/**
|
||||
* Script scans are content-keyed (same rationale as _gsapLabelCache): the
|
||||
* panel recomputes usage on every preview reload, and unchanged script text
|
||||
* is the common case — never pay a second acorn parse for identical input.
|
||||
*/
|
||||
private _variableUsageScanCache = new Map<string, ReturnType<typeof scanVariableUsage>>();
|
||||
|
||||
// Scan/merge dispatcher — same complexity class as the suppressed
|
||||
// variableUsage.ts classifiers it drives.
|
||||
// fallow-ignore-next-line complexity
|
||||
getVariableUsage(): VariableUsageReport {
|
||||
const usedIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let scanIncomplete = false;
|
||||
const freshCache = new Map<string, ReturnType<typeof scanVariableUsage>>();
|
||||
// Inline scripts only — external src scripts aren't part of the document model.
|
||||
for (const script of Array.from(this.parsed.document.querySelectorAll("script"))) {
|
||||
if (script.getAttribute("src")) continue;
|
||||
const text = script.textContent ?? "";
|
||||
// Direct global reads (window.__hfVariables / __hfVariablesByComp) are
|
||||
// invisible to the getVariables() scanner — the report must degrade to
|
||||
// a lower bound instead of confidently claiming declarations unused.
|
||||
if (text.includes("__hfVariables")) scanIncomplete = true;
|
||||
if (!text.includes("getVariables")) continue; // cheap pre-filter before an acorn parse
|
||||
const scan = this._variableUsageScanCache.get(text) ?? scanVariableUsage(text);
|
||||
freshCache.set(text, scan);
|
||||
scanIncomplete = scanIncomplete || scan.scanIncomplete;
|
||||
for (const id of scan.usedIds) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
usedIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Declarative bindings (data-var-src / data-var-text) are direct reads.
|
||||
for (const el of Array.from(
|
||||
this.parsed.document.querySelectorAll("[data-var-src], [data-var-text]"),
|
||||
)) {
|
||||
for (const attr of ["data-var-src", "data-var-text"]) {
|
||||
const id = el.getAttribute(attr)?.trim();
|
||||
if (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
usedIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
this._variableUsageScanCache = freshCache;
|
||||
const declaredIds = this.getVariableDeclarations().map((d) => d.id);
|
||||
// The CSS compat channel counts as usage: a variable consumed only via
|
||||
// var(--id) in stylesheets or inline styles must not be badged unused
|
||||
// (removing it also removes the --{id} root prop and breaks the binding).
|
||||
const cssText = this._collectCssText();
|
||||
// Match var(--id) only at a custom-property-name boundary: the id must be
|
||||
// followed by whitespace, a comma (fallback), or the closing paren — so id
|
||||
// "foo" is NOT counted as used by an unrelated var(--foo-header). Ids are
|
||||
// regex-escaped because a value read from disk may predate can()'s
|
||||
// /^[A-Za-z_][A-Za-z0-9_-]*$/ enforcement and carry metacharacters.
|
||||
const cssUsed = (id: string) =>
|
||||
new RegExp(`var\\(\\s*--${escapeRegExp(id)}[\\s,)]`).test(cssText);
|
||||
const declaredSet = new Set(declaredIds);
|
||||
return {
|
||||
usedIds,
|
||||
unusedDeclarations: declaredIds.filter((id) => !seen.has(id) && !cssUsed(id)),
|
||||
undeclaredReads: usedIds.filter((id) => !declaredSet.has(id)),
|
||||
scanIncomplete,
|
||||
};
|
||||
}
|
||||
|
||||
/** All stylesheet + inline-style text, for var(--id) consumption checks. */
|
||||
private _collectCssText(): string {
|
||||
const cssParts: string[] = [];
|
||||
for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) {
|
||||
cssParts.push(styleEl.textContent ?? "");
|
||||
}
|
||||
for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) {
|
||||
cssParts.push(el.getAttribute("style") ?? "");
|
||||
}
|
||||
return cssParts.join("\n");
|
||||
}
|
||||
|
||||
setPreviewVariables(values: Record<string, unknown> | null): boolean {
|
||||
if (!this.preview?.setPreviewVariables) return false;
|
||||
this.preview.setPreviewVariables(values);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cache of parsed GSAP labels keyed by the ordered list of EXACT script texts.
|
||||
* extractGsapLabels does a full acorn parse; caching avoids re-parsing on repeated
|
||||
* getElementTimings reads when the scripts are unchanged.
|
||||
*/
|
||||
private _gsapLabelCache: {
|
||||
scripts: string[];
|
||||
labels: ReturnType<typeof extractGsapLabels>;
|
||||
} | null = null;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
getElementTimings(): Record<HfId, ElementTimingSnapshot> {
|
||||
const scripts = getGsapScripts(this.parsed.document);
|
||||
|
||||
// Extract all addLabel("name", position) calls from every GSAP script.
|
||||
let allLabels: ReturnType<typeof extractGsapLabels>;
|
||||
const cachedScripts = this._gsapLabelCache?.scripts;
|
||||
const cacheMatches =
|
||||
cachedScripts?.length === scripts.length &&
|
||||
cachedScripts.every((script, index) => script === scripts[index]);
|
||||
if (cacheMatches) {
|
||||
allLabels = this._gsapLabelCache?.labels ?? [];
|
||||
} else {
|
||||
allLabels = scripts.flatMap((script) => extractGsapLabels(script));
|
||||
this._gsapLabelCache = scripts.length ? { scripts: [...scripts], labels: allLabels } : null;
|
||||
}
|
||||
|
||||
// Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" —
|
||||
// parseStartExpression's grammar) into an absolute second, recursively against the
|
||||
// referenced element's own resolved start + duration. A plain numeric data-start keeps
|
||||
// the old parseFloat path unchanged — this only touches the case that used to silently
|
||||
// resolve to 0 (parseFloat("intro + 2") is NaN). Node-safe static counterpart of the
|
||||
// runtime's own resolver (runtime/startResolver.ts): no live GSAP timeline to fall back
|
||||
// on, so an unauthored sub-composition duration still resolves to 0, same as before.
|
||||
//
|
||||
// refId is always a BARE id (the reference grammar has no scope syntax), resolved via
|
||||
// resolveScoped's bare-id rule: prefer the canonical top-level match, else document
|
||||
// order. An element inside a sub-composition referencing a bare id that also exists at
|
||||
// the top level resolves to the TOP-LEVEL one, not a same-scope sibling — this matches
|
||||
// the runtime's own resolver (also a global, not scope-aware, lookup), so the two stay
|
||||
// consistent, but it means a bare-id collision across scopes is a real footgun for
|
||||
// authored content.
|
||||
const startCache = new Map<Element, number>();
|
||||
const visiting = new Set<Element>();
|
||||
// Split out of resolveStart so its own branching stays low — this is the ONE
|
||||
// path that recurses + calls resolveDuration, kept here so that's visible at a
|
||||
// glance rather than buried inside resolveStart's try block.
|
||||
const resolveReferenceStart = (refId: string, offset: number): number => {
|
||||
const target = resolveScoped(this.parsed.document, refId);
|
||||
if (!target) return 0;
|
||||
return Math.max(0, resolveStart(target) + (resolveDuration(target) ?? 0) + offset);
|
||||
};
|
||||
const resolveStart = (el: Element): number => {
|
||||
const cached = startCache.get(el);
|
||||
if (cached !== undefined) return cached;
|
||||
if (visiting.has(el)) return 0; // reference cycle — fail safe, don't loop
|
||||
visiting.add(el);
|
||||
let resolved: number;
|
||||
try {
|
||||
const startStr = el.getAttribute("data-start");
|
||||
const expr = parseStartExpression(startStr);
|
||||
if (expr?.kind === "reference") {
|
||||
resolved = resolveReferenceStart(expr.refId, expr.offset);
|
||||
} else if (expr?.kind === "absolute") {
|
||||
resolved = expr.value;
|
||||
} else {
|
||||
resolved = startStr !== null ? parseFloat(startStr) : 0;
|
||||
}
|
||||
} finally {
|
||||
visiting.delete(el);
|
||||
}
|
||||
const finite = Number.isFinite(resolved) ? resolved : 0;
|
||||
startCache.set(el, finite);
|
||||
return finite;
|
||||
};
|
||||
// Same preference as handleSetTiming: prefer data-duration, fall back to end - start.
|
||||
const resolveDuration = (el: Element): number | null => {
|
||||
const durationStr = el.getAttribute("data-duration");
|
||||
const durationAttr = durationStr !== null ? parseFloat(durationStr) : null;
|
||||
if (durationAttr !== null && Number.isFinite(durationAttr)) return durationAttr;
|
||||
const endStr = el.getAttribute("data-end");
|
||||
const endAttr = endStr !== null ? parseFloat(endStr) : null;
|
||||
if (endAttr !== null && Number.isFinite(endAttr)) return endAttr - resolveStart(el);
|
||||
return null;
|
||||
};
|
||||
|
||||
const result: Record<HfId, ElementTimingSnapshot> = {};
|
||||
const elements = this.getElements();
|
||||
for (const el of elements) {
|
||||
const domEl = resolveScoped(this.parsed.document, el.scopedId);
|
||||
if (!domEl) continue;
|
||||
|
||||
const enterAt = resolveStart(domEl);
|
||||
const duration = resolveDuration(domEl);
|
||||
if (duration === null) continue; // no timing info — skip non-timed elements
|
||||
|
||||
const exitAt = enterAt + duration;
|
||||
|
||||
// Labels whose position falls within [enterAt, exitAt] (end-inclusive: a
|
||||
// label exactly at exitAt is treated as within the element's window).
|
||||
const labels = allLabels
|
||||
.filter(({ position }) => position >= enterAt && position <= exitAt)
|
||||
.map(({ name }) => name);
|
||||
|
||||
result[el.scopedId] = { enterAt, exitAt, labels };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
setElementTiming(
|
||||
map: Record<HfId, { start?: number; duration?: number; trackIndex?: number }>,
|
||||
): void {
|
||||
const entries = Object.entries(map);
|
||||
if (entries.length === 0) return;
|
||||
|
||||
this.batch(() => {
|
||||
for (const [id, timing] of entries) {
|
||||
this.dispatch({ type: "setTiming", target: id, ...timing });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setHold(id: HfId, hold: ElasticHold): void {
|
||||
this.dispatch({ type: "setHold", target: id, hold });
|
||||
}
|
||||
|
||||
addGsapTween(target: HfId, tween: GsapTweenSpec): string {
|
||||
const result = this._dispatch({ type: "addGsapTween", target, tween }, ORIGIN_LOCAL);
|
||||
return result.meta?.animationId ?? "";
|
||||
}
|
||||
|
||||
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void {
|
||||
this.dispatch({ type: "setGsapTween", animationId, properties });
|
||||
}
|
||||
|
||||
removeGsapTween(animationId: string): void {
|
||||
this.dispatch({ type: "removeGsapTween", animationId });
|
||||
}
|
||||
|
||||
addWithKeyframes(
|
||||
targetSelector: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
keyframes: KeyframeSpec[],
|
||||
ease?: string,
|
||||
): string {
|
||||
const result = this._dispatch(
|
||||
{ type: "addWithKeyframes", targetSelector, position, duration, keyframes, ease },
|
||||
ORIGIN_LOCAL,
|
||||
);
|
||||
return result.meta?.animationId ?? "";
|
||||
}
|
||||
|
||||
replaceWithKeyframes(
|
||||
animationId: string,
|
||||
targetSelector: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
keyframes: KeyframeSpec[],
|
||||
ease?: string,
|
||||
): string {
|
||||
const result = this._dispatch(
|
||||
{
|
||||
type: "replaceWithKeyframes",
|
||||
animationId,
|
||||
targetSelector,
|
||||
position,
|
||||
duration,
|
||||
keyframes,
|
||||
ease,
|
||||
},
|
||||
ORIGIN_LOCAL,
|
||||
);
|
||||
// Position-derived IDs renumber after the remove — this is the NEW id, which
|
||||
// may differ from the input animationId.
|
||||
return result.meta?.animationId ?? "";
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.historyModule?.undo();
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
this.historyModule?.redo();
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.historyModule?.canUndo() ?? false;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.historyModule?.canRedo() ?? false;
|
||||
}
|
||||
|
||||
// ── Query API (F1) ───────────────────────────────────────────────────────────
|
||||
|
||||
getElements(): ElementSnapshot[] {
|
||||
// Walk the live linkedom DOM directly — no serialize/re-parse round trip.
|
||||
this.elementsCache ??= flatElements(buildRoots(this.parsed.document));
|
||||
return [...this.elementsCache];
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level elements only (each still carrying its full descendant subtree via
|
||||
* `.children`) — unlike `getElements()`, no element appears twice. Consumers building a
|
||||
* tree view (a layer panel) want this, not `getElements()`: that method's flat list
|
||||
* includes every descendant a second time as its own top-level entry, since each
|
||||
* snapshot in it still carries its children. `buildRoots` already computes true roots
|
||||
* internally for `getElements()` to flatten — this just returns them unflattened.
|
||||
* Cached like elementsCache — a layer panel calling this every render tick shouldn't
|
||||
* repay the DOM walk each time.
|
||||
*/
|
||||
getRootElements(): ElementSnapshot[] {
|
||||
this.rootsCache ??= buildRoots(this.parsed.document);
|
||||
return [...this.rootsCache];
|
||||
}
|
||||
|
||||
getElement(id: HfId): ElementSnapshot | null {
|
||||
// Accept both bare ids (top-level) and scoped ids (sub-composition elements).
|
||||
// Match by scopedId first (canonical); bare-id fallback keeps top-level compat
|
||||
// for callers that don't yet use scoped ids.
|
||||
return (
|
||||
this.getElements().find((el) => el.scopedId === id) ??
|
||||
this.getElements().find((el) => el.id === id && el.scopedId === el.id) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
find(query: FindQuery): string[] {
|
||||
return (
|
||||
this.getElements()
|
||||
// fallow-ignore-next-line complexity
|
||||
.filter((el) => {
|
||||
if (query.tag && el.tag !== query.tag) return false;
|
||||
if (query.text && !el.text?.includes(query.text)) return false;
|
||||
if (query.name && el.attributes["data-name"] !== query.name) return false;
|
||||
if (query.track !== undefined && el.trackIndex !== query.track) return false;
|
||||
if (query.composition && !el.scopedId.startsWith(`${query.composition}/`)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((el) => el.scopedId)
|
||||
);
|
||||
}
|
||||
|
||||
getAllAnimationIds(): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const script of getGsapScripts(this.parsed.document)) {
|
||||
for (const id of parsedAnimationIds(script)) ids.add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ── Selection API ────────────────────────────────────────────────────────────
|
||||
|
||||
selection(): SelectionProxy {
|
||||
const ids = [...this.currentSelection];
|
||||
return {
|
||||
ids,
|
||||
setStyle: (styles) => this.dispatch({ type: "setStyle", target: ids, styles }),
|
||||
setText: (value) => this.dispatch({ type: "setText", target: ids, value }),
|
||||
setAttribute: (name, value) =>
|
||||
this.dispatch({ type: "setAttribute", target: ids, name, value }),
|
||||
setTiming: (timing) => this.dispatch({ type: "setTiming", target: ids, ...timing }),
|
||||
removeElement: () => this.dispatch({ type: "removeElement", target: ids }),
|
||||
};
|
||||
}
|
||||
|
||||
element(id: HfId): ElementHandle {
|
||||
return {
|
||||
id,
|
||||
setStyle: (styles) => this.dispatch({ type: "setStyle", target: id, styles }),
|
||||
setText: (value) => this.dispatch({ type: "setText", target: id, value }),
|
||||
setAttribute: (name, value) =>
|
||||
this.dispatch({ type: "setAttribute", target: id, name, value }),
|
||||
setTiming: (timing) => this.dispatch({ type: "setTiming", target: id, ...timing }),
|
||||
removeElement: () => this.dispatch({ type: "removeElement", target: id }),
|
||||
};
|
||||
}
|
||||
|
||||
getSelection(): string[] {
|
||||
return [...this.currentSelection];
|
||||
}
|
||||
|
||||
setSelection(ids: string[]): void {
|
||||
const deduped = Array.from(new Set(ids));
|
||||
if (
|
||||
deduped.length === this.currentSelection.length &&
|
||||
deduped.every((id, i) => id === this.currentSelection[i])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.updateSelection(deduped);
|
||||
}
|
||||
|
||||
private updateSelection(ids: readonly string[]): void {
|
||||
this.currentSelection = [...ids];
|
||||
for (const handler of this.selectionHandlers) {
|
||||
handler([...this.currentSelection]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dispatch / batch ─────────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
private _dispatch(op: EditOp, origin: unknown): MutationResult {
|
||||
const result = applyOp(this.parsed, op);
|
||||
const { forward, inverse } = result;
|
||||
|
||||
if (forward.length === 0 && inverse.length === 0) {
|
||||
if (this.batchDepth === 0) this.changeHandlers.forEach((h) => h());
|
||||
return result;
|
||||
}
|
||||
|
||||
this.elementsCache = null;
|
||||
this.rootsCache = null;
|
||||
|
||||
// Update override-set from forward patches
|
||||
for (const p of forward) {
|
||||
const key = pathToKey(p.path);
|
||||
if (key !== null) {
|
||||
this.overrides[key] =
|
||||
p.op === "remove"
|
||||
? null
|
||||
: (p.value as string | number | boolean | Record<string, unknown> | null);
|
||||
}
|
||||
}
|
||||
|
||||
// Purge orphan property keys for removed elements so the override-set stays
|
||||
// compact and a future T3 session doesn't replay stale properties onto a
|
||||
// non-existent element. Override-set keys use decoded scoped ids ("hf-host/hf-leaf")
|
||||
// while path segments use RFC 6902 encoding ("hf-host~1hf-leaf") — decode before compare.
|
||||
for (const p of forward) {
|
||||
const elemMatch = /^\/elements\/([^/]+)$/.exec(p.path);
|
||||
if (p.op === "remove" && elemMatch) {
|
||||
// Decode RFC 6902 escaping: ~1 → /, ~0 → ~
|
||||
const id = elemMatch[1]!.replace(/~1/g, "/").replace(/~0/g, "~");
|
||||
for (const key of Object.keys(this.overrides)) {
|
||||
// Purge property sub-keys (e.g. "hf-x.style.color") but preserve
|
||||
// the removal marker itself (key === id, set to null in the loop above).
|
||||
if (key.startsWith(`${id}.`) || key.startsWith(`${id}/`)) {
|
||||
delete this.overrides[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.batchDepth > 0) {
|
||||
this.batchForward.push(...forward);
|
||||
this.batchInverse.push(...inverse);
|
||||
if (!this.batchOpTypes.includes(op.type)) this.batchOpTypes.push(op.type);
|
||||
} else {
|
||||
// Reverse the inverse list (parity with batch() below): an op that emits
|
||||
// multiple patches whose undo order matters — same path (reorderElements
|
||||
// with a duplicate target), an aliased multi-target, or a nested
|
||||
// parent+child removeElement — must undo in reverse application order, or
|
||||
// undo lands on an intermediate value / drops a subtree. Harmless for the
|
||||
// common single-patch / independent-path case.
|
||||
const event = buildPatchEvent(forward, [...inverse].reverse(), origin, [op.type]);
|
||||
this.patchHandlers.forEach((h) => h(event));
|
||||
this.changeHandlers.forEach((h) => h());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
dispatch(op: EditOp, opts?: { origin?: unknown }): void {
|
||||
this._dispatch(op, opts?.origin ?? ORIGIN_LOCAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce multiple dispatches into one undo entry / one patch event.
|
||||
*
|
||||
* Transactional: if the callback throws, all DOM mutations applied so far
|
||||
* are reverted (accumulated inverse patches replayed in reverse) and the
|
||||
* override-set is restored — the model is exactly as it was at batch entry.
|
||||
*
|
||||
* Note: a batch that produces no effective mutations still fires 'change'
|
||||
* handlers (parity with no-op dispatch) — subscribers must not assume
|
||||
* silence when wrapping speculative operations.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
batch(fn: () => void, opts?: { origin?: unknown }): void {
|
||||
const origin = opts?.origin ?? ORIGIN_LOCAL;
|
||||
this.batchDepth++;
|
||||
if (this.batchDepth === 1) {
|
||||
this.batchOrigin = origin; // only set on outermost entry
|
||||
this.batchOverridesSnapshot = { ...this.overrides };
|
||||
}
|
||||
let threw = false;
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
throw err;
|
||||
} finally {
|
||||
this.batchDepth--;
|
||||
if (this.batchDepth === 0) {
|
||||
if (!threw && this.batchForward.length > 0) {
|
||||
const event = buildPatchEvent(
|
||||
this.batchForward,
|
||||
[...this.batchInverse].reverse(),
|
||||
this.batchOrigin,
|
||||
this.batchOpTypes,
|
||||
);
|
||||
// Fire handlers before resetting batch state so that if a handler
|
||||
// throws the patch data (batchForward/batchInverse) is still intact
|
||||
// for callers that inspect it on error. The event was already built
|
||||
// from a snapshot so handler re-entrancy does not corrupt the event.
|
||||
this.patchHandlers.forEach((h) => h(event));
|
||||
this.changeHandlers.forEach((h) => h());
|
||||
this.resetBatchState();
|
||||
} else {
|
||||
if (threw && this.batchInverse.length > 0) {
|
||||
// Roll back: the dispatches inside the batch already mutated the
|
||||
// DOM. Without this, a throwing batch would leave the model in a
|
||||
// partial state with no patch trail to undo it.
|
||||
applyPatchesToDocument(this.parsed, [...this.batchInverse].reverse());
|
||||
this.overrides = { ...this.batchOverridesSnapshot };
|
||||
this.elementsCache = null;
|
||||
this.rootsCache = null;
|
||||
}
|
||||
this.resetBatchState();
|
||||
// Empty no-op batch: fire changeHandlers (parity with dispatch)
|
||||
if (!threw) this.changeHandlers.forEach((h) => h());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resetBatchState(): void {
|
||||
this.batchForward = [];
|
||||
this.batchInverse = [];
|
||||
this.batchOpTypes = [];
|
||||
this.batchOrigin = ORIGIN_LOCAL;
|
||||
this.batchOverridesSnapshot = {};
|
||||
}
|
||||
|
||||
can(op: EditOp): CanResult {
|
||||
return validateOp(this.parsed, op);
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────────
|
||||
|
||||
on(event: "change", handler: () => void): () => void;
|
||||
on(event: "selectionchange", handler: (ids: string[]) => void): () => void;
|
||||
on(event: "patch", handler: (event: PatchEvent) => void): () => void;
|
||||
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
|
||||
// fallow-ignore-next-line complexity
|
||||
on(event: string, handler: unknown): () => void {
|
||||
const h = handler as (...args: unknown[]) => void;
|
||||
if (event === "change") {
|
||||
this.changeHandlers.push(h as () => void);
|
||||
return () => {
|
||||
this.changeHandlers = this.changeHandlers.filter((x) => x !== h);
|
||||
};
|
||||
}
|
||||
if (event === "selectionchange") {
|
||||
this.selectionHandlers.push(h as (ids: string[]) => void);
|
||||
return () => {
|
||||
this.selectionHandlers = this.selectionHandlers.filter((x) => x !== h);
|
||||
};
|
||||
}
|
||||
if (event === "patch") {
|
||||
this.patchHandlers.push(h as (e: PatchEvent) => void);
|
||||
return () => {
|
||||
this.patchHandlers = this.patchHandlers.filter((x) => x !== h);
|
||||
};
|
||||
}
|
||||
if (event === "persist:error") {
|
||||
const typedH = h as (e: PersistErrorEvent) => void;
|
||||
this.errorHandlers.push(typedH);
|
||||
const offPersist = this.persist?.on("persist:error", typedH);
|
||||
return () => {
|
||||
this.errorHandlers = this.errorHandlers.filter((x) => x !== typedH);
|
||||
offPersist?.();
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// ── Serialization ────────────────────────────────────────────────────────────
|
||||
|
||||
serialize(opts?: { stripRuntime?: boolean }): string {
|
||||
const html = serializeDocument(this.parsed);
|
||||
// Newer agent-generated compositions embed hyperframe.runtime.iife.js in their own
|
||||
// HTML. Any host driving its own clock (not just an editing iframe — anything that
|
||||
// owns seeking/playback itself) must not let that runtime self-init: it races the
|
||||
// host's first seek and resets the timeline to t=0. Opt-in (default false) since a
|
||||
// host playing the composition normally wants the runtime.
|
||||
return opts?.stripRuntime ? stripEmbeddedRuntimeScripts(html) : html;
|
||||
}
|
||||
|
||||
// ── T3 embedded-mode extras ──────────────────────────────────────────────────
|
||||
|
||||
getOverrides(): OverrideSet {
|
||||
return { ...this.overrides };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
applyPatches(patches: readonly JsonPatchOp[], opts?: { origin?: unknown }): void {
|
||||
const origin = opts?.origin ?? ORIGIN_APPLY_PATCHES;
|
||||
|
||||
// The emitted PatchEvent carries an EMPTY inversePatches array — hosts
|
||||
// maintaining an external inverse log must compute inverses from their own
|
||||
// state; applyPatches events never enter history (origin-guarded).
|
||||
// Emit a patch event so subscribers stay in sync.
|
||||
applyPatchesToDocument(this.parsed, patches);
|
||||
this.elementsCache = null;
|
||||
this.rootsCache = null;
|
||||
|
||||
// Update override-set
|
||||
for (const p of patches) {
|
||||
const key = pathToKey(p.path);
|
||||
if (key !== null) {
|
||||
this.overrides[key] =
|
||||
p.op === "remove"
|
||||
? null
|
||||
: (p.value as string | number | boolean | Record<string, unknown> | null);
|
||||
}
|
||||
}
|
||||
|
||||
const opTypes = ["applyPatches"];
|
||||
const event = buildPatchEvent(patches, [], origin, opTypes);
|
||||
this.patchHandlers.forEach((h) => h(event));
|
||||
this.changeHandlers.forEach((h) => h());
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.persistQueueModule?.flush();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.previewSelectionUnsubscribe?.();
|
||||
this.previewSelectionUnsubscribe = null;
|
||||
this.persistQueueModule?.dispose();
|
||||
this.historyModule?.dispose();
|
||||
this.changeHandlers = [];
|
||||
this.selectionHandlers = [];
|
||||
this.patchHandlers = [];
|
||||
this.errorHandlers = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public factory ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a composition for editing.
|
||||
*
|
||||
* Standalone (T1/T2): supply persist adapter — SDK owns history + auto-save.
|
||||
* Embedded (T3): supply overrides — SDK emits patches; host owns history + persistence.
|
||||
* Headless (agents): omit both — SDK is a stateless transform + serializer.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function openComposition(
|
||||
html: string,
|
||||
opts?: OpenCompositionOptions,
|
||||
): Promise<Composition> {
|
||||
// Single parse: parseMutable stamps hf-ids + builds the live linkedom DOM;
|
||||
// the query API derives element snapshots from it lazily.
|
||||
const parsed = parseMutable(html);
|
||||
|
||||
// T3 embedded: replay the stored override-set onto the base in one pass,
|
||||
// so the session exposes the user's exact edited state — not the template.
|
||||
if (opts?.overrides) applyOverrideSet(parsed, opts.overrides);
|
||||
|
||||
const session = new CompositionImpl(parsed, opts ?? {});
|
||||
|
||||
const isEmbedded = opts?.overrides !== undefined;
|
||||
|
||||
if (!isEmbedded) {
|
||||
// history:false opts out of the SDK undo stack ONLY. Persist (auto-save) is
|
||||
// independent — gating it on the history flag too would silently drop every
|
||||
// disk write for a caller that just wanted to disable undo (data loss).
|
||||
if (opts?.history !== false) {
|
||||
const history = createHistory(session, {
|
||||
coalesceMs: opts?.coalesceMs ?? 300,
|
||||
trackedOrigins: opts?.trackedOrigins,
|
||||
});
|
||||
session.attachHistory(history);
|
||||
}
|
||||
|
||||
if (opts?.persist) {
|
||||
const pq = createPersistQueue(session, opts.persist, {
|
||||
path: opts.persistPath,
|
||||
onError: (e) => session._fireError(e),
|
||||
});
|
||||
session.attachPersistQueue(pq);
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Variable declaration edit ops: declareVariable / updateVariableDeclaration /
|
||||
* removeVariableDeclaration. Covers dispatch semantics, can() validation,
|
||||
* CSS compat sync, patch grammar, and undo round-trips.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
import { variableDeclPath, pathToKey, keyToPath } from "./engine/patches.js";
|
||||
import type { CompositionVariable } from "@hyperframes/core/variables";
|
||||
|
||||
const TITLE_DECL: CompositionVariable = {
|
||||
id: "title",
|
||||
type: "string",
|
||||
label: "Title",
|
||||
default: "Hello",
|
||||
};
|
||||
|
||||
const COUNT_DECL: CompositionVariable = {
|
||||
id: "count",
|
||||
type: "number",
|
||||
label: "Count",
|
||||
default: 3,
|
||||
min: 0,
|
||||
max: 10,
|
||||
};
|
||||
|
||||
const BARE_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" data-start="0" data-end="3">Hello</h1>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
const DECLARED_HTML = `<!DOCTYPE html>
|
||||
<html data-composition-variables='${JSON.stringify([TITLE_DECL, COUNT_DECL])}'>
|
||||
<body>${BARE_HTML}</body>
|
||||
</html>`;
|
||||
|
||||
const UNDECLARED_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<body>${BARE_HTML}</body>
|
||||
</html>`;
|
||||
|
||||
describe("declareVariable", () => {
|
||||
it("creates the attribute on a composition with no declarations", async () => {
|
||||
const comp = await openComposition(UNDECLARED_HTML);
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
comp.declareVariable(TITLE_DECL);
|
||||
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
|
||||
expect(comp.serialize()).toContain("data-composition-variables");
|
||||
});
|
||||
|
||||
it("appends to existing declarations and survives serialize round-trip", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.declareVariable({ id: "dark", type: "boolean", label: "Dark", default: false });
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count", "dark"]);
|
||||
|
||||
const reopened = await openComposition(comp.serialize());
|
||||
expect(reopened.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count", "dark"]);
|
||||
});
|
||||
|
||||
it("no-ops on duplicate ids and can() reports E_DUPLICATE_VARIABLE", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
const dup = comp.can({ type: "declareVariable", declaration: TITLE_DECL });
|
||||
expect(dup).toMatchObject({ ok: false, code: "E_DUPLICATE_VARIABLE" });
|
||||
comp.declareVariable({ ...TITLE_DECL, default: "clobbered" });
|
||||
expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.default).toBe("Hello");
|
||||
});
|
||||
|
||||
it("rejects structurally invalid declarations", async () => {
|
||||
const comp = await openComposition(UNDECLARED_HTML);
|
||||
const invalid = {
|
||||
id: "broken",
|
||||
type: "number",
|
||||
label: "Broken",
|
||||
default: "not-a-number",
|
||||
} as unknown as CompositionVariable;
|
||||
expect(comp.can({ type: "declareVariable", declaration: invalid })).toMatchObject({
|
||||
ok: false,
|
||||
code: "E_INVALID_ARGS",
|
||||
});
|
||||
comp.declareVariable(invalid);
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects ids that are not valid CSS/attribute identifiers", async () => {
|
||||
const comp = await openComposition(UNDECLARED_HTML);
|
||||
for (const id of ["", " ", "has space", "1leading", "dot.id", "sla/sh", 'quo"te']) {
|
||||
expect(
|
||||
comp.can({ type: "declareVariable", declaration: { ...TITLE_DECL, id } }),
|
||||
).toMatchObject({ ok: false, code: "E_INVALID_VARIABLE_ID" });
|
||||
comp.declareVariable({ ...TITLE_DECL, id });
|
||||
}
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
// Sanity: a valid id with the allowed charset still declares.
|
||||
comp.declareVariable({ ...TITLE_DECL, id: "brand_color-2" });
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["brand_color-2"]);
|
||||
});
|
||||
|
||||
it("undo removes the declaration again", async () => {
|
||||
const comp = await openComposition(UNDECLARED_HTML);
|
||||
comp.declareVariable(TITLE_DECL);
|
||||
comp.undo();
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
expect(comp.serialize()).not.toContain("data-composition-variables");
|
||||
comp.redo();
|
||||
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("supports fragment compositions with a root element (schema on the root div)", async () => {
|
||||
// A fragment (no <html>) still has a composition root; declarations live on
|
||||
// that root div, which survives serialize — so template/sub-comp files are
|
||||
// first-class editable, not refused.
|
||||
const comp = await openComposition(BARE_HTML);
|
||||
expect(comp.can({ type: "declareVariable", declaration: TITLE_DECL })).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
comp.declareVariable(TITLE_DECL);
|
||||
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
|
||||
expect(comp.serialize()).toContain("data-composition-variables");
|
||||
});
|
||||
|
||||
it("refuses a fragment with no root element (nowhere durable to write)", async () => {
|
||||
const comp = await openComposition("just text, no element");
|
||||
expect(comp.can({ type: "declareVariable", declaration: TITLE_DECL })).toMatchObject({
|
||||
ok: false,
|
||||
code: "E_FRAGMENT_COMPOSITION",
|
||||
});
|
||||
comp.declareVariable(TITLE_DECL);
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
expect(comp.serialize()).not.toContain("data-composition-variables");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVariableDeclaration", () => {
|
||||
it("replaces the declaration wholesale", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.updateVariableDeclaration("count", { ...COUNT_DECL, label: "Item count", max: 20 });
|
||||
const decl = comp.getVariableDeclarations().find((d) => d.id === "count");
|
||||
expect(decl).toMatchObject({ label: "Item count", max: 20, default: 3 });
|
||||
});
|
||||
|
||||
it("syncs the CSS compat prop when a scalar default changes", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.updateVariableDeclaration("count", { ...COUNT_DECL, default: 7 });
|
||||
const root = comp.getElements().find((e) => e.id === "hf-stage");
|
||||
expect(root?.inlineStyles["--count"]).toBe("7");
|
||||
});
|
||||
|
||||
it("keeps CSS untouched when the default is unchanged", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.updateVariableDeclaration("count", { ...COUNT_DECL, label: "Renamed only" });
|
||||
const root = comp.getElements().find((e) => e.id === "hf-stage");
|
||||
expect(root?.inlineStyles["--count"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates id immutability and existence via can()", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
expect(
|
||||
comp.can({ type: "updateVariableDeclaration", id: "count", declaration: TITLE_DECL }),
|
||||
).toMatchObject({ ok: false, code: "E_INVALID_ARGS" });
|
||||
expect(
|
||||
comp.can({
|
||||
type: "updateVariableDeclaration",
|
||||
id: "ghost",
|
||||
declaration: { ...TITLE_DECL, id: "ghost" },
|
||||
}),
|
||||
).toMatchObject({ ok: false, code: "E_VARIABLE_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("undo restores the previous declaration", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.updateVariableDeclaration("title", { ...TITLE_DECL, label: "Headline" });
|
||||
comp.undo();
|
||||
expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.label).toBe("Title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeVariableDeclaration", () => {
|
||||
it("removes the entry and drops the attribute with the last one", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.removeVariableDeclaration("count");
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title"]);
|
||||
comp.removeVariableDeclaration("title");
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
expect(comp.serialize()).not.toContain("data-composition-variables");
|
||||
});
|
||||
|
||||
it("clears the CSS compat prop and undo restores declaration + CSS", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
comp.setVariableValue("count", 5);
|
||||
const rootBefore = comp.getElements().find((e) => e.id === "hf-stage");
|
||||
expect(rootBefore?.inlineStyles["--count"]).toBe("5");
|
||||
|
||||
comp.removeVariableDeclaration("count");
|
||||
const rootAfter = comp.getElements().find((e) => e.id === "hf-stage");
|
||||
expect(rootAfter?.inlineStyles["--count"]).toBeUndefined();
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title"]);
|
||||
|
||||
comp.undo();
|
||||
const rootRestored = comp.getElements().find((e) => e.id === "hf-stage");
|
||||
expect(rootRestored?.inlineStyles["--count"]).toBe("5");
|
||||
expect(comp.getVariableDeclarations().find((d) => d.id === "count")?.default).toBe(5);
|
||||
});
|
||||
|
||||
it("no-ops on unknown ids and can() reports E_VARIABLE_NOT_FOUND", async () => {
|
||||
const comp = await openComposition(DECLARED_HTML);
|
||||
expect(comp.can({ type: "removeVariableDeclaration", id: "ghost" })).toMatchObject({
|
||||
ok: false,
|
||||
code: "E_VARIABLE_NOT_FOUND",
|
||||
});
|
||||
comp.removeVariableDeclaration("ghost");
|
||||
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patch grammar", () => {
|
||||
it("maps /variableDeclarations/{id} ↔ varDecl.{id} without colliding with /variables/", () => {
|
||||
const path = variableDeclPath("brand-color");
|
||||
expect(path).toBe("/variableDeclarations/brand-color");
|
||||
expect(pathToKey(path)).toBe("varDecl.brand-color");
|
||||
expect(keyToPath("varDecl.brand-color")).toBe(path);
|
||||
// The value path family must stay untouched.
|
||||
expect(pathToKey("/variables/brand-color")).toBe("var.brand-color");
|
||||
expect(keyToPath("var.brand-color")).toBe("/variables/brand-color");
|
||||
});
|
||||
|
||||
it("emits declaration patches on dispatch", async () => {
|
||||
const comp = await openComposition(UNDECLARED_HTML);
|
||||
const events: string[] = [];
|
||||
comp.on("patch", (e) => {
|
||||
for (const p of e.patches) events.push(`${p.op} ${p.path}`);
|
||||
});
|
||||
comp.declareVariable(TITLE_DECL);
|
||||
comp.removeVariableDeclaration("title");
|
||||
// Declaration ops also maintain the --{id} CSS compat prop (scalar defaults).
|
||||
expect(events).toEqual([
|
||||
"add /variableDeclarations/title",
|
||||
"add /elements/hf-stage/inlineStyles/--title",
|
||||
"remove /variableDeclarations/title",
|
||||
"remove /elements/hf-stage/inlineStyles/--title",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Variable read APIs: getVariableDeclarations / getVariableValues /
|
||||
* validateVariableValues. Semantics contract: declarations use the strict
|
||||
* canonical parser; values mirror the runtime's loose defaults + overrides
|
||||
* merge; validation matches --strict-variables.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
|
||||
const DECLS = [
|
||||
{ id: "title", type: "string", label: "Title", default: "Hello" },
|
||||
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
|
||||
{ id: "count", type: "number", label: "Count", default: 3, min: 0, max: 10 },
|
||||
{ id: "dark", type: "boolean", label: "Dark mode", default: false },
|
||||
{
|
||||
id: "layout",
|
||||
type: "enum",
|
||||
label: "Layout",
|
||||
default: "wide",
|
||||
options: [
|
||||
{ value: "wide", label: "Wide" },
|
||||
{ value: "tall", label: "Tall" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function htmlWithVariables(attr: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html data-composition-variables='${attr}'>
|
||||
<body>
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" data-start="0" data-end="3">Hello</h1>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const BASE_HTML = htmlWithVariables(JSON.stringify(DECLS));
|
||||
|
||||
describe("getVariableDeclarations", () => {
|
||||
it("returns the declared schema with per-type metadata intact", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const decls = comp.getVariableDeclarations();
|
||||
expect(decls.map((d) => d.id)).toEqual(["title", "accent", "count", "dark", "layout"]);
|
||||
const count = decls.find((d) => d.id === "count");
|
||||
expect(count).toMatchObject({ type: "number", min: 0, max: 10, default: 3 });
|
||||
const layout = decls.find((d) => d.id === "layout");
|
||||
expect(layout).toMatchObject({ type: "enum", default: "wide" });
|
||||
});
|
||||
|
||||
it("returns [] when the attribute is absent", async () => {
|
||||
const comp = await openComposition(
|
||||
`<div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div>`,
|
||||
);
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for invalid JSON and drops malformed entries", async () => {
|
||||
const invalid = await openComposition(htmlWithVariables("{not json"));
|
||||
expect(invalid.getVariableDeclarations()).toEqual([]);
|
||||
|
||||
const mixed = JSON.stringify([
|
||||
{ id: "ok", type: "string", label: "Ok", default: "yes" },
|
||||
{ id: "bad-type", type: "gradient", label: "Nope", default: "x" },
|
||||
{ id: "bad-default", type: "number", label: "Nope", default: "not-a-number" },
|
||||
"not-an-object",
|
||||
]);
|
||||
const mixedComp = await openComposition(htmlWithVariables(mixed));
|
||||
const decls = mixedComp.getVariableDeclarations();
|
||||
expect(decls.map((d) => d.id)).toEqual(["ok"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVariableValues", () => {
|
||||
it("returns declared defaults when no overrides given", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
expect(comp.getVariableValues()).toEqual({
|
||||
title: "Hello",
|
||||
accent: "#00C3FF",
|
||||
count: 3,
|
||||
dark: false,
|
||||
layout: "wide",
|
||||
});
|
||||
});
|
||||
|
||||
it("overrides win and undeclared override keys pass through (runtime parity)", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const values = comp.getVariableValues({ title: "Custom", extra: 42 });
|
||||
expect(values.title).toBe("Custom");
|
||||
expect(values.accent).toBe("#00C3FF");
|
||||
expect(values.extra).toBe(42);
|
||||
});
|
||||
|
||||
it("uses the loose runtime defaults filter, not the strict declaration parser", async () => {
|
||||
// A number variable with a string default is dropped by the strict parser
|
||||
// but its default still flows through the runtime's readDeclaredDefaults —
|
||||
// getVariableValues must match what a composition script actually reads.
|
||||
const attr = JSON.stringify([
|
||||
{ id: "loose", type: "number", label: "Loose", default: "not-a-number" },
|
||||
]);
|
||||
const comp = await openComposition(htmlWithVariables(attr));
|
||||
expect(comp.getVariableDeclarations()).toEqual([]);
|
||||
expect(comp.getVariableValues()).toEqual({ loose: "not-a-number" });
|
||||
});
|
||||
|
||||
it("returns {} for a composition with no declarations", async () => {
|
||||
const comp = await openComposition(
|
||||
`<div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div>`,
|
||||
);
|
||||
expect(comp.getVariableValues()).toEqual({});
|
||||
expect(comp.getVariableValues({ a: 1 })).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateVariableValues", () => {
|
||||
it("returns [] for values conforming to the schema", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
expect(comp.validateVariableValues({ title: "x", count: 5, dark: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags undeclared keys, type mismatches, and enum violations", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const issues = comp.validateVariableValues({
|
||||
ghost: "boo",
|
||||
count: "five",
|
||||
layout: "diagonal",
|
||||
});
|
||||
const kinds = issues.map((i) => `${i.kind}:${i.variableId}`).sort();
|
||||
expect(kinds).toEqual(["enum-out-of-range:layout", "type-mismatch:count", "undeclared:ghost"]);
|
||||
});
|
||||
|
||||
it("stays consistent after setVariableValue edits the default", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setVariableValue("title", "Edited");
|
||||
expect(comp.getVariableValues().title).toBe("Edited");
|
||||
expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.default).toBe("Edited");
|
||||
expect(comp.validateVariableValues({ title: "still-a-string" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* getVariableUsage (declaration ↔ script-scan cross-reference) and
|
||||
* setPreviewVariables (preview adapter delegation).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
import type { PreviewAdapter } from "./adapters/types.js";
|
||||
|
||||
const DECLS = JSON.stringify([
|
||||
{ id: "title", type: "string", label: "Title", default: "Hello" },
|
||||
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
|
||||
{ id: "orphan", type: "string", label: "Never read", default: "x" },
|
||||
]);
|
||||
|
||||
function doc(script: string, decls: string | null = DECLS): string {
|
||||
const attr = decls ? ` data-composition-variables='${decls}'` : "";
|
||||
return `<!DOCTYPE html>
|
||||
<html${attr}>
|
||||
<body>
|
||||
<div data-hf-id="hf-stage" data-hf-root data-duration="5">
|
||||
<h1 data-hf-id="hf-title">Hello</h1>
|
||||
</div>
|
||||
<script>${script}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
describe("getVariableUsage", () => {
|
||||
it("cross-references used, unused, and undeclared ids", async () => {
|
||||
const comp = await openComposition(
|
||||
doc(`
|
||||
const { title, ghost } = __hyperframes.getVariables();
|
||||
document.querySelector("h1").textContent = title;
|
||||
const vars = __hyperframes.getVariables();
|
||||
el.style.color = vars.accent;
|
||||
`),
|
||||
);
|
||||
const usage = comp.getVariableUsage();
|
||||
expect(usage.usedIds).toEqual(["title", "ghost", "accent"]);
|
||||
expect(usage.unusedDeclarations).toEqual(["orphan"]);
|
||||
expect(usage.undeclaredReads).toEqual(["ghost"]);
|
||||
expect(usage.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("reports all declarations unused when no script reads variables", async () => {
|
||||
const comp = await openComposition(doc(`gsap.timeline({ paused: true });`));
|
||||
const usage = comp.getVariableUsage();
|
||||
expect(usage.usedIds).toEqual([]);
|
||||
expect(usage.unusedDeclarations).toEqual(["title", "accent", "orphan"]);
|
||||
expect(usage.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("propagates scanIncomplete from opaque access", async () => {
|
||||
const comp = await openComposition(
|
||||
doc(`const vars = getVariables(); const v = vars[pickKey()];`),
|
||||
);
|
||||
expect(comp.getVariableUsage().scanIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("counts a variable used only via var(--id) in a <style> block as used", async () => {
|
||||
const comp = await openComposition(
|
||||
doc(`gsap.timeline({ paused: true });`).replace(
|
||||
"<body>",
|
||||
`<body><style>.stage { background: var(--accent); }</style>`,
|
||||
),
|
||||
);
|
||||
const usage = comp.getVariableUsage();
|
||||
// accent is CSS-consumed, so it must NOT be badged unused.
|
||||
expect(usage.unusedDeclarations).toEqual(["title", "orphan"]);
|
||||
});
|
||||
|
||||
it("does not count var(--id) as usage of a prefix-extended custom property", async () => {
|
||||
// `accent` must not be marked used by an unrelated `var(--accent-shadow)`.
|
||||
const comp = await openComposition(
|
||||
doc(`gsap.timeline({ paused: true });`).replace(
|
||||
"<body>",
|
||||
`<body><style>.stage { box-shadow: 0 0 4px var(--accent-shadow); }</style>`,
|
||||
),
|
||||
);
|
||||
const usage = comp.getVariableUsage();
|
||||
expect(usage.unusedDeclarations).toContain("accent");
|
||||
});
|
||||
|
||||
it("counts declarative data-var-src / data-var-text bindings as usage", async () => {
|
||||
const comp = await openComposition(`<!DOCTYPE html>
|
||||
<html data-composition-variables='${DECLS}'>
|
||||
<body>
|
||||
<div data-hf-id="hf-stage" data-hf-root data-duration="5">
|
||||
<img data-hf-id="hf-img" data-var-src="accent" src="x.jpg" />
|
||||
<h1 data-hf-id="hf-h" data-var-text="title">t</h1>
|
||||
</div>
|
||||
</body>
|
||||
</html>`);
|
||||
const usage = comp.getVariableUsage();
|
||||
expect(usage.usedIds).toEqual(expect.arrayContaining(["accent", "title"]));
|
||||
expect(usage.unusedDeclarations).toEqual(["orphan"]);
|
||||
expect(usage.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("handles compositions with no declarations and no scripts", async () => {
|
||||
const comp = await openComposition(
|
||||
`<!DOCTYPE html><html><body><div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div></body></html>`,
|
||||
);
|
||||
expect(comp.getVariableUsage()).toEqual({
|
||||
usedIds: [],
|
||||
unusedDeclarations: [],
|
||||
undeclaredReads: [],
|
||||
scanIncomplete: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPreviewVariables", () => {
|
||||
function makeAdapter(calls: Array<Record<string, unknown> | null>): PreviewAdapter {
|
||||
return {
|
||||
elementAtPoint: () => null,
|
||||
applyDraft: () => {},
|
||||
commitPreview: () => {},
|
||||
cancelPreview: () => {},
|
||||
select: () => {},
|
||||
on: () => () => {},
|
||||
setPreviewVariables: (values) => calls.push(values),
|
||||
};
|
||||
}
|
||||
|
||||
it("delegates to the preview adapter and reports handling", async () => {
|
||||
const calls: Array<Record<string, unknown> | null> = [];
|
||||
const comp = await openComposition(doc(""), { preview: makeAdapter(calls) });
|
||||
expect(comp.setPreviewVariables({ title: "Custom" })).toBe(true);
|
||||
expect(comp.setPreviewVariables(null)).toBe(true);
|
||||
expect(calls).toEqual([{ title: "Custom" }, null]);
|
||||
});
|
||||
|
||||
it("returns false without an adapter or without adapter support", async () => {
|
||||
const noAdapter = await openComposition(doc(""));
|
||||
expect(noAdapter.setPreviewVariables({ a: 1 })).toBe(false);
|
||||
|
||||
const bare = makeAdapter([]);
|
||||
delete bare.setPreviewVariables;
|
||||
const unsupported = await openComposition(doc(""), { preview: bare });
|
||||
expect(unsupported.setPreviewVariables({ a: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* SDK smoke test — end-to-end pipeline.
|
||||
*
|
||||
* Exercises the full public surface: openComposition → mutate → applyPatches →
|
||||
* serialize round-trip, plus batch transactionality, history undo, persist
|
||||
* adapter, and patch subscription.
|
||||
*
|
||||
* This file is the "golden example" pinned as a regression smoke test.
|
||||
* If it breaks, the SDK's public contract has changed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
openComposition,
|
||||
ORIGIN_APPLY_PATCHES,
|
||||
resolveScoped,
|
||||
findById,
|
||||
escapeHfId,
|
||||
readVariableDefault,
|
||||
} from "./index.js";
|
||||
import { createMemoryAdapter } from "./adapters/memory.js";
|
||||
|
||||
// ─── Fixture ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_HTML = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<div id="stage" data-hf-root data-width="1920" data-height="1080" data-duration="5">
|
||||
<h1 id="title" data-hf-id="hf-title" data-start="0" data-end="3"
|
||||
style="color: #fff; font-size: 64px">Hello World</h1>
|
||||
<img id="logo" data-hf-id="hf-logo" src="/logo.png" alt="Logo"
|
||||
data-x="100" data-y="200" data-start="0" data-end="5" />
|
||||
<p id="body-copy" data-hf-id="hf-body" data-start="1" data-end="4"
|
||||
style="font-size: 24px">Body copy</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
|
||||
// ─── init → mutate → serialize ────────────────────────────────────────────────
|
||||
|
||||
describe("openComposition + basic mutations", () => {
|
||||
it("opens without error and exposes element snapshots", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const els = comp.getElements();
|
||||
expect(els.length).toBeGreaterThanOrEqual(3);
|
||||
const title = comp.getElement("hf-title");
|
||||
expect(title).not.toBeNull();
|
||||
expect(title?.inlineStyles.color).toBe("#fff");
|
||||
});
|
||||
|
||||
it("setStyle mutates inline styles and serializes back to HTML", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#f00", fontSize: "96px" });
|
||||
const html = comp.serialize();
|
||||
expect(html).toContain("color: #f00");
|
||||
expect(html).toContain("font-size: 96px");
|
||||
});
|
||||
|
||||
it("element handle sugar — same result as direct setStyle", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.element("hf-title").setStyle({ color: "#0f0" });
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
|
||||
});
|
||||
|
||||
it("setText updates text content", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setText("hf-title", "Goodbye World");
|
||||
expect(comp.getElement("hf-title")?.text).toContain("Goodbye World");
|
||||
expect(comp.serialize()).toContain("Goodbye World");
|
||||
});
|
||||
|
||||
it("dispatch moveElement writes data-x/data-y attributes", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.dispatch({ type: "moveElement", target: "hf-logo", x: 300, y: 400 });
|
||||
const el = comp.getElement("hf-logo");
|
||||
expect(el?.attributes["data-x"]).toBe("300");
|
||||
expect(el?.attributes["data-y"]).toBe("400");
|
||||
});
|
||||
|
||||
it("serialize round-trip: mutate → serialize → reopen → same state", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#0f0" });
|
||||
comp.setText("hf-body", "Round-tripped");
|
||||
const html = comp.serialize();
|
||||
|
||||
const comp2 = await openComposition(html);
|
||||
expect(comp2.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
|
||||
expect(comp2.getElement("hf-body")?.text).toContain("Round-tripped");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── patch subscription ───────────────────────────────────────────────────────
|
||||
|
||||
describe("patch events", () => {
|
||||
it("emits a patch event per dispatch with correct path and value", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const events: unknown[] = [];
|
||||
comp.on("patch", (e) => events.push(e));
|
||||
|
||||
comp.setStyle("hf-title", { fontSize: "48px" });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
const event = events[0] as { patches: { path: string; value: unknown }[] };
|
||||
const patch = event.patches.find((p) => p.path.endsWith("/fontSize"));
|
||||
expect(patch?.value).toBe("48px");
|
||||
});
|
||||
|
||||
it("applyPatches origin is tagged ORIGIN_APPLY_PATCHES", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const origins: unknown[] = [];
|
||||
comp.on("patch", (e) => origins.push((e as { origin: unknown }).origin));
|
||||
|
||||
comp.applyPatches([
|
||||
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
|
||||
]);
|
||||
|
||||
expect(origins[0]).toBe(ORIGIN_APPLY_PATCHES);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyPatches ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("applyPatches", () => {
|
||||
it("applies external RFC 6902 patches to the live document", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.applyPatches([
|
||||
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
|
||||
{ op: "replace", path: "/elements/hf-title/text", value: "Patched" },
|
||||
]);
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#00f");
|
||||
expect(comp.getElement("hf-title")?.text).toContain("Patched");
|
||||
});
|
||||
|
||||
it("applyPatches does NOT enter undo history — undo() is a no-op", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.applyPatches([
|
||||
{ op: "replace", path: "/elements/hf-title/inlineStyles/color", value: "#00f" },
|
||||
]);
|
||||
comp.undo(); // no-op: applyPatches bypasses history
|
||||
// color must still be the patched value (undo had nothing to revert)
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#00f");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batch transactionality ───────────────────────────────────────────────────
|
||||
|
||||
describe("batch()", () => {
|
||||
it("coalesces multiple dispatches into one patch event", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const events: unknown[] = [];
|
||||
comp.on("patch", (e) => events.push(e));
|
||||
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
comp.setText("hf-body", "Batched");
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rolls back DOM on throw — model unchanged after throwing batch", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const beforeColor = comp.getElement("hf-title")?.inlineStyles.color;
|
||||
|
||||
try {
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
throw new Error("user cancelled");
|
||||
});
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
// DOM must be exactly as before
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe(beforeColor);
|
||||
});
|
||||
|
||||
it("throwing batch does NOT add a history entry — undo is a no-op", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
try {
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
throw new Error("rollback");
|
||||
});
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
// undo should be a no-op since no history entry was added
|
||||
comp.undo();
|
||||
// color should still be the original (batch was rolled back + undo had nothing to do)
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#fff");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("undo / redo", () => {
|
||||
it("undo reverts last mutation, redo re-applies it", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#f00");
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#fff");
|
||||
|
||||
comp.redo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#f00");
|
||||
});
|
||||
|
||||
it("undo with no history is a no-op", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const before = comp.getElement("hf-title")?.inlineStyles.color;
|
||||
comp.undo(); // no-op
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── persist adapter ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("persist adapter", () => {
|
||||
it("writes serialized HTML to the adapter on mutation", async () => {
|
||||
const adapter = createMemoryAdapter();
|
||||
const writeSpy = vi.spyOn(adapter, "write");
|
||||
|
||||
const comp = await openComposition(BASE_HTML, { persist: adapter });
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
await comp.flush();
|
||||
|
||||
expect(writeSpy).toHaveBeenCalled();
|
||||
const [, content] = writeSpy.mock.calls[0] as [string, string];
|
||||
expect(content).toContain("color: #f00");
|
||||
});
|
||||
|
||||
it("still persists when history:false (undo opt-out must not disable auto-save)", async () => {
|
||||
const adapter = createMemoryAdapter();
|
||||
const writeSpy = vi.spyOn(adapter, "write");
|
||||
|
||||
const comp = await openComposition(BASE_HTML, { persist: adapter, history: false });
|
||||
expect(comp.canUndo()).toBe(false); // undo is off…
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
await comp.flush();
|
||||
|
||||
expect(writeSpy).toHaveBeenCalled(); // …but the write still happened
|
||||
const [, content] = writeSpy.mock.calls[0] as [string, string];
|
||||
expect(content).toContain("color: #f00");
|
||||
});
|
||||
|
||||
it("surfaces persist errors via on('persist:error')", async () => {
|
||||
const adapter = createMemoryAdapter();
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const comp = await openComposition(BASE_HTML, { persist: adapter });
|
||||
comp.on("persist:error", (e) => errors.push(e));
|
||||
|
||||
adapter.injectFault("disk full");
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("defaults the write path to composition.html when persistPath is omitted", async () => {
|
||||
const adapter = createMemoryAdapter();
|
||||
const writeSpy = vi.spyOn(adapter, "write");
|
||||
|
||||
const comp = await openComposition(BASE_HTML, { persist: adapter });
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
await comp.flush();
|
||||
|
||||
const [path] = writeSpy.mock.calls[0] as [string, string];
|
||||
expect(path).toBe("composition.html");
|
||||
});
|
||||
|
||||
it("writes to persistPath when supplied", async () => {
|
||||
const adapter = createMemoryAdapter();
|
||||
const writeSpy = vi.spyOn(adapter, "write");
|
||||
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
persist: adapter,
|
||||
persistPath: "scenes/intro.html",
|
||||
});
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
await comp.flush();
|
||||
|
||||
const [path] = writeSpy.mock.calls[0] as [string, string];
|
||||
expect(path).toBe("scenes/intro.html");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── T3 embedded mode (override-set) ─────────────────────────────────────────
|
||||
|
||||
describe("T3 embedded mode", () => {
|
||||
it("applies override-set on open, mutations layer on top", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.style.color": "#0f0" },
|
||||
});
|
||||
expect(comp.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
|
||||
});
|
||||
|
||||
it("getOverrides() returns accumulated override-set", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
const overrides = comp.getOverrides();
|
||||
expect(overrides["hf-title.style.color"]).toBe("#f00");
|
||||
});
|
||||
|
||||
it("serialize → reopen with overrides → same state as direct mutation", async () => {
|
||||
// Simulate host storing overrides + base template separately (T3 pattern)
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#0f0" });
|
||||
comp.setText("hf-body", "Override text");
|
||||
const overrides = comp.getOverrides();
|
||||
|
||||
// Host reopens the original template with the stored overrides
|
||||
const comp2 = await openComposition(BASE_HTML, { overrides });
|
||||
expect(comp2.getElement("hf-title")?.inlineStyles.color).toBe("#0f0");
|
||||
expect(comp2.getElement("hf-body")?.text).toContain("Override text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("engine helper exports (resolveScoped, findById, escapeHfId, readVariableDefault)", () => {
|
||||
it("are importable from the public index and work against a live document", async () => {
|
||||
// These operate on a Document directly, not the Composition — exercise them
|
||||
// against a document parsed the same way the SDK parses internally.
|
||||
const { document } = await import("linkedom").then((m) => m.parseHTML(BASE_HTML));
|
||||
expect(findById(document as unknown as Document, "hf-title")).not.toBeNull();
|
||||
expect(resolveScoped(document as unknown as Document, "hf-title")).not.toBeNull();
|
||||
expect(escapeHfId('hf-"quoted"')).toBe('hf-\\"quoted\\"');
|
||||
// readVariableDefault takes the declaration element (the <html>/root), not the Document.
|
||||
const declEl = (document as unknown as Document).documentElement;
|
||||
expect(readVariableDefault(declEl, "never-declared")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,611 @@
|
||||
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
|
||||
|
||||
/**
|
||||
* Cross-referenced variable usage for a whole composition: the per-script
|
||||
* static scans merged and compared against the declared schema.
|
||||
*/
|
||||
export interface VariableUsageReport {
|
||||
/** Variable ids read by composition scripts (static analysis, first-seen order). */
|
||||
usedIds: string[];
|
||||
/** Declared ids never read by any script. */
|
||||
unusedDeclarations: string[];
|
||||
/** Ids read by scripts but missing from data-composition-variables. */
|
||||
undeclaredReads: string[];
|
||||
/**
|
||||
* True when any script accesses variables opaquely (computed keys, escaping
|
||||
* values object…) — usedIds is then a lower bound and unusedDeclarations
|
||||
* may be false positives.
|
||||
*/
|
||||
scanIncomplete: boolean;
|
||||
}
|
||||
|
||||
// ─── Document model ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Full DOM-level view of one editable element. Built by the SDK adaptation layer. */
|
||||
export interface HyperFramesElement {
|
||||
readonly id: string;
|
||||
/**
|
||||
* Fully-qualified scoped id — host-chain prefix + leaf, separated by "/".
|
||||
* For top-level elements: scopedId === id.
|
||||
* For elements inside inlined sub-compositions: "hf-HOST/hf-LEAF" (any depth).
|
||||
* This is the canonical identifier to use in dispatch targets, getElement(),
|
||||
* find(), and override-set keys when addressing sub-composition elements.
|
||||
*/
|
||||
readonly scopedId: string;
|
||||
readonly tag: string;
|
||||
readonly children: readonly HyperFramesElement[];
|
||||
/** camelCase property names — mirrors CSSStyleDeclaration convention */
|
||||
readonly inlineStyles: Readonly<Record<string, string>>;
|
||||
readonly classNames: readonly string[];
|
||||
/** All attributes except style, class, and data-hf-* (those are model-level) */
|
||||
readonly attributes: Readonly<Record<string, string>>;
|
||||
/** Display text for the SDK setText target, not a full descendant-text snapshot. */
|
||||
readonly text: string | null;
|
||||
// Timing — null when element has no data-start
|
||||
readonly start: number | null;
|
||||
readonly duration: number | null;
|
||||
readonly trackIndex: number | null;
|
||||
/** Phase 2: GSAP tween IDs whose target is this element */
|
||||
readonly animationIds: readonly string[];
|
||||
}
|
||||
|
||||
/** The SDK's in-memory document. Built from ensureHfIds + linkedom DOM walk. */
|
||||
export interface SdkDocument {
|
||||
readonly roots: readonly HyperFramesElement[];
|
||||
readonly gsapScript: string | null;
|
||||
readonly styles: string | null;
|
||||
readonly width: number | null;
|
||||
readonly height: number | null;
|
||||
readonly compositionDuration: number | null;
|
||||
/**
|
||||
* BUILD-TIME snapshot of the ensureHfIds-stamped HTML. Never updated after
|
||||
* mutations — use Composition.serialize() for the current document state.
|
||||
*/
|
||||
readonly html: string;
|
||||
}
|
||||
|
||||
// ─── Override-set (T3 embedded mode) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sparse map of `hfId.prop.path → value` overrides layered on top of the base template.
|
||||
* null value = removal marker (element or property deleted by user).
|
||||
* Examples: { "hf-x7k2.style.fontSize": "96px", "hf-y3a1.text": "Hello", "hf-z5k2": null }
|
||||
*
|
||||
* Font and image variable overrides store their object values under the var.{id} key:
|
||||
* { "var.brand-font": { name: "Roboto", source: "https://fonts.googleapis.com/…" } }
|
||||
*/
|
||||
/**
|
||||
* A set of variable overrides. The `Record<string, unknown>` member admits
|
||||
* object-valued variables (font/image). NOTE for SDK consumers: this widening
|
||||
* means code reading an OverrideSet value must narrow before assuming a scalar —
|
||||
* an object value will type-check anywhere `unknown` is accepted.
|
||||
*/
|
||||
export type OverrideSet = Record<
|
||||
string,
|
||||
string | number | boolean | Record<string, unknown> | null
|
||||
>;
|
||||
|
||||
// ─── can() result ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Structured result from can(op).
|
||||
*
|
||||
* `ok: true` — dispatch(op) will succeed.
|
||||
* `ok: false` — dispatch would be a no-op or error; `code` is stable for switch.
|
||||
* Codes: E_TARGET_NOT_FOUND | E_NO_ROOT | E_NO_GSAP_TIMELINE | E_NO_GSAP_SCRIPT
|
||||
*/
|
||||
export type CanResult = { ok: true } | { ok: false; code: string; message: string; hint?: string };
|
||||
|
||||
// ─── Edit operations (F1: explicit target on every element op) ────────────────
|
||||
|
||||
export type HfId = string;
|
||||
|
||||
/** Every element op takes explicit target id(s). No selection-implicit mutation. */
|
||||
export type EditOp =
|
||||
| { type: "setStyle"; target: HfId | HfId[]; styles: Record<string, string | null> }
|
||||
| { type: "setText"; target: HfId | HfId[]; value: string }
|
||||
| { type: "setAttribute"; target: HfId | HfId[]; name: string; value: string | null }
|
||||
| {
|
||||
type: "setTiming";
|
||||
target: HfId | HfId[];
|
||||
start?: number;
|
||||
duration?: number;
|
||||
trackIndex?: number;
|
||||
}
|
||||
| { type: "setHold"; target: HfId | HfId[]; hold: ElasticHold }
|
||||
| { type: "moveElement"; target: HfId | HfId[]; x: number; y: number }
|
||||
| { type: "removeElement"; target: HfId | HfId[] }
|
||||
| {
|
||||
type: "addElement";
|
||||
/** Id of the parent element, or null to insert at the document body root. */
|
||||
parent: HfId | null;
|
||||
/** Zero-based sibling index at which to insert (append if >= childCount). */
|
||||
index: number;
|
||||
/** Single-root HTML fragment. Must not contain <script>. */
|
||||
html: string;
|
||||
}
|
||||
| {
|
||||
type: "reorderElements";
|
||||
/** Each entry sets inline zIndex on one element. Positioning is unchanged — z-index only takes effect on non-static elements, so the caller must ensure the target is positioned. */
|
||||
entries: Array<{ target: HfId; zIndex: number }>;
|
||||
}
|
||||
| { type: "setClassStyle"; selector: string; styles: Record<string, string | null> }
|
||||
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
|
||||
| { type: "declareVariable"; declaration: CompositionVariable }
|
||||
| { type: "updateVariableDeclaration"; id: string; declaration: CompositionVariable }
|
||||
| { type: "removeVariableDeclaration"; id: string }
|
||||
| {
|
||||
type: "setVariableValue";
|
||||
id: string;
|
||||
value: string | number | boolean | FontValue | ImageValue;
|
||||
}
|
||||
// #2098 alias op — remove-by-id, kept for its shipped session.removeVariable().
|
||||
| { type: "removeVariable"; id: string }
|
||||
| { type: "addGsapTween"; target: HfId; tween: GsapTweenSpec }
|
||||
| { type: "setGsapTween"; animationId: string; properties: Partial<GsapTweenSpec> }
|
||||
| {
|
||||
type: "setGsapKeyframe";
|
||||
animationId: string;
|
||||
keyframeIndex: number;
|
||||
position?: number;
|
||||
value?: Record<string, unknown>;
|
||||
ease?: string;
|
||||
}
|
||||
| {
|
||||
type: "addGsapKeyframe";
|
||||
animationId: string;
|
||||
position: number;
|
||||
value: Record<string, unknown>;
|
||||
}
|
||||
| { type: "removeGsapKeyframe"; animationId: string; percentage: number }
|
||||
| { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean }
|
||||
| { type: "removeGsapTween"; animationId: string }
|
||||
| { type: "removeAllKeyframes"; animationId: string }
|
||||
| {
|
||||
type: "convertToKeyframes";
|
||||
animationId: string;
|
||||
resolvedFromValues?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "deleteAllForSelector"; selector: string }
|
||||
| {
|
||||
type: "materializeKeyframes";
|
||||
animationId: string;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>;
|
||||
easeEach?: string;
|
||||
resolvedSelector?: string;
|
||||
}
|
||||
| { type: "splitIntoPropertyGroups"; animationId: string }
|
||||
| {
|
||||
type: "splitAnimations";
|
||||
originalId: string;
|
||||
newId: string;
|
||||
splitTime: number;
|
||||
elementStart: number;
|
||||
elementDuration: number;
|
||||
}
|
||||
| { type: "addLabel"; name: string; position: number }
|
||||
| { type: "removeLabel"; name: string }
|
||||
| {
|
||||
type: "setArcPath";
|
||||
animationId: string;
|
||||
config: {
|
||||
enabled: boolean;
|
||||
autoRotate: boolean | number;
|
||||
segments: Array<{
|
||||
curviness?: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
}>;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "updateArcSegment";
|
||||
animationId: string;
|
||||
segmentIndex: number;
|
||||
update: {
|
||||
curviness?: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
};
|
||||
}
|
||||
| { type: "removeArcPath"; animationId: string }
|
||||
| {
|
||||
type: "unrollDynamicAnimations";
|
||||
animationId: string;
|
||||
elements: Array<{
|
||||
selector: string;
|
||||
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||
easeEach?: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
/** Insert a new keyframed tween for targetSelector at the given position/duration. */
|
||||
type: "addWithKeyframes";
|
||||
targetSelector: string;
|
||||
/** Timeline position in seconds. Number-only (unlike GsapTweenSpec.position, which also accepts label-relative strings). */
|
||||
position: number;
|
||||
duration: number;
|
||||
keyframes: KeyframeSpec[];
|
||||
ease?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Replace an existing tween (by animationId) with a new keyframed tween.
|
||||
* Equivalent to removeGsapTween + addWithKeyframes in one atomic op.
|
||||
* Position-derived tween IDs renumber after the remove; callers must
|
||||
* re-parse to discover the new ID.
|
||||
*/
|
||||
type: "replaceWithKeyframes";
|
||||
animationId: string;
|
||||
targetSelector: string;
|
||||
/** Timeline position in seconds. Number-only (unlike GsapTweenSpec.position, which also accepts label-relative strings). */
|
||||
position: number;
|
||||
duration: number;
|
||||
keyframes: KeyframeSpec[];
|
||||
ease?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single keyframe entry for `addWithKeyframes` / `replaceWithKeyframes`.
|
||||
* Single source of truth — Studio-side mirrors (KeyframeEntry/KeyframeSpec) should
|
||||
* import this rather than redeclare the shape.
|
||||
*/
|
||||
export interface KeyframeSpec {
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
/** GSAP endpoint flag — emitted as numeric `_auto: 1`, not boolean. */
|
||||
auto?: boolean;
|
||||
}
|
||||
|
||||
export interface ElasticHold {
|
||||
start: number;
|
||||
end: number;
|
||||
fill: "freeze" | "loop";
|
||||
}
|
||||
|
||||
/**
|
||||
* Object value for a `font` variable (LOCKED §7 — object-valued, never a CSS string).
|
||||
* `name` is the CSS font-family value; `source` is the stylesheet URL to load.
|
||||
*/
|
||||
export interface FontValue {
|
||||
name: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Object value for an `image` variable (LOCKED §7 — object-valued, never a CSS string).
|
||||
* `url` is the image src. Add explicit optional fields here as consumers need them —
|
||||
* an open `[key: string]: unknown` index signature was dropped because it let any
|
||||
* `{url}`-shaped object through and swallowed key typos.
|
||||
*/
|
||||
export interface ImageValue {
|
||||
url: string;
|
||||
alt?: string;
|
||||
fit?: "cover" | "contain" | "fill" | "none" | "scale-down";
|
||||
}
|
||||
|
||||
export interface GsapTweenSpec {
|
||||
method: "from" | "to" | "fromTo" | "set";
|
||||
position?: number | string;
|
||||
duration?: number;
|
||||
ease?: string;
|
||||
fromProperties?: Record<string, unknown>;
|
||||
toProperties?: Record<string, unknown>;
|
||||
/** For 'to' tweens — the properties to animate toward */
|
||||
properties?: Record<string, unknown>;
|
||||
repeat?: number;
|
||||
yoyo?: boolean;
|
||||
stagger?: number | Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── Patch layer (F2: RFC 6902 frozen contract) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit-only subset of RFC 6902: the SDK never emits move/copy/test, and
|
||||
* applyPatches() ignores ops outside this subset. Hosts feeding patches back
|
||||
* must restrict themselves to add/remove/replace.
|
||||
*/
|
||||
export interface JsonPatchOp {
|
||||
op: "add" | "remove" | "replace";
|
||||
path: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted by session.on('patch') after every committed change.
|
||||
* formatVersion bumps = breaking; hosts check once and reject unknown versions.
|
||||
*/
|
||||
export interface PatchEvent {
|
||||
readonly formatVersion: 1;
|
||||
readonly patches: readonly JsonPatchOp[];
|
||||
readonly inversePatches: readonly JsonPatchOp[];
|
||||
/** Re-emitted verbatim from the mutation entry. Use ORIGIN_APPLY_PATCHES to detect undo loops. */
|
||||
readonly origin: unknown;
|
||||
/** Semantic op names ('setStyle') — for analytics/history labels. Not versioned. */
|
||||
readonly opTypes: readonly string[];
|
||||
}
|
||||
|
||||
// ─── Origin model (F4) ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reserved origin tag for applyPatches().
|
||||
* Host listeners MUST skip this origin to prevent undo loops:
|
||||
* comp.on('patch', ({ origin }) => { if (origin === ORIGIN_APPLY_PATCHES) return; ... })
|
||||
*
|
||||
* A namespaced string (not a unique symbol) so the sentinel survives realm
|
||||
* boundaries — postMessage, structured clone, JSON — which T3 embedded hosts
|
||||
* may forward patch events across. The namespace prefix keeps collision risk
|
||||
* with host-chosen origins negligible.
|
||||
*/
|
||||
export const ORIGIN_APPLY_PATCHES = "@hyperframes/sdk:applyPatches" as const;
|
||||
|
||||
/** Default origin when none specified — UI-driven dispatch. */
|
||||
export const ORIGIN_LOCAL = "local" as const;
|
||||
|
||||
// ─── Event types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PersistErrorEvent {
|
||||
error: { message: string; hint?: string; cause?: unknown };
|
||||
}
|
||||
|
||||
// ─── Element query / snapshot (F1 query API) ─────────────────────────────────
|
||||
|
||||
/** Flat read-only snapshot returned by getElements() / getElement() */
|
||||
export type ElementSnapshot = HyperFramesElement;
|
||||
|
||||
export interface FindQuery {
|
||||
tag?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
track?: number;
|
||||
/** Filter to elements inside a specific sub-composition host (by host hf-id). */
|
||||
composition?: string;
|
||||
}
|
||||
|
||||
// ─── Typed method sugar (F10) ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Proxy returned by comp.selection() — resolves getSelection() → explicit ops at call time.
|
||||
* Multi-select gets well-defined semantics: op applied per id within one batch.
|
||||
*/
|
||||
export interface SelectionProxy {
|
||||
readonly ids: readonly string[];
|
||||
setStyle(styles: Record<string, string | null>): void;
|
||||
setText(value: string): void;
|
||||
setAttribute(name: string, value: string | null): void;
|
||||
setTiming(timing: { start?: number; duration?: number; trackIndex?: number }): void;
|
||||
removeElement(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Curried element handle — holds only the id string, no stale-ref hazard.
|
||||
* comp.element('hf-x7k2').setStyle({ color: '#fff' })
|
||||
*/
|
||||
export interface ElementHandle {
|
||||
readonly id: string;
|
||||
setStyle(styles: Record<string, string | null>): void;
|
||||
setText(value: string): void;
|
||||
setAttribute(name: string, value: string | null): void;
|
||||
setTiming(timing: { start?: number; duration?: number; trackIndex?: number }): void;
|
||||
removeElement(): void;
|
||||
}
|
||||
|
||||
// ─── Timing accessor types (WS-C) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolved timing snapshot for one element.
|
||||
* Labels are GSAP timeline label names whose numeric position falls within
|
||||
* [enterAt, exitAt] for this element. Parsed fresh on every call — never cached.
|
||||
*/
|
||||
export interface ElementTimingSnapshot {
|
||||
enterAt: number;
|
||||
exitAt: number;
|
||||
/** GSAP addLabel names active during this element's window. */
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
// ─── Composition (the main public surface, F10) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* An open composition editing session.
|
||||
* Typed methods (docs page one) sugar over dispatch() — all validation in dispatch.
|
||||
* dispatch() is the advanced/agent layer (data-shaped ops, automation, replay).
|
||||
*/
|
||||
export interface Composition {
|
||||
// ── Typed methods (F10 layer 1) ────────────────────────────────────────────
|
||||
setStyle(id: HfId, styles: Record<string, string | null>): void;
|
||||
setText(id: HfId, value: string): void;
|
||||
setAttribute(id: HfId, name: string, value: string | null): void;
|
||||
setTiming(id: HfId, timing: { start?: number; duration?: number; trackIndex?: number }): void;
|
||||
removeElement(id: HfId): void;
|
||||
/**
|
||||
* Insert an HTML fragment as a child of `parent` at `index` (WS-D).
|
||||
* Mints a stable hf-id against the live document's existing id set.
|
||||
* Returns the minted id of the inserted root element.
|
||||
* Inverse = removeElement of the returned id.
|
||||
*/
|
||||
addElement(parent: HfId | null, index: number, html: string): HfId;
|
||||
setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void;
|
||||
/**
|
||||
* Current `default` value for a declared variable, or undefined if
|
||||
* undeclared/unset. Convenience over getVariableValues() (kept from #2098).
|
||||
*/
|
||||
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined;
|
||||
/**
|
||||
* Every declared variable's full schema (id/type/label/default/…), or [] when
|
||||
* none. Alias of getVariableDeclarations() (kept from #2098's surface).
|
||||
*/
|
||||
listVariables(): CompositionVariable[];
|
||||
/** Remove a variable's declaration — alias of removeVariableDeclaration(). */
|
||||
removeVariable(id: string): void;
|
||||
/**
|
||||
* Declare a new variable in `data-composition-variables`. No-ops when the
|
||||
* id is already declared (see can() for the E_DUPLICATE_VARIABLE check);
|
||||
* creates the attribute when the composition has none yet.
|
||||
*/
|
||||
declareVariable(declaration: CompositionVariable): void;
|
||||
/**
|
||||
* Replace an existing declaration wholesale (label, type, constraints,
|
||||
* default — the id itself is immutable; rename = remove + declare). When
|
||||
* the default changes to/from a scalar, the `--{id}` CSS compat custom
|
||||
* property on the root is kept in sync, mirroring setVariableValue.
|
||||
*/
|
||||
updateVariableDeclaration(id: string, declaration: CompositionVariable): void;
|
||||
/**
|
||||
* Remove a declaration (and the last one removes the whole attribute).
|
||||
* Also clears the `--{id}` CSS compat custom property if present.
|
||||
*/
|
||||
removeVariableDeclaration(id: string): void;
|
||||
/**
|
||||
* Read the typed variable declarations from `data-composition-variables`
|
||||
* (the canonical schema — same filter the render pipeline uses; malformed
|
||||
* entries are dropped). Read-only — does not dispatch.
|
||||
*/
|
||||
getVariableDeclarations(): CompositionVariable[];
|
||||
/**
|
||||
* Resolve this composition's variable values: its declared defaults merged
|
||||
* with `overrides` (overrides win, undeclared override keys pass through).
|
||||
* Read-only — does not dispatch.
|
||||
*
|
||||
* Scope: reads THIS composition file's own declaration element, not a union of
|
||||
* every `[data-composition-variables]` in a bundled document. The runtime's
|
||||
* `getVariables()` additionally walks inlined sub-composition declarers because
|
||||
* it runs on the fully-bundled document; the SDK models one composition file,
|
||||
* so per-file scope is intentional (and is what Studio needs to predict a
|
||||
* single file's `--variables` payload). For the common single-`<html>`
|
||||
* composition the two agree; they diverge only when sub-comp declarers are
|
||||
* inlined into one document.
|
||||
*/
|
||||
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown>;
|
||||
/**
|
||||
* Validate a values map against the declared schema. Returns undeclared /
|
||||
* type-mismatch / enum-out-of-range issues (same checks as the CLI's
|
||||
* `--strict-variables`). Read-only — does not dispatch.
|
||||
*/
|
||||
validateVariableValues(values: Record<string, unknown>): VariableValidationIssue[];
|
||||
/**
|
||||
* Cross-reference the declared schema against a static scan of every inline
|
||||
* composition script (getVariables() reads). Read-only — does not dispatch.
|
||||
*/
|
||||
getVariableUsage(): VariableUsageReport;
|
||||
/**
|
||||
* Apply variable values to the preview surface (ephemeral — never written
|
||||
* to the document; use setVariableValue to persist a default). Pass null to
|
||||
* restore declared defaults. No-op when the preview adapter doesn't
|
||||
* implement setPreviewVariables; returns whether the adapter handled it.
|
||||
*/
|
||||
setPreviewVariables(values: Record<string, unknown> | null): boolean;
|
||||
/**
|
||||
* Read enter/exit times and GSAP labels for every timed element (WS-C).
|
||||
* Derives enterAt/exitAt using the same data-duration vs data-end preference
|
||||
* as handleSetTiming (data-duration wins; data-end − data-start as fallback).
|
||||
* Labels are parsed fresh from the GSAP script each call.
|
||||
* Read-only — does not dispatch.
|
||||
*/
|
||||
getElementTimings(): Record<HfId, ElementTimingSnapshot>;
|
||||
/**
|
||||
* Apply a sparse timing map in a single batch (WS-C).
|
||||
* Dispatches one setTiming op per entry inside a batch so the history sees
|
||||
* one undo step. Skips entries for unknown ids silently.
|
||||
*/
|
||||
setElementTiming(
|
||||
map: Record<HfId, { start?: number; duration?: number; trackIndex?: number }>,
|
||||
): void;
|
||||
/**
|
||||
* Set an elastic hold window on an element (WS-C).
|
||||
* Thin typed wrapper over the existing setHold op — mirrors setVariableValue pattern.
|
||||
*/
|
||||
setHold(id: HfId, hold: ElasticHold): void;
|
||||
/** Returns the newly-assigned tween ID */
|
||||
addGsapTween(target: HfId, tween: GsapTweenSpec): string;
|
||||
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void;
|
||||
removeGsapTween(animationId: string): void;
|
||||
/**
|
||||
* Add a keyframed tween. Typed wrapper over the addWithKeyframes op (mirrors
|
||||
* addGsapTween). Returns the newly-minted animationId, or "" if rejected.
|
||||
*/
|
||||
addWithKeyframes(
|
||||
targetSelector: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
keyframes: KeyframeSpec[],
|
||||
ease?: string,
|
||||
): string;
|
||||
/**
|
||||
* Replace an existing keyframed tween. Typed wrapper over replaceWithKeyframes.
|
||||
* Returns the replacement's animationId (treat as NEW — position-derived IDs
|
||||
* renumber after the remove), or "" if rejected.
|
||||
*/
|
||||
replaceWithKeyframes(
|
||||
animationId: string,
|
||||
targetSelector: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
keyframes: KeyframeSpec[],
|
||||
ease?: string,
|
||||
): string;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
canUndo(): boolean;
|
||||
canRedo(): boolean;
|
||||
|
||||
// ── Query API (F1) ─────────────────────────────────────────────────────────
|
||||
getElements(): ElementSnapshot[];
|
||||
/** Top-level elements only, each carrying its full subtree — no id appears twice. */
|
||||
getRootElements(): ElementSnapshot[];
|
||||
getElement(id: HfId): ElementSnapshot | null;
|
||||
find(query: FindQuery): string[];
|
||||
/**
|
||||
* Every GSAP tween id parsed from the composition's script, regardless of
|
||||
* whether its target selector currently matches a live DOM element. See
|
||||
* parsedAnimationIds in document.ts for why this differs from the
|
||||
* per-element animationIds on ElementSnapshot.
|
||||
*/
|
||||
getAllAnimationIds(): Set<string>;
|
||||
|
||||
// ── Selection API ──────────────────────────────────────────────────────────
|
||||
/** Sugar: resolves getSelection() → explicit ops at call time */
|
||||
selection(): SelectionProxy;
|
||||
/** Curried handle — holds only the id, no stale-ref hazard */
|
||||
element(id: HfId): ElementHandle;
|
||||
getSelection(): string[];
|
||||
/** Replace the current selection; fires selectionchange. Pass [] to clear. */
|
||||
setSelection(ids: string[]): void;
|
||||
|
||||
// ── Advanced / agent layer (F10 layer 2) ──────────────────────────────────
|
||||
dispatch(op: EditOp, opts?: { origin?: unknown }): void;
|
||||
batch(fn: () => void, opts?: { origin?: unknown }): void;
|
||||
/**
|
||||
* Dry-run validation — would dispatch(op) succeed?
|
||||
* Returns {ok:true} when dispatch would mutate the document, {ok:false,code,message} otherwise.
|
||||
* Use as a feature-detection gate: `const r = comp.can(op); if (!r.ok) return;`
|
||||
* Phase 3b ops return {ok:false,code:'E_NO_GSAP_TIMELINE'} until parser engine ships.
|
||||
*/
|
||||
can(op: EditOp): CanResult;
|
||||
|
||||
// ── Events (one typed emitter — F10) ──────────────────────────────────────
|
||||
on(event: "change", handler: () => void): () => void;
|
||||
on(event: "selectionchange", handler: (ids: string[]) => void): () => void;
|
||||
on(event: "patch", handler: (event: PatchEvent) => void): () => void;
|
||||
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
|
||||
|
||||
// ── Serialization ──────────────────────────────────────────────────────────
|
||||
/** stripRuntime removes an embedded preview-runtime script — for a host driving its own clock. */
|
||||
serialize(opts?: { stripRuntime?: boolean }): string;
|
||||
|
||||
// ── T3 embedded-mode extras ────────────────────────────────────────────────
|
||||
/** Current override-set — serialize for host storage */
|
||||
getOverrides(): OverrideSet;
|
||||
/** Apply inverse patches from host undo stack; auto-tags origin: ORIGIN_APPLY_PATCHES */
|
||||
applyPatches(patches: readonly JsonPatchOp[], opts?: { origin?: unknown }): void;
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
/** Drain the persist queue — resolves when any queued write is committed. No-op if no adapter. */
|
||||
flush(): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*", "examples/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user