forked from
me.webbeef.org/browser.html
Rewild Your Web
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("pointerdown", this.onEdgePointerDown.bind(this), {
63 capture: false,
64 });
65 overlay.addEventListener("pointermove", this.onPointerMove.bind(this), {
66 capture: false,
67 });
68 overlay.addEventListener("pointerup", this.onPointerUp.bind(this), {
69 capture: false,
70 });
71 overlay.addEventListener("pointercancel", this.onPointerCancel.bind(this), {
72 capture: false,
73 });
74 });
75 }
76
77 onEdgePointerDown(event) {
78 this.touchStartX = event.clientX;
79 this.touchStartY = event.clientY;
80 this.touchCurrentX = event.clientX;
81 this.touchCurrentY = event.clientY;
82
83 // Set edge swipe state based on which edge was touched
84 this.isEdgeSwipe = true;
85 this.edgeDirection = event.currentTarget.dataset.edge;
86
87 // Expand overlay to full screen so we keep receiving touch events
88 this.activeOverlay = event.currentTarget;
89 this.activeOverlay.classList.add("fullscreen-overlay");
90
91 event.preventDefault();
92 }
93
94 onPointerMove(event) {
95 if (!this.isEdgeSwipe) {
96 return;
97 }
98
99 this.touchCurrentX = event.clientX;
100 this.touchCurrentY = event.clientY;
101
102 const deltaX = this.touchCurrentX - this.touchStartX;
103 const deltaY = this.touchCurrentY - this.touchStartY;
104
105 // Handle horizontal edge swipes (left/right)
106 if (this.edgeDirection === "left" && deltaX > 0) {
107 event.preventDefault();
108 this.handleLeftEdgeMove(deltaX);
109 } else if (this.edgeDirection === "right" && deltaX < 0) {
110 event.preventDefault();
111 this.handleRightEdgeMove(-deltaX);
112 } else if (this.edgeDirection === "bottom" && deltaY < 0) {
113 event.preventDefault();
114 this.handleBottomEdgeMove(-deltaY);
115 } else if (this.edgeDirection === "top" && deltaY > 0) {
116 event.preventDefault();
117 this.handleTopEdgeMove(deltaY);
118 }
119 }
120
121 onPointerUp() {
122 if (!this.isEdgeSwipe) {
123 return;
124 }
125
126 const deltaX = this.touchCurrentX - this.touchStartX;
127
128 // Complete the gesture based on direction and distance
129 if (this.edgeDirection === "left") {
130 this.completeEdgeSwipe(deltaX, "prev");
131 } else if (this.edgeDirection === "right") {
132 this.completeEdgeSwipe(-deltaX, "next");
133 }
134
135 this.resetGestureState();
136 }
137
138 onPointerCancel() {
139 this.resetGestureState();
140 this.animatePreviewOut();
141 }
142
143 resetGestureState() {
144 if (this.activeOverlay) {
145 this.activeOverlay.classList.remove("fullscreen-overlay");
146 this.activeOverlay = null;
147 }
148 this.isEdgeSwipe = false;
149 this.edgeDirection = null;
150 this.isPeeking = false;
151 }
152
153 // ============================================================================
154 // Left Edge (Previous WebView)
155 // ============================================================================
156
157 handleLeftEdgeMove(distance) {
158 this.handleHorizontalEdgeMove(distance, "prev");
159 }
160
161 // ============================================================================
162 // Right Edge (Next WebView)
163 // ============================================================================
164
165 handleRightEdgeMove(distance) {
166 this.handleHorizontalEdgeMove(distance, "next");
167 }
168
169 // ============================================================================
170 // Shared horizontal edge logic
171 // ============================================================================
172
173 handleHorizontalEdgeMove(distance, direction) {
174 if (!this.isPeeking) {
175 this.isPeeking = true;
176 this.showPeekPreview(direction);
177 }
178 this.updatePeekPreview(distance);
179 }
180
181 completeEdgeSwipe(distance, direction) {
182 const threshold = window.innerWidth * this.commitThreshold;
183 if (distance >= threshold) {
184 // Animate preview to fully cover the viewport, then switch
185 this.animatePreviewIn(() => {
186 if (direction === "prev") {
187 this.lm.prevWebView();
188 } else {
189 this.lm.nextWebView();
190 }
191 this.hidePeekPreview();
192 });
193 } else {
194 // Animate preview back out
195 this.animatePreviewOut();
196 }
197 }
198
199 // ============================================================================
200 // Bottom Edge (Action Bar)
201 // ============================================================================
202
203 handleBottomEdgeMove(distance) {
204 if (distance >= this.edgeThreshold / 2) {
205 this.resetGestureState();
206 this.lm.showActionBar();
207 }
208 }
209
210 // ============================================================================
211 // Top Edge (Notifications)
212 // ============================================================================
213
214 handleTopEdgeMove(distance) {
215 if (distance >= this.edgeThreshold / 2) {
216 this.resetGestureState();
217 document.dispatchEvent(
218 new CustomEvent("mobile-show-notifications", { bubbles: true }),
219 );
220 }
221 }
222
223 // ============================================================================
224 // Peek Preview
225 // ============================================================================
226
227 showPeekPreview(direction) {
228 const adjacentId = this.lm.getAdjacentWebViewId(direction);
229 if (!adjacentId) {
230 console.error(`[EdgeGesture] no adjacentId`);
231 return;
232 }
233
234 const info = this.lm.getWebViewInfo(adjacentId);
235 if (!info) {
236 console.error(`[EdgeGesture] no WebViewInfo for ${adjacentId}`);
237 return;
238 }
239
240 // Create peek preview element if it doesn't exist
241 if (!this.peekPreview) {
242 this.peekPreview = document.createElement("div");
243 this.peekPreview.className = "mobile-peek-preview";
244 this.peekPreview.innerHTML = `
245 <div class="peek-header">
246 <img class="peek-favicon" src="" alt="">
247 <span class="peek-title"></span>
248 </div>
249 <img class="peek-screenshot" src="" alt="">
250 `;
251 document.body.appendChild(this.peekPreview);
252 }
253
254 // Update content
255 const favicon = this.peekPreview.querySelector(".peek-favicon");
256 const title = this.peekPreview.querySelector(".peek-title");
257 const screenshot = this.peekPreview.querySelector(".peek-screenshot");
258
259 favicon.src = info.favicon || "";
260 title.textContent = info.title;
261 if (info.screenshotUrl) {
262 screenshot.src = info.screenshotUrl;
263 screenshot.style.display = "block";
264 } else {
265 screenshot.style.display = "none";
266 }
267
268 // Position based on direction, no transition during drag
269 this.peekPreview.classList.remove("from-left", "from-right", "animating");
270 this.peekPreview.classList.add(
271 direction === "prev" ? "from-left" : "from-right",
272 );
273 this.peekPreview.classList.add("visible");
274 }
275
276 updatePeekPreview(distance) {
277 if (!this.peekPreview) {
278 return;
279 }
280
281 // Directly track the finger: translateX in pixels
282 if (this.peekPreview.classList.contains("from-left")) {
283 const offset = -window.innerWidth + distance;
284 this.peekPreview.style.transform = `translateX(${Math.min(offset, 0)}px)`;
285 } else {
286 const offset = window.innerWidth - distance;
287 this.peekPreview.style.transform = `translateX(${Math.max(offset, 0)}px)`;
288 }
289 }
290
291 animatePreviewIn(onDone) {
292 if (!this.peekPreview) {
293 onDone();
294 return;
295 }
296 this.peekPreview.classList.add("animating");
297 let called = false;
298 const done = () => {
299 if (called) return;
300 called = true;
301 onDone();
302 };
303 this.peekPreview.addEventListener("transitionend", done, { once: true });
304 setTimeout(done, 300);
305 this.peekPreview.style.transform = "translateX(0)";
306 }
307
308 animatePreviewOut() {
309 if (!this.peekPreview) {
310 return;
311 }
312 this.peekPreview.classList.add("animating");
313 let called = false;
314 const done = () => {
315 if (called) return;
316 called = true;
317 this.hidePeekPreview();
318 };
319 this.peekPreview.addEventListener("transitionend", done, { once: true });
320 setTimeout(done, 300);
321 // Animate back off-screen
322 if (this.peekPreview.classList.contains("from-left")) {
323 this.peekPreview.style.transform = `translateX(-100%)`;
324 } else {
325 this.peekPreview.style.transform = `translateX(100%)`;
326 }
327 }
328
329 hidePeekPreview() {
330 if (this.peekPreview) {
331 this.peekPreview.classList.remove("visible", "animating");
332 this.peekPreview.style.transform = "";
333 }
334 }
335
336 // Enable or disable specific edge overlays
337 // edges: object with left, right, bottom, top boolean properties
338 setEdgesEnabled(edges) {
339 for (const [edge, enabled] of Object.entries(edges)) {
340 const overlay = this.edgeOverlays[edge];
341 if (overlay) {
342 if (edge === "left" || edge === "right") {
343 overlay.style.width = enabled ? `${this.edgeThreshold}px` : "0";
344 } else {
345 overlay.style.height = enabled ? `${this.edgeThreshold}px` : "0";
346 }
347 }
348 }
349 }
350}