From a960f4e18b3e3368ec5a95bbe78077d3a1a5031c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sat, 4 Oct 2025 00:10:23 +0200 Subject: [PATCH] UI improvements --- src/App.tsx | 96 ++++++++++++++++++++++++++++--- src/components/BytebeatTile.tsx | 10 ++-- src/hooks/useKeyboardShortcuts.ts | 86 +++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 src/hooks/useKeyboardShortcuts.ts diff --git a/src/App.tsx b/src/App.tsx index abbc0e35..be07130f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { useStore } from '@nanostores/react' -import { Square } from 'lucide-react' +import { Square, Archive, Dices } from 'lucide-react' import { PlaybackManager } from './services/PlaybackManager' import { DownloadService } from './services/DownloadService' import { generateFormulaGrid, generateRandomFormula } from './utils/bytebeatFormulas' @@ -9,6 +9,7 @@ import { EffectsBar } from './components/EffectsBar' import { EngineControls } from './components/EngineControls' import { getSampleRateFromIndex } from './config/effects' import { engineSettings, effectSettings } from './stores/settings' +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' function App() { const engineValues = useStore(engineSettings) @@ -22,6 +23,7 @@ function App() { const [regenerating, setRegenerating] = useState(null) const [playbackPosition, setPlaybackPosition] = useState(0) const [downloading, setDownloading] = useState(false) + const [focusedTile, setFocusedTile] = useState<{ row: number; col: number }>({ row: 0, col: 0 }) const playbackManagerRef = useRef(null) const downloadServiceRef = useRef(new DownloadService()) const animationFrameRef = useRef(null) @@ -82,6 +84,7 @@ function App() { const handleTileClick = (formula: string, row: number, col: number, isDoubleClick: boolean = false) => { const id = `${row}-${col}` + setFocusedTile({ row, col }) if (playing === id) { playbackManagerRef.current?.stop() @@ -179,12 +182,89 @@ function App() { } } + const moveFocus = (direction: 'up' | 'down' | 'left' | 'right', step: number = 1) => { + setFocusedTile(prev => { + let { row, col } = prev + const maxRow = formulas.length - 1 + const maxCol = (formulas[row]?.length || 1) - 1 + + switch (direction) { + case 'up': + row = Math.max(0, row - step) + break + case 'down': + row = Math.min(maxRow, row + step) + break + case 'left': + col = Math.max(0, col - step) + break + case 'right': + col = Math.min(maxCol, col + step) + break + } + return { row, col } + }) + } + + const handleKeyboardSpace = () => { + if (playing) { + handleStop() + } else { + const formula = formulas[focusedTile.row]?.[focusedTile.col] + if (formula) { + handleTileClick(formula, focusedTile.row, focusedTile.col, true) + } + } + } + + const handleKeyboardEnter = () => { + const formula = formulas[focusedTile.row]?.[focusedTile.col] + if (formula) { + handleTileClick(formula, focusedTile.row, focusedTile.col, false) + } + } + + const handleKeyboardDoubleEnter = () => { + const formula = formulas[focusedTile.row]?.[focusedTile.col] + if (formula) { + handleTileClick(formula, focusedTile.row, focusedTile.col, true) + } + } + + const handleKeyboardR = () => { + handleRegenerate(focusedTile.row, focusedTile.col) + } + + const handleKeyboardShiftR = () => { + handleRandom() + } + + useKeyboardShortcuts({ + onSpace: handleKeyboardSpace, + onArrowUp: (shift) => moveFocus('up', shift ? 10 : 1), + onArrowDown: (shift) => moveFocus('down', shift ? 10 : 1), + onArrowLeft: (shift) => moveFocus('left', shift ? 10 : 1), + onArrowRight: (shift) => moveFocus('right', shift ? 10 : 1), + onEnter: handleKeyboardEnter, + onDoubleEnter: handleKeyboardDoubleEnter, + onR: handleKeyboardR, + onShiftR: handleKeyboardShiftR + }) + + useEffect(() => { + const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`) + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + }, [focusedTile]) + return (
-
-

BRUITISTE

-
+
+

BRUITISTE

+ +
-
@@ -224,6 +305,7 @@ function App() { isPlaying={playing === id} isQueued={queued === id} isRegenerating={regenerating === id} + isFocused={focusedTile.row === i && focusedTile.col === j} playbackPosition={playing === id ? playbackPosition : 0} onPlay={handleTileClick} onDoubleClick={handleTileDoubleClick} diff --git a/src/components/BytebeatTile.tsx b/src/components/BytebeatTile.tsx index 4a70a769..e571212d 100644 --- a/src/components/BytebeatTile.tsx +++ b/src/components/BytebeatTile.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect } from 'react' -import { Download, RefreshCw } from 'lucide-react' +import { Download, Dices } from 'lucide-react' import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator' interface BytebeatTileProps { @@ -9,6 +9,7 @@ interface BytebeatTileProps { isPlaying: boolean isQueued: boolean isRegenerating: boolean + isFocused: boolean playbackPosition: number onPlay: (formula: string, row: number, col: number) => void onDoubleClick: (formula: string, row: number, col: number) => void @@ -16,7 +17,7 @@ interface BytebeatTileProps { onRegenerate: (row: number, col: number) => void } -export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegenerating, playbackPosition, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) { +export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegenerating, isFocused, playbackPosition, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) { const canvasRef = useRef(null) useEffect(() => { @@ -44,11 +45,12 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener return (
onPlay(formula, row, col)} onDoubleClick={() => onDoubleClick(formula, row, col)} className={`relative hover:scale-[0.98] transition-all duration-150 font-mono p-3 flex items-center justify-between gap-3 cursor-pointer overflow-hidden ${ isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : isRegenerating ? 'bg-black text-white border-2 border-white' : 'bg-black text-white' - }`} + } ${isFocused ? 'outline outline-2 outline-white outline-offset-[-4px]' : ''}`} > - +
void + onArrowUp?: (shift: boolean) => void + onArrowDown?: (shift: boolean) => void + onArrowLeft?: (shift: boolean) => void + onArrowRight?: (shift: boolean) => void + onEnter?: () => void + onDoubleEnter?: () => void + onR?: () => void + onShiftR?: () => void +} + +const DOUBLE_ENTER_THRESHOLD = 300 + +export function useKeyboardShortcuts(handlers: KeyboardShortcutHandlers) { + const handlersRef = useRef(handlers) + + useEffect(() => { + handlersRef.current = handlers + }, [handlers]) + + useEffect(() => { + let lastEnterTime = 0 + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return + } + + const h = handlersRef.current + + switch (e.key) { + case ' ': + e.preventDefault() + h.onSpace?.() + break + + case 'ArrowUp': + e.preventDefault() + h.onArrowUp?.(e.shiftKey) + break + + case 'ArrowDown': + e.preventDefault() + h.onArrowDown?.(e.shiftKey) + break + + case 'ArrowLeft': + e.preventDefault() + h.onArrowLeft?.(e.shiftKey) + break + + case 'ArrowRight': + e.preventDefault() + h.onArrowRight?.(e.shiftKey) + break + + case 'Enter': + e.preventDefault() + const now = Date.now() + if (now - lastEnterTime < DOUBLE_ENTER_THRESHOLD) { + h.onDoubleEnter?.() + } else { + h.onEnter?.() + } + lastEnterTime = now + break + + case 'r': + case 'R': + e.preventDefault() + if (e.shiftKey) { + h.onShiftR?.() + } else { + h.onR?.() + } + break + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) +}