feat: implemented global tempo (bpm) track

This commit is contained in:
Xiaohan-Tian
2026-05-23 23:18:47 -07:00
parent 8eb3f5d84d
commit 7d275802c9
23 changed files with 1700 additions and 131 deletions
+5 -11
View File
@@ -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', () => {
+146 -5
View File
@@ -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}
+21 -6
View File
@@ -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;
+17 -5
View File
@@ -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>) => {
+2
View File
@@ -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}
/>
);
})}
+31 -2
View File
@@ -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);