mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b) (#1299)
* feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b) * refactor(studio): extract readHfId helper, fix empty-string normalization, add comments (R7 review) - Extract readHfId(el) to domEditingLayers.ts — centralizes `?.trim() || undefined` normalisation; guards against empty-string data-hf-id reaching findTagByTarget - Wire readHfId into domEditingLayers.ts and useDomEditCommits.ts (the one site that still used `?? undefined` instead of `|| undefined`) - Re-export readHfId through domEditing.ts public API - Add readHfId unit tests: present, absent, empty-string, whitespace-only - Add comment on PatchTarget: runtime validation lives in findTagByTarget, type is docs-only - Suppress pre-existing unused re-exports in timelineDOM.ts (backward-compat re-exports brought into fallow scope by the T5b hfId changes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): clear data-hf-id on split clone to prevent dual-match (R7 review) cloneNode(true) copies all attributes including data-hf-id. Without clearing it, both halves of a split share the same hf-id; the server's findByHfId picks the first match and silently patches the wrong clip. Remove the attribute from the clone so write-back re-mints a fresh id on the next preview load. Adds a test: splitElementInHtml — hfId clone isolation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(studio): add hfId to DomEditLayerItem + getDomLayerPatchTarget return type (R7 review) - Add hfId to DomEditLayerItem interface (domEditingTypes.ts) so layer item construction in collectDomEditLayerItems compiles - Widen getDomLayerPatchTarget return type to include hfId + populate it from data-hf-id attribute (domEditingElement.ts) - Widen findDomEditSelectionTarget to check hfId-first when no id/selector - Widen Pick types in domEditOverlayGeometry.ts and useGsapScriptCommits.ts - Add hfId to buildMissingCompositionElements element construction - Add hfId-targeted test coverage in domEditing.test.ts, domEditOverlayGeometry.test.ts, timelineIframeHelpers.test.ts - Update hfIds.test.ts KNOWN LIMITATION labels — write-back landed in R7 T1-2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
82c754f27a
commit
0923bc0787
@@ -100,9 +100,9 @@ describe("ensureHfIds", () => {
|
||||
});
|
||||
|
||||
// Lock the edit-lifecycle behavior. These pin BOTH the guarantee that holds
|
||||
// once ids are persisted to source (pinning) AND the two limitations that hold
|
||||
// while they are not (design §3 write-back is not yet wired — see
|
||||
// notes/r1-stable-hf-ids-design.md "Implementation status & verified lifecycle gap").
|
||||
// once ids are persisted to source (pinning) AND the behavior for truly unpinned
|
||||
// HTML (no data-hf-id in the input — unreachable in production after write-back
|
||||
// landed in R7 Task 1-2, but still the correct contract for that path).
|
||||
describe("ensureHfIds — edit lifecycle (R1 stability)", () => {
|
||||
it("pinned id survives a content edit (the §3 write-back guarantee)", () => {
|
||||
// Element already carries data-hf-id in source (as it would after write-back).
|
||||
@@ -110,25 +110,24 @@ describe("ensureHfIds — edit lifecycle (R1 stability)", () => {
|
||||
expect(idOf(ensureHfIds(edited), "p.body")).toBe("hf-abcd");
|
||||
});
|
||||
|
||||
it("KNOWN LIMITATION: an unpinned id changes when the element's text is edited", () => {
|
||||
// No data-hf-id in source → every parse re-mints from content. Editing the
|
||||
// text changes the hash, so the id drifts. This is the "pure-hash" mode the
|
||||
// design rejected; flip this assertion to .toBe once write-back lands.
|
||||
it("unpinned id drifts when element text is edited (pure-hash, unreachable after write-back)", () => {
|
||||
// No data-hf-id in source → every parse re-mints from content. This path is
|
||||
// unreachable in production after R7 write-back: the first serve pins the id.
|
||||
const before = idOf(ensureHfIds(doc(`<p class="body">Hello</p>`)), "p.body");
|
||||
const after = idOf(ensureHfIds(doc(`<p class="body">Hello world</p>`)), "p.body");
|
||||
expect(before).not.toBe(after);
|
||||
});
|
||||
|
||||
it("KNOWN LIMITATION: an unpinned id changes when an attribute is edited", () => {
|
||||
it("unpinned id drifts when attribute is edited (pure-hash, unreachable after write-back)", () => {
|
||||
const before = idOf(ensureHfIds(doc(`<p class="body">x</p>`)), "p");
|
||||
const after = idOf(ensureHfIds(doc(`<p class="lead">x</p>`)), "p");
|
||||
expect(before).not.toBe(after);
|
||||
});
|
||||
|
||||
it("KNOWN LIMITATION: identical-content siblings have no content-stable id for the 2nd occurrence", () => {
|
||||
it("identical-content siblings: second occurrence gets a position-derived dedup id", () => {
|
||||
// Insertion stability holds for DISTINCT content (covered elsewhere), but a
|
||||
// second identical sibling collides and gets a position-derived dedup id —
|
||||
// there is no content-stable handle for it. The first keeps the base id.
|
||||
// second identical sibling collides and gets a position-derived dedup id.
|
||||
// First element keeps the base (content-derived) id; documented in project_hfid_dedup_tiebreak.
|
||||
const single = idOf(ensureHfIds(doc(`<p class="x">same</p>`)), "p.x");
|
||||
const pair = ids(ensureHfIds(doc(`<p class="x">same</p><p class="x">same</p>`)));
|
||||
expect(pair[0]).toBe(single); // first identical element: stable, content-derived
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
removeElementFromHtml,
|
||||
patchElementInHtml,
|
||||
probeElementInSource,
|
||||
splitElementInHtml,
|
||||
} from "./sourceMutation.js";
|
||||
|
||||
describe("removeElementFromHtml", () => {
|
||||
@@ -455,3 +456,14 @@ describe("T7 — data-hf-id targeting (spec for R1)", () => {
|
||||
expect(html).toContain('data-hf-id="hf-a1b2"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitElementInHtml — hfId clone isolation", () => {
|
||||
it("does not copy data-hf-id to the cloned second half", () => {
|
||||
const source = `<html><body><div data-composition-id="root"><div id="clip1" class="clip" data-start="0" data-duration="10" data-hf-id="hf-abc123"></div></div></body></html>`;
|
||||
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
|
||||
|
||||
expect(matched).toBe(true);
|
||||
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,6 +275,7 @@ export function splitElementInHtml(
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.setAttribute("id", newId);
|
||||
clone.removeAttribute("data-hf-id");
|
||||
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
|
||||
clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000));
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectionCacheKey } from "./domEditOverlayGeometry";
|
||||
|
||||
describe("selectionCacheKey — hfId collision (R7)", () => {
|
||||
it("produces distinct keys for two elements that differ only by hfId", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const a = selectionCacheKey({ sourceFile: "index.html", hfId: "hf-111" } as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b = selectionCacheKey({ sourceFile: "index.html", hfId: "hf-222" } as any);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -190,10 +190,11 @@ export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }
|
||||
}
|
||||
|
||||
export function selectionCacheKey(
|
||||
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
): string {
|
||||
return [
|
||||
selection.sourceFile ?? "",
|
||||
selection.hfId ?? "",
|
||||
selection.id ?? "",
|
||||
selection.selector ?? "",
|
||||
selection.selectorIndex ?? "",
|
||||
|
||||
@@ -1156,3 +1156,46 @@ describe("patch builders and prompt builder", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hfId — find, key, capabilities (R7 fixes)", () => {
|
||||
it("getDomEditTargetKey keeps two hfId-only elements distinct", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const a = getDomEditTargetKey({ sourceFile: "index.html", hfId: "hf-aaa" } as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b = getDomEditTargetKey({ sourceFile: "index.html", hfId: "hf-bbb" } as any);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("findElementForSelection finds element by data-hf-id when no id or selector", () => {
|
||||
const doc = createDocument(`
|
||||
<div data-composition-id="root">
|
||||
<div data-hf-id="hf-xyz789" class="clip" style="position:absolute;left:0;top:0;width:100px;height:100px;"></div>
|
||||
</div>
|
||||
`);
|
||||
const el = doc.querySelector('[data-hf-id="hf-xyz789"]') as HTMLElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const found = findElementForSelection(doc, { hfId: "hf-xyz789" } as any);
|
||||
expect(found).toBe(el);
|
||||
});
|
||||
|
||||
it("resolveDomEditCapabilities enables editing for hfId-only element (no CSS selector)", () => {
|
||||
const result = resolveDomEditCapabilities({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hfId: "hf-abc" as any,
|
||||
selector: undefined,
|
||||
inlineStyles: { left: "10px", top: "20px", width: "100px", height: "50px" },
|
||||
computedStyles: {
|
||||
position: "absolute",
|
||||
left: "10px",
|
||||
top: "20px",
|
||||
width: "100px",
|
||||
height: "50px",
|
||||
},
|
||||
isCompositionHost: false,
|
||||
isInsideLockedComposition: false,
|
||||
isMasterView: false,
|
||||
});
|
||||
expect(result.canSelect).toBe(true);
|
||||
expect(result.canMove).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ export {
|
||||
getDomEditNonEditableReason,
|
||||
getDomEditTargetKey,
|
||||
isTextEditableSelection,
|
||||
readHfId,
|
||||
refreshDomEditSelection,
|
||||
resolveDomEditCapabilities,
|
||||
resolveDomEditSelection,
|
||||
|
||||
@@ -95,7 +95,7 @@ function isInspectableLayerElement(el: HTMLElement): boolean {
|
||||
export function getDomLayerPatchTarget(
|
||||
el: HTMLElement,
|
||||
activeCompositionPath: string | null,
|
||||
): Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile"> | null {
|
||||
): Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile"> | null {
|
||||
if (!isInspectableLayerElement(el)) return null;
|
||||
if (el.hasAttribute("data-composition-id")) return null;
|
||||
|
||||
@@ -105,6 +105,7 @@ export function getDomLayerPatchTarget(
|
||||
const { sourceFile } = getSourceFileForElement(el, activeCompositionPath);
|
||||
return {
|
||||
id: el.id || undefined,
|
||||
hfId: el.getAttribute("data-hf-id") || undefined,
|
||||
selector,
|
||||
selectorIndex: getSelectorIndex(
|
||||
el.ownerDocument,
|
||||
@@ -229,9 +230,14 @@ export function isLargeRasterDomEditSelection(
|
||||
|
||||
export function findElementForSelection(
|
||||
doc: Document,
|
||||
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
activeCompositionPath: string | null = null,
|
||||
): HTMLElement | null {
|
||||
if (selection.hfId) {
|
||||
const byHfId = doc.querySelector(`[data-hf-id="${selection.hfId}"]`);
|
||||
if (isHtmlElement(byHfId)) return byHfId;
|
||||
}
|
||||
|
||||
if (selection.id) {
|
||||
const byId = doc.getElementById(selection.id);
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDomEditSelection, buildDomEditPatchTarget } from "./domEditingLayers";
|
||||
import { resolveDomEditSelection, buildDomEditPatchTarget, readHfId } from "./domEditingLayers";
|
||||
|
||||
const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
|
||||
|
||||
@@ -27,6 +27,31 @@ describe("buildDomEditPatchTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("readHfId", () => {
|
||||
it("returns the attribute value when present", () => {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("data-hf-id", "hf-abc");
|
||||
expect(readHfId(el)).toBe("hf-abc");
|
||||
});
|
||||
|
||||
it("returns undefined when attribute is absent", () => {
|
||||
const el = document.createElement("div");
|
||||
expect(readHfId(el)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when attribute is empty string", () => {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("data-hf-id", "");
|
||||
expect(readHfId(el)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when attribute is whitespace-only", () => {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("data-hf-id", " ");
|
||||
expect(readHfId(el)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDomEditSelection — hfId from data-hf-id", () => {
|
||||
it("populates hfId from the element data-hf-id attribute", async () => {
|
||||
const el = document.createElement("div");
|
||||
|
||||
@@ -173,6 +173,7 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
|
||||
// fallow-ignore-next-line complexity
|
||||
export function resolveDomEditCapabilities(args: {
|
||||
selector?: string;
|
||||
hfId?: string;
|
||||
tagName?: string;
|
||||
className?: string;
|
||||
inlineStyles: Record<string, string>;
|
||||
@@ -182,7 +183,7 @@ export function resolveDomEditCapabilities(args: {
|
||||
isMasterView: boolean;
|
||||
existsInSource?: boolean;
|
||||
}): DomEditCapabilities {
|
||||
if (!args.selector || args.isInsideLockedComposition) {
|
||||
if ((!args.selector && !args.hfId) || args.isInsideLockedComposition) {
|
||||
return {
|
||||
canSelect: !args.isInsideLockedComposition,
|
||||
canEditStyles: false,
|
||||
@@ -289,7 +290,7 @@ export function buildElementLabel(el: HTMLElement): string {
|
||||
async function probeSourceElement(
|
||||
projectId: string,
|
||||
sourceFile: string,
|
||||
target: { id?: string; selector?: string; selectorIndex?: number },
|
||||
target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
@@ -321,7 +322,8 @@ export async function resolveDomEditSelection(
|
||||
let current: HTMLElement | null = getSelectionCandidate(startEl, options);
|
||||
while (current && current !== doc.body && current !== doc.documentElement) {
|
||||
const selector = buildStableSelector(current);
|
||||
if (!selector) {
|
||||
const hfId = readHfId(current);
|
||||
if (!selector && !hfId) {
|
||||
current = current.parentElement;
|
||||
continue;
|
||||
}
|
||||
@@ -330,13 +332,9 @@ export async function resolveDomEditSelection(
|
||||
current,
|
||||
options.activeCompositionPath,
|
||||
);
|
||||
const selectorIndex = getSelectorIndex(
|
||||
doc,
|
||||
current,
|
||||
selector,
|
||||
sourceFile,
|
||||
options.activeCompositionPath,
|
||||
);
|
||||
const selectorIndex = selector
|
||||
? getSelectorIndex(doc, current, selector, sourceFile, options.activeCompositionPath)
|
||||
: undefined;
|
||||
const compositionSrc =
|
||||
current.getAttribute("data-composition-src") ??
|
||||
current.getAttribute("data-composition-file") ??
|
||||
@@ -346,15 +344,18 @@ export async function resolveDomEditSelection(
|
||||
const textFields = collectDomEditTextFields(current);
|
||||
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
|
||||
let existsInSource: boolean | undefined;
|
||||
if (!options.skipSourceProbe && options.projectId && (current.id || selector)) {
|
||||
const probeTarget: { id?: string; selector?: string; selectorIndex?: number } = {};
|
||||
if (!options.skipSourceProbe && options.projectId && (current.id || selector || hfId)) {
|
||||
const probeTarget: { id?: string; hfId?: string; selector?: string; selectorIndex?: number } =
|
||||
{};
|
||||
if (current.id) probeTarget.id = current.id;
|
||||
if (hfId) probeTarget.hfId = hfId;
|
||||
if (selector) probeTarget.selector = selector;
|
||||
if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
|
||||
existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
|
||||
}
|
||||
const capabilities = resolveDomEditCapabilities({
|
||||
selector,
|
||||
hfId,
|
||||
tagName: current.tagName.toLowerCase(),
|
||||
className: current.className,
|
||||
inlineStyles,
|
||||
@@ -369,7 +370,7 @@ export async function resolveDomEditSelection(
|
||||
return {
|
||||
element: current,
|
||||
id: current.id || undefined,
|
||||
hfId: current.getAttribute("data-hf-id") ?? undefined,
|
||||
hfId,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
@@ -452,6 +453,7 @@ export function collectDomEditLayerItems(
|
||||
if (!root) return [];
|
||||
|
||||
const items: DomEditLayerItem[] = [];
|
||||
// fallow-ignore-next-line complexity
|
||||
const visit = (el: HTMLElement, depth: number) => {
|
||||
if (items.length >= maxItems) return;
|
||||
|
||||
@@ -465,6 +467,7 @@ export function collectDomEditLayerItems(
|
||||
depth,
|
||||
childCount: getDirectLayerChildren(el, options).length,
|
||||
id: target.id ?? undefined,
|
||||
hfId: target.hfId ?? undefined,
|
||||
selector: target.selector ?? undefined,
|
||||
selectorIndex: target.selectorIndex,
|
||||
sourceFile: target.sourceFile,
|
||||
@@ -536,10 +539,11 @@ export function getDomEditNonEditableReason(
|
||||
}
|
||||
|
||||
export function getDomEditTargetKey(
|
||||
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
): string {
|
||||
return [
|
||||
selection.sourceFile || "index.html",
|
||||
selection.hfId ?? "",
|
||||
selection.id ?? "",
|
||||
selection.selector ?? "",
|
||||
selection.selectorIndex ?? "",
|
||||
@@ -556,6 +560,10 @@ export function isTextEditableSelection(selection: DomEditSelection): boolean {
|
||||
|
||||
// buildElementAgentPrompt is in domEditingAgentPrompt.ts
|
||||
|
||||
export function readHfId(element: Element): string | undefined {
|
||||
return element.getAttribute("data-hf-id")?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function buildDomEditPatchTarget(
|
||||
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex">,
|
||||
): { id?: string | null; hfId?: string; selector?: string; selectorIndex?: number } {
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface DomEditLayerItem {
|
||||
depth: number;
|
||||
childCount: number;
|
||||
id?: string;
|
||||
hfId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
||||
import {
|
||||
buildDomEditPatchTarget,
|
||||
getDomEditTargetKey,
|
||||
readHfId,
|
||||
type DomEditSelection,
|
||||
} from "../components/editor/domEditing";
|
||||
import {
|
||||
@@ -533,7 +534,7 @@ export function useDomEditCommits({
|
||||
}>,
|
||||
) => {
|
||||
if (entries.length === 0) return;
|
||||
const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? "el").join(":")}`;
|
||||
const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
entry.element.style.zIndex = String(entry.zIndex);
|
||||
@@ -553,7 +554,7 @@ export function useDomEditCommits({
|
||||
{
|
||||
element: entry.element,
|
||||
id: entry.id ?? null,
|
||||
hfId: entry.element.getAttribute("data-hf-id") ?? undefined,
|
||||
hfId: readHfId(entry.element),
|
||||
selector: entry.selector,
|
||||
selectorIndex: entry.selectorIndex,
|
||||
sourceFile: entry.sourceFile,
|
||||
|
||||
@@ -285,6 +285,7 @@ export function useGsapScriptCommits({
|
||||
body: JSON.stringify({
|
||||
target: {
|
||||
id: selection.id,
|
||||
hfId: selection.hfId,
|
||||
selector: selection.selector,
|
||||
selectorIndex: selection.selectorIndex,
|
||||
},
|
||||
|
||||
@@ -45,9 +45,22 @@ interface UseTimelineEditingOptions {
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function buildPatchTarget(element: { domId?: string; selector?: string; selectorIndex?: number }) {
|
||||
function buildPatchTarget(element: {
|
||||
domId?: string;
|
||||
hfId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
}) {
|
||||
if (element.domId) {
|
||||
return { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex };
|
||||
return {
|
||||
id: element.domId,
|
||||
hfId: element.hfId,
|
||||
selector: element.selector,
|
||||
selectorIndex: element.selectorIndex,
|
||||
};
|
||||
}
|
||||
if (element.hfId) {
|
||||
return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex };
|
||||
}
|
||||
if (element.selector) {
|
||||
return { selector: element.selector, selectorIndex: element.selectorIndex };
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTimelineFromDOM, createImplicitTimelineLayersFromDOM } from "./timelineDOM";
|
||||
|
||||
function makeDoc(html: string): Document {
|
||||
const d = document.implementation.createHTMLDocument();
|
||||
d.body.innerHTML = html;
|
||||
return d;
|
||||
}
|
||||
|
||||
describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
||||
it("harvests hfId from a data-start element that has data-hf-id", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div id="hero" class="clip" data-start="0" data-duration="5" data-hf-id="hf-abc123"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const elements = parseTimelineFromDOM(doc, 10);
|
||||
const hero = elements.find((el) => el.domId === "hero");
|
||||
|
||||
expect(hero).toBeDefined();
|
||||
expect(hero?.hfId).toBe("hf-abc123");
|
||||
});
|
||||
|
||||
it("leaves hfId undefined when element has no data-hf-id", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div id="plain" class="clip" data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const elements = parseTimelineFromDOM(doc, 10);
|
||||
const plain = elements.find((el) => el.domId === "plain");
|
||||
|
||||
expect(plain).toBeDefined();
|
||||
expect(plain?.hfId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
|
||||
it("harvests hfId from an implicit layer child that has data-hf-id", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div id="layer" class="clip" data-hf-id="hf-xyz789"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const layers = createImplicitTimelineLayersFromDOM(doc, 10);
|
||||
const layer = layers.find((el) => el.domId === "layer");
|
||||
|
||||
expect(layer).toBeDefined();
|
||||
expect(layer?.hfId).toBe("hf-xyz789");
|
||||
});
|
||||
});
|
||||
@@ -26,14 +26,21 @@ import {
|
||||
|
||||
// Re-export helpers that were previously public from this module so that
|
||||
// existing import sites (hook + tests) don't need to change.
|
||||
// fallow-ignore-next-line unused-exports
|
||||
export {
|
||||
readTimelineDurationFromDocument,
|
||||
// fallow-ignore-next-line unused-exports
|
||||
resolveMediaElement,
|
||||
// fallow-ignore-next-line unused-exports
|
||||
applyMediaMetadataFromElement,
|
||||
getTimelineElementSelector,
|
||||
// fallow-ignore-next-line unused-exports
|
||||
getTimelineElementSourceFile,
|
||||
// fallow-ignore-next-line unused-exports
|
||||
getTimelineElementSelectorIndex,
|
||||
// fallow-ignore-next-line unused-exports
|
||||
buildTimelineElementIdentity,
|
||||
// fallow-ignore-next-line unused-exports
|
||||
getTimelineElementIdentity,
|
||||
findTimelineDomNodeForClip,
|
||||
} from "./timelineElementHelpers";
|
||||
@@ -72,8 +79,10 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
let selectorIndex: number | undefined;
|
||||
let sourceFile: string | undefined;
|
||||
|
||||
let hfId: string | undefined;
|
||||
if (hostEl) {
|
||||
domId = hostEl.id || undefined;
|
||||
hfId = hostEl.getAttribute("data-hf-id") || undefined;
|
||||
selector = getTimelineElementSelector(hostEl);
|
||||
selectorIndex =
|
||||
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
|
||||
@@ -98,6 +107,7 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
duration: clip.duration,
|
||||
track: clip.track,
|
||||
domId,
|
||||
hfId,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
@@ -127,6 +137,7 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
}
|
||||
if (hostEl) {
|
||||
entry.domId = hostEl.id || undefined;
|
||||
entry.hfId = hostEl.getAttribute("data-hf-id") || undefined;
|
||||
entry.selector = getTimelineElementSelector(hostEl);
|
||||
entry.selectorIndex =
|
||||
doc && entry.selector
|
||||
@@ -187,6 +198,7 @@ export function createImplicitTimelineLayersFromDOM(
|
||||
|
||||
layers.push({
|
||||
domId: child.id || undefined,
|
||||
hfId: child.getAttribute("data-hf-id") || undefined,
|
||||
duration: rootDuration,
|
||||
id: identity.id,
|
||||
key: identity.key,
|
||||
@@ -262,6 +274,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
duration: dur,
|
||||
track: isNaN(track) ? 0 : track,
|
||||
domId: el.id || undefined,
|
||||
hfId: el.getAttribute("data-hf-id") || undefined,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMissingCompositionElements } from "./timelineIframeHelpers";
|
||||
import type { IframeWindow } from "./playbackTypes";
|
||||
|
||||
function makeDoc(html: string): Document {
|
||||
const d = document.implementation.createHTMLDocument();
|
||||
d.body.innerHTML = html;
|
||||
return d;
|
||||
}
|
||||
|
||||
describe("buildMissingCompositionElements — hfId (R7)", () => {
|
||||
it("harvests hfId from data-hf-id on composition host elements", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div
|
||||
data-composition-id="scene-a"
|
||||
data-composition-src="scenes/a.html"
|
||||
data-hf-id="hf-scene1"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
|
||||
const entry = missing[0];
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.hfId).toBe("hf-scene1");
|
||||
});
|
||||
|
||||
it("leaves hfId undefined when element has no data-hf-id", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div
|
||||
data-composition-id="scene-b"
|
||||
data-composition-src="scenes/b.html"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
|
||||
const entry = missing[0];
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.hfId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -286,6 +286,7 @@ export function buildMissingCompositionElements(
|
||||
duration: dur,
|
||||
track: isNaN(track) ? 0 : track,
|
||||
domId: el.id || undefined,
|
||||
hfId: el.getAttribute("data-hf-id") || undefined,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface TimelineElement {
|
||||
duration: number;
|
||||
track: number;
|
||||
domId?: string;
|
||||
/** Stable `data-hf-id` attribute value — used as primary patch target when present */
|
||||
hfId?: string;
|
||||
/** Best-effort selector used when patching source HTML back from timeline edits */
|
||||
selector?: string;
|
||||
/** Zero-based occurrence index for non-unique selectors */
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface PatchOperation {
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
|
||||
// escape). This type is documentation only; the server's MutationTarget mirrors this shape.
|
||||
export interface PatchTarget {
|
||||
id?: string | null;
|
||||
hfId?: string;
|
||||
|
||||
Reference in New Issue
Block a user