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 BottomSheet from './components/BottomSheet.jsx';
|
||||||
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
||||||
import { useIsMobile } from './hooks/useIsMobile.js';
|
import { useIsMobile } from './hooks/useIsMobile.js';
|
||||||
|
import { usePinchZoom } from './hooks/usePinchZoom.js';
|
||||||
import { getModulesByCategory } from './engine/moduleRegistry.js';
|
import { getModulesByCategory } from './engine/moduleRegistry.js';
|
||||||
|
|
||||||
export default function App({ onSwitchToGame }) {
|
export default function App({ onSwitchToGame }) {
|
||||||
@@ -24,6 +25,11 @@ export default function App({ onSwitchToGame }) {
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
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
|
// Subscribe to state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribe(() => forceUpdate(n => n + 1));
|
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 = '' }) {
|
export default function BottomSheet({ tabs, activeTab, onTabChange, children, className = '' }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const startY = useRef(0);
|
const startY = useRef(0);
|
||||||
const sheetRef = useRef(null);
|
|
||||||
|
|
||||||
const handleTouchStart = useCallback((e) => {
|
const handleTouchStart = useCallback((e) => {
|
||||||
startY.current = e.touches[0].clientY;
|
startY.current = e.touches[0].clientY;
|
||||||
@@ -11,28 +10,30 @@ export default function BottomSheet({ tabs, activeTab, onTabChange, children, cl
|
|||||||
|
|
||||||
const handleTouchEnd = useCallback((e) => {
|
const handleTouchEnd = useCallback((e) => {
|
||||||
const deltaY = e.changedTouches[0].clientY - startY.current;
|
const deltaY = e.changedTouches[0].clientY - startY.current;
|
||||||
if (deltaY < -40) setExpanded(true);
|
if (deltaY < -30) setExpanded(true);
|
||||||
if (deltaY > 40) setExpanded(false);
|
if (deltaY > 30) setExpanded(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={sheetRef}
|
className={`bottom-sheet ${expanded ? 'expanded' : 'collapsed'} ${className}`}
|
||||||
className={`bottom-sheet ${expanded ? 'expanded' : ''} ${className}`}
|
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
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" />
|
<div className="bottom-sheet-handle-bar" />
|
||||||
|
{!expanded && !tabs && (
|
||||||
|
<span className="bottom-sheet-peek-label">Modulos ▲</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tabs && tabs.length > 0 && (
|
{tabs && tabs.length > 0 && (
|
||||||
<div className="bottom-sheet-tabs">
|
<div className="bottom-sheet-tabs" onClick={() => !expanded && setExpanded(true)}>
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`bottom-sheet-tab ${activeTab === tab.id ? 'active' : ''}`}
|
className={`bottom-sheet-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||||
onClick={() => onTabChange?.(tab.id)}
|
onClick={() => { onTabChange?.(tab.id); setExpanded(true); }}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{activeTab === tab.id && <div className="bottom-sheet-tab-line" />}
|
{activeTab === tab.id && <div className="bottom-sheet-tab-line" />}
|
||||||
@@ -41,9 +42,11 @@ export default function BottomSheet({ tabs, activeTab, onTabChange, children, cl
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bottom-sheet-content">
|
{expanded && (
|
||||||
{children}
|
<div className="bottom-sheet-content">
|
||||||
</div>
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ModuleNode from '../components/ModuleNode.jsx';
|
|||||||
import WireLayer from '../components/WireLayer.jsx';
|
import WireLayer from '../components/WireLayer.jsx';
|
||||||
import BottomSheet from '../components/BottomSheet.jsx';
|
import BottomSheet from '../components/BottomSheet.jsx';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile.js';
|
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||||
|
import { usePinchZoom } from '../hooks/usePinchZoom.js';
|
||||||
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
||||||
import LevelComplete from './LevelComplete.jsx';
|
import LevelComplete from './LevelComplete.jsx';
|
||||||
import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
|
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 isMobile = useIsMobile();
|
||||||
const [mobileTab, setMobileTab] = useState('mission');
|
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(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribe(() => {
|
const unsub = subscribe(() => {
|
||||||
forceUpdate(n => n + 1);
|
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;
|
display: flex; flex-direction: column;
|
||||||
background: var(--panel); border-top: 1px solid var(--border);
|
background: var(--panel); border-top: 1px solid var(--border);
|
||||||
border-radius: 16px 16px 0 0; flex-shrink: 0;
|
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;
|
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 {
|
.bottom-sheet-handle {
|
||||||
display: flex; justify-content: center; padding: 10px 0 6px;
|
display: flex; align-items: center; justify-content: center;
|
||||||
cursor: grab;
|
gap: 8px; padding: 10px 0 6px; cursor: pointer; min-height: 34px;
|
||||||
}
|
}
|
||||||
.bottom-sheet-handle-bar {
|
.bottom-sheet-handle-bar {
|
||||||
width: 40px; height: 4px; background: var(--border);
|
width: 40px; height: 4px; background: var(--border);
|
||||||
border-radius: 2px;
|
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 {
|
.bottom-sheet-tabs {
|
||||||
display: flex; padding: 0 16px; gap: 0;
|
display: flex; padding: 0 16px; gap: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user