Rewild Your Web
web browser dweb

system: improve mobile gestures

+162 -112
+96 -80
resources/browserhtml/system/edge_gesture_handler.js
··· 6 6 7 7 // Configuration 8 8 this.edgeThreshold = 30; // px from edge to trigger edge swipe 9 - this.swipeThreshold = 50; // px to complete a swipe action 10 - this.longSwipeThreshold = 200; // px for long swipe (overview mode) 9 + this.commitThreshold = 0.4; // fraction of viewport to commit swipe 11 10 12 11 // Touch state 13 12 this.touchStartX = 0; ··· 20 19 21 20 // Peek preview element 22 21 this.peekPreview = null; 22 + 23 + // Active overlay element (expanded during gesture) 24 + this.activeOverlay = null; 23 25 24 26 // Edge overlay elements 25 27 this.edgeOverlays = {}; ··· 51 53 edges.forEach((edge) => { 52 54 const overlay = document.createElement("div"); 53 55 overlay.className = `gesture-edge-overlay gesture-edge-${edge.name}`; 54 - overlay.style.cssText = ` 55 - position: fixed; 56 - ${edge.style} 57 - z-index: var(--z-gesture); 58 - touch-action: none; 59 - pointer-events: auto; 60 - background: transparent; /* rgba(77, 215, 220, 0.49); */ 61 - `; 56 + overlay.style.cssText = edge.style; 62 57 overlay.dataset.edge = edge.name; 63 58 document.body.appendChild(overlay); 64 59 this.edgeOverlays[edge.name] = overlay; ··· 80 75 } 81 76 82 77 onEdgeTouchStart(event) { 83 - console.log(`[EdgeGesture] touchstart`); 84 - console.log("onEdgeTouchStart"); 85 78 if (event.touches.length !== 1) { 86 79 return; 87 80 } ··· 97 90 this.isEdgeSwipe = true; 98 91 this.edgeDirection = event.currentTarget.dataset.edge; 99 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 + 100 97 event.preventDefault(); 101 98 } 102 99 103 100 onTouchMove(event) { 104 - console.log(`[EdgeGesture] touchmove isEdgeSwipe=${this.isEdgeSwipe}`); 105 - 106 101 if (!this.isEdgeSwipe || event.touches.length !== 1) { 107 102 return; 108 103 } ··· 131 126 } 132 127 133 128 onTouchEnd() { 134 - console.log(`[EdgeGesture] touchend ${this.edgeDirection}`); 135 129 if (!this.isEdgeSwipe) { 136 130 return; 137 131 } 138 132 139 133 const deltaX = this.touchCurrentX - this.touchStartX; 140 - const deltaY = this.touchCurrentY - this.touchStartY; 141 134 142 135 // Complete the gesture based on direction and distance 143 136 if (this.edgeDirection === "left") { 144 - this.completeLeftEdgeSwipe(deltaX); 137 + this.completeEdgeSwipe(deltaX, "prev"); 145 138 } else if (this.edgeDirection === "right") { 146 - this.completeRightEdgeSwipe(-deltaX); 139 + this.completeEdgeSwipe(-deltaX, "next"); 147 140 } 148 141 149 - // Reset state 150 - this.isEdgeSwipe = false; 151 - this.edgeDirection = null; 152 - this.isPeeking = false; 153 - this.hidePeekPreview(); 142 + this.resetGestureState(); 154 143 } 155 144 156 145 onTouchCancel() { 157 - console.log(`[EdgeGesture] touchcancel`); 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 + } 158 155 this.isEdgeSwipe = false; 159 156 this.edgeDirection = null; 160 157 this.isPeeking = false; 161 - this.hidePeekPreview(); 162 158 } 163 159 164 160 // ============================================================================ ··· 166 162 // ============================================================================ 167 163 168 164 handleLeftEdgeMove(distance) { 169 - const progress = Math.min(distance / this.swipeThreshold, 1); 170 - console.log( 171 - `[EdgeGesture] handleLeftEdgeMove distance=${distance} progress=${progress} isPeeking=${this.isPeeking}`, 172 - ); 173 - 174 - // Show peek preview of previous webview 175 - if (!this.isPeeking && progress > 0.1) { 176 - this.isPeeking = true; 177 - this.showPeekPreview("prev", progress); 178 - } else if (this.isPeeking) { 179 - this.updatePeekPreview(progress); 180 - } 181 - } 182 - 183 - completeLeftEdgeSwipe(distance) { 184 - if (distance >= this.longSwipeThreshold) { 185 - // Long swipe: enter overview mode 186 - this.lm.showOverview(); 187 - } else if (distance >= this.swipeThreshold) { 188 - // Normal swipe: switch to previous webview 189 - this.lm.prevWebView(); 190 - } 191 - // Otherwise: cancelled, peek snaps back 165 + this.handleHorizontalEdgeMove(distance, "prev"); 192 166 } 193 167 194 168 // ============================================================================ ··· 196 170 // ============================================================================ 197 171 198 172 handleRightEdgeMove(distance) { 199 - const progress = Math.min(distance / this.swipeThreshold, 1); 173 + this.handleHorizontalEdgeMove(distance, "next"); 174 + } 200 175 201 - // Show peek preview of next webview 202 - if (!this.isPeeking && progress > 0.1) { 176 + // ============================================================================ 177 + // Shared horizontal edge logic 178 + // ============================================================================ 179 + 180 + handleHorizontalEdgeMove(distance, direction) { 181 + if (!this.isPeeking) { 203 182 this.isPeeking = true; 204 - this.showPeekPreview("next", progress); 205 - } else if (this.isPeeking) { 206 - this.updatePeekPreview(progress); 183 + this.showPeekPreview(direction); 207 184 } 185 + this.updatePeekPreview(distance); 208 186 } 209 187 210 - completeRightEdgeSwipe(distance) { 211 - if (distance >= this.longSwipeThreshold) { 212 - // Long swipe: enter overview mode 213 - this.lm.showOverview(); 214 - } else if (distance >= this.swipeThreshold) { 215 - // Normal swipe: switch to next webview 216 - this.lm.nextWebView(); 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(); 217 203 } 218 204 } 219 205 ··· 222 208 // ============================================================================ 223 209 224 210 handleBottomEdgeMove(distance) { 225 - console.log(`[EdgeGesture] handleBottomEdgeMove ${distance}px`); 226 211 if (distance >= this.edgeThreshold / 2) { 227 - // Swipe up from bottom: show action bar 212 + this.resetGestureState(); 228 213 this.lm.showActionBar(); 229 214 } 230 215 } ··· 235 220 236 221 handleTopEdgeMove(distance) { 237 222 if (distance >= this.edgeThreshold / 2) { 238 - // Swipe down from top: show notifications 239 - // Dispatch event for notification panel 223 + this.resetGestureState(); 240 224 document.dispatchEvent( 241 225 new CustomEvent("mobile-show-notifications", { bubbles: true }), 242 226 ); ··· 247 231 // Peek Preview 248 232 // ============================================================================ 249 233 250 - showPeekPreview(direction, progress) { 251 - console.log(`[EdgeGesture] showPeekPreview ${direction} ${progress}`); 234 + showPeekPreview(direction) { 252 235 const adjacentId = this.lm.getAdjacentWebViewId(direction); 253 236 if (!adjacentId) { 254 237 console.error(`[EdgeGesture] no adjacentId`); ··· 289 272 screenshot.style.display = "none"; 290 273 } 291 274 292 - // Position based on direction 293 - this.peekPreview.classList.remove("from-left", "from-right"); 275 + // Position based on direction, no transition during drag 276 + this.peekPreview.classList.remove("from-left", "from-right", "animating"); 294 277 this.peekPreview.classList.add( 295 278 direction === "prev" ? "from-left" : "from-right", 296 279 ); 297 280 this.peekPreview.classList.add("visible"); 298 - 299 - this.updatePeekPreview(progress); 300 281 } 301 282 302 - updatePeekPreview(progress) { 303 - console.log(`[EdgeGesture] updatePeekPreview ${progress}`); 283 + updatePeekPreview(distance) { 304 284 if (!this.peekPreview) { 305 285 return; 306 286 } 307 287 308 - // Animate the preview sliding in 309 - const maxTranslate = 30; // percentage of screen width to reveal 310 - const translatePercent = progress * maxTranslate; 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 + } 311 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 312 329 if (this.peekPreview.classList.contains("from-left")) { 313 - this.peekPreview.style.transform = `translateX(${-100 + translatePercent}%)`; 330 + this.peekPreview.style.transform = `translateX(-100%)`; 314 331 } else { 315 - this.peekPreview.style.transform = `translateX(${100 - translatePercent}%)`; 332 + this.peekPreview.style.transform = `translateX(100%)`; 316 333 } 317 334 } 318 335 319 336 hidePeekPreview() { 320 - console.log(`[EdgeGesture] hidePeekPreview`); 321 337 if (this.peekPreview) { 322 - this.peekPreview.classList.remove("visible"); 338 + this.peekPreview.classList.remove("visible", "animating"); 323 339 this.peekPreview.style.transform = ""; 324 340 } 325 341 }
-2
resources/browserhtml/system/index.js
··· 590 590 591 591 // Override layout manager's showOverview to use mobile tab overview 592 592 layoutManager.showOverview = function () { 593 - // Capture screenshots for all tabs before showing overview 594 - layoutManager.captureAllScreenshots(); 595 593 updateTabOverviewData(); 596 594 mobileOverview.open = true; 597 595 };
+36 -7
resources/browserhtml/system/mobile.css
··· 26 26 bottom: var(--keyboard-offset); 27 27 opacity: 0; 28 28 visibility: hidden; 29 - transition: opacity 0.3s ease, visibility 0.3s ease, bottom 0.3s ease; 29 + transition: 30 + opacity 0.3s ease, 31 + visibility 0.3s ease, 32 + bottom 0.3s ease; 30 33 z-index: var(--z-base); 31 34 } 32 35 ··· 72 75 .mobile-peek-preview { 73 76 position: fixed; 74 77 top: 0; 75 - width: 30%; 78 + width: 100%; 76 79 height: 100%; 77 80 background: var(--bg-menu); 78 81 z-index: var(--z-gesture); ··· 81 84 box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); 82 85 opacity: 0; 83 86 visibility: hidden; 84 - transition: opacity 0.2s ease, visibility 0.2s ease; 85 87 } 86 88 87 89 .mobile-peek-preview.visible { ··· 89 91 visibility: visible; 90 92 } 91 93 94 + /* Animate transform on release (commit or cancel) */ 95 + .mobile-peek-preview.animating { 96 + transition: transform var(--transition-fast); 97 + } 98 + 92 99 .mobile-peek-preview.from-left { 93 100 left: 0; 94 101 transform: translateX(-100%); 95 - border-right: 2px solid var(--color-focus-ring); 96 102 } 97 103 98 104 .mobile-peek-preview.from-right { 99 105 right: 0; 100 106 left: auto; 101 107 transform: translateX(100%); 102 - border-left: 2px solid var(--color-focus-ring); 103 108 } 104 109 105 110 .peek-header { ··· 155 160 z-index: var(--z-gesture); 156 161 opacity: 0; 157 162 visibility: hidden; 158 - transition: opacity 0.2s ease, visibility 0.2s ease; 163 + transition: 164 + opacity 0.2s ease, 165 + visibility 0.2s ease; 159 166 } 160 167 161 168 .mobile-tab-indicator.visible { ··· 168 175 height: 8px; 169 176 border-radius: 50%; 170 177 background: rgba(255, 255, 255, 0.3); 171 - transition: background 0.2s ease, transform 0.2s ease; 178 + transition: 179 + background 0.2s ease, 180 + transform 0.2s ease; 172 181 } 173 182 174 183 .tab-dot.active { ··· 280 289 height: 4px; 281 290 background: linear-gradient(to bottom, var(--color-focus-ring), transparent); 282 291 } 292 + 293 + .gesture-edge-overlay { 294 + position: fixed; 295 + z-index: calc(var(--z-gesture) + 1); 296 + touch-action: none; 297 + pointer-events: auto; 298 + background: transparent; 299 + /* rgba(77, 215, 220, 0.49); */ 300 + } 301 + 302 + /* Expand edge overlay to full screen during active gesture, 303 + above the peek preview so we keep receiving touch events */ 304 + .gesture-edge-overlay.fullscreen-overlay { 305 + left: 0 !important; 306 + top: 0 !important; 307 + right: auto !important; 308 + bottom: auto !important; 309 + width: 100% !important; 310 + height: 100% !important; 311 + }
+17 -19
resources/browserhtml/system/mobile_layout_manager.js
··· 114 114 115 115 // Remove from DOM 116 116 const container = this.root.querySelector( 117 - `.mobile-webview-container[data-webview-id="${webviewId}"]` 117 + `.mobile-webview-container[data-webview-id="${webviewId}"]`, 118 118 ); 119 119 if (container) { 120 120 container.remove(); ··· 153 153 prevEntry.webview.active = false; 154 154 155 155 // Capture screenshot of the tab we're switching away from 156 - if (prevEntry.webview.captureScreenshot) { 157 - prevEntry.webview.captureScreenshot(); 158 - } 156 + // TODO: fix screenshot capture when not fully visible. 157 + // prevEntry.webview.captureScreenshot(true); 159 158 160 159 const prevContainer = this.root.querySelector( 161 - `.mobile-webview-container[data-webview-id="${this.activeWebviewId}"]` 160 + `.mobile-webview-container[data-webview-id="${this.activeWebviewId}"]`, 162 161 ); 163 162 if (prevContainer) { 164 163 prevContainer.classList.remove("active"); ··· 173 172 entry.webview.active = true; 174 173 this.activeIndex = entry.index; 175 174 const container = this.root.querySelector( 176 - `.mobile-webview-container[data-webview-id="${webviewId}"]` 175 + `.mobile-webview-container[data-webview-id="${webviewId}"]`, 177 176 ); 178 177 if (container) { 179 178 container.classList.add("active"); ··· 192 191 193 192 // Navigate to next webview in carousel (skips homescreen) 194 193 nextWebView() { 195 - const regularViews = this.webviewOrder.filter((id) => !this.isHomescreen(id)); 194 + const regularViews = this.webviewOrder.filter( 195 + (id) => !this.isHomescreen(id), 196 + ); 196 197 if (regularViews.length <= 1) { 197 198 return; 198 199 } ··· 209 210 210 211 // Navigate to previous webview in carousel (skips homescreen) 211 212 prevWebView() { 212 - const regularViews = this.webviewOrder.filter((id) => !this.isHomescreen(id)); 213 + const regularViews = this.webviewOrder.filter( 214 + (id) => !this.isHomescreen(id), 215 + ); 213 216 if (regularViews.length <= 1) { 214 217 return; 215 218 } ··· 227 230 228 231 // Get adjacent webview ID for peek preview (skips homescreen) 229 232 getAdjacentWebViewId(direction) { 230 - const regularViews = this.webviewOrder.filter((id) => !this.isHomescreen(id)); 233 + const regularViews = this.webviewOrder.filter( 234 + (id) => !this.isHomescreen(id), 235 + ); 231 236 if (regularViews.length <= 1) { 232 237 return null; 233 238 } ··· 364 369 }); 365 370 } 366 371 367 - // Capture screenshots for all webviews (for tab overview) 368 - captureAllScreenshots() { 369 - for (const [id, entry] of this.webviews) { 370 - if (entry.webview.captureScreenshot) { 371 - entry.webview.captureScreenshot(); 372 - } 373 - } 374 - } 375 - 376 372 // ============================================================================ 377 373 // Overview Mode (for compatibility, minimal implementation for MVP) 378 374 // ============================================================================ ··· 388 384 showOverview() { 389 385 this.overviewMode = true; 390 386 // TODO: Implement mobile tab overview grid 391 - console.log("[MobileLayoutManager] Overview mode enabled (not yet implemented)"); 387 + console.log( 388 + "[MobileLayoutManager] Overview mode enabled (not yet implemented)", 389 + ); 392 390 } 393 391 394 392 hideOverview() {
+13 -4
resources/browserhtml/system/web_view.js
··· 791 791 canGoForward: this.canGoForward, 792 792 url: this.currentUrl, 793 793 }, 794 - }) 794 + }), 795 795 ); 796 796 797 797 // Capture screenshot for overview mode after a short delay ··· 800 800 } 801 801 802 802 // Capture a screenshot and cache it for overview mode 803 - captureScreenshot() { 803 + captureScreenshot(immediate = false) { 804 804 // Debounce: clear any pending capture 805 805 if (this.screenshotTimeout) { 806 806 clearTimeout(this.screenshotTimeout); 807 807 } 808 808 809 - // Delay capture to allow page to render 810 - this.screenshotTimeout = setTimeout(() => { 809 + let doCapture = () => { 811 810 this.getContentIframe() 812 811 .takeScreenshot() 813 812 .then((blob) => { ··· 822 821 .catch(() => { 823 822 // Ignore screenshot errors (e.g., for off-screen webviews) 824 823 }); 824 + }; 825 + 826 + if (immediate) { 827 + doCapture(); 828 + return; 829 + } 830 + 831 + // Delay capture to allow page to render 832 + this.screenshotTimeout = setTimeout(() => { 833 + doCapture(); 825 834 }, 500); 826 835 } 827 836