11import {
22 useRef ,
3- type ChangeEvent ,
43 type CSSProperties ,
54 type DragEvent ,
65 type KeyboardEvent ,
6+ type PointerEvent ,
77} from "react" ;
88
99import { Icon } from "../../components" ;
@@ -59,14 +59,7 @@ const barNumbers = Array.from(
5959 { length : ARRANGEMENT_BAR_COUNT } ,
6060 ( _ , index ) => index + 1 ,
6161) ;
62- const loopStartBoundaryOptions = Array . from (
63- { length : ARRANGEMENT_BAR_COUNT } ,
64- ( _ , index ) => index ,
65- ) ;
66- const loopEndBoundaryOptions = Array . from (
67- { length : ARRANGEMENT_BAR_COUNT } ,
68- ( _ , index ) => index + 1 ,
69- ) ;
62+ type LoopBoundaryKind = "start" | "end" ;
7063
7164export function ArrangementView ( {
7265 clipInstances,
@@ -83,7 +76,9 @@ export function ArrangementView({
8376 shouldShowPlayhead,
8477 tracks,
8578} : ArrangementViewProps ) {
79+ const rulerRef = useRef < HTMLDivElement > ( null ) ;
8680 const timelineGridRef = useRef < HTMLDivElement > ( null ) ;
81+ const draggingLoopBoundaryRef = useRef < LoopBoundaryKind | null > ( null ) ;
8782 const clipById = new Map ( clips . map ( ( clip ) => [ clip . id , clip ] ) ) ;
8883 const trackIndexById = new Map (
8984 tracks . map ( ( track , index ) => [ track . id , index ] as const ) ,
@@ -107,24 +102,6 @@ export function ArrangementView({
107102 "--arrangement-track-header-width" : `${ TRACK_HEADER_WIDTH } px` ,
108103 } ;
109104
110- function handleLoopStartChange ( event : ChangeEvent < HTMLSelectElement > ) {
111- const boundaryIndex = Number . parseInt ( event . currentTarget . value , 10 ) ;
112-
113- onLoopRangeChange ( {
114- endTick : loopRange . endTick ,
115- startTick : boundaryIndex * TICKS_PER_4_4_BAR ,
116- } ) ;
117- }
118-
119- function handleLoopEndChange ( event : ChangeEvent < HTMLSelectElement > ) {
120- const boundaryIndex = Number . parseInt ( event . currentTarget . value , 10 ) ;
121-
122- onLoopRangeChange ( {
123- endTick : boundaryIndex * TICKS_PER_4_4_BAR ,
124- startTick : loopRange . startTick ,
125- } ) ;
126- }
127-
128105 function handleTimelineDragOver ( event : DragEvent < HTMLDivElement > ) {
129106 if ( ! hasArrangementDragPayload ( event ) ) {
130107 return ;
@@ -175,6 +152,56 @@ export function ArrangementView({
175152 }
176153 }
177154
155+ function handleLoopPointerDown (
156+ event : PointerEvent < HTMLButtonElement > ,
157+ boundary : LoopBoundaryKind ,
158+ ) {
159+ event . preventDefault ( ) ;
160+ event . stopPropagation ( ) ;
161+ event . currentTarget . setPointerCapture ( event . pointerId ) ;
162+ draggingLoopBoundaryRef . current = boundary ;
163+ updateLoopBoundaryFromClientX ( event . clientX , boundary ) ;
164+ }
165+
166+ function handleLoopPointerMove ( event : PointerEvent < HTMLButtonElement > ) {
167+ const boundary = draggingLoopBoundaryRef . current ;
168+
169+ if ( ! boundary ) {
170+ return ;
171+ }
172+
173+ event . preventDefault ( ) ;
174+ updateLoopBoundaryFromClientX ( event . clientX , boundary ) ;
175+ }
176+
177+ function handleLoopPointerEnd ( event : PointerEvent < HTMLButtonElement > ) {
178+ if ( event . currentTarget . hasPointerCapture ( event . pointerId ) ) {
179+ event . currentTarget . releasePointerCapture ( event . pointerId ) ;
180+ }
181+
182+ draggingLoopBoundaryRef . current = null ;
183+ }
184+
185+ function updateLoopBoundaryFromClientX (
186+ clientX : number ,
187+ boundary : LoopBoundaryKind ,
188+ ) {
189+ const boundaryIndex = getBoundaryIndexFromClientX ( clientX , rulerRef . current ) ;
190+
191+ if ( boundary === "start" ) {
192+ onLoopRangeChange ( {
193+ endTick : loopRange . endTick ,
194+ startTick : boundaryIndex * TICKS_PER_4_4_BAR ,
195+ } ) ;
196+ return ;
197+ }
198+
199+ onLoopRangeChange ( {
200+ endTick : boundaryIndex * TICKS_PER_4_4_BAR ,
201+ startTick : loopRange . startTick ,
202+ } ) ;
203+ }
204+
178205 return (
179206 < section
180207 aria-label = "Arrangement view"
@@ -189,38 +216,6 @@ export function ArrangementView({
189216 { errorMessage ? (
190217 < p className = { styles . errorBadge } > { errorMessage } </ p >
191218 ) : null }
192- < div className = { styles . loopControls } aria-label = "Arrangement loop range" >
193- < label className = { styles . loopControl } >
194- < span > Loop Start</ span >
195- < select
196- aria-label = "Loop start bar boundary"
197- className = { styles . loopSelect }
198- onChange = { handleLoopStartChange }
199- value = { loopStartBoundaryIndex }
200- >
201- { loopStartBoundaryOptions . map ( ( boundaryIndex ) => (
202- < option key = { boundaryIndex } value = { boundaryIndex } >
203- Bar { boundaryIndex + 1 }
204- </ option >
205- ) ) }
206- </ select >
207- </ label >
208- < label className = { styles . loopControl } >
209- < span > Loop End</ span >
210- < select
211- aria-label = "Loop end bar boundary"
212- className = { styles . loopSelect }
213- onChange = { handleLoopEndChange }
214- value = { loopEndBoundaryIndex }
215- >
216- { loopEndBoundaryOptions . map ( ( boundaryIndex ) => (
217- < option key = { boundaryIndex } value = { boundaryIndex } >
218- Bar { boundaryIndex + 1 }
219- </ option >
220- ) ) }
221- </ select >
222- </ label >
223- </ div >
224219 < button
225220 className = { styles . deleteButton }
226221 disabled = { ! selectedClipInstance }
@@ -276,12 +271,41 @@ export function ArrangementView({
276271
277272 < div className = { styles . timelineScroller } >
278273 < div className = { styles . timelineContent } >
279- < div className = { styles . ruler } aria-label = "Timeline ruler" >
274+ < div className = { styles . ruler } aria-label = "Timeline ruler" ref = { rulerRef } >
280275 { barNumbers . map ( ( barNumber ) => (
281276 < div className = { styles . barMarker } key = { barNumber } >
282277 { barNumber }
283278 </ div >
284279 ) ) }
280+ < div
281+ aria-hidden = "true"
282+ className = { styles . loopRulerConnector }
283+ style = { getLoopRegionStyle ( loopRange ) }
284+ />
285+ < button
286+ aria-label = { `Drag loop start, currently bar ${
287+ loopStartBoundaryIndex + 1
288+ } `}
289+ className = { `${ styles . loopHandle } ${ styles . loopHandleStart } ` }
290+ onPointerCancel = { handleLoopPointerEnd }
291+ onPointerDown = { ( event ) => handleLoopPointerDown ( event , "start" ) }
292+ onPointerMove = { handleLoopPointerMove }
293+ onPointerUp = { handleLoopPointerEnd }
294+ style = { { left : `${ tickToPixels ( loopRange . startTick ) } px` } }
295+ type = "button"
296+ />
297+ < button
298+ aria-label = { `Drag loop end, currently bar ${
299+ loopEndBoundaryIndex + 1
300+ } `}
301+ className = { `${ styles . loopHandle } ${ styles . loopHandleEnd } ` }
302+ onPointerCancel = { handleLoopPointerEnd }
303+ onPointerDown = { ( event ) => handleLoopPointerDown ( event , "end" ) }
304+ onPointerMove = { handleLoopPointerMove }
305+ onPointerUp = { handleLoopPointerEnd }
306+ style = { { left : `${ tickToPixels ( loopRange . endTick ) } px` } }
307+ type = "button"
308+ />
285309 </ div >
286310
287311 < div
@@ -293,18 +317,11 @@ export function ArrangementView({
293317 < div aria-hidden = "true" className = { styles . laneGrid } />
294318 < div aria-hidden = "true" className = { styles . beatGrid } />
295319 < div className = { styles . clipLayer } aria-label = "Arrangement clips" >
296- < div
297- aria-hidden = "true"
298- className = { styles . loopRegion }
299- style = { getLoopRegionStyle ( loopRange ) }
300- />
301320 < div
302321 aria-hidden = "true"
303322 className = { `${ styles . loopBoundary } ${ styles . loopBoundaryStart } ` }
304323 style = { { left : `${ tickToPixels ( loopRange . startTick ) } px` } }
305- >
306- < span > Loop</ span >
307- </ div >
324+ />
308325 < div
309326 aria-hidden = "true"
310327 className = { `${ styles . loopBoundary } ${ styles . loopBoundaryEnd } ` }
@@ -430,6 +447,20 @@ function getLoopRegionStyle(loopRange: ArrangementLoopRange): CSSProperties {
430447 } ;
431448}
432449
450+ function getBoundaryIndexFromClientX (
451+ clientX : number ,
452+ ruler : HTMLDivElement | null ,
453+ ) : number {
454+ if ( ! ruler ) {
455+ return 0 ;
456+ }
457+
458+ const rect = ruler . getBoundingClientRect ( ) ;
459+ const x = Math . max ( 0 , Math . min ( clientX - rect . left , TIMELINE_WIDTH ) ) ;
460+
461+ return clamp ( Math . round ( x / BAR_WIDTH ) , 0 , ARRANGEMENT_BAR_COUNT ) ;
462+ }
463+
433464function getClipStyle ( {
434465 instance,
435466 trackCount,
0 commit comments