feat: fullscreen keyboard + new Drum Pad module
Keyboard fullscreen: - Double-tap keyboard widget to enter fullscreen piano mode - 2-octave touch-friendly piano with labeled keys - Active key highlights cyan, close button to exit Drum Pad module (🥁): - New module type with 4x4 colored pad grid - Each pad triggers a unique frequency (C2-D4 range) - Outputs freq + gate signals (same as keyboard) - Double-tap for fullscreen pad mode with large touch targets - Color-coded pads with hit animation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
122
src/components/DrumPadWidget.jsx
Normal file
122
src/components/DrumPadWidget.jsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
|
import { triggerKeyboard } from '../engine/audioEngine.js';
|
||||||
|
import { state } from '../engine/state.js';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||||
|
|
||||||
|
// 4x4 pad layout — each pad maps to a MIDI note
|
||||||
|
const PAD_NOTES = [
|
||||||
|
{ note: 36, label: 'C2', color: '#ff4466' },
|
||||||
|
{ note: 38, label: 'D2', color: '#ff6644' },
|
||||||
|
{ note: 40, label: 'E2', color: '#ffcc00' },
|
||||||
|
{ note: 42, label: 'F#2', color: '#44ff88' },
|
||||||
|
{ note: 43, label: 'G2', color: '#00e5ff' },
|
||||||
|
{ note: 45, label: 'A2', color: '#aa55ff' },
|
||||||
|
{ note: 47, label: 'B2', color: '#ff4466' },
|
||||||
|
{ note: 48, label: 'C3', color: '#ff6644' },
|
||||||
|
{ note: 50, label: 'D3', color: '#ffcc00' },
|
||||||
|
{ note: 52, label: 'E3', color: '#44ff88' },
|
||||||
|
{ note: 53, label: 'F3', color: '#00e5ff' },
|
||||||
|
{ note: 55, label: 'G3', color: '#aa55ff' },
|
||||||
|
{ note: 57, label: 'A3', color: '#ff4466' },
|
||||||
|
{ note: 59, label: 'B3', color: '#ff6644' },
|
||||||
|
{ note: 60, label: 'C4', color: '#ffcc00' },
|
||||||
|
{ note: 62, label: 'D4', color: '#44ff88' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function midiToFreq(midi) {
|
||||||
|
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FullscreenDrumPad({ moduleId, onClose }) {
|
||||||
|
const [activePad, setActivePad] = useState(-1);
|
||||||
|
|
||||||
|
const hitPad = useCallback((pad, idx) => {
|
||||||
|
triggerKeyboard(moduleId, midiToFreq(pad.note), true);
|
||||||
|
setActivePad(idx);
|
||||||
|
setTimeout(() => {
|
||||||
|
triggerKeyboard(moduleId, midiToFreq(pad.note), false);
|
||||||
|
setActivePad(-1);
|
||||||
|
}, 150);
|
||||||
|
}, [moduleId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="drumpad-fullscreen">
|
||||||
|
<div className="drumpad-fs-header">
|
||||||
|
<span className="drumpad-fs-title">🥁 Drum Pads</span>
|
||||||
|
<button className="drumpad-fs-close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="drumpad-fs-grid">
|
||||||
|
{PAD_NOTES.map((pad, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="drumpad-fs-pad"
|
||||||
|
style={{
|
||||||
|
background: activePad === i ? pad.color : `${pad.color}15`,
|
||||||
|
borderColor: activePad === i ? pad.color : `${pad.color}40`,
|
||||||
|
color: activePad === i ? '#000' : pad.color,
|
||||||
|
}}
|
||||||
|
onPointerDown={() => hitPad(pad, i)}
|
||||||
|
>
|
||||||
|
{pad.label}
|
||||||
|
<span className="pad-label">{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DrumPadWidget({ moduleId }) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const [activePad, setActivePad] = useState(-1);
|
||||||
|
const lastTap = useRef(0);
|
||||||
|
|
||||||
|
const hitPad = useCallback((pad, idx) => {
|
||||||
|
triggerKeyboard(moduleId, midiToFreq(pad.note), true);
|
||||||
|
setActivePad(idx);
|
||||||
|
setTimeout(() => {
|
||||||
|
triggerKeyboard(moduleId, midiToFreq(pad.note), false);
|
||||||
|
setActivePad(-1);
|
||||||
|
}, 150);
|
||||||
|
}, [moduleId]);
|
||||||
|
|
||||||
|
const handleDoubleTap = useCallback((e) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTap.current < 350) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setFullscreen(true);
|
||||||
|
}
|
||||||
|
lastTap.current = now;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div onPointerDown={handleDoubleTap}>
|
||||||
|
<div className="drumpad-grid">
|
||||||
|
{PAD_NOTES.map((pad, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`drumpad-pad ${activePad === i ? 'active' : ''}`}
|
||||||
|
style={{
|
||||||
|
background: activePad === i ? pad.color : `${pad.color}15`,
|
||||||
|
borderColor: `${pad.color}60`,
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); hitPad(pad, i); }}
|
||||||
|
>
|
||||||
|
{pad.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
|
||||||
|
{isMobile ? 'Doble-tap: pantalla completa' : 'Tap pads to trigger'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fullscreen && (
|
||||||
|
<FullscreenDrumPad moduleId={moduleId} onClose={() => setFullscreen(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { triggerKeyboard } from '../engine/audioEngine.js';
|
import { triggerKeyboard } from '../engine/audioEngine.js';
|
||||||
import { state } from '../engine/state.js';
|
import { state } from '../engine/state.js';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||||
|
|
||||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
|
|
||||||
// Computer keyboard to semitone offset mapping (2 octaves)
|
|
||||||
const KEY_MAP = {
|
const KEY_MAP = {
|
||||||
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6,
|
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6,
|
||||||
'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12,
|
'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12,
|
||||||
@@ -16,10 +16,97 @@ function midiToFreq(midi) {
|
|||||||
return 440 * Math.pow(2, (midi - 69) / 12);
|
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fullscreen piano overlay for mobile
|
||||||
|
function FullscreenPiano({ moduleId, octave, onClose }) {
|
||||||
|
const activeRef = useRef(new Set());
|
||||||
|
|
||||||
|
const playNote = useCallback((semitone) => {
|
||||||
|
const midi = (octave + 1) * 12 + semitone;
|
||||||
|
triggerKeyboard(moduleId, midiToFreq(midi), true);
|
||||||
|
}, [moduleId, octave]);
|
||||||
|
|
||||||
|
const stopNote = useCallback(() => {
|
||||||
|
triggerKeyboard(moduleId, 440, false);
|
||||||
|
}, [moduleId]);
|
||||||
|
|
||||||
|
const handleTouch = useCallback((semitone, isDown) => {
|
||||||
|
if (isDown) {
|
||||||
|
activeRef.current.add(semitone);
|
||||||
|
playNote(semitone);
|
||||||
|
} else {
|
||||||
|
activeRef.current.delete(semitone);
|
||||||
|
if (activeRef.current.size === 0) stopNote();
|
||||||
|
}
|
||||||
|
}, [playNote, stopNote]);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
const keyW = 100 / totalWhites;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="keyboard-fullscreen">
|
||||||
|
<div className="keyboard-fs-header">
|
||||||
|
<span className="keyboard-fs-title">🎹 Piano — Oct {octave}</span>
|
||||||
|
<button className="keyboard-fs-close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="keyboard-fs-keys">
|
||||||
|
{/* White keys */}
|
||||||
|
{whites.map((k, i) => (
|
||||||
|
<div
|
||||||
|
key={`w${i}`}
|
||||||
|
className="keyboard-fs-white"
|
||||||
|
style={{ width: `${keyW}%` }}
|
||||||
|
onPointerDown={() => handleTouch(k.note, true)}
|
||||||
|
onPointerUp={() => handleTouch(k.note, false)}
|
||||||
|
onPointerLeave={() => handleTouch(k.note, false)}
|
||||||
|
>
|
||||||
|
<span className="keyboard-fs-note-label">
|
||||||
|
{NOTE_NAMES[k.note % 12]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Black keys */}
|
||||||
|
{blackPositions.map((bp, i) => (
|
||||||
|
<div
|
||||||
|
key={`b${i}`}
|
||||||
|
className="keyboard-fs-black"
|
||||||
|
style={{ left: `${(bp.whiteIdx + 0.65) * keyW}%`, width: `${keyW * 0.7}%` }}
|
||||||
|
onPointerDown={() => handleTouch(bp.note, true)}
|
||||||
|
onPointerUp={() => handleTouch(bp.note, false)}
|
||||||
|
onPointerLeave={() => handleTouch(bp.note, false)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function KeyboardWidget({ moduleId }) {
|
export default function KeyboardWidget({ moduleId }) {
|
||||||
const mod = state.modules.find(m => m.id === moduleId);
|
const mod = state.modules.find(m => m.id === moduleId);
|
||||||
const octave = mod?.params?.octave ?? 4;
|
const octave = mod?.params?.octave ?? 4;
|
||||||
const activeKeys = useRef(new Set());
|
const activeKeys = useRef(new Set());
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const lastTap = useRef(0);
|
||||||
|
|
||||||
const playNote = useCallback((semitone) => {
|
const playNote = useCallback((semitone) => {
|
||||||
const midi = (octave + 1) * 12 + semitone;
|
const midi = (octave + 1) * 12 + semitone;
|
||||||
@@ -47,7 +134,6 @@ export default function KeyboardWidget({ moduleId }) {
|
|||||||
if (activeKeys.current.size === 0) stopNote();
|
if (activeKeys.current.size === 0) stopNote();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleDown);
|
window.addEventListener('keydown', handleDown);
|
||||||
window.addEventListener('keyup', handleUp);
|
window.addEventListener('keyup', handleUp);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -56,12 +142,23 @@ export default function KeyboardWidget({ moduleId }) {
|
|||||||
};
|
};
|
||||||
}, [playNote, stopNote]);
|
}, [playNote, stopNote]);
|
||||||
|
|
||||||
// Draw mini keyboard (1 octave)
|
const handleDoubleTap = useCallback((e) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTap.current < 350) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setFullscreen(true);
|
||||||
|
}
|
||||||
|
lastTap.current = now;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mini keyboard (1 octave)
|
||||||
const whites = [0, 2, 4, 5, 7, 9, 11];
|
const whites = [0, 2, 4, 5, 7, 9, 11];
|
||||||
const blacks = [1, 3, -1, 6, 8, 10];
|
const blacks = [1, 3, -1, 6, 8, 10];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2px 0' }}>
|
<>
|
||||||
|
<div style={{ padding: '2px 0' }} onPointerDown={handleDoubleTap}>
|
||||||
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
|
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
|
||||||
{whites.map((note, i) => (
|
{whites.map((note, i) => (
|
||||||
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
|
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
|
||||||
@@ -84,8 +181,17 @@ export default function KeyboardWidget({ moduleId }) {
|
|||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
|
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
|
||||||
Z-M / Q-I keys · Oct {octave}
|
{isMobile ? 'Doble-tap: pantalla completa' : 'Z-M / Q-I keys'} · Oct {octave}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fullscreen && (
|
||||||
|
<FullscreenPiano
|
||||||
|
moduleId={moduleId}
|
||||||
|
octave={octave}
|
||||||
|
onClose={() => setFullscreen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { updateParam } from '../engine/audioEngine.js';
|
|||||||
import Knob from './Knob.jsx';
|
import Knob from './Knob.jsx';
|
||||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||||
import KeyboardWidget from './KeyboardWidget.jsx';
|
import KeyboardWidget from './KeyboardWidget.jsx';
|
||||||
|
import DrumPadWidget from './DrumPadWidget.jsx';
|
||||||
import SequencerWidget from './SequencerWidget.jsx';
|
import SequencerWidget from './SequencerWidget.jsx';
|
||||||
import PianoRollWidget from './PianoRollWidget.jsx';
|
import PianoRollWidget from './PianoRollWidget.jsx';
|
||||||
|
|
||||||
@@ -248,6 +249,9 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
{/* Keyboard widget */}
|
{/* Keyboard widget */}
|
||||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
|
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
|
||||||
|
|
||||||
|
{/* Drum Pad widget */}
|
||||||
|
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} />}
|
||||||
|
|
||||||
{/* Sequencer widget */}
|
{/* Sequencer widget */}
|
||||||
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
|
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,8 @@ function createNode(mod) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'keyboard': {
|
case 'keyboard':
|
||||||
|
case 'drumpad': {
|
||||||
const freqSig = new Tone.Signal(440);
|
const freqSig = new Tone.Signal(440);
|
||||||
const gateSig = new Tone.Signal(0);
|
const gateSig = new Tone.Signal(0);
|
||||||
return {
|
return {
|
||||||
@@ -251,7 +252,7 @@ export function connectWire(conn) {
|
|||||||
// set the oscillator frequency directly when notes are played.
|
// set the oscillator frequency directly when notes are played.
|
||||||
const fromMod = state.modules.find(m => m.id === conn.from.moduleId);
|
const fromMod = state.modules.find(m => m.id === conn.from.moduleId);
|
||||||
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
|
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
|
||||||
if (fromMod && ['keyboard', 'sequencer', 'pianoroll'].includes(fromMod.type) &&
|
if (fromMod && ['keyboard', 'drumpad', 'sequencer', 'pianoroll'].includes(fromMod.type) &&
|
||||||
conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') {
|
conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') {
|
||||||
return; // handled imperatively in triggerKeyboard / setSequencerSignals
|
return; // handled imperatively in triggerKeyboard / setSequencerSignals
|
||||||
}
|
}
|
||||||
@@ -354,6 +355,7 @@ export function updateParam(moduleId, paramName, value) {
|
|||||||
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
|
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
|
||||||
break;
|
break;
|
||||||
case 'keyboard':
|
case 'keyboard':
|
||||||
|
case 'drumpad':
|
||||||
case 'sequencer':
|
case 'sequencer':
|
||||||
case 'pianoroll':
|
case 'pianoroll':
|
||||||
// All params stored in state, managed by widgets
|
// All params stored in state, managed by widgets
|
||||||
|
|||||||
@@ -258,6 +258,20 @@ defineModule('keyboard', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== DRUM PAD ====================
|
||||||
|
|
||||||
|
defineModule('drumpad', {
|
||||||
|
name: 'Drum Pad',
|
||||||
|
icon: '🥁',
|
||||||
|
category: 'Source',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||||
|
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== SEQUENCER ====================
|
// ==================== SEQUENCER ====================
|
||||||
|
|
||||||
defineModule('sequencer', {
|
defineModule('sequencer', {
|
||||||
|
|||||||
@@ -786,6 +786,99 @@ html, body, #root {
|
|||||||
.admin-star-btn.zero { color: var(--red); }
|
.admin-star-btn.zero { color: var(--red); }
|
||||||
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
|
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
|
||||||
|
|
||||||
|
/* ===== Fullscreen Keyboard ===== */
|
||||||
|
.keyboard-fullscreen {
|
||||||
|
position: fixed; inset: 0; z-index: 500;
|
||||||
|
background: #050510; display: flex; flex-direction: column;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.keyboard-fs-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.keyboard-fs-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||||
|
.keyboard-fs-close {
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
color: var(--text); font-size: 16px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-fs-keys {
|
||||||
|
flex: 1; position: relative; display: flex;
|
||||||
|
padding: 0; touch-action: none; user-select: none;
|
||||||
|
}
|
||||||
|
.keyboard-fs-white {
|
||||||
|
flex-shrink: 0; height: 100%; background: #1a1a2e;
|
||||||
|
border-right: 1px solid #252545; position: relative;
|
||||||
|
display: flex; align-items: flex-end; justify-content: center;
|
||||||
|
padding-bottom: 16px; cursor: pointer; transition: background 0.05s;
|
||||||
|
}
|
||||||
|
.keyboard-fs-white:active { background: var(--accent); }
|
||||||
|
.keyboard-fs-note-label {
|
||||||
|
font-size: 10px; color: var(--text2); font-family: 'JetBrains Mono', monospace;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.keyboard-fs-white:active .keyboard-fs-note-label { color: #000; }
|
||||||
|
|
||||||
|
.keyboard-fs-black {
|
||||||
|
position: absolute; top: 0; height: 55%;
|
||||||
|
background: #0a0a18; border: 1px solid #333;
|
||||||
|
border-top: none; border-radius: 0 0 4px 4px;
|
||||||
|
z-index: 2; cursor: pointer; transition: background 0.05s;
|
||||||
|
}
|
||||||
|
.keyboard-fs-black:active { background: var(--accent); }
|
||||||
|
|
||||||
|
/* ===== Drum Pad ===== */
|
||||||
|
.drumpad-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 3px; padding: 2px 0;
|
||||||
|
}
|
||||||
|
.drumpad-pad {
|
||||||
|
aspect-ratio: 1; border-radius: 4px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; transition: all 0.05s; border: 1px solid var(--border);
|
||||||
|
font-size: 8px; font-weight: 600; color: var(--text2);
|
||||||
|
font-family: 'JetBrains Mono', monospace; user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.drumpad-pad:active { transform: scale(0.92); }
|
||||||
|
.drumpad-pad.active { border-color: var(--accent); box-shadow: 0 0 8px rgba(0,229,255,0.3); }
|
||||||
|
|
||||||
|
.drumpad-fullscreen {
|
||||||
|
position: fixed; inset: 0; z-index: 500;
|
||||||
|
background: #050510; display: flex; flex-direction: column;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.drumpad-fs-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.drumpad-fs-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||||
|
.drumpad-fs-close {
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
color: var(--text); font-size: 16px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.drumpad-fs-grid {
|
||||||
|
flex: 1; display: grid; grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px; padding: 16px; touch-action: none;
|
||||||
|
}
|
||||||
|
.drumpad-fs-pad {
|
||||||
|
border-radius: 8px; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; gap: 4px;
|
||||||
|
cursor: pointer; transition: all 0.05s;
|
||||||
|
border: 2px solid var(--border); font-size: 12px;
|
||||||
|
font-weight: 600; color: var(--text2);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
user-select: none; touch-action: none;
|
||||||
|
}
|
||||||
|
.drumpad-fs-pad:active { transform: scale(0.95); border-color: var(--accent); }
|
||||||
|
.drumpad-fs-pad .pad-label { font-size: 10px; color: var(--text2); }
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
MOBILE RESPONSIVE — max-width: 768px
|
MOBILE RESPONSIVE — max-width: 768px
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
Reference in New Issue
Block a user