@hyperframes/studio 0.8.107 → 0.8.109

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 (183) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
  5. package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/chunk-6LUNLTHR.js +611 -0
  9. package/dist/chunk-6LUNLTHR.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
  13. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  14. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  15. package/dist/index.d.ts +53 -4
  16. package/dist/index.html +2 -2
  17. package/dist/index.js +15746 -13173
  18. package/dist/index.js.map +1 -1
  19. package/dist/player.js +3 -3
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +1 -1
  22. package/package.json +8 -8
  23. package/src/App.tsx +8 -11
  24. package/src/components/EditorShell.tsx +6 -0
  25. package/src/components/LinkedSelectionToggle.tsx +73 -0
  26. package/src/components/TimelineToolbar.test.tsx +31 -0
  27. package/src/components/TimelineToolbar.tsx +2 -0
  28. package/src/components/editor/CropPresetBar.tsx +69 -0
  29. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  30. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  31. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  32. package/src/components/editor/InlineTextCaret.tsx +211 -0
  33. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  34. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  35. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  36. package/src/components/editor/carveOutput.ts +54 -0
  37. package/src/components/editor/cropPresetStore.ts +33 -0
  38. package/src/components/editor/cropPresets.test.ts +41 -0
  39. package/src/components/editor/cropPresets.ts +24 -0
  40. package/src/components/editor/domEditInlineText.ts +6 -4
  41. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  42. package/src/components/editor/domEditingLayers.test.ts +21 -0
  43. package/src/components/editor/domEditingLayers.ts +2 -0
  44. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  45. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  46. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  47. package/src/components/editor/manualOffsetDrag.ts +11 -0
  48. package/src/components/editor/mediaAudioEdits.test.ts +79 -4
  49. package/src/components/editor/mediaAudioEdits.ts +66 -24
  50. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  51. package/src/components/editor/mediaLinkEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
  55. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  56. package/src/components/editor/useDomEditNudge.ts +17 -12
  57. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  58. package/src/components/editor/useFxCarve.ts +3 -61
  59. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  60. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  61. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  62. package/src/components/editor/useVolumeAutomation.ts +29 -7
  63. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  64. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  65. package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
  66. package/src/components/ui/Menu.test.tsx +3 -22
  67. package/src/components/ui/Menu.tsx +2 -2
  68. package/src/components/ui/Popover.tsx +2 -2
  69. package/src/components/ui/Select.tsx +2 -2
  70. package/src/components/ui/Tooltip.tsx +2 -2
  71. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  72. package/src/components/ui/valueControls.test.tsx +2 -21
  73. package/src/contexts/TimelineEditContext.tsx +4 -0
  74. package/src/hooks/appHotkeysDispatch.ts +2 -1
  75. package/src/hooks/linkShortcuts.test.ts +70 -0
  76. package/src/hooks/linkShortcuts.ts +41 -0
  77. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  78. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  79. package/src/hooks/timelineMoveAdapter.ts +13 -0
  80. package/src/hooks/timelineTimingSync.test.ts +59 -0
  81. package/src/hooks/timelineTimingSync.ts +22 -3
  82. package/src/hooks/useAppHotkeys.ts +9 -0
  83. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  84. package/src/hooks/useEditHistoryActions.ts +13 -4
  85. package/src/hooks/useElementPicker.test.ts +62 -13
  86. package/src/hooks/useElementPicker.ts +10 -2
  87. package/src/hooks/useFreezeFrame.test.ts +56 -0
  88. package/src/hooks/useFreezeFrame.ts +121 -0
  89. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  90. package/src/hooks/useGsapScriptCommits.ts +23 -3
  91. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  92. package/src/hooks/usePersistentEditHistory.ts +1 -1
  93. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  94. package/src/hooks/usePreviewPersistence.ts +15 -0
  95. package/src/hooks/useRazorSplit.test.tsx +48 -0
  96. package/src/hooks/useRazorSplit.ts +26 -2
  97. package/src/hooks/useRenderClipContent.test.ts +108 -4
  98. package/src/hooks/useRenderClipContent.ts +33 -23
  99. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  100. package/src/hooks/useTimelineEditGuard.ts +54 -0
  101. package/src/hooks/useTimelineEditing.ts +38 -57
  102. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  103. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  104. package/src/index.ts +2 -0
  105. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  106. package/src/player/components/AudioWaveform.test.tsx +30 -3
  107. package/src/player/components/AudioWaveform.tsx +6 -7
  108. package/src/player/components/ClipBadges.test.tsx +82 -0
  109. package/src/player/components/ClipBadges.tsx +67 -0
  110. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  111. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  112. package/src/player/components/ClipContextMenu.tsx +124 -77
  113. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  114. package/src/player/components/ClipPeakMarks.tsx +71 -0
  115. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  116. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  117. package/src/player/components/TimelineClip.test.tsx +17 -0
  118. package/src/player/components/TimelineClip.tsx +8 -0
  119. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  120. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  121. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  122. package/src/player/components/audioClipLink.test.ts +228 -31
  123. package/src/player/components/audioClipLink.ts +72 -25
  124. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  125. package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
  126. package/src/player/components/clipMenuAudioItems.tsx +116 -0
  127. package/src/player/components/clipMenuDuck.test.ts +117 -0
  128. package/src/player/components/clipMenuDuck.ts +147 -0
  129. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  130. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  131. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  132. package/src/player/components/clipMenuNormalize.ts +96 -0
  133. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  134. package/src/player/components/clipMenuToolItems.tsx +233 -0
  135. package/src/player/components/clipPeakRuns.test.ts +38 -0
  136. package/src/player/components/clipPeakRuns.ts +59 -0
  137. package/src/player/components/clipSync.test.ts +67 -0
  138. package/src/player/components/clipSync.ts +80 -0
  139. package/src/player/components/clipToolAttrs.test.ts +161 -0
  140. package/src/player/components/clipToolAttrs.ts +187 -0
  141. package/src/player/components/linkLabelColor.test.ts +20 -0
  142. package/src/player/components/linkLabelColor.ts +24 -0
  143. package/src/player/components/timelineCallbacks.ts +12 -0
  144. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  145. package/src/player/components/timelineClipDragTypes.ts +2 -0
  146. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  147. package/src/player/components/timelineLinkSelection.ts +23 -0
  148. package/src/player/components/useClipToolState.ts +92 -0
  149. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  150. package/src/player/components/useTimelineClipDrag.ts +21 -0
  151. package/src/player/components/useTimelineProviderState.tsx +3 -0
  152. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  153. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  154. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  155. package/src/player/lib/timelineDOM.test.ts +31 -0
  156. package/src/player/lib/timelineDOM.ts +13 -0
  157. package/src/player/lib/timelineElementHelpers.ts +9 -0
  158. package/src/player/store/timelineElement.ts +12 -0
  159. package/src/styles/components.css +27 -11
  160. package/src/styles/theme.css +0 -1
  161. package/src/timelineChromeExports.test.tsx +7 -0
  162. package/src/utils/audioMeterMath.test.ts +37 -0
  163. package/src/utils/audioMeterMath.ts +9 -3
  164. package/src/utils/exportPeakCeiling.ts +1 -0
  165. package/src/utils/gsapSoftReload.test.ts +238 -2
  166. package/src/utils/gsapSoftReload.ts +89 -74
  167. package/src/utils/gsapUndoRestore.test.ts +4 -3
  168. package/src/utils/gsapUndoRestore.ts +15 -0
  169. package/src/utils/linkedClipPreferences.ts +34 -0
  170. package/src/utils/previewRelativePath.ts +19 -0
  171. package/src/utils/softReloadTargets.ts +128 -0
  172. package/src/utils/sourcePatcher.ts +1 -1
  173. package/src/utils/studioPendingEdits.test.ts +66 -0
  174. package/src/utils/studioPendingEdits.ts +45 -11
  175. package/src/utils/studioUiPreferences.ts +8 -0
  176. package/src/utils/timelineInspector.test.ts +33 -2
  177. package/src/utils/timelineInspector.ts +16 -4
  178. package/dist/chunk-46XP7J3B.js.map +0 -1
  179. package/dist/chunk-4OZC426H.js.map +0 -1
  180. package/dist/chunk-DI4LCJHL.js +0 -102
  181. package/dist/chunk-DI4LCJHL.js.map +0 -1
  182. package/dist/chunk-NCUBAVGK.js.map +0 -1
  183. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
