Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .filesize-allowlist
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ packages/studio/src/utils/sourcePatcher.test.ts
packages/studio/src/App.tsx
packages/studio/src/player/components/Timeline.tsx
packages/studio/src/player/components/timelineEditing.test.ts
packages/studio/src/components/editor/domEditing.test.ts
184 changes: 183 additions & 1 deletion packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import { createStudioManualEditsRenderBodyScript } from "./manualEditsRenderScript";
import {
createStudioManualEditsRenderBodyScript,
createStudioPositionSeekReapplyScript,
} from "./manualEditsRenderScript";

function runScript(
window: Window,
Expand Down Expand Up @@ -380,3 +383,182 @@ describe("createStudioManualEditsRenderBodyScript", () => {
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
});
});

describe("createStudioPositionSeekReapplyScript", () => {
function runPositionScript(
window: Window,
timers: {
setInterval?: typeof globalThis.setInterval;
clearInterval?: typeof globalThis.clearInterval;
} = {},
): void {
Object.assign(window, { SyntaxError });
const script = createStudioPositionSeekReapplyScript();
const execute = new Function(
"window",
"document",
"HTMLElement",
"DOMMatrix",
"setInterval",
"clearInterval",
script,
);
execute(
window,
window.document,
window.HTMLElement,
globalThis.DOMMatrix,
timers.setInterval ??
(((callback: TimerHandler) => {
void callback;
return 0 as never;
}) as typeof globalThis.setInterval),
timers.clearInterval ?? globalThis.clearInterval,
);
}

it("reapplies box-size after seek", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-box-size="true"
style="--hf-studio-width: 200px; --hf-studio-height: 100px; width: 200px; height: 100px">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;

const originalSeek = () => {
card.style.removeProperty("width");
card.style.removeProperty("height");
};
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };

runPositionScript(window);
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
wrappedSeek(1);

expect(card.style.getPropertyValue("width")).toBe("200px");
expect(card.style.getPropertyValue("height")).toBe("100px");
});

it("strips GSAP translate from transform after reapplying path offset", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-path-offset="true"
data-hf-studio-original-translate=""
style="--hf-studio-offset-x: 50px; --hf-studio-offset-y: 30px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;

const originalSeek = () => {
card.style.setProperty("transform", "matrix(1, 0, 0, 1, 120, 60)");
};
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };

runPositionScript(window);
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
wrappedSeek(1);

expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
const transform = card.style.getPropertyValue("transform");
if (transform && transform !== "none") {
const m = new DOMMatrix(transform);
expect(m.m41).toBe(0);
expect(m.m42).toBe(0);
}
});

it("preserves non-translate components when stripping GSAP transform", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-path-offset="true"
data-hf-studio-original-translate=""
style="--hf-studio-offset-x: 10px; --hf-studio-offset-y: 20px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;

const originalSeek = () => {
card.style.setProperty("transform", "matrix(0.5, 0, 0, 0.5, 80, 40)");
};
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };

runPositionScript(window);
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
wrappedSeek(1);

const transform = card.style.getPropertyValue("transform");
expect(transform).toBeTruthy();
expect(transform).not.toContain("80");
expect(transform).not.toContain("40");
});

it("removes transform entirely when it becomes identity after stripping translate", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-path-offset="true"
data-hf-studio-original-translate=""
style="--hf-studio-offset-x: 10px; --hf-studio-offset-y: 20px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;

const originalSeek = () => {
card.style.setProperty("transform", "matrix(1, 0, 0, 1, 50, 25)");
};
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };

runPositionScript(window);
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
wrappedSeek(1);

const transform = card.style.getPropertyValue("transform");
expect(!transform || transform === "none" || transform === "").toBe(true);
});

it("no-ops when transform is 'none'", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-path-offset="true"
data-hf-studio-original-translate=""
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">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;

(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: () => {} };
runPositionScript(window);

expect(card.style.getPropertyValue("transform")).toBe("none");
});

