feat: added shortcut for split and merge regions

This commit is contained in:
Xiaohan-Tian
2026-05-18 18:39:42 -07:00
parent fe1b4fc8ae
commit d10965066f
6 changed files with 396 additions and 123 deletions
+181
View File
@@ -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<typeof import('../util/osUtil')>();
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<string, string> = {
'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(<HookHarness />);
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(<HookHarness />);
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');
});
});
});
+36 -3
View File
@@ -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
};