From f38eaf409ae1a9b94d3caf9d9e4f0fa167fbe8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 27 May 2026 12:14:45 -0400 Subject: [PATCH] fix(studio): compensate GSAP translate when starting manual drag (#1095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(studio): compensate GSAP translate when starting manual drag When an element has an active GSAP transform with translate (x/y), starting a drag via createManualOffsetDragMember would strip the GSAP translate from element.style.transform during the probe phase without accounting for it in the initial offset. This caused the persisted manual offset to be wrong by exactly the GSAP translate amount, producing a visible position shift after page reload. Read the GSAP translate contribution (m41/m42 from the transform matrix) and fold it into initialOffset before the probe runs. The offset now compensates for the stripped translate, so the element's visual position is preserved across the drag start, commit, and subsequent reloads. * fix(studio): show visual position in Layout panel and fix save-reload race PropertyPanel: X/Y fields now display the visual position (manual offset + GSAP translate) instead of the raw CSS var offset. Editing a value reverses the compensation so the correct raw offset is persisted. This matches what the user sees in the preview during GSAP playback. persistDomEditOperations: move domEditSaveTimestampRef update before the patch API call. The server writes the file and emits an SSE file-change event during the fetch — if the event arrived before the response, the file watcher would trigger a spurious reloadPreview(), resetting playback to t=0. Setting the timestamp upfront suppresses that race. * fix(studio): apply same timestamp race fix to element delete, relocate helper Move readGsapTranslateFromTransform to manualEditsDom.ts alongside its sibling stripGsapTranslateFromTransform and re-export through the manualEdits barrel. PropertyPanel and manualOffsetDrag now import from the shared location instead of the drag module owning a display concern. Move domEditSaveTimestampRef update before the remove-element fetch in handleDomEditElementDelete — same SSE race as persistDomEditOperations. --- .../src/components/editor/PropertyPanel.tsx | 23 ++++-- .../src/components/editor/manualEdits.ts | 1 + .../src/components/editor/manualEditsDom.ts | 14 ++++ .../editor/manualOffsetDrag.test.ts | 81 +++++++++++++++++++ .../src/components/editor/manualOffsetDrag.ts | 8 +- .../studio/src/hooks/useDomEditCommits.ts | 12 +-- 6 files changed, 127 insertions(+), 12 deletions(-) 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",