tangled
alpha
login
or
join now
me.webbeef.org
/
browser.html
Rewild Your Web
web
browser
dweb
5
fork
atom
overview
issues
1
pulls
pipelines
system: improve mobile gestures
webbeef.tngl.sh
3 days ago
246ded04
52b759ec
+162
-112
5 changed files
expand all
collapse all
unified
split
resources
browserhtml
system
edge_gesture_handler.js
index.js
mobile.css
mobile_layout_manager.js
web_view.js
+96
-80
resources/browserhtml/system/edge_gesture_handler.js
···
6
6
7
7
// Configuration
8
8
this.edgeThreshold = 30; // px from edge to trigger edge swipe
9
9
-
this.swipeThreshold = 50; // px to complete a swipe action
10
10
-
this.longSwipeThreshold = 200; // px for long swipe (overview mode)
9
9
+
this.commitThreshold = 0.4; // fraction of viewport to commit swipe
11
10
12
11
// Touch state
13
12
this.touchStartX = 0;
···
20
19
21
20
// Peek preview element
22
21
this.peekPreview = null;
22
22
+
23
23
+
// Active overlay element (expanded during gesture)
24
24
+
this.activeOverlay = null;
23
25
24
26
// Edge overlay elements
25
27
this.edgeOverlays = {};
···
51
53
edges.forEach((edge) => {
52
54
const overlay = document.createElement("div");
53
55
overlay.className = `gesture-edge-overlay gesture-edge-${edge.name}`;
54
54
-
overlay.style.cssText = `
55
55
-
position: fixed;
56
56
-
${edge.style}
57
57
-
z-index: var(--z-gesture);
58
58
-
touch-action: none;
59
59
-
pointer-events: auto;
60
60
-
background: transparent; /* rgba(77, 215, 220, 0.49); */
61
61
-
`;
56
56
+
overlay.style.cssText = edge.style;
62
57
overlay.dataset.edge = edge.name;
63
58
document.body.appendChild(overlay);
64
59
this.edgeOverlays[edge.name] = overlay;
···
80
75
}
81
76
82
77
onEdgeTouchStart(event) {
83
83
-
console.log(`[EdgeGesture] touchstart`);
84
84
-
console.log("onEdgeTouchStart");
85
78
if (event.touches.length !== 1) {
86
79
return;
87
80
}
···
97
90
this.isEdgeSwipe = true;
98
91
this.edgeDirection = event.currentTarget.dataset.edge;
99
92
93
93
+
// Expand overlay to full screen so we keep receiving touch events
94
94
+
this.activeOverlay = event.currentTarget;
95
95
+
this.activeOverlay.classList.add("fullscreen-overlay");
96
96
+
100
97
event.preventDefault();
101
98
}
102
99
103
100
onTouchMove(event) {
104
104
-
console.log(`[EdgeGesture] touchmove isEdgeSwipe=${this.isEdgeSwipe}`);
105
105
-
106
101
if (!this.isEdgeSwipe || event.touches.length !== 1) {
107
102
return;
108
103
}
···
131
126
}
132
127
133
128
onTouchEnd() {
134
134
-
console.log(`[EdgeGesture] touchend ${this.edgeDirection}`);
135
129
if (!this.isEdgeSwipe) {
136
130
return;
137
131
}
138
132
139
133
const deltaX = this.touchCurrentX - this.touchStartX;
140
140
-
const deltaY = this.touchCurrentY - this.touchStartY;
141
134
142
135
// Complete the gesture based on direction and distance
143
136
if (this.edgeDirection === "left") {
144
144
-
this.completeLeftEdgeSwipe(deltaX);
137
137
+
this.completeEdgeSwipe(deltaX, "prev");
145
138
} else if (this.edgeDirection === "right") {
146
146
-
this.completeRightEdgeSwipe(-deltaX);
139
139
+
this.completeEdgeSwipe(-deltaX, "next");
147
140
}
148
141
149
149
-
// Reset state
150
150
-
this.isEdgeSwipe = false;
151
151
-
this.edgeDirection = null;
152
152
-
this.isPeeking = false;
153
153
-
this.hidePeekPreview();
142
142
+
this.resetGestureState();
154
143
}
155
144
156
145
onTouchCancel() {
157
157
-
console.log(`[EdgeGesture] touchcancel`);
146
146
+
this.resetGestureState();
147
147
+
this.animatePreviewOut();
148
148
+
}
149
149
+
150
150
+
resetGestureState() {
151
151
+
if (this.activeOverlay) {
152
152
+
this.activeOverlay.classList.remove("fullscreen-overlay");
153
153
+
this.activeOverlay = null;
154
154
+
}
158
155
this.isEdgeSwipe = false;
159
156
this.edgeDirection = null;
160
157
this.isPeeking = false;
161
161
-
this.hidePeekPreview();
162
158
}
163
159
164
160
// ============================================================================
···
166
162
// ============================================================================
167
163
168
164
handleLeftEdgeMove(distance) {
169
169
-
const progress = Math.min(distance / this.swipeThreshold, 1);
170
170
-
console.log(
171
171
-
`[EdgeGesture] handleLeftEdgeMove distance=${distance} progress=${progress} isPeeking=${this.isPeeking}`,
172
172
-
);
173
173
-
174
174
-
// Show peek preview of previous webview
175
175
-
if (!this.isPeeking && progress > 0.1) {
176
176
-
this.isPeeking = true;
177
177
-
this.showPeekPreview("prev", progress);
178
178
-
} else if (this.isPeeking) {
179
179
-
this.updatePeekPreview(progress);
180
180
-
}
181
181
-
}
182
182
-
183
183
-
completeLeftEdgeSwipe(distance) {
184
184
-
if (distance >= this.longSwipeThreshold) {
185
185
-
// Long swipe: enter overview mode
186
186
-
this.lm.showOverview();
187
187
-
} else if (distance >= this.swipeThreshold) {
188
188
-
// Normal swipe: switch to previous webview
189
189
-
this.lm.prevWebView();
190
190
-
}
191
191
-
// Otherwise: cancelled, peek snaps back
165
165
+
this.handleHorizontalEdgeMove(distance, "prev");
192
166
}
193
167
194
168
// ============================================================================
···
196
170
// ============================================================================
197
171
198
172
handleRightEdgeMove(distance) {
199
199
-
const progress = Math.min(distance / this.swipeThreshold, 1);
173
173
+
this.handleHorizontalEdgeMove(distance, "next");
174
174
+
}
200
175
201
201
-
// Show peek preview of next webview
202
202
-
if (!this.isPeeking && progress > 0.1) {
176
176
+
// ============================================================================
177
177
+
// Shared horizontal edge logic
178
178
+
// ============================================================================
179
179
+
180
180
+
handleHorizontalEdgeMove(distance, direction) {
181
181
+
if (!this.isPeeking) {
203
182
this.isPeeking = true;
204
204
-
this.showPeekPreview("next", progress);
205
205
-
} else if (this.isPeeking) {
206
206
-
this.updatePeekPreview(progress);
183
183
+
this.showPeekPreview(direction);
207
184
}
185
185
+
this.updatePeekPreview(distance);
208
186
}
209
187
210
210
-
completeRightEdgeSwipe(distance) {
211
211
-
if (distance >= this.longSwipeThreshold) {
212
212
-
// Long swipe: enter overview mode
213
213
-
this.lm.showOverview();
214
214
-
} else if (distance >= this.swipeThreshold) {
215
215
-
// Normal swipe: switch to next webview
216
216
-
this.lm.nextWebView();
188
188
+
completeEdgeSwipe(distance, direction) {
189
189
+
const threshold = window.innerWidth * this.commitThreshold;
190
190
+
if (distance >= threshold) {
191
191
+
// Animate preview to fully cover the viewport, then switch
192
192
+
this.animatePreviewIn(() => {
193
193
+
if (direction === "prev") {
194
194
+
this.lm.prevWebView();
195
195
+
} else {
196
196
+
this.lm.nextWebView();
197
197
+
}
198
198
+
this.hidePeekPreview();
199
199
+
});
200
200
+
} else {
201
201
+
// Animate preview back out
202
202
+
this.animatePreviewOut();
217
203
}
218
204
}
219
205
···
222
208
// ============================================================================
223
209
224
210
handleBottomEdgeMove(distance) {
225
225
-
console.log(`[EdgeGesture] handleBottomEdgeMove ${distance}px`);
226
211
if (distance >= this.edgeThreshold / 2) {
227
227
-
// Swipe up from bottom: show action bar
212
212
+
this.resetGestureState();
228
213
this.lm.showActionBar();
229
214
}
230
215
}
···
235
220
236
221
handleTopEdgeMove(distance) {
237
222
if (distance >= this.edgeThreshold / 2) {
238
238
-
// Swipe down from top: show notifications
239
239
-
// Dispatch event for notification panel
223
223
+
this.resetGestureState();
240
224
document.dispatchEvent(
241
225
new CustomEvent("mobile-show-notifications", { bubbles: true }),
242
226
);
···
247
231
// Peek Preview
248
232
// ============================================================================
249
233
250
250
-
showPeekPreview(direction, progress) {
251
251
-
console.log(`[EdgeGesture] showPeekPreview ${direction} ${progress}`);
234
234
+
showPeekPreview(direction) {
252
235
const adjacentId = this.lm.getAdjacentWebViewId(direction);
253
236
if (!adjacentId) {
254
237
console.error(`[EdgeGesture] no adjacentId`);
···
289
272
screenshot.style.display = "none";
290
273
}
291
274
292
292
-
// Position based on direction
293
293
-
this.peekPreview.classList.remove("from-left", "from-right");
275
275
+
// Position based on direction, no transition during drag
276
276
+
this.peekPreview.classList.remove("from-left", "from-right", "animating");
294
277
this.peekPreview.classList.add(
295
278
direction === "prev" ? "from-left" : "from-right",
296
279
);
297
280
this.peekPreview.classList.add("visible");
298
298
-
299
299
-
this.updatePeekPreview(progress);
300
281
}
301
282
302
302
-
updatePeekPreview(progress) {
303
303
-
console.log(`[EdgeGesture] updatePeekPreview ${progress}`);
283
283
+
updatePeekPreview(distance) {
304
284
if (!this.peekPreview) {
305
285
return;
306
286
}
307
287
308
308
-
// Animate the preview sliding in
309
309
-
const maxTranslate = 30; // percentage of screen width to reveal
310
310
-
const translatePercent = progress * maxTranslate;
288
288
+
// Directly track the finger: translateX in pixels
289
289
+
if (this.peekPreview.classList.contains("from-left")) {
290
290
+
const offset = -window.innerWidth + distance;
291
291
+
this.peekPreview.style.transform = `translateX(${Math.min(offset, 0)}px)`;
292
292
+
} else {
293
293
+
const offset = window.innerWidth - distance;
294
294
+
this.peekPreview.style.transform = `translateX(${Math.max(offset, 0)}px)`;
295
295
+
}
296
296
+
}
311
297
298
298
+
animatePreviewIn(onDone) {
299
299
+
if (!this.peekPreview) {
300
300
+
onDone();
301
301
+
return;
302
302
+
}
303
303
+
this.peekPreview.classList.add("animating");
304
304
+
let called = false;
305
305
+
const done = () => {
306
306
+
if (called) return;
307
307
+
called = true;
308
308
+
onDone();
309
309
+
};
310
310
+
this.peekPreview.addEventListener("transitionend", done, { once: true });
311
311
+
setTimeout(done, 300);
312
312
+
this.peekPreview.style.transform = "translateX(0)";
313
313
+
}
314
314
+
315
315
+
animatePreviewOut() {
316
316
+
if (!this.peekPreview) {
317
317
+
return;
318
318
+
}
319
319
+
this.peekPreview.classList.add("animating");
320
320
+
let called = false;
321
321
+
const done = () => {
322
322
+
if (called) return;
323
323
+
called = true;
324
324
+
this.hidePeekPreview();
325
325
+
};
326
326
+
this.peekPreview.addEventListener("transitionend", done, { once: true });
327
327
+
setTimeout(done, 300);
328
328
+
// Animate back off-screen
312
329
if (this.peekPreview.classList.contains("from-left")) {
313
313
-
this.peekPreview.style.transform = `translateX(${-100 + translatePercent}%)`;
330
330
+
this.peekPreview.style.transform = `translateX(-100%)`;
314
331
} else {
315
315
-
this.peekPreview.style.transform = `translateX(${100 - translatePercent}%)`;
332
332
+
this.peekPreview.style.transform = `translateX(100%)`;
316
333
}
317
334
}
318
335
319
336
hidePeekPreview() {
320
320
-
console.log(`[EdgeGesture] hidePeekPreview`);
321
337
if (this.peekPreview) {
322
322
-
this.peekPreview.classList.remove("visible");
338
338
+
this.peekPreview.classList.remove("visible", "animating");
323
339
this.peekPreview.style.transform = "";
324
340
}
325
341
}
-2
resources/browserhtml/system/index.js
···
590
590
591
591
// Override layout manager's showOverview to use mobile tab overview
592
592
layoutManager.showOverview = function () {
593
593
-
// Capture screenshots for all tabs before showing overview
594
594
-
layoutManager.captureAllScreenshots();
595
593
updateTabOverviewData();
596
594
mobileOverview.open = true;
597
595
};
+36
-7
resources/browserhtml/system/mobile.css
···
26
26
bottom: var(--keyboard-offset);
27
27
opacity: 0;
28
28
visibility: hidden;
29
29
-
transition: opacity 0.3s ease, visibility 0.3s ease, bottom 0.3s ease;
29
29
+
transition:
30
30
+
opacity 0.3s ease,
31
31
+
visibility 0.3s ease,
32
32
+
bottom 0.3s ease;
30
33
z-index: var(--z-base);
31
34
}
32
35
···
72
75
.mobile-peek-preview {
73
76
position: fixed;
74
77
top: 0;
75
75
-
width: 30%;
78
78
+
width: 100%;
76
79
height: 100%;
77
80
background: var(--bg-menu);
78
81
z-index: var(--z-gesture);
···
81
84
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
82
85
opacity: 0;
83
86
visibility: hidden;
84
84
-
transition: opacity 0.2s ease, visibility 0.2s ease;
85
87
}
86
88
87
89
.mobile-peek-preview.visible {
···
89
91
visibility: visible;
90
92
}
91
93
94
94
+
/* Animate transform on release (commit or cancel) */
95
95
+
.mobile-peek-preview.animating {
96
96
+
transition: transform var(--transition-fast);
97
97
+
}
98
98
+
92
99
.mobile-peek-preview.from-left {
93
100
left: 0;
94
101
transform: translateX(-100%);
95
95
-
border-right: 2px solid var(--color-focus-ring);
96
102
}
97
103
98
104
.mobile-peek-preview.from-right {
99
105
right: 0;
100
106
left: auto;
101
107
transform: translateX(100%);
102
102
-
border-left: 2px solid var(--color-focus-ring);
103
108
}
104
109
105
110
.peek-header {
···
155
160
z-index: var(--z-gesture);
156
161
opacity: 0;
157
162
visibility: hidden;
158
158
-
transition: opacity 0.2s ease, visibility 0.2s ease;
163
163
+
transition:
164
164
+
opacity 0.2s ease,
165
165
+
visibility 0.2s ease;
159
166
}
160
167
161
168
.mobile-tab-indicator.visible {
···
168
175
height: 8px;
169
176
border-radius: 50%;
170
177
background: rgba(255, 255, 255, 0.3);
171
171
-
transition: background 0.2s ease, transform 0.2s ease;
178
178
+
transition:
179
179
+
background 0.2s ease,
180
180
+
transform 0.2s ease;
172
181
}
173
182
174
183
.tab-dot.active {
···
280
289
height: 4px;
281
290
background: linear-gradient(to bottom, var(--color-focus-ring), transparent);
282
291
}
292
292
+
293
293
+
.gesture-edge-overlay {
294
294
+
position: fixed;
295
295
+
z-index: calc(var(--z-gesture) + 1);
296
296
+
touch-action: none;
297
297
+
pointer-events: auto;
298
298
+
background: transparent;
299
299
+
/* rgba(77, 215, 220, 0.49); */
300
300
+
}
301
301
+
302
302
+
/* Expand edge overlay to full screen during active gesture,
303
303
+
above the peek preview so we keep receiving touch events */
304
304
+
.gesture-edge-overlay.fullscreen-overlay {
305
305
+
left: 0 !important;
306
306
+
top: 0 !important;
307
307
+
right: auto !important;
308
308
+
bottom: auto !important;
309
309
+
width: 100% !important;
310
310
+
height: 100% !important;
311
311
+
}
+17
-19
resources/browserhtml/system/mobile_layout_manager.js
···
114
114
115
115
// Remove from DOM
116
116
const container = this.root.querySelector(
117
117
-
`.mobile-webview-container[data-webview-id="${webviewId}"]`
117
117
+
`.mobile-webview-container[data-webview-id="${webviewId}"]`,
118
118
);
119
119
if (container) {
120
120
container.remove();
···
153
153
prevEntry.webview.active = false;
154
154
155
155
// Capture screenshot of the tab we're switching away from
156
156
-
if (prevEntry.webview.captureScreenshot) {
157
157
-
prevEntry.webview.captureScreenshot();
158
158
-
}
156
156
+
// TODO: fix screenshot capture when not fully visible.
157
157
+
// prevEntry.webview.captureScreenshot(true);
159
158
160
159
const prevContainer = this.root.querySelector(
161
161
-
`.mobile-webview-container[data-webview-id="${this.activeWebviewId}"]`
160
160
+
`.mobile-webview-container[data-webview-id="${this.activeWebviewId}"]`,
162
161
);
163
162
if (prevContainer) {
164
163
prevContainer.classList.remove("active");
···
173
172
entry.webview.active = true;
174
173
this.activeIndex = entry.index;
175
174
const container = this.root.querySelector(
176
176
-
`.mobile-webview-container[data-webview-id="${webviewId}"]`
175
175
+
`.mobile-webview-container[data-webview-id="${webviewId}"]`,
177
176
);
178
177
if (container) {
179
178
container.classList.add("active");
···
192
191
193
192
// Navigate to next webview in carousel (skips homescreen)
194
193
nextWebView() {
195
195
-
const regularViews = this.webviewOrder.filter((id) => !this.isHomescreen(id));
194
194
+
const regularViews = this.webviewOrder.filter(
195
195
+
(id) => !this.isHomescreen(id),
196
196
+
);
196
197
if (regularViews.length <= 1) {
197
198
return;
198
199
}
···
209
210
210
211
// Navigate to previous webview in carousel (skips homescreen)
211
212
prevWebView() {
212
212
-
const regularViews = this.webviewOrder.filter((id) => !this.isHomescreen(id));
213
213
+
const regularViews = this.webviewOrder.filter(
214
214
+
(id) => !this.isHomescreen(id),
215
215
+
);
213
216
if (regularViews.length <= 1) {
214
217
return;
215
218
}
···
227
230
228
231
// Get adjacent webview ID for peek preview (skips homescreen)
229
232
getAdjacentWebViewId(direction) {
230
230
-
const regularViews = this.webviewOrder.filter((id) => !this.isHomescreen(id));
233
233
+
const regularViews = this.webviewOrder.filter(
234
234
+
(id) => !this.isHomescreen(id),
235
235
+
);
231
236
if (regularViews.length <= 1) {
232
237
return null;
233
238
}
···
364
369
});
365
370
}
366
371
367
367
-
// Capture screenshots for all webviews (for tab overview)
368
368
-
captureAllScreenshots() {
369
369
-
for (const [id, entry] of this.webviews) {
370
370
-
if (entry.webview.captureScreenshot) {
371
371
-
entry.webview.captureScreenshot();
372
372
-
}
373
373
-
}
374
374
-
}
375
375
-
376
372
// ============================================================================
377
373
// Overview Mode (for compatibility, minimal implementation for MVP)
378
374
// ============================================================================
···
388
384
showOverview() {
389
385
this.overviewMode = true;
390
386
// TODO: Implement mobile tab overview grid
391
391
-
console.log("[MobileLayoutManager] Overview mode enabled (not yet implemented)");
387
387
+
console.log(
388
388
+
"[MobileLayoutManager] Overview mode enabled (not yet implemented)",
389
389
+
);
392
390
}
393
391
394
392
hideOverview() {
+13
-4
resources/browserhtml/system/web_view.js
···
791
791
canGoForward: this.canGoForward,
792
792
url: this.currentUrl,
793
793
},
794
794
-
})
794
794
+
}),
795
795
);
796
796
797
797
// Capture screenshot for overview mode after a short delay
···
800
800
}
801
801
802
802
// Capture a screenshot and cache it for overview mode
803
803
-
captureScreenshot() {
803
803
+
captureScreenshot(immediate = false) {
804
804
// Debounce: clear any pending capture
805
805
if (this.screenshotTimeout) {
806
806
clearTimeout(this.screenshotTimeout);
807
807
}
808
808
809
809
-
// Delay capture to allow page to render
810
810
-
this.screenshotTimeout = setTimeout(() => {
809
809
+
let doCapture = () => {
811
810
this.getContentIframe()
812
811
.takeScreenshot()
813
812
.then((blob) => {
···
822
821
.catch(() => {
823
822
// Ignore screenshot errors (e.g., for off-screen webviews)
824
823
});
824
824
+
};
825
825
+
826
826
+
if (immediate) {
827
827
+
doCapture();
828
828
+
return;
829
829
+
}
830
830
+
831
831
+
// Delay capture to allow page to render
832
832
+
this.screenshotTimeout = setTimeout(() => {
833
833
+
doCapture();
825
834
}, 500);
826
835
}
827
836