forked from
me.webbeef.org/browser.html
Rewild Your Web
1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3import { EdgeGestureHandler } from "./edge_gesture_handler.js";
4
5export class MobileLayoutManager {
6 constructor(rootElement, webViewBuilder) {
7 this.root = rootElement;
8 this.webViewBuilder = webViewBuilder;
9
10 // Linear array of webviews (carousel model)
11 this.webviews = new Map(); // webviewId -> { webview, index }
12 this.webviewOrder = []; // Array of webviewIds in order
13 this.activeIndex = -1;
14 this.activeWebviewId = null;
15 this.nextId = 1;
16
17 // Homescreen webview (special, cannot be closed, excluded from overview/swipe)
18 this.homescreenWebviewId = null;
19
20 // Overview mode state
21 this.overviewMode = false;
22 this.overviewElement = null;
23
24 // Gesture handler
25 this.gestureHandler = new EdgeGestureHandler(this);
26
27 // Action bar component
28 this.actionBar = null;
29
30 // Setup event listeners
31 this.setupEventListeners();
32 }
33
34 setupEventListeners() {
35 document.addEventListener("webview-close", (e) => {
36 this.removeWebView(e.detail.webviewId);
37 });
38
39 document.addEventListener("webview-focus", (e) => {
40 this.setActiveWebView(e.detail.webviewId);
41 });
42
43 // Listen for navigation state changes from webviews
44 document.addEventListener("navigation-state-changed", (e) => {
45 // Only update if it's from the active webview
46 if (e.detail.webviewId === this.activeWebviewId && this.actionBar) {
47 this.actionBar.updateState();
48 }
49 });
50
51 // Disable all edges when action bar or notification sheet opens
52 document.addEventListener("action-bar-opened", () => {
53 this.disableAllEdges();
54 });
55 document.addEventListener("action-bar-closed", () => {
56 this.restoreEdges();
57 });
58 document.addEventListener("sheet-opened", () => {
59 this.disableAllEdges();
60 });
61 document.addEventListener("sheet-closed", () => {
62 this.restoreEdges();
63 });
64 }
65
66 // Disable all edge gestures (when overlay UI is open)
67 disableAllEdges() {
68 this.gestureHandler.setEdgesEnabled({
69 left: false,
70 right: false,
71 bottom: false,
72 top: false,
73 });
74 }
75
76 // Restore edge gestures based on current state
77 restoreEdges() {
78 const onHomescreen = this.isHomescreen(this.activeWebviewId);
79 this.gestureHandler.setEdgesEnabled({
80 left: !onHomescreen,
81 right: !onHomescreen,
82 bottom: !onHomescreen,
83 top: true,
84 });
85 }
86
87 generateId() {
88 return `wv-${this.nextId++}`;
89 }
90
91 // Set the homescreen webview
92 setHomescreen(webviewId) {
93 this.homescreenWebviewId = webviewId;
94 // Update edge gestures if homescreen is currently active
95 if (this.activeWebviewId === webviewId) {
96 this.restoreEdges();
97 }
98 }
99
100 // Check if a webview is the homescreen
101 isHomescreen(webviewId) {
102 return webviewId === this.homescreenWebviewId;
103 }
104
105 // Get the currently active webview entry
106 getActiveEntry() {
107 if (this.activeWebviewId) {
108 return this.webviews.get(this.activeWebviewId);
109 }
110 return null;
111 }
112
113 // Add a new webview to the carousel
114 addWebView(webview) {
115 const id = this.generateId();
116 webview.webviewId = id;
117
118 // Mark webview as mobile mode for styling
119 webview.classList.add("mobile-mode");
120
121 // Add to the end of the order
122 const index = this.webviewOrder.length;
123 this.webviewOrder.push(id);
124 this.webviews.set(id, { webview, index });
125
126 // Create container for the webview
127 const container = document.createElement("div");
128 container.className = "mobile-webview-container";
129 container.dataset.webviewId = id;
130 container.appendChild(webview);
131 this.root.appendChild(container);
132
133 // Make this the active webview
134 this.setActiveWebView(id);
135
136 return webview;
137 }
138
139 // Remove a webview from the carousel
140 removeWebView(webviewId) {
141 // Prevent closing the homescreen
142 if (this.isHomescreen(webviewId)) {
143 console.warn("[MobileLayoutManager] Cannot close homescreen");
144 return;
145 }
146
147 const entry = this.webviews.get(webviewId);
148 if (!entry) {
149 return;
150 }
151
152 const { webview, index } = entry;
153
154 // Remove from DOM
155 const container = this.root.querySelector(
156 `.mobile-webview-container[data-webview-id="${webviewId}"]`,
157 );
158 if (container) {
159 container.remove();
160 }
161
162 // Remove from tracking
163 this.webviews.delete(webviewId);
164 this.webviewOrder = this.webviewOrder.filter((id) => id !== webviewId);
165
166 // Update indices for remaining webviews
167 this.webviewOrder.forEach((id, newIndex) => {
168 const e = this.webviews.get(id);
169 if (e) {
170 e.index = newIndex;
171 }
172 });
173
174 // If we removed the active webview, activate another one
175 if (this.activeWebviewId === webviewId) {
176 if (this.webviewOrder.length > 0) {
177 // Activate the webview at the same position or the last one
178 const newIndex = Math.min(index, this.webviewOrder.length - 1);
179 this.setActiveWebView(this.webviewOrder[newIndex]);
180 } else {
181 this.activeWebviewId = null;
182 this.activeIndex = -1;
183 }
184 }
185 }
186
187 // Set the active webview by ID
188 setActiveWebView(webviewId) {
189 // Remove active state from previous and capture its screenshot
190 if (this.activeWebviewId && this.webviews.has(this.activeWebviewId)) {
191 const prevEntry = this.webviews.get(this.activeWebviewId);
192 prevEntry.webview.active = false;
193
194 // Capture screenshot of the tab we're switching away from
195 // TODO: fix screenshot capture when not fully visible.
196 // prevEntry.webview.captureScreenshot(true);
197
198 const prevContainer = this.root.querySelector(
199 `.mobile-webview-container[data-webview-id="${this.activeWebviewId}"]`,
200 );
201 if (prevContainer) {
202 prevContainer.classList.remove("active");
203 }
204 }
205
206 this.activeWebviewId = webviewId;
207
208 // Set active state on new
209 if (webviewId && this.webviews.has(webviewId)) {
210 const entry = this.webviews.get(webviewId);
211 entry.webview.active = true;
212 this.activeIndex = entry.index;
213 const container = this.root.querySelector(
214 `.mobile-webview-container[data-webview-id="${webviewId}"]`,
215 );
216 if (container) {
217 container.classList.add("active");
218 }
219 }
220
221 // Update edge gesture areas based on whether we're on the homescreen
222 this.restoreEdges();
223 }
224
225 // Switch to webview at given index
226 switchTo(index) {
227 if (index < 0 || index >= this.webviewOrder.length) {
228 return;
229 }
230 const webviewId = this.webviewOrder[index];
231 this.setActiveWebView(webviewId);
232 }
233
234 // Navigate to next webview in carousel (skips homescreen)
235 nextWebView() {
236 const regularViews = this.webviewOrder.filter(
237 (id) => !this.isHomescreen(id),
238 );
239 if (regularViews.length <= 1) {
240 return;
241 }
242
243 const currentIndex = regularViews.indexOf(this.activeWebviewId);
244 if (currentIndex === -1) {
245 // Currently on homescreen, switch to first regular view
246 this.setActiveWebView(regularViews[0]);
247 } else {
248 const nextIndex = (currentIndex + 1) % regularViews.length;
249 this.setActiveWebView(regularViews[nextIndex]);
250 }
251 }
252
253 // Navigate to previous webview in carousel (skips homescreen)
254 prevWebView() {
255 const regularViews = this.webviewOrder.filter(
256 (id) => !this.isHomescreen(id),
257 );
258 if (regularViews.length <= 1) {
259 return;
260 }
261
262 const currentIndex = regularViews.indexOf(this.activeWebviewId);
263 if (currentIndex === -1) {
264 // Currently on homescreen, switch to last regular view
265 this.setActiveWebView(regularViews[regularViews.length - 1]);
266 } else {
267 const prevIndex =
268 (currentIndex - 1 + regularViews.length) % regularViews.length;
269 this.setActiveWebView(regularViews[prevIndex]);
270 }
271 }
272
273 // Get adjacent webview ID for peek preview (skips homescreen)
274 getAdjacentWebViewId(direction) {
275 const regularViews = this.webviewOrder.filter(
276 (id) => !this.isHomescreen(id),
277 );
278 if (regularViews.length <= 1) {
279 return null;
280 }
281
282 const currentIndex = regularViews.indexOf(this.activeWebviewId);
283 if (currentIndex === -1) {
284 // On homescreen, no peek
285 return null;
286 }
287
288 if (direction === "next") {
289 const nextIndex = (currentIndex + 1) % regularViews.length;
290 return regularViews[nextIndex];
291 } else {
292 const prevIndex =
293 (currentIndex - 1 + regularViews.length) % regularViews.length;
294 return regularViews[prevIndex];
295 }
296 }
297
298 // Get webview info for peek preview
299 getWebViewInfo(webviewId) {
300 const entry = this.webviews.get(webviewId);
301 if (!entry) {
302 return null;
303 }
304 return {
305 id: webviewId,
306 title: entry.webview.title || "Untitled",
307 favicon: entry.webview.favicon || "",
308 screenshotUrl: entry.webview.screenshotUrl || null,
309 };
310 }
311
312 // Show action bar (bottom slide-up)
313 // Not allowed when on homescreen
314 showActionBar() {
315 if (this.isHomescreen(this.activeWebviewId)) {
316 return;
317 }
318 if (this.actionBar) {
319 this.actionBar.show();
320 }
321 }
322
323 // Hide action bar
324 hideActionBar() {
325 if (this.actionBar) {
326 this.actionBar.hide();
327 }
328 }
329
330 // Toggle action bar
331 toggleActionBar() {
332 if (this.actionBar) {
333 this.actionBar.toggle();
334 }
335 }
336
337 // Set the action bar component reference
338 setActionBar(actionBar) {
339 this.actionBar = actionBar;
340 }
341
342 // Perform navigation action on active webview
343 goBack() {
344 const entry = this.getActiveEntry();
345 if (entry) {
346 entry.webview.goBack();
347 }
348 }
349
350 goForward() {
351 const entry = this.getActiveEntry();
352 if (entry) {
353 entry.webview.goForward();
354 }
355 }
356
357 reload() {
358 const entry = this.getActiveEntry();
359 if (entry) {
360 entry.webview.doReload();
361 }
362 }
363
364 // Navigate to URL in active webview
365 navigateTo(url) {
366 const entry = this.getActiveEntry();
367 if (entry) {
368 entry.webview.ensureIframe();
369 entry.webview.iframe.load(url);
370 }
371 }
372
373 // Get current URL of active webview
374 getCurrentUrl() {
375 const entry = this.getActiveEntry();
376 if (entry) {
377 return entry.webview.currentUrl || "";
378 }
379 return "";
380 }
381
382 // Get navigation state of active webview
383 getNavigationState() {
384 const entry = this.getActiveEntry();
385 if (entry) {
386 return {
387 canGoBack: entry.webview.canGoBack || false,
388 canGoForward: entry.webview.canGoForward || false,
389 };
390 }
391 return { canGoBack: false, canGoForward: false };
392 }
393
394 // Get tab count (excludes homescreen)
395 getTabCount() {
396 return this.webviewOrder.filter((id) => !this.isHomescreen(id)).length;
397 }
398
399 // Get tabs for overview (excludes homescreen)
400 getOverviewTabs() {
401 return this.webviewOrder
402 .filter((id) => !this.isHomescreen(id))
403 .map((id) => {
404 const entry = this.webviews.get(id);
405 return {
406 id,
407 title: entry.webview.title || "Untitled",
408 favicon: entry.webview.favicon || "",
409 screenshotUrl: entry.webview.screenshotUrl || null,
410 };
411 });
412 }
413
414 // ============================================================================
415 // Overview Mode (for compatibility, minimal implementation for MVP)
416 // ============================================================================
417
418 toggleOverview() {
419 if (this.overviewMode) {
420 this.hideOverview();
421 } else {
422 this.showOverview();
423 }
424 }
425
426 showOverview() {
427 this.overviewMode = true;
428 // TODO: Implement mobile tab overview grid
429 console.log(
430 "[MobileLayoutManager] Overview mode enabled (not yet implemented)",
431 );
432 }
433
434 hideOverview() {
435 this.overviewMode = false;
436 console.log("[MobileLayoutManager] Overview mode disabled");
437 }
438
439 // Navigation methods for compatibility with desktop shortcuts
440 nextPanel() {
441 this.nextWebView();
442 }
443
444 prevPanel() {
445 this.prevWebView();
446 }
447
448 scrollToPanel(index) {
449 this.switchTo(index);
450 }
451}