mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-09-02 09:34:33 +02:00
Add hints on mouseover on virtual instrument
This commit is contained in:
@@ -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({
|
||||
<div className="fretboard-container" style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
overflow: 'visible', // Allow tooltips to pop out
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -185,6 +196,27 @@ export function Fretboard({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hover Highlight (Ghost Dot) */}
|
||||
{interactive && hoverPos && (
|
||||
(() => {
|
||||
const cx = getNoteX(hoverPos.fret);
|
||||
const cy = getStringY(hoverPos.stringIndex);
|
||||
|
||||
return (
|
||||
<g style={{ pointerEvents: 'none' }}>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={8}
|
||||
fill="transparent"
|
||||
stroke="#888"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<rect
|
||||
@@ -217,7 +250,14 @@ export function Fretboard({
|
||||
fill="transparent"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<title>String {stringIndex + 1}, Fret {fret}</title>
|
||||
@@ -226,6 +266,48 @@ export function Fretboard({
|
||||
});
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* 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 (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `${left}%`,
|
||||
top: `${top}%`,
|
||||
transform: 'translate(-50%, -100%) translateY(-15px)', // Center X, Move Y up.
|
||||
background: '#333',
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 10,
|
||||
opacity: 0.9,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{getNoteDetails(midi + displayTranspose).scientific}
|
||||
{/* CSS Arrow */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: '6px solid #333'
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<g key={k.midi} onClick={() => interactive && onPlayNote?.(k.midi)} style={{ cursor: interactive ? 'pointer' : 'default' }}>
|
||||
<g
|
||||
key={k.midi}
|
||||
onClick={() => 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' }}
|
||||
>
|
||||
<rect
|
||||
x={k.x}
|
||||
y={0}
|
||||
@@ -255,9 +281,52 @@ export function PianoKeys({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
key={`tooltip-${k.midi}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: left,
|
||||
bottom: '40px', // Positioned above labels
|
||||
transform: 'translateX(-50%)',
|
||||
background: '#333',
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 10,
|
||||
opacity: 0.9,
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{getNoteDetails(hoverMidi + displayTranspose).scientific}
|
||||
{/* Simple CSS Arrow */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: '6px solid #333'
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SheetMusicProps> = ({
|
||||
@@ -22,7 +23,8 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
||||
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<HTMLDivElement>(null);
|
||||
|
||||
@@ -46,7 +48,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
||||
};
|
||||
|
||||
// --- 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<SheetMusicProps> = ({
|
||||
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<SheetMusicProps> = ({
|
||||
});
|
||||
|
||||
|
||||
}, [targetMidi, playedMidi, clef, width, height, transpose, keySignature, hideTargetNote]);
|
||||
}, [targetMidi, playedMidi, hoverMidi, clef, width, height, transpose, keySignature, hideTargetNote]);
|
||||
|
||||
return <div ref={containerRef} className="sheet-music-container" />;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user