diff --git a/chrome.html b/chrome.html index 35224c7..bba398a 100644 --- a/chrome.html +++ b/chrome.html @@ -39,13 +39,24 @@ even when tabs are next to each other. */ .tab .gdot { width: 8px; height: 8px; flex: none; border-radius: 50%; } .tab.grp { box-shadow: inset 0 2px 0 var(--gc, transparent); } - .tab.g-red, .gdot.g-red { --gc: #f6768a; } .gdot.g-red { background: #f6768a; } - .tab.g-orange, .gdot.g-orange { --gc: #ffa96a; } .gdot.g-orange { background: #ffa96a; } - .tab.g-yellow, .gdot.g-yellow { --gc: #ffd44f; } .gdot.g-yellow { background: #ffd44f; } - .tab.g-green, .gdot.g-green { --gc: #4fd1a5; } .gdot.g-green { background: #4fd1a5; } - .tab.g-cyan, .gdot.g-cyan { --gc: #4fd1e5; } .gdot.g-cyan { background: #4fd1e5; } - .tab.g-blue, .gdot.g-blue { --gc: #4b7bec; } .gdot.g-blue { background: #4b7bec; } - .tab.g-purple, .gdot.g-purple { --gc: #b39ddb; } .gdot.g-purple { background: #b39ddb; } + /* Group chip that appears before the first tab of a group. Clicking + collapses the group (hides its member tabs) and shows a count. */ + .gchip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; + background: var(--surface); border: 1px solid var(--line2); border-bottom: none; + border-radius: 8px 8px 0 0; cursor: pointer; height: 28px; + font: 12px/1 system-ui, sans-serif; color: var(--mut); + box-shadow: inset 0 2px 0 var(--gc, transparent); } + .gchip:hover { color: var(--ink); } + .gchip .gcdot { width: 10px; height: 10px; border-radius: 50%; background: var(--gc); flex: none; } + .gchip .gccnt { color: var(--ink); font-weight: 600; } + .gchip.collapsed { padding: 4px 10px; } + .tab.g-red, .gdot.g-red, .gcdot.g-red { --gc: #f6768a; } .gdot.g-red { background: #f6768a; } + .tab.g-orange, .gdot.g-orange, .gcdot.g-orange { --gc: #ffa96a; } .gdot.g-orange { background: #ffa96a; } + .tab.g-yellow, .gdot.g-yellow, .gcdot.g-yellow { --gc: #ffd44f; } .gdot.g-yellow { background: #ffd44f; } + .tab.g-green, .gdot.g-green, .gcdot.g-green { --gc: #4fd1a5; } .gdot.g-green { background: #4fd1a5; } + .tab.g-cyan, .gdot.g-cyan, .gcdot.g-cyan { --gc: #4fd1e5; } .gdot.g-cyan { background: #4fd1e5; } + .tab.g-blue, .gdot.g-blue, .gcdot.g-blue { --gc: #4b7bec; } .gdot.g-blue { background: #4b7bec; } + .tab.g-purple, .gdot.g-purple, .gcdot.g-purple { --gc: #b39ddb; } .gdot.g-purple { background: #b39ddb; } /* Submenu popover for the tab context menu (Group → color). */ .ctxmenu .mi.sub { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .ctxmenu .mi.sub::after { content: "▸"; color: var(--dim); font-size: 11px; } @@ -573,17 +584,39 @@ current.title = active ? active.title : ""; updateStar(); const box = $("tabs"); + // Render tabs with group awareness. For every group, emit a chip BEFORE + // the group's first tab. If the group is collapsed, tabs inside it are + // hidden and the chip shows the count as "[● 3]"; expanded chips show + // just the color dot. + const collapsed = new Set(d.collapsedGroups || []); + const groupsSeen = new Set(); + const groupsCount = {}; + for (const t of d.tabs) if (t.group) groupsCount[t.group] = (groupsCount[t.group] || 0) + 1; box.innerHTML = d.tabs.map((t) => { + let out = ""; + // Chip appears once, at the FIRST tab of the group in render order. + if (t.group && !groupsSeen.has(t.group)) { + groupsSeen.add(t.group); + const isCollapsed = collapsed.has(t.group); + out += ``; + } + // Skip rendering the tab itself if its group is collapsed. + if (t.group && collapsed.has(t.group)) return out; // Loading spinner takes the icon slot while a page is loading, then // hands it back to the favicon once page-favicon-updated fires. const icon = t.loading ? '' : (t.favicon ? `` : ''); - const groupDot = t.group ? `` : ""; const mute = t.muted ? `🔇` : ""; - return `
${groupDot}${icon}${(t.title||"New Tab").replace(/${mute}
`; + out += `
${icon}${(t.title||"New Tab").replace(/${mute}
`; + return out; }).join("") + `+`; + // Group chips toggle collapsed state. + box.querySelectorAll(".gchip").forEach((chip) => chip.onclick = (ev) => { + ev.stopPropagation(); + T.tabGroupToggle && T.tabGroupToggle(chip.dataset.group); + }); box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => { if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close)); else T.switchTab(Number(el.dataset.id)); @@ -639,7 +672,12 @@ { id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" }, { id: "purple", label: "Purple" }, ]; - function closeAllMenus() { document.querySelectorAll(".ctxmenu").forEach((m) => m.remove()); } + function closeAllMenus() { + document.querySelectorAll(".ctxmenu").forEach((m) => m.remove()); + // Menus were rendered outside the chrome's normal flow (position:fixed), + // so closing them means the natural chrome height wins again. + syncHeight(); + } function openTabContextMenu(x, y, t) { closeAllMenus(); const m = document.createElement("div"); @@ -670,10 +708,23 @@ const sep = document.createElement("div"); sep.className = "sep"; m.appendChild(sep); m.appendChild(item("Close", "danger", () => T.closeTab(t.id))); document.body.appendChild(m); - // Clamp inside viewport + // Clamp horizontally to the viewport. VERTICALLY: the chrome view has a + // fixed height (main.js CHROME_H), which clips anything below that line. + // Grow the chrome view so the menu is fully visible — restored on close. const r = m.getBoundingClientRect(); if (r.right > window.innerWidth) m.style.left = Math.max(4, window.innerWidth - r.width - 4) + "px"; - if (r.bottom > window.innerHeight) m.style.top = Math.max(4, window.innerHeight - r.height - 4) + "px"; + growChromeForMenu(); + } + function growChromeForMenu() { + // Wait a frame so all newly-added menus contribute to the bounding box. + requestAnimationFrame(() => { + let needed = document.body.scrollHeight; + for (const m of document.querySelectorAll(".ctxmenu")) { + const r = m.getBoundingClientRect(); + needed = Math.max(needed, Math.ceil(r.bottom) + 4); + } + T.setChromeHeight(needed); + }); } function openGroupSubmenu(x, y, t) { document.querySelectorAll(".ctxmenu.sub2").forEach((m) => m.remove()); @@ -694,7 +745,7 @@ document.body.appendChild(m); const r = m.getBoundingClientRect(); if (r.right > window.innerWidth) m.style.left = Math.max(4, x - r.width - 6) + "px"; - if (r.bottom > window.innerHeight) m.style.top = Math.max(4, window.innerHeight - r.height - 4) + "px"; + growChromeForMenu(); } document.addEventListener("click", (e) => { // Close any open menus on outside click. @@ -710,7 +761,10 @@ else if (d.host && !$("url").value) $("url").value = d.host; } // Favorites bar shows only on the new-tab / home page (hides once a page loads). - $("bookmarks").hidden = !(!d || d.kind === "home"); + // Bookmarks bar was originally home-only; users adding a bookmark from + // a real tab saw nothing visible. Keep it visible on every tab so the + // add / remove has an immediate on-screen effect. + $("bookmarks").hidden = false; syncHeight(); }); diff --git a/main.js b/main.js index b7397ce..75ae665 100644 --- a/main.js +++ b/main.js @@ -1669,6 +1669,7 @@ function emitTabs() { const wc = t?.view.webContents; chrome?.webContents.send("tabs", { tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "" })), + collapsedGroups: [...tabGroupCollapsed], url: t?.url || "", loading: !!t?.loading, canBack: wc ? wc.navigationHistory.canGoBack() : false, @@ -2126,10 +2127,38 @@ ipcMain.handle("tab-group", (_e, id, color) => { const t = tabById(id); if (!t) return null; // color: null | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple" const allowed = new Set(["red","orange","yellow","green","cyan","blue","purple"]); - t.group = allowed.has(color) ? color : null; + const next = allowed.has(color) ? color : null; + t.group = next; + // Cluster: move this tab so all same-group tabs sit contiguously. Place + // it right after the LAST existing tab of that group; if there are no + // other members yet, leave it in place. When a tab is removed from a + // group (color === null) we don't reorder — the visual break is enough. + if (next) { + const idx = tabs.indexOf(t); + let insertAfter = -1; + for (let i = 0; i < tabs.length; i++) { + if (i !== idx && tabs[i].group === next) insertAfter = i; + } + if (insertAfter !== -1) { + tabs.splice(idx, 1); + const dst = insertAfter > idx ? insertAfter : insertAfter + 1; + tabs.splice(dst, 0, t); + } + } emitTabs(); return t.group; }); +// Groups get a collapsed/expanded state, per-color, in-memory only (resets +// on relaunch). Flipping this doesn't touch tabs — the renderer just hides +// tabs whose group is collapsed and shows the group chip in their place. +const tabGroupCollapsed = new Set(); // colors currently collapsed +ipcMain.handle("tab-group-toggle", (_e, color) => { + if (!color) return false; + if (tabGroupCollapsed.has(color)) tabGroupCollapsed.delete(color); else tabGroupCollapsed.add(color); + // Piggy-back on emitTabs so the chrome renderer receives the change. + emitTabs(); + return tabGroupCollapsed.has(color); +}); ipcMain.handle("tab-bookmark", (_e, id) => { const t = tabById(id); if (!t) return false; const url = t.url; const title = t.title || url; diff --git a/package.json b/package.json index d511e84..45243b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.2", + "version": "0.3.3", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", "main": "main.js", diff --git a/preload.js b/preload.js index f885e08..29fa42a 100644 --- a/preload.js +++ b/preload.js @@ -45,6 +45,7 @@ contextBridge.exposeInMainWorld("theseus", { tabDuplicate: (id) => ipcRenderer.invoke("tab-duplicate", id), tabMute: (id, on) => ipcRenderer.invoke("tab-mute", id, on), tabGroup: (id, color) => ipcRenderer.invoke("tab-group", id, color), + tabGroupToggle: (color) => ipcRenderer.invoke("tab-group-toggle", color), tabBookmark: (id) => ipcRenderer.invoke("tab-bookmark", id), onAddressPicked: (cb) => ipcRenderer.on("address-picked", (_e, url) => cb(url)), onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)),