diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index f8c187b..35f5351 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -8,6 +8,7 @@ import type { RegionClickOptions, RegionUI, ResizeAction } from '../interfaces'; import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; +import { useProjectStore } from '../../stores/projectStore'; interface TrackGridItemProps { track: KGTrack; @@ -60,6 +61,7 @@ const TrackGridItem: React.FC = ({ allTracks, onKGOneClipDrop, }) => { + const selectedRegionIds = useProjectStore(state => state.selectedRegionIds); const [containerWidth, setContainerWidth] = useState(0); const [resizingRegion, setResizingRegion] = useState(null); const [draggingRegion, setDraggingRegion] = useState(null); @@ -80,6 +82,8 @@ const TrackGridItem: React.FC = ({ const currentDragRegion = useRef(null); const trackElementRef = useRef(null); + const isBulkRegionEdit = (regionId: string) => selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId); + // Update container width when the grid container changes size useEffect(() => { if (!gridContainerRef.current) return; @@ -393,6 +397,9 @@ const TrackGridItem: React.FC = ({ // Get the initial left position const initialLeft = (region.barNumber - 1) * barWidth; + const isBulkEdit = isBulkRegionEdit(regionId); + const appliedDeltaY = isBulkEdit ? 0 : deltaY; + // Calculate new left position const newLeft = initialLeft + deltaX; @@ -401,7 +408,7 @@ const TrackGridItem: React.FC = ({ // Store the current drag position for use in handleRegionDragEnd currentDragLeft.current = newLeft; - currentDragTop.current = deltaY; + currentDragTop.current = appliedDeltaY; if (DEBUG_MODE.TRACK_GRID_ITEM) { console.log(`DRAG: regionId=${regionId}, deltaX=${deltaX}, deltaY=${deltaY}, newBarNumber=${newBarNumber}`); @@ -413,7 +420,7 @@ const TrackGridItem: React.FC = ({ width: `${region.length * barWidth}px`, position: 'absolute' as const, zIndex: 100, // Keep on top during drag - transform: `translateY(${deltaY}px)`, + transform: `translateY(${appliedDeltaY}px)`, }; setTempRegionStyles(prev => ({ @@ -449,13 +456,14 @@ const TrackGridItem: React.FC = ({ // If the mouse was moved, calculate the final position if (mouseMoved.current && currentDragLeft.current !== null && currentDragTop.current !== null) { + const isBulkEdit = isBulkRegionEdit(regionId); // Calculate the new bar number; snap to nearest integer when snapping is on const snap = KGMainContentState.instance().isSnappingEnabled(); const rawBarNumber = (currentDragLeft.current / barWidth) + 1; finalBarNumber = Math.max(1, snap ? Math.round(rawBarNumber) : rawBarNumber); // Calculate the closest track based on vertical position - if (allTracks && allTracks.length > 0 && gridContainerRef.current) { + if (!isBulkEdit && allTracks && allTracks.length > 0 && gridContainerRef.current) { const trackHeight = gridContainerRef.current.clientHeight / allTracks.length; // Calculate the absolute vertical position @@ -476,6 +484,8 @@ const TrackGridItem: React.FC = ({ console.log(`Track change: from trackIndex=${region.trackIndex} (trackId=${region.trackId}) to trackIndex=${finalTrackIndex} (trackId=${allTracks[finalTrackIndex].getId()})`); } } + } else { + finalTrackIndex = region.trackIndex; } if (DEBUG_MODE.TRACK_GRID_ITEM) { diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index e186464..2a41ae6 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -7,7 +7,7 @@ import type { RegionClickOptions, RegionUI } from '../interfaces'; import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; -import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand, ImportAudioCommand, ImportMidiClipCommand } from '../../core/commands'; +import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand, MoveMultipleRegionsCommand, ResizeMultipleRegionsCommand, ImportAudioCommand, ImportMidiClipCommand } from '../../core/commands'; import { KGCore } from '../../core/KGCore'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { KGAudioRegion } from '../../core/region/KGAudioRegion'; @@ -16,6 +16,7 @@ import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage'; import { showAlert } from '../../util/dialogUtil'; import { parseMidiFirstTrackNotes } from '../../util/midiUtil'; import * as Tone from 'tone'; +import { useProjectStore } from '../../stores/projectStore'; interface TrackGridPanelProps { tracks: KGTrack[]; @@ -56,10 +57,18 @@ const TrackGridPanel: React.FC = ({ onOpenHybrid, onExternalDropComplete, }) => { + const selectedRegionIds = useProjectStore(state => state.selectedRegionIds); + const refreshProjectState = useProjectStore(state => state.refreshProjectState); const gridContainerRef = useRef(null); const [showAudioImportModal, setShowAudioImportModal] = useState(false); const pendingAudioImportRef = useRef<{ barNumber: number; trackIndex: number } | null>(null); + const getBulkSelectedRegionIds = (primaryRegionId: string) => ( + selectedRegionIds.length > 1 && selectedRegionIds.includes(primaryRegionId) + ? selectedRegionIds + : [primaryRegionId] + ); + // Utility function to create a region at a specific position const createRegionAtPosition = async (e: React.MouseEvent, trackIndex: number) => { // Get the grid container element @@ -252,7 +261,7 @@ const TrackGridPanel: React.FC = ({ }; // Handle region resize end - const handleRegionResizeEnd = (regionId: string, finalBarNumber: number, finalLength: number) => { + const handleRegionResizeEnd = async (regionId: string, finalBarNumber: number, finalLength: number) => { // Now we update the model with the final rounded values if (DEBUG_MODE.TRACK_GRID_PANEL) { console.log(`Finished resizing region ${regionId} to barNumber ${finalBarNumber}, length ${finalLength}`); @@ -278,15 +287,17 @@ const TrackGridPanel: React.FC = ({ if (coreRegion) { const oldStartBeat = coreRegion.getStartFromBeat(); const oldBarNumber = region.barNumber; + const bulkRegionIds = getBulkSelectedRegionIds(regionId); + const isBulkEdit = bulkRegionIds.length > 1; if (DEBUG_MODE.TRACK_GRID_PANEL) { console.log(`Updating KGRegion model - Before: startBeat=${oldStartBeat}, length=${coreRegion.getLength()}`); console.log(`Bar numbers - old: ${oldBarNumber}, new: ${finalBarNumber}`); } - // Clamp audio region resize to audio file boundaries + // Clamp audio region resize to audio file boundaries for single-region editing only. let newClipStartOffsetSeconds: number | undefined; - if (coreRegion instanceof KGAudioRegion) { + if (!isBulkEdit && coreRegion instanceof KGAudioRegion) { const bpm = KGCore.instance().getCurrentProject().getBpm(); const secondsPerBeat = 60 / bpm; const clipOffset = coreRegion.getClipStartOffsetSeconds(); @@ -332,6 +343,20 @@ const TrackGridPanel: React.FC = ({ // Use command pattern to update the region position and length (note adjustments handled inside command) try { + if (isBulkEdit) { + const command = new ResizeMultipleRegionsCommand( + regionId, + clampedBarNumber !== oldBarNumber ? 'start' : 'end', + newStartBeat - oldStartBeat, + newLengthInBeats - coreRegion.getLength(), + bulkRegionIds + ); + + KGCore.instance().executeCommand(command, { rethrow: true }); + refreshProjectState(); + return; + } + const command = ResizeRegionCommand.fromBarCoordinates( regionId, clampedBarNumber, @@ -340,7 +365,7 @@ const TrackGridPanel: React.FC = ({ newClipStartOffsetSeconds ); - KGCore.instance().executeCommand(command); + KGCore.instance().executeCommand(command, { rethrow: true }); if (DEBUG_MODE.TRACK_GRID_PANEL) { console.log(`Executed ResizeRegionCommand: region ${regionId} resized using command pattern`); @@ -351,6 +376,7 @@ const TrackGridPanel: React.FC = ({ } } catch (error) { console.error('Error resizing region:', error); + await showAlert(error instanceof Error ? error.message : 'Unable to resize the selected regions.'); return; } @@ -377,7 +403,7 @@ const TrackGridPanel: React.FC = ({ }; // Handle region drag end - const handleRegionDragEnd = (regionId: string, finalBarNumber: number, finalTrackIndex: number) => { + const handleRegionDragEnd = async (regionId: string, finalBarNumber: number, finalTrackIndex: number) => { // Now we update the model with the final rounded values if (DEBUG_MODE.TRACK_GRID_PANEL) { console.log(`Finished dragging region ${regionId} to barNumber ${finalBarNumber}, trackIndex ${finalTrackIndex}`); @@ -386,8 +412,11 @@ const TrackGridPanel: React.FC = ({ // Find the region const region = regions.find(r => r.id === regionId); if (!region) return; + const bulkRegionIds = getBulkSelectedRegionIds(regionId); + const isBulkEdit = bulkRegionIds.length > 1; + const effectiveTrackIndex = isBulkEdit ? region.trackIndex : finalTrackIndex; - if (finalBarNumber === region.barNumber && finalTrackIndex === region.trackIndex) { + if (finalBarNumber === region.barNumber && effectiveTrackIndex === region.trackIndex) { if (DEBUG_MODE.TRACK_GRID_PANEL) { console.log(`Skipping no-op move for region ${regionId}`); } @@ -395,7 +424,7 @@ const TrackGridPanel: React.FC = ({ } // Get the target track - const targetTrack = tracks[finalTrackIndex]; + const targetTrack = tracks[effectiveTrackIndex]; if (!targetTrack) return; // Block cross-type region moves (MIDI <-> Audio) @@ -412,15 +441,29 @@ const TrackGridPanel: React.FC = ({ // Use command pattern to move the region try { + if (isBulkEdit) { + const oldStartBeat = (region.barNumber - 1) * timeSignature.numerator; + const newStartBeat = (finalBarNumber - 1) * timeSignature.numerator; + const command = new MoveMultipleRegionsCommand( + regionId, + newStartBeat - oldStartBeat, + bulkRegionIds + ); + + KGCore.instance().executeCommand(command, { rethrow: true }); + refreshProjectState(); + return; + } + const command = MoveRegionCommand.fromBarCoordinates( regionId, finalBarNumber, targetTrack.getId().toString(), - finalTrackIndex, + effectiveTrackIndex, timeSignature ); - KGCore.instance().executeCommand(command); + KGCore.instance().executeCommand(command, { rethrow: true }); // Copy audio buffer to target track if this is a cross-track audio region move if (sourceTrack && targetTrack && sourceTrack.getId() !== targetTrack.getId()) { @@ -444,6 +487,7 @@ const TrackGridPanel: React.FC = ({ } } catch (error) { console.error('Error moving region:', error); + await showAlert(error instanceof Error ? error.message : 'Unable to move the selected regions.'); return; } @@ -454,7 +498,7 @@ const TrackGridPanel: React.FC = ({ // Update the region in the parent component with expected model values if (onRegionUpdated) { // Find the updated region to get its length - const updatedTrack = tracks[finalTrackIndex]; + const updatedTrack = tracks[effectiveTrackIndex]; const updatedRegions = updatedTrack.getRegions(); const updatedRegion = updatedRegions.find(r => r.getId() === regionId); @@ -462,7 +506,7 @@ const TrackGridPanel: React.FC = ({ regionId, { trackId: targetTrack.getId().toString(), - trackIndex: finalTrackIndex, + trackIndex: effectiveTrackIndex, barNumber: finalBarNumber }, { @@ -474,19 +518,32 @@ const TrackGridPanel: React.FC = ({ }; // Handle fine-move end — execute MoveRegionCommand with float-precision beat position - const handleRegionFineMoveEnd = (regionId: string, deltaInBars: number) => { + const handleRegionFineMoveEnd = async (regionId: string, deltaInBars: number) => { const region = regions.find(r => r.id === regionId); if (!region) return; const track = tracks.find(t => t.getId().toString() === region.trackId); if (!track) return; const coreRegion = track.getRegions().find(r => r.getId() === regionId); if (!coreRegion) return; + const bulkRegionIds = getBulkSelectedRegionIds(regionId); + const isBulkEdit = bulkRegionIds.length > 1; const beatsPerBar = timeSignature.numerator; const newStartFromBeat = Math.max(0, coreRegion.getStartFromBeat() + deltaInBars * beatsPerBar); if (newStartFromBeat === coreRegion.getStartFromBeat()) return; try { + if (isBulkEdit) { + const command = new MoveMultipleRegionsCommand( + regionId, + deltaInBars * beatsPerBar, + bulkRegionIds + ); + KGCore.instance().executeCommand(command, { rethrow: true }); + refreshProjectState(); + return; + } + // Use constructor directly (NOT fromBarCoordinates) to preserve float precision const command = new MoveRegionCommand( regionId, @@ -494,7 +551,7 @@ const TrackGridPanel: React.FC = ({ track.getId().toString(), region.trackIndex ); - KGCore.instance().executeCommand(command); + KGCore.instance().executeCommand(command, { rethrow: true }); if (DEBUG_MODE.TRACK_GRID_PANEL) { console.log(`Fine-moved region ${regionId}: startFromBeat=${newStartFromBeat}`); @@ -508,6 +565,7 @@ const TrackGridPanel: React.FC = ({ ); } catch (error) { console.error('Error executing fine-move:', error); + await showAlert(error instanceof Error ? error.message : 'Unable to move the selected regions.'); } }; diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index 23073ae..fab304c 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -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); } /** diff --git a/src/core/commands/KGCommandHistory.ts b/src/core/commands/KGCommandHistory.ts index d0cc12e..a91914e 100644 --- a/src/core/commands/KGCommandHistory.ts +++ b/src/core/commands/KGCommandHistory.ts @@ -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(); } } -} \ No newline at end of file +} diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index 1ae1d69..a57c03f 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -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'; \ No newline at end of file +export { ChangeLoopSettingsCommand, type LoopSettings } from './project/ChangeLoopSettingsCommand'; diff --git a/src/core/commands/region/TransformRegionsCommand.test.ts b/src/core/commands/region/TransformRegionsCommand.test.ts new file mode 100644 index 0000000..67e3fe8 --- /dev/null +++ b/src/core/commands/region/TransformRegionsCommand.test.ts @@ -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 +} + +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); + }); +}); diff --git a/src/core/commands/region/TransformRegionsCommand.ts b/src/core/commands/region/TransformRegionsCommand.ts new file mode 100644 index 0000000..3bc2a18 --- /dev/null +++ b/src/core/commands/region/TransformRegionsCommand.ts @@ -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(); + + 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}`; + } +}