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
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 { StudioElementLook } from "./gestureUndoRevert";
import type { CssRotationTarget, RotationCommit } from "./rotationDraft";
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
import type { SnapContext } from "./snapTargetCollection";
Expand Down Expand Up @@ -57,6 +58,7 @@ export interface GestureState {
initialPathOffset: StudioPathOffsetSnapshot;
initialRotation: StudioRotationSnapshot;
initialBoxSize: StudioBoxSizeSnapshot;
initialLook: StudioElementLook;
pathOffsetMember?: ManualOffsetDragMember;
originLeft: number;
originTop: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
import { readElementLook } from "./gestureUndoRevert";
import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
import {
beginStudioManualEditGesture,
Expand Down Expand Up @@ -179,6 +180,10 @@ export function startGesture(
Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
const contentScaleY =
Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
const initialLook = readElementLook(
sel.element,
kind !== "drag" && !editsPlainCss(sel.element, kind === "rotate" ? "rotate" : "resize"),
);
let initialPathOffset = captureStudioPathOffset(sel.element);
let manualEditDragToken: string | undefined;
let pathOffsetMember: ManualOffsetDragMember | undefined;
Expand Down Expand Up @@ -274,6 +279,7 @@ export function startGesture(
initialPathOffset,
initialRotation: captureStudioRotation(sel.element),
initialBoxSize: captureStudioBoxSize(sel.element),
initialLook,
pathOffsetMember,
originLeft: rect.left,
originTop: rect.top,
Expand Down
172 changes: 170 additions & 2 deletions packages/studio/src/components/editor/dragUndoPaint.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,40 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from "vitest";
import { savePlainRotation } from "../../hooks/plainRotation";
import type { DomEditSelection } from "./domEditing";
import type { GestureState } from "./domEditOverlayGestures";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import {
adoptingStudioPendingEdit,
hasStudioPendingEdits,
paintBackNewestStudioPendingEdit,
type StudioEditInFlight,
} from "../../utils/studioPendingEdits";

const gsapOwns = vi.hoisted(() => ({ on: false }));
vi.mock("../../hooks/gsapRuntimeKeyframes", async (importOriginal) => ({
...(await importOriginal<typeof import("../../hooks/gsapRuntimeKeyframes")>()),
editsPlainCss: () => !gsapOwns.on,
}));

/** GSAP's own transform values per element, as `gsap.set` / `gsap.getProperty` see them. */
function fakeGsap(start: Record<string, number>) {
const values = new Map<Element, Record<string, number>>();
const of = (el: Element) => values.get(el) ?? values.set(el, { ...start }).get(el)!;
gsapOwns.on = true;
Object.assign(window, {
gsap: {
set: (el: Element, props: Record<string, number>) => Object.assign(of(el), props),
getProperty: (el: Element, prop: string) => of(el)[prop] ?? 0,
},
});
return of;
}

afterEach(() => {
document.body.innerHTML = "";
gsapOwns.on = false;
Reflect.deleteProperty(window, "gsap");
});

const ref = <T>(current: T) => ({ current });
Expand All @@ -24,7 +49,7 @@ const pointer = (x: number, y: number) => ({
});

/** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
function dragWithSaveRunning(save: Promise<void>) {
function dragWithSaveRunning(save: Promise<void> | (() => Promise<void>)) {
const element = document.createElement("div");
element.style.setProperty("translate", "40px 30px");
document.body.append(element);
Expand All @@ -39,7 +64,7 @@ function dragWithSaveRunning(save: Promise<void>) {
rafPausedRef: ref(false),
onManualDragStartRef: ref(vi.fn()),
onBlockedMoveRef: ref(vi.fn()),
onPathOffsetCommitRef: ref(vi.fn(() => save)),
onPathOffsetCommitRef: ref(vi.fn(typeof save === "function" ? save : () => save)),
snapGuidesRef: ref(null),
groupGestureRef: ref(null),
blockedMoveRef: ref(null),
Expand Down Expand Up @@ -69,3 +94,146 @@ it("a drag whose save is still running can be painted back at once, and shown ag
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
expect(paintBackNewestStudioPendingEdit()).toBeNull();
});

/** Resizes or rotates a plain 240x160 box; the commit draws what it saves at once and its save waits for `save`. */
function gestureWithSaveRunning(kind: "resize" | "rotate", save: Promise<void>) {
const element = document.createElement("div");
element.setAttribute(
"style",
"position: absolute; width: 240px; height: 160px; clip-path: inset(10px)",
);
document.body.append(element);
const selection = {
element,
capabilities: { canApplyManualSize: true, canApplyManualRotation: true },
};
const handlers = createDomEditOverlayGestureHandlers({
selectionRef: ref(selection as unknown as DomEditSelection),
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
boxRef: ref(document.createElement("div")),
overlayRef: ref(null),
iframeRef: ref(null),
gestureRef: ref<GestureState | null>(null),
rafPausedRef: ref(false),
onBoxSizeCommitRef: ref(
vi.fn(() => {
element.style.setProperty("clip-path", "inset(15px)");
return save;
}),
),
onRotationCommitRef: ref((sel: DomEditSelection, next: never) =>
savePlainRotation({ commitPositionPatchToHtml: () => save.then(() => undefined) }, sel, next),
),
snapGuidesRef: ref(null),
groupGestureRef: ref(null),
blockedMoveRef: ref(null),
setOverlayRect: vi.fn(),
suppressNextBoxClickRef: ref(false),
hoverSelectionRef: ref(null),
onCanvasMouseDown: vi.fn(),
} as never);
const start = kind === "resize" ? pointer(240, 160) : pointer(240, 80);
expect(handlers.startGesture(kind, start as never, { resizeHandle: "se" })).toBe(true);
const end = kind === "resize" ? pointer(300, 200) : pointer(120, 200);
handlers.onPointerMove(end as never);
handlers.onPointerUp(end as never);
return element;
}

it.each(["resize", "rotate"] as const)(
"a %s whose save is still running can be painted back at once, and shown again",
async (kind) => {
let saved!: () => void;
const element = gestureWithSaveRunning(kind, new Promise<void>((resolve) => (saved = resolve)));
const before = "position: absolute; width: 240px; height: 160px; clip-path: inset(10px)";
const edited = element.getAttribute("style");
expect(edited).not.toBe(before);

const shown = paintBackNewestStudioPendingEdit();
expect(element.getAttribute("style")).toBe(before);
expect(paintBackNewestStudioPendingEdit()).toBeNull();
shown!.showAgain();
expect(element.getAttribute("style")).toBe(edited);

saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
},
);

it("a drag of a box GSAP positions is painted back to where GSAP had it, and shown again", async () => {
const gsapOf = fakeGsap({ x: 5, y: 7 });
let saved!: () => void;
const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
const moved = { ...gsapOf(element) };
expect(moved).not.toMatchObject({ x: 5, y: 7 });

const shown = paintBackNewestStudioPendingEdit();
expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
shown!.showAgain();
expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });

saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});

it("a rotate of a box GSAP turns is painted back to GSAP's angle at press, and shown again", async () => {
const gsapOf = fakeGsap({ rotation: 10 });
let saved!: () => void;
const element = gestureWithSaveRunning(
"rotate",
new Promise<void>((resolve) => (saved = resolve)),
);
const turned = gsapOf(element).rotation;
expect(turned).not.toBe(10);

const shown = paintBackNewestStudioPendingEdit();
expect(gsapOf(element).rotation).toBe(10);
shown!.showAgain();
expect(gsapOf(element).rotation).toBe(turned);

saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});

it("a GSAP drag saved as a left/top offset stays painted back while that offset is drawn", async () => {
fakeGsap({ x: 5, y: 7 });
let saved!: () => void;
let drawn!: Promise<void>;
const element = dragWithSaveRunning(() => {
const edit = adoptingStudioPendingEdit()!;
drawn = new Promise<void>((resolve) => (saved = resolve)).then(() =>
edit.drawKeepingUndone(() => void element.style.setProperty("left", "99px")),
);
return drawn;
});

const shown = paintBackNewestStudioPendingEdit();
saved();
await drawn;
expect(element.style.getPropertyValue("left")).toBe("");
shown!.showAgain();
expect(element.style.getPropertyValue("left")).toBe("99px");
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});

it("keeps a GSAP drag's base through every repaint of its undo, so its save adds the drag once", async () => {
const gsapOf = fakeGsap({ x: 5, y: 7 });
let edit!: StudioEditInFlight;
let release!: () => void;
const element = dragWithSaveRunning(() => {
edit = adoptingStudioPendingEdit()!;
return new Promise<void>((resolve) => (release = resolve));
});
const moved = { ...gsapOf(element) };

const shown = paintBackNewestStudioPendingEdit();
edit.drawKeepingUndone(() => undefined);
expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
shown!.showAgain();
expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");

release();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
});
104 changes: 104 additions & 0 deletions packages/studio/src/components/editor/gestureUndoRevert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
captureStudioPathOffset,
restoreStudioPathOffset,
type StudioPathOffsetSnapshot,
} from "./manualEdits";
import {
getOffsetDragGsap,
stampGestureBase,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
import type { StudioEditRevert } from "../../utils/studioPendingEdits";

interface MemberPosition {
offset: StudioPathOffsetSnapshot;
gsap: { x: number; y: number } | null;
placement: string[];
}

// Where an element-offset move draws the element when no transform channel can take it.
const PLACEMENT = ["position", "left", "top"];
const placementOf = (element: HTMLElement) =>
PLACEMENT.map((prop) => element.style.getPropertyValue(prop));

function showPlacement(element: HTMLElement, placement: string[]): void {
PLACEMENT.forEach((prop, i) => {
if (placement[i]) element.style.setProperty(prop, placement[i]!);
else element.style.removeProperty(prop);
});
}

function readMemberPosition(member: ManualOffsetDragMember): MemberPosition {
const gsap = member.plainTranslate ? null : getOffsetDragGsap(member.element);
return {
offset: captureStudioPathOffset(member.element),
gsap: gsap && {
x: Number(gsap.getProperty(member.element, "x")),
y: Number(gsap.getProperty(member.element, "y")),
},
placement: placementOf(member.element),
};
}

function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosition): void {
restoreStudioPathOffset(member.element, position.offset);
showPlacement(member.element, position.placement);
if (position.gsap) getOffsetDragGsap(member.element)?.set(member.element, { ...position.gsap });
// The restore drops the gesture's base stamps; its save still reads them.
if (!member.plainTranslate)
stampGestureBase(member.element, member.initialOffset, member.baseGsap);
}

/** Undo's live revert of a move: its members at gesture start. */
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert {
const startPlacement = members.map((member) => placementOf(member.element));
return () => {
const shown = members.map(readMemberPosition);
members.forEach((member, i) =>
showMemberPosition(member, {
offset: member.initialPathOffset,
gsap: member.plainTranslate ? null : member.baseGsap,
placement: startPlacement[i]!,
}),
);
return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!));
};
}

