Keyboard Shortcuts

This commit is contained in:
2025-12-25 21:23:31 +01:00
parent 894321f0dc
commit 0c2a84464a
5 changed files with 312 additions and 58 deletions

View File

@@ -110,6 +110,12 @@
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
color: var(--color-text-main);
}
/* Force button colors to ensure dark mode visibility */
button {
color: var(--color-text-main);
}
/* Pitch Monitor Bar */
@@ -341,4 +347,154 @@
color: var(--color-text-main);
border-color: var(--color-text-main);
transform: scale(1.05);
}
/* Help Popup */
.help-popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
}
.help-popup {
background-color: var(--color-surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
max-width: 400px;
width: 90%;
position: relative;
border: 1px solid rgba(128, 128, 128, 0.1);
}
.help-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-sm);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
}
.help-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.shortcut-key {
background-color: var(--color-bg);
border: 1px solid rgba(128, 128, 128, 0.2);
padding: 2px 8px;
border-radius: 4px;
font-family: monospace;
font-weight: bold;
color: var(--color-text-main);
}
.help-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
}
.help-close:hover {
color: var(--color-text-main);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Header Adjustments */
.app-header-left {
display: flex;
flex-direction: column;
}
.app-subtitle {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-muted);
margin-top: -4px;
}
.app-subtitle a {
color: var(--color-primary);
text-decoration: none;
transition: color 0.2s;
}
.app-subtitle a:hover {
color: var(--color-primary-dark);
text-decoration: underline;
}
.header-controls {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.icon-button {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 8px;
border-radius: 50%;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.icon-button:hover {
background-color: rgba(128, 128, 128, 0.1);
color: var(--color-text-main);
}
.game-mode-toggle {
display: flex;
background-color: var(--color-bg);
padding: 2px;
border-radius: 20px;
border: 1px solid rgba(128, 128, 128, 0.1);
}
.toggle-option {
padding: 4px 12px;
border-radius: 16px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
background: transparent;
border: none;
color: var(--color-text-muted);
transition: all 0.2s;
}
.toggle-option.active {
background-color: var(--color-surface);
color: var(--color-primary);
box-shadow: var(--shadow-sm);
}

View File

@@ -15,7 +15,7 @@ import {
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
import { FretboardHint } from './components/FretboardHint';
import { TuningMeter } from './components/TuningMeter';
import { Mic, MicOff, SkipForward, HelpCircle, Volume2 } from 'lucide-react';
import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X } from 'lucide-react';
import './App.css';
import './styles/skip-button.css';
@@ -185,6 +185,47 @@ function App() {
}
}, [pitchData, targetMidi, matchStartTime, generateNewNote, settings.rhythm, restartMetronome, feedbackMessage]);
/* Keyboard Shortcuts */
const [showHelp, setShowHelp] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
// prevent default for space to stop scrolling
if (e.code === 'Space') {
e.preventDefault();
}
switch (e.key.toLowerCase()) {
case 'h':
setSettings(s => ({ ...s, showHint: !s.showHint }));
break;
case 'z':
setSettings(s => ({ ...s, zenMode: !s.zenMode }));
break;
case 'r': // Replay
case 'p': // Play
playNote(targetMidi, 1.0);
break;
}
// Check non-character keys via code to avoid layout issues for function keys
if (e.code === 'Space' || e.code === 'Enter') {
generateNewNote();
}
if (e.code === 'Escape') {
if (showHelp) setShowHelp(false);
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [settings.zenMode, showHelp, generateNewNote, playNote, targetMidi]);
// Hint text construction
const hintPositions = useMemo(() => {
if (!settings.showHint) return [];
@@ -197,7 +238,37 @@ function App() {
<div className={`app-container ${settings.zenMode ? 'zen-mode' : ''}`}>
{!settings.zenMode && (
<header className="app-header">
<div className="logo">Sheet music trainer</div>
<div className="app-header-left">
<div className="logo">Sheet music trainer</div>
<div className="app-subtitle">
<a href="http://jensmohrmann.de" target="_blank" rel="noopener noreferrer">jensmohrmann.de</a>
</div>
</div>
<div className="header-controls">
<div className="game-mode-toggle">
<button
className={`toggle-option ${settings.gameMode === 'sight_reading' ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, gameMode: 'sight_reading' }))}
>
Sight Reading
</button>
<button
className={`toggle-option ${settings.gameMode === 'ear_training' ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, gameMode: 'ear_training' }))}
>
Ear Training
</button>
</div>
<button
className="icon-button"
onClick={() => setShowHelp(true)}
title="Shortcuts Help"
>
<HelpCircle size={24} />
</button>
</div>
</header>
)}
@@ -257,21 +328,15 @@ function App() {
<button
className={`hint-button ${settings.showHint ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
title="Keyboard Shortcut: H"
>
<HelpCircle size={18} />
{settings.showHint ? "Hide Hint" : "Show Hint"}
</button>
<button className="skip-button" onClick={generateNewNote}>
<button className="skip-button" onClick={generateNewNote} title="Keyboard Shortcut: Space">
<SkipForward size={18} />
Skip Note
</button>
<button
className="zen-button"
onClick={() => setSettings(s => ({ ...s, zenMode: true }))}
title="Zen Mode"
>
<span>Zen</span>
</button>
</div>
)}
</div>
@@ -316,16 +381,45 @@ function App() {
)
}
{
settings.zenMode && (
<button
className="exit-zen-button"
onClick={() => setSettings(s => ({ ...s, zenMode: false }))}
>
Exit Zen Mode
</button>
)
}
{/* Floating Zen Mode Button (Toggle) */}
<button
className="exit-zen-button"
onClick={() => setSettings(s => ({ ...s, zenMode: !s.zenMode }))}
title="Keyboard Shortcut: Z"
>
{settings.zenMode ? "Exit Zen Mode" : "Zen Mode"}
</button>
{/* Help Popup */}
{showHelp && (
<div className="help-popup-overlay" onClick={() => setShowHelp(false)}>
<div className="help-popup" onClick={e => e.stopPropagation()}>
<button className="help-close" onClick={() => setShowHelp(false)}><X size={20} /></button>
<h2 style={{ marginTop: 0, marginBottom: '24px' }}>Keyboard Shortcuts</h2>
<div className="help-item">
<span>Skip Note</span>
<span className="shortcut-key">Space</span>
</div>
<div className="help-item">
<span>Toggle Hint</span>
<span className="shortcut-key">H</span>
</div>
<div className="help-item">
<span>Toggle Zen Mode</span>
<span className="shortcut-key">Z</span>
</div>
<div className="help-item">
<span>Replay Note</span>
<span className="shortcut-key">R</span>
</div>
<div className="help-item">
<span>Close Help / Exit Zen</span>
<span className="shortcut-key">Esc</span>
</div>
</div>
</div>
)}
</div >
);
}

View File

@@ -152,27 +152,7 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
</select>
</div>
<div className="control-group" style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '12px', marginTop: '12px', width: '100%' }}>
<label className="control-label" style={{ marginBottom: '8px' }}>
<span>Game Mode</span>
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className={`control-button ${settings.gameMode === 'sight_reading' ? 'active' : ''}`}
onClick={() => onUpdateSettings({ ...settings, gameMode: 'sight_reading' })}
style={{ flex: 1 }}
>
Sight Reading
</button>
<button
className={`control-button ${settings.gameMode === 'ear_training' ? 'active' : ''}`}
onClick={() => onUpdateSettings({ ...settings, gameMode: 'ear_training' })}
style={{ flex: 1 }}
>
Ear Training
</button>
</div>
</div>
{/* Rhythm Controls */}
<div className="control-group rhythm-group" style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '12px', marginTop: '12px', width: '100%' }}>

View File

@@ -36,6 +36,14 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
renderer.resize(width, height);
const context = renderer.getContext();
// IMPORTANT: Set global styles for the context to ensure everything draws with correct color
// VexFlow uses context properties for default colors.
// We can't easily extract CSS var value here without getComputedStyle, which is expensive inside render.
// However, VexFlow SVGContext just adds attributes.
// We can set fillStyle/strokeStyle to the var string.
context.setFillStyle("var(--color-text-main)");
context.setStrokeStyle("var(--color-text-main)");
// --- Helper: Decide which clef a note belongs to in Grand Staff ---
// For Grand Staff: usually Split at Middle C (C4 / Midi 60).
// >= 60 -> Treble, < 60 -> Bass.
@@ -45,6 +53,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
let staves: Record<string, Stave> = {};
if (clef === 'grand') {
// Create Treble Stave
const topStave = new Stave(20, 40, width - 30);
@@ -97,8 +106,15 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
clef: noteClef as 'treble' | 'bass'
});
// Set styles for all notes to use CSS variables if not colored specifically
staveNote.setStyle({ fillStyle: "var(--color-text-main)", strokeStyle: "var(--color-text-main)" });
if (data.accidental) {
staveNote.addModifier(new Accidental(data.accidental));
const accidental = new Accidental(data.accidental);
// Ensure accidentals also use the color
// VexFlow 4 might not inherit automatically in all contexts, but good to be safe.
// However, StaveNote.setStyle usually propagates.
staveNote.addModifier(accidental);
}
if (type === 'played') {
@@ -131,7 +147,14 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
});
// Add a Question Mark Annotation
ghost.addModifier(new Annotation("?").setVerticalJustification(Annotation.VerticalJustify.CENTER));
const annotation = new Annotation("?");
annotation.setVerticalJustification(Annotation.VerticalJustify.CENTER);
// Annotations don't natively support setStyle in older VexFlow, but let's see.
// In VexFlow 4, Font settings are different.
// We can try to rely on current contexts or just standard fill.
// Actually, context fillStyle affects it.
ghost.addModifier(annotation);
targetObj = { note: ghost, clef: noteClef };
} else {

View File

@@ -11,9 +11,10 @@
--color-primary-dark: hsl(var(--hue-primary), 60%, 40%);
--color-text-main: hsl(var(--hue-text), 20%, 20%);
--color-text-muted: hsl(var(--hue-text), 10%, 60%);
--color-success: hsl(var(--hue-success), 60%, 45%);
--color-error: hsl(var(--hue-error), 70%, 55%);
--color-warning: hsl(35, 90%, 50%);
/* Spacing */
--spacing-xs: 4px;
@@ -29,11 +30,11 @@
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.05), 0 4px 6px rgba(0,0,0,0.02);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.05), 0 4px 6px rgba(0, 0, 0, 0.02);
/* Radius */
--radius-sm: 6px;
@@ -51,16 +52,16 @@
}
body {
margin: 0;
font-family: var(--font-family);
background-color: var(--color-bg);
color: var(--color-text-main);
transition: background-color 0.3s ease, color 0.3s ease;
line-height: 1.5;
margin: 0;
font-family: var(--font-family);
background-color: var(--color-bg);
color: var(--color-text-main);
transition: background-color 0.3s ease, color 0.3s ease;
line-height: 1.5;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
}
height: 100vh;
display: flex;
flex-direction: column;
}