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: 3 additions & 3 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +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, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import { readStudioBoxSize, readStudioRotation } from "./manualEdits";
import { readMoveOffset } from "./plainTranslate";
import {
buildElementInfoText,
EMPTY_STYLES,
Expand Down Expand Up @@ -199,7 +200,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
// selection) so the Timing section shows for pure-GSAP elements with no data-start.
const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length));
const showEditableSections = element.capabilities.canEditStyles && sections.style;
const manualOffset = readStudioPathOffset(element.element);
const manualOffset = readMoveOffset(element.element);
const manualSize = readStudioBoxSize(element.element);
const resolvedWidth =
manualSize.width > 0
Expand All @@ -209,7 +210,6 @@ 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 elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditing";
import type { GestureState } from "./domEditOverlayGestures";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";

afterEach(() => {
delete (window as { gsap?: unknown }).gsap;
delete (window as { __timelines?: unknown }).__timelines;
document.body.innerHTML = "";
});

const PRESS = {
clientX: 10,
clientY: 10,
pointerId: 1,
button: 0,
preventDefault() {},
stopPropagation() {},
currentTarget: { setPointerCapture() {} },
};

/** Presses a drag on the element and returns the gesture it started. */
function pressDrag(element: HTMLElement): GestureState | null {
const opts = pressOptions(element);
expect(
createDomEditOverlayGestureHandlers(opts as never).startGesture("drag", PRESS as never),
).toBe(true);
return opts.gestureRef.current;
}

function pressOptions(element: HTMLElement) {
const ref = <T>(current: T) => ({ current });
const selection = { element, capabilities: { canApplyManualOffset: true } };
return {
selectionRef: ref(selection as unknown as DomEditSelection),
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
boxRef: ref(null),
overlayRef: ref(null),
iframeRef: ref(null),
gestureRef: ref<GestureState | null>(null),
rafPausedRef: ref(false),
onManualDragStartRef: ref(vi.fn()),
onBlockedMoveRef: ref(vi.fn()),
};
}

describe("a drag press on a page that loads GSAP", () => {
it.each([
["GSAP animates nothing", false],
["GSAP animates only its parent", true],
])(
"%s: the press never asks GSAP about the element, so its translate stays CSS",
(_, parentTween) => {
const getProperty = vi.fn(() => 0);
const set = vi.fn();
const parent = document.createElement("div");
const element = document.createElement("div");
element.style.setProperty("translate", "40px 30px");
parent.append(element);
document.body.append(parent);
const tween = { targets: () => [parent], vars: { x: 100 }, duration: () => 2 };
const timelines = { main: { getChildren: () => (parentTween ? [tween] : []) } };
Object.assign(window, { gsap: { getProperty, set }, __timelines: timelines });
expect(pressDrag(element)?.pathOffsetMember?.plainTranslate).toBe(true);
expect(getProperty).not.toHaveBeenCalled();
expect(set).not.toHaveBeenCalled();
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
},
);
});

describe("a drag press on a centred element without GSAP", () => {
it("starts from its -50% translate in px and leaves its style as it was", () => {
const element = document.createElement("div");
element.style.cssText =
"position: absolute; left: 50%; top: 50%; width: 240px; height: 160px; translate: -50% -50%";
document.body.append(element);
const style = element.getAttribute("style");
expect(pressDrag(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
expect(element.getAttribute("style")).toBe(style);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function startGroupDrag(
selection: item.selection,
element: item.element,
rect: item.rect,
gesture: "drag",
});
if (!result.ok) {
restoreManualOffsetDragMembers(members);
Expand Down Expand Up @@ -151,10 +152,6 @@ 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 };
// 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 Expand Up @@ -198,6 +195,7 @@ export function startGesture(
selection: sel,
element: sel.element,
rect,
gesture: "drag",
});
if (!result.ok) {
opts.onBlockedMoveRef.current(result.selection);
Expand Down Expand Up @@ -232,6 +230,10 @@ 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 };
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
70 changes: 70 additions & 0 deletions packages/studio/src/components/editor/manualOffsetDrag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
resumeGsapTimelines,
applyManualOffsetDragDraft,
applyManualOffsetDragMatrix,
applyManualOffsetNudgeDraft,
createManualOffsetDragMember,
endManualOffsetDragMembers,
invertManualOffsetDragMatrix,
Expand Down Expand Up @@ -566,3 +567,72 @@ describe("resumeGsapTimelines", () => {
expect(() => resumeGsapTimelines(element)).not.toThrow();
});
});

describe("a move of an element GSAP does not position", () => {
function plainBox(options: { gsapLoaded?: boolean } = {}) {
const window = new Window();
const element = window.document.createElement("div");
window.document.body.append(element);
element.style.setProperty("translate", "40px 30px");
// A rect that never moves: the probe falls back to the 1:1 preview-scale mapping.
element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
const gsapCalls: string[] = [];
if (options.gsapLoaded) {
Object.assign(window, {
gsap: { set: () => gsapCalls.push("set"), getProperty: () => gsapCalls.push("read") },
});
}
const member = (gesture: "drag" | "nudge" = "drag") => {
const result = createManualOffsetDragMember({
key: "box",
selection: { element } as never,
element,
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
gesture,
});
if (!result.ok) throw new Error(result.reason);
return result.member;
};
return { window, element, gsapCalls, member };
}

it("drafts and drops the element's own translate in plain px, never touching GSAP", () => {
const { element, gsapCalls, member } = plainBox({ gsapLoaded: true });
const m = member();
expect(m.plainTranslate).toBe(true);
applyManualOffsetDragDraft(m, 90.25, 60);
expect(element.style.getPropertyValue("translate")).toBe("130.25px 90px");
expect(applyManualOffsetDragCommit(m, 90.25, 60)).toEqual({ x: 130.25, y: 90 });
endManualOffsetDragMembers([m]);
expect(element.style.getPropertyValue("translate")).toBe("130.25px 90px");
expect(gsapCalls).toEqual([]);
expect(element.hasAttribute("data-hf-drag-gsap-base-x")).toBe(false);
});

it("starts a second move where the first dropped, while the first save is still in flight", () => {
const { member } = plainBox();
const first = member();
applyManualOffsetDragCommit(first, 90, 60);
const second = member();
expect(second.initialOffset).toEqual({ x: 130, y: 90 });
expect(applyManualOffsetDragCommit(second, 40, 0)).toEqual({ x: 170, y: 90 });
});

it("nudges from the element's translate", () => {
const { member } = plainBox();
expect(applyManualOffsetNudgeDraft(member("nudge"), { x: 5, y: -1 })).toEqual({ x: 45, y: 29 });
});

it("keeps the GSAP writer once GSAP renders the element's transform", () => {
const { element, member } = plainBox({ gsapLoaded: true });
Object.assign(element, { _gsap: { renderTransform: () => {} } });
expect(member().plainTranslate).toBe(false);
});

it("keeps the GSAP writer for an element a timeline hold positions", () => {
const { window, element, member } = plainBox();
const hold = { targets: () => [element], vars: { x: 40 }, duration: () => 0 };
Object.assign(window, { __timelines: { main: { getChildren: () => [hold] } } });
expect(member().plainTranslate).toBe(false);
});
});
Loading
Loading