@@ -2,12 +2,14 @@ import { useCallback, useRef, useState } from "react";
22import type { PointerEvent as ReactPointerEvent } from "react" ;
33import {
44 clampClipFades ,
5+ fadeSampler ,
56 fadeWedgePath ,
67 MIN_FADE_SECONDS ,
78 type ClipFades ,
8- type FadeCurve ,
99 type FadeSampler ,
1010} from "./clipFades" ;
11+ import { bendFromPointer , bendHandlePosition } from "./clipFadeBendDrag" ;
12+ import { FADE_CURVE_LIMIT } from "@hyperframes/core/clip-fade" ;
1113
1214/**
1315 * The fade grips on a clip's top corners, and the wedges they draw.
@@ -26,8 +28,9 @@ export interface TimelineClipFadesProps {
2628 duration : number ;
2729 pixelsPerSecond : number ;
2830 width : number ;
29- curve : FadeCurve ;
30- /** How the level rises across the fade — the medium decides. */
31+ /** How far the fade is bent; 0 is a straight ramp. */
32+ curve : number ;
33+ /** How the level rises across the fade, read back from where it is kept. */
3134 sample : FadeSampler ;
3235 /**
3336 * The clip's accent. The same colour, weight and opacity the automation lane
@@ -40,8 +43,8 @@ export interface TimelineClipFadesProps {
4043 showGrips : boolean ;
4144 /** True when this is not the selected clip, which is what makes it editable. */
4245 readOnly : boolean ;
43- /** Double-clicking a grip steps the fade through its curve shapes . */
44- onCycleCurve ( ) : void ;
46+ /** Dragging the fade line bends it. Live while dragging, once on release . */
47+ onBend ( curve : number , persist : boolean ) : void ;
4548 /** Live during the drag: preview only, never persisted. */
4649 onPreview ( next : ClipFades ) : void ;
4750 /** Once, on release. */
@@ -51,6 +54,16 @@ export interface TimelineClipFadesProps {
5154/** Side of the grip square, in px. Matches the trim handle's visual weight. */
5255const GRIP = 9 ;
5356
57+ /** Diameter of the bend handle. Smaller than a grip: it is a finer adjustment,
58+ * and it sits on the line rather than on a corner you throw the pointer at. */
59+ const BEND = 7 ;
60+
61+ /** What the bend reads as, for a title and for a screen reader. */
62+ function bendLabel ( curve : number ) : string {
63+ if ( Math . abs ( curve ) < 0.02 ) return "Straight" ;
64+ return curve < 0 ? "Starts slow, finishes fast" : "Starts fast, finishes slow" ;
65+ }
66+
5467/**
5568 * Vertical units the wedge is drawn in. A clip's height is set by the row, and
5669 * sometimes by `bottom` rather than a number, so the overlay draws in its own
@@ -71,21 +84,27 @@ export function TimelineClipFades({
7184 readOnly,
7285 onPreview,
7386 onCommit,
74- onCycleCurve ,
87+ onBend ,
7588} : TimelineClipFadesProps ) {
7689 // While dragging, the drawn fades come from here: the committed value only
7790 // catches up once the write lands, and the wedge has to track the pointer.
7891 const [ draft , setDraft ] = useState < ClipFades | null > ( null ) ;
92+ const [ bendDraft , setBendDraft ] = useState < number | null > ( null ) ;
7993 const dragRef = useRef < { edge : "in" | "out" ; originX : number ; from : ClipFades } | null > ( null ) ;
94+ const bendRef = useRef < { top : number ; height : number } | null > ( null ) ;
95+ const hostRef = useRef < HTMLDivElement | null > ( null ) ;
8096 const shown = draft ?? fades ;
97+ // The bend is previewed the same way the lengths are: the committed value
98+ // only catches up once the write lands, and the line has to stay under the
99+ // pointer until then.
100+ const shownCurve = bendDraft ?? curve ;
101+ const shownSample = bendDraft === null ? sample : fadeSampler ( bendDraft ) ;
81102
82103 const onGripDown = useCallback (
83104 ( edge : "in" | "out" , event : ReactPointerEvent ) => {
84105 if ( event . button !== 0 ) return ;
85106 // The grip sits on top of the trim handle and inside the clip body; both
86- // would otherwise start their own gesture from this same press. NOT
87- // preventDefault: that suppresses the compatibility click events, and the
88- // double-click that cycles the curve is one of them.
107+ // would otherwise start their own gesture from this same press.
89108 event . stopPropagation ( ) ;
90109 event . currentTarget . setPointerCapture ( event . pointerId ) ;
91110 dragRef . current = { edge, originX : event . clientX , from : fades } ;
@@ -126,15 +145,108 @@ export function TimelineClipFades({
126145 const from = dragRef . current ?. from ;
127146 dragRef . current = null ;
128147 setDraft ( null ) ;
129- // A press that moved nothing — the first half of a double-click, or a
130- // mis-aimed click — must not write the same fade back to the file .
148+ // A press that moved nothing must not write the same fade back to the
149+ // file: a mis-aimed click is not an edit .
131150 if ( next && from && ( next . fadeIn !== from . fadeIn || next . fadeOut !== from . fadeOut ) ) {
132151 onCommit ( next ) ;
133152 }
134153 } ,
135154 [ onCommit , resolveDrag ] ,
136155 ) ;
137156
157+ /**
158+ * The bend drag. It measures the clip's own pixel box on the way down rather
159+ * than taking a height prop, because a clip row is sometimes sized by
160+ * `bottom` and so has no number to pass down.
161+ */
162+ const onBendDown = useCallback ( ( event : ReactPointerEvent ) => {
163+ if ( event . button !== 0 ) return ;
164+ event . stopPropagation ( ) ;
165+ const host = hostRef . current ;
166+ if ( ! host ) return ;
167+ const box = host . getBoundingClientRect ( ) ;
168+ if ( ! ( box . height > 0 ) ) return ;
169+ event . currentTarget . setPointerCapture ( event . pointerId ) ;
170+ bendRef . current = { top : box . top , height : box . height } ;
171+ } , [ ] ) ;
172+
173+ const resolveBend = useCallback ( ( clientY : number ) : number | null => {
174+ const drag = bendRef . current ;
175+ return drag ? bendFromPointer ( clientY - drag . top , drag . height ) : null ;
176+ } , [ ] ) ;
177+
178+ const onBendMove = useCallback (
179+ ( event : ReactPointerEvent ) => {
180+ const next = resolveBend ( event . clientY ) ;
181+ if ( next === null ) return ;
182+ setBendDraft ( next ) ;
183+ onBend ( next , false ) ;
184+ } ,
185+ [ onBend , resolveBend ] ,
186+ ) ;
187+
188+ const onBendUp = useCallback (
189+ ( event : ReactPointerEvent ) => {
190+ const next = resolveBend ( event . clientY ) ;
191+ bendRef . current = null ;
192+ setBendDraft ( null ) ;
193+ if ( next !== null && next !== curve ) onBend ( next , true ) ;
194+ } ,
195+ [ curve , onBend , resolveBend ] ,
196+ ) ;
197+
198+ /**
199+ * The handle that bends a fade, sitting on the curve's own midpoint so it
200+ * stays under the pointer as the shape changes. Only present once there is a
201+ * fade to bend: with none there is no line to pull, and the corner grips are
202+ * what start one.
203+ */
204+ const bendFor = ( edge : "in" | "out" ) => {
205+ const seconds = edge === "in" ? shown . fadeIn : shown . fadeOut ;
206+ if ( seconds < MIN_FADE_SECONDS ) return null ;
207+ const at = bendHandlePosition ( {
208+ edge,
209+ seconds,
210+ pixelsPerSecond,
211+ width,
212+ height : hostRef . current ?. getBoundingClientRect ( ) . height ?? 0 ,
213+ level : shownSample ( 0.5 ) ,
214+ } ) ;
215+ if ( ! at ) return null ;
216+ const label = bendLabel ( shownCurve ) ;
217+ return (
218+ < div
219+ key = { `bend-${ edge } ` }
220+ role = "slider"
221+ tabIndex = { - 1 }
222+ aria-label = { edge === "in" ? "Fade in curve" : "Fade out curve" }
223+ aria-valuemin = { - FADE_CURVE_LIMIT }
224+ aria-valuemax = { FADE_CURVE_LIMIT }
225+ aria-valuenow = { shownCurve }
226+ aria-valuetext = { label }
227+ data-clip-fade-bend = { edge }
228+ onPointerDown = { onBendDown }
229+ onPointerMove = { onBendMove }
230+ onPointerUp = { onBendUp }
231+ onPointerCancel = { onBendUp }
232+ title = { `Drag up or down to bend this fade. ${ label } ` }
233+ style = { {
234+ position : "absolute" ,
235+ left : at . x - BEND / 2 ,
236+ top : at . y - BEND / 2 ,
237+ width : BEND ,
238+ height : BEND ,
239+ borderRadius : "50%" ,
240+ background : accent ,
241+ boxShadow : "0 0 0 1.5px rgba(0,0,0,0.55)" ,
242+ cursor : "ns-resize" ,
243+ pointerEvents : "auto" ,
244+ zIndex : 6 ,
245+ } }
246+ />
247+ ) ;
248+ } ;
249+
138250 const gripFor = ( edge : "in" | "out" ) => {
139251 const seconds = edge === "in" ? shown . fadeIn : shown . fadeOut ;
140252 const span = Math . min ( seconds * pixelsPerSecond , width ) ;
@@ -156,11 +268,7 @@ export function TimelineClipFades({
156268 onPointerMove = { onGripMove }
157269 onPointerUp = { onGripUp }
158270 onPointerCancel = { onGripUp }
159- onDoubleClick = { ( event ) => {
160- event . stopPropagation ( ) ;
161- if ( seconds > 0 ) onCycleCurve ( ) ;
162- } }
163- title = { `Fade ${ edge } : drag to set its length, double-click to change its ${ curve } curve` }
271+ title = { `Fade ${ edge } : drag to set its length. Drag the dot on the line to bend it.` }
164272 style = { {
165273 position : "absolute" ,
166274 left : x - GRIP / 2 ,
@@ -171,14 +279,17 @@ export function TimelineClipFades({
171279 background : "rgba(255,255,255,0.9)" ,
172280 boxShadow : "0 0 0 1px rgba(0,0,0,0.5)" ,
173281 cursor : "ew-resize" ,
282+ pointerEvents : "auto" ,
174283 zIndex : 6 ,
175284 } }
176285 />
177286 ) ;
178287 } ;
179288
180289 return (
181- < >
290+ // One positioned box owns the overlay so the bend handle has a parent whose
291+ // height it can measure, and so both handles share the clip's coordinates.
292+ < div ref = { hostRef } style = { { position : "absolute" , inset : 0 , pointerEvents : "none" } } >
182293 < svg
183294 aria-hidden = "true"
184295 width = "100%"
@@ -193,7 +304,7 @@ export function TimelineClipFades({
193304 const { line, fill } = fadeWedgePath ( {
194305 edge,
195306 seconds,
196- sample,
307+ sample : shownSample ,
197308 pixelsPerSecond,
198309 width,
199310 height : VIEW_HEIGHT ,
@@ -218,6 +329,7 @@ export function TimelineClipFades({
218329 } ) }
219330 </ svg >
220331 { showGrips && ! readOnly && ( [ "in" , "out" ] as const ) . map ( gripFor ) }
221- </ >
332+ { showGrips && ! readOnly && ( [ "in" , "out" ] as const ) . map ( bendFor ) }
333+ </ div >
222334 ) ;
223335}
0 commit comments