feat: allow user to bulk resize and move regions

This commit is contained in:
Xiaohan-Tian
2026-05-04 18:15:34 -07:00
parent 6d952b5b0b
commit 01a3f886d6
7 changed files with 541 additions and 22 deletions
+2 -2
View File
@@ -588,8 +588,8 @@ export class KGCore {
* Execute a command through the command history system
* @param command The command to execute
*/
public executeCommand(command: KGCommand): void {
this.commandHistory.executeCommand(command);
public executeCommand(command: KGCommand, options?: { rethrow?: boolean }): void {
this.commandHistory.executeCommand(command, options);
}
/**
+5 -2
View File
@@ -33,7 +33,7 @@ export class KGCommandHistory {
* Execute a command and add it to the history
* @param command The command to execute
*/
public executeCommand(command: KGCommand): void {
public executeCommand(command: KGCommand, options?: { rethrow?: boolean }): void {
try {
// Execute the command
command.execute();
@@ -76,6 +76,9 @@ export class KGCommandHistory {
this.notifyHistoryChanged();
} catch (error) {
console.error('Failed to execute command:', error);
if (options?.rethrow) {
throw error;
}
// Don't add failed commands to history
}
}
@@ -236,4 +239,4 @@ export class KGCommandHistory {
this.onHistoryChanged();
}
}
}
}
+2 -1
View File
@@ -18,6 +18,7 @@ export { CreateRegionCommand } from './region/CreateRegionCommand';
export { DeleteRegionCommand, DeleteMultipleRegionsCommand } from './region/DeleteRegionCommand';
export { ResizeRegionCommand } from './region/ResizeRegionCommand';
export { MoveRegionCommand } from './region/MoveRegionCommand';
export { MoveMultipleRegionsCommand, ResizeMultipleRegionsCommand } from './region/TransformRegionsCommand';
export { PasteRegionsCommand } from './region/PasteRegionsCommand';
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
export { ImportAudioCommand } from './region/ImportAudioCommand';
@@ -36,4 +37,4 @@ export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand'
// Project commands
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
export { ChangeLoopSettingsCommand, type LoopSettings } from './project/ChangeLoopSettingsCommand';
export { ChangeLoopSettingsCommand, type LoopSettings } from './project/ChangeLoopSettingsCommand';
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { MoveMultipleRegionsCommand, ResizeMultipleRegionsCommand } from './TransformRegionsCommand';
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../../test/utils/mock-data';
import { KGAudioTrack } from '../../track/KGAudioTrack';
import { KGAudioRegion } from '../../region/KGAudioRegion';
vi.mock('../../KGCore', () => ({
KGCore: {
instance: vi.fn()
}
}));
interface MockCore {
getCurrentProject: ReturnType<typeof vi.fn>
}
describe('TransformRegionsCommand', () => {
let mockCore: MockCore;
beforeEach(() => {
vi.clearAllMocks();
mockCore = {
getCurrentProject: vi.fn()
};
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
});
it('moves multiple regions across tracks by the same horizontal delta', () => {
const regionA = createMockMidiRegion({ id: 'region-a', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 4 });
const regionB = createMockMidiRegion({ id: 'region-b', trackId: '2', trackIndex: 1, startFromBeat: 8, length: 4 });
const trackA = createMockMidiTrack({ id: 1, regions: [regionA] });
const trackB = createMockMidiTrack({ id: 2, regions: [regionB] });
trackA.setTrackIndex(0);
trackB.setTrackIndex(1);
mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [trackA, trackB] }));
const command = new MoveMultipleRegionsCommand('region-a', 4, ['region-a', 'region-b']);
command.execute();
expect(regionA.getStartFromBeat()).toBe(4);
expect(regionB.getStartFromBeat()).toBe(12);
command.undo();
expect(regionA.getStartFromBeat()).toBe(0);
expect(regionB.getStartFromBeat()).toBe(8);
});
it('aborts bulk move when any projected region would overlap', () => {
const regionA = createMockMidiRegion({ id: 'region-a', trackId: '1', trackIndex: 0, name: 'Region A', startFromBeat: 0, length: 4 });
const regionB = createMockMidiRegion({ id: 'region-b', trackId: '1', trackIndex: 0, name: 'Region B', startFromBeat: 8, length: 4 });
const blocker = createMockMidiRegion({ id: 'blocker', trackId: '1', trackIndex: 0, name: 'Blocker', startFromBeat: 14, length: 4 });
const track = createMockMidiTrack({ id: 1, regions: [regionA, regionB, blocker] });
track.setTrackIndex(0);
mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
const command = new MoveMultipleRegionsCommand('region-a', 6, ['region-a', 'region-b']);
expect(() => command.execute()).toThrow('would overlap another region');
expect(regionA.getStartFromBeat()).toBe(0);
expect(regionB.getStartFromBeat()).toBe(8);
});
it('resizes multiple MIDI regions from the start and preserves absolute note timing', () => {
const midiNoteA = createMockMidiNote({ id: 'note-a', startBeat: 1, endBeat: 2 });
const midiNoteB = createMockMidiNote({ id: 'note-b', startBeat: 0.5, endBeat: 1.5 });
const regionA = createMockMidiRegion({ id: 'region-a', trackId: '1', trackIndex: 0, startFromBeat: 4, length: 4, notes: [midiNoteA] });
const regionB = createMockMidiRegion({ id: 'region-b', trackId: '1', trackIndex: 0, startFromBeat: 12, length: 4, notes: [midiNoteB] });
const track = createMockMidiTrack({ id: 1, regions: [regionA, regionB] });
track.setTrackIndex(0);
mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track], bpm: 120 }));
const command = new ResizeMultipleRegionsCommand('region-a', 'start', 1, 0, ['region-a', 'region-b']);
command.execute();
expect(regionA.getStartFromBeat()).toBe(5);
expect(regionA.getLength()).toBe(3);
expect(midiNoteA.getStartBeat()).toBe(0);
expect(midiNoteA.getEndBeat()).toBe(1);
expect(regionB.getStartFromBeat()).toBe(13);
expect(regionB.getLength()).toBe(3);
expect(midiNoteB.getStartBeat()).toBe(-0.5);
expect(midiNoteB.getEndBeat()).toBe(0.5);
command.undo();
expect(regionA.getStartFromBeat()).toBe(4);
expect(regionA.getLength()).toBe(4);
expect(midiNoteA.getStartBeat()).toBe(1);
expect(midiNoteA.getEndBeat()).toBe(2);
expect(regionB.getStartFromBeat()).toBe(12);
expect(regionB.getLength()).toBe(4);
});
it('aborts bulk resize when any audio region would exceed its source audio bounds', () => {
const audioTrack = new KGAudioTrack('Audio', 2);
audioTrack.setTrackIndex(0);
const audioA = new KGAudioRegion('audio-a', '2', 0, 'Audio A', 0, 4, 'file-a', 'a.wav', 2, 0);
const audioB = new KGAudioRegion('audio-b', '2', 0, 'Audio B', 8, 4, 'file-b', 'b.wav', 2, 0);
audioTrack.setRegions([audioA, audioB]);
mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [audioTrack as never], bpm: 120 }));
const command = new ResizeMultipleRegionsCommand('audio-a', 'end', 0, 1, ['audio-a', 'audio-b']);
expect(() => command.execute()).toThrow('would extend past the end of its audio file');
expect(audioA.getLength()).toBe(4);
expect(audioB.getLength()).toBe(4);
});
});
@@ -0,0 +1,334 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGRegion } from '../../region/KGRegion';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGAudioRegion } from '../../region/KGAudioRegion';
import { KGTrack } from '../../track/KGTrack';
import { REGION_CONSTANTS } from '../../../constants';
interface RegionSnapshot {
regionId: string;
trackId: string;
trackIndex: number;
startBeat: number;
length: number;
clipStartOffsetSeconds?: number;
}
interface ProjectedRegionState extends RegionSnapshot {
region: KGRegion;
}
interface NoteAdjustment {
noteId: string;
originalStartBeat: number;
originalEndBeat: number;
}
const EPSILON = 1e-9;
function getRegionById(tracks: KGTrack[], regionId: string): { region: KGRegion; track: KGTrack } | null {
for (const track of tracks) {
const region = track.getRegions().find(candidate => candidate.getId() === regionId);
if (region) {
return { region, track };
}
}
return null;
}
function rangesOverlap(aStart: number, aLength: number, bStart: number, bLength: number): boolean {
const aEnd = aStart + aLength;
const bEnd = bStart + bLength;
return aStart < bEnd - EPSILON && aEnd > bStart + EPSILON;
}
function validateNoProjectedOverlaps(projectedStates: ProjectedRegionState[], allTracks: KGTrack[]): void {
const projectedById = new Map(projectedStates.map(state => [state.regionId, state]));
for (const projectedState of projectedStates) {
const targetTrack = allTracks.find(track => track.getId().toString() === projectedState.trackId);
if (!targetTrack) {
throw new Error('Unable to validate region movement because the target track was not found.');
}
for (const region of targetTrack.getRegions()) {
const comparisonState = projectedById.get(region.getId()) ?? {
regionId: region.getId(),
trackId: region.getTrackId(),
trackIndex: region.getTrackIndex(),
startBeat: region.getStartFromBeat(),
length: region.getLength(),
clipStartOffsetSeconds: region instanceof KGAudioRegion ? region.getClipStartOffsetSeconds() : undefined,
region,
};
if (comparisonState.regionId === projectedState.regionId) {
continue;
}
if (rangesOverlap(projectedState.startBeat, projectedState.length, comparisonState.startBeat, comparisonState.length)) {
throw new Error(`Cannot complete this edit because "${projectedState.region.getName()}" would overlap another region on its track.`);
}
}
}
}
export class MoveMultipleRegionsCommand extends KGCommand {
private readonly primaryRegionId: string;
private readonly startBeatDelta: number;
private readonly regionIdsToMove: string[];
private originalStates: RegionSnapshot[] = [];
private targetRegions: KGRegion[] = [];
constructor(primaryRegionId: string, startBeatDelta: number, regionIdsToMove: string[]) {
super();
this.primaryRegionId = primaryRegionId;
this.startBeatDelta = startBeatDelta;
this.regionIdsToMove = [...regionIdsToMove];
}
execute(): void {
const tracks = KGCore.instance().getCurrentProject().getTracks();
const resolvedRegions = this.regionIdsToMove.map(regionId => {
const resolved = getRegionById(tracks, regionId);
if (!resolved) {
throw new Error(`Region with ID ${regionId} not found.`);
}
return resolved;
});
if (!resolvedRegions.some(({ region }) => region.getId() === this.primaryRegionId)) {
throw new Error(`Primary region with ID ${this.primaryRegionId} was not found in the selected set.`);
}
const projectedStates: ProjectedRegionState[] = resolvedRegions.map(({ region, track }) => {
const newStartBeat = region.getStartFromBeat() + this.startBeatDelta;
if (newStartBeat < -EPSILON) {
throw new Error(`Cannot move regions because "${region.getName()}" would start before bar 1.`);
}
return {
regionId: region.getId(),
trackId: track.getId().toString(),
trackIndex: track.getTrackIndex(),
startBeat: Math.max(0, newStartBeat),
length: region.getLength(),
clipStartOffsetSeconds: region instanceof KGAudioRegion ? region.getClipStartOffsetSeconds() : undefined,
region,
};
});
validateNoProjectedOverlaps(projectedStates, tracks);
this.originalStates = resolvedRegions.map(({ region, track }) => ({
regionId: region.getId(),
trackId: track.getId().toString(),
trackIndex: track.getTrackIndex(),
startBeat: region.getStartFromBeat(),
length: region.getLength(),
clipStartOffsetSeconds: region instanceof KGAudioRegion ? region.getClipStartOffsetSeconds() : undefined,
}));
this.targetRegions = resolvedRegions.map(({ region }) => region);
projectedStates.forEach(projectedState => {
projectedState.region.setStartFromBeat(projectedState.startBeat);
});
console.log(`Moved ${projectedStates.length} regions by ${this.startBeatDelta.toFixed(3)} beats`);
}
undo(): void {
if (this.originalStates.length === 0) {
throw new Error('Cannot undo: no regions were moved.');
}
this.originalStates.forEach(originalState => {
const region = this.targetRegions.find(candidate => candidate.getId() === originalState.regionId);
if (!region) {
return;
}
region.setStartFromBeat(originalState.startBeat);
});
}
getDescription(): string {
return this.regionIdsToMove.length === 1
? 'Move region'
: `Move ${this.regionIdsToMove.length} regions`;
}
}
export class ResizeMultipleRegionsCommand extends KGCommand {
private readonly primaryRegionId: string;
private readonly resizeEdge: 'start' | 'end';
private readonly primaryStartBeatDelta: number;
private readonly primaryEndBeatDelta: number;
private readonly regionIdsToResize: string[];
private originalStates: RegionSnapshot[] = [];
private targetRegions: KGRegion[] = [];
private noteAdjustments = new Map<string, NoteAdjustment[]>();
constructor(
primaryRegionId: string,
resizeEdge: 'start' | 'end',
primaryStartBeatDelta: number,
primaryEndBeatDelta: number,
regionIdsToResize: string[]
) {
super();
this.primaryRegionId = primaryRegionId;
this.resizeEdge = resizeEdge;
this.primaryStartBeatDelta = primaryStartBeatDelta;
this.primaryEndBeatDelta = primaryEndBeatDelta;
this.regionIdsToResize = [...regionIdsToResize];
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const tracks = project.getTracks();
const bpm = project.getBpm();
const secondsPerBeat = 60 / bpm;
const resolvedRegions = this.regionIdsToResize.map(regionId => {
const resolved = getRegionById(tracks, regionId);
if (!resolved) {
throw new Error(`Region with ID ${regionId} not found.`);
}
return resolved;
});
if (!resolvedRegions.some(({ region }) => region.getId() === this.primaryRegionId)) {
throw new Error(`Primary region with ID ${this.primaryRegionId} was not found in the selected set.`);
}
const projectedStates: ProjectedRegionState[] = resolvedRegions.map(({ region, track }) => {
const startDelta = this.resizeEdge === 'start' ? this.primaryStartBeatDelta : 0;
const endDelta = this.resizeEdge === 'end' ? this.primaryEndBeatDelta : 0;
const newStartBeat = region.getStartFromBeat() + startDelta;
const newLength = this.resizeEdge === 'start'
? region.getLength() - startDelta
: region.getLength() + endDelta;
if (newStartBeat < -EPSILON) {
throw new Error(`Cannot resize regions because "${region.getName()}" would start before bar 1.`);
}
if (newLength < REGION_CONSTANTS.MIN_REGION_LENGTH - EPSILON) {
throw new Error(`Cannot resize regions because "${region.getName()}" would become shorter than the minimum region length.`);
}
let clipStartOffsetSeconds = region instanceof KGAudioRegion ? region.getClipStartOffsetSeconds() : undefined;
if (region instanceof KGAudioRegion) {
const audioDuration = region.getAudioDurationSeconds();
if (this.resizeEdge === 'start') {
const beatOffset = newStartBeat - region.getStartFromBeat();
const secondsDelta = beatOffset * secondsPerBeat;
const nextOffset = region.getClipStartOffsetSeconds() + secondsDelta;
if (nextOffset < -EPSILON) {
throw new Error(`Cannot resize regions because "${region.getName()}" would extend before the start of its audio file.`);
}
clipStartOffsetSeconds = Math.min(nextOffset, audioDuration);
}
const effectiveOffset = clipStartOffsetSeconds ?? 0;
const maxLengthInBeats = (audioDuration - effectiveOffset) / secondsPerBeat;
if (newLength > maxLengthInBeats + EPSILON) {
throw new Error(`Cannot resize regions because "${region.getName()}" would extend past the end of its audio file.`);
}
}
return {
regionId: region.getId(),
trackId: track.getId().toString(),
trackIndex: track.getTrackIndex(),
startBeat: Math.max(0, newStartBeat),
length: newLength,
clipStartOffsetSeconds,
region,
};
});
validateNoProjectedOverlaps(projectedStates, tracks);
this.originalStates = resolvedRegions.map(({ region, track }) => ({
regionId: region.getId(),
trackId: track.getId().toString(),
trackIndex: track.getTrackIndex(),
startBeat: region.getStartFromBeat(),
length: region.getLength(),
clipStartOffsetSeconds: region instanceof KGAudioRegion ? region.getClipStartOffsetSeconds() : undefined,
}));
this.targetRegions = resolvedRegions.map(({ region }) => region);
this.noteAdjustments.clear();
projectedStates.forEach(projectedState => {
const region = projectedState.region;
if (this.resizeEdge === 'start' && region instanceof KGMidiRegion) {
const beatOffset = projectedState.startBeat - region.getStartFromBeat();
const adjustments: NoteAdjustment[] = region.getNotes().map(note => ({
noteId: note.getId(),
originalStartBeat: note.getStartBeat(),
originalEndBeat: note.getEndBeat(),
}));
this.noteAdjustments.set(region.getId(), adjustments);
region.getNotes().forEach(note => {
note.setStartBeat(note.getStartBeat() - beatOffset);
note.setEndBeat(note.getEndBeat() - beatOffset);
});
}
if (region instanceof KGAudioRegion && projectedState.clipStartOffsetSeconds !== undefined) {
region.setClipStartOffsetSeconds(projectedState.clipStartOffsetSeconds);
}
region.setStartFromBeat(projectedState.startBeat);
region.setLength(projectedState.length);
});
console.log(`Resized ${projectedStates.length} regions from ${this.resizeEdge}`);
}
undo(): void {
if (this.originalStates.length === 0) {
throw new Error('Cannot undo: no regions were resized.');
}
this.originalStates.forEach(originalState => {
const region = this.targetRegions.find(candidate => candidate.getId() === originalState.regionId);
if (!region) {
return;
}
if (region instanceof KGMidiRegion) {
const adjustments = this.noteAdjustments.get(region.getId()) ?? [];
adjustments.forEach(adjustment => {
const note = region.getNotes().find(candidate => candidate.getId() === adjustment.noteId);
if (note) {
note.setStartBeat(adjustment.originalStartBeat);
note.setEndBeat(adjustment.originalEndBeat);
}
});
}
if (region instanceof KGAudioRegion && originalState.clipStartOffsetSeconds !== undefined) {
region.setClipStartOffsetSeconds(originalState.clipStartOffsetSeconds);
}
region.setStartFromBeat(originalState.startBeat);
region.setLength(originalState.length);
});
}
getDescription(): string {
return this.regionIdsToResize.length === 1
? `Resize region from ${this.resizeEdge}`
: `Resize ${this.regionIdsToResize.length} regions from ${this.resizeEdge}`;
}
}