diff --git a/chrome.html b/chrome.html
index 9a0188b..e3d36bc 100644
--- a/chrome.html
+++ b/chrome.html
@@ -60,6 +60,8 @@
box-shadow: inset 0 2px 0 var(--acid); }
.tab.active.grp { box-shadow: inset 0 2px 0 var(--gc, var(--acid)); }
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
+ .tab .ico { display: flex; align-items: center; flex: none; }
+ .tab .ico:empty { display: none; }
.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: var(--acid); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@@ -1159,6 +1161,10 @@
// still shows focused — belt-and-braces for cases where blur() is
// async and the tabs event arrives first.
let overrideUrlBarUntil = 0;
+ const urlBarEditing = () => document.hasFocus() && document.activeElement === $("url");
+ // When focus leaves this view (the user clicked into the page or another
+ // window), drop the input's focus so the bar tracks the page again.
+ window.addEventListener("blur", () => { if (document.activeElement === $("url")) $("url").blur(); });
T.onAddressPicked && T.onAddressPicked((url) => {
try { $("url").blur(); } catch (e) {}
$("url").value = String(url || "");
@@ -1181,122 +1187,123 @@
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 || "";
+ // Only hold the typed text while the field really has the user's focus:
+ // this view keeps document.activeElement on the input after the user
+ // clicks into the page (focus moved to the tab's own view), and the bar
+ // then stopped following navigations until something blurred it.
+ if (!urlBarEditing() || Date.now() < overrideUrlBarUntil) $("url").value = d.url || "";
current.url = d.url || "";
const active = d.tabs.find((t) => t.active);
current.title = active ? active.title : "";
current.favicon = active ? (active.favicon || null) : null;
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.
+ renderTabStrip($("tabs"), d);
+ });
+ // ---- tab strip: keyed reconciliation ------------------------------------
+ // The strip used to be rebuilt with innerHTML on every tabs event. That
+ // recreated every favicon — a visible blink on the OTHER tabs each
+ // time one tab loaded, reloaded or changed title — and dropped drag/hover
+ // state mid-gesture. Elements are now keyed (tab id, or group colour for a
+ // chip), updated in place, and moved into order with insertBefore, which
+ // never recreates a node or reloads its image. Handlers attach once, at
+ // creation; they read the latest payload from lastTabsData.
+ let dragId = null; // shared by tab drag-reorder and group-chip drop targets
+ let lastTabsData = null;
+ function makeChip(color) {
+ const chip = document.createElement("button");
+ chip.className = "gchip g-" + color; chip.dataset.group = color; chip.dataset.key = "chip:" + color;
+ chip.innerHTML = ``;
+ // collapsed chip → popover listing the group's tabs; expanded → collapse
+ chip.onclick = (ev) => {
+ ev.stopPropagation();
+ if (chip.classList.contains("collapsed")) openGroupPopover(chip, color, lastTabsData);
+ else T.tabGroupToggle && T.tabGroupToggle(color);
+ };
+ // drop target: drag any tab onto the chip to assign it to that group
+ 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, color); dragId = null; });
+ return chip;
+ }
+ function makeTab(id) {
+ const el = document.createElement("div");
+ el.className = "tab"; el.dataset.id = String(id); el.dataset.key = "tab:" + id; el.draggable = true;
+ el.innerHTML = `🔇✕`;
+ el.onclick = (e) => { if (e.target.dataset.close) T.closeTab(id); else T.switchTab(id); };
+ // Right-click → native OS menu popped from main.js (a DOM menu forced the
+ // chrome view taller and opened a gap under the toolbar).
+ el.addEventListener("contextmenu", (e) => { e.preventDefault(); if (T.tabContextMenuPopup) T.tabContextMenuPopup(id, { x: e.clientX, y: e.clientY }); });
+ // Drag-reorder — drop side chosen by pointer half (matches Chrome UX).
+ el.addEventListener("dragstart", (e) => { dragId = id; try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); } catch {} el.classList.add("dragging"); });
+ el.addEventListener("dragend", () => { el.classList.remove("dragging"); el.parentElement?.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter")); dragId = null; });
+ el.addEventListener("dragover", (e) => {
+ if (dragId == null || id === dragId) return;
+ e.preventDefault(); e.dataTransfer.dropEffect = "move";
+ const r = el.getBoundingClientRect(); const before = (e.clientX - r.left) < r.width / 2;
+ el.classList.toggle("dropbefore", before); el.classList.toggle("dropafter", !before);
+ });
+ el.addEventListener("dragleave", () => el.classList.remove("dropbefore", "dropafter"));
+ el.addEventListener("drop", (e) => {
+ e.preventDefault();
+ if (dragId == null || dragId === id) return;
+ const r = el.getBoundingClientRect(); const before = (e.clientX - r.left) < r.width / 2;
+ T.moveTab(dragId, id, before ? "before" : "after");
+ });
+ return el;
+ }
+ function updateTab(el, t) {
+ el.classList.toggle("active", !!t.active);
+ el.classList.toggle("grp", !!t.group);
+ for (const c of [...el.classList]) if (c.startsWith("g-") && c !== "g-" + t.group) el.classList.remove(c);
+ if (t.group) el.classList.add("g-" + t.group);
+ const tt = (t.title || "New Tab") + (t.url ? "\n" + t.url : "");
+ if (el.title !== tt) el.title = tt;
+ const label = el.querySelector(".t"); const txt = t.title || "New Tab";
+ if (label.textContent !== txt) label.textContent = txt;
+ el.querySelector(".mute").hidden = !t.muted;
+ // Icon slot: spinner while loading, otherwise the favicon. The
is
+ // only touched when the URL actually changes, so it never reloads.
+ const ico = el.querySelector(".ico");
+ const want = t.loading ? "spin" : (t.favicon ? "fav:" + t.favicon : "none");
+ if (ico.dataset.state !== want) {
+ ico.dataset.state = want;
+ if (want === "spin") ico.innerHTML = '';
+ else if (want === "none") ico.replaceChildren();
+ else { const img = document.createElement("img"); img.className = "fav"; img.src = t.favicon; img.onerror = () => { img.remove(); }; ico.replaceChildren(img); }
+ }
+ }
+ function renderTabStrip(box, d) {
+ lastTabsData = d;
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 existing = new Map();
+ for (const el of box.children) if (el.dataset.key) existing.set(el.dataset.key, el);
+ const order = [], seen = new Set();
+ for (const t of d.tabs) {
+ // Chip once, before the group's first tab in render order.
+ if (t.group && !seen.has(t.group)) {
+ seen.add(t.group);
+ const chip = existing.get("chip:" + t.group) || makeChip(t.group);
const isCollapsed = collapsed.has(t.group);
- out += ``;
+ chip.classList.toggle("collapsed", isCollapsed);
+ chip.title = (isCollapsed ? "Expand " : "Collapse ") + t.group + " group";
+ const cnt = chip.querySelector(".gccnt");
+ cnt.hidden = !isCollapsed; if (isCollapsed) cnt.textContent = String(groupsCount[t.group]);
+ order.push(chip);
}
- // 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 mute = t.muted ? `🔇` : "";
- const tt = ((t.title || "New Tab") + (t.url ? "\n" + t.url : "")).replace(/"/g, """);
- out += `