Skip to content

Commit f7d63fb

Browse files
committed
fix(studio): address PR review — add tests, strip GSAP transform for rotation, remove dead exports
Addresses review feedback from Rames and Vai: 1. Add 7 new tests for createStudioPositionSeekReapplyScript: box-size reapplication, GSAP translate stripping (identity removal, scale+translate preservation, transform:none no-op), and rotation- only elements with GSAP-baked translate. 2. Add pinning test for the PiP-over-sub-composition selection bug: elementsFromPoint returns [pipVideo, subCompRoot, sfChromeImg] as siblings — assert the topmost (pipVideo) wins. 3. Apply stripGsapTranslateFromTransform to rotation-only elements too, not just path-offset elements. A rotation-only element with a GSAP-animated translate would have its position clobbered. 4. Remove dead exports: getPreviewLocalPointer, buildRasterClickSelectionContext, getPreviewPlayer, seekStudioPreview, PreviewPlayerCompat, PreviewLocalPointer from studioPreviewHelpers.ts. Unexport resolvePreviewLocalPointer.
1 parent 6498807 commit f7d63fb

5 files changed

Lines changed: 211 additions & 70 deletions

File tree

.filesize-allowlist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ packages/studio/src/utils/sourcePatcher.test.ts
99
packages/studio/src/App.tsx
1010
packages/studio/src/player/components/Timeline.tsx
1111
packages/studio/src/player/components/timelineEditing.test.ts
12+
packages/studio/src/components/editor/domEditing.test.ts

packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, expect, it } from "vitest";
22
import { Window } from "happy-dom";
3-
import { createStudioManualEditsRenderBodyScript } from "./manualEditsRenderScript";
3+
import {
4+
createStudioManualEditsRenderBodyScript,
5+
createStudioPositionSeekReapplyScript,
6+
} from "./manualEditsRenderScript";
47

