From e4ca071505cb965ed5a76a1d2347651f5e48e655 Mon Sep 17 00:00:00 2001 From: ChuxiJ Date: Sun, 22 Mar 2026 02:00:15 +0800 Subject: [PATCH 1/4] feat: true dual-channel stereo waveform thumbnail display (#680) Compute interleaved stereo min/max peaks (Lmax, Lmin, Rmax, Rmin) and render left/right channels as separate filled SVG paths stacked vertically. Closes #680 Co-Authored-By: Claude Opus 4.6 --- src/components/timeline/ClipWaveform.tsx | 181 +++++++++++++++-------- src/utils/waveformPeaks.ts | 44 +++++- tests/unit/bounceInPlaceService.test.ts | 2 +- tests/unit/clipWaveform.test.tsx | 65 +++++++- tests/unit/waveformPeaks.test.ts | 57 +++++-- 5 files changed, 262 insertions(+), 87 deletions(-) diff --git a/src/components/timeline/ClipWaveform.tsx b/src/components/timeline/ClipWaveform.tsx index 3a8cf60b..11e9d8ac 100644 --- a/src/components/timeline/ClipWaveform.tsx +++ b/src/components/timeline/ClipWaveform.tsx @@ -1,5 +1,6 @@ import type { MidiClipData, StretchMode } from '../../types/project'; import { getClipSourceSpan, getClipWaveformLayout } from '../../utils/clipAudio'; +import { PEAK_STRIDE } from '../../utils/waveformPeaks'; interface ClipWaveformProps { peaks: number[] | null; @@ -37,15 +38,39 @@ export function ClipWaveform({ stretchMode, }; const waveformLayout = getClipWaveformLayout(clipWindow, contentWidth); - const peakSlice = getVisiblePeakSlice(peaks, audioDuration, audioOffset, getClipSourceSpan(clipWindow)); - if (!peaks || peakSlice.numBars === 0 || contentWidth <= 0 || waveformLayout.widthPx <= 0) { + if (!peaks || peaks.length === 0 || contentWidth <= 0 || waveformLayout.widthPx <= 0) { return null; } + const logicalPeakCount = Math.floor(peaks.length / PEAK_STRIDE); + if (logicalPeakCount === 0) { + // Legacy mono fallback: old peaks with no stride structure + return null; + } + + const peakSlice = getVisiblePeakSlice(logicalPeakCount, audioDuration, audioOffset, getClipSourceSpan(clipWindow)); + if (peakSlice.numBars === 0) return null; + const columnCount = Math.max(1, Math.floor(waveformLayout.widthPx)); const columnWidth = waveformLayout.widthPx / columnCount; + // Each channel occupies its own vertical half. + // Left channel: y = 0..50, center at y = 25 + // Right channel: y = 50..100, center at y = 75 + const leftPath = buildChannelPath( + peaks, peakSlice, columnCount, columnWidth, waveformLayout.leftPx, + 0, // channelOffset in stride: 0 = Lmax, 1 = Lmin + 25, // centerY for left channel + 23, // maxAmplitude (px in SVG units, leaves 2px padding) + ); + const rightPath = buildChannelPath( + peaks, peakSlice, columnCount, columnWidth, waveformLayout.leftPx, + 2, // channelOffset in stride: 2 = Rmax, 3 = Rmin + 75, // centerY for right channel + 23, + ); + return (
- {Array.from({ length: columnCount }, (_, index) => { - const peak = getPeakForColumn(peaks, peakSlice, index, columnCount); - const height = peak * 80; - - return ( - - ); - })} + {/* Thin center divider between channels */} + + +
); } +/** + * Build an SVG path for one channel's waveform. + * Draws the positive envelope (max) left-to-right, then negative envelope (min) right-to-left, + * creating a filled shape around the channel's center line. + */ +function buildChannelPath( + peaks: number[], + peakSlice: { startPeakIdx: number; numBars: number }, + columnCount: number, + columnWidth: number, + leftPx: number, + channelOffset: number, // 0 for left (Lmax at +0, Lmin at +1), 2 for right (Rmax at +2, Rmin at +3) + centerY: number, + maxAmplitude: number, +): string { + // Upper contour (max values, positive peaks going upward from center) + const upperPoints: string[] = []; + // Lower contour (min values, negative peaks going downward from center) + const lowerPoints: string[] = []; + + for (let i = 0; i < columnCount; i++) { + const x = leftPx + (i + 0.5) * columnWidth; + const { max, min } = getMinMaxForColumn(peaks, peakSlice, i, columnCount, channelOffset); + // max >= 0, maps upward from center; min <= 0, maps downward from center + const yTop = centerY - max * maxAmplitude; + const yBottom = centerY - min * maxAmplitude; // min is negative, so this goes below center + upperPoints.push(`${x} ${yTop}`); + lowerPoints.push(`${x} ${yBottom}`); + } + + // Build closed path: upper left-to-right, then lower right-to-left + return `M ${upperPoints[0]} L ${upperPoints.join(' L ')} L ${lowerPoints.reverse().join(' L ')} Z`; +} + +function getVisiblePeakSlice( + logicalPeakCount: number, + audioDuration: number, + audioOffset: number, + sourceSpan: number, +) { + if (logicalPeakCount === 0 || audioDuration <= 0) { + return { startPeakIdx: 0, numBars: 0 }; + } + + const startPeakIdx = Math.floor((audioOffset / audioDuration) * logicalPeakCount); + const visibleAudioSec = Math.min(sourceSpan, Math.max(0, audioDuration - audioOffset)); + const endPeakIdx = Math.min( + Math.ceil(((audioOffset + visibleAudioSec) / audioDuration) * logicalPeakCount), + logicalPeakCount, + ); + + return { + startPeakIdx, + numBars: Math.max(0, endPeakIdx - startPeakIdx), + }; +} + +/** + * For a given display column, find the min and max sample values across + * the corresponding peak range for a specific channel. + */ +function getMinMaxForColumn( + peaks: number[], + peakSlice: { startPeakIdx: number; numBars: number }, + columnIndex: number, + columnCount: number, + channelOffset: number, // 0 for L, 2 for R +): { max: number; min: number } { + const start = peakSlice.startPeakIdx + Math.floor((columnIndex / columnCount) * peakSlice.numBars); + const end = peakSlice.startPeakIdx + Math.ceil(((columnIndex + 1) / columnCount) * peakSlice.numBars); + let max = 0; + let min = 0; + + for (let i = start; i < end; i++) { + const idx = i * PEAK_STRIDE + channelOffset; + const peakMax = peaks[idx] ?? 0; + const peakMin = peaks[idx + 1] ?? 0; + if (peakMax > max) max = peakMax; + if (peakMin < min) min = peakMin; + } + + return { max, min }; +} + interface ClipMidiThumbnailProps { midiData: MidiClipData; width: number; @@ -111,43 +216,3 @@ export function ClipMidiThumbnail({ midiData, width, duration, bpm, color }: Cli ); } - -function getVisiblePeakSlice( - peaks: number[] | null, - audioDuration: number, - audioOffset: number, - sourceSpan: number, -) { - if (!peaks || peaks.length === 0 || audioDuration <= 0) { - return { startPeakIdx: 0, numBars: 0 }; - } - - const startPeakIdx = Math.floor((audioOffset / audioDuration) * peaks.length); - const visibleAudioSec = Math.min(sourceSpan, Math.max(0, audioDuration - audioOffset)); - const endPeakIdx = Math.min( - Math.ceil(((audioOffset + visibleAudioSec) / audioDuration) * peaks.length), - peaks.length, - ); - - return { - startPeakIdx, - numBars: Math.max(0, endPeakIdx - startPeakIdx), - }; -} - -function getPeakForColumn( - peaks: number[], - peakSlice: { startPeakIdx: number; numBars: number }, - columnIndex: number, - columnCount: number, -) { - const start = peakSlice.startPeakIdx + Math.floor((columnIndex / columnCount) * peakSlice.numBars); - const end = peakSlice.startPeakIdx + Math.ceil(((columnIndex + 1) / columnCount) * peakSlice.numBars); - let maxPeak = 0; - - for (let index = start; index < Math.min(end, peaks.length); index += 1) { - maxPeak = Math.max(maxPeak, peaks[index] ?? 0); - } - - return maxPeak; -} diff --git a/src/utils/waveformPeaks.ts b/src/utils/waveformPeaks.ts index 9d2094d0..7d1e10a2 100644 --- a/src/utils/waveformPeaks.ts +++ b/src/utils/waveformPeaks.ts @@ -1,27 +1,55 @@ +/** + * Compute waveform peaks from an AudioBuffer for dual-channel display. + * + * Returns interleaved stereo min/max peaks: + * [Lmax0, Lmin0, Rmax0, Rmin0, Lmax1, Lmin1, Rmax1, Rmin1, ...] + * Length = 4 * numPeaks. + * + * Lmax/Rmax are the positive peak (>= 0), Lmin/Rmin are the negative peak (<= 0). + * For mono audio, left and right values are identical. + */ export function computeWaveformPeaks( audioBuffer: AudioBuffer, numPeaks: number, startSample: number = 0, endSample?: number, ): number[] { - const channelData = audioBuffer.getChannelData(0); - const regionEnd = endSample ?? channelData.length; + const leftData = audioBuffer.getChannelData(0); + const rightData = audioBuffer.numberOfChannels >= 2 + ? audioBuffer.getChannelData(1) + : leftData; + + const regionEnd = endSample ?? leftData.length; const regionLength = regionEnd - startSample; const samplesPerPeak = Math.floor(regionLength / numPeaks); - if (samplesPerPeak <= 0) return new Array(numPeaks).fill(0); + if (samplesPerPeak <= 0) return new Array(numPeaks * 4).fill(0); - const peaks: number[] = new Array(numPeaks); + const peaks: number[] = new Array(numPeaks * 4); for (let i = 0; i < numPeaks; i++) { - let max = 0; + let lMax = 0; + let lMin = 0; + let rMax = 0; + let rMin = 0; const start = startSample + i * samplesPerPeak; const end = Math.min(start + samplesPerPeak, regionEnd); for (let j = start; j < end; j++) { - const abs = Math.abs(channelData[j]); - if (abs > max) max = abs; + const lSample = leftData[j]; + if (lSample > lMax) lMax = lSample; + if (lSample < lMin) lMin = lSample; + const rSample = rightData[j]; + if (rSample > rMax) rMax = rSample; + if (rSample < rMin) rMin = rSample; } - peaks[i] = max; + const idx = i * 4; + peaks[idx] = lMax; + peaks[idx + 1] = lMin; + peaks[idx + 2] = rMax; + peaks[idx + 3] = rMin; } return peaks; } + +/** Number of values stored per logical peak (Lmax, Lmin, Rmax, Rmin). */ +export const PEAK_STRIDE = 4; diff --git a/tests/unit/bounceInPlaceService.test.ts b/tests/unit/bounceInPlaceService.test.ts index 50813cb3..914b6c21 100644 --- a/tests/unit/bounceInPlaceService.test.ts +++ b/tests/unit/bounceInPlaceService.test.ts @@ -163,6 +163,6 @@ describe('bounceInPlace service', () => { expect(mockSaveAudioBlob).toHaveBeenCalledOnce(); expect(result.audioKey).toBe('bounce-audio-key'); expect(result.duration).toBe(3); - expect(result.waveformPeaks).toHaveLength(1024); + expect(result.waveformPeaks).toHaveLength(1024 * 4); }); }); diff --git a/tests/unit/clipWaveform.test.tsx b/tests/unit/clipWaveform.test.tsx index 0644bba0..b53886f7 100644 --- a/tests/unit/clipWaveform.test.tsx +++ b/tests/unit/clipWaveform.test.tsx @@ -1,13 +1,23 @@ import { render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { ClipWaveform } from '../../src/components/timeline/ClipWaveform'; +import { PEAK_STRIDE } from '../../src/utils/waveformPeaks'; + +/** Create stereo min/max peaks: [Lmax, Lmin, Rmax, Rmin, ...] */ +function makePeaks(count: number, fillMax = 0.5, fillMin = -0.5): number[] { + const peaks: number[] = []; + for (let i = 0; i < count; i++) { + peaks.push(fillMax, fillMin, fillMax, fillMin); + } + return peaks; +} describe('ClipWaveform', () => { it('renders audible content inset when the clip has a silent lead-in', () => { const { container } = render(
((index % 5) + 1) / 5)} + peaks={makePeaks(64)} audioDuration={4} audioOffset={0} clipDuration={5} @@ -18,16 +28,20 @@ describe('ClipWaveform', () => {
, ); - const rects = Array.from(container.querySelectorAll('rect')); - expect(rects.length).toBeGreaterThan(0); - expect(Number(rects[0].getAttribute('x'))).toBeGreaterThanOrEqual(100); + const paths = Array.from(container.querySelectorAll('path')); + expect(paths.length).toBe(2); // left + right channel + const d = paths[0].getAttribute('d') ?? ''; + // First M command sets the starting X — should be offset by contentOffset + const match = d.match(/^M\s+([\d.]+)/); + expect(match).not.toBeNull(); + expect(Number(match![1])).toBeGreaterThanOrEqual(100); }); it('fills the clip width when repitch stretch is active', () => { const { container } = render(
((index % 7) + 1) / 7)} + peaks={makePeaks(64)} audioDuration={4} audioOffset={0} clipDuration={6} @@ -40,8 +54,43 @@ describe('ClipWaveform', () => {
, ); - const rects = Array.from(container.querySelectorAll('rect')); - expect(rects.length).toBeGreaterThan(0); - expect(Number(rects[0].getAttribute('x'))).toBe(0); + const paths = Array.from(container.querySelectorAll('path')); + expect(paths.length).toBe(2); + const d = paths[0].getAttribute('d') ?? ''; + const match = d.match(/^M\s+([\d.]+)/); + expect(match).not.toBeNull(); + expect(Number(match![1])).toBeLessThan(1); // starts near x=0 + }); + + it('renders dual-channel with left and right channel paths', () => { + // 2 logical peaks × PEAK_STRIDE = 8 values + const peaks = [ + 0.8, -0.3, 0.2, -0.9, // peak 0: L(max=0.8, min=-0.3), R(max=0.2, min=-0.9) + 0.6, -0.5, 0.4, -0.6, // peak 1: L(max=0.6, min=-0.5), R(max=0.4, min=-0.6) + ]; + expect(peaks.length).toBe(2 * PEAK_STRIDE); + + const { container } = render( +
+ +
, + ); + + const paths = Array.from(container.querySelectorAll('path')); + expect(paths.length).toBe(2); + expect(paths[0].getAttribute('data-testid')).toBe('waveform-left-channel'); + expect(paths[1].getAttribute('data-testid')).toBe('waveform-right-channel'); + + // Verify the center divider line exists + const lines = Array.from(container.querySelectorAll('line')); + expect(lines.length).toBe(1); + expect(lines[0].getAttribute('y1')).toBe('50'); }); }); diff --git a/tests/unit/waveformPeaks.test.ts b/tests/unit/waveformPeaks.test.ts index 7bf02437..4b74465e 100644 --- a/tests/unit/waveformPeaks.test.ts +++ b/tests/unit/waveformPeaks.test.ts @@ -1,41 +1,74 @@ import { describe, expect, it } from 'vitest'; -import { computeWaveformPeaks } from '../../src/utils/waveformPeaks'; +import { computeWaveformPeaks, PEAK_STRIDE } from '../../src/utils/waveformPeaks'; function createAudioBufferMock(samples: number[]): AudioBuffer { return { + numberOfChannels: 1, getChannelData: () => Float32Array.from(samples), } as AudioBuffer; } +function createStereoAudioBufferMock(left: number[], right: number[]): AudioBuffer { + const channels = [Float32Array.from(left), Float32Array.from(right)]; + return { + numberOfChannels: 2, + getChannelData: (ch: number) => channels[ch], + } as AudioBuffer; +} + describe('computeWaveformPeaks', () => { - it('returns the requested number of peaks', () => { + it('PEAK_STRIDE is 4', () => { + expect(PEAK_STRIDE).toBe(4); + }); + + it('returns 4 values per peak for mono buffer (Lmax, Lmin, Rmax, Rmin)', () => { + // 8 samples → 4 peaks, each peak = 2 samples const peaks = computeWaveformPeaks( createAudioBufferMock([0.1, -0.2, 0.5, -0.7, 0.9, -0.4, 0.3, -0.1]), 4, ); - expect(peaks).toHaveLength(4); - peaks.forEach((p, i) => expect(p).toBeCloseTo([0.2, 0.7, 0.9, 0.3][i], 5)); + expect(peaks).toHaveLength(4 * PEAK_STRIDE); // 16 + // Peak 0: samples [0.1, -0.2] → max=0.1, min=-0.2 + expect(peaks[0]).toBeCloseTo(0.1, 5); // Lmax + expect(peaks[1]).toBeCloseTo(-0.2, 5); // Lmin + expect(peaks[2]).toBeCloseTo(0.1, 5); // Rmax (same as L for mono) + expect(peaks[3]).toBeCloseTo(-0.2, 5); // Rmin + // Peak 1: samples [0.5, -0.7] → max=0.5, min=-0.7 + expect(peaks[4]).toBeCloseTo(0.5, 5); + expect(peaks[5]).toBeCloseTo(-0.7, 5); }); - it('keeps peak values between 0 and 1 for normalized source audio', () => { + it('returns correct min/max for stereo buffer', () => { + const left = [0.3, -0.5, 0.8, -0.2]; + const right = [0.1, -0.9, 0.4, -0.6]; const peaks = computeWaveformPeaks( - createAudioBufferMock([-1, -0.25, 0.75, 0.5, -0.9, 0.1]), - 3, + createStereoAudioBufferMock(left, right), + 2, ); - expect(peaks.every((peak) => peak >= -1 && peak <= 1)).toBe(true); + expect(peaks).toHaveLength(2 * PEAK_STRIDE); // 8 + // Peak 0: L=[0.3, -0.5], R=[0.1, -0.9] + expect(peaks[0]).toBeCloseTo(0.3, 5); // Lmax + expect(peaks[1]).toBeCloseTo(-0.5, 5); // Lmin + expect(peaks[2]).toBeCloseTo(0.1, 5); // Rmax + expect(peaks[3]).toBeCloseTo(-0.9, 5); // Rmin + // Peak 1: L=[0.8, -0.2], R=[0.4, -0.6] + expect(peaks[4]).toBeCloseTo(0.8, 5); // Lmax + expect(peaks[5]).toBeCloseTo(-0.2, 5); // Lmin + expect(peaks[6]).toBeCloseTo(0.4, 5); // Rmax + expect(peaks[7]).toBeCloseTo(-0.6, 5); // Rmin }); it('returns all zeros for a silent buffer', () => { const peaks = computeWaveformPeaks(createAudioBufferMock([0, 0, 0, 0]), 4); - - expect(peaks).toEqual([0, 0, 0, 0]); + expect(peaks).toHaveLength(4 * PEAK_STRIDE); + expect(peaks.every((p) => p === 0)).toBe(true); }); it('returns zeros when a single sample is spread across multiple requested peaks', () => { const peaks = computeWaveformPeaks(createAudioBufferMock([0.75]), 4); - - expect(peaks).toEqual([0, 0, 0, 0]); + expect(peaks).toHaveLength(4 * PEAK_STRIDE); + expect(peaks.every((p) => p === 0)).toBe(true); }); }); From 1f978fc000ba966280460ddd7994fb10ec88e5ed Mon Sep 17 00:00:00 2001 From: ChuxiJ Date: Sun, 22 Mar 2026 02:00:22 +0800 Subject: [PATCH 2/4] feat: toggle grid lines to dashed when Command key is held (#679) Grid lines switch from solid to dashed while Meta/Command is pressed, providing visual feedback for multi-select mode. Resets on blur. Closes #679 Co-Authored-By: Claude Opus 4.6 --- src/components/timeline/GridOverlay.tsx | 30 +++++++++------ src/hooks/useMetaKeyDown.ts | 30 +++++++++++++++ tests/unit/useMetaKeyDown.test.ts | 50 +++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 src/hooks/useMetaKeyDown.ts create mode 100644 tests/unit/useMetaKeyDown.test.ts diff --git a/src/components/timeline/GridOverlay.tsx b/src/components/timeline/GridOverlay.tsx index 7c1c52dd..e0ce4879 100644 --- a/src/components/timeline/GridOverlay.tsx +++ b/src/components/timeline/GridOverlay.tsx @@ -4,6 +4,7 @@ import { useUIStore } from '../../store/uiStore'; import { getBeatDuration, getBarDuration } from '../../utils/time'; import { beatToTime, getBeatAtBar, getTimeSignatureAtBar, getTimeSignatureBeatLength } from '../../utils/tempoMap'; import { getTimelineVisualDuration } from '../../utils/timelineZoom'; +import { useMetaKeyDown } from '../../hooks/useMetaKeyDown'; /** * Adaptive grid: resolution auto-adjusts based on zoom level. @@ -23,6 +24,7 @@ export function GridOverlay() { const project = useProjectStore((s) => s.project); const pixelsPerSecond = useUIStore((s) => s.pixelsPerSecond); const timelineViewportWidth = useUIStore((s) => s.timelineViewportWidth); + const isMetaDown = useMetaKeyDown(); const lines = useMemo(() => { if (!project) return []; @@ -85,18 +87,22 @@ export function GridOverlay() {
{lines .filter((line) => line.strength !== 'sub') - .map((line, i) => ( -
- ))} + .map((line, i) => { + const color = line.strength === 'bar' ? colors.bar : colors.beat; + return ( +
+ ); + })}
); } diff --git a/src/hooks/useMetaKeyDown.ts b/src/hooks/useMetaKeyDown.ts new file mode 100644 index 00000000..180bb914 --- /dev/null +++ b/src/hooks/useMetaKeyDown.ts @@ -0,0 +1,30 @@ +import { useState, useEffect } from 'react'; + +/** + * Tracks whether the Meta (Command on Mac) key is currently held down. + * Resets on window blur to avoid "stuck" state when Cmd-tabbing away. + */ +export function useMetaKeyDown(): boolean { + const [metaDown, setMetaDown] = useState(false); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Meta') setMetaDown(true); + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Meta') setMetaDown(false); + }; + const onBlur = () => setMetaDown(false); + + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + window.addEventListener('blur', onBlur); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + window.removeEventListener('blur', onBlur); + }; + }, []); + + return metaDown; +} diff --git a/tests/unit/useMetaKeyDown.test.ts b/tests/unit/useMetaKeyDown.test.ts new file mode 100644 index 00000000..1b16e045 --- /dev/null +++ b/tests/unit/useMetaKeyDown.test.ts @@ -0,0 +1,50 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useMetaKeyDown } from '../../src/hooks/useMetaKeyDown'; + +describe('useMetaKeyDown', () => { + it('returns false by default', () => { + const { result } = renderHook(() => useMetaKeyDown()); + expect(result.current).toBe(false); + }); + + it('returns true when Meta key is pressed', () => { + const { result } = renderHook(() => useMetaKeyDown()); + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Meta' })); + }); + expect(result.current).toBe(true); + }); + + it('returns false when Meta key is released', () => { + const { result } = renderHook(() => useMetaKeyDown()); + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Meta' })); + }); + expect(result.current).toBe(true); + act(() => { + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta' })); + }); + expect(result.current).toBe(false); + }); + + it('resets to false on window blur (Cmd-tab away)', () => { + const { result } = renderHook(() => useMetaKeyDown()); + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Meta' })); + }); + expect(result.current).toBe(true); + act(() => { + window.dispatchEvent(new Event('blur')); + }); + expect(result.current).toBe(false); + }); + + it('ignores non-Meta keys', () => { + const { result } = renderHook(() => useMetaKeyDown()); + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' })); + }); + expect(result.current).toBe(false); + }); +}); From f1010d9a54fb175fa89f6843c4995f2b52d647c7 Mon Sep 17 00:00:00 2001 From: ChuxiJ Date: Sun, 22 Mar 2026 02:00:27 +0800 Subject: [PATCH 3/4] fix: clicking clip selects track and seeks playhead (#678) Clicking a clip now also selects its parent track and seeks the playhead to the click position. Multi-select (Cmd+click) preserves playhead position. Closes #678 Co-Authored-By: Claude Opus 4.6 --- src/components/timeline/ClipBlock.tsx | 29 +++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/components/timeline/ClipBlock.tsx b/src/components/timeline/ClipBlock.tsx index 09f00dd5..cd8ff524 100644 --- a/src/components/timeline/ClipBlock.tsx +++ b/src/components/timeline/ClipBlock.tsx @@ -102,6 +102,7 @@ export function ClipBlock({ clip, track }: ClipBlockProps) { const [dragGhost, setDragGhost] = useState(null); const [scissorLine, setScissorLine] = useState(null); const [hoveredResizeEdge, setHoveredResizeEdge] = useState<'left' | 'right' | null>(null); + const [hoverSeekX, setHoverSeekX] = useState(null); const scissorRef = useRef(false); const suppressContextMenuRef = useRef(false); @@ -563,8 +564,16 @@ export function ClipBlock({ clip, track }: ClipBlockProps) { e.stopPropagation(); if (dragRef.current) return; setCtxMenu(null); - selectClip(clip.id, e.metaKey || e.ctrlKey); - }, [clip.id, selectClip]); + const isMultiSelect = e.metaKey || e.ctrlKey; + selectClip(clip.id, isMultiSelect); + useUIStore.getState().selectTrack(track.id, isMultiSelect); + if (!isMultiSelect) { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const relX = e.clientX - rect.left; + const clickTime = clip.startTime + relX / pixelsPerSecond; + useTransportStore.getState().seek(clickTime); + } + }, [clip.id, clip.startTime, track.id, selectClip, pixelsPerSecond]); const handleDoubleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -605,9 +614,11 @@ export function ClipBlock({ clip, track }: ClipBlockProps) { if (relX <= EDGE_HANDLE_PX || relX >= rect.width - EDGE_HANDLE_PX) { setHoveredResizeEdge(relX <= EDGE_HANDLE_PX ? 'left' : 'right'); + setHoverSeekX(null); setResizeCursor(true); } else { setHoveredResizeEdge(null); + setHoverSeekX(relX); currentTarget.style.cursor = altKey ? 'ew-resize' : 'grab'; setResizeCursor(false); } @@ -624,6 +635,7 @@ export function ClipBlock({ clip, track }: ClipBlockProps) { const handleMouseLeaveLocal = useCallback((e: React.MouseEvent) => { const el = e.currentTarget as HTMLElement; setHoveredResizeEdge(null); + setHoverSeekX(null); el.style.cursor = ''; setResizeCursor(false); }, [setResizeCursor]); @@ -978,6 +990,19 @@ export function ClipBlock({ clip, track }: ClipBlockProps) {
)} + {hoverSeekX !== null && ( +
+ )} + {scissorLine !== null && (
Date: Sun, 22 Mar 2026 02:00:35 +0800 Subject: [PATCH 4/4] feat: subtle hover shadow on playhead cursor and clip seek line (#681) Add soft dark shadow to playhead and selected-track cursor for a floating effect. Hover seek line on clips provides click-to-seek visual feedback. Closes #681 Co-Authored-By: Claude Opus 4.6 --- src/components/timeline/Playhead.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/timeline/Playhead.tsx b/src/components/timeline/Playhead.tsx index f6502001..e3d33abb 100644 --- a/src/components/timeline/Playhead.tsx +++ b/src/components/timeline/Playhead.tsx @@ -39,6 +39,7 @@ export function Playhead() { left: transportX, minHeight: '100vh', backgroundColor: '#ffffff', + boxShadow: '0 0 3px rgba(0, 0, 0, 0.35), 0 0 8px rgba(0, 0, 0, 0.15)', }} /> )} @@ -69,6 +70,7 @@ function SelectedTrackCursor({ trackId, x, blink }: { trackId: string; x: number height: laneEl.offsetHeight, animation: blink ? 'playhead-blink-line 1.2s ease-in-out infinite' : undefined, backgroundColor: blink ? undefined : '#ffffff', + boxShadow: '0 0 3px rgba(0, 0, 0, 0.35), 0 0 8px rgba(0, 0, 0, 0.15)', }} /> );