fix: collapsible bottom sheet + pinch-to-zoom on mobile

- Bottom sheet starts collapsed (handle bar only), swipe up to expand
- Tabs visible when collapsed in puzzle view, content hidden
- Swipe down or tap handle to collapse
- Add usePinchZoom hook: two-finger pinch gesture controls canvas zoom
- Pinch zoom wired into both Sandbox and Puzzle View canvases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 15:53:28 +01:00
parent 8b66944e52
commit 323f30cfb9
5 changed files with 85 additions and 15 deletions

View File

@@ -11,6 +11,7 @@ import PresetModal from './components/PresetModal.jsx';
import BottomSheet from './components/BottomSheet.jsx';
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
import { useIsMobile } from './hooks/useIsMobile.js';
import { usePinchZoom } from './hooks/usePinchZoom.js';
import { getModulesByCategory } from './engine/moduleRegistry.js';
export default function App({ onSwitchToGame }) {
@@ -24,6 +25,11 @@ export default function App({ onSwitchToGame }) {
const isMobile = useIsMobile();
const [menuOpen, setMenuOpen] = useState(false);
// Pinch-to-zoom on mobile
const getZoom = useCallback(() => state.zoom, []);
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
usePinchZoom(containerRef, getZoom, setZoom);
// Subscribe to state changes
useEffect(() => {
const unsub = subscribe(() => forceUpdate(n => n + 1));

View File

@@ -3,7 +3,6 @@ import { useState, useRef, useCallback } from 'react';
export default function BottomSheet({ tabs, activeTab, onTabChange, children, className = '' }) {
const [expanded, setExpanded] = useState(false);
const startY = useRef(0);
const sheetRef = useRef(null);
const handleTouchStart = useCallback((e) => {
startY.current = e.touches[0].clientY;
@@ -11,28 +10,30 @@ export default function BottomSheet({ tabs, activeTab, onTabChange, children, cl
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - startY.current;
if (deltaY < -40) setExpanded(true);
if (deltaY > 40) setExpanded(false);
if (deltaY < -30) setExpanded(true);
if (deltaY > 30) setExpanded(false);
}, []);
return (
<div
ref={sheetRef}
className={`bottom-sheet ${expanded ? 'expanded' : ''} ${className}`}
className={`bottom-sheet ${expanded ? 'expanded' : 'collapsed'} ${className}`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div className="bottom-sheet-handle" onClick={() => setExpanded(e => !e)}>
<div className="bottom-sheet-handle" onClick={() => setExpanded(v => !v)}>
<div className="bottom-sheet-handle-bar" />
{!expanded && !tabs && (
<span className="bottom-sheet-peek-label">Modulos </span>
)}
</div>
{tabs && tabs.length > 0 && (
<div className="bottom-sheet-tabs">
<div className="bottom-sheet-tabs" onClick={() => !expanded && setExpanded(true)}>
{tabs.map(tab => (
<button
key={tab.id}
className={`bottom-sheet-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => onTabChange?.(tab.id)}
onClick={() => { onTabChange?.(tab.id); setExpanded(true); }}
>
{tab.label}
{activeTab === tab.id && <div className="bottom-sheet-tab-line" />}
@@ -41,9 +42,11 @@ export default function BottomSheet({ tabs, activeTab, onTabChange, children, cl
</div>
)}
{expanded && (
<div className="bottom-sheet-content">
{children}
</div>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import ModuleNode from '../components/ModuleNode.jsx';
import WireLayer from '../components/WireLayer.jsx';
import BottomSheet from '../components/BottomSheet.jsx';
import { useIsMobile } from '../hooks/useIsMobile.js';
import { usePinchZoom } from '../hooks/usePinchZoom.js';
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
import LevelComplete from './LevelComplete.jsx';
import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
@@ -25,6 +26,11 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
const isMobile = useIsMobile();
const [mobileTab, setMobileTab] = useState('mission');
// Pinch-to-zoom on mobile
const getZoom = useCallback(() => state.zoom, []);
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
usePinchZoom(containerRef, getZoom, setZoom);
useEffect(() => {
const unsub = subscribe(() => {
forceUpdate(n => n + 1);

48
src/hooks/usePinchZoom.js Normal file
View File

@@ -0,0 +1,48 @@
import { useRef, useEffect } from 'react';
export function usePinchZoom(containerRef, getZoom, setZoom) {
const pinchRef = useRef({ active: false, startDist: 0, startZoom: 1 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const getDistance = (t1, t2) =>
Math.sqrt((t1.clientX - t2.clientX) ** 2 + (t1.clientY - t2.clientY) ** 2);
const onTouchStart = (e) => {
if (e.touches.length === 2) {
e.preventDefault();
pinchRef.current = {
active: true,
startDist: getDistance(e.touches[0], e.touches[1]),
startZoom: getZoom(),
};
}
};
const onTouchMove = (e) => {
if (pinchRef.current.active && e.touches.length === 2) {
e.preventDefault();
const dist = getDistance(e.touches[0], e.touches[1]);
const scale = dist / pinchRef.current.startDist;
const newZoom = Math.max(0.3, Math.min(3, pinchRef.current.startZoom * scale));
setZoom(newZoom);
}
};
const onTouchEnd = () => {
pinchRef.current.active = false;
};
el.addEventListener('touchstart', onTouchStart, { passive: false });
el.addEventListener('touchmove', onTouchMove, { passive: false });
el.addEventListener('touchend', onTouchEnd);
return () => {
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd);
};
}, [containerRef, getZoom, setZoom]);
}

View File

@@ -873,19 +873,26 @@ html, body, #root {
display: flex; flex-direction: column;
background: var(--panel); border-top: 1px solid var(--border);
border-radius: 16px 16px 0 0; flex-shrink: 0;
max-height: 40vh; transition: max-height 0.3s ease;
transition: max-height 0.3s ease;
overflow: hidden;
}
.bottom-sheet.expanded { max-height: 60vh; }
.bottom-sheet.collapsed { max-height: 42px; }
.bottom-sheet.collapsed:has(.bottom-sheet-tabs) { max-height: 76px; }
.bottom-sheet.expanded { max-height: 55vh; }
.bottom-sheet-handle {
display: flex; justify-content: center; padding: 10px 0 6px;
cursor: grab;
display: flex; align-items: center; justify-content: center;
gap: 8px; padding: 10px 0 6px; cursor: pointer; min-height: 34px;
}
.bottom-sheet-handle-bar {
width: 40px; height: 4px; background: var(--border);
border-radius: 2px;
}
.bottom-sheet-peek-label {
font-size: 10px; font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
text-transform: uppercase;
}
.bottom-sheet-tabs {
display: flex; padding: 0 16px; gap: 0;