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,