Rewild Your Web
web browser dweb
at main 910 lines 25 kB view raw
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}