@hyperframes/studio 0.8.113 → 0.8.115

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 (194) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
  4. package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/index-t1LH6H5H.css +1 -0
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +46 -22
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +3899 -3487
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +10 -8
  19. package/src/App.tsx +84 -86
  20. package/src/components/EditorShell.tsx +3 -3
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/StudioHeader.tsx +4 -4
  23. package/src/components/StudioLeftPanels.tsx +3 -3
  24. package/src/components/StudioOverlays.tsx +4 -4
  25. package/src/components/StudioRightPanels.tsx +14 -9
  26. package/src/components/StudioToast.tsx +1 -1
  27. package/src/components/TimelineToolbar.tsx +3 -3
  28. package/src/components/dock/Dock.tsx +5 -7
  29. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  30. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  31. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  32. package/src/components/editor/DomEditOverlay.tsx +5 -1
  33. package/src/components/editor/FileTreeNodes.tsx +13 -10
  34. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  35. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  37. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  38. package/src/components/editor/SnapToolbar.tsx +43 -5
  39. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  40. package/src/components/editor/audioFxTelemetry.ts +2 -3
  41. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  42. package/src/components/editor/colorValue.test.ts +16 -0
  43. package/src/components/editor/colorValue.ts +100 -24
  44. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  45. package/src/components/editor/cropResize.test.ts +11 -0
  46. package/src/components/editor/cropResize.ts +6 -6
  47. package/src/components/editor/domEditingDom.ts +4 -0
  48. package/src/components/editor/domEditingLayers.ts +1 -5
  49. package/src/components/editor/gradientValue.ts +28 -36
  50. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  51. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  52. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  53. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  54. package/src/components/editor/manualEdits.ts +1 -0
  55. package/src/components/editor/manualEditsDom.ts +2 -2
  56. package/src/components/editor/manualEditsParsing.ts +7 -0
  57. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  58. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  59. package/src/components/editor/marqueeCommit.ts +24 -8
  60. package/src/components/editor/motionPathCommit.test.ts +29 -1
  61. package/src/components/editor/motionPathCommit.ts +21 -9
  62. package/src/components/editor/probeSourceElement.test.ts +45 -0
  63. package/src/components/editor/probeSourceElement.ts +39 -15
  64. package/src/components/editor/propertyPanelColor.tsx +1 -1
  65. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  66. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  67. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  68. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  69. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  70. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  71. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  72. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  73. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  74. package/src/components/editor/propertyPanelTypes.ts +6 -3
  75. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  76. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  77. package/src/components/editor/useDomEditNudge.ts +2 -0
  78. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  79. package/src/components/editor/useDomEditZOrder.ts +12 -2
  80. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  81. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  82. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  83. package/src/components/ui/menuStyle.test.ts +61 -0
  84. package/src/components/ui/menuStyle.ts +10 -0
  85. package/src/contexts/DomEditContext.tsx +1 -1
  86. package/src/contexts/TimelineEditContext.tsx +1 -0
  87. package/src/domEditExports.test.tsx +9 -1
  88. package/src/hooks/domEditCommitRunner.ts +1 -1
  89. package/src/hooks/elementOffsetStager.ts +5 -4
  90. package/src/hooks/gsapDragCommit.ts +2 -20
  91. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  92. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  93. package/src/hooks/gsapGestureOutcome.ts +64 -0
  94. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  95. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  96. package/src/hooks/gsapResizeIntercept.ts +1 -0
  97. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  98. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  99. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  100. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  101. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  102. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  103. package/src/hooks/plainRotation.ts +3 -2
  104. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  105. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  106. package/src/hooks/refreshTimelineRowText.ts +29 -0
  107. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  108. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  109. package/src/hooks/useAppHotkeys.ts +11 -5
  110. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  112. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  113. package/src/hooks/useDomEditTextCommits.ts +5 -1
  114. package/src/hooks/useDomEditWiring.ts +4 -2
  115. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  116. package/src/hooks/useDomGeometryCommit.ts +7 -4
  117. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  118. package/src/hooks/useDomGeometryCommits.ts +3 -2
  119. package/src/hooks/useDomSelection.test.ts +26 -0
  120. package/src/hooks/useDomSelection.ts +10 -3
  121. package/src/hooks/useDomSelectionTypes.ts +6 -1
  122. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  123. package/src/hooks/useEnableKeyframes.ts +5 -3
  124. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  125. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  126. package/src/hooks/useFrameCapture.ts +15 -8
  127. package/src/hooks/useGestureCommit.test.tsx +225 -55
  128. package/src/hooks/useGestureCommit.ts +90 -56
  129. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  130. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  131. package/src/hooks/useGsapAwareEditing.ts +96 -195
  132. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  133. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  134. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  135. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  136. package/src/hooks/useGsapScriptCommits.ts +3 -0
  137. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  138. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  139. package/src/hooks/useGsapTweenCache.ts +18 -12
  140. package/src/hooks/usePanelLayout.ts +5 -1
  141. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  142. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  143. package/src/hooks/usePreviewInteraction.ts +2 -0
  144. package/src/hooks/useRenderClipContent.test.ts +18 -0
  145. package/src/hooks/useRenderClipContent.ts +4 -5
  146. package/src/hooks/useShellSlices.ts +53 -0
  147. package/src/hooks/useStableHandlers.test.tsx +77 -0
  148. package/src/hooks/useStableHandlers.ts +44 -0
  149. package/src/hooks/useStudioSdkSessions.ts +2 -1
  150. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  151. package/src/hooks/useStudioSidePanels.tsx +23 -0
  152. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  153. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  154. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  155. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  156. package/src/hooks/useTimelineEditing.ts +33 -27
  157. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  158. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  159. package/src/player/components/ClipContextMenu.tsx +33 -31
  160. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  161. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  162. package/src/player/components/TextClipContent.test.ts +19 -0
  163. package/src/player/components/TextClipContent.tsx +27 -0
  164. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  165. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  166. package/src/player/components/clipMenuToolItems.tsx +13 -9
  167. package/src/player/components/timelineCallbacks.ts +1 -0
  168. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  169. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  170. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  171. package/src/player/components/timelineEditing.ts +3 -1
  172. package/src/player/components/timelineLinkSelection.ts +35 -1
  173. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  174. package/src/player/components/useTimelineClipDrag.ts +6 -7
  175. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  176. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  177. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  178. package/src/player/lib/timelineElementHelpers.ts +6 -15
  179. package/src/player/lib/timelineText.ts +40 -0
  180. package/src/player/store/timelineElement.ts +12 -0
  181. package/src/styles/theme.css +3 -0
  182. package/src/utils/frameCapture.ts +3 -1
  183. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  184. package/src/utils/keyframeUsage.ts +79 -0
  185. package/src/utils/previewFeatureUsage.test.ts +32 -0
  186. package/src/utils/previewFeatureUsage.ts +46 -0
  187. package/src/utils/studioTelemetry.test.ts +107 -0
  188. package/src/utils/studioTelemetry.ts +49 -4
  189. package/src/webmcp/StudioAgentTools.tsx +2 -2
  190. package/dist/assets/index-DbfKVTId.css +0 -1
  191. package/dist/chunk-BIUXP3PG.js.map +0 -1
  192. package/dist/chunk-K47E2ZJA.js.map +0 -1
  193. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  194. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -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
  }
