diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx
index fdbdbdd..871d847 100644
--- a/src/components/Toolbar.test.tsx
+++ b/src/components/Toolbar.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { fireEvent, render, screen } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Toolbar from './Toolbar';
@@ -52,13 +52,13 @@ const storeState = {
setShowPianoRoll: vi.fn(),
activeRegionId: null,
setActiveRegionId: vi.fn(),
- selectedRegionIds: [],
+ selectedRegionIds: [] as string[],
selectedTrackId: null,
playheadPosition: 0,
refreshProjectState: vi.fn(),
requestMainContentScroll: vi.fn(),
requestPianoRollScroll: vi.fn(),
- tracks: [],
+ tracks: [] as unknown[],
};
type StoreState = typeof storeState;
@@ -108,6 +108,14 @@ vi.mock('../core/state/KGMainContentState', () => ({ KGMainContentState: {} }));
vi.mock('../util/regionDeleteUtil', () => ({ regionDeleteManager: { deleteSelectedRegions: vi.fn(() => false) } }));
vi.mock('../core/commands/region/SplitRegionCommand', () => ({ SplitRegionCommand: class {} }));
vi.mock('../core/commands/region/MergeMidiRegionsCommand', () => ({ MergeMidiRegionsCommand: class {} }));
+const regionEditUtilMocks = vi.hoisted(() => ({
+ splitSelectedRegionAtPlayheadMock: vi.fn(),
+ mergeSelectedMidiRegionsMock: vi.fn(),
+}));
+vi.mock('../util/regionEditUtil', () => ({
+ splitSelectedRegionAtPlayhead: regionEditUtilMocks.splitSelectedRegionAtPlayheadMock,
+ mergeSelectedMidiRegions: regionEditUtilMocks.mergeSelectedMidiRegionsMock,
+}));
vi.mock('../util/copyPasteUtil', () => ({
handleCopyOperation: vi.fn(() => false),
handlePasteOperation: vi.fn(() => false),
@@ -135,6 +143,8 @@ vi.mock('../util/dialogUtil', () => ({
describe('Toolbar settings side-panel behavior', () => {
beforeEach(() => {
+ regionEditUtilMocks.splitSelectedRegionAtPlayheadMock.mockReset();
+ regionEditUtilMocks.mergeSelectedMidiRegionsMock.mockReset();
storeState.toggleChatBox.mockClear();
storeState.toggleKGOnePanel.mockClear();
storeState.toggleEventListPanel.mockClear();
@@ -162,4 +172,25 @@ describe('Toolbar settings side-panel behavior', () => {
expect(storeState.activateSidePanel).toHaveBeenCalledWith('eventList');
expect(storeState.toggleEventListPanel).not.toHaveBeenCalled();
});
+
+ it('routes the split toolbar button through the shared split helper', async () => {
+ storeState.selectedRegionIds = ['region-1'];
+ storeState.playheadPosition = 12;
+ regionEditUtilMocks.splitSelectedRegionAtPlayheadMock.mockResolvedValue('Split 1 note at beat 12.00');
+
+ render();
+ fireEvent.click(screen.getByTitle('Split Region at Playhead'));
+
+ await waitFor(() => {
+ expect(regionEditUtilMocks.splitSelectedRegionAtPlayheadMock).toHaveBeenCalledWith({
+ selectedRegionIds: ['region-1'],
+ playheadPosition: 12,
+ refreshProjectState: storeState.refreshProjectState,
+ });
+ });
+
+ await waitFor(() => {
+ expect(storeState.setStatus).toHaveBeenCalledWith('Split 1 note at beat 12.00');
+ });
+ });
});
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index be83bbb..19f4c66 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -16,6 +16,7 @@ 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';
diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts
index cc321f9..cbd015d 100644
--- a/src/core/commands/index.ts
+++ b/src/core/commands/index.ts
@@ -41,6 +41,7 @@ export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
export { MoveNotesCommand } from './note/MoveNotesCommand';
export { PasteNotesCommand } from './note/PasteNotesCommand';
+export { SplitSelectedNotesCommand } from './note/SplitSelectedNotesCommand';
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
export { UpdateControllerEventPropertiesCommand } from './note/UpdateControllerEventPropertiesCommand';
diff --git a/src/core/commands/note/SplitSelectedNotesCommand.test.ts b/src/core/commands/note/SplitSelectedNotesCommand.test.ts
new file mode 100644
index 0000000..624cd00
--- /dev/null
+++ b/src/core/commands/note/SplitSelectedNotesCommand.test.ts
@@ -0,0 +1,131 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Selectable } from '../../../components/interfaces';
+import { KGCore } from '../../KGCore';
+import { KGMidiNote } from '../../midi/KGMidiNote';
+import { SplitSelectedNotesCommand } from './SplitSelectedNotesCommand';
+import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../../test/utils/mock-data';
+
+vi.mock('../../KGCore', () => ({
+ KGCore: {
+ instance: vi.fn(),
+ }
+}));
+
+interface MockCore {
+ getCurrentProject: ReturnType;
+ getSelectedItems: ReturnType;
+ clearSelectedItems: ReturnType;
+ addSelectedItems: ReturnType;
+}
+
+describe('SplitSelectedNotesCommand', () => {
+ let mockCore: MockCore;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockCore = {
+ getCurrentProject: vi.fn(),
+ getSelectedItems: vi.fn(),
+ clearSelectedItems: vi.fn(),
+ addSelectedItems: vi.fn(),
+ };
+
+ vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
+ });
+
+ it('splits one selected note into two halves and selects both results', () => {
+ const splitNote = createMockMidiNote({ id: 'note-a', startBeat: 1, endBeat: 5, pitch: 64, velocity: 90 });
+ splitNote.select();
+ const region = createMockMidiRegion({
+ id: 'region-1',
+ trackId: '1',
+ trackIndex: 0,
+ notes: [splitNote],
+ });
+ const track = createMockMidiTrack({ id: 1, regions: [region] });
+ mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
+ mockCore.getSelectedItems.mockReturnValue([splitNote]);
+
+ const command = new SplitSelectedNotesCommand(region.getId(), [splitNote.getId()], 3);
+
+ command.execute();
+
+ const notes = region.getNotes();
+ expect(notes).toHaveLength(2);
+ expect(notes.map(note => [note.getStartBeat(), note.getEndBeat(), note.getPitch(), note.getVelocity()])).toEqual([
+ [1, 3, 64, 90],
+ [3, 5, 64, 90],
+ ]);
+ expect(notes.every(note => note.isSelected())).toBe(true);
+ expect(command.getSplitCount()).toBe(1);
+ expect(mockCore.clearSelectedItems).toHaveBeenCalledTimes(1);
+ expect(mockCore.addSelectedItems).toHaveBeenCalledTimes(1);
+ expect((mockCore.addSelectedItems.mock.calls[0][0] as Selectable[])).toHaveLength(2);
+ });
+
+ it('splits multiple selected notes in one undoable operation and leaves uncrossed selected notes unchanged', () => {
+ const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 4, pitch: 60, velocity: 70 });
+ const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62, velocity: 75 });
+ const noteC = createMockMidiNote({ id: 'note-c', startBeat: 2, endBeat: 5, pitch: 65, velocity: 80 });
+ [noteA, noteB, noteC].forEach(note => note.select());
+ const region = createMockMidiRegion({
+ id: 'region-1',
+ trackId: '1',
+ trackIndex: 0,
+ notes: [noteA, noteB, noteC],
+ });
+ const track = createMockMidiTrack({ id: 1, regions: [region] });
+ mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
+ mockCore.getSelectedItems.mockReturnValue([noteA, noteB, noteC]);
+
+ const command = new SplitSelectedNotesCommand(region.getId(), [noteA.getId(), noteB.getId(), noteC.getId()], 3);
+
+ command.execute();
+
+ const notes = region.getNotes();
+ expect(notes).toHaveLength(5);
+ expect(notes.map(note => [note.getStartBeat(), note.getEndBeat(), note.getPitch()])).toEqual([
+ [0, 3, 60],
+ [3, 4, 60],
+ [1, 2, 62],
+ [2, 3, 65],
+ [3, 5, 65],
+ ]);
+ expect(command.getSplitCount()).toBe(2);
+ expect(command.getUnchangedSelectedNoteIds()).toEqual(['note-b']);
+
+ command.undo();
+ expect(region.getNotes()).toEqual([noteA, noteB, noteC]);
+ expect(mockCore.clearSelectedItems).toHaveBeenCalledTimes(2);
+ expect(mockCore.addSelectedItems).toHaveBeenLastCalledWith([noteA, noteB, noteC]);
+
+ command.execute();
+ expect(region.getNotes().map(note => [note.getStartBeat(), note.getEndBeat(), note.getPitch()])).toEqual([
+ [0, 3, 60],
+ [3, 4, 60],
+ [1, 2, 62],
+ [2, 3, 65],
+ [3, 5, 65],
+ ]);
+ });
+
+ it('throws when no selected note crosses the playhead', () => {
+ const noteA = new KGMidiNote('note-a', 0, 1, 60, 100);
+ const noteB = new KGMidiNote('note-b', 4, 5, 62, 100);
+ const region = createMockMidiRegion({
+ id: 'region-1',
+ trackId: '1',
+ trackIndex: 0,
+ notes: [noteA, noteB],
+ });
+ const track = createMockMidiTrack({ id: 1, regions: [region] });
+ mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
+ mockCore.getSelectedItems.mockReturnValue([noteA, noteB]);
+
+ const command = new SplitSelectedNotesCommand(region.getId(), [noteA.getId(), noteB.getId()], 3);
+
+ expect(() => command.execute()).toThrow(
+ 'The playhead is not inside any selected note. Move the playhead inside a selected note before splitting.'
+ );
+ });
+});
diff --git a/src/core/commands/note/SplitSelectedNotesCommand.ts b/src/core/commands/note/SplitSelectedNotesCommand.ts
new file mode 100644
index 0000000..3de7260
--- /dev/null
+++ b/src/core/commands/note/SplitSelectedNotesCommand.ts
@@ -0,0 +1,164 @@
+import type { Selectable } from '../../../components/interfaces';
+import { generateUniqueId } from '../../../util/miscUtil';
+import { KGCore } from '../../KGCore';
+import { KGMidiNote } from '../../midi/KGMidiNote';
+import { KGMidiRegion } from '../../region/KGMidiRegion';
+import { KGCommand } from '../KGCommand';
+
+interface SplitNoteRecord {
+ originalNoteId: string;
+ leftNoteId: string;
+ rightNoteId: string;
+ startBeat: number;
+ endBeat: number;
+ pitch: number;
+ velocity: number;
+}
+
+export class SplitSelectedNotesCommand extends KGCommand {
+ private readonly regionId: string;
+ private readonly selectedNoteIds: string[];
+ private readonly splitAtBeat: number;
+
+ private targetRegion: KGMidiRegion | null = null;
+ private originalNotes: KGMidiNote[] = [];
+ private originalSelectedItems: Selectable[] = [];
+ private splitNoteRecords: SplitNoteRecord[] = [];
+ private unchangedSelectedNoteIds: string[] = [];
+
+ constructor(regionId: string, selectedNoteIds: string[], splitAtBeat: number) {
+ super();
+ this.regionId = regionId;
+ this.selectedNoteIds = [...selectedNoteIds];
+ this.splitAtBeat = splitAtBeat;
+ }
+
+ execute(): void {
+ const core = KGCore.instance();
+ const targetRegion = this.resolveTargetRegion();
+ const noteIdSet = new Set(this.selectedNoteIds);
+ const currentNotes = targetRegion.getNotes();
+ const selectedNotes = currentNotes.filter(note => noteIdSet.has(note.getId()));
+
+ if (selectedNotes.length === 0) {
+ throw new Error('No selected notes were found in the active MIDI region.');
+ }
+
+ if (this.originalSelectedItems.length === 0) {
+ this.originalSelectedItems = [...core.getSelectedItems()];
+ }
+
+ if (this.originalNotes.length === 0) {
+ this.originalNotes = [...currentNotes];
+ }
+
+ if (this.splitNoteRecords.length === 0) {
+ this.splitNoteRecords = selectedNotes
+ .filter(note => note.getStartBeat() < this.splitAtBeat && this.splitAtBeat < note.getEndBeat())
+ .map(note => ({
+ originalNoteId: note.getId(),
+ leftNoteId: generateUniqueId('KGMidiNote'),
+ rightNoteId: generateUniqueId('KGMidiNote'),
+ startBeat: note.getStartBeat(),
+ endBeat: note.getEndBeat(),
+ pitch: note.getPitch(),
+ velocity: note.getVelocity(),
+ }));
+ }
+
+ if (this.splitNoteRecords.length === 0) {
+ throw new Error('The playhead is not inside any selected note. Move the playhead inside a selected note before splitting.');
+ }
+
+ const splitRecordByOriginalId = new Map(
+ this.splitNoteRecords.map(record => [record.originalNoteId, record])
+ );
+ this.unchangedSelectedNoteIds = selectedNotes
+ .filter(note => !splitRecordByOriginalId.has(note.getId()))
+ .map(note => note.getId());
+
+ const nextNotes: KGMidiNote[] = [];
+ const nextSelectedNotes: KGMidiNote[] = [];
+
+ for (const note of currentNotes) {
+ const splitRecord = splitRecordByOriginalId.get(note.getId());
+ if (!splitRecord) {
+ note.deselect();
+ nextNotes.push(note);
+
+ if (noteIdSet.has(note.getId())) {
+ note.select();
+ nextSelectedNotes.push(note);
+ }
+ continue;
+ }
+
+ const leftNote = new KGMidiNote(
+ splitRecord.leftNoteId,
+ splitRecord.startBeat,
+ this.splitAtBeat,
+ splitRecord.pitch,
+ splitRecord.velocity
+ );
+ const rightNote = new KGMidiNote(
+ splitRecord.rightNoteId,
+ this.splitAtBeat,
+ splitRecord.endBeat,
+ splitRecord.pitch,
+ splitRecord.velocity
+ );
+
+ leftNote.select();
+ rightNote.select();
+ nextNotes.push(leftNote, rightNote);
+ nextSelectedNotes.push(leftNote, rightNote);
+ }
+
+ targetRegion.setNotes(nextNotes);
+ core.clearSelectedItems();
+ core.addSelectedItems(nextSelectedNotes);
+ }
+
+ undo(): void {
+ if (!this.targetRegion) {
+ throw new Error('Cannot undo: split was never executed');
+ }
+
+ const core = KGCore.instance();
+ this.targetRegion.setNotes([...this.originalNotes]);
+ this.originalNotes.forEach(note => note.deselect());
+ this.originalSelectedItems.forEach(item => item.select());
+ core.clearSelectedItems();
+ core.addSelectedItems(this.originalSelectedItems);
+ }
+
+ getDescription(): string {
+ const splitCount = this.splitNoteRecords.length || this.selectedNoteIds.length;
+ return splitCount === 1 ? 'Split note' : `Split ${splitCount} notes`;
+ }
+
+ public getSplitCount(): number {
+ return this.splitNoteRecords.length;
+ }
+
+ public getUnchangedSelectedNoteIds(): string[] {
+ return [...this.unchangedSelectedNoteIds];
+ }
+
+ private resolveTargetRegion(): KGMidiRegion {
+ if (this.targetRegion) {
+ return this.targetRegion;
+ }
+
+ const tracks = KGCore.instance().getCurrentProject().getTracks();
+ for (const track of tracks) {
+ const region = track.getRegions().find(candidate => candidate.getId() === this.regionId);
+ if (region instanceof KGMidiRegion) {
+ this.targetRegion = region;
+ return region;
+ }
+ }
+
+ throw new Error(`MIDI region with ID ${this.regionId} not found`);
+ }
+}
diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts
index 8a0027b..06e0464 100644
--- a/src/core/config/ConfigManager.ts
+++ b/src/core/config/ConfigManager.ts
@@ -231,13 +231,16 @@ export class ConfigManager {
hold_to_create_region: 'ctrl',
play: 'space',
loop: 'c',
+ record: 'r',
undo: 'ctrl+z',
redo: 'ctrl+shift+z',
select_all: 'ctrl+a',
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/hooks/useGlobalKeyboardHandler.test.tsx b/src/hooks/useGlobalKeyboardHandler.test.tsx
index 305c7a8..795b12a 100644
--- a/src/hooks/useGlobalKeyboardHandler.test.tsx
+++ b/src/hooks/useGlobalKeyboardHandler.test.tsx
@@ -25,6 +25,7 @@ const storeState = {
stopRecording: vi.fn(),
activeRegionId: null,
selectedRegionIds: ['region-a', 'region-b'],
+ selectedNoteIds: [],
setActiveRegionId: vi.fn(),
setShowPianoRoll: vi.fn(),
showPianoRoll: false,
@@ -32,6 +33,7 @@ const storeState = {
openSpectrogramViewer: vi.fn(),
playheadPosition: 12,
refreshProjectState: vi.fn(),
+ pianoRollMode: 'midi-edit' as const,
};
type StoreState = typeof storeState;
@@ -143,7 +145,7 @@ describe('useGlobalKeyboardHandler region shortcuts', () => {
});
it('triggers split on Ctrl+T', async () => {
- regionEditUtilMocks.splitSelectedRegionAtPlayhead.mockResolvedValue('Split region at beat 12.00');
+ regionEditUtilMocks.splitSelectedRegionAtPlayhead.mockResolvedValue('Split 1 note at beat 12.00');
render();
fireEvent.keyDown(document.body, { key: 't', ctrlKey: true });
@@ -157,7 +159,7 @@ describe('useGlobalKeyboardHandler region shortcuts', () => {
});
await waitFor(() => {
- expect(storeState.setStatus).toHaveBeenCalledWith('Split region at beat 12.00');
+ expect(storeState.setStatus).toHaveBeenCalledWith('Split 1 note at beat 12.00');
});
});
diff --git a/src/util/regionEditUtil.test.ts b/src/util/regionEditUtil.test.ts
new file mode 100644
index 0000000..ae596bf
--- /dev/null
+++ b/src/util/regionEditUtil.test.ts
@@ -0,0 +1,214 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Selectable } from '../components/interfaces';
+import { KGMidiNote } from '../core/midi/KGMidiNote';
+import { createMockMidiRegion, createMockMidiTrack, createMockProject } from '../test/utils/mock-data';
+import { splitSelectedRegionAtPlayhead } from './regionEditUtil';
+
+const storeState = {
+ showPianoRoll: false,
+ pianoRollMode: 'midi-edit' as 'midi-edit' | 'spectrogram' | 'hybrid',
+ activeRegionId: null as string | null,
+ selectedNoteIds: [] as string[],
+ setShowPianoRoll: vi.fn(),
+ setActiveRegionId: vi.fn(),
+};
+
+const mockCore = {
+ getCurrentProject: vi.fn(),
+ getSelectedItems: vi.fn<() => Selectable[]>(() => []),
+ clearSelectedItems: vi.fn(),
+ addSelectedItems: vi.fn(),
+ executeCommand: vi.fn((command: { execute: () => void }) => {
+ command.execute();
+ }),
+};
+
+const dialogMocks = vi.hoisted(() => ({
+ showAlert: vi.fn(),
+ showConfirm: vi.fn(),
+}));
+
+const pianoRollStateMock = vi.hoisted(() => ({
+ getSheetMusicViewEnabled: vi.fn(() => false),
+}));
+
+vi.mock('../stores/projectStore', () => ({
+ useProjectStore: {
+ getState: vi.fn(() => storeState),
+ },
+}));
+
+vi.mock('../core/KGCore', () => ({
+ KGCore: {
+ instance: vi.fn(() => mockCore),
+ },
+}));
+
+vi.mock('../core/state/KGPianoRollState', () => ({
+ KGPianoRollState: {
+ instance: vi.fn(() => pianoRollStateMock),
+ },
+}));
+
+vi.mock('./dialogUtil', () => ({
+ showAlert: dialogMocks.showAlert,
+ showConfirm: dialogMocks.showConfirm,
+}));
+
+describe('splitSelectedRegionAtPlayhead', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ storeState.showPianoRoll = false;
+ storeState.pianoRollMode = 'midi-edit';
+ storeState.activeRegionId = null;
+ storeState.selectedNoteIds = [];
+ storeState.setShowPianoRoll.mockReset();
+ storeState.setActiveRegionId.mockReset();
+ pianoRollStateMock.getSheetMusicViewEnabled.mockReturnValue(false);
+ mockCore.getSelectedItems.mockReset();
+ mockCore.getSelectedItems.mockReturnValue([]);
+ mockCore.clearSelectedItems.mockReset();
+ mockCore.addSelectedItems.mockReset();
+ });
+
+ it('falls back to region split when the piano roll is closed', async () => {
+ const region = createMockMidiRegion({ id: 'region-1', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 8 });
+ const track = createMockMidiTrack({ id: 1, regions: [region] });
+ mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
+
+ const status = await splitSelectedRegionAtPlayhead({
+ selectedRegionIds: ['region-1'],
+ playheadPosition: 4,
+ refreshProjectState: vi.fn(),
+ });
+
+ expect(status).toBe('Split region at beat 4.00');
+ expect(mockCore.executeCommand).toHaveBeenCalledTimes(1);
+ expect(dialogMocks.showAlert).not.toHaveBeenCalled();
+ });
+
+ it('falls back to region split when not in piano-roll view', async () => {
+ const region = createMockMidiRegion({ id: 'region-1', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 8 });
+ const track = createMockMidiTrack({ id: 1, regions: [region] });
+ mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
+ storeState.showPianoRoll = true;
+ storeState.pianoRollMode = 'spectrogram';
+ storeState.selectedNoteIds = ['note-1'];
+
+ const status = await splitSelectedRegionAtPlayhead({
+ selectedRegionIds: ['region-1'],
+ playheadPosition: 4,
+ refreshProjectState: vi.fn(),
+ });
+
+ expect(status).toBe('Split region at beat 4.00');
+ expect(mockCore.executeCommand).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to region split when sheet music view is enabled', async () => {
+ const region = createMockMidiRegion({ id: 'region-1', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 8 });
+ const track = createMockMidiTrack({ id: 1, regions: [region] });
+ mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
+ storeState.showPianoRoll = true;
+ storeState.activeRegionId = 'region-1';
+ storeState.selectedNoteIds = ['note-1'];
+ pianoRollStateMock.getSheetMusicViewEnabled.mockReturnValue(true);
+
+ const status = await splitSelectedRegionAtPlayhead({
+ selectedRegionIds: ['region-1'],
+ playheadPosition: 4,
+ refreshProjectState: vi.fn(),
+ });
+
+ expect(status).toBe('Split region at beat 4.00');
+ expect(mockCore.executeCommand).toHaveBeenCalledTimes(1);
+ });
+
+ it('splits selected notes when the piano roll is open in midi-edit view', async () => {
+ const note = new KGMidiNote('note-1', 1, 5, 60, 100);
+ const region = createMockMidiRegion({
+ id: 'region-1',
+ trackId: '1',
+ trackIndex: 0,
+ notes: [note],
+ });
+ const track = createMockMidiTrack({ id: 1, regions: [region] });
+ mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
+ storeState.showPianoRoll = true;
+ storeState.activeRegionId = 'region-1';
+ storeState.selectedNoteIds = ['note-1'];
+ const refreshProjectState = vi.fn();
+ mockCore.getSelectedItems.mockReturnValue([note]);
+
+ const status = await splitSelectedRegionAtPlayhead({
+ selectedRegionIds: ['region-1'],
+ playheadPosition: 3,
+ refreshProjectState,
+ });
+
+ expect(status).toBe('Split 1 note at beat 3.00');
+ expect(mockCore.executeCommand).toHaveBeenCalledTimes(1);
+ expect(refreshProjectState).toHaveBeenCalledTimes(1);
+ expect(region.getNotes().map(candidate => [candidate.getStartBeat(), candidate.getEndBeat()])).toEqual([
+ [1, 3],
+ [3, 5],
+ ]);
+ });
+
+ it('splits selected notes using playhead position relative to the region start', async () => {
+ const note = new KGMidiNote('note-1', 1, 5, 60, 100);
+ const region = createMockMidiRegion({
+ id: 'region-1',
+ trackId: '1',
+ trackIndex: 0,
+ startFromBeat: 8,
+ length: 8,
+ notes: [note],
+ });
+ const track = createMockMidiTrack({ id: 1, regions: [region] });
+ mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
+ storeState.showPianoRoll = true;
+ storeState.activeRegionId = 'region-1';
+ storeState.selectedNoteIds = ['note-1'];
+ mockCore.getSelectedItems.mockReturnValue([note]);
+
+ const status = await splitSelectedRegionAtPlayhead({
+ selectedRegionIds: ['region-1'],
+ playheadPosition: 11,
+ refreshProjectState: vi.fn(),
+ });
+
+ expect(status).toBe('Split 1 note at beat 11.00');
+ expect(region.getNotes().map(candidate => [candidate.getStartBeat(), candidate.getEndBeat()])).toEqual([
+ [1, 3],
+ [3, 5],
+ ]);
+ });
+
+ it('shows an alert when no selected note crosses the playhead', async () => {
+ const note = new KGMidiNote('note-1', 1, 2, 60, 100);
+ const region = createMockMidiRegion({
+ id: 'region-1',
+ trackId: '1',
+ trackIndex: 0,
+ notes: [note],
+ });
+ const track = createMockMidiTrack({ id: 1, regions: [region] });
+ mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
+ storeState.showPianoRoll = true;
+ storeState.activeRegionId = 'region-1';
+ storeState.selectedNoteIds = ['note-1'];
+
+ const status = await splitSelectedRegionAtPlayhead({
+ selectedRegionIds: ['region-1'],
+ playheadPosition: 3,
+ refreshProjectState: vi.fn(),
+ });
+
+ expect(status).toBeNull();
+ expect(dialogMocks.showAlert).toHaveBeenCalledWith(
+ 'The playhead is not inside any selected note. Move the playhead inside a selected note before splitting.'
+ );
+ expect(mockCore.executeCommand).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/util/regionEditUtil.ts b/src/util/regionEditUtil.ts
index 669464f..5aa945e 100644
--- a/src/util/regionEditUtil.ts
+++ b/src/util/regionEditUtil.ts
@@ -1,7 +1,10 @@
import { KGCore } from '../core/KGCore';
+import { SplitSelectedNotesCommand } from '../core/commands/note/SplitSelectedNotesCommand';
import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand';
import { MergeMidiRegionsCommand } from '../core/commands/region/MergeMidiRegionsCommand';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
+import { KGPianoRollState } from '../core/state/KGPianoRollState';
+import { useProjectStore } from '../stores/projectStore';
import { showAlert, showConfirm } from './dialogUtil';
interface SplitSelectedRegionParams {
@@ -19,6 +22,95 @@ export const splitSelectedRegionAtPlayhead = async ({
selectedRegionIds,
playheadPosition,
refreshProjectState,
+}: SplitSelectedRegionParams): Promise => {
+ const {
+ activeRegionId,
+ pianoRollMode,
+ selectedNoteIds,
+ showPianoRoll,
+ } = useProjectStore.getState();
+ const sheetMusicViewEnabled = KGPianoRollState.instance().getSheetMusicViewEnabled();
+
+ if (
+ showPianoRoll &&
+ pianoRollMode === 'midi-edit' &&
+ !sheetMusicViewEnabled &&
+ activeRegionId &&
+ selectedNoteIds.length > 0
+ ) {
+ return splitSelectedNotesAtPlayhead({
+ activeRegionId,
+ selectedNoteIds,
+ playheadPosition,
+ refreshProjectState,
+ });
+ }
+
+ return splitSingleSelectedRegionAtPlayhead({
+ selectedRegionIds,
+ playheadPosition,
+ refreshProjectState,
+ });
+};
+
+const splitSelectedNotesAtPlayhead = async ({
+ activeRegionId,
+ selectedNoteIds,
+ playheadPosition,
+ refreshProjectState,
+}: {
+ activeRegionId: string;
+ selectedNoteIds: string[];
+ playheadPosition: number;
+ refreshProjectState: () => void;
+}): Promise => {
+ const tracks = KGCore.instance().getCurrentProject().getTracks();
+ let activeRegion: KGMidiRegion | null = null;
+
+ for (const track of tracks) {
+ const region = track.getRegions().find(candidate => candidate.getId() === activeRegionId);
+ if (region instanceof KGMidiRegion) {
+ activeRegion = region;
+ break;
+ }
+ }
+
+ if (!activeRegion) {
+ await showAlert('The active MIDI region could not be found. Please reopen the piano roll and try again.');
+ return null;
+ }
+
+ const selectedNoteIdSet = new Set(selectedNoteIds);
+ const selectedNotes = activeRegion.getNotes().filter(note => selectedNoteIdSet.has(note.getId()));
+ if (selectedNotes.length === 0) {
+ await showAlert('The selected notes could not be found in the active MIDI region. Please reselect the notes and try again.');
+ return null;
+ }
+
+ const regionRelativePlayhead = playheadPosition - activeRegion.getStartFromBeat();
+ const splitCount = selectedNotes.filter(note => (
+ note.getStartBeat() < regionRelativePlayhead && regionRelativePlayhead < note.getEndBeat()
+ )).length;
+ if (splitCount === 0) {
+ await showAlert('The playhead is not inside any selected note. Move the playhead inside a selected note before splitting.');
+ return null;
+ }
+
+ try {
+ const command = new SplitSelectedNotesCommand(activeRegionId, selectedNoteIds, regionRelativePlayhead);
+ KGCore.instance().executeCommand(command, { rethrow: true });
+ refreshProjectState();
+ return `Split ${splitCount} note${splitCount === 1 ? '' : 's'} at beat ${playheadPosition.toFixed(2)}`;
+ } catch (error) {
+ await showAlert(error instanceof Error ? error.message : 'Unable to split the selected notes.');
+ return null;
+ }
+};
+
+const splitSingleSelectedRegionAtPlayhead = async ({
+ selectedRegionIds,
+ playheadPosition,
+ refreshProjectState,
}: SplitSelectedRegionParams): Promise => {
if (selectedRegionIds.length === 0) {
await showAlert('Please select a region to split.');