Skip to content

Commit 70d5fe8

Browse files
vanceingallsclaude
andcommitted
fix(studio,core): persist manual position edits for GSAP-owned elements
- sourceMutation: linkedom CSSStyleDeclaration silently drops CSS custom properties and transform longhands via setProperty; patch the style attribute string directly so --hf-studio-offset-* and translate survive the server round-trip (positions never reached disk before this) - gsapAnimatesTransform(): GSAP owns the full transform stack when it tweens ANY transform prop (scale, rotation, ...), not just x/y — it folds CSS translate into its cache once at init, zeroes the longhand once, and never re-reads it - applyStudioPathOffset: for GSAP-owned elements keep translate:none live and sync the offset into GSAP's cache via gsap.set; writing the longhand double-applied the offset (disappearing elements, scrub snap-back) - buildPathOffsetPatches: emit the var() translate expression explicitly so the persisted file re-folds on reload (live inline is none) - StudioPathOffsetSnapshot: capture/restore GSAP x/y — the drag-response probe mutates GSAP's cache, which inline-style restore cannot undo (click made elements jump by the probe distance) - reapplyPathOffsets: skip GSAP-owned elements (was x/y-only) to stop seek-time double-apply - STUDIO_GSAP_DRAG_INTERCEPT flag (default off): keyframe drag intercept is opt-in until its recording path is hardened; commits take the CSS persist path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c1b0b24 commit 70d5fe8

12 files changed

Lines changed: 185 additions & 16 deletions

packages/core/src/studio-api/helpers/manualEditsRenderScript.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
export interface StudioManualEditsRenderScriptOptions {
23
activeCompositionPath?: string | null;
34
}

packages/core/src/studio-api/helpers/sourceMutation.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,33 @@ function isSafeAttributeValue(name: string, value: string): boolean {
223223
return true;
224224
}
225225

