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
5 changes: 3 additions & 2 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type BlockedMoveState,
type DomEditGroupPathOffsetCommit,
type FocusableDomEditOverlay,
type MoveCommitOptions,
type GestureState,
type GroupGestureState,
focusDomEditOverlayElement,
Expand Down Expand Up @@ -45,7 +46,7 @@ export {
hasDomEditRotationChanged,
resolveDomEditRotationGesture,
} from "./domEditOverlayGestures";
export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
export type { DomEditGroupPathOffsetCommit, MoveCommitOptions } from "./domEditOverlayGestures";

export interface DomEditOverlayProps {
iframeRef: RefObject<HTMLIFrameElement | null>;
Expand Down Expand Up @@ -81,7 +82,7 @@ export interface DomEditOverlayProps {
onPathOffsetCommit: (
selection: DomEditSelection,
next: { x: number; y: number },
modifiers?: { altKey?: boolean },
modifiers?: MoveCommitOptions,
) => Promise<unknown> | void;
onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown> | void;
onBoxSizeCommit: (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,15 @@ export function hasDomEditRotationChanged(initialAngle: number, nextAngle: numbe
// These live here (rather than in DomEditOverlay.tsx or useDomEditOverlayGestures.ts)
// to break circular imports between those files.

export interface MoveCommitOptions {
altKey?: boolean;
plainTranslate?: boolean;
}

export interface DomEditGroupPathOffsetCommit {
selection: DomEditSelection;
next: { x: number; y: number };
plainTranslate?: boolean;
}

// Refs are stable across renders; values are read via .current.
Expand All @@ -245,7 +251,7 @@ export type UseDomEditOverlayGesturesOptions = {
(
s: DomEditSelection,
n: { x: number; y: number },
m?: { altKey?: boolean },
m?: MoveCommitOptions,
) => Promise<unknown> | void
>;
onGroupPathOffsetCommitRef: RefObject<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ function pressOptions(element: HTMLElement) {
rafPausedRef: ref(false),
onManualDragStartRef: ref(vi.fn()),
onBlockedMoveRef: ref(vi.fn()),
onPathOffsetCommitRef: ref(vi.fn()),
snapGuidesRef: ref(null),
groupGestureRef: ref(null),
blockedMoveRef: ref(null),
setOverlayRect: vi.fn(),
suppressNextBoxClickRef: ref(false),
hoverSelectionRef: ref(null),
onCanvasMouseDown: vi.fn(),
};
}

Expand Down Expand Up @@ -83,3 +91,58 @@ describe("a drag press on a centred element without GSAP", () => {
expect(element.getAttribute("style")).toBe(style);
});
});

describe("a drag on an element without GSAP", () => {
it("drops on the route it chose at press, even if GSAP takes the element over mid-drag", () => {
const element = document.createElement("div");
element.style.setProperty("translate", "40px 30px");
document.body.append(element);
const opts = pressOptions(element);
const handlers = createDomEditOverlayGestureHandlers(opts as never);
expect(handlers.startGesture("drag", PRESS as never)).toBe(true);
Object.assign(element, { _gsap: { renderTransform: () => {} } });
const release = {
...PRESS,
clientX: 110,
clientY: 70,
currentTarget: { releasePointerCapture() {} },
};
handlers.onPointerUp(release as never);
expect(opts.onPathOffsetCommitRef.current).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ plainTranslate: true }),
);
});
});

describe("a group drag of elements without GSAP", () => {
it("drops every member on the route it chose at press", () => {
const items = [0, 1].map((i) => {
const element = document.createElement("div");
element.style.setProperty("translate", `${i * 10}px 0px`);
document.body.append(element);
const selection = { element, capabilities: { canApplyManualOffset: true } };
const rect = { left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 };
return { key: `m${i}`, selection, element, rect };
});
const onGroupPathOffsetCommit = vi.fn();
const opts = {
...pressOptions(items[0]!.element),
groupOverlayItemsRef: { current: items },
onGroupPathOffsetCommitRef: { current: onGroupPathOffsetCommit },
setGroupOverlayItems: vi.fn(),
};
const handlers = createDomEditOverlayGestureHandlers(opts as never);
expect(handlers.startGroupDrag(PRESS as never)).toBe(true);
const release = {
...PRESS,
clientX: 110,
clientY: 70,
currentTarget: { releasePointerCapture() {} },
};
handlers.onPointerUp(release as never);
const updates = onGroupPathOffsetCommit.mock.calls[0]?.[0] as { plainTranslate?: boolean }[];
expect(updates.map((update) => update.plainTranslate)).toEqual([true, true]);
});
});
4 changes: 2 additions & 2 deletions packages/studio/src/components/editor/plainTranslate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,6 @@ export function writeTranslatePx(el: HTMLElement, p: Point): void {
}

/** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */
export function readMoveOffset(el: HTMLElement): Point {
return gsapWritesPosition(el) ? readStudioPathOffset(el) : readTranslatePx(el);
export function readMoveOffset(el: HTMLElement, plainTranslate = !gsapWritesPosition(el)): Point {
return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el);
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,32 @@ describe("the panel's position fields on a translate Studio can't read", () => {
expect(showToast).toHaveBeenCalledWith(UNREADABLE_TRANSLATE);
});
});

describe("the panel's position fields", () => {
it("read and move an element without GSAP by its CSS translate, on the route they chose", async () => {
const onSetManualOffset = vi.fn();
const box = document.createElement("div");
box.style.setProperty("translate", "40px 30px");
const element = { id: "box", selector: "#box", element: box } as unknown as DomEditSelection;
const handlers = createTransformCommitHandlers({
element,
styles: {},
hasGsapAnimation: false,
gsapAnimId: null,
gsapKeyframes: null,
currentPct: 0,
onCommitAnimatedProperty: undefined,
onAddKeyframe: undefined,
onSetManualOffset,
onSetManualSize: vi.fn(),
onSetManualRotation: vi.fn(),
showToast: vi.fn(),
});
await handlers.commitManualOffset("x", "100px");
expect(onSetManualOffset).toHaveBeenCalledWith(
element,
{ x: 100, y: 30 },
{ plainTranslate: true },
);
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
import type { DomEditSelection } from "./domEditingTypes";
import { readStudioBoxSize } from "./manualEdits";
import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
Expand Down Expand Up @@ -74,14 +75,16 @@ export function createTransformCommitHandlers({
)
)
return;
const current = readMoveOffset(element.element);
const plainTranslate = !gsapWritesPosition(element.element);
const current = readMoveOffset(element.element, plainTranslate);
if (!Number.isFinite(current.x) || !Number.isFinite(current.y))
return void showToast?.(UNREADABLE_TRANSLATE);
await Promise.resolve(
onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
}),
onSetManualOffset(
element,
{ x: axis === "x" ? parsed : current.x, y: axis === "y" ? parsed : current.y },
{ plainTranslate },
),
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export interface PropertyPanelProps {
onSetManualOffset: (
element: DomEditSelection,
next: { x: number; y: number },
route?: { plainTranslate: boolean },
) => void | Promise<void>;
onSetManualSize: (
element: DomEditSelection,
Expand Down
33 changes: 33 additions & 0 deletions packages/studio/src/components/editor/useDomEditNudge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,36 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
elementB.remove();
});
});

describe("useDomEditNudge carries the route its press chose", () => {
beforeEach(() => {
vi.useFakeTimers();
__resetForTests();
});
afterEach(() => vi.useRealTimers());

it("commits an element without GSAP on the CSS route", () => {
const host = document.createElement("div");
const root = createRoot(host);
const element = document.createElement("div");
document.body.append(element);
const commit = vi.fn();
act(() => {
root.render(
React.createElement(Harness, {
selection: makeSelection("Dot", element),
onPathOffsetCommit: commit,
}),
);
});
act(() => dispatchArrowRight());
act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
expect(commit).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ plainTranslate: true }),
);
act(() => root.unmount());
element.remove();
});
});
8 changes: 6 additions & 2 deletions packages/studio/src/components/editor/useDomEditNudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import type {
BlockedMoveState,
DomEditGroupPathOffsetCommit,
MoveCommitOptions,
GestureState,
GroupGestureState,
} from "./domEditOverlayGestures";
Expand Down Expand Up @@ -60,7 +61,7 @@ export interface UseDomEditNudgeParams {
(
s: DomEditSelection,
n: { x: number; y: number },
m?: { altKey?: boolean },
m?: MoveCommitOptions,
) => Promise<unknown> | void
>;
onGroupPathOffsetCommitRef: RefObject<
Expand Down Expand Up @@ -150,11 +151,14 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
selection: member.selection,
next: applyManualOffsetNudgeCommit(member, session.accum),
plainTranslate: member.plainTranslate,
}));
const p = paramsRef.current;
const commit = session.isGroup
? p.onGroupPathOffsetCommitRef.current(updates)
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next);
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
plainTranslate: updates[0].plainTranslate,
});
void Promise.resolve(commit)
.catch(() => {
for (const member of session.members) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
const updates = groupG.members.map((member) => ({
selection: member.selection,
next: applyManualOffsetDragCommit(member, dx, dy),
plainTranslate: member.plainTranslate,
}));
logDrag("drop", {
pointer: `${Math.round(rawDx)},${Math.round(rawDy)}`,
Expand Down Expand Up @@ -476,7 +477,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
box.style.top = `${nextBoxTop}px`;
}
void Promise.resolve(
opts.onPathOffsetCommitRef.current(sel, finalOffset, { altKey: e.altKey }),
opts.onPathOffsetCommitRef.current(sel, finalOffset, {
altKey: e.altKey,
plainTranslate: g.pathOffsetMember.plainTranslate,
}),
)
.catch(() => {
if (
Expand Down
32 changes: 26 additions & 6 deletions packages/studio/src/hooks/elementOffsetStager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers";
import type { PatchOperation } from "../utils/sourcePatcher";
import { translatePatch } from "../components/editor/plainTranslate";
import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
import { gsapHoldsTranslate } from "./gsapRuntimeKeyframes";
import { markStudioSaveErrorAlreadyToasted } from "../utils/studioSaveDiagnostics";

const GSAP_TOOK_OVER =
"The animation took over this layer's position during the move, so it was not saved.";

const ELEMENT_OFFSET_REFUSED: Record<ElementOffsetRefusal, string> = {
anchored: "This layer is anchored from its right or bottom edge. Move it in the Code tab.",
Expand All @@ -23,15 +27,29 @@ export interface ElementOffsetStagerDeps {
readOnlyPreview?: boolean;
}

function gsapOf(el: HTMLElement): { set: (t: Element, v: object) => void } | undefined {
return (el.ownerDocument.defaultView as { gsap?: { set: (t: Element, v: object) => void } })
?.gsap;
}

/** The drag draft moved GSAP's x/y; left/top carries the move now, so put them back. */
function settleGsapDraftAtGestureStart(el: HTMLElement): void {
const gsap = (el.ownerDocument.defaultView as { gsap?: { set: (t: Element, v: object) => void } })
?.gsap;
const gsap = gsapOf(el);
const x = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "");
const y = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "");
if (gsap && Number.isFinite(x) && Number.isFinite(y)) gsap.set(el, { x, y });
}

export function refuseGsapTakeover(
el: HTMLElement,
showToast: ElementOffsetStagerDeps["showToast"],
) {
if (!gsapHoldsTranslate(el)) return;
gsapOf(el)?.set(el, { x: 0, y: 0, xPercent: 0, yPercent: 0 });
showToast(GSAP_TOOK_OVER, "error");
throw markStudioSaveErrorAlreadyToasted(new Error(GSAP_TOOK_OVER));
}

let plainMoveCounter = 0;

/** GSAP does not position the element: `next` is its whole translate, live now and saved as drawn. */
Expand All @@ -58,17 +76,19 @@ function stagePlainTranslate(
return { save, rollback };
}

/** Applies a move on the element itself live now: its translate when GSAP does not position it,
* else left/top for a shared-tween element. Throws, after a toast, when it cannot. */
/** Applies a move on the element itself live now: its translate on the plain route, else
* left/top for a shared-tween element. Throws, after a toast, when it cannot. */
export function stageElementOffset(
{ commitPositionPatchToHtml, showToast, readOnlyPreview }: ElementOffsetStagerDeps,
selection: DomEditSelection,
next: { x: number; y: number },
plainTranslate: boolean,
coalesceKey?: string,
): { save: () => Promise<void>; rollback: () => void } {
const el = selection.element;
if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
if (!gsapWritesPosition(el)) {
if (plainTranslate) {
refuseGsapTakeover(el, showToast);
return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey);
}
const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
Expand Down
7 changes: 7 additions & 0 deletions packages/studio/src/hooks/gsapRuntimeKeyframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,13 @@ export function gsapWritesPosition(el: Element): boolean {
return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS);
}

export function gsapHoldsTranslate(el: Element): boolean {
const cache = (el as { _gsap?: Record<string, unknown> })._gsap;
return ["x", "y", "xPercent", "yPercent"].some(
(key) => !!Number.parseFloat(String(cache?.[key])),
);
}

const BOX_CHANNELS = [
...BOX_SIZE_STYLE_PROPS.map((prop) =>
prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()),
Expand Down
Loading
Loading