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:
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
48
src/hooks/usePinchZoom.js
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user