58
function runScript(
69
window: Window,
@@ -380,3 +383,182 @@ describe("createStudioManualEditsRenderBodyScript", () => {
380383
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
381384
});
382385
});
386+
387+
describe("createStudioPositionSeekReapplyScript", () => {
388+
function runPositionScript(
389+
window: Window,
390+
timers: {
391+
setInterval?: typeof globalThis.setInterval;
392+
clearInterval?: typeof globalThis.clearInterval;
393+
} = {},
394+
): void {
395+
Object.assign(window, { SyntaxError });
396+
const script = createStudioPositionSeekReapplyScript();
397+
const execute = new Function(
398+
"window",
399+
"document",
400+
"HTMLElement",
401+
"DOMMatrix",
402+
"setInterval",
403+
"clearInterval",
404+
script,
405+
);
406+
execute(
407+
window,
408+
window.document,
409+
window.HTMLElement,
410+
globalThis.DOMMatrix,
411+
timers.setInterval ??
412+
(((callback: TimerHandler) => {
413+
void callback;
414+
return 0 as never;
415+
}) as typeof globalThis.setInterval),
416+
timers.clearInterval ?? globalThis.clearInterval,
417+
);
418+
}
419+
420+
it("reapplies box-size after seek", () => {
421+
const window = new Window();
422+
window.document.body.innerHTML = `
423+
<div id="card"
424+
data-hf-studio-box-size="true"
425+
style="--hf-studio-width: 200px; --hf-studio-height: 100px; width: 200px; height: 100px">
426+
</div>
427+
`;
428+
const card = window.document.getElementById("card") as unknown as HTMLElement;
429+
430+
const originalSeek = () => {
431+
card.style.removeProperty("width");
432+
card.style.removeProperty("height");
433+
};
434+
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };
435+
436+
runPositionScript(window);
437+
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
438+
wrappedSeek(1);
439+
440+
expect(card.style.getPropertyValue("width")).toBe("200px");
441+
expect(card.style.getPropertyValue("height")).toBe("100px");
442+
});
443+
444+
it("strips GSAP translate from transform after reapplying path offset", () => {
445+
const window = new Window();
446+
window.document.body.innerHTML = `
447+
<div id="card"
448+
data-hf-studio-path-offset="true"
449+
data-hf-studio-original-translate=""
450+
style="--hf-studio-offset-x: 50px; --hf-studio-offset-y: 30px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)">
451+
</div>
452+
`;
453+
const card = window.document.getElementById("card") as unknown as HTMLElement;
454+
455+
const originalSeek = () => {
456+
card.style.setProperty("transform", "matrix(1, 0, 0, 1, 120, 60)");
457+
};
458+
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };
459+
460+
runPositionScript(window);
461+
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
462+
wrappedSeek(1);
463+
464+
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
465+
const transform = card.style.getPropertyValue("transform");
466+
if (transform && transform !== "none") {
467+
const m = new DOMMatrix(transform);
468+
expect(m.m41).toBe(0);
469+
expect(m.m42).toBe(0);
470+
}
471+
});
472+
473+
it("preserves non-translate components when stripping GSAP transform", () => {
474+
const window = new Window();
475+
window.document.body.innerHTML = `
476+
<div id="card"
477+
data-hf-studio-path-offset="true"
478+
data-hf-studio-original-translate=""
479+
style="--hf-studio-offset-x: 10px; --hf-studio-offset-y: 20px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)">
480+
</div>
481+
`;
482+
const card = window.document.getElementById("card") as unknown as HTMLElement;
483+
484+
const originalSeek = () => {
485+
card.style.setProperty("transform", "matrix(0.5, 0, 0, 0.5, 80, 40)");
486+
};
487+
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };
488+
489+
runPositionScript(window);
490+
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
491+
wrappedSeek(1);
492+
493+
const transform = card.style.getPropertyValue("transform");
494+
expect(transform).toBeTruthy();
495+
expect(transform).not.toContain("80");
496+
expect(transform).not.toContain("40");
497+
});
498+
499+
it("removes transform entirely when it becomes identity after stripping translate", () => {
500+
const window = new Window();
501+
window.document.body.innerHTML = `
502+
<div id="card"
503+
data-hf-studio-path-offset="true"
504+
data-hf-studio-original-translate=""
505+
style="--hf-studio-offset-x: 10px; --hf-studio-offset-y: 20px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)">
506+
</div>
507+
`;
508+
const card = window.document.getElementById("card") as unknown as HTMLElement;
509+
510+
const originalSeek = () => {
511+
card.style.setProperty("transform", "matrix(1, 0, 0, 1, 50, 25)");
512+
};
513+
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };
514+
515+
runPositionScript(window);
516+
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
517+
wrappedSeek(1);
518+
519+
const transform = card.style.getPropertyValue("transform");
520+
expect(!transform || transform === "none" || transform === "").toBe(true);
521+
});
522+
523+
it("no-ops when transform is 'none'", () => {
524+
const window = new Window();
525+
window.document.body.innerHTML = `
526+
<div id="card"
527+
data-hf-studio-path-offset="true"
528+
data-hf-studio-original-translate=""
529+
style="--hf-studio-offset-x: 10px; --hf-studio-offset-y: 20px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px); transform: none">
530+
</div>
531+
`;
532+
const card = window.document.getElementById("card") as unknown as HTMLElement;
533+
534+
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: () => {} };
535+
runPositionScript(window);
536+
537+
expect(card.style.getPropertyValue("transform")).toBe("none");
538+
});
539+
540+
it("strips GSAP translate for rotation-only elements", () => {
541+
const window = new Window();
542+
window.document.body.innerHTML = `
543+
<div id="card"
544+
data-hf-studio-rotation="true"
545+
data-hf-studio-original-rotate=""
546+
style="--hf-studio-rotation: 45deg; rotate: var(--hf-studio-rotation, 0deg)">
547+
</div>
548+
`;
549+
const card = window.document.getElementById("card") as unknown as HTMLElement;
550+
551+
const originalSeek = () => {
552+
card.style.setProperty("transform", "matrix(1, 0, 0, 1, 100, 50)");
553+
};
554+
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };
555+
556+
runPositionScript(window);
557+
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
558+
wrappedSeek(1);
559+
560+
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
561+
const transform = card.style.getPropertyValue("transform");
562+
expect(!transform || transform === "none" || transform === "").toBe(true);
563+
});
564+
});

packages/core/src/studio-api/helpers/manualEditsRenderScript.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ function studioPositionSeekReapplyRuntime(): void {
251251
const rot = el.style.getPropertyValue(ROTATION_PROP);
252252
if (rot) {
253253
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
254+
stripGsapTranslateFromTransform(el);
254255
}
255256
}
256257
reapplyMotionTimeline();

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,29 @@ describe("resolveVisualDomEditSelectionTarget", () => {
321321
expect(visualTarget).toBe(headline);
322322
expect(explicitSelection?.id).toBe("container");
323323
});
324+
325+
it("prefers the visually-on-top sibling over a deeper element in a separate visual layer", () => {
326+
const document = createDocument(`
327+
<div id="comp-root">
328+
<div id="sub-comp" class="sub-comp">
329+
<img id="sf-chrome" class="sf-chrome" style="width:100%;height:100%" />
330+
</div>
331+
<video id="pip-studio" class="pip-studio" style="position:absolute;z-index:15" />
332+
</div>
333+
`);
334+
const pipStudio = document.getElementById("pip-studio") as HTMLElement;
335+
const sfChrome = document.getElementById("sf-chrome") as HTMLElement;
336+
const subComp = document.getElementById("sub-comp") as HTMLElement;
337+
setElementRect(pipStudio, { left: 50, top: 50, width: 320, height: 320 });
338+
setElementRect(sfChrome, { left: 0, top: 0, width: 1920, height: 1080 });
339+
setElementRect(subComp, { left: 0, top: 0, width: 1920, height: 1080 });
340+
341+
expect(
342+
resolveVisualDomEditSelectionTarget([pipStudio, subComp, sfChrome], {
343+
activeCompositionPath: "index.html",
344+
}),
345+
).toBe(pipStudio);
346+
});
324347
});
325348

