feat: implemented global tempo (bpm) track
This commit is contained in:
@@ -332,25 +332,19 @@ describe('MainContent', () => {
|
||||
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the tempo and chord global track add buttons as visual-only controls', () => {
|
||||
it('routes the tempo global track add button through a command and keeps chord visual-only', () => {
|
||||
render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
|
||||
const addButtons = [
|
||||
screen.getByRole('button', { name: 'Add Tempo global track item' }),
|
||||
screen.getByRole('button', { name: 'Add Chord global track item' }),
|
||||
];
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Tempo global track item' }));
|
||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(addButtons).toHaveLength(2);
|
||||
|
||||
addButtons.forEach(button => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chord global track item' }));
|
||||
|
||||
expect(storeState.addTrack).not.toHaveBeenCalled();
|
||||
expect(storeState.addAudioTrack).not.toHaveBeenCalled();
|
||||
expect(executeCommandMock).not.toHaveBeenCalled();
|
||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('routes the signature global track add button through a command', () => {
|
||||
|
||||
@@ -9,12 +9,14 @@ import { KGTrack } from '../core/track/KGTrack';
|
||||
import { KGRegion } from '../core/region/KGRegion';
|
||||
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGMarkerRegion } from '../core/region/KGMarkerRegion';
|
||||
import TrackInfoPanel from './track/TrackInfoPanel';
|
||||
import TrackGridPanel from './track/TrackGridPanel';
|
||||
import GlobalKeySignatureLane from './global-track/GlobalKeySignatureLane';
|
||||
import GlobalTempoLane from './global-track/GlobalTempoLane';
|
||||
import GlobalMarkerLane from './global-track/GlobalMarkerLane';
|
||||
import PianoRoll from './piano-roll/PianoRoll';
|
||||
import { TrackCreateDialog } from './common';
|
||||
@@ -27,19 +29,25 @@ import {
|
||||
ChangeLoopSettingsCommand,
|
||||
CreateGlobalMarkerRegionCommand,
|
||||
CreateKeySignatureRegionCommand,
|
||||
CreateTempoRegionCommand,
|
||||
DeleteKeySignatureRegionCommand,
|
||||
DeleteMultipleKeySignatureRegionsCommand,
|
||||
DeleteMultipleTempoRegionsCommand,
|
||||
DeleteTempoRegionCommand,
|
||||
DeleteMultipleGlobalRegionsCommand,
|
||||
DeleteTrackAutomationPointsCommand,
|
||||
MoveGlobalRegionCommand,
|
||||
ResizeKeySignatureRegionCommand,
|
||||
ResizeTempoRegionCommand,
|
||||
ResizeGlobalRegionCommand,
|
||||
UpdateKeySignatureRegionCommand,
|
||||
UpdateGlobalRegionTextCommand,
|
||||
UpdateTempoRegionCommand,
|
||||
} from '../core/commands';
|
||||
import { DEFAULT_MARKER_REGION_NAME, getSortedKeySignatureRegions } from '../util/globalTrackUtil';
|
||||
import { DEFAULT_MARKER_REGION_NAME, getAudioRegionDisplayLengthBeats, getSortedKeySignatureRegions, getSortedTempoRegions } from '../util/globalTrackUtil';
|
||||
import { FaPlus } from 'react-icons/fa';
|
||||
import { FaSquareArrowUpRight } from 'react-icons/fa6';
|
||||
import { TIME_CONSTANTS } from '../constants/coreConstants';
|
||||
|
||||
interface MainContentProps {
|
||||
onTrackClick?: () => void;
|
||||
@@ -98,6 +106,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
activeTrackAutomationType,
|
||||
selectedTrackAutomationPointIds,
|
||||
bumpTrackAutomationRedrawVersion,
|
||||
bumpAudioWaveformRedrawVersion,
|
||||
refreshProjectState,
|
||||
} = useProjectStore();
|
||||
|
||||
@@ -119,6 +128,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const [editingGlobalRegionId, setEditingGlobalRegionId] = useState<string | null>(null);
|
||||
const [editingGlobalRegionText, setEditingGlobalRegionText] = useState('');
|
||||
const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState<string | null>(null);
|
||||
const [editingTempoRegionId, setEditingTempoRegionId] = useState<string | null>(null);
|
||||
const [editingTempoText, setEditingTempoText] = useState('');
|
||||
|
||||
// Use the region operations hook
|
||||
const { deleteSelectedRegions } = useRegionOperations({
|
||||
@@ -173,6 +184,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
);
|
||||
const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null;
|
||||
const signatureRegions = signatureTrack ? getSortedKeySignatureRegions(signatureTrack, timeSignature.numerator) : [];
|
||||
const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null;
|
||||
const tempoRegions = tempoTrack ? getSortedTempoRegions(tempoTrack, timeSignature.numerator) : [];
|
||||
|
||||
const findProjectRegionById = useCallback((regionId: string): KGRegion | null => {
|
||||
for (const track of tracks) {
|
||||
@@ -205,14 +218,22 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
|
||||
try {
|
||||
const signatureRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGKeySignatureRegion);
|
||||
const tempoRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGTempoRegion);
|
||||
const markerRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGMarkerRegion);
|
||||
|
||||
if (signatureRegionIds.length > 0 && markerRegionIds.length === 0) {
|
||||
if (signatureRegionIds.length > 0 && markerRegionIds.length === 0 && tempoRegionIds.length === 0) {
|
||||
KGCore.instance().executeCommand(
|
||||
signatureRegionIds.length === 1
|
||||
? new DeleteKeySignatureRegionCommand(signatureRegionIds[0])
|
||||
: new DeleteMultipleKeySignatureRegionsCommand(signatureRegionIds)
|
||||
);
|
||||
} else if (tempoRegionIds.length > 0 && markerRegionIds.length === 0 && signatureRegionIds.length === 0) {
|
||||
KGCore.instance().executeCommand(
|
||||
tempoRegionIds.length === 1
|
||||
? new DeleteTempoRegionCommand(tempoRegionIds[0])
|
||||
: new DeleteMultipleTempoRegionsCommand(tempoRegionIds)
|
||||
);
|
||||
bumpAudioWaveformRedrawVersion();
|
||||
} else if (markerRegionIds.length > 0 && signatureRegionIds.length === 0) {
|
||||
KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(markerRegionIds));
|
||||
} else {
|
||||
@@ -226,13 +247,17 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
if (editingKeySignatureRegionId && selectedGlobalRegionIds.includes(editingKeySignatureRegionId)) {
|
||||
setEditingKeySignatureRegionId(null);
|
||||
}
|
||||
if (editingTempoRegionId && selectedGlobalRegionIds.includes(editingTempoRegionId)) {
|
||||
setEditingTempoRegionId(null);
|
||||
setEditingTempoText('');
|
||||
}
|
||||
refreshProjectState();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting global marker regions:', error);
|
||||
return false;
|
||||
}
|
||||
}, [editingGlobalRegionId, editingKeySignatureRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
|
||||
}, [bumpAudioWaveformRedrawVersion, editingGlobalRegionId, editingKeySignatureRegionId, editingTempoRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
|
||||
|
||||
// Register the delete function with the global manager
|
||||
useEffect(() => {
|
||||
@@ -430,7 +455,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
// Calculate bar number and length from beats
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const barNumber = (region.getStartFromBeat() / beatsPerBar) + 1;
|
||||
const length = region.getLength() / beatsPerBar;
|
||||
const lengthBeats = region instanceof KGAudioRegion
|
||||
? getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), region)
|
||||
: region.getLength();
|
||||
const length = lengthBeats / beatsPerBar;
|
||||
|
||||
// Create a RegionUI object
|
||||
updatedRegions.push({
|
||||
@@ -447,7 +475,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
|
||||
// Update the regions state
|
||||
setRegions(updatedRegions);
|
||||
}, [tracks, timeSignature]);
|
||||
}, [globalTracks, tracks, timeSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPianoRoll || !activeRegionId) {
|
||||
@@ -914,6 +942,16 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
setEditingKeySignatureRegionId(regionId);
|
||||
}, [findProjectRegionById]);
|
||||
|
||||
const beginEditingTempoRegion = useCallback((regionId: string) => {
|
||||
const region = findProjectRegionById(regionId);
|
||||
if (!(region instanceof KGTempoRegion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingTempoRegionId(regionId);
|
||||
setEditingTempoText(region.getBpm().toString());
|
||||
}, [findProjectRegionById]);
|
||||
|
||||
const commitGlobalRegionEdit = useCallback((regionId: string) => {
|
||||
const region = findProjectRegionById(regionId);
|
||||
if (!(region instanceof KGMarkerRegion)) {
|
||||
@@ -1044,6 +1082,84 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
}
|
||||
}, [refreshProjectState]);
|
||||
|
||||
const commitTempoRegionEdit = useCallback((regionId: string) => {
|
||||
const region = findProjectRegionById(regionId);
|
||||
if (!(region instanceof KGTempoRegion)) {
|
||||
setEditingTempoRegionId(null);
|
||||
setEditingTempoText('');
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = editingTempoText.trim();
|
||||
setEditingTempoRegionId(null);
|
||||
setEditingTempoText('');
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextBpm = parseInt(trimmed, 10);
|
||||
if (Number.isNaN(nextBpm)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextBpm <= TIME_CONSTANTS.MIN_BPM || nextBpm >= TIME_CONSTANTS.MAX_BPM || nextBpm === region.getBpm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
KGCore.instance().executeCommand(new UpdateTempoRegionCommand(regionId, nextBpm));
|
||||
bumpAudioWaveformRedrawVersion();
|
||||
refreshProjectState();
|
||||
} catch (error) {
|
||||
console.error('Error updating tempo region:', error);
|
||||
}
|
||||
}, [bumpAudioWaveformRedrawVersion, editingTempoText, findProjectRegionById, refreshProjectState]);
|
||||
|
||||
const createTempoAtBar = useCallback((requestedStartBar: number) => {
|
||||
const normalizedStartBar = Math.max(0, Math.min(requestedStartBar, maxBars - 1));
|
||||
const existingRegionAtStart = tempoRegions.find(region => region.getStartBar() === normalizedStartBar);
|
||||
if (existingRegionAtStart) {
|
||||
selectGlobalRegion(existingRegionAtStart.getId(), { shiftKey: false });
|
||||
beginEditingTempoRegion(existingRegionAtStart.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new CreateTempoRegionCommand(normalizedStartBar);
|
||||
KGCore.instance().executeCommand(command);
|
||||
bumpAudioWaveformRedrawVersion();
|
||||
refreshProjectState();
|
||||
|
||||
const createdRegion = command.getCreatedRegion();
|
||||
if (!createdRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
|
||||
setEditingTempoRegionId(createdRegion.getId());
|
||||
setEditingTempoText(createdRegion.getBpm().toString());
|
||||
} catch (error) {
|
||||
console.error('Error creating tempo region:', error);
|
||||
}
|
||||
}, [beginEditingTempoRegion, bumpAudioWaveformRedrawVersion, maxBars, refreshProjectState, selectGlobalRegion, tempoRegions]);
|
||||
|
||||
const createTempoAtPlayheadBar = useCallback(() => {
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const startBar = Math.floor(playheadPosition / beatsPerBar);
|
||||
createTempoAtBar(startBar);
|
||||
}, [createTempoAtBar, playheadPosition, timeSignature.numerator]);
|
||||
|
||||
const resizeTempoRegion = useCallback((regionId: string, edge: 'start' | 'end', bar: number) => {
|
||||
try {
|
||||
KGCore.instance().executeCommand(new ResizeTempoRegionCommand(regionId, edge, Math.round(bar)));
|
||||
bumpAudioWaveformRedrawVersion();
|
||||
refreshProjectState();
|
||||
} catch (error) {
|
||||
console.error('Error resizing tempo region:', error);
|
||||
}
|
||||
}, [bumpAudioWaveformRedrawVersion, refreshProjectState]);
|
||||
|
||||
/**
|
||||
* Add keyboard event listener for region deletion
|
||||
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
|
||||
@@ -1397,6 +1513,11 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.id === 'tempo') {
|
||||
createTempoAtPlayheadBar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.id === 'signature') {
|
||||
createKeySignatureAtPlayheadBar();
|
||||
}
|
||||
@@ -1433,6 +1554,26 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
onMoveRegion={moveGlobalMarkerRegion}
|
||||
onResizeRegion={resizeGlobalMarkerRegion}
|
||||
/>
|
||||
) : track.id === 'tempo' ? (
|
||||
<GlobalTempoLane
|
||||
key={track.id}
|
||||
tempoRegions={tempoRegions}
|
||||
maxBars={maxBars}
|
||||
barWidthMultiplier={barWidthMultiplier}
|
||||
selectedRegionIds={selectedRegionIds}
|
||||
editingRegionId={editingTempoRegionId}
|
||||
editingText={editingTempoText}
|
||||
onEditingTextChange={setEditingTempoText}
|
||||
onCommitEdit={commitTempoRegionEdit}
|
||||
onCancelEdit={() => {
|
||||
setEditingTempoRegionId(null);
|
||||
setEditingTempoText('');
|
||||
}}
|
||||
onBeginEdit={beginEditingTempoRegion}
|
||||
onSelectRegion={selectGlobalRegion}
|
||||
onCreateAtBar={createTempoAtBar}
|
||||
onResizeRegion={resizeTempoRegion}
|
||||
/>
|
||||
) : track.id === 'signature' ? (
|
||||
<GlobalKeySignatureLane
|
||||
key={track.id}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { KGProject, type KeySignature } from '../core/KGProject';
|
||||
import { GlobalTrackType } from '../core/global-track';
|
||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
@@ -38,7 +39,7 @@ import PianoIcon from './common/icons/PianoIcon';
|
||||
import MetronomeIcon from './common/icons/MetronomeIcon';
|
||||
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
|
||||
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
|
||||
import { UpdateKeySignatureRegionCommand } from '../core/commands';
|
||||
import { UpdateKeySignatureRegionCommand, UpdateTempoRegionCommand } from '../core/commands';
|
||||
|
||||
const Toolbar: React.FC = () => {
|
||||
const {
|
||||
@@ -60,7 +61,7 @@ const Toolbar: React.FC = () => {
|
||||
selectedRegionIds, selectedTrackId,
|
||||
// Playhead and refresh
|
||||
playheadPosition, refreshProjectState,
|
||||
requestMainContentScroll, requestPianoRollScroll
|
||||
requestMainContentScroll, requestPianoRollScroll, bumpAudioWaveformRedrawVersion
|
||||
} = useProjectStore();
|
||||
|
||||
// State for main content tools
|
||||
@@ -74,10 +75,18 @@ const Toolbar: React.FC = () => {
|
||||
const signatureRegions = (signatureTrack?.getRegions() ?? [])
|
||||
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
|
||||
.sort((left, right) => left.getStartBar() - right.getStartBar());
|
||||
const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null;
|
||||
const tempoRegions = (tempoTrack?.getRegions() ?? [])
|
||||
.filter((region): region is KGTempoRegion => region instanceof KGTempoRegion)
|
||||
.sort((left, right) => left.getStartBar() - right.getStartBar());
|
||||
const playheadBar = Math.floor(playheadPosition / timeSignature.numerator);
|
||||
const activeKeySignatureRegion = signatureRegions.find(
|
||||
region => playheadBar >= region.getStartBar() && playheadBar < region.getEndBar()
|
||||
) ?? null;
|
||||
const activeTempoRegion = tempoRegions.find(
|
||||
region => playheadBar >= region.getStartBar() && playheadBar < region.getEndBar()
|
||||
) ?? null;
|
||||
const displayedBpm = activeTempoRegion?.getBpm() ?? bpm;
|
||||
const displayedKeySignature = activeKeySignatureRegion?.getKeySignature() ?? keySignature;
|
||||
|
||||
// State for export dropdown
|
||||
@@ -650,7 +659,7 @@ const Toolbar: React.FC = () => {
|
||||
console.log("BPM clicked, current BPM:", bpm);
|
||||
}
|
||||
|
||||
const newBpmStr = await showPrompt(`Enter new BPM (${TIME_CONSTANTS.MIN_BPM}-${TIME_CONSTANTS.MAX_BPM}):`, bpm.toString());
|
||||
const newBpmStr = await showPrompt(`Enter new BPM (${TIME_CONSTANTS.MIN_BPM}-${TIME_CONSTANTS.MAX_BPM}):`, displayedBpm.toString());
|
||||
|
||||
// Check if user cancelled
|
||||
if (newBpmStr === null) {
|
||||
@@ -673,11 +682,17 @@ const Toolbar: React.FC = () => {
|
||||
}
|
||||
|
||||
// Update BPM
|
||||
setBpm(newBpm);
|
||||
if (activeTempoRegion) {
|
||||
KGCore.instance().executeCommand(new UpdateTempoRegionCommand(activeTempoRegion.getId(), newBpm));
|
||||
bumpAudioWaveformRedrawVersion();
|
||||
refreshProjectState();
|
||||
} else {
|
||||
setBpm(newBpm);
|
||||
}
|
||||
setStatus(`BPM changed to ${newBpm}`);
|
||||
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log(`BPM updated from ${bpm} to ${newBpm}`);
|
||||
console.log(`BPM updated from ${displayedBpm} to ${newBpm}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1168,7 +1183,7 @@ const Toolbar: React.FC = () => {
|
||||
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
|
||||
</div>
|
||||
<div className="transport-item">
|
||||
<span className='current-bpm' onClick={handleBpmClick} style={{ cursor: 'pointer' }}>{bpm}</span>
|
||||
<span className='current-bpm' onClick={handleBpmClick} style={{ cursor: 'pointer' }}>{displayedBpm}</span>
|
||||
</div>
|
||||
<div className="transport-item">
|
||||
<span className='current-time-signature' onClick={handleTimeSignatureClick} style={{ cursor: 'pointer' }}>{timeSignature.numerator + "/" + timeSignature.denominator}</span>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import GlobalTempoLane from './GlobalTempoLane';
|
||||
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
|
||||
|
||||
describe('GlobalTempoLane', () => {
|
||||
const baseRegion = new KGTempoRegion('tempo-1', 'global-tempo', 1, 128, 0, 4, 4);
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.setProperty('--track-grid-bar-width', '40');
|
||||
});
|
||||
|
||||
it('opens inline editing for an existing region', () => {
|
||||
const onBeginEdit = vi.fn();
|
||||
|
||||
render(
|
||||
<GlobalTempoLane
|
||||
tempoRegions={[baseRegion]}
|
||||
maxBars={8}
|
||||
barWidthMultiplier={1}
|
||||
selectedRegionIds={[]}
|
||||
editingRegionId={null}
|
||||
editingText=""
|
||||
onEditingTextChange={vi.fn()}
|
||||
onCommitEdit={vi.fn()}
|
||||
onCancelEdit={vi.fn()}
|
||||
onBeginEdit={onBeginEdit}
|
||||
onSelectRegion={vi.fn()}
|
||||
onCreateAtBar={vi.fn()}
|
||||
onResizeRegion={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(screen.getByText('128 BPM'));
|
||||
expect(onBeginEdit).toHaveBeenCalledWith('tempo-1');
|
||||
});
|
||||
|
||||
it('creates a new region at a bar-aligned position on empty-lane double click', () => {
|
||||
const onCreateAtBar = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<GlobalTempoLane
|
||||
tempoRegions={[baseRegion]}
|
||||
maxBars={8}
|
||||
barWidthMultiplier={1}
|
||||
selectedRegionIds={[]}
|
||||
editingRegionId={null}
|
||||
editingText=""
|
||||
onEditingTextChange={vi.fn()}
|
||||
onCommitEdit={vi.fn()}
|
||||
onCancelEdit={vi.fn()}
|
||||
onBeginEdit={vi.fn()}
|
||||
onSelectRegion={vi.fn()}
|
||||
onCreateAtBar={onCreateAtBar}
|
||||
onResizeRegion={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const lane = container.querySelector('.global-tempo-lane') as HTMLDivElement;
|
||||
vi.spyOn(lane, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 24,
|
||||
width: 320,
|
||||
height: 24,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
fireEvent.doubleClick(lane, { clientX: 159, clientY: 10 });
|
||||
expect(onCreateAtBar).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('strips non-digit characters during inline editing', () => {
|
||||
const onEditingTextChange = vi.fn();
|
||||
|
||||
render(
|
||||
<GlobalTempoLane
|
||||
tempoRegions={[baseRegion]}
|
||||
maxBars={8}
|
||||
barWidthMultiplier={1}
|
||||
selectedRegionIds={[]}
|
||||
editingRegionId="tempo-1"
|
||||
editingText="128"
|
||||
onEditingTextChange={onEditingTextChange}
|
||||
onCommitEdit={vi.fn()}
|
||||
onCancelEdit={vi.fn()}
|
||||
onBeginEdit={vi.fn()}
|
||||
onSelectRegion={vi.fn()}
|
||||
onCreateAtBar={vi.fn()}
|
||||
onResizeRegion={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('128'), { target: { value: '12a8!' } });
|
||||
expect(onEditingTextChange).toHaveBeenCalledWith('128');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
|
||||
import type { RegionClickOptions } from '../interfaces';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
import { TIME_CONSTANTS, TOOLBAR_CONSTANTS } from '../../constants';
|
||||
|
||||
interface GlobalTempoLaneProps {
|
||||
tempoRegions: KGTempoRegion[];
|
||||
maxBars: number;
|
||||
barWidthMultiplier: number;
|
||||
selectedRegionIds: string[];
|
||||
editingRegionId: string | null;
|
||||
editingText: string;
|
||||
onEditingTextChange: (value: string) => void;
|
||||
onCommitEdit: (regionId: string) => void;
|
||||
onCancelEdit: () => void;
|
||||
onBeginEdit: (regionId: string) => void;
|
||||
onSelectRegion: (regionId: string, options?: RegionClickOptions) => void;
|
||||
onCreateAtBar: (startBar: number) => void;
|
||||
onResizeRegion: (regionId: string, edge: 'start' | 'end', bar: number) => void;
|
||||
}
|
||||
|
||||
type ResizeEdge = 'start' | 'end' | null;
|
||||
|
||||
const REGION_EDGE_HITBOX_PX = 8;
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
const GlobalTempoLane: React.FC<GlobalTempoLaneProps> = ({
|
||||
tempoRegions,
|
||||
maxBars,
|
||||
barWidthMultiplier,
|
||||
selectedRegionIds,
|
||||
editingRegionId,
|
||||
editingText,
|
||||
onEditingTextChange,
|
||||
onCommitEdit,
|
||||
onCancelEdit,
|
||||
onBeginEdit,
|
||||
onSelectRegion,
|
||||
onCreateAtBar,
|
||||
onResizeRegion,
|
||||
}) => {
|
||||
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||
const [previewBars, setPreviewBars] = useState<Record<string, { startBar: number; lengthBars: number }>>({});
|
||||
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
|
||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||
const interactionRef = useRef<{
|
||||
mode: 'resize' | null;
|
||||
regionId: string;
|
||||
resizeEdge: ResizeEdge;
|
||||
initialMouseX: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const barWidth = useMemo(
|
||||
() => TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier,
|
||||
[barWidthMultiplier]
|
||||
);
|
||||
|
||||
const regionOrder = useMemo(
|
||||
() => tempoRegions.map(region => region.getId()),
|
||||
[tempoRegions]
|
||||
);
|
||||
|
||||
const getRegionIndex = (regionId: string) => regionOrder.findIndex(candidateId => candidateId === regionId);
|
||||
const canResizeEdge = (regionId: string, edge: 'start' | 'end') => {
|
||||
const regionIndex = getRegionIndex(regionId);
|
||||
if (regionIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (edge === 'start') {
|
||||
return regionIndex > 0;
|
||||
}
|
||||
|
||||
return regionIndex < tempoRegions.length - 1;
|
||||
};
|
||||
|
||||
const getRenderedBarState = (region: KGTempoRegion) => (
|
||||
previewBars[region.getId()] ?? {
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}
|
||||
);
|
||||
|
||||
const getResizeEdgeFromMouseEvent = (event: React.MouseEvent<HTMLDivElement, MouseEvent>): ResizeEdge => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const offsetX = event.clientX - rect.left;
|
||||
|
||||
if (offsetX <= REGION_EDGE_HITBOX_PX) {
|
||||
return 'start';
|
||||
}
|
||||
|
||||
if (rect.width - offsetX <= REGION_EDGE_HITBOX_PX) {
|
||||
return 'end';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getBarFromClientX = (clientX: number) => {
|
||||
if (!laneRef.current) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rect = laneRef.current.getBoundingClientRect();
|
||||
const relativeX = clientX - rect.left;
|
||||
return Math.max(0, Math.min(maxBars, Math.round(relativeX / barWidth)));
|
||||
};
|
||||
|
||||
const getCreateBarFromClientX = (clientX: number) => {
|
||||
if (!laneRef.current) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rect = laneRef.current.getBoundingClientRect();
|
||||
const relativeX = clientX - rect.left;
|
||||
return Math.max(0, Math.min(maxBars - 1, Math.floor(relativeX / barWidth)));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isModifierKeyPressed(event)) {
|
||||
setIsModifierPressed(true);
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
onCancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (!isModifierKeyPressed(event)) {
|
||||
setIsModifierPressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [onCancelEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!interactionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interaction = interactionRef.current;
|
||||
const deltaX = event.clientX - interaction.initialMouseX;
|
||||
if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) {
|
||||
interaction.moved = true;
|
||||
}
|
||||
|
||||
if (interaction.mode !== 'resize' || !interaction.resizeEdge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRegion = tempoRegions.find(region => region.getId() === interaction.regionId);
|
||||
if (!targetRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = getRegionIndex(interaction.regionId);
|
||||
const desiredBoundaryBar = getBarFromClientX(event.clientX);
|
||||
|
||||
if (interaction.resizeEdge === 'start' && targetIndex > 0) {
|
||||
const previousRegion = tempoRegions[targetIndex - 1];
|
||||
const targetEndBar = targetRegion.getEndBar();
|
||||
const clampedBoundaryBar = Math.max(
|
||||
previousRegion.getStartBar() + 1,
|
||||
Math.min(desiredBoundaryBar, targetEndBar - 1)
|
||||
);
|
||||
|
||||
setPreviewBars({
|
||||
[previousRegion.getId()]: {
|
||||
startBar: previousRegion.getStartBar(),
|
||||
lengthBars: clampedBoundaryBar - previousRegion.getStartBar(),
|
||||
},
|
||||
[targetRegion.getId()]: {
|
||||
startBar: clampedBoundaryBar,
|
||||
lengthBars: targetEndBar - clampedBoundaryBar,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.resizeEdge === 'end' && targetIndex < tempoRegions.length - 1) {
|
||||
const nextRegion = tempoRegions[targetIndex + 1];
|
||||
const nextEndBar = nextRegion.getEndBar();
|
||||
const clampedBoundaryBar = Math.max(
|
||||
targetRegion.getStartBar() + 1,
|
||||
Math.min(desiredBoundaryBar, nextEndBar - 1)
|
||||
);
|
||||
|
||||
setPreviewBars({
|
||||
[targetRegion.getId()]: {
|
||||
startBar: targetRegion.getStartBar(),
|
||||
lengthBars: clampedBoundaryBar - targetRegion.getStartBar(),
|
||||
},
|
||||
[nextRegion.getId()]: {
|
||||
startBar: clampedBoundaryBar,
|
||||
lengthBars: nextEndBar - clampedBoundaryBar,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (event: MouseEvent) => {
|
||||
if (!interactionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interaction = interactionRef.current;
|
||||
interactionRef.current = null;
|
||||
const resizeEdge = interaction.resizeEdge;
|
||||
const shouldResize = interaction.moved && resizeEdge !== null;
|
||||
setPreviewBars({});
|
||||
|
||||
if (!shouldResize) {
|
||||
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
|
||||
return;
|
||||
}
|
||||
|
||||
onResizeRegion(interaction.regionId, resizeEdge, getBarFromClientX(event.clientX));
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [getBarFromClientX, onResizeRegion, onSelectRegion, tempoRegions]);
|
||||
|
||||
const handleLaneMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(event.target instanceof HTMLElement) || event.target.closest('.global-tempo-region')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isModifierKeyPressed(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCreateAtBar(getCreateBarFromClientX(event.clientX));
|
||||
};
|
||||
|
||||
const handleLaneDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!(event.target instanceof HTMLElement) || event.target.closest('.global-tempo-region')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCreateAtBar(getCreateBarFromClientX(event.clientX));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={laneRef}
|
||||
className={`global-marker-lane global-tempo-lane${isModifierPressed ? ' pencil-cursor' : ''}`}
|
||||
onMouseDown={handleLaneMouseDown}
|
||||
onDoubleClick={handleLaneDoubleClick}
|
||||
>
|
||||
{tempoRegions.map(region => {
|
||||
const rendered = getRenderedBarState(region);
|
||||
const isSelected = selectedRegionIds.includes(region.getId());
|
||||
const isEditing = editingRegionId === region.getId();
|
||||
const left = rendered.startBar * barWidth;
|
||||
const clampedEndBar = Math.max(rendered.startBar, Math.min(rendered.startBar + rendered.lengthBars, maxBars));
|
||||
const widthBars = Math.max(0, clampedEndBar - rendered.startBar);
|
||||
const width = Math.max(barWidth, widthBars * barWidth);
|
||||
|
||||
if (rendered.startBar >= maxBars || widthBars <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={region.getId()}
|
||||
className={`global-marker-region global-tempo-region${isSelected ? ' selected' : ''}`}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
cursor: isEditing ? 'text' : hoverEdges[region.getId()] ? 'col-resize' : 'pointer',
|
||||
}}
|
||||
onMouseEnter={() => setHoverEdges(prev => ({ ...prev, [region.getId()]: null }))}
|
||||
onMouseMove={(event) => {
|
||||
if (isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEdge = getResizeEdgeFromMouseEvent(event);
|
||||
const normalizedEdge = nextEdge && canResizeEdge(region.getId(), nextEdge) ? nextEdge : null;
|
||||
setHoverEdges(prev => ({ ...prev, [region.getId()]: normalizedEdge }));
|
||||
}}
|
||||
onMouseLeave={() => setHoverEdges(prev => ({ ...prev, [region.getId()]: null }))}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 0 || isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEdge = getResizeEdgeFromMouseEvent(event);
|
||||
const normalizedEdge = nextEdge && canResizeEdge(region.getId(), nextEdge) ? nextEdge : null;
|
||||
if (!normalizedEdge) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
interactionRef.current = {
|
||||
mode: 'resize',
|
||||
regionId: region.getId(),
|
||||
resizeEdge: normalizedEdge,
|
||||
initialMouseX: event.clientX,
|
||||
moved: false,
|
||||
};
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectRegion(region.getId(), { shiftKey: event.shiftKey });
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelectRegion(region.getId(), { shiftKey: false });
|
||||
onBeginEdit(region.getId());
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
className="global-marker-input"
|
||||
value={editingText}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
onChange={(event) => {
|
||||
const digitsOnly = event.target.value.replace(/\D+/g, '');
|
||||
onEditingTextChange(digitsOnly);
|
||||
}}
|
||||
onBlur={() => onCommitEdit(region.getId())}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onCommitEdit(region.getId());
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onCancelEdit();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="global-marker-label">{region.getDisplayName()}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalTempoLane;
|
||||
@@ -10,6 +10,8 @@ import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||
import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
|
||||
import type { RegionPreviewContentStyle } from '../interfaces';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { beatRangeToSeconds } from '../../util/globalTrackUtil';
|
||||
|
||||
const DRAG_START_THRESHOLD_PX = 4;
|
||||
|
||||
@@ -48,6 +50,7 @@ interface RegionItemProps {
|
||||
isPreview?: boolean;
|
||||
isAudioRegion?: boolean;
|
||||
previewContentStyle?: RegionPreviewContentStyle;
|
||||
redrawVersion?: number;
|
||||
}
|
||||
|
||||
const RegionItem: React.FC<RegionItemProps> = ({
|
||||
@@ -76,6 +79,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
isPreview = false,
|
||||
isAudioRegion = false,
|
||||
previewContentStyle,
|
||||
redrawVersion = 0,
|
||||
}) => {
|
||||
// Get selection state and time signature from store
|
||||
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
||||
@@ -273,10 +277,18 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
const clipStartOffsetSeconds = audioRegion ? audioRegion.getClipStartOffsetSeconds() : 0;
|
||||
const clipStartSample = Math.floor(clipStartOffsetSeconds * sampleRate);
|
||||
|
||||
// Calculate visible duration from region length in beats
|
||||
const secondsPerBeat = 60 / bpm;
|
||||
const currentProject = KGCore.instance().getCurrentProject();
|
||||
const regionLengthBeats = audioRegion ? audioRegion.getLength() : 0;
|
||||
const visibleDurationSeconds = regionLengthBeats * secondsPerBeat;
|
||||
const visibleDurationSeconds = audioRegion
|
||||
? Math.min(
|
||||
beatRangeToSeconds(
|
||||
currentProject,
|
||||
audioRegion.getStartFromBeat(),
|
||||
audioRegion.getStartFromBeat() + regionLengthBeats
|
||||
),
|
||||
Math.max(0, audioRegion.getAudioDurationSeconds() - clipStartOffsetSeconds)
|
||||
)
|
||||
: 0;
|
||||
const visibleSamples = Math.floor(visibleDurationSeconds * sampleRate);
|
||||
|
||||
// Clamp to buffer boundaries
|
||||
@@ -383,7 +395,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
} else {
|
||||
renderNotesOnCanvas();
|
||||
}
|
||||
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length, previewContentStyle?.width]);
|
||||
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length, previewContentStyle?.width, redrawVersion]);
|
||||
|
||||
// Re-render canvas when region content size changes
|
||||
useEffect(() => {
|
||||
@@ -406,7 +418,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
resizeObserver.unobserve(previewContentRef.current);
|
||||
}
|
||||
};
|
||||
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, previewContentStyle?.width]);
|
||||
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, previewContentStyle?.width, redrawVersion]);
|
||||
|
||||
// Handle mouse movement to detect edge proximity
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -91,6 +91,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
||||
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
|
||||
const trackAutomationRedrawVersion = useProjectStore(state => state.trackAutomationRedrawVersion);
|
||||
const audioWaveformRedrawVersion = useProjectStore(state => state.audioWaveformRedrawVersion);
|
||||
const recordingMode = useProjectStore(state => state.recordingMode);
|
||||
const recordingTargetTrackIndex = useProjectStore(state => state.recordingTargetTrackIndex);
|
||||
const recordingCommitStartBeatAbsolute = useProjectStore(state => state.recordingCommitStartBeatAbsolute);
|
||||
@@ -796,6 +797,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
audioRegion={audioRegion}
|
||||
audioBuffer={audioBuffer}
|
||||
previewContentStyle={tempPreviewRegionContentStyles[region.id]}
|
||||
redrawVersion={audioWaveformRedrawVersion}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { showAlert } from '../../util/dialogUtil';
|
||||
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
|
||||
import * as Tone from 'tone';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { getAudioRegionDisplayLengthBeats } from '../../util/globalTrackUtil';
|
||||
|
||||
interface TrackGridPanelProps {
|
||||
tracks: KGTrack[];
|
||||
@@ -64,6 +65,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
}) => {
|
||||
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
|
||||
const refreshProjectState = useProjectStore(state => state.refreshProjectState);
|
||||
const bumpAudioWaveformRedrawVersion = useProjectStore(state => state.bumpAudioWaveformRedrawVersion);
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||
const [previewRegionStyles, setPreviewRegionStyles] = useState<Record<string, React.CSSProperties>>({});
|
||||
@@ -356,12 +358,16 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
|
||||
const created = cmd.getCreatedRegion();
|
||||
if (created && onExternalDropComplete) {
|
||||
const displayLengthInBars = Math.max(
|
||||
1,
|
||||
getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), created) / beatsPerBar
|
||||
);
|
||||
const regionUI: RegionUI = {
|
||||
id: created.getId(),
|
||||
trackId: track.getId().toString(),
|
||||
trackIndex,
|
||||
barNumber,
|
||||
length: lengthInBars,
|
||||
length: displayLengthInBars,
|
||||
name: created.getName(),
|
||||
};
|
||||
onExternalDropComplete(trackIndex, regionUI);
|
||||
@@ -571,6 +577,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
const sourceTrack = tracks.find(t => {
|
||||
return t.getRegions().some(r => r.getId() === regionId);
|
||||
});
|
||||
const movedRegionWasAudio = sourceTrack?.getRegions().find(r => r.getId() === regionId) instanceof KGAudioRegion;
|
||||
if (sourceTrack && sourceTrack.getType() !== targetTrack.getType()) {
|
||||
// Snap back — don't execute the move
|
||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||
@@ -592,6 +599,12 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
|
||||
KGCore.instance().executeCommand(command, { rethrow: true });
|
||||
refreshProjectState();
|
||||
if (bulkRegionIds.some(selectedId => {
|
||||
const candidate = tracks.flatMap(track => track.getRegions()).find(region => region.getId() === selectedId);
|
||||
return candidate instanceof KGAudioRegion;
|
||||
})) {
|
||||
bumpAudioWaveformRedrawVersion();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -625,6 +638,9 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
const movedRegion = command.getTargetRegion();
|
||||
console.log(`Verified region: ${movedRegion ? 'found' : 'not found'}, startBeat=${movedRegion?.getStartFromBeat()}, trackId=${movedRegion?.getTrackId()}`);
|
||||
}
|
||||
if (movedRegionWasAudio) {
|
||||
bumpAudioWaveformRedrawVersion();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moving region:', error);
|
||||
await showAlert(error instanceof Error ? error.message : 'Unable to move the selected regions.');
|
||||
@@ -681,6 +697,12 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
);
|
||||
KGCore.instance().executeCommand(command, { rethrow: true });
|
||||
refreshProjectState();
|
||||
if (bulkRegionIds.some(selectedId => {
|
||||
const candidate = tracks.flatMap(track => track.getRegions()).find(region => region.getId() === selectedId);
|
||||
return candidate instanceof KGAudioRegion;
|
||||
})) {
|
||||
bumpAudioWaveformRedrawVersion();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -692,6 +714,9 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
region.trackIndex
|
||||
);
|
||||
KGCore.instance().executeCommand(command, { rethrow: true });
|
||||
if (coreRegion instanceof KGAudioRegion) {
|
||||
bumpAudioWaveformRedrawVersion();
|
||||
}
|
||||
|
||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||
console.log(`Fine-moved region ${regionId}: startFromBeat=${newStartFromBeat}`);
|
||||
@@ -793,12 +818,16 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
|
||||
const created = cmd.getCreatedRegion();
|
||||
if (created && onExternalDropComplete) {
|
||||
const displayLengthInBars = Math.max(
|
||||
1,
|
||||
getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), created as unknown as KGAudioRegion) / beatsPerBar
|
||||
);
|
||||
const regionUI: RegionUI = {
|
||||
id: created.getId(),
|
||||
trackId: track.getId().toString(),
|
||||
trackIndex,
|
||||
barNumber,
|
||||
length: lengthInBars,
|
||||
length: displayLengthInBars,
|
||||
name: created.getName(),
|
||||
};
|
||||
onExternalDropComplete(trackIndex, regionUI);
|
||||
|
||||
Reference in New Issue
Block a user