feat: added merge MIDI regions feature
This commit is contained in:
+101
-1
@@ -12,7 +12,7 @@ import {
|
|||||||
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
||||||
FaPlay, FaPause, FaComments, FaSync,
|
FaPlay, FaPause, FaComments, FaSync,
|
||||||
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
||||||
FaCog, FaMagnet, FaCut, FaCircle
|
FaCog, FaMagnet, FaCut, FaCircle, FaCompress
|
||||||
} 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';
|
||||||
@@ -22,6 +22,7 @@ import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-i
|
|||||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||||
import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand';
|
import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand';
|
||||||
|
import { MergeMidiRegionsCommand } from '../core/commands/region/MergeMidiRegionsCommand';
|
||||||
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
|
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
|
||||||
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
||||||
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||||
@@ -762,6 +763,99 @@ const Toolbar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMergeClick = async () => {
|
||||||
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
|
console.log('Merge button clicked');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRegionIds.length < 2) {
|
||||||
|
await showAlert('Please select at least two MIDI regions on the same track to merge.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||||
|
const selectedRegionIdSet = new Set(selectedRegionIds);
|
||||||
|
const selectedMidiRegions: KGMidiRegion[] = [];
|
||||||
|
let targetTrackId: string | null = null;
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
for (const region of track.getRegions()) {
|
||||||
|
if (!selectedRegionIdSet.has(region.getId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(region instanceof KGMidiRegion)) {
|
||||||
|
await showAlert('Only MIDI regions can be merged. Please adjust your selection and try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionTrackId = track.getId().toString();
|
||||||
|
if (targetTrackId && targetTrackId !== regionTrackId) {
|
||||||
|
await showAlert('Please select only MIDI regions from a single track before merging.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
targetTrackId = regionTrackId;
|
||||||
|
selectedMidiRegions.push(region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedMidiRegions.length !== selectedRegionIds.length || !targetTrackId) {
|
||||||
|
await showAlert('Some selected regions could not be found. Please reselect the MIDI regions and try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedSelectedRegions = [...selectedMidiRegions].sort((a, b) => {
|
||||||
|
const startDelta = a.getStartFromBeat() - b.getStartFromBeat();
|
||||||
|
if (startDelta !== 0) return startDelta;
|
||||||
|
return a.getLength() - b.getLength();
|
||||||
|
});
|
||||||
|
|
||||||
|
let regionIdsToMerge = selectedRegionIds;
|
||||||
|
const firstSelectedRegion = sortedSelectedRegions[0];
|
||||||
|
const lastSelectedRegion = sortedSelectedRegions[sortedSelectedRegions.length - 1];
|
||||||
|
const spanStart = firstSelectedRegion.getStartFromBeat();
|
||||||
|
const spanEnd = lastSelectedRegion.getStartFromBeat() + lastSelectedRegion.getLength();
|
||||||
|
|
||||||
|
const targetTrack = tracks.find(track => track.getId().toString() === targetTrackId);
|
||||||
|
const inBetweenRegions = targetTrack
|
||||||
|
?.getRegions()
|
||||||
|
.filter(region => (
|
||||||
|
region instanceof KGMidiRegion &&
|
||||||
|
!selectedRegionIdSet.has(region.getId()) &&
|
||||||
|
region.getStartFromBeat() >= spanStart &&
|
||||||
|
region.getStartFromBeat() <= spanEnd
|
||||||
|
)) ?? [];
|
||||||
|
|
||||||
|
if (inBetweenRegions.length > 0) {
|
||||||
|
const shouldIncludeInBetweenRegions = await showConfirm(
|
||||||
|
'There are additional MIDI regions between the first and last selected regions on this track. Would you like KGStudio to merge those as well?',
|
||||||
|
{
|
||||||
|
confirmLabel: 'Merge All In Between',
|
||||||
|
cancelLabel: 'Stop',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldIncludeInBetweenRegions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
regionIdsToMerge = Array.from(new Set([
|
||||||
|
...selectedRegionIds,
|
||||||
|
...inBetweenRegions.map(region => region.getId()),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new MergeMidiRegionsCommand(regionIdsToMerge);
|
||||||
|
KGCore.instance().executeCommand(command, { rethrow: true });
|
||||||
|
refreshProjectState();
|
||||||
|
setStatus(`Merged ${regionIdsToMerge.length} MIDI regions`);
|
||||||
|
} catch (error) {
|
||||||
|
await showAlert(error instanceof Error ? error.message : 'Unable to merge the selected MIDI regions.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle undo button click
|
// Handle undo button click
|
||||||
const handleUndoClick = async () => {
|
const handleUndoClick = async () => {
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
@@ -975,6 +1069,12 @@ const Toolbar: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FaCut />
|
<FaCut />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
title="Merge Selected MIDI Regions"
|
||||||
|
onClick={handleMergeClick}
|
||||||
|
>
|
||||||
|
<FaCompress />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Snap to Grid"
|
title="Snap to Grid"
|
||||||
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
|
|||||||
export { ImportStemsCommand } from './region/ImportStemsCommand';
|
export { ImportStemsCommand } from './region/ImportStemsCommand';
|
||||||
export type { StemImportEntry } from './region/ImportStemsCommand';
|
export type { StemImportEntry } from './region/ImportStemsCommand';
|
||||||
export { SplitRegionCommand } from './region/SplitRegionCommand';
|
export { SplitRegionCommand } from './region/SplitRegionCommand';
|
||||||
|
export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
|
||||||
|
|
||||||
// Note commands
|
// Note commands
|
||||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { MergeMidiRegionsCommand } from './MergeMidiRegionsCommand';
|
||||||
|
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../../test/utils/mock-data';
|
||||||
|
|
||||||
|
const storeState = {
|
||||||
|
showPianoRoll: false,
|
||||||
|
activeRegionId: null as string | null,
|
||||||
|
setShowPianoRoll: vi.fn(),
|
||||||
|
setActiveRegionId: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../KGCore', () => ({
|
||||||
|
KGCore: {
|
||||||
|
instance: vi.fn(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/projectStore', () => ({
|
||||||
|
useProjectStore: {
|
||||||
|
getState: vi.fn(() => storeState),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface MockCore {
|
||||||
|
getCurrentProject: ReturnType<typeof vi.fn>;
|
||||||
|
getSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
clearSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
addSelectedItem: ReturnType<typeof vi.fn>;
|
||||||
|
addSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MergeMidiRegionsCommand', () => {
|
||||||
|
let mockCore: MockCore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
storeState.showPianoRoll = false;
|
||||||
|
storeState.activeRegionId = null;
|
||||||
|
|
||||||
|
mockCore = {
|
||||||
|
getCurrentProject: vi.fn(),
|
||||||
|
getSelectedItems: vi.fn().mockReturnValue([]),
|
||||||
|
clearSelectedItems: vi.fn(),
|
||||||
|
addSelectedItem: vi.fn(),
|
||||||
|
addSelectedItems: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges multiple MIDI regions and preserves absolute note timing', () => {
|
||||||
|
const regionA = createMockMidiRegion({
|
||||||
|
id: 'region-a',
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 4,
|
||||||
|
length: 2,
|
||||||
|
notes: [createMockMidiNote({ id: 'note-a', startBeat: 0.5, endBeat: 1.5, pitch: 60 })],
|
||||||
|
});
|
||||||
|
const regionB = createMockMidiRegion({
|
||||||
|
id: 'region-b',
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 8,
|
||||||
|
length: 4,
|
||||||
|
notes: [createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 67 })],
|
||||||
|
});
|
||||||
|
const regionC = createMockMidiRegion({
|
||||||
|
id: 'region-c',
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 14,
|
||||||
|
length: 2,
|
||||||
|
notes: [createMockMidiNote({ id: 'note-c', startBeat: 0, endBeat: 1, pitch: 72 })],
|
||||||
|
});
|
||||||
|
const track = createMockMidiTrack({ id: 1, regions: [regionA, regionB, regionC] });
|
||||||
|
track.setTrackIndex(0);
|
||||||
|
mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
|
||||||
|
|
||||||
|
const command = new MergeMidiRegionsCommand(['region-b', 'region-a', 'region-c']);
|
||||||
|
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
expect(track.getRegions()).toEqual([regionA]);
|
||||||
|
expect(regionA.getStartFromBeat()).toBe(4);
|
||||||
|
expect(regionA.getLength()).toBe(12);
|
||||||
|
|
||||||
|
const mergedNotes = regionA.getNotes();
|
||||||
|
expect(mergedNotes).toHaveLength(3);
|
||||||
|
expect(mergedNotes.map(note => [note.getId(), note.getStartBeat(), note.getEndBeat()])).toEqual([
|
||||||
|
['note-a', 0.5, 1.5],
|
||||||
|
['note-b', 5, 6],
|
||||||
|
['note-c', 10, 11],
|
||||||
|
]);
|
||||||
|
expect(mockCore.clearSelectedItems).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockCore.addSelectedItem).toHaveBeenCalledWith(regionA);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retargets the piano roll to the surviving region when the active region is removed', () => {
|
||||||
|
const regionA = createMockMidiRegion({ id: 'region-a', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 4 });
|
||||||
|
const regionB = createMockMidiRegion({ id: 'region-b', trackId: '1', trackIndex: 0, startFromBeat: 6, length: 4 });
|
||||||
|
const track = createMockMidiTrack({ id: 1, regions: [regionA, regionB] });
|
||||||
|
track.setTrackIndex(0);
|
||||||
|
mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
|
||||||
|
storeState.showPianoRoll = true;
|
||||||
|
storeState.activeRegionId = 'region-b';
|
||||||
|
|
||||||
|
const command = new MergeMidiRegionsCommand(['region-a', 'region-b']);
|
||||||
|
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
expect(storeState.setActiveRegionId).toHaveBeenCalledWith('region-a');
|
||||||
|
expect(storeState.setShowPianoRoll).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo restores original regions, notes, selection, and piano roll state', () => {
|
||||||
|
const regionA = createMockMidiRegion({
|
||||||
|
id: 'region-a',
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 4,
|
||||||
|
length: 4,
|
||||||
|
notes: [createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1 })],
|
||||||
|
});
|
||||||
|
const regionB = createMockMidiRegion({
|
||||||
|
id: 'region-b',
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 10,
|
||||||
|
length: 2,
|
||||||
|
notes: [createMockMidiNote({ id: 'note-b', startBeat: 0.5, endBeat: 1.5 })],
|
||||||
|
});
|
||||||
|
const otherRegion = createMockMidiRegion({
|
||||||
|
id: 'region-x',
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 20,
|
||||||
|
length: 2,
|
||||||
|
});
|
||||||
|
const track = createMockMidiTrack({ id: 1, regions: [regionA, regionB, otherRegion] });
|
||||||
|
track.setTrackIndex(0);
|
||||||
|
mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
|
||||||
|
mockCore.getSelectedItems.mockReturnValue([regionA, regionB]);
|
||||||
|
storeState.showPianoRoll = true;
|
||||||
|
storeState.activeRegionId = 'region-b';
|
||||||
|
|
||||||
|
const command = new MergeMidiRegionsCommand(['region-a', 'region-b']);
|
||||||
|
|
||||||
|
command.execute();
|
||||||
|
command.undo();
|
||||||
|
|
||||||
|
expect(track.getRegions()).toEqual([regionA, regionB, otherRegion]);
|
||||||
|
expect(regionA.getLength()).toBe(4);
|
||||||
|
expect(regionA.getNotes().map(note => [note.getId(), note.getStartBeat(), note.getEndBeat()])).toEqual([
|
||||||
|
['note-a', 0, 1],
|
||||||
|
]);
|
||||||
|
expect(regionB.getLength()).toBe(2);
|
||||||
|
expect(regionB.getNotes().map(note => [note.getId(), note.getStartBeat(), note.getEndBeat()])).toEqual([
|
||||||
|
['note-b', 0.5, 1.5],
|
||||||
|
]);
|
||||||
|
expect(mockCore.addSelectedItems).toHaveBeenCalledWith([regionA, regionB]);
|
||||||
|
expect(storeState.setShowPianoRoll).toHaveBeenCalledWith(true);
|
||||||
|
expect(storeState.setActiveRegionId).toHaveBeenLastCalledWith('region-b');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
|
import { useProjectStore } from '../../../stores/projectStore';
|
||||||
|
|
||||||
|
interface RegionSnapshot {
|
||||||
|
regionId: string;
|
||||||
|
startFromBeat: number;
|
||||||
|
length: number;
|
||||||
|
notes: Array<{
|
||||||
|
id: string;
|
||||||
|
startBeat: number;
|
||||||
|
endBeat: number;
|
||||||
|
pitch: number;
|
||||||
|
velocity: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedRegion {
|
||||||
|
region: KGMidiRegion;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneNote(note: KGMidiNote, startBeat: number, endBeat: number): KGMidiNote {
|
||||||
|
return new KGMidiNote(
|
||||||
|
note.getId(),
|
||||||
|
startBeat,
|
||||||
|
endBeat,
|
||||||
|
note.getPitch(),
|
||||||
|
note.getVelocity()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MergeMidiRegionsCommand extends KGCommand {
|
||||||
|
private readonly regionIdsToMerge: string[];
|
||||||
|
private targetTrack: KGTrack | null = null;
|
||||||
|
private survivingRegion: KGMidiRegion | null = null;
|
||||||
|
private removedRegions: Array<{ region: KGMidiRegion; index: number }> = [];
|
||||||
|
private originalRegionSnapshots = new Map<string, RegionSnapshot>();
|
||||||
|
private originalSelectedItems = [] as ReturnType<KGCore['getSelectedItems']>;
|
||||||
|
private originalPianoRollState: { showPianoRoll: boolean; activeRegionId: string | null } | null = null;
|
||||||
|
|
||||||
|
constructor(regionIdsToMerge: string[]) {
|
||||||
|
super();
|
||||||
|
this.regionIdsToMerge = [...regionIdsToMerge];
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
if (this.regionIdsToMerge.length < 2) {
|
||||||
|
throw new Error('At least two MIDI regions are required to merge.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const tracks = core.getCurrentProject().getTracks();
|
||||||
|
const regionIdSet = new Set(this.regionIdsToMerge);
|
||||||
|
|
||||||
|
const resolvedRegions: ResolvedRegion[] = [];
|
||||||
|
let targetTrack: KGTrack | null = null;
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
track.getRegions().forEach((region, index) => {
|
||||||
|
if (!regionIdSet.has(region.getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(region instanceof KGMidiRegion)) {
|
||||||
|
throw new Error('Only MIDI regions can be merged.');
|
||||||
|
}
|
||||||
|
if (targetTrack && targetTrack !== track) {
|
||||||
|
throw new Error('All MIDI regions must be on the same track to merge.');
|
||||||
|
}
|
||||||
|
targetTrack = track;
|
||||||
|
resolvedRegions.push({ region, index });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetTrack || resolvedRegions.length !== regionIdSet.size) {
|
||||||
|
throw new Error('One or more MIDI regions could not be found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedRegions.sort((a, b) => {
|
||||||
|
const startDelta = a.region.getStartFromBeat() - b.region.getStartFromBeat();
|
||||||
|
if (startDelta !== 0) return startDelta;
|
||||||
|
return a.index - b.index;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.targetTrack = targetTrack;
|
||||||
|
this.survivingRegion = resolvedRegions[0].region;
|
||||||
|
this.removedRegions = resolvedRegions.slice(1).map(({ region, index }) => ({ region, index }));
|
||||||
|
this.originalRegionSnapshots.clear();
|
||||||
|
|
||||||
|
resolvedRegions.forEach(({ region }) => {
|
||||||
|
this.originalRegionSnapshots.set(region.getId(), {
|
||||||
|
regionId: region.getId(),
|
||||||
|
startFromBeat: region.getStartFromBeat(),
|
||||||
|
length: region.getLength(),
|
||||||
|
notes: region.getNotes().map(note => ({
|
||||||
|
id: note.getId(),
|
||||||
|
startBeat: note.getStartBeat(),
|
||||||
|
endBeat: note.getEndBeat(),
|
||||||
|
pitch: note.getPitch(),
|
||||||
|
velocity: note.getVelocity(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.originalSelectedItems = [...core.getSelectedItems()];
|
||||||
|
const { showPianoRoll, activeRegionId } = useProjectStore.getState();
|
||||||
|
this.originalPianoRollState = { showPianoRoll, activeRegionId };
|
||||||
|
const resolvedTargetTrack: KGTrack = this.targetTrack;
|
||||||
|
|
||||||
|
const survivingRegionStart = this.survivingRegion.getStartFromBeat();
|
||||||
|
const mergedEndBeat = resolvedRegions.reduce((maxEndBeat, { region }) => (
|
||||||
|
Math.max(maxEndBeat, region.getStartFromBeat() + region.getLength())
|
||||||
|
), survivingRegionStart + this.survivingRegion.getLength());
|
||||||
|
|
||||||
|
const mergedNotes = [...this.survivingRegion.getNotes()];
|
||||||
|
for (const { region } of resolvedRegions.slice(1)) {
|
||||||
|
const regionStart = region.getStartFromBeat();
|
||||||
|
region.getNotes().forEach(note => {
|
||||||
|
const absoluteStart = regionStart + note.getStartBeat();
|
||||||
|
const absoluteEnd = regionStart + note.getEndBeat();
|
||||||
|
mergedNotes.push(cloneNote(
|
||||||
|
note,
|
||||||
|
absoluteStart - survivingRegionStart,
|
||||||
|
absoluteEnd - survivingRegionStart
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.survivingRegion.setLength(mergedEndBeat - survivingRegionStart);
|
||||||
|
this.survivingRegion.setNotes(mergedNotes);
|
||||||
|
|
||||||
|
const removedRegionIds = new Set(this.removedRegions.map(({ region }) => region.getId()));
|
||||||
|
const nextRegions = resolvedTargetTrack.getRegions().filter(region => !removedRegionIds.has(region.getId()));
|
||||||
|
resolvedTargetTrack.setRegions(nextRegions);
|
||||||
|
|
||||||
|
core.clearSelectedItems();
|
||||||
|
core.addSelectedItem(this.survivingRegion);
|
||||||
|
|
||||||
|
if (showPianoRoll && activeRegionId && removedRegionIds.has(activeRegionId)) {
|
||||||
|
const { setActiveRegionId, setShowPianoRoll } = useProjectStore.getState();
|
||||||
|
setActiveRegionId(this.survivingRegion.getId());
|
||||||
|
setShowPianoRoll(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.targetTrack || !this.survivingRegion || !this.originalPianoRollState) {
|
||||||
|
throw new Error('Cannot undo: merge was never executed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const survivingSnapshot = this.originalRegionSnapshots.get(this.survivingRegion.getId());
|
||||||
|
if (!survivingSnapshot) {
|
||||||
|
throw new Error('Cannot undo: missing surviving region snapshot.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.survivingRegion.setStartFromBeat(survivingSnapshot.startFromBeat);
|
||||||
|
this.survivingRegion.setLength(survivingSnapshot.length);
|
||||||
|
this.survivingRegion.setNotes(survivingSnapshot.notes.map(note => new KGMidiNote(
|
||||||
|
note.id,
|
||||||
|
note.startBeat,
|
||||||
|
note.endBeat,
|
||||||
|
note.pitch,
|
||||||
|
note.velocity
|
||||||
|
)));
|
||||||
|
|
||||||
|
for (const { region } of this.removedRegions) {
|
||||||
|
const snapshot = this.originalRegionSnapshots.get(region.getId());
|
||||||
|
if (!snapshot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
region.setStartFromBeat(snapshot.startFromBeat);
|
||||||
|
region.setLength(snapshot.length);
|
||||||
|
region.setNotes(snapshot.notes.map(note => new KGMidiNote(
|
||||||
|
note.id,
|
||||||
|
note.startBeat,
|
||||||
|
note.endBeat,
|
||||||
|
note.pitch,
|
||||||
|
note.velocity
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = [...this.targetTrack.getRegions()];
|
||||||
|
const survivingIndex = regions.findIndex(region => region.getId() === this.survivingRegion!.getId());
|
||||||
|
if (survivingIndex === -1) {
|
||||||
|
throw new Error('Cannot undo: surviving region is missing from the track.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { region, index } of [...this.removedRegions].sort((a, b) => a.index - b.index)) {
|
||||||
|
const insertIndex = Math.min(index, regions.length);
|
||||||
|
regions.splice(insertIndex, 0, region);
|
||||||
|
}
|
||||||
|
this.targetTrack.setRegions(regions);
|
||||||
|
|
||||||
|
const core = KGCore.instance();
|
||||||
|
core.clearSelectedItems();
|
||||||
|
if (this.originalSelectedItems.length > 0) {
|
||||||
|
core.addSelectedItems(this.originalSelectedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
setShowPianoRoll,
|
||||||
|
setActiveRegionId,
|
||||||
|
} = useProjectStore.getState();
|
||||||
|
setShowPianoRoll(this.originalPianoRollState.showPianoRoll);
|
||||||
|
setActiveRegionId(this.originalPianoRollState.activeRegionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return this.regionIdsToMerge.length === 2
|
||||||
|
? 'Merge 2 MIDI regions'
|
||||||
|
: `Merge ${this.regionIdsToMerge.length} MIDI regions`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1005;
|
z-index: 1005;
|
||||||
margin-left: -120px;
|
margin-left: -140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tool buttons (used by Toolbar and PianoRollToolbar) */
|
/* Tool buttons (used by Toolbar and PianoRollToolbar) */
|
||||||
|
|||||||
Reference in New Issue
Block a user