feat: added shortcut for split and merge regions
This commit is contained in:
+3
-1
@@ -49,7 +49,9 @@
|
|||||||
"copy": "ctrl+c",
|
"copy": "ctrl+c",
|
||||||
"cut": "ctrl+x",
|
"cut": "ctrl+x",
|
||||||
"paste": "ctrl+v",
|
"paste": "ctrl+v",
|
||||||
"save": "ctrl+s"
|
"save": "ctrl+s",
|
||||||
|
"split_region": "ctrl+t",
|
||||||
|
"merge_regions": "ctrl+j"
|
||||||
},
|
},
|
||||||
"piano_roll": {
|
"piano_roll": {
|
||||||
"switch": "tab",
|
"switch": "tab",
|
||||||
|
|||||||
+15
-119
@@ -16,14 +16,11 @@ import {
|
|||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
|
||||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6';
|
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6';
|
||||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
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 { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
|
||||||
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
||||||
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||||
@@ -35,7 +32,7 @@ import OpenProjectModal from './common/OpenProjectModal';
|
|||||||
import { clearChatHistoryAndUI } from '../util/chatUtil';
|
import { clearChatHistoryAndUI } from '../util/chatUtil';
|
||||||
import PianoIcon from './common/icons/PianoIcon';
|
import PianoIcon from './common/icons/PianoIcon';
|
||||||
import MetronomeIcon from './common/icons/MetronomeIcon';
|
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';
|
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
|
||||||
|
|
||||||
const Toolbar: React.FC = () => {
|
const Toolbar: React.FC = () => {
|
||||||
@@ -791,43 +788,19 @@ const Toolbar: React.FC = () => {
|
|||||||
console.log("Split button clicked");
|
console.log("Split button clicked");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedRegionIds.length === 0) {
|
const status = await splitSelectedRegionAtPlayhead({
|
||||||
await showAlert("Please select a region to split.");
|
selectedRegionIds,
|
||||||
return;
|
playheadPosition,
|
||||||
}
|
refreshProjectState,
|
||||||
if (selectedRegionIds.length > 1) {
|
});
|
||||||
await showAlert("Please select exactly one region to split.");
|
if (!status) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const regionId = lastSelectedRegionId;
|
setStatus(status);
|
||||||
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)}`);
|
|
||||||
|
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
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');
|
console.log('Merge button clicked');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedRegionIds.length < 2) {
|
const status = await mergeSelectedMidiRegions({
|
||||||
await showAlert('Please select at least two MIDI regions on the same track to merge.');
|
selectedRegionIds,
|
||||||
return;
|
refreshProjectState,
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
if (!status) {
|
||||||
let regionIdsToMerge = selectedRegionIds;
|
return;
|
||||||
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()),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
setStatus(status);
|
||||||
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.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle undo button click
|
// Handle undo button click
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ interface AppConfig {
|
|||||||
hold_to_create_region: string;
|
hold_to_create_region: string;
|
||||||
play: string;
|
play: string;
|
||||||
loop: string;
|
loop: string;
|
||||||
|
record: string;
|
||||||
undo: string;
|
undo: string;
|
||||||
redo: string;
|
redo: string;
|
||||||
select_all: string;
|
select_all: string;
|
||||||
@@ -54,6 +55,8 @@ interface AppConfig {
|
|||||||
cut: string;
|
cut: string;
|
||||||
paste: string;
|
paste: string;
|
||||||
save: string;
|
save: string;
|
||||||
|
split_region: string;
|
||||||
|
merge_regions: string;
|
||||||
};
|
};
|
||||||
piano_roll: {
|
piano_roll: {
|
||||||
switch: string;
|
switch: string;
|
||||||
|
|||||||
@@ -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 { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
|
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
|
||||||
import { showAlert } from '../util/dialogUtil';
|
import { showAlert } from '../util/dialogUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,14 +17,14 @@ import { showAlert } from '../util/dialogUtil';
|
|||||||
* Handles keyboard shortcuts defined in the configuration
|
* Handles keyboard shortcuts defined in the configuration
|
||||||
*/
|
*/
|
||||||
export const useGlobalKeyboardHandler = () => {
|
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;
|
const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
// Skip if user is typing in an input field
|
// Skip if user is typing in an input field
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target;
|
||||||
if (target && (
|
if (target instanceof HTMLElement && (
|
||||||
target.tagName === 'INPUT' ||
|
target.tagName === 'INPUT' ||
|
||||||
target.tagName === 'TEXTAREA' ||
|
target.tagName === 'TEXTAREA' ||
|
||||||
target.contentEditable === 'true' ||
|
target.contentEditable === 'true' ||
|
||||||
@@ -71,6 +72,8 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
const loopShortcut = configManager.get('hotkeys.main.loop') as string;
|
const loopShortcut = configManager.get('hotkeys.main.loop') as string;
|
||||||
const saveShortcut = configManager.get('hotkeys.main.save') as string;
|
const saveShortcut = configManager.get('hotkeys.main.save') as string;
|
||||||
const recordShortcut = configManager.get('hotkeys.main.record') 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
|
// Check for undo shortcut
|
||||||
if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) {
|
if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) {
|
||||||
@@ -202,6 +205,33 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
return;
|
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
|
// 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) {
|
if (event.key.toLowerCase() === 'e' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -272,11 +302,14 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
activeRegionId,
|
activeRegionId,
|
||||||
|
selectedRegionIds,
|
||||||
lastSelectedRegionId,
|
lastSelectedRegionId,
|
||||||
setActiveRegionId,
|
setActiveRegionId,
|
||||||
setShowPianoRoll,
|
setShowPianoRoll,
|
||||||
showPianoRoll,
|
showPianoRoll,
|
||||||
openMidiPianoRoll,
|
openMidiPianoRoll,
|
||||||
openSpectrogramViewer,
|
openSpectrogramViewer,
|
||||||
|
playheadPosition,
|
||||||
|
refreshProjectState,
|
||||||
]); // Include dependencies for store actions
|
]); // Include dependencies for store actions
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user