export interface StudioElementLook {
style: string | null;
gsap: Record<string, number> | null;
}

const GSAP_LOOK_PROPS = ["x", "y", "rotation", "scaleX", "scaleY"];

/** Reads GSAP only for an element it owns: reading a plain one makes GSAP bake its CSS into a transform. */
export function readElementLook(element: HTMLElement, gsapOwned: boolean): StudioElementLook {
const gsap = gsapOwned ? getOffsetDragGsap(element) : null;
return {
style: element.getAttribute("style"),
gsap:
gsap &&
Object.fromEntries(
GSAP_LOOK_PROPS.map((prop) => [prop, Number(gsap.getProperty(element, prop))]),
),
};
}

function showElementLook(element: HTMLElement, look: StudioElementLook): void {
if (look.style === null) element.removeAttribute("style");
else element.setAttribute("style", look.style);
if (look.gsap) getOffsetDragGsap(element)?.set(element, { ...look.gsap });
}

/** Undo's live revert of a resize or rotate: the element as it looked at press. */
export function elementLookRevert(
element: HTMLElement,
start: StudioElementLook,
): StudioEditRevert {
return () => {
const shown = readElementLook(element, start.gsap !== null);
showElementLook(element, start);
return () => showElementLook(element, shown);
};
}
13 changes: 1 addition & 12 deletions packages/studio/src/components/editor/manualOffsetDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
import type { StudioEditRevert } from "../../utils/studioPendingEdits";

interface OffsetDragGsap {
set: (el: Element, vars: Record<string, number | string>) => void;
Expand Down Expand Up @@ -448,7 +447,7 @@ export function applyManualOffsetDragDraft(
* the element flies off-screen the instant you drop it. The member holds the
* true gesture-start values in JS, immune to the re-render.
*/
function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
export function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
Expand Down Expand Up @@ -510,16 +509,6 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
endStudioManualEditGesture(member.element, member.gestureToken);
}

/** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
if (!members.every((member) => member.plainTranslate)) return null;
return () => {
const shown = members.map((member) => captureStudioPathOffset(member.element));
for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
};
}

/** Roll back a FAILED drag to the exact gesture-start state. */
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
for (const member of members) {
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/useDomEditNudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ import {
applyManualOffsetNudgeDraft,
createManualOffsetDragMember,
endManualOffsetDragMembers,
manualOffsetMoveRevert,
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
import { manualOffsetMoveRevert } from "./gestureUndoRevert";
import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
import {
CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
Expand Down
Loading
Loading