Skip to content

Commit 1f55861

Browse files
committed
fix(studio): rotate from the angle a CSS-rotated element shows, without GSAP too
1 parent e83e3e4 commit 1f55861

8 files changed

Lines changed: 108 additions & 66 deletions

File tree

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { readElementGsapNumber } from "../../utils/elementGsap";
66
import { type DomEditSelection } from "./domEditing";
77
import {
88
createManualOffsetDragMember,
9-
readGsapRotation,
9+
readRotationBase,
1010
restoreManualOffsetDragMembers,
1111
type ManualOffsetDragMember,
1212
} from "./manualOffsetDrag";
@@ -16,7 +16,6 @@ import {
1616
captureStudioPathOffset,
1717
captureStudioRotation,
1818
readStudioBoxSize,
19-
readStudioRotation,
2019
} from "./manualEdits";
2120
import {
2221
type OverlayRect,
@@ -152,10 +151,10 @@ export function startGesture(
152151
return false;
153152

154153
const size = readStudioBoxSize(sel.element);
155-
// Single-source rotation base = the live GSAP transform rotation plus any legacy
156-
// `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
157-
// element's actual visual angle and commits an absolute angle to the timeline.
158-
const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
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) };
159158
// The draft writes CSS width/height, so the resize base must be the CSS
160159
// layout size. offsetWidth/Height are transform-free; the overlay-rect
161160
// fallback (rect / editScale) includes the element's own GSAP scale and

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

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
applyStudioPathOffset,
1111
applyStudioPathOffsetDraft,
1212
applyStudioRotation,
13-
applyStudioRotationDraft,
1413
beginStudioManualEditGesture,
1514
captureStudioBoxSize,
1615
captureStudioRotation,
@@ -210,16 +209,11 @@ describe("studio manual edits", () => {
210209
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
211210
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
212211

213-
applyStudioRotationDraft(card, { angle: -12.2604 });
214-
expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
215-
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
216-
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
217-
218212
const snapshot = captureStudioRotation(card);
219-
applyStudioRotationDraft(card, { angle: 45 });
213+
applyStudioRotation(card, { angle: 45 });
220214
restoreStudioRotation(card, snapshot);
221-
expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
222-
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
215+
expect(readStudioRotation(card)).toEqual({ angle: 24.247 });
216+
expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.247deg");
223217
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
224218
});
225219

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ export {
2929
applyStudioBoxSize,
3030
applyStudioBoxSizeDraft,
3131
applyStudioRotation,
32-
applyStudioRotationDraft,
3332
} from "./manualEditsDom";
3433

3534
export { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -538,13 +538,3 @@ export function applyStudioRotation(element: HTMLElement, rotation: { angle: num
538538
composeStudioRotationValue(element, `var(${STUDIO_ROTATION_PROP}, 0deg)`),
539539
);
540540
}
541-
542-
export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle: number }): void {
543-
promoteInlineForTransform(element);
544-
writeStudioRotationVars(element, rotation, { updateBase: false });
545-
element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
546-
element.style.setProperty(
547-
"rotate",
548-
composeStudioRotationValue(element, `${roundTo3(rotation.angle)}deg`),
549-
);
550-
}

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
applyManualOffsetDragCommit,
55
resumeGsapTimelines,
66
applyManualOffsetDragDraft,
7+
applyRotationDraft,
8+
readRotationBase,
79
applyManualOffsetDragMatrix,
810
applyManualOffsetNudgeDraft,
911
createManualOffsetDragMember,
@@ -636,3 +638,37 @@ describe("a move of an element GSAP does not position", () => {
636638
expect(member().plainTranslate).toBe(false);
637639
});
638640
});
641+
642+
describe("rotate in a composition without GSAP", () => {
643+
it("starts from the authored CSS rotation and drafts the absolute angle the commit writes", () => {
644+
const window = new Window();
645+
window.document.head.innerHTML = "<style>#title { rotate: 30deg; }</style>";
646+
const element = window.document.createElement("h1");
647+
element.id = "title";
648+
window.document.body.append(element);
649+
const base = readRotationBase(element);
650+
expect(base).toBeCloseTo(30);
651+
applyRotationDraft(element, 55);
652+
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
653+
});
654+
655+
it("drafts only the part of the angle the CSS rotate owns, leaving transform and scale theirs", () => {
656+
const cases: Array<[string, number, number]> = [
657+
["transform: rotate(30deg);", 30, 25],
658+
["rotate: 20deg; transform: rotate(10deg);", 30, 45],
659+
["scale: -1 1;", 180, 25],
660+
["transform: scaleX(-1);", 180, 25],
661+
];
662+
for (const [css, base, drafted] of cases) {
663+
const window = new Window();
664+
window.document.head.innerHTML = `<style>#title { ${css} }</style>`;
665+
const element = window.document.createElement("h1");
666+
element.id = "title";
667+
window.document.body.append(element);
668+
expect({ css, base: readRotationBase(element) }).toEqual({ css, base: expect.closeTo(base) });
669+
applyRotationDraft(element, base + 25);
670+
const rotate = Number.parseFloat(element.style.getPropertyValue("rotate"));
671+
expect({ css, rotate }).toEqual({ css, rotate: expect.closeTo(drafted) });
672+
}
673+
});
674+
});

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

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import {
77
clearStudioPathOffset,
88
endStudioManualEditGesture,
99
readAppliedStudioPathOffset,
10+
readStudioRotation,
1011
restoreStudioPathOffset,
12+
restoreStudioRotation,
13+
type StudioRotationSnapshot,
1114
type StudioPathOffsetSnapshot,
1215
} from "./manualEdits";
13-
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
16+
import { computeDraggedGsapPosition, readCssRotation } from "../../hooks/draggedGsapPosition";
1417
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
1518
import { readTranslatePx, writeTranslatePx } from "./plainTranslate";
1619

