Skip to content
Open
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
@@ -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);
});
});
173 changes: 173 additions & 0 deletions packages/studio/src/hooks/useRazorSplit.history.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = `<div class="clip" id="clip1" data-start="0" data-duration="4">hi</div>`;
const SPLIT =
`<div class="clip" id="clip1" data-start="0" data-duration="2">hi</div>` +
`<div class="clip" id="clip1-split" data-start="2" data-duration="2">hi</div>`;

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<void>;

interface Harness {
disk: Record<string, string>;
store: ReturnType<typeof createPersistentEditHistoryStore>;
splitRef: { current: Split | undefined };
root: ReturnType<typeof createRoot>;
expected: string;
}

const SPLIT_GSAP = SPLIT.replace(
"</div>",
"</div><script>window.__timelines={};const tl=gsap.timeline({paused:true});" +
'tl.set("#clip1-split",{x:0},2);window.__timelines["c"]=tl;</script>',
);

function mountRazorSplit(opts: { gsap?: boolean } = {}): Harness {
const disk: Record<string, string> = { "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(<Component />);
});

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);
});
});
}
});
Loading