@hyperframes/studio 0.8.114 → 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 (155) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-CAvZ0AA-.js} +201 -201
  4. package/dist/assets/{index-C9N-DGjC.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  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 +43 -19
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +2939 -2596
  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 +9 -8
  19. package/src/App.tsx +2 -2
  20. package/src/components/LinkedSelectionToggle.tsx +3 -2
  21. package/src/components/dock/Dock.tsx +5 -7
  22. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  23. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  24. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  25. package/src/components/editor/DomEditOverlay.tsx +5 -1
  26. package/src/components/editor/FileTreeNodes.tsx +13 -10
  27. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  28. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  29. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  30. package/src/components/editor/SnapToolbar.tsx +43 -5
  31. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  32. package/src/components/editor/audioFxTelemetry.ts +2 -3
  33. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  34. package/src/components/editor/cropResize.ts +4 -3
  35. package/src/components/editor/domEditingDom.ts +4 -0
  36. package/src/components/editor/domEditingLayers.ts +1 -5
  37. package/src/components/editor/manualEdits.ts +1 -0
  38. package/src/components/editor/manualEditsParsing.ts +7 -0
  39. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  40. package/src/components/editor/marqueeCommit.ts +24 -8
  41. package/src/components/editor/motionPathCommit.test.ts +29 -1
  42. package/src/components/editor/motionPathCommit.ts +21 -9
  43. package/src/components/editor/probeSourceElement.test.ts +45 -0
  44. package/src/components/editor/probeSourceElement.ts +39 -15
  45. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  46. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  47. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  48. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  49. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  50. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  51. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  52. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  53. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  54. package/src/components/editor/propertyPanelTypes.ts +6 -3
  55. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  56. package/src/components/editor/useDomEditNudge.ts +2 -0
  57. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  58. package/src/components/editor/useDomEditZOrder.ts +12 -2
  59. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  60. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  61. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  62. package/src/components/ui/menuStyle.test.ts +61 -0
  63. package/src/components/ui/menuStyle.ts +10 -0
  64. package/src/contexts/DomEditContext.tsx +1 -1
  65. package/src/contexts/TimelineEditContext.tsx +1 -0
  66. package/src/domEditExports.test.tsx +9 -1
  67. package/src/hooks/domEditCommitRunner.ts +1 -1
  68. package/src/hooks/elementOffsetStager.ts +5 -4
  69. package/src/hooks/gsapDragCommit.ts +2 -20
  70. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  71. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  72. package/src/hooks/gsapGestureOutcome.ts +64 -0
  73. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  74. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  75. package/src/hooks/gsapResizeIntercept.ts +1 -0
  76. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  77. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  78. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  79. package/src/hooks/plainRotation.ts +3 -2
  80. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  81. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  82. package/src/hooks/refreshTimelineRowText.ts +29 -0
  83. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  84. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  85. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  86. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  87. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  88. package/src/hooks/useDomEditTextCommits.ts +5 -1
  89. package/src/hooks/useDomEditWiring.ts +4 -2
  90. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  91. package/src/hooks/useDomGeometryCommit.ts +7 -4
  92. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  93. package/src/hooks/useDomGeometryCommits.ts +3 -2
  94. package/src/hooks/useDomSelection.test.ts +26 -0
  95. package/src/hooks/useDomSelection.ts +10 -3
  96. package/src/hooks/useDomSelectionTypes.ts +6 -1
  97. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  98. package/src/hooks/useEnableKeyframes.ts +5 -3
  99. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  100. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  101. package/src/hooks/useGestureCommit.test.tsx +225 -55
  102. package/src/hooks/useGestureCommit.ts +86 -55
  103. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  104. package/src/hooks/useGsapAwareEditing.ts +96 -195
  105. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  106. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  107. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  108. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  109. package/src/hooks/useGsapScriptCommits.ts +3 -0
  110. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  111. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  112. package/src/hooks/useGsapTweenCache.ts +18 -12
  113. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  114. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  115. package/src/hooks/usePreviewInteraction.ts +2 -0
  116. package/src/hooks/useRenderClipContent.test.ts +18 -0
  117. package/src/hooks/useRenderClipContent.ts +4 -5
  118. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  119. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  120. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  121. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  122. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  123. package/src/player/components/ClipContextMenu.tsx +33 -31
  124. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  125. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  126. package/src/player/components/TextClipContent.test.ts +19 -0
  127. package/src/player/components/TextClipContent.tsx +27 -0
  128. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  129. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  130. package/src/player/components/clipMenuToolItems.tsx +13 -9
  131. package/src/player/components/timelineCallbacks.ts +1 -0
  132. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  133. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  134. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  135. package/src/player/components/timelineEditing.ts +3 -1
  136. package/src/player/components/timelineLinkSelection.ts +35 -1
  137. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  138. package/src/player/components/useTimelineClipDrag.ts +6 -7
  139. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  140. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  141. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  142. package/src/player/lib/timelineElementHelpers.ts +6 -15
  143. package/src/player/lib/timelineText.ts +40 -0
  144. package/src/player/store/timelineElement.ts +12 -0
  145. package/src/styles/theme.css +3 -0
  146. package/src/utils/keyframeUsage.ts +79 -0
  147. package/src/utils/previewFeatureUsage.test.ts +32 -0
  148. package/src/utils/previewFeatureUsage.ts +46 -0
  149. package/src/utils/studioTelemetry.test.ts +107 -0
  150. package/src/utils/studioTelemetry.ts +49 -4
  151. package/src/webmcp/StudioAgentTools.tsx +2 -2
  152. package/dist/chunk-BIUXP3PG.js.map +0 -1
  153. package/dist/chunk-K47E2ZJA.js.map +0 -1
  154. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  155. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -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}`);
@@ -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
+ });
@@ -8,10 +8,10 @@ import type { TimelineClipMenuItem } from "./TimelineTypes";
8
8
  import { ClipMenuToolItems } from "./clipMenuToolItems";
9
9
  import { ClipMenuAudioItems } from "./clipMenuAudioItems";
10
10
  import { ClipMenuLinkItems } from "./clipMenuLinkItems";
11
+ import { menuClasses } from "../../components/ui/menuStyle";
12
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
11
13
 
12
14
  const MENU_MARGIN = 8;
13
- // Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider.
14
- const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60";
15
15
 
16
16
  function useMeasuredHeight(ref: RefObject<HTMLDivElement | null>, anchorKey: string): number {
17
17
  const [height, setHeight] = useState(0);
@@ -42,13 +42,8 @@ interface ClipContextMenuProps {
42
42
  hostItems?: readonly TimelineClipMenuItem[] | undefined;
43
43
  }
44
44
 
45
- // Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
46
45
  const itemClass = (enabled: boolean) =>
47
- `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
48
- enabled
49
- ? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
50
- : " text-neutral-600 cursor-not-allowed"
51
- }`;
46
+ `${menuClasses.row} flex items-center justify-between ${enabled ? menuClasses.rowEnabled : menuClasses.rowDisabled}`;
52
47
 
53
48
  /** The host's items, above Studio's. A pick closes the menu; focus the item moves stays where it went. */
54
49
  function HostItems({
@@ -103,6 +98,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
103
98
  }: ClipContextMenuProps) {
104
99
  const menuRef = useContextMenuDismiss(onClose);
105
100
  useMenuKeyboardNav(menuRef);
101
+ const tools = useTimelineEditContextOptional().clipMenuTools !== false;
106
102
  // The right-clicked clip's own id: a member of the live multi-selection
107
103
  // means Copy/Duplicate act on the whole group, matching onContextMenuClip's
108
104
  // selection-preserving behaviour for a right-click inside it.
@@ -134,15 +130,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({
134
130
  ref={menuRef}
135
131
  role="menu"
136
132
  aria-label="Clip actions"
137
- className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
133
+ className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
138
134
  style={{ left: adjustedX, top: adjustedY }}
139
135
  >
140
136
  {hostItems.length > 0 && (
141
- <div role="group" aria-label="Host" className={GROUP_CLASS}>
137
+ <div role="group" aria-label="Host" className={menuClasses.group}>
142
138
  <HostItems items={hostItems} onClose={onClose} />
143
139
  </div>
144
140
  )}
145
- <div role="group" aria-label="Time" className={GROUP_CLASS}>
141
+ <div role="group" aria-label="Time" className={menuClasses.group}>
146
142
  {splitLabel && (
147
143
  <button
148
144
  type="button"
@@ -164,7 +160,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
164
160
  <span className="text-neutral-500 text-[10px] ml-3">S</span>
165
161
  </button>
166
162
  )}
167
- {splitLabel && (
163
+ {tools && splitLabel && (
168
164
  <ClipMenuToolItems
169
165
  group="time"
170
166
  element={element}
@@ -174,28 +170,34 @@ export const ClipContextMenu = memo(function ClipContextMenu({
174
170
  )}
175
171
  </div>
176
172
 
177
- <div role="group" aria-label="Sound" className={GROUP_CLASS}>
178
- <ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
179
- <ClipMenuToolItems
180
- group="sound"
181
- element={element}
182
- currentTime={currentTime}
183
- onClose={onClose}
184
- />
185
- <ClipMenuLinkItems part="link" element={element} onClose={onClose} />
186
- <ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
173
+ <div role="group" aria-label="Sound" className={menuClasses.group}>
174
+ {tools && (
175
+ <>
176
+ <ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
177
+ <ClipMenuToolItems
178
+ group="sound"
179
+ element={element}
180
+ currentTime={currentTime}
181
+ onClose={onClose}
182
+ />
183
+ <ClipMenuLinkItems part="link" element={element} onClose={onClose} />
184
+ <ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
185
+ </>
186
+ )}
187
187
  </div>
188
188
 
189
- <div role="group" aria-label="Picture" className={GROUP_CLASS}>
190
- <ClipMenuToolItems
191
- group="picture"
192
- element={element}
193
- currentTime={currentTime}
194
- onClose={onClose}
195
- />
189
+ <div role="group" aria-label="Picture" className={menuClasses.group}>
190
+ {tools && (
191
+ <ClipMenuToolItems
192
+ group="picture"
193
+ element={element}
194
+ currentTime={currentTime}
195
+ onClose={onClose}
196
+ />
197
+ )}
196
198
  </div>
197
199
 
198
- <div role="group" aria-label="Clipboard" className={GROUP_CLASS}>
200
+ <div role="group" aria-label="Clipboard" className={menuClasses.group}>
199
201
  {onCopy && (
200
202
  <button
201
203
  type="button"
@@ -255,7 +257,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
255
257
  <span>Delete</span>
256
258
  <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
257
259
  </button>
258
- <ClipMenuLinkItems part="delete" element={element} onClose={onClose} />
260
+ {tools && <ClipMenuLinkItems part="delete" element={element} onClose={onClose} />}
259
261
  </div>
260
262
  </div>,
261
263
  document.body,
@@ -4,6 +4,7 @@ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
4
  import { useMenuKeyboardNav } from "./menuKeyboardNav";
5
5
  import type { TimelineElement } from "../store/playerStore";
6
6
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
7
+ import { menuClasses } from "../../components/ui/menuStyle";
7
8
 
8
9
  export interface KeyframeDiamondContextMenuState {
9
10
  x: number;
@@ -39,10 +40,8 @@ interface KeyframeDiamondContextMenuProps {
39
40
  onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
40
41
  }
41
42
 
42
- const ITEM_CLS =
43
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
44
- const DESTRUCTIVE_ITEM_CLS =
45
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
43
+ const ITEM_CLS = `${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`;
44
+ const DESTRUCTIVE_ITEM_CLS = `${menuClasses.row} text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer flex items-center gap-2`;
46
45
 
47
46
  export function KeyframeDiamondContextMenu({
48
47
  state,
@@ -96,7 +95,7 @@ export function KeyframeDiamondContextMenu({
96
95
  ref={menuRef}
97
96
  role="menu"
98
97
  aria-label="Keyframe actions"
99
- className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
98
+ className={`${menuClasses.panel} fixed z-200 min-w-[180px] overflow-y-auto`}
100
99
  style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
101
100
  >
102
101
  {onMoveToPlayhead && (
@@ -165,14 +164,14 @@ export function KeyframeDiamondContextMenu({
165
164
 
166
165
  {/* Deleting every keyframe sat adjacent to the single delete and styled
167
166
  identically. Separate and mark it so the two cannot be misread. */}
168
- <div className="my-1 border-t border-neutral-700/60" role="separator" />
169
-
170
- <div className="my-1 border-t border-neutral-700/60" role="separator" />
167
+ <div className={menuClasses.divider} role="separator" />
168
+ <div className="h-1" aria-hidden="true" />
169
+ <div className={menuClasses.divider} role="separator" />
171
170
 
172
171
  <button
173
172
  type="button"
174
173
  role="menuitem"
175
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 outline-hidden cursor-pointer text-left"
174
+ className={`${menuClasses.row} ${menuClasses.rowDanger} flex items-center gap-2`}
176
175
  onClick={() => {
177
176
  onDeleteAll(state.element, state.animationId);
178
177
  onClose();
@@ -7,11 +7,11 @@ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
7
7
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
8
8
  import { clipSyncState, type ClipSyncState } from "./clipSync";
9
9
  import { useMenuKeyboardNav } from "./menuKeyboardNav";
10
+ import { menuClasses } from "../../components/ui/menuStyle";
10
11
 
11
12
  const stop = (e: SyntheticEvent) => e.stopPropagation();
12
13
 
13
- const MENU_ROW =
14
- "block w-full px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800 disabled:opacity-40";
14
+ const ROW = `${menuClasses.row} ${menuClasses.rowEnabled} block disabled:cursor-not-allowed disabled:opacity-40`;
15
15
 
16
16
  function SyncMenu({
17
17
  x,
@@ -39,13 +39,13 @@ function SyncMenu({
39
39
  ref={menuRef}
40
40
  role="menu"
41
41
  aria-label="Out of sync"
42
- className="fixed z-200 min-w-[170px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
42
+ className={`${menuClasses.panel} fixed z-200 min-w-[170px]`}
43
43
  style={{ left, top }}
44
44
  >
45
45
  <button
46
46
  type="button"
47
47
  role="menuitem"
48
- className={MENU_ROW}
48
+ className={ROW}
49
49
  disabled={!onLinkEdit || moveStart === null}
50
50
  title={moveStart === null ? "Would move the clip before 0:00" : undefined}
51
51
  onClick={() => {
@@ -59,7 +59,7 @@ function SyncMenu({
59
59
  <button
60
60
  type="button"
61
61
  role="menuitem"
62
- className={MENU_ROW}
62
+ className={ROW}
63
63
  disabled={!onLinkEdit || slipMediaStart === null}
64
64
  title={slipMediaStart === null ? "Would slip before the start of the file" : undefined}
65
65
  onClick={() => {
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { textClipBackground } from "./TextClipContent";
3
+
4
+ describe("textClipBackground", () => {
5
+ it("puts dark text on a light strip and light text on a dark one", () => {
6
+ expect(textClipBackground({ value: "a", color: "rgb(16, 20, 24)" })).toBe(
7
+ "var(--timeline-text-clip-light-bg)",
8
+ );
9
+ expect(textClipBackground({ value: "a", color: "rgb(255, 255, 255)" })).toBe(
10
+ "var(--timeline-text-clip-dark-bg)",
11
+ );
12
+ });
13
+
14
+ it("uses the layer's own background when it paints one", () => {
15
+ expect(
16
+ textClipBackground({ value: "a", color: "rgb(16, 20, 24)", background: "rgb(230, 57, 70)" }),
17
+ ).toBe("rgb(230, 57, 70)");
18
+ });
19
+ });
@@ -0,0 +1,27 @@
1
+ import { parseCssColor } from "../../components/editor/colorValue";
2
+ import type { TimelineText } from "../store/timelineElement";
3
+
4
+ /** The layer's own background, or a strip the text reads on: light behind dark text, dark otherwise. */
5
+ export function textClipBackground(text: TimelineText): string {
6
+ if (text.background) return text.background;
7
+ const color = parseCssColor(text.color ?? "");
8
+ const light = color ? 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue : 255;
9
+ return light < 128 ? "var(--timeline-text-clip-light-bg)" : "var(--timeline-text-clip-dark-bg)";
10
+ }
11
+
12
+ /** A text layer's row: its own words in its own font and colour, live, on one clipped line. */
13
+ export function TextClipContent({ text }: { text: TimelineText }) {
14
+ return (
15
+ <div
16
+ className="absolute inset-0 flex items-center overflow-hidden px-2"
17
+ style={{ background: textClipBackground(text) }}
18
+ >
19
+ <span
20
+ className="whitespace-nowrap text-[15px] leading-none"
21
+ style={{ fontFamily: text.fontFamily, fontWeight: text.fontWeight, color: text.color }}
22
+ >
23
+ {text.value}
24
+ </span>
25
+ </div>
26
+ );
27
+ }
@@ -1,6 +1,7 @@
1
1
  import { memo } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
+ import { menuClasses } from "../../components/ui/menuStyle";
4
5
 
5
6
  interface TrackGapContextMenuProps {
6
7
  x: number;
@@ -53,11 +54,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
53
54
  const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
54
55
 
55
56
  const itemClass = (enabled: boolean) =>
56
- `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
57
- enabled
58
- ? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
59
- : "text-neutral-600 cursor-not-allowed"
60
- }`;
57
+ `${menuClasses.row} flex items-center justify-between ${enabled ? menuClasses.rowEnabled : menuClasses.rowDisabled}`;
61
58
 
62
59
  // Disabled reasons: no gap under the pointer beats the lock reason — a
63
60
  // pointer not on a gap has nothing to close regardless of movability.
@@ -75,7 +72,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
75
72
  return createPortal(
76
73
  <div
77
74
  ref={menuRef}
78
- className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
75
+ className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
79
76
  style={{ left: adjustedX, top: adjustedY }}
80
77
  onPointerLeave={() => onHoverAction(null)}
81
78
  >
@@ -99,6 +99,29 @@ describe("ClipMenuToolItems", () => {
99
99
  expect(onClose).toHaveBeenCalled();
100
100
  });
101
101
 
102
+ it("opens its submenu at the row's edge in window pixels, and flips left near the window's right edge", () => {
103
+ const rowAt = (rect: { top: number; left: number; right: number }) =>
104
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect as DOMRect);
105
+ const spy = rowAt({ top: 50, left: 100, right: 300 });
106
+ renderItems("sound", video);
107
+ openSubmenu("Voice");
108
+ let menu = document.querySelector<HTMLElement>('[role="menu"]')!;
109
+ expect(menu.className).toContain("fixed");
110
+ expect([menu.style.top, menu.style.left]).toEqual(["50px", "300px"]);
111
+ act(() => root?.unmount());
112
+ document.body.innerHTML = "";
113
+ spy.mockReturnValue({
114
+ top: 50,
115
+ left: window.innerWidth - 200,
116
+ right: window.innerWidth - 10,
117
+ } as DOMRect);
118
+ renderItems("sound", video);
119
+ openSubmenu("Voice");
120
+ menu = document.querySelector<HTMLElement>('[role="menu"]')!;
121
+ expect([menu.style.top, menu.style.right, menu.style.left]).toEqual(["50px", "200px", ""]);
122
+ spy.mockRestore();
123
+ });
124
+
102
125
  it("Voice None drops the attribute", () => {
103
126
  const { setQuiet } = renderItems("sound", video);
104
127
  openSubmenu("Voice");
@@ -15,6 +15,7 @@ import {
15
15
  type ClipToolChoice,
16
16
  } from "./clipToolAttrs";
17
17
  import { useClipToolState } from "./useClipToolState";
18
+ import { menuClasses } from "../../components/ui/menuStyle";
18
19
 
19
20
  export type ClipMenuToolGroup = "time" | "sound" | "picture";
20
21
 
@@ -25,10 +26,8 @@ interface ClipMenuToolItemsProps {
25
26
  onClose: () => void;
26
27
  }
27
28
 
28
- const ROW_CLASS =
29
- "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer";
30
- const DISABLED_ROW_CLASS =
31
- "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
29
+ const ROW_CLASS = `${menuClasses.row} flex items-center justify-between ${menuClasses.rowEnabled}`;
30
+ const DISABLED_ROW_CLASS = `${menuClasses.row} flex items-center justify-between ${menuClasses.rowDisabled}`;
32
31
  const SUBMENU_WIDTH = 170;
33
32
  const HOVER_PREVIEW_DELAY_MS = 80;
34
33
 
@@ -98,7 +97,7 @@ function ChoiceSubmenu({
98
97
  preview?: HoverPreview;
99
98
  }) {
100
99
  const [open, setOpen] = useState(false);
101
- const [flipLeft, setFlipLeft] = useState(false);
100
+ const [anchor, setAnchor] = useState<{ top: number; left?: number; right?: number }>({ top: 0 });
102
101
  const rowRef = useRef<HTMLButtonElement | null>(null);
103
102
  const submenuRef = useRef<HTMLDivElement | null>(null);
104
103
  const hover = useHoverPreview(preview);
@@ -111,7 +110,12 @@ function ChoiceSubmenu({
111
110
 
112
111
  const show = (focusFirst: boolean) => {
113
112
  const rect = rowRef.current?.getBoundingClientRect();
114
- setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false);
113
+ if (rect)
114
+ setAnchor(
115
+ rect.right + SUBMENU_WIDTH > window.innerWidth
116
+ ? { top: rect.top, right: window.innerWidth - rect.left }
117
+ : { top: rect.top, left: rect.right },
118
+ );
115
119
  setOpen(true);
116
120
  if (!focusFirst) return;
117
121
  openingFocusRef.current = true;
@@ -184,8 +188,8 @@ function ChoiceSubmenu({
184
188
  ref={submenuRef}
185
189
  role="menu"
186
190
  aria-label={label}
187
- className="absolute top-0 z-10 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1"
188
- style={{ width: SUBMENU_WIDTH, ...(flipLeft ? { right: "100%" } : { left: "100%" }) }}
191
+ className={`${menuClasses.panel} fixed z-10`}
192
+ style={{ width: SUBMENU_WIDTH, ...anchor }}
189
193
  onKeyDown={onSubmenuKeyDown}
190
194
  >
191
195
  {choiceRow(null, "None")}
@@ -193,7 +197,7 @@ function ChoiceSubmenu({
193
197
  <div key={section.heading ?? index}>
194
198
  {section.heading && (
195
199
  <>
196
- <div className="my-1 border-t border-neutral-700/60" />
200
+ <div className={menuClasses.divider} />
197
201
  <div className="px-3 py-1 text-[9px] uppercase tracking-wide text-neutral-500">
198
202
  {section.heading}
199
203
  </div>