From 4cf474be7ee54a147366eacf40d085fe976d78e3 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 18 May 2026 19:00:21 -0700 Subject: [PATCH] feat: added split notes function --- src/components/Toolbar.test.tsx | 37 ++- src/components/Toolbar.tsx | 1 + src/core/commands/index.ts | 1 + .../note/SplitSelectedNotesCommand.test.ts | 131 +++++++++++ .../note/SplitSelectedNotesCommand.ts | 164 ++++++++++++++ src/core/config/ConfigManager.ts | 5 +- src/hooks/useGlobalKeyboardHandler.test.tsx | 6 +- src/util/regionEditUtil.test.ts | 214 ++++++++++++++++++ src/util/regionEditUtil.ts | 92 ++++++++ 9 files changed, 645 insertions(+), 6 deletions(-) create mode 100644 src/core/commands/note/SplitSelectedNotesCommand.test.ts create mode 100644 src/core/commands/note/SplitSelectedNotesCommand.ts create mode 100644 src/util/regionEditUtil.test.ts 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.');