diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index a6edb06..5009d9d 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -520,46 +520,8 @@ const MainContent: React.FC = ({ }; // Helper function to select a region (clears previous selections) - const selectRegion = ( - regionId: string, - options: RegionClickOptions = { shiftKey: false }, - regionsToSearch?: RegionUI[] - ) => { - // Find the region in the UI state (use provided regions or current state) - const regionsToUse = regionsToSearch || regions; - const region = regionsToUse.find(r => r.id === regionId); - if (!region) { - if (DEBUG_MODE.MAIN_CONTENT) { - console.log(`Region not found in UI state: ${regionId}`); - } - return; - } - - // Find the track that contains this region - const track = tracks.find(t => t.getId().toString() === region.trackId); - if (!track) { - if (DEBUG_MODE.MAIN_CONTENT) { - console.log(`Track not found for region: ${regionId}`); - } - return; - } - - // Find the region in the track's model - const coreRegion = track.getRegions().find(r => r.getId() === regionId); - - if (!coreRegion) { - if (DEBUG_MODE.MAIN_CONTENT) { - console.log(`Region not found in track model: ${regionId}`); - } - return; - } - + const applyRegionSelection = (orderedSelectionIds: string[]) => { const core = KGCore.instance(); - const orderedSelection = options.shiftKey - ? (selectedRegionIds.includes(regionId) - ? selectedRegionIds.filter(id => id !== regionId) - : [...selectedRegionIds, regionId]) - : [regionId]; tracks.forEach(projectTrack => { projectTrack.getRegions().forEach(projectRegion => projectRegion.deselect()); @@ -567,7 +529,7 @@ const MainContent: React.FC = ({ clearAllSelections(); - const selectedRegions: KGRegion[] = orderedSelection + const selectedRegions: KGRegion[] = orderedSelectionIds .map(selectedId => { for (const projectTrack of tracks) { const selectedRegion = projectTrack.getRegions().find(r => r.getId() === selectedId); @@ -612,6 +574,67 @@ const MainContent: React.FC = ({ } }; + const selectRegion = ( + regionId: string, + options: RegionClickOptions = { shiftKey: false }, + regionsToSearch?: RegionUI[] + ) => { + const regionsToUse = regionsToSearch || regions; + const region = regionsToUse.find(r => r.id === regionId); + if (!region) { + if (DEBUG_MODE.MAIN_CONTENT) { + console.log(`Region not found in UI state: ${regionId}`); + } + return; + } + + const track = tracks.find(t => t.getId().toString() === region.trackId); + if (!track) { + if (DEBUG_MODE.MAIN_CONTENT) { + console.log(`Track not found for region: ${regionId}`); + } + return; + } + + const coreRegion = track.getRegions().find(r => r.getId() === regionId); + if (!coreRegion) { + if (DEBUG_MODE.MAIN_CONTENT) { + console.log(`Region not found in track model: ${regionId}`); + } + return; + } + + const orderedSelection = options.shiftKey + ? (selectedRegionIds.includes(regionId) + ? selectedRegionIds.filter(id => id !== regionId) + : [...selectedRegionIds, regionId]) + : [regionId]; + + applyRegionSelection(orderedSelection); + }; + + const handleRegionLassoSelection = (regionIds: string[], options: RegionClickOptions = { shiftKey: false }) => { + const orderedRegionIds = regionIds.filter(regionId => regions.some(region => region.id === regionId)); + const orderedSelection = options.shiftKey + ? orderedRegionIds.reduce((nextSelection, regionId) => { + if (nextSelection.includes(regionId)) { + return nextSelection.filter(id => id !== regionId); + } + return [...nextSelection, regionId]; + }, [...selectedRegionIds]) + : orderedRegionIds; + + applyRegionSelection(orderedSelection); + }; + + const handleEmptyMainContentClick = (e: React.MouseEvent) => { + if (e.target !== e.currentTarget) { + return; + } + + handleRegionLassoSelection([], { shiftKey: false }); + }; + // Handle region single click: selection only (no piano roll opening) const handleRegionClick = (regionId: string, options: RegionClickOptions = { shiftKey: false }) => { if (DEBUG_MODE.MAIN_CONTENT) { @@ -916,8 +939,12 @@ const MainContent: React.FC = ({ }; return ( -
-
+
+
{/* Top-left spacer */}
@@ -940,7 +967,7 @@ const MainContent: React.FC = ({ ))}
-
+
{/* Fixed left panel with track info */} = ({ onRegionCreated={handleRegionCreated} onRegionUpdated={handleRegionUpdated} onRegionClick={handleRegionClick} + onRegionLassoSelection={handleRegionLassoSelection} onOpenPianoRoll={handleOpenPianoRoll} onOpenSpectrogram={handleOpenSpectrogram} showHybridButtonForAudio={showHybridButtonForAudio} diff --git a/src/components/track/TrackGridPanel.test.tsx b/src/components/track/TrackGridPanel.test.tsx new file mode 100644 index 0000000..d900ef2 --- /dev/null +++ b/src/components/track/TrackGridPanel.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; +import TrackGridPanel from './TrackGridPanel'; +import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data'; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: (selector?: (state: { + selectedRegionIds: string[], + refreshProjectState: () => void, + timeSignature: { numerator: number; denominator: number }, + bpm: number, + }) => unknown) => { + const state = { + selectedRegionIds: [], + refreshProjectState: vi.fn(), + timeSignature: { numerator: 4, denominator: 4 }, + bpm: 120, + }; + return selector ? selector(state) : state; + }, +})); + +vi.mock('../common', () => ({ + Playhead: () => null, + FileImportModal: () => null, +})); + +describe('TrackGridPanel lasso selection', () => { + beforeAll(() => { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + value: vi.fn(() => ({ + clearRect: vi.fn(), + fillRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + })), + }); + + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + }); + + const renderPanel = () => { + const regionA = createMockMidiRegion({ id: 'region-a', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 4 }); + const regionB = createMockMidiRegion({ id: 'region-b', trackId: '2', trackIndex: 1, startFromBeat: 8, length: 4 }); + const trackA = createMockMidiTrack({ id: 1, regions: [regionA] }); + const trackB = createMockMidiTrack({ id: 2, regions: [regionB] }); + trackA.setTrackIndex(0); + trackB.setTrackIndex(1); + + const onRegionLassoSelection = vi.fn(); + + const view = render( + + ); + + const gridContainer = view.container.querySelector('.grid-container') as HTMLDivElement; + Object.defineProperty(gridContainer, 'clientWidth', { configurable: true, value: 320 }); + vi.spyOn(gridContainer, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 320, + bottom: 240, + width: 320, + height: 240, + toJSON: () => ({}), + }); + + return { ...view, onRegionLassoSelection }; + }; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('selects intersecting regions across multiple track rows', () => { + const { container, onRegionLassoSelection } = renderPanel(); + const firstTrackGrid = container.querySelector('[data-test-id="track-grid-1"]') as HTMLDivElement; + + fireEvent.mouseDown(firstTrackGrid, { clientX: 10, clientY: 10, button: 0 }); + fireEvent.mouseMove(document, { clientX: 130, clientY: 200 }); + fireEvent.mouseUp(document, { clientX: 130, clientY: 200 }); + + expect(onRegionLassoSelection).toHaveBeenCalledWith(['region-a', 'region-b'], { shiftKey: false }); + }); + + it('moves the release-point region to the end of the lasso selection order', () => { + const { container, onRegionLassoSelection } = renderPanel(); + const firstTrackGrid = container.querySelector('[data-test-id="track-grid-1"]') as HTMLDivElement; + + fireEvent.mouseDown(firstTrackGrid, { clientX: 130, clientY: 200, button: 0 }); + fireEvent.mouseMove(document, { clientX: 20, clientY: 20 }); + fireEvent.mouseUp(document, { clientX: 20, clientY: 20 }); + + expect(onRegionLassoSelection).toHaveBeenCalledWith(['region-b', 'region-a'], { shiftKey: false }); + }); + + it('clears selection on a plain empty-space click', () => { + const { container, onRegionLassoSelection } = renderPanel(); + const firstTrackGrid = container.querySelector('[data-test-id="track-grid-1"]') as HTMLDivElement; + + fireEvent.mouseDown(firstTrackGrid, { clientX: 10, clientY: 10, button: 0 }); + fireEvent.mouseUp(document, { clientX: 11, clientY: 11 }); + + expect(onRegionLassoSelection).toHaveBeenCalledWith([], { shiftKey: false }); + }); +}); diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index 2a41ae6..ba13681 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -1,10 +1,11 @@ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { KGTrack, TrackType } from '../../core/track/KGTrack'; import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import TrackGridItem from './TrackGridItem'; import { Playhead, FileImportModal } from '../common'; +import SelectionBox from '../piano-roll/SelectionBox'; import type { RegionClickOptions, RegionUI } from '../interfaces'; -import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants'; +import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, REGION_CONSTANTS } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand, MoveMultipleRegionsCommand, ResizeMultipleRegionsCommand, ImportAudioCommand, ImportMidiClipCommand } from '../../core/commands'; @@ -30,6 +31,7 @@ interface TrackGridPanelProps { onRegionCreated: (trackIndex: number, region: RegionUI, midiRegion: KGMidiRegion) => void; onRegionUpdated?: (regionId: string, updates: Partial, expectedModelUpdates?: { startBeat: number, length: number }) => void; onRegionClick?: (regionId: string, options: RegionClickOptions) => void; + onRegionLassoSelection?: (regionIds: string[], options: RegionClickOptions) => void; onOpenPianoRoll?: (regionId: string) => void; onOpenSpectrogram?: (regionId: string) => void; showHybridButtonForAudio?: boolean; @@ -50,6 +52,7 @@ const TrackGridPanel: React.FC = ({ onRegionCreated, onRegionUpdated, onRegionClick, + onRegionLassoSelection, onOpenPianoRoll, onOpenSpectrogram, showHybridButtonForAudio, @@ -62,6 +65,10 @@ const TrackGridPanel: React.FC = ({ const gridContainerRef = useRef(null); const [showAudioImportModal, setShowAudioImportModal] = useState(false); const pendingAudioImportRef = useRef<{ barNumber: number; trackIndex: number } | null>(null); + const isLassoSelectingRef = useRef(false); + const isLassoShiftPressedRef = useRef(false); + const lassoBoxRef = useRef({ startX: 0, startY: 0, endX: 0, endY: 0 }); + const [lassoRenderTick, setLassoRenderTick] = useState(0); const getBulkSelectedRegionIds = (primaryRegionId: string) => ( selectedRegionIds.length > 1 && selectedRegionIds.includes(primaryRegionId) @@ -69,6 +76,111 @@ const TrackGridPanel: React.FC = ({ : [primaryRegionId] ); + const startLassoSelection = (e: React.MouseEvent) => { + if (e.button !== 0) return; + if (KGMainContentState.instance().getActiveTool() !== 'pointer') return; + if (isModifierKeyPressed(e)) return; + if (!(e.target instanceof HTMLElement)) return; + if (!e.target.closest('.track-grid')) return; + if (e.target.closest('.track-region')) return; + + const rect = gridContainerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const startX = e.clientX - rect.left; + const startY = e.clientY - rect.top; + + isLassoSelectingRef.current = true; + isLassoShiftPressedRef.current = e.shiftKey; + lassoBoxRef.current = { startX, startY, endX: startX, endY: startY }; + setLassoRenderTick(prev => prev + 1); + + document.addEventListener('mousemove', handleLassoMouseMove); + document.addEventListener('mouseup', handleLassoMouseUp); + }; + + const handleLassoMouseMove = (e: MouseEvent) => { + if (!isLassoSelectingRef.current || !gridContainerRef.current) return; + + const rect = gridContainerRef.current.getBoundingClientRect(); + lassoBoxRef.current = { + ...lassoBoxRef.current, + endX: e.clientX - rect.left, + endY: e.clientY - rect.top, + }; + setLassoRenderTick(prev => prev + 1); + }; + + const handleLassoMouseUp = () => { + if (!isLassoSelectingRef.current || !gridContainerRef.current) return; + + const { startX, startY, endX, endY } = lassoBoxRef.current; + const left = Math.min(startX, endX); + const top = Math.min(startY, endY); + const right = Math.max(startX, endX); + const bottom = Math.max(startY, endY); + const isClick = (right - left < PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD) + && (bottom - top < PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD); + + if (isClick) { + if (!isLassoShiftPressedRef.current) { + onRegionLassoSelection?.([], { shiftKey: false }); + } + } else { + const containerWidth = gridContainerRef.current.clientWidth; + const barWidth = containerWidth > 0 ? containerWidth / maxBars : 0; + const releasePointX = endX; + const releasePointY = endY; + const intersectedRegions = barWidth > 0 + ? regions.filter(region => { + const regionLeft = (region.barNumber - 1) * barWidth; + const regionRight = regionLeft + (region.length * barWidth); + const regionTop = region.trackIndex * 120; + const regionBottom = regionTop + 120; + + return !( + regionRight < left || + regionLeft > right || + regionBottom < top || + regionTop > bottom + ); + }) + : []; + + const releaseRegionIndex = intersectedRegions.findIndex(region => { + const regionLeft = (region.barNumber - 1) * barWidth; + const regionRight = regionLeft + (region.length * barWidth); + const regionTop = region.trackIndex * 120; + const regionBottom = regionTop + 120; + + return releasePointX >= regionLeft + && releasePointX <= regionRight + && releasePointY >= regionTop + && releasePointY <= regionBottom; + }); + + const intersectedRegionIds = intersectedRegions.map(region => region.id); + if (releaseRegionIndex > -1) { + const [releaseRegionId] = intersectedRegionIds.splice(releaseRegionIndex, 1); + intersectedRegionIds.push(releaseRegionId); + } + + onRegionLassoSelection?.(intersectedRegionIds, { shiftKey: isLassoShiftPressedRef.current }); + } + + isLassoSelectingRef.current = false; + setLassoRenderTick(prev => prev + 1); + document.removeEventListener('mousemove', handleLassoMouseMove); + document.removeEventListener('mouseup', handleLassoMouseUp); + }; + + useEffect(() => { + return () => { + document.removeEventListener('mousemove', handleLassoMouseMove); + document.removeEventListener('mouseup', handleLassoMouseUp); + }; + }, []); + // Utility function to create a region at a specific position const createRegionAtPosition = async (e: React.MouseEvent, trackIndex: number) => { // Get the grid container element @@ -738,7 +850,7 @@ const TrackGridPanel: React.FC = ({ }; return ( -
+
{/* Playhead */} @@ -780,6 +892,11 @@ const TrackGridPanel: React.FC = ({ title="Import Audio" description="Drag and drop your audio file here" /> +
); };