@hyperframes/studio 0.8.108 → 0.8.110

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
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.108",
3
+ "version": "0.8.110",
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/core": "0.8.108",
64
- "@hyperframes/parsers": "0.8.108",
65
- "@hyperframes/player": "0.8.108",
66
- "@hyperframes/sdk": "0.8.108",
67
- "@hyperframes/studio-server": "0.8.108",
68
- "@hyperframes/shader-transitions": "0.8.108"
63
+ "@hyperframes/parsers": "0.8.110",
64
+ "@hyperframes/player": "0.8.110",
65
+ "@hyperframes/core": "0.8.110",
66
+ "@hyperframes/studio-server": "0.8.110",
67
+ "@hyperframes/shader-transitions": "0.8.110",
68
+ "@hyperframes/sdk": "0.8.110"
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.108"
84
+ "@hyperframes/producer": "0.8.110"
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,
@@ -553,6 +547,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
553
547
  handleRazorSplit={timelineEditing.handleRazorSplit}
554
548
  handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
555
549
  handleFreezeFrame={timelineEditing.handleFreezeFrame}
550
+ handleLinkEdit={timelineEditing.handleLinkEdit}
551
+ handleTimelineElementDeleteOnly={timelineEditing.handleTimelineElementDeleteOnly}
556
552
  onCopyClip={handleCopy}
557
553
  onPasteClip={handlePaste}
558
554
  onDuplicateClip={handleDuplicate}
@@ -104,6 +104,8 @@ export function EditorShell({
104
104
  handleRazorSplit,
105
105
  handleRazorSplitAll,
106
106
  handleFreezeFrame,
107
+ handleLinkEdit,
108
+ handleTimelineElementDeleteOnly,
107
109
  onCopyClip,
108
110
  onPasteClip,
109
111
  onDuplicateClip,
@@ -165,6 +167,8 @@ export function EditorShell({
165
167
  handleRazorSplit,
166
168
  handleRazorSplitAll,
167
169
  handleFreezeFrame,
170
+ handleLinkEdit,
171
+ handleTimelineElementDeleteOnly,
168
172
  });
169
173
 
170
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"
@@ -35,6 +35,19 @@ function makeEl(id: string, clip: string): HTMLElement {
35
35
  return el;
36
36
  }
37
37
 
38
+ /** Presses at the first x, moves through the rest with their buttons, and lets go where the last held move was. */
39
+ function dragCropRight(handle: HTMLElement, pointerId: number, points: [number, number][]) {
40
+ const [[start], ...moves] = points;
41
+ const release = moves.filter(([, buttons]) => buttons & 1).at(-1)?.[0] ?? start;
42
+ const send = (type: string, clientX: number, buttons: number) =>
43
+ handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId, buttons, clientX }));
44
+ act(() => {
45
+ send("pointerdown", start, 1);
46
+ for (const [x, buttons] of moves) send("pointermove", x, buttons);
47
+ send("pointerup", release, 0);
48
+ });
49
+ }
50
+
38
51
  function render(
39
52
  el: HTMLElement,
40
53
  onStyleCommit: (property: string, value: string) => Promise<unknown> | void = () => undefined,
@@ -111,7 +124,14 @@ describe("DomEditCropHandles clip lift", () => {
111
124
  ] as const) {
112
125
  const at = { clientX: 100 + d * c.dx, clientY: 50 + d * c.dy };
113
126
  act(() =>
114
- handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 3, ...at })),
127
+ handle.dispatchEvent(
128
+ new PointerEvent(type, {
129
+ bubbles: true,
130
+ buttons: type === "pointerup" ? 0 : 1,
131
+ pointerId: 3,
132
+ ...at,
133
+ }),
134
+ ),
115
135
  );
116
136
  }
117
137
  expect(onStyleCommit).not.toHaveBeenCalled();
@@ -121,16 +141,23 @@ describe("DomEditCropHandles clip lift", () => {
121
141
  const onStyleCommit = vi.fn();
122
142
  render(makeEl("a", "inset(10px)"), onStyleCommit);
123
143
  const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
124
- act(() => {
125
- for (const [type, clientX] of [
126
- ["pointerdown", 100],
127
- ["pointermove", 90],
128
- ["pointermove", 80],
129
- ["pointerup", 80],
130
- ] as const) {
131
- handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 4, clientX }));
132
- }
133
- });
144
+ dragCropRight(handle, 4, [
145
+ [100, 1],
146
+ [90, 1],
147
+ [80, 1],
148
+ ]);
149
+ expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
150
+ });
151
+
152
+ it("commits where the pointer let go, not at a buttonless move back at the press point", () => {
153
+ const onStyleCommit = vi.fn();
154
+ render(makeEl("a", "inset(10px)"), onStyleCommit);
155
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
156
+ dragCropRight(handle, 6, [
157
+ [100, 1],
158
+ [80, 1],
159
+ [100, 0],
160
+ ]);
134
161
  expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
135
162
  });
136
163
 