@@ -53,25 +56,34 @@ function applyOffsetDragDraftViaGsap(
5356
return true;
5457
}
5558

56-
/**
57-
* Live rotation preview through the GSAP channel — the SAME channel the commit
58-
* lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
59-
* GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
60-
* the two channels from composing. `angle` is the absolute target rotation. Returns
61-
* false when gsap is unavailable (caller falls back to the CSS draft).
62-
*/
63-
export function applyRotationDraftViaGsap(element: HTMLElement, angle: number): boolean {
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 {
6462
const gsap = getOffsetDragGsap(element);
65-
if (!gsap) return false;
66-
element.style.setProperty("rotate", "none");
67-
gsap.set(element, { rotation: angle });
68-
return true;
63+
element.style.setProperty(
64+
"rotate",
65+
gsap ? "none" : `${angle - readCssRotation(element, false)}deg`,
66+
);
67+
gsap?.set(element, { rotation: angle });
68+
}
69+
70+
/** Back to the gesture start: the CSS snapshot, and GSAP's rotation without the legacy var. */
71+
export function restoreRotationDraft(
72+
element: HTMLElement,
73+
angle: number,
74+
snapshot: StudioRotationSnapshot,
75+
): void {
76+
getOffsetDragGsap(element)?.set(element, {
77+
rotation: angle - (Number.parseFloat(snapshot.studioRotation) || 0),
78+
});
79+
restoreStudioRotation(element, snapshot);
6980
}
7081

71-
/** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */
72-
export function readGsapRotation(element: HTMLElement): number {
82+
/** The angle a rotate gesture starts from, as the element shows it. */
83+
export function readRotationBase(element: HTMLElement): number {
7384
const gsap = getOffsetDragGsap(element);
74-
return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0;
85+
if (!gsap) return readCssRotation(element);
86+
return Number(gsap.getProperty(element, "rotation")) + readStudioRotation(element).angle;
7587
}
7688

7789
const DEFAULT_OFFSET_PROBE_PX = 100;

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

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,19 @@ import { type DomEditSelection } from "./domEditing";
99
import {
1010
applyManualOffsetDragCommit,
1111
applyManualOffsetDragDraft,
12-
applyRotationDraftViaGsap,
12+
applyRotationDraft,
1313
endManualOffsetDragMembers,
1414
restoreManualOffsetDragMembers,
15+
restoreRotationDraft,
1516
} from "./manualOffsetDrag";
1617
import {
1718
applyStudioBoxSize,
1819
applyStudioBoxSizeDraft,
19-
applyStudioRotation,
20-
applyStudioRotationDraft,
2120
endStudioManualEditGesture,
2221
isStudioManualEditGestureCurrent,
2322
readStudioBoxSize,
2423
restoreStudioBoxSize,
2524
restoreStudioPathOffset,
26-
restoreStudioRotation,
2725
} from "./manualEdits";
2826
import {
2927
type GroupOverlayItem,
@@ -166,9 +164,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
166164
actualAngle: g.actualRotation,
167165
snap: e.shiftKey,
168166
});
169-
if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
170-
applyStudioRotationDraft(sel.element, rotated);
171-
}
167+
applyRotationDraft(sel.element, rotated.angle);
172168
return;
173169
}
174170

