@hyperframes/studio 0.8.88 → 0.8.90
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/dist/assets/{hyperframes-player-w3Bbv0kd.js → hyperframes-player-DDjER_ii.js} +1 -1
- package/dist/assets/{index-DldknZPp.js → index-5SFeUXP8.js} +1 -1
- package/dist/assets/{index-DE6cZYR5.js → index-BKE3_N4v.js} +180 -180
- package/dist/assets/{index-B6PGJTzp.js → index-CVsCxnlz.js} +1 -1
- package/dist/index.d.ts +39 -5
- package/dist/index.html +1 -1
- package/dist/index.js +156 -65
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditOverlay.tsx +7 -4
- package/src/components/editor/domEditOverlayGestures.ts +4 -4
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useInlineTextEditing.tsx +2 -4
- package/src/hooks/useDomGeometryCommit.test.tsx +226 -0
- package/src/hooks/useDomGeometryCommit.ts +147 -0
- package/src/hooks/useGsapAwareEditing.ts +9 -4
- package/src/index.ts +3 -1
- package/src/player/sceneSwap.ts +21 -0
- package/src/utils/gsapUndoRestore.test.ts +58 -0
- package/src/utils/gsapUndoRestore.ts +6 -1
- package/src/utils/studioPreviewHelpers.test.ts +29 -0
- package/src/utils/studioPreviewHelpers.ts +37 -80
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.90",
|
|
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/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/player": "0.8.
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.90",
|
|
64
|
+
"@hyperframes/parsers": "0.8.90",
|
|
65
|
+
"@hyperframes/player": "0.8.90",
|
|
66
|
+
"@hyperframes/sdk": "0.8.90",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.90",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.90"
|
|
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.
|
|
84
|
+
"@hyperframes/producer": "0.8.90"
|
|
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<
|
|
85
|
-
onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<
|
|
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<
|
|
92
|
-
onRotationCommit: (
|
|
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<
|
|
250
|
+
) => Promise<unknown> | void
|
|
251
251
|
>;
|
|
252
252
|
onGroupPathOffsetCommitRef: RefObject<
|
|
253
|
-
(updates: DomEditGroupPathOffsetCommit[]) => Promise<
|
|
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<
|
|
261
|
+
) => Promise<unknown> | void
|
|
262
262
|
>;
|
|
263
263
|
onRotationCommitRef: RefObject<
|
|
264
|
-
(s: DomEditSelection, n: { angle: number }) => Promise<
|
|
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<
|
|
63
|
+
) => Promise<unknown> | void
|
|
64
64
|
>;
|
|
65
65
|
onGroupPathOffsetCommitRef: RefObject<
|
|
66
|
-
(updates: DomEditGroupPathOffsetCommit[]) => Promise<
|
|
66
|
+
(updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown> | void
|
|
67
67
|
>;
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -61,10 +61,8 @@ export function useInlineTextEditing(
|
|
|
61
61
|
const elementUnderPress = (event: { clientX: number; clientY: number }) => {
|
|
62
62
|
const iframe = actions?.previewIframeRef?.current;
|
|
63
63
|
if (!iframe) return null;
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// finds wrappers. This helper lifts that for the length of the hit test,
|
|
67
|
-
// and is the same one the canvas uses to decide what was clicked.
|
|
64
|
+
// The same hit test the canvas uses to decide what was clicked; a plain
|
|
65
|
+
// elementFromPoint only finds wrappers, since Studio suppresses pointer events.
|
|
68
66
|
return getPreviewTargetFromPointer(
|
|
69
67
|
iframe,
|
|
70
68
|
event.clientX,
|
|
@@ -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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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;
|
|
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,
|
package/src/player/sceneSwap.ts
CHANGED
|
@@ -17,6 +17,27 @@ export function onPreviewContentReplaced(
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function markScenesStale(iframe: HTMLIFrameElement | null, files: readonly string[]): void {
|
|
21
|
+
const doc = iframe?.contentDocument;
|
|
22
|
+
const meta = doc?.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]');
|
|
23
|
+
if (!doc || !meta) return;
|
|
24
|
+
let parts: { shared: string; scenes: Record<string, string> };
|
|
25
|
+
try {
|
|
26
|
+
parts = JSON.parse(meta.content);
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
const hosts = doc.querySelectorAll(`[data-composition-file="${CSS.escape(file)}"]`);
|
|
32
|
+
if (hosts.length === 0 && file.endsWith(".html")) parts.shared = "";
|
|
33
|
+
for (const host of hosts) {
|
|
34
|
+
const scene = host.closest("[data-hf-scene]")?.getAttribute("data-hf-scene");
|
|
35
|
+
if (scene && scene in parts.scenes) parts.scenes[scene] = "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
meta.content = JSON.stringify(parts);
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
/** Null when the preview cannot swap; rejects when superseded, cancelled or a full reload is needed. */
|
|
21
42
|
export function sceneSwapFor(
|
|
22
43
|
iframe: HTMLIFrameElement,
|
|
@@ -259,6 +259,64 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
259
259
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
it("blanks a restored scene's live hash before reloading, so the scene swap takes it", () => {
|
|
263
|
+
const { iframe, doc } = buildLiveIframe(
|
|
264
|
+
`<div data-hf-scene="scene0" data-composition-file="scenes/intro.html"><h1>Rep0</h1></div>` +
|
|
265
|
+
`<div data-hf-scene="scene1" data-composition-file="scenes/outro.html"></div>`,
|
|
266
|
+
);
|
|
267
|
+
const meta = doc.createElement("meta");
|
|
268
|
+
meta.name = "hf-scene-parts";
|
|
269
|
+
meta.content = JSON.stringify({ shared: "s", scenes: { scene0: "h0", scene1: "h1" } });
|
|
270
|
+
doc.head.append(meta);
|
|
271
|
+
let partsAtReload: unknown;
|
|
272
|
+
const reloadPreview = vi.fn(() => (partsAtReload = JSON.parse(meta.content)));
|
|
273
|
+
const files = {
|
|
274
|
+
"scenes/intro.html": { previous: "<h1>Rep0</h1>", restored: "<h1>Alpha</h1>" },
|
|
275
|
+
};
|
|
276
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
277
|
+
expect(partsAtReload).toEqual({ shared: "s", scenes: { scene0: "", scene1: "h1" } });
|
|
278
|
+
|
|
279
|
+
const withRoot = {
|
|
280
|
+
...files,
|
|
281
|
+
[ROOT]: { previous: wrap("<p>b</p>"), restored: wrap("<p>a</p>") },
|
|
282
|
+
};
|
|
283
|
+
applyUndoRestoreToPreview(iframe, ROOT, withRoot, 3, reloadPreview);
|
|
284
|
+
// A restored page outside every scene forces the full reload, never a partial swap.
|
|
285
|
+
expect(partsAtReload).toEqual({ shared: "", scenes: { scene0: "", scene1: "h1" } });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("leaves the scene hashes alone on a soft restore, so the next edit can still swap", () => {
|
|
289
|
+
const { iframe, doc } = buildLiveIframe(`<div id="a" style="color: red">t</div>`);
|
|
290
|
+
const meta = doc.createElement("meta");
|
|
291
|
+
meta.name = "hf-scene-parts";
|
|
292
|
+
meta.content = JSON.stringify({ shared: "s", scenes: { scene0: "h0" } });
|
|
293
|
+
doc.head.append(meta);
|
|
294
|
+
const files = {
|
|
295
|
+
[ROOT]: {
|
|
296
|
+
previous: wrap(`<div id="a" style="color: red">t</div>`),
|
|
297
|
+
restored: wrap(`<div id="a" style="color: blue">t</div>`),
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
|
|
301
|
+
expect(JSON.parse(meta.content)).toEqual({ shared: "s", scenes: { scene0: "h0" } });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it.each([
|
|
305
|
+
["a restored stylesheet leaves the shared hash", "styles.css", '{"shared":"s","scenes":{}}'],
|
|
306
|
+
["an unreadable manifest still reloads", "scenes/intro.html", "not json"],
|
|
307
|
+
])("%s", (_name, path, content) => {
|
|
308
|
+
const { iframe, doc } = buildLiveIframe(`<div id="a">t</div>`);
|
|
309
|
+
const meta = doc.createElement("meta");
|
|
310
|
+
meta.name = "hf-scene-parts";
|
|
311
|
+
meta.content = content;
|
|
312
|
+
doc.head.append(meta);
|
|
313
|
+
const reloadPreview = vi.fn();
|
|
314
|
+
const files = { [path]: { previous: "a", restored: "b" } };
|
|
315
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
316
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
317
|
+
expect(meta.content).toBe(content);
|
|
318
|
+
});
|
|
319
|
+
|
|
262
320
|
it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
|
|
263
321
|
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
264
322
|
const reloadPreview = vi.fn();
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
extractGsapScriptText,
|
|
9
9
|
findGsapScriptElements,
|
|
10
10
|
} from "./gsapSoftReload";
|
|
11
|
+
import { markScenesStale } from "../player/sceneSwap";
|
|
11
12
|
|
|
12
13
|
type PreviewWindow = Window & {
|
|
13
14
|
__player?: { seek?: (t: number) => void };
|
|
@@ -184,7 +185,7 @@ export function applyUndoRestoreToPreview(
|
|
|
184
185
|
activeCompPath: string | null,
|
|
185
186
|
files: Record<string, UndoRestoreFile> | undefined,
|
|
186
187
|
currentTime: number,
|
|
187
|
-
|
|
188
|
+
reload: () => void,
|
|
188
189
|
): "soft" | "full" {
|
|
189
190
|
// The master view carries a NULL activeCompPath but the root iframe shows
|
|
190
191
|
// index.html — the codebase-wide convention (`activeCompPath || "index.html"`).
|
|
@@ -192,6 +193,10 @@ export function applyUndoRestoreToPreview(
|
|
|
192
193
|
// full-reloaded: the original "undo always blinks".
|
|
193
194
|
const activeDocPath = activeCompPath ?? "index.html";
|
|
194
195
|
const paths = files ? Object.keys(files) : [];
|
|
196
|
+
const reloadPreview = () => {
|
|
197
|
+
markScenesStale(iframe, paths);
|
|
198
|
+
reload();
|
|
199
|
+
};
|
|
195
200
|
// Soft path only covers the single active-comp document in the root iframe.
|
|
196
201
|
if (!iframe || !files || paths.length !== 1 || paths[0] !== activeDocPath) {
|
|
197
202
|
reloadPreview();
|