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",