From 4515d632416452b0bacceb5257f69721452bc2ed Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Fri, 17 Apr 2026 21:12:53 -0700 Subject: [PATCH 1/2] Bottom sheet for properties on mobile (#27) Replaces the right-side slide-over panel with a bottom sheet that slides up from the bottom edge, which feels more natural on phones. Features: - Drag handle to resize by swiping up/down - Peek height of 260px (enough for most property editors) - Expands up to 85% of viewport height for complex nodes - Swipe down past minimum height to dismiss - Backdrop tap to close - Escape key to close - Smooth CSS transitions when not actively dragging - Rounded top corners with shadow for depth The tree panel keeps its left-side slide-over since it needs more vertical space for the node hierarchy. Closes #27 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 5 +- src/components/mobile/BottomSheet.tsx | 102 ++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/components/mobile/BottomSheet.tsx diff --git a/src/App.tsx b/src/App.tsx index 0602297..249dfbd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { PropertyPanel, PropertyContent } from './components/properties/Property import { Toolbar } from './components/toolbar/Toolbar'; import { ChatDrawer } from './components/chat/ChatDrawer'; import { MobilePanel } from './components/mobile/MobilePanel'; +import { BottomSheet } from './components/mobile/BottomSheet'; import { LoginPage } from './components/auth/LoginPage'; import { LandingPage } from './components/landing/LandingPage'; import { SharedViewer } from './components/share/SharedViewer'; @@ -141,9 +142,9 @@ function ModelerApp() { )} {mobilePanel === 'props' && ( - setMobilePanel(null)}> + setMobilePanel(null)}> - + )} ); diff --git a/src/components/mobile/BottomSheet.tsx b/src/components/mobile/BottomSheet.tsx new file mode 100644 index 0000000..16e6d80 --- /dev/null +++ b/src/components/mobile/BottomSheet.tsx @@ -0,0 +1,102 @@ +import { useRef, useEffect, useState, useCallback } from 'react'; +import { GripHorizontal } from 'lucide-react'; + +interface Props { + onClose: () => void; + children: React.ReactNode; +} + +const PEEK_HEIGHT = 260; // collapsed height +const MIN_HEIGHT = 120; // minimum before dismissing +const MAX_VH = 0.85; // maximum height as fraction of viewport + +/** + * Draggable bottom sheet for mobile. Slides up from the bottom edge + * with a drag handle. Swipe down to dismiss. + */ +export function BottomSheet({ onClose, children }: Props) { + const sheetRef = useRef(null); + const [height, setHeight] = useState(PEEK_HEIGHT); + const [dragging, setDragging] = useState(false); + const dragStart = useRef<{ y: number; h: number } | null>(null); + + const maxHeight = typeof window !== 'undefined' ? window.innerHeight * MAX_VH : 600; + + const onPointerDown = useCallback((e: React.PointerEvent) => { + dragStart.current = { y: e.clientY, h: height }; + setDragging(true); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, [height]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!dragStart.current) return; + const dy = dragStart.current.y - e.clientY; + const newH = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStart.current.h + dy)); + setHeight(newH); + }, [maxHeight]); + + const onPointerUp = useCallback(() => { + if (!dragStart.current) return; + if (height < MIN_HEIGHT + 20) { + onClose(); + } + dragStart.current = null; + setDragging(false); + }, [height, onClose]); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onClose]); + + return ( +
+ {/* Backdrop */} +
+ + {/* Sheet */} +
e.stopPropagation()} + > + {/* Drag handle */} +
+ +
+ + {/* Header */} +
+ + Properties + +
+ + {/* Content */} +
+ {children} +
+
+
+ ); +} From 0d722401c3e178217d728248e3363d4bd06b468b Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Fri, 17 Apr 2026 21:19:14 -0700 Subject: [PATCH 2/2] Snap points, scroll handoff, and auto-open on node select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom sheet improvements: 1. Snap points: sheet snaps to 33%, 55%, or 85% of viewport height instead of free-form dragging. Release between snaps and it animates to the nearest one. Below minimum → dismiss. 2. Scroll handoff: when the content area is scrolled to the top and the user swipes down, the gesture transitions from scrolling the content to dragging the sheet closed. Prevents the jarring bounce at the top of the scroll. 3. Auto-open: selecting a node on mobile (via viewport tap or tree) automatically opens the bottom sheet with that node's properties. No need to manually tap the properties button. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 17 ++++ src/components/mobile/BottomSheet.tsx | 124 +++++++++++++++++++++----- 2 files changed, 118 insertions(+), 23 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 249dfbd..da98e0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -66,10 +66,27 @@ function App() { ); } +function isMobile() { + return typeof window !== 'undefined' && window.innerWidth < 768; +} + function ModelerApp() { useEvaluator(); const [mobilePanel, setMobilePanel] = useState<'tree' | 'props' | null>(null); + // Auto-open bottom sheet when a node is selected on mobile + useEffect(() => { + let prev = useModelerStore.getState().selectedNodeId; + const unsub = useModelerStore.subscribe(() => { + const curr = useModelerStore.getState().selectedNodeId; + if (curr && curr !== prev && isMobile()) { + setMobilePanel('props'); + } + prev = curr; + }); + return unsub; + }, []); + useEffect(() => { startAutoSave(); startLocalAutoSave(); diff --git a/src/components/mobile/BottomSheet.tsx b/src/components/mobile/BottomSheet.tsx index 16e6d80..038868f 100644 --- a/src/components/mobile/BottomSheet.tsx +++ b/src/components/mobile/BottomSheet.tsx @@ -6,43 +6,118 @@ interface Props { children: React.ReactNode; } -const PEEK_HEIGHT = 260; // collapsed height -const MIN_HEIGHT = 120; // minimum before dismissing +const MIN_HEIGHT = 100; // below this → dismiss const MAX_VH = 0.85; // maximum height as fraction of viewport +/** Snap points as fractions of viewport height */ +const SNAPS = [0.33, 0.55, MAX_VH]; + +function getSnapHeights(vh: number): number[] { + return SNAPS.map((s) => Math.round(s * vh)); +} + +/** Find nearest snap point; if below minimum, return -1 (dismiss) */ +function nearestSnap(h: number, snaps: number[]): number { + if (h < MIN_HEIGHT) return -1; + let best = snaps[0], bestDist = Math.abs(h - snaps[0]); + for (let i = 1; i < snaps.length; i++) { + const dist = Math.abs(h - snaps[i]); + if (dist < bestDist) { best = snaps[i]; bestDist = dist; } + } + return best; +} + /** - * Draggable bottom sheet for mobile. Slides up from the bottom edge - * with a drag handle. Swipe down to dismiss. + * Draggable bottom sheet for mobile with snap points and scroll handoff. + * Swipe down to dismiss. Content scroll transitions to sheet drag when + * scrolled to the top. */ export function BottomSheet({ onClose, children }: Props) { const sheetRef = useRef(null); - const [height, setHeight] = useState(PEEK_HEIGHT); - const [dragging, setDragging] = useState(false); - const dragStart = useRef<{ y: number; h: number } | null>(null); + const contentRef = useRef(null); + const vh = typeof window !== 'undefined' ? window.innerHeight : 700; + const snaps = getSnapHeights(vh); - const maxHeight = typeof window !== 'undefined' ? window.innerHeight * MAX_VH : 600; + const [height, setHeight] = useState(snaps[0]); + const [dragging, setDragging] = useState(false); + const dragState = useRef<{ y: number; h: number; scrolling: boolean } | null>(null); + // --- Handle drag --- const onPointerDown = useCallback((e: React.PointerEvent) => { - dragStart.current = { y: e.clientY, h: height }; + dragState.current = { y: e.clientY, h: height, scrolling: false }; setDragging(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); }, [height]); const onPointerMove = useCallback((e: React.PointerEvent) => { - if (!dragStart.current) return; - const dy = dragStart.current.y - e.clientY; - const newH = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStart.current.h + dy)); + if (!dragState.current) return; + const dy = dragState.current.y - e.clientY; + const newH = Math.max(0, Math.min(snaps[snaps.length - 1], dragState.current.h + dy)); setHeight(newH); - }, [maxHeight]); + }, [snaps]); const onPointerUp = useCallback(() => { - if (!dragStart.current) return; - if (height < MIN_HEIGHT + 20) { - onClose(); - } - dragStart.current = null; + if (!dragState.current) return; + dragState.current = null; setDragging(false); - }, [height, onClose]); + // Snap to nearest point or dismiss + const snap = nearestSnap(height, snaps); + if (snap < 0) { onClose(); return; } + setHeight(snap); + }, [height, snaps, onClose]); + + // --- Content scroll handoff --- + // When the content is scrolled to the top and the user swipes down, + // intercept the touch and start closing the sheet instead. + useEffect(() => { + const content = contentRef.current; + if (!content) return; + + let touchStartY = 0; + let intercepted = false; + + function onTouchStart(e: TouchEvent) { + touchStartY = e.touches[0].clientY; + intercepted = false; + } + + function onTouchMove(e: TouchEvent) { + if (intercepted) return; + const dy = e.touches[0].clientY - touchStartY; + // Swiping down while at top of scroll → start sheet drag + if (dy > 5 && content!.scrollTop <= 0) { + intercepted = true; + e.preventDefault(); + dragState.current = { y: touchStartY, h: height, scrolling: true }; + setDragging(true); + } + if (intercepted && dragState.current) { + const moveY = dragState.current.y - e.touches[0].clientY; + const newH = Math.max(0, Math.min(snaps[snaps.length - 1], dragState.current.h + moveY)); + setHeight(newH); + } + } + + function onTouchEnd() { + if (intercepted && dragState.current) { + dragState.current = null; + setDragging(false); + const snap = nearestSnap(height, snaps); + if (snap < 0) { onClose(); return; } + setHeight(snap); + } + intercepted = false; + } + + content.addEventListener('touchstart', onTouchStart, { passive: true }); + content.addEventListener('touchmove', onTouchMove, { passive: false }); + content.addEventListener('touchend', onTouchEnd, { passive: true }); + return () => { + content.removeEventListener('touchstart', onTouchStart); + content.removeEventListener('touchmove', onTouchMove); + content.removeEventListener('touchend', onTouchEnd); + }; + }, [height, snaps, onClose]); // Close on Escape useEffect(() => { @@ -55,8 +130,11 @@ export function BottomSheet({ onClose, children }: Props) {
{/* Backdrop */}
{/* Sheet */} @@ -67,7 +145,7 @@ export function BottomSheet({ onClose, children }: Props) { height: `${height}px`, background: 'var(--bg-panel)', boxShadow: '0 -4px 20px rgba(0,0,0,0.25)', - transition: dragging ? 'none' : 'height 0.2s ease-out', + transition: dragging ? 'none' : 'height 0.25s ease-out', }} onClick={(e) => e.stopPropagation()} > @@ -93,7 +171,7 @@ export function BottomSheet({ onClose, children }: Props) {
{/* Content */} -
+
{children}