// SPDX-License-Identifier: AGPL-3.0-or-later export class EdgeGestureHandler { constructor(layoutManager) { this.lm = layoutManager; // Configuration this.edgeThreshold = 30; // px from edge to trigger edge swipe this.commitThreshold = 0.4; // fraction of viewport to commit swipe // Touch state this.touchStartX = 0; this.touchStartY = 0; this.touchCurrentX = 0; this.touchCurrentY = 0; this.isEdgeSwipe = false; this.edgeDirection = null; // 'left', 'right', 'bottom', 'top' this.isPeeking = false; // Peek preview element this.peekPreview = null; // Active overlay element (expanded during gesture) this.activeOverlay = null; // Edge overlay elements this.edgeOverlays = {}; this.createEdgeOverlays(); } createEdgeOverlays() { // Create thin overlay strips along each edge const edges = [ { name: "left", style: `left: 0; top: 0; width: ${this.edgeThreshold}px; height: 100%;`, }, { name: "right", style: `right: 0; top: 0; width: ${this.edgeThreshold}px; height: 100%;`, }, { name: "top", style: `top: 0; left: 0; width: 100%; height: ${this.edgeThreshold}px;`, }, { name: "bottom", style: `bottom: 0; left: 0; width: 100%; height: ${this.edgeThreshold}px;`, }, ]; edges.forEach((edge) => { const overlay = document.createElement("div"); overlay.className = `gesture-edge-overlay gesture-edge-${edge.name}`; overlay.style.cssText = edge.style; overlay.dataset.edge = edge.name; document.body.appendChild(overlay); this.edgeOverlays[edge.name] = overlay; // Add listeners to each edge overlay overlay.addEventListener("touchstart", this.onEdgeTouchStart.bind(this), { capture: false, }); overlay.addEventListener("touchmove", this.onTouchMove.bind(this), { capture: false, }); overlay.addEventListener("touchend", this.onTouchEnd.bind(this), { capture: false, }); overlay.addEventListener("touchcancel", this.onTouchCancel.bind(this), { capture: false, }); }); } onEdgeTouchStart(event) { if (event.touches.length !== 1) { return; } const touch = event.touches[0]; this.touchStartX = touch.clientX; this.touchStartY = touch.clientY; this.touchCurrentX = touch.clientX; this.touchCurrentY = touch.clientY; // Set edge swipe state based on which edge was touched this.isEdgeSwipe = true; this.edgeDirection = event.currentTarget.dataset.edge; // Expand overlay to full screen so we keep receiving touch events this.activeOverlay = event.currentTarget; this.activeOverlay.classList.add("fullscreen-overlay"); event.preventDefault(); } onTouchMove(event) { if (!this.isEdgeSwipe || event.touches.length !== 1) { return; } const touch = event.touches[0]; this.touchCurrentX = touch.clientX; this.touchCurrentY = touch.clientY; const deltaX = this.touchCurrentX - this.touchStartX; const deltaY = this.touchCurrentY - this.touchStartY; // Handle horizontal edge swipes (left/right) if (this.edgeDirection === "left" && deltaX > 0) { event.preventDefault(); this.handleLeftEdgeMove(deltaX); } else if (this.edgeDirection === "right" && deltaX < 0) { event.preventDefault(); this.handleRightEdgeMove(-deltaX); } else if (this.edgeDirection === "bottom" && deltaY < 0) { event.preventDefault(); this.handleBottomEdgeMove(-deltaY); } else if (this.edgeDirection === "top" && deltaY > 0) { event.preventDefault(); this.handleTopEdgeMove(deltaY); } } onTouchEnd() { if (!this.isEdgeSwipe) { return; } const deltaX = this.touchCurrentX - this.touchStartX; // Complete the gesture based on direction and distance if (this.edgeDirection === "left") { this.completeEdgeSwipe(deltaX, "prev"); } else if (this.edgeDirection === "right") { this.completeEdgeSwipe(-deltaX, "next"); } this.resetGestureState(); } onTouchCancel() { this.resetGestureState(); this.animatePreviewOut(); } resetGestureState() { if (this.activeOverlay) { this.activeOverlay.classList.remove("fullscreen-overlay"); this.activeOverlay = null; } this.isEdgeSwipe = false; this.edgeDirection = null; this.isPeeking = false; } // ============================================================================ // Left Edge (Previous WebView) // ============================================================================ handleLeftEdgeMove(distance) { this.handleHorizontalEdgeMove(distance, "prev"); } // ============================================================================ // Right Edge (Next WebView) // ============================================================================ handleRightEdgeMove(distance) { this.handleHorizontalEdgeMove(distance, "next"); } // ============================================================================ // Shared horizontal edge logic // ============================================================================ handleHorizontalEdgeMove(distance, direction) { if (!this.isPeeking) { this.isPeeking = true; this.showPeekPreview(direction); } this.updatePeekPreview(distance); } completeEdgeSwipe(distance, direction) { const threshold = window.innerWidth * this.commitThreshold; if (distance >= threshold) { // Animate preview to fully cover the viewport, then switch this.animatePreviewIn(() => { if (direction === "prev") { this.lm.prevWebView(); } else { this.lm.nextWebView(); } this.hidePeekPreview(); }); } else { // Animate preview back out this.animatePreviewOut(); } } // ============================================================================ // Bottom Edge (Action Bar) // ============================================================================ handleBottomEdgeMove(distance) { if (distance >= this.edgeThreshold / 2) { this.resetGestureState(); this.lm.showActionBar(); } } // ============================================================================ // Top Edge (Notifications) // ============================================================================ handleTopEdgeMove(distance) { if (distance >= this.edgeThreshold / 2) { this.resetGestureState(); document.dispatchEvent( new CustomEvent("mobile-show-notifications", { bubbles: true }), ); } } // ============================================================================ // Peek Preview // ============================================================================ showPeekPreview(direction) { const adjacentId = this.lm.getAdjacentWebViewId(direction); if (!adjacentId) { console.error(`[EdgeGesture] no adjacentId`); return; } const info = this.lm.getWebViewInfo(adjacentId); if (!info) { console.error(`[EdgeGesture] no WebViewInfo for ${adjacentId}`); return; } // Create peek preview element if it doesn't exist if (!this.peekPreview) { this.peekPreview = document.createElement("div"); this.peekPreview.className = "mobile-peek-preview"; this.peekPreview.innerHTML = `