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 += `
${icon}${(t.title||"New Tab").replace(/${mute}
`; - return out; - }).join("") + - `+`; - // 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 → 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); - 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). - // 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(); - }); + if (t.group && collapsed.has(t.group)) continue; // hidden inside a collapsed group + const el = existing.get("tab:" + t.id) || makeTab(t.id); + updateTab(el, t); + order.push(el); + } + let plus = document.getElementById("newtab"); + if (!plus) { plus = document.createElement("span"); plus.className = "newtab"; plus.id = "newtab"; plus.textContent = "+"; plus.onclick = () => T.newTab(); } + order.push(plus); + const keep = new Set(order); + for (const el of [...box.children]) if (!keep.has(el)) el.remove(); + order.forEach((el, i) => { if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null); }); + } // ---- Tab context menu (right-click a tab) ---- const TAB_GROUP_COLORS = [ @@ -1432,7 +1439,7 @@ // ---- provenance ---- T.onNav((d) => { setBadge(d); setReg(d); - if (document.activeElement !== $("url")) { + if (!urlBarEditing()) { if (d.kind === "home") $("url").value = ""; else if (d.host && !$("url").value) $("url").value = d.host; } diff --git a/settings.html b/settings.html index 264779d..e6cd70c 100644 --- a/settings.html +++ b/settings.html @@ -597,6 +597,7 @@ Bundled reference extensions (like the Notepad) are copied there on first run — you can edit or remove them without losing anything the browser needs.

+ Browse extensions on theseus.x ↗