diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
index 34e6792ad4..8b997b9489 100644
--- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
+++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
@@ -5,7 +5,7 @@
import { type DomEditSelection } from "./domEditing";
import {
createManualOffsetDragMember,
- readGsapRotation,
+ readRotationBase,
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
@@ -15,7 +15,6 @@ import {
captureStudioPathOffset,
captureStudioRotation,
readStudioBoxSize,
- readStudioRotation,
} from "./manualEdits";
import {
type OverlayRect,
@@ -150,10 +149,10 @@ export function startGesture(
return false;
const size = readStudioBoxSize(sel.element);
- // Single-source rotation base = the live GSAP transform rotation plus any legacy
- // `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
- // element's actual visual angle and commits an absolute angle to the timeline.
- const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
+ // Single-source rotation base: GSAP's rotation plus any legacy `--hf-studio-rotation`, or
+ // without GSAP the CSS fold, so a rotate gesture starts from the element's visual angle
+ // and commits an absolute angle to the timeline.
+ const rotation = { angle: readRotationBase(sel.element) };
// The draft writes CSS width/height, so the resize base must be the CSS
// layout size. offsetWidth/Height are transform-free; the overlay-rect
// fallback (rect / editScale) includes the element's own GSAP scale and
diff --git a/packages/studio/src/components/editor/manualEdits.test.ts b/packages/studio/src/components/editor/manualEdits.test.ts
index 832683a300..466a566445 100644
--- a/packages/studio/src/components/editor/manualEdits.test.ts
+++ b/packages/studio/src/components/editor/manualEdits.test.ts
@@ -10,7 +10,6 @@ import {
applyStudioPathOffset,
applyStudioPathOffsetDraft,
applyStudioRotation,
- applyStudioRotationDraft,
beginStudioManualEditGesture,
captureStudioBoxSize,
captureStudioRotation,
@@ -210,16 +209,11 @@ describe("studio manual edits", () => {
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
- applyStudioRotationDraft(card, { angle: -12.2604 });
- expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
- expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
- expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
-
const snapshot = captureStudioRotation(card);
- applyStudioRotationDraft(card, { angle: 45 });
+ applyStudioRotation(card, { angle: 45 });
restoreStudioRotation(card, snapshot);
- expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
- expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
+ expect(readStudioRotation(card)).toEqual({ angle: 24.247 });
+ expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.247deg");
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
});
diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts
index 0df9738b51..031aae549d 100644
--- a/packages/studio/src/components/editor/manualEdits.ts
+++ b/packages/studio/src/components/editor/manualEdits.ts
@@ -29,7 +29,6 @@ export {
applyStudioBoxSize,
applyStudioBoxSizeDraft,
applyStudioRotation,
- applyStudioRotationDraft,
} from "./manualEditsDom";
export { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts
index 848ec82fe0..d8ebe976fa 100644
--- a/packages/studio/src/components/editor/manualEditsDom.ts
+++ b/packages/studio/src/components/editor/manualEditsDom.ts
@@ -522,13 +522,3 @@ export function applyStudioRotation(element: HTMLElement, rotation: { angle: num
composeStudioRotationValue(element, `var(${STUDIO_ROTATION_PROP}, 0deg)`),
);
}
-
-export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle: number }): void {
- promoteInlineForTransform(element);
- writeStudioRotationVars(element, rotation, { updateBase: false });
- element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
- element.style.setProperty(
- "rotate",
- composeStudioRotationValue(element, `${roundTo3(rotation.angle)}deg`),
- );
-}
diff --git a/packages/studio/src/components/editor/manualOffsetDrag.test.ts b/packages/studio/src/components/editor/manualOffsetDrag.test.ts
index 7f6cd6fd80..72b4c1e35f 100644
--- a/packages/studio/src/components/editor/manualOffsetDrag.test.ts
+++ b/packages/studio/src/components/editor/manualOffsetDrag.test.ts
@@ -4,6 +4,8 @@ import {
applyManualOffsetDragCommit,
resumeGsapTimelines,
applyManualOffsetDragDraft,
+ applyRotationDraft,
+ readRotationBase,
applyManualOffsetDragMatrix,
createManualOffsetDragMember,
endManualOffsetDragMembers,
@@ -713,3 +715,37 @@ describe("drag in a composition without GSAP", () => {
);
});
});
+
+describe("rotate in a composition without GSAP", () => {
+ it("starts from the authored CSS rotation and drafts the absolute angle the commit writes", () => {
+ const window = new Window();
+ window.document.head.innerHTML = "";
+ const element = window.document.createElement("h1");
+ element.id = "title";
+ window.document.body.append(element);
+ const base = readRotationBase(element);
+ expect(base).toBeCloseTo(30);
+ applyRotationDraft(element, 55);
+ expect(element.style.getPropertyValue("rotate")).toBe("55deg");
+ });
+
+ it("drafts only the part of the angle the CSS rotate owns, leaving transform and scale theirs", () => {
+ const cases: Array<[string, number, number]> = [
+ ["transform: rotate(30deg);", 30, 25],
+ ["rotate: 20deg; transform: rotate(10deg);", 30, 45],
+ ["scale: -1 1;", 180, 25],
+ ["transform: scaleX(-1);", 180, 25],
+ ];
+ for (const [css, base, drafted] of cases) {
+ const window = new Window();
+ window.document.head.innerHTML = ``;
+ const element = window.document.createElement("h1");
+ element.id = "title";
+ window.document.body.append(element);
+ expect({ css, base: readRotationBase(element) }).toEqual({ css, base: expect.closeTo(base) });
+ applyRotationDraft(element, base + 25);
+ const rotate = Number.parseFloat(element.style.getPropertyValue("rotate"));
+ expect({ css, rotate }).toEqual({ css, rotate: expect.closeTo(drafted) });
+ }
+ });
+});
diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts
index 8c84a5dd54..23efb49831 100644
--- a/packages/studio/src/components/editor/manualOffsetDrag.ts
+++ b/packages/studio/src/components/editor/manualOffsetDrag.ts
@@ -6,12 +6,16 @@ import {
clearStudioPathOffset,
endStudioManualEditGesture,
readAppliedStudioPathOffset,
+ readStudioRotation,
restoreStudioPathOffset,
+ restoreStudioRotation,
+ type StudioRotationSnapshot,
type StudioPathOffsetSnapshot,
} from "./manualEdits";
import {
computeDraggedGsapPosition,
cssTranslateForGsapPosition,
+ readCssRotation,
readGsapPosition,
} from "../../hooks/draggedGsapPosition";
@@ -46,25 +50,34 @@ function applyOffsetDragDraft(member: ManualOffsetDragMember, offset: { x: numbe
gsap.set(element, { x: newX, y: newY });
}
-/**
- * Live rotation preview through the GSAP channel — the SAME channel the commit
- * lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraft`.
- * GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
- * the two channels from composing. `angle` is the absolute target rotation. Returns
- * false when gsap is unavailable (caller falls back to the CSS draft).
- */
-export function applyRotationDraftViaGsap(element: HTMLElement, angle: number): boolean {
+// The rotation draft shows the absolute angle the commit writes: GSAP's rotation, or without GSAP a
+// CSS `rotate` of the angle less what `scale` and `transform` already turn.
+export function applyRotationDraft(element: HTMLElement, angle: number): void {
const gsap = getOffsetDragGsap(element);
- if (!gsap) return false;
- element.style.setProperty("rotate", "none");
- gsap.set(element, { rotation: angle });
- return true;
+ element.style.setProperty(
+ "rotate",
+ gsap ? "none" : `${angle - readCssRotation(element, false)}deg`,
+ );
+ gsap?.set(element, { rotation: angle });
+}
+
+/** Back to the gesture start: the CSS snapshot, and GSAP's rotation without the legacy var. */
+export function restoreRotationDraft(
+ element: HTMLElement,
+ angle: number,
+ snapshot: StudioRotationSnapshot,
+): void {
+ getOffsetDragGsap(element)?.set(element, {
+ rotation: angle - (Number.parseFloat(snapshot.studioRotation) || 0),
+ });
+ restoreStudioRotation(element, snapshot);
}
-/** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */
-export function readGsapRotation(element: HTMLElement): number {
+/** The angle a rotate gesture starts from, as the element shows it. */
+export function readRotationBase(element: HTMLElement): number {
const gsap = getOffsetDragGsap(element);
- return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0;
+ if (!gsap) return readCssRotation(element);
+ return Number(gsap.getProperty(element, "rotation")) + readStudioRotation(element).angle;
}
const DEFAULT_OFFSET_PROBE_PX = 100;
diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts
index 90157345c3..52750659d3 100644
--- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts
+++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts
@@ -9,21 +9,19 @@ import { type DomEditSelection } from "./domEditing";
import {
applyManualOffsetDragCommit,
applyManualOffsetDragDraft,
- applyRotationDraftViaGsap,
+ applyRotationDraft,
endManualOffsetDragMembers,
restoreManualOffsetDragMembers,
+ restoreRotationDraft,
} from "./manualOffsetDrag";
import {
applyStudioBoxSize,
applyStudioBoxSizeDraft,
- applyStudioRotation,
- applyStudioRotationDraft,
endStudioManualEditGesture,
isStudioManualEditGestureCurrent,
readStudioBoxSize,
restoreStudioBoxSize,
restoreStudioPathOffset,
- restoreStudioRotation,
} from "./manualEdits";
import {
type GroupOverlayItem,
@@ -166,9 +164,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
actualAngle: g.actualRotation,
snap: e.shiftKey,
});
- if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
- applyStudioRotationDraft(sel.element, rotated);
- }
+ applyRotationDraft(sel.element, rotated.angle);
return;
}
@@ -421,23 +417,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
actualAngle: g.actualRotation,
snap: e.shiftKey,
});
- const restoreRotation = () => {
- // Single source of truth: snap the GSAP rotation back to the gesture's base
- // angle; fall back to the legacy CSS-var restore when gsap is unavailable.
- if (!applyRotationDraftViaGsap(sel.element, g.actualRotation)) {
- restoreStudioRotation(sel.element, g.initialRotation);
- }
- };
+ const restoreRotation = () =>
+ restoreRotationDraft(sel.element, g.actualRotation, g.initialRotation);
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
restoreRotation();
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
return;
}
- // Keep the preview at the final angle through the GSAP channel (NOT the CSS var)
- // while the commit lands a `tl.set`/keyframe rotation on the timeline.
- if (!applyRotationDraftViaGsap(sel.element, finalRotation.angle)) {
- applyStudioRotation(sel.element, finalRotation);
- }
+ // Hold the final angle while the commit lands.
+ applyRotationDraft(sel.element, finalRotation.angle);
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
.catch((error) => {
logGestureCommitFailure("rotate commit failed", error);
@@ -555,11 +543,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
restoreGestureOverlayRect(g);
}
if (g?.mode === "rotation" && sel) {
- applyRotationDraftViaGsap(
- sel.element,
- g.actualRotation - (Number.parseFloat(g.initialRotation.studioRotation) || 0),
- );
- restoreStudioRotation(sel.element, g.initialRotation);
+ restoreRotationDraft(sel.element, g.actualRotation, g.initialRotation);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
}
opts.blockedMoveRef.current = null;
diff --git a/packages/studio/src/hooks/draggedGsapPosition.ts b/packages/studio/src/hooks/draggedGsapPosition.ts
index 074d8b8a97..3a5f98616f 100644
--- a/packages/studio/src/hooks/draggedGsapPosition.ts
+++ b/packages/studio/src/hooks/draggedGsapPosition.ts
@@ -14,16 +14,24 @@ const cssValue = (style: CSSStyleDeclaration, prop: string) => {
return value === "none" ? "" : value;
};
-function transformTranslation(view: Window & typeof globalThis, list: string) {
- const matrix = list ? new view.DOMMatrix(list) : null;
- return { x: matrix?.m41 ?? 0, y: matrix?.m42 ?? 0 };
+function transformOf(view: Window & typeof globalThis, list: string) {
+ const m = list ? new view.DOMMatrix(list) : null;
+ return {
+ x: m?.m41 ?? 0,
+ y: m?.m42 ?? 0,
+ rotation: m ? (Math.atan2(m.b, m.a) * 180) / Math.PI : 0,
+ };
}
// GSAP writes `translate rotate scale transform` as one inline transform; a value the browser rejects
// (e.g. `rotate: x 30deg`) drops the whole string, leaving GSAP only the plain transform.
-function foldedTranslation(view: Window & typeof globalThis, style: CSSStyleDeclaration) {
+function foldedTransform(
+ view: Window & typeof globalThis,
+ style: CSSStyleDeclaration,
+ withRotate = true,
+) {
const [tx = "", ty = ""] = splitTopLevelWhitespace(cssValue(style, "translate"));
- const rotate = cssValue(style, "rotate");
+ const rotate = withRotate ? cssValue(style, "rotate") : "";
const scale = cssValue(style, "scale");
const transform = cssValue(style, "transform");
const folded = [
@@ -32,9 +40,9 @@ function foldedTranslation(view: Window & typeof globalThis, style: CSSStyleDecl
transform,
];
try {
- return { tx, ty, ...transformTranslation(view, folded.join(" ").trim()) };
+ return { tx, ty, ...transformOf(view, folded.join(" ").trim()) };
} catch {
- return { tx: "", ty: "", ...transformTranslation(view, transform) };
+ return { tx: "", ty: "", ...transformOf(view, transform) };
}
}
@@ -66,7 +74,7 @@ function foldedAxis(translate: string, fromTransform: number, size: number) {
}
function readCssFold(element: HTMLElement, view: Window & typeof globalThis) {
- const moved = foldedTranslation(view, view.getComputedStyle(element));
+ const moved = foldedTransform(view, view.getComputedStyle(element));
return {
x: foldedAxis(moved.tx, moved.x, element.offsetWidth),
y: foldedAxis(moved.ty, moved.y, element.offsetHeight),
@@ -89,6 +97,13 @@ export function readGsapPosition(element: HTMLElement): { x: number; y: number }
return { x: fold.x.value, y: fold.y.value };
}
+// Without GSAP, the rotation GSAP will parse from the CSS `rotate`, `scale` and `transform`; with
+// `withRotate` false, only the part `scale` and `transform` draw.
+export function readCssRotation(element: HTMLElement, withRotate = true): number {
+ const view = element.ownerDocument.defaultView as GsapView | null;
+ return view ? foldedTransform(view, view.getComputedStyle(element), withRotate).rotation : 0;
+}
+
// Without GSAP, the inline `translate` that shows x/y where the committed `gsap.set` will. Null with GSAP.
export function cssTranslateForGsapPosition(
element: HTMLElement,