diff --git a/src/App.tsx b/src/App.tsx index 7af3eb5..a22f087 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,6 +71,7 @@ function App() { const [virtualNote, setVirtualNote] = useState(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false); + const [hoveredMidi, setHoveredMidi] = useState(null); const feedbackTimeoutRef = useRef | null>(null); const currentTuning = TUNINGS[settings.tuningId]; @@ -450,6 +451,7 @@ function App() { width={Math.min(window.innerWidth - 40, 500)} height={activeClef === 'grand' ? 260 : 180} hideTargetNote={settings.gameMode === 'ear_training' && !revealed} + hoverMidi={settings.showHint ? hoveredMidi : null} /> @@ -491,16 +493,22 @@ function App() { minMidi={36} // C2 maxMidi={84} // C6 markedNotes={settings.showHint ? [targetMidi] : []} - interactive={settings.showFretboard} + interactive={settings.showFretboard || settings.showHint} + showTooltips={settings.showHint} + displayTranspose={activeTranspose} onPlayNote={handleVirtualInstrumentPlay} + onHover={setHoveredMidi} /> ) : ( currentTuning && ( diff --git a/src/components/Fretboard.tsx b/src/components/Fretboard.tsx index be9f89e..31a3c01 100644 --- a/src/components/Fretboard.tsx +++ b/src/components/Fretboard.tsx @@ -1,4 +1,6 @@ +import { useState } from 'react'; import type { Tuning, FretPosition } from '../music/Tunings'; +import { getNoteDetails } from '../music/NoteUtils'; interface FretboardProps { tuning: Tuning; @@ -6,7 +8,10 @@ interface FretboardProps { maxFrets?: number; interactive?: boolean; showHints?: boolean; + showTooltips?: boolean; + displayTranspose?: number; onPlayNote?: (midi: number) => void; + onHover?: (midi: number | null) => void; } export function Fretboard({ @@ -15,8 +20,13 @@ export function Fretboard({ maxFrets = 15, interactive = false, showHints = true, - onPlayNote + showTooltips = false, + displayTranspose = 0, + onPlayNote, + onHover }: FretboardProps) { + const [hoverPos, setHoverPos] = useState<{ stringIndex: number, fret: number } | null>(null); + // Config const numStrings = tuning.strings.length; // Visual params @@ -66,7 +76,8 @@ export function Fretboard({
{ + const cx = getNoteX(hoverPos.fret); + const cy = getStringY(hoverPos.stringIndex); + + return ( + + + + ); + })() + )} + {/* Interaction Overlay (Invisible hit targets) */} {interactive && tuning.strings.map((_, stringIndex) => { const y = getStringY(stringIndex); @@ -206,6 +238,7 @@ export function Fretboard({ } const rectWidth = xEnd - xStart; + const midi = tuning.strings[stringIndex] + fret; return ( handleFretClick(stringIndex, fret)} - // Hover effect could be added here via CSS class if we want + onMouseEnter={() => { + setHoverPos({ stringIndex, fret }); + onHover?.(midi); + }} + onMouseLeave={() => { + setHoverPos(null); + onHover?.(null); + }} className="fret-hit-target" > String {stringIndex + 1}, Fret {fret} @@ -226,6 +266,48 @@ export function Fretboard({ }); })} + + {/* HTML Tooltip Overlay */} + {interactive && showTooltips && hoverPos && (() => { + const cx = getNoteX(hoverPos.fret); + const cy = getStringY(hoverPos.stringIndex); + const midi = tuning.strings[hoverPos.stringIndex] + hoverPos.fret; + + // Convert to percentages to handle SVG scaling + const left = (cx / width) * 100; + const top = (cy / height) * 100; + + return ( +
+ {getNoteDetails(midi + displayTranspose).scientific} + {/* CSS Arrow */} +
+
+ ); + })()}
); } diff --git a/src/components/PianoKeys.tsx b/src/components/PianoKeys.tsx index 34a9591..9ac4c11 100644 --- a/src/components/PianoKeys.tsx +++ b/src/components/PianoKeys.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useMemo } from 'react'; +import { getNoteDetails } from '../music/NoteUtils'; // Constants const WHITE_KEY_WIDTH_PX = 40; @@ -11,6 +12,9 @@ interface PianoKeysProps { markedNotes?: number[]; // Notes to highlight (e.g. hints) interactive?: boolean; onPlayNote?: (midi: number) => void; + onHover?: (midi: number | null) => void; + showTooltips?: boolean; + displayTranspose?: number; height?: number; } @@ -20,9 +24,13 @@ export function PianoKeys({ markedNotes = [], interactive = true, onPlayNote, + onHover, + showTooltips = false, + displayTranspose = 0, height = 160 }: PianoKeysProps) { const [viewMode, setViewMode] = useState<'full' | 'zoomed'>('zoomed'); + const [hoverMidi, setHoverMidi] = useState(null); const scrollRef = useRef(null); // Helpers to determine key type and position @@ -203,13 +211,31 @@ export function PianoKeys({ let fill = k.isBlack ? '#222' : '#fff'; if (isMarked) { fill = k.isBlack ? '#d32f2f' : '#ffcdd2'; // Red-ish for marked + } else if (interactive && hoverMidi === k.midi) { + fill = k.isBlack ? '#444' : '#eee'; // Subtle hover } const stroke = '#000'; const rectHeight = k.isBlack ? BLACK_KEY_HEIGHT_PERCENT * 100 : 100; return ( - interactive && onPlayNote?.(k.midi)} style={{ cursor: interactive ? 'pointer' : 'default' }}> + interactive && onPlayNote?.(k.midi)} + onMouseEnter={() => { + if (interactive) { + setHoverMidi(k.midi); + onHover?.(k.midi); + } + }} + onMouseLeave={() => { + if (interactive) { + setHoverMidi(null); + onHover?.(null); + } + }} + style={{ cursor: interactive ? 'pointer' : 'default' }} + > ); })} + + {/* HTML Tooltip Overlay (Prevents distortion) */} + {interactive && showTooltips && hoverMidi && (() => { + const k = keyRects.find(k => k.midi === hoverMidi); + if (!k) return null; + + const left = viewMode === 'full' + ? `${k.x + (k.width / 2)}%` + : `${k.x + (k.width / 2)}px`; + + return ( +
+ {getNoteDetails(hoverMidi + displayTranspose).scientific} + {/* Simple CSS Arrow */} +
+
+ ); + })()}
- + ); } diff --git a/src/components/SheetMusic.tsx b/src/components/SheetMusic.tsx index 2d85709..1337a97 100644 --- a/src/components/SheetMusic.tsx +++ b/src/components/SheetMusic.tsx @@ -12,6 +12,7 @@ interface SheetMusicProps { transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar) keySignature?: string; hideTargetNote?: boolean; + hoverMidi?: number | null; } export const SheetMusic: React.FC = ({ @@ -22,7 +23,8 @@ export const SheetMusic: React.FC = ({ height = 250, // Increased default height for Grand Staff transpose = 12, // Default to +1 octave (Guitar Notation) keySignature = 'C', - hideTargetNote = false + hideTargetNote = false, + hoverMidi = null }) => { const containerRef = useRef(null); @@ -46,7 +48,7 @@ export const SheetMusic: React.FC = ({ }; // --- Measure Calculation --- - const numMeasures = playedMidi ? 2 : 1; + const numMeasures = (playedMidi || hoverMidi) ? 2 : 1; // If 2 measures, split total width. // We want a bit of padding. width is total width. // Let's reserve 10px on Left/Right. @@ -195,15 +197,26 @@ export const SheetMusic: React.FC = ({ voicesToDraw.push({ stave: m1StaveForKey, voice }); } - // --- Render Played Note (Measure 2) --- - if (playedMidi && numMeasures === 2) { - const playedNoteObj = createStaveNote(playedMidi, "w", 'played'); + // --- Render Played Note OR Hover Preview (Measure 2) --- + // If playedMidi exists, it takes precedence. + // If not, we show hoverMidi as a preview (ghost/grey). + const noteToShowMidi = playedMidi ?? (hoverMidi || null); + const isPreview = !playedMidi && hoverMidi; - // Add Played to Measure 2 Stave - const m2StaveForKey = stavesMeasure2[playedNoteObj.clef]; + if (noteToShowMidi && numMeasures === 2) { + // We reuse 'played' logic for creation but handle style manually if preview + const noteObj = createStaveNote(noteToShowMidi, "w", 'played'); + + if (isPreview) { + // Apply grey style for preview + noteObj.note.setStyle({ fillStyle: "#888888", strokeStyle: "#888888" }); + } + + // Add Played/Preview to Measure 2 Stave + const m2StaveForKey = stavesMeasure2[noteObj.clef]; if (m2StaveForKey) { const voice = new Voice({ numBeats: 4, beatValue: 4 }); - voice.addTickables([playedNoteObj.note]); + voice.addTickables([noteObj.note]); new Formatter().joinVoices([voice]).format([voice], measureWidth - 50); voicesToDraw.push({ stave: m2StaveForKey, voice }); } @@ -216,7 +229,7 @@ export const SheetMusic: React.FC = ({ }); - }, [targetMidi, playedMidi, clef, width, height, transpose, keySignature, hideTargetNote]); + }, [targetMidi, playedMidi, hoverMidi, clef, width, height, transpose, keySignature, hideTargetNote]); return
; };