mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user