@hyperframes/studio 0.8.88 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.88",
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/parsers": "0.8.88",
64
- "@hyperframes/core": "0.8.88",
65
- "@hyperframes/player": "0.8.88",
66
- "@hyperframes/studio-server": "0.8.88",
67
- "@hyperframes/sdk": "0.8.88",
68
- "@hyperframes/shader-transitions": "0.8.88"
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.88"
84
+ "@hyperframes/producer": "0.8.89"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -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
 
@@ -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
+ });
@@ -0,0 +1,147 @@
1
+ import { useCallback, useMemo, useRef } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { DomEditSelection } from "../components/editor/domEditing";
4
+ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
5
+ import { isPreviewBooted } from "../player/store/playerStore";
6
+ import type { DomEditCommitOutcome } from "./domEditCommitRunner";
7
+ import type { UseDomStyleCommitOptions } from "./useDomStyleCommit";
8
+ import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
9
+ import { useGsapAwareEditing } from "./useGsapAwareEditing";
10
+ import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
11
+ import { useGsapScriptCommits } from "./useGsapScriptCommits";
12
+ import { useGsapCacheVersion } from "./useGsapTweenCache";
13
+
14
+ /**
15
+ * Studio's `Player` must show the project, with `beginTimelineSession(projectId)` run before it
16
+ * mounts; drain `waitForPendingSaves` before switching projects.
17
+ */
18
+ export interface UseDomGeometryCommitOptions extends UseDomStyleCommitOptions {
19
+ /** Called when a save cannot patch the preview in place; defaults to reloading the iframe. */
20
+ reloadPreview?: () => void;
21
+ }
22
+
23
+ export interface DomGeometryCommits {
24
+ commitPathOffset: (
25
+ selection: DomEditSelection,
26
+ next: { x: number; y: number },
27
+ modifiers?: { altKey?: boolean },
28
+ ) => Promise<DomEditCommitOutcome>;
29
+ commitGroupPathOffset: (updates: DomEditGroupPathOffsetCommit[]) => Promise<DomEditCommitOutcome>;
30
+ commitBoxSize: (
31
+ selection: DomEditSelection,
32
+ next: { width: number; height: number },
33
+ offset?: { x: number; y: number },
34
+ restore?: () => void,
35
+ ) => Promise<DomEditCommitOutcome>;
36
+ commitRotation: (
37
+ selection: DomEditSelection,
38
+ next: { angle: number },
39
+ ) => Promise<DomEditCommitOutcome>;
40
+ waitForPendingSaves: () => Promise<void>;
41
+ }
42
+
43
+ const noop = () => {};
44
+ const NO_SELECTED_ANIMATIONS: GsapAnimation[] = [];
45
+ // ponytail: unreachable, the GSAP writer always exists so a resize never takes the DOM route.
46
+ const noDomBoxSizeRoute = () => Promise.reject(new Error("Resize has no DOM route here"));
47
+
48
+ /**
49
+ * Saves canvas moves, resizes and rotations through Studio's own GSAP-aware commits, for a host
50
+ * without the editor. A failed save rejects, so DomEditOverlay undoes the live gesture.
51
+ */
52
+ export function useDomGeometryCommit({
53
+ projectId,
54
+ iframeRef,
55
+ writeProjectFile,
56
+ recordEdit,
57
+ activeCompPath = null,
58
+ showToast = noop,
59
+ reloadPreview,
60
+ }: UseDomGeometryCommitOptions): DomGeometryCommits {
61
+ const projectIdRef = useRef(projectId);
62
+ projectIdRef.current = projectId;
63
+ const pending = useRef(new Set<Promise<void>>()).current;
64
+ const editHistory = useMemo(() => ({ recordEdit }), [recordEdit]);
65
+ const reload = useCallback(
66
+ () => (reloadPreview ? reloadPreview() : iframeRef.current?.contentWindow?.location.reload()),
67
+ [reloadPreview, iframeRef],
68
+ );
69
+ const { bump: bumpGsapCache } = useGsapCacheVersion();
70
+ const gsap = useGsapScriptCommits({
71
+ projectIdRef,
72
+ activeCompPath,
73
+ previewIframeRef: iframeRef,
74
+ editHistory,
75
+ reloadPreview: reload,
76
+ onCacheInvalidate: bumpGsapCache,
77
+ showToast,
78
+ writeProjectFile,
79
+ });
80
+ const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
81
+ const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
82
+ const {
83
+ handleGsapAwarePathOffsetCommit,
84
+ handleGsapAwareGroupPathOffsetCommit,
85
+ handleGsapAwareBoxSizeCommit,
86
+ handleGsapAwareRotationCommit,
87
+ } = useGsapAwareEditing({
88
+ // The host owns selection, so every gesture reads its element's animations from the server.
89
+ domEditSelection: null,
90
+ selectedGsapAnimations: NO_SELECTED_ANIMATIONS,
91
+ gsapCommitMutation: gsap.commitMutation,
92
+ previewIframeRef: iframeRef,
93
+ showToast,
94
+ bumpGsapCache,
95
+ makeFetchFallback,
96
+ trackGsapInteractionFailure,
97
+ handleDomBoxSizeCommit: noDomBoxSizeRoute,
98
+ addGsapAnimation: gsap.addGsapAnimation,
99
+ convertToKeyframes: gsap.convertToKeyframes,
100
+ setArcPath: gsap.setArcPath,
101
+ updateArcSegment: gsap.updateArcSegment,
102
+ });
103
+ const saved = useCallback(
104
+ async (commit: () => Promise<void>, restore = noop): Promise<DomEditCommitOutcome> => {
105
+ const refusal = !projectIdRef.current
106
+ ? "No project is open"
107
+ : !isPreviewBooted(projectIdRef.current)
108
+ ? "The preview for this project has not loaded yet"
109
+ : null;
110
+ if (refusal) {
111
+ restore();
112
+ showToast(refusal, "error");
113
+ throw new Error(refusal);
114
+ }
115
+ const run = commit();
116
+ pending.add(run);
117
+ try {
118
+ await run;
119
+ } finally {
120
+ pending.delete(run);
121
+ }
122
+ return { ok: true };
123
+ },
124
+ [pending, showToast],
125
+ );
126
+ return useMemo(
127
+ () => ({
128
+ commitPathOffset: (selection, next, modifiers) =>
129
+ saved(() => handleGsapAwarePathOffsetCommit(selection, next, modifiers)),
130
+ commitGroupPathOffset: (updates) =>
131
+ saved(() => handleGsapAwareGroupPathOffsetCommit(updates)),
132
+ commitBoxSize: (selection, next, offset, restore) =>
133
+ saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore), restore),
134
+ commitRotation: (selection, next) =>
135
+ saved(() => handleGsapAwareRotationCommit(selection, next)),
136
+ waitForPendingSaves: () => Promise.allSettled([...pending]).then(noop),
137
+ }),
138
+ [
139
+ saved,
140
+ pending,
141
+ handleGsapAwarePathOffsetCommit,
142
+ handleGsapAwareGroupPathOffsetCommit,
143
+ handleGsapAwareBoxSizeCommit,
144
+ handleGsapAwareRotationCommit,
145
+ ],
146
+ );
147
+ }
@@ -298,10 +298,15 @@ export function useGsapAwareEditing({
298
298
  offset?: { x: number; y: number },
299
299
  restore: () => void = () => undefined,
300
300
  ) => {
301
- const ownedAnimations = getGsapAnimationsForSelection(selection);
302
- const targetAnimations = Array.isArray(ownedAnimations)
303
- ? ownedAnimations
304
- : await ownedAnimations;
301
+ let targetAnimations: GsapAnimation[];
302
+ try {
303
+ const ownedAnimations = getGsapAnimationsForSelection(selection);
304
+ targetAnimations = Array.isArray(ownedAnimations) ? ownedAnimations : await ownedAnimations;
305
+ } catch (error) {
306
+ restore();
307
+ trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
308
+ throw error;
309
+ }
305
310
  const scaleRoute = targetAnimations.some((anim) => anim.propertyGroup === "scale");
306
311
  const selector = selectorFromSelection(selection);
307
312
  const hasLivePositionTween = selector
package/src/index.ts CHANGED
@@ -167,7 +167,7 @@ export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
167
167
  export { useClipboard } from "./hooks/useClipboard";
168
168
  export type { UseClipboardOptions } from "./hooks/useClipboard";
169
169
 
170
- // DOM editing for a host outside EditorShell; useDomStyleCommit saves styles without the session.
170
+ // DOM editing for a host outside EditorShell; the Commit hooks save without useDomEditSession.
171
171
  export { useDomEditSession } from "./hooks/useDomEditSession";
172
172
  export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
173
173
  export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
@@ -181,6 +181,8 @@ export { useDomStyleCommit } from "./hooks/useDomStyleCommit";
181
181
  export type { UseDomStyleCommitOptions } from "./hooks/useDomStyleCommit";
182
182
  export type { DomEditCommitDeclineReason, DomEditCommitOutcome } from "./hooks/domEditCommitRunner";
183
183
  export { resolveDomEditSelection } from "./components/editor/domEditingLayers";
184
+ export { useDomGeometryCommit } from "./hooks/useDomGeometryCommit";
185
+ export type { DomGeometryCommits, UseDomGeometryCommitOptions } from "./hooks/useDomGeometryCommit";
184
186
  export { DomEditOverlay } from "./components/editor/DomEditOverlay";
185
187
  export type {
186
188
  DomEditGroupPathOffsetCommit,