// SPDX-License-Identifier: AGPL-3.0-or-later
// ============================================================================
// LayoutManager: Manages the split tree and CSS Grid layout
// ============================================================================
import "./overview.js"; // Registers the panel-overview custom element
export class LayoutManager {
constructor(rootElement, webViewBuilder) {
this.root = rootElement;
// Top-level is an array of "panels" (each panel is a split tree)
// New tabs add new panels, splits divide within a panel
this.panels = []; // Array of { tree, element } where element is a panel container
this.webviews = new Map(); // webviewId -> { webview, panelIndex }
this.nextId = 1;
this.activeWebviewId = null;
// Sidebar icons container
this.viewsIconsContainer = document.getElementById("views-icons");
// Create floating preview element for sidebar hover
this.sidebarPreview = this.createSidebarPreview();
// Overview mode state
this.overviewMode = false;
this.overviewElement = null;
// Listen for webview events
document.addEventListener("webview-split", (e) => {
this.splitWebView(e.detail.webviewId, e.detail.direction);
});
document.addEventListener("webview-close", (e) => {
this.removeWebView(e.detail.webviewId);
});
document.addEventListener("webview-focus", (e) => {
this.setActiveWebView(e.detail.webviewId);
});
document.addEventListener("webview-resize-panel", (e) => {
this.resizePanel(e.detail.webviewId, e.detail.delta);
});
document.addEventListener("webview-favicon-change", (e) => {
this.updateSidebarIcon(e.detail.webviewId, e.detail.favicon);
});
this.webViewBuilder = webViewBuilder;
}
generateId() {
return `wv-${this.nextId++}`;
}
// Create a new panel (container for a split tree) - used by Cmd+T
createPanel() {
const panel = document.createElement("div");
panel.className = "panel";
this.root.appendChild(panel);
return panel;
}
// Apply panel width based on widthPercent
applyPanelWidth(panelIndex) {
const panel = this.panels[panelIndex];
if (!panel) {
return;
}
const widthPercent = panel.widthPercent || 100;
// Subtract sidebar width from viewport-based calculation
panel.element.style.width = `calc(${widthPercent}vw - var(--sidebar-width) - 1em)`;
panel.element.style.minWidth = `calc(${widthPercent}vw - var(--sidebar-width) - 1em)`;
}
// Resize a panel by delta percent (e.g., +10 or -10)
resizePanel(webviewId, delta) {
const entry = this.webviews.get(webviewId);
if (!entry) {
return;
}
const panel = this.panels[entry.panelIndex];
if (!panel) {
return;
}
const currentWidth = panel.widthPercent || 100;
const newWidth = Math.max(30, Math.min(200, currentWidth + delta));
panel.widthPercent = newWidth;
this.applyPanelWidth(entry.panelIndex);
}
// Add a new webview as a new panel (Cmd+T behavior)
addWebView(webview) {
const id = this.generateId();
webview.webviewId = id;
// Create a new panel for this webview
const panelElement = this.createPanel();
const panelIndex = this.panels.length;
this.panels.push({
tree: { type: "leaf", webviewId: id },
element: panelElement,
});
this.webviews.set(id, { webview, panelIndex });
panelElement.appendChild(webview);
// Create sidebar icon for this webview
this.createSidebarIcon(id);
this.applyPanelLayout(panelIndex);
this.setActiveWebView(id);
this.scrollToPanel(panelIndex);
return webview;
}
// Scroll to a specific panel
scrollToPanel(panelIndex) {
const panel = this.panels[panelIndex];
if (panel && panel.element) {
panel.element.scrollIntoView({ behavior: "smooth", inline: "start" });
}
}
// Navigate to the next panel
nextPanel() {
if (this.panels.length <= 1) {
return;
}
const entry = this.webviews.get(this.activeWebviewId);
if (!entry) {
return;
}
const nextIndex = (entry.panelIndex + 1) % this.panels.length;
const leaf = this.findFirstLeaf(this.panels[nextIndex].tree);
if (leaf) {
this.setActiveWebView(leaf.webviewId);
this.scrollToPanel(nextIndex);
}
}
// Navigate to the previous panel
prevPanel() {
if (this.panels.length <= 1) {
return;
}
const entry = this.webviews.get(this.activeWebviewId);
if (!entry) {
return;
}
const prevIndex =
(entry.panelIndex - 1 + this.panels.length) % this.panels.length;
const leaf = this.findFirstLeaf(this.panels[prevIndex].tree);
if (leaf) {
this.setActiveWebView(leaf.webviewId);
this.scrollToPanel(prevIndex);
}
}
// Split within the same panel (split button behavior)
splitWebView(webviewId, direction) {
const entry = this.webviews.get(webviewId);
if (!entry) {
return;
}
const { panelIndex } = entry;
const panel = this.panels[panelIndex];
const newId = this.generateId();
const newWebview = this.webViewBuilder();
newWebview.webviewId = newId;
this.webviews.set(newId, { webview: newWebview, panelIndex });
panel.element.appendChild(newWebview);
// Create sidebar icon for this webview
this.createSidebarIcon(newId);
// Replace the leaf with a split node
// ratio is the fraction of space given to the first child (0.5 = 50/50)
panel.tree = this.replaceLeaf(panel.tree, webviewId, {
type: "split",
direction,
ratio: 0.5,
children: [
{ type: "leaf", webviewId },
{ type: "leaf", webviewId: newId },
],
});
this.applyPanelLayout(panelIndex);
this.setActiveWebView(newId);
}
removeWebView(webviewId) {
const entry = this.webviews.get(webviewId);
if (!entry) {
return;
}
const { webview, panelIndex } = entry;
webview.remove();
this.webviews.delete(webviewId);
// Remove sidebar icon for this webview
this.removeSidebarIcon(webviewId);
const panel = this.panels[panelIndex];
panel.tree = this.removeLeaf(panel.tree, webviewId);
// If panel is empty, remove it
if (!panel.tree) {
panel.element.remove();
this.panels.splice(panelIndex, 1);
// Update panel indices for remaining webviews
for (const [id, entry] of this.webviews) {
if (entry.panelIndex > panelIndex) {
entry.panelIndex--;
}
}
// No panels left, bail out.
if (this.panels.length === 0) {
return;
}
} else {
this.applyPanelLayout(panelIndex);
}
// Update active webview if needed
if (this.activeWebviewId === webviewId) {
const firstLeaf = this.findFirstLeafInAnyPanel();
if (firstLeaf) {
this.setActiveWebView(firstLeaf);
}
}
}
findFirstLeafInAnyPanel() {
for (const panel of this.panels) {
const leaf = this.findFirstLeaf(panel.tree);
if (leaf) {
return leaf.webviewId;
}
}
return null;
}
setActiveWebView(webviewId) {
// Remove active state from previous
if (this.activeWebviewId && this.webviews.has(this.activeWebviewId)) {
this.webviews.get(this.activeWebviewId).webview.active = false;
}
// Update sidebar icon active states
const prevIcon = this.viewsIconsContainer?.querySelector(
`[data-webview-id="${this.activeWebviewId}"]`,
);
if (prevIcon) {
prevIcon.classList.remove("active");
}
this.activeWebviewId = webviewId;
// Set active state on new
if (webviewId && this.webviews.has(webviewId)) {
this.webviews.get(webviewId).webview.active = true;
}
const newIcon = this.viewsIconsContainer?.querySelector(
`[data-webview-id="${webviewId}"]`,
);
if (newIcon) {
newIcon.classList.add("active");
}
}
// ============================================================================
// Sidebar Icon Management
// ============================================================================
// Create a sidebar icon for a webview
createSidebarIcon(webviewId) {
if (!this.viewsIconsContainer) {
return;
}
const icon = document.createElement("div");
icon.className = "view-icon";
icon.dataset.webviewId = webviewId;
// Create an img element for the favicon
const img = document.createElement("img");
img.className = "view-icon-img";
img.src = ""; // Will be set when favicon loads
icon.appendChild(img);
// Click handler to activate and scroll to the webview
icon.addEventListener("click", () => {
const entry = this.webviews.get(webviewId);
if (entry) {
this.setActiveWebView(webviewId);
this.scrollToPanel(entry.panelIndex);
}
});
// Hover handlers for floating preview
icon.addEventListener("mouseenter", () => {
this.showSidebarPreview(webviewId, icon);
});
icon.addEventListener("mouseleave", () => {
this.hideSidebarPreview();
});
this.viewsIconsContainer.appendChild(icon);
}
// Update the sidebar icon favicon
updateSidebarIcon(webviewId, favicon) {
if (!this.viewsIconsContainer) {
return;
}
const icon = this.viewsIconsContainer.querySelector(
`[data-webview-id="${webviewId}"]`,
);
if (icon) {
const img = icon.querySelector(".view-icon-img");
if (img) {
img.src = favicon || "";
}
}
}
// Remove a sidebar icon
removeSidebarIcon(webviewId) {
if (!this.viewsIconsContainer) {
return;
}
const icon = this.viewsIconsContainer.querySelector(
`[data-webview-id="${webviewId}"]`,
);
if (icon) {
icon.remove();
}
}
// Create the floating preview element
createSidebarPreview() {
const preview = document.createElement("div");
preview.className = "sidebar-preview";
preview.innerHTML = `
`;
document.body.appendChild(preview);
return preview;
}
// Show the sidebar preview for a webview
showSidebarPreview(webviewId, iconElement) {
const entry = this.webviews.get(webviewId);
if (!entry) {
return;
}
const { webview } = entry;
const title = this.sidebarPreview.querySelector(".sidebar-preview-title");
const screenshot = this.sidebarPreview.querySelector(
".sidebar-preview-screenshot",
);
title.textContent = webview.title || "Untitled";
if (webview.screenshotUrl) {
screenshot.src = webview.screenshotUrl;
screenshot.style.display = "block";
} else {
screenshot.style.display = "none";
}
// Position the preview to the right of the icon
const iconRect = iconElement.getBoundingClientRect();
this.sidebarPreview.style.left = `${iconRect.right + 8}px`;
this.sidebarPreview.style.top = `${iconRect.top}px`;
this.sidebarPreview.classList.add("visible");
}
// Hide the sidebar preview
hideSidebarPreview() {
this.sidebarPreview.classList.remove("visible");
}
// Replace a leaf node with a new node (used for splitting)
replaceLeaf(node, webviewId, replacement) {
if (!node) {
return null;
}
if (node.type === "leaf") {
return node.webviewId === webviewId ? replacement : node;
}
return {
...node,
children: node.children.map((child) =>
this.replaceLeaf(child, webviewId, replacement),
),
};
}
// Remove a leaf node and collapse single-child splits
removeLeaf(node, webviewId) {
if (!node) {
return null;
}
if (node.type === "leaf") {
return node.webviewId === webviewId ? null : node;
}
// Process children
const newChildren = node.children
.map((child) => this.removeLeaf(child, webviewId))
.filter((child) => child !== null);
// If no children left, this node is gone
if (newChildren.length === 0) {
return null;
}
// If only one child, collapse the split
if (newChildren.length === 1) {
return newChildren[0];
}
return { ...node, children: newChildren };
}
findFirstLeaf(node) {
if (!node) {
return null;
}
if (node.type === "leaf") {
return node;
}
return this.findFirstLeaf(node.children[0]);
}
// Apply grid layout within a single panel
applyPanelLayout(panelIndex) {
const panel = this.panels[panelIndex];
if (!panel || !panel.tree) {
return;
}
// Remove existing resize handles
panel.element.querySelectorAll(".resize-handle").forEach((h) => h.remove());
// Collect all split points to create grid tracks
const colPoints = new Set([0, 100]);
const rowPoints = new Set([0, 100]);
this.collectSplitPoints(panel.tree, 0, 100, 0, 100, colPoints, rowPoints);
// Sort points to create ordered grid lines
const colLines = [...colPoints].sort((a, b) => a - b);
const rowLines = [...rowPoints].sort((a, b) => a - b);
// Generate grid template from split points (using fr units based on percentages)
const colTemplate = [];
for (let i = 0; i < colLines.length - 1; i++) {
colTemplate.push(`${colLines[i + 1] - colLines[i]}fr`);
}
const rowTemplate = [];
for (let i = 0; i < rowLines.length - 1; i++) {
rowTemplate.push(`${rowLines[i + 1] - rowLines[i]}fr`);
}
panel.element.style.gridTemplateColumns = colTemplate.join(" ");
panel.element.style.gridTemplateRows = rowTemplate.join(" ");
// Compute grid positions for each webview
const positions = this.computeGridPositions(
panel.tree,
0,
100,
0,
100,
colLines,
rowLines,
);
for (const pos of positions) {
const entry = this.webviews.get(pos.webviewId);
if (entry) {
const wv = entry.webview;
wv.style.gridColumn = pos.gridColumn;
wv.style.gridRow = pos.gridRow;
}
}
// Add resize handles for splits
this.addResizeHandles(panel, panelIndex, colLines, rowLines);
}
// Collect all split points in the tree
collectSplitPoints(node, left, right, top, bottom, colPoints, rowPoints) {
if (node.type === "leaf") {
return;
}
const ratio = node.ratio || 0.5;
if (node.direction === "horizontal") {
const mid = left + (right - left) * ratio;
colPoints.add(mid);
this.collectSplitPoints(
node.children[0],
left,
mid,
top,
bottom,
colPoints,
rowPoints,
);
this.collectSplitPoints(
node.children[1],
mid,
right,
top,
bottom,
colPoints,
rowPoints,
);
} else {
const mid = top + (bottom - top) * ratio;
rowPoints.add(mid);
this.collectSplitPoints(
node.children[0],
left,
right,
top,
mid,
colPoints,
rowPoints,
);
this.collectSplitPoints(
node.children[1],
left,
right,
mid,
bottom,
colPoints,
rowPoints,
);
}
}
// Compute grid column/row for each webview
computeGridPositions(node, left, right, top, bottom, colLines, rowLines) {
if (node.type === "leaf") {
// Find which grid lines correspond to our bounds
const colStart = colLines.indexOf(left) + 1;
const colEnd = colLines.indexOf(right) + 1;
const rowStart = rowLines.indexOf(top) + 1;
const rowEnd = rowLines.indexOf(bottom) + 1;
return [
{
webviewId: node.webviewId,
gridColumn: `${colStart} / ${colEnd}`,
gridRow: `${rowStart} / ${rowEnd}`,
},
];
}
const ratio = node.ratio || 0.5;
if (node.direction === "horizontal") {
const mid = left + (right - left) * ratio;
return [
...this.computeGridPositions(
node.children[0],
left,
mid,
top,
bottom,
colLines,
rowLines,
),
...this.computeGridPositions(
node.children[1],
mid,
right,
top,
bottom,
colLines,
rowLines,
),
];
} else {
const mid = top + (bottom - top) * ratio;
return [
...this.computeGridPositions(
node.children[0],
left,
right,
top,
mid,
colLines,
rowLines,
),
...this.computeGridPositions(
node.children[1],
left,
right,
mid,
bottom,
colLines,
rowLines,
),
];
}
}
// Find split node that contains a webview
findParentSplit(node, webviewId, parent = null) {
if (!node) {
return null;
}
if (node.type === "leaf") {
return node.webviewId === webviewId ? parent : null;
}
for (const child of node.children) {
const result = this.findParentSplit(child, webviewId, node);
if (result) {
return result;
}
}
return null;
}
// Add resize handles between split children
addResizeHandles(panel, panelIndex, colLines, rowLines) {
const handles = this.collectSplitHandles(panel.tree, 0, 100, 0, 100);
for (const handle of handles) {
const handleEl = document.createElement("div");
handleEl.className = `resize-handle resize-handle-${handle.direction}`;
// Use absolute positioning for handles to overlay the gap
handleEl.style.position = "absolute";
if (handle.direction === "horizontal") {
// Vertical bar at horizontal split point
const leftPercent = handle.position;
const topPercent = handle.top;
const heightPercent = handle.bottom - handle.top;
handleEl.style.left = `calc(${leftPercent}% - 0.25em)`;
handleEl.style.top = `${topPercent}%`;
handleEl.style.width = "0.5em";
handleEl.style.height = `${heightPercent}%`;
} else {
// Horizontal bar at vertical split point
const topPercent = handle.position;
const leftPercent = handle.left;
const widthPercent = handle.right - handle.left;
handleEl.style.left = `${leftPercent}%`;
handleEl.style.top = `calc(${topPercent}% - 0.25em)`;
handleEl.style.width = `${widthPercent}%`;
handleEl.style.height = "0.5em";
}
handleEl.addEventListener("mousedown", (e) => {
e.preventDefault();
this.startResize(e, handle.splitNode, handle.direction, panelIndex);
});
panel.element.appendChild(handleEl);
}
}
// Collect information about where to place resize handles
collectSplitHandles(node, left = 0, right = 100, top = 0, bottom = 100) {
if (node.type === "leaf") {
return [];
}
const handles = [];
const ratio = node.ratio || 0.5;
if (node.direction === "horizontal") {
const mid = left + (right - left) * ratio;
handles.push({
splitNode: node,
direction: "horizontal",
position: mid,
top,
bottom,
});
handles.push(
...this.collectSplitHandles(node.children[0], left, mid, top, bottom),
);
handles.push(
...this.collectSplitHandles(node.children[1], mid, right, top, bottom),
);
} else {
const mid = top + (bottom - top) * ratio;
handles.push({
splitNode: node,
direction: "vertical",
position: mid,
left,
right,
});
handles.push(
...this.collectSplitHandles(node.children[0], left, right, top, mid),
);
handles.push(
...this.collectSplitHandles(node.children[1], left, right, mid, bottom),
);
}
return handles;
}
// Start resize operation
startResize(e, splitNode, direction, panelIndex) {
const panel = this.panels[panelIndex];
const panelRect = panel.element.getBoundingClientRect();
const startX = e.clientX;
const startY = e.clientY;
const startRatio = splitNode.ratio || 0.5;
// Disable pointer events on all webviews during resize
document.body.classList.add("resizing");
const onMouseMove = (e) => {
e.preventDefault();
e.stopPropagation();
let delta;
if (direction === "horizontal") {
delta = (e.clientX - startX) / panelRect.width;
} else {
delta = (e.clientY - startY) / panelRect.height;
}
splitNode.ratio = Math.max(0.1, Math.min(0.9, startRatio + delta));
this.applyPanelLayout(panelIndex);
};
const onMouseUp = (e) => {
e.preventDefault();
e.stopPropagation();
document.body.removeEventListener("mousemove", onMouseMove, true);
document.body.removeEventListener("mouseup", onMouseUp, true);
document.body.style.cursor = "";
document.body.style.userSelect = "";
// Re-enable pointer events on webviews
document.body.classList.remove("resizing");
};
// Use capture phase on body to intercept events before they reach webviews
document.body.addEventListener("mousemove", onMouseMove, true);
document.body.addEventListener("mouseup", onMouseUp, true);
document.body.style.cursor =
direction === "horizontal" ? "col-resize" : "row-resize";
document.body.style.userSelect = "none";
}
// ============================================================================
// Overview Mode
// ============================================================================
// Toggle overview mode on/off
toggleOverview() {
if (this.overviewMode) {
this.hideOverview();
} else {
this.showOverview();
}
}
// Show overview mode with screenshots of all webviews
showOverview() {
if (this.overviewMode) {
return;
}
this.overviewMode = true;
// Ensure the overview element exists
this.ensureOverviewElement();
// Build overview data and set properties directly on the element
const overviewData = this.buildOverviewData();
this.overviewElement.panels = overviewData.panels;
this.overviewElement.rootWidth = overviewData.rootWidth;
this.overviewElement.rootHeight = overviewData.rootHeight;
this.overviewElement.open = true;
}
// Hide overview mode
hideOverview() {
if (!this.overviewMode) {
return;
}
this.overviewMode = false;
if (this.overviewElement) {
this.overviewElement.open = false;
}
}
// Ensure overview element exists
ensureOverviewElement() {
if (this.overviewElement) {
return;
}
// Create the panel-overview element
const overview = document.createElement("panel-overview");
document.body.appendChild(overview);
this.overviewElement = overview;
// Listen for events from the overview element
overview.addEventListener("overview-select", (e) => {
this.setActiveWebView(e.detail.webviewId);
if (e.detail.panelIndex !== undefined) {
this.scrollToPanel(e.detail.panelIndex);
}
});
overview.addEventListener("overview-close", () => {
this.hideOverview();
});
}
// Build the data structure for the overview element
buildOverviewData() {
const panels = [];
// Get the root's bounding rect as reference for relative positioning
const rootRect = this.root.getBoundingClientRect();
for (let panelIndex = 0; panelIndex < this.panels.length; panelIndex++) {
const panel = this.panels[panelIndex];
const panelRect = panel.element.getBoundingClientRect();
// Build thumbnails data with actual bounding rects
const thumbnails = [];
const leafIds = this.collectLeafIds(panel.tree);
for (const webviewId of leafIds) {
const entry = this.webviews.get(webviewId);
if (!entry) continue;
// Get the webview's bounding rect relative to the panel
const wvRect = entry.webview.getBoundingClientRect();
thumbnails.push({
webviewId: webviewId,
panelIndex: panelIndex,
title: entry.webview.title,
screenshotUrl: entry.webview.screenshotUrl || null,
themeColor: entry.webview.themeColor || null,
active: webviewId === this.activeWebviewId,
// Position relative to panel origin
x: wvRect.left - panelRect.left,
y: wvRect.top - panelRect.top,
width: wvRect.width,
height: wvRect.height,
});
}
panels.push({
width: panelRect.width,
height: panelRect.height,
thumbnails,
});
}
return {
panels,
rootWidth: rootRect.width,
rootHeight: rootRect.height,
};
}
// Collect all leaf webview IDs from a tree
collectLeafIds(node) {
if (!node) {
return [];
}
if (node.type === "leaf") {
return [node.webviewId];
}
return [
...this.collectLeafIds(node.children[0]),
...this.collectLeafIds(node.children[1]),
];
}
}