326349
describe("isLargeRasterDomEditSelection", () => {

packages/studio/src/utils/studioPreviewHelpers.ts

Lines changed: 3 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,18 @@
1-
import type { DomEditViewport, DomEditSelection } from "../components/editor/domEditing";
1+
import type { DomEditViewport } from "../components/editor/domEditing";
22
import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing";
33
import {
44
getDomLayerPatchTarget,
55
isElementComputedVisible,
66
} from "../components/editor/domEditingElement";
7-
import { usePlayerStore, liveTime } from "../player";
87
import { getEventTargetElement } from "./studioHelpers";
98

10-
export interface PreviewLocalPointer {
9+
interface PreviewLocalPointer {
1110
x: number;
1211
y: number;
1312
viewport: DomEditViewport;
1413
}
1514

16-
export interface PreviewPlayerCompat {
17-
getTime: () => number;
18-
renderSeek: (timeSeconds: number) => void;
19-
}
20-
21-
export function resolvePreviewLocalPointer(
15+
function resolvePreviewLocalPointer(
2216
iframe: HTMLIFrameElement,
2317
doc: Document,
2418
win: Window,
@@ -42,24 +36,6 @@ export function resolvePreviewLocalPointer(
4236
};
4337
}
4438

45-
export function getPreviewLocalPointer(
46-
iframe: HTMLIFrameElement,
47-
clientX: number,
48-
clientY: number,
49-
): PreviewLocalPointer | null {
50-
let doc: Document | null = null;
51-
let win: Window | null = null;
52-
try {
53-
doc = iframe.contentDocument;
54-
win = iframe.contentWindow;
55-
} catch {
56-
return null;
57-
}
58-
if (!doc || !win) return null;
59-
60-
return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
61-
}
62-
6339
const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
6440

6541
function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
@@ -122,21 +98,6 @@ export function getPreviewTargetFromPointer(
12298
}
12399
}
124100

125-
export function buildRasterClickSelectionContext(
126-
selection: DomEditSelection,
127-
localPointer: PreviewLocalPointer,
128-
): string {
129-
return [
130-
"The user clicked a large raster/background element in the Studio preview.",
131-
`Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
132-
localPointer.viewport.width,
133-
)}x${Math.round(localPointer.viewport.height)} composition.`,
134-
`Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
135-
"Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
136-
"If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
137-
].join("\n");
138-
}
139-
140101
function objectLike(value: unknown): object | null {
141102
return value && (typeof value === "object" || typeof value === "function") ? value : null;
142103
}
@@ -162,33 +123,6 @@ function readPlaybackTime(target: object | null, key: string): number | null {
162123
}
163124
}
164125

165-
export function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null {
166-
const player = objectLike(win ? Reflect.get(win, "__player") : null);
167-
if (!player) return null;
168-
const getTime = Reflect.get(player, "getTime");
169-
const renderSeek = Reflect.get(player, "renderSeek");
170-
if (typeof getTime !== "function" || typeof renderSeek !== "function") return null;
171-
return {
172-
getTime: () => {
173-
const value = getTime.call(player);
174-
return typeof value === "number" && Number.isFinite(value) ? value : 0;
175-
},
176-
renderSeek: (timeSeconds: number) => {
177-
renderSeek.call(player, timeSeconds);
178-
},
179-
};
180-
}
181-
182-
export function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean {
183-
const player = getPreviewPlayer(iframe?.contentWindow);
184-
if (!player) return false;
185-
const nextTime = Math.max(0, timeSeconds);
186-
player.renderSeek(nextTime);
187-
usePlayerStore.getState().setCurrentTime(nextTime);
188-
liveTime.notify(nextTime);
189-
return true;
190-
}
191-
192126
export function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
193127
const win = iframe?.contentWindow;
194128
if (!win) return null;

0 commit comments

Comments
 (0)