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