@@ -138,16 +165,11 @@ describe("DomEditCropHandles clip lift", () => {
138
165
  const a = makeEl("a", "");
139
166
  const { root } = render(a, (property, value) => void a.style.setProperty(property, value));
140
167
  const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
141
- act(() => {
142
- for (const [type, clientX] of [
143
- ["pointerdown", 100],
144
- ["pointermove", 90],
145
- ["pointermove", 80],
146
- ["pointerup", 80],
147
- ] as const) {
148
- handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 5, clientX }));
149
- }
150
- });
168
+ dragCropRight(handle, 5, [
169
+ [100, 1],
170
+ [90, 1],
171
+ [80, 1],
172
+ ]);
151
173
  await act(async () => undefined);
152
174
  act(() => root.unmount());
153
175
  expect(a.style.getPropertyValue("clip-path")).toBe("inset(0px 20px 0px 0px)");
@@ -163,7 +185,14 @@ describe("DomEditCropHandles clip lift", () => {
163
185
  const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
164
186
  const press = (type: string, clientX: number) =>
165
187
  act(() =>
166
- handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientX })),
188
+ handle.dispatchEvent(
189
+ new PointerEvent(type, {
190
+ bubbles: true,
191
+ buttons: type === "pointerup" ? 0 : 1,
192
+ pointerId: 1,
193
+ clientX,
194
+ }),
195
+ ),
167
196
  );
168
197
  press("pointerdown", 100);
169
198
  press("pointermove", 80);
@@ -17,6 +17,7 @@ import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelp
17
17
  import { readCropFollowingResize } from "./cropResize";
18
18
  import { isCropBarTarget, useCropPresetBarStore } from "./cropPresetStore";
19
19
  import { CropPresetBar } from "./CropPresetBar";
20
+ import { movesGesture } from "./domEditOverlayGestures";
20
21
 
