Rewild Your Web
at main 350 lines 11 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("pointerdown", this.onEdgePointerDown.bind(this), { 63 capture: false, 64 }); 65 overlay.addEventListener("pointermove", this.onPointerMove.bind(this), { 66 capture: false, 67 }); 68 overlay.addEventListener("pointerup", this.onPointerUp.bind(this), { 69 capture: false, 70 }); 71 overlay.addEventListener("pointercancel", this.onPointerCancel.bind(this), { 72 capture: false, 73 }); 74 }); 75 } 76 77 onEdgePointerDown(event) { 78 this.touchStartX = event.clientX; 79 this.touchStartY = event.clientY; 80 this.touchCurrentX = event.clientX; 81 this.touchCurrentY = event.clientY; 82 83 // Set edge swipe state based on which edge was touched 84 this.isEdgeSwipe = true; 85 this.edgeDirection = event.currentTarget.dataset.edge; 86 87 // Expand overlay to full screen so we keep receiving touch events 88 this.activeOverlay = event.currentTarget; 89 this.activeOverlay.classList.add("fullscreen-overlay"); 90 91 event.preventDefault(); 92 } 93 94 onPointerMove(event) { 95 if (!this.isEdgeSwipe) { 96 return; 97 } 98 99 this.touchCurrentX = event.clientX; 100 this.touchCurrentY = event.clientY; 101 102 const deltaX = this.touchCurrentX - this.touchStartX; 103 const deltaY = this.touchCurrentY - this.touchStartY; 104 105 // Handle horizontal edge swipes (left/right) 106 if (this.edgeDirection === "left" && deltaX > 0) { 107 event.preventDefault(); 108 this.handleLeftEdgeMove(deltaX); 109 } else if (this.edgeDirection === "right" && deltaX < 0) { 110 event.preventDefault(); 111 this.handleRightEdgeMove(-deltaX); 112 } else if (this.edgeDirection === "bottom" && deltaY < 0) { 113 event.preventDefault(); 114 this.handleBottomEdgeMove(-deltaY); 115 } else if (this.edgeDirection === "top" && deltaY > 0) { 116 event.preventDefault(); 117 this.handleTopEdgeMove(deltaY); 118 } 119 } 120 121 onPointerUp() { 122 if (!this.isEdgeSwipe) { 123 return; 124 } 125 126 const deltaX = this.touchCurrentX - this.touchStartX; 127 128 // Complete the gesture based on direction and distance 129 if (this.edgeDirection === "left") { 130 this.completeEdgeSwipe(deltaX, "prev"); 131 } else if (this.edgeDirection === "right") { 132 this.completeEdgeSwipe(-deltaX, "next"); 133 } 134 135 this.resetGestureState(); 136 } 137 138 onPointerCancel() { 139 this.resetGestureState(); 140 this.animatePreviewOut(); 141 } 142 143 resetGestureState() { 144 if (this.activeOverlay) { 145 this.activeOverlay.classList.remove("fullscreen-overlay"); 146 this.activeOverlay = null; 147 } 148 this.isEdgeSwipe = false; 149 this.edgeDirection = null; 150 this.isPeeking = false; 151 } 152 153 // ============================================================================ 154 // Left Edge (Previous WebView) 155 // ============================================================================ 156 157 handleLeftEdgeMove(distance) { 158 this.handleHorizontalEdgeMove(distance, "prev"); 159 } 160 161 // ============================================================================ 162 // Right Edge (Next WebView) 163 // ============================================================================ 164 165 handleRightEdgeMove(distance) { 166 this.handleHorizontalEdgeMove(distance, "next"); 167 } 168 169 // ============================================================================ 170 // Shared horizontal edge logic 171 // ============================================================================ 172 173 handleHorizontalEdgeMove(distance, direction) { 174 if (!this.isPeeking) { 175 this.isPeeking = true; 176 this.showPeekPreview(direction); 177 } 178 this.updatePeekPreview(distance); 179 } 180 181 completeEdgeSwipe(distance, direction) { 182 const threshold = window.innerWidth * this.commitThreshold; 183 if (distance >= threshold) { 184 // Animate preview to fully cover the viewport, then switch 185 this.animatePreviewIn(() => { 186 if (direction === "prev") { 187 this.lm.prevWebView(); 188 } else { 189 this.lm.nextWebView(); 190 } 191 this.hidePeekPreview(); 192 }); 193 } else { 194 // Animate preview back out 195 this.animatePreviewOut(); 196 } 197 } 198 199 // ============================================================================ 200 // Bottom Edge (Action Bar) 201 // ============================================================================ 202 203 handleBottomEdgeMove(distance) { 204 if (distance >= this.edgeThreshold / 2) { 205 this.resetGestureState(); 206 this.lm.showActionBar(); 207 } 208 } 209 210 // ============================================================================ 211 // Top Edge (Notifications) 212 // ============================================================================ 213 214 handleTopEdgeMove(distance) { 215 if (distance >= this.edgeThreshold / 2) { 216 this.resetGestureState(); 217 document.dispatchEvent( 218 new CustomEvent("mobile-show-notifications", { bubbles: true }), 219 ); 220 } 221 } 222 223 // ============================================================================ 224 // Peek Preview 225 // ============================================================================ 226 227 showPeekPreview(direction) { 228 const adjacentId = this.lm.getAdjacentWebViewId(direction); 229 if (!adjacentId) { 230 console.error(`[EdgeGesture] no adjacentId`); 231 return; 232 } 233 234 const info = this.lm.getWebViewInfo(adjacentId); 235 if (!info) { 236 console.error(`[EdgeGesture] no WebViewInfo for ${adjacentId}`); 237 return; 238 } 239 240 // Create peek preview element if it doesn't exist 241 if (!this.peekPreview) { 242 this.peekPreview = document.createElement("div"); 243 this.peekPreview.className = "mobile-peek-preview"; 244 this.peekPreview.innerHTML = ` 245 <div class="peek-header"> 246 <img class="peek-favicon" src="" alt=""> 247 <span class="peek-title"></span> 248 </div> 249 <img class="peek-screenshot" src="" alt=""> 250 `; 251 document.body.appendChild(this.peekPreview); 252 } 253 254 // Update content 255 const favicon = this.peekPreview.querySelector(".peek-favicon"); 256 const title = this.peekPreview.querySelector(".peek-title"); 257 const screenshot = this.peekPreview.querySelector(".peek-screenshot"); 258 259 favicon.src = info.favicon || ""; 260 title.textContent = info.title; 261 if (info.screenshotUrl) { 262 screenshot.src = info.screenshotUrl; 263 screenshot.style.display = "block"; 264 } else { 265 screenshot.style.display = "none"; 266 } 267 268 // Position based on direction, no transition during drag 269 this.peekPreview.classList.remove("from-left", "from-right", "animating"); 270 this.peekPreview.classList.add( 271 direction === "prev" ? "from-left" : "from-right", 272 ); 273 this.peekPreview.classList.add("visible"); 274 } 275 276 updatePeekPreview(distance) { 277 if (!this.peekPreview) { 278 return; 279 } 280 281 // Directly track the finger: translateX in pixels 282 if (this.peekPreview.classList.contains("from-left")) { 283 const offset = -window.innerWidth + distance; 284 this.peekPreview.style.transform = `translateX(${Math.min(offset, 0)}px)`; 285 } else { 286 const offset = window.innerWidth - distance; 287 this.peekPreview.style.transform = `translateX(${Math.max(offset, 0)}px)`; 288 } 289 } 290 291 animatePreviewIn(onDone) { 292 if (!this.peekPreview) { 293 onDone(); 294 return; 295 } 296 this.peekPreview.classList.add("animating"); 297 let called = false; 298 const done = () => { 299 if (called) return; 300 called = true; 301 onDone(); 302 }; 303 this.peekPreview.addEventListener("transitionend", done, { once: true }); 304 setTimeout(done, 300); 305 this.peekPreview.style.transform = "translateX(0)"; 306 } 307 308 animatePreviewOut() { 309 if (!this.peekPreview) { 310 return; 311 } 312 this.peekPreview.classList.add("animating"); 313 let called = false; 314 const done = () => { 315 if (called) return; 316 called = true; 317 this.hidePeekPreview(); 318 }; 319 this.peekPreview.addEventListener("transitionend", done, { once: true }); 320 setTimeout(done, 300); 321 // Animate back off-screen 322 if (this.peekPreview.classList.contains("from-left")) { 323 this.peekPreview.style.transform = `translateX(-100%)`; 324 } else { 325 this.peekPreview.style.transform = `translateX(100%)`; 326 } 327 } 328 329 hidePeekPreview() { 330 if (this.peekPreview) { 331 this.peekPreview.classList.remove("visible", "animating"); 332 this.peekPreview.style.transform = ""; 333 } 334 } 335 336 // Enable or disable specific edge overlays 337 // edges: object with left, right, bottom, top boolean properties 338 setEdgesEnabled(edges) { 339 for (const [edge, enabled] of Object.entries(edges)) { 340 const overlay = this.edgeOverlays[edge]; 341 if (overlay) { 342 if (edge === "left" || edge === "right") { 343 overlay.style.width = enabled ? `${this.edgeThreshold}px` : "0"; 344 } else { 345 overlay.style.height = enabled ? `${this.edgeThreshold}px` : "0"; 346 } 347 } 348 } 349 } 350}