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:
12
src/App.tsx
12
src/App.tsx
@@ -71,6 +71,7 @@ function App() {
|
|||||||
const [virtualNote, setVirtualNote] = useState<number | null>(null);
|
const [virtualNote, setVirtualNote] = useState<number | null>(null);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
|
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
|
||||||
|
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null);
|
||||||
const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const currentTuning = TUNINGS[settings.tuningId];
|
const currentTuning = TUNINGS[settings.tuningId];
|
||||||
@@ -450,6 +451,7 @@ function App() {
|
|||||||
width={Math.min(window.innerWidth - 40, 500)}
|
width={Math.min(window.innerWidth - 40, 500)}
|
||||||
height={activeClef === 'grand' ? 260 : 180}
|
height={activeClef === 'grand' ? 260 : 180}
|
||||||
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
|
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
|
||||||
|
hoverMidi={settings.showHint ? hoveredMidi : null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -491,16 +493,22 @@ function App() {
|
|||||||
minMidi={36} // C2
|
minMidi={36} // C2
|
||||||
maxMidi={84} // C6
|
maxMidi={84} // C6
|
||||||
markedNotes={settings.showHint ? [targetMidi] : []}
|
markedNotes={settings.showHint ? [targetMidi] : []}
|
||||||
interactive={settings.showFretboard}
|
interactive={settings.showFretboard || settings.showHint}
|
||||||
|
showTooltips={settings.showHint}
|
||||||
|
displayTranspose={activeTranspose}
|
||||||
onPlayNote={handleVirtualInstrumentPlay}
|
onPlayNote={handleVirtualInstrumentPlay}
|
||||||
|
onHover={setHoveredMidi}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
currentTuning && (
|
currentTuning && (
|
||||||
<Fretboard
|
<Fretboard
|
||||||
tuning={currentTuning}
|
tuning={currentTuning}
|
||||||
positions={hintPositions}
|
positions={hintPositions}
|
||||||
interactive={settings.showFretboard}
|
interactive={settings.showFretboard || settings.showHint}
|
||||||
|
showTooltips={settings.showHint}
|
||||||
|
displayTranspose={activeTranspose}
|
||||||
onPlayNote={handleVirtualInstrumentPlay}
|
onPlayNote={handleVirtualInstrumentPlay}
|
||||||
|
onHover={setHoveredMidi}
|
||||||
showHints={settings.showHint}
|
showHints={settings.showHint}
|
||||||
maxFrets={15}
|
maxFrets={15}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import type { Tuning, FretPosition } from '../music/Tunings';
|
import type { Tuning, FretPosition } from '../music/Tunings';
|
||||||
|
import { getNoteDetails } from '../music/NoteUtils';
|
||||||
|
|
||||||
interface FretboardProps {
|
interface FretboardProps {
|
||||||
tuning: Tuning;
|
tuning: Tuning;
|
||||||
@@ -6,7 +8,10 @@ interface FretboardProps {
|
|||||||
maxFrets?: number;
|
maxFrets?: number;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
showHints?: boolean;
|
showHints?: boolean;
|
||||||
|
showTooltips?: boolean;
|
||||||
|
displayTranspose?: number;
|
||||||
onPlayNote?: (midi: number) => void;
|
onPlayNote?: (midi: number) => void;
|
||||||
|
onHover?: (midi: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Fretboard({
|
export function Fretboard({
|
||||||
@@ -15,8 +20,13 @@ export function Fretboard({
|
|||||||
maxFrets = 15,
|
maxFrets = 15,
|
||||||
interactive = false,
|
interactive = false,
|
||||||
showHints = true,
|
showHints = true,
|
||||||
onPlayNote
|
showTooltips = false,
|
||||||
|
displayTranspose = 0,
|
||||||
|
onPlayNote,
|
||||||
|
onHover
|
||||||
}: FretboardProps) {
|
}: FretboardProps) {
|
||||||
|
const [hoverPos, setHoverPos] = useState<{ stringIndex: number, fret: number } | null>(null);
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
const numStrings = tuning.strings.length;
|
const numStrings = tuning.strings.length;
|
||||||
// Visual params
|
// Visual params
|
||||||
@@ -66,7 +76,8 @@ export function Fretboard({
|
|||||||
<div className="fretboard-container" style={{
|
<div className="fretboard-container" style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
overflow: 'hidden',
|
overflow: 'visible', // Allow tooltips to pop out
|
||||||
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: '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) */}
|
{/* Interaction Overlay (Invisible hit targets) */}
|
||||||
{interactive && tuning.strings.map((_, stringIndex) => {
|
{interactive && tuning.strings.map((_, stringIndex) => {
|
||||||
const y = getStringY(stringIndex);
|
const y = getStringY(stringIndex);
|
||||||
@@ -206,6 +238,7 @@ export function Fretboard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rectWidth = xEnd - xStart;
|
const rectWidth = xEnd - xStart;
|
||||||
|
const midi = tuning.strings[stringIndex] + fret;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<rect
|
<rect
|
||||||
@@ -217,7 +250,14 @@ export function Fretboard({
|
|||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => handleFretClick(stringIndex, fret)}
|
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"
|
className="fret-hit-target"
|
||||||
>
|
>
|
||||||
<title>String {stringIndex + 1}, Fret {fret}</title>
|
<title>String {stringIndex + 1}, Fret {fret}</title>
|
||||||
@@ -226,6 +266,48 @@ export function Fretboard({
|
|||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
</svg>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { getNoteDetails } from '../music/NoteUtils';
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const WHITE_KEY_WIDTH_PX = 40;
|
const WHITE_KEY_WIDTH_PX = 40;
|
||||||
@@ -11,6 +12,9 @@ interface PianoKeysProps {
|
|||||||
markedNotes?: number[]; // Notes to highlight (e.g. hints)
|
markedNotes?: number[]; // Notes to highlight (e.g. hints)
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
onPlayNote?: (midi: number) => void;
|
onPlayNote?: (midi: number) => void;
|
||||||
|
onHover?: (midi: number | null) => void;
|
||||||
|
showTooltips?: boolean;
|
||||||
|
displayTranspose?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,9 +24,13 @@ export function PianoKeys({
|
|||||||
markedNotes = [],
|
markedNotes = [],
|
||||||
interactive = true,
|
interactive = true,
|
||||||
onPlayNote,
|
onPlayNote,
|
||||||
|
onHover,
|
||||||
|
showTooltips = false,
|
||||||
|
displayTranspose = 0,
|
||||||
height = 160
|
height = 160
|
||||||
}: PianoKeysProps) {
|
}: PianoKeysProps) {
|
||||||
const [viewMode, setViewMode] = useState<'full' | 'zoomed'>('zoomed');
|
const [viewMode, setViewMode] = useState<'full' | 'zoomed'>('zoomed');
|
||||||
|
const [hoverMidi, setHoverMidi] = useState<number | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Helpers to determine key type and position
|
// Helpers to determine key type and position
|
||||||
@@ -203,13 +211,31 @@ export function PianoKeys({
|
|||||||
let fill = k.isBlack ? '#222' : '#fff';
|
let fill = k.isBlack ? '#222' : '#fff';
|
||||||
if (isMarked) {
|
if (isMarked) {
|
||||||
fill = k.isBlack ? '#d32f2f' : '#ffcdd2'; // Red-ish for marked
|
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 stroke = '#000';
|
||||||
const rectHeight = k.isBlack ? BLACK_KEY_HEIGHT_PERCENT * 100 : 100;
|
const rectHeight = k.isBlack ? BLACK_KEY_HEIGHT_PERCENT * 100 : 100;
|
||||||
|
|
||||||
return (
|
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
|
<rect
|
||||||
x={k.x}
|
x={k.x}
|
||||||
y={0}
|
y={0}
|
||||||
@@ -255,6 +281,49 @@ export function PianoKeys({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface SheetMusicProps {
|
|||||||
transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar)
|
transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar)
|
||||||
keySignature?: string;
|
keySignature?: string;
|
||||||
hideTargetNote?: boolean;
|
hideTargetNote?: boolean;
|
||||||
|
hoverMidi?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SheetMusic: React.FC<SheetMusicProps> = ({
|
export const SheetMusic: React.FC<SheetMusicProps> = ({
|
||||||
@@ -22,7 +23,8 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
height = 250, // Increased default height for Grand Staff
|
height = 250, // Increased default height for Grand Staff
|
||||||
transpose = 12, // Default to +1 octave (Guitar Notation)
|
transpose = 12, // Default to +1 octave (Guitar Notation)
|
||||||
keySignature = 'C',
|
keySignature = 'C',
|
||||||
hideTargetNote = false
|
hideTargetNote = false,
|
||||||
|
hoverMidi = null
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- Measure Calculation ---
|
// --- Measure Calculation ---
|
||||||
const numMeasures = playedMidi ? 2 : 1;
|
const numMeasures = (playedMidi || hoverMidi) ? 2 : 1;
|
||||||
// If 2 measures, split total width.
|
// If 2 measures, split total width.
|
||||||
// We want a bit of padding. width is total width.
|
// We want a bit of padding. width is total width.
|
||||||
// Let's reserve 10px on Left/Right.
|
// Let's reserve 10px on Left/Right.
|
||||||
@@ -195,15 +197,26 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
voicesToDraw.push({ stave: m1StaveForKey, voice });
|
voicesToDraw.push({ stave: m1StaveForKey, voice });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Render Played Note (Measure 2) ---
|
// --- Render Played Note OR Hover Preview (Measure 2) ---
|
||||||
if (playedMidi && numMeasures === 2) {
|
// If playedMidi exists, it takes precedence.
|
||||||
const playedNoteObj = createStaveNote(playedMidi, "w", 'played');
|
// 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
|
if (noteToShowMidi && numMeasures === 2) {
|
||||||
const m2StaveForKey = stavesMeasure2[playedNoteObj.clef];
|
// 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) {
|
if (m2StaveForKey) {
|
||||||
const voice = new Voice({ numBeats: 4, beatValue: 4 });
|
const voice = new Voice({ numBeats: 4, beatValue: 4 });
|
||||||
voice.addTickables([playedNoteObj.note]);
|
voice.addTickables([noteObj.note]);
|
||||||
new Formatter().joinVoices([voice]).format([voice], measureWidth - 50);
|
new Formatter().joinVoices([voice]).format([voice], measureWidth - 50);
|
||||||
voicesToDraw.push({ stave: m2StaveForKey, voice });
|
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" />;
|
return <div ref={containerRef} className="sheet-music-container" />;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user