@hyperframes/studio 0.8.105 → 0.8.107

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/index-CM1xA_jD.js +394 -0
  3. package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
  7. package/dist/chunk-4OZC426H.js.map +1 -0
  8. package/dist/index.d.ts +381 -14
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +9974 -9045
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/App.tsx +5 -3
  16. package/src/agentToolsExport.test.ts +8 -0
  17. package/src/captions/keyboard.test.ts +12 -0
  18. package/src/captions/keyboard.ts +2 -1
  19. package/src/components/StudioHeader.dom.test.tsx +31 -9
  20. package/src/components/StudioHeader.tsx +4 -1
  21. package/src/components/StudioRightPanels.tsx +2 -0
  22. package/src/components/ThemeToggle.tsx +99 -0
  23. package/src/components/TimelineToolbar.test.tsx +27 -0
  24. package/src/components/TimelineToolbar.tsx +2 -5
  25. package/src/components/editor/DomEditOverlay.tsx +31 -5
  26. package/src/components/editor/PropertyPanel.tsx +1 -14
  27. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  28. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  29. package/src/components/editor/SnapToolbar.tsx +3 -4
  30. package/src/components/editor/SourceEditor.tsx +2 -6
  31. package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
  32. package/src/components/editor/fontAssets.test.ts +12 -22
  33. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
  34. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  35. package/src/components/editor/manualEdits.test.ts +4 -3
  36. package/src/components/editor/manualEditsDom.ts +58 -2
  37. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  38. package/src/components/editor/manualOffsetDrag.ts +4 -1
  39. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  40. package/src/components/editor/marqueeCommit.ts +6 -1
  41. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  42. package/src/components/editor/mediaAudioEdits.ts +253 -0
  43. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  44. package/src/components/editor/overlayFrameLoop.ts +21 -0
  45. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  46. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  47. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  48. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  49. package/src/components/editor/propertyPanelFont.tsx +69 -46
  50. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  51. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  52. package/src/components/editor/propertyPanelTypes.ts +2 -0
  53. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  54. package/src/components/editor/studioCodeTheme.ts +99 -0
  55. package/src/components/editor/useDomEditNudge.test.tsx +165 -2
  56. package/src/components/editor/useDomEditNudge.ts +43 -25
  57. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  58. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  59. package/src/components/editor/useMotionPathData.ts +5 -1
  60. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  61. package/src/components/panels/SlideshowPanel.tsx +12 -7
  62. package/src/contexts/DomEditContext.test.tsx +62 -0
  63. package/src/contexts/DomEditContext.tsx +94 -77
  64. package/src/hooks/appHotkeysDispatch.ts +2 -1
  65. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  66. package/src/hooks/domEditCommitTypes.ts +10 -0
  67. package/src/hooks/domEditPersistFailure.ts +12 -1
  68. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  69. package/src/hooks/domStyleCommit.ts +2 -8
  70. package/src/hooks/gsapDragCommit.ts +3 -8
  71. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  72. package/src/hooks/gsapEditOutcome.ts +2 -1
  73. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  74. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  75. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  76. package/src/hooks/gsapResizeIntercept.ts +6 -2
  77. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  78. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  79. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  80. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  81. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  82. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  83. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  84. package/src/hooks/useAppHotkeys.test.ts +49 -0
  85. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  86. package/src/hooks/useAppHotkeys.ts +4 -5
  87. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  88. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  89. package/src/hooks/useClipboard.source.test.ts +99 -19
  90. package/src/hooks/useClipboard.ts +145 -124
  91. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  92. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  93. package/src/hooks/useDomEditCommits.ts +2 -0
  94. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  95. package/src/hooks/useDomEditPersist.ts +28 -13
  96. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  97. package/src/hooks/useDomEditSession.test.tsx +76 -72
  98. package/src/hooks/useDomEditSession.ts +9 -46
  99. package/src/hooks/useDomEditTextCommits.ts +4 -8
  100. package/src/hooks/useDomGeometryCommits.ts +11 -2
  101. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  102. package/src/hooks/useGsapAwareEditing.ts +6 -6
  103. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  104. package/src/hooks/useGsapScriptCommits.ts +5 -4
  105. package/src/hooks/useGsapTweenCache.ts +1 -2
  106. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  107. package/src/hooks/useInlineTextEdit.ts +21 -5
  108. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  109. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  110. package/src/hooks/useLivePreviewIframe.ts +18 -0
  111. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  112. package/src/hooks/usePreviewPersistence.ts +8 -5
  113. package/src/hooks/useRenderClipContent.ts +3 -3
  114. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  115. package/src/index.ts +9 -0
  116. package/src/livePreviewIframeExport.test.tsx +33 -0
  117. package/src/main.tsx +4 -1
  118. package/src/player/components/AudioWaveform.test.tsx +36 -15
  119. package/src/player/components/AudioWaveform.tsx +24 -7
  120. package/src/player/components/Timeline.test.ts +0 -21
  121. package/src/player/components/Timeline.tsx +0 -1
  122. package/src/player/components/TimelineClip.test.tsx +38 -0
  123. package/src/player/components/TimelineClip.tsx +14 -6
  124. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  125. package/src/player/components/TimelineClipFades.tsx +368 -137
  126. package/src/player/components/timelineLayout.ts +0 -28
  127. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  128. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  129. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  130. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  131. package/src/player/lib/automationStoreSync.test.ts +18 -1
  132. package/src/player/lib/automationStoreSync.ts +29 -17
  133. package/src/player/lib/playbackShortcuts.ts +3 -7
  134. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  135. package/src/player/lib/timelineElementHelpers.ts +16 -2
  136. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  137. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  138. package/src/player/sceneSwap.ts +18 -0
  139. package/src/styles/components.css +26 -0
  140. package/src/styles/studio.css +6 -0
  141. package/src/styles/theme.css +10 -0
  142. package/src/utils/clipboardPayload.ts +10 -1
  143. package/src/utils/gsapSoftReload.ts +3 -2
  144. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  145. package/src/utils/gsapUndoRestore.test.ts +145 -1
  146. package/src/utils/gsapUndoRestore.ts +70 -21
  147. package/src/utils/sourcePatcher.ts +1 -1
  148. package/src/utils/studioFontHelpers.ts +2 -23
  149. package/src/utils/studioHelpers.test.ts +30 -0
  150. package/src/utils/studioHelpers.ts +18 -4
  151. package/src/utils/studioPendingEdits.test.ts +63 -0
  152. package/src/utils/studioPendingEdits.ts +37 -7
  153. package/src/utils/studioTheme.test.tsx +141 -0
  154. package/src/utils/studioTheme.ts +34 -0
  155. package/src/utils/studioUiPreferences.ts +3 -0
  156. package/src/utils/typingTarget.test.ts +21 -1
  157. package/src/utils/typingTarget.ts +8 -0
  158. package/src/webmcp/useStudioAgentTools.ts +3 -1
  159. package/dist/assets/index-Cre9X4RS.css +0 -1
  160. package/dist/assets/index-DEDhPmPD.js +0 -399
  161. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -1,57 +1,151 @@
