Rewild Your Web
web browser dweb

system: improve mobile gestures

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