feat: added shortcut for split and merge regions
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user