diff --git a/public/config.json b/public/config.json index 5a9763c..8f6b495 100644 --- a/public/config.json +++ b/public/config.json @@ -49,7 +49,9 @@ "copy": "ctrl+c", "cut": "ctrl+x", "paste": "ctrl+v", - "save": "ctrl+s" + "save": "ctrl+s", + "split_region": "ctrl+t", + "merge_regions": "ctrl+j" }, "piano_roll": { "switch": "tab", diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index da6941f..be83bbb 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -16,14 +16,11 @@ import { } from 'react-icons/fa'; import { KGProject, type KeySignature } from '../core/KGProject'; import { KGMidiInput } from '../core/midi-input/KGMidiInput'; -import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { plainToInstance } from 'class-transformer'; import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6'; import { KGMainContentState } from '../core/state/KGMainContentState'; import { regionDeleteManager } from '../util/regionDeleteUtil'; -import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand'; -import { MergeMidiRegionsCommand } from '../core/commands/region/MergeMidiRegionsCommand'; import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil'; import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil'; import { KEY_SIGNATURE_MAP } from '../constants/coreConstants'; @@ -35,7 +32,7 @@ import OpenProjectModal from './common/OpenProjectModal'; import { clearChatHistoryAndUI } from '../util/chatUtil'; import PianoIcon from './common/icons/PianoIcon'; import MetronomeIcon from './common/icons/MetronomeIcon'; -import { ConfigManager } from '../core/config/ConfigManager'; +import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil'; import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil'; const Toolbar: React.FC = () => { @@ -791,43 +788,19 @@ const Toolbar: React.FC = () => { console.log("Split button clicked"); } - if (selectedRegionIds.length === 0) { - await showAlert("Please select a region to split."); - return; - } - if (selectedRegionIds.length > 1) { - await showAlert("Please select exactly one region to split."); + const status = await splitSelectedRegionAtPlayhead({ + selectedRegionIds, + playheadPosition, + refreshProjectState, + }); + if (!status) { return; } - const regionId = lastSelectedRegionId; - const tracks = KGCore.instance().getCurrentProject().getTracks(); - let targetRegion = null; - for (const track of tracks) { - const found = track.getRegions().find(r => r.getId() === regionId); - if (found) { targetRegion = found; break; } - } - - if (!targetRegion) { - await showAlert("Selected region not found."); - return; - } - - const regionStart = targetRegion.getStartFromBeat(); - const regionEnd = regionStart + targetRegion.getLength(); - - if (playheadPosition <= regionStart || playheadPosition >= regionEnd) { - await showAlert("The playhead is not inside the selected region. Move the playhead inside the region before splitting."); - return; - } - - const command = new SplitRegionCommand(regionId, playheadPosition); - KGCore.instance().executeCommand(command); - refreshProjectState(); - setStatus(`Split region at beat ${playheadPosition.toFixed(2)}`); + setStatus(status); if (DEBUG_MODE.TOOLBAR) { - console.log(`Split region ${regionId} at beat ${playheadPosition}`); + console.log(`Split selected region at beat ${playheadPosition}`); } }; @@ -836,92 +809,15 @@ const Toolbar: React.FC = () => { console.log('Merge button clicked'); } - if (selectedRegionIds.length < 2) { - await showAlert('Please select at least two MIDI regions on the same track to merge.'); - return; - } - - const tracks = KGCore.instance().getCurrentProject().getTracks(); - const selectedRegionIdSet = new Set(selectedRegionIds); - const selectedMidiRegions: KGMidiRegion[] = []; - let targetTrackId: string | null = null; - - for (const track of tracks) { - for (const region of track.getRegions()) { - if (!selectedRegionIdSet.has(region.getId())) { - continue; - } - - if (!(region instanceof KGMidiRegion)) { - await showAlert('Only MIDI regions can be merged. Please adjust your selection and try again.'); - return; - } - - const regionTrackId = track.getId().toString(); - if (targetTrackId && targetTrackId !== regionTrackId) { - await showAlert('Please select only MIDI regions from a single track before merging.'); - return; - } - - targetTrackId = regionTrackId; - selectedMidiRegions.push(region); - } - } - - if (selectedMidiRegions.length !== selectedRegionIds.length || !targetTrackId) { - await showAlert('Some selected regions could not be found. Please reselect the MIDI regions and try again.'); - return; - } - - const sortedSelectedRegions = [...selectedMidiRegions].sort((a, b) => { - const startDelta = a.getStartFromBeat() - b.getStartFromBeat(); - if (startDelta !== 0) return startDelta; - return a.getLength() - b.getLength(); + const status = await mergeSelectedMidiRegions({ + selectedRegionIds, + refreshProjectState, }); - - let regionIdsToMerge = selectedRegionIds; - const firstSelectedRegion = sortedSelectedRegions[0]; - const lastSelectedRegion = sortedSelectedRegions[sortedSelectedRegions.length - 1]; - const spanStart = firstSelectedRegion.getStartFromBeat(); - const spanEnd = lastSelectedRegion.getStartFromBeat() + lastSelectedRegion.getLength(); - - const targetTrack = tracks.find(track => track.getId().toString() === targetTrackId); - const inBetweenRegions = targetTrack - ?.getRegions() - .filter(region => ( - region instanceof KGMidiRegion && - !selectedRegionIdSet.has(region.getId()) && - region.getStartFromBeat() >= spanStart && - region.getStartFromBeat() <= spanEnd - )) ?? []; - - if (inBetweenRegions.length > 0) { - const shouldIncludeInBetweenRegions = await showConfirm( - 'There are additional MIDI regions between the first and last selected regions on this track. Would you like KGStudio to merge those as well?', - { - confirmLabel: 'Merge All In Between', - cancelLabel: 'Stop', - } - ); - - if (!shouldIncludeInBetweenRegions) { - return; - } - - regionIdsToMerge = Array.from(new Set([ - ...selectedRegionIds, - ...inBetweenRegions.map(region => region.getId()), - ])); + if (!status) { + return; } - try { - const command = new MergeMidiRegionsCommand(regionIdsToMerge); - KGCore.instance().executeCommand(command, { rethrow: true }); - refreshProjectState(); - setStatus(`Merged ${regionIdsToMerge.length} MIDI regions`); - } catch (error) { - await showAlert(error instanceof Error ? error.message : 'Unable to merge the selected MIDI regions.'); - } + setStatus(status); }; // Handle undo button click diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 1886049..8a0027b 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -47,6 +47,7 @@ interface AppConfig { hold_to_create_region: string; play: string; loop: string; + record: string; undo: string; redo: string; select_all: string; @@ -54,6 +55,8 @@ interface AppConfig { cut: string; paste: string; save: string; + split_region: string; + merge_regions: string; }; piano_roll: { switch: string; diff --git a/src/hooks/useGlobalKeyboardHandler.test.tsx b/src/hooks/useGlobalKeyboardHandler.test.tsx new file mode 100644 index 0000000..305c7a8 --- /dev/null +++ b/src/hooks/useGlobalKeyboardHandler.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useGlobalKeyboardHandler } from './useGlobalKeyboardHandler'; + +const regionEditUtilMocks = vi.hoisted(() => ({ + splitSelectedRegionAtPlayhead: vi.fn(), + mergeSelectedMidiRegions: vi.fn(), +})); + +const storeState = { + undo: vi.fn(), + redo: vi.fn(), + setStatus: vi.fn(), + isPlaying: false, + startPlaying: vi.fn(), + stopTransport: vi.fn(), + toggleLoop: vi.fn(), + projectName: 'Test Project', + savedProjectName: 'Test Project', + setSavedProjectName: vi.fn(), + setProjectName: vi.fn(), + isRecording: false, + startRecording: vi.fn(), + stopRecording: vi.fn(), + activeRegionId: null, + selectedRegionIds: ['region-a', 'region-b'], + setActiveRegionId: vi.fn(), + setShowPianoRoll: vi.fn(), + showPianoRoll: false, + openMidiPianoRoll: vi.fn(), + openSpectrogramViewer: vi.fn(), + playheadPosition: 12, + refreshProjectState: vi.fn(), +}; + +type StoreState = typeof storeState; + +vi.mock('../stores/projectStore', () => ({ + useProjectStore: (selector?: unknown) => ( + selector ? (selector as (state: StoreState) => unknown)(storeState) : storeState + ), +})); + +vi.mock('../util/osUtil', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + matchesKeyboardShortcut: (event: KeyboardEvent, shortcut: string) => { + if (shortcut === 'ctrl+t') { + return event.ctrlKey && event.key.toLowerCase() === 't'; + } + if (shortcut === 'ctrl+j') { + return event.ctrlKey && event.key.toLowerCase() === 'j'; + } + return false; + }, + }; +}); + +vi.mock('../util/copyPasteUtil', () => ({ + handleCopyOperation: vi.fn(() => false), + handlePasteOperation: vi.fn(() => false), +})); + +vi.mock('../util/saveUtil', () => ({ + saveProject: vi.fn(), +})); + +vi.mock('../core/config/ConfigManager', () => ({ + ConfigManager: { + instance: () => ({ + getIsInitialized: () => true, + get: (path: string) => { + const shortcuts: Record = { + 'hotkeys.main.undo': 'ctrl+z', + 'hotkeys.main.redo': 'ctrl+shift+z', + 'hotkeys.main.copy': 'ctrl+c', + 'hotkeys.main.paste': 'ctrl+v', + 'hotkeys.main.select_all': 'ctrl+a', + 'hotkeys.main.play': 'space', + 'hotkeys.main.loop': 'c', + 'hotkeys.main.save': 'ctrl+s', + 'hotkeys.main.record': 'r', + 'hotkeys.main.split_region': 'ctrl+t', + 'hotkeys.main.merge_regions': 'ctrl+j', + }; + return shortcuts[path]; + }, + }), + }, +})); + +vi.mock('../util/selectionUtil', () => ({ + selectAllNotesInActiveRegion: vi.fn(), +})); + +vi.mock('../core/KGCore', () => ({ + KGCore: { + instance: () => ({ + getCurrentProject: () => ({ + getTracks: () => [], + }), + }), + }, +})); + +vi.mock('../core/midi-input/KGMidiInput', () => ({ + KGMidiInput: { + instance: () => ({ + getConnectedInputCount: () => 0, + }), + }, +})); + +vi.mock('../core/region/KGMidiRegion', () => ({ + KGMidiRegion: class {}, +})); + +vi.mock('../core/track/KGAudioTrack', () => ({ + KGAudioTrack: class {}, +})); + +vi.mock('../util/regionEditUtil', () => ({ + splitSelectedRegionAtPlayhead: regionEditUtilMocks.splitSelectedRegionAtPlayhead, + mergeSelectedMidiRegions: regionEditUtilMocks.mergeSelectedMidiRegions, +})); + +vi.mock('../util/dialogUtil', () => ({ + showAlert: vi.fn(), +})); + +const HookHarness = () => { + useGlobalKeyboardHandler(); + return null; +}; + +describe('useGlobalKeyboardHandler region shortcuts', () => { + beforeEach(() => { + regionEditUtilMocks.splitSelectedRegionAtPlayhead.mockReset(); + regionEditUtilMocks.mergeSelectedMidiRegions.mockReset(); + storeState.setStatus.mockClear(); + }); + + it('triggers split on Ctrl+T', async () => { + regionEditUtilMocks.splitSelectedRegionAtPlayhead.mockResolvedValue('Split region at beat 12.00'); + + render(); + fireEvent.keyDown(document.body, { key: 't', ctrlKey: true }); + + await waitFor(() => { + expect(regionEditUtilMocks.splitSelectedRegionAtPlayhead).toHaveBeenCalledWith({ + selectedRegionIds: ['region-a', 'region-b'], + playheadPosition: 12, + refreshProjectState: storeState.refreshProjectState, + }); + }); + + await waitFor(() => { + expect(storeState.setStatus).toHaveBeenCalledWith('Split region at beat 12.00'); + }); + }); + + it('triggers merge on Ctrl+J', async () => { + regionEditUtilMocks.mergeSelectedMidiRegions.mockResolvedValue('Merged 2 MIDI regions'); + + render(); + fireEvent.keyDown(document.body, { key: 'j', ctrlKey: true }); + + await waitFor(() => { + expect(regionEditUtilMocks.mergeSelectedMidiRegions).toHaveBeenCalledWith({ + selectedRegionIds: ['region-a', 'region-b'], + refreshProjectState: storeState.refreshProjectState, + }); + }); + + await waitFor(() => { + expect(storeState.setStatus).toHaveBeenCalledWith('Merged 2 MIDI regions'); + }); + }); +}); diff --git a/src/hooks/useGlobalKeyboardHandler.ts b/src/hooks/useGlobalKeyboardHandler.ts index 2e005c9..589d551 100644 --- a/src/hooks/useGlobalKeyboardHandler.ts +++ b/src/hooks/useGlobalKeyboardHandler.ts @@ -9,6 +9,7 @@ import { KGCore } from '../core/KGCore'; import { KGMidiInput } from '../core/midi-input/KGMidiInput'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; +import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil'; import { showAlert } from '../util/dialogUtil'; /** @@ -16,14 +17,14 @@ import { showAlert } from '../util/dialogUtil'; * Handles keyboard shortcuts defined in the configuration */ 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 { undo, redo, setStatus, isPlaying, startPlaying, stopTransport, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRoll, openSpectrogramViewer, playheadPosition, refreshProjectState } = useProjectStore(); const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Skip if user is typing in an input field - const target = event.target as HTMLElement; - if (target && ( + const target = event.target; + if (target instanceof HTMLElement && ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true' || @@ -71,6 +72,8 @@ export const useGlobalKeyboardHandler = () => { const loopShortcut = configManager.get('hotkeys.main.loop') as string; const saveShortcut = configManager.get('hotkeys.main.save') as string; const recordShortcut = configManager.get('hotkeys.main.record') as string; + const splitShortcut = configManager.get('hotkeys.main.split_region') as string; + const mergeShortcut = configManager.get('hotkeys.main.merge_regions') as string; // Check for undo shortcut if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) { @@ -202,6 +205,33 @@ export const useGlobalKeyboardHandler = () => { return; } + if (splitShortcut && matchesKeyboardShortcut(event, splitShortcut)) { + event.preventDefault(); + void splitSelectedRegionAtPlayhead({ + selectedRegionIds, + playheadPosition, + refreshProjectState, + }).then(status => { + if (status) { + setStatus(status); + } + }); + return; + } + + if (mergeShortcut && matchesKeyboardShortcut(event, mergeShortcut)) { + event.preventDefault(); + void mergeSelectedMidiRegions({ + selectedRegionIds, + refreshProjectState, + }).then(status => { + if (status) { + setStatus(status); + } + }); + return; + } + // Check for edit/view shortcut (E) — open piano roll for MIDI, spectrogram for audio if (event.key.toLowerCase() === 'e' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { event.preventDefault(); @@ -272,11 +302,14 @@ export const useGlobalKeyboardHandler = () => { startRecording, stopRecording, activeRegionId, + selectedRegionIds, lastSelectedRegionId, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRoll, openSpectrogramViewer, + playheadPosition, + refreshProjectState, ]); // Include dependencies for store actions }; diff --git a/src/util/regionEditUtil.ts b/src/util/regionEditUtil.ts new file mode 100644 index 0000000..669464f --- /dev/null +++ b/src/util/regionEditUtil.ts @@ -0,0 +1,158 @@ +import { KGCore } from '../core/KGCore'; +import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand'; +import { MergeMidiRegionsCommand } from '../core/commands/region/MergeMidiRegionsCommand'; +import { KGMidiRegion } from '../core/region/KGMidiRegion'; +import { showAlert, showConfirm } from './dialogUtil'; + +interface SplitSelectedRegionParams { + selectedRegionIds: string[]; + playheadPosition: number; + refreshProjectState: () => void; +} + +interface MergeSelectedMidiRegionsParams { + selectedRegionIds: string[]; + refreshProjectState: () => void; +} + +export const splitSelectedRegionAtPlayhead = async ({ + selectedRegionIds, + playheadPosition, + refreshProjectState, +}: SplitSelectedRegionParams): Promise => { + if (selectedRegionIds.length === 0) { + await showAlert('Please select a region to split.'); + return null; + } + + if (selectedRegionIds.length > 1) { + await showAlert('Please select exactly one region to split.'); + return null; + } + + const regionId = selectedRegionIds[selectedRegionIds.length - 1]; + const tracks = KGCore.instance().getCurrentProject().getTracks(); + let targetRegion = null; + + for (const track of tracks) { + const foundRegion = track.getRegions().find(region => region.getId() === regionId); + if (foundRegion) { + targetRegion = foundRegion; + break; + } + } + + if (!targetRegion) { + await showAlert('Selected region not found.'); + return null; + } + + const regionStart = targetRegion.getStartFromBeat(); + const regionEnd = regionStart + targetRegion.getLength(); + + if (playheadPosition <= regionStart || playheadPosition >= regionEnd) { + await showAlert('The playhead is not inside the selected region. Move the playhead inside the region before splitting.'); + return null; + } + + const command = new SplitRegionCommand(regionId, playheadPosition); + KGCore.instance().executeCommand(command); + refreshProjectState(); + + return `Split region at beat ${playheadPosition.toFixed(2)}`; +}; + +export const mergeSelectedMidiRegions = async ({ + selectedRegionIds, + refreshProjectState, +}: MergeSelectedMidiRegionsParams): Promise => { + if (selectedRegionIds.length < 2) { + await showAlert('Please select at least two MIDI regions on the same track to merge.'); + return null; + } + + const tracks = KGCore.instance().getCurrentProject().getTracks(); + const selectedRegionIdSet = new Set(selectedRegionIds); + const selectedMidiRegions: KGMidiRegion[] = []; + let targetTrackId: string | null = null; + + for (const track of tracks) { + for (const region of track.getRegions()) { + if (!selectedRegionIdSet.has(region.getId())) { + continue; + } + + if (!(region instanceof KGMidiRegion)) { + await showAlert('Only MIDI regions can be merged. Please adjust your selection and try again.'); + return null; + } + + const regionTrackId = track.getId().toString(); + if (targetTrackId && targetTrackId !== regionTrackId) { + await showAlert('Please select only MIDI regions from a single track before merging.'); + return null; + } + + targetTrackId = regionTrackId; + selectedMidiRegions.push(region); + } + } + + if (selectedMidiRegions.length !== selectedRegionIds.length || !targetTrackId) { + await showAlert('Some selected regions could not be found. Please reselect the MIDI regions and try again.'); + return null; + } + + const sortedSelectedRegions = [...selectedMidiRegions].sort((a, b) => { + const startDelta = a.getStartFromBeat() - b.getStartFromBeat(); + if (startDelta !== 0) { + return startDelta; + } + return a.getLength() - b.getLength(); + }); + + let regionIdsToMerge = selectedRegionIds; + const firstSelectedRegion = sortedSelectedRegions[0]; + const lastSelectedRegion = sortedSelectedRegions[sortedSelectedRegions.length - 1]; + const spanStart = firstSelectedRegion.getStartFromBeat(); + const spanEnd = lastSelectedRegion.getStartFromBeat() + lastSelectedRegion.getLength(); + + const targetTrack = tracks.find(track => track.getId().toString() === targetTrackId); + const inBetweenRegions = targetTrack + ?.getRegions() + .filter(region => ( + region instanceof KGMidiRegion && + !selectedRegionIdSet.has(region.getId()) && + region.getStartFromBeat() >= spanStart && + region.getStartFromBeat() <= spanEnd + )) ?? []; + + if (inBetweenRegions.length > 0) { + const shouldIncludeInBetweenRegions = await showConfirm( + 'There are additional MIDI regions between the first and last selected regions on this track. Would you like KGStudio to merge those as well?', + { + confirmLabel: 'Merge All In Between', + cancelLabel: 'Stop', + } + ); + + if (!shouldIncludeInBetweenRegions) { + return null; + } + + regionIdsToMerge = Array.from(new Set([ + ...selectedRegionIds, + ...inBetweenRegions.map(region => region.getId()), + ])); + } + + try { + const command = new MergeMidiRegionsCommand(regionIdsToMerge); + KGCore.instance().executeCommand(command, { rethrow: true }); + refreshProjectState(); + return `Merged ${regionIdsToMerge.length} MIDI regions`; + } catch (error) { + await showAlert(error instanceof Error ? error.message : 'Unable to merge the selected MIDI regions.'); + return null; + } +};