diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts
index de41251004..a69f07a794 100644
--- a/packages/studio/src/components/editor/domEditOverlayGestures.ts
+++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts
@@ -6,6 +6,7 @@ import type {
StudioRotationSnapshot,
} from "./manualEdits";
import type { ManualOffsetDragMember } from "./manualOffsetDrag";
+import type { CssRotationTarget } from "./rotationDraft";
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
import type { SnapContext } from "./snapTargetCollection";
import type { SnapGuidesState } from "./SnapGuideOverlay";
@@ -63,8 +64,8 @@ export interface GestureState {
actualWidth: number;
actualHeight: number;
actualRotation: number;
- /** Null when GSAP owns the rotate; else what the element's scale and transform turn, read at press. */
- plainRotationShare: number | null;
+ /** Null when GSAP owns the rotate; else where its CSS turn is drawn and saved, read at press. */
+ plainRotation: CssRotationTarget | null;
editScaleX: number;
editScaleY: number;
// Rendered px per CSS px of the element at gesture start (> 1 under a GSAP scale()); the resize
diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts
index d155119fe1..a85d609fd5 100644
--- a/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts
+++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts
@@ -106,7 +106,11 @@ describe("a rotate on a page that loads GSAP", () => {
const handlers = createDomEditOverlayGestureHandlers(opts as never);
expect(handlers.startGesture("rotate", pointer(25, -20) as never)).toBe(true);
- expect(opts.gestureRef.current?.plainRotationShare).toBe(0);
+ expect(opts.gestureRef.current?.plainRotation).toEqual({
+ property: "rotate",
+ prefix: "",
+ share: 0,
+ });
expect(opts.gestureRef.current?.actualRotation).toBeCloseTo(30);
handlers.onPointerMove(pointer(60, 20) as never);
diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
index a614ca983b..6df8ff6e0c 100644
--- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
+++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts
@@ -9,7 +9,7 @@ import {
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
-import { readRotationBase } from "./rotationDraft";
+import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
import {
beginStudioManualEditGesture,
captureStudioBoxSize,
@@ -32,7 +32,6 @@ import {
} from "./domEditOverlayGestures";
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
import { gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
-import { readCssRotation } from "../../hooks/draggedGsapPosition";
import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
@@ -234,8 +233,7 @@ export function startGesture(
// Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
// move, never asks GSAP: reading a property makes it bake the CSS into its transform.
const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
- const plainRotationShare =
- plain && kind === "rotate" ? readCssRotation(sel.element, false) : null;
+ const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
const rotation = { angle: readRotationBase(sel.element, plain) };
const overlayBounds = overlayEl?.getBoundingClientRect();
const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
@@ -284,7 +282,7 @@ export function startGesture(
actualWidth,
actualHeight,
actualRotation: rotation.angle,
- plainRotationShare,
+ plainRotation,
editScaleX: rect.editScaleX,
editScaleY: rect.editScaleY,
contentScaleX,
diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts
index 1fb3569869..e2cf99966d 100644
--- a/packages/studio/src/components/editor/manualEditsSnapshot.ts
+++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts
@@ -82,6 +82,7 @@ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapsho
export function captureStudioRotation(element: HTMLElement): StudioRotationSnapshot {
return {
rotate: element.style.getPropertyValue("rotate"),
+ transform: element.style.getPropertyValue("transform"),
transformOrigin: element.style.getPropertyValue("transform-origin"),
studioRotation: element.style.getPropertyValue(STUDIO_ROTATION_PROP),
marker: element.getAttribute(STUDIO_ROTATION_ATTR),
diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts
index d39c9df0e5..4c8dbf0fc6 100644
--- a/packages/studio/src/components/editor/manualEditsTypes.ts
+++ b/packages/studio/src/components/editor/manualEditsTypes.ts
@@ -99,6 +99,7 @@ export interface StudioBoxSizeSnapshot {
export interface StudioRotationSnapshot {
rotate: string;
+ transform: string;
transformOrigin: string;
studioRotation: string;
marker: string | null;
diff --git a/packages/studio/src/components/editor/rotationDraft.test.ts b/packages/studio/src/components/editor/rotationDraft.test.ts
index 86ee1a24db..f03b6619a5 100644
--- a/packages/studio/src/components/editor/rotationDraft.test.ts
+++ b/packages/studio/src/components/editor/rotationDraft.test.ts
@@ -1,7 +1,6 @@
import { Window } from "happy-dom";
import { describe, expect, it, vi } from "vitest";
-import { readCssRotation } from "../../hooks/draggedGsapPosition";
-import { applyRotationDraft, readRotationBase } from "./rotationDraft";
+import { applyRotationDraft, readCssRotationTarget, readRotationBase } from "./rotationDraft";
describe("rotate in a composition without GSAP", () => {
it("starts from the authored CSS rotation and drafts the absolute angle the commit writes", () => {
@@ -12,7 +11,7 @@ describe("rotate in a composition without GSAP", () => {
window.document.body.append(element);
const base = readRotationBase(element, true);
expect(base).toBeCloseTo(30);
- applyRotationDraft(element, 55, readCssRotation(element, false));
+ applyRotationDraft(element, 55, readCssRotationTarget(element));
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
});
@@ -33,7 +32,7 @@ describe("rotate in a composition without GSAP", () => {
css,
base: expect.closeTo(base),
});
- applyRotationDraft(element, base + 25, readCssRotation(element, false));
+ applyRotationDraft(element, base + 25, readCssRotationTarget(element));
const rotate = Number.parseFloat(element.style.getPropertyValue("rotate"));
expect({ css, rotate }).toEqual({ css, rotate: expect.closeTo(drafted) });
}
@@ -48,9 +47,9 @@ describe("rotate in a composition without GSAP", () => {
element.id = "title";
window.document.body.append(element);
expect(readRotationBase(element, true)).toBeCloseTo(30);
- const share = readCssRotation(element, false);
+ const target = readCssRotationTarget(element);
const styleReads = vi.spyOn(window, "getComputedStyle");
- applyRotationDraft(element, 55, share);
+ applyRotationDraft(element, 55, target);
expect(styleReads).not.toHaveBeenCalled();
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
expect(gsap.set).not.toHaveBeenCalled();
@@ -59,4 +58,29 @@ describe("rotate in a composition without GSAP", () => {
expect(element.style.getPropertyValue("rotate")).toBe("none");
expect(gsap.set).toHaveBeenCalledWith(element, { rotation: 55 });
});
+
+ it("turns a transform-centred element inside its transform, after the translate, so it stays put", () => {
+ const window = new Window();
+ window.document.head.innerHTML =
+ "";
+ const element = window.document.createElement("h1");
+ element.id = "title";
+ window.document.body.append(element);
+ const target = readCssRotationTarget(element);
+ expect(target).toEqual({
+ property: "transform",
+ prefix: "translate(-120px, -80px)",
+ share: expect.closeTo(30),
+ });
+ applyRotationDraft(element, 55, target);
+ expect(element.style.getPropertyValue("transform")).toBe(
+ "translate(-120px, -80px) rotate(25deg)",
+ );
+ expect(element.style.getPropertyValue("rotate")).toBe("");
+ // The next rotate replaces its own trailing turn instead of stacking a second one.
+ applyRotationDraft(element, 70, readCssRotationTarget(element));
+ expect(element.style.getPropertyValue("transform")).toBe(
+ "translate(-120px, -80px) rotate(40deg)",
+ );
+ });
});
diff --git a/packages/studio/src/components/editor/rotationDraft.ts b/packages/studio/src/components/editor/rotationDraft.ts
index f945e0135b..be2efc7759 100644
--- a/packages/studio/src/components/editor/rotationDraft.ts
+++ b/packages/studio/src/components/editor/rotationDraft.ts
@@ -1,5 +1,6 @@
import { readCssRotation } from "../../hooks/draggedGsapPosition";
import { roundTo3 } from "../../utils/rounding";
+import type { PatchOperation } from "../../utils/sourcePatcher";
import {
readStudioRotation,
restoreStudioRotation,
@@ -7,24 +8,78 @@ import {
} from "./manualEdits";
import { getOffsetDragGsap } from "./manualOffsetDrag";
-/** The element's own `rotate` that shows `angle`, less `share`, what its `scale` and `transform` turn. */
+const TRAILING_TURN = /\s*rotate\(\s*-?[\d.]+(?:e[+-]?\d+)?deg\s*\)\s*$/;
+
+// ponytail: the authored transform if it moves the box, from the last matching rule in sheet order;
+// specificity, !important and @media are not weighed. Weigh them when a film's rule is missed.
+function translatingTransform(element: HTMLElement): string {
+ const view = element.ownerDocument.defaultView;
+ const computed = view?.getComputedStyle(element).transform ?? "none";
+ const m = computed === "none" ? null : new view!.DOMMatrix(computed);
+ if (!m || (m.m41 === 0 && m.m42 === 0)) return "";
+ let value = element.style.getPropertyValue("transform");
+ for (const sheet of value ? [] : Array.from(element.ownerDocument.styleSheets)) {
+ let rules: CSSRule[];
+ try {
+ rules = Array.from(sheet.cssRules);
+ } catch {
+ continue; // a cross-origin sheet
+ }
+ for (const rule of rules as CSSStyleRule[]) {
+ const declared = rule.style?.getPropertyValue("transform");
+ if (declared && element.matches(rule.selectorText)) value = declared;
+ }
+ }
+ return value === "none" ? "" : value;
+}
+
+/** Where a plain rotate draws its turn, read once at press: the element's own `rotate`, or, when its
+ * transform translates it (often the translate(-50%, -50%) centring), a trailing rotate() in that
+ * transform, so the translate is not turned with the box. `share` is what the rest already turns. */
+export interface CssRotationTarget {
+ property: "rotate" | "transform";
+ prefix: string;
+ share: number;
+}
+
+export function readCssRotationTarget(element: HTMLElement): CssRotationTarget {
+ const transform = translatingTransform(element);
+ if (!transform) return { property: "rotate", prefix: "", share: readCssRotation(element, false) };
+ const prefix = transform.replace(TRAILING_TURN, "");
+ const style = element.style;
+ const inline = [style.getPropertyValue("transform"), style.getPropertyPriority("transform")];
+ style.setProperty("transform", prefix || "none");
+ const share = readCssRotation(element);
+ style.setProperty("transform", inline[0] ?? "", inline[1] ?? "");
+ return { property: "transform", prefix, share };
+}
+
+/** Draws `angle` where `target` says, and returns the source patch that saves it as drawn. */
export function applyCssRotation(
element: HTMLElement,
angle: number,
- share = readCssRotation(element, false),
-): void {
- element.style.setProperty("rotate", `${roundTo3(angle - share)}deg`);
+ target = readCssRotationTarget(element),
+): PatchOperation & { value: string } {
+ const turn = `${roundTo3(angle - target.share)}deg`;
+ const value = target.property === "rotate" ? turn : `${target.prefix} rotate(${turn})`.trim();
+ element.style.setProperty(target.property, value);
+ return { type: "inline-style", property: target.property, value };
+}
+
+/** Back to the press: the rotation snapshot and, for a plain rotate, the inline transform it drew in. */
+export function restorePlainRotation(element: HTMLElement, snapshot: StudioRotationSnapshot): void {
+ restoreStudioRotation(element, snapshot);
+ element.style.setProperty("transform", snapshot.transform);
}
-// `plainShare` is decided once, at gesture start: a number when GSAP turns nothing on the element (the
-// rotate draws its own CSS `rotate`), null for GSAP's rotation, as the commit writes it.
+// `plain`, read at press: where a turn GSAP does not own draws; null for GSAP's rotation.
export function applyRotationDraft(
element: HTMLElement,
angle: number,
- plainShare: number | null,
+ plain: CssRotationTarget | null,
): void {
- const gsap = plainShare === null ? getOffsetDragGsap(element) : null;
- if (!gsap) return applyCssRotation(element, angle, plainShare ?? undefined);
+ const gsap = plain ? null : getOffsetDragGsap(element);
+ if (!gsap) return void applyCssRotation(element, angle, plain ?? undefined);
element.style.setProperty("rotate", "none");
gsap.set(element, { rotation: angle });
}
@@ -39,7 +94,8 @@ export function restoreRotationDraft(
snapshot: StudioRotationSnapshot,
plain: boolean,
): void {
- rotationGsap(element, plain)?.set(element, {
+ if (plain) return restorePlainRotation(element, snapshot);
+ getOffsetDragGsap(element)?.set(element, {
rotation: angle - (Number.parseFloat(snapshot.studioRotation) || 0),
});
restoreStudioRotation(element, snapshot);
diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts
index fcc9e1351c..69dcf75c63 100644
--- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts
+++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts
@@ -163,7 +163,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
actualAngle: g.actualRotation,
snap: e.shiftKey,
});
- applyRotationDraft(sel.element, rotated.angle, g.plainRotationShare);
+ applyRotationDraft(sel.element, rotated.angle, g.plainRotation);
return;
}
@@ -421,7 +421,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
sel.element,
g.actualRotation,
g.initialRotation,
- g.plainRotationShare !== null,
+ g.plainRotation !== null,
);
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
restoreRotation();
@@ -429,7 +429,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
return;
}
// Hold the final angle while the commit lands.
- applyRotationDraft(sel.element, finalRotation.angle, g.plainRotationShare);
+ applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
.catch((error) => {
logGestureCommitFailure("rotate commit failed", error);
@@ -551,7 +551,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
sel.element,
g.actualRotation,
g.initialRotation,
- g.plainRotationShare !== null,
+ g.plainRotation !== null,
);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
}
diff --git a/packages/studio/src/hooks/plainRotation.test.ts b/packages/studio/src/hooks/plainRotation.test.ts
index 4671892be3..19c5da12f3 100644
--- a/packages/studio/src/hooks/plainRotation.test.ts
+++ b/packages/studio/src/hooks/plainRotation.test.ts
@@ -75,4 +75,21 @@ describe("savePlainRotation", () => {
expect(element.style.getPropertyValue("rotate")).toBe("");
expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
});
+
+ it("saves a transform-centred element's turn in its transform and puts it back on a failed save", async () => {
+ const element = document.createElement("div");
+ element.style.transform = "translate(-120px, -80px)";
+ const { saved, commitPositionPatchToHtml } = rotate(element, 25, {
+ save: () => Promise.reject(new Error("offline")),
+ });
+ expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
+ {
+ type: "inline-style",
+ property: "transform",
+ value: "translate(-120px, -80px) rotate(25deg)",
+ },
+ ]);
+ await expect(saved).rejects.toThrow("offline");
+ expect(element.style.getPropertyValue("transform")).toBe("translate(-120px, -80px)");
+ });
});
diff --git a/packages/studio/src/hooks/plainRotation.ts b/packages/studio/src/hooks/plainRotation.ts
index df983d33d5..3ab76ef31a 100644
--- a/packages/studio/src/hooks/plainRotation.ts
+++ b/packages/studio/src/hooks/plainRotation.ts
@@ -1,18 +1,14 @@
import type { DomEditSelection } from "../components/editor/domEditing";
-import {
- captureStudioRotation,
- clearStudioRotation,
- restoreStudioRotation,
-} from "../components/editor/manualEdits";
+import { captureStudioRotation, clearStudioRotation } from "../components/editor/manualEdits";
import { buildClearRotationPatches } from "../components/editor/manualEditsDomPatches";
import { STUDIO_ROTATION_ATTR } from "../components/editor/manualEditsTypes";
-import { applyCssRotation } from "../components/editor/rotationDraft";
+import { applyCssRotation, restorePlainRotation } from "../components/editor/rotationDraft";
import type { PatchOperation } from "../utils/sourcePatcher";
import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
let plainRotateCounter = 0;
-/** GSAP does not turn the element: `next` is its whole angle, drawn and saved as its own `rotate`. */
+/** GSAP does not turn the element: `next` is its whole angle, drawn and saved as its CSS turn. */
export function savePlainRotation(
{ commitPositionPatchToHtml, readOnlyPreview }: Omit,
selection: DomEditSelection,
@@ -26,15 +22,16 @@ export function savePlainRotation(
? buildClearRotationPatches(element)
: [];
if (patches.length) clearStudioRotation(element);
- applyCssRotation(element, next.angle);
- const value = element.style.getPropertyValue("rotate");
- patches.push({ type: "inline-style", property: "rotate", value });
+ const drawn = applyCssRotation(element, next.angle);
+ patches.push(drawn);
return commitPositionPatchToHtml(selection, patches, {
label: "Rotate layer",
coalesceKey: `rotate:${++plainRotateCounter}`,
coalesceMs: Number.POSITIVE_INFINITY,
}).catch((error) => {
- if (element.style.getPropertyValue("rotate") === value) restoreStudioRotation(element, before);
+ if (element.style.getPropertyValue(drawn.property) === drawn.value) {
+ restorePlainRotation(element, before);
+ }
throw error;
});
}