@hyperframes/studio 0.8.87 → 0.8.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/assets/{hyperframes-player-CtNVImMi.js → hyperframes-player-CVoS7kv8.js} +1 -1
  2. package/dist/assets/{index-DZKcDAtf.js → index-BM8PwYby.js} +187 -187
  3. package/dist/assets/{index-DJ-Ol7YS.js → index-D35-L_yn.js} +1 -1
  4. package/dist/assets/{index-B_MF86RE.js → index-DBub9qQY.js} +1 -1
  5. package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
  6. package/dist/chunk-KU4F7QZ3.js.map +1 -0
  7. package/dist/index.d.ts +42 -5
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +269 -94
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineHistoryButtons.dom.test.tsx +14 -3
  14. package/src/components/TimelineToolPicker.tsx +90 -0
  15. package/src/components/TimelineToolbar.tsx +2 -27
  16. package/src/components/editor/DomEditOverlay.tsx +7 -4
  17. package/src/components/editor/domEditOverlayGestures.ts +4 -4
  18. package/src/components/editor/useDomEditNudge.ts +2 -2
  19. package/src/hooks/appHotkeysDispatch.ts +8 -0
  20. package/src/hooks/useAppHotkeys.test.ts +58 -1
  21. package/src/hooks/useDomGeometryCommit.test.tsx +226 -0
  22. package/src/hooks/useDomGeometryCommit.ts +147 -0
  23. package/src/hooks/useGsapAwareEditing.ts +9 -4
  24. package/src/index.ts +3 -1
  25. package/src/player/components/Timeline.showKeyframes.test.tsx +91 -0
  26. package/src/player/components/TimelineTypes.ts +1 -0
  27. package/src/player/components/studioShortcuts.ts +2 -0
  28. package/src/player/components/useTimelineKeyframeData.ts +14 -0
  29. package/src/player/components/useTimelineProviderState.tsx +5 -3
  30. package/src/player/store/playerStore.ts +16 -0
  31. package/dist/chunk-7PLTC6SV.js.map +0 -1
package/dist/player.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-7PLTC6SV.js";
3
+ } from "./chunk-KU4F7QZ3.js";
4
4
  import "./chunk-QZ6HNNFG.js";
5
5
  import "./chunk-S65AQH6M.js";
6
6
  import "./chunk-PZ5AY32C.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.87",
3
+ "version": "0.8.89",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.87",
64
- "@hyperframes/parsers": "0.8.87",
65
- "@hyperframes/player": "0.8.87",
66
- "@hyperframes/shader-transitions": "0.8.87",
67
- "@hyperframes/studio-server": "0.8.87",
68
- "@hyperframes/sdk": "0.8.87"
63
+ "@hyperframes/parsers": "0.8.89",
64
+ "@hyperframes/sdk": "0.8.89",
65
+ "@hyperframes/shader-transitions": "0.8.89",
66
+ "@hyperframes/core": "0.8.89",
67
+ "@hyperframes/player": "0.8.89",
68
+ "@hyperframes/studio-server": "0.8.89"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.87"
84
+ "@hyperframes/producer": "0.8.89"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -96,14 +96,25 @@ it("leaves enabled buttons without the disabled attribute", () => {
96
96
  expect(el.className).not.toContain("cursor-not-allowed");
97
97
  });
98
98
 
