Skip to content

Commit 7792b8b

Browse files
committed
fix(studio): a rotate GSAP does not own saves the element's own CSS rotate
1 parent 1f55861 commit 7792b8b

16 files changed

Lines changed: 294 additions & 116 deletions

‎packages/studio/src/components/editor/domEditOverlayGestures.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export interface GestureState {
6363
actualWidth: number;
6464
actualHeight: number;
6565
actualRotation: number;
66+
/** GSAP turns nothing on the element: what its scale and transform turn, read once, so the
67+
* rotate draws and saves only the rest as its own CSS `rotate`. Null: GSAP's rotation. */
68+
plainRotationShare: number | null;
6669
editScaleX: number;
6770
editScaleY: number;
6871
// Rendered-per-CSS-pixel factor of the element itself at gesture start (a GSAP

‎packages/studio/src/components/editor/domEditOverlayStartGesture.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {
3131
type UseDomEditOverlayGesturesOptions,
3232
} from "./domEditOverlayGestures";
3333
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
34+
import { gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
35+
import { readCssRotation } from "../../hooks/draggedGsapPosition";
3436
import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
3537
import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
3638

@@ -151,10 +153,11 @@ export function startGesture(
151153
return false;
152154

153155
const size = readStudioBoxSize(sel.element);
154-
// Single-source rotation base: GSAP's rotation plus any legacy `--hf-studio-rotation`, or
155-
// without GSAP the CSS fold, so a rotate gesture starts from the element's visual angle
156-
// and commits an absolute angle to the timeline.
157-
const rotation = { angle: readRotationBase(sel.element) };
156+
// The rotate starts from the angle the element shows and commits an absolute one.
157+
const plainRotationShare = gsapWritesRotation(sel.element)
158+
? null
159+
: readCssRotation(sel.element, false);
160+
const rotation = { angle: readRotationBase(sel.element, plainRotationShare !== null) };
158161
// The draft writes CSS width/height, so the resize base must be the CSS
159162
// layout size. offsetWidth/Height are transform-free; the overlay-rect
160163
// fallback (rect / editScale) includes the element's own GSAP scale and
@@ -280,6 +283,7 @@ export function startGesture(
280283
actualWidth,
281284
actualHeight,
282285
actualRotation: rotation.angle,
286+
plainRotationShare,
283287
editScaleX: rect.editScaleX,
284288
editScaleY: rect.editScaleY,
285289
contentScaleX,

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

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ import {
4646
buildClearPathOffsetPatches,
4747
buildBoxSizePatches,
4848
buildClearBoxSizePatches,
49-
buildRotationPatches,
5049
buildClearRotationPatches,
5150
buildMotionPatches,
5251
buildClearMotionPatches,
@@ -335,45 +334,7 @@ describe("anchored-corner combined patch: [...buildBoxSizePatches, ...buildPathO
335334

336335
/* ── Rotation ────────────────────────────────────────────────────────────── */
337336

338-
describe("buildRotationPatches / buildClearRotationPatches", () => {
339-
function populatedRotEl(): HTMLElement {
340-
const e = div();
341-
e.style.setProperty(STUDIO_ROTATION_PROP, "45");
342-
e.style.setProperty("rotate", "45deg");
343-
e.style.setProperty("transform-origin", "left center");
344-
e.style.setProperty("display", "block");
345-
e.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, "0deg");
346-
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "0deg");
347-
e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "center center");
348-
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "flex");
349-
return e;
350-
}
351-
352-
it("populated: captures rotation styles, attrs, and transform-display marker in declaration order", () => {
353-
const ops = buildRotationPatches(populatedRotEl());
354-
expect(ops).toEqual([
355-
{ type: "inline-style", property: STUDIO_ROTATION_PROP, value: "45" },
356-
{ type: "inline-style", property: "rotate", value: "45deg" },
357-
{ type: "inline-style", property: "transform-origin", value: "left center" },
358-
{ type: "inline-style", property: "display", value: "block" },
359-
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
360-
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: "0deg" },
361-
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: "0deg" },
362-
{
363-
type: "attribute",
364-
property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
365-
value: "center center",
366-
},
367-
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "flex" },
368-
]);
369-
});
370-
371-
it("empty: bare element yields only the rotation marker", () => {
372-
expect(buildRotationPatches(div())).toEqual([
373-
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
374-
]);
375-
});
376-
337+
describe("buildClearRotationPatches", () => {
377338
it("clear: restores rotate and transform-origin from orig attrs, nulls draft attr", () => {
378339
const e = div();
379340
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "30deg");
@@ -405,11 +366,6 @@ describe("buildRotationPatches / buildClearRotationPatches", () => {
405366
const ops = buildClearRotationPatches(e);
406367
expect(ops.find((o) => o.property === "rotate")?.value).toBeNull();
407368
});
408-
409-
it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
410-
const e = populatedRotEl();
411-
assertClearCoversKeys(buildRotationPatches(e), buildClearRotationPatches(e));
412-
});
413369
});
414370

