From 68735ad49065a5b97574919d462e6c79049f30ff Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 20 Sep 2026 14:39:07 +0200 Subject: [PATCH] fix(theseus): address bar follows the page; tab strip stops blinking; extensions page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Address bar: the chrome view keeps document.activeElement on the URL input after the user clicks into the page (focus moved to the tab's own view), and the "don't clobber typed text" guard then froze the bar until something blurred the field. The guard now requires real focus (document.hasFocus()), and the field is blurred when the view loses focus. - Tab strip: rebuilt with innerHTML on every tabs event, which recreated every favicon — a blink on the other tabs whenever one tab loaded, reloaded or changed title — and dropped drag state mid-gesture. Elements are now keyed by tab id (chips by group colour), updated in place, and moved into order; the strip is never rebuilt. - Settings › Extensions links to theseus.x/extensions. That page now has a card per bundled add-on with the current signed version, tarball and hash read from each add-on's updates.json at load (it still claimed Screenshot 0.2.4 while the channel serves 0.6.5). --- chrome.html | 221 ++++++++++++++++++++++++++------------------------ settings.html | 1 + 2 files changed, 115 insertions(+), 107 deletions(-) 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 ↗