@@ -421,23 +417,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
421417
actualAngle: g.actualRotation,
422418
snap: e.shiftKey,
423419
});
424-
const restoreRotation = () => {
425-
// Single source of truth: snap the GSAP rotation back to the gesture's base
426-
// angle; fall back to the legacy CSS-var restore when gsap is unavailable.
427-
if (!applyRotationDraftViaGsap(sel.element, g.actualRotation)) {
428-
restoreStudioRotation(sel.element, g.initialRotation);
429-
}
430-
};
420+
const restoreRotation = () =>
421+
restoreRotationDraft(sel.element, g.actualRotation, g.initialRotation);
431422
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
432423
restoreRotation();
433424
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
434425
return;
435426
}
436-
// Keep the preview at the final angle through the GSAP channel (NOT the CSS var)
437-
// while the commit lands a `tl.set`/keyframe rotation on the timeline.
438-
if (!applyRotationDraftViaGsap(sel.element, finalRotation.angle)) {
439-
applyStudioRotation(sel.element, finalRotation);
440-
}
427+
// Hold the final angle while the commit lands.
428+
applyRotationDraft(sel.element, finalRotation.angle);
441429
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
442430
.catch((error) => {
443431
logGestureCommitFailure("rotate commit failed", error);
@@ -555,11 +543,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
555543
restoreGestureOverlayRect(g);
556544
}
557545
if (g?.mode === "rotation" && sel) {
558-
applyRotationDraftViaGsap(
559-
sel.element,
560-
g.actualRotation - (Number.parseFloat(g.initialRotation.studioRotation) || 0),
561-
);
562-
restoreStudioRotation(sel.element, g.initialRotation);
546+
restoreRotationDraft(sel.element, g.actualRotation, g.initialRotation);
563547
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
564548
}
565549
opts.blockedMoveRef.current = null;

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@ import { roundTo3 } from "../utils/rounding";
88
* it without pulling the GSAP commit graph into its module scope.
99
*/
1010

11+
const cssValue = (style: CSSStyleDeclaration, prop: string) => {
12+
const value = style.getPropertyValue(prop).trim();
13+
return value === "none" ? "" : value;
14+
};
15+
16+
// Without GSAP, the rotation GSAP will parse from the CSS `rotate`, `scale` and `transform`; with
17+
// `withRotate` false, only the part `scale` and `transform` draw. GSAP folds them into one transform,
18+
// and a list the browser rejects (e.g. `rotate: x 30deg`) leaves it only the plain transform.
19+
export function readCssRotation(element: HTMLElement, withRotate = true): number {
20+
const view = element.ownerDocument.defaultView;
21+
if (!view) return 0;
22+
const style = view.getComputedStyle(element);
23+
const rotate = withRotate ? cssValue(style, "rotate") : "";
24+
const scale = cssValue(style, "scale");
25+
const transform = cssValue(style, "transform");
26+
const angle = (list: string) => {
27+
if (!list) return 0;
28+
const m = new view.DOMMatrix(list);
29+
return (Math.atan2(m.b, m.a) * 180) / Math.PI;
30+
};
31+
const folded = [rotate && `rotate(${rotate})`, scale && `scale(${scale.split(/\s+/).join(",")})`];
32+
try {
33+
return angle([...folded, transform].join(" ").trim());
34+
} catch {
35+
return angle(transform);
36+
}
37+
}
38+
1139
/**
1240
* Translate a studio drag offset into absolute GSAP x/y, accounting for the
1341
* element's rotation and its drag-start base pose. Reads the drag-start

0 commit comments

Comments
 (0)