|
23 | 23 | import { shareModal } from "$lib/stores/shareModal"; |
24 | 24 | import { loading } from "$lib/stores/loading"; |
25 | 25 | import { requireAuthUser } from "$lib/utils/auth"; |
| 26 | +
|
26 | 27 | interface Props { |
27 | 28 | title: string | undefined; |
28 | 29 | children?: import("svelte").Snippet; |
|
44 | 45 | // Define the width for the drawer (less than 100% to create the gap) |
45 | 46 | const drawerWidthPercentage = 85; |
46 | 47 |
|
47 | | - const tween = Spring.of( |
48 | | - () => { |
49 | | - if (isOpen) { |
50 | | - return 0 as number; |
51 | | - } |
52 | | - return -100 as number; |
53 | | - }, |
54 | | - { stiffness: 0.2, damping: 0.8 } |
55 | | - ); |
56 | | -
|
57 | 48 | $effect(() => { |
58 | 49 | title ??= "New Chat"; |
59 | 50 | }); |
|
78 | 69 | isOpen = false; |
79 | 70 | } |
80 | 71 |
|
81 | | - // Swipe gesture support for opening/closing the nav |
| 72 | + // Swipe gesture support for opening/closing the nav with live feedback |
| 73 | + // Thresholds from vaul drawer library |
| 74 | + const VELOCITY_THRESHOLD = 0.4; // px/ms - if exceeded, snap in swipe direction |
| 75 | + const CLOSE_THRESHOLD = 0.25; // 25% position threshold |
| 76 | + const DIRECTION_LOCK_THRESHOLD = 10; // px - movement needed to lock direction |
| 77 | +
|
82 | 78 | let touchstart: Touch | null = null; |
83 | | - let touchend: Touch | null = null; |
| 79 | + let dragStartTime: number = 0; |
| 80 | + let isDragging = $state(false); |
| 81 | + let dragOffset = $state(-100); // percentage: -100 (closed) to 0 (open) |
| 82 | + let dragStartedOpen = false; |
| 83 | +
|
| 84 | + // Direction lock: null = undecided, 'horizontal' = drawer drag, 'vertical' = scroll |
| 85 | + let directionLock: "horizontal" | "vertical" | null = null; |
| 86 | + let potentialDrag = false; |
84 | 87 |
|
85 | | - function checkDirection() { |
86 | | - if (!touchstart || !touchend) return; |
| 88 | + // Spring target: follows dragOffset during drag, follows isOpen after drag ends |
| 89 | + const springTarget = $derived(isDragging ? dragOffset : isOpen ? 0 : -100); |
| 90 | + const tween = Spring.of(() => springTarget, { stiffness: 0.2, damping: 0.8 }); |
87 | 91 |
|
88 | | - const screenWidth = window.innerWidth; |
89 | | - const swipeDistance = touchend.screenX - touchstart.screenX; |
90 | | - const absSwipeDistance = Math.abs(swipeDistance); |
| 92 | + function onTouchStart(e: TouchEvent) { |
| 93 | + const touch = e.changedTouches[0]; |
| 94 | + touchstart = touch; |
| 95 | + dragStartTime = Date.now(); |
| 96 | + directionLock = null; |
91 | 97 |
|
92 | | - // Only trigger if swipe is significant (1/8 of screen width) |
93 | | - if (absSwipeDistance < screenWidth / 8) return; |
| 98 | + const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100); |
| 99 | + const touchOnDrawer = isOpen && touch.clientX < drawerWidth; |
94 | 100 |
|
95 | | - // Swipe right from left edge (within 40px) -> open |
96 | | - if (touchstart.clientX < 40 && swipeDistance > 0 && !isOpen) { |
97 | | - isOpen = true; |
98 | | - } |
99 | | - // Swipe left while open -> close |
100 | | - else if (swipeDistance < 0 && isOpen) { |
101 | | - isOpen = false; |
| 101 | + // Potential drag scenarios - never start isDragging until direction is locked |
| 102 | + // Exception: overlay tap (no scroll content, so no direction conflict) |
| 103 | + if (!isOpen && touch.clientX < 40) { |
| 104 | + // Opening gesture - wait for direction lock before starting drag |
| 105 | + potentialDrag = true; |
| 106 | + dragStartedOpen = false; |
| 107 | + } else if (isOpen && !touchOnDrawer) { |
| 108 | + // Touch on overlay - can start immediately (no scroll conflict) |
| 109 | + potentialDrag = true; |
| 110 | + isDragging = true; |
| 111 | + dragStartedOpen = true; |
| 112 | + dragOffset = 0; |
| 113 | + directionLock = "horizontal"; |
| 114 | + } else if (isOpen && touchOnDrawer) { |
| 115 | + // Touch on drawer content - wait for direction lock |
| 116 | + potentialDrag = true; |
| 117 | + dragStartedOpen = true; |
102 | 118 | } |
103 | 119 | } |
104 | 120 |
|
105 | | - function onTouchStart(e: TouchEvent) { |
106 | | - touchstart = e.changedTouches[0]; |
| 121 | + function onTouchMove(e: TouchEvent) { |
| 122 | + if (!touchstart || !potentialDrag) return; |
| 123 | +
|
| 124 | + const touch = e.changedTouches[0]; |
| 125 | + const deltaX = touch.clientX - touchstart.clientX; |
| 126 | + const deltaY = touch.clientY - touchstart.clientY; |
| 127 | +
|
| 128 | + // Determine direction lock if not yet decided |
| 129 | + if (directionLock === null) { |
| 130 | + const absX = Math.abs(deltaX); |
| 131 | + const absY = Math.abs(deltaY); |
| 132 | +
|
| 133 | + if (absX > DIRECTION_LOCK_THRESHOLD || absY > DIRECTION_LOCK_THRESHOLD) { |
| 134 | + if (absX > absY) { |
| 135 | + // Horizontal movement - commit to drawer drag |
| 136 | + directionLock = "horizontal"; |
| 137 | + isDragging = true; |
| 138 | + dragOffset = dragStartedOpen ? 0 : -100; |
| 139 | + } else { |
| 140 | + // Vertical movement - abort potential drag, let content scroll |
| 141 | + directionLock = "vertical"; |
| 142 | + potentialDrag = false; |
| 143 | + return; |
| 144 | + } |
| 145 | + } else { |
| 146 | + return; |
| 147 | + } |
| 148 | + } |
| 149 | +
|
| 150 | + if (directionLock !== "horizontal") return; |
| 151 | +
|
| 152 | + const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100); |
| 153 | +
|
| 154 | + if (dragStartedOpen) { |
| 155 | + dragOffset = Math.max(-100, Math.min(0, (deltaX / drawerWidth) * 100)); |
| 156 | + } else { |
| 157 | + dragOffset = Math.max(-100, Math.min(0, -100 + (deltaX / drawerWidth) * 100)); |
| 158 | + } |
107 | 159 | } |
108 | 160 |
|
109 | 161 | function onTouchEnd(e: TouchEvent) { |
110 | | - touchend = e.changedTouches[0]; |
111 | | - checkDirection(); |
| 162 | + if (!potentialDrag) return; |
| 163 | +
|
| 164 | + if (!isDragging || !touchstart) { |
| 165 | + resetDragState(); |
| 166 | + return; |
| 167 | + } |
| 168 | +
|
| 169 | + const touch = e.changedTouches[0]; |
| 170 | + const timeTaken = Date.now() - dragStartTime; |
| 171 | + const distMoved = touch.clientX - touchstart.clientX; |
| 172 | + const velocity = Math.abs(distMoved) / timeTaken; |
| 173 | +
|
| 174 | + // Determine snap direction based on velocity first, then position |
| 175 | + if (velocity > VELOCITY_THRESHOLD) { |
| 176 | + isOpen = distMoved > 0; |
| 177 | + } else { |
| 178 | + const openThreshold = -100 + CLOSE_THRESHOLD * 100; |
| 179 | + isOpen = dragOffset > openThreshold; |
| 180 | + } |
| 181 | +
|
| 182 | + resetDragState(); |
| 183 | + } |
| 184 | +
|
| 185 | + function onTouchCancel() { |
| 186 | + if (isDragging) { |
| 187 | + isOpen = dragStartedOpen; |
| 188 | + } |
| 189 | + resetDragState(); |
| 190 | + } |
| 191 | +
|
| 192 | + function resetDragState() { |
| 193 | + isDragging = false; |
| 194 | + potentialDrag = false; |
| 195 | + touchstart = null; |
| 196 | + directionLock = null; |
112 | 197 | } |
113 | 198 |
|
114 | 199 | onMount(() => { |
115 | | - window.addEventListener("touchstart", onTouchStart); |
116 | | - window.addEventListener("touchend", onTouchEnd); |
| 200 | + window.addEventListener("touchstart", onTouchStart, { passive: true }); |
| 201 | + window.addEventListener("touchmove", onTouchMove, { passive: true }); |
| 202 | + window.addEventListener("touchend", onTouchEnd, { passive: true }); |
| 203 | + window.addEventListener("touchcancel", onTouchCancel, { passive: true }); |
117 | 204 | }); |
118 | 205 |
|
119 | 206 | onDestroy(() => { |
120 | 207 | if (browser) { |
121 | 208 | window.removeEventListener("touchstart", onTouchStart); |
| 209 | + window.removeEventListener("touchmove", onTouchMove); |
122 | 210 | window.removeEventListener("touchend", onTouchEnd); |
| 211 | + window.removeEventListener("touchcancel", onTouchCancel); |
123 | 212 | } |
124 | 213 | }); |
125 | 214 | </script> |
|
170 | 259 | </div> |
171 | 260 | </nav> |
172 | 261 |
|
173 | | -<!-- Mobile drawer overlay - shows when drawer is open --> |
174 | | -{#if isOpen} |
| 262 | +<!-- Mobile drawer overlay - shows when drawer is open or dragging --> |
| 263 | +{#if isOpen || isDragging} |
175 | 264 | <button |
176 | 265 | type="button" |
177 | 266 | class="fixed inset-0 z-20 cursor-default bg-black/30 md:hidden" |
178 | | - style="opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))};" |
| 267 | + style="opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))}; will-change: opacity;" |
179 | 268 | onclick={closeDrawer} |
180 | 269 | aria-label="Close mobile navigation" |
181 | 270 | ></button> |
182 | 271 | {/if} |
183 | 272 |
|
184 | 273 | <nav |
185 | | - style="transform: translateX({Math.max( |
186 | | - -100, |
187 | | - Math.min(0, tween.current) |
188 | | - )}%); width: {drawerWidthPercentage}%;" |
189 | | - class:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen} |
| 274 | + style="transform: translateX({isDragging |
| 275 | + ? dragOffset |
| 276 | + : tween.current}%); width: {drawerWidthPercentage}%; will-change: transform;" |
| 277 | + class:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen || isDragging} |
190 | 278 | class="fixed bottom-0 left-0 top-0 z-30 grid max-h-dvh grid-cols-1 |
191 | 279 | grid-rows-[auto,1fr,auto,auto] rounded-r-xl bg-white pt-4 dark:bg-gray-900 md:hidden" |
192 | 280 | > |
|
0 commit comments