diff --git a/.filesize-allowlist b/.filesize-allowlist index 94f488521..b0cfc09e1 100644 --- a/.filesize-allowlist +++ b/.filesize-allowlist @@ -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 diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts index 3e41a15f2..f052a6d2c 100644 --- a/packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts @@ -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, @@ -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 = ` +
+
+ `; + 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 }).__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 = ` +
+
+ `; + 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 }).__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 = ` +
+
+ `; + 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 }).__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 = ` +
+
+ `; + 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 }).__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 = ` +
+
+ `; + const card = window.document.getElementById("card") as unknown as HTMLElement; + + (window as unknown as { __hf: Record }).__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 = ` +
+
+ `; + 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 }).__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); + }); +}); diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts index 976f49df0..f5b57f9f1 100644 --- a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts @@ -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"; @@ -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 + "]") ) @@ -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++) { @@ -207,8 +232,18 @@ 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; @@ -216,6 +251,7 @@ function studioPositionSeekReapplyRuntime(): void { const rot = el.style.getPropertyValue(ROTATION_PROP); if (rot) { el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)")); + stripGsapTranslateFromTransform(el); } } reapplyMotionTimeline(); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 4b9362404..5894a3e58 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -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="', ]; diff --git a/packages/studio/src/components/editor/domEditing.test.ts b/packages/studio/src/components/editor/domEditing.test.ts index a15d31083..6368e0fde 100644 --- a/packages/studio/src/components/editor/domEditing.test.ts +++ b/packages/studio/src/components/editor/domEditing.test.ts @@ -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(` +
+
+ +
+
+ `); + 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", () => { diff --git a/packages/studio/src/components/editor/domEditingElement.ts b/packages/studio/src/components/editor/domEditingElement.ts index 4a339c859..7d986234a 100644 --- a/packages/studio/src/components/editor/domEditingElement.ts +++ b/packages/studio/src/components/editor/domEditingElement.ts @@ -12,11 +12,9 @@ import type { import { buildStableSelector, escapeCssString, - getElementDepth, getSelectorIndex, getSourceFileForElement, isHtmlElement, - isTextBearingTag, normalizeTimelineCompositionSource, querySelectorAllSafely, } from "./domEditingDom"; @@ -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([ @@ -172,25 +153,31 @@ export function resolveVisualDomEditSelectionTarget( elementsFromPoint: Iterable, options: Pick, ): 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 ──────────────────────────────────────────────────────── diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index ba0260e06..d33508440 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -151,7 +151,6 @@ export function useDomEditSession({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, - preloadAgentPromptSnippet, handleAskAgent, handleAgentModalSubmit, } = useAskAgentModal({ @@ -181,10 +180,6 @@ export function useDomEditSession({ applyDomSelection, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection, - preloadAgentPromptSnippet, - setAgentPromptSelectionContext, - setAgentModalAnchorPoint, - setAgentModalOpen, onClickToSource, }); diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index 289acc1e9..0da7e6645 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.ts @@ -1,16 +1,8 @@ import { useCallback } from "react"; import { liveTime, usePlayerStore } from "../player"; -import { - getPreviewLocalPointer, - buildRasterClickSelectionContext, - pauseStudioPreviewPlayback, -} from "../utils/studioPreviewHelpers"; +import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers"; import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability"; -import { - isLargeRasterDomEditSelection, - type DomEditSelection, -} from "../components/editor/domEditing"; -import type { AgentModalAnchorPoint } from "../utils/studioHelpers"; +import { type DomEditSelection } from "../components/editor/domEditing"; // ── Types ── @@ -32,12 +24,6 @@ export interface UsePreviewInteractionParams { ) => DomEditSelection | null; updateDomEditHoverSelection: (selection: DomEditSelection | null) => void; - // From useAskAgentModal - preloadAgentPromptSnippet: (selection: DomEditSelection) => Promise; - setAgentPromptSelectionContext: (context: string | undefined) => void; - setAgentModalAnchorPoint: (point: AgentModalAnchorPoint | null) => void; - setAgentModalOpen: (open: boolean) => void; - onClickToSource?: (selection: DomEditSelection) => void; } @@ -51,10 +37,6 @@ export function usePreviewInteraction({ applyDomSelection, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection, - preloadAgentPromptSnippet, - setAgentPromptSelectionContext, - setAgentModalAnchorPoint, - setAgentModalOpen, onClickToSource, }: UsePreviewInteractionParams) { const handlePreviewCanvasMouseDown = useCallback( @@ -69,37 +51,17 @@ export function usePreviewInteraction({ } e.preventDefault(); e.stopPropagation(); - const localPointer = previewIframeRef.current - ? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY) - : null; applyDomSelection(nextSelection, { additive: e.shiftKey }); if (!e.shiftKey && e.altKey && onClickToSource) { onClickToSource(nextSelection); } - if ( - !e.shiftKey && - localPointer && - isLargeRasterDomEditSelection(nextSelection, localPointer.viewport) - ) { - setAgentPromptSelectionContext( - buildRasterClickSelectionContext(nextSelection, localPointer), - ); - setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY }); - void preloadAgentPromptSnippet(nextSelection); - setAgentModalOpen(true); - } }, [ applyDomSelection, captionEditMode, compositionLoading, onClickToSource, - preloadAgentPromptSnippet, resolveDomSelectionFromPreviewPoint, - previewIframeRef, - setAgentModalAnchorPoint, - setAgentModalOpen, - setAgentPromptSelectionContext, ], ); diff --git a/packages/studio/src/utils/studioPreviewHelpers.ts b/packages/studio/src/utils/studioPreviewHelpers.ts index 6c9485bcd..654016539 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.ts @@ -1,24 +1,18 @@ -import type { DomEditViewport, DomEditSelection } from "../components/editor/domEditing"; +import type { DomEditViewport } from "../components/editor/domEditing"; import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing"; import { getDomLayerPatchTarget, isElementComputedVisible, } from "../components/editor/domEditingElement"; -import { usePlayerStore, liveTime } from "../player"; import { getEventTargetElement } from "./studioHelpers"; -export interface PreviewLocalPointer { +interface PreviewLocalPointer { x: number; y: number; viewport: DomEditViewport; } -export interface PreviewPlayerCompat { - getTime: () => number; - renderSeek: (timeSeconds: number) => void; -} - -export function resolvePreviewLocalPointer( +function resolvePreviewLocalPointer( iframe: HTMLIFrameElement, doc: Document, win: Window, @@ -42,24 +36,6 @@ export function resolvePreviewLocalPointer( }; } -export function getPreviewLocalPointer( - iframe: HTMLIFrameElement, - clientX: number, - clientY: number, -): PreviewLocalPointer | null { - let doc: Document | null = null; - let win: Window | null = null; - try { - doc = iframe.contentDocument; - win = iframe.contentWindow; - } catch { - return null; - } - if (!doc || !win) return null; - - return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY); -} - const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__"; function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null { @@ -122,21 +98,6 @@ export function getPreviewTargetFromPointer( } } -export function buildRasterClickSelectionContext( - selection: DomEditSelection, - localPointer: PreviewLocalPointer, -): string { - return [ - "The user clicked a large raster/background element in the Studio preview.", - `Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round( - localPointer.viewport.width, - )}x${Math.round(localPointer.viewport.height)} composition.`, - `Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`, - "Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.", - "If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.", - ].join("\n"); -} - function objectLike(value: unknown): object | null { return value && (typeof value === "object" || typeof value === "function") ? value : null; } @@ -162,33 +123,6 @@ function readPlaybackTime(target: object | null, key: string): number | null { } } -export function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null { - const player = objectLike(win ? Reflect.get(win, "__player") : null); - if (!player) return null; - const getTime = Reflect.get(player, "getTime"); - const renderSeek = Reflect.get(player, "renderSeek"); - if (typeof getTime !== "function" || typeof renderSeek !== "function") return null; - return { - getTime: () => { - const value = getTime.call(player); - return typeof value === "number" && Number.isFinite(value) ? value : 0; - }, - renderSeek: (timeSeconds: number) => { - renderSeek.call(player, timeSeconds); - }, - }; -} - -export function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean { - const player = getPreviewPlayer(iframe?.contentWindow); - if (!player) return false; - const nextTime = Math.max(0, timeSeconds); - player.renderSeek(nextTime); - usePlayerStore.getState().setCurrentTime(nextTime); - liveTime.notify(nextTime); - return true; -} - export function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null { const win = iframe?.contentWindow; if (!win) return null;