feat: enhance multi-region selection feature to support both shift and cmd/ctrl

This commit is contained in:
Xiaohan-Tian
2026-05-25 13:50:28 -07:00
parent 2c84b48f63
commit 1f330818d8
16 changed files with 763 additions and 93 deletions
-4
View File
@@ -340,10 +340,6 @@
transition: box-shadow 0.1s ease, border-color 0.1s ease;
}
.global-marker-region:hover {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.45);
}
.global-marker-region.selected {
border-color: #ffffff;
box-shadow: none;
+251 -5
View File
@@ -1,10 +1,11 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import MainContent from './MainContent';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGAudioRegion } from '../core/region/KGAudioRegion';
import { KGChordRegion } from '../core/region/KGChordRegion';
import { KGTempoRegion } from '../core/region/KGTempoRegion';
import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { createDefaultGlobalTracks, GlobalTrackType } from '../core/global-track';
import { createMockMidiTrack } from '../test/utils/mock-data';
@@ -71,7 +72,7 @@ const storeState = {
// eslint-disable-next-line no-unused-vars
type StoreSelector = (...args: [typeof storeState]) => unknown;
// eslint-disable-next-line no-unused-vars
type RegionClickHandler = (...args: [string, { shiftKey: boolean }]) => void;
type RegionClickHandler = (...args: [string, { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }]) => void;
const finishTrackCreateDialogClose = () => {
const overlay = document.querySelector('.dialog-overlay');
@@ -116,15 +117,27 @@ vi.mock('./track/TrackInfoPanel', () => ({
vi.mock('./track/TrackGridPanel', () => ({
default: ({ onRegionClick }: { onRegionClick?: RegionClickHandler }) => (
<>
<button type="button" onClick={() => onRegionClick?.('region-1', { shiftKey: false })}>
<button type="button" onClick={() => onRegionClick?.('region-1', { shiftKey: false, metaKey: false, ctrlKey: false })}>
select-midi-region
</button>
<button type="button" onClick={() => onRegionClick?.('region-2', { shiftKey: false })}>
<button type="button" onClick={() => onRegionClick?.('region-2', { shiftKey: false, metaKey: false, ctrlKey: false })}>
select-second-midi-region
</button>
<button type="button" onClick={() => onRegionClick?.('audio-1', { shiftKey: false })}>
<button type="button" onClick={() => onRegionClick?.('region-2', { shiftKey: true, metaKey: false, ctrlKey: false })}>
shift-select-second-midi-region
</button>
<button type="button" onClick={() => onRegionClick?.('region-2', { shiftKey: false, metaKey: true, ctrlKey: false })}>
meta-select-second-midi-region
</button>
<button type="button" onClick={() => onRegionClick?.('region-2', { shiftKey: true, metaKey: true, ctrlKey: false })}>
shift-meta-select-second-midi-region
</button>
<button type="button" onClick={() => onRegionClick?.('audio-1', { shiftKey: false, metaKey: false, ctrlKey: false })}>
select-audio-region
</button>
<button type="button" onClick={() => onRegionClick?.('audio-1', { shiftKey: false, metaKey: true, ctrlKey: false })}>
meta-select-audio-region
</button>
</>
),
}));
@@ -139,6 +152,9 @@ vi.mock('./piano-roll/PianoRoll', () => ({
describe('MainContent', () => {
beforeEach(() => {
midiTrack.setRegions([midiRegion, anotherMidiRegion]);
audioTrack.setRegions([audioRegion]);
storeState.tracks = [midiTrack, audioTrack];
storeState.globalTracks = createDefaultGlobalTracks();
storeState.selectedRegionIds = [];
storeState.activeRegionId = null;
@@ -207,6 +223,77 @@ describe('MainContent', () => {
expect(storeState.openSpectrogramViewer).toHaveBeenCalledWith('audio-1');
});
it('cmd-click adds a second regular region without range fill', () => {
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'select-midi-region' }));
fireEvent.click(screen.getByRole('button', { name: 'meta-select-second-midi-region' }));
expect(storeState.selectedRegionIds).toEqual(['region-1', 'region-2']);
expect(storeState.activeRegionId).toBe('region-2');
});
it('cmd-click on an already selected regular region removes it', () => {
storeState.selectedRegionIds = ['region-1', 'region-2'];
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'meta-select-second-midi-region' }));
expect(storeState.selectedRegionIds).toEqual(['region-1']);
});
it('shift-click on the same track selects the full in-between range and keeps the clicked region primary', () => {
const middleMidiRegion = new KGMidiRegion('region-1b', '1', 0, 'Region 1B', 4, 4);
midiTrack.setRegions([midiRegion, middleMidiRegion, anotherMidiRegion]);
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'select-midi-region' }));
fireEvent.click(screen.getByRole('button', { name: 'shift-select-second-midi-region' }));
expect(storeState.selectedRegionIds).toEqual(['region-1', 'region-1b', 'region-2']);
midiTrack.setRegions([midiRegion, anotherMidiRegion]);
});
it('shift-click with no same-track anchor falls back to single selection', () => {
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'shift-select-second-midi-region' }));
expect(storeState.selectedRegionIds).toEqual(['region-2']);
});
it('shift-click preserves already selected regions on other regular tracks', () => {
const middleMidiRegion = new KGMidiRegion('region-1b', '1', 0, 'Region 1B', 4, 4);
midiTrack.setRegions([midiRegion, middleMidiRegion, anotherMidiRegion]);
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'select-midi-region' }));
fireEvent.click(screen.getByRole('button', { name: 'meta-select-audio-region' }));
fireEvent.click(screen.getByRole('button', { name: 'shift-select-second-midi-region' }));
expect(storeState.selectedRegionIds).toEqual(['audio-1', 'region-1', 'region-1b', 'region-2']);
midiTrack.setRegions([midiRegion, anotherMidiRegion]);
});
it('shift-plus-cmd-click uses additive-toggle behavior instead of range selection', () => {
const middleMidiRegion = new KGMidiRegion('region-1b', '1', 0, 'Region 1B', 4, 4);
midiTrack.setRegions([midiRegion, middleMidiRegion, anotherMidiRegion]);
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'select-midi-region' }));
fireEvent.click(screen.getByRole('button', { name: 'shift-meta-select-second-midi-region' }));
expect(storeState.selectedRegionIds).toEqual(['region-1', 'region-2']);
midiTrack.setRegions([midiRegion, anotherMidiRegion]);
});
it('explicit close still clears piano roll visibility and active region', () => {
storeState.showPianoRoll = true;
storeState.activeRegionId = 'region-1';
@@ -352,6 +439,123 @@ describe('MainContent', () => {
expect((executeCommandMock.mock.calls[1][0] as { startBeat?: number }).startBeat).toBe(5);
});
it('selecting a global region clears regular-region selection', () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
chordTrack?.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 4),
]);
storeState.globalTracks = globalTracks;
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'select-midi-region' }));
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.click(screen.getByText('Am'));
expect(storeState.selectedRegionIds).toEqual(['chord-1']);
storeState.globalTracks = createDefaultGlobalTracks();
});
it('shift-click across globals ranges only within the same global lane and keeps the clicked region primary', () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
chordTrack?.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 4),
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'F', 4, 4),
new KGChordRegion('chord-3', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 8, 4),
]);
storeState.globalTracks = globalTracks;
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.click(screen.getByText('Am'));
fireEvent.click(screen.getByText('G'), { shiftKey: true });
expect(storeState.selectedRegionIds).toEqual(['chord-1', 'chord-2', 'chord-3']);
storeState.globalTracks = createDefaultGlobalTracks();
});
it('shift-clicking from the first chord region to the last chord region selects the full chord range', () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
chordTrack?.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 4),
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'F', 4, 4),
new KGChordRegion('chord-3', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 8, 4),
]);
storeState.globalTracks = globalTracks;
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
const firstChord = screen.getByText('Am');
const lastChord = screen.getByText('G');
fireEvent.mouseDown(firstChord, { clientX: 10, clientY: 10, button: 0 });
fireEvent.mouseUp(window, { clientX: 10, clientY: 10 });
fireEvent.click(firstChord);
fireEvent.mouseDown(lastChord, { clientX: 10, clientY: 10, button: 0, shiftKey: true });
fireEvent.mouseUp(window, { clientX: 10, clientY: 10, shiftKey: true });
fireEvent.click(lastChord, { shiftKey: true });
expect(storeState.selectedRegionIds).toEqual(['chord-1', 'chord-2', 'chord-3']);
storeState.globalTracks = createDefaultGlobalTracks();
});
it('cmd-clicking a region in a different global lane clears the previous global selection', () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo);
chordTrack?.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 4),
]);
tempoTrack?.setRegions([
new KGTempoRegion('tempo-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 128, 0, 4, 4),
]);
storeState.globalTracks = globalTracks;
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.click(screen.getByText('Am'));
fireEvent.click(screen.getByText('128 BPM'), { metaKey: true });
expect(storeState.selectedRegionIds).toEqual(['tempo-1']);
storeState.globalTracks = createDefaultGlobalTracks();
});
it('shift-clicking a region in a different global lane clears the previous global selection', () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo);
chordTrack?.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 4),
]);
tempoTrack?.setRegions([
new KGTempoRegion('tempo-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 128, 0, 4, 4),
new KGTempoRegion('tempo-2', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 4, 4, 4),
]);
storeState.globalTracks = globalTracks;
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.click(screen.getByText('Am'));
fireEvent.click(screen.getByText('140 BPM'), { shiftKey: true });
expect(storeState.selectedRegionIds).toEqual(['tempo-2']);
storeState.globalTracks = createDefaultGlobalTracks();
});
it('uses split-insert chord command when the playhead is inside an existing chord region', () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
@@ -439,4 +643,46 @@ describe('MainContent', () => {
expect(executeCommandMock).toHaveBeenCalledTimes(1);
});
it('creates new marker regions with a one-bar default length', () => {
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.click(screen.getByRole('button', { name: 'Add Marker global track item' }));
expect(executeCommandMock).toHaveBeenCalledTimes(1);
expect((executeCommandMock.mock.calls[0][0] as { preferredLength?: number }).preferredLength).toBe(
storeState.timeSignature.numerator
);
});
it('keeps a double-clicked global region editor visible past the sticky left panel', async () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
chordTrack?.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 40, 16),
]);
storeState.globalTracks = globalTracks;
const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
const { container } = render(<MainContent />);
const mainContent = container.querySelector('.main-content') as HTMLDivElement;
Object.defineProperty(mainContent, 'scrollWidth', { configurable: true, value: 4000 });
Object.defineProperty(mainContent, 'clientWidth', { configurable: true, value: 900 });
mainContent.scrollLeft = 500;
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.doubleClick(screen.getByText('Am'));
await waitFor(() => {
expect(mainContent.scrollLeft).toBe(188);
});
requestAnimationFrameSpy.mockRestore();
storeState.globalTracks = createDefaultGlobalTracks();
});
});
+273 -32
View File
@@ -70,6 +70,12 @@ const GLOBAL_TRACKS: GlobalTrackDefinition[] = [
{ id: 'chord', label: 'Chord' },
];
const DEFAULT_REGION_CLICK_OPTIONS: RegionClickOptions = {
shiftKey: false,
metaKey: false,
ctrlKey: false,
};
const MainContent: React.FC<MainContentProps> = ({
onTrackClick = () => { } // Default to empty function if not provided
}) => {
@@ -417,6 +423,57 @@ const MainContent: React.FC<MainContentProps> = ({
useProjectStore.setState({ mainContentScrollRequest: null });
}, [mainContentScrollRequest, timeSignature]);
useEffect(() => {
const editingRegionId = editingGlobalRegionId
?? editingTempoRegionId
?? editingKeySignatureRegionId
?? editingChordRegionId;
if (!editingRegionId) {
return;
}
const container = mainContentRef.current;
if (!container) {
return;
}
const editingRegion = findProjectRegionById(editingRegionId);
if (!(editingRegion instanceof KGGlobalRegion)) {
return;
}
const barWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier;
const leftInset = (parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width'),
10
) || 200) + 12;
const regionStartBeat = editingRegion instanceof KGTempoRegion || editingRegion instanceof KGKeySignatureRegion
? editingRegion.getStartBar() * timeSignature.numerator
: editingRegion.getStartFromBeat();
const regionStartPixel = (regionStartBeat / timeSignature.numerator) * barWidth;
const minimumVisiblePixel = container.scrollLeft + leftInset;
if (regionStartPixel >= minimumVisiblePixel) {
return;
}
const targetScrollLeft = Math.max(0, regionStartPixel - leftInset);
requestAnimationFrame(() => {
if (!mainContentRef.current) {
return;
}
mainContentRef.current.scrollLeft = targetScrollLeft;
});
}, [
barWidthMultiplier,
editingChordRegionId,
editingGlobalRegionId,
editingKeySignatureRegionId,
editingTempoRegionId,
findProjectRegionById,
timeSignature.numerator,
]);
// Effect to verify track updates
useEffect(() => {
// Check for pending updates
@@ -514,7 +571,7 @@ const MainContent: React.FC<MainContentProps> = ({
if (!regionExists) return;
pendingAutoSelectionRegionIdRef.current = null;
selectRegion(pendingRegionId, { shiftKey: false }, regions);
selectRegion(pendingRegionId, DEFAULT_REGION_CLICK_OPTIONS, regions);
}, [regions]);
// Handle track name edit
@@ -783,9 +840,137 @@ const MainContent: React.FC<MainContentProps> = ({
}
};
const isAdditiveSelection = (options: RegionClickOptions) => options.metaKey || options.ctrlKey;
const getOrderedTrackRegionIds = useCallback((trackId: string) => (
regions
.filter(region => region.trackId === trackId)
.sort((left, right) => {
if (left.barNumber !== right.barNumber) {
return left.barNumber - right.barNumber;
}
if (left.length !== right.length) {
return left.length - right.length;
}
return left.id.localeCompare(right.id);
})
.map(region => region.id)
), [regions]);
const getOrderedGlobalRegionIds = useCallback((regionType: GlobalTrackDefinition['id']) => (
globalTracks
.flatMap(globalTrack => globalTrack.getRegions())
.filter((region): region is KGGlobalRegion => {
if (regionType === 'tempo') {
return region instanceof KGTempoRegion;
}
if (regionType === 'signature') {
return region instanceof KGKeySignatureRegion;
}
if (regionType === 'chord') {
return region instanceof KGChordRegion;
}
return region instanceof KGMarkerRegion;
})
.sort((left, right) => {
const leftStart = left instanceof KGTempoRegion || left instanceof KGKeySignatureRegion
? left.getStartBar()
: Math.round(left.getStartFromBeat() / timeSignature.numerator);
const rightStart = right instanceof KGTempoRegion || right instanceof KGKeySignatureRegion
? right.getStartBar()
: Math.round(right.getStartFromBeat() / timeSignature.numerator);
if (leftStart !== rightStart) {
return leftStart - rightStart;
}
const leftLength = left instanceof KGTempoRegion || left instanceof KGKeySignatureRegion
? left.getLengthBars()
: left.getLength();
const rightLength = right instanceof KGTempoRegion || right instanceof KGKeySignatureRegion
? right.getLengthBars()
: right.getLength();
if (leftLength !== rightLength) {
return leftLength - rightLength;
}
return left.getId().localeCompare(right.getId());
})
.map(region => region.getId())
), [globalTracks, timeSignature.numerator]);
const appendPrimarySelection = (orderedIds: string[], primaryRegionId: string) => {
const deduped = orderedIds.filter(id => id !== primaryRegionId);
return [...deduped, primaryRegionId];
};
const buildSameTrackRangeSelection = (
existingRegularSelectionIds: string[],
anchorRegionId: string,
targetRegionId: string,
orderedTrackRegionIds: string[]
) => {
const anchorIndex = orderedTrackRegionIds.indexOf(anchorRegionId);
const targetIndex = orderedTrackRegionIds.indexOf(targetRegionId);
if (anchorIndex === -1 || targetIndex === -1) {
return [targetRegionId];
}
const [startIndex, endIndex] = anchorIndex <= targetIndex
? [anchorIndex, targetIndex]
: [targetIndex, anchorIndex];
const rangeIds = orderedTrackRegionIds.slice(startIndex, endIndex + 1);
const rangeIdSet = new Set(rangeIds);
const preservedOtherTrackIds = existingRegularSelectionIds.filter(selectedId => !rangeIdSet.has(selectedId));
return appendPrimarySelection([...preservedOtherTrackIds, ...rangeIds], targetRegionId);
};
const buildSameLaneRangeSelection = (
existingGlobalSelectionIds: string[],
anchorRegionId: string,
targetRegionId: string,
orderedLaneRegionIds: string[]
) => {
const anchorIndex = orderedLaneRegionIds.indexOf(anchorRegionId);
const targetIndex = orderedLaneRegionIds.indexOf(targetRegionId);
if (anchorIndex === -1 || targetIndex === -1) {
return [targetRegionId];
}
const [startIndex, endIndex] = anchorIndex <= targetIndex
? [anchorIndex, targetIndex]
: [targetIndex, anchorIndex];
const rangeIds = orderedLaneRegionIds.slice(startIndex, endIndex + 1);
const laneIdSet = new Set(orderedLaneRegionIds);
const preservedOtherLaneIds = existingGlobalSelectionIds.filter(selectedId => !laneIdSet.has(selectedId));
return appendPrimarySelection([...preservedOtherLaneIds, ...rangeIds], targetRegionId);
};
const getGlobalRegionLaneType = (region: KGGlobalRegion): GlobalTrackDefinition['id'] => {
if (region instanceof KGTempoRegion) {
return 'tempo';
}
if (region instanceof KGKeySignatureRegion) {
return 'signature';
}
if (region instanceof KGChordRegion) {
return 'chord';
}
return 'marker';
};
const selectRegion = (
regionId: string,
options: RegionClickOptions = { shiftKey: false },
options: RegionClickOptions = DEFAULT_REGION_CLICK_OPTIONS,
regionsToSearch?: RegionUI[]
) => {
const regionsToUse = regionsToSearch || regions;
@@ -814,35 +999,91 @@ const MainContent: React.FC<MainContentProps> = ({
}
const regularSelectedRegionIds = selectedRegionIds.filter(selectedId => !isGlobalRegionId(selectedId));
const orderedSelection = options.shiftKey
? (selectedRegionIds.includes(regionId)
const additiveSelection = isAdditiveSelection(options);
let orderedSelection: string[];
if (additiveSelection) {
orderedSelection = regularSelectedRegionIds.includes(regionId)
? regularSelectedRegionIds.filter(id => id !== regionId)
: [...regularSelectedRegionIds, regionId])
: [regionId];
: appendPrimarySelection([...regularSelectedRegionIds, regionId], regionId);
} else if (options.shiftKey) {
const sameTrackSelectedIds = regularSelectedRegionIds.filter(selectedId => {
const selectedRegion = regionsToUse.find(candidate => candidate.id === selectedId);
return selectedRegion?.trackId === region.trackId;
});
const anchorRegionId = [...sameTrackSelectedIds].reverse().find(selectedId => selectedId !== regionId) ?? null;
if (!anchorRegionId) {
orderedSelection = [regionId];
} else {
orderedSelection = buildSameTrackRangeSelection(
regularSelectedRegionIds,
anchorRegionId,
regionId,
getOrderedTrackRegionIds(region.trackId)
);
}
} else {
orderedSelection = [regionId];
}
applyRegionSelection(orderedSelection);
};
const selectGlobalRegion = (regionId: string, options: RegionClickOptions = { shiftKey: false }) => {
const selectGlobalRegion = (regionId: string, options: RegionClickOptions = DEFAULT_REGION_CLICK_OPTIONS) => {
const globalSelectedRegionIds = selectedRegionIds.filter(selectedId => isGlobalRegionId(selectedId));
const orderedSelection = options.shiftKey
? (globalSelectedRegionIds.includes(regionId)
? globalSelectedRegionIds.filter(id => id !== regionId)
: [...globalSelectedRegionIds, regionId])
: [regionId];
const globalRegion = findProjectRegionById(regionId);
if (!(globalRegion instanceof KGGlobalRegion)) {
return;
}
const targetLaneType = getGlobalRegionLaneType(globalRegion);
const sameLaneSelectedRegionIds = globalSelectedRegionIds.filter(selectedId => {
const selectedRegion = findProjectRegionById(selectedId);
return selectedRegion instanceof KGGlobalRegion && getGlobalRegionLaneType(selectedRegion) === targetLaneType;
});
const additiveSelection = isAdditiveSelection(options);
let orderedSelection: string[];
if (additiveSelection) {
orderedSelection = sameLaneSelectedRegionIds.includes(regionId)
? sameLaneSelectedRegionIds.filter(id => id !== regionId)
: appendPrimarySelection([...sameLaneSelectedRegionIds, regionId], regionId);
} else if (options.shiftKey) {
const orderedLaneRegionIds = getOrderedGlobalRegionIds(targetLaneType);
const laneRegionIdSet = new Set(orderedLaneRegionIds);
const sameLaneSelectedIds = sameLaneSelectedRegionIds.filter(selectedId => laneRegionIdSet.has(selectedId));
const anchorRegionId = [...sameLaneSelectedIds].reverse().find(selectedId => selectedId !== regionId) ?? null;
if (!anchorRegionId) {
orderedSelection = [regionId];
} else {
orderedSelection = buildSameLaneRangeSelection(
sameLaneSelectedRegionIds,
anchorRegionId,
regionId,
orderedLaneRegionIds
);
}
} else {
orderedSelection = [regionId];
}
applyRegionSelection(orderedSelection);
};
const handleRegionLassoSelection = (regionIds: string[], options: RegionClickOptions = { shiftKey: false }) => {
const handleRegionLassoSelection = (regionIds: string[], options: RegionClickOptions = DEFAULT_REGION_CLICK_OPTIONS) => {
const orderedRegionIds = regionIds.filter(regionId => regions.some(region => region.id === regionId));
const regularSelectedRegionIds = selectedRegionIds.filter(selectedId => !isGlobalRegionId(selectedId));
const orderedSelection = options.shiftKey
const additiveSelection = isAdditiveSelection(options);
const orderedSelection = additiveSelection
? orderedRegionIds.reduce<string[]>((nextSelection, regionId) => {
if (nextSelection.includes(regionId)) {
return nextSelection.filter(id => id !== regionId);
}
return [...nextSelection, regionId];
return appendPrimarySelection([...nextSelection, regionId], regionId);
}, [...regularSelectedRegionIds])
: orderedRegionIds;
@@ -859,7 +1100,7 @@ const MainContent: React.FC<MainContentProps> = ({
return;
}
handleRegionLassoSelection([], { shiftKey: false });
handleRegionLassoSelection([], DEFAULT_REGION_CLICK_OPTIONS);
};
const handleRegionLassoCommit = () => {
@@ -867,7 +1108,7 @@ const MainContent: React.FC<MainContentProps> = ({
};
// Handle region single click: selection only (no piano roll opening)
const handleRegionClick = (regionId: string, options: RegionClickOptions = { shiftKey: false }) => {
const handleRegionClick = (regionId: string, options: RegionClickOptions = DEFAULT_REGION_CLICK_OPTIONS) => {
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Region clicked in MainContent (selection only): ${regionId}`);
}
@@ -900,7 +1141,7 @@ const MainContent: React.FC<MainContentProps> = ({
}
// Reuse selection logic
handleRegionClick(regionId, { shiftKey: false });
handleRegionClick(regionId, DEFAULT_REGION_CLICK_OPTIONS);
// Activate and show piano roll in midi-edit mode
openMidiPianoRoll(regionId);
@@ -908,7 +1149,7 @@ const MainContent: React.FC<MainContentProps> = ({
// Handle spectrogram viewer open
const handleOpenSpectrogram = (regionId: string) => {
handleRegionClick(regionId, { shiftKey: false });
handleRegionClick(regionId, DEFAULT_REGION_CLICK_OPTIONS);
openSpectrogramViewer(regionId);
};
@@ -1002,7 +1243,7 @@ const MainContent: React.FC<MainContentProps> = ({
const normalizedStartBeat = Math.max(0, Math.round(requestedStartBeat));
const occupiedRegion = markerRegions.find(region => region.getStartFromBeat() === normalizedStartBeat);
if (occupiedRegion) {
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
selectGlobalRegion(occupiedRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
beginEditingGlobalRegion(occupiedRegion.getId());
return;
}
@@ -1010,7 +1251,7 @@ const MainContent: React.FC<MainContentProps> = ({
try {
const command = new CreateGlobalMarkerRegionCommand(
normalizedStartBeat,
8 * timeSignature.numerator,
timeSignature.numerator,
DEFAULT_MARKER_REGION_NAME
);
KGCore.instance().executeCommand(command);
@@ -1021,7 +1262,7 @@ const MainContent: React.FC<MainContentProps> = ({
return;
}
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
selectGlobalRegion(createdRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
setEditingGlobalRegionId(createdRegion.getId());
setEditingGlobalRegionText(createdRegion.getName());
} catch (error) {
@@ -1057,7 +1298,7 @@ const MainContent: React.FC<MainContentProps> = ({
const normalizedStartBar = Math.max(0, Math.min(requestedStartBar, maxBars - 1));
const existingRegionAtStart = signatureRegions.find(region => region.getStartBar() === normalizedStartBar);
if (existingRegionAtStart) {
selectGlobalRegion(existingRegionAtStart.getId(), { shiftKey: false });
selectGlobalRegion(existingRegionAtStart.getId(), DEFAULT_REGION_CLICK_OPTIONS);
beginEditingKeySignatureRegion(existingRegionAtStart.getId());
return;
}
@@ -1072,7 +1313,7 @@ const MainContent: React.FC<MainContentProps> = ({
return;
}
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
selectGlobalRegion(createdRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
setEditingKeySignatureRegionId(createdRegion.getId());
} catch (error) {
console.error('Error creating key signature region:', error);
@@ -1142,7 +1383,7 @@ const MainContent: React.FC<MainContentProps> = ({
const normalizedStartBar = Math.max(0, Math.min(requestedStartBar, maxBars - 1));
const existingRegionAtStart = tempoRegions.find(region => region.getStartBar() === normalizedStartBar);
if (existingRegionAtStart) {
selectGlobalRegion(existingRegionAtStart.getId(), { shiftKey: false });
selectGlobalRegion(existingRegionAtStart.getId(), DEFAULT_REGION_CLICK_OPTIONS);
beginEditingTempoRegion(existingRegionAtStart.getId());
return;
}
@@ -1158,7 +1399,7 @@ const MainContent: React.FC<MainContentProps> = ({
return;
}
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
selectGlobalRegion(createdRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
setEditingTempoRegionId(createdRegion.getId());
setEditingTempoText(createdRegion.getBpm().toString());
} catch (error) {
@@ -1189,7 +1430,7 @@ const MainContent: React.FC<MainContentProps> = ({
&& normalizedStartBeat < region.getStartFromBeat() + region.getLength()
));
if (occupiedRegion) {
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
selectGlobalRegion(occupiedRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
beginEditingChordRegion(occupiedRegion.getId());
return;
}
@@ -1204,7 +1445,7 @@ const MainContent: React.FC<MainContentProps> = ({
return;
}
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
selectGlobalRegion(createdRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
setEditingChordRegionId(createdRegion.getId());
} catch (error) {
console.error('Error creating chord region:', error);
@@ -1229,19 +1470,19 @@ const MainContent: React.FC<MainContentProps> = ({
const createdRegion = command.getCreatedRegion();
if (!createdRegion) {
if (occupiedRegion) {
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
selectGlobalRegion(occupiedRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
beginEditingChordRegion(occupiedRegion.getId());
}
return null;
}
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
selectGlobalRegion(createdRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
setEditingChordRegionId(createdRegion.getId());
return createdRegion;
} catch (error) {
console.error('Error creating chord region at exact beat:', error);
if (occupiedRegion) {
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
selectGlobalRegion(occupiedRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
beginEditingChordRegion(occupiedRegion.getId());
}
return null;
@@ -1278,7 +1519,7 @@ const MainContent: React.FC<MainContentProps> = ({
.find(region => region.getStartFromBeat() < currentStartBeat);
if (targetRegion) {
selectGlobalRegion(targetRegion.getId(), { shiftKey: false });
selectGlobalRegion(targetRegion.getId(), DEFAULT_REGION_CLICK_OPTIONS);
setEditingChordRegionId(targetRegion.getId());
return;
}
@@ -29,6 +29,7 @@ describe('GlobalChordLane', () => {
onResizeRegion={vi.fn()}
onChangeChord={vi.fn()}
onOpenPopup={onOpenPopup}
onTabNavigate={vi.fn()}
/>
);
@@ -54,6 +55,7 @@ describe('GlobalChordLane', () => {
onResizeRegion={vi.fn()}
onChangeChord={vi.fn()}
onOpenPopup={vi.fn()}
onTabNavigate={vi.fn()}
/>
);
@@ -95,6 +97,7 @@ describe('GlobalChordLane', () => {
onResizeRegion={vi.fn()}
onChangeChord={vi.fn()}
onOpenPopup={vi.fn()}
onTabNavigate={vi.fn()}
/>
);
@@ -117,4 +120,31 @@ describe('GlobalChordLane', () => {
expect(onMoveRegion).toHaveBeenCalledWith('chord-1', 2);
});
it('passes modifier state through region selection clicks', () => {
const onSelectRegion = vi.fn();
render(
<GlobalChordLane
chordRegions={[baseRegion]}
maxBars={8}
barWidthMultiplier={1}
timeSignature={{ numerator: 4, denominator: 4 }}
selectedRegionIds={[]}
popupRegionId={null}
onClosePopup={vi.fn()}
onSelectRegion={onSelectRegion}
onCreateAtBeat={vi.fn()}
onMoveRegion={vi.fn()}
onResizeRegion={vi.fn()}
onChangeChord={vi.fn()}
onOpenPopup={vi.fn()}
onTabNavigate={vi.fn()}
/>
);
fireEvent.click(screen.getByText('Cmaj7'), { metaKey: true, shiftKey: true });
expect(onSelectRegion).toHaveBeenCalledWith('chord-1', { shiftKey: true, metaKey: true, ctrlKey: false });
});
});
@@ -27,6 +27,11 @@ type ResizeEdge = 'start' | 'end' | null;
const REGION_EDGE_HITBOX_PX = 8;
const DRAG_THRESHOLD_PX = 4;
const getRegionClickOptions = (event: Pick<MouseEvent | React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
shiftKey: event.shiftKey,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
});
const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
chordRegions,
@@ -48,6 +53,7 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
const [previewBeats, setPreviewBeats] = useState<Record<string, { startBeat: number; length: number }>>({});
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
const [isModifierPressed, setIsModifierPressed] = useState(false);
const suppressClickSelectionRef = useRef(false);
const interactionRef = useRef<{
mode: 'drag' | 'resize' | null;
regionId: string;
@@ -193,7 +199,8 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
if (!interaction.moved) {
setPreviewBeats({});
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
suppressClickSelectionRef.current = true;
onSelectRegion(interaction.regionId, getRegionClickOptions(event));
return;
}
@@ -283,7 +290,7 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
onSelectRegion(region.getId(), { shiftKey: false });
onSelectRegion(region.getId(), { shiftKey: false, metaKey: false, ctrlKey: false });
onOpenPopup(region.getId());
}}
onMouseMove={(event) => {
@@ -334,7 +341,11 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
}}
onClick={(event) => {
event.stopPropagation();
onSelectRegion(region.getId(), { shiftKey: event.shiftKey });
if (suppressClickSelectionRef.current) {
suppressClickSelectionRef.current = false;
return;
}
onSelectRegion(region.getId(), getRegionClickOptions(event));
}}
>
<span className="global-marker-label">{region.getSymbol()}</span>
@@ -26,6 +26,11 @@ type ResizeEdge = 'start' | 'end' | null;
const REGION_EDGE_HITBOX_PX = 8;
const DRAG_THRESHOLD_PX = 4;
const getRegionClickOptions = (event: Pick<MouseEvent | React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
shiftKey: event.shiftKey,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
});
const GlobalKeySignatureLane: React.FC<GlobalKeySignatureLaneProps> = ({
signatureRegions,
@@ -45,6 +50,7 @@ const GlobalKeySignatureLane: React.FC<GlobalKeySignatureLaneProps> = ({
const [previewBars, setPreviewBars] = useState<Record<string, { startBar: number; lengthBars: number }>>({});
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
const [isModifierPressed, setIsModifierPressed] = useState(false);
const suppressClickSelectionRef = useRef(false);
const interactionRef = useRef<{
mode: 'resize' | null;
regionId: string;
@@ -223,7 +229,8 @@ const GlobalKeySignatureLane: React.FC<GlobalKeySignatureLaneProps> = ({
setPreviewBars({});
if (!shouldResize) {
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
suppressClickSelectionRef.current = true;
onSelectRegion(interaction.regionId, getRegionClickOptions(event));
return;
}
@@ -326,7 +333,11 @@ const GlobalKeySignatureLane: React.FC<GlobalKeySignatureLaneProps> = ({
}}
onClick={(event) => {
event.stopPropagation();
onSelectRegion(region.getId(), { shiftKey: event.shiftKey });
if (suppressClickSelectionRef.current) {
suppressClickSelectionRef.current = false;
return;
}
onSelectRegion(region.getId(), getRegionClickOptions(event));
}}
onDoubleClick={(event) => {
event.preventDefault();
@@ -26,6 +26,11 @@ type ResizeEdge = 'start' | 'end' | null;
const REGION_EDGE_HITBOX_PX = 8;
const DRAG_THRESHOLD_PX = 4;
const getRegionClickOptions = (event: Pick<MouseEvent | React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
shiftKey: event.shiftKey,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
});
const GlobalMarkerLane: React.FC<GlobalMarkerLaneProps> = ({
markerRegions,
@@ -186,7 +191,7 @@ const GlobalMarkerLane: React.FC<GlobalMarkerLaneProps> = ({
if (!interaction.moved) {
setPreviewBeats({});
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
onSelectRegion(interaction.regionId, getRegionClickOptions(event));
return;
}
@@ -263,7 +268,7 @@ const GlobalMarkerLane: React.FC<GlobalMarkerLaneProps> = ({
}}
onDoubleClick={(event) => {
event.stopPropagation();
onSelectRegion(region.getId(), { shiftKey: false });
onSelectRegion(region.getId(), { shiftKey: false, metaKey: false, ctrlKey: false });
onBeginEdit(region.getId());
}}
onMouseMove={(event) => {
@@ -120,4 +120,30 @@ describe('GlobalTempoLane', () => {
expect(container.querySelector('.global-marker-region.global-tempo-region')).toBeInTheDocument();
});
it('passes modifier state through region selection clicks', () => {
const onSelectRegion = vi.fn();
render(
<GlobalTempoLane
tempoRegions={[baseRegion]}
maxBars={8}
barWidthMultiplier={1}
selectedRegionIds={[]}
editingRegionId={null}
editingText=""
onEditingTextChange={vi.fn()}
onCommitEdit={vi.fn()}
onCancelEdit={vi.fn()}
onBeginEdit={vi.fn()}
onSelectRegion={onSelectRegion}
onCreateAtBar={vi.fn()}
onResizeRegion={vi.fn()}
/>
);
fireEvent.click(screen.getByText('128 BPM'), { ctrlKey: true });
expect(onSelectRegion).toHaveBeenCalledWith('tempo-1', { shiftKey: false, metaKey: false, ctrlKey: true });
});
});
@@ -24,6 +24,11 @@ type ResizeEdge = 'start' | 'end' | null;
const REGION_EDGE_HITBOX_PX = 8;
const DRAG_THRESHOLD_PX = 4;
const getRegionClickOptions = (event: Pick<MouseEvent | React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
shiftKey: event.shiftKey,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
});
const GlobalTempoLane: React.FC<GlobalTempoLaneProps> = ({
tempoRegions,
@@ -44,6 +49,7 @@ const GlobalTempoLane: React.FC<GlobalTempoLaneProps> = ({
const [previewBars, setPreviewBars] = useState<Record<string, { startBar: number; lengthBars: number }>>({});
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
const [isModifierPressed, setIsModifierPressed] = useState(false);
const suppressClickSelectionRef = useRef(false);
const interactionRef = useRef<{
mode: 'resize' | null;
regionId: string;
@@ -222,7 +228,8 @@ const GlobalTempoLane: React.FC<GlobalTempoLaneProps> = ({
setPreviewBars({});
if (!shouldResize) {
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
suppressClickSelectionRef.current = true;
onSelectRegion(interaction.regionId, getRegionClickOptions(event));
return;
}
@@ -328,12 +335,16 @@ const GlobalTempoLane: React.FC<GlobalTempoLaneProps> = ({
}}
onClick={(event) => {
event.stopPropagation();
onSelectRegion(region.getId(), { shiftKey: event.shiftKey });
if (suppressClickSelectionRef.current) {
suppressClickSelectionRef.current = false;
return;
}
onSelectRegion(region.getId(), getRegionClickOptions(event));
}}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
onSelectRegion(region.getId(), { shiftKey: false });
onSelectRegion(region.getId(), { shiftKey: false, metaKey: false, ctrlKey: false });
onBeginEdit(region.getId());
}}
>
+2
View File
@@ -25,6 +25,8 @@ export interface RegionPreviewContentStyle {
export interface RegionClickOptions {
shiftKey: boolean;
metaKey: boolean;
ctrlKey: boolean;
}
// Define resize action types
-8
View File
@@ -15,10 +15,6 @@
will-change: transform;
}
.track-region:hover {
box-shadow: 0 0 0 1px #7a9bba;
}
.track-region.selected {
/* box-shadow: 0 0 0 2px #ffffff, 0 0 8px rgba(255, 255, 255, 0.3); */ /* let's only apply a border */
border-color: #ffffff;
@@ -99,10 +95,6 @@
border-color: #4a8b5a;
}
.track-region.audio-region:hover {
box-shadow: 0 0 0 1px #6aab7a;
}
.track-region.audio-region.selected {
border-color: #ffffff;
}
+15 -2
View File
@@ -72,7 +72,7 @@ describe('RegionItem', () => {
fireEvent.mouseMove(document, { clientX: 102, clientY: 102 });
fireEvent.mouseUp(document, { clientX: 102, clientY: 102 });
expect(onClick).toHaveBeenCalledWith('midi-1', { shiftKey: false });
expect(onClick).toHaveBeenCalledWith('midi-1', { shiftKey: false, metaKey: false, ctrlKey: false });
expect(onDragStart).not.toHaveBeenCalled();
expect(onDrag).not.toHaveBeenCalled();
expect(onDragEnd).not.toHaveBeenCalled();
@@ -88,7 +88,20 @@ describe('RegionItem', () => {
fireEvent.mouseDown(region!, { clientX: 100, clientY: 100, shiftKey: true });
fireEvent.mouseUp(document, { clientX: 100, clientY: 100, shiftKey: true });
expect(onClick).toHaveBeenCalledWith('midi-1', { shiftKey: true });
expect(onClick).toHaveBeenCalledWith('midi-1', { shiftKey: true, metaKey: false, ctrlKey: false });
});
it('passes cmd-click state through the region click callback', () => {
const onClick = vi.fn();
const { container } = renderRegion({ onClick });
const region = container.querySelector('.track-region');
expect(region).toBeTruthy();
fireEvent.mouseDown(region!, { clientX: 100, clientY: 100, metaKey: true });
fireEvent.mouseUp(document, { clientX: 100, clientY: 100, metaKey: true });
expect(onClick).toHaveBeenCalledWith('midi-1', { shiftKey: false, metaKey: true, ctrlKey: false });
});
it('starts a drag after crossing the movement threshold', () => {
+8 -3
View File
@@ -14,6 +14,11 @@ import { KGCore } from '../../core/KGCore';
import { beatRangeToSeconds } from '../../util/globalTrackUtil';
const DRAG_START_THRESHOLD_PX = 4;
const getRegionClickOptions = (event: Pick<React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
shiftKey: event.shiftKey,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
});
interface RegionItemProps {
id: string;
@@ -481,7 +486,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
if (DEBUG_MODE.REGION_ITEM) {
console.log(`REGION CLICKED (pencil mode): regionId=${id}`);
}
onClick(id, { shiftKey: e.shiftKey });
onClick(id, getRegionClickOptions(e));
}
return;
}
@@ -608,7 +613,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
if (DEBUG_MODE.REGION_ITEM) {
console.log(`REGION CLICKED: regionId=${id}`);
}
onClick(id, { shiftKey: e.shiftKey });
onClick(id, getRegionClickOptions(e));
}
isPendingDragRef.current = false;
@@ -708,7 +713,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
if (onOpenPianoRoll) {
onOpenPianoRoll(id);
} else if (onClick) {
onClick(id, { shiftKey: e.shiftKey });
onClick(id, getRegionClickOptions(e));
}
}}
aria-label="Open piano roll"
+11 -17
View File
@@ -8,7 +8,6 @@ import TrackAutomationLane from './TrackAutomationLane';
import type { RegionClickOptions, RegionPreviewContentStyle, RegionUI, ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
import { KGMainContentState } from '../../core/state/KGMainContentState';
import { isModifierKeyPressed } from '../../util/osUtil';
import { useProjectStore } from '../../stores/projectStore';
interface RegionResizePreviewBaseline {
@@ -200,28 +199,23 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
};
}, [gridContainerRef]);
// Track modifier key state for cursor feedback
// Track tool state for cursor feedback.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isModifierKeyPressed(e)) {
setIsModifierPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!isModifierKeyPressed(e)) {
setIsModifierPressed(false);
}
const syncCursorState = () => {
setIsModifierPressed(KGMainContentState.instance().getActiveTool() === 'pencil');
};
// Add global event listeners
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('keydown', syncCursorState);
window.addEventListener('keyup', syncCursorState);
window.addEventListener('focus', syncCursorState);
syncCursorState();
// Cleanup listeners on unmount
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('keydown', syncCursorState);
window.removeEventListener('keyup', syncCursorState);
window.removeEventListener('focus', syncCursorState);
};
}, []);
@@ -785,7 +779,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onOpenPianoRoll(regionId);
} else if (onRegionClick) {
// Fallback to legacy behavior
onRegionClick(regionId, { shiftKey: false });
onRegionClick(regionId, { shiftKey: false, metaKey: false, ctrlKey: false });
}
}}
onOpenSpectrogram={audioRegion ? (regionId) => {
+72 -6
View File
@@ -4,6 +4,9 @@ import { fireEvent, render } from '@testing-library/react';
import TrackGridPanel from './TrackGridPanel';
import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
const executeCommandMock = vi.fn();
const getCreatedRegionMock = vi.fn();
vi.mock('../../stores/projectStore', () => ({
useProjectStore: (selector?: (state: {
selectedRegionIds: string[],
@@ -26,6 +29,36 @@ vi.mock('../common', () => ({
FileImportModal: () => null,
}));
vi.mock('../../core/KGCore', () => ({
KGCore: {
instance: () => ({
executeCommand: executeCommandMock,
}),
},
}));
vi.mock('../../util/miscUtil', () => ({
generateNewRegionName: () => 'New Region',
}));
vi.mock('../../core/commands', async () => {
const actual = await vi.importActual<typeof import('../../core/commands')>('../../core/commands');
return {
...actual,
CreateRegionCommand: {
fromBarCoordinates: vi.fn((trackId: string, trackIndex: number, barNumber: number) => ({
getCreatedRegion: () => getCreatedRegionMock() ?? createMockMidiRegion({
id: 'created-region',
trackId,
trackIndex,
startFromBeat: (barNumber - 1) * 4,
length: 4,
}),
})),
},
};
});
describe('TrackGridPanel lasso selection', () => {
beforeAll(() => {
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
@@ -57,6 +90,7 @@ describe('TrackGridPanel lasso selection', () => {
trackB.setTrackIndex(1);
const onRegionLassoSelection = vi.fn();
const onRegionCreated = vi.fn();
const view = render(
<TrackGridPanel
@@ -71,7 +105,7 @@ describe('TrackGridPanel lasso selection', () => {
dragOverTrackIndex={null}
selectedRegionId={null}
projectName="Test"
onRegionCreated={vi.fn()}
onRegionCreated={onRegionCreated}
onRegionLassoSelection={onRegionLassoSelection}
/>
);
@@ -90,11 +124,13 @@ describe('TrackGridPanel lasso selection', () => {
toJSON: () => ({}),
});
return { ...view, onRegionLassoSelection };
return { ...view, onRegionLassoSelection, onRegionCreated };
};
beforeEach(() => {
vi.restoreAllMocks();
executeCommandMock.mockReset();
getCreatedRegionMock.mockReset();
});
it('selects intersecting regions across multiple track rows', () => {
@@ -105,7 +141,7 @@ describe('TrackGridPanel lasso selection', () => {
fireEvent.mouseMove(document, { clientX: 130, clientY: 200 });
fireEvent.mouseUp(document, { clientX: 130, clientY: 200 });
expect(onRegionLassoSelection).toHaveBeenCalledWith(['region-a', 'region-b'], { shiftKey: false });
expect(onRegionLassoSelection).toHaveBeenCalledWith(['region-a', 'region-b'], { shiftKey: false, metaKey: false, ctrlKey: false });
});
it('moves the release-point region to the end of the lasso selection order', () => {
@@ -116,7 +152,7 @@ describe('TrackGridPanel lasso selection', () => {
fireEvent.mouseMove(document, { clientX: 20, clientY: 20 });
fireEvent.mouseUp(document, { clientX: 20, clientY: 20 });
expect(onRegionLassoSelection).toHaveBeenCalledWith(['region-b', 'region-a'], { shiftKey: false });
expect(onRegionLassoSelection).toHaveBeenCalledWith(['region-b', 'region-a'], { shiftKey: false, metaKey: false, ctrlKey: false });
});
it('uses the closest intersected region as primary when release is outside all regions', () => {
@@ -127,7 +163,7 @@ describe('TrackGridPanel lasso selection', () => {
fireEvent.mouseMove(document, { clientX: 150, clientY: 170 });
fireEvent.mouseUp(document, { clientX: 150, clientY: 170 });
expect(onRegionLassoSelection).toHaveBeenCalledWith(['region-a', 'region-b'], { shiftKey: false });
expect(onRegionLassoSelection).toHaveBeenCalledWith(['region-a', 'region-b'], { shiftKey: false, metaKey: false, ctrlKey: false });
});
it('clears selection on a plain empty-space click', () => {
@@ -137,6 +173,36 @@ describe('TrackGridPanel lasso selection', () => {
fireEvent.mouseDown(firstTrackGrid, { clientX: 10, clientY: 10, button: 0 });
fireEvent.mouseUp(document, { clientX: 11, clientY: 11 });
expect(onRegionLassoSelection).toHaveBeenCalledWith([], { shiftKey: false });
expect(onRegionLassoSelection).toHaveBeenCalledWith([], { shiftKey: false, metaKey: false, ctrlKey: false });
});
it('does not create a region when ctrl-clicking an existing region', () => {
const { container, onRegionCreated } = renderPanel();
const region = container.querySelector('[data-region-id="region-a"]') as HTMLDivElement;
fireEvent.mouseDown(region, { clientX: 20, clientY: 20, button: 0, ctrlKey: true });
fireEvent.mouseUp(document, { clientX: 20, clientY: 20, ctrlKey: true });
fireEvent.click(region, { ctrlKey: true });
expect(onRegionCreated).not.toHaveBeenCalled();
});
it('creates a region when ctrl-clicking empty track space', async () => {
const { container, onRegionCreated } = renderPanel();
const firstTrackGrid = container.querySelector('[data-test-id="track-grid-1"]') as HTMLDivElement;
getCreatedRegionMock.mockReturnValue(createMockMidiRegion({
id: 'created-region',
trackId: '1',
trackIndex: 0,
startFromBeat: 12,
length: 4,
name: 'New Region',
}));
fireEvent.click(firstTrackGrid, { clientX: 140, clientY: 20, ctrlKey: true });
await vi.waitFor(() => {
expect(onRegionCreated).toHaveBeenCalledTimes(1);
});
});
});
+27 -6
View File
@@ -20,6 +20,12 @@ import * as Tone from 'tone';
import { useProjectStore } from '../../stores/projectStore';
import { getAudioRegionDisplayLengthBeats } from '../../util/globalTrackUtil';
const getRegionClickOptions = (event: Pick<MouseEvent | React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
shiftKey: event.shiftKey,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
});
interface TrackGridPanelProps {
tracks: KGTrack[];
regions: RegionUI[];
@@ -130,7 +136,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
if (isClick) {
if (!isLassoShiftPressedRef.current) {
onRegionLassoSelection?.([], { shiftKey: false });
onRegionLassoSelection?.([], getRegionClickOptions({ shiftKey: false, metaKey: false, ctrlKey: false }));
}
} else {
const containerWidth = gridContainerRef.current.clientWidth;
@@ -194,7 +200,11 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
intersectedRegionIds.push(primaryRegionId);
}
onRegionLassoSelection?.(intersectedRegionIds, { shiftKey: isLassoShiftPressedRef.current });
onRegionLassoSelection?.(intersectedRegionIds, {
shiftKey: isLassoShiftPressedRef.current,
metaKey: false,
ctrlKey: false,
});
onRegionLassoCommit?.();
}
@@ -390,12 +400,23 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
createRegionAtPosition(e, trackIndex);
};
// Handle single click on track grid for pencil mode or modifier+click
// Handle single click on track grid for pencil mode or modifier-click on empty space.
const handleTrackGridClick = (e: React.MouseEvent<HTMLDivElement>, trackIndex: number) => {
// Create region on single click in pencil mode OR when modifier key is pressed
if (KGMainContentState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(e)) {
createRegionAtPosition(e, trackIndex);
if (!(e.target instanceof HTMLElement)) {
return;
}
const clickedInsideRegion = e.target.closest('.track-region');
if (clickedInsideRegion) {
return;
}
const isPencilMode = KGMainContentState.instance().getActiveTool() === 'pencil';
if (!isPencilMode && !isModifierKeyPressed(e)) {
return;
}
createRegionAtPosition(e, trackIndex);
};
// Handle region resize during drag