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 = () => { > +