415371
/* ── Motion ──────────────────────────────────────────────────────────────── */

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

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -185,28 +185,6 @@ export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[]
185185

186186
/* ── Rotation patches ────────────────────────────────────────────── */
187187

188-
const ROTATION_STYLE_PROPS = [
189-
STUDIO_ROTATION_PROP,
190-
"rotate",
191-
"transform-origin",
192-
"display",
193-
] as const;
194-
195-
const ROTATION_ORIG_ATTRS = [
196-
STUDIO_ORIGINAL_ROTATE_ATTR,
197-
STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
198-
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
199-
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
200-
] as const;
201-
202-
export function buildRotationPatches(element: HTMLElement): PatchOperation[] {
203-
const ops: PatchOperation[] = [];
204-
collectInlineStyleOps(element, ROTATION_STYLE_PROPS, ops);
205-
ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" });
206-
collectAttributeOps(element, ROTATION_ORIG_ATTRS, ops);
207-
return ops;
208-
}
209-
210188
export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] {
211189
const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
212190
const origRotationTransformOrigin = element.getAttribute(

‎packages/studio/src/components/editor/manualOffsetDrag.test.ts‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Window } from "happy-dom";
2-
import { describe, expect, it } from "vitest";
2+
import { describe, expect, it, vi } from "vitest";
33
import {
44
applyManualOffsetDragCommit,
55
resumeGsapTimelines,
@@ -16,7 +16,7 @@ import {
1616
type ManualOffsetDragMatrix,
1717
} from "./manualOffsetDrag";
1818
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
19-
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
19+
import { computeDraggedGsapPosition, readCssRotation } from "../../hooks/draggedGsapPosition";
2020

2121
function expectMatrixClose(actual: ManualOffsetDragMatrix, expected: ManualOffsetDragMatrix): void {
2222
expect(actual.a).toBeCloseTo(expected.a, 6);
@@ -646,9 +646,9 @@ describe("rotate in a composition without GSAP", () => {
646646
const element = window.document.createElement("h1");
647647
element.id = "title";
648648
window.document.body.append(element);
649-
const base = readRotationBase(element);
649+
const base = readRotationBase(element, true);
650650
expect(base).toBeCloseTo(30);
651-
applyRotationDraft(element, 55);
651+
applyRotationDraft(element, 55, readCssRotation(element, false));
652652
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
653653
});
654654

@@ -665,10 +665,34 @@ describe("rotate in a composition without GSAP", () => {
665665
const element = window.document.createElement("h1");
666666
element.id = "title";
667667
window.document.body.append(element);
668-
expect({ css, base: readRotationBase(element) }).toEqual({ css, base: expect.closeTo(base) });
669-
applyRotationDraft(element, base + 25);
668+
expect({ css, base: readRotationBase(element, true) }).toEqual({
669+
css,
670+
base: expect.closeTo(base),
671+
});
672+
applyRotationDraft(element, base + 25, readCssRotation(element, false));
670673
const rotate = Number.parseFloat(element.style.getPropertyValue("rotate"));
671674
expect({ css, rotate }).toEqual({ css, rotate: expect.closeTo(drafted) });
672675
}
673676
});
677+
678+
it("never asks GSAP, nor restyles per frame, when GSAP turns nothing on the element", () => {
679+
const window = new Window();
680+
const gsap = { set: vi.fn(), getProperty: vi.fn(() => 0) };
681+
Object.assign(window, { gsap });
682+
window.document.head.innerHTML = "<style>#title { rotate: 30deg; }</style>";
683+
const element = window.document.createElement("h1");
684+
element.id = "title";
685+
window.document.body.append(element);
686+
expect(readRotationBase(element, true)).toBeCloseTo(30);
687+
const share = readCssRotation(element, false);
688+
const styleReads = vi.spyOn(window, "getComputedStyle");
689+
applyRotationDraft(element, 55, share);
690+
expect(styleReads).not.toHaveBeenCalled();
691+
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
692+
expect(gsap.set).not.toHaveBeenCalled();
693+
expect(gsap.getProperty).not.toHaveBeenCalled();
694+
applyRotationDraft(element, 55, null);
695+
expect(element.style.getPropertyValue("rotate")).toBe("none");
696+
expect(gsap.set).toHaveBeenCalledWith(element, { rotation: 55 });
697+
});
674698
});

