Rewild Your Web
web browser dweb
at main 342 lines 10 kB view raw
1// SPDX-License-Identifier: AGPL-3.0-or-later 2 3export class EdgeGestureHandler { 4 constructor(layoutManager) { 5 this.lm = layoutManager; 6 7 // Configuration 8 this.edgeThreshold = 30; // px from edge to trigger edge swipe 9 this.commitThreshold = 0.4; // fraction of viewport to commit swipe 10 11 // Touch state 12 this.touchStartX = 0; 13 this.touchStartY = 0; 14 this.touchCurrentX = 0; 15 this.touchCurrentY = 0; 16 this.isEdgeSwipe = false; 17 this.edgeDirection = null; // 'left', 'right', 'bottom', 'top' 18 this.isPeeking = false; 19 20 // Peek preview element 21 this.peekPreview = null; 22 23 // Active overlay element (expanded during gesture) 24 this.activeOverlay = null; 25 26 // Edge overlay elements 27 this.edgeOverlays = {}; 28 29 this.createEdgeOverlays(); 30 } 31 32 createEdgeOverlays() { 33 // Create thin overlay strips along each edge 34 const edges = [ 35 { 36 name: "left", 37 style: `left: 0; top: 0; width: ${this.edgeThreshold}px; height: 100%;`, 38 }, 39 { 40 name: "right", 41 style: `right: 0; top: 0; width: ${this.edgeThreshold}px; height: 100%;`, 42 }, 43 { 44 name: "top", 45 style: `top: 0; left: 0; width: 100%; height: ${this.edgeThreshold}px;`, 46 }, 47 { 48 name: "bottom", 49 style: `bottom: 0; left: 0; width: 100%; height: ${this.edgeThreshold}px;`, 50 }, 51 ]; 52 53 edges.forEach((edge) => { 54 const overlay = document.createElement("div"); 55 overlay.className = `gesture-edge-overlay gesture-edge-${edge.name}`; 56 overlay.style.cssText = edge.style; 57 overlay.dataset.edge = edge.name; 58 document.body.appendChild(overlay); 59 this.edgeOverlays[edge.name] = overlay; 60 61 // Add listeners to each edge overlay 62 overlay.addEventListener("touchstart", this.onEdgeTouchStart.bind(this), { 63 capture: false, 64 }); 65 overlay.addEventListener("touchmove", this.onTouchMove.bind(this), { 66 capture: false, 67 }); 68 overlay.addEventListener("touchend", this.onTouchEnd.bind(this), { 69 capture: false, 70 }); 71 overlay.addEventListener("touchcancel", this.onTouchCancel.bind(this), { 72 capture: false, 73 }); 74 }); 75 } 76 77 onEdgeTouchStart(event) { 78 if (event.touches.length !== 1) { 79 return; 80 } 81 82 const touch = event.touches[0]; 83 84 this.touchStartX = touch.clientX; 85 this.touchStartY = touch.clientY; 86 this.touchCurrentX = touch.clientX; 87 this.touchCurrentY = touch.clientY; 88 89 // Set edge swipe state based on which edge was touched 90 this.isEdgeSwipe = true; 91 this.edgeDirection = event.currentTarget.dataset.edge; 92 93 // Expand overlay to full screen so we keep receiving touch events 94 this.activeOverlay = event.currentTarget; 95 this.activeOverlay.classList.add("fullscreen-overlay"); 96 97 event.preventDefault(); 98 } 99 100 onTouchMove(event) { 101 if (!this.isEdgeSwipe || event.touches.length !== 1) { 102 return; 103 } 104 105 const touch = event.touches[0]; 106 this.touchCurrentX = touch.clientX; 107 this.touchCurrentY = touch.clientY; 108 109 const deltaX = this.touchCurrentX - this.touchStartX; 110 const deltaY = this.touchCurrentY - this.touchStartY; 111 112 // Handle horizontal edge swipes (left/right) 113 if (this.edgeDirection === "left" && deltaX > 0) { 114 event.preventDefault(); 115 this.handleLeftEdgeMove(deltaX); 116 } else if (this.edgeDirection === "right" && deltaX < 0) { 117 event.preventDefault(); 118 this.handleRightEdgeMove(-deltaX); 119 } else if (this.edgeDirection === "bottom" && deltaY < 0) { 120 event.preventDefault(); 121 this.handleBottomEdgeMove(-deltaY); 122 } else if (this.edgeDirection === "top" && deltaY > 0) { 123 event.preventDefault(); 124 this.handleTopEdgeMove(deltaY); 125 } 126 } 127 128 onTouchEnd() { 129 if (!this.isEdgeSwipe) { 130 return; 131 } 132 133 const deltaX = this.touchCurrentX - this.touchStartX; 134 135 // Complete the gesture based on direction and distance 136 if (this.edgeDirection === "left") { 137 this.completeEdgeSwipe(deltaX, "prev"); 138 } else if (this.edgeDirection === "right") { 139 this.completeEdgeSwipe(-deltaX, "next"); 140 } 141 142 this.resetGestureState(); 143 } 144 145 onTouchCancel() { 146 this.resetGestureState(); 147 this.animatePreviewOut(); 148 } 149 150 resetGestureState() { 151 if (this.activeOverlay) { 152 this.activeOverlay.classList.remove("fullscreen-overlay"); 153 this.activeOverlay = null; 154 } 155 this.isEdgeSwipe = false; 156 this.edgeDirection = null; 157 this.isPeeking = false; 158 } 159 160 // ============================================================================ 161 // Left Edge (Previous WebView) 162 // ============================================================================ 163 164 handleLeftEdgeMove(distance) { 165 this.handleHorizontalEdgeMove(distance, "prev"); 166 } 167 168 // ============================================================================ 169 // Right Edge (Next WebView) 170 // ============================================================================ 171 172 handleRightEdgeMove(distance) { 173 this.handleHorizontalEdgeMove(distance, "next"); 174 } 175 176 // ============================================================================ 177 // Shared horizontal edge logic 178 // ============================================================================ 179 180 handleHorizontalEdgeMove(distance, direction) { 181 if (!this.isPeeking) { 182 this.isPeeking = true; 183 this.showPeekPreview(direction); 184 } 185 this.updatePeekPreview(distance); 186 } 187 188 completeEdgeSwipe(distance, direction) { 189 const threshold = window.innerWidth * this.commitThreshold; 190 if (distance >= threshold) { 191 // Animate preview to fully cover the viewport, then switch 192 this.animatePreviewIn(() => { 193 if (direction === "prev") { 194 this.lm.prevWebView(); 195 } else { 196 this.lm.nextWebView(); 197 } 198 this.hidePeekPreview(); 199 }); 200 } else { 201 // Animate preview back out 202 this.animatePreviewOut(); 203 } 204 } 205 206 // ============================================================================ 207 // Bottom Edge (Action Bar) 208 // ============================================================================ 209 210 handleBottomEdgeMove(distance) { 211 if (distance >= this.edgeThreshold / 2) { 212 this.resetGestureState(); 213 this.lm.showActionBar(); 214 } 215 } 216 217 // ============================================================================ 218 // Top Edge (Notifications) 219 // ============================================================================ 220 221 handleTopEdgeMove(distance) { 222 if (distance >= this.edgeThreshold / 2) { 223 this.resetGestureState(); 224 document.dispatchEvent( 225 new CustomEvent("mobile-show-notifications", { bubbles: true }), 226 ); 227 } 228 } 229 230 // ============================================================================ 231 // Peek Preview 232 // ============================================================================ 233 234 showPeekPreview(direction) { 235 const adjacentId = this.lm.getAdjacentWebViewId(direction); 236 if (!adjacentId) { 237 console.error(`[EdgeGesture] no adjacentId`); 238 return; 239 } 240 241 const info = this.lm.getWebViewInfo(adjacentId); 242 if (!info) { 243 console.error(`[EdgeGesture] no WebViewInfo for ${adjacentId}`); 244 return; 245 } 246 247 // Create peek preview element if it doesn't exist 248 if (!this.peekPreview) { 249 this.peekPreview = document.createElement("div"); 250 this.peekPreview.className = "mobile-peek-preview"; 251 this.peekPreview.innerHTML = ` 252 <div class="peek-header"> 253 <img class="peek-favicon" src="" alt=""> 254 <span class="peek-title"></span> 255 </div> 256 <img class="peek-screenshot" src="" alt=""> 257 `; 258 document.body.appendChild(this.peekPreview); 259 } 260 261 // Update content 262 const favicon = this.peekPreview.querySelector(".peek-favicon"); 263 const title = this.peekPreview.querySelector(".peek-title"); 264 const screenshot = this.peekPreview.querySelector(".peek-screenshot"); 265 266 favicon.src = info.favicon || ""; 267 title.textContent = info.title; 268 if (info.screenshotUrl) { 269 screenshot.src = info.screenshotUrl; 270 screenshot.style.display = "block"; 271 } else { 272 screenshot.style.display = "none"; 273 } 274 275 // Position based on direction, no transition during drag 276 this.peekPreview.classList.remove("from-left", "from-right", "animating"); 277 this.peekPreview.classList.add( 278 direction === "prev" ? "from-left" : "from-right", 279 ); 280 this.peekPreview.classList.add("visible"); 281 } 282 283 updatePeekPreview(distance) { 284 if (!this.peekPreview) { 285 return; 286 } 287 288 // Directly track the finger: translateX in pixels 289 if (this.peekPreview.classList.contains("from-left")) { 290 const offset = -window.innerWidth + distance; 291 this.peekPreview.style.transform = `translateX(${Math.min(offset, 0)}px)`; 292 } else { 293 const offset = window.innerWidth - distance; 294 this.peekPreview.style.transform = `translateX(${Math.max(offset, 0)}px)`; 295 } 296 } 297 298 animatePreviewIn(onDone) { 299 if (!this.peekPreview) { 300 onDone(); 301 return; 302 } 303 this.peekPreview.classList.add("animating"); 304 let called = false; 305 const done = () => { 306 if (called) return; 307 called = true; 308 onDone(); 309 }; 310 this.peekPreview.addEventListener("transitionend", done, { once: true }); 311 setTimeout(done, 300); 312 this.peekPreview.style.transform = "translateX(0)"; 313 } 314 315 animatePreviewOut() { 316 if (!this.peekPreview) { 317 return; 318 } 319 this.peekPreview.classList.add("animating"); 320 let called = false; 321 const done = () => { 322 if (called) return; 323 called = true; 324 this.hidePeekPreview(); 325 }; 326 this.peekPreview.addEventListener("transitionend", done, { once: true }); 327 setTimeout(done, 300); 328 // Animate back off-screen 329 if (this.peekPreview.classList.contains("from-left")) { 330 this.peekPreview.style.transform = `translateX(-100%)`; 331 } else { 332 this.peekPreview.style.transform = `translateX(100%)`; 333 } 334 } 335 336 hidePeekPreview() { 337 if (this.peekPreview) { 338 this.peekPreview.classList.remove("visible", "animating"); 339 this.peekPreview.style.transform = ""; 340 } 341 } 342}