226+
function patchStyleAttrString(style: string, property: string, value: string | null): string {
227+
const props = new Map<string, string>();
228+
const order: string[] = [];
229+
for (const decl of style.split(";")) {
230+
const colon = decl.indexOf(":");
231+
if (colon < 0) continue;
232+
const key = decl.slice(0, colon).trim();
233+
const val = decl.slice(colon + 1).trim();
234+
if (!key) continue;
235+
if (!props.has(key)) order.push(key);
236+
props.set(key, val);
237+
}
238+
if (value === null) {
239+
props.delete(property);
240+
const idx = order.indexOf(property);
241+
if (idx >= 0) order.splice(idx, 1);
242+
} else {
243+
if (!props.has(property)) order.push(property);
244+
props.set(property, value);
245+
}
246+
return order
247+
.map((k) => `${k}: ${props.get(k) ?? ""}`)
248+
.filter((d) => d.trim())
249+
.join("; ");
250+
}
251+
252+
// fallow-ignore-next-line complexity
226253
export function patchElementInHtml(
227254
source: string,
228255
target: SourceMutationTarget,
@@ -236,10 +263,14 @@ export function patchElementInHtml(
236263
for (const op of operations) {
237264
switch (op.type) {
238265
case "inline-style":
239-
if (op.value != null) {
240-
htmlEl.style.setProperty(op.property, op.value);
241-
} else {
242-
htmlEl.style.removeProperty(op.property);
266+
// linkedom's CSSStyleDeclaration does not support CSS custom properties
267+
// (--foo) or newer individual transform properties (translate, rotate,
268+
// scale) via style.setProperty(). Manipulate the style attribute string
269+
// directly so all property names survive the round-trip.
270+
{
271+
const raw = htmlEl.getAttribute("style") ?? "";
272+
const patched = patchStyleAttrString(raw, op.property, op.value);
273+
htmlEl.setAttribute("style", patched);
243274
}
244275
break;
245276
case "attribute":

packages/studio/src/components/editor/gsapAnimatesProperty.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,37 @@
1+
// GSAP's CSSPlugin takes ownership of the element's entire transform stack
2+
// when it tweens ANY of these — it bakes the CSS `translate` longhand into
3+
// style.transform at init and writes `translate: none` every tick. Position
4+
// reapply/strip logic must therefore stand down for all of them, not just x/y.
5+
const GSAP_TRANSFORM_PROPS = [
6+
"x",
7+
"y",
8+
"xPercent",
9+
"yPercent",
10+
"scale",
11+
"scaleX",
12+
"scaleY",
13+
"rotation",
14+
"rotate",
15+
"rotationX",
16+
"rotationY",
17+
"skewX",
18+
"skewY",
19+
"transform",
20+
];
21+
22+
/**
23+
* True when GSAP animates any transform-affecting property on the element,
24+
* meaning GSAP owns `style.transform` and has neutralized CSS `translate`.
25+
*/
26+
export function gsapAnimatesTransform(el: HTMLElement): boolean {
27+
return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS);
28+
}
29+
130
/**
231
* Checks whether GSAP actively animates one or more CSS/GSAP properties on
332
* the given element by inspecting all registered `__timelines`.
433
*/
34+
// fallow-ignore-next-line complexity
535
export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
636
const win = el.ownerDocument.defaultView as
737
| (Window & {

packages/studio/src/components/editor/manualEditingAvailability.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
8383
false,
8484
);
8585

86+
// When disabled (the default), drag/resize/rotate commits always take the CSS
87+
// persist path instead of being intercepted into GSAP script keyframe
88+
// mutations. The keyframe intercept rewrites timeline tweens from drag
89+
// gestures and is opt-in until its recording path is hardened.
90+
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
91+
env,
92+
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"],
93+
false,
94+
);
95+
8696
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
8797

8898
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";

packages/studio/src/components/editor/manualEditsDom.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
} from "./manualEditsTypes";
3333
import { roundRotationAngle } from "./manualEditsParsing";
3434
import { applyStudioMotionFromDom } from "./studioMotion";
35-
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
35+
import { gsapAnimatesProperty, gsapAnimatesTransform } from "./gsapAnimatesProperty";
3636

3737
/* ── Gesture tracking ─────────────────────────────────────────────── */
3838
let studioManualEditGestureId = 0;
@@ -223,6 +223,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
223223
return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1;
224224
}
225225

226+
// fallow-ignore-next-line complexity
226227
function stripGsapTranslateFromTransform(element: HTMLElement): void {
227228
if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
228229
const transform = element.style.getPropertyValue("transform");
@@ -257,6 +258,18 @@ export function applyStudioPathOffset(
257258
): void {
258259
promoteInlineForTransform(element);
259260
writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true });
261+
if (gsapAnimatesTransform(element)) {
262+
// GSAP folded the CSS translate into its transform cache at init and owns
263+
// style.transform from then on — it zeroes the translate longhand exactly
264+
// once (at fold time) and never re-reads it. Writing translate here would
265+
// double-apply the offset on top of the baked transform. Keep translate
266+
// neutral in the live DOM and push the offset into GSAP's cache instead;
267+
// the var() expression is persisted to the source file by the patch
268+
// builder, where a reload re-folds it.
269+
element.style.setProperty("translate", "none");
270+
syncGsapOwnedTransformPosition(element);
271+
return;
272+
}
260273
element.style.setProperty(
261274
"translate",
262275
composeTranslateValue(
@@ -268,18 +281,36 @@ export function applyStudioPathOffset(
268281
stripGsapTranslateFromTransform(element);
269282
}
270283

284+
/**
285+
* After committing a new path offset on an element whose transform GSAP owns,
286+
* GSAP's internal cache still holds the pre-drag baked translate — the next
287+
* seek re-renders from that cache and snaps the element back. Push the new
288+
* offset into GSAP so live scrubbing matches what was persisted. (A page
289+
* reload re-initializes GSAP from the persisted CSS translate, so this is
290+
* only needed for the live session.)
291+
*/
292+
function syncGsapOwnedTransformPosition(element: HTMLElement): void {
293+
if (!gsapAnimatesTransform(element)) return;
294+
const win = element.ownerDocument.defaultView as
295+
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
296+
| null;
297+
if (!win?.gsap?.set) return;
298+
const { x, y } = readStudioPathOffset(element);
299+
win.gsap.set(element, { x, y });
300+
}
301+
271302
export function applyStudioPathOffsetDraft(
272303
element: HTMLElement,
273304
offset: { x: number; y: number },
274305
): void {
275306
promoteInlineForTransform(element);
276307
writeStudioPathOffsetVars(element, offset, { updateBase: false });
277308

278-
const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
309+
const isGsapAnimated = gsapAnimatesTransform(element);
279310
if (isGsapAnimated) {
280-
// For GSAP-animated elements: use gsap.set for positioning (the timeline
281-
// is paused during drag). Set translate:none explicitly to prevent
282-
// double-counting with the transform.
311+
// GSAP owns style.transform (see applyStudioPathOffset): position via
312+
// gsap.set while the timeline is paused. Set translate:none explicitly to
313+
// prevent double-counting with the baked transform.
283314
element.style.setProperty("translate", "none");
284315
const win = element.ownerDocument.defaultView as
285316
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
@@ -520,10 +551,14 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
520551

521552
function reapplyPathOffsets(doc: Document): void {
522553
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
523-
const gsapSkip = gsapAnimatesProperty(el, "x", "y");
554+
// Skip elements where GSAP owns the transform stack — GSAP bakes the
555+
// CSS translate into its transform and sets translate: none every tick
556+
// when it tweens ANY transform property (x/y, scale, rotation, ...).
557+
// Stripping/restoring would oscillate against GSAP's rendering and
558+
// double-apply the offset.
559+
if (gsapAnimatesTransform(el)) continue;
524560
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
525561
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
526-
if (gsapSkip) continue;
527562
if (x || y) {
528563
applyStudioPathOffset(
529564
el,

packages/studio/src/components/editor/manualEditsDomPatches.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
// @vitest-environment happy-dom
23

34
import { describe, it, expect } from "vitest";

packages/studio/src/components/editor/manualEditsDomPatches.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,23 @@ function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]):
7272

7373
export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
7474
const ops: PatchOperation[] = [];
75-
collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, "translate"], ops);
75+
collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP], ops);
76+
// When GSAP owns the element's transform, the live inline translate is kept
77+
// at "none" (the offset lives in GSAP's cache — see applyStudioPathOffset).
78+
// Persist the var() expression in that case, so a reload re-folds the offset.
79+
const inlineTranslate = element.style.getPropertyValue("translate");
80+
const hasOffsetVars =
81+
element.style.getPropertyValue(STUDIO_OFFSET_X_PROP) ||
82+
element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
83+
const translateValue =
84+
inlineTranslate && inlineTranslate !== "none"
85+
? inlineTranslate
86+
: hasOffsetVars
87+
? `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`
88+
: null;
89+
if (translateValue) {
90+
ops.push({ type: "inline-style", property: "translate", value: translateValue });
91+
}
7692
ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
7793
collectAttributeOps(
7894
element,

packages/studio/src/components/editor/manualEditsSnapshot.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
styleUsesStudioRotation,
55
restoreInlineDisplay,
66
} from "./manualEditsDom";
7+
import { gsapAnimatesTransform } from "./gsapAnimatesProperty";
78
import {
89
STUDIO_OFFSET_X_PROP,
910
STUDIO_OFFSET_Y_PROP,
@@ -87,14 +88,32 @@ export function captureStudioRotation(element: HTMLElement): StudioRotationSnaps
8788
};
8889
}
8990

91+
type GsapWindow = Window & {
92+
gsap?: {
93+
getProperty?: (el: Element, prop: string) => number | string;
94+
set?: (el: Element, vars: Record<string, unknown>) => void;
95+
};
96+
};
97+
9098
export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetSnapshot {
99+
let gsapX: number | null = null;
100+
let gsapY: number | null = null;
101+
if (gsapAnimatesTransform(element)) {
102+
const win = element.ownerDocument.defaultView as GsapWindow | null;
103+
if (win?.gsap?.getProperty) {
104+
gsapX = Number(win.gsap.getProperty(element, "x")) || 0;
105+
gsapY = Number(win.gsap.getProperty(element, "y")) || 0;
106+
}
107+
}
91108
return {
92109
translate: element.style.getPropertyValue("translate"),
93110
x: element.style.getPropertyValue(STUDIO_OFFSET_X_PROP),
94111
y: element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP),
95112
marker: element.getAttribute(STUDIO_PATH_OFFSET_ATTR),
96113
originalTranslate: element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR),
97114
originalInlineTranslate: element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR),
115+
gsapX,
116+
gsapY,
98117
};
99118
}
100119

