diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index d40ab0974..c8a8d14fe 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -1,7 +1,12 @@ import { memo } from "react"; import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons"; import { type DomEditSelection } from "./domEditing"; -import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits"; +import { + readStudioBoxSize, + readStudioPathOffset, + readStudioRotation, + readGsapTranslateFromTransform, +} from "./manualEdits"; import type { ImportedFontAsset } from "./fontAssets"; import { EMPTY_STYLES, @@ -181,6 +186,11 @@ export const PropertyPanel = memo(function PropertyPanel({ const sourceLabel = element.id ? `#${element.id}` : element.selector; const showEditableSections = element.capabilities.canEditStyles; const manualOffset = readStudioPathOffset(element.element); + const gsapTranslate = readGsapTranslateFromTransform(element.element); + const visualOffset = { + x: manualOffset.x + gsapTranslate.x, + y: manualOffset.y + gsapTranslate.y, + }; const manualSize = readStudioBoxSize(element.element); const resolvedWidth = manualSize.width > 0 @@ -194,10 +204,11 @@ export const PropertyPanel = memo(function PropertyPanel({ const commitManualOffset = (axis: "x" | "y", nextValue: string) => { const parsed = parsePxMetricValue(nextValue); if (parsed == null) return; - const current = readStudioPathOffset(element.element); + const currentRaw = readStudioPathOffset(element.element); + const currentGsap = readGsapTranslateFromTransform(element.element); onSetManualOffset(element, { - x: axis === "x" ? parsed : current.x, - y: axis === "y" ? parsed : current.y, + x: axis === "x" ? parsed - currentGsap.x : currentRaw.x, + y: axis === "y" ? parsed - currentGsap.y : currentRaw.y, }); }; @@ -289,14 +300,14 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualOffset("x", next)} /> commitManualOffset("y", next)} diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index 13e969e76..fd69969d5 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -20,6 +20,7 @@ export { readStudioPathOffset, readStudioBoxSize, readStudioRotation, + readGsapTranslateFromTransform, applyStudioPathOffset, applyStudioPathOffsetDraft, applyStudioBoxSize, diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index f5eb262a7..f2cc83806 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -219,6 +219,20 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean { return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1; } +export function readGsapTranslateFromTransform(element: HTMLElement): { x: number; y: number } { + const transform = element.style.getPropertyValue("transform"); + if (!transform || transform === "none") return { x: 0, y: 0 }; + const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null) + ?.DOMMatrix; + if (!DOMMatrixCtor) return { x: 0, y: 0 }; + try { + const m = new DOMMatrixCtor(transform); + return { x: m.m41, y: m.m42 }; + } catch { + return { x: 0, y: 0 }; + } +} + function stripGsapTranslateFromTransform(element: HTMLElement): void { const transform = element.style.getPropertyValue("transform"); if (!transform || transform === "none") return; diff --git a/packages/studio/src/components/editor/manualOffsetDrag.test.ts b/packages/studio/src/components/editor/manualOffsetDrag.test.ts index 0289732b3..48f9abffd 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.test.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.test.ts @@ -2,6 +2,7 @@ import { Window } from "happy-dom"; import { describe, expect, it } from "vitest"; import { applyManualOffsetDragMatrix, + createManualOffsetDragMember, invertManualOffsetDragMatrix, measureManualOffsetDragScreenToOffsetMatrix, resolveManualOffsetForPointerDelta, @@ -138,3 +139,83 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => { expect(measured.ok).toBe(false); }); }); + +describe("createManualOffsetDragMember GSAP translate compensation", () => { + it("folds GSAP translate from element.style.transform into initialOffset", () => { + const window = new Window(); + const element = window.document.createElement("div"); + window.document.body.append(element); + + element.style.setProperty("transform", "translate(0px, -20px)"); + + element.getBoundingClientRect = () => { + const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0; + const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0; + return new window.DOMRect(10 + offsetX, 20 + offsetY, 100, 50); + }; + + const result = createManualOffsetDragMember({ + key: "test", + selection: { element } as never, + element, + rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.member.initialOffset.x).toBe(0); + expect(result.member.initialOffset.y).toBe(-20); + }); + + it("leaves initialOffset unchanged when no GSAP transform is present", () => { + const window = new Window(); + const element = window.document.createElement("div"); + window.document.body.append(element); + + element.getBoundingClientRect = () => { + const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0; + const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0; + return new window.DOMRect(10 + offsetX, 20 + offsetY, 100, 50); + }; + + const result = createManualOffsetDragMember({ + key: "test", + selection: { element } as never, + element, + rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.member.initialOffset.x).toBe(0); + expect(result.member.initialOffset.y).toBe(0); + }); + + it("combines existing manual offset with GSAP translate", () => { + const window = new Window(); + const element = window.document.createElement("div"); + window.document.body.append(element); + + element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px"); + element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px"); + element.style.setProperty("transform", "translate(50px, -15px)"); + + element.getBoundingClientRect = () => { + const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0; + const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0; + return new window.DOMRect(10 + offsetX, 20 + offsetY, 100, 50); + }; + + const result = createManualOffsetDragMember({ + key: "test", + selection: { element } as never, + element, + rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.member.initialOffset.x).toBe(80); + expect(result.member.initialOffset.y).toBe(-5); + }); +}); diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 67aae3397..bc5827ed3 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -5,6 +5,7 @@ import { beginStudioManualEditGesture, captureStudioPathOffset, endStudioManualEditGesture, + readGsapTranslateFromTransform, readStudioPathOffset, restoreStudioPathOffset, type StudioPathOffsetSnapshot, @@ -231,7 +232,12 @@ export function createManualOffsetDragMember(input: { element: HTMLElement; rect: ManualOffsetDragRect; }): ManualOffsetDragMemberResult { - const initialOffset = readStudioPathOffset(input.element); + const rawOffset = readStudioPathOffset(input.element); + const gsapTranslate = readGsapTranslateFromTransform(input.element); + const initialOffset = { + x: rawOffset.x + gsapTranslate.x, + y: rawOffset.y + gsapTranslate.y, + }; const initialPathOffset = captureStudioPathOffset(input.element); const gestureToken = beginStudioManualEditGesture(input.element); const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 03af4b8e6..d78e7b598 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -155,6 +155,11 @@ export function useDomEditCommits({ selectorIndex: selection.selectorIndex, }; + // Mark the save timestamp before the file write so the SSE file-change + // handler suppresses the reload even if the event arrives before the + // response (the server writes the file and emits SSE during the fetch). + domEditSaveTimestampRef.current = Date.now(); + const patchResponse = await fetch( `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, { @@ -193,9 +198,7 @@ export function useDomEditCommits({ files: { [targetPath]: { before: originalContent, after: finalContent } }, }); - if (options?.skipRefresh) { - domEditSaveTimestampRef.current = Date.now(); - } else { + if (!options?.skipRefresh) { reloadPreview(); } }, @@ -427,6 +430,7 @@ export function useDomEditCommits({ throw new Error("Selected element has no patchable target"); } + domEditSaveTimestampRef.current = Date.now(); const removeResponse = await fetch( `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, { @@ -440,8 +444,6 @@ export function useDomEditCommits({ const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string }; const patchedContent = typeof removeData.content === "string" ? removeData.content : originalContent; - - domEditSaveTimestampRef.current = Date.now(); await saveProjectFilesWithHistory({ projectId: pid, label: "Delete element",