From 066946ea646f375c2e906a1cc6e5b6c4a7ba7cd2 Mon Sep 17 00:00:00 2001
From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com>
Date: Mon, 4 May 2026 18:58:51 -0700
Subject: [PATCH] feat: added merge MIDI regions feature
---
src/components/Toolbar.tsx | 102 ++++++++-
src/core/commands/index.ts | 1 +
.../region/MergeMidiRegionsCommand.test.ts | 166 ++++++++++++++
.../region/MergeMidiRegionsCommand.ts | 216 ++++++++++++++++++
src/styles/shared.css | 2 +-
5 files changed, 485 insertions(+), 2 deletions(-)
create mode 100644 src/core/commands/region/MergeMidiRegionsCommand.test.ts
create mode 100644 src/core/commands/region/MergeMidiRegionsCommand.ts
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index f2b076c..ba68fea 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -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 = () => {
>
+