Skip to content
Open
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
6 changes: 3 additions & 3 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { memo, useMemo, useRef, useState } from "react";
import { Move } from "../../icons/SystemIcons";
import { InspectorHeaderActions } from "./InspectorHeaderActions";
import { useStudioShellContext } from "../../contexts/StudioContext";
import { readStudioBoxSize, readStudioRotation } from "./manualEdits";
import { readMoveOffset } from "./plainTranslate";
import { readStudioBoxSize } from "./manualEdits";
import { readMoveOffset, readShownRotation } from "./plainTranslate";
import {
buildElementInfoText,
EMPTY_STYLES,
Expand Down Expand Up @@ -210,7 +210,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
manualSize.height > 0
? manualSize.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
const manualRotation = readStudioRotation(element.element);
const manualRotation = readShownRotation(element.element);

const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -63,11 +64,12 @@ export interface GestureState {
actualWidth: number;
actualHeight: number;
actualRotation: number;
/** 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-per-CSS-pixel factor of the element itself at gesture start (a GSAP
// scale() transform makes this > 1) — the resize draft divides by it so the box
// follows the cursor instead of overshooting by the live scale.
// Rendered px per CSS px of the element at gesture start (> 1 under a GSAP scale()); the resize
// draft divides by it so the box follows the cursor instead of overshooting by the live scale.
contentScaleX: number;
contentScaleY: number;
// Resize anchor pinning: with a live scale transform, growing the CSS box
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,95 @@ describe("a drag press on a centred element without GSAP", () => {
expect(element.getAttribute("style")).toBe(style);
});
});

describe("a rotate on a page that loads GSAP", () => {
it("never asks GSAP about an element it does not turn, and draws the turn as its CSS rotate", () => {
const getProperty = vi.fn(() => 0);
const set = vi.fn();
const element = document.createElement("div");
element.style.setProperty("rotate", "30deg");
document.body.append(element);
Object.assign(window, {
gsap: { getProperty, set },
__timelines: { main: { getChildren: () => [] } },
});
const selection = { element, capabilities: { canApplyManualRotation: true } };
const ref = <T>(current: T) => ({ current });
const opts = {
selectionRef: ref(selection as unknown as DomEditSelection),
overlayRectRef: ref({ left: 0, top: 0, width: 50, height: 40, editScaleX: 1, editScaleY: 1 }),
boxRef: ref(document.createElement("div")),
overlayRef: ref(null),
iframeRef: ref(null),
gestureRef: ref<GestureState | null>(null),
groupGestureRef: ref(null),
blockedMoveRef: ref(null),
rafPausedRef: ref(false),
onCanvasPointerMoveRef: ref(vi.fn()),
};
const pointer = (clientX: number, clientY: number) => ({
clientX,
clientY,
pointerId: 1,
button: 0,
shiftKey: false,
preventDefault() {},
stopPropagation() {},
currentTarget: { setPointerCapture() {} },
});

const handlers = createDomEditOverlayGestureHandlers(opts as never);
expect(handlers.startGesture("rotate", pointer(25, -20) as never)).toBe(true);
expect(opts.gestureRef.current?.plainRotation).toEqual({
property: "rotate",
before: "",
after: "",
share: 0,
sign: 1,
inline: false,
});
expect(opts.gestureRef.current?.actualRotation).toBeCloseTo(30);
handlers.onPointerMove(pointer(60, 20) as never);

expect(element.style.getPropertyValue("rotate")).toMatch(/deg$/);
expect(element.style.getPropertyValue("rotate")).not.toBe("30deg");
expect(getProperty).not.toHaveBeenCalled();
expect(set).not.toHaveBeenCalled();
});
});

describe("a rotate press on an element a GSAP tween turns", () => {
it.each(["rotation", "rotate", "rotateZ"])("reads its base from GSAP (%s)", (channel) => {
const getProperty = vi.fn(() => 40);
const element = document.createElement("div");
document.body.append(element);
const tween = { targets: () => [element], vars: { [channel]: 40 }, duration: () => 2 };
const timelines = { main: { getChildren: () => [tween] } };
Object.assign(window, { gsap: { getProperty, set: vi.fn() }, __timelines: timelines });
const ref = <T>(current: T) => ({ current });
const selection = { element, capabilities: { canApplyManualRotation: true } };
const opts = {
selectionRef: ref(selection as unknown as DomEditSelection),
overlayRectRef: ref({ left: 0, top: 0, width: 50, height: 40, editScaleX: 1, editScaleY: 1 }),
boxRef: ref(document.createElement("div")),
overlayRef: ref(null),
iframeRef: ref(null),
gestureRef: ref<GestureState | null>(null),
rafPausedRef: ref(false),
};
const press = {
clientX: 25,
clientY: -20,
pointerId: 1,
button: 0,
preventDefault() {},
stopPropagation() {},
currentTarget: { setPointerCapture() {} },
};
const handlers = createDomEditOverlayGestureHandlers(opts as never);
expect(handlers.startGesture("rotate", press as never)).toBe(true);
expect(opts.gestureRef.current?.plainRotation).toBeNull();
expect(opts.gestureRef.current?.actualRotation).toBe(40);
expect(getProperty).toHaveBeenCalledWith(element, "rotation");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,16 @@ import { readElementGsapNumber } from "../../utils/elementGsap";
import { type DomEditSelection } from "./domEditing";
import {
createManualOffsetDragMember,
readGsapRotation,
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
import {
beginStudioManualEditGesture,
captureStudioBoxSize,
captureStudioPathOffset,
captureStudioRotation,
readStudioBoxSize,
readStudioRotation,
} from "./manualEdits";
import {
type OverlayRect,
Expand All @@ -32,6 +31,7 @@ import {
type UseDomEditOverlayGesturesOptions,
} from "./domEditOverlayGestures";
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
import { gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";

Expand Down Expand Up @@ -230,10 +230,11 @@ export function startGesture(
}
}

// Rotation base = GSAP's rotation plus the legacy `--hf-studio-rotation` var. A plain-translate
// move never asks GSAP: reading a property makes it bake the CSS translate into its transform.
const gsapRotation = pathOffsetMember?.plainTranslate ? 0 : readGsapRotation(sel.element);
const rotation = { angle: gsapRotation + readStudioRotation(sel.element).angle };
// 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 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;
const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
Expand Down Expand Up @@ -281,6 +282,7 @@ export function startGesture(
actualWidth,
actualHeight,
actualRotation: rotation.angle,
plainRotation,
editScaleX: rect.editScaleX,
editScaleY: rect.editScaleY,
contentScaleX,
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 @@ -538,13 +538,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`),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ import {
buildClearPathOffsetPatches,
buildBoxSizePatches,
buildClearBoxSizePatches,
buildRotationPatches,
buildClearRotationPatches,
buildMotionPatches,
buildClearMotionPatches,
Expand Down Expand Up @@ -335,45 +334,7 @@ describe("anchored-corner combined patch: [...buildBoxSizePatches, ...buildPathO

/* ── Rotation ────────────────────────────────────────────────────────────── */

describe("buildRotationPatches / buildClearRotationPatches", () => {
function populatedRotEl(): HTMLElement {
const e = div();
e.style.setProperty(STUDIO_ROTATION_PROP, "45");
e.style.setProperty("rotate", "45deg");
e.style.setProperty("transform-origin", "left center");
e.style.setProperty("display", "block");
e.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, "0deg");
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "0deg");
e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "center center");
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "flex");
return e;
}

