diff --git a/packages/core/src/parsers/hfIds.test.ts b/packages/core/src/parsers/hfIds.test.ts index a409fbbaf..359082349 100644 --- a/packages/core/src/parsers/hfIds.test.ts +++ b/packages/core/src/parsers/hfIds.test.ts @@ -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(`

Hello

`)), "p.body"); const after = idOf(ensureHfIds(doc(`

Hello world

`)), "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(`

x

`)), "p"); const after = idOf(ensureHfIds(doc(`

x

`)), "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(`

same

`)), "p.x"); const pair = ids(ensureHfIds(doc(`

same

same

`))); expect(pair[0]).toBe(single); // first identical element: stable, content-derived diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 8915d411d..4aff09247 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -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 = `
`; + 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); + }); +}); diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index d7c2c2ef7..ff455408b 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -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)); diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.test.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.test.ts new file mode 100644 index 000000000..2bd16d839 --- /dev/null +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.test.ts @@ -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); + }); +}); diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts index bfebd852b..f3a3fc1d3 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts @@ -190,10 +190,11 @@ export function filterNestedDomEditGroupItems, + selection: Pick, ): string { return [ selection.sourceFile ?? "", + selection.hfId ?? "", selection.id ?? "", selection.selector ?? "", selection.selectorIndex ?? "", diff --git a/packages/studio/src/components/editor/domEditing.test.ts b/packages/studio/src/components/editor/domEditing.test.ts index 370e9c096..cb663c386 100644 --- a/packages/studio/src/components/editor/domEditing.test.ts +++ b/packages/studio/src/components/editor/domEditing.test.ts @@ -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(` +
+
+
+ `); + 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); + }); +}); diff --git a/packages/studio/src/components/editor/domEditing.ts b/packages/studio/src/components/editor/domEditing.ts index d8a16344e..45927cfe7 100644 --- a/packages/studio/src/components/editor/domEditing.ts +++ b/packages/studio/src/components/editor/domEditing.ts @@ -35,6 +35,7 @@ export { getDomEditNonEditableReason, getDomEditTargetKey, isTextEditableSelection, + readHfId, refreshDomEditSelection, resolveDomEditCapabilities, resolveDomEditSelection, diff --git a/packages/studio/src/components/editor/domEditingElement.ts b/packages/studio/src/components/editor/domEditingElement.ts index b081d6498..b4ca30113 100644 --- a/packages/studio/src/components/editor/domEditingElement.ts +++ b/packages/studio/src/components/editor/domEditingElement.ts @@ -95,7 +95,7 @@ function isInspectableLayerElement(el: HTMLElement): boolean { export function getDomLayerPatchTarget( el: HTMLElement, activeCompositionPath: string | null, -): Pick | null { +): Pick | 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, + selection: Pick, 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 ( diff --git a/packages/studio/src/components/editor/domEditingLayers.test.ts b/packages/studio/src/components/editor/domEditingLayers.test.ts index 900b2aea6..beb7fb480 100644 --- a/packages/studio/src/components/editor/domEditingLayers.test.ts +++ b/packages/studio/src/components/editor/domEditingLayers.test.ts @@ -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"); diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 4f3195389..8b3fa73b7 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -173,6 +173,7 @@ export function buildDefaultDomEditTextField(base?: Partial): // fallow-ignore-next-line complexity export function resolveDomEditCapabilities(args: { selector?: string; + hfId?: string; tagName?: string; className?: string; inlineStyles: Record; @@ -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 { 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, + selection: Pick, ): 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, ): { id?: string | null; hfId?: string; selector?: string; selectorIndex?: number } { diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts index 96f69ff8c..50d82cafa 100644 --- a/packages/studio/src/components/editor/domEditingTypes.ts +++ b/packages/studio/src/components/editor/domEditingTypes.ts @@ -98,6 +98,7 @@ export interface DomEditLayerItem { depth: number; childCount: number; id?: string; + hfId?: string; selector?: string; selectorIndex?: number; sourceFile: string; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 2d53c1a03..752b0fff2 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -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, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 4c5908f4a..89b21c1d5 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -285,6 +285,7 @@ export function useGsapScriptCommits({ body: JSON.stringify({ target: { id: selection.id, + hfId: selection.hfId, selector: selection.selector, selectorIndex: selection.selectorIndex, }, diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index f2b7c032a..53d01ee76 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -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 }; diff --git a/packages/studio/src/player/lib/timelineDOM.test.ts b/packages/studio/src/player/lib/timelineDOM.test.ts new file mode 100644 index 000000000..69fdfdfc3 --- /dev/null +++ b/packages/studio/src/player/lib/timelineDOM.test.ts @@ -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(` +
+
+
+ `); + + 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(` +
+
+
+ `); + + 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(` +
+
+
+ `); + + const layers = createImplicitTimelineLayersFromDOM(doc, 10); + const layer = layers.find((el) => el.domId === "layer"); + + expect(layer).toBeDefined(); + expect(layer?.hfId).toBe("hf-xyz789"); + }); +}); diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index 963832359..215d419ec 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -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, diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts new file mode 100644 index 000000000..b97bf498e --- /dev/null +++ b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts @@ -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(` +
+
+
+ `); + + 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(` +
+
+
+ `); + + const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10); + const entry = missing[0]; + + expect(entry).toBeDefined(); + expect(entry?.hfId).toBeUndefined(); + }); +}); diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts index 7bb824bdb..a4ba94d94 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts @@ -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, diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index d67aad396..3382fb945 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -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 */ diff --git a/packages/studio/src/utils/sourcePatcher.ts b/packages/studio/src/utils/sourcePatcher.ts index 9d19114d4..f8ee9d67f 100644 --- a/packages/studio/src/utils/sourcePatcher.ts +++ b/packages/studio/src/utils/sourcePatcher.ts @@ -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;