fix(studio): compensate GSAP translate when starting manual drag (#1095)

* 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.
This commit is contained in:
Miguel Ángel
2026-05-27 12:14:45 -04:00
committed by GitHub
parent 5cd4db07e3
commit f38eaf409a
6 changed files with 127 additions and 12 deletions
@@ -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({
<div className={RESPONSIVE_GRID}>
<MetricField
label="X"
value={formatPxMetricValue(manualOffset.x)}
value={formatPxMetricValue(visualOffset.x)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("x", next)}
/>
<MetricField
label="Y"
value={formatPxMetricValue(manualOffset.y)}
value={formatPxMetricValue(visualOffset.y)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("y", next)}
@@ -20,6 +20,7 @@ export {
readStudioPathOffset,
readStudioBoxSize,
readStudioRotation,
readGsapTranslateFromTransform,
applyStudioPathOffset,
applyStudioPathOffsetDraft,
applyStudioBoxSize,
@@ -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;
@@ -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);
});
});
@@ -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);
@@ -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",