mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 13:53:55 +01:00
Move Setup panel above notation; host note filter inside it; fix key signature semantics
- Phrase mode: Setup panel now sits between the title row and the sheet music (was below the transport). It hosts the material options plus the shared TargetNoteControls (key/strings/fret window). - TargetNoteControls: in Phrase Mode the Key section binds to the generator key (tonic+mode always applied, no enable toggle) and the display-signature sync checkbox is hidden — phrase notation takes its signature from the score's key, so a display-signature select there would be a no-op. - Footer filter button hidden in Phrase Mode (lives in Setup now); still present in single-note modes. - Remove leftover '&& null' placeholder blocks.
This commit is contained in:
@@ -1040,9 +1040,9 @@ button {
|
||||
}
|
||||
|
||||
.phrase-setup {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
|
||||
padding-top: 12px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.phrase-setup summary {
|
||||
|
||||
@@ -30,17 +30,23 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const inPhraseMode = settings.gameMode === 'phrase';
|
||||
|
||||
const pf = getPracticeFilter(settings);
|
||||
const activeFilterCount =
|
||||
(pf.keyEnabled ? 1 : 0) +
|
||||
(pf.strings.length > 0 ? 1 : 0) +
|
||||
(pf.fretWindowEnabled ? 1 : 0);
|
||||
|
||||
const inPhraseMode = settings.gameMode === 'phrase';
|
||||
// Phrase Mode hosts the filter inside its Setup panel (above the
|
||||
// notation) — no footer filter button there.
|
||||
const showFilterButton = !inPhraseMode;
|
||||
const shouldShowFilters = showFilterButton && filtersOpen;
|
||||
|
||||
return (
|
||||
<div className="controls-container">
|
||||
{/* Note filter: collapsed behind a button — visible only when needed */}
|
||||
{showFilterButton && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '8px' }}>
|
||||
<button
|
||||
className={`hint-button ${filtersOpen ? 'active' : ''}`}
|
||||
@@ -59,7 +65,8 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{filtersOpen && <TargetNoteControls />}
|
||||
)}
|
||||
{shouldShowFilters && <TargetNoteControls />}
|
||||
|
||||
{/* Bottom Section: Tools Grid (2 Columns now) */}
|
||||
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getNoteDetails } from '../music/NoteUtils';
|
||||
import { EXERCISES, loadExercise } from '../exercises/library';
|
||||
import { parseAbc } from '../exercises/abcParser';
|
||||
import { PhraseSheetMusic } from './PhraseSheetMusic';
|
||||
import { TargetNoteControls } from './TargetNoteControls';
|
||||
import { Fretboard } from './Fretboard';
|
||||
import { PianoKeys } from './PianoKeys';
|
||||
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
|
||||
@@ -435,118 +436,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="sheet-music-container">
|
||||
{scoreOk ? (
|
||||
<PhraseSheetMusic
|
||||
score={trainer.activeScore}
|
||||
currentIdx={trainer.currentIdx}
|
||||
statuses={trainer.statuses}
|
||||
clef={activeClef}
|
||||
transpose={activeTranspose}
|
||||
width={staffWidth}
|
||||
theme={settings.theme}
|
||||
/>
|
||||
) : (
|
||||
<div className="phrase-error">{materialError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trainer.error && <div className="error-message">{trainer.error}</div>}
|
||||
{importError && <div className="error-message">{importError}</div>}
|
||||
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
|
||||
|
||||
<div className="feedback-area">
|
||||
{trainer.phase === 'countIn' && (
|
||||
<span
|
||||
className="phrase-countin"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
minWidth: '20px',
|
||||
padding: '1px 7px',
|
||||
marginRight: '8px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '11px',
|
||||
opacity: 0.75,
|
||||
background: 'color-mix(in srgb, var(--color-text-main) 10%, transparent)',
|
||||
}}
|
||||
aria-label={`Count-in: ${trainer.countInLeft} beats left`}
|
||||
>{trainer.countInLeft}</span>
|
||||
)}
|
||||
<div className="instruction-text phrase-status">{statusText}</div>
|
||||
</div>
|
||||
|
||||
{/* Transport — reuses the single-note button styles/behavior */}
|
||||
<div className="action-row phrase-transport">
|
||||
<button className="skip-button" onClick={startAction} title="Start / Pause (Space)">
|
||||
{trainer.phase === 'playing' || trainer.phase === 'countIn' ? <Pause size={18} /> : <Play size={18} />}
|
||||
{startLabel}
|
||||
</button>
|
||||
{!settings.zenMode && (
|
||||
<>
|
||||
<button
|
||||
className="hint-button"
|
||||
onClick={trainer.previewToggle}
|
||||
title="Preview the phrase (P)"
|
||||
>
|
||||
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />}
|
||||
{trainer.phase === 'preview' ? 'Stop' : 'Preview'}
|
||||
</button>
|
||||
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
|
||||
<RotateCcw size={18} />
|
||||
Retry
|
||||
</button>
|
||||
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
|
||||
<button className="hint-button" onClick={nextAction} title="New melody / next bars (N)">
|
||||
<SkipForward size={18} />
|
||||
{phrase.material === 'melody' ? 'New' : 'Next'}
|
||||
</button>
|
||||
)}
|
||||
{phrase.pace === 'step' && (
|
||||
<button className="hint-button" onClick={trainer.skip} title="Skip this note (S)">
|
||||
<SkipForward size={18} />
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Same virtual-instrument / hint toggles as single-note mode */}
|
||||
{trainer.phase === 'done' && settings.zenMode && (
|
||||
<>
|
||||
<button className="hint-button" onClick={trainer.retry} title="Retry (R)">
|
||||
<RotateCcw size={18} />
|
||||
Retry
|
||||
</button>
|
||||
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
|
||||
<button className="hint-button" onClick={nextAction} title="New / Next (N)">
|
||||
<SkipForward size={18} />
|
||||
{phrase.material === 'melody' ? 'New' : 'Next'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!settings.zenMode && currentInstrumentDef.showTuning && (
|
||||
<button
|
||||
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
||||
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
|
||||
>
|
||||
<Guitar size={18} />
|
||||
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
|
||||
</button>
|
||||
)}
|
||||
{!settings.zenMode && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Setup panel */}
|
||||
{/* Setup panel — above the notation, holds material options
|
||||
plus the shared note filter (key/strings/fret window) */}
|
||||
{!settings.zenMode && (
|
||||
<details className="phrase-setup">
|
||||
<summary><ChevronDown size={16} /> Setup</summary>
|
||||
@@ -562,8 +453,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && null}
|
||||
|
||||
{phrase.material === 'melody' && (
|
||||
<>
|
||||
<label>Bars
|
||||
@@ -778,8 +667,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
</label>
|
||||
)}
|
||||
|
||||
{fretted && null}
|
||||
|
||||
{/* Pace lives in the metronome widget now:
|
||||
'Sync to exercise' checked = tempo pace,
|
||||
unchecked = at-your-pace (step). */}
|
||||
@@ -815,9 +702,122 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
Matching checks pitch, not fingering — several fretboard positions produce the same pitch.
|
||||
For repeated identical notes, briefly release between notes so each attack is detected.
|
||||
</p>
|
||||
<TargetNoteControls />
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="sheet-music-container">
|
||||
{scoreOk ? (
|
||||
<PhraseSheetMusic
|
||||
score={trainer.activeScore}
|
||||
currentIdx={trainer.currentIdx}
|
||||
statuses={trainer.statuses}
|
||||
clef={activeClef}
|
||||
transpose={activeTranspose}
|
||||
width={staffWidth}
|
||||
theme={settings.theme}
|
||||
/>
|
||||
) : (
|
||||
<div className="phrase-error">{materialError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trainer.error && <div className="error-message">{trainer.error}</div>}
|
||||
{importError && <div className="error-message">{importError}</div>}
|
||||
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
|
||||
|
||||
<div className="feedback-area">
|
||||
{trainer.phase === 'countIn' && (
|
||||
<span
|
||||
className="phrase-countin"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
minWidth: '20px',
|
||||
padding: '1px 7px',
|
||||
marginRight: '8px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '11px',
|
||||
opacity: 0.75,
|
||||
background: 'color-mix(in srgb, var(--color-text-main) 10%, transparent)',
|
||||
}}
|
||||
aria-label={`Count-in: ${trainer.countInLeft} beats left`}
|
||||
>{trainer.countInLeft}</span>
|
||||
)}
|
||||
<div className="instruction-text phrase-status">{statusText}</div>
|
||||
</div>
|
||||
|
||||
{/* Transport — reuses the single-note button styles/behavior */}
|
||||
<div className="action-row phrase-transport">
|
||||
<button className="skip-button" onClick={startAction} title="Start / Pause (Space)">
|
||||
{trainer.phase === 'playing' || trainer.phase === 'countIn' ? <Pause size={18} /> : <Play size={18} />}
|
||||
{startLabel}
|
||||
</button>
|
||||
{!settings.zenMode && (
|
||||
<>
|
||||
<button
|
||||
className="hint-button"
|
||||
onClick={trainer.previewToggle}
|
||||
title="Preview the phrase (P)"
|
||||
>
|
||||
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />}
|
||||
{trainer.phase === 'preview' ? 'Stop' : 'Preview'}
|
||||
</button>
|
||||
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
|
||||
<RotateCcw size={18} />
|
||||
Retry
|
||||
</button>
|
||||
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
|
||||
<button className="hint-button" onClick={nextAction} title="New melody / next bars (N)">
|
||||
<SkipForward size={18} />
|
||||
{phrase.material === 'melody' ? 'New' : 'Next'}
|
||||
</button>
|
||||
)}
|
||||
{phrase.pace === 'step' && (
|
||||
<button className="hint-button" onClick={trainer.skip} title="Skip this note (S)">
|
||||
<SkipForward size={18} />
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Same virtual-instrument / hint toggles as single-note mode */}
|
||||
{trainer.phase === 'done' && settings.zenMode && (
|
||||
<>
|
||||
<button className="hint-button" onClick={trainer.retry} title="Retry (R)">
|
||||
<RotateCcw size={18} />
|
||||
Retry
|
||||
</button>
|
||||
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
|
||||
<button className="hint-button" onClick={nextAction} title="New / Next (N)">
|
||||
<SkipForward size={18} />
|
||||
{phrase.material === 'melody' ? 'New' : 'Next'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!settings.zenMode && currentInstrumentDef.showTuning && (
|
||||
<button
|
||||
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
||||
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
|
||||
>
|
||||
<Guitar size={18} />
|
||||
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
|
||||
</button>
|
||||
)}
|
||||
{!settings.zenMode && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Hint instruments */}
|
||||
{(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && (
|
||||
<div className="hint-card">
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
* - Strings: pick which strings contribute target notes (fretted instruments).
|
||||
* - Fret window: restrict target notes to a fret range (fretted instruments).
|
||||
*
|
||||
* In Phrase Mode the same controls bind to the shared practice filter
|
||||
* (mirrored into phrase settings), so state stays consistent across modes.
|
||||
* In Phrase Mode the Key section binds to the generator key (tonic+mode are
|
||||
* always applied; no toggle), and the display-signature sync checkbox is
|
||||
* hidden (phrase notation takes its signature from the score's key).
|
||||
* Rendered inside the per-mode Setup panel above the notation.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react';
|
||||
@@ -46,6 +48,8 @@ const MINOR_TONIC_TO_SIGNATURE: Record<string, string> = {
|
||||
export const TargetNoteControls: React.FC = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const pf = getPracticeFilter(settings);
|
||||
// Phrase Mode: key tonic/mode are the generator key — always in effect.
|
||||
const inPhraseMode = settings.gameMode === 'phrase';
|
||||
|
||||
const setFilter = (updates: Partial<PracticeFilter>) => {
|
||||
updateSettings(s => {
|
||||
@@ -81,7 +85,8 @@ export const TargetNoteControls: React.FC = () => {
|
||||
|
||||
const allSelected = pf.strings.length === 0;
|
||||
|
||||
const anyActive = pf.keyEnabled || pf.fretWindowEnabled || pf.strings.length > 0;
|
||||
const anyActive =
|
||||
(!inPhraseMode && pf.keyEnabled) || pf.fretWindowEnabled || pf.strings.length > 0;
|
||||
return (
|
||||
<div className="practice-filter-grid" style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px', gridColumn: '1 / -1' }}>
|
||||
{/* Section header: unified with Tuner/Metronome style */}
|
||||
@@ -101,35 +106,6 @@ export const TargetNoteControls: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display key signature (sheet notation) */}
|
||||
<div className="control-group">
|
||||
<label className="control-label">
|
||||
<span>Key signature (display)</span>
|
||||
</label>
|
||||
<select
|
||||
value={settings.keySignature}
|
||||
onChange={e => updateSettings(s => ({ ...s, keySignature: e.target.value }))}
|
||||
className="control-select"
|
||||
aria-label="Display key signature"
|
||||
>
|
||||
<optgroup label="Major Keys">
|
||||
<option value="C">C Major</option>
|
||||
<option value="G">G Major</option>
|
||||
<option value="D">D Major</option>
|
||||
<option value="A">A Major</option>
|
||||
<option value="E">E Major</option>
|
||||
<option value="F">F Major</option>
|
||||
<option value="Bb">Bb Major</option>
|
||||
<option value="Eb">Eb Major</option>
|
||||
</optgroup>
|
||||
<optgroup label="Minor Keys">
|
||||
<option value="Am">A Minor</option>
|
||||
<option value="Em">E Minor</option>
|
||||
<option value="Dm">D Minor</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Key filter */}
|
||||
<div className="control-group">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -137,15 +113,17 @@ export const TargetNoteControls: React.FC = () => {
|
||||
<Key size={18} />
|
||||
<span>Key</span>
|
||||
</label>
|
||||
<button
|
||||
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
|
||||
onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })}
|
||||
title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'}
|
||||
>
|
||||
<div className="switch-thumb" />
|
||||
</button>
|
||||
{!inPhraseMode && (
|
||||
<button
|
||||
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
|
||||
onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })}
|
||||
title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'}
|
||||
>
|
||||
<div className="switch-thumb" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{pf.keyEnabled && (
|
||||
{(pf.keyEnabled || inPhraseMode) && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||
<select
|
||||
@@ -169,23 +147,25 @@ export const TargetNoteControls: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.keyFollowsTarget ?? false}
|
||||
onChange={e => updateSettings(s => {
|
||||
const next = { ...s, keyFollowsTarget: e.target.checked };
|
||||
// When enabling sync, snap the display signature now.
|
||||
if (e.target.checked) {
|
||||
const table = pf.keyMode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE;
|
||||
const sig = table[pf.keyTonic];
|
||||
if (sig) next.keySignature = sig;
|
||||
}
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
Sync display key signature with target key
|
||||
</label>
|
||||
{!inPhraseMode && (
|
||||
<label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.keyFollowsTarget ?? false}
|
||||
onChange={e => updateSettings(s => {
|
||||
const next = { ...s, keyFollowsTarget: e.target.checked };
|
||||
// When enabling sync, snap the display signature now.
|
||||
if (e.target.checked) {
|
||||
const table = pf.keyMode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE;
|
||||
const sig = table[pf.keyTonic];
|
||||
if (sig) next.keySignature = sig;
|
||||
}
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
Sync display key signature with target key
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user