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; }); }