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
5 changes: 3 additions & 2 deletions packages/studio/src/utils/gsapSoftReload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export interface SoftReloadOptions {
currentTimeOverride?: number;
/** After-write file HTML — the primary source for the authored opacity and transform restore. */
authoredHtml?: string;
reparse?: Element[];
}

/**
Expand Down Expand Up @@ -251,7 +252,7 @@ export function applySoftReload(
scriptText: string,
options: SoftReloadOptions = {},
): SoftReloadResult {
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
const { onAsyncFailure, currentTimeOverride, authoredHtml, reparse = [] } = options;
if (!iframe || !scriptText) return "cannot-soft-reload";

const win = iframe.contentWindow as IframeWindow | null;
Expand Down Expand Up @@ -329,7 +330,7 @@ export function applySoftReload(
// fallow-ignore-next-line complexity
const doReload = () => {
const timelines = win.__timelines;
const allTargets: Element[] = [];
const allTargets: Element[] = [...reparse];

// Kill ONLY the target composition's timeline(s) — leaving every other
// composition's timeline (and its children on the global timeline) intact.
Expand Down
94 changes: 94 additions & 0 deletions packages/studio/src/utils/gsapUndoRestore.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Real Chrome: the box GSAP and a stylesheet translate produce together is what no DOM emulation computes.
import { mkdtempSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import puppeteer, { type Browser } from "puppeteer-core";
import { build } from "vite";
import { afterAll, beforeAll, expect, it } from "vitest";
import { findSystemChrome } from "../../vite.browser";
import { writeFixture } from "../../tests/e2e/edit-accuracy/grid.mjs";

const require = createRequire(import.meta.url);
const CROP = "inset(0px 40px 0px 0px)";
let browser: Browser;
let undoBundle: string;

beforeAll(async () => {
const executablePath = findSystemChrome();
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
const out = await build({
configFile: false,
logLevel: "silent",
resolve: {
alias: { canvas: fileURLToPath(new URL("../shims/canvasBrowserStub.js", import.meta.url)) },
},
build: {
write: false,
minify: false,
lib: {
entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
formats: ["iife"],
name: "hfUndo",
},
},
});
undoBundle = (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0]
.code;
}, 60_000);

afterAll(() => browser?.close());

it.each([
{ gsap: "hold", placement: "px", rotation: 0 },
{ gsap: "tween", placement: "px", rotation: 0 },
{ gsap: "tween", placement: "xpercent", rotation: 30 },
])("an undone crop leaves a $gsap $placement r$rotation layer where it was", async (spec) => {
const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
const restored = readFileSync(join(dir, "index.html"), "utf8");
const previous = restored.replace(
`<div id="target"`,
`<div id="target" style="clip-path: ${CROP}"`,
);
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.setRequestInterception(true);
page.on("request", (request) =>
request.url().endsWith("/gsap.min.js")
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
: request.continue(),
);
await page.setContent(restored, { waitUntil: "load" });
await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
await page.waitForFunction(() => "__player" in window);
await page.evaluate(undoBundle);

const box = () =>
page.evaluate(() => {
const { left, top, width, height } = document
.getElementById("target")!
.getBoundingClientRect();
return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
});
await page.evaluate(() =>
(window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
);
const before = await box();
const outcome = await page.evaluate(
(files, crop) => {
document.getElementById("target")!.style.setProperty("clip-path", crop);
const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
},
{ "index.html": { previous, restored } },
CROP,
);

expect(outcome).toBe("soft");
expect(await box()).toEqual(before);
await page.close();
});
Loading
Loading