1
- import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
1
+ import {
2
+ createContext,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type KeyboardEvent,
10
+ type PointerEvent,
11
+ } from "react";
2
12
  import {
3
13
  HF_AUDIO_FADE_IN_ATTR,
4
14
  HF_AUDIO_FADE_OUT_ATTR,
5
15
  clampFadesToDuration,
6
16
  formatFadeSeconds,
17
+ type AudioFades,
7
18
  } from "@hyperframes/core/audio-fade";
8
- import type { TimelineElement } from "../store/playerStore";
19
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
9
20
  import { Tooltip } from "../../components/ui";
10
21
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
11
22
  import { releasedOutsideWindow } from "./timelinePointerRelease";
23
+ import {
24
+ collectTimelineSnapTargets,
25
+ snapTimelineTime,
26
+ TIMELINE_SNAP_PX,
27
+ type TimelineSnapTarget,
28
+ type TimelineSnapType,
29
+ } from "./timelineSnapping";
12
30
 
13
31
  type FadeEdge = "in" | "out";
32
+ type FadeDraft = { edge: FadeEdge; seconds: number } | null;
14
33
 
15
- const HANDLE_SIZE = 10;
34
+ const TAB_WIDTH = 4;
35
+ const TAB_HEIGHT = 15;
16
36
  const HANDLE_HIT = 24;
37
+ const TAB_INSET = 7;
38
+ const TAB_CENTER_IN_HIT = 8;
17
39
  const HANDLE_Z_ABOVE_CLIP_CONTENT = 30;
18
40
  const SUPPRESS_CLIP_NATIVE_TITLE = "";
19
41
  /** Pixels of pointer travel before a press on the handle counts as a drag. */
20
42
  const DRAG_THRESHOLD_PX = 2;
43
+ const DEFAULT_FADE_SECONDS = 0.5;
44
+ const SNAP_LABEL: Record<TimelineSnapType, string> = {
45
+ playhead: "playhead",
46
+ "clip-edge": "clip edge",
47
+ beat: "beat",
48
+ };
49
+
50
+ export type ClipFadeShape = AudioFades & { duration: number };
51
+ export const ClipFadesContext = createContext<ClipFadeShape | null>(null);
52
+
53
+ // Owned by TimelineClip so the handles and the waveform share the value under the pointer;
54
+ // the draft also bridges release until the save lands in the store.
55
+ export function useClipFadeDraft(el: TimelineElement) {
56
+ const [draft, setDraft] = useState<FadeDraft>(null);
57
+ const authoredIn = el.fadeIn ?? 0;
58
+ const authoredOut = el.fadeOut ?? 0;
59
+ const { fadeIn, fadeOut } = clampFadesToDuration(
60
+ {
61
+ fadeIn: draft?.edge === "in" ? draft.seconds : authoredIn,
62
+ fadeOut: draft?.edge === "out" ? draft.seconds : authoredOut,
63
+ },
64
+ el.duration,
65
+ );
66
+ const shape = useMemo(
67
+ () => ({ fadeIn, fadeOut, duration: el.duration }),
68
+ [fadeIn, fadeOut, el.duration],
69
+ );
70
+ return { draft, setDraft, shape };
71
+ }
21
72
 