@@ -28,6 +28,17 @@ describe("applyRippleShifts", () => {
28
28
  });
29
29
  });
30
30
 
31
+ function stubRemoveElementsFetch(current: string, afterRemoval: string) {
32
+ vi.stubGlobal(
33
+ "fetch",
34
+ vi.fn(async (input: RequestInfo | URL) => {
35
+ const isRemoval = String(input).includes("/file-mutations/remove-elements/");
36
+ const content = isRemoval ? afterRemoval : current;
37
+ return new Response(JSON.stringify({ changed: isRemoval, content }), { status: 200 });
38
+ }),
39
+ );
40
+ }
41
+
31
42
  describe("useTimelineDeleteOps: ripple undo label", () => {
32
43
  const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
33
44
  <div data-hf-id="hf-stage" data-hf-root data-duration="6">
@@ -38,16 +49,7 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
38
49
  </body></html>`;
39
50
 
40
51
  beforeEach(() => {
41
- vi.stubGlobal(
42
- "fetch",
43
- vi.fn(async (input: RequestInfo | URL) => {
44
- const url = String(input);
45
- if (url.includes("/file-mutations/remove-element/")) {
46
- return new Response(JSON.stringify({ changed: true, content: html }), { status: 200 });
47
- }
48
- return new Response(JSON.stringify({ content: html }), { status: 200 });
49
- }),
50
- );
52
+ stubRemoveElementsFetch(html, html);
51
53
  });
52
54
 
53
55
  afterEach(() => {
@@ -118,6 +120,23 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
118
120
  );
119
121
  });
120
122
 
123
+ it("removes a multi-clip selection with one request, however many clips it holds", async () => {
124
+ const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
125
+ const { elements, getHook } = mountDeleteHarness({ handleTimelineGroupMove });
126
+
127
+ await act(async () => {
128
+ await getHook().handleTimelineElementsDelete(elements);
129
+ });
130
+
131
+ const removals = vi
132
+ .mocked(fetch)
133
+ .mock.calls.filter(([url]) => String(url).includes("/file-mutations/remove-element"));
134
+ expect(removals).toHaveLength(1);
135
+ const [, init] = removals[0] as [unknown, RequestInit];
136
+ const body = JSON.parse(String(init.body));
137
+ expect(body.targets).toHaveLength(elements.length);
138
+ });
139
+
121
140
  it("shows exactly one toast when a ripple fails to persist after a committed delete", async () => {
122
141
  const handleTimelineGroupMove = vi.fn().mockRejectedValue(new Error("persist failed"));
123
142
  const showToast = vi.fn();
@@ -162,18 +181,7 @@ describe("useTimelineDeleteOps: undo race", () => {
162
181
  </body></html>`;
163
182
 
164
183
  beforeEach(() => {
165
- vi.stubGlobal(
166
- "fetch",
167
- vi.fn(async (input: RequestInfo | URL) => {
168
- const url = String(input);
169
- if (url.includes("/file-mutations/remove-element/")) {
170
- return new Response(JSON.stringify({ changed: true, content: htmlAfterDeletingC }), {
171
- status: 200,
172
- });
173
- }
174
- return new Response(JSON.stringify({ content: html }), { status: 200 });
175
- }),
176
- );
184
+ stubRemoveElementsFetch(html, htmlAfterDeletingC);
177
185
  });
178
186
 
179
187
  afterEach(() => {
@@ -121,38 +121,32 @@ export function useTimelineDeleteOps({
121
121
  writeFile: writeProjectFile,
122
122
  recordEdit,
123
123
  rewrite: async (originalContent) => {
124
- // Remove every selected element before saving once. The server rewrites
125
- // the file per call, so `removedContent` after the last one holds them
126
- // all — which is what makes this a single history entry, and a single
127
- // undo, rather than one per clip.
128
- let removedContent = originalContent;
129
- for (const target of sameFile) {
124
+ // One request removes every selected element and rewrites the file once, so a
125
+ // large selection is a single round trip and a single history entry.
126
+ const patchTargets = sameFile.map((target) => {
130
127
  const patchTarget = buildPatchTarget(target);
131
128
  if (!patchTarget) {
132
129
  throw new Error(`Timeline element ${target.id} is missing a patchable target`);
133
130
  }
134
-
135
- const removeResponse = await fetch(
136
- buildProjectApiPath(
137
- pid,
138
- `/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
139
- ),
140
- {
141
- method: "POST",
142
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
143
- body: JSON.stringify({ target: patchTarget }),
144
- },
145
- );
146
- if (!removeResponse.ok) {
147
- throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
148
- }
149
-
150
- const removeData = (await removeResponse.json()) as {
151
- changed?: boolean;
152
- content?: string;
153
- };
154
- if (typeof removeData.content === "string") removedContent = removeData.content;
131
+ return patchTarget;
132
+ });
133
+ const removeResponse = await fetch(
134
+ buildProjectApiPath(
135
+ pid,
136
+ `/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
137
+ ),
138
+ {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
141
+ body: JSON.stringify({ targets: patchTargets }),
142
+ },
143
+ );
144
+ if (!removeResponse.ok) {
145
+ throw new Error(`Failed to delete ${sameFile.length} clips from ${targetPath}`);
155
146
  }
147
+ const removeData = (await removeResponse.json()) as { content?: string };
148
+ const removedContent =
149
+ typeof removeData.content === "string" ? removeData.content : originalContent;
156
150
  // Shrink to the furthest remaining clip end, read from the post-removal source:
157
151
  // store durations are runtime-truncated.
158
152
  const deleteContentEnd = furthestClipEndFromSource(removedContent);
@@ -1296,7 +1296,7 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
1296
1296
  if (url.includes("/api/projects/p1/files/")) {
1297
1297
  return jsonResponse({ content: DELETE_SOURCE });
1298
1298
  }
1299
- if (url.includes("/api/projects/p1/file-mutations/remove-element/")) {
1299
+ if (url.includes("/api/projects/p1/file-mutations/remove-elements/")) {
1300
1300
  return jsonResponse({ changed: true, content: DELETE_REMOVED_SOURCE });
1301
1301
  }
1302
1302
  throw new Error(`Unexpected fetch: ${url}`);
@@ -1,5 +1,6 @@
1
1
  // fallow-ignore-file complexity
2
2
  import { useCallback, useRef } from "react";
3
+ import { useStableHandlers } from "./useStableHandlers";
3
4
  import { usePlayerStore, type TimelineElement } from "../player";
4
5
  import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
5
6
  import { useRazorSplit } from "./useRazorSplit";
@@ -508,13 +509,41 @@ export function useTimelineEditing({
508
509
  return [...flatMembers, ...domMembers];
509
510
  };
510
511
 
512
+ const audioGroupAttribute = {
513
+ ...setAudioGroupAttribute,
514
+ // Same two-array member lookup syncStoredGroupAttribute mirrors into
515
+ // (timelineAudioGroupVolume.ts): a sub-composition's group members have
516
+ // no flat twin, only a domClipChildren entry, so both are checked.
517
+ setQuiet: track(
518
+ guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
519
+ setAudioGroupAttribute.revertLive(groupId, attr);
520
+ return refused(reason);
521
+ }),
522
+ ),
523
+ };
524
+ const stableAudioGroupAttribute = useStableHandlers(audioGroupAttribute, projectId);
525
+ const elementFxAttribute = {
526
+ ...setElementFxAttribute,
527
+ setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)),
528
+ setQuiet: track(
529
+ guard(
530
+ (element) => [element],
531
+ setElementFxAttribute.setQuiet,
532
+ (reason, element, attr) => {
533
+ setElementFxAttribute.revertLive(element, attr);
534
+ return refused(reason);
535
+ },
536
+ ),
537
+ ),
538
+ };
539
+ const stableElementFxAttribute = useStableHandlers(elementFxAttribute, projectId);
511
540
  // Every write-handler is tracked here, the one place all hand edits
512
541
  // converge, so undo never races a write; canEdit gates the same point.
513
542
  // Coverage boundary: see the PR body, not every kind resolves an element.
514
543
  const trackedRazorSplit = track(
515
544
  guard((element) => withLinkPartners([element]), handleRazorSplit),
516
545
  );
517
- return {
546
+ const editing = {
518
547
  handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
519
548
  handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
520
549
  handleToggleTrackHidden: track(
@@ -530,32 +559,8 @@ export function useTimelineEditing({
530
559
  }, handleToggleElementHidden),
531
560
  ),
532
561
  handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
533
- setAudioGroupAttribute: {
534
- ...setAudioGroupAttribute,
535
- // Same two-array member lookup syncStoredGroupAttribute mirrors into
536
- // (timelineAudioGroupVolume.ts): a sub-composition's group members have
537
- // no flat twin, only a domClipChildren entry, so both are checked.
538
- setQuiet: track(
539
- guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
540
- setAudioGroupAttribute.revertLive(groupId, attr);
541
- return refused(reason);
542
- }),
543
- ),
544
- },
545
- setElementFxAttribute: {
546
- ...setElementFxAttribute,
547
- setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)),
548
- setQuiet: track(
549
- guard(
550
- (element) => [element],
551
- setElementFxAttribute.setQuiet,
552
- (reason, element, attr) => {
553
- setElementFxAttribute.revertLive(element, attr);
554
- return refused(reason);
555
- },
556
- ),
557
- ),
558
- },
562
+ setAudioGroupAttribute: stableAudioGroupAttribute,
563
+ setElementFxAttribute: stableElementFxAttribute,
559
564
  handleTimelineElementDelete: track(
560
565
  guard((element) => withLinkPartners([element]), linkEditing.handleLinkedElementDelete),
561
566
  ),
@@ -591,4 +596,5 @@ export function useTimelineEditing({
591
596
  setAudioGroupAttribute.restoreLive(restore);
592
597
  },
593
598
  };
599
+ return useStableHandlers(editing, projectId);
594
600
  }
@@ -8,6 +8,7 @@ import { memo } from "react";
8
8
  import { createPortal } from "react-dom";
9
9
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
10
10
  import { AUTOMATION_SHAPES, type AutomationShapeId } from "./automationShapes";
11
+ import { menuClasses } from "../../components/ui/menuStyle";
11
12
 
12
13
  interface AutomationSelectionMenuProps {
13
14
  x: number;
@@ -45,7 +46,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
45
46
  // portaled to `document.body`, but the ruler's sticky header sits at z-70
46
47
  // in the SAME root stacking context, so a z-50 menu opened near the top of
47
48
  // the timeline is painted through by the ruler and the playhead.
48
- className="hf-automation-menu fixed z-200 min-w-[140px] rounded-sm border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
49
+ className={`${menuClasses.panel} hf-automation-menu fixed z-200 min-w-[140px]`}
49
50
  style={{ left: adjustedX, top: adjustedY }}
50
51
  >
51
52
  {AUTOMATION_SHAPES.map((shape) => (
@@ -61,7 +62,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
61
62
  {shape.label}
62
63
  </button>
63
64
  ))}
64
- <div className="my-1 border-t border-panel-border-input" />
65
+ <div className={menuClasses.divider} />
65
66
  <button
66
67
  type="button"
67
68
  className={row}
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { TimelineElement } from "../store/playerStore";
6
6
  import { ClipContextMenu } from "./ClipContextMenu";
7
7
  import type { TimelineClipMenuItem } from "./TimelineTypes";
8
+ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -117,6 +118,67 @@ describe("ClipContextMenu host items", () => {
117
118
  });
118
119
  });
119
120
 
121
+ describe("ClipContextMenu tools", () => {
122
+ const video = {
123
+ id: "talk",
124
+ domId: "talk",
125
+ tag: "video",
126
+ start: 0,
127
+ duration: 4,
128
+ track: 0,
129
+ hasAudio: true,
130
+ } as unknown as TimelineElement;
131
+
132
+ function menuWith(clipMenuTools: boolean | undefined) {
133
+ const host = document.createElement("div");
134
+ document.body.appendChild(host);
135
+ const root = createRoot(host);
136
+ act(() =>
137
+ root.render(
138
+ <TimelineEditProvider
139
+ value={{
140
+ onFreezeFrame: vi.fn(),
141
+ onSetElementAttributeQuiet: vi.fn(),
142
+ onLinkEdit: vi.fn(),
143
+ ...(clipMenuTools === undefined ? {} : { clipMenuTools }),
144
+ }}
145
+ >
146
+ <ClipContextMenu
147
+ x={10}
148
+ y={10}
149
+ element={video}
150
+ currentTime={1}
151
+ onClose={vi.fn()}
152
+ onSplit={vi.fn()}
153
+ onDelete={vi.fn()}
154
+ />
155
+ </TimelineEditProvider>,
156
+ ),
157
+ );
158
+ const labels = () =>
159
+ Array.from(document.body.querySelectorAll("[role=menuitem]")).map((item) => item.textContent);
160
+ return { labels, unmount: () => act(() => root.unmount()) };
161
+ }
162
+
163
+ it("offers Freeze frame and the attribute tools by default", () => {
164
+ const { labels, unmount } = menuWith(undefined);
165
+ expect(labels().some((label) => label?.startsWith("Freeze frame"))).toBe(true);
166
+ expect(labels().some((label) => label?.startsWith("Look"))).toBe(true);
167
+ expect(labels().some((label) => label?.startsWith("Voice"))).toBe(true);
168
+ unmount();
169
+ });
170
+
171
+ it("hides every tool when the host turns them off, keeping Split and Delete", () => {
172
+ const { labels, unmount } = menuWith(false);
173
+ expect(labels().some((label) => label?.startsWith("Freeze frame"))).toBe(false);
174
+ expect(labels().some((label) => label?.startsWith("Look"))).toBe(false);
175
+ expect(labels().some((label) => label?.startsWith("Voice"))).toBe(false);
176
+ expect(labels().some((label) => label?.startsWith("Split"))).toBe(true);
177
+ expect(labels().some((label) => label?.startsWith("Delete"))).toBe(true);
178
+ unmount();
179
+ });
180
+ });
181
+
120
182
  /** A clip button that opens the real menu, whose Ask moves the focus the way `focus` says. */
121
183
  function FocusHost({ focus }: { focus: "existing" | "mounted" }) {
122
184
  const [open, setOpen] = useState(false);
@@ -186,3 +248,14 @@ describe("ClipContextMenu focus on close", () => {
186
248
  unmount();
187
249
  });
188
250
  });
251
+
252
+ describe("ClipContextMenu row fill", () => {
253
+ it("leaves no padding or margin between its rows and the panel edge or a divider", () => {
254
+ renderMenu([{ id: "ask", label: "Ask", onSelect: vi.fn() }]);
255
+ const menu = document.body.querySelector<HTMLElement>("[role=menu]")!;
256
+ const spacing = /(^| )(p|py|pt|pb|m|my|mt|mb)-\d/;
257
+ expect(menu.className).not.toMatch(spacing);
258
+ for (const group of menu.querySelectorAll<HTMLElement>("[role=group]"))
259
+ expect(group.className).not.toMatch(spacing);
260
+ });
261
+ });