fix: proper fullscreen piano (1 octave, big keys) + block native zoom

Fullscreen piano redesign:
- 1 octave with 7 large white keys filling the entire screen
- Gradient-lit keys with cyan press highlight
- Octave navigation buttons (◀ ▶) to shift up/down
- Note labels on each key (C4, D4, etc.)
- Black keys proportionally sized at 58% height
- touch-action: none to prevent any browser interference

Block native browser zoom:
- viewport meta: maximum-scale=1.0, user-scalable=no
- html touch-action: manipulation (prevents double-tap zoom on Safari)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 16:08:10 +01:00
parent 816e7270ed
commit 892195410b
3 changed files with 102 additions and 76 deletions

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Reaktor — MontLab Modular Synth</title> <title>Reaktor — MontLab Modular Synth</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head> </head>

View File

@@ -16,84 +16,80 @@ function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12); return 440 * Math.pow(2, (midi - 69) / 12);
} }
// Fullscreen piano overlay for mobile // Fullscreen piano — 1 octave, big comfortable keys like a real piano app
function FullscreenPiano({ moduleId, octave, onClose }) { function FullscreenPiano({ moduleId, initialOctave, onClose }) {
const activeRef = useRef(new Set()); const [oct, setOct] = useState(initialOctave);
const [activeNotes, setActiveNotes] = useState(new Set());
const playNote = useCallback((semitone) => { const play = useCallback((semitone) => {
const midi = (octave + 1) * 12 + semitone; const midi = (oct + 1) * 12 + semitone;
triggerKeyboard(moduleId, midiToFreq(midi), true); triggerKeyboard(moduleId, midiToFreq(midi), true);
}, [moduleId, octave]); setActiveNotes(prev => new Set(prev).add(semitone));
}, [moduleId, oct]);
const stopNote = useCallback(() => { const stop = useCallback((semitone) => {
triggerKeyboard(moduleId, 440, false); setActiveNotes(prev => {
const next = new Set(prev);
next.delete(semitone);
if (next.size === 0) triggerKeyboard(moduleId, 440, false);
return next;
});
}, [moduleId]); }, [moduleId]);
const handleTouch = useCallback((semitone, isDown) => { // 1 octave: 7 white keys, 5 black keys
if (isDown) { const whiteKeys = [
activeRef.current.add(semitone); { note: 0, name: 'C' },
playNote(semitone); { note: 2, name: 'D' },
} else { { note: 4, name: 'E' },
activeRef.current.delete(semitone); { note: 5, name: 'F' },
if (activeRef.current.size === 0) stopNote(); { note: 7, name: 'G' },
} { note: 9, name: 'A' },
}, [playNote, stopNote]); { note: 11, name: 'B' },
// 2 octaves of keys
const whites = [];
const blacks = [];
const whiteNotes = [0, 2, 4, 5, 7, 9, 11];
const blackNotes = [1, 3, -1, 6, 8, 10, -1];
for (let oct = 0; oct < 2; oct++) {
const offset = oct * 12;
whiteNotes.forEach((note, i) => {
whites.push({ note: note + offset, idx: whites.length });
});
}
const blackPositions = [
{ note: 1, whiteIdx: 0 }, { note: 3, whiteIdx: 1 },
{ note: 6, whiteIdx: 3 }, { note: 8, whiteIdx: 4 }, { note: 10, whiteIdx: 5 },
{ note: 13, whiteIdx: 7 }, { note: 15, whiteIdx: 8 },
{ note: 18, whiteIdx: 10 }, { note: 20, whiteIdx: 11 }, { note: 22, whiteIdx: 12 },
]; ];
const totalWhites = whites.length; // Black key positions relative to white key index (0-6)
const keyW = 100 / totalWhites; const blackKeys = [
{ note: 1, name: 'C#', after: 0 },
{ note: 3, name: 'D#', after: 1 },
{ note: 6, name: 'F#', after: 3 },
{ note: 8, name: 'G#', after: 4 },
{ note: 10, name: 'A#', after: 5 },
];
return ( return (
<div className="keyboard-fullscreen"> <div className="keyboard-fullscreen">
<div className="keyboard-fs-header"> <div className="keyboard-fs-header">
<span className="keyboard-fs-title">🎹 Piano Oct {octave}</span>
<button className="keyboard-fs-close" onClick={onClose}></button> <button className="keyboard-fs-close" onClick={onClose}></button>
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.max(1, o - 1))}></button>
<span className="keyboard-fs-title">Octave {oct}</span>
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.min(8, o + 1))}></button>
<div style={{ width: 36 }} />
</div> </div>
<div className="keyboard-fs-keys"> <div className="keyboard-fs-keys">
{/* White keys */} {whiteKeys.map((k, i) => (
{whites.map((k, i) => (
<div <div
key={`w${i}`} key={k.note}
className="keyboard-fs-white" className={`keyboard-fs-white ${activeNotes.has(k.note) ? 'pressed' : ''}`}
style={{ width: `${keyW}%` }} onPointerDown={() => play(k.note)}
onPointerDown={() => handleTouch(k.note, true)} onPointerUp={() => stop(k.note)}
onPointerUp={() => handleTouch(k.note, false)} onPointerLeave={() => stop(k.note)}
onPointerLeave={() => handleTouch(k.note, false)} onPointerCancel={() => stop(k.note)}
> >
<span className="keyboard-fs-note-label"> <span className="keyboard-fs-note-label">{k.name}{oct}</span>
{NOTE_NAMES[k.note % 12]}
</span>
</div> </div>
))} ))}
{/* Black keys */} {blackKeys.map((k) => (
{blackPositions.map((bp, i) => (
<div <div
key={`b${i}`} key={k.note}
className="keyboard-fs-black" className={`keyboard-fs-black ${activeNotes.has(k.note) ? 'pressed' : ''}`}
style={{ left: `${(bp.whiteIdx + 0.65) * keyW}%`, width: `${keyW * 0.7}%` }} style={{ left: `${(k.after + 0.65) * (100 / 7)}%`, width: `${(100 / 7) * 0.65}%` }}
onPointerDown={() => handleTouch(bp.note, true)} onPointerDown={() => play(k.note)}
onPointerUp={() => handleTouch(bp.note, false)} onPointerUp={() => stop(k.note)}
onPointerLeave={() => handleTouch(bp.note, false)} onPointerLeave={() => stop(k.note)}
/> onPointerCancel={() => stop(k.note)}
>
<span className="keyboard-fs-black-label">{k.name}</span>
</div>
))} ))}
</div> </div>
</div> </div>
@@ -188,7 +184,7 @@ export default function KeyboardWidget({ moduleId }) {
{fullscreen && ( {fullscreen && (
<FullscreenPiano <FullscreenPiano
moduleId={moduleId} moduleId={moduleId}
octave={octave} initialOctave={octave}
onClose={() => setFullscreen(false)} onClose={() => setFullscreen(false)}
/> />
)} )}

View File

@@ -30,7 +30,11 @@ html, body, #root {
font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif; font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif;
font-size: 13px; font-size: 13px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
touch-action: pan-x pan-y;
-ms-touch-action: pan-x pan-y;
} }
/* Block native browser zoom gestures globally */
html { touch-action: manipulation; }
/* ===== Layout ===== */ /* ===== Layout ===== */
.app { display: flex; flex-direction: column; height: 100vh; } .app { display: flex; flex-direction: column; height: 100vh; }
@@ -790,46 +794,72 @@ html, body, #root {
.keyboard-fullscreen { .keyboard-fullscreen {
position: fixed; inset: 0; z-index: 500; position: fixed; inset: 0; z-index: 500;
background: #050510; display: flex; flex-direction: column; background: #050510; display: flex; flex-direction: column;
animation: fadeIn 0.2s ease-out; animation: fadeIn 0.2s ease-out; touch-action: none;
} }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.keyboard-fs-header { .keyboard-fs-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: center;
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--border); gap: 16px; padding: 8px 16px; background: var(--panel);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.keyboard-fs-title {
font-size: 16px; font-weight: 700; color: var(--text);
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
} }
.keyboard-fs-title { font-size: 14px; font-weight: 600; color: var(--text); }
.keyboard-fs-close { .keyboard-fs-close {
width: 36px; height: 36px; border-radius: 8px; width: 36px; height: 36px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border); background: var(--surface); border: 1px solid var(--border);
color: var(--text); font-size: 16px; cursor: pointer; color: var(--text); font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
} }
.keyboard-fs-oct-btn {
width: 44px; height: 36px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
color: var(--accent); font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.keyboard-fs-oct-btn:active { background: var(--accent); color: #000; }
.keyboard-fs-keys { .keyboard-fs-keys {
flex: 1; position: relative; display: flex; flex: 1; position: relative; display: flex;
padding: 0; touch-action: none; user-select: none; touch-action: none; user-select: none;
} }
.keyboard-fs-white { .keyboard-fs-white {
flex-shrink: 0; height: 100%; background: #1a1a2e; flex: 1; height: 100%; background: linear-gradient(to bottom, #1e1e38, #14142a);
border-right: 1px solid #252545; position: relative; border-right: 2px solid #0a0a18; position: relative;
display: flex; align-items: flex-end; justify-content: center; display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 16px; cursor: pointer; transition: background 0.05s; padding-bottom: 20px; cursor: pointer; transition: background 0.05s;
}
.keyboard-fs-white.pressed, .keyboard-fs-white:active {
background: linear-gradient(to bottom, var(--accent), #0a8a9e);
} }
.keyboard-fs-white:active { background: var(--accent); }
.keyboard-fs-note-label { .keyboard-fs-note-label {
font-size: 10px; color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 14px; font-weight: 600; color: var(--text2);
pointer-events: none; font-family: 'JetBrains Mono', monospace; pointer-events: none;
} }
.keyboard-fs-white.pressed .keyboard-fs-note-label,
.keyboard-fs-white:active .keyboard-fs-note-label { color: #000; } .keyboard-fs-white:active .keyboard-fs-note-label { color: #000; }
.keyboard-fs-black { .keyboard-fs-black {
position: absolute; top: 0; height: 55%; position: absolute; top: 0; height: 58%;
background: #0a0a18; border: 1px solid #333; background: linear-gradient(to bottom, #0a0a16, #060610);
border-top: none; border-radius: 0 0 4px 4px; border: 2px solid #222; border-top: none;
border-radius: 0 0 6px 6px;
z-index: 2; cursor: pointer; transition: background 0.05s; z-index: 2; cursor: pointer; transition: background 0.05s;
display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 8px;
} }
.keyboard-fs-black:active { background: var(--accent); } .keyboard-fs-black.pressed, .keyboard-fs-black:active {
background: linear-gradient(to bottom, #0088aa, #006688);
border-color: var(--accent);
}
.keyboard-fs-black-label {
font-size: 10px; font-weight: 600; color: #555;
font-family: 'JetBrains Mono', monospace; pointer-events: none;
}
.keyboard-fs-black.pressed .keyboard-fs-black-label,
.keyboard-fs-black:active .keyboard-fs-black-label { color: var(--accent); }
/* ===== Drum Pad ===== */ /* ===== Drum Pad ===== */
.drumpad-grid { .drumpad-grid {