package/dist/ui.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  fieldBase,
31
31
  fieldText,
32
32
  popupSurface
33
- } from "./chunk-NCUBAVGK.js";
33
+ } from "./chunk-5VSXRGIX.js";
34
34
  import "./chunk-PZ5AY32C.js";
35
35
  export {
36
36
  Button,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.107",
3
+ "version": "0.8.109",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/parsers": "0.8.107",
64
- "@hyperframes/core": "0.8.107",
65
- "@hyperframes/sdk": "0.8.107",
66
- "@hyperframes/shader-transitions": "0.8.107",
67
- "@hyperframes/studio-server": "0.8.107",
68
- "@hyperframes/player": "0.8.107"
63
+ "@hyperframes/core": "0.8.109",
64
+ "@hyperframes/parsers": "0.8.109",
65
+ "@hyperframes/sdk": "0.8.109",
66
+ "@hyperframes/shader-transitions": "0.8.109",
67
+ "@hyperframes/player": "0.8.109",
68
+ "@hyperframes/studio-server": "0.8.109"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.107"
84
+ "@hyperframes/producer": "0.8.109"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
package/src/App.tsx CHANGED
@@ -17,11 +17,7 @@ import { useFileManager } from "./hooks/useFileManager";
17
17
  import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
18
18
  import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
19
19
  import { useTimelineEditing } from "./hooks/useTimelineEditing";
20
- import {
21
- persistTimelineMoveEditsAtomically,
22
- type TimelineMoveEditsHandler,
23
- type TimelineMoveOperation,
24
- } from "./hooks/timelineMoveAdapter";
20
+ import { useTimelineMoveEditsHandler } from "./hooks/timelineMoveAdapter";
25
21
  import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
26
22
  import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
27
23
  import { useDomEditSession } from "./hooks/useDomEditSession";
@@ -179,12 +175,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
179
175
  invalidateGsapCache,
180
176
  handleDomZIndexReorderCommitRef,
181
177
  });
