Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,13 @@ export function startGesture(
// Hold a % translate as the same px now, so a growing box can't drag it along mid-frame.
if (result.member.plainTranslate) applyManualOffsetDragDraft(result.member, 0, 0);
} else {
manualEditDragToken = beginStudioManualEditGesture(sel.element);
manualEditDragToken = beginStudioManualEditGesture(sel.element, "resize");
}
} else {
manualEditDragToken = beginStudioManualEditGesture(sel.element);
manualEditDragToken = beginStudioManualEditGesture(
sel.element,
kind === "rotate" ? "rotate" : "resize",
);
}
}

Expand Down
7 changes: 4 additions & 3 deletions packages/studio/src/components/editor/manualEdits.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { describe, expect, it, vi } from "vitest";
import { Window } from "happy-dom";
import {
Expand Down Expand Up @@ -285,7 +286,7 @@ describe("studio manual edits", () => {
expect(set).not.toHaveBeenCalled();
expect(tickerTick).not.toHaveBeenCalled();

beginStudioManualEditGesture(card);
beginStudioManualEditGesture(card, "move");
applyStudioPathOffsetDraft(card, { x: 35, y: -6 });

expect(readStudioPathOffset(card)).toEqual({ x: 35, y: -6 });
Expand Down Expand Up @@ -395,8 +396,8 @@ describe("studio manual edits", () => {
const card = document.getElementById("card") as HTMLElement;

applyStudioPathOffset(card, { x: 40, y: 24 });
const firstToken = beginStudioManualEditGesture(card);
const secondToken = beginStudioManualEditGesture(card);
const firstToken = beginStudioManualEditGesture(card, "move");
const secondToken = beginStudioManualEditGesture(card, "move");
endStudioManualEditGesture(card, firstToken);

// Gesture still active — offset should remain
Expand Down
23 changes: 21 additions & 2 deletions packages/studio/src/components/editor/manualEditsDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,26 @@ import {
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches";

/* ── Gesture tracking ─────────────────────────────────────────────── */
let studioManualEditGestureId = 0;

export function beginStudioManualEditGesture(element: HTMLElement): string {
export type StudioGestureDraws = "move" | "resize" | "rotate" | "edit";
const MOVE_DRAWS = ["translate", STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP];
const GESTURE_DRAWS: Record<StudioGestureDraws, readonly string[]> = {
move: MOVE_DRAWS,
resize: [...MOVE_DRAWS, STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP, ...BOX_SIZE_STYLE_PROPS],
rotate: ["rotate", "transform", "transform-origin", "display", STUDIO_ROTATION_PROP],
edit: [],
};

export function beginStudioManualEditGesture(
element: HTMLElement,
draws: StudioGestureDraws,
): string {
studioManualEditGestureId += 1;
const token = `gesture-${studioManualEditGestureId}`;
const token = `gesture-${studioManualEditGestureId}:${draws}`;
element.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, token);
return token;
}
Expand Down Expand Up @@ -84,6 +97,12 @@ export function noteStudioManualEditSave(element: HTMLElement): void {
gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
}

export function studioGestureDraws(element: Element): readonly string[] | null {
const token = element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
if (token === null) return null;
return GESTURE_DRAWS[token.split(":")[1] as StudioGestureDraws] ?? [];
}

function isStudioManualEditGestureActive(element: HTMLElement): boolean {
return element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/studio/src/components/editor/manualOffsetDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,10 @@ export function createManualOffsetDragMember(input: {
}

const initialPathOffset = captureStudioPathOffset(input.element);
const gestureToken = beginStudioManualEditGesture(input.element);
const gestureToken = beginStudioManualEditGesture(
input.element,
input.gesture === "resize" ? "resize" : "move",
);
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
scaleX: input.rect.editScaleX,
scaleY: input.rect.editScaleY,
Expand Down
27 changes: 27 additions & 0 deletions packages/studio/src/hooks/useInlineTextEdit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
import { applyUndoRestoreToPreview } from "../utils/gsapUndoRestore";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

Expand Down Expand Up @@ -643,3 +644,29 @@ describe("useInlineTextEdit with styled runs", () => {
act(() => root.unmount());
});
});

describe("an undo that lands while a text edit is open", () => {
it("marks the edit as drawing nothing, so the undo reverts the move and keeps the edit", () => {
const wrap = (body: string) => `<html><body>${body}</body></html>`;
const moved = `<h1 id="t" style="translate: 90px 60px">Title</h1>`;
document.body.innerHTML = moved;
const element = document.getElementById("t")!;
const { controls, root } = mount();
act(() => {
controls().start(element);
});
const contentWindow = { __player: { seek: vi.fn() }, __hfStudioManualEditsApply: vi.fn() };
const iframe = { contentWindow, contentDocument: document } as unknown as HTMLIFrameElement;
const files = {
"index.html": { previous: wrap(moved), restored: wrap(`<h1 id="t">Title</h1>`) },
};

expect(element.getAttribute("data-hf-studio-manual-edit-gesture")).toMatch(/:edit$/);
expect(applyUndoRestoreToPreview(iframe, "index.html", files, 0, vi.fn())).toBe("soft");

expect(element.style.getPropertyValue("translate")).toBe("");
expect(element.getAttribute("contenteditable")).toBe("true");
expect(element.style.getPropertyValue("outline")).not.toBe("");
act(() => root.unmount());
});
});
2 changes: 1 addition & 1 deletion packages/studio/src/hooks/useInlineTextEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function useInlineTextEdit({
original: element.innerHTML,
outline: element.style.outline,
outlineOffset: element.style.outlineOffset,
gesture: beginStudioManualEditGesture(element),
gesture: beginStudioManualEditGesture(element, "edit"),
};
// Drawn on the element itself, not in Studio's overlay above it. This is
// the only mark that says the caret is in the TEXT rather than the
Expand Down
127 changes: 126 additions & 1 deletion packages/studio/src/utils/gsapUndoRestore.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// @vitest-environment happy-dom

import { describe, it, expect, vi } from "vitest";
import { applyUndoRestoreToPreview, diffSoftReloadableRestore } from "./gsapUndoRestore";
import {
applyUndoRestoreToPreview,
diffSoftReloadableRestore,
showRestoreInPlace,
} from "./gsapUndoRestore";
import { applyPatch } from "./sourcePatcher";
import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";

// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────

Expand Down Expand Up @@ -420,3 +427,121 @@ describe("applyUndoRestoreToPreview", () => {
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
});

describe("an undo that lands while the layer is being dragged", () => {
const ROOT = "index.html";
const undone = `<div id="a" style="width: 120px; translate: 90px 60px" data-start="2" data-hf-studio-original-inline-translate="">t</div>`;
const restored = wrap(`<div id="a" style="width: 100px" data-start="1">t</div>`);
const files = { [ROOT]: { previous: wrap(undone), restored } };
const saveDrop = (el: HTMLElement, file: string) =>
writePlainMove(el, { x: 20, y: 110 }).reduce((html, op) => applyPatch(html, "a", op), file);

function dragging(drag: boolean) {
const { iframe, doc } = buildLiveIframe(undone);
const el = doc.getElementById("a")!;
if (drag) beginStudioManualEditGesture(el, "move");
if (drag) writeTranslatePx(el, { x: 70, y: 110 });
else el.style.setProperty("opacity", "0.5");
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
return el;
}

it("keeps the translate the drag is drawing and reverts the rest", () => {
const el = dragging(true);

expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
expect(el.style.getPropertyValue("width")).toBe("100px");
expect(el.getAttribute("data-start")).toBe("1");
expect(el.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
});

it("saves the drop onto the undone file, the same bytes as dragging the undone layer", () => {
const el = dragging(true);
const fresh = new DOMParser().parseFromString(restored, "text/html").getElementById("a")!;

const saved = saveDrop(el, restored);

expect(saved).toBe(saveDrop(fresh, restored));
expect(saved).toContain("width: 100px; translate: 20px 110px");
expect(saved).not.toContain("120px");
});

it("keeps the box where it is when the press has not moved it yet", () => {
const { iframe, doc } = buildLiveIframe(undone);
const el = doc.getElementById("a")!;
beginStudioManualEditGesture(el, "move");

applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());

expect(el.style.getPropertyValue("translate")).toBe("90px 60px");
expect(el.style.getPropertyValue("width")).toBe("100px");
});

it("reverts the translate under a gesture that draws nothing, such as a text edit", () => {
const { iframe, doc } = buildLiveIframe(undone);
const el = doc.getElementById("a")!;
beginStudioManualEditGesture(el, "edit");

applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());

expect(el.style.getPropertyValue("translate")).toBe("");
expect(el.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
});

it("keeps the drag's translate when the undo is shown in place", () => {
const { iframe, doc } = buildLiveIframe(undone);
const el = doc.getElementById("a")!;
beginStudioManualEditGesture(el, "move");
writeTranslatePx(el, { x: 70, y: 110 });

expect(showRestoreInPlace(iframe, ROOT, files, 3)).not.toBeNull();

expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
expect(el.style.getPropertyValue("width")).toBe("100px");
});

it("keeps a drag that started after the undo was shown when the undo is put back", () => {
const { iframe, doc } = buildLiveIframe(undone);
const el = doc.getElementById("a")!;
const putBack = showRestoreInPlace(iframe, ROOT, files, 3)!;
beginStudioManualEditGesture(el, "move");
writeTranslatePx(el, { x: 70, y: 110 });

putBack(3);

expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
expect(el.style.getPropertyValue("width")).toBe("120px");
});

it("keeps the drag's translate when the undo re-runs a GSAP script", () => {
const script = (extra: string) =>
`<script>window.__timelines["root"]=gsap.timeline();${extra}</script>`;
const layer = `<div id="a" data-hf-studio-path-offset="true" style="translate: 40px 30px">t</div>`;
const { iframe, contentWindow, doc } = buildLiveIframe(
`${layer}${script("tl.set('#b',{x:1});")}`,
);
Object.assign(contentWindow.gsap, { set: () => {} });
const el = doc.getElementById("a")!;
beginStudioManualEditGesture(el, "move");
writeTranslatePx(el, { x: 70, y: 110 });
const scripted = {
[ROOT]: {
previous: wrap(`<div id="a">t</div>${script("tl.set('#b',{x:1});")}`),
restored: wrap(`<div id="a">t</div>${script("")}`),
},
};

expect(applyUndoRestoreToPreview(iframe, ROOT, scripted, 3, vi.fn())).toBe("soft");

expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
});

it("restores a layer no gesture is drawing exactly as before", () => {
const el = dragging(false);
const want = new DOMParser().parseFromString(restored, "text/html").getElementById("a")!;

expect(el.getAttributeNames().map((n) => [n, el.getAttribute(n)])).toEqual(
want.getAttributeNames().map((n) => [n, want.getAttribute(n)]),
);
});
});
Loading
Loading