it("strips GSAP translate for rotation-only elements", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-rotation="true"
data-hf-studio-original-rotate=""
style="--hf-studio-rotation: 45deg; rotate: var(--hf-studio-rotation, 0deg)">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;

const originalSeek = () => {
card.style.setProperty("transform", "matrix(1, 0, 0, 1, 100, 50)");
};
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };

runPositionScript(window);
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
wrappedSeek(1);

expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
const transform = card.style.getPropertyValue("transform");
expect(!transform || transform === "none" || transform === "").toBe(true);
});
});
36 changes: 36 additions & 0 deletions packages/core/src/studio-api/helpers/manualEditsRenderScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ export function createStudioPositionSeekReapplyScript(): string {
function studioPositionSeekReapplyRuntime(): void {
const OFFSET_X_PROP = "--hf-studio-offset-x";
const OFFSET_Y_PROP = "--hf-studio-offset-y";
const WIDTH_PROP = "--hf-studio-width";
const HEIGHT_PROP = "--hf-studio-height";
const ROTATION_PROP = "--hf-studio-rotation";
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
const BOX_SIZE_ATTR = "data-hf-studio-box-size";
const ROTATION_ATTR = "data-hf-studio-rotation";
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
Expand All @@ -36,6 +39,7 @@ function studioPositionSeekReapplyRuntime(): void {

if (
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
!document.querySelector("[" + BOX_SIZE_ATTR + '="true"]') &&
!document.querySelector("[" + ROTATION_ATTR + '="true"]') &&
!document.querySelector("[" + MOTION_ATTR + "]")
)
Expand Down Expand Up @@ -191,6 +195,27 @@ function studioPositionSeekReapplyRuntime(): void {
(tl.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
};

const stripGsapTranslateFromTransform = (el: HTMLElement): void => {
const transform = el.style.getPropertyValue("transform");
if (!transform || transform === "none") return;
const win = el.ownerDocument.defaultView as (Window & typeof globalThis) | null;
const MatrixCtor = (win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrix;
if (!MatrixCtor) return;
try {
const m = new MatrixCtor(transform);
if (m.m41 === 0 && m.m42 === 0) return;
m.m41 = 0;
m.m42 = 0;
if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) {
el.style.removeProperty("transform");
} else {
el.style.setProperty("transform", m.toString());
}
} catch {
/* non-parseable transform — leave as-is */
}
};

const reapplyAll = (): void => {
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
for (let i = 0; i < offsetEls.length; i++) {
Expand All @@ -207,15 +232,26 @@ function studioPositionSeekReapplyRuntime(): void {
"var(" + OFFSET_Y_PROP + ", 0px)",
),
);
stripGsapTranslateFromTransform(el);
}
}
const boxSizeEls = document.querySelectorAll("[" + BOX_SIZE_ATTR + '="true"]');
for (let i = 0; i < boxSizeEls.length; i++) {
const el = boxSizeEls[i] as HTMLElement;
if (!(el instanceof HTMLElement)) continue;
const w = el.style.getPropertyValue(WIDTH_PROP);
const h = el.style.getPropertyValue(HEIGHT_PROP);
if (w) el.style.setProperty("width", w);
if (h) el.style.setProperty("height", h);
}
const rotEls = document.querySelectorAll("[" + ROTATION_ATTR + '="true"]');
for (let i = 0; i < rotEls.length; i++) {
const el = rotEls[i] as HTMLElement;
if (!(el instanceof HTMLElement)) continue;
const rot = el.style.getPropertyValue(ROTATION_PROP);
if (rot) {
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
stripGsapTranslateFromTransform(el);
}
}
reapplyMotionTimeline();
Expand Down
1 change: 1 addition & 0 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,7 @@ export async function compileForRender(
// positions survive frame-by-frame rendering without a JSON sidecar.
const HF_POSITION_ATTRS = [
'data-hf-studio-path-offset="true"',
'data-hf-studio-box-size="true"',
'data-hf-studio-rotation="true"',
'data-hf-studio-motion="',
];
Expand Down
23 changes: 23 additions & 0 deletions packages/studio/src/components/editor/domEditing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,29 @@ describe("resolveVisualDomEditSelectionTarget", () => {
expect(visualTarget).toBe(headline);
expect(explicitSelection?.id).toBe("container");
});

it("prefers the visually-on-top sibling over a deeper element in a separate visual layer", () => {
const document = createDocument(`
<div id="comp-root">
<div id="sub-comp" class="sub-comp">
<img id="sf-chrome" class="sf-chrome" style="width:100%;height:100%" />
</div>
<video id="pip-studio" class="pip-studio" style="position:absolute;z-index:15" />
</div>
`);
const pipStudio = document.getElementById("pip-studio") as HTMLElement;
const sfChrome = document.getElementById("sf-chrome") as HTMLElement;
const subComp = document.getElementById("sub-comp") as HTMLElement;
setElementRect(pipStudio, { left: 50, top: 50, width: 320, height: 320 });
setElementRect(sfChrome, { left: 0, top: 0, width: 1920, height: 1080 });
setElementRect(subComp, { left: 0, top: 0, width: 1920, height: 1080 });

expect(
resolveVisualDomEditSelectionTarget([pipStudio, subComp, sfChrome], {
activeCompositionPath: "index.html",
}),
).toBe(pipStudio);
});
});

describe("isLargeRasterDomEditSelection", () => {
Expand Down
49 changes: 18 additions & 31 deletions packages/studio/src/components/editor/domEditingElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,9 @@ import type {
import {
buildStableSelector,
escapeCssString,
getElementDepth,
getSelectorIndex,
getSourceFileForElement,
isHtmlElement,
isTextBearingTag,
normalizeTimelineCompositionSource,
querySelectorAllSafely,
} from "./domEditingDom";
Expand Down Expand Up @@ -68,23 +66,6 @@ function hasRenderedBox(el: HTMLElement): boolean {

// ─── Visual scoring ──────────────────────────────────────────────────────────

function isEditableTextLeafForScoring(el: HTMLElement): boolean {
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
}

function getVisualElementScore(el: HTMLElement, pointerStackIndex: number): number {
const tagName = el.tagName.toLowerCase();
const rect = el.getBoundingClientRect();
const area = Math.max(1, rect.width * rect.height);
const smallerElementBonus = Math.max(0, 1_000_000 - Math.min(area, 1_000_000)) / 1_000;
const visualLeafBonus =
isEditableTextLeafForScoring(el) || ["img", "video", "canvas", "svg"].includes(tagName)
? 2_000
: 0;

return getElementDepth(el) * 10_000 + visualLeafBonus + smallerElementBonus - pointerStackIndex;
}

// ─── Layer patch target ──────────────────────────────────────────────────────

const DOM_LAYER_IGNORED_TAGS = new Set([
Expand Down Expand Up @@ -172,25 +153,31 @@ export function resolveVisualDomEditSelectionTarget(
elementsFromPoint: Iterable<Element | null | undefined>,
options: Pick<DomEditContextOptions, "activeCompositionPath">,
): HTMLElement | null {
let best: { element: HTMLElement; score: number } | null = null;
let pointerStackIndex = 0;
const candidates: HTMLElement[] = [];

for (const entry of elementsFromPoint) {
if (!isHtmlElement(entry)) {
pointerStackIndex += 1;
continue;
if (!isHtmlElement(entry)) continue;
if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
candidates.push(entry);
}
}

if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
const score = getVisualElementScore(entry, pointerStackIndex);
if (!best || score > best.score) {
best = { element: entry, score };
}
if (candidates.length === 0) return null;

// candidates are in visual stacking order (topmost first, from elementsFromPoint).
// Start with the topmost and only replace with a descendant that is more
// specific within the same visual subtree. Never jump to an unrelated
// element that happens to be painted behind the current pick.
let best = candidates[0];

for (let i = 1; i < candidates.length; i++) {
const candidate = candidates[i];
if (best.contains(candidate)) {
best = candidate;
}
pointerStackIndex += 1;
}

return best?.element ?? null;
return best;
}

// ─── Raster detection ────────────────────────────────────────────────────────
Expand Down
Loading
Loading