182
- const handleTimelineElementsMove: TimelineMoveEditsHandler = useCallback(
183
- async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => {
184
- const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
185
- await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
186
- },
187
- [timelineEditing.handleTimelineGroupMove],
178
+ const handleTimelineElementsMove = useTimelineMoveEditsHandler(
179
+ timelineEditing.handleTimelineGroupMove,
188
180
  );
189
181
  const {
190
182
  addAssetAtPlayhead: handleAddAssetAtPlayhead,
@@ -246,6 +238,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
246
238
  });
247
239
  const appHotkeys = useAppHotkeys({
248
240
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
241
+ handleLinkEdit: timelineEditing.handleLinkEdit,
242
+ handleTimelineElementDeleteOnly: timelineEditing.handleTimelineElementDeleteOnly,
249
243
  handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit,
250
244
  handleDomEditElementDelete: domEditDeleteBridge,
251
245
  domEditSelectionRef: domEditSelectionBridgeRef,
@@ -552,6 +546,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
552
546
  handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
553
547
  handleRazorSplit={timelineEditing.handleRazorSplit}
554
548
  handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
549
+ handleFreezeFrame={timelineEditing.handleFreezeFrame}
550
+ handleLinkEdit={timelineEditing.handleLinkEdit}
551
+ handleTimelineElementDeleteOnly={timelineEditing.handleTimelineElementDeleteOnly}
555
552
  onCopyClip={handleCopy}
556
553
  onPasteClip={handlePaste}
557
554
  onDuplicateClip={handleDuplicate}
@@ -103,6 +103,9 @@ export function EditorShell({
103
103
  handleTimelineElementSplit,
104
104
  handleRazorSplit,
105
105
  handleRazorSplitAll,
106
+ handleFreezeFrame,
107
+ handleLinkEdit,
108
+ handleTimelineElementDeleteOnly,
106
109
  onCopyClip,
107
110
  onPasteClip,
108
111
  onDuplicateClip,
@@ -163,6 +166,9 @@ export function EditorShell({
163
166
  handleTimelineElementSplit,
164
167
  handleRazorSplit,
165
168
  handleRazorSplitAll,
169
+ handleFreezeFrame,
170
+ handleLinkEdit,
171
+ handleTimelineElementDeleteOnly,
166
172
  });
167
173
 
168
174
  return (
@@ -0,0 +1,73 @@
1
+ import { useCallback, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { LinkSimple } from "@phosphor-icons/react";
4
+ import { useContextMenuDismiss } from "../hooks/useContextMenuDismiss";
5
+ import { useMenuKeyboardNav } from "../player/components/menuKeyboardNav";
6
+ import { useLinkedClipPreferences } from "../utils/linkedClipPreferences";
7
+ import { Tooltip } from "./ui";
8
+ import { flatActive, flatIdle } from "./timelineToolbarStyles";
9
+
10
+ function SyncIndicatorMenu({ x, y, onClose }: { x: number; y: number; onClose: () => void }) {
11
+ const menuRef = useContextMenuDismiss(onClose);
12
+ useMenuKeyboardNav(menuRef);
13
+ const visible = useLinkedClipPreferences((s) => s.syncIndicatorsVisible);
14
+ const setVisible = useLinkedClipPreferences((s) => s.setSyncIndicatorsVisible);
15
+ return createPortal(
16
+ <div
17
+ ref={menuRef}
18
+ role="menu"
19
+ aria-label="Linked clips"
20
+ className="fixed z-200 min-w-[200px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
21
+ style={{ left: x, top: y }}
22
+ >
23
+ <button
24
+ type="button"
25
+ role="menuitemcheckbox"
26
+ aria-checked={visible}
27
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800"
28
+ onClick={() => {
29
+ setVisible(!visible);
30
+ onClose();
31
+ }}
32
+ >
33
+ <span className="w-3 text-studio-accent">{visible ? "✓" : ""}</span>
34
+ Show out-of-sync indicators
35
+ </button>
36
+ </div>,
37
+ document.body,
38
+ );
39
+ }
40
+
41
+ /** Premiere's Linked Selection; right-click holds the out-of-sync indicator preference. */
42
+ export function LinkedSelectionToggle() {
43
+ const linkedSelection = useLinkedClipPreferences((s) => s.linkedSelection);
44
+ const setLinkedSelection = useLinkedClipPreferences((s) => s.setLinkedSelection);
45
+ const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
46
+ const closeMenu = useCallback(() => setMenuAt(null), []);
47
+ return (
48
+ <>
49
+ <Tooltip
50
+ label={
51
+ linkedSelection
52
+ ? "Linked Selection on — a click selects both halves (⌥-click for one)"
53
+ : "Linked Selection off — a click selects one clip"
54
+ }
55
+ >
56
+ <button
57
+ type="button"
58
+ onClick={() => setLinkedSelection(!linkedSelection)}
59
+ onContextMenu={(e) => {
60
+ e.preventDefault();
61
+ setMenuAt({ x: e.clientX, y: e.clientY });
62
+ }}
63
+ aria-label="Linked Selection"
64
+ aria-pressed={linkedSelection}
65
+ className={linkedSelection ? flatActive : flatIdle}
66
+ >
67
+ <LinkSimple size={16} weight="bold" aria-hidden="true" />
68
+ </button>
69
+ </Tooltip>
70
+ {menuAt && <SyncIndicatorMenu x={menuAt.x} y={menuAt.y} onClose={closeMenu} />}
71
+ </>
72
+ );
73
+ }
@@ -391,3 +391,34 @@ describe("TimelineToolbar add beat", () => {
391
391
  act(() => root.unmount());
392
392
  });
393
393
  });
394
+
395
+ describe("TimelineToolbar Linked Selection", () => {
396
+ it("is on by default, first among the editing toggles, and persists a click", async () => {
397
+ const { useLinkedClipPreferences } = await import("../utils/linkedClipPreferences");
398
+ const { host } = renderToolbar();
399
+ const button = host.querySelector<HTMLButtonElement>('button[aria-label="Linked Selection"]');
400
+ expect(button?.getAttribute("aria-pressed")).toBe("true");
401
+ const snapping = host.querySelector('button[aria-label="Toggle timeline snapping"]');
402
+ expect(snapping && button?.compareDocumentPosition(snapping)).toBe(
403
+ Node.DOCUMENT_POSITION_FOLLOWING,
404
+ );
405
+ act(() => button?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
406
+ expect(button?.getAttribute("aria-pressed")).toBe("false");
407
+ expect(readStudioUiPreferences().linkedSelectionEnabled).toBe(false);
408
+ act(() => useLinkedClipPreferences.getState().setLinkedSelection(true));
409
+ });
410
+
411
+ it("right-click toggles the out-of-sync indicator preference", async () => {
412
+ const { useLinkedClipPreferences } = await import("../utils/linkedClipPreferences");
413
+ const { host } = renderToolbar();
414
+ const button = host.querySelector('button[aria-label="Linked Selection"]');
415
+ act(() => button?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })));
416
+ const item = document.querySelector<HTMLButtonElement>('[role="menuitemcheckbox"]');
417
+ expect(item?.textContent).toContain("Show out-of-sync indicators");
418
+ expect(item?.getAttribute("aria-checked")).toBe("true");
419
+ act(() => item?.click());
420
+ expect(useLinkedClipPreferences.getState().syncIndicatorsVisible).toBe(false);
421
+ expect(readStudioUiPreferences().syncIndicatorsVisible).toBe(false);
422
+ act(() => useLinkedClipPreferences.getState().setSyncIndicatorsVisible(true));
423
+ });
424
+ });
@@ -31,6 +31,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
31
31
  import type { DomEditSelection } from "./editor/domEditingTypes";
32
32
  import { canSplitElement } from "../utils/timelineElementSplit";
33
33
  import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
34
+ import { LinkedSelectionToggle } from "./LinkedSelectionToggle";
34
35
  import { useProjectHasAudio } from "../utils/audioMeterMath";
35
36
  import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
36
37
  import { isTypingTarget } from "../utils/typingTarget";
@@ -218,6 +219,7 @@ export function TimelineToolbar({
218
219
  <TimelineToolPicker showSelectAroundPlayhead={showSelectAroundPlayhead} />
219
220
  {/* Divider: tool-mode | editing-actions */}
220
221
  <div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
222
+ <LinkedSelectionToggle />
221
223
  <Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
222
224
  <button
223
225
  type="button"
@@ -0,0 +1,69 @@
1
+ import { useEffect } from "react";
2
+ import type { ClipPathInsetSides } from "./clipPathHelpers";
3
+ import { CROP_ASPECT_PRESETS, centredCropInsets } from "./cropPresets";
4
+
5
+ interface CropPresetBarProps {
6
+ left: number;
7
+ top: number;
8
+ elementWidth: number;
9
+ elementHeight: number;
10
+ /** Centred insets for a ratio, or `null` to remove the crop. */
11
+ onApply: (insets: ClipPathInsetSides | null) => void;
12
+ onDone: () => void;
13
+ }
14
+
15
+ const CHIP =
16
+ "rounded-sm border border-white/20 px-1.5 py-0.5 text-[10px] text-white/85 hover:bg-white/10";
17
+
18
+ /** Free · aspect presets · Reset · Done, floating above the element being cropped. */
19
+ export function CropPresetBar({
20
+ left,
21
+ top,
22
+ elementWidth,
23
+ elementHeight,
24
+ onApply,
25
+ onDone,
26
+ }: CropPresetBarProps) {
27
+ useEffect(() => {
28
+ const onKeyDown = (event: KeyboardEvent) => {
29
+ if (event.key === "Escape") onDone();
30
+ };
31
+ window.addEventListener("keydown", onKeyDown);
32
+ return () => window.removeEventListener("keydown", onKeyDown);
33
+ }, [onDone]);
34
+
35
+ return (
36
+ <div
37
+ role="toolbar"
38
+ aria-label="Crop presets"
39
+ data-dom-edit-crop-bar="true"
40
+ className="pointer-events-auto absolute z-20 flex gap-1 rounded-md border border-white/15 bg-neutral-900/95 p-1 shadow-lg"
41
+ style={{ left, top: Math.max(4, top - 34), transform: "translateX(-50%)" }}
42
+ onPointerDown={(event) => event.stopPropagation()}
43
+ >
44
+ <button type="button" className={CHIP} title="Drag the edges freely">
45
+ Free
46
+ </button>
47
+ {CROP_ASPECT_PRESETS.map((preset) => (
48
+ <button
49
+ key={preset.label}
50
+ type="button"
51
+ className={CHIP}
52
+ onClick={() => onApply(centredCropInsets(elementWidth, elementHeight, preset.ratio))}
53
+ >
54
+ {preset.label}
55
+ </button>
56
+ ))}
57
+ <button type="button" className={CHIP} onClick={() => onApply(null)}>
58
+ Reset
59
+ </button>
60
+ <button
61
+ type="button"
62
+ className="rounded-sm border border-studio-accent bg-studio-accent px-1.5 py-0.5 text-[10px] font-semibold text-black"
63
+ onClick={onDone}
64
+ >
65
+ Done
66
+ </button>
67
+ </div>
68
+ );
69
+ }
@@ -6,6 +6,7 @@ import type { DomEditSelection } from "./domEditing";
6
6
  import type { OverlayRect } from "./domEditOverlayGeometry";
7
7
  import { DomEditCropHandles } from "./DomEditCropHandles";
8
8
  import { isElementCropLifted } from "./domEditOverlayCrop";
9
+ import { useCropPresetBarStore } from "./cropPresetStore";
9
10
 
10
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
12
 
@@ -272,3 +273,46 @@ describe("DomEditCropHandles leaves the corner resize dots free", () => {
272
273
  expect(handles().map((h) => h.label)).toEqual(["Crop top", "Crop bottom"]);
273
274
  });
274
275
  });
276
+
277
+ describe("DomEditCropHandles preset bar", () => {
278
+ const click = (label: string) => {
279
+ const button = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(
280
+ (b) => b.textContent === label,
281
+ );
282
+ act(() => button?.click());
283
+ };
284
+
285
+ afterEach(() => useCropPresetBarStore.getState().close());
286
+
287
+ it("shows only for the clip the menu opened it for", () => {
288
+ const a = makeEl("a", "");
289
+ useCropPresetBarStore.getState().open({ id: "other" });
290
+ render(a);
291
+ expect(document.querySelector("[data-dom-edit-crop-bar]")).toBeNull();
292
+ });
293
+
294
+ it("commits a centred 1:1 crop, then Reset removes the clip-path", async () => {
295
+ const a = makeEl("a", "");
296
+ const commits: string[] = [];
297
+ useCropPresetBarStore.getState().open({ id: "a" });
298
+ const { root } = render(a, (_property, value) => {
299
+ commits.push(value);
300
+ });
301
+ click("1:1");
302
+ await act(async () => {});
303
+ expect(commits[0]).toBe("inset(0px 50px 0px 50px)");
304
+ click("Reset");
305
+ await act(async () => {});
306
+ expect(commits[1]).toBe("");
307
+ act(() => root.unmount());
308
+ expect(a.style.getPropertyValue("clip-path")).toBe("");
309
+ });
310
+
311
+ it("Done closes the bar", () => {
312
+ const a = makeEl("a", "");
313
+ useCropPresetBarStore.getState().open({ id: "a" });
314
+ render(a);
315
+ click("Done");
316
+ expect(useCropPresetBarStore.getState().openFor).toBeNull();
317
+ });
318
+ });
@@ -15,6 +15,8 @@ import {
15
15
  } from "./domEditOverlayCrop";
16
16
  import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
17
17
  import { readCropFollowingResize } from "./cropResize";
18
+ import { isCropBarTarget, useCropPresetBarStore } from "./cropPresetStore";
19
+ import { CropPresetBar } from "./CropPresetBar";
18
20
 
19
21
  interface CropGestureState {
20
22
  edge: CropEdge | "move";
@@ -119,6 +121,9 @@ export function DomEditCropHandles({
119
121
  return { element, croppable: parsed !== null, insets: { top, right, bottom, left } };
120
122
  };
121
123
  const [state, setState] = useState(() => cropStateFor(selection.element));
124
+ const presetBarTarget = useCropPresetBarStore((s) => s.openFor);
125
+ const closePresetBar = useCropPresetBarStore((s) => s.close);
126
+ const showPresetBar = isCropBarTarget(selection.element, presetBarTarget);
122
127
 
123
128
  // Re-sync when the selection targets a different element (reselect, or an
124
129
  // undo/redo that re-keys the node).
@@ -223,10 +228,21 @@ export function DomEditCropHandles({
223
228
  // the lift keeps it hidden while selected. A drag that ends where it started saves nothing.
224
229
  const value = buildInsetClipPathSides(gesture.insets, gesture.radius);
225
230
  if (value === buildInsetClipPathSides(gesture.startInsets, gesture.radius)) return;
231
+ commitClipPath(value);
232
+ };
233
+
234
+ const commitClipPath = (value: string) => {
226
235
  const commit = onStyleCommit?.("clip-path", value);
227
236
  void Promise.resolve(commit).catch(() => undefined);
228
237
  };
229
238
 
239
+ const applyPresetInsets = (insets: ClipPathInsetSides | null) => {
240
+ const next = insets ?? { top: 0, right: 0, bottom: 0, left: 0 };
241
+ setState((prev) => ({ ...prev, insets: next }));
242
+ const radius = readCropFollowingResize(selection.element)?.radius ?? 0;
243
+ commitClipPath(insets === null ? "" : buildInsetClipPathSides(next, radius));
244
+ };
245
+
230
246
  const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
231
247
  const gesture = endCropGesture(event);
232
248
  if (!gesture) return;
@@ -239,119 +255,131 @@ export function DomEditCropHandles({
239
255
  if (!state.croppable) return null;
240
256
 
241
257
  return (
242
- <div
243
- data-dom-edit-crop-frame="true"
244
- className="pointer-events-none absolute"
245
- style={{
246
- left: frame.left,
247
- top: frame.top,
248
- width: frame.width,
249
- height: frame.height,
250
- transform: frame.angleDeg !== 0 ? `rotate(${frame.angleDeg}deg)` : undefined,
251
- }}
252
- >
253
- {/* Dim the cropped-away area whenever the element is cropped and selected,
254
- so the hidden content is visible (ghosted) without dragging. Clipped to
255
- the element's own (rotated) box. */}
256
- {hasCrop && (
257
- <div className="pointer-events-none absolute inset-0 overflow-hidden">
258
- <div
259
- className="absolute"
260
- style={{
261
- left: cropRect.left,
262
- top: cropRect.top,
263
- width: cropRect.width,
264
- height: cropRect.height,
265
- boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
266
- }}
267
- />
268
- </div>
258
+ <>
259
+ {showPresetBar && onStyleCommit && (
260
+ <CropPresetBar
261
+ left={frame.left + frame.width / 2}
262
+ top={frame.top}
263
+ elementWidth={width}
264
+ elementHeight={height}
265
+ onApply={applyPresetInsets}
266
+ onDone={closePresetBar}
267
+ />
269
268
  )}
270
- {/* Dashed clip outline on the crop boundary, with a rule-of-thirds grid
271
- shown while dragging. */}
272
269
  <div
273
- aria-hidden="true"
274
- className="pointer-events-none absolute border border-dashed border-studio-accent"
270
+ data-dom-edit-crop-frame="true"
271
+ className="pointer-events-none absolute"
275
272
  style={{
276
- left: cropRect.left,
277
- top: cropRect.top,
278
- width: cropRect.width,
279
- height: cropRect.height,
273
+ left: frame.left,
274
+ top: frame.top,
275
+ width: frame.width,
276
+ height: frame.height,
277
+ transform: frame.angleDeg !== 0 ? `rotate(${frame.angleDeg}deg)` : undefined,
280
278
  }}
281
279
  >
282
- {dragging && (
283
- <>
284
- <div className="absolute inset-y-0 left-1/3 w-px bg-studio-accent/40" />
285
- <div className="absolute inset-y-0 left-2/3 w-px bg-studio-accent/40" />
286
- <div className="absolute inset-x-0 top-1/3 h-px bg-studio-accent/40" />
287
- <div className="absolute inset-x-0 top-2/3 h-px bg-studio-accent/40" />
288
- </>
280
+ {/* Dim the cropped-away area whenever the element is cropped and selected,
281
+ so the hidden content is visible (ghosted) without dragging. Clipped to
282
+ the element's own (rotated) box. */}
283
+ {hasCrop && (
284
+ <div className="pointer-events-none absolute inset-0 overflow-hidden">
285
+ <div
286
+ className="absolute"
287
+ style={{
288
+ left: cropRect.left,
289
+ top: cropRect.top,
290
+ width: cropRect.width,
291
+ height: cropRect.height,
292
+ boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
293
+ }}
294
+ />
295
+ </div>
289
296
  )}
290
- </div>
291
- {/* Reposition handle — a center circle shown only once cropped. Drag it to
292
- pan the crop window (which part of the element shows) without resizing
293
- the crop. It's a small, discrete target, so a body drag still MOVES. */}
294
- {hasCrop && repositionSize > 0 && (
295
- <button
296
- type="button"
297
- aria-label="Reposition crop"
298
- title="Reposition crop"
299
- data-dom-edit-crop-handle="true"
300
- className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
297
+ {/* Dashed clip outline on the crop boundary, with a rule-of-thirds grid
298
+ shown while dragging. */}
299
+ <div
300
+ aria-hidden="true"
301
+ className="pointer-events-none absolute border border-dashed border-studio-accent"
301
302
  style={{
302
- left: cropRect.left + (cropRect.width - repositionSize) / 2,
303
- top: cropRect.top + (cropRect.height - repositionSize) / 2,
304
- width: repositionSize,
305
- height: repositionSize,
306
- cursor: "move",
307
- touchAction: "none",
303
+ left: cropRect.left,
304
+ top: cropRect.top,
305
+ width: cropRect.width,
306
+ height: cropRect.height,
308
307
  }}
309
- onPointerDown={(event) => startCropGesture("move", event)}
310
- onPointerMove={updateCropGesture}
311
- onPointerUp={finishCropGesture}
312
- onPointerCancel={cancelCropGesture}
313
- />
314
- )}
315
- {/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
316
- edge (via edgeHandleLayout) so they never overlap the element body:
317
- dragging the body always MOVES, only a handle crops. The pill is
318
- hover-revealed (or shown while dragging / once a crop exists) so the
319
- resting selection chrome stays uncluttered; the hit strip is always
320
- live, and the title names the affordance. */}
321
- {EDGES.map((edge) => {
322
- const layout = edgeHandleLayout(edge, cropRect);
323
- if (!layout) return null;
324
- const revealed = dragging || hasCrop || hotEdge === edge;
325
- return (
308
+ >
309
+ {dragging && (
310
+ <>
311
+ <div className="absolute inset-y-0 left-1/3 w-px bg-studio-accent/40" />
312
+ <div className="absolute inset-y-0 left-2/3 w-px bg-studio-accent/40" />
313
+ <div className="absolute inset-x-0 top-1/3 h-px bg-studio-accent/40" />
314
+ <div className="absolute inset-x-0 top-2/3 h-px bg-studio-accent/40" />
315
+ </>
316
+ )}
317
+ </div>
318
+ {/* Reposition handle — a center circle shown only once cropped. Drag it to
319
+ pan the crop window (which part of the element shows) without resizing
320
+ the crop. It's a small, discrete target, so a body drag still MOVES. */}
321
+ {hasCrop && repositionSize > 0 && (
326
322
  <button
327
- key={edge}
328
323
  type="button"
329
- aria-label={`Crop ${edge}`}
330
- title="Crop"
324
+ aria-label="Reposition crop"
325
+ title="Reposition crop"
331
326
  data-dom-edit-crop-handle="true"
332
- className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
327
+ className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
333
328
  style={{
334
- ...layout.hit,
335
- cursor: layout.cursor,
329
+ left: cropRect.left + (cropRect.width - repositionSize) / 2,
330
+ top: cropRect.top + (cropRect.height - repositionSize) / 2,
331
+ width: repositionSize,
332
+ height: repositionSize,
333
+ cursor: "move",
336
334
  touchAction: "none",
337
335
  }}
338
- onPointerEnter={() => setHotEdge(edge)}
339
- onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
340
- onPointerDown={(event) => startCropGesture(edge, event)}
336
+ onPointerDown={(event) => startCropGesture("move", event)}
341
337
  onPointerMove={updateCropGesture}
342
338
  onPointerUp={finishCropGesture}
343
339
  onPointerCancel={cancelCropGesture}
344
- >
345
- <span
346
- className="pointer-events-none rounded-full bg-studio-accent/90 shadow-[0_0_0_1px_rgba(0,0,0,0.4)] transition-opacity duration-100"
340
+ />
341
+ )}
342
+ {/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
343
+ edge (via edgeHandleLayout) so they never overlap the element body:
344
+ dragging the body always MOVES, only a handle crops. The pill is
345
+ hover-revealed (or shown while dragging / once a crop exists) so the
346
+ resting selection chrome stays uncluttered; the hit strip is always
347
+ live, and the title names the affordance. */}
348
+ {EDGES.map((edge) => {
349
+ const layout = edgeHandleLayout(edge, cropRect);
350
+ if (!layout) return null;
351
+ const revealed = dragging || hasCrop || hotEdge === edge;
352
+ return (
353
+ <button
354
+ key={edge}
355
+ type="button"
356
+ aria-label={`Crop ${edge}`}
357
+ title="Crop"
358
+ data-dom-edit-crop-handle="true"
359
+ className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
347
360
  style={{
348
- ...layout.pill,
349
- opacity: revealed ? 1 : 0,
361
+ ...layout.hit,
362
+ cursor: layout.cursor,
363
+ touchAction: "none",
350
364
  }}
351
- />
352
- </button>
353
- );
354
- })}
355
- </div>
365
+ onPointerEnter={() => setHotEdge(edge)}
366
+ onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
367
+ onPointerDown={(event) => startCropGesture(edge, event)}
368
+ onPointerMove={updateCropGesture}
369
+ onPointerUp={finishCropGesture}
370
+ onPointerCancel={cancelCropGesture}
371
+ >
372
+ <span
373
+ className="pointer-events-none rounded-full bg-studio-accent/90 shadow-[0_0_0_1px_rgba(0,0,0,0.4)] transition-opacity duration-100"
374
+ style={{
375
+ ...layout.pill,
376
+ opacity: revealed ? 1 : 0,
377
+ }}
378
+ />
379
+ </button>
380
+ );
381
+ })}
382
+ </div>
383
+ </>
356
384
  );
357
385
  }