feat: implemented user-friendly chord guide editor; added real time chord guide info to the status bar
This commit is contained in:
@@ -11,7 +11,7 @@ import AudioWaveformCanvas from './AudioWaveformCanvas';
|
||||
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
|
||||
import { getNextChordCandidateIndex } from './chordGuideUtil';
|
||||
import { getMatchingChordGuideChordsForPitch } from '../../util/chordGuideDataUtil';
|
||||
import { getMatchingChordGuideCandidatesForPitch } from '../../util/chordGuideDataUtil';
|
||||
|
||||
interface PianoGridProps {
|
||||
gridRef: MutableRefObject<HTMLDivElement | null>;
|
||||
@@ -160,24 +160,28 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
};
|
||||
|
||||
// Get all matching chords for the current hover position
|
||||
const matchingChords = useMemo(() => {
|
||||
const matchingCandidates = useMemo(() => {
|
||||
if (!cursorPosition) return [];
|
||||
|
||||
// If chord guide is disabled, return empty array
|
||||
if (chordGuide === 'N') return [];
|
||||
|
||||
// Use the utility function to get matching chords
|
||||
// Use the utility function to get matching chords in the same order used by candidate cycling.
|
||||
const functionType = chordGuide as 'T' | 'S' | 'D';
|
||||
return getMatchingChordGuideChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
|
||||
return getMatchingChordGuideCandidatesForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
|
||||
}, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]);
|
||||
|
||||
const matchingChords = useMemo(() => (
|
||||
matchingCandidates.map((candidate) => candidate.displayPitchClasses)
|
||||
), [matchingCandidates]);
|
||||
|
||||
// Calculate chord highlights based on selected chord index
|
||||
const chordHighlights = useMemo(() => {
|
||||
if (matchingChords.length === 0) return [];
|
||||
if (matchingCandidates.length === 0) return [];
|
||||
|
||||
// Use the selected chord index (wrap around if needed)
|
||||
const chordIndex = selectedChordIndex % matchingChords.length;
|
||||
const matchedChordPitches = matchingChords[chordIndex];
|
||||
const chordIndex = selectedChordIndex % matchingCandidates.length;
|
||||
const matchedChordPitches = matchingCandidates[chordIndex].displayPitchClasses;
|
||||
|
||||
// Convert pitch classes to actual pitches in the same octave as cursor
|
||||
const highlights: Array<{ pitch: number; beat: number }> = [];
|
||||
@@ -195,15 +199,22 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
}
|
||||
|
||||
return highlights;
|
||||
}, [cursorPosition, matchingChords, selectedChordIndex]);
|
||||
}, [cursorPosition, matchingCandidates, selectedChordIndex]);
|
||||
|
||||
const cursorPitch = cursorPosition?.pitch ?? null;
|
||||
const selectedHoverCandidate = useMemo(() => {
|
||||
if (matchingCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return matchingCandidates[selectedChordIndex % matchingCandidates.length].item;
|
||||
}, [matchingCandidates, selectedChordIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const pianoRollState = KGPianoRollState.instance();
|
||||
pianoRollState.setCurrentMatchingChords(matchingChords);
|
||||
pianoRollState.setCurrentChordCursorPitch(cursorPitch);
|
||||
}, [matchingChords, cursorPitch]);
|
||||
pianoRollState.setCurrentHoveredChordGuideCandidate(selectedHoverCandidate);
|
||||
}, [matchingChords, cursorPitch, selectedHoverCandidate]);
|
||||
|
||||
useEffect(() => {
|
||||
KGPianoRollState.instance().setCurrentSelectedChordIndex(selectedChordIndex);
|
||||
@@ -219,8 +230,8 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
// Expose switchChord function via window for hotkey handler
|
||||
useEffect(() => {
|
||||
const switchChord = (direction: 1 | -1 = 1) => {
|
||||
if (matchingChords.length > 1) {
|
||||
setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingChords.length, direction));
|
||||
if (matchingCandidates.length > 1) {
|
||||
setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingCandidates.length, direction));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,7 +243,7 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (window as any).__pianoGridSwitchChord;
|
||||
};
|
||||
}, [matchingChords.length]);
|
||||
}, [matchingCandidates.length]);
|
||||
|
||||
return (
|
||||
<div className="piano-grid-container">
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import type { ChordGuideData } from '../../core/ChordGuideTypes';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { getMatchingChordGuideCandidatesForPitch } from '../../util/chordGuideDataUtil';
|
||||
import chordGuideDataJson from '../../../public/resources/modes/chord_guide.json';
|
||||
|
||||
const chordGuideData = chordGuideDataJson as ChordGuideData;
|
||||
|
||||
vi.mock('../common', () => ({
|
||||
Playhead: () => null,
|
||||
}));
|
||||
|
||||
import PianoGrid from './PianoGrid';
|
||||
|
||||
describe('PianoGrid chord-guide hover candidate state', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.setProperty('--region-grid-beat-width', '40');
|
||||
document.documentElement.style.setProperty('--region-piano-key-height', '20');
|
||||
KGCore.CHORD_GUIDE_DATA = structuredClone(chordGuideData);
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = {
|
||||
ionian: { steps: [2, 2, 1, 2, 2, 2, 1] },
|
||||
aeolian: { steps: [2, 1, 2, 2, 1, 2, 2] },
|
||||
} as unknown as typeof KGCore.FUNCTIONAL_CHORDS_DATA;
|
||||
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null);
|
||||
KGPianoRollState.instance().setCurrentMatchingChords([]);
|
||||
KGPianoRollState.instance().setCurrentSelectedChordIndex(0);
|
||||
KGPianoRollState.instance().setCurrentChordCursorPitch(null);
|
||||
});
|
||||
|
||||
it('tracks the hovered candidate, updates on candidate cycling, and clears on mouse leave', async () => {
|
||||
const gridRef = { current: null as HTMLDivElement | null };
|
||||
const { container } = render(
|
||||
<PianoGrid
|
||||
gridRef={gridRef}
|
||||
onDoubleClick={() => {}}
|
||||
onClick={() => {}}
|
||||
onMouseDown={() => {}}
|
||||
isBoxSelecting={false}
|
||||
selectionBox={{ startX: 0, startY: 0, endX: 0, endY: 0 }}
|
||||
selectedMode="ionian"
|
||||
keySignature="C major"
|
||||
chordGuide="T"
|
||||
chordGuideKeySignature="C major"
|
||||
chordGuideMode="ionian"
|
||||
>
|
||||
{null}
|
||||
</PianoGrid>
|
||||
);
|
||||
|
||||
const pianoGrid = container.querySelector('.piano-grid') as HTMLDivElement;
|
||||
pianoGrid.getBoundingClientRect = () => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 1200,
|
||||
right: 800,
|
||||
bottom: 1200,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
fireEvent.mouseMove(pianoGrid, { clientX: 10, clientY: 941 });
|
||||
|
||||
const expectedCandidates = getMatchingChordGuideCandidatesForPitch(60, 'C major', 'ionian', 'T');
|
||||
expect(expectedCandidates.length).toBeGreaterThan(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toMatchObject({
|
||||
name: expectedCandidates[0].item.name,
|
||||
resolvedNotes: expectedCandidates[0].item.resolvedNotes,
|
||||
note: expectedCandidates[0].item.note,
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).__pianoGridSwitchChord(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toMatchObject({
|
||||
name: expectedCandidates[1].item.name,
|
||||
resolvedNotes: expectedCandidates[1].item.resolvedNotes,
|
||||
note: expectedCandidates[1].item.note,
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.mouseLeave(pianoGrid);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -741,6 +741,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
// Disabled - clear chord data
|
||||
pianoRollState.setCurrentSuitableChords([]);
|
||||
pianoRollState.setCurrentSuitableChordsPitchClasses({});
|
||||
pianoRollState.setCurrentHoveredChordGuideCandidate(null);
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Chord guide disabled - cleared suitable chords`);
|
||||
@@ -756,6 +757,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
// Update piano roll state
|
||||
pianoRollState.setCurrentSuitableChords(suitableChords);
|
||||
pianoRollState.setCurrentSuitableChordsPitchClasses(chordsPitchClasses);
|
||||
pianoRollState.setCurrentHoveredChordGuideCandidate(null);
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Chord guide updated: ${chordGuide} (${functionType})`);
|
||||
|
||||
Reference in New Issue
Block a user