diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 00bc552..a6edb06 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -4,12 +4,13 @@ import { createPortal } from 'react-dom'; import { useProjectStore } from '../stores/projectStore'; import { KGCore } from '../core/KGCore'; import { KGTrack } from '../core/track/KGTrack'; +import { KGRegion } from '../core/region/KGRegion'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import TrackInfoPanel from './track/TrackInfoPanel'; import TrackGridPanel from './track/TrackGridPanel'; import PianoRoll from './piano-roll/PianoRoll'; -import type { RegionUI } from './interfaces'; +import type { RegionClickOptions, RegionUI } from './interfaces'; import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS, TOOLBAR_CONSTANTS } from '../constants'; import { useRegionOperations } from '../hooks/useRegionOperations'; import { regionDeleteManager } from '../util/regionDeleteUtil'; @@ -38,6 +39,7 @@ const MainContent: React.FC = ({ setAutoScrollEnabled, clearAllSelections, setSelectedTrack, + selectedRegionIds, showPianoRoll, activeRegionId, setShowPianoRoll, @@ -349,7 +351,7 @@ const MainContent: React.FC = ({ const updatedRegions = [...prevRegions, regionUI]; // Select the region using the updated regions array - selectRegion(regionUI.id, updatedRegions); + selectRegion(regionUI.id, { shiftKey: false }, updatedRegions); // Manually trigger selection sync to ensure UI updates immediately const { syncSelectionFromCore } = useProjectStore.getState(); @@ -384,7 +386,7 @@ const MainContent: React.FC = ({ setRegions(prev => { const updated = [...prev, regionUI]; - selectRegion(regionUI.id, updated); + selectRegion(regionUI.id, { shiftKey: false }, updated); return updated; }); }; @@ -518,10 +520,11 @@ const MainContent: React.FC = ({ }; // Helper function to select a region (clears previous selections) - const selectRegion = (regionId: string, regionsToSearch?: RegionUI[]) => { - // Clear any existing selections using store method - clearAllSelections(); - + 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); @@ -542,39 +545,81 @@ const MainContent: React.FC = ({ } // Find the region in the track's model - const trackRegions = track.getRegions(); - const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined; + const coreRegion = track.getRegions().find(r => r.getId() === regionId); - if (!midiRegion) { + if (!coreRegion) { if (DEBUG_MODE.MAIN_CONTENT) { - console.log(`MIDI region not found in track model: ${regionId}`); + console.log(`Region not found in track model: ${regionId}`); } return; } - // Add the region to KGCore's selection const core = KGCore.instance(); - core.addSelectedItem(midiRegion); + const orderedSelection = options.shiftKey + ? (selectedRegionIds.includes(regionId) + ? selectedRegionIds.filter(id => id !== regionId) + : [...selectedRegionIds, regionId]) + : [regionId]; - // Update the region's internal selection state - midiRegion.select(); + tracks.forEach(projectTrack => { + projectTrack.getRegions().forEach(projectRegion => projectRegion.deselect()); + }); - // Set the selected region (this might be redundant now, but keeping for compatibility) - setSelectedRegionId(regionId); + clearAllSelections(); + + const selectedRegions: KGRegion[] = orderedSelection + .map(selectedId => { + for (const projectTrack of tracks) { + const selectedRegion = projectTrack.getRegions().find(r => r.getId() === selectedId); + if (selectedRegion) { + selectedRegion.select(); + return selectedRegion as KGRegion; + } + } + return null; + }) + .filter((selectedRegion): selectedRegion is KGRegion => selectedRegion !== null); + + if (selectedRegions.length > 0) { + core.addSelectedItems(selectedRegions); + } + + const lastSelectedRegionId = selectedRegions.length > 0 + ? selectedRegions[selectedRegions.length - 1].getId() + : null; + + setSelectedRegionId(lastSelectedRegionId); if (DEBUG_MODE.MAIN_CONTENT) { - console.log(`Selected region: ${regionId} (added to KGCore selection)`); + console.log(`Selected regions: ${selectedRegions.map(selectedRegion => selectedRegion.getId()).join(', ')}`); + } + + if (!showPianoRoll) { + return; + } + + if (!lastSelectedRegionId) { + setShowPianoRoll(false); + setActiveRegionId(null); + return; + } + + const lastSelectedRegion = selectedRegions[selectedRegions.length - 1]; + if (lastSelectedRegion instanceof KGAudioRegion) { + openSpectrogramViewer(lastSelectedRegionId); + } else if (lastSelectedRegion instanceof KGMidiRegion) { + openMidiPianoRoll(lastSelectedRegionId); } }; // Handle region single click: selection only (no piano roll opening) - const handleRegionClick = (regionId: string) => { + const handleRegionClick = (regionId: string, options: RegionClickOptions = { shiftKey: false }) => { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Region clicked in MainContent (selection only): ${regionId}`); } // Select the region - selectRegion(regionId); + selectRegion(regionId, options); // Also select the containing track const region = regions.find(r => r.id === regionId); @@ -583,15 +628,6 @@ const MainContent: React.FC = ({ if (!track) return; setSelectedTrack(track.getId().toString()); - // If the piano roll window is already open, follow the selected region's type - if (showPianoRoll) { - const coreRegion = track.getRegions().find(r => r.getId() === regionId); - if (coreRegion?.getCurrentType() === 'KGAudioRegion') { - openSpectrogramViewer(regionId); - } else if (coreRegion?.getCurrentType() === 'KGMidiRegion') { - openMidiPianoRoll(regionId); - } - } }; // Handle explicit pencil action: select region and open piano roll @@ -610,7 +646,7 @@ const MainContent: React.FC = ({ } // Reuse selection logic - handleRegionClick(regionId); + handleRegionClick(regionId, { shiftKey: false }); // Activate and show piano roll in midi-edit mode openMidiPianoRoll(regionId); @@ -618,7 +654,7 @@ const MainContent: React.FC = ({ // Handle spectrogram viewer open const handleOpenSpectrogram = (regionId: string) => { - handleRegionClick(regionId); + handleRegionClick(regionId, { shiftKey: false }); openSpectrogramViewer(regionId); }; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index d4f12a5..f2b076c 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -94,6 +94,7 @@ const Toolbar: React.FC = () => { // Export options const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"]; + const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null; const handleProjectNameClick = async () => { const newName = await showPrompt("Enter project name:", projectName); @@ -730,7 +731,7 @@ const Toolbar: React.FC = () => { return; } - const regionId = selectedRegionIds[0]; + const regionId = lastSelectedRegionId; const tracks = KGCore.instance().getCurrentProject().getTracks(); let targetRegion = null; for (const track of tracks) { @@ -845,7 +846,7 @@ const Toolbar: React.FC = () => { } // Prefer current active region; otherwise, first selected region - const candidateRegionId = activeRegionId || (selectedRegionIds && selectedRegionIds.length > 0 ? selectedRegionIds[0] : null); + const candidateRegionId = activeRegionId || lastSelectedRegionId; if (!candidateRegionId) { if (DEBUG_MODE.TOOLBAR) { @@ -877,7 +878,7 @@ const Toolbar: React.FC = () => { } // Require an active or selected MIDI region - const candidateId = activeRegionId ?? (selectedRegionIds[0] ?? null); + const candidateId = activeRegionId ?? lastSelectedRegionId; if (!candidateId) { await showAlert("Please open a MIDI region in the Piano Roll before starting recording."); return; diff --git a/src/components/interfaces.ts b/src/components/interfaces.ts index 9c6cbf1..5451700 100644 --- a/src/components/interfaces.ts +++ b/src/components/interfaces.ts @@ -18,6 +18,10 @@ export interface RegionUI { name: string; } +export interface RegionClickOptions { + shiftKey: boolean; +} + // Define resize action types export type ResizeAction = 'none' | 'start' | 'end'; @@ -39,4 +43,4 @@ export interface RegionDragState { initialY: number; initialBarNumber: number; initialTrackIndex: number; -} \ No newline at end of file +} diff --git a/src/components/track/Region.css b/src/components/track/Region.css index ef43a56..620911c 100644 --- a/src/components/track/Region.css +++ b/src/components/track/Region.css @@ -24,6 +24,10 @@ border-color: #ffffff; } +.track-region.selected-secondary { + border-color: rgba(255, 255, 255, 0.7); +} + .track-region.dragging { opacity: 0.8; box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); @@ -89,6 +93,10 @@ border-color: #ffffff; } +.track-region.audio-region.selected-secondary { + border-color: rgba(255, 255, 255, 0.7); +} + .track-region.audio-region .region-header { background-color: #4a8b5a; } diff --git a/src/components/track/RegionItem.test.tsx b/src/components/track/RegionItem.test.tsx index 7172df7..ee903b9 100644 --- a/src/components/track/RegionItem.test.tsx +++ b/src/components/track/RegionItem.test.tsx @@ -68,12 +68,25 @@ describe('RegionItem', () => { fireEvent.mouseMove(document, { clientX: 102, clientY: 102 }); fireEvent.mouseUp(document, { clientX: 102, clientY: 102 }); - expect(onClick).toHaveBeenCalledWith('midi-1'); + expect(onClick).toHaveBeenCalledWith('midi-1', { shiftKey: false }); expect(onDragStart).not.toHaveBeenCalled(); expect(onDrag).not.toHaveBeenCalled(); expect(onDragEnd).not.toHaveBeenCalled(); }); + it('passes shift-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, shiftKey: true }); + fireEvent.mouseUp(document, { clientX: 100, clientY: 100, shiftKey: true }); + + expect(onClick).toHaveBeenCalledWith('midi-1', { shiftKey: true }); + }); + it('starts a drag after crossing the movement threshold', () => { const onClick = vi.fn(); const onDragStart = vi.fn(); diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx index 934a9b2..81072b5 100644 --- a/src/components/track/RegionItem.tsx +++ b/src/components/track/RegionItem.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react'; import './Region.css'; import { FaPencilAlt, FaPlus } from 'react-icons/fa'; import { MdGraphicEq, MdSwapHoriz } from 'react-icons/md'; -import type { ResizeAction } from '../interfaces'; +import type { RegionClickOptions, ResizeAction } from '../interfaces'; import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants'; import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGAudioRegion } from '../../core/region/KGAudioRegion'; @@ -27,7 +27,7 @@ interface RegionItemProps { onDrag?: (regionId: string, deltaX: number, deltaY: number) => void; onDragEnd?: (regionId: string) => void; // Click prop - onClick?: (regionId: string) => void; + onClick?: (regionId: string, options: RegionClickOptions) => void; // Explicit open piano roll action from header pencil icon onOpenPianoRoll?: (regionId: string) => void; // Open spectrogram viewer for audio regions @@ -70,6 +70,7 @@ const RegionItem: React.FC = ({ // Get selection state and time signature from store const { selectedRegionIds, timeSignature, bpm } = useProjectStore(); const isSelected = selectedRegionIds.includes(id); + const isPrimarySelected = isSelected && selectedRegionIds[selectedRegionIds.length - 1] === id; const [cursor, setCursor] = useState('pointer'); const [resizeEdge, setResizeEdge] = useState('none'); const [isResizing, setIsResizing] = useState(false); @@ -408,7 +409,7 @@ const RegionItem: React.FC = ({ if (DEBUG_MODE.REGION_ITEM) { console.log(`REGION CLICKED (pencil mode): regionId=${id}`); } - onClick(id); + onClick(id, { shiftKey: e.shiftKey }); } return; } @@ -535,7 +536,7 @@ const RegionItem: React.FC = ({ if (DEBUG_MODE.REGION_ITEM) { console.log(`REGION CLICKED: regionId=${id}`); } - onClick(id); + onClick(id, { shiftKey: e.shiftKey }); } isPendingDragRef.current = false; @@ -602,7 +603,7 @@ const RegionItem: React.FC = ({ return (
= ({ if (onOpenPianoRoll) { onOpenPianoRoll(id); } else if (onClick) { - onClick(id); + onClick(id, { shiftKey: e.shiftKey }); } }} aria-label="Open piano roll" diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index dbb758c..f8c187b 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -4,7 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGAudioRegion } from '../../core/region/KGAudioRegion'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import RegionItem from './RegionItem'; -import type { RegionUI, ResizeAction } from '../interfaces'; +import type { RegionClickOptions, RegionUI, ResizeAction } from '../interfaces'; import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; @@ -25,7 +25,7 @@ interface TrackGridItemProps { onRegionDrag?: (regionId: string, newBarNumber: number, newTrackIndex: number) => void; onRegionDragEnd?: (regionId: string, finalBarNumber: number, finalTrackIndex: number) => void; onRegionFineMoveEnd?: (regionId: string, deltaInBars: number) => void; - onRegionClick?: (regionId: string) => void; + onRegionClick?: (regionId: string, options: RegionClickOptions) => void; onOpenPianoRoll?: (regionId: string) => void; onOpenSpectrogram?: (regionId: string) => void; showHybridButtonForAudio?: boolean; @@ -516,13 +516,13 @@ const TrackGridItem: React.FC = ({ }; // Handle region click - const handleRegionClick = (regionId: string) => { + const handleRegionClick = (regionId: string, options: RegionClickOptions) => { if (DEBUG_MODE.TRACK_GRID_ITEM) { console.log(`Region clicked: ${regionId}`); } if (onRegionClick) { - onRegionClick(regionId); + onRegionClick(regionId, options); } }; @@ -589,7 +589,7 @@ const TrackGridItem: React.FC = ({ onOpenPianoRoll(regionId); } else if (onRegionClick) { // Fallback to legacy behavior - onRegionClick(regionId); + onRegionClick(regionId, { shiftKey: false }); } }} onOpenSpectrogram={audioRegion ? (regionId) => { diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index c506142..e186464 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -3,7 +3,7 @@ import { KGTrack, TrackType } from '../../core/track/KGTrack'; import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import TrackGridItem from './TrackGridItem'; import { Playhead, FileImportModal } from '../common'; -import type { RegionUI } from '../interfaces'; +import type { RegionClickOptions, RegionUI } from '../interfaces'; import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; @@ -28,7 +28,7 @@ interface TrackGridPanelProps { projectName: string; onRegionCreated: (trackIndex: number, region: RegionUI, midiRegion: KGMidiRegion) => void; onRegionUpdated?: (regionId: string, updates: Partial, expectedModelUpdates?: { startBeat: number, length: number }) => void; - onRegionClick?: (regionId: string) => void; + onRegionClick?: (regionId: string, options: RegionClickOptions) => void; onOpenPianoRoll?: (regionId: string) => void; onOpenSpectrogram?: (regionId: string) => void; showHybridButtonForAudio?: boolean; @@ -512,7 +512,7 @@ const TrackGridPanel: React.FC = ({ }; // Handle region click - const handleRegionClick = (regionId: string) => { + const handleRegionClick = (regionId: string, options: RegionClickOptions) => { if (DEBUG_MODE.TRACK_GRID_PANEL) { console.log(`Region clicked in panel: ${regionId}`); } @@ -535,7 +535,7 @@ const TrackGridPanel: React.FC = ({ // Notify parent about the click if (onRegionClick) { - onRegionClick(regionId); + onRegionClick(regionId, options); } }; diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index 6864a7b..23073ae 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -450,11 +450,14 @@ export class KGCore { } public addSelectedItem(item: Selectable): void { + this.selectedItems = this.selectedItems.filter(i => i.getId() !== item.getId()); this.selectedItems.push(item); this.notifySelectionChanged(); } public addSelectedItems(items: Selectable[]): void { + const seenIds = new Set(items.map(item => item.getId())); + this.selectedItems = this.selectedItems.filter(item => !seenIds.has(item.getId())); this.selectedItems.push(...items); this.notifySelectionChanged(); } diff --git a/src/hooks/useGlobalKeyboardHandler.ts b/src/hooks/useGlobalKeyboardHandler.ts index 63d6e7a..f2d7c90 100644 --- a/src/hooks/useGlobalKeyboardHandler.ts +++ b/src/hooks/useGlobalKeyboardHandler.ts @@ -16,6 +16,7 @@ import { showAlert } from '../util/dialogUtil'; */ export const useGlobalKeyboardHandler = () => { const { undo, redo, setStatus, isPlaying, startPlaying, stopTransport, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRoll, openSpectrogramViewer } = useProjectStore(); + const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -166,7 +167,7 @@ export const useGlobalKeyboardHandler = () => { setStatus('Recording stopped — notes committed'); return; } - const candidateId = activeRegionId ?? (selectedRegionIds[0] ?? null); + const candidateId = activeRegionId ?? lastSelectedRegionId; if (!candidateId) { setStatus('Select a MIDI region before recording'); return; @@ -201,7 +202,7 @@ export const useGlobalKeyboardHandler = () => { setShowPianoRoll(false); return; } - const candidateId = activeRegionId ?? (selectedRegionIds[0] ?? null); + const candidateId = activeRegionId ?? lastSelectedRegionId; if (!candidateId) { void showAlert('Please select a region to open the editor.'); return; @@ -264,7 +265,7 @@ export const useGlobalKeyboardHandler = () => { startRecording, stopRecording, activeRegionId, - selectedRegionIds, + lastSelectedRegionId, setActiveRegionId, setShowPianoRoll, showPianoRoll,