fix(theseus): tab context menu goes native + bigger uniform bookmark chips with title-only labels
Two visible fixes from the same 2026-09-09 screenshot:
Right-click a tab was building a DOM menu and then growing the chrome
view height so it would fit under the tabstrip. That opened a
visible gap between the toolbar and the tab body while the menu was
up. Now the tab context menu goes through a new IPC
"tab-context-menu-popup" (main.js) that pops an OS-native Menu at
the click point, floating above every WebContentsView — no layout
change, no gap. Preload exposes tabContextMenuPopup(id, {x, y});
chrome.html's tab contextmenu handler now calls it directly and the
DOM openTabContextMenu / openGroupSubmenu / growChromeForMenu path
is bypassed for tabs. (The bookmark bar's own ctxmenu still uses
the DOM path — its short 2-3-row menus don't grow chrome enough
to be visible.)
Bookmark chips were too small (130px max-width, 11px text, 22px row).
Now every chip is a fixed 150px × 28px cell so the row reads as a
uniform grid, 12.5px text, 14px favicon. Labels drop the descriptor:
"GAME.X — Bitcoin Cash game platform" renders as "GAME.X". The
trimmer splits on the first em-dash / en-dash / hyphen that's
surrounded by whitespace, so single-word titles and hyphenated
compound names ("Foo-Bar" with no spaces) come through intact. Full
title still shows on hover.
This commit is contained in:
parent
e0c829dd10
commit
11ffb91ad6
4 changed files with 125 additions and 17 deletions
45
chrome.html
45
chrome.html
|
|
@ -297,10 +297,10 @@
|
|||
padding: 6px 12px; border: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); border-radius: 8px; cursor: pointer; background: rgb(from var(--acid) r g b / .08); font-size: 13px; }
|
||||
.logo:hover { background: rgb(from var(--acid) r g b / .16); }
|
||||
.logo .gear { font-size: 12px; opacity: .8; }
|
||||
/* favorites bar — new-tab page only, single compact row. Chips are
|
||||
tight so many fit in the row; the whole strip is 22px tall (down
|
||||
from 26) so the chrome view gains a bit of vertical space. */
|
||||
.bookmarks { display: flex; align-items: center; gap: 3px; padding: 2px 10px 4px; height: 22px; overflow: hidden; }
|
||||
/* favorites bar — new-tab page only, single compact row. Chips are a
|
||||
uniform fixed width so the row reads as a neat grid regardless of the
|
||||
underlying title length; the strip is 28px tall to fit the roomier chips. */
|
||||
.bookmarks { display: flex; align-items: center; gap: 6px; padding: 4px 10px 6px; height: 28px; overflow: hidden; }
|
||||
/* Ctrl+F find-in-page bar. Sits below the address bar (above tabs body),
|
||||
~32px tall so it's part of the chrome view height like .tordisc / .bcnrbar.
|
||||
syncHeight in the tabs handler picks it up automatically. */
|
||||
|
|
@ -313,14 +313,17 @@
|
|||
.findbar .findbtn { border: 1px solid var(--line); background: var(--surface2); color: var(--ink); border-radius: 6px;
|
||||
padding: 3px 8px; font-size: 13px; cursor: pointer; }
|
||||
.findbar .findbtn:hover { background: var(--surface); border-color: color-mix(in srgb, var(--acid) 35%, transparent); }
|
||||
.bm { display: flex; align-items: center; gap: 5px; max-width: 130px; padding: 2px 7px; border-radius: 5px;
|
||||
background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 11px; cursor: grab; white-space: nowrap; }
|
||||
/* Fixed width (not max-width) so every chip is the same size — the row
|
||||
reads as a grid. Title-only labels keep this width workable; renderer
|
||||
trims descriptors after " — ". */
|
||||
.bm { display: flex; align-items: center; gap: 6px; width: 150px; flex: 0 0 150px; padding: 4px 10px; border-radius: 6px;
|
||||
background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 12.5px; cursor: grab; white-space: nowrap; }
|
||||
.bm:hover { background: var(--active); color: var(--ink); }
|
||||
.bm.dragging { opacity: .45; cursor: grabbing; }
|
||||
.bm.dropbefore { box-shadow: -2px 0 0 var(--acid); }
|
||||
.bm.dropafter { box-shadow: 2px 0 0 var(--acid); }
|
||||
.bm .bmfav { width: 12px; height: 12px; flex: none; object-fit: contain; border-radius: 2px; }
|
||||
.bm .bt { overflow: hidden; text-overflow: ellipsis; max-width: 96px; }
|
||||
.bm .bmfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; }
|
||||
.bm .bt { overflow: hidden; text-overflow: ellipsis; flex: 1; }
|
||||
/* In-chrome prompt modal (window.prompt is disabled in Electron BrowserViews,
|
||||
so bookmark rename etc. use this instead). */
|
||||
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 10000;
|
||||
|
|
@ -802,6 +805,17 @@
|
|||
|
||||
// ---- favorites bar (new-tab page only) ----
|
||||
let current = { url: "", title: "", favicon: null }, bookmarks = [];
|
||||
// Site titles often end with a descriptor after " — " or " - ", e.g.
|
||||
// "GAME.X — Bitcoin Cash game platform". The chip only has room for the
|
||||
// leading brand token, so trim at the first em-dash / en-dash / hyphen
|
||||
// that's surrounded by whitespace. The full title still shows on hover
|
||||
// (tooltip below) so the descriptor isn't actually lost.
|
||||
function bookmarkShortLabel(title, url) {
|
||||
const src = (title || url || "").trim();
|
||||
if (!src) return "";
|
||||
const cut = src.split(/\s+[—–-]\s+/, 1)[0];
|
||||
return cut || src;
|
||||
}
|
||||
function renderBookmarks() {
|
||||
const box = $("bookmarks");
|
||||
box.innerHTML = bookmarks.length
|
||||
|
|
@ -809,7 +823,8 @@
|
|||
const fav = b.favicon
|
||||
? `<img class="bmfav" src="${String(b.favicon).replace(/"/g,""")}" onerror="this.remove()">`
|
||||
: `<span class="bmfav" style="display:inline-block"></span>`;
|
||||
return `<div class="bm" draggable="true" data-i="${i}" data-url="${(b.url||"").replace(/"/g,""")}" title="${((b.title||b.url)+" — "+(b.url||"")).replace(/"/g,""")}">${fav}<span class="bt">${(b.title||b.url).replace(/</g,"<")}</span><span class="bx" data-x="${i}">✕</span></div>`;
|
||||
const short = bookmarkShortLabel(b.title, b.url);
|
||||
return `<div class="bm" draggable="true" data-i="${i}" data-url="${(b.url||"").replace(/"/g,""")}" title="${((b.title||b.url)+" — "+(b.url||"")).replace(/"/g,""")}">${fav}<span class="bt">${short.replace(/</g,"<")}</span><span class="bx" data-x="${i}">✕</span></div>`;
|
||||
}).join("")
|
||||
: `<span class="bm-empty">No saved pages yet — click ☆ or right-click here to add one.</span>`;
|
||||
box.querySelectorAll(".bm").forEach((el) => el.onclick = (e) => {
|
||||
|
|
@ -1161,14 +1176,16 @@
|
|||
if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close));
|
||||
else T.switchTab(Number(el.dataset.id));
|
||||
});
|
||||
// Right-click a tab → floating menu (Reload / Duplicate / Group / Add to
|
||||
// Bookmarks / Mute / Close). Menu closes on any other click.
|
||||
// Right-click a tab → native OS menu (Reload / Duplicate / Group / Add
|
||||
// to Bookmarks / Mute / Close), popped from main.js. Doing this in the
|
||||
// DOM used to force the chrome view taller than its natural height so
|
||||
// the menu was visible below the tabstrip — that opened a visible gap
|
||||
// between the toolbar and the tab body. A native Menu.popup sits above
|
||||
// every WebContentsView so nothing has to grow.
|
||||
box.querySelectorAll(".tab").forEach((el) => el.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const id = Number(el.dataset.id);
|
||||
const t = d.tabs.find((x) => x.id === id);
|
||||
if (!t) return;
|
||||
openTabContextMenu(e.clientX, e.clientY, t);
|
||||
if (T.tabContextMenuPopup) T.tabContextMenuPopup(id, { x: e.clientX, y: e.clientY });
|
||||
}));
|
||||
// Drag-reorder — HTML5 drag events. Drop-side chosen by whether the pointer
|
||||
// is on the left or right half of the target tab (matches Chrome UX).
|
||||
|
|
|
|||
94
main.js
94
main.js
|
|
@ -1521,6 +1521,19 @@ function initAddons() {
|
|||
hostRequire: (name) => require(name),
|
||||
hostImport: (name) => import(require("node:url").pathToFileURL(require.resolve(name)).href),
|
||||
openTab: (url) => { if (win) createTab(url); },
|
||||
// openSettings: routes through the existing "open-settings" IPC handler
|
||||
// so the same section-hint validation applies. Add-ons hit this when they
|
||||
// want to point users at Passwords, Extensions, etc.
|
||||
openSettings: (section) => {
|
||||
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : "";
|
||||
const ex = tabs.find((t) => t.settings);
|
||||
if (ex) {
|
||||
setActive(ex.id);
|
||||
if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} }
|
||||
return;
|
||||
}
|
||||
createTab(null, { settings: true, settingsSection: slug });
|
||||
},
|
||||
// open-tab (addon-file variant): open one of the add-on's OWN files in a
|
||||
// full tab. The path is joined against the resolved add-on folder and
|
||||
// rejected if the result escapes it — belt-and-braces with the sanity
|
||||
|
|
@ -2429,7 +2442,8 @@ function createTab(initial, opts = {}) {
|
|||
else setActive(id);
|
||||
if (opts.settings) {
|
||||
tab.prov = { host: "", kind: "home" };
|
||||
wc.loadFile("settings.html");
|
||||
const settingsOpts = opts.settingsSection ? { hash: opts.settingsSection } : undefined;
|
||||
wc.loadFile("settings.html", settingsOpts);
|
||||
if (id === activeId) pushNav(tab.prov);
|
||||
emitTabs();
|
||||
} else if (opts.addonFile) {
|
||||
|
|
@ -2725,6 +2739,70 @@ ipcMain.handle("tab-bookmark", (_e, id) => {
|
|||
saveBookmarks(); emitBookmarks();
|
||||
return true;
|
||||
});
|
||||
// Native tab context-menu popup. Anchored at the (chrome-view-relative)
|
||||
// point the renderer sends. Prevents the visible "chrome view expands to
|
||||
// hold a DOM menu" gap between the tabs and the tab content — the OS-owned
|
||||
// popup floats above every WebContentsView and doesn't affect layout at
|
||||
// all. Mirrors the DOM-menu shape: Reload / Duplicate / Group (submenu) /
|
||||
// Add to Bookmarks / Mute / Close.
|
||||
ipcMain.handle("tab-context-menu-popup", (e, tabId, rect) => {
|
||||
if (chrome && e.sender !== chrome.webContents) throw new Error("tab-context-menu-popup: untrusted sender");
|
||||
const t = tabById(tabId);
|
||||
if (!t) return false;
|
||||
const colorLabels = [
|
||||
{ id: "red", label: "Red" }, { id: "orange", label: "Orange" },
|
||||
{ id: "yellow", label: "Yellow" }, { id: "green", label: "Green" },
|
||||
{ id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" },
|
||||
{ id: "purple", label: "Purple" },
|
||||
];
|
||||
const groupSubmenu = [
|
||||
{ label: "None" + (t.group ? "" : " ✓"), click: () => { t.group = null; emitTabs(); } },
|
||||
{ type: "separator" },
|
||||
...colorLabels.map((c) => ({
|
||||
label: c.label + (t.group === c.id ? " ✓" : ""),
|
||||
click: () => {
|
||||
t.group = c.id;
|
||||
// Cluster tabs of the same colour, matching the "tab-group" IPC.
|
||||
const idx = tabs.indexOf(t);
|
||||
let insertAfter = -1;
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
if (i !== idx && tabs[i].group === c.id) insertAfter = i;
|
||||
}
|
||||
if (insertAfter !== -1) {
|
||||
tabs.splice(idx, 1);
|
||||
const dst = insertAfter > idx ? insertAfter : insertAfter + 1;
|
||||
tabs.splice(dst, 0, t);
|
||||
}
|
||||
emitTabs();
|
||||
},
|
||||
})),
|
||||
];
|
||||
const bmDisabled = !t.url;
|
||||
const template = [
|
||||
{ label: "Reload", click: () => { try { t.view.webContents.reload(); } catch {} } },
|
||||
{ label: "Duplicate", click: () => { if (t.url) createTab(t.url); else createTab(); } },
|
||||
{ label: "Group", submenu: groupSubmenu },
|
||||
{ label: "Add to Bookmarks", enabled: !bmDisabled, click: () => {
|
||||
if (bmDisabled) return;
|
||||
const url = t.url, title = t.title || url;
|
||||
if (!bookmarks.some((b) => b.url === url)) {
|
||||
bookmarks.unshift({ url, title, addedAt: Date.now() });
|
||||
saveBookmarks(); emitBookmarks();
|
||||
}
|
||||
} },
|
||||
{ label: t.muted ? "Unmute" : "Mute", click: () => {
|
||||
try { t.view.webContents.setAudioMuted(!t.muted); t.muted = !t.muted; emitTabs(); } catch {}
|
||||
} },
|
||||
{ type: "separator" },
|
||||
{ label: "Close", click: () => closeTab(t.id) },
|
||||
];
|
||||
const popup = Menu.buildFromTemplate(template);
|
||||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||||
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
|
||||
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
|
||||
popup.popup({ window: win, x, y });
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("move-tab", (_e, id, targetId, place) => {
|
||||
const src = tabs.findIndex((t) => t.id === id);
|
||||
const dst = tabs.findIndex((t) => t.id === targetId);
|
||||
|
|
@ -3268,7 +3346,19 @@ ipcMain.handle("reload", (_e, hard) => {
|
|||
});
|
||||
ipcMain.handle("stop", () => { const t = activeTab(); try { t?.view.webContents.stop(); } catch {} setLoading(t, false); });
|
||||
ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); });
|
||||
ipcMain.handle("open-settings", () => { const ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); });
|
||||
ipcMain.handle("open-settings", (_e, section) => {
|
||||
// Optional section slug ("passwords", "addons", …) lands directly on
|
||||
// that sidebar entry when the page loads. settings.html watches for a
|
||||
// fragment on load and an IPC message when already loaded.
|
||||
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : "";
|
||||
const ex = tabs.find((t) => t.settings);
|
||||
if (ex) {
|
||||
setActive(ex.id);
|
||||
if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} }
|
||||
return;
|
||||
}
|
||||
createTab(null, { settings: true, settingsSection: slug });
|
||||
});
|
||||
ipcMain.handle("toggle-site-info", (_e, rect) => {
|
||||
if (popVisible) return showPopover(false);
|
||||
if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.3.40",
|
||||
"version": "0.3.41",
|
||||
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
|
||||
"author": "Silent Mode",
|
||||
"main": "main.js",
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ contextBridge.exposeInMainWorld("theseus", {
|
|||
// then covered by the tab view below it — a native Menu.popup escapes that
|
||||
// layering entirely. Pass the button's viewport-rect so main can anchor.
|
||||
toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect),
|
||||
tabContextMenuPopup: (tabId, rect) => ipcRenderer.invoke("tab-context-menu-popup", tabId, rect),
|
||||
// Chrome listens so it can drop the "active" tint when the native menu
|
||||
// dismisses (Esc, outside click, item picked — main fires all three).
|
||||
onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue