Rewild Your Web
web
browser
dweb
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.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;
14 this.touchStartY = 0;
15 this.touchCurrentX = 0;
16 this.touchCurrentY = 0;
17 this.isEdgeSwipe = false;
18 this.edgeDirection = null; // 'left', 'right', 'bottom', 'top'
19 this.isPeeking = false;
20
21 // Peek preview element
22 this.peekPreview = null;
23
24 // Edge overlay elements
25 this.edgeOverlays = {};
26
27 this.createEdgeOverlays();
28 }
29
30 createEdgeOverlays() {
31 // Create thin overlay strips along each edge
32 const edges = [
33 {
34 name: "left",
35 style: `left: 0; top: 0; width: ${this.edgeThreshold}px; height: 100%;`,
36 },
37 {
38 name: "right",
39 style: `right: 0; top: 0; width: ${this.edgeThreshold}px; height: 100%;`,
40 },
41 {
42 name: "top",
43 style: `top: 0; left: 0; width: 100%; height: ${this.edgeThreshold}px;`,
44 },
45 {
46 name: "bottom",
47 style: `bottom: 0; left: 0; width: 100%; height: ${this.edgeThreshold}px;`,
48 },
49 ];
50
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;
65
66 // Add listeners to each edge overlay
67 overlay.addEventListener("touchstart", this.onEdgeTouchStart.bind(this), {
68 capture: false,
69 });
70 overlay.addEventListener("touchmove", this.onTouchMove.bind(this), {
71 capture: false,
72 });
73 overlay.addEventListener("touchend", this.onTouchEnd.bind(this), {
74 capture: false,
75 });
76 overlay.addEventListener("touchcancel", this.onTouchCancel.bind(this), {
77 capture: false,
78 });
79 });
80 }
81
82 onEdgeTouchStart(event) {
83 console.log(`[EdgeGesture] touchstart`);
84 console.log("onEdgeTouchStart");
85 if (event.touches.length !== 1) {
86 return;
87 }
88
89 const touch = event.touches[0];
90
91 this.touchStartX = touch.clientX;
92 this.touchStartY = touch.clientY;
93 this.touchCurrentX = touch.clientX;
94 this.touchCurrentY = touch.clientY;
95
96 // Set edge swipe state based on which edge was touched
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 }
109
110 const touch = event.touches[0];
111 this.touchCurrentX = touch.clientX;
112 this.touchCurrentY = touch.clientY;
113
114 const deltaX = this.touchCurrentX - this.touchStartX;
115 const deltaY = this.touchCurrentY - this.touchStartY;
116
117 // Handle horizontal edge swipes (left/right)
118 if (this.edgeDirection === "left" && deltaX > 0) {
119 event.preventDefault();
120 this.handleLeftEdgeMove(deltaX);
121 } else if (this.edgeDirection === "right" && deltaX < 0) {
122 event.preventDefault();
123 this.handleRightEdgeMove(-deltaX);
124 } else if (this.edgeDirection === "bottom" && deltaY < 0) {
125 event.preventDefault();
126 this.handleBottomEdgeMove(-deltaY);
127 } else if (this.edgeDirection === "top" && deltaY > 0) {
128 event.preventDefault();
129 this.handleTopEdgeMove(deltaY);
130 }
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 // ============================================================================
165 // Left Edge (Previous WebView)
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 // ============================================================================
195 // Right Edge (Next WebView)
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
220 // ============================================================================
221 // Bottom Edge (Action Bar)
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 }
231
232 // ============================================================================
233 // Top Edge (Notifications)
234 // ============================================================================
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 );
243 }
244 }
245
246 // ============================================================================
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`);
255 return;
256 }
257
258 const info = this.lm.getWebViewInfo(adjacentId);
259 if (!info) {
260 console.error(`[EdgeGesture] no WebViewInfo for ${adjacentId}`);
261 return;
262 }
263
264 // Create peek preview element if it doesn't exist
265 if (!this.peekPreview) {
266 this.peekPreview = document.createElement("div");
267 this.peekPreview.className = "mobile-peek-preview";
268 this.peekPreview.innerHTML = `
269 <div class="peek-header">
270 <img class="peek-favicon" src="" alt="">
271 <span class="peek-title"></span>
272 </div>
273 <img class="peek-screenshot" src="" alt="">
274 `;
275 document.body.appendChild(this.peekPreview);
276 }
277
278 // Update content
279 const favicon = this.peekPreview.querySelector(".peek-favicon");
280 const title = this.peekPreview.querySelector(".peek-title");
281 const screenshot = this.peekPreview.querySelector(".peek-screenshot");
282
283 favicon.src = info.favicon || "";
284 title.textContent = info.title;
285 if (info.screenshotUrl) {
286 screenshot.src = info.screenshotUrl;
287 screenshot.style.display = "block";
288 } else {
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 }
326}