feat: added merge MIDI regions feature
This commit is contained in:
+101
-1
@@ -12,7 +12,7 @@ import {
|
||||
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
||||
FaPlay, FaPause, FaComments, FaSync,
|
||||
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
||||
FaCog, FaMagnet, FaCut, FaCircle
|
||||
FaCog, FaMagnet, FaCut, FaCircle, FaCompress
|
||||
} from 'react-icons/fa';
|
||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||
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 { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||
import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand';
|
||||
import { MergeMidiRegionsCommand } from '../core/commands/region/MergeMidiRegionsCommand';
|
||||
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
|
||||
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
||||
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
|
||||
const handleUndoClick = async () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
@@ -975,6 +1069,12 @@ const Toolbar: React.FC = () => {
|
||||
>
|
||||
<FaCut />
|
||||
</button>
|
||||
<button
|
||||
title="Merge Selected MIDI Regions"
|
||||
onClick={handleMergeClick}
|
||||
>
|
||||
<FaCompress />
|
||||
</button>
|
||||
<button
|
||||
title="Snap to Grid"
|
||||
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
||||
|
||||
@@ -26,6 +26,7 @@ export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
|
||||
export { ImportStemsCommand } from './region/ImportStemsCommand';
|
||||
export type { StemImportEntry } from './region/ImportStemsCommand';
|
||||
export { SplitRegionCommand } from './region/SplitRegionCommand';
|
||||
export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
|
||||
|
||||
// Note commands
|
||||
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;
|
||||
justify-content: center;
|
||||
z-index: 1005;
|
||||
margin-left: -120px;
|
||||
margin-left: -140px;
|
||||
}
|
||||
|
||||
/* Tool buttons (used by Toolbar and PianoRollToolbar) */
|
||||
|
||||
Reference in New Issue
Block a user