feat: added split notes function
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import Toolbar from './Toolbar';
|
import Toolbar from './Toolbar';
|
||||||
|
|
||||||
@@ -52,13 +52,13 @@ const storeState = {
|
|||||||
setShowPianoRoll: vi.fn(),
|
setShowPianoRoll: vi.fn(),
|
||||||
activeRegionId: null,
|
activeRegionId: null,
|
||||||
setActiveRegionId: vi.fn(),
|
setActiveRegionId: vi.fn(),
|
||||||
selectedRegionIds: [],
|
selectedRegionIds: [] as string[],
|
||||||
selectedTrackId: null,
|
selectedTrackId: null,
|
||||||
playheadPosition: 0,
|
playheadPosition: 0,
|
||||||
refreshProjectState: vi.fn(),
|
refreshProjectState: vi.fn(),
|
||||||
requestMainContentScroll: vi.fn(),
|
requestMainContentScroll: vi.fn(),
|
||||||
requestPianoRollScroll: vi.fn(),
|
requestPianoRollScroll: vi.fn(),
|
||||||
tracks: [],
|
tracks: [] as unknown[],
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoreState = typeof storeState;
|
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('../util/regionDeleteUtil', () => ({ regionDeleteManager: { deleteSelectedRegions: vi.fn(() => false) } }));
|
||||||
vi.mock('../core/commands/region/SplitRegionCommand', () => ({ SplitRegionCommand: class {} }));
|
vi.mock('../core/commands/region/SplitRegionCommand', () => ({ SplitRegionCommand: class {} }));
|
||||||
vi.mock('../core/commands/region/MergeMidiRegionsCommand', () => ({ MergeMidiRegionsCommand: 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', () => ({
|
vi.mock('../util/copyPasteUtil', () => ({
|
||||||
handleCopyOperation: vi.fn(() => false),
|
handleCopyOperation: vi.fn(() => false),
|
||||||
handlePasteOperation: vi.fn(() => false),
|
handlePasteOperation: vi.fn(() => false),
|
||||||
@@ -135,6 +143,8 @@ vi.mock('../util/dialogUtil', () => ({
|
|||||||
|
|
||||||
describe('Toolbar settings side-panel behavior', () => {
|
describe('Toolbar settings side-panel behavior', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
regionEditUtilMocks.splitSelectedRegionAtPlayheadMock.mockReset();
|
||||||
|
regionEditUtilMocks.mergeSelectedMidiRegionsMock.mockReset();
|
||||||
storeState.toggleChatBox.mockClear();
|
storeState.toggleChatBox.mockClear();
|
||||||
storeState.toggleKGOnePanel.mockClear();
|
storeState.toggleKGOnePanel.mockClear();
|
||||||
storeState.toggleEventListPanel.mockClear();
|
storeState.toggleEventListPanel.mockClear();
|
||||||
@@ -162,4 +172,25 @@ describe('Toolbar settings side-panel behavior', () => {
|
|||||||
expect(storeState.activateSidePanel).toHaveBeenCalledWith('eventList');
|
expect(storeState.activateSidePanel).toHaveBeenCalledWith('eventList');
|
||||||
expect(storeState.toggleEventListPanel).not.toHaveBeenCalled();
|
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(<Toolbar />);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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';
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand
|
|||||||
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
||||||
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
||||||
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
||||||
|
export { SplitSelectedNotesCommand } from './note/SplitSelectedNotesCommand';
|
||||||
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
||||||
export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
|
export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
|
||||||
export { UpdateControllerEventPropertiesCommand } from './note/UpdateControllerEventPropertiesCommand';
|
export { UpdateControllerEventPropertiesCommand } from './note/UpdateControllerEventPropertiesCommand';
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
getSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
clearSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
addSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -231,13 +231,16 @@ export class ConfigManager {
|
|||||||
hold_to_create_region: 'ctrl',
|
hold_to_create_region: 'ctrl',
|
||||||
play: 'space',
|
play: 'space',
|
||||||
loop: 'c',
|
loop: 'c',
|
||||||
|
record: 'r',
|
||||||
undo: 'ctrl+z',
|
undo: 'ctrl+z',
|
||||||
redo: 'ctrl+shift+z',
|
redo: 'ctrl+shift+z',
|
||||||
select_all: 'ctrl+a',
|
select_all: 'ctrl+a',
|
||||||
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',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const storeState = {
|
|||||||
stopRecording: vi.fn(),
|
stopRecording: vi.fn(),
|
||||||
activeRegionId: null,
|
activeRegionId: null,
|
||||||
selectedRegionIds: ['region-a', 'region-b'],
|
selectedRegionIds: ['region-a', 'region-b'],
|
||||||
|
selectedNoteIds: [],
|
||||||
setActiveRegionId: vi.fn(),
|
setActiveRegionId: vi.fn(),
|
||||||
setShowPianoRoll: vi.fn(),
|
setShowPianoRoll: vi.fn(),
|
||||||
showPianoRoll: false,
|
showPianoRoll: false,
|
||||||
@@ -32,6 +33,7 @@ const storeState = {
|
|||||||
openSpectrogramViewer: vi.fn(),
|
openSpectrogramViewer: vi.fn(),
|
||||||
playheadPosition: 12,
|
playheadPosition: 12,
|
||||||
refreshProjectState: vi.fn(),
|
refreshProjectState: vi.fn(),
|
||||||
|
pianoRollMode: 'midi-edit' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoreState = typeof storeState;
|
type StoreState = typeof storeState;
|
||||||
@@ -143,7 +145,7 @@ describe('useGlobalKeyboardHandler region shortcuts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('triggers split on Ctrl+T', async () => {
|
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(<HookHarness />);
|
render(<HookHarness />);
|
||||||
fireEvent.keyDown(document.body, { key: 't', ctrlKey: true });
|
fireEvent.keyDown(document.body, { key: 't', ctrlKey: true });
|
||||||
@@ -157,7 +159,7 @@ describe('useGlobalKeyboardHandler region shortcuts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(storeState.setStatus).toHaveBeenCalledWith('Split region at beat 12.00');
|
expect(storeState.setStatus).toHaveBeenCalledWith('Split 1 note at beat 12.00');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
|
import { SplitSelectedNotesCommand } from '../core/commands/note/SplitSelectedNotesCommand';
|
||||||
import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand';
|
import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand';
|
||||||
import { MergeMidiRegionsCommand } from '../core/commands/region/MergeMidiRegionsCommand';
|
import { MergeMidiRegionsCommand } from '../core/commands/region/MergeMidiRegionsCommand';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
|
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||||
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
import { showAlert, showConfirm } from './dialogUtil';
|
import { showAlert, showConfirm } from './dialogUtil';
|
||||||
|
|
||||||
interface SplitSelectedRegionParams {
|
interface SplitSelectedRegionParams {
|
||||||
@@ -19,6 +22,95 @@ export const splitSelectedRegionAtPlayhead = async ({
|
|||||||
selectedRegionIds,
|
selectedRegionIds,
|
||||||
playheadPosition,
|
playheadPosition,
|
||||||
refreshProjectState,
|
refreshProjectState,
|
||||||
|
}: SplitSelectedRegionParams): Promise<string | null> => {
|
||||||
|
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<string | null> => {
|
||||||
|
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<string | null> => {
|
}: SplitSelectedRegionParams): Promise<string | null> => {
|
||||||
if (selectedRegionIds.length === 0) {
|
if (selectedRegionIds.length === 0) {
|
||||||
await showAlert('Please select a region to split.');
|
await showAlert('Please select a region to split.');
|
||||||
|
|||||||
Reference in New Issue
Block a user