Skip to content

Commit 4f252b2

Browse files
vanceingallsclaude
andcommitted
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>
1 parent a04382c commit 4f252b2

6 files changed

Lines changed: 65 additions & 21 deletions

File tree

packages/studio/src/components/editor/domEditing.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export {
3535
getDomEditNonEditableReason,
3636
getDomEditTargetKey,
3737
isTextEditableSelection,
38+
readHfId,
3839
refreshDomEditSelection,
3940
resolveDomEditCapabilities,
4041
resolveDomEditSelection,

packages/studio/src/components/editor/domEditingLayers.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @vitest-environment jsdom
22
import { describe, expect, it } from "vitest";
3-
import { resolveDomEditSelection, buildDomEditPatchTarget } from "./domEditingLayers";
3+
import { resolveDomEditSelection, buildDomEditPatchTarget, readHfId } from "./domEditingLayers";
44

55
const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
66

@@ -27,6 +27,31 @@ describe("buildDomEditPatchTarget", () => {
2727
});
2828
});
2929

30+
describe("readHfId", () => {
31+
it("returns the attribute value when present", () => {
32+
const el = document.createElement("div");
33+
el.setAttribute("data-hf-id", "hf-abc");
34+
expect(readHfId(el)).toBe("hf-abc");
35+
});
36+
37+
it("returns undefined when attribute is absent", () => {
38+
const el = document.createElement("div");
39+
expect(readHfId(el)).toBeUndefined();
40+
});
41+
42+
it("returns undefined when attribute is empty string", () => {
43+
const el = document.createElement("div");
44+
el.setAttribute("data-hf-id", "");
45+
expect(readHfId(el)).toBeUndefined();
46+
});
47+
48+
it("returns undefined when attribute is whitespace-only", () => {
49+
const el = document.createElement("div");
50+
el.setAttribute("data-hf-id", " ");
51+
expect(readHfId(el)).toBeUndefined();
52+
});
53+
});
54+
3055
describe("resolveDomEditSelection — hfId from data-hf-id", () => {
3156
it("populates hfId from the element data-hf-id attribute", async () => {
3257
const el = document.createElement("div");

packages/studio/src/components/editor/domEditingLayers.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
173173
// fallow-ignore-next-line complexity
174174
export function resolveDomEditCapabilities(args: {
175175
selector?: string;
176+
hfId?: string;
176177
tagName?: string;
177178
className?: string;
178179
inlineStyles: Record<string, string>;
@@ -182,7 +183,7 @@ export function resolveDomEditCapabilities(args: {
182183
isMasterView: boolean;
183184
existsInSource?: boolean;
184185
}): DomEditCapabilities {
185-
if (!args.selector || args.isInsideLockedComposition) {
186+
if ((!args.selector && !args.hfId) || args.isInsideLockedComposition) {
186187
return {
187188
canSelect: !args.isInsideLockedComposition,
188189
canEditStyles: false,
@@ -289,7 +290,7 @@ export function buildElementLabel(el: HTMLElement): string {
289290
async function probeSourceElement(
290291
projectId: string,
291292
sourceFile: string,
292-
target: { id?: string; selector?: string; selectorIndex?: number },
293+
target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number },
293294
): Promise<boolean> {
294295
try {
295296
const response = await fetch(
@@ -321,7 +322,8 @@ export async function resolveDomEditSelection(
321322
let current: HTMLElement | null = getSelectionCandidate(startEl, options);
322323
while (current && current !== doc.body && current !== doc.documentElement) {
323324
const selector = buildStableSelector(current);
324-
if (!selector) {
325+
const hfId = readHfId(current);
326+
if (!selector && !hfId) {
325327
current = current.parentElement;
326328
continue;
327329
}
@@ -330,13 +332,9 @@ export async function resolveDomEditSelection(
330332
current,
331333
options.activeCompositionPath,
332334
);
333-
const selectorIndex = getSelectorIndex(
334-
doc,
335-
current,
336-
selector,
337-
sourceFile,
338-
options.activeCompositionPath,
339-
);
335+
const selectorIndex = selector
336+
? getSelectorIndex(doc, current, selector, sourceFile, options.activeCompositionPath)
337+
: undefined;
340338
const compositionSrc =
341339
current.getAttribute("data-composition-src") ??
342340
current.getAttribute("data-composition-file") ??
@@ -346,15 +344,18 @@ export async function resolveDomEditSelection(
346344
const textFields = collectDomEditTextFields(current);
347345
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
348346
let existsInSource: boolean | undefined;
349-
if (!options.skipSourceProbe && options.projectId && (current.id || selector)) {
350-
const probeTarget: { id?: string; selector?: string; selectorIndex?: number } = {};
347+
if (!options.skipSourceProbe && options.projectId && (current.id || selector || hfId)) {
348+
const probeTarget: { id?: string; hfId?: string; selector?: string; selectorIndex?: number } =
349+
{};
351350
if (current.id) probeTarget.id = current.id;
351+
if (hfId) probeTarget.hfId = hfId;
352352
if (selector) probeTarget.selector = selector;
353353
if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
354354
existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
355355
}
356356
const capabilities = resolveDomEditCapabilities({
357357
selector,
358+
hfId,
358359
tagName: current.tagName.toLowerCase(),
359360
className: current.className,
360361
inlineStyles,
@@ -369,7 +370,7 @@ export async function resolveDomEditSelection(
369370
return {
370371
element: current,
371372
id: current.id || undefined,
372-
hfId: current.getAttribute("data-hf-id") ?? undefined,
373+
hfId,
373374
selector,
374375
selectorIndex,
375376
sourceFile,
@@ -452,6 +453,7 @@ export function collectDomEditLayerItems(
452453
if (!root) return [];
453454

454455
const items: DomEditLayerItem[] = [];
456+
// fallow-ignore-next-line complexity
455457
const visit = (el: HTMLElement, depth: number) => {
456458
if (items.length >= maxItems) return;
457459

@@ -465,6 +467,7 @@ export function collectDomEditLayerItems(
465467
depth,
466468
childCount: getDirectLayerChildren(el, options).length,
467469
id: target.id ?? undefined,
470+
hfId: target.hfId ?? undefined,
468471
selector: target.selector ?? undefined,
469472
selectorIndex: target.selectorIndex,
470473
sourceFile: target.sourceFile,
@@ -536,10 +539,11 @@ export function getDomEditNonEditableReason(
536539
}
537540

538541
export function getDomEditTargetKey(
539-
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
542+
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
540543
): string {
541544
return [
542545
selection.sourceFile || "index.html",
546+
selection.hfId ?? "",
543547
selection.id ?? "",
544548
selection.selector ?? "",
545549
selection.selectorIndex ?? "",
@@ -556,6 +560,10 @@ export function isTextEditableSelection(selection: DomEditSelection): boolean {
556560

557561
// buildElementAgentPrompt is in domEditingAgentPrompt.ts
558562

563+
export function readHfId(element: Element): string | undefined {
564+
return element.getAttribute("data-hf-id")?.trim() || undefined;
565+
}
566+
559567
export function buildDomEditPatchTarget(
560568
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex">,
561569
): { id?: string | null; hfId?: string; selector?: string; selectorIndex?: number } {

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
88
import {
99
buildDomEditPatchTarget,
1010
getDomEditTargetKey,
11+
readHfId,
1112
type DomEditSelection,
1213
} from "../components/editor/domEditing";
1314
import {
@@ -533,7 +534,7 @@ export function useDomEditCommits({
533534
}>,
534535
) => {
535536
if (entries.length === 0) return;
536-
const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? "el").join(":")}`;
537+
const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
537538
for (let i = 0; i < entries.length; i++) {
538539
const entry = entries[i];
539540
entry.element.style.zIndex = String(entry.zIndex);
@@ -553,7 +554,7 @@ export function useDomEditCommits({
553554
{
554555
element: entry.element,
555556
id: entry.id ?? null,
556-
hfId: entry.element.getAttribute("data-hf-id") ?? undefined,
557+
hfId: readHfId(entry.element),
557558
selector: entry.selector,
558559
selectorIndex: entry.selectorIndex,
559560
sourceFile: entry.sourceFile,

packages/studio/src/player/lib/timelineDOM.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,21 @@ import {
2626

2727
// Re-export helpers that were previously public from this module so that
2828
// existing import sites (hook + tests) don't need to change.
29+
// fallow-ignore-next-line unused-exports
2930
export {
3031
readTimelineDurationFromDocument,
32+
// fallow-ignore-next-line unused-exports
3133
resolveMediaElement,
34+
// fallow-ignore-next-line unused-exports
3235
applyMediaMetadataFromElement,
3336
getTimelineElementSelector,
37+
// fallow-ignore-next-line unused-exports
3438
getTimelineElementSourceFile,
39+
// fallow-ignore-next-line unused-exports
3540
getTimelineElementSelectorIndex,
41+
// fallow-ignore-next-line unused-exports
3642
buildTimelineElementIdentity,
43+
// fallow-ignore-next-line unused-exports
3744
getTimelineElementIdentity,
3845
findTimelineDomNodeForClip,
3946
} from "./timelineElementHelpers";
@@ -75,7 +82,7 @@ export function createTimelineElementFromManifestClip(params: {
7582
let hfId: string | undefined;
7683
if (hostEl) {
7784
domId = hostEl.id || undefined;
78-
hfId = hostEl.getAttribute("data-hf-id") ?? undefined;
85+
hfId = hostEl.getAttribute("data-hf-id") || undefined;
7986
selector = getTimelineElementSelector(hostEl);
8087
selectorIndex =
8188
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
@@ -130,7 +137,7 @@ export function createTimelineElementFromManifestClip(params: {
130137
}
131138
if (hostEl) {
132139
entry.domId = hostEl.id || undefined;
133-
entry.hfId = hostEl.getAttribute("data-hf-id") ?? undefined;
140+
entry.hfId = hostEl.getAttribute("data-hf-id") || undefined;
134141
entry.selector = getTimelineElementSelector(hostEl);
135142
entry.selectorIndex =
136143
doc && entry.selector
@@ -191,7 +198,7 @@ export function createImplicitTimelineLayersFromDOM(
191198

192199
layers.push({
193200
domId: child.id || undefined,
194-
hfId: child.getAttribute("data-hf-id") ?? undefined,
201+
hfId: child.getAttribute("data-hf-id") || undefined,
195202
duration: rootDuration,
196203
id: identity.id,
197204
key: identity.key,
@@ -267,7 +274,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
267274
duration: dur,
268275
track: isNaN(track) ? 0 : track,
269276
domId: el.id || undefined,
270-
hfId: el.getAttribute("data-hf-id") ?? undefined,
277+
hfId: el.getAttribute("data-hf-id") || undefined,
271278
selector,
272279
selectorIndex,
273280
sourceFile,

packages/studio/src/utils/sourcePatcher.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export interface PatchOperation {
9292
value: string | null;
9393
}
9494

95+
// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
96+
// escape). This type is documentation only; the server's MutationTarget mirrors this shape.
9597
export interface PatchTarget {
9698
id?: string | null;
9799
hfId?: string;

0 commit comments

Comments
 (0)