‎packages/studio/src/components/editor/manualOffsetDrag.ts‎

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { computeDraggedGsapPosition, readCssRotation } from "../../hooks/draggedGsapPosition";
1717
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
1818
import { readTranslatePx, writeTranslatePx } from "./plainTranslate";
19+
import { roundTo3 } from "../../utils/rounding";
1920

2021
interface OffsetDragGsap {
2122
set: (el: Element, vars: Record<string, number | string>) => void;
@@ -56,32 +57,47 @@ function applyOffsetDragDraftViaGsap(
5657
return true;
5758
}
5859

59-
// The rotation draft shows the absolute angle the commit writes: GSAP's rotation, or without GSAP a
60-
// CSS `rotate` of the angle less what `scale` and `transform` already turn.
61-
export function applyRotationDraft(element: HTMLElement, angle: number): void {
62-
const gsap = getOffsetDragGsap(element);
63-
element.style.setProperty(
64-
"rotate",
65-
gsap ? "none" : `${angle - readCssRotation(element, false)}deg`,
66-
);
67-
gsap?.set(element, { rotation: angle });
60+
/** The element's own `rotate` that shows `angle`, less `share`, what its `scale` and `transform` turn. */
61+
export function applyCssRotation(
62+
element: HTMLElement,
63+
angle: number,
64+
share = readCssRotation(element, false),
65+
): void {
66+
element.style.setProperty("rotate", `${roundTo3(angle - share)}deg`);
67+
}
68+
69+
// `plainShare` is decided once, at gesture start: a number when GSAP turns nothing on the element (the
70+
// rotate draws its own CSS `rotate`), null for GSAP's rotation, as the commit writes it.
71+
export function applyRotationDraft(
72+
element: HTMLElement,
73+
angle: number,
74+
plainShare: number | null,
75+
): void {
76+
const gsap = plainShare === null ? getOffsetDragGsap(element) : null;
77+
if (!gsap) return applyCssRotation(element, angle, plainShare ?? undefined);
78+
element.style.setProperty("rotate", "none");
79+
gsap.set(element, { rotation: angle });
6880
}
6981

82+
const rotationGsap = (element: HTMLElement, plain: boolean) =>
83+
plain ? null : getOffsetDragGsap(element);
84+
7085
/** Back to the gesture start: the CSS snapshot, and GSAP's rotation without the legacy var. */
7186
export function restoreRotationDraft(
7287
element: HTMLElement,
7388
angle: number,
7489
snapshot: StudioRotationSnapshot,
90+
plain: boolean,
7591
): void {
76-
getOffsetDragGsap(element)?.set(element, {
92+
rotationGsap(element, plain)?.set(element, {
7793
rotation: angle - (Number.parseFloat(snapshot.studioRotation) || 0),
7894
});
7995
restoreStudioRotation(element, snapshot);
8096
}
8197

8298
/** The angle a rotate gesture starts from, as the element shows it. */
83-
export function readRotationBase(element: HTMLElement): number {
84-
const gsap = getOffsetDragGsap(element);
99+
export function readRotationBase(element: HTMLElement, plain: boolean): number {
100+
const gsap = rotationGsap(element, plain);
85101
if (!gsap) return readCssRotation(element);
86102
return Number(gsap.getProperty(element, "rotation")) + readStudioRotation(element).angle;
87103
}

‎packages/studio/src/components/editor/useDomEditOverlayGestures.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
164164
actualAngle: g.actualRotation,
165165
snap: e.shiftKey,
166166
});
167-
applyRotationDraft(sel.element, rotated.angle);
167+
applyRotationDraft(sel.element, rotated.angle, g.plainRotationShare);
168168
return;
169169
}
170170

@@ -418,14 +418,19 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
418418
snap: e.shiftKey,
419419
});
420420
const restoreRotation = () =>
421-
restoreRotationDraft(sel.element, g.actualRotation, g.initialRotation);
421+
restoreRotationDraft(
422+
sel.element,
423+
g.actualRotation,
424+
g.initialRotation,
425+
g.plainRotationShare !== null,
426+
);
422427
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
423428
restoreRotation();
424429
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
425430
return;
426431
}
427432
// Hold the final angle while the commit lands.
428-
applyRotationDraft(sel.element, finalRotation.angle);
433+
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotationShare);
429434
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
430435
.catch((error) => {
431436
logGestureCommitFailure("rotate commit failed", error);
@@ -543,7 +548,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
543548
restoreGestureOverlayRect(g);
544549
}
545550
if (g?.mode === "rotation" && sel) {
546-
restoreRotationDraft(sel.element, g.actualRotation, g.initialRotation);
551+
restoreRotationDraft(
552+
sel.element,
553+
g.actualRotation,
554+
g.initialRotation,
555+
g.plainRotationShare !== null,
556+
);
547557
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
548558
}
549559
opts.blockedMoveRef.current = null;

‎packages/studio/src/hooks/gsapRuntimeBridge.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ import {
3535
pickClosestToPlayhead,
3636
readGsapPositionFromIframe,
3737
} from "./gsapPositionDetection";
38-
import { hasNonHoldTweenForElement, POSITION_CHANNELS } from "./gsapRuntimeKeyframes";
38+
import {
39+
hasNonHoldTweenForElement,
40+
POSITION_CHANNELS,
41+
ROTATION_CHANNELS,
42+
} from "./gsapRuntimeKeyframes";
3943
import { getAnimationsForElement } from "./gsapElementMatch";
4044
import {
4145
animationWritesAnyProperty,
@@ -46,7 +50,6 @@ import {
4650
export { POSITION_CHANNELS };
4751
const POSITION_CHANNEL_SET = new Set<string>(POSITION_CHANNELS);
4852

49-
const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
5053
const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
5154

5255
// ── Property-group tween resolution ───────────────────────────────────────

‎packages/studio/src/hooks/gsapRuntimeKeyframes.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,11 +433,20 @@ function gsapWritesChannels(el: Element, channels: string[]): boolean {
433433
);
434434
}
435435

436+
export const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
437+
438+
const gsapRendersTransform = (el: Element) =>
439+
!!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform;
440+
436441
/** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
437442
* transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
438443
export function gsapWritesPosition(el: Element): boolean {
439-
const cache = (el as { _gsap?: { renderTransform?: unknown } })._gsap;
440-
return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS);
444+
return gsapRendersTransform(el) || gsapWritesChannels(el, MOVE_CHANNELS);
445+
}
446+
447+
/** `gsapWritesPosition` for a rotate: everything else turns by its own CSS `rotate`. */
448+
export function gsapWritesRotation(el: Element): boolean {
449+
return gsapRendersTransform(el) || gsapWritesChannels(el, ROTATION_CHANNELS);
441450
}
442451

443452
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */

0 commit comments

Comments
 (0)