22
73
  interface TimelineClipFadesProps {
23
74
  el: TimelineElement;
24
75
  pps: number;
25
76
  widthPx: number;
26
- /** Handles show on hover/selection; the shaded ramps show whenever a fade is set. */
77
+ /** Handles show on hover/selection; the ramps show whenever a fade is set. */
27
78
  showHandles: boolean;
79
+ focusable?: boolean;
80
+ /** Audio clips draw the fade in their waveform; others get the shaded wedge. */
81
+ hasWaveform?: boolean;
82
+ fade: ReturnType<typeof useClipFadeDraft>;
83
+ }
84
+
85
+ type KeyedFade = (current: number, step: number, limit: number) => number | null;
86
+ const grow: KeyedFade = (current, step) => current + step;
87
+ const shrink: KeyedFade = (current, step) => current - step;
88
+ const clear: KeyedFade = () => 0;
89
+ const FADE_KEYS: Record<string, KeyedFade> = {
90
+ ArrowRight: grow,
91
+ ArrowUp: grow,
92
+ ArrowLeft: shrink,
93
+ ArrowDown: shrink,
94
+ Home: clear,
95
+ Delete: clear,
96
+ Backspace: clear,
97
+ End: (_current, _step, limit) => limit,
98
+ Enter: (current) => (current === 0 ? DEFAULT_FADE_SECONDS : null),
99
+ };
100
+
101
+ function keyedFadeSeconds(key: string, shift: boolean, current: number, limit: number) {
102
+ return FADE_KEYS[key]?.(current, shift ? 1 : 0.1, limit) ?? null;
103
+ }
104
+
105
+ function topEdgeY(x: number, widthPx: number, heightPx: number, radiusPx: number): number {
106
+ const r = Math.min(radiusPx, widthPx / 2, heightPx / 2);
107
+ const d = x < r ? r - x : x > widthPx - r ? x - (widthPx - r) : 0;
108
+ return d > 0 ? r - Math.sqrt(Math.max(0, r * r - d * d)) : 0;
28
109
  }
29
110
 
