Skip to content
Merged
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: 2 additions & 4 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { RotationCommit } from "./rotationDraft";
import { memo, useEffect, useMemo, useRef, type RefObject } from "react";
import { type DomEditSelection } from "./domEditing";
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
Expand Down Expand Up @@ -91,10 +92,7 @@ export interface DomEditOverlayProps {
offset?: { x: number; y: number },
restore?: () => void,
) => Promise<unknown> | void;
onRotationCommit: (
selection: DomEditSelection,
next: { angle: number },
) => Promise<unknown> | void;
onRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<unknown> | void;
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
recordingState?: GestureRecordingState;
onToggleRecording?: () => void;
Expand Down
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
10 changes: 6 additions & 4 deletions packages/studio/src/components/editor/domEditOverlayGestures.ts
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, RotationCommit } 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;
manualEditDragToken?: string;
Expand Down Expand Up @@ -266,7 +268,7 @@ export type UseDomEditOverlayGesturesOptions = {
) => Promise<unknown> | void
>;
onRotationCommitRef: RefObject<
(s: DomEditSelection, n: { angle: number }) => Promise<unknown> | void
(s: DomEditSelection, n: RotationCommit) => Promise<unknown> | void
>;
onCanvasPointerMoveRef: RefObject<
(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,95 @@ describe("a group drag of elements without GSAP", () => {
expect(updates.map((update) => update.plainTranslate)).toEqual([true, true]);
});
});

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 { type DomEditSelection } from "./domEditing";
import {
applyManualOffsetDragDraft,
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 @@ -219,10 +219,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 @@ -270,6 +271,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
Loading
Loading