mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(sdk): stage 6 — sub-composition scoped ids (F9) (#1434)
* feat(sdk): stage 6 — sub-composition scoped ids (F9) Adds fully-qualified scoped ids for addressing elements inside inlined sub-compositions, so callers can target "hf-HOST/hf-LEAF" unambiguously even when bare hf-ids collide across sub-composition boundaries. Changes: - model.ts: resolveScoped() traverses id segments through nested subtrees; isNewHostBoundary() detects host boundaries (dcf ≠ parent dcf handles outerHTML innerRoot edge case) - types.ts: HyperFramesElement gains scopedId field - document.ts: buildElement carries scopePrefix, propagates childPrefix at host boundaries; buildRoots starts with "" - patches.ts: RFC 6902 escapeIdForPath / decodePathSegment for scoped ids containing "/"; all path builders and pathToKey/keyToPath updated - session.ts: getElement() matches by scopedId; find() returns scopedIds; orphan cleanup decodes RFC 6902 before key comparison, preserves removal markers, purges property sub-keys for both bare and scoped ids - mutate.ts: all element handlers use resolveScoped instead of findById; handleRemoveElement collects full subtree hf-ids before removal for complete GSAP animation cascade (Q3 fix); validateOp uses resolveScoped 20 new contract tests in session.subcomp.test.ts covering resolveScoped, scopedId propagation, dispatch to scoped targets, RFC 6902 patch encoding, override-set key format, orphan purge, and serialize stability. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sdk): add find({ composition }) filter — Stage 6 WS-C completion Closes the last headless-testable Stage 6 gap (F9 workstream C). `find({ composition: "hf-host" })` returns all scopedIds whose prefix matches the given host id — i.e. every element mounted inside that sub-composition, at any depth. Combinable with other FindQuery fields (tag, text, name, track). 3 new contract tests in session.subcomp.test.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sdk): addGsapTween resolves scoped id to bare leaf; validateOp checks target exists - handleAddGsapTween: strip host prefix for scoped ids (hf-host/hf-leaf → selector [data-hf-id="hf-leaf"]) — DOM element carries only the leaf part - validateOp addGsapTween: call resolveScoped to surface E_TARGET_NOT_FOUND before the GSAP script checks (previously can() returned ok for missing targets) - patches.ts pathToKey: remove dead ?? null (decodePathSegment never returns undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Miguel Ángel
parent
f10f3425a5
commit
b158870d8f
@@ -34,6 +34,49 @@ export function findById(document: Document, id: string): Element | null {
|
||||
return document.querySelector(`[data-hf-id="${escaped}"]`);
|
||||
}
|
||||
|
||||
function escapeHfId(id: string): string {
|
||||
return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare or scoped hf-id to its DOM element.
|
||||
*
|
||||
* Bare id ("hf-x"): equivalent to findById — top-level document search.
|
||||
* 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("/");
|
||||
let context: Element | Document = document;
|
||||
for (const part of parts) {
|
||||
const escaped = escapeHfId(part);
|
||||
const found: Element | null =
|
||||
context === document
|
||||
? (context as Document).querySelector(`[data-hf-id="${escaped}"]`)
|
||||
: (context as Element).querySelector(`[data-hf-id="${escaped}"]`);
|
||||
if (!found) return null;
|
||||
context = found;
|
||||
}
|
||||
return context as Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export function findRoot(document: Document): Element | null {
|
||||
return (
|
||||
document.querySelector("[data-hf-root]") ??
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import type { CanResult, EditOp, GsapTweenSpec, HfId, JsonPatchOp } from "../types.js";
|
||||
import type { ParsedDocument } from "./model.js";
|
||||
import {
|
||||
findById,
|
||||
resolveScoped,
|
||||
findRoot,
|
||||
getElementStyles,
|
||||
setElementStyles,
|
||||
@@ -194,7 +194,7 @@ function handleSetStyle(
|
||||
): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
const el = resolveScoped(parsed.document, id);
|
||||
if (!el) continue;
|
||||
const old = getElementStyles(el);
|
||||
setElementStyles(el, styles);
|
||||
@@ -234,7 +234,7 @@ function handleMoveElement(
|
||||
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);
|
||||
const el = resolveScoped(parsed.document, id);
|
||||
if (!el) continue;
|
||||
const oldText = getOwnText(el);
|
||||
setOwnText(el, value);
|
||||
@@ -259,7 +259,7 @@ function handleSetAttribute(
|
||||
validateSetAttribute(name, value);
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
const el = resolveScoped(parsed.document, id);
|
||||
if (!el) continue;
|
||||
const oldValue = el.getAttribute(name);
|
||||
const path = attrPath(id, name);
|
||||
@@ -293,7 +293,7 @@ function handleSetTiming(
|
||||
let currentScript = origScript;
|
||||
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
const el = resolveScoped(parsed.document, id);
|
||||
if (!el) continue;
|
||||
|
||||
const oldStartStr = el.getAttribute("data-start");
|
||||
@@ -373,7 +373,7 @@ function handleSetHold(
|
||||
): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
const el = resolveScoped(parsed.document, id);
|
||||
if (!el) continue;
|
||||
|
||||
const fields: Array<["start" | "end" | "fill", string]> = [
|
||||
@@ -401,20 +401,28 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
|
||||
let currentScript = origScript;
|
||||
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
const el = resolveScoped(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;
|
||||
|
||||
// Collect all bare hf-ids in the subtree BEFORE removal so GSAP cascade
|
||||
// removes animations targeting any sub-composition element, not just the host.
|
||||
const subtreeIds = collectSubtreeHfIds(el);
|
||||
|
||||
el.remove();
|
||||
|
||||
const path = elementPath(id);
|
||||
result.forward.push(patchRemove(path));
|
||||
result.inverse.push(patchAdd(path, { html, parentId, siblingIndex }));
|
||||
|
||||
if (currentScript) currentScript = cascadeRemoveAnimations(currentScript, id);
|
||||
if (currentScript) {
|
||||
for (const subtreeId of subtreeIds) {
|
||||
currentScript = cascadeRemoveAnimations(currentScript, subtreeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (origScript && currentScript && currentScript !== origScript) {
|
||||
@@ -509,10 +517,24 @@ function selectorMatchesId(selector: string, id: HfId): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// v1 limitation: uses bare-id matching across the whole script, so a selector targeting
|
||||
// "hf-leaf" will cascade-remove animations for both "hf-parent/hf-leaf" and any other
|
||||
// element whose scoped or bare id matches "hf-leaf". Acceptable for typical single-comp
|
||||
// use; sub-composition authors with leaf-id collisions should use fully-qualified selectors.
|
||||
// v1 limitation: selectorMatchesId uses bare-id matching across the whole script, so a
|
||||
// selector targeting "hf-leaf" will cascade-remove animations for both "hf-parent/hf-leaf"
|
||||
// and any other element whose scoped or bare id matches "hf-leaf". Acceptable for typical
|
||||
// single-comp use; sub-composition authors with leaf-id collisions should use
|
||||
// fully-qualified selectors.
|
||||
|
||||
/** Collect all bare data-hf-id values from el and all its descendants. */
|
||||
function collectSubtreeHfIds(el: Element): string[] {
|
||||
const ids: string[] = [];
|
||||
const own = el.getAttribute("data-hf-id");
|
||||
if (own) ids.push(own);
|
||||
for (const child of Array.from(el.querySelectorAll("[data-hf-id]"))) {
|
||||
const id = child.getAttribute("data-hf-id");
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function cascadeRemoveAnimations(script: string, id: HfId): string {
|
||||
const parsedGsap = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsedGsap) return script;
|
||||
@@ -576,8 +598,11 @@ function handleAddGsapTween(
|
||||
? ((tween.toProperties ?? {}) as Record<string, number | string>)
|
||||
: ((tween.toProperties ?? tween.properties ?? {}) as Record<string, number | string>);
|
||||
|
||||
// Scoped ids like "hf-host/hf-leaf" must use the bare leaf id in the GSAP
|
||||
// selector — only the leaf part is written as data-hf-id on the DOM element.
|
||||
const bareTarget = target.includes("/") ? (target.split("/").at(-1) ?? target) : target;
|
||||
const animation: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: `[data-hf-id="${target}"]`,
|
||||
targetSelector: `[data-hf-id="${bareTarget}"]`,
|
||||
method: tween.method,
|
||||
position: tween.position ?? 0,
|
||||
...(tween.duration !== undefined ? { duration: tween.duration } : {}),
|
||||
@@ -753,7 +778,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
case "removeElement": {
|
||||
const ids = targets(op.target);
|
||||
if (ids.length === 0) return canErr("E_TARGET_NOT_FOUND", "No target ids provided.");
|
||||
const missing = ids.filter((id) => findById(parsed.document, id) === null);
|
||||
const missing = ids.filter((id) => resolveScoped(parsed.document, id) === null);
|
||||
if (missing.length > 0)
|
||||
return canErr(
|
||||
"E_TARGET_NOT_FOUND",
|
||||
@@ -771,6 +796,12 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
return CAN_OK;
|
||||
case "addGsapTween":
|
||||
case "addLabel": {
|
||||
if (op.type === "addGsapTween" && resolveScoped(parsed.document, op.target) === null)
|
||||
return canErr(
|
||||
"E_TARGET_NOT_FOUND",
|
||||
`Element not found: ${op.target}.`,
|
||||
"Verify the id against comp.getElements() or comp.find().",
|
||||
);
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script)
|
||||
return canErr(
|
||||
|
||||
@@ -30,30 +30,45 @@ 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/${id}/inlineStyles/${prop}`;
|
||||
return `/elements/${escapeIdForPath(id)}/inlineStyles/${prop}`;
|
||||
}
|
||||
|
||||
export function textPath(id: string): string {
|
||||
return `/elements/${id}/text`;
|
||||
return `/elements/${escapeIdForPath(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}`;
|
||||
const escapedName = name.replace(/~/g, "~0").replace(/\//g, "~1");
|
||||
return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`;
|
||||
}
|
||||
|
||||
export function timingPath(id: string, field: "start" | "end" | "trackIndex"): string {
|
||||
return `/elements/${id}/timing/${field}`;
|
||||
return `/elements/${escapeIdForPath(id)}/timing/${field}`;
|
||||
}
|
||||
|
||||
export function holdPath(id: string, field: "start" | "end" | "fill"): string {
|
||||
return `/elements/${id}/hold/${field}`;
|
||||
return `/elements/${escapeIdForPath(id)}/hold/${field}`;
|
||||
}
|
||||
|
||||
export function elementPath(id: string): string {
|
||||
return `/elements/${id}`;
|
||||
return `/elements/${escapeIdForPath(id)}`;
|
||||
}
|
||||
|
||||
export function variablePath(id: string): string {
|
||||
@@ -80,29 +95,30 @@ export function styleSheetPath(): string {
|
||||
*/
|
||||
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 `${styleMatch[1]}.style.${styleMatch[2]}`;
|
||||
if (styleMatch) return `${decodePathSegment(styleMatch[1]!)}.style.${styleMatch[2]}`;
|
||||
|
||||
// /elements/{id}/text → "{id}.text"
|
||||
const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path);
|
||||
if (textMatch) return `${textMatch[1]}.text`;
|
||||
if (textMatch) return `${decodePathSegment(textMatch[1]!)}.text`;
|
||||
|
||||
// /elements/{id}/attributes/{name} → "{id}.attr.{name}"
|
||||
const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
|
||||
if (attrMatch) return `${attrMatch[1]}.attr.${attrMatch[2]}`;
|
||||
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 `${timingMatch[1]}.timing.${timingMatch[2]}`;
|
||||
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 `${holdMatch[1]}.hold.${holdMatch[2]}`;
|
||||
if (holdMatch) return `${decodePathSegment(holdMatch[1]!)}.hold.${holdMatch[2]}`;
|
||||
|
||||
// /elements/{id} (whole element) → "{id}"
|
||||
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
|
||||
if (elemMatch) return elemMatch[1] ?? null;
|
||||
if (elemMatch) return decodePathSegment(elemMatch[1]!);
|
||||
|
||||
// /variables/{id} → "var.{id}"
|
||||
const varMatch = /^\/variables\/(.+)$/.exec(path);
|
||||
@@ -133,9 +149,10 @@ export function keyToPath(key: string): string | null {
|
||||
if (text?.[1]) return textPath(text[1]);
|
||||
|
||||
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
|
||||
// pathToKey stores the RFC 6902-encoded segment verbatim; do NOT call attrPath()
|
||||
// here (it would re-escape '~' → '~0'), just reconstruct the path directly.
|
||||
if (attr?.[1] && attr[2]) return `/elements/${attr[1]}/attributes/${attr[2]}`;
|
||||
// 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|trackIndex)$/.exec(key);
|
||||
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");
|
||||
|
||||
Reference in New Issue
Block a user