Rewild Your Web
web
browser
dweb
1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// ============================================================================
4// LayoutManager: Manages the split tree and CSS Grid layout
5// ============================================================================
6
7import "./overview.js"; // Registers the panel-overview custom element
8
9export class LayoutManager {
10 constructor(rootElement, webViewBuilder) {
11 this.root = rootElement;
12 // Top-level is an array of "panels" (each panel is a split tree)
13 // New tabs add new panels, splits divide within a panel
14 this.panels = []; // Array of { tree, element } where element is a panel container
15 this.webviews = new Map(); // webviewId -> { webview, panelIndex }
16 this.nextId = 1;
17 this.activeWebviewId = null;
18
19 // Sidebar icons container
20 this.viewsIconsContainer = document.getElementById("views-icons");
21
22 // Create floating preview element for sidebar hover
23 this.sidebarPreview = this.createSidebarPreview();
24
25 // Overview mode state
26 this.overviewMode = false;
27 this.overviewElement = null;
28
29 // Listen for webview events
30 document.addEventListener("webview-split", (e) => {
31 this.splitWebView(e.detail.webviewId, e.detail.direction);
32 });
33
34 document.addEventListener("webview-close", (e) => {
35 this.removeWebView(e.detail.webviewId);
36 });
37
38 document.addEventListener("webview-focus", (e) => {
39 this.setActiveWebView(e.detail.webviewId);
40 });
41
42 document.addEventListener("webview-resize-panel", (e) => {
43 this.resizePanel(e.detail.webviewId, e.detail.delta);
44 });
45
46 document.addEventListener("webview-favicon-change", (e) => {
47 this.updateSidebarIcon(e.detail.webviewId, e.detail.favicon);
48 });
49
50 this.webViewBuilder = webViewBuilder;
51 }
52
53 generateId() {
54 return `wv-${this.nextId++}`;
55 }
56
57 // Create a new panel (container for a split tree) - used by Cmd+T
58 createPanel() {
59 const panel = document.createElement("div");
60 panel.className = "panel";
61 this.root.appendChild(panel);
62 return panel;
63 }
64
65 // Apply panel width based on widthPercent
66 applyPanelWidth(panelIndex) {
67 const panel = this.panels[panelIndex];
68 if (!panel) {
69 return;
70 }
71 const widthPercent = panel.widthPercent || 100;
72 // Subtract sidebar width from viewport-based calculation
73 panel.element.style.width = `calc(${widthPercent}vw - var(--sidebar-width) - 1em)`;
74 panel.element.style.minWidth = `calc(${widthPercent}vw - var(--sidebar-width) - 1em)`;
75 }
76
77 // Resize a panel by delta percent (e.g., +10 or -10)
78 resizePanel(webviewId, delta) {
79 const entry = this.webviews.get(webviewId);
80 if (!entry) {
81 return;
82 }
83 const panel = this.panels[entry.panelIndex];
84 if (!panel) {
85 return;
86 }
87 const currentWidth = panel.widthPercent || 100;
88 const newWidth = Math.max(30, Math.min(200, currentWidth + delta));
89 panel.widthPercent = newWidth;
90 this.applyPanelWidth(entry.panelIndex);
91 }
92
93 // Add a new webview as a new panel (Cmd+T behavior)
94 addWebView(webview) {
95 const id = this.generateId();
96 webview.webviewId = id;
97
98 // Create a new panel for this webview
99 const panelElement = this.createPanel();
100 const panelIndex = this.panels.length;
101
102 this.panels.push({
103 tree: { type: "leaf", webviewId: id },
104 element: panelElement,
105 });
106
107 this.webviews.set(id, { webview, panelIndex });
108 panelElement.appendChild(webview);
109
110 // Create sidebar icon for this webview
111 this.createSidebarIcon(id);
112
113 this.applyPanelLayout(panelIndex);
114 this.setActiveWebView(id);
115 this.scrollToPanel(panelIndex);
116 return webview;
117 }
118
119 // Scroll to a specific panel
120 scrollToPanel(panelIndex) {
121 const panel = this.panels[panelIndex];
122 if (panel && panel.element) {
123 panel.element.scrollIntoView({ behavior: "smooth", inline: "start" });
124 }
125 }
126
127 // Navigate to the next panel
128 nextPanel() {
129 if (this.panels.length <= 1) {
130 return;
131 }
132 const entry = this.webviews.get(this.activeWebviewId);
133 if (!entry) {
134 return;
135 }
136 const nextIndex = (entry.panelIndex + 1) % this.panels.length;
137 const leaf = this.findFirstLeaf(this.panels[nextIndex].tree);
138 if (leaf) {
139 this.setActiveWebView(leaf.webviewId);
140 this.scrollToPanel(nextIndex);
141 }
142 }
143
144 // Navigate to the previous panel
145 prevPanel() {
146 if (this.panels.length <= 1) {
147 return;
148 }
149 const entry = this.webviews.get(this.activeWebviewId);
150 if (!entry) {
151 return;
152 }
153 const prevIndex =
154 (entry.panelIndex - 1 + this.panels.length) % this.panels.length;
155 const leaf = this.findFirstLeaf(this.panels[prevIndex].tree);
156 if (leaf) {
157 this.setActiveWebView(leaf.webviewId);
158 this.scrollToPanel(prevIndex);
159 }
160 }
161
162 // Split within the same panel (split button behavior)
163 splitWebView(webviewId, direction) {
164 const entry = this.webviews.get(webviewId);
165 if (!entry) {
166 return;
167 }
168
169 const { panelIndex } = entry;
170 const panel = this.panels[panelIndex];
171
172 const newId = this.generateId();
173 const newWebview = this.webViewBuilder();
174 newWebview.webviewId = newId;
175
176 this.webviews.set(newId, { webview: newWebview, panelIndex });
177 panel.element.appendChild(newWebview);
178
179 // Create sidebar icon for this webview
180 this.createSidebarIcon(newId);
181
182 // Replace the leaf with a split node
183 // ratio is the fraction of space given to the first child (0.5 = 50/50)
184 panel.tree = this.replaceLeaf(panel.tree, webviewId, {
185 type: "split",
186 direction,
187 ratio: 0.5,
188 children: [
189 { type: "leaf", webviewId },
190 { type: "leaf", webviewId: newId },
191 ],
192 });
193
194 this.applyPanelLayout(panelIndex);
195 this.setActiveWebView(newId);
196 }
197
198 removeWebView(webviewId) {
199 const entry = this.webviews.get(webviewId);
200 if (!entry) {
201 return;
202 }
203
204 const { webview, panelIndex } = entry;
205 webview.remove();
206 this.webviews.delete(webviewId);
207
208 // Remove sidebar icon for this webview
209 this.removeSidebarIcon(webviewId);
210
211 const panel = this.panels[panelIndex];
212 panel.tree = this.removeLeaf(panel.tree, webviewId);
213
214 // If panel is empty, remove it
215 if (!panel.tree) {
216 panel.element.remove();
217 this.panels.splice(panelIndex, 1);
218
219 // Update panel indices for remaining webviews
220 for (const [id, entry] of this.webviews) {
221 if (entry.panelIndex > panelIndex) {
222 entry.panelIndex--;
223 }
224 }
225
226 // No panels left, bail out.
227 if (this.panels.length === 0) {
228 return;
229 }
230 } else {
231 this.applyPanelLayout(panelIndex);
232 }
233
234 // Update active webview if needed
235 if (this.activeWebviewId === webviewId) {
236 const firstLeaf = this.findFirstLeafInAnyPanel();
237 if (firstLeaf) {
238 this.setActiveWebView(firstLeaf);
239 }
240 }
241 }
242
243 findFirstLeafInAnyPanel() {
244 for (const panel of this.panels) {
245 const leaf = this.findFirstLeaf(panel.tree);
246 if (leaf) {
247 return leaf.webviewId;
248 }
249 }
250 return null;
251 }
252
253 setActiveWebView(webviewId) {
254 // Remove active state from previous
255 if (this.activeWebviewId && this.webviews.has(this.activeWebviewId)) {
256 this.webviews.get(this.activeWebviewId).webview.active = false;
257 }
258
259 // Update sidebar icon active states
260 const prevIcon = this.viewsIconsContainer?.querySelector(
261 `[data-webview-id="${this.activeWebviewId}"]`,
262 );
263 if (prevIcon) {
264 prevIcon.classList.remove("active");
265 }
266
267 this.activeWebviewId = webviewId;
268
269 // Set active state on new
270 if (webviewId && this.webviews.has(webviewId)) {
271 this.webviews.get(webviewId).webview.active = true;
272 }
273
274 const newIcon = this.viewsIconsContainer?.querySelector(
275 `[data-webview-id="${webviewId}"]`,
276 );
277 if (newIcon) {
278 newIcon.classList.add("active");
279 }
280 }
281
282 // ============================================================================
283 // Sidebar Icon Management
284 // ============================================================================
285
286 // Create a sidebar icon for a webview
287 createSidebarIcon(webviewId) {
288 if (!this.viewsIconsContainer) {
289 return;
290 }
291
292 const icon = document.createElement("div");
293 icon.className = "view-icon";
294 icon.dataset.webviewId = webviewId;
295
296 // Create an img element for the favicon
297 const img = document.createElement("img");
298 img.className = "view-icon-img";
299 img.src = ""; // Will be set when favicon loads
300 icon.appendChild(img);
301
302 // Click handler to activate and scroll to the webview
303 icon.addEventListener("click", () => {
304 const entry = this.webviews.get(webviewId);
305 if (entry) {
306 this.setActiveWebView(webviewId);
307 this.scrollToPanel(entry.panelIndex);
308 }
309 });
310
311 // Hover handlers for floating preview
312 icon.addEventListener("mouseenter", () => {
313 this.showSidebarPreview(webviewId, icon);
314 });
315
316 icon.addEventListener("mouseleave", () => {
317 this.hideSidebarPreview();
318 });
319
320 this.viewsIconsContainer.appendChild(icon);
321 }
322
323 // Update the sidebar icon favicon
324 updateSidebarIcon(webviewId, favicon) {
325 if (!this.viewsIconsContainer) {
326 return;
327 }
328
329 const icon = this.viewsIconsContainer.querySelector(
330 `[data-webview-id="${webviewId}"]`,
331 );
332 if (icon) {
333 const img = icon.querySelector(".view-icon-img");
334 if (img) {
335 img.src = favicon || "";
336 }
337 }
338 }
339
340 // Remove a sidebar icon
341 removeSidebarIcon(webviewId) {
342 if (!this.viewsIconsContainer) {
343 return;
344 }
345
346 const icon = this.viewsIconsContainer.querySelector(
347 `[data-webview-id="${webviewId}"]`,
348 );
349 if (icon) {
350 icon.remove();
351 }
352 }
353
354 // Create the floating preview element
355 createSidebarPreview() {
356 const preview = document.createElement("div");
357 preview.className = "sidebar-preview";
358 preview.innerHTML = `
359 <div class="sidebar-preview-title"></div>
360 <img class="sidebar-preview-screenshot" />
361 `;
362 document.body.appendChild(preview);
363 return preview;
364 }
365
366 // Show the sidebar preview for a webview
367 showSidebarPreview(webviewId, iconElement) {
368 const entry = this.webviews.get(webviewId);
369 if (!entry) {
370 return;
371 }
372
373 const { webview } = entry;
374 const title = this.sidebarPreview.querySelector(".sidebar-preview-title");
375 const screenshot = this.sidebarPreview.querySelector(
376 ".sidebar-preview-screenshot",
377 );
378
379 title.textContent = webview.title || "Untitled";
380
381 if (webview.screenshotUrl) {
382 screenshot.src = webview.screenshotUrl;
383 screenshot.style.display = "block";
384 } else {
385 screenshot.style.display = "none";
386 }
387
388 // Position the preview to the right of the icon
389 const iconRect = iconElement.getBoundingClientRect();
390 this.sidebarPreview.style.left = `${iconRect.right + 8}px`;
391 this.sidebarPreview.style.top = `${iconRect.top}px`;
392
393 this.sidebarPreview.classList.add("visible");
394 }
395
396 // Hide the sidebar preview
397 hideSidebarPreview() {
398 this.sidebarPreview.classList.remove("visible");
399 }
400
401 // Replace a leaf node with a new node (used for splitting)
402 replaceLeaf(node, webviewId, replacement) {
403 if (!node) {
404 return null;
405 }
406
407 if (node.type === "leaf") {
408 return node.webviewId === webviewId ? replacement : node;
409 }
410
411 return {
412 ...node,
413 children: node.children.map((child) =>
414 this.replaceLeaf(child, webviewId, replacement),
415 ),
416 };
417 }
418
419 // Remove a leaf node and collapse single-child splits
420 removeLeaf(node, webviewId) {
421 if (!node) {
422 return null;
423 }
424
425 if (node.type === "leaf") {
426 return node.webviewId === webviewId ? null : node;
427 }
428
429 // Process children
430 const newChildren = node.children
431 .map((child) => this.removeLeaf(child, webviewId))
432 .filter((child) => child !== null);
433
434 // If no children left, this node is gone
435 if (newChildren.length === 0) {
436 return null;
437 }
438
439 // If only one child, collapse the split
440 if (newChildren.length === 1) {
441 return newChildren[0];
442 }
443
444 return { ...node, children: newChildren };
445 }
446
447 findFirstLeaf(node) {
448 if (!node) {
449 return null;
450 }
451 if (node.type === "leaf") {
452 return node;
453 }
454 return this.findFirstLeaf(node.children[0]);
455 }
456
457 // Apply grid layout within a single panel
458 applyPanelLayout(panelIndex) {
459 const panel = this.panels[panelIndex];
460 if (!panel || !panel.tree) {
461 return;
462 }
463
464 // Remove existing resize handles
465 panel.element.querySelectorAll(".resize-handle").forEach((h) => h.remove());
466
467 // Collect all split points to create grid tracks
468 const colPoints = new Set([0, 100]);
469 const rowPoints = new Set([0, 100]);
470 this.collectSplitPoints(panel.tree, 0, 100, 0, 100, colPoints, rowPoints);
471
472 // Sort points to create ordered grid lines
473 const colLines = [...colPoints].sort((a, b) => a - b);
474 const rowLines = [...rowPoints].sort((a, b) => a - b);
475
476 // Generate grid template from split points (using fr units based on percentages)
477 const colTemplate = [];
478 for (let i = 0; i < colLines.length - 1; i++) {
479 colTemplate.push(`${colLines[i + 1] - colLines[i]}fr`);
480 }
481 const rowTemplate = [];
482 for (let i = 0; i < rowLines.length - 1; i++) {
483 rowTemplate.push(`${rowLines[i + 1] - rowLines[i]}fr`);
484 }
485
486 panel.element.style.gridTemplateColumns = colTemplate.join(" ");
487 panel.element.style.gridTemplateRows = rowTemplate.join(" ");
488
489 // Compute grid positions for each webview
490 const positions = this.computeGridPositions(
491 panel.tree,
492 0,
493 100,
494 0,
495 100,
496 colLines,
497 rowLines,
498 );
499
500 for (const pos of positions) {
501 const entry = this.webviews.get(pos.webviewId);
502 if (entry) {
503 const wv = entry.webview;
504 wv.style.gridColumn = pos.gridColumn;
505 wv.style.gridRow = pos.gridRow;
506 }
507 }
508
509 // Add resize handles for splits
510 this.addResizeHandles(panel, panelIndex, colLines, rowLines);
511 }
512
513 // Collect all split points in the tree
514 collectSplitPoints(node, left, right, top, bottom, colPoints, rowPoints) {
515 if (node.type === "leaf") {
516 return;
517 }
518
519 const ratio = node.ratio || 0.5;
520
521 if (node.direction === "horizontal") {
522 const mid = left + (right - left) * ratio;
523 colPoints.add(mid);
524 this.collectSplitPoints(
525 node.children[0],
526 left,
527 mid,
528 top,
529 bottom,
530 colPoints,
531 rowPoints,
532 );
533 this.collectSplitPoints(
534 node.children[1],
535 mid,
536 right,
537 top,
538 bottom,
539 colPoints,
540 rowPoints,
541 );
542 } else {
543 const mid = top + (bottom - top) * ratio;
544 rowPoints.add(mid);
545 this.collectSplitPoints(
546 node.children[0],
547 left,
548 right,
549 top,
550 mid,
551 colPoints,
552 rowPoints,
553 );
554 this.collectSplitPoints(
555 node.children[1],
556 left,
557 right,
558 mid,
559 bottom,
560 colPoints,
561 rowPoints,
562 );
563 }
564 }
565
566 // Compute grid column/row for each webview
567 computeGridPositions(node, left, right, top, bottom, colLines, rowLines) {
568 if (node.type === "leaf") {
569 // Find which grid lines correspond to our bounds
570 const colStart = colLines.indexOf(left) + 1;
571 const colEnd = colLines.indexOf(right) + 1;
572 const rowStart = rowLines.indexOf(top) + 1;
573 const rowEnd = rowLines.indexOf(bottom) + 1;
574
575 return [
576 {
577 webviewId: node.webviewId,
578 gridColumn: `${colStart} / ${colEnd}`,
579 gridRow: `${rowStart} / ${rowEnd}`,
580 },
581 ];
582 }
583
584 const ratio = node.ratio || 0.5;
585
586 if (node.direction === "horizontal") {
587 const mid = left + (right - left) * ratio;
588 return [
589 ...this.computeGridPositions(
590 node.children[0],
591 left,
592 mid,
593 top,
594 bottom,
595 colLines,
596 rowLines,
597 ),
598 ...this.computeGridPositions(
599 node.children[1],
600 mid,
601 right,
602 top,
603 bottom,
604 colLines,
605 rowLines,
606 ),
607 ];
608 } else {
609 const mid = top + (bottom - top) * ratio;
610 return [
611 ...this.computeGridPositions(
612 node.children[0],
613 left,
614 right,
615 top,
616 mid,
617 colLines,
618 rowLines,
619 ),
620 ...this.computeGridPositions(
621 node.children[1],
622 left,
623 right,
624 mid,
625 bottom,
626 colLines,
627 rowLines,
628 ),
629 ];
630 }
631 }
632
633 // Find split node that contains a webview
634 findParentSplit(node, webviewId, parent = null) {
635 if (!node) {
636 return null;
637 }
638 if (node.type === "leaf") {
639 return node.webviewId === webviewId ? parent : null;
640 }
641 for (const child of node.children) {
642 const result = this.findParentSplit(child, webviewId, node);
643 if (result) {
644 return result;
645 }
646 }
647 return null;
648 }
649
650 // Add resize handles between split children
651 addResizeHandles(panel, panelIndex, colLines, rowLines) {
652 const handles = this.collectSplitHandles(panel.tree, 0, 100, 0, 100);
653
654 for (const handle of handles) {
655 const handleEl = document.createElement("div");
656 handleEl.className = `resize-handle resize-handle-${handle.direction}`;
657 // Use absolute positioning for handles to overlay the gap
658 handleEl.style.position = "absolute";
659
660 if (handle.direction === "horizontal") {
661 // Vertical bar at horizontal split point
662 const leftPercent = handle.position;
663 const topPercent = handle.top;
664 const heightPercent = handle.bottom - handle.top;
665 handleEl.style.left = `calc(${leftPercent}% - 0.25em)`;
666 handleEl.style.top = `${topPercent}%`;
667 handleEl.style.width = "0.5em";
668 handleEl.style.height = `${heightPercent}%`;
669 } else {
670 // Horizontal bar at vertical split point
671 const topPercent = handle.position;
672 const leftPercent = handle.left;
673 const widthPercent = handle.right - handle.left;
674 handleEl.style.left = `${leftPercent}%`;
675 handleEl.style.top = `calc(${topPercent}% - 0.25em)`;
676 handleEl.style.width = `${widthPercent}%`;
677 handleEl.style.height = "0.5em";
678 }
679
680 handleEl.addEventListener("mousedown", (e) => {
681 e.preventDefault();
682 this.startResize(e, handle.splitNode, handle.direction, panelIndex);
683 });
684
685 panel.element.appendChild(handleEl);
686 }
687 }
688
689 // Collect information about where to place resize handles
690 collectSplitHandles(node, left = 0, right = 100, top = 0, bottom = 100) {
691 if (node.type === "leaf") {
692 return [];
693 }
694
695 const handles = [];
696 const ratio = node.ratio || 0.5;
697
698 if (node.direction === "horizontal") {
699 const mid = left + (right - left) * ratio;
700 handles.push({
701 splitNode: node,
702 direction: "horizontal",
703 position: mid,
704 top,
705 bottom,
706 });
707 handles.push(
708 ...this.collectSplitHandles(node.children[0], left, mid, top, bottom),
709 );
710 handles.push(
711 ...this.collectSplitHandles(node.children[1], mid, right, top, bottom),
712 );
713 } else {
714 const mid = top + (bottom - top) * ratio;
715 handles.push({
716 splitNode: node,
717 direction: "vertical",
718 position: mid,
719 left,
720 right,
721 });
722 handles.push(
723 ...this.collectSplitHandles(node.children[0], left, right, top, mid),
724 );
725 handles.push(
726 ...this.collectSplitHandles(node.children[1], left, right, mid, bottom),
727 );
728 }
729
730 return handles;
731 }
732
733 // Start resize operation
734 startResize(e, splitNode, direction, panelIndex) {
735 const panel = this.panels[panelIndex];
736 const panelRect = panel.element.getBoundingClientRect();
737 const startX = e.clientX;
738 const startY = e.clientY;
739 const startRatio = splitNode.ratio || 0.5;
740
741 // Disable pointer events on all webviews during resize
742 document.body.classList.add("resizing");
743
744 const onMouseMove = (e) => {
745 e.preventDefault();
746 e.stopPropagation();
747
748 let delta;
749 if (direction === "horizontal") {
750 delta = (e.clientX - startX) / panelRect.width;
751 } else {
752 delta = (e.clientY - startY) / panelRect.height;
753 }
754 splitNode.ratio = Math.max(0.1, Math.min(0.9, startRatio + delta));
755 this.applyPanelLayout(panelIndex);
756 };
757
758 const onMouseUp = (e) => {
759 e.preventDefault();
760 e.stopPropagation();
761
762 document.body.removeEventListener("mousemove", onMouseMove, true);
763 document.body.removeEventListener("mouseup", onMouseUp, true);
764 document.body.style.cursor = "";
765 document.body.style.userSelect = "";
766
767 // Re-enable pointer events on webviews
768 document.body.classList.remove("resizing");
769 };
770
771 // Use capture phase on body to intercept events before they reach webviews
772 document.body.addEventListener("mousemove", onMouseMove, true);
773 document.body.addEventListener("mouseup", onMouseUp, true);
774 document.body.style.cursor =
775 direction === "horizontal" ? "col-resize" : "row-resize";
776 document.body.style.userSelect = "none";
777 }
778
779 // ============================================================================
780 // Overview Mode
781 // ============================================================================
782
783 // Toggle overview mode on/off
784 toggleOverview() {
785 if (this.overviewMode) {
786 this.hideOverview();
787 } else {
788 this.showOverview();
789 }
790 }
791
792 // Show overview mode with screenshots of all webviews
793 showOverview() {
794 if (this.overviewMode) {
795 return;
796 }
797 this.overviewMode = true;
798
799 // Ensure the overview element exists
800 this.ensureOverviewElement();
801
802 // Build overview data and set properties directly on the element
803 const overviewData = this.buildOverviewData();
804 this.overviewElement.panels = overviewData.panels;
805 this.overviewElement.rootWidth = overviewData.rootWidth;
806 this.overviewElement.rootHeight = overviewData.rootHeight;
807 this.overviewElement.open = true;
808 }
809
810 // Hide overview mode
811 hideOverview() {
812 if (!this.overviewMode) {
813 return;
814 }
815 this.overviewMode = false;
816
817 if (this.overviewElement) {
818 this.overviewElement.open = false;
819 }
820 }
821
822 // Ensure overview element exists
823 ensureOverviewElement() {
824 if (this.overviewElement) {
825 return;
826 }
827
828 // Create the panel-overview element
829 const overview = document.createElement("panel-overview");
830 document.body.appendChild(overview);
831 this.overviewElement = overview;
832
833 // Listen for events from the overview element
834 overview.addEventListener("overview-select", (e) => {
835 this.setActiveWebView(e.detail.webviewId);
836 if (e.detail.panelIndex !== undefined) {
837 this.scrollToPanel(e.detail.panelIndex);
838 }
839 });
840
841 overview.addEventListener("overview-close", () => {
842 this.hideOverview();
843 });
844 }
845
846 // Build the data structure for the overview element
847 buildOverviewData() {
848 const panels = [];
849
850 // Get the root's bounding rect as reference for relative positioning
851 const rootRect = this.root.getBoundingClientRect();
852
853 for (let panelIndex = 0; panelIndex < this.panels.length; panelIndex++) {
854 const panel = this.panels[panelIndex];
855 const panelRect = panel.element.getBoundingClientRect();
856
857 // Build thumbnails data with actual bounding rects
858 const thumbnails = [];
859 const leafIds = this.collectLeafIds(panel.tree);
860
861 for (const webviewId of leafIds) {
862 const entry = this.webviews.get(webviewId);
863 if (!entry) continue;
864
865 // Get the webview's bounding rect relative to the panel
866 const wvRect = entry.webview.getBoundingClientRect();
867
868 thumbnails.push({
869 webviewId: webviewId,
870 panelIndex: panelIndex,
871 title: entry.webview.title,
872 screenshotUrl: entry.webview.screenshotUrl || null,
873 themeColor: entry.webview.themeColor || null,
874 active: webviewId === this.activeWebviewId,
875 // Position relative to panel origin
876 x: wvRect.left - panelRect.left,
877 y: wvRect.top - panelRect.top,
878 width: wvRect.width,
879 height: wvRect.height,
880 });
881 }
882
883 panels.push({
884 width: panelRect.width,
885 height: panelRect.height,
886 thumbnails,
887 });
888 }
889
890 return {
891 panels,
892 rootWidth: rootRect.width,
893 rootHeight: rootRect.height,
894 };
895 }
896
897 // Collect all leaf webview IDs from a tree
898 collectLeafIds(node) {
899 if (!node) {
900 return [];
901 }
902 if (node.type === "leaf") {
903 return [node.webviewId];
904 }
905 return [
906 ...this.collectLeafIds(node.children[0]),
907 ...this.collectLeafIds(node.children[1]),
908 ];
909 }
910}