The onTabs / onNav re-sync catches the common menu-close path, but a menu that gets orphaned by a drag interruption, a focus flip, or a submenu race can leave the chrome view taller than the natural body-scrollHeight until the next tabs/nav event fires. Symptom: a huge blank strip between the address bar and the page that grows and doesn't shrink until the tab changes. Fix: a 750ms interval that re-runs syncHeight whenever no .ctxmenu / .grouppop is present. setChromeHeight in main.js only re-layouts when the value actually changes, so this is a cheap no-op in the steady state. Holding the ship: will bundle with the next batch per the slower-cadence policy. Users on 0.3.5 keep the existing gap fix; this one lands with the next release.
895 lines
52 KiB
HTML
895 lines
52 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head><meta charset="utf-8">
|
|
<style>
|
|
:root {
|
|
color-scheme: light dark; font-family: system-ui, sans-serif;
|
|
--bg:#0f1420; --surface:#1b2330; --surface2:#171e2a; --active:#263041cc;
|
|
--ink:#e7eaf1; --mut:#b9c2d0; --dim:#8b98a9; --faint:#5e6678;
|
|
--line:rgba(255,255,255,.20); --line2:rgba(255,255,255,.13); --hover:rgba(255,255,255,.08);
|
|
}
|
|
@media (prefers-color-scheme: light) {
|
|
:root {
|
|
--bg:#e9ecf1; --surface:#ffffff; --surface2:#f1f3f7; --active:#dbe1ec;
|
|
--ink:#1a1f28; --mut:#3c4453; --dim:#697280; --faint:#98a1b0;
|
|
--line:rgba(0,0,0,.18); --line2:rgba(0,0,0,.11); --hover:rgba(0,0,0,.06);
|
|
}
|
|
}
|
|
[hidden] { display: none !important; }
|
|
body { margin: 0; height: 100vh; background: var(--bg); color: var(--ink); user-select: none; overflow: hidden; }
|
|
.tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; height: 34px; overflow: hidden; }
|
|
/* Same-size tabs: each tab claims an equal share of the row, capped at
|
|
200px so 2 tabs don't stretch across the whole window. min-width lets
|
|
many tabs shrink cleanly while still showing a couple letters. */
|
|
.tab { display: flex; align-items: center; gap: 8px; flex: 1 1 0; max-width: 200px; min-width: 60px; padding: 6px 10px;
|
|
background: var(--surface); border: 1px solid var(--line2); border-bottom: none; border-radius: 8px 8px 0 0;
|
|
font-size: 12.5px; cursor: default; color: var(--mut); user-select: none; }
|
|
.tab.dragging { opacity: .45; cursor: grabbing; }
|
|
.tab.dropbefore { box-shadow: -2px 0 0 var(--acid); }
|
|
.tab.dropafter { box-shadow: 2px 0 0 var(--acid); }
|
|
.tab.active { background: var(--active); color: var(--ink); }
|
|
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
|
.tab .fav { width: 14px; height: 14px; flex: none; border-radius: 2px; object-fit: contain; }
|
|
.tab .spin { width: 10px; height: 10px; flex: none; border: 1.5px solid #ffffff2e; border-top-color: #d6ff3d; border-radius: 50%; animation: spin .7s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; }
|
|
.tab .mute { font-size: 10px; opacity: .8; margin-right: 2px; }
|
|
/* Tab group visual: a colored dot before the title, plus a matching top
|
|
accent stripe on the tab itself so a whole group reads as one cluster
|
|
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); }
|
|
/* 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; }
|
|
/* Drag hover state — a tab being dragged onto a group chip. */
|
|
.gchip.droptarget { background: rgba(214,255,61,.15); border-color: var(--acid); }
|
|
/* Vertical popover: click a collapsed chip → list its member tabs. */
|
|
.grouppop { position: fixed; z-index: 9999; background: var(--surface, #1c222c); border: 1px solid var(--line2);
|
|
border-radius: 10px; box-shadow: 0 12px 34px #000c; padding: 4px; min-width: 220px; max-width: 360px;
|
|
font-size: 13px; color: var(--ink); }
|
|
.grouppop .gph { padding: 6px 10px 4px; font-size: 11px; color: var(--dim); letter-spacing: .08em; text-transform: uppercase;
|
|
display: flex; align-items: center; gap: 8px; }
|
|
.grouppop .gph .gcdot { width: 8px; height: 8px; border-radius: 50%; }
|
|
.grouppop .gpit { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; cursor: pointer;
|
|
white-space: nowrap; overflow: hidden; }
|
|
.grouppop .gpit:hover { background: var(--hover, #ffffff10); }
|
|
.grouppop .gpit.active { background: rgba(214,255,61,.08); color: var(--acid); }
|
|
.grouppop .gpit .gfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; }
|
|
.grouppop .gpit .gt { overflow: hidden; text-overflow: ellipsis; flex: 1; }
|
|
.grouppop .gpit .gx { opacity: .5; padding: 0 4px; font-size: 12px; }
|
|
.grouppop .gpit:hover .gx { opacity: .9; }
|
|
.grouppop .gpit .gx:hover { color: #f6768a; opacity: 1; }
|
|
@media (prefers-color-scheme: light) {
|
|
.grouppop { background: #ffffff; border-color: rgba(0,0,0,.15); }
|
|
.grouppop .gpit:hover { background: rgba(0,0,0,.05); }
|
|
}
|
|
.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; }
|
|
.ctxmenu.sub2 { min-width: 140px; }
|
|
.ctxmenu .swatch { width: 12px; height: 12px; border-radius: 50%; display: inline-block; margin-right: 8px; vertical-align: -1px; border: 1px solid rgba(255,255,255,.10); }
|
|
.tab .x:hover { opacity: 1; background: var(--line2); }
|
|
.newtab { padding: 4px 10px; cursor: pointer; color: var(--dim); border-radius: 6px; font-size: 16px; }
|
|
.newtab:hover { background: var(--hover); color: var(--ink); }
|
|
.bar { display: flex; gap: 6px; align-items: center; padding: 6px 10px; }
|
|
.nav { display: flex; gap: 1px; }
|
|
.ic { width: 34px; height: 32px; display: grid; place-items: center; border-radius: 8px; cursor: pointer;
|
|
color: var(--mut); background: transparent; border: none; padding: 0; }
|
|
.ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
|
|
.ic:hover { background: var(--line2); color: var(--ink); }
|
|
.ic.active { background: rgba(214,255,61,.12); color: var(--acid); }
|
|
.ic:active { background: var(--line); }
|
|
.ic:disabled { opacity: .28; cursor: default; background: transparent; }
|
|
/* downloads button — a small badge sits over the icon when there's activity */
|
|
.dlbtn { position: relative; }
|
|
.dlbtn.spin::before { content: ""; position: absolute; inset: 4px; border-radius: 999px;
|
|
border: 1.5px solid transparent; border-top-color: #4b7bec; animation: dlspin .9s infinite linear; }
|
|
@keyframes dlspin { to { transform: rotate(360deg); } }
|
|
.dlbadge { position: absolute; top: 2px; right: 2px; min-width: 14px; height: 14px; padding: 0 3px;
|
|
background: #4b7bec; color: #fff; border-radius: 7px; font-size: 9.5px; font-weight: 700;
|
|
display: grid; place-items: center; line-height: 1; box-shadow: 0 0 0 2px var(--bg); }
|
|
.dlbtn.done .dlbadge { background: #4fd1a5; }
|
|
.dlbtn.err .dlbadge { background: #f6768a; }
|
|
/* indeterminate loading bar under the toolbar — collapses when idle so it
|
|
doesn't take a permanent 2px strip below the address bar */
|
|
.loadbar { height: 0; overflow: hidden; background: transparent; transition: height .12s linear; }
|
|
.loadbar.on { height: 2px; }
|
|
.loadbar.on::after { content: ""; display: block; height: 100%; width: 35%; border-radius: 2px;
|
|
background: linear-gradient(90deg, transparent, #4b7bec, #d6ff3d, transparent); animation: loadslide 1.1s infinite linear; }
|
|
@keyframes loadslide { 0% { transform: translateX(-110%); } 100% { transform: translateX(400%); } }
|
|
/* address bar */
|
|
.urlwrap { flex: 1; display: flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
|
|
border-radius: 999px; padding: 0 6px 0 4px; }
|
|
.urlwrap:focus-within { border-color: #4b7bec; }
|
|
/* Site security shield — always present. Colour communicates state:
|
|
neutral (default) for home / resolving, green when the connection is
|
|
secure (BCNR chain-verified OR https:// clearnet), red when it isn't
|
|
(nxdomain / resolver error / plain http). Shape stays constant so the
|
|
button doesn't jump around when the state changes. */
|
|
.secbadge { display: grid; place-items: center; width: 28px; height: 26px; border-radius: 6px; border: none;
|
|
background: transparent; cursor: pointer; padding: 0; color: var(--dim); }
|
|
.secbadge:hover { background: var(--line2); }
|
|
.secbadge svg { width: 16px; height: 16px; fill: currentColor; }
|
|
.secbadge.secure { color: #3fb950; } /* GitHub-style saturated green */
|
|
.secbadge.insecure { color: #f6768a; }
|
|
.secbadge.warn { color: #f6ad55; } /* kept for legacy call sites */
|
|
input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; }
|
|
#url { flex: 1; }
|
|
/* Registry indicator at the END of the address bar. Two shapes:
|
|
- Toggle (segmented control) when this host could be served by EITHER
|
|
registry — a single pill split in half, active side filled, inactive
|
|
side transparent and clickable to switch.
|
|
- Single pill when there's no alternative (pure ICANN or a BCNR-unique TLD).*/
|
|
#reg { display: inline-flex; align-items: center; }
|
|
.reg { font-size: 10.5px; letter-spacing: .03em; padding: 2px 10px; white-space: nowrap;
|
|
cursor: default; user-select: none; transition: background .12s, color .12s, border-color .12s; }
|
|
/* Single-chip form (non-collision hosts) */
|
|
.reg.single { border-radius: 999px; border: 1px solid; }
|
|
.reg.single.bcdn.active { background: rgba(214,255,61,.15); color: #d6ff3d; border-color: #d6ff3d55; }
|
|
.reg.single.icann.active { background: rgba(76,158,255,.15); color: #4c9eff; border-color: #4c9eff55; }
|
|
/* Toggle (segmented control) form for collision candidates */
|
|
.tog { display: inline-flex; border-radius: 999px; overflow: hidden; border: 1px solid #ffffff26; }
|
|
.tog .reg { border: 0; }
|
|
.tog .reg + .reg { border-left: 1px solid #ffffff26; }
|
|
.tog .reg.active.bcdn { background: rgba(214,255,61,.18); color: #d6ff3d; }
|
|
.tog .reg.active.icann { background: rgba(76,158,255,.18); color: #4c9eff; }
|
|
.tog .reg.switch { background: transparent; color: #7b8494; cursor: pointer; }
|
|
.tog .reg.switch:hover { color: #e5e7eb; background: rgba(255,255,255,.05); }
|
|
.star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; }
|
|
.star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; }
|
|
/* Update-available chip — shows when the releases manifest names a newer
|
|
version than this build. Two buttons: main body opens the download URL
|
|
in the system browser; ✕ dismisses for the session. */
|
|
.upchip { display: inline-flex; align-items: center; gap: 0; background: rgba(214,255,61,.14);
|
|
border: 1px solid #d6ff3d55; border-radius: 999px; color: #eaffb0; font-size: 12px;
|
|
font-weight: 600; padding: 0; overflow: hidden; }
|
|
.upchip .upcore { background: transparent; border: none; color: inherit; font: inherit;
|
|
padding: 5px 10px 5px 12px; cursor: pointer; white-space: nowrap; }
|
|
.upchip .upcore:hover { background: rgba(214,255,61,.10); }
|
|
.upchip .updismiss { background: transparent; border: none; border-left: 1px solid #d6ff3d33;
|
|
color: inherit; opacity: .55; padding: 5px 8px; cursor: pointer; font-size: 11px; }
|
|
.upchip .updismiss:hover { opacity: 1; }
|
|
/* Password-fill chip — only visible when the vault is unlocked AND the
|
|
current site has matching credentials. Count sits to the left of the key. */
|
|
.pwchip { display: inline-flex; align-items: center; gap: 3px; border: none; background: transparent; color: #3fb950; cursor: pointer; padding: 2px 5px; border-radius: 6px; font-size: 11px; font-weight: 700; }
|
|
.pwchip:hover { background: rgba(63,185,80,.14); }
|
|
.pwchip svg { width: 14px; height: 14px; }
|
|
/* search box: engine icon (click = pick engine) + a wide typing area */
|
|
.searchbox { display: flex; align-items: center; width: 300px; background: var(--surface); border: 1px solid var(--line);
|
|
border-radius: 999px; padding: 0 12px 0 3px; }
|
|
.searchbox:focus-within { border-color: #4b7bec; }
|
|
/* dropdown button shows the SELECTED engine's favicon (emoji fallback) + a caret */
|
|
.engine-btn { display: flex; align-items: center; gap: 3px; height: 30px; border: none; cursor: pointer;
|
|
border-radius: 999px 0 0 999px; padding: 0 4px 0 8px; background: transparent; }
|
|
.engine-btn:hover { background-color: var(--hover); }
|
|
.engine-btn #engineIcon { display: grid; place-items: center; width: 16px; height: 16px; }
|
|
.engine-btn .eimg { width: 16px; height: 16px; border-radius: 3px; }
|
|
.engine-btn .esym { font-size: 14px; line-height: 1; }
|
|
.engine-btn .cv { font-size: 8px; color: var(--dim); }
|
|
#search { flex: 1; padding: 7px 4px; }
|
|
.tor { padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line); background: #2b2f3a; color: var(--ink); cursor: pointer; white-space: nowrap; font-size: 12.5px; }
|
|
.tor.connecting { background: #7a5c14; } .tor.on { background: #6b3fa0; }
|
|
.logo { display: flex; align-items: center; gap: 6px; font-weight: 700; color: #d6ff3d; white-space: nowrap;
|
|
padding: 6px 12px; border: 1px solid #d6ff3d33; border-radius: 8px; cursor: pointer; background: rgba(214,255,61,.08); font-size: 13px; }
|
|
.logo:hover { background: rgba(214,255,61,.16); }
|
|
.logo .gear { font-size: 12px; opacity: .8; }
|
|
/* favorites bar — new-tab page only, single compact row */
|
|
.bookmarks { display: flex; align-items: center; gap: 4px; padding: 3px 10px 5px; height: 26px; overflow: hidden; }
|
|
.bm { display: flex; align-items: center; gap: 6px; max-width: 180px; padding: 3px 9px; border-radius: 6px;
|
|
background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 12px; cursor: pointer; white-space: nowrap; }
|
|
.bm:hover { background: var(--active); color: var(--ink); }
|
|
.bm .bt { overflow: hidden; text-overflow: ellipsis; }
|
|
.bm .bx { opacity: 0; cursor: pointer; font-size: 11px; } .bm:hover .bx { opacity: .55; }
|
|
.bm .bx:hover { opacity: 1; color: #f6768a; }
|
|
.bm-empty { color: var(--faint); font-size: 11.5px; white-space: nowrap; }
|
|
/* Right-click menu on the bookmarks bar (same visual as settings ctxmenu). */
|
|
.ctxmenu { position: fixed; z-index: 9999; background: #1c222c; border: 1px solid var(--line);
|
|
border-radius: 8px; box-shadow: 0 12px 34px #000c; padding: 4px; min-width: 180px; font-size: 13px; color: var(--ink); }
|
|
.ctxmenu .mi { padding: 7px 12px; border-radius: 5px; cursor: pointer; white-space: nowrap; }
|
|
.ctxmenu .mi:hover { background: #ffffff10; }
|
|
.ctxmenu .mi.danger { color: #f6768a; } .ctxmenu .mi.danger:hover { background: rgba(246,118,138,.12); }
|
|
.ctxmenu .mi.off { color: var(--faint); cursor: default; } .ctxmenu .mi.off:hover { background: transparent; }
|
|
.ctxmenu .sep { height: 1px; background: var(--line); margin: 4px 0; }
|
|
@media (prefers-color-scheme: light) {
|
|
.ctxmenu { background: #ffffff; border-color: rgba(0,0,0,.15); }
|
|
.ctxmenu .mi:hover { background: rgba(0,0,0,.05); }
|
|
}
|
|
.tordisc { font-size: 11.5px; color: #d9c7f2; background: #2a1c40; border-top: 1px solid #6b3fa055; padding: 5px 14px; }
|
|
.tordisc a { color: #d6ff3d; }
|
|
.bcnrbar { display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: var(--mut);
|
|
background: #1a2417; border-top: 1px solid #d6ff3d33; padding: 6px 14px; }
|
|
.bcnrbar .breg { font-size: 10px; letter-spacing: .03em; padding: 1px 7px; border-radius: 999px;
|
|
background: rgba(214,255,61,.14); color: #d6ff3d; border: 1px solid #d6ff3d33; }
|
|
.bcnrbar b { color: var(--ink); }
|
|
.bcnrbar .bopen { margin-left: auto; padding: 4px 11px; border-radius: 7px; border: 1px solid #d6ff3d55;
|
|
background: rgba(214,255,61,.12); color: #eaffb0; cursor: pointer; font-size: 11.5px; }
|
|
.bcnrbar .bopen:hover { background: rgba(214,255,61,.22); }
|
|
.bcnrbar .bdismiss { color: var(--dim); text-decoration: none; } .bcnrbar .bdismiss:hover { color: var(--ink); }
|
|
</style></head>
|
|
<body>
|
|
<div class="tabs" id="tabs"></div>
|
|
<div class="bar">
|
|
<div class="nav">
|
|
<button class="ic" id="back" title="Back"><svg viewBox="0 0 16 16"><path d="M10 3 L5 8 L10 13"/></svg></button>
|
|
<button class="ic" id="fwd" title="Forward"><svg viewBox="0 0 16 16"><path d="M6 3 L11 8 L6 13"/></svg></button>
|
|
<button class="ic" id="reload" title="Reload"><svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg></button>
|
|
<button class="ic" id="home" title="Home"><svg viewBox="0 0 16 16"><path d="M2.5 7.5 L8 3 L13.5 7.5"/><path d="M4 6.7 L4 13 L12 13 L12 6.7"/></svg></button>
|
|
</div>
|
|
<div class="urlwrap">
|
|
<button class="secbadge" id="secbadge" title="Site information">
|
|
<!-- Heraldic shield outline matching the reference image: flared top
|
|
shoulders, concave sides tapering to a sharp bottom point. Single
|
|
filled path, `currentColor` so state class toggles the tint. -->
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" aria-hidden="true">
|
|
<path d="M8 1.2 C6 1.2 3.5 1.7 2.5 2.3 Q2 2.6 2 3.5 L2 7 C2 11.2 4.2 13.7 8 15 C11.8 13.7 14 11.2 14 7 L14 3.5 Q14 2.6 13.5 2.3 C12.5 1.7 10 1.2 8 1.2 Z"/>
|
|
</svg>
|
|
</button>
|
|
<input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false">
|
|
<span class="reg" id="reg" hidden></span>
|
|
<button class="pwchip" id="pwchip" title="Fill password for this site" hidden><span id="pwchipCount"></span><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="9" r="3.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.8 8.6 L14 8.6 M13.2 8.6 L13.2 11 M11.5 8.6 L11.5 10.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
|
<button class="star" id="star" title="Save this page">☆</button>
|
|
</div>
|
|
<div class="searchbox">
|
|
<button id="engineBtn" class="engine-btn" title="Choose search engine"><span id="engineIcon"></span><span class="cv">▾</span></button>
|
|
<input id="search" placeholder="Search" spellcheck="false">
|
|
</div>
|
|
<button class="ic dlbtn" id="downloads" title="Downloads">
|
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2 V10 M4.5 6.5 L8 10 L11.5 6.5"/><path d="M3 12.5 L3 13.5 L13 13.5 L13 12.5"/></svg>
|
|
<span class="dlbadge" id="dlbadge" hidden>0</span>
|
|
</button>
|
|
<button class="ic" id="sidebarbtn" title="Toggle extension sidebar" hidden>
|
|
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M10 3 L10 13"/></svg>
|
|
</button>
|
|
<span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now">✕</button></span>
|
|
<button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button>
|
|
<button class="logo" id="logo" title="Settings"><span>⛓ Theseus</span><span class="gear">⚙</span></button>
|
|
</div>
|
|
<div class="loadbar" id="loadbar"></div>
|
|
<div class="bookmarks" id="bookmarks" hidden></div>
|
|
<div id="tordisc" class="tordisc" hidden>
|
|
Onion mode hides your IP from sites and your ISP (including .bch lookups).
|
|
It is <b>not full anonymity</b> — this browser can still be fingerprinted; for that use the Tor Browser.
|
|
<a href="#" id="tordismiss">got it</a>
|
|
</div>
|
|
<div id="bcnrbar" class="bcnrbar" hidden>
|
|
<span class="breg" id="bcnrreg">BCDN</span>
|
|
<span><b id="bcnrhost"></b> also exists on <b>BCDN</b> — the decentralized registry.</span>
|
|
<button id="bcnropen" class="bopen">Open on BCDN</button>
|
|
<a href="#" id="bcnrdismiss" class="bdismiss">dismiss</a>
|
|
</div>
|
|
<script>
|
|
const $ = (id) => document.getElementById(id);
|
|
const T = window.theseus;
|
|
function syncHeight() { requestAnimationFrame(() => T.setChromeHeight(document.body.scrollHeight)); }
|
|
const RELOAD_SVG = `<svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg>`;
|
|
const STOP_SVG = `<svg viewBox="0 0 16 16"><path d="M4.5 4.5 L11.5 11.5 M11.5 4.5 L4.5 11.5"/></svg>`;
|
|
|
|
function goURL() {
|
|
const v = $("url").value.trim();
|
|
if (!v) return;
|
|
// Blur so the tabs-event handler can update the URL bar to the actual
|
|
// loaded page — the "don't clobber the typed text" guard would otherwise
|
|
// keep the search keyword displayed while the results are loading.
|
|
$("url").blur();
|
|
T.navigate(v);
|
|
}
|
|
// Address-bar suggestions — debounced call to main on every keystroke.
|
|
// Main computes matches from history and shows/hides a floating overlay
|
|
// WebContentsView anchored under the address bar's client rect.
|
|
let suggestTimer = null;
|
|
let pickerOpen = false;
|
|
const askSuggest = () => {
|
|
const q = $("url").value;
|
|
const r = $("url").getBoundingClientRect();
|
|
const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 4), w: Math.round(r.width + 40) };
|
|
T.suggestAddress(q, rect);
|
|
// main hides itself when there are zero matches; assume open otherwise.
|
|
pickerOpen = true;
|
|
};
|
|
// Whether the user has moved the picker's highlight since last typing.
|
|
// Enter with a highlight submits THAT url (via the picker), not the
|
|
// typed text — so pressing ↓ then Enter opens the highlighted entry
|
|
// instead of doing a web search for the letters you typed.
|
|
let pickerHasCursor = false;
|
|
const debouncedSuggest = () => { clearTimeout(suggestTimer); suggestTimer = setTimeout(askSuggest, 80); };
|
|
$("url").addEventListener("input", () => { pickerHasCursor = false; debouncedSuggest(); });
|
|
$("url").addEventListener("focus", debouncedSuggest);
|
|
$("url").addEventListener("blur", () => {
|
|
// Delay: gives a click on the picker time to register before we close.
|
|
setTimeout(() => { T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; }, 160);
|
|
});
|
|
$("url").addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
if (pickerOpen && pickerHasCursor) {
|
|
// Forward Enter to the picker so it navigates to the HIGHLIGHTED
|
|
// suggestion instead of running goURL against the typed text
|
|
// (which would search for the letters, not open the URL).
|
|
T.addressCursor("enter");
|
|
return;
|
|
}
|
|
T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; goURL();
|
|
return;
|
|
}
|
|
if (e.key === "Escape") { T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; return; }
|
|
if (!pickerOpen) return;
|
|
if (e.key === "ArrowDown") { e.preventDefault(); pickerHasCursor = true; T.addressCursor("next"); }
|
|
else if (e.key === "ArrowUp") { e.preventDefault(); pickerHasCursor = true; T.addressCursor("prev"); }
|
|
});
|
|
// Enter submits the search; keep the query visible so the user can refine
|
|
// it or search again — clearing it on submit lost context and made refining
|
|
// annoying (esp. when the engine's own results page uses its own search box).
|
|
$("search").addEventListener("keydown", (e) => { if (e.key === "Enter") { const q = $("search").value.trim(); if (q) T.search(q); } });
|
|
$("back").onclick = () => T.back();
|
|
$("fwd").onclick = () => T.forward();
|
|
// Shift-click on the reload button = hard reload (bypass cache), same
|
|
// convention as Chrome/Firefox. Keyboard alternative: Ctrl+Shift+R.
|
|
$("reload").onclick = (e) => T.reload(e.shiftKey);
|
|
$("home").onclick = () => T.goHome();
|
|
$("logo").onclick = () => T.openSettings();
|
|
$("secbadge").onclick = () => { const r = $("secbadge").getBoundingClientRect(); T.toggleSiteInfo({ x: Math.round(r.left), y: Math.round(r.bottom + 4) }); };
|
|
|
|
// ---- search-engine picker: the magnifier button opens a custom overlay
|
|
// dropdown (real favicons); here we only track the current engine's name. ----
|
|
function setEnginePlaceholder(d) {
|
|
const cur = (d.engines || []).find((e) => e.id === d.current);
|
|
$("search").placeholder = cur ? "Search with " + cur.name : "Search";
|
|
const icon = $("engineIcon");
|
|
const sym = (cur && cur.sym) || "🔍";
|
|
if (cur && cur.favicon)
|
|
icon.innerHTML = `<img class="eimg" src="${cur.favicon.replace(/"/g, """)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'esym',textContent:'${sym}'}))">`;
|
|
else icon.innerHTML = `<span class="esym">${sym}</span>`;
|
|
}
|
|
$("engineBtn").onclick = () => {
|
|
const r = $("engineBtn").getBoundingClientRect();
|
|
T.toggleEnginePicker({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
|
|
};
|
|
T.getSearchEngines().then(setEnginePlaceholder);
|
|
T.onEngines && T.onEngines(setEnginePlaceholder);
|
|
|
|
// ---- downloads button: badge + click toggles the panel ----
|
|
const dlBtn = $("downloads"), dlBadge = $("dlbadge");
|
|
function renderDownloads(items) {
|
|
const list = items || [];
|
|
const active = list.filter((d) => d.state === "progressing").length;
|
|
const doneNew = list.filter((d) => d.state === "completed").length;
|
|
const failed = list.filter((d) => d.state === "interrupted").length;
|
|
dlBtn.classList.toggle("spin", active > 0);
|
|
dlBtn.classList.toggle("done", active === 0 && doneNew > 0 && failed === 0);
|
|
dlBtn.classList.toggle("err", active === 0 && failed > 0);
|
|
const badge = active || (doneNew + failed);
|
|
if (badge > 0) { dlBadge.hidden = false; dlBadge.textContent = badge > 99 ? "99+" : String(badge); }
|
|
else { dlBadge.hidden = true; }
|
|
}
|
|
dlBtn.onclick = () => {
|
|
const r = dlBtn.getBoundingClientRect();
|
|
T.toggleDownloads({ x: Math.round(r.right - 340), y: Math.round(r.bottom + 4) });
|
|
};
|
|
T.getDownloads && T.getDownloads().then(renderDownloads);
|
|
T.onDownloads && T.onDownloads(renderDownloads);
|
|
|
|
// ---- add-on sidebar toggle ----
|
|
// Only surfaces the button when at least one add-on has registered a
|
|
// sidebar panel; a fresh install with no add-ons keeps the toolbar clean.
|
|
const sideBtn = $("sidebarbtn");
|
|
function renderSidebarState(d) {
|
|
if (!d) return;
|
|
const has = Array.isArray(d.panels) && d.panels.length > 0;
|
|
sideBtn.hidden = !has;
|
|
sideBtn.classList.toggle("active", !!d.visible);
|
|
if (has && d.panels[0]) sideBtn.title = "Toggle " + d.panels[0].title;
|
|
}
|
|
sideBtn.onclick = () => T.toggleSidebar && T.toggleSidebar();
|
|
T.sidebarState && T.sidebarState().then(renderSidebarState);
|
|
T.onSidebarState && T.onSidebarState(renderSidebarState);
|
|
|
|
// ---- favorites bar (new-tab page only) ----
|
|
let current = { url: "", title: "" }, bookmarks = [];
|
|
function renderBookmarks() {
|
|
const box = $("bookmarks");
|
|
box.innerHTML = bookmarks.length
|
|
? bookmarks.map((b, i) => `<div class="bm" data-i="${i}" title="${(b.url||"").replace(/"/g,""")}"><span class="bt">${(b.title||b.url).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) => {
|
|
const i = Number(el.dataset.i);
|
|
if (e.target.dataset.x !== undefined) T.removeBookmark(bookmarks[i].url);
|
|
else T.navigate(bookmarks[i].url);
|
|
});
|
|
updateStar(); syncHeight();
|
|
}
|
|
// Right-click context menu on the bookmarks bar. Two modes:
|
|
// - On a specific bookmark: Open / Edit title / Remove
|
|
// - On empty area OR the placeholder text: Add current page (or Remove
|
|
// if the page is already bookmarked)
|
|
function closeBmMenu() { const m = document.getElementById("bmMenu"); if (m) m.remove(); }
|
|
function openBmMenu(x, y, ctx) {
|
|
closeBmMenu();
|
|
const items = [];
|
|
if (ctx.kind === "item") {
|
|
const bm = bookmarks[ctx.index];
|
|
items.push({ label: "Open", act: () => T.navigate(bm.url) });
|
|
items.push({ label: "Edit title…", act: () => {
|
|
const nt = prompt("Edit bookmark title", bm.title || bm.url);
|
|
if (nt != null && nt.trim() && nt !== bm.title) {
|
|
// No dedicated update IPC yet — round-trip: remove + re-add with new title at same URL.
|
|
T.removeBookmark(bm.url).then(() => T.addBookmark({ title: nt.trim(), url: bm.url }));
|
|
}
|
|
}});
|
|
items.push({ label: "Remove", act: () => T.removeBookmark(bm.url), danger: true });
|
|
items.push({ sep: true });
|
|
}
|
|
if (current.url) {
|
|
const saved = bookmarks.some((b) => b.url === current.url);
|
|
items.push(saved
|
|
? { label: "Remove current page", act: () => T.removeBookmark(current.url), danger: true }
|
|
: { label: "Add current page", act: () => T.addBookmark({ title: current.title || current.url, url: current.url }) });
|
|
} else {
|
|
items.push({ label: "Add current page", disabled: true });
|
|
}
|
|
const m = document.createElement("div");
|
|
m.id = "bmMenu"; m.className = "ctxmenu";
|
|
m.innerHTML = items.map((it, i) => it.sep
|
|
? `<div class="sep"></div>`
|
|
: `<div class="mi${it.danger ? " danger" : ""}${it.disabled ? " off" : ""}" data-i="${i}">${it.label.replace(/</g, "<")}</div>`
|
|
).join("");
|
|
document.body.appendChild(m);
|
|
const r = m.getBoundingClientRect();
|
|
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
|
|
m.style.left = Math.min(x, vw - r.width - 6) + "px";
|
|
m.style.top = Math.min(y, vh - r.height - 6) + "px";
|
|
m.querySelectorAll(".mi").forEach((el) => {
|
|
const it = items[Number(el.dataset.i)];
|
|
if (it.disabled) return;
|
|
el.onclick = () => { closeBmMenu(); try { it.act(); } catch (e) { console.error(e); } };
|
|
});
|
|
setTimeout(() => {
|
|
const off = (ev) => { if (!m.contains(ev.target)) { closeBmMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
|
|
const esc = (ev) => { if (ev.key === "Escape") { closeBmMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
|
|
document.addEventListener("mousedown", off);
|
|
document.addEventListener("keydown", esc);
|
|
}, 0);
|
|
}
|
|
$("bookmarks").addEventListener("contextmenu", (e) => {
|
|
e.preventDefault();
|
|
const bm = e.target.closest(".bm");
|
|
openBmMenu(e.clientX, e.clientY, bm ? { kind: "item", index: Number(bm.dataset.i) } : { kind: "bar" });
|
|
});
|
|
function updateStar() {
|
|
const saved = !!current.url && bookmarks.some((b) => b.url === current.url);
|
|
const s = $("star"); s.textContent = saved ? "★" : "☆"; s.classList.toggle("on", saved);
|
|
s.title = saved ? "Remove from saved pages" : "Save this page";
|
|
}
|
|
$("star").onclick = () => {
|
|
if (!current.url) return;
|
|
if (bookmarks.some((b) => b.url === current.url)) T.removeBookmark(current.url);
|
|
else T.addBookmark({ title: current.title || current.url, url: current.url });
|
|
};
|
|
T.getBookmarks().then((b) => { bookmarks = b || []; renderBookmarks(); });
|
|
T.onBookmarks((b) => { bookmarks = b || []; renderBookmarks(); });
|
|
|
|
// ---- password-fill chip ----
|
|
// Visible only when the vault is unlocked AND the current site has matching
|
|
// credentials. Click opens a floating picker of matches; clicking a match
|
|
// injects the fill into the active tab.
|
|
T.onPwAvailability && T.onPwAvailability((d) => {
|
|
const chip = $("pwchip"); if (!chip) return;
|
|
const n = d && d.count ? d.count : 0;
|
|
chip.hidden = n === 0;
|
|
$("pwchipCount").textContent = n > 1 ? String(n) : "";
|
|
chip.title = n === 1 ? "Fill password for this site" : `${n} saved credentials for this site`;
|
|
});
|
|
$("pwchip") && ($("pwchip").onclick = () => {
|
|
const r = $("pwchip").getBoundingClientRect();
|
|
T.togglePwFill({ x: Math.round(r.right - 280), y: Math.round(r.bottom + 4) });
|
|
});
|
|
|
|
// ---- update-available chip ----
|
|
// Main sends "update-available" with { version, setupUrl, portableUrl, ... }
|
|
// when the releases manifest names a newer Theseus. Null clears the chip
|
|
// (up-to-date, or dismissed this session).
|
|
T.onUpdateAvailable && T.onUpdateAvailable((d) => {
|
|
const chip = $("upchip"); if (!chip) return;
|
|
if (!d) { chip.hidden = true; return; }
|
|
chip.hidden = false;
|
|
const url = d.setupUrl || d.portableUrl || "";
|
|
const core = $("upDownload");
|
|
// State machine: idle (nothing yet) -> downloading (silent fetch running,
|
|
// show percent) -> ready (chip becomes "Install & restart, one click") ->
|
|
// failed (fall back to explicit user-triggered download).
|
|
const state = d.downloadState || "idle";
|
|
if (state === "downloading") {
|
|
const pct = d.downloadTotal ? Math.floor((d.downloadReceived / d.downloadTotal) * 100) : null;
|
|
core.textContent = pct != null ? `↓ ${pct}% — ${d.version}` : `↓ Downloading ${d.version}…`;
|
|
core.title = "Downloading update in the background";
|
|
core.onclick = () => {};
|
|
} else if (state === "ready") {
|
|
core.textContent = `✓ Install ${d.version} & restart`;
|
|
core.title = "One-click install: launches the installer silently and restarts Theseus";
|
|
core.onclick = () => T.installUpdateNow && T.installUpdateNow();
|
|
} else if (state === "failed") {
|
|
core.textContent = `↓ Update to ${d.version}`;
|
|
core.title = url ? `Retry download — ${url}` : "Retry download";
|
|
core.onclick = () => { if (url) T.openUpdateDownload(url); };
|
|
} else {
|
|
core.textContent = `↓ Update to ${d.version}`;
|
|
core.title = url ? `Download ${url}` : "Download the new version";
|
|
core.onclick = () => { if (url) T.openUpdateDownload(url); };
|
|
}
|
|
});
|
|
$("upDismiss") && ($("upDismiss").onclick = () => T.dismissUpdate());
|
|
|
|
// ---- security badge + minimal registry indicator ----
|
|
// Shield colour communicates connection state at a glance:
|
|
// neutral — home / resolving (no site OR pending)
|
|
// secure — BCDN chain-verified (kind:"ok") OR https:// clearnet
|
|
// insecure — nxdomain / resolver error / plain http:// clearnet
|
|
function setBadge(d) {
|
|
const b = $("secbadge");
|
|
let cls = "secbadge", title = "Site information";
|
|
const isHttps = typeof d?.url === "string" && d.url.startsWith("https:");
|
|
const isHttp = typeof d?.url === "string" && d.url.startsWith("http:") && !isHttps;
|
|
if (!d || d.kind === "home") title = "Theseus";
|
|
else if (d.kind === "ok") { cls = "secbadge secure"; title = "Secure · BCDN via " + (d.source || "on-chain") + " — click for details"; }
|
|
else if (d.kind === "web" && isHttps) { cls = "secbadge secure"; title = "Secure · ICANN (HTTPS) — click for details"; }
|
|
else if (d.kind === "web" && isHttp) { cls = "secbadge insecure"; title = "Not secure · plain HTTP — click for details"; }
|
|
else if (d.kind === "web") title = "Connection · ICANN — click for details";
|
|
else if (d.kind === "resolving") title = "Resolving…";
|
|
else if (d.kind === "nxdomain") { cls = "secbadge insecure"; title = "Not registered on BCDN"; }
|
|
else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; }
|
|
b.className = cls; b.title = title;
|
|
}
|
|
function setReg(d) {
|
|
const r = $("reg");
|
|
if (!d || d.kind === "home") { r.hidden = true; r.innerHTML = ""; return; }
|
|
// Keep chips visible during "resolving" so the switch feels like a toggle,
|
|
// not a disappear-and-reappear (user complaint 2026-08-02).
|
|
if (d.kind === "resolving") { r.hidden = false; return; }
|
|
r.hidden = false;
|
|
// Two chips (toggle) when this host is a collision candidate — could be
|
|
// BCDN OR ICANN. Active one is coloured; inactive is greyed + clickable.
|
|
const isCand = !!d.tld && (d.kind === "ok" || d.kind === "web") && d.bcnrNativeTld === false;
|
|
const active = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : null;
|
|
if (isCand) {
|
|
const bcdn = `<span class="reg bcdn ${active === "bcdn" ? "active" : "switch"}" data-c="bcdn" title="${active === "bcdn" ? "Serving from BCDN" : "Switch to BCDN"}">BCDN</span>`;
|
|
const icann = `<span class="reg icann ${active === "icann" ? "active" : "switch"}" data-c="icann" title="${active === "icann" ? "Serving from ICANN" : "Switch to ICANN"}">ICANN</span>`;
|
|
r.innerHTML = `<span class="tog">${bcdn}${icann}</span>`;
|
|
for (const chip of r.querySelectorAll(".reg.switch")) {
|
|
chip.onclick = () => T.collisionSwitch({ choice: chip.dataset.c, remember: "no" });
|
|
}
|
|
} else if (d.kind === "web") {
|
|
r.innerHTML = `<span class="reg single icann active">ICANN</span>`;
|
|
} else {
|
|
r.innerHTML = `<span class="reg single bcdn active">BCDN</span>`;
|
|
}
|
|
}
|
|
|
|
// ---- Tor ----
|
|
let torShown = false;
|
|
$("tor").onclick = () => { T.toggleTor(); if (!torShown) { $("tordisc").hidden = false; torShown = true; syncHeight(); } };
|
|
$("tordismiss").onclick = (e) => { e.preventDefault(); $("tordisc").hidden = true; syncHeight(); };
|
|
|
|
// ---- passive BCNR offer ----
|
|
T.onBcnrOffer((d) => {
|
|
const bar = $("bcnrbar");
|
|
if (d && d.host) { $("bcnrhost").textContent = d.host; $("bcnrreg").textContent = "BCDN"; bar.hidden = false; }
|
|
else bar.hidden = true;
|
|
syncHeight();
|
|
});
|
|
$("bcnropen").onclick = () => { $("bcnrbar").hidden = true; syncHeight(); T.switchToBcnr(); };
|
|
$("bcnrdismiss").onclick = (e) => { e.preventDefault(); $("bcnrbar").hidden = true; syncHeight(); };
|
|
T.onTor((d) => {
|
|
const b = $("tor"); b.className = "tor " + (d.state === "on" ? "on" : d.state === "connecting" ? "connecting" : "");
|
|
b.textContent = d.state === "on" ? "🧅 Tor: On" : d.state === "connecting" ? "🧅 connecting…" : "🧅 Tor: Off";
|
|
});
|
|
|
|
// Address-picker pick: the picker fires "address-pick" which routes to
|
|
// navigateTab, but the tabs event's focus-guard would leave the typed
|
|
// query in place if the URL input still had DOM focus. Force the full
|
|
// picked URL into the bar and drop focus so subsequent tabs events
|
|
// paint the loaded URL cleanly. Also arm a short-lived override that
|
|
// makes the next onTabs event overwrite the URL bar even if the input
|
|
// still shows focused — belt-and-braces for cases where blur() is
|
|
// async and the tabs event arrives first.
|
|
let overrideUrlBarUntil = 0;
|
|
T.onAddressPicked && T.onAddressPicked((url) => {
|
|
try { $("url").blur(); } catch (e) {}
|
|
$("url").value = String(url || "");
|
|
overrideUrlBarUntil = Date.now() + 1500;
|
|
});
|
|
|
|
// ---- tabs ----
|
|
T.onTabs((d) => {
|
|
$("back").disabled = !d.canBack; $("fwd").disabled = !d.canForward;
|
|
// loading indicator: progress bar + reload↔stop button
|
|
$("loadbar").classList.toggle("on", !!d.loading);
|
|
// Any tabs event that fires while no menu is open is a good moment to
|
|
// reset the chrome view height to the natural body-scrollHeight — this
|
|
// eliminates the "chrome stays huge after a menu action" gap that could
|
|
// otherwise linger if a menu-close path missed syncHeight().
|
|
if (!document.querySelector(".ctxmenu, .grouppop")) syncHeight();
|
|
const rb = $("reload");
|
|
if (d.loading) { rb.innerHTML = STOP_SVG; rb.title = "Stop"; rb.onclick = () => T.stop(); }
|
|
else { rb.innerHTML = RELOAD_SVG; rb.title = "Reload (Shift-click: hard reload)"; rb.onclick = (ev) => T.reload(ev.shiftKey); }
|
|
if (document.activeElement !== $("url") || Date.now() < overrideUrlBarUntil) $("url").value = d.url || "";
|
|
current.url = d.url || "";
|
|
const active = d.tabs.find((t) => t.active);
|
|
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 += `<button class="gchip g-${t.group}${isCollapsed ? " collapsed" : ""}" data-group="${t.group}" title="${isCollapsed ? "Expand" : "Collapse"} ${t.group} group"><span class="gcdot g-${t.group}"></span>${isCollapsed ? `<span class="gccnt">${groupsCount[t.group]}</span>` : ""}</button>`;
|
|
}
|
|
// 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
|
|
? '<span class="spin"></span>'
|
|
: (t.favicon ? `<img class="fav" src="${String(t.favicon).replace(/"/g,""")}" onerror="this.remove()">` : '');
|
|
const mute = t.muted ? `<span class="mute" title="Muted">🔇</span>` : "";
|
|
out += `<div class="tab ${t.active ? "active" : ""}${t.group ? " grp g-" + t.group : ""}" data-id="${t.id}" draggable="true">${icon}<span class="t">${(t.title||"New Tab").replace(/</g,"<")}</span>${mute}<span class="x" data-close="${t.id}">✕</span></div>`;
|
|
return out;
|
|
}).join("") +
|
|
`<span class="newtab" id="newtab">+</span>`;
|
|
// dragId is shared between tab-reorder handlers and group-chip drop
|
|
// handlers; declared here so both scopes see the same identity.
|
|
let dragId = null;
|
|
// Group chip clicks:
|
|
// collapsed chip → show a vertical popover listing the group's tabs
|
|
// expanded chip → toggle to collapsed
|
|
// Also acts as a drop target: drag any tab onto a chip to assign it
|
|
// to that group.
|
|
box.querySelectorAll(".gchip").forEach((chip) => {
|
|
chip.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
const color = chip.dataset.group;
|
|
if (chip.classList.contains("collapsed")) {
|
|
openGroupPopover(chip, color, d);
|
|
} else {
|
|
T.tabGroupToggle && T.tabGroupToggle(color);
|
|
}
|
|
};
|
|
chip.addEventListener("dragover", (ev) => {
|
|
if (dragId == null) return;
|
|
ev.preventDefault(); ev.dataTransfer.dropEffect = "move";
|
|
chip.classList.add("droptarget");
|
|
});
|
|
chip.addEventListener("dragleave", () => chip.classList.remove("droptarget"));
|
|
chip.addEventListener("drop", (ev) => {
|
|
ev.preventDefault();
|
|
chip.classList.remove("droptarget");
|
|
if (dragId != null) T.tabGroup(dragId, chip.dataset.group);
|
|
dragId = null;
|
|
});
|
|
});
|
|
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));
|
|
});
|
|
// Right-click a tab → floating menu (Reload / Duplicate / Group / Add to
|
|
// Bookmarks / Mute / Close). Menu closes on any other click.
|
|
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);
|
|
}));
|
|
// 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).
|
|
// dragId already declared above so group-chip drop targets share it.
|
|
box.querySelectorAll(".tab").forEach((row) => {
|
|
row.addEventListener("dragstart", (e) => {
|
|
dragId = Number(row.dataset.id);
|
|
try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(dragId)); } catch {}
|
|
row.classList.add("dragging");
|
|
});
|
|
row.addEventListener("dragend", () => {
|
|
row.classList.remove("dragging");
|
|
box.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter"));
|
|
dragId = null;
|
|
});
|
|
row.addEventListener("dragover", (e) => {
|
|
if (dragId == null || Number(row.dataset.id) === dragId) return;
|
|
e.preventDefault(); e.dataTransfer.dropEffect = "move";
|
|
const r = row.getBoundingClientRect();
|
|
const before = (e.clientX - r.left) < r.width / 2;
|
|
row.classList.toggle("dropbefore", before);
|
|
row.classList.toggle("dropafter", !before);
|
|
});
|
|
row.addEventListener("dragleave", () => row.classList.remove("dropbefore", "dropafter"));
|
|
row.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
const targetId = Number(row.dataset.id);
|
|
if (dragId == null || dragId === targetId) return;
|
|
const r = row.getBoundingClientRect();
|
|
const before = (e.clientX - r.left) < r.width / 2;
|
|
T.moveTab(dragId, targetId, before ? "before" : "after");
|
|
});
|
|
});
|
|
$("newtab").onclick = () => T.newTab();
|
|
});
|
|
|
|
// ---- Tab context menu (right-click a tab) ----
|
|
const TAB_GROUP_COLORS = [
|
|
{ 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" },
|
|
];
|
|
function closeAllMenus() {
|
|
document.querySelectorAll(".ctxmenu, .grouppop").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();
|
|
}
|
|
// Vertical dropdown of a collapsed group's tabs. Click a row → switch to
|
|
// that tab (and expand the group so the newly-active tab is visible in
|
|
// the strip). The X on a row closes just that tab. Popover closes on
|
|
// outside click via the shared document listener at the bottom.
|
|
function openGroupPopover(chip, color, d) {
|
|
closeAllMenus();
|
|
const rect = chip.getBoundingClientRect();
|
|
const pop = document.createElement("div");
|
|
pop.className = "grouppop";
|
|
pop.dataset.group = color;
|
|
const groupTabs = d.tabs.filter((t) => t.group === color);
|
|
const header = document.createElement("div");
|
|
header.className = "gph";
|
|
header.innerHTML = '<span class="gcdot g-' + color + '"></span><span>' + color + ' group · ' + groupTabs.length + ' tab' + (groupTabs.length === 1 ? '' : 's') + '</span>';
|
|
pop.appendChild(header);
|
|
for (const t of groupTabs) {
|
|
const row = document.createElement("div");
|
|
row.className = "gpit" + (t.active ? " active" : "");
|
|
const fav = t.favicon
|
|
? '<img class="gfav" src="' + String(t.favicon).replace(/"/g, """) + '" onerror="this.remove()">'
|
|
: '<span class="gfav" style="display:inline-block"></span>';
|
|
row.innerHTML = fav + '<span class="gt">' + String(t.title || "New Tab").replace(/</g, "<") + '</span><span class="gx" data-close="' + t.id + '" title="Close tab">✕</span>';
|
|
row.addEventListener("click", (ev) => {
|
|
if (ev.target.dataset.close) {
|
|
ev.stopPropagation();
|
|
T.closeTab(Number(ev.target.dataset.close));
|
|
// Rebuild popover after close (next onTabs update will refresh anyway).
|
|
return;
|
|
}
|
|
// Switch to this tab and expand the group so it's visible in the strip.
|
|
T.switchTab(t.id);
|
|
if (T.tabGroupToggle) T.tabGroupToggle(color); // was collapsed, now expand
|
|
closeAllMenus();
|
|
});
|
|
pop.appendChild(row);
|
|
}
|
|
pop.style.left = Math.round(rect.left) + "px";
|
|
pop.style.top = Math.round(rect.bottom + 4) + "px";
|
|
document.body.appendChild(pop);
|
|
const r = pop.getBoundingClientRect();
|
|
if (r.right > window.innerWidth) pop.style.left = Math.max(4, window.innerWidth - r.width - 4) + "px";
|
|
growChromeForMenu();
|
|
}
|
|
function openTabContextMenu(x, y, t) {
|
|
closeAllMenus();
|
|
const m = document.createElement("div");
|
|
m.className = "ctxmenu";
|
|
m.style.left = x + "px"; m.style.top = y + "px";
|
|
// Build items
|
|
const item = (label, cls, fn) => {
|
|
const el = document.createElement("div");
|
|
el.className = "mi" + (cls ? " " + cls : "");
|
|
el.innerHTML = label;
|
|
el.onclick = (ev) => { ev.stopPropagation(); if (fn) fn(); closeAllMenus(); };
|
|
return el;
|
|
};
|
|
m.appendChild(item("Reload", "", () => T.tabReload(t.id)));
|
|
m.appendChild(item("Duplicate", "", () => T.tabDuplicate(t.id)));
|
|
// Group submenu — hover/click opens a second menu next to the first.
|
|
const gp = item("Group " + (t.group ? '<span class="swatch g-' + t.group + '"></span>' : ""), "sub", null);
|
|
gp.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
const rect = gp.getBoundingClientRect();
|
|
openGroupSubmenu(rect.right + 2, rect.top, t);
|
|
};
|
|
m.appendChild(gp);
|
|
// Add to Bookmarks (disabled if the tab has no url — e.g. Home).
|
|
const bmDisabled = !t.url;
|
|
m.appendChild(item("Add to Bookmarks", bmDisabled ? "off" : "", bmDisabled ? null : () => T.tabBookmark(t.id)));
|
|
m.appendChild(item(t.muted ? "Unmute" : "Mute", "", () => T.tabMute(t.id)));
|
|
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 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";
|
|
growChromeForMenu();
|
|
}
|
|
function growChromeForMenu() {
|
|
// Wait a frame so all newly-added overlays contribute to the bounding box.
|
|
requestAnimationFrame(() => {
|
|
let needed = document.body.scrollHeight;
|
|
for (const m of document.querySelectorAll(".ctxmenu, .grouppop")) {
|
|
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());
|
|
const m = document.createElement("div");
|
|
m.className = "ctxmenu sub2";
|
|
m.style.left = x + "px"; m.style.top = y + "px";
|
|
const row = (html, fn) => {
|
|
const el = document.createElement("div");
|
|
el.className = "mi";
|
|
el.innerHTML = html;
|
|
el.onclick = (ev) => { ev.stopPropagation(); fn(); closeAllMenus(); };
|
|
m.appendChild(el);
|
|
};
|
|
row('<span class="swatch" style="background:transparent;border:1px dashed var(--line2)"></span>None' + (t.group ? "" : " ✓"), () => T.tabGroup(t.id, null));
|
|
for (const c of TAB_GROUP_COLORS) {
|
|
row('<span class="swatch g-' + c.id + '"></span>' + c.label + (t.group === c.id ? " ✓" : ""), () => T.tabGroup(t.id, c.id));
|
|
}
|
|
document.body.appendChild(m);
|
|
const r = m.getBoundingClientRect();
|
|
if (r.right > window.innerWidth) m.style.left = Math.max(4, x - r.width - 6) + "px";
|
|
growChromeForMenu();
|
|
}
|
|
document.addEventListener("click", (e) => {
|
|
// Close any open menus on outside click.
|
|
if (!e.target.closest(".ctxmenu, .grouppop, .gchip")) closeAllMenus();
|
|
});
|
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeAllMenus(); });
|
|
|
|
// ---- provenance ----
|
|
T.onNav((d) => {
|
|
setBadge(d); setReg(d);
|
|
if (document.activeElement !== $("url")) {
|
|
if (d.kind === "home") $("url").value = "";
|
|
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 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();
|
|
});
|
|
|
|
syncHeight();
|
|
// Self-heal: some menu-close paths (drag interruptions, focus flips) can
|
|
// leave the chrome view taller than the natural body-scrollHeight. Every
|
|
// 750 ms with no menu open, re-sync — main.js only re-layouts when the
|
|
// value actually changes, so this is a cheap no-op in the steady state.
|
|
setInterval(() => { if (!document.querySelector(".ctxmenu, .grouppop")) syncHeight(); }, 750);
|
|
</script>
|
|
</body>
|
|
</html>
|