@hyperframes/studio 0.8.113 → 0.8.114
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-DzJbtQVO.js → hyperframes-player-aXNJKiIg.js} +1 -1
- package/dist/assets/index-BYnsJnJV.css +1 -0
- package/dist/assets/{index-n5FUn1xv.js → index-Bn2RJNTJ.js} +1 -1
- package/dist/assets/{index-DgwDQK5G.js → index-C9N-DGjC.js} +1 -1
- package/dist/assets/{index-DRhF3v4Z.js → index-Dz7doz9l.js} +199 -199
- package/dist/index.d.ts +3 -3
- package/dist/index.html +2 -2
- package/dist/index.js +1605 -1536
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +82 -84
- package/src/components/EditorShell.tsx +3 -3
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftPanels.tsx +3 -3
- package/src/components/StudioOverlays.tsx +4 -4
- package/src/components/StudioRightPanels.tsx +14 -9
- package/src/components/StudioToast.tsx +1 -1
- package/src/components/TimelineToolbar.tsx +3 -3
- package/src/components/editor/InlineTextToolbar.tsx +11 -27
- package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
- package/src/components/editor/colorValue.test.ts +16 -0
- package/src/components/editor/colorValue.ts +100 -24
- package/src/components/editor/cropResize.test.ts +11 -0
- package/src/components/editor/cropResize.ts +2 -3
- package/src/components/editor/gradientValue.ts +28 -36
- package/src/components/editor/gsapAnimatesProperty.ts +0 -54
- package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
- package/src/components/editor/inlineTextStyleRange.ts +5 -2
- package/src/components/editor/inlineTextStyleRead.ts +9 -0
- package/src/components/editor/manualEditsDom.ts +2 -2
- package/src/components/editor/manualEditsSeekReapply.ts +2 -2
- package/src/components/editor/propertyPanelColor.tsx +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
- package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
- package/src/hooks/gsapRuntimeReaders.ts +3 -90
- package/src/hooks/useAppHotkeys.ts +11 -5
- package/src/hooks/useFrameCapture.ts +15 -8
- package/src/hooks/useGestureCommit.ts +4 -1
- package/src/hooks/useGestureTrailOverlay.tsx +33 -0
- package/src/hooks/usePanelLayout.ts +5 -1
- package/src/hooks/useShellSlices.ts +53 -0
- package/src/hooks/useStableHandlers.test.tsx +77 -0
- package/src/hooks/useStableHandlers.ts +44 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -1
- package/src/hooks/useStudioSidePanels.test.tsx +44 -0
- package/src/hooks/useStudioSidePanels.tsx +23 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
- package/src/hooks/useTimelineEditing.ts +33 -27
- package/src/utils/frameCapture.ts +3 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
- package/dist/assets/index-DbfKVTId.css +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { gsap } from "gsap";
|
|
3
4
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
5
|
import {
|
|
5
6
|
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
|
|
@@ -109,3 +110,50 @@ describe("color-grading opacity truth", () => {
|
|
|
109
110
|
expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.3);
|
|
110
111
|
});
|
|
111
112
|
});
|
|
113
|
+
|
|
114
|
+
describe("readAllAnimatedProperties with a sibling tween", () => {
|
|
115
|
+
it("leaves a sibling tween's rotation to that tween after an opacity keyframe edit", () => {
|
|
116
|
+
const el = document.createElement("div");
|
|
117
|
+
el.id = "clip";
|
|
118
|
+
document.body.appendChild(el);
|
|
119
|
+
const build = (keyframes: Record<string, Record<string, number>>) => {
|
|
120
|
+
const tl = gsap.timeline({ paused: true });
|
|
121
|
+
tl.to(el, { duration: 4, ease: "none", keyframes }, 0);
|
|
122
|
+
tl.to(el, { rotation: 90, duration: 3 }, 0);
|
|
123
|
+
return tl;
|
|
124
|
+
};
|
|
125
|
+
const live = build({ "0%": { opacity: 0 }, "100%": { opacity: 1 } });
|
|
126
|
+
live.seek(2);
|
|
127
|
+
const iframe = {
|
|
128
|
+
contentWindow: { __timelines: { main: live }, gsap },
|
|
129
|
+
contentDocument: document,
|
|
130
|
+
} as unknown as HTMLIFrameElement;
|
|
131
|
+
const anim = {
|
|
132
|
+
id: "#clip-to-0-visual",
|
|
133
|
+
targetSelector: "#clip",
|
|
134
|
+
method: "to",
|
|
135
|
+
properties: {},
|
|
136
|
+
keyframes: {
|
|
137
|
+
keyframes: [
|
|
138
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
139
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
} as unknown as GsapAnimation;
|
|
143
|
+
|
|
144
|
+
// commitKeyframeProps: these values plus the edit at the playhead, and backfilled into the rest.
|
|
145
|
+
const read = readAllAnimatedProperties(iframe, "#clip", anim);
|
|
146
|
+
live.kill();
|
|
147
|
+
gsap.set(el, { clearProps: "all" });
|
|
148
|
+
const edited = build({
|
|
149
|
+
"0%": { ...read, opacity: 0 },
|
|
150
|
+
"50%": { ...read, opacity: 0.3 },
|
|
151
|
+
"100%": { ...read, opacity: 1 },
|
|
152
|
+
});
|
|
153
|
+
edited.seek(3.2);
|
|
154
|
+
edited.seek(3.5);
|
|
155
|
+
|
|
156
|
+
expect(gsap.getProperty(el, "rotation")).toBe(90);
|
|
157
|
+
edited.kill();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
|
9
9
|
} from "@hyperframes/core/color-grading";
|
|
10
10
|
import { getIframeGsap, queryIframeElement, type IframeGsap } from "./gsapShared";
|
|
11
|
+
import { gsapWritesChannels } from "./gsapRuntimeKeyframes";
|
|
11
12
|
import { roundTo3 } from "../utils/rounding";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -50,27 +51,6 @@ export function readGsapProperty(
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]);
|
|
53
|
-
const GSAP_CONFIG_KEYS = new Set([
|
|
54
|
-
"duration",
|
|
55
|
-
"ease",
|
|
56
|
-
"delay",
|
|
57
|
-
"stagger",
|
|
58
|
-
"id",
|
|
59
|
-
"onComplete",
|
|
60
|
-
"onUpdate",
|
|
61
|
-
"onStart",
|
|
62
|
-
"onRepeat",
|
|
63
|
-
"repeat",
|
|
64
|
-
"yoyo",
|
|
65
|
-
"repeatDelay",
|
|
66
|
-
"paused",
|
|
67
|
-
"immediateRender",
|
|
68
|
-
"lazy",
|
|
69
|
-
"overwrite",
|
|
70
|
-
"keyframes",
|
|
71
|
-
"parent",
|
|
72
|
-
]);
|
|
73
|
-
|
|
74
54
|
export function readAllAnimatedProperties(
|
|
75
55
|
iframe: HTMLIFrameElement | null,
|
|
76
56
|
selector: string,
|
|
@@ -115,74 +95,7 @@ export function readAllAnimatedProperties(
|
|
|
115
95
|
}
|
|
116
96
|
}
|
|
117
97
|
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
const win = iframe.contentWindow as unknown as { __timelines?: Record<string, unknown> };
|
|
121
|
-
const timelines = win.__timelines;
|
|
122
|
-
if (timelines) {
|
|
123
|
-
for (const tl of Object.values(timelines)) {
|
|
124
|
-
const tlObj = tl as {
|
|
125
|
-
getChildren?: (
|
|
126
|
-
deep: boolean,
|
|
127
|
-
) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
|
|
128
|
-
};
|
|
129
|
-
if (!tlObj?.getChildren) continue;
|
|
130
|
-
for (const child of tlObj.getChildren(true)) {
|
|
131
|
-
if (typeof child.targets !== "function") continue;
|
|
132
|
-
const targets = child.targets();
|
|
133
|
-
if (!targets.includes(el)) continue;
|
|
134
|
-
const vars = child.vars;
|
|
135
|
-
if (!vars) continue;
|
|
136
|
-
for (const k of Object.keys(vars)) {
|
|
137
|
-
if (!GSAP_CONFIG_KEYS.has(k) && inGroup(k)) otherTweenProps.add(k);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
} catch {}
|
|
143
|
-
for (const p of groupedPropKeys) otherTweenProps.delete(p);
|
|
144
|
-
|
|
145
|
-
// Tier 1: Transform + visual properties with universal CSS defaults.
|
|
146
|
-
// Safe to compare against hardcoded values — these are always 0 or 1
|
|
147
|
-
// regardless of the element's stylesheet.
|
|
148
|
-
const UNIVERSAL_BASELINE: Record<string, number> = {
|
|
149
|
-
opacity: 1,
|
|
150
|
-
scale: 1,
|
|
151
|
-
scaleX: 1,
|
|
152
|
-
scaleY: 1,
|
|
153
|
-
scaleZ: 1,
|
|
154
|
-
rotation: 0,
|
|
155
|
-
rotationX: 0,
|
|
156
|
-
rotationY: 0,
|
|
157
|
-
skewX: 0,
|
|
158
|
-
skewY: 0,
|
|
159
|
-
z: 0,
|
|
160
|
-
xPercent: 0,
|
|
161
|
-
yPercent: 0,
|
|
162
|
-
transformPerspective: 0,
|
|
163
|
-
blur: 0,
|
|
164
|
-
brightness: 1,
|
|
165
|
-
contrast: 1,
|
|
166
|
-
saturate: 1,
|
|
167
|
-
hueRotate: 0,
|
|
168
|
-
grayscale: 0,
|
|
169
|
-
sepia: 0,
|
|
170
|
-
invert: 0,
|
|
171
|
-
};
|
|
172
|
-
// Collect all properties that ANY tween on this element explicitly targets.
|
|
173
|
-
// Only capture baseline values for these — GSAP reports non-default values
|
|
174
|
-
// (scaleZ=0, brightness=0) for untouched properties, polluting keyframes.
|
|
175
|
-
const allTweenedProps = new Set([...groupedPropKeys, ...otherTweenProps]);
|
|
176
|
-
for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
|
|
177
|
-
if (prop in result) continue;
|
|
178
|
-
if (!allTweenedProps.has(prop)) continue;
|
|
179
|
-
const val = readLiveGsapValue(gsap, el, prop);
|
|
180
|
-
if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
|
|
181
|
-
result[prop] = roundTo3(val);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Tier 2: Element-dependent properties — their "default" depends on the
|
|
98
|
+
// Element-dependent properties — their "default" depends on the
|
|
186
99
|
// stylesheet, so we compare GSAP's runtime value against the element's
|
|
187
100
|
// computed CSS value. Only capture if GSAP has actively changed it.
|
|
188
101
|
const COMPUTED_BASELINE = [
|
|
@@ -209,7 +122,7 @@ export function readAllAnimatedProperties(
|
|
|
209
122
|
for (const prop of COMPUTED_BASELINE) {
|
|
210
123
|
if (prop in result) continue;
|
|
211
124
|
if (!inGroup(prop)) continue;
|
|
212
|
-
if (
|
|
125
|
+
if (gsapWritesChannels(el, [prop])) continue;
|
|
213
126
|
const gsapVal = Number(gsap.getProperty(el, prop));
|
|
214
127
|
if (!Number.isFinite(gsapVal)) continue;
|
|
215
128
|
let cssVal = NaN;
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type EditHistoryHandle,
|
|
16
16
|
type UseEditHistoryActionsOptions,
|
|
17
17
|
} from "./useEditHistoryActions";
|
|
18
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
18
19
|
|
|
19
20
|
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
|
|
20
21
|
try {
|
|
@@ -72,6 +73,7 @@ function tryApplyBeatHistory(
|
|
|
72
73
|
// ── Types ──
|
|
73
74
|
|
|
74
75
|
interface UseAppHotkeysParams {
|
|
76
|
+
projectId?: string | null;
|
|
75
77
|
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
76
78
|
handleLinkEdit?: LinkShortcutCallbacks["handleLinkEdit"];
|
|
77
79
|
handleTimelineElementDeleteOnly?: LinkShortcutCallbacks["handleTimelineElementDeleteOnly"];
|
|
@@ -117,6 +119,7 @@ interface UseAppHotkeysParams {
|
|
|
117
119
|
// ── Hook ──
|
|
118
120
|
|
|
119
121
|
export function useAppHotkeys({
|
|
122
|
+
projectId,
|
|
120
123
|
handleTimelineElementsDelete,
|
|
121
124
|
handleLinkEdit,
|
|
122
125
|
handleTimelineElementDeleteOnly,
|
|
@@ -266,9 +269,12 @@ export function useAppHotkeys({
|
|
|
266
269
|
[],
|
|
267
270
|
);
|
|
268
271
|
|
|
269
|
-
return
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
return useStableHandlers(
|
|
273
|
+
{
|
|
274
|
+
handleUndo,
|
|
275
|
+
handleRedo,
|
|
276
|
+
syncPreviewHotkeys,
|
|
277
|
+
},
|
|
278
|
+
projectId,
|
|
279
|
+
);
|
|
274
280
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useRef, type MouseEvent } from "react";
|
|
1
|
+
import { useMemo, useState, useCallback, useRef, type MouseEvent } from "react";
|
|
2
2
|
import { useMountEffect } from "./useMountEffect";
|
|
3
3
|
import { liveTime, usePlayerStore } from "../player";
|
|
4
4
|
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
|
|
@@ -34,8 +34,10 @@ export function useFrameCapture({
|
|
|
34
34
|
return isPlaying ? livePlayheadRef.current : currentTime;
|
|
35
35
|
}, []);
|
|
36
36
|
|
|
37
|
+
const [captureStamp, setCaptureStamp] = useState(Date.now);
|
|
37
38
|
const refreshCaptureFrameTime = useCallback(() => {
|
|
38
39
|
setCaptureFrameTime(playheadTime());
|
|
40
|
+
setCaptureStamp(Date.now());
|
|
39
41
|
}, [playheadTime]);
|
|
40
42
|
|
|
41
43
|
const handleCaptureFrameClick = useCallback(
|
|
@@ -103,13 +105,18 @@ export function useFrameCapture({
|
|
|
103
105
|
[activeCompPath, playheadTime, projectId, showToast, waitForPendingDomEditSaves],
|
|
104
106
|
);
|
|
105
107
|
|
|
106
|
-
const captureFrameHref =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
const captureFrameHref = useMemo(
|
|
109
|
+
() =>
|
|
110
|
+
projectId
|
|
111
|
+
? buildFrameCaptureUrl({
|
|
112
|
+
projectId,
|
|
113
|
+
compositionPath: activeCompPath,
|
|
114
|
+
currentTime: captureFrameTime,
|
|
115
|
+
version: captureStamp,
|
|
116
|
+
})
|
|
117
|
+
: "#",
|
|
118
|
+
[projectId, activeCompPath, captureFrameTime, captureStamp],
|
|
119
|
+
);
|
|
113
120
|
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
|
114
121
|
|
|
115
122
|
return {
|
|
@@ -14,6 +14,7 @@ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
|
14
14
|
import { roundTo3 } from "../utils/rounding";
|
|
15
15
|
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
16
16
|
import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
|
|
17
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
17
18
|
|
|
18
19
|
type RecordedKeyframe = {
|
|
19
20
|
percentage: number;
|
|
@@ -83,6 +84,7 @@ function reloadOnlyLast(index: number, count: number): Partial<CommitMutationOpt
|
|
|
83
84
|
let gestureRecordingCommitCounter = 0;
|
|
84
85
|
|
|
85
86
|
interface UseGestureCommitParams {
|
|
87
|
+
projectId?: string | null;
|
|
86
88
|
domEditSessionRef: React.MutableRefObject<GestureSessionRef>;
|
|
87
89
|
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
88
90
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
@@ -98,6 +100,7 @@ export interface UseGestureCommitResult {
|
|
|
98
100
|
|
|
99
101
|
// fallow-ignore-next-line complexity
|
|
100
102
|
export function useGestureCommit({
|
|
103
|
+
projectId,
|
|
101
104
|
domEditSessionRef,
|
|
102
105
|
previewIframeRef,
|
|
103
106
|
showToast,
|
|
@@ -401,5 +404,5 @@ export function useGestureCommit({
|
|
|
401
404
|
readOnlyPreview,
|
|
402
405
|
]);
|
|
403
406
|
|
|
404
|
-
return { gestureState, gestureRecording, handleToggleRecording };
|
|
407
|
+
return useStableHandlers({ gestureState, gestureRecording, handleToggleRecording }, projectId);
|
|
405
408
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from "react";
|
|
2
|
+
import { GestureTrailOverlay } from "../components/editor/GestureTrailOverlay";
|
|
3
|
+
import type { UseGestureCommitResult } from "./useGestureCommit";
|
|
4
|
+
|
|
5
|
+
/** The live gesture trail drawn over the preview while a gesture is recording, or nothing. */
|
|
6
|
+
export function useGestureTrailOverlay(
|
|
7
|
+
{
|
|
8
|
+
gestureState,
|
|
9
|
+
gestureRecording,
|
|
10
|
+
}: Pick<UseGestureCommitResult, "gestureState" | "gestureRecording">,
|
|
11
|
+
previewIframe: HTMLIFrameElement | null,
|
|
12
|
+
compositionSize: { width: number; height: number } | null,
|
|
13
|
+
) {
|
|
14
|
+
const canvasRectRef = useRef<DOMRect | null>(null);
|
|
15
|
+
useLayoutEffect(() => {
|
|
16
|
+
if (gestureState !== "recording" || !previewIframe) {
|
|
17
|
+
canvasRectRef.current = null;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
canvasRectRef.current = previewIframe.getBoundingClientRect();
|
|
21
|
+
}, [gestureState, previewIframe]);
|
|
22
|
+
if (gestureState !== "recording" || !previewIframe) return undefined;
|
|
23
|
+
return (
|
|
24
|
+
<GestureTrailOverlay
|
|
25
|
+
samples={gestureRecording.samplesRef.current}
|
|
26
|
+
sampleCount={gestureRecording.samplesRef.current.length}
|
|
27
|
+
trail={gestureRecording.trailRef.current}
|
|
28
|
+
canvasRect={canvasRectRef.current!}
|
|
29
|
+
compositionSize={compositionSize ?? undefined}
|
|
30
|
+
mode="recording"
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { RightPanelTab } from "../utils/studioHelpers";
|
|
|
3
3
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
4
4
|
import { useDockLayoutStore, visiblePanelInZone } from "../components/dock/dockLayoutStore";
|
|
5
5
|
import { PANEL_DEFINITIONS, type PanelId } from "../components/dock/panelRegistry";
|
|
6
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
6
7
|
|
|
7
8
|
export interface InitialPanelLayoutState {
|
|
8
9
|
rightCollapsed?: boolean | null;
|
|
@@ -66,5 +67,8 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
66
67
|
store.setZoneVisible("right", !collapsed);
|
|
67
68
|
}, []);
|
|
68
69
|
|
|
69
|
-
return
|
|
70
|
+
return useStableHandlers(
|
|
71
|
+
{ rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab },
|
|
72
|
+
null,
|
|
73
|
+
);
|
|
70
74
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { EnableKeyframesSession } from "./useEnableKeyframes";
|
|
3
|
+
|
|
4
|
+
interface HistoryFlags {
|
|
5
|
+
canUndo: boolean;
|
|
6
|
+
canRedo: boolean;
|
|
7
|
+
undoLabel: string | undefined;
|
|
8
|
+
redoLabel: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The four history fields the shell context carries, as one object that changes only when they do. */
|
|
12
|
+
export function useHistoryFlags({ canUndo, canRedo, undoLabel, redoLabel }: HistoryFlags) {
|
|
13
|
+
return useMemo(
|
|
14
|
+
() => ({ canUndo, canRedo, undoLabel, redoLabel }),
|
|
15
|
+
[canUndo, canRedo, undoLabel, redoLabel],
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The session fields the timeline toolbar reads, so a hover or any other session change leaves it alone. */
|
|
20
|
+
export function useToolbarSession(session: EnableKeyframesSession): EnableKeyframesSession {
|
|
21
|
+
const {
|
|
22
|
+
domEditSelection,
|
|
23
|
+
selectedGsapAnimations,
|
|
24
|
+
previewIframeRef,
|
|
25
|
+
commitMutation,
|
|
26
|
+
handleGsapAddAnimation,
|
|
27
|
+
handleGsapAddKeyframeBatch,
|
|
28
|
+
handleGsapConvertToKeyframes,
|
|
29
|
+
handleGsapRemoveKeyframe,
|
|
30
|
+
} = session;
|
|
31
|
+
return useMemo(
|
|
32
|
+
() => ({
|
|
33
|
+
domEditSelection,
|
|
34
|
+
selectedGsapAnimations,
|
|
35
|
+
previewIframeRef,
|
|
36
|
+
commitMutation,
|
|
37
|
+
handleGsapAddAnimation,
|
|
38
|
+
handleGsapAddKeyframeBatch,
|
|
39
|
+
handleGsapConvertToKeyframes,
|
|
40
|
+
handleGsapRemoveKeyframe,
|
|
41
|
+
}),
|
|
42
|
+
[
|
|
43
|
+
domEditSelection,
|
|
44
|
+
selectedGsapAnimations,
|
|
45
|
+
previewIframeRef,
|
|
46
|
+
commitMutation,
|
|
47
|
+
handleGsapAddAnimation,
|
|
48
|
+
handleGsapAddKeyframeBatch,
|
|
49
|
+
handleGsapConvertToKeyframes,
|
|
50
|
+
handleGsapRemoveKeyframe,
|
|
51
|
+
],
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
interface Result {
|
|
11
|
+
count: number;
|
|
12
|
+
read: () => number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let root: Root | null = null;
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
act(() => root?.unmount());
|
|
18
|
+
root = null;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function renderStable(seen: Result[]) {
|
|
22
|
+
function Probe({ count, base }: { count: number; base: number }) {
|
|
23
|
+
seen.push(useStableHandlers({ count, read: () => base }, "project"));
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
root = createRoot(document.createElement("div"));
|
|
27
|
+
return (count: number, base: number) =>
|
|
28
|
+
act(() => root!.render(<Probe count={count} base={base} />));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("useStableHandlers", () => {
|
|
32
|
+
it("keeps the object while only its handlers change, and the handler calls the latest one", () => {
|
|
33
|
+
const seen: Result[] = [];
|
|
34
|
+
const render = renderStable(seen);
|
|
35
|
+
render(1, 10);
|
|
36
|
+
render(1, 20);
|
|
37
|
+
expect(seen[1]).toBe(seen[0]);
|
|
38
|
+
expect(seen[1]!.read()).toBe(20);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("gives a new object when a data field changes, with the same handler identity", () => {
|
|
42
|
+
const seen: Result[] = [];
|
|
43
|
+
const render = renderStable(seen);
|
|
44
|
+
render(1, 10);
|
|
45
|
+
render(2, 10);
|
|
46
|
+
expect(seen[1]).not.toBe(seen[0]);
|
|
47
|
+
expect(seen[1]!.count).toBe(2);
|
|
48
|
+
expect(seen[1]!.read).toBe(seen[0]!.read);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps a wrapper from an earlier scope calling that scope's handler", () => {
|
|
52
|
+
const seen: { write: () => string }[] = [];
|
|
53
|
+
function Scoped({ project }: { project: string }) {
|
|
54
|
+
seen.push(useStableHandlers({ write: () => project }, project));
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
root = createRoot(document.createElement("div"));
|
|
58
|
+
act(() => root!.render(<Scoped project="a" />));
|
|
59
|
+
const kept = seen[0]!.write;
|
|
60
|
+
act(() => root!.render(<Scoped project="b" />));
|
|
61
|
+
expect(kept()).toBe("a");
|
|
62
|
+
expect(seen[1]!.write).not.toBe(kept);
|
|
63
|
+
expect(seen[1]!.write()).toBe("b");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("gives a new object when a key is swapped for another with the same value", () => {
|
|
67
|
+
const seen: Record<string, unknown>[] = [];
|
|
68
|
+
function Keys({ name }: { name: string }) {
|
|
69
|
+
seen.push(useStableHandlers({ [name]: undefined }, "project"));
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
root = createRoot(document.createElement("div"));
|
|
73
|
+
act(() => root!.render(<Keys name="old" />));
|
|
74
|
+
act(() => root!.render(<Keys name="fresh" />));
|
|
75
|
+
expect(Object.keys(seen[1]!)).toEqual(["fresh"]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
|
|
3
|
+
type Handler = (...args: never[]) => unknown;
|
|
4
|
+
type Epoch = {
|
|
5
|
+
scope: unknown;
|
|
6
|
+
latest: object;
|
|
7
|
+
wrappers: Map<string, Handler>;
|
|
8
|
+
previous: object | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// The same object while its non-function fields are unchanged, and one identity per function field calling the
|
|
12
|
+
// latest one; a new `scope` (the project) starts new identities, and a wrapper kept from before calls its own scope's.
|
|
13
|
+
export function useStableHandlers<T extends object>(value: T, scope: unknown): T {
|
|
14
|
+
const epoch = useRef<Epoch | null>(null);
|
|
15
|
+
if (!epoch.current || !Object.is(epoch.current.scope, scope))
|
|
16
|
+
epoch.current = { scope, latest: value, wrappers: new Map(), previous: null };
|
|
17
|
+
const current = epoch.current;
|
|
18
|
+
current.latest = value;
|
|
19
|
+
|
|
20
|
+
const next: Record<string, unknown> = {};
|
|
21
|
+
for (const [key, field] of Object.entries(value)) {
|
|
22
|
+
if (typeof field !== "function") {
|
|
23
|
+
next[key] = field;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
let wrapper = current.wrappers.get(key);
|
|
27
|
+
if (!wrapper) {
|
|
28
|
+
wrapper = (...args: never[]) =>
|
|
29
|
+
(current.latest as Record<string, (...a: never[]) => unknown>)[key]!(...args);
|
|
30
|
+
current.wrappers.set(key, wrapper);
|
|
31
|
+
}
|
|
32
|
+
next[key] = wrapper;
|
|
33
|
+
}
|
|
34
|
+
const prev = current.previous as Record<string, unknown> | null;
|
|
35
|
+
const keys = Object.keys(next);
|
|
36
|
+
if (
|
|
37
|
+
prev &&
|
|
38
|
+
keys.length === Object.keys(prev).length &&
|
|
39
|
+
keys.every((k) => k in prev && prev[k] === next[k])
|
|
40
|
+
)
|
|
41
|
+
return prev as T;
|
|
42
|
+
current.previous = next;
|
|
43
|
+
return next as T;
|
|
44
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import { useSdkSession } from "./useSdkSession";
|
|
3
3
|
import { usePreviewVariablesStore } from "./previewVariablesStore";
|
|
4
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Open the studio's SDK session with master-view semantics.
|
|
@@ -37,5 +38,5 @@ export function useStudioSdkSessions(
|
|
|
37
38
|
useEffect(() => {
|
|
38
39
|
usePreviewVariablesStore.getState().setValues(null);
|
|
39
40
|
}, [projectId, activeCompPath]);
|
|
40
|
-
return { sdkHandle, editFlowSdkSession };
|
|
41
|
+
return { sdkHandle: useStableHandlers(sdkHandle, projectId), editFlowSdkSession };
|
|
41
42
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, type ComponentProps } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { StudioLeftPanels } from "../components/StudioLeftPanels";
|
|
7
|
+
import type { StudioRightPanels } from "../components/StudioRightPanels";
|
|
8
|
+
import { useStudioSidePanels } from "./useStudioSidePanels";
|
|
9
|
+
|
|
10
|
+
const rightProps = vi.hoisted(() => [] as { recordEdit: (project: string) => string }[]);
|
|
11
|
+
vi.mock("../components/StudioLeftPanels", () => ({ StudioLeftPanels: () => null }));
|
|
12
|
+
vi.mock("../components/StudioRightPanels", () => ({
|
|
13
|
+
StudioRightPanels: (props: { recordEdit: (project: string) => string }) => {
|
|
14
|
+
rightProps.push(props);
|
|
15
|
+
return null;
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
20
|
+
|
|
21
|
+
let root: Root | null = null;
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
act(() => root?.unmount());
|
|
24
|
+
root = null;
|
|
25
|
+
rightProps.length = 0;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function Panels({ project }: { project: string }) {
|
|
29
|
+
const right = { recordEdit: () => project } as unknown as ComponentProps<
|
|
30
|
+
typeof StudioRightPanels
|
|
31
|
+
>;
|
|
32
|
+
return useStudioSidePanels({} as ComponentProps<typeof StudioLeftPanels>, right, project);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("useStudioSidePanels", () => {
|
|
36
|
+
it("keeps a panel handler from one project calling that project after a switch", () => {
|
|
37
|
+
root = createRoot(document.createElement("div"));
|
|
38
|
+
act(() => root!.render(<Panels project="a" />));
|
|
39
|
+
const kept = rightProps.at(-1)!.recordEdit;
|
|
40
|
+
act(() => root!.render(<Panels project="b" />));
|
|
41
|
+
expect(kept("")).toBe("a");
|
|
42
|
+
expect(rightProps.at(-1)!.recordEdit("")).toBe("b");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useMemo, type ComponentProps } from "react";
|
|
2
|
+
import { StudioLeftPanels } from "../components/StudioLeftPanels";
|
|
3
|
+
import { StudioRightPanels } from "../components/StudioRightPanels";
|
|
4
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
5
|
+
|
|
6
|
+
/** Both side panels as one element that changes only when a panel's data does, never for a new handler. */
|
|
7
|
+
export function useStudioSidePanels(
|
|
8
|
+
left: ComponentProps<typeof StudioLeftPanels>,
|
|
9
|
+
right: ComponentProps<typeof StudioRightPanels>,
|
|
10
|
+
projectId: string | null,
|
|
11
|
+
) {
|
|
12
|
+
const stableLeft = useStableHandlers(left, projectId);
|
|
13
|
+
const stableRight = useStableHandlers(right, projectId);
|
|
14
|
+
return useMemo(
|
|
15
|
+
() => (
|
|
16
|
+
<>
|
|
17
|
+
<StudioLeftPanels {...stableLeft} />
|
|
18
|
+
<StudioRightPanels {...stableRight} />
|
|
19
|
+
</>
|
|
20
|
+
),
|
|
21
|
+
[stableLeft, stableRight],
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
3
4
|
|
|
4
5
|
type AddAtPlacement = (path: string, placement: { start: number; track: number }) => unknown;
|
|
5
6
|
|
|
6
|
-
export function useTimelineAddAtPlayhead(
|
|
7
|
+
export function useTimelineAddAtPlayhead(
|
|
8
|
+
addAsset: AddAtPlacement,
|
|
9
|
+
addComposition: AddAtPlacement,
|
|
10
|
+
projectId: string | null,
|
|
11
|
+
) {
|
|
7
12
|
const placement = () => ({ start: usePlayerStore.getState().currentTime, track: 0 });
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
return useStableHandlers(
|
|
14
|
+
{
|
|
15
|
+
addAssetAtPlayhead: useCallback((path: string) => addAsset(path, placement()), [addAsset]),
|
|
16
|
+
addCompositionAtPlayhead: useCallback(
|
|
17
|
+
(path: string) => addComposition(path, placement()),
|
|
18
|
+
[addComposition],
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
projectId,
|
|
22
|
+
);
|
|
15
23
|
}
|