diff --git a/src/common/hooks/useSwipeNavigation.ts b/src/common/hooks/useSwipeNavigation.ts new file mode 100644 index 0000000..5eb6a68 --- /dev/null +++ b/src/common/hooks/useSwipeNavigation.ts @@ -0,0 +1,65 @@ +import type { RefObject } from "preact"; +import { useEffect, useRef } from "preact/hooks"; + +const EDGE_THRESHOLD = 30; // px from screen edge to start a swipe +const MIN_SWIPE = 50; // minimum horizontal distance to trigger + +export function useSwipeNavigation( + elementRef: RefObject, + onSwipeLeft: () => void, + onSwipeRight: () => void, +) { + // Keep callbacks in a ref so the effect doesn't re-run when they change + const callbacksRef = useRef({ onSwipeLeft, onSwipeRight }); + callbacksRef.current = { onSwipeLeft, onSwipeRight }; + + useEffect(() => { + const el = elementRef.current; + if (!el) return; + + let start: { x: number; y: number } | null = null; + + const onTouchStart = (e: TouchEvent) => { + const t = e.touches[0]; + const fromEdge = t.clientX < EDGE_THRESHOLD + || t.clientX > window.innerWidth - EDGE_THRESHOLD; + start = fromEdge ? { x: t.clientX, y: t.clientY } : null; + if (fromEdge) { + e.preventDefault(); // Prevent scrolling on swipe + } + }; + + const onTouchMove = (e: TouchEvent) => { + if (!start) return; + const t = e.touches[0]; + const dx = Math.abs(t.clientX - start.x); + const dy = Math.abs(t.clientY - start.y); + // Prevent Chrome's back/forward navigation once we're sure it's horizontal + if (dx > dy && dx > 10) e.preventDefault(); + }; + + const onTouchEnd = (e: TouchEvent) => { + if (!start) return; + const t = e.changedTouches[0]; + const dx = t.clientX - start.x; + const dy = t.clientY - start.y; + start = null; + + if (Math.abs(dx) < MIN_SWIPE || Math.abs(dx) < Math.abs(dy)) return; + + e.preventDefault(); // prevent Chrome's back/forward navigation + if (dx > 0) callbacksRef.current.onSwipeRight(); + else callbacksRef.current.onSwipeLeft(); + }; + + el.addEventListener('touchstart', onTouchStart, { passive: false }); + el.addEventListener('touchmove', onTouchMove, { passive: false }); + el.addEventListener('touchend', onTouchEnd, { passive: false }); + + return () => { + el.removeEventListener('touchstart', onTouchStart); + el.removeEventListener('touchmove', onTouchMove); + el.removeEventListener('touchend', onTouchEnd); + }; + }, [elementRef]); +} diff --git a/src/games/storywriter/components/editor.tsx b/src/games/storywriter/components/editor.tsx index 5686b97..bc2bfea 100644 --- a/src/games/storywriter/components/editor.tsx +++ b/src/games/storywriter/components/editor.tsx @@ -1,5 +1,6 @@ import { highlight } from "@common/highlight"; import { useMediaQuery } from "@common/hooks/useMediaQuery"; +import { useSwipeNavigation } from "@common/hooks/useSwipeNavigation"; import clsx from "clsx"; import { BookMarked, BookOpen, BrainCircuit, Code, FileText, Globe, Layers, List, MapPin, MessageSquare, MessagesSquare, Users, type LucideIcon } from "lucide-preact"; import { useEffect, useMemo, useRef } from "preact/hooks"; @@ -62,6 +63,7 @@ export const Editor = () => { }; const contentRef = useRef(null); + const editorRef = useRef(null); const promptPreview = useMemo(() => { const text = Prompt.formatSystemPrompt(appState); @@ -93,6 +95,20 @@ export const Editor = () => { const isMobile = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}px)`); + useSwipeNavigation( + editorRef, + () => { // swipe left → next tab + const idx = tabs.findIndex(t => t.id === currentTab); + const next = tabs[idx + 1]; + if (next) dispatch({ type: 'SET_CURRENT_TAB', tab: next.id }); + }, + () => { // swipe right → prev tab + const idx = tabs.findIndex(t => t.id === currentTab); + const prev = tabs[idx - 1]; + if (prev) dispatch({ type: 'SET_CURRENT_TAB', tab: prev.id }); + }, + ); + const hasSelection = currentWorld !== null; const isChatOnly = currentWorld?.chatOnly ?? false; @@ -117,7 +133,7 @@ export const Editor = () => { : null; return ( -
+
{titleBar}