From 323f30cfb98bfb2e15e1519c8f71340b822d752f Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 15:53:28 +0100 Subject: [PATCH] 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) --- src/App.jsx | 6 +++++ src/components/BottomSheet.jsx | 25 ++++++++++-------- src/game/PuzzleView.jsx | 6 +++++ src/hooks/usePinchZoom.js | 48 ++++++++++++++++++++++++++++++++++ src/index.css | 15 ++++++++--- 5 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 src/hooks/usePinchZoom.js diff --git a/src/App.jsx b/src/App.jsx index b9d8a28..2d500a9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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)); diff --git a/src/components/BottomSheet.jsx b/src/components/BottomSheet.jsx index 62ad89c..d141cfe 100644 --- a/src/components/BottomSheet.jsx +++ b/src/components/BottomSheet.jsx @@ -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 (
-
setExpanded(e => !e)}> +
setExpanded(v => !v)}>
+ {!expanded && !tabs && ( + Modulos ▲ + )}
{tabs && tabs.length > 0 && ( -
+
!expanded && setExpanded(true)}> {tabs.map(tab => (
); } diff --git a/src/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index 0c32da9..8fae2c7 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -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); diff --git a/src/hooks/usePinchZoom.js b/src/hooks/usePinchZoom.js new file mode 100644 index 0000000..6aaa3f5 --- /dev/null +++ b/src/hooks/usePinchZoom.js @@ -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]); +} diff --git a/src/index.css b/src/index.css index 0e28381..b789ff2 100644 --- a/src/index.css +++ b/src/index.css @@ -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;