feat: add scale highlighting to Piano Roll grid
This commit is contained in:
+2
-13
@@ -863,19 +863,8 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-size: var(--region-grid-beat-width) var(--region-piano-key-height), var(--region-grid-bar-width) var(--region-piano-key-height);
|
background-size: var(--region-grid-beat-width) var(--region-piano-key-height), 100% 100%;
|
||||||
background-image:
|
/* background-image is now set dynamically via React inline styles in PianoGrid component */
|
||||||
/* Vertical lines for beats */
|
|
||||||
linear-gradient(to right,
|
|
||||||
transparent calc(var(--region-grid-beat-width) - 1px), #444 calc(var(--region-grid-beat-width) - 1px), #444 var(--region-grid-beat-width),
|
|
||||||
transparent calc(var(--region-grid-beat-width)), transparent calc(var(--region-grid-beat-width) * 2 - 1px), #444 calc(var(--region-grid-beat-width) * 2 - 1px), #444 calc(var(--region-grid-beat-width) * 2),
|
|
||||||
transparent calc(var(--region-grid-beat-width) * 2), transparent calc(var(--region-grid-beat-width) * 3 - 1px), #444 calc(var(--region-grid-beat-width) * 3 - 1px), #444 calc(var(--region-grid-beat-width) * 3),
|
|
||||||
transparent calc(var(--region-grid-beat-width) * 3), transparent calc(var(--region-grid-beat-width) * 4 - 1px), #3a3a3a calc(var(--region-grid-beat-width) * 4 - 1px), #3a3a3a calc(var(--region-grid-beat-width) * 4)
|
|
||||||
),
|
|
||||||
/* Horizontal lines for notes */
|
|
||||||
linear-gradient(to bottom,
|
|
||||||
transparent calc(var(--region-piano-key-height) - 1px), #3a3a3a calc(var(--region-piano-key-height) - 1px), #3a3a3a var(--region-piano-key-height)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.piano-grid.pencil-cursor {
|
.piano-grid.pencil-cursor {
|
||||||
|
|||||||
+6
-4
@@ -38,13 +38,15 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/mode_list.json`);
|
const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/mode_list.json`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const modeNames = data.modes.map((mode: { name: string; steps: number[] }) => mode.name);
|
|
||||||
KGPianoRollState.MODE_OPTIONS = modeNames;
|
// Store mode data with id, name, and steps
|
||||||
console.log(`Loaded ${modeNames.length} modes:`, modeNames);
|
KGPianoRollState.MODE_DATA = data.modes;
|
||||||
|
|
||||||
|
console.log(`Loaded ${data.modes.length} modes:`, data.modes.map((m: { name: string }) => m.name));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load mode list:', error);
|
console.error('Failed to load mode list:', error);
|
||||||
// Fallback to default mode
|
// Fallback to default mode
|
||||||
KGPianoRollState.MODE_OPTIONS = ['ionian'];
|
KGPianoRollState.MODE_DATA = [{ id: 'ionian', name: 'Ionian', steps: [2, 2, 1, 2, 2, 2, 1] }];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log maxBars to console
|
// Log maxBars to console
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import type { MutableRefObject } from 'react';
|
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 type { KeySignature } from '../../core/KGProject';
|
||||||
|
|
||||||
interface PianoGridProps {
|
interface PianoGridProps {
|
||||||
gridRef: MutableRefObject<HTMLDivElement | null>;
|
gridRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
@@ -18,6 +20,8 @@ interface PianoGridProps {
|
|||||||
endY: number;
|
endY: number;
|
||||||
};
|
};
|
||||||
regionStartBeat?: number;
|
regionStartBeat?: number;
|
||||||
|
selectedMode: string;
|
||||||
|
keySignature: KeySignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CursorPosition {
|
interface CursorPosition {
|
||||||
@@ -35,11 +39,18 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
|||||||
onMouseDown,
|
onMouseDown,
|
||||||
isBoxSelecting,
|
isBoxSelecting,
|
||||||
selectionBox,
|
selectionBox,
|
||||||
regionStartBeat = 0
|
regionStartBeat = 0,
|
||||||
|
selectedMode,
|
||||||
|
keySignature
|
||||||
}) => {
|
}) => {
|
||||||
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
|
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
|
||||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||||
|
|
||||||
|
// Generate background with scale highlighting - only regenerate when mode or key changes
|
||||||
|
const backgroundImage = useMemo(() => {
|
||||||
|
return generatePianoGridBackground(selectedMode, keySignature);
|
||||||
|
}, [selectedMode, keySignature]);
|
||||||
|
|
||||||
// Track modifier key state
|
// Track modifier key state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -116,9 +127,10 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="piano-grid-container">
|
<div className="piano-grid-container">
|
||||||
<div
|
<div
|
||||||
className={`piano-grid ${isModifierPressed ? 'pencil-cursor' : ''}`}
|
className={`piano-grid ${isModifierPressed ? 'pencil-cursor' : ''}`}
|
||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
|
style={{ backgroundImage }}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseDown={(e) => onMouseDown(e)}
|
onMouseDown={(e) => onMouseDown(e)}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
initialPosition,
|
initialPosition,
|
||||||
initialSize
|
initialSize
|
||||||
}) => {
|
}) => {
|
||||||
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection } = useProjectStore();
|
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature } = useProjectStore();
|
||||||
|
|
||||||
// Tool state for piano roll
|
// Tool state for piano roll
|
||||||
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
||||||
@@ -792,6 +792,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
tracks={tracks}
|
tracks={tracks}
|
||||||
onSetNoteUpdateTrigger={handleSetNoteUpdateTrigger}
|
onSetNoteUpdateTrigger={handleSetNoteUpdateTrigger}
|
||||||
onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger}
|
onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger}
|
||||||
|
selectedMode={selectedMode}
|
||||||
|
keySignature={keySignature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import PianoGridHeader from './PianoGridHeader';
|
|||||||
import PianoGrid from './PianoGrid';
|
import PianoGrid from './PianoGrid';
|
||||||
import { useNoteOperations } from '../../hooks/useNoteOperations';
|
import { useNoteOperations } from '../../hooks/useNoteOperations';
|
||||||
import { useNoteSelection } from '../../hooks/useNoteSelection';
|
import { useNoteSelection } from '../../hooks/useNoteSelection';
|
||||||
|
import type { KeySignature } from '../../core/KGProject';
|
||||||
|
|
||||||
interface PianoRollContentProps {
|
interface PianoRollContentProps {
|
||||||
contentRef: React.MutableRefObject<HTMLDivElement | null>;
|
contentRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
@@ -21,6 +22,8 @@ interface PianoRollContentProps {
|
|||||||
tracks: KGTrack[];
|
tracks: KGTrack[];
|
||||||
onSetNoteUpdateTrigger?: (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => void;
|
onSetNoteUpdateTrigger?: (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => void;
|
||||||
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
|
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
|
||||||
|
selectedMode: string;
|
||||||
|
keySignature: KeySignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||||
@@ -32,7 +35,9 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
updateTrack,
|
updateTrack,
|
||||||
tracks,
|
tracks,
|
||||||
onSetNoteUpdateTrigger,
|
onSetNoteUpdateTrigger,
|
||||||
onSetDeleteNotesTrigger
|
onSetDeleteNotesTrigger,
|
||||||
|
selectedMode,
|
||||||
|
keySignature
|
||||||
}) => {
|
}) => {
|
||||||
// Get KGCore instance
|
// Get KGCore instance
|
||||||
const core = KGCore.instance();
|
const core = KGCore.instance();
|
||||||
@@ -227,6 +232,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
isBoxSelecting={isBoxSelectingRef.current}
|
isBoxSelecting={isBoxSelectingRef.current}
|
||||||
selectionBox={selectionBoxRef.current}
|
selectionBox={selectionBoxRef.current}
|
||||||
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
|
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
|
||||||
|
selectedMode={selectedMode}
|
||||||
|
keySignature={keySignature}
|
||||||
>
|
>
|
||||||
{memoizedNotes}
|
{memoizedNotes}
|
||||||
</PianoGrid>
|
</PianoGrid>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
<div className="toolbar-left">
|
<div className="toolbar-left">
|
||||||
{/* Left section with mode dropdown */}
|
{/* Left section with mode dropdown */}
|
||||||
<KGDropdown
|
<KGDropdown
|
||||||
options={KGPianoRollState.MODE_OPTIONS}
|
options={KGPianoRollState.MODE_DATA.map(m => ({ label: m.name, value: m.id }))}
|
||||||
value={selectedMode}
|
value={selectedMode}
|
||||||
onChange={(value) => onModeChange(value)}
|
onChange={(value) => onModeChange(value)}
|
||||||
label="Mode"
|
label="Mode"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class KGPianoRollState {
|
|||||||
public static SNAP_OPTIONS: string[] = ['NO SNAP', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
public static SNAP_OPTIONS: string[] = ['NO SNAP', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
||||||
public static QUANT_POS_OPTIONS: string[] = ['1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
public static QUANT_POS_OPTIONS: string[] = ['1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
||||||
public static QUANT_LEN_OPTIONS: string[] = ['1/1', '1/2', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
public static QUANT_LEN_OPTIONS: string[] = ['1/1', '1/2', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
||||||
public static MODE_OPTIONS: string[] = []; // Will be populated from mode_list.json
|
public static MODE_DATA: Array<{ id: string; name: string; steps: number[] }> = []; // Modes with id, display name, and interval steps
|
||||||
|
|
||||||
private activeTool: string = "pointer";
|
private activeTool: string = "pointer";
|
||||||
private currentSnap: string = "NO SNAP";
|
private currentSnap: string = "NO SNAP";
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||||
|
import { pianoRollIndexToPitch } from './midiUtil';
|
||||||
|
import type { KeySignature } from '../core/KGProject';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the root note from a key signature string
|
||||||
|
* @param keySignature - Key signature like "C major", "F# minor", "Bb major"
|
||||||
|
* @returns Root note like "C", "F#", "Bb"
|
||||||
|
*/
|
||||||
|
export const getRootNoteFromKeySignature = (keySignature: KeySignature): string => {
|
||||||
|
// Extract the note before " major" or " minor"
|
||||||
|
const match = keySignature.match(/^([A-G][#b]?)\s+(major|minor)$/);
|
||||||
|
if (!match) {
|
||||||
|
console.warn(`Invalid key signature format: ${keySignature}, defaulting to C`);
|
||||||
|
return 'C';
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a note name (without octave) to pitch class (0-11)
|
||||||
|
* @param noteName - Note name like "C", "C#", "Db", "F#"
|
||||||
|
* @returns Pitch class (0=C, 1=C#/Db, 2=D, ..., 11=B)
|
||||||
|
*/
|
||||||
|
export const noteNameToPitchClass = (noteName: string): number => {
|
||||||
|
const noteMap: { [key: string]: number } = {
|
||||||
|
'C': 0, 'C#': 1, 'Db': 1,
|
||||||
|
'D': 2, 'D#': 3, 'Eb': 3,
|
||||||
|
'E': 4,
|
||||||
|
'F': 5, 'F#': 6, 'Gb': 6,
|
||||||
|
'G': 7, 'G#': 8, 'Ab': 8,
|
||||||
|
'A': 9, 'A#': 10, 'Bb': 10,
|
||||||
|
'B': 11
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!(noteName in noteMap)) {
|
||||||
|
console.warn(`Invalid note name: ${noteName}, defaulting to C (0)`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return noteMap[noteName];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the pitch classes (0-11) that belong to a scale
|
||||||
|
* @param rootNote - Root note like "C", "F#", "Bb"
|
||||||
|
* @param modeSteps - Mode interval steps (e.g., [2, 2, 1, 2, 2, 2, 1] for ionian)
|
||||||
|
* @returns Array of pitch classes in the scale
|
||||||
|
*/
|
||||||
|
export const getScalePitchClasses = (rootNote: string, modeSteps: number[]): number[] => {
|
||||||
|
const rootPitchClass = noteNameToPitchClass(rootNote);
|
||||||
|
const scalePitchClasses: number[] = [rootPitchClass];
|
||||||
|
|
||||||
|
let currentPitch = rootPitchClass;
|
||||||
|
for (const step of modeSteps.slice(0, -1)) { // Exclude last step (returns to root)
|
||||||
|
currentPitch = (currentPitch + step) % 12;
|
||||||
|
scalePitchClasses.push(currentPitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scalePitchClasses;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the mode steps for a given mode id
|
||||||
|
* @param modeId - ID of the mode (e.g., "ionian", "aeolian")
|
||||||
|
* @returns Array of interval steps, or default ionian if not found
|
||||||
|
*/
|
||||||
|
export const getModeSteps = (modeId: string): number[] => {
|
||||||
|
const modeData = KGPianoRollState.MODE_DATA.find(m => m.id === modeId);
|
||||||
|
if (!modeData) {
|
||||||
|
console.warn(`Mode not found: ${modeId}, defaulting to ionian`);
|
||||||
|
return [2, 2, 1, 2, 2, 2, 1]; // Default to ionian (major scale)
|
||||||
|
}
|
||||||
|
return modeData.steps;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the CSS background-image string for the piano grid with scale highlighting
|
||||||
|
* @param selectedMode - Current mode ID (e.g., "ionian", "dorian")
|
||||||
|
* @param keySignature - Current key signature (e.g., "C major", "F# minor")
|
||||||
|
* @returns CSS background-image string with highlighted scale notes
|
||||||
|
*/
|
||||||
|
export const generatePianoGridBackground = (
|
||||||
|
selectedMode: string,
|
||||||
|
keySignature: KeySignature
|
||||||
|
): string => {
|
||||||
|
// Get root note and scale pitch classes
|
||||||
|
const rootNote = getRootNoteFromKeySignature(keySignature);
|
||||||
|
const modeSteps = getModeSteps(selectedMode);
|
||||||
|
const scalePitchClasses = getScalePitchClasses(rootNote, modeSteps);
|
||||||
|
|
||||||
|
// Generate horizontal lines for each of 96 rows (8 octaves)
|
||||||
|
const horizontalLines = Array.from({ length: 96 }, (_, index) => {
|
||||||
|
const pitch = pianoRollIndexToPitch(index);
|
||||||
|
const pitchClass = pitch % 12;
|
||||||
|
const isInScale = scalePitchClasses.includes(pitchClass);
|
||||||
|
|
||||||
|
// Calculate row positions using CSS calc() with --region-piano-key-height variable
|
||||||
|
const rowTop = `calc(var(--region-piano-key-height) * ${index})`;
|
||||||
|
const rowBottomMinusOne = `calc(var(--region-piano-key-height) * ${index + 1} - 1px)`;
|
||||||
|
const rowBottom = `calc(var(--region-piano-key-height) * ${index + 1})`;
|
||||||
|
|
||||||
|
// For scale notes: highlight the full row with a semi-transparent blue background
|
||||||
|
// For non-scale notes: use transparent background with just the separator line
|
||||||
|
if (isInScale) {
|
||||||
|
return `
|
||||||
|
rgba(90, 123, 154, 0.15) ${rowTop},
|
||||||
|
rgba(90, 123, 154, 0.15) ${rowBottomMinusOne},
|
||||||
|
#3a3a3a ${rowBottomMinusOne},
|
||||||
|
#3a3a3a ${rowBottom}
|
||||||
|
`.trim();
|
||||||
|
} else {
|
||||||
|
return `
|
||||||
|
transparent ${rowTop},
|
||||||
|
transparent ${rowBottomMinusOne},
|
||||||
|
#3a3a3a ${rowBottomMinusOne},
|
||||||
|
#3a3a3a ${rowBottom}
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
// Return complete background-image with vertical and horizontal gradients
|
||||||
|
// Note: Vertical beat lines gradient should be preserved from existing CSS
|
||||||
|
return `
|
||||||
|
linear-gradient(to right,
|
||||||
|
transparent calc(var(--region-grid-beat-width) - 1px),
|
||||||
|
#3a3a3a calc(var(--region-grid-beat-width) - 1px),
|
||||||
|
#3a3a3a var(--region-grid-beat-width)
|
||||||
|
),
|
||||||
|
linear-gradient(to bottom, ${horizontalLines})
|
||||||
|
`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user