it("populated: captures rotation styles, attrs, and transform-display marker in declaration order", () => {
const ops = buildRotationPatches(populatedRotEl());
expect(ops).toEqual([
{ type: "inline-style", property: STUDIO_ROTATION_PROP, value: "45" },
{ type: "inline-style", property: "rotate", value: "45deg" },
{ type: "inline-style", property: "transform-origin", value: "left center" },
{ type: "inline-style", property: "display", value: "block" },
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: "0deg" },
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: "0deg" },
{
type: "attribute",
property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
value: "center center",
},
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "flex" },
]);
});

it("empty: bare element yields only the rotation marker", () => {
expect(buildRotationPatches(div())).toEqual([
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
]);
});

describe("buildClearRotationPatches", () => {
it("clear: restores rotate and transform-origin from orig attrs, nulls draft attr", () => {
const e = div();
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "30deg");
Expand Down Expand Up @@ -405,11 +366,6 @@ describe("buildRotationPatches / buildClearRotationPatches", () => {
const ops = buildClearRotationPatches(e);
expect(ops.find((o) => o.property === "rotate")?.value).toBeNull();
});

it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
const e = populatedRotEl();
assertClearCoversKeys(buildRotationPatches(e), buildClearRotationPatches(e));
});
});

/* ── Motion ──────────────────────────────────────────────────────────────── */
Expand Down
22 changes: 0 additions & 22 deletions packages/studio/src/components/editor/manualEditsDomPatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,28 +185,6 @@ export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[]

