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:
Vance Ingalls
2026-06-09 15:15:27 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 82c754f27a
commit 0923bc0787
20 changed files with 282 additions and 33 deletions
@@ -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,