30
- /** Corner dots that drag `data-fade-in` / `data-fade-out`; the wedge is the faded region. */
31
- export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineClipFadesProps) {
111
+ /** Slim tabs at each fade's end that drag `data-fade-in` / `data-fade-out`. */
112
+ // fallow-ignore-next-line complexity
113
+ export function TimelineClipFades({
114
+ el,
115
+ pps,
116
+ widthPx,
117
+ showHandles,
118
+ focusable = false,
119
+ hasWaveform = false,
120
+ fade,
121
+ }: TimelineClipFadesProps) {
32
122
  const { onSetElementAttributeLive, onSetElementAttributeQuiet, onRevertElementAttributeLive } =
33
123
  useTimelineEditContextOptional();
34
124
  const canEdit = Boolean(onSetElementAttributeLive && onSetElementAttributeQuiet);
35
-
36
- // Draft holds the value under the pointer during a drag; it also bridges
37
- // the gap between release and the store re-reading the file, so the ramp
38
- // does not snap back for a frame.
39
- const [draft, setDraft] = useState<{ edge: FadeEdge; seconds: number } | null>(null);
125
+ const { setDraft } = fade;
126
+ const fades = fade.shape;
40
127
  const [dragging, setDragging] = useState<FadeEdge | null>(null);
128
+ const [focused, setFocused] = useState<FadeEdge | null>(null);
129
+ const [snapType, setSnapType] = useState<TimelineSnapType | null>(null);
41
130
  const authoredIn = el.fadeIn ?? 0;
42
131
  const authoredOut = el.fadeOut ?? 0;
43
- useEffect(() => setDraft(null), [authoredIn, authoredOut]);
44
-
45
- const fades = clampFadesToDuration(
46
- {
47
- fadeIn: draft?.edge === "in" ? draft.seconds : authoredIn,
48
- fadeOut: draft?.edge === "out" ? draft.seconds : authoredOut,
49
- },
50
- el.duration,
51
- );
52
132
  const inPx = Math.min(widthPx, fades.fadeIn * pps);
53
133
  const outPx = Math.min(widthPx, fades.fadeOut * pps);
54
134
 
135
+ const visible =
136
+ fades.fadeIn > 0 || fades.fadeOut > 0 || showHandles || dragging !== null || focused !== null;
137
+ const rootRef = useRef<HTMLDivElement | null>(null);
138
+ const [clipBox, setClipBox] = useState({ height: 0, radius: 0 });
139
+ useLayoutEffect(() => {
140
+ const clip = rootRef.current?.parentElement;
141
+ if (!clip) return;
142
+ const radius = parseFloat(getComputedStyle(clip).borderTopLeftRadius) || 0;
143
+ const height = clip.clientHeight;
144
+ setClipBox((box) =>
145
+ box.height === height && box.radius === radius ? box : { height, radius },
146
+ );
147
+ }, [widthPx, visible]);
148
+
55
149
  const gesture = useRef<{
56
150
  edge: FadeEdge;
57
151
  pointerId: number;
@@ -60,11 +154,39 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
60
154
  otherSeconds: number;
61
155
  moved: boolean;
62
156
  last: number;
157
+ snapTargets: TimelineSnapTarget[];
63
158
  } | null>(null);
64
159
 
65
160
  const attrFor = (edge: FadeEdge) =>
66
161
  edge === "in" ? HF_AUDIO_FADE_IN_ATTR : HF_AUDIO_FADE_OUT_ATTR;
67
162
  const attrText = (seconds: number) => (seconds > 0 ? formatFadeSeconds(seconds) : null);
163
+ const labelFor = (edge: FadeEdge) => (edge === "in" ? "Fade in" : "Fade out");
164
+ const currentSeconds = (edge: FadeEdge) =>
165
+ fade.draft?.edge === edge ? fade.draft.seconds : edge === "in" ? authoredIn : authoredOut;
166
+ const limitFor = (edge: FadeEdge) =>
167
+ Math.max(0, el.duration - currentSeconds(edge === "in" ? "out" : "in"));
168
+
169
+ // When the other fade makes the pair overrun the clip, neither a key nor a drag lowers this one.
170
+ const stepLimit = (current: number, other: number) =>
171
+ Math.max(0, el.duration - other, other > 0 ? current : 0);
172
+ const startSeconds = (edge: FadeEdge) => {
173
+ const current = currentSeconds(edge);
174
+ return Math.min(current, stepLimit(current, currentSeconds(edge === "in" ? "out" : "in")));
175
+ };
176
+ const dragLimit = (g: { otherSeconds: number; originSeconds: number }) =>
177
+ stepLimit(g.originSeconds, g.otherSeconds);
178
+
179
+ /** Moves the fade's end onto a playhead or clip edge within the timeline's snap radius. */
180
+ const snapSeconds = (g: NonNullable<typeof gesture.current>, seconds: number) => {
181
+ const knee = g.edge === "in" ? el.start + seconds : el.start + el.duration - seconds;
182
+ const snapped = snapTimelineTime(knee, g.snapTargets, TIMELINE_SNAP_PX / Math.max(pps, 1e-6));
183
+ if (!snapped.target) return { seconds, type: null };
184
+ const next = g.edge === "in" ? snapped.time - el.start : el.start + el.duration - snapped.time;
185
+ const limit = dragLimit(g);
186
+ return next >= 0 && next <= limit
187
+ ? { seconds: next, type: snapped.target.type }
188
+ : { seconds, type: null };
189
+ };
68
190
 
69
191
  const secondsAt = (clientX: number): number => {
70
192
  const g = gesture.current;
@@ -72,7 +194,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
72
194
  const deltaSeconds = (clientX - g.originClientX) / Math.max(pps, 1e-6);
73
195
  // Fade-in grows to the right, fade-out grows to the left.
74
196
  const raw = g.edge === "in" ? g.originSeconds + deltaSeconds : g.originSeconds - deltaSeconds;
75
- const limit = Math.max(0, el.duration - g.otherSeconds);
197
+ const limit = dragLimit(g);
76
198
  const clamped = Math.min(limit, Math.max(0, raw));
77
199
  return Math.round(clamped * 100) / 100;
78
200
  };
@@ -83,14 +205,24 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
83
205
  e.stopPropagation();
84
206
  e.preventDefault();
85
207
  e.currentTarget.setPointerCapture(e.pointerId);
208
+ flushKeys();
209
+ const store = usePlayerStore.getState();
86
210
  gesture.current = {
87
211
  edge,
88
212
  pointerId: e.pointerId,
89
213
  originClientX: e.clientX,
90
- originSeconds: edge === "in" ? authoredIn : authoredOut,
91
- otherSeconds: edge === "in" ? authoredOut : authoredIn,
214
+ originSeconds: startSeconds(edge),
215
+ otherSeconds: currentSeconds(edge === "in" ? "out" : "in"),
92
216
  moved: false,
93
- last: edge === "in" ? authoredIn : authoredOut,
217
+ last: startSeconds(edge),
218
+ snapTargets: store.timelineSnapEnabled
219
+ ? collectTimelineSnapTargets({
220
+ elements: store.elements,
221
+ playheadTime: store.currentTime,
222
+ beatTimes: [],
223
+ excludeElementKey: el.key ?? el.id,
224
+ }).filter(({ time }) => time > el.start + 1e-3 && time < el.start + el.duration - 1e-3)
225
+ : [],
94
226
  };
95
227
  setDragging(edge);
96
228
  };
@@ -100,7 +232,9 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
100
232
  if (!g || g.pointerId !== e.pointerId) return;
101
233
  if (!g.moved && Math.abs(e.clientX - g.originClientX) < DRAG_THRESHOLD_PX) return;
102
234
  g.moved = true;
103
- const seconds = secondsAt(e.clientX);
235
+ const { seconds: raw, type } = snapSeconds(g, secondsAt(e.clientX));
236
+ const seconds = Math.round(raw * 100) / 100;
237
+ setSnapType(type);
104
238
  if (seconds === g.last) return;
105
239
  g.last = seconds;
106
240
  setDraft({ edge: g.edge, seconds });
@@ -115,6 +249,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
115
249
  if (!g || g.pointerId !== e.pointerId) return null;
116
250
  gesture.current = null;
117
251
  setDragging(null);
252
+ setSnapType(null);
118
253
  if (e.currentTarget.hasPointerCapture(e.pointerId)) {
119
254
  e.currentTarget.releasePointerCapture(e.pointerId);
120
255
  }
@@ -123,35 +258,69 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
123
258
 
124
259
  /** Puts the live document back where the file has it and drops the draft. */
125
260
  const revertGesture = (g: Gesture) => {
126
- if (g.moved) {
127
- onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds));
128
- onRevertElementAttributeLive?.(el, attrFor(g.edge));
129
- }
261
+ if (!g.moved) return;
262
+ onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds));
263
+ onRevertElementAttributeLive?.(el, attrFor(g.edge));
130
264
  setDraft(null);
