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.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("touchstart", this.onEdgeTouchStart.bind(this), {
63 capture: false,
64 });
65 overlay.addEventListener("touchmove", this.onTouchMove.bind(this), {
66 capture: false,
67 });
68 overlay.addEventListener("touchend", this.onTouchEnd.bind(this), {
69 capture: false,
70 });
71 overlay.addEventListener("touchcancel", this.onTouchCancel.bind(this), {
72 capture: false,
73 });
74 });
75 }
76
77 onEdgeTouchStart(event) {
78 if (event.touches.length !== 1) {
79 return;
80 }
81
82 const touch = event.touches[0];
83
84 this.touchStartX = touch.clientX;
85 this.touchStartY = touch.clientY;
86 this.touchCurrentX = touch.clientX;
87 this.touchCurrentY = touch.clientY;
88
89 // Set edge swipe state based on which edge was touched
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 }
104
105 const touch = event.touches[0];
106 this.touchCurrentX = touch.clientX;
107 this.touchCurrentY = touch.clientY;
108
109 const deltaX = this.touchCurrentX - this.touchStartX;
110 const deltaY = this.touchCurrentY - this.touchStartY;
111
112 // Handle horizontal edge swipes (left/right)
113 if (this.edgeDirection === "left" && deltaX > 0) {
114 event.preventDefault();
115 this.handleLeftEdgeMove(deltaX);
116 } else if (this.edgeDirection === "right" && deltaX < 0) {
117 event.preventDefault();
118 this.handleRightEdgeMove(-deltaX);
119 } else if (this.edgeDirection === "bottom" && deltaY < 0) {
120 event.preventDefault();
121 this.handleBottomEdgeMove(-deltaY);
122 } else if (this.edgeDirection === "top" && deltaY > 0) {
123 event.preventDefault();
124 this.handleTopEdgeMove(deltaY);
125 }
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 // ============================================================================
161 // Left Edge (Previous WebView)
162 // ============================================================================
163
164 handleLeftEdgeMove(distance) {
165 this.handleHorizontalEdgeMove(distance, "prev");
166 }
167
168 // ============================================================================
169 // Right Edge (Next WebView)
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
206 // ============================================================================
207 // Bottom Edge (Action Bar)
208 // ============================================================================
209
210 handleBottomEdgeMove(distance) {
211 if (distance >= this.edgeThreshold / 2) {
212 this.resetGestureState();
213 this.lm.showActionBar();
214 }
215 }
216
217 // ============================================================================
218 // Top Edge (Notifications)
219 // ============================================================================
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 );
227 }
228 }
229
230 // ============================================================================
231 // Peek Preview
232 // ============================================================================
233
234 showPeekPreview(direction) {
235 const adjacentId = this.lm.getAdjacentWebViewId(direction);
236 if (!adjacentId) {
237 console.error(`[EdgeGesture] no adjacentId`);
238 return;
239 }
240
241 const info = this.lm.getWebViewInfo(adjacentId);
242 if (!info) {
243 console.error(`[EdgeGesture] no WebViewInfo for ${adjacentId}`);
244 return;
245 }
246
247 // Create peek preview element if it doesn't exist
248 if (!this.peekPreview) {
249 this.peekPreview = document.createElement("div");
250 this.peekPreview.className = "mobile-peek-preview";
251 this.peekPreview.innerHTML = `
252 <div class="peek-header">
253 <img class="peek-favicon" src="" alt="">
254 <span class="peek-title"></span>
255 </div>
256 <img class="peek-screenshot" src="" alt="">
257 `;
258 document.body.appendChild(this.peekPreview);
259 }
260
261 // Update content
262 const favicon = this.peekPreview.querySelector(".peek-favicon");
263 const title = this.peekPreview.querySelector(".peek-title");
264 const screenshot = this.peekPreview.querySelector(".peek-screenshot");
265
266 favicon.src = info.favicon || "";
267 title.textContent = info.title;
268 if (info.screenshotUrl) {
269 screenshot.src = info.screenshotUrl;
270 screenshot.style.display = "block";
271 } else {
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 }
342}