Skip to content

Commit 8a15e39

Browse files
committed
feat: add draggable arrangement loop handles
1 parent 22484ca commit 8a15e39

3 files changed

Lines changed: 159 additions & 123 deletions

File tree

docs/ui-design.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ The next arrangement step should make the `SONG` view editable and playable.
126126
- Selecting a clip block should show a clear selected state.
127127
- Deleting a selected clip block removes only the arrangement instance, not the source clip in the sidebar.
128128
- Static demo clip blocks should be removed or replaced with seeded serializable state.
129-
- The arrangement toolbar may expose loop start and loop end controls snapped to bar boundaries.
130-
- The timeline should show the active loop region and start/end markers without interfering with clip dragging.
129+
- The arrangement ruler may expose draggable loop start and loop end handles snapped to bar boundaries.
130+
- The timeline should show loop start/end boundary lines. Avoid filling the full loop range across tracks; a small ruler connector between the loop handles is enough.
131131

132132
Use shared arrangement geometry constants for row height, ruler height, bar width, beat width, timeline width, and track header width. Inline styles are acceptable for dynamic clip geometry such as `left`, `top`, `width`, and transform values.
133133

src/features/arrangement-view/ArrangementView.module.css

Lines changed: 58 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -51,38 +51,6 @@
5151
white-space: nowrap;
5252
}
5353

54-
.loopControls {
55-
align-items: center;
56-
background: var(--color-surface-container-lowest);
57-
border: 1px solid var(--color-outline-variant);
58-
border-radius: var(--radius-sm);
59-
display: flex;
60-
gap: var(--space-gutter);
61-
min-height: 28px;
62-
padding: 0 var(--space-gutter);
63-
}
64-
65-
.loopControl {
66-
align-items: center;
67-
color: var(--color-text-dim);
68-
display: flex;
69-
font-size: var(--font-size-label);
70-
font-weight: var(--font-weight-label);
71-
gap: var(--space-unit);
72-
letter-spacing: var(--letter-spacing-label);
73-
text-transform: uppercase;
74-
}
75-
76-
.loopSelect {
77-
background: var(--color-surface-container);
78-
border: 1px solid var(--color-outline-variant);
79-
border-radius: var(--radius-xs);
80-
color: var(--color-primary-strong);
81-
font: inherit;
82-
height: 22px;
83-
padding: 0 var(--space-unit);
84-
}
85-
8654
.deleteButton {
8755
align-items: center;
8856
background: var(--color-surface-container-lowest);
@@ -265,6 +233,7 @@
265233
font-size: var(--font-size-label);
266234
font-weight: var(--font-weight-label);
267235
height: var(--arrangement-ruler-height);
236+
position: relative;
268237
}
269238

270239
.barMarker {
@@ -321,41 +290,77 @@
321290
position: absolute;
322291
}
323292

324-
.loopRegion {
325-
background: color-mix(in srgb, var(--color-primary-container) 24%, transparent);
326-
border-left: 1px solid var(--color-primary);
327-
border-right: 1px solid var(--color-primary);
293+
.loopBoundary {
294+
background: var(--color-primary);
328295
bottom: 0;
296+
box-shadow: 0 0 8px color-mix(in srgb, var(--color-primary) 48%, transparent);
329297
pointer-events: none;
330298
position: absolute;
331299
top: 0;
332-
z-index: 1;
300+
width: 1px;
301+
z-index: 3;
333302
}
334303

335-
.loopBoundary {
304+
.loopRulerConnector {
336305
background: var(--color-primary);
337-
bottom: 0;
338-
box-shadow: 0 0 8px color-mix(in srgb, var(--color-primary) 48%, transparent);
306+
box-shadow: 0 0 8px color-mix(in srgb, var(--color-primary) 52%, transparent);
307+
height: 2px;
339308
pointer-events: none;
340309
position: absolute;
341-
top: calc(-1 * var(--arrangement-ruler-height));
342-
width: 1px;
343-
z-index: 3;
310+
top: 16px;
311+
z-index: 4;
312+
}
313+
314+
.loopHandle {
315+
appearance: none;
316+
background: transparent;
317+
border: 0;
318+
cursor: ew-resize;
319+
height: 20px;
320+
margin: 0;
321+
padding: 0;
322+
position: absolute;
323+
top: 5px;
324+
transform: translateX(-50%);
325+
touch-action: none;
326+
width: 24px;
327+
z-index: 5;
344328
}
345329

346-
.loopBoundaryStart span {
330+
.loopHandle::before,
331+
.loopHandle::after {
347332
background: var(--color-primary);
348333
border-radius: var(--radius-xs);
349-
color: var(--color-on-primary);
350-
display: block;
351-
font-size: var(--font-size-label);
352-
font-weight: var(--font-weight-label);
353-
left: var(--space-unit);
354-
letter-spacing: var(--letter-spacing-label);
355-
padding: 2px var(--space-unit);
334+
box-shadow: 0 0 6px color-mix(in srgb, var(--color-primary) 56%, transparent);
335+
content: "";
336+
height: 2px;
356337
position: absolute;
357-
text-transform: uppercase;
358-
top: var(--space-unit);
338+
top: 9px;
339+
width: 12px;
340+
}
341+
342+
.loopHandle::before {
343+
left: 2px;
344+
transform: rotate(42deg);
345+
transform-origin: right center;
346+
}
347+
348+
.loopHandle::after {
349+
right: 2px;
350+
transform: rotate(-42deg);
351+
transform-origin: left center;
352+
}
353+
354+
.loopHandle:hover::before,
355+
.loopHandle:hover::after,
356+
.loopHandle:focus-visible::before,
357+
.loopHandle:focus-visible::after {
358+
background: var(--color-primary-strong);
359+
}
360+
361+
.loopHandle:focus-visible {
362+
outline: 1px solid var(--color-primary-strong);
363+
outline-offset: 2px;
359364
}
360365

361366
.loopBoundaryEnd {

src/features/arrangement-view/ArrangementView.tsx

Lines changed: 99 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {
22
useRef,
3-
type ChangeEvent,
43
type CSSProperties,
54
type DragEvent,
65
type KeyboardEvent,
6+
type PointerEvent,
77
} from "react";
88

99
import { 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

7164
export 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+
433464
function getClipStyle({
434465
instance,
435466
trackCount,

0 commit comments

Comments
 (0)