21
22
  interface CropGestureState {
22
23
  edge: CropEdge | "move";
@@ -188,7 +189,7 @@ export function DomEditCropHandles({
188
189
 
189
190
  const updateCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
190
191
  const gesture = gestureRef.current;
191
- if (!gesture || gesture.pointerId !== event.pointerId) return;
192
+ if (!gesture || !movesGesture(gesture, event)) return;
192
193
  event.preventDefault();
193
194
  event.stopPropagation();
194
195
  const local = rotateDeltaIntoFrame(
@@ -0,0 +1,150 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { cleanupMounted, trackedRoot } from "../ui/mountHost.testHelpers";
6
+ import { CARET_PX, InlineTextCaret } from "./InlineTextCaret";
7
+ import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ // Unmount before clearing the body: the caret is portaled there, so clearing first orphans React's node.
13
+ cleanupMounted();
14
+ document.body.innerHTML = "";
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ /** A frame showing the composition at a quarter of its width, from 100,50 on Studio's screen. */
19
+ function fakeFrame() {
20
+ const iframe = document.body.appendChild(document.createElement("iframe"));
21
+ iframe.getBoundingClientRect = () =>
22
+ ({ left: 100, top: 50, width: window.innerWidth / 4 }) as DOMRect;
23
+ return iframe;
24
+ }
25
+
26
+ /** A text open for editing in a fake frame shown at a quarter of the composition's width, its caret after "Ti". */
27
+ function scene() {
28
+ document.body.innerHTML = `<h1 contenteditable="true" style="color: rgb(250, 250, 250)">Title</h1>`;
29
+ const element = document.body.firstElementChild as HTMLElement;
30
+ const iframe = fakeFrame();
31
+ vi.spyOn(Range.prototype, "getClientRects").mockReturnValue([
32
+ { left: 400, top: 200, height: 120 },
33
+ ] as unknown as DOMRectList);
34
+ element.focus();
35
+ const text = element.firstChild as Text;
36
+ window.getSelection()!.collapse(text, 2);
37
+ const session: InlineTextEditSession = {
38
+ element,
39
+ original: "Title",
40
+ outline: "",
41
+ outlineOffset: "",
42
+ };
43
+ return { element, iframe, session, text };
44
+ }
45
+
46
+ function render(session: InlineTextEditSession | null, iframe: HTMLIFrameElement | null) {
47
+ const root = trackedRoot(document.body.appendChild(document.createElement("div")));
48
+ act(() => root.render(<InlineTextCaret session={session} iframe={iframe} />));
49
+ return { root, caret: () => document.querySelector<HTMLElement>("[data-inline-text-caret]") };
50
+ }
51
+
52
+ const fire = (target: EventTarget, type: string) =>
53
+ act(() => void target.dispatchEvent(new Event(type)));
54
+
55
+ describe("InlineTextCaret", () => {
56
+ it("draws a 2 px caret on Studio's screen where the composition's caret stands, in the text's colour", () => {
57
+ const { iframe, session } = scene();
58
+ const caret = render(session, iframe).caret();
59
+ expect(caret).not.toBeNull();
60
+ expect(caret!.style.left).toBe(`${100 + 400 / 4 - CARET_PX / 2}px`);
61
+ expect(caret!.style.top).toBe(`${50 + 200 / 4}px`);
62
+ expect(caret!.style.height).toBe(`${120 / 4}px`);
63
+ expect(caret!.style.width).toBe(`${CARET_PX}px`);
64
+ expect(caret!.style.background).toBe("rgb(250, 250, 250)");
65
+ });
66
+
67
+ it("follows a pan or zoom of the preview, which moves the iframe with no event", async () => {
68
+ const { iframe, session } = scene();
69
+ const caret = render(session, iframe).caret;
70
+ iframe.getBoundingClientRect = () =>
71
+ ({ left: 140, top: 50, width: window.innerWidth / 4 }) as DOMRect;
72
+ await act(() => new Promise((done) => requestAnimationFrame(() => done(undefined))));
73
+ expect(caret()!.style.left).toBe(`${140 + 400 / 4 - CARET_PX / 2}px`);
74
+ });
75
+
76
+ it("after End at a soft wrap the caret stands at the earlier line's end; after Home, at the next line's start", () => {
77
+ const { element, iframe, session } = scene();
78
+ // A collapsed range at a wrap point has the next line's box; the character before it ends the earlier line.
79
+ vi.spyOn(Range.prototype, "getClientRects").mockImplementation(function (this: Range) {
80
+ return (this.collapsed
81
+ ? [{ left: 0, top: 320, height: 120 }]
82
+ : [{ left: 300, right: 400, top: 200, height: 120 }]) as unknown as DOMRectList;
83
+ });
84
+ const caret = render(session, iframe).caret;
85
+ const press = (key: string) => {
86
+ act(() => void element.dispatchEvent(new KeyboardEvent("keydown", { key })));
87
+ fire(document, "selectionchange");
88
+ };
89
+ press("End");
90
+ expect([caret()!.style.left, caret()!.style.top]).toEqual([
91
+ `${100 + 400 / 4 - CARET_PX / 2}px`,
92
+ `${50 + 200 / 4}px`,
93
+ ]);
94
+ press("Shift");
95
+ expect(caret()!.style.top, "a modifier alone keeps the side").toBe(`${50 + 200 / 4}px`);
96
+ press("Home");
97
+ expect([caret()!.style.left, caret()!.style.top]).toEqual([
98
+ `${100 - CARET_PX / 2}px`,
99
+ `${50 + 320 / 4}px`,
100
+ ]);
101
+ });
102
+
103
+ it("shows no caret while a range is selected, and again once the selection collapses", () => {
104
+ const { iframe, session, text } = scene();
105
+ const { caret } = render(session, iframe);
106
+ act(() => window.getSelection()!.setBaseAndExtent(text, 0, text, 3));
107
+ fire(document, "selectionchange");
108
+ expect(caret()).toBeNull();
109
+ act(() => window.getSelection()!.collapse(text, 3));
110
+ fire(document, "selectionchange");
111
+ expect(caret()).not.toBeNull();
112
+ });
113
+
114
+ it("shows no caret while an input method composes, nor once the text loses focus", () => {
115
+ const { element, iframe, session } = scene();
116
+ const { caret } = render(session, iframe);
117
+ fire(element, "compositionstart");
118
+ expect(caret()).toBeNull();
119
+ fire(element, "compositionend");
120
+ expect(caret()).not.toBeNull();
121
+ act(() => element.blur());
122
+ fire(element, "blur");
123
+ expect(caret()).toBeNull();
124
+ });
125
+
126
+ it("hides the browser's own caret while it draws one, and gives it back after", () => {
127
+ const { element, iframe, session } = scene();
128
+ element.style.caretColor = "red";
129
+ const { root } = render(session, iframe);
130
+ expect(element.style.caretColor).toBe("transparent");
131
+ act(() => root.render(<InlineTextCaret session={null} iframe={iframe} />));
132
+ expect(element.style.caretColor).toBe("red");
133
+ });
134
+
135
+ it("stands at the end of a bold word when the caret is past it, where no box of its own is drawn", () => {
136
+ document.body.innerHTML = `<h1 contenteditable="true">Go <strong>bold</strong></h1>`;
137
+ const element = document.body.firstElementChild as HTMLElement;
138
+ const iframe = fakeFrame();
139
+ vi.spyOn(Range.prototype, "getClientRects").mockImplementation(function (this: Range) {
140
+ const rects = this.collapsed ? [] : [{ left: 300, right: 500, top: 200, height: 120 }];
141
+ return rects as unknown as DOMRectList;
142
+ });
143
+ element.focus();
144
+ window.getSelection()!.collapse(element, element.childNodes.length);
145
+ const session = { element, original: "", outline: "", outlineOffset: "" };
146
+ const caret = render(session, iframe).caret();
147
+ expect(caret!.style.left).toBe(`${100 + 500 / 4 - CARET_PX / 2}px`);
148
+ expect(caret!.style.top).toBe(`${50 + 200 / 4}px`);
149
+ });
150
+ });