131
265
  };
132
266
 
267
+ // The save syncs the store before it settles, so its own draft can go then; a newer draft stays.
268
+ const commit = (edge: FadeEdge, seconds: number) => {
269
+ const draft = { edge, seconds };
270
+ setDraft(draft);
271
+ const settle = () => setDraft((current) => (current === draft ? null : current));
272
+ void onSetElementAttributeQuiet?.(el, attrFor(edge), attrText(seconds), labelFor(edge)).then(
273
+ settle,
274
+ settle,
275
+ );
276
+ };
277
+
133
278
  const finish = (e: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
134
279
  const g = endGesture(e);
135
280
  if (!g) return;
136
- if (cancelled || !g.moved || releasedOutsideWindow(e)) return revertGesture(g);
137
- setDraft({ edge: g.edge, seconds: g.last });
138
- void onSetElementAttributeQuiet?.(
139
- el,
140
- attrFor(g.edge),
141
- attrText(g.last),
142
- g.edge === "in" ? "Fade in" : "Fade out",
143
- );
281
+ if (cancelled || !g.moved || g.last === g.originSeconds || releasedOutsideWindow(e)) {
282
+ return revertGesture(g);
283
+ }
284
+ commit(g.edge, g.last);
285
+ };
286
+
287
+ const onHandleDoubleClick = (edge: FadeEdge) => {
288
+ commit(edge, currentSeconds(edge) > 0 ? 0 : Math.min(DEFAULT_FADE_SECONDS, limitFor(edge)));
289
+ };
290
+
291
+ const keyBurst = useRef<{ edge: FadeEdge; seconds: number } | null>(null);
292
+ const flushKeys = () => {
293
+ const burst = keyBurst.current;
294
+ keyBurst.current = null;
295
+ if (burst) commit(burst.edge, burst.seconds);
296
+ };
297
+
298
+ // A held key previews live and saves once on release: one undo step per burst.
299
+ const onHandleKeyDown = (edge: FadeEdge) => (e: KeyboardEvent<HTMLDivElement>) => {
300
+ if (gesture.current || !canEdit) return;
301
+ const current = startSeconds(edge);
302
+ const limit = stepLimit(current, currentSeconds(edge === "in" ? "out" : "in"));
303
+ const next = keyedFadeSeconds(e.key, e.shiftKey, current, limit);
304
+ if (next === null) return;
305
+ e.preventDefault();
306
+ e.stopPropagation();
307
+ const seconds = Math.round(Math.min(limit, Math.max(0, next)) * 100) / 100;
308
+ if (seconds === current) return;
309
+ keyBurst.current = { edge, seconds };
310
+ setDraft({ edge, seconds });
311
+ onSetElementAttributeLive?.(el, attrFor(edge), attrText(seconds));
144
312
  };
