mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-09-02 09:34:33 +02:00
Keyboard Shortcuts
This commit is contained in:
156
src/App.css
156
src/App.css
@@ -110,6 +110,12 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.8rem;
|
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 */
|
/* Pitch Monitor Bar */
|
||||||
@@ -342,3 +348,153 @@
|
|||||||
border-color: var(--color-text-main);
|
border-color: var(--color-text-main);
|
||||||
transform: scale(1.05);
|
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);
|
||||||
|
}
|
||||||
124
src/App.tsx
124
src/App.tsx
@@ -15,7 +15,7 @@ import {
|
|||||||
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
|
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
|
||||||
import { FretboardHint } from './components/FretboardHint';
|
import { FretboardHint } from './components/FretboardHint';
|
||||||
import { TuningMeter } from './components/TuningMeter';
|
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 './App.css';
|
||||||
import './styles/skip-button.css';
|
import './styles/skip-button.css';
|
||||||
|
|
||||||
@@ -185,6 +185,47 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [pitchData, targetMidi, matchStartTime, generateNewNote, settings.rhythm, restartMetronome, feedbackMessage]);
|
}, [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
|
// Hint text construction
|
||||||
const hintPositions = useMemo(() => {
|
const hintPositions = useMemo(() => {
|
||||||
if (!settings.showHint) return [];
|
if (!settings.showHint) return [];
|
||||||
@@ -197,7 +238,37 @@ function App() {
|
|||||||
<div className={`app-container ${settings.zenMode ? 'zen-mode' : ''}`}>
|
<div className={`app-container ${settings.zenMode ? 'zen-mode' : ''}`}>
|
||||||
{!settings.zenMode && (
|
{!settings.zenMode && (
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
|
<div className="app-header-left">
|
||||||
<div className="logo">Sheet music trainer</div>
|
<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>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -257,21 +328,15 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
className={`hint-button ${settings.showHint ? 'active' : ''}`}
|
className={`hint-button ${settings.showHint ? 'active' : ''}`}
|
||||||
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
||||||
|
title="Keyboard Shortcut: H"
|
||||||
>
|
>
|
||||||
<HelpCircle size={18} />
|
<HelpCircle size={18} />
|
||||||
{settings.showHint ? "Hide Hint" : "Show Hint"}
|
{settings.showHint ? "Hide Hint" : "Show Hint"}
|
||||||
</button>
|
</button>
|
||||||
<button className="skip-button" onClick={generateNewNote}>
|
<button className="skip-button" onClick={generateNewNote} title="Keyboard Shortcut: Space">
|
||||||
<SkipForward size={18} />
|
<SkipForward size={18} />
|
||||||
Skip Note
|
Skip Note
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="zen-button"
|
|
||||||
onClick={() => setSettings(s => ({ ...s, zenMode: true }))}
|
|
||||||
title="Zen Mode"
|
|
||||||
>
|
|
||||||
<span>Zen</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -316,16 +381,45 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{/* Floating Zen Mode Button (Toggle) */}
|
||||||
settings.zenMode && (
|
|
||||||
<button
|
<button
|
||||||
className="exit-zen-button"
|
className="exit-zen-button"
|
||||||
onClick={() => setSettings(s => ({ ...s, zenMode: false }))}
|
onClick={() => setSettings(s => ({ ...s, zenMode: !s.zenMode }))}
|
||||||
|
title="Keyboard Shortcut: Z"
|
||||||
>
|
>
|
||||||
Exit Zen Mode
|
{settings.zenMode ? "Exit Zen Mode" : "Zen Mode"}
|
||||||
</button>
|
</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 >
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,27 +152,7 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 */}
|
{/* Rhythm Controls */}
|
||||||
<div className="control-group rhythm-group" style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '12px', marginTop: '12px', width: '100%' }}>
|
<div className="control-group rhythm-group" style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '12px', marginTop: '12px', width: '100%' }}>
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
renderer.resize(width, height);
|
renderer.resize(width, height);
|
||||||
const context = renderer.getContext();
|
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 ---
|
// --- Helper: Decide which clef a note belongs to in Grand Staff ---
|
||||||
// For Grand Staff: usually Split at Middle C (C4 / Midi 60).
|
// For Grand Staff: usually Split at Middle C (C4 / Midi 60).
|
||||||
// >= 60 -> Treble, < 60 -> Bass.
|
// >= 60 -> Treble, < 60 -> Bass.
|
||||||
@@ -45,6 +53,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
|
|
||||||
let staves: Record<string, Stave> = {};
|
let staves: Record<string, Stave> = {};
|
||||||
|
|
||||||
|
|
||||||
if (clef === 'grand') {
|
if (clef === 'grand') {
|
||||||
// Create Treble Stave
|
// Create Treble Stave
|
||||||
const topStave = new Stave(20, 40, width - 30);
|
const topStave = new Stave(20, 40, width - 30);
|
||||||
@@ -97,8 +106,15 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
clef: noteClef as 'treble' | 'bass'
|
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) {
|
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') {
|
if (type === 'played') {
|
||||||
@@ -131,7 +147,14 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add a Question Mark Annotation
|
// 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 };
|
targetObj = { note: ghost, clef: noteClef };
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
--color-success: hsl(var(--hue-success), 60%, 45%);
|
--color-success: hsl(var(--hue-success), 60%, 45%);
|
||||||
--color-error: hsl(var(--hue-error), 70%, 55%);
|
--color-error: hsl(var(--hue-error), 70%, 55%);
|
||||||
|
--color-warning: hsl(35, 90%, 50%);
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing */
|
||||||
--spacing-xs: 4px;
|
--spacing-xs: 4px;
|
||||||
@@ -31,9 +32,9 @@
|
|||||||
--font-size-2xl: 2rem;
|
--font-size-2xl: 2rem;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
--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-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-lg: 0 10px 15px rgba(0, 0, 0, 0.05), 0 4px 6px rgba(0, 0, 0, 0.02);
|
||||||
|
|
||||||
/* Radius */
|
/* Radius */
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
|
|||||||
Reference in New Issue
Block a user