feat: allow user to use lasso to select multiple regions

This commit is contained in:
Xiaohan-Tian
2026-05-04 18:29:29 -07:00
parent 01a3f886d6
commit f323f1a6b5
3 changed files with 322 additions and 46 deletions
@@ -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(
<TrackGridPanel
tracks={[trackA, trackB]}
regions={[
{ id: 'region-a', trackId: '1', trackIndex: 0, barNumber: 1, length: 1, name: 'Region A' },
{ id: 'region-b', trackId: '2', trackIndex: 1, barNumber: 3, length: 1, name: 'Region B' },
]}
maxBars={8}
timeSignature={{ numerator: 4, denominator: 4 }}
draggedTrackIndex={null}
dragOverTrackIndex={null}
selectedRegionId={null}
projectName="Test"
onRegionCreated={vi.fn()}
onRegionLassoSelection={onRegionLassoSelection}
/>
);
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 });
});
});
+120 -3
View File
@@ -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<RegionUI>, 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<TrackGridPanelProps> = ({
onRegionCreated,
onRegionUpdated,
onRegionClick,
onRegionLassoSelection,
onOpenPianoRoll,
onOpenSpectrogram,
showHybridButtonForAudio,
@@ -62,6 +65,10 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
const gridContainerRef = useRef<HTMLDivElement>(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<TrackGridPanelProps> = ({
: [primaryRegionId]
);
const startLassoSelection = (e: React.MouseEvent<HTMLDivElement>) => {
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<HTMLDivElement>, trackIndex: number) => {
// Get the grid container element
@@ -738,7 +850,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
};
return (
<div className="grid-container" ref={gridContainerRef}>
<div className="grid-container" ref={gridContainerRef} onMouseDownCapture={startLassoSelection}>
{/* Playhead */}
<Playhead context="main-grid" />
@@ -780,6 +892,11 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
title="Import Audio"
description="Drag and drop your audio file here"
/>
<SelectionBox
key={lassoRenderTick}
isSelecting={isLassoSelectingRef.current}
selectionBox={lassoBoxRef.current}
/>
</div>
);
};