feat: added highlighted chord guide on piano grid
This commit is contained in:
@@ -42,6 +42,7 @@
|
|||||||
"save": "ctrl+s"
|
"save": "ctrl+s"
|
||||||
},
|
},
|
||||||
"piano_roll": {
|
"piano_roll": {
|
||||||
|
"switch": "tab",
|
||||||
"select": "q",
|
"select": "q",
|
||||||
"pencil": "w",
|
"pencil": "w",
|
||||||
"hold_to_create_note": "ctrl",
|
"hold_to_create_note": "ctrl",
|
||||||
|
|||||||
@@ -896,6 +896,15 @@ body {
|
|||||||
transition: opacity 0.1s ease;
|
transition: opacity 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.piano-grid-chord-highlight {
|
||||||
|
position: absolute;
|
||||||
|
background-color: rgba(255, 77, 77, 0.25);
|
||||||
|
border: 1px solid rgba(255, 77, 77, 0.8);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5px;
|
right: 5px;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react';
|
|||||||
import { Playhead } from '../common';
|
import { Playhead } from '../common';
|
||||||
import SelectionBox from './SelectionBox';
|
import SelectionBox from './SelectionBox';
|
||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
import { generatePianoGridBackground } from '../../util/scaleUtil';
|
import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil';
|
||||||
import type { KeySignature } from '../../core/KGProject';
|
import type { KeySignature } from '../../core/KGProject';
|
||||||
|
|
||||||
interface PianoGridProps {
|
interface PianoGridProps {
|
||||||
@@ -22,6 +22,7 @@ interface PianoGridProps {
|
|||||||
regionStartBeat?: number;
|
regionStartBeat?: number;
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
keySignature: KeySignature;
|
keySignature: KeySignature;
|
||||||
|
chordGuide: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CursorPosition {
|
interface CursorPosition {
|
||||||
@@ -41,10 +42,12 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
|||||||
selectionBox,
|
selectionBox,
|
||||||
regionStartBeat = 0,
|
regionStartBeat = 0,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
keySignature
|
keySignature,
|
||||||
|
chordGuide
|
||||||
}) => {
|
}) => {
|
||||||
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
|
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
|
||||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||||
|
const [selectedChordIndex, setSelectedChordIndex] = useState(0);
|
||||||
|
|
||||||
// Generate background with scale highlighting - only regenerate when mode or key changes
|
// Generate background with scale highlighting - only regenerate when mode or key changes
|
||||||
const backgroundImage = useMemo(() => {
|
const backgroundImage = useMemo(() => {
|
||||||
@@ -125,6 +128,67 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
|||||||
setCursorPosition(null);
|
setCursorPosition(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get all matching chords for the current hover position
|
||||||
|
const matchingChords = useMemo(() => {
|
||||||
|
if (!cursorPosition) return [];
|
||||||
|
|
||||||
|
// If chord guide is disabled, return empty array
|
||||||
|
if (chordGuide === 'N') return [];
|
||||||
|
|
||||||
|
// Use the utility function to get matching chords
|
||||||
|
const functionType = chordGuide as 'T' | 'S' | 'D';
|
||||||
|
return getMatchingChordsForPitch(cursorPosition.pitch, keySignature, selectedMode, functionType);
|
||||||
|
}, [cursorPosition, chordGuide, keySignature, selectedMode]);
|
||||||
|
|
||||||
|
// Calculate chord highlights based on selected chord index
|
||||||
|
const chordHighlights = useMemo(() => {
|
||||||
|
if (matchingChords.length === 0) return [];
|
||||||
|
|
||||||
|
// Use the selected chord index (wrap around if needed)
|
||||||
|
const chordIndex = selectedChordIndex % matchingChords.length;
|
||||||
|
const matchedChordPitches = matchingChords[chordIndex];
|
||||||
|
|
||||||
|
// Convert pitch classes to actual pitches in the same octave as cursor
|
||||||
|
const highlights: Array<{ pitch: number; beat: number }> = [];
|
||||||
|
const cursorOctave = Math.floor(cursorPosition!.pitch / 12);
|
||||||
|
|
||||||
|
// For each pitch class in the chord, create highlights
|
||||||
|
for (const pitchClass of matchedChordPitches) {
|
||||||
|
// Calculate the actual pitch in the cursor's octave
|
||||||
|
const actualPitch = cursorOctave * 12 + pitchClass;
|
||||||
|
|
||||||
|
// Ensure the pitch is in valid MIDI range (0-127)
|
||||||
|
if (actualPitch >= 0 && actualPitch <= 127) {
|
||||||
|
highlights.push({ pitch: actualPitch, beat: cursorPosition!.beat });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlights;
|
||||||
|
}, [cursorPosition, matchingChords, selectedChordIndex]);
|
||||||
|
|
||||||
|
// Reset selected chord index when cursor moves to a different pitch or beat
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedChordIndex(0);
|
||||||
|
}, [cursorPosition?.pitch, cursorPosition?.beat]);
|
||||||
|
|
||||||
|
// Expose switchChord function via window for hotkey handler
|
||||||
|
useEffect(() => {
|
||||||
|
const switchChord = () => {
|
||||||
|
if (matchingChords.length > 1) {
|
||||||
|
setSelectedChordIndex(prev => (prev + 1) % matchingChords.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the function on window object so PianoRoll can call it
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(window as any).__pianoGridSwitchChord = switchChord;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
delete (window as any).__pianoGridSwitchChord;
|
||||||
|
};
|
||||||
|
}, [matchingChords.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="piano-grid-container">
|
<div className="piano-grid-container">
|
||||||
<div
|
<div
|
||||||
@@ -150,13 +214,33 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Vertical beat column highlight */}
|
{/* Vertical beat column highlight */}
|
||||||
<div
|
<div
|
||||||
className="piano-grid-beat-highlight"
|
className="piano-grid-beat-highlight"
|
||||||
style={{
|
style={{
|
||||||
left: cursorPosition.beat * (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40),
|
left: cursorPosition.beat * (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40),
|
||||||
width: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40
|
width: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Chord highlights - render red boxes for each note in the matched chord */}
|
||||||
|
{chordHighlights.map((highlight, index) => {
|
||||||
|
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
|
||||||
|
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
|
||||||
|
const yPosition = (107 - highlight.pitch) * noteHeight; // B7 = 107, reverse for display
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`chord-highlight-${index}`}
|
||||||
|
className="piano-grid-chord-highlight"
|
||||||
|
style={{
|
||||||
|
top: yPosition,
|
||||||
|
left: highlight.beat * beatWidth,
|
||||||
|
width: beatWidth,
|
||||||
|
height: noteHeight
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -677,6 +677,19 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
// Handle piano roll hotkeys
|
// Handle piano roll hotkeys
|
||||||
const configManager = ConfigManager.instance();
|
const configManager = ConfigManager.instance();
|
||||||
if (configManager.getIsInitialized()) {
|
if (configManager.getIsInitialized()) {
|
||||||
|
// Chord guide switch hotkey
|
||||||
|
const switch_key = configManager.get('hotkeys.piano_roll.switch') as string;
|
||||||
|
if (event.key && event.key.toLowerCase() === switch_key.toLowerCase()) {
|
||||||
|
// Call the switchChord function exposed by PianoGrid
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const switchChord = (window as any).__pianoGridSwitchChord;
|
||||||
|
if (typeof switchChord === 'function') {
|
||||||
|
switchChord();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Snapping hotkeys
|
// Snapping hotkeys
|
||||||
const snap_none_key = configManager.get('hotkeys.piano_roll.snap_none') as string;
|
const snap_none_key = configManager.get('hotkeys.piano_roll.snap_none') as string;
|
||||||
const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string;
|
const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string;
|
||||||
@@ -851,6 +864,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger}
|
onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger}
|
||||||
selectedMode={selectedMode}
|
selectedMode={selectedMode}
|
||||||
keySignature={keySignature}
|
keySignature={keySignature}
|
||||||
|
chordGuide={chordGuide}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface PianoRollContentProps {
|
|||||||
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
|
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
keySignature: KeySignature;
|
keySignature: KeySignature;
|
||||||
|
chordGuide: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||||
@@ -37,7 +38,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
onSetNoteUpdateTrigger,
|
onSetNoteUpdateTrigger,
|
||||||
onSetDeleteNotesTrigger,
|
onSetDeleteNotesTrigger,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
keySignature
|
keySignature,
|
||||||
|
chordGuide
|
||||||
}) => {
|
}) => {
|
||||||
// Get KGCore instance
|
// Get KGCore instance
|
||||||
const core = KGCore.instance();
|
const core = KGCore.instance();
|
||||||
@@ -234,6 +236,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
|
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
|
||||||
selectedMode={selectedMode}
|
selectedMode={selectedMode}
|
||||||
keySignature={keySignature}
|
keySignature={keySignature}
|
||||||
|
chordGuide={chordGuide}
|
||||||
>
|
>
|
||||||
{memoizedNotes}
|
{memoizedNotes}
|
||||||
</PianoGrid>
|
</PianoGrid>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface AppConfig {
|
|||||||
save: string;
|
save: string;
|
||||||
};
|
};
|
||||||
piano_roll: {
|
piano_roll: {
|
||||||
|
switch: string;
|
||||||
select: string;
|
select: string;
|
||||||
pencil: string;
|
pencil: string;
|
||||||
hold_to_create_note: string;
|
hold_to_create_note: string;
|
||||||
@@ -203,6 +204,7 @@ export class ConfigManager {
|
|||||||
save: 'ctrl+s'
|
save: 'ctrl+s'
|
||||||
},
|
},
|
||||||
piano_roll: {
|
piano_roll: {
|
||||||
|
switch: 'tab',
|
||||||
select: 'q',
|
select: 'q',
|
||||||
pencil: 'w',
|
pencil: 'w',
|
||||||
hold_to_create_note: 'ctrl',
|
hold_to_create_note: 'ctrl',
|
||||||
|
|||||||
@@ -205,6 +205,95 @@ export const getChordNotesInKey = (
|
|||||||
return transposedNotes;
|
return transposedNotes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets matching chords for a given pitch, prioritized by which note in the chord matches
|
||||||
|
* @param hoverPitch - The MIDI pitch being hovered over (0-127)
|
||||||
|
* @param keySignature - Key signature like "C major", "F# minor"
|
||||||
|
* @param modeId - Mode ID (e.g., "ionian", "aeolian")
|
||||||
|
* @param functionType - Functional harmony group: "T" (Tonic), "S" (Subdominant), or "D" (Dominant)
|
||||||
|
* @returns Array of chord pitch class arrays, prioritized by match position
|
||||||
|
* (root matches first, then 2nd note matches, then 3rd note, etc.)
|
||||||
|
* Each chord is adjusted so its pitch classes work in the same octave as the hover pitch
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* - getMatchingChordsForPitch(60, "C major", "ionian", "T")
|
||||||
|
* Returns chords where C (pitch class 0) appears, with pitch classes adjusted for display
|
||||||
|
*/
|
||||||
|
export const getMatchingChordsForPitch = (
|
||||||
|
hoverPitch: number,
|
||||||
|
keySignature: KeySignature,
|
||||||
|
modeId: string,
|
||||||
|
functionType: 'T' | 'S' | 'D'
|
||||||
|
): number[][] => {
|
||||||
|
// Get suitable chords for the given key, mode, and function
|
||||||
|
const suitableChords = getSuitableChords(keySignature, modeId, functionType);
|
||||||
|
|
||||||
|
if (Object.keys(suitableChords).length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert chord note names to ascending pitch classes
|
||||||
|
const chordsPitchClasses: Record<string, number[]> = {};
|
||||||
|
|
||||||
|
for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) {
|
||||||
|
const pitchClasses: number[] = [];
|
||||||
|
let previousPitch = -1;
|
||||||
|
|
||||||
|
for (const noteName of noteNames) {
|
||||||
|
let pitchClass = noteNameToPitchClass(noteName);
|
||||||
|
// Ensure ascending order by adding 12 if pitch class <= previous
|
||||||
|
if (previousPitch >= 0 && pitchClass <= previousPitch) {
|
||||||
|
pitchClass += 12;
|
||||||
|
}
|
||||||
|
pitchClasses.push(pitchClass);
|
||||||
|
previousPitch = pitchClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
chordsPitchClasses[chordSymbol] = pitchClasses;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the pitch class of the current hovering note (mod 12)
|
||||||
|
const hoverPitchClass = hoverPitch % 12;
|
||||||
|
|
||||||
|
// Group chords by which note position matches the hovering pitch class
|
||||||
|
// matchesByPosition[0] = chords where 1st note matches
|
||||||
|
// matchesByPosition[1] = chords where 2nd note matches, etc.
|
||||||
|
const matchesByPosition: number[][][] = [];
|
||||||
|
|
||||||
|
for (const pitchClasses of Object.values(chordsPitchClasses)) {
|
||||||
|
// Check each note position in the chord
|
||||||
|
for (let i = 0; i < pitchClasses.length; i++) {
|
||||||
|
if (pitchClasses[i] % 12 === hoverPitchClass) {
|
||||||
|
// Ensure the array exists for this position
|
||||||
|
if (!matchesByPosition[i]) {
|
||||||
|
matchesByPosition[i] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offset the chord based on the matched pitch class
|
||||||
|
// If the matched note is >= 12 (in the second octave), shift all notes down by 12
|
||||||
|
if (pitchClasses[i] >= 12) {
|
||||||
|
const offsetChord = pitchClasses.map(p => p - 12);
|
||||||
|
matchesByPosition[i].push(offsetChord);
|
||||||
|
} else {
|
||||||
|
matchesByPosition[i].push(pitchClasses);
|
||||||
|
}
|
||||||
|
|
||||||
|
break; // Only count each chord once (at first matching position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten the grouped matches: 1st position matches first, then 2nd, then 3rd, etc.
|
||||||
|
const result: number[][] = [];
|
||||||
|
for (const matches of matchesByPosition) {
|
||||||
|
if (matches) {
|
||||||
|
result.push(...matches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the CSS background-image string for the piano grid with scale highlighting
|
* Generates the CSS background-image string for the piano grid with scale highlighting
|
||||||
* @param selectedMode - Current mode ID (e.g., "ionian", "dorian")
|
* @param selectedMode - Current mode ID (e.g., "ionian", "dorian")
|
||||||
|
|||||||
Reference in New Issue
Block a user