feat: added shortcut N for sheet music view
This commit is contained in:
@@ -35,6 +35,8 @@ const storeState = {
|
||||
storeState.activeRegionId = regionId;
|
||||
}),
|
||||
pianoRollMode: 'midi-edit' as const,
|
||||
requestedSheetMusicViewEnabled: false,
|
||||
pianoRollViewRequestVersion: 0,
|
||||
openMidiPianoRoll: vi.fn(),
|
||||
openSpectrogramViewer: vi.fn(),
|
||||
openHybridMode: vi.fn(),
|
||||
|
||||
@@ -46,6 +46,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
setShowPianoRoll,
|
||||
setActiveRegionId,
|
||||
pianoRollMode,
|
||||
requestedSheetMusicViewEnabled,
|
||||
pianoRollViewRequestVersion,
|
||||
openMidiPianoRoll,
|
||||
openSpectrogramViewer,
|
||||
openHybridMode,
|
||||
@@ -1052,6 +1054,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
onClose={handlePianoRollClose}
|
||||
regionId={activeRegionId}
|
||||
mode={pianoRollMode}
|
||||
requestedSheetMusicViewEnabled={requestedSheetMusicViewEnabled}
|
||||
pianoRollViewRequestVersion={pianoRollViewRequestVersion}
|
||||
audioRegion={(() => {
|
||||
// spectrogram mode: audio region IS the activeRegionId
|
||||
// hybrid mode: audio region is hybridAudioRegionId
|
||||
|
||||
@@ -63,6 +63,8 @@ interface PianoRollProps {
|
||||
initialPosition?: { x: number; y: number };
|
||||
initialSize?: { width: number; height: number };
|
||||
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
|
||||
requestedSheetMusicViewEnabled?: boolean;
|
||||
pianoRollViewRequestVersion?: number;
|
||||
audioRegion?: KGAudioRegion;
|
||||
trackId?: string;
|
||||
projectName?: string;
|
||||
@@ -74,6 +76,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
initialPosition,
|
||||
initialSize,
|
||||
mode = 'midi-edit',
|
||||
requestedSheetMusicViewEnabled = false,
|
||||
pianoRollViewRequestVersion = 0,
|
||||
audioRegion,
|
||||
trackId,
|
||||
projectName,
|
||||
@@ -153,6 +157,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
const pendingModeSwitchRequestRef = useRef<PendingModeSwitchRequest | null>(null);
|
||||
const previousSheetMusicViewEnabledRef = useRef<boolean>(false);
|
||||
const previousActiveRegionIdRef = useRef<string | null>(null);
|
||||
const lastAppliedViewRequestVersionRef = useRef<number>(0);
|
||||
|
||||
// Ref for storing the setNoteUpdateCounter function
|
||||
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
|
||||
@@ -268,6 +273,42 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
}
|
||||
}, []); // Empty dependency array means this runs once on mount
|
||||
|
||||
useEffect(() => {
|
||||
if (isSpectrogram) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pianoRollViewRequestVersion === 0 || lastAppliedViewRequestVersionRef.current === pianoRollViewRequestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedViewRequestVersionRef.current = pianoRollViewRequestVersion;
|
||||
|
||||
if (activeRegion) {
|
||||
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
|
||||
playheadBeat: playheadPosition,
|
||||
regionStartBeat: activeRegion.getStartFromBeat(),
|
||||
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
|
||||
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
|
||||
destinationSheetMusicViewEnabled: requestedSheetMusicViewEnabled,
|
||||
destinationSheetMusicTrackScopeEnabled: requestedSheetMusicViewEnabled && sheetMusicTrackScopeEnabled,
|
||||
});
|
||||
} else {
|
||||
pendingModeSwitchRequestRef.current = null;
|
||||
}
|
||||
|
||||
setSheetMusicViewEnabled(requestedSheetMusicViewEnabled);
|
||||
KGPianoRollState.instance().setSheetMusicViewEnabled(requestedSheetMusicViewEnabled);
|
||||
}, [
|
||||
activeRegion,
|
||||
isSpectrogram,
|
||||
pianoRollViewRequestVersion,
|
||||
playheadPosition,
|
||||
requestedSheetMusicViewEnabled,
|
||||
sheetMusicTrackScopeEnabled,
|
||||
sheetMusicViewEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
|
||||
@@ -2,12 +2,27 @@ import React from 'react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useGlobalKeyboardHandler } from './useGlobalKeyboardHandler';
|
||||
import { showAlert } from '../util/dialogUtil';
|
||||
|
||||
const regionEditUtilMocks = vi.hoisted(() => ({
|
||||
splitSelectedRegionAtPlayhead: vi.fn(),
|
||||
mergeSelectedMidiRegions: vi.fn(),
|
||||
}));
|
||||
|
||||
const MockMidiRegion = vi.hoisted(() => class {
|
||||
private readonly id: string;
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
});
|
||||
|
||||
let mockTracks: Array<{ getRegions: () => Array<{ getId: () => string }> }> = [];
|
||||
|
||||
const storeState = {
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
@@ -30,6 +45,7 @@ const storeState = {
|
||||
setShowPianoRoll: vi.fn(),
|
||||
showPianoRoll: false,
|
||||
openMidiPianoRoll: vi.fn(),
|
||||
openMidiPianoRollWithSheetMusicView: vi.fn(),
|
||||
openSpectrogramViewer: vi.fn(),
|
||||
playheadPosition: 12,
|
||||
refreshProjectState: vi.fn(),
|
||||
@@ -101,7 +117,7 @@ vi.mock('../core/KGCore', () => ({
|
||||
KGCore: {
|
||||
instance: () => ({
|
||||
getCurrentProject: () => ({
|
||||
getTracks: () => [],
|
||||
getTracks: () => mockTracks,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
@@ -116,7 +132,7 @@ vi.mock('../core/midi-input/KGMidiInput', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../core/region/KGMidiRegion', () => ({
|
||||
KGMidiRegion: class {},
|
||||
KGMidiRegion: MockMidiRegion,
|
||||
}));
|
||||
|
||||
vi.mock('../core/track/KGAudioTrack', () => ({
|
||||
@@ -139,9 +155,18 @@ const HookHarness = () => {
|
||||
|
||||
describe('useGlobalKeyboardHandler region shortcuts', () => {
|
||||
beforeEach(() => {
|
||||
mockTracks = [];
|
||||
regionEditUtilMocks.splitSelectedRegionAtPlayhead.mockReset();
|
||||
regionEditUtilMocks.mergeSelectedMidiRegions.mockReset();
|
||||
storeState.setStatus.mockClear();
|
||||
storeState.setShowPianoRoll.mockClear();
|
||||
storeState.showPianoRoll = false;
|
||||
storeState.activeRegionId = null;
|
||||
storeState.selectedRegionIds = ['region-a', 'region-b'];
|
||||
storeState.openMidiPianoRoll.mockClear();
|
||||
storeState.openMidiPianoRollWithSheetMusicView.mockClear();
|
||||
storeState.openSpectrogramViewer.mockClear();
|
||||
vi.mocked(showAlert).mockClear();
|
||||
});
|
||||
|
||||
it('triggers split on Ctrl+T', async () => {
|
||||
@@ -180,4 +205,76 @@ describe('useGlobalKeyboardHandler region shortcuts', () => {
|
||||
expect(storeState.setStatus).toHaveBeenCalledWith('Merged 2 MIDI regions');
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a selected MIDI region in piano roll view on E', () => {
|
||||
mockTracks = [
|
||||
{
|
||||
getRegions: () => [new MockMidiRegion('region-b')],
|
||||
},
|
||||
];
|
||||
|
||||
render(<HookHarness />);
|
||||
fireEvent.keyDown(document.body, { key: 'e' });
|
||||
|
||||
expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', false);
|
||||
expect(storeState.openSpectrogramViewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a selected MIDI region in sheet music view on N', () => {
|
||||
mockTracks = [
|
||||
{
|
||||
getRegions: () => [new MockMidiRegion('region-b')],
|
||||
},
|
||||
];
|
||||
|
||||
render(<HookHarness />);
|
||||
fireEvent.keyDown(document.body, { key: 'n' });
|
||||
|
||||
expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', true);
|
||||
expect(storeState.setShowPianoRoll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the editor alert on N when no region is selected', () => {
|
||||
storeState.selectedRegionIds = [];
|
||||
|
||||
render(<HookHarness />);
|
||||
fireEvent.keyDown(document.body, { key: 'n' });
|
||||
|
||||
expect(showAlert).toHaveBeenCalledWith('Please select a region to open the editor.');
|
||||
expect(storeState.openMidiPianoRollWithSheetMusicView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not open spectrogram on N for an audio region', () => {
|
||||
mockTracks = [
|
||||
{
|
||||
getRegions: () => [
|
||||
{
|
||||
getId: () => 'region-b',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(<HookHarness />);
|
||||
fireEvent.keyDown(document.body, { key: 'n' });
|
||||
|
||||
expect(showAlert).toHaveBeenCalledWith('Sheet music view is only available for MIDI regions.');
|
||||
expect(storeState.openSpectrogramViewer).not.toHaveBeenCalled();
|
||||
expect(storeState.openMidiPianoRollWithSheetMusicView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not close the editor when N is pressed while piano roll is already open', () => {
|
||||
storeState.showPianoRoll = true;
|
||||
mockTracks = [
|
||||
{
|
||||
getRegions: () => [new MockMidiRegion('region-b')],
|
||||
},
|
||||
];
|
||||
|
||||
render(<HookHarness />);
|
||||
fireEvent.keyDown(document.body, { key: 'n' });
|
||||
|
||||
expect(storeState.setShowPianoRoll).not.toHaveBeenCalled();
|
||||
expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import { showAlert } from '../util/dialogUtil';
|
||||
* Handles keyboard shortcuts defined in the configuration
|
||||
*/
|
||||
export const useGlobalKeyboardHandler = () => {
|
||||
const { undo, redo, setStatus, isPlaying, startPlaying, stopTransport, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRoll, openSpectrogramViewer, playheadPosition, refreshProjectState } = useProjectStore();
|
||||
const { undo, redo, setStatus, isPlaying, startPlaying, stopTransport, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRollWithSheetMusicView, openSpectrogramViewer, playheadPosition, refreshProjectState } = useProjectStore();
|
||||
const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -256,13 +256,40 @@ export const useGlobalKeyboardHandler = () => {
|
||||
}
|
||||
}
|
||||
if (foundMidi) {
|
||||
openMidiPianoRoll(candidateId);
|
||||
openMidiPianoRollWithSheetMusicView(candidateId, false);
|
||||
} else if (foundAudio) {
|
||||
openSpectrogramViewer(candidateId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for sheet music shortcut (N) — open piano roll for MIDI in sheet music view
|
||||
if (event.key.toLowerCase() === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const candidateId = activeRegionId ?? lastSelectedRegionId;
|
||||
if (!candidateId) {
|
||||
void showAlert('Please select a region to open the editor.');
|
||||
return;
|
||||
}
|
||||
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||
let foundMidi = false;
|
||||
let foundAudio = false;
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(r => r.getId() === candidateId);
|
||||
if (region) {
|
||||
if (region instanceof KGMidiRegion) foundMidi = true;
|
||||
else foundAudio = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundMidi) {
|
||||
openMidiPianoRollWithSheetMusicView(candidateId, true);
|
||||
} else if (foundAudio) {
|
||||
void showAlert('Sheet music view is only available for MIDI regions.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for save shortcut
|
||||
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
|
||||
event.preventDefault();
|
||||
@@ -307,7 +334,7 @@ export const useGlobalKeyboardHandler = () => {
|
||||
setActiveRegionId,
|
||||
setShowPianoRoll,
|
||||
showPianoRoll,
|
||||
openMidiPianoRoll,
|
||||
openMidiPianoRollWithSheetMusicView,
|
||||
openSpectrogramViewer,
|
||||
playheadPosition,
|
||||
refreshProjectState,
|
||||
|
||||
@@ -3,6 +3,10 @@ import { act } from '@testing-library/react';
|
||||
import { KGTrack } from '../core/track/KGTrack';
|
||||
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
||||
|
||||
const pianoRollStateMocks = vi.hoisted(() => ({
|
||||
setSheetMusicViewEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
||||
const mockProject = {
|
||||
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
||||
@@ -76,10 +80,17 @@ vi.mock('../core/config/ConfigManager', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../core/state/KGPianoRollState', () => ({
|
||||
KGPianoRollState: {
|
||||
instance: () => pianoRollStateMocks,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('projectStore piano roll state', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
pianoRollStateMocks.setSheetMusicViewEnabled.mockReset();
|
||||
mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
||||
mockCore.startPlaying.mockReset();
|
||||
mockCore.startPlaying.mockResolvedValue(undefined);
|
||||
@@ -118,10 +129,34 @@ describe('projectStore piano roll state', () => {
|
||||
});
|
||||
|
||||
state = useProjectStore.getState();
|
||||
expect(pianoRollStateMocks.setSheetMusicViewEnabled).toHaveBeenCalledWith(false);
|
||||
expect(state.showPianoRoll).toBe(true);
|
||||
expect(state.pianoRollMode).toBe('midi-edit');
|
||||
expect(state.activeRegionId).toBe('midi-b');
|
||||
expect(state.hybridAudioRegionId).toBeNull();
|
||||
expect(state.requestedSheetMusicViewEnabled).toBe(false);
|
||||
expect(state.pianoRollViewRequestVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('opens a MIDI region in sheet music view when requested', async () => {
|
||||
const { useProjectStore } = await import('./projectStore');
|
||||
|
||||
act(() => {
|
||||
useProjectStore.getState().openHybridMode('midi-a', 'audio-a');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useProjectStore.getState().openMidiPianoRollWithSheetMusicView('midi-b', true);
|
||||
});
|
||||
|
||||
const state = useProjectStore.getState();
|
||||
expect(pianoRollStateMocks.setSheetMusicViewEnabled).toHaveBeenCalledWith(true);
|
||||
expect(state.showPianoRoll).toBe(true);
|
||||
expect(state.pianoRollMode).toBe('midi-edit');
|
||||
expect(state.activeRegionId).toBe('midi-b');
|
||||
expect(state.hybridAudioRegionId).toBeNull();
|
||||
expect(state.requestedSheetMusicViewEnabled).toBe(true);
|
||||
expect(state.pianoRollViewRequestVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('tracks playback preparation around startPlaying success', async () => {
|
||||
|
||||
@@ -98,6 +98,8 @@ interface ProjectState {
|
||||
activeRegionId: string | null;
|
||||
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
|
||||
hybridAudioRegionId: string | null;
|
||||
requestedSheetMusicViewEnabled: boolean;
|
||||
pianoRollViewRequestVersion: number;
|
||||
automationRedrawVersion: number;
|
||||
activeTrackAutomationTrackId: string | null;
|
||||
activeTrackAutomationType: TrackAutomationType | null;
|
||||
@@ -194,6 +196,7 @@ interface ProjectState {
|
||||
setShowPianoRoll: (show: boolean) => void;
|
||||
setActiveRegionId: (regionId: string | null) => void;
|
||||
openMidiPianoRoll: (regionId: string) => void;
|
||||
openMidiPianoRollWithSheetMusicView: (regionId: string, sheetMusicViewEnabled: boolean) => void;
|
||||
openSpectrogramViewer: (regionId: string) => void;
|
||||
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
|
||||
bumpAutomationRedrawVersion: () => void;
|
||||
@@ -430,6 +433,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
activeRegionId: null,
|
||||
pianoRollMode: 'midi-edit' as const,
|
||||
hybridAudioRegionId: null,
|
||||
requestedSheetMusicViewEnabled: false,
|
||||
pianoRollViewRequestVersion: 0,
|
||||
automationRedrawVersion: 0,
|
||||
activeTrackAutomationTrackId: null,
|
||||
activeTrackAutomationType: null,
|
||||
@@ -1560,15 +1565,47 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
},
|
||||
|
||||
openMidiPianoRoll: (regionId: string) => {
|
||||
set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'midi-edit', hybridAudioRegionId: null });
|
||||
KGPianoRollState.instance().setSheetMusicViewEnabled(false);
|
||||
set(state => ({
|
||||
showPianoRoll: true,
|
||||
activeRegionId: regionId,
|
||||
pianoRollMode: 'midi-edit',
|
||||
hybridAudioRegionId: null,
|
||||
requestedSheetMusicViewEnabled: false,
|
||||
pianoRollViewRequestVersion: state.pianoRollViewRequestVersion + 1,
|
||||
}));
|
||||
},
|
||||
|
||||
openMidiPianoRollWithSheetMusicView: (regionId: string, sheetMusicViewEnabled: boolean) => {
|
||||
KGPianoRollState.instance().setSheetMusicViewEnabled(sheetMusicViewEnabled);
|
||||
set(state => ({
|
||||
showPianoRoll: true,
|
||||
activeRegionId: regionId,
|
||||
pianoRollMode: 'midi-edit',
|
||||
hybridAudioRegionId: null,
|
||||
requestedSheetMusicViewEnabled: sheetMusicViewEnabled,
|
||||
pianoRollViewRequestVersion: state.pianoRollViewRequestVersion + 1,
|
||||
}));
|
||||
},
|
||||
|
||||
openSpectrogramViewer: (regionId: string) => {
|
||||
set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'spectrogram', hybridAudioRegionId: null });
|
||||
set({
|
||||
showPianoRoll: true,
|
||||
activeRegionId: regionId,
|
||||
pianoRollMode: 'spectrogram',
|
||||
hybridAudioRegionId: null,
|
||||
requestedSheetMusicViewEnabled: false,
|
||||
});
|
||||
},
|
||||
|
||||
openHybridMode: (midiRegionId: string, audioRegionId: string) => {
|
||||
set({ showPianoRoll: true, activeRegionId: midiRegionId, hybridAudioRegionId: audioRegionId, pianoRollMode: 'hybrid' });
|
||||
set({
|
||||
showPianoRoll: true,
|
||||
activeRegionId: midiRegionId,
|
||||
hybridAudioRegionId: audioRegionId,
|
||||
pianoRollMode: 'hybrid',
|
||||
requestedSheetMusicViewEnabled: false,
|
||||
});
|
||||
},
|
||||
bumpAutomationRedrawVersion: () => {
|
||||
set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 }));
|
||||
@@ -1587,6 +1624,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
activeRegionId: null,
|
||||
hybridAudioRegionId: null,
|
||||
pianoRollMode: 'midi-edit',
|
||||
requestedSheetMusicViewEnabled: false,
|
||||
pianoRollViewRequestVersion: 0,
|
||||
activeTrackAutomationTrackId: null,
|
||||
activeTrackAutomationType: null,
|
||||
trackAutomationRedrawVersion: 0,
|
||||
|
||||
Reference in New Issue
Block a user