Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { type DomEditSelection } from "./domEditing";
import {
createManualOffsetDragMember,
readGsapRotation,
readRotationBase,
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
Expand All @@ -15,7 +15,6 @@ import {
captureStudioPathOffset,
captureStudioRotation,
readStudioBoxSize,
readStudioRotation,
} from "./manualEdits";
import {
type OverlayRect,
Expand Down Expand Up @@ -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
Expand Down
12 changes: 3 additions & 9 deletions packages/studio/src/components/editor/manualEdits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
applyStudioPathOffset,
applyStudioPathOffsetDraft,
applyStudioRotation,
applyStudioRotationDraft,
beginStudioManualEditGesture,
captureStudioBoxSize,
captureStudioRotation,
Expand Down Expand Up @@ -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");
});

Expand Down
1 change: 0 additions & 1 deletion packages/studio/src/components/editor/manualEdits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export {
applyStudioBoxSize,
applyStudioBoxSizeDraft,
applyStudioRotation,
applyStudioRotationDraft,
} from "./manualEditsDom";

export { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
Expand Down
10 changes: 0 additions & 10 deletions packages/studio/src/components/editor/manualEditsDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
);
}
36 changes: 36 additions & 0 deletions packages/studio/src/components/editor/manualOffsetDrag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
applyManualOffsetDragCommit,
resumeGsapTimelines,
applyManualOffsetDragDraft,
applyRotationDraft,
readRotationBase,
applyManualOffsetDragMatrix,
createManualOffsetDragMember,
endManualOffsetDragMembers,
Expand Down Expand Up @@ -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 = "<style>#title { rotate: 30deg; }</style>";
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 = `<style>#title { ${css} }</style>`;
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) });
}
});
});
43 changes: 28 additions & 15 deletions packages/studio/src/components/editor/manualOffsetDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
32 changes: 8 additions & 24 deletions packages/studio/src/components/editor/useDomEditOverlayGestures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 23 additions & 8 deletions packages/studio/src/hooks/draggedGsapPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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) };
}
}

Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand Down
Loading