/* ── Rotation patches ────────────────────────────────────────────── */

const ROTATION_STYLE_PROPS = [
STUDIO_ROTATION_PROP,
"rotate",
"transform-origin",
"display",
] as const;

const ROTATION_ORIG_ATTRS = [
STUDIO_ORIGINAL_ROTATE_ATTR,
STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
] as const;

export function buildRotationPatches(element: HTMLElement): PatchOperation[] {
const ops: PatchOperation[] = [];
collectInlineStyleOps(element, ROTATION_STYLE_PROPS, ops);
ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" });
collectAttributeOps(element, ROTATION_ORIG_ATTRS, ops);
return ops;
}

export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] {
const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
const origRotationTransformOrigin = element.getAttribute(
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/components/editor/manualEditsSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapsho
export function captureStudioRotation(element: HTMLElement): StudioRotationSnapshot {
return {
rotate: element.style.getPropertyValue("rotate"),
transform: element.style.getPropertyValue("transform"),
display: element.style.getPropertyValue("display"),
transformOrigin: element.style.getPropertyValue("transform-origin"),
studioRotation: element.style.getPropertyValue(STUDIO_ROTATION_PROP),
marker: element.getAttribute(STUDIO_ROTATION_ATTR),
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/components/editor/manualEditsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export interface StudioBoxSizeSnapshot {

export interface StudioRotationSnapshot {
rotate: string;
transform: string;
display: string;
transformOrigin: string;
studioRotation: string;
marker: string | null;
Expand Down
23 changes: 1 addition & 22 deletions packages/studio/src/components/editor/manualOffsetDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface OffsetDragGsap {
getProperty: (el: Element, prop: string) => number;
}

function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
export function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
const win = element.ownerDocument.defaultView as
| (Window & { gsap?: Partial<OffsetDragGsap> })
| null;
Expand Down Expand Up @@ -53,27 +53,6 @@ function applyOffsetDragDraftViaGsap(
return true;
}

/**
* Live rotation preview through the GSAP channel — the SAME channel the commit
* lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
* 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 {
const gsap = getOffsetDragGsap(element);
if (!gsap) return false;
element.style.setProperty("rotate", "none");
gsap.set(element, { rotation: angle });
return true;
}

/** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */
export function readGsapRotation(element: HTMLElement): number {
const gsap = getOffsetDragGsap(element);
return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0;
}

const DEFAULT_OFFSET_PROBE_PX = 100;
const MIN_PROBE_VECTOR_LENGTH_PX = 0.01;
const MIN_MATRIX_DETERMINANT = 0.000001;
Expand Down
Loading
Loading