@@ -183,6 +202,13 @@ export function restoreStudioPathOffset(
183202
STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
184203
previous.originalInlineTranslate,
185204
);
205+
206+
// Draft positioning on GSAP-owned elements goes through gsap.set, which
207+
// mutates GSAP's transform cache — restore it alongside the inline styles.
208+
if (previous.gsapX != null || previous.gsapY != null) {
209+
const win = element.ownerDocument.defaultView as GsapWindow | null;
210+
win?.gsap?.set?.(element, { x: previous.gsapX ?? 0, y: previous.gsapY ?? 0 });
211+
}
186212
}
187213

188214
/* ── Clear functions ──────────────────────────────────────────────── */

packages/studio/src/components/editor/manualEditsTypes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,12 @@ export interface StudioPathOffsetSnapshot {
101101
marker: string | null;
102102
originalTranslate: string | null;
103103
originalInlineTranslate: string | null;
104+
/**
105+
* GSAP's cached x/y at capture time, for elements whose transform GSAP
106+
* owns. Draft positioning mutates GSAP's cache (gsap.set), which inline
107+
* style restoration alone cannot undo. Null when GSAP does not own the
108+
* element's transform.
109+
*/
110+
gsapX: number | null;
111+
gsapY: number | null;
104112
}

packages/studio/src/contexts/DomEditContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
import { createContext, useContext, useMemo, type ReactNode } from "react";
23
import type { useDomEditSession } from "../hooks/useDomEditSession";
34

0 commit comments

Comments
 (0)