diff --git a/packages/studio/src/components/editor/anchoredResizeReleaseShift.test.ts b/packages/studio/src/components/editor/anchoredResizeReleaseShift.test.ts new file mode 100644 index 0000000000..7e97d65d33 --- /dev/null +++ b/packages/studio/src/components/editor/anchoredResizeReleaseShift.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { + applyStudioBoxSize, + applyStudioPathOffset, + readStudioBoxSize, + reapplyPositionEditsAfterSeek, +} from "./manualEditsDom"; +import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches"; +import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag"; +import type { PatchOperation } from "../../utils/sourcePatcher"; +import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers"; + +/** + * Center-anchored corner resize (CapCut model): the element scales about its + * CENTER, which must stay planted across the whole gesture — including after + * release, on every corner and at any rotation. + * + * This file lands here with ONLY the persist round-trip test, which exercises the + * real apply → persist → reload symbols that already exist at this point in the + * stack. The two center-anchor CONVERGENCE tests (the release-shift root cause) + * drive the exported `computeNextResizeAnchor` accumulator, which is extracted from + * the resize pointermove branch of `useDomEditOverlayGestures.ts`. That gesture code + * lands later in the stack (with the canvas glue swap), so those two tests are added + * to this file at that point — importing the real production helper rather than a + * test-local copy, so they can never pass against a stand-in that drifts from the + * shipped math. + */ + +afterEach(() => { + document.body.innerHTML = ""; +}); + +/** Apply a built PatchOperation[] to a live element, mirroring sourcePatcher's + * inline-style / attribute application — i.e. what the persisted source carries + * when it is re-parsed into the DOM on the next preview load. */ +function applyPatchesToElement(el: HTMLElement, ops: PatchOperation[]): void { + for (const op of ops) { + if (op.type === "inline-style") { + if (op.value === null) el.style.removeProperty(op.property); + else el.style.setProperty(op.property, op.value); + } else if (op.type === "attribute") { + if (op.value === null) el.removeAttribute(op.property); + else el.setAttribute(op.property, op.value); + } + } +} + +/** Net translate applied to an element, resolving the studio offset var() + * expression to its px value so we compare the actually-rendered translation. */ +function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } { + const raw = el.style.getPropertyValue("translate").trim(); + if (!raw || raw === "none") return { x: 0, y: 0 }; + const vx = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0; + const vy = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0; + const parts = splitTopLevelWhitespace(raw); + const parseAxis = (part: string, varVal: number): number => { + if (part && part.includes("--hf-studio-offset")) return varVal; + const n = Number.parseFloat(part); + return Number.isFinite(n) ? n : 0; + }; + return { + x: parseAxis(parts[0] ?? "", vx), + y: parseAxis(parts[1] ?? "", vy), + }; +} + +describe("center-anchored corner resize — no shift after release", () => { + it("net translate after persist+reload equals the committed anchor offset (non-GSAP)", () => { + // The committed offset flows through the real apply → persist → reload chain + // unchanged (this hop was proved clean; the shift is upstream in the anchor + // loop, tested with the gesture code, not in persistence). + const el = document.createElement("div"); + el.style.setProperty("width", "200px"); + el.style.setProperty("height", "100px"); + document.body.appendChild(el); + + const anchorDx = -30; + const anchorDy = -18; + const finalSize = { width: 240, height: 130 }; + + applyStudioBoxSize(el, finalSize); + const memberResult = createManualOffsetDragMember({ + key: "k", + selection: { element: el } as never, + element: el, + rect: { left: 0, top: 0, width: 240, height: 130, editScaleX: 1, editScaleY: 1 }, + }); + expect(memberResult.ok).toBe(true); + if (!memberResult.ok) return; + + const finalOffset = applyManualOffsetDragCommit(memberResult.member, anchorDx, anchorDy); + + applyStudioBoxSize(el, finalSize); + const patches = buildBoxSizePatches(el); + applyStudioPathOffset(el, finalOffset); + patches.push(...buildPathOffsetPatches(el)); + + expect(resolvedTranslatePx(el)).toEqual({ x: anchorDx, y: anchorDy }); + + // Persist → fresh element re-parsed from source → reload re-stamp. + const reloaded = document.createElement("div"); + reloaded.style.setProperty("width", "200px"); + reloaded.style.setProperty("height", "100px"); + document.body.appendChild(reloaded); + applyPatchesToElement(reloaded, patches); + reapplyPositionEditsAfterSeek(reloaded.ownerDocument); + + expect(resolvedTranslatePx(reloaded)).toEqual({ x: anchorDx, y: anchorDy }); + expect(readStudioBoxSize(reloaded)).toEqual(finalSize); + }); +}); diff --git a/packages/studio/src/hooks/useRazorSplit.history.test.tsx b/packages/studio/src/hooks/useRazorSplit.history.test.tsx new file mode 100644 index 0000000000..b543676b61 --- /dev/null +++ b/packages/studio/src/hooks/useRazorSplit.history.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../player"; +import { useRazorSplit } from "./useRazorSplit"; +import { createPersistentEditHistoryStore } from "./usePersistentEditHistory"; +import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage"; +import { createEmptyEditHistory } from "../utils/editHistory"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const ORIGINAL = `
hi
`; +const SPLIT = + `
hi
` + + `
hi
`; + +const element: TimelineElement = { + id: "clip1", + tag: "div", + start: 0, + duration: 4, + track: 0, + domId: "clip1", + sourceFile: "index.html", + timingSource: "authored", +}; + +type Split = (element: TimelineElement, splitTime: number) => Promise; + +interface Harness { + disk: Record; + store: ReturnType; + splitRef: { current: Split | undefined }; + root: ReturnType; + expected: string; +} + +const SPLIT_GSAP = SPLIT.replace( + "", + "', +); + +function mountRazorSplit(opts: { gsap?: boolean } = {}): Harness { + const disk: Record = { "index.html": ORIGINAL }; + const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT; + + const storage = createMemoryEditHistoryStorage(); + const store = createPersistentEditHistoryStore({ + projectId: "p1", + storage, + initialState: createEmptyEditHistory(), + now: (() => { + let t = 1000; + return () => (t += 10); + })(), + onChange: () => {}, + }); + + // Faithful stand-in for the studio-server file-mutation endpoints: the server + // writes the split to disk itself, then returns the patched content. + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const u = String(url); + if (u.includes("/gsap-mutations/")) { + if (opts.gsap) { + // Mirror the server: rewrites the GSAP script for the new id, writes to + // disk, returns the final content. + disk["index.html"] = SPLIT_GSAP; + return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + // The fixture has no GSAP script — mirror the server's 400 response. + return new Response(JSON.stringify({ error: "no GSAP script found in file" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (u.includes("/file-mutations/split-element/")) { + disk["index.html"] = SPLIT; + return new Response( + JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (u.includes("/files/")) { + return new Response(JSON.stringify({ content: disk["index.html"] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + void init; + throw new Error(`unexpected fetch: ${u}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const splitRef: { current: Split | undefined } = { current: undefined }; + + function Component() { + const { handleRazorSplit } = useRazorSplit({ + projectId: "p1", + activeCompPath: "index.html", + showToast: () => {}, + writeProjectFile: async (path, content) => { + disk[path] = content; + }, + recordEdit: store.recordEdit, + domEditSaveTimestampRef: { current: 0 }, + reloadPreview: () => {}, + }); + splitRef.current = handleRazorSplit; + return null; + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + + return { disk, store, splitRef, root, expected: finalContent }; +} + +async function undoViaDisk(harness: Harness) { + return harness.store.undo({ + readFile: async (path) => harness.disk[path], + writeFile: async (path, content) => { + harness.disk[path] = content; + }, + }); +} + +afterEach(() => { + document.body.innerHTML = ""; + vi.unstubAllGlobals(); +}); + +describe("useRazorSplit — split is undoable via edit history", () => { + for (const gsap of [false, true]) { + describe(gsap ? "with GSAP rewrite" : "plain HTML split", () => { + let harness: Harness; + beforeEach(() => { + harness = mountRazorSplit({ gsap }); + }); + afterEach(() => { + act(() => harness.root.unmount()); + }); + + it("records a single 'Split timeline clip' history entry that undo restores", async () => { + await act(async () => { + await harness.splitRef.current!(element, 2); + }); + + // The split reached disk. + expect(harness.disk["index.html"]).toBe(harness.expected); + + // The split must be the top of the undo stack — not a prior/other entry. + expect(harness.store.snapshot().canUndo).toBe(true); + expect(harness.store.snapshot().undoLabel).toBe("Split timeline clip"); + + // Undo restores the exact pre-split file. + const result = await undoViaDisk(harness); + expect(result.ok).toBe(true); + expect(result.label).toBe("Split timeline clip"); + expect(harness.disk["index.html"]).toBe(ORIGINAL); + }); + }); + } +});