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
+3 -1
View File
@@ -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",
+15 -119
View File
@@ -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
+3
View File
@@ -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;
+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
};
+158
View File
@@ -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<string | null> => {
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<string | null> => {
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;
}
};