mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches) (#1324)
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches) * fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access - index.ts no longer exports document/session/history/persist-queue (those modules land in the next stacked PR); branch now typechecks standalone - setOwnText: optional-chain children[i] access (TS2532 under noUncheckedIndexedAccess) - fallow suppressions for buildPatchEvent + adapters/types.ts — consumers arrive in #1325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline - applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9 parser-backed ops instead of silently no-opping — callers must never believe an animation edit succeeded when nothing was mutated - validateOp returns false for Phase 3b ops so can() feature-detects - root package.json build filter now includes @hyperframes/sdk (package is dist-only; top-level build previously produced no SDK artifacts). publish.yml intentionally NOT updated — sdk stays unpublished until Phase 3 completes. Adversarial-review findings F3 + F4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs Round-2 review (Rames/Miguel) on the engine layer: - ORIGIN_APPLY_PATCHES: unique symbol → namespaced string ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't survive postMessage/structured-clone, which T3 embedded hosts may forward patch events across. Namespaced string keeps collision risk negligible. - setCompositionMetadata width/height: runtime treats data-width/data-height as a forced override of inline style (init.ts applyCompositionSizing). Style is always written; the data-* attr is updated when already present so the edit isn't clobbered on load. Absent attrs stay absent — inverses stay exact. Mirrored in the patch applier; 3 new tests. - JsonPatchOp documented as the emit-only RFC 6902 subset (add/remove/replace); applier header notes move/copy/test are ignored. - SdkDocument.html documented as a build-time snapshot (serialize() is the live state). - patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}. NOT changed (with reasons, see PR reply): moveElement left/top matches Studio's own inline-style commit convention (sourcePatcher); package version follows the repo-wide single-version policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): moveElement writes data-x/data-y, not left/top CSS HF elements use data-x/data-y for positioning (read by htmlParser.ts, emitted by hyperframes generator). CSS left/top is not the runtime convention. Adds inverse round-trip test for prior position restore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update bun.lock after sdk package registration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2c64f99694
commit
22bb6737c5
@@ -47,6 +47,7 @@
|
||||
"packages/**/__goldens__/**",
|
||||
"registry/**",
|
||||
"examples/**",
|
||||
"packages/sdk/examples/**",
|
||||
".github/workflows/fixtures/**",
|
||||
// Auto-generated TS client for the HeyGen cloud API. Regenerated by
|
||||
// experiment-framework/scripts/generate_hyperframes_cli_client.py via
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/aws-lambda": {
|
||||
"name": "@hyperframes/aws-lambda",
|
||||
"version": "0.6.88",
|
||||
"version": "0.6.90",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/client-sfn": "^3.700.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.6.88",
|
||||
"version": "0.6.90",
|
||||
"bin": {
|
||||
"hyperframes": "./dist/cli.js",
|
||||
},
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.6.88",
|
||||
"version": "0.6.90",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
@@ -131,7 +131,7 @@
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.6.88",
|
||||
"version": "0.6.90",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"packages/gcp-cloud-run": {
|
||||
"name": "@hyperframes/gcp-cloud-run",
|
||||
"version": "0.6.88",
|
||||
"version": "0.6.90",
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.14.0",
|
||||
"@google-cloud/workflows": "^4.2.0",
|
||||
@@ -169,7 +169,7 @@
|
||||
},
|
||||
"packages/player": {
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.6.88",
|
||||
"version": "0.6.90",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.0",
|
||||
"gsap": "^3.12.5",
|
||||
@@ -181,7 +181,7 @@
|
||||
},
|
||||
"packages/producer": {
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.6.88",
|
||||
"version": "0.6.90",
|
||||
"dependencies": {
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
@@ -220,9 +220,22 @@
|
||||
"typescript": "^5.7.2",
|
||||
},
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@hyperframes/sdk",
|
||||
"version": "0.6.86",
|
||||
"dependencies": {
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"linkedom": "^0.18.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
},
|
||||
"packages/shader-transitions": {
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.6.88",
|
||||
"version": "0.6.90",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1",
|
||||
},
|
||||
@@ -234,7 +247,7 @@
|
||||
},
|
||||
"packages/studio": {
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.6.88",
|
||||
"version": "0.6.90",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -631,6 +644,8 @@
|
||||
|
||||
"@hyperframes/producer": ["@hyperframes/producer@workspace:packages/producer"],
|
||||
|
||||
"@hyperframes/sdk": ["@hyperframes/sdk@workspace:packages/sdk"],
|
||||
|
||||
"@hyperframes/shader-transitions": ["@hyperframes/shader-transitions@workspace:packages/shader-transitions"],
|
||||
|
||||
"@hyperframes/studio": ["@hyperframes/studio@workspace:packages/studio"],
|
||||
@@ -1739,7 +1754,7 @@
|
||||
|
||||
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@7.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Wjvt4scRFouioIInHf51IFNP4ltJ2EngJM+cZPGiqbKetBfmP3vpdPV8ID2S6JS6/jdo74N8+aEYH9lQr2C6sA=="],
|
||||
"postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run studio",
|
||||
"build": "bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run}' build && bun run --filter @hyperframes/cli build",
|
||||
"build": "bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build",
|
||||
"build:producer": "bun run --filter @hyperframes/producer build",
|
||||
"studio": "bun run --filter @hyperframes/studio dev",
|
||||
"build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "@hyperframes/sdk",
|
||||
"version": "0.6.86",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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:*",
|
||||
"linkedom": "^0.18.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Consumed by session.ts + adapter implementations in the next stacked PR (#1325).
|
||||
// fallow-ignore-file unused-file
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
|
||||
// ─── PersistAdapter ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface PersistVersionEntry {
|
||||
/** Opaque key identifying this version (adapter-defined format) */
|
||||
key: string;
|
||||
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;
|
||||
|
||||
on(event: "selection", handler: (ids: string[]) => void): () => void;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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 } from "../types.js";
|
||||
import type { ParsedDocument } from "./model.js";
|
||||
import { findById, findRoot, setElementStyles, setOwnText } from "./model.js";
|
||||
|
||||
// ─── Path parser ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedPath {
|
||||
type: "style" | "text" | "attribute" | "timing" | "hold" | "element" | "variable" | "metadata";
|
||||
id?: string;
|
||||
prop?: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
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 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] };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Patch application ───────────────────────────────────────────────────────
|
||||
|
||||
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 === "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 "variable": {
|
||||
const root = findRoot(parsed.document);
|
||||
if (!root || !p.id) return;
|
||||
const cssVar = `--${p.id}`;
|
||||
if (patch.op === "remove") {
|
||||
setElementStyles(root, { [cssVar]: null });
|
||||
} else {
|
||||
setElementStyles(root, { [cssVar]: 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,141 @@
|
||||
/**
|
||||
* 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 } 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 {
|
||||
// CSS.escape is browser-only; hf-ids are restricted identifiers so simple quote-escaping is safe.
|
||||
const escaped = id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
return document.querySelector(`[data-hf-id="${escaped}"]`);
|
||||
}
|
||||
|
||||
export function findRoot(document: Document): Element | null {
|
||||
return (
|
||||
document.querySelector("[data-hf-root]") ??
|
||||
document.getElementById("stage") ??
|
||||
document.body?.firstElementChild ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inline style helpers ─────────────────────────────────────────────────────
|
||||
|
||||
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). */
|
||||
function parseStyleAttr(styleAttr: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const decl of styleAttr.split(";")) {
|
||||
const idx = decl.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const rawProp = decl.slice(0, idx).trim();
|
||||
const value = decl.slice(idx + 1).trim();
|
||||
if (!rawProp || !value) 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)) {
|
||||
if (value === null) {
|
||||
delete current[prop];
|
||||
} else {
|
||||
current[prop] = value;
|
||||
}
|
||||
}
|
||||
const serialized = serializeStyleAttr(current);
|
||||
if (serialized) {
|
||||
el.setAttribute("style", serialized);
|
||||
} else {
|
||||
el.removeAttribute("style");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Text helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Read only direct (non-descendant) text node content. */
|
||||
export function getOwnText(el: Element): string {
|
||||
let text = "";
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Replace only direct text nodes — preserves child elements. */
|
||||
export function setOwnText(el: Element, text: string): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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,436 @@
|
||||
/**
|
||||
* T4 — Op contract tests for the Phase 3a dispatch boundary.
|
||||
*
|
||||
* Tests verify: correct DOM mutation, correct RFC 6902 forward patches,
|
||||
* correct inverse patches (applying them restores the original state),
|
||||
* and override-set key mapping.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseMutable } from "./model.js";
|
||||
import { applyOp, validateOp } from "./mutate.js";
|
||||
import { applyPatchesToDocument } from "./apply-patches.js";
|
||||
import { pathToKey } from "./patches.js";
|
||||
import { serializeDocument } from "./serialize.js";
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
// No trailing semicolons in style attrs — serializeStyleAttr never adds them.
|
||||
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>
|
||||
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
|
||||
<div data-hf-id="hf-sub">
|
||||
<span data-hf-id="hf-span" style="opacity: 0.5">sub text</span>
|
||||
</div>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
function fresh() {
|
||||
return parseMutable(BASE_HTML);
|
||||
}
|
||||
|
||||
// ─── setStyle ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setStyle", () => {
|
||||
it("mutates existing style prop and emits replace patches", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setStyle",
|
||||
target: "hf-title",
|
||||
styles: { fontSize: "96px" },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
expect(result.forward[0]).toEqual({
|
||||
op: "replace",
|
||||
path: "/elements/hf-title/inlineStyles/fontSize",
|
||||
value: "96px",
|
||||
});
|
||||
expect(result.inverse[0]).toEqual({
|
||||
op: "replace",
|
||||
path: "/elements/hf-title/inlineStyles/fontSize",
|
||||
value: "64px",
|
||||
});
|
||||
// DOM mutated
|
||||
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
||||
expect(el?.getAttribute("style")).toContain("font-size: 96px");
|
||||
});
|
||||
|
||||
it("adds new style prop and emits add patch", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setStyle",
|
||||
target: "hf-logo",
|
||||
styles: { opacity: "0.8" },
|
||||
});
|
||||
expect(result.forward[0]?.op).toBe("add");
|
||||
expect(result.inverse[0]?.op).toBe("remove");
|
||||
});
|
||||
|
||||
it("removes style prop when value is null", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setStyle",
|
||||
target: "hf-title",
|
||||
styles: { color: null },
|
||||
});
|
||||
expect(result.forward[0]?.op).toBe("remove");
|
||||
expect(result.inverse[0]?.op).toBe("add");
|
||||
expect(result.inverse[0]?.value).toBe("#fff");
|
||||
});
|
||||
|
||||
it("inverse patches restore original state", () => {
|
||||
const parsed = fresh();
|
||||
const before = serializeDocument(parsed);
|
||||
const { inverse } = applyOp(parsed, {
|
||||
type: "setStyle",
|
||||
target: "hf-title",
|
||||
styles: { fontSize: "96px", color: "#f00" },
|
||||
});
|
||||
applyPatchesToDocument(parsed, inverse);
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
|
||||
it("applies to multiple targets", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setStyle",
|
||||
target: ["hf-title", "hf-span"],
|
||||
styles: { opacity: "1" },
|
||||
});
|
||||
expect(result.forward).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("override-set key maps correctly", () => {
|
||||
const key = pathToKey("/elements/hf-title/inlineStyles/fontSize");
|
||||
expect(key).toBe("hf-title.style.fontSize");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setText ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setText", () => {
|
||||
it("updates text content and emits replace patch", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setText",
|
||||
target: "hf-title",
|
||||
value: "Goodbye World",
|
||||
});
|
||||
expect(result.forward[0]).toEqual({
|
||||
op: "replace",
|
||||
path: "/elements/hf-title/text",
|
||||
value: "Goodbye World",
|
||||
});
|
||||
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
||||
// text node should contain new value
|
||||
expect(el?.textContent).toContain("Goodbye World");
|
||||
});
|
||||
|
||||
it("inverse patches restore original text", () => {
|
||||
const parsed = fresh();
|
||||
const before = serializeDocument(parsed);
|
||||
const { inverse } = applyOp(parsed, {
|
||||
type: "setText",
|
||||
target: "hf-title",
|
||||
value: "Changed",
|
||||
});
|
||||
applyPatchesToDocument(parsed, inverse);
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
|
||||
it("override-set key maps correctly", () => {
|
||||
expect(pathToKey("/elements/hf-title/text")).toBe("hf-title.text");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setAttribute ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setAttribute", () => {
|
||||
it("sets a new attribute and emits add patch", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setAttribute",
|
||||
target: "hf-logo",
|
||||
name: "src",
|
||||
value: "/new-logo.png",
|
||||
});
|
||||
expect(result.forward[0]).toEqual({
|
||||
op: "replace",
|
||||
path: "/elements/hf-logo/attributes/src",
|
||||
value: "/new-logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes attribute when value is null", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setAttribute",
|
||||
target: "hf-logo",
|
||||
name: "alt",
|
||||
value: null,
|
||||
});
|
||||
expect(result.forward[0]?.op).toBe("remove");
|
||||
expect(result.inverse[0]?.value).toBe("Logo");
|
||||
});
|
||||
|
||||
it("inverse patches restore original attribute", () => {
|
||||
const parsed = fresh();
|
||||
const before = serializeDocument(parsed);
|
||||
const { inverse } = applyOp(parsed, {
|
||||
type: "setAttribute",
|
||||
target: "hf-logo",
|
||||
name: "src",
|
||||
value: "/changed.png",
|
||||
});
|
||||
applyPatchesToDocument(parsed, inverse);
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setTiming ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setTiming", () => {
|
||||
it("updates start and recalculates end", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setTiming",
|
||||
target: "hf-title",
|
||||
start: 1,
|
||||
});
|
||||
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
||||
expect(el?.getAttribute("data-start")).toBe("1");
|
||||
// duration was 3 (0→3), so end = 1+3 = 4
|
||||
expect(el?.getAttribute("data-end")).toBe("4");
|
||||
const startPatch = result.forward.find((p) => p.path.endsWith("/start"));
|
||||
expect(startPatch?.value).toBe(1);
|
||||
});
|
||||
|
||||
it("updates duration and recalculates end", () => {
|
||||
const parsed = fresh();
|
||||
applyOp(parsed, { type: "setTiming", target: "hf-title", duration: 2 });
|
||||
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
||||
expect(el?.getAttribute("data-end")).toBe("2"); // start=0, duration=2 → end=2
|
||||
});
|
||||
|
||||
it("inverse patches restore original timing", () => {
|
||||
const parsed = fresh();
|
||||
const before = serializeDocument(parsed);
|
||||
const { inverse } = applyOp(parsed, {
|
||||
type: "setTiming",
|
||||
target: "hf-title",
|
||||
start: 1,
|
||||
duration: 2,
|
||||
trackIndex: 1,
|
||||
});
|
||||
applyPatchesToDocument(parsed, inverse);
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── removeElement ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("removeElement", () => {
|
||||
it("removes element from DOM and emits remove patch", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "removeElement",
|
||||
target: "hf-span",
|
||||
});
|
||||
expect(result.forward[0]?.op).toBe("remove");
|
||||
expect(result.forward[0]?.path).toBe("/elements/hf-span");
|
||||
expect(parsed.document.querySelector('[data-hf-id="hf-span"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("inverse patch carries html and restore position", () => {
|
||||
const parsed = fresh();
|
||||
const { inverse } = applyOp(parsed, {
|
||||
type: "removeElement",
|
||||
target: "hf-span",
|
||||
});
|
||||
expect(inverse[0]?.op).toBe("add");
|
||||
const val = inverse[0]?.value as {
|
||||
html: string;
|
||||
parentId: string | null;
|
||||
siblingIndex: number;
|
||||
};
|
||||
expect(val.html).toContain("hf-span");
|
||||
expect(val.parentId).toBe("hf-sub");
|
||||
expect(val.siblingIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("applying inverse patch restores the element in correct parent", () => {
|
||||
const parsed = fresh();
|
||||
const { inverse } = applyOp(parsed, {
|
||||
type: "removeElement",
|
||||
target: "hf-span",
|
||||
});
|
||||
applyPatchesToDocument(parsed, inverse);
|
||||
const restored = parsed.document.querySelector('[data-hf-id="hf-span"]');
|
||||
expect(restored).not.toBeNull();
|
||||
expect(restored?.parentElement?.getAttribute("data-hf-id")).toBe("hf-sub");
|
||||
expect(restored?.getAttribute("style")).toBe("opacity: 0.5");
|
||||
expect(restored?.textContent).toBe("sub text");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setVariableValue ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("setVariableValue", () => {
|
||||
it("sets CSS custom property on root element", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "setVariableValue",
|
||||
id: "brand-color-primary",
|
||||
value: "#ff0000",
|
||||
});
|
||||
expect(result.forward[0]?.path).toBe("/variables/brand-color-primary");
|
||||
expect(result.forward[0]?.value).toBe("#ff0000");
|
||||
const root = parsed.document.querySelector("[data-hf-root]");
|
||||
expect(root?.getAttribute("style")).toContain("--brand-color-primary: #ff0000");
|
||||
});
|
||||
|
||||
it("override-set key maps correctly", () => {
|
||||
expect(pathToKey("/variables/brand-color-primary")).toBe("var.brand-color-primary");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setCompositionMetadata ───────────────────────────────────────────────────
|
||||
|
||||
describe("setCompositionMetadata", () => {
|
||||
it("updates width, height, duration on root element", () => {
|
||||
const parsed = fresh();
|
||||
applyOp(parsed, {
|
||||
type: "setCompositionMetadata",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 10,
|
||||
});
|
||||
const root = parsed.document.querySelector("[data-hf-root]");
|
||||
expect(root?.getAttribute("style")).toContain("width: 1920px");
|
||||
expect(root?.getAttribute("style")).toContain("height: 1080px");
|
||||
expect(root?.getAttribute("data-duration")).toBe("10");
|
||||
});
|
||||
|
||||
it("inverse patches restore original metadata", () => {
|
||||
const parsed = fresh();
|
||||
const before = serializeDocument(parsed);
|
||||
const { inverse } = applyOp(parsed, {
|
||||
type: "setCompositionMetadata",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 10,
|
||||
});
|
||||
applyPatchesToDocument(parsed, inverse);
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── moveElement ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("moveElement", () => {
|
||||
it("sets data-x and data-y attributes (HF positioning convention)", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "moveElement",
|
||||
target: "hf-title",
|
||||
x: 100,
|
||||
y: 200,
|
||||
});
|
||||
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
||||
expect(el?.getAttribute("data-x")).toBe("100");
|
||||
expect(el?.getAttribute("data-y")).toBe("200");
|
||||
expect(result.forward.some((p) => p.path.endsWith("/data-x"))).toBe(true);
|
||||
expect(result.forward.some((p) => p.path.endsWith("/data-y"))).toBe(true);
|
||||
});
|
||||
|
||||
it("inverse restores prior data-x/data-y", () => {
|
||||
const parsed = fresh();
|
||||
const el = parsed.document.querySelector('[data-hf-id="hf-title"]') as Element;
|
||||
el.setAttribute("data-x", "50");
|
||||
el.setAttribute("data-y", "75");
|
||||
const result = applyOp(parsed, { type: "moveElement", target: "hf-title", x: 100, y: 200 });
|
||||
applyPatchesToDocument(parsed, result.inverse);
|
||||
expect(el.getAttribute("data-x")).toBe("50");
|
||||
expect(el.getAttribute("data-y")).toBe("75");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateOp (can()) ───────────────────────────────────────────────────────
|
||||
|
||||
describe("validateOp", () => {
|
||||
it("returns true for existing element", () => {
|
||||
expect(validateOp(fresh(), { type: "setStyle", target: "hf-title", styles: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unknown element id", () => {
|
||||
expect(validateOp(fresh(), { type: "setStyle", target: "hf-unknown", styles: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for setCompositionMetadata (no target)", () => {
|
||||
expect(validateOp(fresh(), { type: "setCompositionMetadata", width: 100 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 3b ops — fail loudly, feature-detectable ───────────────────────────
|
||||
|
||||
describe("Phase 3b ops", () => {
|
||||
it("applyOp throws UnsupportedOpError instead of silently no-opping", () => {
|
||||
expect(() =>
|
||||
applyOp(fresh(), {
|
||||
type: "addGsapTween",
|
||||
target: "hf-title",
|
||||
id: "tw-1",
|
||||
tween: { method: "from", fromProperties: { opacity: 0 } },
|
||||
}),
|
||||
).toThrowError(/Phase 3b/);
|
||||
});
|
||||
|
||||
it("validateOp returns false so can() feature-detects", () => {
|
||||
expect(validateOp(fresh(), { type: "removeGsapTween", animationId: "tw-1" })).toBe(false);
|
||||
expect(
|
||||
validateOp(fresh(), {
|
||||
type: "addGsapTween",
|
||||
target: "hf-title",
|
||||
id: "tw-1",
|
||||
tween: { method: "from", fromProperties: { opacity: 0 } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setCompositionMetadata — data-width/data-height forced override ─────────
|
||||
|
||||
describe("setCompositionMetadata data-* channel", () => {
|
||||
const ATTR_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root data-width="1280" data-height="720" style="width: 1280px; height: 720px">
|
||||
<h1 data-hf-id="hf-title">Hi</h1>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
it("updates data-width/data-height when the composition carries them", () => {
|
||||
const parsed = parseMutable(ATTR_HTML);
|
||||
applyOp(parsed, { type: "setCompositionMetadata", width: 1920, height: 1080 });
|
||||
const root = parsed.document.querySelector("[data-hf-root]");
|
||||
expect(root?.getAttribute("data-width")).toBe("1920");
|
||||
expect(root?.getAttribute("data-height")).toBe("1080");
|
||||
expect(root?.getAttribute("style")).toContain("width: 1920px");
|
||||
});
|
||||
|
||||
it("inverse restores both channels", () => {
|
||||
const parsed = parseMutable(ATTR_HTML);
|
||||
const before = serializeDocument(parsed);
|
||||
const { inverse } = applyOp(parsed, { type: "setCompositionMetadata", width: 1920 });
|
||||
applyPatchesToDocument(parsed, inverse);
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
|
||||
it("does not mint data-* attributes on compositions without them", () => {
|
||||
const parsed = fresh();
|
||||
applyOp(parsed, { type: "setCompositionMetadata", width: 1920 });
|
||||
const root = parsed.document.querySelector("[data-hf-root]");
|
||||
expect(root?.hasAttribute("data-width")).toBe(false);
|
||||
expect(root?.getAttribute("style")).toContain("width: 1920px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Op handlers for Phase 3a (non-parser ops).
|
||||
*
|
||||
* Each handler: mutates the linkedom Document, returns {forward, inverse} RFC 6902 patches.
|
||||
* Pure with respect to events — callers emit events from the patches.
|
||||
*
|
||||
* Phase 3b (parser-backed) will add setClassStyle + 7 GSAP ops as additional handlers.
|
||||
*/
|
||||
|
||||
import type { EditOp, HfId, JsonPatchOp } from "../types.js";
|
||||
import type { ParsedDocument } from "./model.js";
|
||||
import {
|
||||
findById,
|
||||
findRoot,
|
||||
getElementStyles,
|
||||
setElementStyles,
|
||||
getOwnText,
|
||||
setOwnText,
|
||||
getSiblingIndex,
|
||||
} from "./model.js";
|
||||
import {
|
||||
stylePath,
|
||||
textPath,
|
||||
attrPath,
|
||||
timingPath,
|
||||
holdPath,
|
||||
elementPath,
|
||||
variablePath,
|
||||
metaPath,
|
||||
scalarChange,
|
||||
scalarDelete,
|
||||
patchAdd,
|
||||
patchRemove,
|
||||
} from "./patches.js";
|
||||
|
||||
export interface MutationResult {
|
||||
forward: JsonPatchOp[];
|
||||
inverse: JsonPatchOp[];
|
||||
}
|
||||
|
||||
const EMPTY: MutationResult = { forward: [], inverse: [] };
|
||||
|
||||
/** Ops that require the Phase 3b parser-backed engine (meriyah/css-tree). */
|
||||
const PHASE3B_OPS = new Set([
|
||||
"setClassStyle",
|
||||
"addGsapTween",
|
||||
"setGsapTween",
|
||||
"setGsapKeyframe",
|
||||
"addGsapKeyframe",
|
||||
"removeGsapKeyframe",
|
||||
"removeGsapTween",
|
||||
"addLabel",
|
||||
"removeLabel",
|
||||
]);
|
||||
|
||||
// Re-exported from the package entry in the next stacked PR (#1325).
|
||||
// fallow-ignore-next-line unused-export
|
||||
export class UnsupportedOpError extends Error {
|
||||
readonly code = "E_UNSUPPORTED_OP";
|
||||
constructor(opType: string) {
|
||||
super(
|
||||
`Op '${opType}' requires the Phase 3b parser-backed engine and is not available yet. ` +
|
||||
`Use can(op) to feature-detect before dispatching.`,
|
||||
);
|
||||
this.name = "UnsupportedOpError";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Target normalization ────────────────────────────────────────────────────
|
||||
|
||||
function targets(target: HfId | HfId[]): HfId[] {
|
||||
return Array.isArray(target) ? target : [target];
|
||||
}
|
||||
|
||||
// ─── Op dispatch ────────────────────────────────────────────────────────────
|
||||
|
||||
export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
|
||||
switch (op.type) {
|
||||
case "setStyle":
|
||||
return handleSetStyle(parsed, targets(op.target), op.styles);
|
||||
case "setText":
|
||||
return handleSetText(parsed, targets(op.target), op.value);
|
||||
case "setAttribute":
|
||||
return handleSetAttribute(parsed, targets(op.target), op.name, op.value);
|
||||
case "setTiming":
|
||||
return handleSetTiming(parsed, targets(op.target), {
|
||||
start: op.start,
|
||||
duration: op.duration,
|
||||
trackIndex: op.trackIndex,
|
||||
});
|
||||
case "setHold":
|
||||
return handleSetHold(parsed, targets(op.target), op.hold);
|
||||
case "moveElement":
|
||||
return handleMoveElement(parsed, targets(op.target), op.x, op.y);
|
||||
case "removeElement":
|
||||
return handleRemoveElement(parsed, targets(op.target));
|
||||
case "setCompositionMetadata":
|
||||
return handleSetCompositionMetadata(parsed, op);
|
||||
case "setVariableValue":
|
||||
return handleSetVariableValue(parsed, op.id, op.value);
|
||||
// Phase 3b parser-backed ops — fail loudly rather than silently no-op:
|
||||
// a caller must never believe an animation edit succeeded when nothing
|
||||
// was mutated and no patch was emitted.
|
||||
case "setClassStyle":
|
||||
case "addGsapTween":
|
||||
case "setGsapTween":
|
||||
case "setGsapKeyframe":
|
||||
case "addGsapKeyframe":
|
||||
case "removeGsapKeyframe":
|
||||
case "removeGsapTween":
|
||||
case "addLabel":
|
||||
case "removeLabel":
|
||||
throw new UnsupportedOpError(op.type);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Op handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
function handleSetStyle(
|
||||
parsed: ParsedDocument,
|
||||
ids: HfId[],
|
||||
styles: Record<string, string | null>,
|
||||
): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
if (!el) continue;
|
||||
const old = getElementStyles(el);
|
||||
setElementStyles(el, styles);
|
||||
for (const [prop, value] of Object.entries(styles)) {
|
||||
const path = stylePath(id, prop);
|
||||
const oldValue = old[prop] ?? null;
|
||||
if (value !== null) {
|
||||
const p = scalarChange(path, oldValue, value);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
} else if (oldValue !== null) {
|
||||
const p = scalarDelete(path, oldValue);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleMoveElement(
|
||||
parsed: ParsedDocument,
|
||||
ids: HfId[],
|
||||
x: number,
|
||||
y: number,
|
||||
): MutationResult {
|
||||
// HF elements are positioned via data-x / data-y (parsed by htmlParser.ts,
|
||||
// emitted by hyperframes generator). CSS left/top is not the convention.
|
||||
const rx = handleSetAttribute(parsed, ids, "data-x", String(x));
|
||||
const ry = handleSetAttribute(parsed, ids, "data-y", String(y));
|
||||
return {
|
||||
forward: [...rx.forward, ...ry.forward],
|
||||
inverse: [...ry.inverse, ...rx.inverse],
|
||||
};
|
||||
}
|
||||
|
||||
function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
if (!el) continue;
|
||||
const oldText = getOwnText(el);
|
||||
setOwnText(el, value);
|
||||
const path = textPath(id);
|
||||
const p = scalarChange(path, oldText || null, value);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleSetAttribute(
|
||||
parsed: ParsedDocument,
|
||||
ids: HfId[],
|
||||
name: string,
|
||||
value: string | null,
|
||||
): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
if (!el) continue;
|
||||
const oldValue = el.getAttribute(name);
|
||||
const path = attrPath(id, name);
|
||||
if (value !== null) {
|
||||
el.setAttribute(name, value);
|
||||
const p = scalarChange(path, oldValue, value);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
} else if (oldValue !== null) {
|
||||
el.removeAttribute(name);
|
||||
const p = scalarDelete(path, oldValue);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function handleSetTiming(
|
||||
parsed: ParsedDocument,
|
||||
ids: HfId[],
|
||||
timing: { start?: number; duration?: number; trackIndex?: number },
|
||||
): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
if (!el) continue;
|
||||
|
||||
const oldStartStr = el.getAttribute("data-start");
|
||||
const oldEndStr = el.getAttribute("data-end");
|
||||
const oldTrackStr = el.getAttribute("data-track-index");
|
||||
|
||||
const oldStart = oldStartStr !== null ? parseFloat(oldStartStr) : null;
|
||||
const oldEnd = oldEndStr !== null ? parseFloat(oldEndStr) : null;
|
||||
const oldDuration = oldStart !== null && oldEnd !== null ? oldEnd - oldStart : null;
|
||||
const oldTrack = oldTrackStr !== null ? parseInt(oldTrackStr, 10) : null;
|
||||
|
||||
const newStart = timing.start ?? oldStart;
|
||||
const newDuration = timing.duration ?? oldDuration;
|
||||
|
||||
if (timing.start !== undefined && newStart !== null) {
|
||||
const path = timingPath(id, "start");
|
||||
const p = scalarChange(path, oldStart, newStart);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
el.setAttribute("data-start", String(newStart));
|
||||
}
|
||||
|
||||
if (
|
||||
(timing.duration !== undefined || timing.start !== undefined) &&
|
||||
newStart !== null &&
|
||||
newDuration !== null
|
||||
) {
|
||||
const newEnd = newStart + newDuration;
|
||||
// Store the computed end value directly (not the logical duration) so the inverse
|
||||
// patch is self-contained and doesn't require data-start to be restored first.
|
||||
const path = timingPath(id, "end");
|
||||
const p = scalarChange(path, oldEnd, newEnd);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
el.setAttribute("data-end", String(newEnd));
|
||||
}
|
||||
|
||||
if (timing.trackIndex !== undefined) {
|
||||
const newTrack = timing.trackIndex;
|
||||
const path = timingPath(id, "trackIndex");
|
||||
const p = scalarChange(path, oldTrack, newTrack);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
el.setAttribute("data-track-index", String(newTrack));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleSetHold(
|
||||
parsed: ParsedDocument,
|
||||
ids: HfId[],
|
||||
hold: { start: number; end: number; fill: "freeze" | "loop" },
|
||||
): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
if (!el) continue;
|
||||
|
||||
const fields: Array<["start" | "end" | "fill", string]> = [
|
||||
["start", String(hold.start)],
|
||||
["end", String(hold.end)],
|
||||
["fill", hold.fill],
|
||||
];
|
||||
|
||||
for (const [field, newVal] of fields) {
|
||||
const attrName = `data-hold-${field}`;
|
||||
const oldVal = el.getAttribute(attrName);
|
||||
const path = holdPath(id, field);
|
||||
el.setAttribute(attrName, newVal);
|
||||
const p = scalarChange(path, oldVal, newVal);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
if (!el) continue;
|
||||
const parentEl = el.parentElement;
|
||||
const parentId = parentEl?.getAttribute("data-hf-id") ?? null;
|
||||
const siblingIndex = getSiblingIndex(el);
|
||||
const html = el.outerHTML;
|
||||
|
||||
el.remove();
|
||||
|
||||
const path = elementPath(id);
|
||||
result.forward.push(patchRemove(path));
|
||||
result.inverse.push(patchAdd(path, { html, parentId, siblingIndex }));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function handleSetCompositionMetadata(
|
||||
parsed: ParsedDocument,
|
||||
op: { width?: number; height?: number; duration?: number },
|
||||
): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
const root = findRoot(parsed.document);
|
||||
if (!root) return result;
|
||||
|
||||
// The runtime treats data-width/data-height as a FORCED override of inline
|
||||
// style when present (core/runtime/init.ts applyCompositionSizing). So:
|
||||
// style is always written; the data-* attribute is updated only when the
|
||||
// composition already carries it — otherwise a style-only write would be
|
||||
// clobbered on load. Absent attributes stay absent (keeps inverses exact).
|
||||
if (op.width !== undefined) {
|
||||
const styles = getElementStyles(root);
|
||||
const oldAttr = root.getAttribute("data-width");
|
||||
const oldWidth = oldAttr ?? styles["width"] ?? null;
|
||||
const newVal = `${op.width}px`;
|
||||
setElementStyles(root, { width: newVal });
|
||||
if (oldAttr !== null) root.setAttribute("data-width", String(op.width));
|
||||
const path = metaPath("width");
|
||||
const p = scalarChange(path, oldWidth !== null ? parseFloat(oldWidth) : null, op.width);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
}
|
||||
|
||||
if (op.height !== undefined) {
|
||||
const styles = getElementStyles(root);
|
||||
const oldAttr = root.getAttribute("data-height");
|
||||
const oldHeight = oldAttr ?? styles["height"] ?? null;
|
||||
const newVal = `${op.height}px`;
|
||||
setElementStyles(root, { height: newVal });
|
||||
if (oldAttr !== null) root.setAttribute("data-height", String(op.height));
|
||||
const path = metaPath("height");
|
||||
const p = scalarChange(path, oldHeight !== null ? parseFloat(oldHeight) : null, op.height);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
}
|
||||
|
||||
if (op.duration !== undefined) {
|
||||
const oldDur = root.getAttribute("data-duration");
|
||||
const oldVal = oldDur !== null ? parseFloat(oldDur) : null;
|
||||
root.setAttribute("data-duration", String(op.duration));
|
||||
const path = metaPath("duration");
|
||||
const p = scalarChange(path, oldVal, op.duration);
|
||||
result.forward.push(p.forward);
|
||||
result.inverse.push(p.inverse);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleSetVariableValue(
|
||||
parsed: ParsedDocument,
|
||||
id: string,
|
||||
value: string | number | boolean,
|
||||
): MutationResult {
|
||||
const root = findRoot(parsed.document);
|
||||
if (!root) return EMPTY;
|
||||
|
||||
const cssVar = `--${id}`;
|
||||
const oldStyles = getElementStyles(root);
|
||||
const oldValue = oldStyles[cssVar] ?? null;
|
||||
const newVal = String(value);
|
||||
setElementStyles(root, { [cssVar]: newVal });
|
||||
|
||||
const path = variablePath(id);
|
||||
const p = scalarChange(path, oldValue, newVal);
|
||||
return { forward: [p.forward], inverse: [p.inverse] };
|
||||
}
|
||||
|
||||
// ─── Validation (can(op)) ────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true if the op can be applied to the current document state. */
|
||||
export function validateOp(parsed: ParsedDocument, op: EditOp): boolean {
|
||||
switch (op.type) {
|
||||
case "setStyle":
|
||||
case "setText":
|
||||
case "setAttribute":
|
||||
case "setTiming":
|
||||
case "setHold":
|
||||
case "moveElement":
|
||||
case "removeElement": {
|
||||
const ids = targets(op.target);
|
||||
return ids.length > 0 && ids.every((id) => findById(parsed.document, id) !== null);
|
||||
}
|
||||
case "setVariableValue":
|
||||
return findRoot(parsed.document) !== null;
|
||||
case "setCompositionMetadata":
|
||||
return true;
|
||||
// Phase 3b — not implemented yet; can() must report false so callers
|
||||
// can feature-detect instead of hitting UnsupportedOpError.
|
||||
default:
|
||||
return !PHASE3B_OPS.has(op.type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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|trackIndex} ← end = computed absolute data-end
|
||||
* /elements/{hfId}/hold/{start|end|fill}
|
||||
* /elements/{hfId} ← whole subtree (removeElement)
|
||||
* /variables/{variableId}
|
||||
* /metadata/{width|height|duration}
|
||||
*
|
||||
* 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"
|
||||
* /metadata/width → "meta.width"
|
||||
*/
|
||||
|
||||
import type { JsonPatchOp, PatchEvent } from "../types.js";
|
||||
|
||||
// ─── Path builders ────────────────────────────────────────────────────────────
|
||||
|
||||
export function stylePath(id: string, prop: string): string {
|
||||
return `/elements/${id}/inlineStyles/${prop}`;
|
||||
}
|
||||
|
||||
export function textPath(id: string): string {
|
||||
return `/elements/${id}/text`;
|
||||
}
|
||||
|
||||
export function attrPath(id: string, name: string): string {
|
||||
// RFC 6902 JSON Pointer: ~ → ~0, / → ~1
|
||||
const escaped = name.replace(/~/g, "~0").replace(/\//g, "~1");
|
||||
return `/elements/${id}/attributes/${escaped}`;
|
||||
}
|
||||
|
||||
export function timingPath(id: string, field: "start" | "end" | "trackIndex"): string {
|
||||
return `/elements/${id}/timing/${field}`;
|
||||
}
|
||||
|
||||
export function holdPath(id: string, field: "start" | "end" | "fill"): string {
|
||||
return `/elements/${id}/hold/${field}`;
|
||||
}
|
||||
|
||||
export function elementPath(id: string): string {
|
||||
return `/elements/${id}`;
|
||||
}
|
||||
|
||||
export function variablePath(id: string): string {
|
||||
return `/variables/${id}`;
|
||||
}
|
||||
|
||||
export function metaPath(field: "width" | "height" | "duration"): string {
|
||||
return `/metadata/${field}`;
|
||||
}
|
||||
|
||||
// ─── 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}"
|
||||
const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
|
||||
if (styleMatch) return `${styleMatch[1]}.style.${styleMatch[2]}`;
|
||||
|
||||
// /elements/{id}/text → "{id}.text"
|
||||
const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path);
|
||||
if (textMatch) return `${textMatch[1]}.text`;
|
||||
|
||||
// /elements/{id}/attributes/{name} → "{id}.attr.{name}"
|
||||
const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
|
||||
if (attrMatch) return `${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 `${timingMatch[1]}.timing.${timingMatch[2]}`;
|
||||
|
||||
// /elements/{id}/hold/{field} → "{id}.hold.{field}"
|
||||
const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path);
|
||||
if (holdMatch) return `${holdMatch[1]}.hold.${holdMatch[2]}`;
|
||||
|
||||
// /elements/{id} (whole element) → "{id}"
|
||||
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
|
||||
if (elemMatch) return elemMatch[1] ?? null;
|
||||
|
||||
// /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]}`;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Patch event builder ──────────────────────────────────────────────────────
|
||||
|
||||
// Consumed by session.ts dispatch/batch in the next stacked PR (#1325).
|
||||
// fallow-ignore-next-line unused-export
|
||||
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 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,19 @@
|
||||
export type {
|
||||
HyperFramesElement,
|
||||
SdkDocument,
|
||||
OverrideSet,
|
||||
EditOp,
|
||||
ElasticHold,
|
||||
GsapTweenSpec,
|
||||
HfId,
|
||||
JsonPatchOp,
|
||||
PatchEvent,
|
||||
PersistErrorEvent,
|
||||
ElementSnapshot,
|
||||
FindQuery,
|
||||
SelectionProxy,
|
||||
ElementHandle,
|
||||
Composition,
|
||||
} from "./types.js";
|
||||
|
||||
export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
|
||||
@@ -0,0 +1,256 @@
|
||||
// ─── Document model ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Full DOM-level view of one editable element. Built by the SDK adaptation layer. */
|
||||
export interface HyperFramesElement {
|
||||
readonly id: 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>>;
|
||||
/** Direct text node content (not descendant text) */
|
||||
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 }
|
||||
*/
|
||||
export type OverrideSet = Record<string, string | number | boolean | null>;
|
||||
|
||||
// ─── 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: "setClassStyle"; selector: string; styles: Record<string, string | null> }
|
||||
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
|
||||
| { type: "setVariableValue"; id: string; value: string | number | boolean }
|
||||
| { type: "addGsapTween"; target: HfId; id: string; 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; keyframeIndex: number }
|
||||
| { type: "removeGsapTween"; animationId: string }
|
||||
| { type: "addLabel"; name: string; position: number }
|
||||
| { type: "removeLabel"; name: string };
|
||||
|
||||
export interface ElasticHold {
|
||||
start: number;
|
||||
end: number;
|
||||
fill: "freeze" | "loop";
|
||||
}
|
||||
|
||||
export interface GsapTweenSpec {
|
||||
method: "from" | "to" | "fromTo";
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
setVariableValue(id: string, value: string | number | boolean): void;
|
||||
/** Returns the newly-assigned tween ID */
|
||||
addGsapTween(target: HfId, tween: GsapTweenSpec): string;
|
||||
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void;
|
||||
removeGsapTween(animationId: string): void;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
|
||||
// ── Query API (F1) ─────────────────────────────────────────────────────────
|
||||
getElements(): ElementSnapshot[];
|
||||
getElement(id: HfId): ElementSnapshot | null;
|
||||
find(query: FindQuery): 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[];
|
||||
|
||||
// ── 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? UI enablement, agent precondition checks. */
|
||||
can(op: EditOp): boolean;
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────────────
|
||||
serialize(): 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 ──────────────────────────────────────────────────────────────
|
||||
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