99
- it("orders the toolbar Undo, Redo, Select, Razor", () => {
99
+ it("orders the toolbar Undo, Redo, then the tool picker showing the active tool", () => {
100
100
  const host = mount(<TimelineToolbar />);
101
101
  const labels = Array.from(host.querySelectorAll("button"))
102
102
  .map((b) => b.getAttribute("aria-label"))
103
103
  .filter((l) => l !== null)
104
- .slice(0, 4);
104
+ .slice(0, 3);
105
105
 
106
- expect(labels).toEqual(["Undo", "Redo", "Selection tool", "Razor tool"]);
106
+ expect(labels).toEqual(["Undo", "Redo", "Timeline tool: Select"]);
107
+ });
108
+
109
+ it("shows the Split tool on the picker while the razor is active", async () => {
110
+ const { usePlayerStore } = await import("../player");
111
+ usePlayerStore.setState({ activeTool: "razor" });
112
+ try {
113
+ const host = mount(<TimelineToolbar />);
114
+ expect(host.querySelector('button[aria-label="Timeline tool: Split"]')).not.toBeNull();
115
+ } finally {
116
+ usePlayerStore.setState({ activeTool: "select" });
117
+ }
107
118
  });
108
119
 
109
120
  it("classifies Undo and Redo for the hotkey filters at their new location (KTD13)", () => {
@@ -0,0 +1,90 @@
1
+ import type { ReactNode } from "react";
2
+ import { ArrowLineLeft, ArrowLineRight, CaretDown } from "@phosphor-icons/react";
3
+ import { usePlayerStore } from "../player";
4
+ import { Scissors } from "../icons/SystemIcons";
5
+ import { Menu, MenuItem } from "./ui";
6
+ import { cn } from "./ui/cn";
7
+ import { flatIdle } from "./timelineToolbarStyles";
8
+
9
+ function SelectIcon() {
10
+ return (
11
+ <svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
12
+ <path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
13
+ </svg>
14
+ );
15
+ }
16
+
17
+ interface ToolRow {
18
+ label: string;
19
+ shortcut: string;
20
+ icon: ReactNode;
21
+ active: boolean;
22
+ run: () => void;
23
+ }
24
+
25
+ /** One button for the active timeline tool; its menu switches tools or selects clips around the playhead. */
26
+ export function TimelineToolPicker() {
27
+ const activeTool = usePlayerStore((s) => s.activeTool);
28
+ const store = () => usePlayerStore.getState();
29
+ const rows: ToolRow[] = [
30
+ {
31
+ label: "Select",
32
+ shortcut: "V",
33
+ icon: <SelectIcon />,
34
+ active: activeTool === "select",
35
+ run: () => store().setActiveTool("select"),
36
+ },
37
+ {
38
+ label: "Split",
39
+ shortcut: "B",
40
+ icon: <Scissors size={16} />,
41
+ active: activeTool === "razor",
42
+ run: () => store().setActiveTool("razor"),
43
+ },
44
+ {
45
+ label: "Select leftward",
46
+ shortcut: "[",
47
+ icon: <ArrowLineLeft size={16} aria-hidden="true" />,
48
+ active: false,
49
+ run: () => store().selectLeftward(),
50
+ },
51
+ {
52
+ label: "Select rightward",
53
+ shortcut: "]",
54
+ icon: <ArrowLineRight size={16} aria-hidden="true" />,
55
+ active: false,
56
+ run: () => store().selectRightward(),
57
+ },
58
+ ];
59
+ const current = rows.find((row) => row.active) ?? rows[0];
60
+ return (
61
+ <Menu
62
+ aria-label="Timeline tools"
63
+ trigger={
64
+ <button
65
+ type="button"
66
+ aria-label={`Timeline tool: ${current.label}`}
67
+ className={cn(flatIdle, "gap-0.5")}
68
+ >
69
+ {current.icon}
70
+ <CaretDown size={10} weight="bold" aria-hidden="true" />
71
+ </button>
72
+ }
73
+ >
74
+ {rows.map((row) => (
75
+ <MenuItem
76
+ key={row.label}
77
+ shortcut={row.shortcut}
78
+ onClick={row.run}
79
+ data-active={row.active || undefined}
80
+ className={row.active ? "bg-neutral-800 text-white" : undefined}
81
+ >
82
+ <span className="flex items-center gap-2">
83
+ {row.icon}
84
+ {row.label}
85
+ </span>
86
+ </MenuItem>
87
+ ))}
88
+ </Menu>
89
+ );
90
+ }
@@ -26,7 +26,7 @@ import { AudioMetersIcon } from "./icons/AudioMetersIcon";
26
26
  import { RippleEditIcon } from "./icons/RippleEditIcon";
27
27
  import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
28
28
  import { TimelineHistoryButtons, type TimelineHistoryButtonsProps } from "./TimelineHistoryButtons";
29
- import { Scissors } from "../icons/SystemIcons";
29
+ import { TimelineToolPicker } from "./TimelineToolPicker";
30
30
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
31
31
  import type { DomEditSelection } from "./editor/domEditingTypes";
32
32
  import { canSplitElement } from "../utils/timelineElementSplit";
@@ -144,8 +144,6 @@ export function TimelineToolbar({
144
144
  showAddBeat = true,
145
145
  showKeyframes = true,
146
146
  }: TimelineToolbarProps) {
147
- const activeTool = usePlayerStore((s) => s.activeTool);
148
- const setActiveTool = usePlayerStore((s) => s.setActiveTool);
149
147
  const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
150
148
  const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
151
149
  const rippleEditEnabled = usePlayerStore((s) => s.rippleEditEnabled);
@@ -216,30 +214,7 @@ export function TimelineToolbar({
216
214
  <div className="flex items-center justify-between px-2 py-0.5">
217
215
  <div className="flex items-center gap-0.5">
218
216
  <TimelineHistoryButtons {...history} />
219
- <Tooltip label="Selection tool (V)">
220
- <button
221
- type="button"
222
- onClick={() => setActiveTool("select")}
223
- aria-label="Selection tool"
224
- aria-pressed={activeTool === "select"}
225
- className={activeTool === "select" ? flatActive : flatIdle}
226
- >
227
- <svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor">
228
- <path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
229
- </svg>
230
- </button>
231
- </Tooltip>
232
- <Tooltip label="Razor tool (B) — Shift+click splits all tracks">
233
- <button
234
- type="button"
235
- onClick={() => setActiveTool("razor")}
236
- aria-label="Razor tool"
237
- aria-pressed={activeTool === "razor"}
238
- className={activeTool === "razor" ? flatActive : flatIdle}
239
- >
240
- <Scissors size={16} />
241
- </button>
242
- </Tooltip>
217
+ <TimelineToolPicker />
243
218
  {/* Divider: tool-mode | editing-actions */}
244
219
  <div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
245
220
  <Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
@@ -81,15 +81,18 @@ export interface DomEditOverlayProps {
81
81
  selection: DomEditSelection,
82
82
  next: { x: number; y: number },
83
83
  modifiers?: { altKey?: boolean },
84
- ) => Promise<void> | void;
85
- onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void;
84
+ ) => Promise<unknown> | void;
85
+ onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown> | void;
86
86
  onBoxSizeCommit: (
87
87
  selection: DomEditSelection,
88
88
  next: { width: number; height: number },
89
89
  offset?: { x: number; y: number },
90
90
  restore?: () => void,
91
- ) => Promise<void> | void;
92
- onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
91
+ ) => Promise<unknown> | void;
92
+ onRotationCommit: (
93
+ selection: DomEditSelection,
94
+ next: { angle: number },
95
+ ) => Promise<unknown> | void;
93
96
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
94
97
  recordingState?: GestureRecordingState;
95
98
  onToggleRecording?: () => void;
@@ -247,10 +247,10 @@ export type UseDomEditOverlayGesturesOptions = {
247
247
  s: DomEditSelection,
248
248
  n: { x: number; y: number },
249
249
  m?: { altKey?: boolean },
250
- ) => Promise<void> | void
250
+ ) => Promise<unknown> | void
251
251
  >;
252
252
  onGroupPathOffsetCommitRef: RefObject<
253
- (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
253
+ (updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown> | void
254
254
  >;
255
255
  onBoxSizeCommitRef: RefObject<
256
256
  (
@@ -258,10 +258,10 @@ export type UseDomEditOverlayGesturesOptions = {
258
258
  n: { width: number; height: number },
259
259
  offset?: { x: number; y: number },
260
260
  restore?: () => void,
261
- ) => Promise<void> | void
261
+ ) => Promise<unknown> | void
262
262
  >;
263
263
  onRotationCommitRef: RefObject<
264
- (s: DomEditSelection, n: { angle: number }) => Promise<void> | void
264
+ (s: DomEditSelection, n: { angle: number }) => Promise<unknown> | void
265
265
  >;
266
266
  onCanvasPointerMoveRef: RefObject<
267
267
  (
@@ -60,10 +60,10 @@ export interface UseDomEditNudgeParams {
60
60
  s: DomEditSelection,
61
61
  n: { x: number; y: number },
62
62
  m?: { altKey?: boolean },
63
- ) => Promise<void> | void
63
+ ) => Promise<unknown> | void
64
64
  >;
65
65
  onGroupPathOffsetCommitRef: RefObject<
66
- (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
66
+ (updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown> | void
67
67
  >;
68
68
  }
69
69
 
@@ -207,6 +207,14 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
207
207
  return;
208
208
  }
209
209
 
210
+ if ((key === "[" || key === "]") && !event.shiftKey && !event.altKey) {
211
+ event.preventDefault();
212
+ const store = usePlayerStore.getState();
213
+ if (key === "[") store.selectLeftward();
214
+ else store.selectRightward();
215
+ return;
216
+ }
217
+
210
218
  // CapCut returns to the select tool with "A". Scoped to the razor being
211
219
  // armed; usePlaybackKeyboard.ts's own "A" (seek to in-point) checks the
212
220
  // same activeTool to stay out of the way.
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { dispatchModifierKey, dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
4
- import { usePlayerStore } from "../player/store/playerStore";
4
+ import { liveTime, usePlayerStore } from "../player/store/playerStore";
5
5
  import type { DomEditSelection } from "../components/editor/domEditing";
6
6
  import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
7
7
  import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
@@ -60,6 +60,63 @@ afterEach(() => {
60
60
  });
61
61
  });
62
62
 
63
+ describe("dispatchPlainKey — select leftward / rightward", () => {
64
+ const clips = [
65
+ { ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
66
+ { ...bgmElement, id: "at", key: "at", start: 4, track: 1 },
67
+ { ...bgmElement, id: "late", key: "late", start: 7, track: 2 },
68
+ ];
69
+ beforeEach(() => usePlayerStore.setState({ elements: clips, currentTime: 4 }));
70
+
71
+ it("[ selects every clip starting before the playhead, on every track", () => {
72
+ const event = press("[");
73
+ dispatchPlainKey(event, "[", callbacks());
74
+ expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["early"]);
75
+ expect(usePlayerStore.getState().selectedElementId).toBe("early");
76
+ expect(event.defaultPrevented).toBe(true);
77
+ });
78
+
79
+ it("uses the live playhead while playing, not the time stored at play start", () => {
80
+ usePlayerStore.setState({ isPlaying: true, currentTime: 0 });
81
+ liveTime.notify(8);
82
+ try {
83
+ dispatchPlainKey(press("["), "[", callbacks());
84
+ expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual([
85
+ "at",
86
+ "early",
87
+ "late",
88
+ ]);
89
+ } finally {
90
+ usePlayerStore.setState({ isPlaying: false });
91
+ liveTime.notify(0);
92
+ }
93
+ });
94
+
95
+ it("clears a clicked keyframe like any other selection change", () => {
96
+ usePlayerStore.setState({ activeKeyframePct: 50 });
97
+ dispatchPlainKey(press("]"), "]", callbacks());
98
+ expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
99
+ });
100
+
101
+ it("] selects every clip starting at or after the playhead, on every track", () => {
102
+ dispatchPlainKey(press("]"), "]", callbacks());
103
+ const { selectedElementIds, selectedElementId } = usePlayerStore.getState();
104
+ expect([...selectedElementIds].sort()).toEqual(["at", "late"]);
105
+ expect(selectedElementId).toBe("at");
106
+ });
107
+
108
+ it("selects nothing when no clip is on that side", () => {
109
+ usePlayerStore.setState({
110
+ currentTime: 0,
111
+ selectedElementId: "late",
112
+ selectedElementIds: new Set(["late"]),
113
+ });
114
+ dispatchPlainKey(press("["), "[", callbacks());
115
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
116
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
117
+ });
118
+ });
119
+
63
120
  describe("dispatchPlainKey — Delete arbitration", () => {
64
121
  const selectBgm = () =>
65
122
  usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
@@ -0,0 +1,226 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement, useRef } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ useDomGeometryCommit,
7
+ usePlayerStore,
8
+ type DomEditOverlayProps,
9
+ type UseDomGeometryCommitOptions,
10
+ } from "../index";
11
+ import { makeSelection } from "./domSelectionTestHarness";
12
+
13
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
14
+
15
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
16
+
17
+ const SOURCE = '<div id="card">Card</div><div id="other">Other</div>';
18
+
19
+ /** A warm parse where only another element animates, and a GSAP writer answering `status`. */
20
+ function stubServer(status = 200, parseStatus = 200) {
21
+ const mutations: unknown[] = [];
22
+ vi.stubGlobal(
23
+ "fetch",
24
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
25
+ const url = String(input instanceof Request ? input.url : input);
26
+ const json = (body: unknown, code = 200) =>
27
+ new Response(JSON.stringify(body), {
28
+ status: code,
29
+ headers: { "content-type": "application/json" },
30
+ });
31
+ if (url.includes("/api/projects/p1/gsap-animations/")) {
32
+ if (parseStatus !== 200) return json({ error: "down" }, parseStatus);
33
+ return json({
34
+ animations: [
35
+ {
36
+ id: "other-fade",
37
+ targetSelector: "#other",
38
+ method: "to",
39
+ position: 0,
40
+ duration: 1,
41
+ properties: { opacity: 1 },
42
+ propertyGroup: "opacity",
43
+ },
44
+ ],
45
+ });
46
+ }
47
+ if (url.includes("/api/projects/p1/gsap-mutations/")) {
48
+ mutations.push(JSON.parse(String(init?.body)));
49
+ if (status !== 200) return json({ error: "refused" }, status);
50
+ return json({ ok: true, changed: true, before: "BEFORE", after: "AFTER" });
51
+ }
52
+ throw new Error(`Unexpected fetch: ${url}`);
53
+ }),
54
+ );
55
+ return mutations;
56
+ }
57
+
58
+ function renderHost(options: Partial<UseDomGeometryCommitOptions> = {}) {
59
+ const iframe = document.createElement("iframe");
60
+ document.body.append(iframe);
61
+ const doc = iframe.contentDocument;
62
+ if (!doc) throw new Error("Expected iframe document");
63
+ doc.body.innerHTML = SOURCE;
64
+ const element = doc.getElementById("card") as HTMLElement;
65
+ const recordEdit = vi.fn(async () => {});
66
+ const writeProjectFile = vi.fn(async () => {});
67
+ const reloadPreview = vi.fn();
68
+ const api: { current: ReturnType<typeof useDomGeometryCommit> | null } = { current: null };
69
+ function Host() {
70
+ const iframeRef = useRef<HTMLIFrameElement | null>(iframe);
71
+ api.current = useDomGeometryCommit({
72
+ projectId: "p1",
73
+ iframeRef,
74
+ writeProjectFile,
75
+ recordEdit,
76
+ reloadPreview,
77
+ ...options,
78
+ });
79
+ return null;
80
+ }
81
+ const root = createRoot(document.createElement("div"));
82
+ act(() => root.render(createElement(Host)));
83
+ const hook = () => {
84
+ if (!api.current) throw new Error("Expected the hook to render");
85
+ return api.current;
86
+ };
87
+ return {
88
+ element,
89
+ recordEdit,
90
+ hook,
91
+ rerender: () => act(() => root.render(createElement(Host))),
92
+ unmount: () => act(() => root.unmount()),
93
+ };
94
+ }
95
+
96
+ beforeEach(() => {
97
+ usePlayerStore.setState({ previewBooted: true, timelineProjectId: "p1" });
98
+ });
99
+
100
+ afterEach(() => {
101
+ vi.unstubAllGlobals();
102
+ document.body.innerHTML = "";
103
+ });
104
+
105
+ describe("useDomGeometryCommit, from the package entry", () => {
106
+ it("plugs into DomEditOverlay and saves a move as one GSAP write and one undo step", async () => {
107
+ const mutations = stubServer();
108
+ const { element, recordEdit, hook, unmount } = renderHost();
109
+ const overlayCommits: Pick<
110
+ DomEditOverlayProps,
111
+ "onPathOffsetCommit" | "onGroupPathOffsetCommit" | "onBoxSizeCommit" | "onRotationCommit"
112
+ > = {
113
+ onPathOffsetCommit: hook().commitPathOffset,
114
+ onGroupPathOffsetCommit: hook().commitGroupPathOffset,
115
+ onBoxSizeCommit: hook().commitBoxSize,
116
+ onRotationCommit: hook().commitRotation,
117
+ };
118
+
119
+ const outcome = await overlayCommits.onPathOffsetCommit(makeSelection("card", element), {
120
+ x: 40,
121
+ y: 20,
122
+ });
123
+
124
+ expect(outcome).toEqual({ ok: true });
125
+ expect(mutations).toEqual([
126
+ expect.objectContaining({ type: "add", targetSelector: "#card", method: "set" }),
127
+ ]);
128
+ expect(recordEdit).toHaveBeenCalledTimes(1);
129
+ expect(recordEdit).toHaveBeenCalledWith(
130
+ expect.objectContaining({ files: { "index.html": { before: "BEFORE", after: "AFTER" } } }),
131
+ );
132
+ unmount();
133
+ });
134
+
135
+ it("keeps the same commits across renders, so the overlay's handlers stay put", () => {
136
+ stubServer();
137
+ const { hook, rerender, unmount } = renderHost();
138
+ const first = hook();
139
+ rerender();
140
+ expect(hook().commitPathOffset).toBe(first.commitPathOffset);
141
+ expect(hook().commitRotation).toBe(first.commitRotation);
142
+ unmount();
143
+ });
144
+
145
+ it("rejects a move the server refuses, so the overlay undoes it", async () => {
146
+ stubServer(500);
147
+ const showToast = vi.fn();
148
+ const { element, recordEdit, hook, unmount } = renderHost({ showToast });
149
+
150
+ await expect(
151
+ hook().commitPathOffset(makeSelection("card", element), { x: 40, y: 20 }),
152
+ ).rejects.toThrow();
153
+ expect(recordEdit).not.toHaveBeenCalled();
154
+ expect(showToast).toHaveBeenCalledWith(expect.any(String), "error");
155
+ unmount();
156
+ });
157
+
158
+ it("undoes a resize whose animations cannot be read, and saves nothing", async () => {
159
+ const mutations = stubServer(200, 500);
160
+ const showToast = vi.fn();
161
+ const restore = vi.fn();
162
+ const { element, recordEdit, hook, unmount } = renderHost({ showToast });
163
+
164
+ await expect(
165
+ hook().commitBoxSize(
166
+ makeSelection("card", element),
167
+ { width: 300, height: 90 },
168
+ undefined,
169
+ restore,
170
+ ),
171
+ ).rejects.toThrow();
172
+ expect(restore).toHaveBeenCalledTimes(1);
173
+ expect(showToast).toHaveBeenCalledWith(expect.any(String), "error");
174
+ expect(mutations).toHaveLength(0);
175
+ expect(recordEdit).not.toHaveBeenCalled();
176
+ unmount();
177
+ });
178
+
179
+ it.each([
180
+ ["the player shows another project", { timelineProjectId: "another-project" }],
181
+ ["the preview has not booted", { previewBooted: false }],
182
+ ])("refuses at once when %s", async (_state, store) => {
183
+ const mutations = stubServer();
184
+ usePlayerStore.setState(store);
185
+ const restore = vi.fn();
186
+ const showToast = vi.fn();
187
+ const { element, hook, unmount } = renderHost({ showToast });
188
+
189
+ await expect(
190
+ hook().commitBoxSize(
191
+ makeSelection("card", element),
192
+ { width: 300, height: 90 },
193
+ undefined,
194
+ restore,
195
+ ),
196
+ ).rejects.toThrow("has not loaded yet");
197
+ expect(restore).toHaveBeenCalledTimes(1);
198
+ expect(showToast).toHaveBeenCalledWith(expect.stringContaining("not loaded"), "error");
199
+ expect(mutations).toHaveLength(0);
200
+ unmount();
201
+ });
202
+
203
+ it("waits for a move still being saved", async () => {
204
+ const mutations = stubServer();
205
+ const { element, recordEdit, hook, unmount } = renderHost();
206
+
207
+ const move = hook().commitPathOffset(makeSelection("card", element), { x: 40, y: 20 });
208
+ await hook().waitForPendingSaves();
209
+
210
+ expect(mutations).toHaveLength(1);
211
+ expect(recordEdit).toHaveBeenCalledTimes(1);
212
+ await move;
213
+ unmount();
214
+ });
215
+
216
+ it("rejects without a project and writes nothing", async () => {
217
+ const mutations = stubServer();
218
+ const { element, hook, unmount } = renderHost({ projectId: null });
219
+
220
+ await expect(
221
+ hook().commitRotation(makeSelection("card", element), { angle: 15 }),
222
+ ).rejects.toThrow("No project is open");
223
+ expect(mutations).toHaveLength(0);
224
+ unmount();
225
+ });
226
+ });