@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.
Files changed (53) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-aXNJKiIg.js} +1 -1
  2. package/dist/assets/index-BYnsJnJV.css +1 -0
  3. package/dist/assets/{index-n5FUn1xv.js → index-Bn2RJNTJ.js} +1 -1
  4. package/dist/assets/{index-DgwDQK5G.js → index-C9N-DGjC.js} +1 -1
  5. package/dist/assets/{index-DRhF3v4Z.js → index-Dz7doz9l.js} +199 -199
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1605 -1536
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +9 -8
  12. package/src/App.tsx +82 -84
  13. package/src/components/EditorShell.tsx +3 -3
  14. package/src/components/StudioHeader.tsx +4 -4
  15. package/src/components/StudioLeftPanels.tsx +3 -3
  16. package/src/components/StudioOverlays.tsx +4 -4
  17. package/src/components/StudioRightPanels.tsx +14 -9
  18. package/src/components/StudioToast.tsx +1 -1
  19. package/src/components/TimelineToolbar.tsx +3 -3
  20. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  21. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  22. package/src/components/editor/colorValue.test.ts +16 -0
  23. package/src/components/editor/colorValue.ts +100 -24
  24. package/src/components/editor/cropResize.test.ts +11 -0
  25. package/src/components/editor/cropResize.ts +2 -3
  26. package/src/components/editor/gradientValue.ts +28 -36
  27. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  28. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  29. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  30. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  31. package/src/components/editor/manualEditsDom.ts +2 -2
  32. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  33. package/src/components/editor/propertyPanelColor.tsx +1 -1
  34. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  35. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  36. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  37. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  38. package/src/hooks/useAppHotkeys.ts +11 -5
  39. package/src/hooks/useFrameCapture.ts +15 -8
  40. package/src/hooks/useGestureCommit.ts +4 -1
  41. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  42. package/src/hooks/usePanelLayout.ts +5 -1
  43. package/src/hooks/useShellSlices.ts +53 -0
  44. package/src/hooks/useStableHandlers.test.tsx +77 -0
  45. package/src/hooks/useStableHandlers.ts +44 -0
  46. package/src/hooks/useStudioSdkSessions.ts +2 -1
  47. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  48. package/src/hooks/useStudioSidePanels.tsx +23 -0
  49. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  50. package/src/hooks/useTimelineEditing.ts +33 -27
  51. package/src/utils/frameCapture.ts +3 -1
  52. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  53. 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
- const otherTweenProps = new Set<string>();
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 (otherTweenProps.has(prop)) continue;
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
- handleUndo,
271
- handleRedo,
272
- syncPreviewHotkeys,
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 = projectId
107
- ? buildFrameCaptureUrl({
108
- projectId,
109
- compositionPath: activeCompPath,
110
- currentTime: captureFrameTime,
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 { rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab };
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(addAsset: AddAtPlacement, addComposition: AddAtPlacement) {
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
- addAssetAtPlayhead: useCallback((path: string) => addAsset(path, placement()), [addAsset]),
10
- addCompositionAtPlayhead: useCallback(
11
- (path: string) => addComposition(path, placement()),
12
- [addComposition],
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
  }