diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index 69f38dc4b7..408b3b7ae9 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { focusDomEditOverlayElement, + resolveDomEditCoordinateScale, + resolveDomEditPathOffsetGesture, resolveDomEditResizeGesture, resolveDomEditRotationGesture, } from "./DomEditOverlay"; @@ -16,6 +18,54 @@ describe("focusDomEditOverlayElement", () => { }); }); +describe("resolveDomEditCoordinateScale", () => { + it("uses the top-level preview scale when no source boundary dimensions are available", () => { + expect( + resolveDomEditCoordinateScale({ + rootScaleX: 0.5, + rootScaleY: 0.5, + }), + ).toEqual({ + scaleX: 0.5, + scaleY: 0.5, + }); + }); + + it("converts source-local pixels through a scaled nested composition host", () => { + expect( + resolveDomEditCoordinateScale({ + rootScaleX: 0.5, + rootScaleY: 0.5, + sourceRectWidth: 960, + sourceRectHeight: 540, + sourceWidth: 1920, + sourceHeight: 1080, + }), + ).toEqual({ + scaleX: 0.25, + scaleY: 0.25, + }); + }); +}); + +describe("resolveDomEditPathOffsetGesture", () => { + it("writes source-local offsets when the edited source is scaled down in master view", () => { + expect( + resolveDomEditPathOffsetGesture({ + actualOffsetX: 10, + actualOffsetY: -4, + scaleX: 0.25, + scaleY: 0.25, + dx: 25, + dy: 10, + }), + ).toEqual({ + x: 110, + y: 36, + }); + }); +}); + describe("resolveDomEditResizeGesture", () => { it("resizes width and height independently by default", () => { expect( @@ -77,6 +127,27 @@ describe("resolveDomEditResizeGesture", () => { height: 260, }); }); + + it("writes source-local dimensions when the edited source is scaled down in master view", () => { + expect( + resolveDomEditResizeGesture({ + originWidth: 100, + originHeight: 50, + actualWidth: 400, + actualHeight: 200, + scaleX: 0.25, + scaleY: 0.25, + dx: 25, + dy: 10, + uniform: false, + }), + ).toEqual({ + overlayWidth: 125, + overlayHeight: 60, + width: 500, + height: 240, + }); + }); }); describe("resolveDomEditRotationGesture", () => { diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index dd38e70bde..d32ef57224 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -30,8 +30,8 @@ interface OverlayRect { top: number; width: number; height: number; - scaleX: number; - scaleY: number; + editScaleX: number; + editScaleY: number; } interface DomEditOverlayProps { @@ -70,16 +70,73 @@ function toOverlayRect( if (!rootWidth || !rootHeight) return null; const elementRect = element.getBoundingClientRect(); - const scaleX = iframeRect.width / rootWidth; - const scaleY = iframeRect.height / rootHeight; + const rootScaleX = iframeRect.width / rootWidth; + const rootScaleY = iframeRect.height / rootHeight; + const sourceBoundary = findSourceBoundary(element); + const sourceBoundaryRect = sourceBoundary?.getBoundingClientRect(); + const editScale = resolveDomEditCoordinateScale({ + rootScaleX, + rootScaleY, + sourceRectWidth: sourceBoundaryRect?.width, + sourceRectHeight: sourceBoundaryRect?.height, + sourceWidth: readPositiveDimension(sourceBoundary?.getAttribute("data-width") ?? null), + sourceHeight: readPositiveDimension(sourceBoundary?.getAttribute("data-height") ?? null), + }); return { - left: iframeRect.left - overlayRect.left + elementRect.left * scaleX, - top: iframeRect.top - overlayRect.top + elementRect.top * scaleY, - width: elementRect.width * scaleX, - height: elementRect.height * scaleY, - scaleX, - scaleY, + left: iframeRect.left - overlayRect.left + (elementRect.left - rootRect.left) * rootScaleX, + top: iframeRect.top - overlayRect.top + (elementRect.top - rootRect.top) * rootScaleY, + width: elementRect.width * rootScaleX, + height: elementRect.height * rootScaleY, + editScaleX: editScale.scaleX, + editScaleY: editScale.scaleY, + }; +} + +function readPositiveDimension(value: string | null): number | null { + if (!value) return null; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function findSourceBoundary(element: HTMLElement): HTMLElement | null { + let current: HTMLElement | null = element; + while (current) { + if ( + current.hasAttribute("data-composition-file") || + current.hasAttribute("data-composition-src") + ) { + return current; + } + current = current.parentElement; + } + return null; +} + +export function resolveDomEditCoordinateScale(input: { + rootScaleX: number; + rootScaleY: number; + sourceRectWidth?: number; + sourceRectHeight?: number; + sourceWidth?: number | null; + sourceHeight?: number | null; +}): { scaleX: number; scaleY: number } { + const rootScaleX = input.rootScaleX > 0 ? input.rootScaleX : 1; + const rootScaleY = input.rootScaleY > 0 ? input.rootScaleY : 1; + const sourceScaleX = + input.sourceRectWidth && input.sourceRectWidth > 0 && input.sourceWidth && input.sourceWidth > 0 + ? (input.sourceRectWidth * rootScaleX) / input.sourceWidth + : rootScaleX; + const sourceScaleY = + input.sourceRectHeight && + input.sourceRectHeight > 0 && + input.sourceHeight && + input.sourceHeight > 0 + ? (input.sourceRectHeight * rootScaleY) / input.sourceHeight + : rootScaleY; + return { + scaleX: sourceScaleX > 0 ? sourceScaleX : rootScaleX, + scaleY: sourceScaleY > 0 ? sourceScaleY : rootScaleY, }; } @@ -97,8 +154,8 @@ function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean { Math.abs(a.top - b.top) < OVERLAY_RECT_EPSILON_PX && Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX && Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX && - Math.abs(a.scaleX - b.scaleX) < 0.001 && - Math.abs(a.scaleY - b.scaleY) < 0.001 + Math.abs(a.editScaleX - b.editScaleX) < 0.001 && + Math.abs(a.editScaleY - b.editScaleY) < 0.001 ); } @@ -156,6 +213,22 @@ export function resolveDomEditResizeGesture(input: { }; } +export function resolveDomEditPathOffsetGesture(input: { + actualOffsetX: number; + actualOffsetY: number; + scaleX: number; + scaleY: number; + dx: number; + dy: number; +}): { x: number; y: number } { + const scaleX = input.scaleX > 0 ? input.scaleX : 1; + const scaleY = input.scaleY > 0 ? input.scaleY : 1; + return { + x: input.actualOffsetX + input.dx / scaleX, + y: input.actualOffsetY + input.dy / scaleY, + }; +} + function pointerAngleDegrees(centerX: number, centerY: number, x: number, y: number): number { return (Math.atan2(y - centerY, x - centerX) * 180) / Math.PI; } @@ -213,8 +286,8 @@ interface GestureState { actualWidth: number; actualHeight: number; actualRotation: number; - scaleX: number; - scaleY: number; + editScaleX: number; + editScaleY: number; manualEditDragToken?: string; } @@ -345,8 +418,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const offset = readStudioPathOffset(sel.element); const size = readStudioBoxSize(sel.element); const rotation = readStudioRotation(sel.element); - const actualWidth = size.width > 0 ? size.width : rect.width / rect.scaleX; - const actualHeight = size.height > 0 ? size.height : rect.height / rect.scaleY; + const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX; + const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY; const manualEditDragToken = beginStudioManualEditGesture(sel.element); const overlayBounds = overlayEl?.getBoundingClientRect(); const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2; @@ -377,8 +450,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({ actualWidth, actualHeight, actualRotation: rotation.angle, - scaleX: rect.scaleX, - scaleY: rect.scaleY, + editScaleX: rect.editScaleX, + editScaleY: rect.editScaleY, manualEditDragToken, }; }; @@ -424,18 +497,25 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const nextBoxTop = g.originTop + dy; box.style.left = `${nextBoxLeft}px`; box.style.top = `${nextBoxTop}px`; - applyStudioPathOffsetDraft(sel.element, { - x: g.actualOffsetX + dx / g.scaleX, - y: g.actualOffsetY + dy / g.scaleY, - }); + applyStudioPathOffsetDraft( + sel.element, + resolveDomEditPathOffsetGesture({ + actualOffsetX: g.actualOffsetX, + actualOffsetY: g.actualOffsetY, + scaleX: g.editScaleX, + scaleY: g.editScaleY, + dx, + dy, + }), + ); } else { const nextSize = resolveDomEditResizeGesture({ originWidth: g.originWidth, originHeight: g.originHeight, actualWidth: g.actualWidth, actualHeight: g.actualHeight, - scaleX: g.scaleX, - scaleY: g.scaleY, + scaleX: g.editScaleX, + scaleY: g.editScaleY, dx, dy, uniform: e.shiftKey,