feat: added split region feature
This commit is contained in:
@@ -12,13 +12,14 @@ 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
|
FaCog, FaMagnet, FaCut
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6';
|
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6';
|
||||||
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 { 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';
|
||||||
@@ -45,7 +46,9 @@ const Toolbar: React.FC = () => {
|
|||||||
// Piano roll state/actions
|
// Piano roll state/actions
|
||||||
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
||||||
// Selection state
|
// Selection state
|
||||||
selectedRegionIds
|
selectedRegionIds,
|
||||||
|
// Playhead and refresh
|
||||||
|
playheadPosition, refreshProjectState
|
||||||
} = useProjectStore();
|
} = useProjectStore();
|
||||||
|
|
||||||
// State for main content tools
|
// State for main content tools
|
||||||
@@ -709,6 +712,52 @@ const Toolbar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle split region button click
|
||||||
|
const handleSplitClick = () => {
|
||||||
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
|
console.log("Split button clicked");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRegionIds.length === 0) {
|
||||||
|
alert("Please select a region to split.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedRegionIds.length > 1) {
|
||||||
|
alert("Please select exactly one region to split.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionId = selectedRegionIds[0];
|
||||||
|
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||||
|
let targetRegion = null;
|
||||||
|
for (const track of tracks) {
|
||||||
|
const found = track.getRegions().find(r => r.getId() === regionId);
|
||||||
|
if (found) { targetRegion = found; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetRegion) {
|
||||||
|
alert("Selected region not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionStart = targetRegion.getStartFromBeat();
|
||||||
|
const regionEnd = regionStart + targetRegion.getLength();
|
||||||
|
|
||||||
|
if (playheadPosition <= regionStart || playheadPosition >= regionEnd) {
|
||||||
|
alert("The playhead is not inside the selected region. Move the playhead inside the region before splitting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new SplitRegionCommand(regionId, playheadPosition);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
refreshProjectState();
|
||||||
|
setStatus(`Split region at beat ${playheadPosition.toFixed(2)}`);
|
||||||
|
|
||||||
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
|
console.log(`Split region ${regionId} at beat ${playheadPosition}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle undo button click
|
// Handle undo button click
|
||||||
const handleUndoClick = () => {
|
const handleUndoClick = () => {
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
@@ -868,6 +917,12 @@ const Toolbar: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FaPencil />
|
<FaPencil />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
title="Split Region at Playhead"
|
||||||
|
onClick={handleSplitClick}
|
||||||
|
>
|
||||||
|
<FaCut />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Snap to Grid"
|
title="Snap to Grid"
|
||||||
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export { ImportAudioCommand } from './region/ImportAudioCommand';
|
|||||||
export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
|
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';
|
||||||
|
|
||||||
// Note commands
|
// Note commands
|
||||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGRegion } from '../../region/KGRegion';
|
||||||
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
|
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||||
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
|
import { generateUniqueId } from '../../../util/miscUtil';
|
||||||
|
import { useProjectStore } from '../../../stores/projectStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command to split a region into two at the given beat position.
|
||||||
|
*
|
||||||
|
* MIDI regions: notes whose startBeat is before the split stay in region 1
|
||||||
|
* (endBeat is NOT clamped even if it crosses the boundary). Notes whose
|
||||||
|
* startBeat is at or after the split go to region 2 with their beat
|
||||||
|
* positions shifted so they are relative to the new region start.
|
||||||
|
*
|
||||||
|
* Audio regions: both halves share the same audioFileId / audioFileName /
|
||||||
|
* audioDurationSeconds (no file copy). Region 2 gets a new
|
||||||
|
* clipStartOffsetSeconds = original + splitOffsetSeconds.
|
||||||
|
*/
|
||||||
|
export class SplitRegionCommand extends KGCommand {
|
||||||
|
private regionId: string;
|
||||||
|
private splitAtBeat: number; // absolute beat position of the playhead
|
||||||
|
|
||||||
|
// Stored during execute(), used during undo()
|
||||||
|
private originalRegion: KGRegion | null = null;
|
||||||
|
private region1: KGRegion | null = null;
|
||||||
|
private region2: KGRegion | null = null;
|
||||||
|
private targetTrack: KGTrack | null = null;
|
||||||
|
private originalRegionIndex: number = -1;
|
||||||
|
|
||||||
|
constructor(regionId: string, splitAtBeat: number) {
|
||||||
|
super();
|
||||||
|
this.regionId = regionId;
|
||||||
|
this.splitAtBeat = splitAtBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||||
|
let targetTrack: KGTrack | null = null;
|
||||||
|
let originalRegion: KGRegion | null = null;
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
const regions = track.getRegions();
|
||||||
|
const idx = regions.findIndex(r => r.getId() === this.regionId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
targetTrack = track;
|
||||||
|
originalRegion = regions[idx];
|
||||||
|
this.originalRegionIndex = idx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetTrack || !originalRegion) {
|
||||||
|
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetTrack = targetTrack;
|
||||||
|
this.originalRegion = originalRegion;
|
||||||
|
|
||||||
|
const regionStart = originalRegion.getStartFromBeat();
|
||||||
|
const regionLength = originalRegion.getLength();
|
||||||
|
const splitOffsetBeats = this.splitAtBeat - regionStart;
|
||||||
|
|
||||||
|
if (splitOffsetBeats <= 0 || splitOffsetBeats >= regionLength) {
|
||||||
|
throw new Error(
|
||||||
|
`Split point ${this.splitAtBeat} is not within region range [${regionStart}, ${regionStart + regionLength})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackId = targetTrack.getId().toString();
|
||||||
|
const trackIdx = targetTrack.getTrackIndex();
|
||||||
|
|
||||||
|
if (originalRegion instanceof KGMidiRegion) {
|
||||||
|
const region1 = new KGMidiRegion(
|
||||||
|
generateUniqueId('KGMidiRegion'),
|
||||||
|
trackId,
|
||||||
|
trackIdx,
|
||||||
|
originalRegion.getName(),
|
||||||
|
regionStart,
|
||||||
|
splitOffsetBeats
|
||||||
|
);
|
||||||
|
|
||||||
|
const region2 = new KGMidiRegion(
|
||||||
|
generateUniqueId('KGMidiRegion'),
|
||||||
|
trackId,
|
||||||
|
trackIdx,
|
||||||
|
originalRegion.getName() + ' (2)',
|
||||||
|
this.splitAtBeat,
|
||||||
|
regionLength - splitOffsetBeats
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const note of originalRegion.getNotes()) {
|
||||||
|
if (note.getStartBeat() < splitOffsetBeats) {
|
||||||
|
// Stays in region 1 — copy as-is, do not clamp endBeat
|
||||||
|
region1.addNote(new KGMidiNote(
|
||||||
|
generateUniqueId('KGMidiNote'),
|
||||||
|
note.getStartBeat(),
|
||||||
|
note.getEndBeat(),
|
||||||
|
note.getPitch(),
|
||||||
|
note.getVelocity()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Goes into region 2 — shift both beats relative to new region start
|
||||||
|
region2.addNote(new KGMidiNote(
|
||||||
|
generateUniqueId('KGMidiNote'),
|
||||||
|
note.getStartBeat() - splitOffsetBeats,
|
||||||
|
note.getEndBeat() - splitOffsetBeats,
|
||||||
|
note.getPitch(),
|
||||||
|
note.getVelocity()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.region1 = region1;
|
||||||
|
this.region2 = region2;
|
||||||
|
|
||||||
|
} else if (originalRegion instanceof KGAudioRegion) {
|
||||||
|
const bpm = KGCore.instance().getCurrentProject().getBpm();
|
||||||
|
const splitOffsetSeconds = splitOffsetBeats * (60 / bpm);
|
||||||
|
|
||||||
|
this.region1 = new KGAudioRegion(
|
||||||
|
generateUniqueId('KGAudioRegion'),
|
||||||
|
trackId,
|
||||||
|
trackIdx,
|
||||||
|
originalRegion.getName(),
|
||||||
|
regionStart,
|
||||||
|
splitOffsetBeats,
|
||||||
|
originalRegion.getAudioFileId(),
|
||||||
|
originalRegion.getAudioFileName(),
|
||||||
|
originalRegion.getAudioDurationSeconds(),
|
||||||
|
originalRegion.getClipStartOffsetSeconds()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.region2 = new KGAudioRegion(
|
||||||
|
generateUniqueId('KGAudioRegion'),
|
||||||
|
trackId,
|
||||||
|
trackIdx,
|
||||||
|
originalRegion.getName() + ' (2)',
|
||||||
|
this.splitAtBeat,
|
||||||
|
regionLength - splitOffsetBeats,
|
||||||
|
originalRegion.getAudioFileId(),
|
||||||
|
originalRegion.getAudioFileName(),
|
||||||
|
originalRegion.getAudioDurationSeconds(),
|
||||||
|
originalRegion.getClipStartOffsetSeconds() + splitOffsetSeconds
|
||||||
|
);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported region type for splitting: ${originalRegion.getCurrentType()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace original region with the two new regions
|
||||||
|
const regions = targetTrack.getRegions();
|
||||||
|
regions.splice(this.originalRegionIndex, 1, this.region1!, this.region2!);
|
||||||
|
targetTrack.setRegions(regions);
|
||||||
|
|
||||||
|
// Close piano roll if it is open for the region being split
|
||||||
|
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
|
||||||
|
if (showPianoRoll && activeRegionId === this.regionId) {
|
||||||
|
setShowPianoRoll(false);
|
||||||
|
setActiveRegionId(null);
|
||||||
|
console.log(`Closed piano roll because active region ${this.regionId} is being split`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Split region "${originalRegion.getName()}" at beat ${this.splitAtBeat} (offset ${splitOffsetBeats} beats)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.targetTrack || !this.originalRegion || !this.region1 || !this.region2) {
|
||||||
|
throw new Error('Cannot undo: split was never executed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find region1 by ID (robust to any reordering)
|
||||||
|
const regions = this.targetTrack.getRegions();
|
||||||
|
const idx1 = regions.findIndex(r => r.getId() === this.region1!.getId());
|
||||||
|
if (idx1 === -1) {
|
||||||
|
throw new Error('Cannot undo: split regions not found in track');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the two produced regions and restore the original
|
||||||
|
regions.splice(idx1, 2, this.originalRegion);
|
||||||
|
this.targetTrack.setRegions(regions);
|
||||||
|
|
||||||
|
console.log(`Restored region "${this.originalRegion.getName()}" (undo split)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
const name = this.originalRegion ? this.originalRegion.getName() : `Region ${this.regionId}`;
|
||||||
|
return `Split region "${name}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRegionId(): string {
|
||||||
|
return this.regionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCreatedRegion1(): KGRegion | null {
|
||||||
|
return this.region1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCreatedRegion2(): KGRegion | null {
|
||||||
|
return this.region2;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user