145
313
 
146
314
  useEffect(() => {
147
315
  if (dragging === null) return;
148
- const cancelOnWindowEscape = (e: KeyboardEvent) => {
316
+ const cancelOnWindowEscape = (e: globalThis.KeyboardEvent) => {
149
317
  const g = gesture.current;
150
318
  if (e.key !== "Escape" || !g) return;
151
319
  e.preventDefault();
152
320
  e.stopPropagation();
153
321
  gesture.current = null;
154
322
  setDragging(null);
323
+ setSnapType(null);
155
324
  revertGesture(g);
156
325
  };
157
326
  window.addEventListener("keydown", cancelOnWindowEscape, { capture: true });
@@ -160,21 +329,43 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
160
329
 
161
330
  const showIn = fades.fadeIn > 0;
162
331
  const showOut = fades.fadeOut > 0;
163
- const handlesVisible = showHandles || dragging !== null;
164
- if (!showIn && !showOut && !handlesVisible) return null;
332
+ const handlesVisible = showHandles || dragging !== null || focused !== null;
333
+ if (!visible) return null;
165
334
 
166
335
  const hitWidth = Math.min(HANDLE_HIT, widthPx / 2);
167
- const handleStyle = (edge: FadeEdge): CSSProperties => ({
336
+ const tabX = (edge: FadeEdge) => {
337
+ const knee = edge === "in" ? inPx : widthPx - outPx;
338
+ const inset = Math.min(TAB_INSET, widthPx / 2);
339
+ return Math.min(widthPx - inset, Math.max(inset, knee));
340
+ };
341
+ const boxLeft = (x: number) => Math.min(widthPx - hitWidth, Math.max(0, x - hitWidth / 2));
342
+ const [inX, outX] = [tabX("in"), tabX("out")];
343
+ const mid = (inX + outX) / 2;
344
+ // A handle with no 0.01 s step to move is not drawn, unless in use: its twin owns the spot.
345
+ const drawn = (edge: FadeEdge) =>
346
+ currentSeconds(edge) > 0 ||
347
+ Math.round(limitFor(edge) * 100) >= 1 ||
348
+ dragging === edge ||
349
+ focused === edge;
350
+ const overlap = drawn("in") && drawn("out") && boxLeft(inX) + hitWidth > boxLeft(outX);
351
+ const handleGeometry = (edge: FadeEdge) => {
352
+ const x = edge === "in" ? inX : outX;
353
+ // Boxes that would overlap meet at the midpoint between the two tabs.
354
+ const [left, right] = !overlap
355
+ ? [boxLeft(x), boxLeft(x) + hitWidth]
356
+ : edge === "in"
357
+ ? [Math.max(0, mid - hitWidth), mid]
358
+ : [mid, Math.min(widthPx, mid + hitWidth)];
359
+ const edgeY = topEdgeY(x, widthPx, clipBox.height, clipBox.radius);
360
+ return { left, width: right - left, tabLeft: x - left, top: edgeY + 1 - TAB_CENTER_IN_HIT };
361
+ };
362
+ const handleStyle = (geometry: { left: number; top: number; width: number }): CSSProperties => ({
168
363
  position: "absolute",
169
- top: -(HANDLE_HIT - HANDLE_SIZE) / 2 + 1,
170
- left: edge === "in" ? 0 : widthPx - hitWidth,
171
- width: hitWidth,
364
+ top: geometry.top,
365
+ left: geometry.left,
366
+ width: geometry.width,
172
367
  height: HANDLE_HIT,
173
- display: "flex",
174
- alignItems: "center",
175
- justifyContent: "center",
176
368
  cursor: "ew-resize",
177
- zIndex: HANDLE_Z_ABOVE_CLIP_CONTENT,
178
369
  opacity: handlesVisible ? 1 : 0,
179
370
  pointerEvents: handlesVisible && canEdit ? "auto" : "none",
180
371
  touchAction: "none",
@@ -182,7 +373,16 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
182
373
  });
183
374
 
184
375
  return (
185
- <>
376
+ <div
377
+ ref={rootRef}
378
+ style={{
379
+ position: "absolute",
380
+ inset: 0,
381
+ borderRadius: "inherit",
382
+ pointerEvents: "none",
383
+ zIndex: HANDLE_Z_ABOVE_CLIP_CONTENT,
384
+ }}
385
+ >
186
386
  {(showIn || showOut) && (
187
387
  <svg
188
388
  aria-hidden="true"
@@ -193,119 +393,153 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
193
393
  width: "100%",
194
394
  height: "100%",
195
395
  pointerEvents: "none",
196
- zIndex: 5,
197
396
  overflow: "hidden",
198
397
  borderRadius: "inherit",
199
398
  }}
200
399
  viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
201
400
  preserveAspectRatio="none"
202
401
  >
203
- {/* Keep the #4250 wedge geometry, but let the real waveform read through it. */}
204
402
  {showIn && (
205
- <>
206
- <polygon
207
- data-testid="clip-fade-in"
208
- points={`0,0 ${inPx},0 0,100`}
209
- fill="var(--timeline-fade-shade)"
210
- fillOpacity={0.35}
211
- />
212
- <line
213
- x1={0}
214
- y1={100}
215
- x2={inPx}
216
- y2={0}
217
- stroke="var(--clip-handle)"
218
- strokeOpacity={0.9}
219
- strokeWidth={1.25}
220
- vectorEffect="non-scaling-stroke"
221
- />
222
- </>
403
+ <FadeRamp
404
+ testId="clip-fade-in"
405
+ wedge={hasWaveform ? null : `0,0 ${inPx},0 0,100`}
406
+ line={[0, 100, inPx, 0]}
407
+ />
223
408
  )}
224
409
  {showOut && (
225
- <>
226
- <polygon
227
- data-testid="clip-fade-out"
228
- points={`${widthPx - outPx},0 ${widthPx},0 ${widthPx},100`}
229
- fill="var(--timeline-fade-shade)"
230
- fillOpacity={0.35}
231
- />
232
- <line
233
- x1={widthPx - outPx}
234
- y1={0}
235
- x2={widthPx}
236
- y2={100}
237
- stroke="var(--clip-handle)"
238
- strokeOpacity={0.9}
239
- strokeWidth={1.25}
240
- vectorEffect="non-scaling-stroke"
241
- />
242
- </>
410
+ <FadeRamp
411
+ testId="clip-fade-out"
412
+ wedge={hasWaveform ? null : `${widthPx - outPx},0 ${widthPx},0 ${widthPx},100`}
413
+ line={[widthPx - outPx, 0, widthPx, 100]}
414
+ />
243
415
  )}
244
416
  </svg>
245
417
  )}
246
- {canEdit && (
247
- <>
248
- <FadeHandle
249
- direction="in"
250
- value={fades.fadeIn}
251
- max={Math.max(0, el.duration - fades.fadeOut)}
252
- style={handleStyle("in")}
253
- dragging={dragging === "in"}
254
- onPointerDown={onHandlePointerDown("in")}
255
- onPointerMove={onHandlePointerMove}
256
- onPointerUp={(e) => finish(e, false)}
257
- onPointerCancel={(e) => finish(e, true)}
258
- />
259
- <FadeHandle
260
- direction="out"
261
- value={fades.fadeOut}
262
- max={Math.max(0, el.duration - fades.fadeIn)}
263
- style={handleStyle("out")}
264
- dragging={dragging === "out"}
265
- onPointerDown={onHandlePointerDown("out")}
266
- onPointerMove={onHandlePointerMove}
267
- onPointerUp={(e) => finish(e, false)}
268
- onPointerCancel={(e) => finish(e, true)}
269
- />
270
- </>
418
+ {canEdit &&
419
+ (["in", "out"] as const).filter(drawn).map((edge) => {
420
+ const geometry = handleGeometry(edge);
421
+ return (
422
+ <FadeHandle
423
+ key={edge}
424
+ direction={edge}
425
+ clipName={el.label || el.id}
426
+ value={edge === "in" ? fades.fadeIn : fades.fadeOut}
427
+ max={Math.max(0, el.duration - (edge === "in" ? fades.fadeOut : fades.fadeIn))}
428
+ snapLabel={dragging === edge && snapType ? SNAP_LABEL[snapType] : null}
429
+ style={handleStyle(geometry)}
430
+ tabLeft={geometry.tabLeft}
431
+ tabTop={TAB_CENTER_IN_HIT - TAB_HEIGHT / 2}
432
+ focusable={focusable}
433
+ dragging={dragging === edge}
434
+ onPointerDown={onHandlePointerDown(edge)}
435
+ onPointerMove={onHandlePointerMove}
436
+ onPointerUp={(e) => finish(e, false)}
437
+ onPointerCancel={(e) => finish(e, true)}
438
+ onDoubleClick={() => onHandleDoubleClick(edge)}
439
+ onKeyDown={onHandleKeyDown(edge)}
440
+ onKeyUp={flushKeys}
441
+ onFocusChange={(on) => {
442
+ if (!on) flushKeys();
443
+ setFocused(on ? edge : null);
444
+ }}
445
+ />
446
+ );
447
+ })}
448
+ </div>
449
+ );
450
+ }
451
+
452
+ /** A 1 px dashed gain line; clips without a waveform also shade the gain they lose. */
453
+ function FadeRamp({
454
+ testId,
455
+ wedge,
456
+ line,
457
+ }: {
458
+ testId: string;
459
+ wedge: string | null;
460
+ line: [number, number, number, number];
461
+ }) {
462
+ const [x1, y1, x2, y2] = line;
463
+ return (
464
+ <>
465
+ {wedge && (
466
+ <polygon
467
+ data-testid={testId}
468
+ points={wedge}
469
+ fill="var(--timeline-fade-shade)"
470
+ fillOpacity={0.35}
471
+ />
271
472
  )}
473
+ <line
474
+ data-testid={wedge ? undefined : testId}
475
+ x1={x1}
476
+ y1={y1}
477
+ x2={x2}
478
+ y2={y2}
479
+ stroke="var(--clip-handle)"
480
+ strokeOpacity={0.55}
481
+ strokeWidth={1}
482
+ strokeDasharray="2 3"
483
+ vectorEffect="non-scaling-stroke"
484
+ />
272
485
  </>
273
486
  );
274
487
  }
275
488
 
276
489
  function FadeHandle({
277
490
  direction,
491
+ clipName,
278
492
  value,
279
493
  max,
494
+ snapLabel,
280
495
  style,
496
+ tabLeft,
497
+ tabTop,
498
+ focusable,
281
499
  dragging,
282
500
  onPointerDown,
283
501
  onPointerMove,
284
502
  onPointerUp,
285
503
  onPointerCancel,
504
+ onDoubleClick,
505
+ onKeyDown,
506
+ onKeyUp,
507
+ onFocusChange,
286
508
  }: {
287
509
  direction: "in" | "out";
510
+ clipName: string;
288
511
  value: number;
289
512
  max: number;
513
+ snapLabel: string | null;
290
514
  style: CSSProperties;
515
+ tabLeft: number;
516
+ tabTop: number;
517
+ focusable: boolean;
291
518
  dragging: boolean;
292
519
  onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
293
520
  onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
294
521
  onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
295
522
  onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
523
+ onDoubleClick: () => void;
524
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
525
+ onKeyUp: () => void;
526
+ onFocusChange: (focused: boolean) => void;
296
527
  }) {
297
528
  const label = direction === "in" ? "Fade in" : "Fade out";
529
+ const text = `${label} ${formatFadeSeconds(value)} s`;
298
530
  return (
299
- <Tooltip label={`${label} ${formatFadeSeconds(value)} s`}>
531
+ <Tooltip label={snapLabel ? `${text}, snapped to ${snapLabel}` : text}>
300
532
  <div
301
533
  role="slider"
302
- tabIndex={-1}
303
- aria-label={label}
534
+ tabIndex={focusable ? 0 : -1}
535
+ aria-label={`${label}, ${clipName}`}
304
536
  aria-valuemin={0}
305
537
  aria-valuemax={max}
306
538
  aria-valuenow={value}
307
539
  aria-valuetext={`${formatFadeSeconds(value)}s`}
308
540
  data-testid={`clip-fade-handle-${direction}`}
541
+ data-dragging={dragging ? "" : undefined}
542
+ className="timeline-fade-handle"
309
543
  title={SUPPRESS_CLIP_NATIVE_TITLE}
310
544
  style={style}
311
545
  onPointerDown={onPointerDown}
@@ -313,30 +547,27 @@ function FadeHandle({
313
547
  onPointerUp={onPointerUp}
314
548
  onPointerCancel={onPointerCancel}
315
549
  onClick={(e) => e.stopPropagation()}
316
- onDoubleClick={(e) => e.stopPropagation()}
550
+ onDoubleClick={(e) => {
551
+ e.stopPropagation();
552
+ onDoubleClick();
553
+ }}
554
+ onKeyDown={onKeyDown}
555
+ onKeyUp={onKeyUp}
556
+ onFocus={() => onFocusChange(true)}
557
+ onBlur={() => onFocusChange(false)}
317
558
  >
318
- <FadeDot active={dragging} />
559
+ <span
560
+ aria-hidden="true"
561
+ className="timeline-fade-tab"
562
+ style={{
563
+ left: tabLeft - TAB_WIDTH / 2,
564
+ top: tabTop,
565
+ width: TAB_WIDTH,
566
+ height: TAB_HEIGHT,
567
+ pointerEvents: "none",
568
+ }}
569
+ />
319
570
  </div>
320
571
  </Tooltip>
321
572
  );
322
573
  }
323
-
324
- function FadeDot({ active }: { active: boolean }) {
325
- return (
326
- <span
327
- aria-hidden="true"
328
- style={{
329
- display: "block",
330
- flexShrink: 0,
331
- pointerEvents: "none",
332
- width: HANDLE_SIZE,
333
- height: HANDLE_SIZE,
334
- borderRadius: "50%",
335
- background: "var(--timeline-fade-dot)",
336
- boxShadow: "0 0 0 1.5px var(--timeline-fade-dot-ring)",
337
- transform: active ? "scale(1.15)" : undefined,
338
- transition: "transform 80ms ease-out",
339
- }}
340
- />
341
- );
342
- }