diff --git a/chrome.html b/chrome.html index af1b1bf..7a884b4 100644 --- a/chrome.html +++ b/chrome.html @@ -115,7 +115,14 @@ shows the panel's icon (usually a single emoji from the manifest); click toggles the sidebar open on that panel, or collapses if it was already the active panel. */ + /* The dock and its inner buttons row never wrap — a shrunken slot makes + us collapse to the .extmore button (via data-extcollapse), it doesn't + stack the buttons vertically. inline-flex on #extbuttons keeps the + buttons on one line; overflow:hidden clips the last button just before + we swap to the collapsed dock in JS. */ + #extbuttons { display: inline-flex; flex-wrap: nowrap; gap: 2px; overflow: hidden; } .extdock { display: flex; gap: 2px; align-items: center; padding-left: 4px; margin-left: 2px; + flex-wrap: nowrap; overflow: hidden; min-width: 0; border-left: 1px solid var(--line); } /* inline-grid (not grid): grid defaults to block-level, which was making every extension button take its own line inside #extbuttons; inline-grid @@ -190,9 +197,15 @@ .bar[data-responsive="3"] .urlwrap { min-width: 120px; } /* Level 3: hide the per-extension button row, show the collapsed puzzle button. Dropdown is a floating .extpop the JS builds on click. */ - .bar[data-responsive="3"] #extbuttons { display: none; } + /* Two paths to the collapsed dock: the coarse width-based level 3 + (window very narrow) AND the fine-grained data-extcollapse (dock's + own slot ran out of room even though the window is wide, e.g. the + user dragged the URL bar so wide the extensions couldn't fit). */ + .bar[data-responsive="3"] #extbuttons, + .bar[data-extcollapse="1"] #extbuttons { display: none; } .extmore { display: none; } - .bar[data-responsive="3"] .extmore { display: inline-grid; } + .bar[data-responsive="3"] .extmore, + .bar[data-extcollapse="1"] .extmore { display: inline-grid; } .extmore { width: 30px; height: 30px; place-items: center; border-radius: 7px; cursor: pointer; border: none; background: transparent; padding: 0; font-size: 15px; line-height: 1; color: var(--mut); } @@ -578,42 +591,25 @@ const menu = lastMenus.find((m) => m.addonId === addonId); if (!menu) return; openMenuAddonId = addonId; - // Update the button's active state cheaply — no full re-render needed - // yet; the pop will be built once and outside-click will re-render. + // Tint the anchor while the menu is showing; main clears it when the + // native menu closes via the "toolbar-menu-closed" event. const btns = extButtons.querySelectorAll(".extbtn[data-menu]"); btns.forEach((b) => b.classList.toggle("active", b.dataset.menu === addonId)); $("extmore").classList.toggle("active", true); - const rect = anchorBtn.getBoundingClientRect(); - const pop = document.createElement("div"); - pop.id = "tbmenu"; pop.className = "extpop"; - pop.innerHTML = (menu.items || []).map((it) => { - const ic = String(it.icon || "•").replace(/${ic}${lbl}`; - }).join("") || `
No items
`; - document.body.appendChild(pop); - const pr = pop.getBoundingClientRect(); - // Anchor under the button, left-aligned with the icon but kept inside - // the viewport. - pop.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - pr.width - 4)) + "px"; - pop.style.top = Math.round(rect.bottom + 4) + "px"; - pop.querySelectorAll(".epit[data-item]").forEach((row) => { - row.onclick = () => { - const iid = row.dataset.item; - closeToolbarMenu(); - if (T.addonMenuSelect) T.addonMenuSelect(addonId, iid).catch((err) => console.warn("addon-menu-select failed:", err?.message || err)); - }; - }); - setTimeout(() => { - const off = (e) => { - if (!pop.contains(e.target) && e.target !== anchorBtn) { - closeToolbarMenu(); - document.removeEventListener("mousedown", off); - } - }; - document.addEventListener("mousedown", off); - }, 0); + // Hand off to a NATIVE OS menu. Renderer-DOM dropdowns get clipped by + // chrome.html's own WebContentsView height and hidden behind the tab + // view below — a native Menu.popup escapes that layering. + const r = anchorBtn.getBoundingClientRect(); + const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 2), width: Math.round(r.width), height: Math.round(r.height) }; + if (T.toolbarMenuPopup) { + T.toolbarMenuPopup(addonId, rect).catch((err) => console.warn("toolbar-menu-popup failed:", err?.message || err)); + } } + // Main tells us when the native menu is dismissed so we can clear the + // "active" tint. `openMenuAddonId` stays set until this arrives (a click + // on the same button while the menu is up is treated as "close by + // reopening intent" by the OS anyway). + if (T.onToolbarMenuClosed) T.onToolbarMenuClosed(() => closeToolbarMenu()); T.sidebarState && T.sidebarState().then(renderSidebarState); T.onSidebarState && T.onSidebarState(renderSidebarState); @@ -679,6 +675,54 @@ for (const b of RESPONSIVE_BREAKS) if (w >= b.w) return b.level; return "3"; } + // Fine-grained overflow check: if the extension buttons can't fit their + // own slot in the bar, force the collapsed dock (single button + dropdown) + // regardless of window width. Fires when the user drags the URL bar wider, + // adds an extension, or a wider-icon extension gets registered. + const extButtonsEl = $("extbuttons"); + const extDockEl = $("extdock"); + // Cache the natural width of the buttons row while expanded. When the dock + // is currently collapsed, #extbuttons has display:none so scrollWidth is + // 0 — measuring it then would falsely decide "plenty of room" and expand, + // which would immediately overflow again → RO loop. Learn while expanded + // and trust that value while collapsed. 8px hysteresis on the re-expand + // side prevents boundary oscillation. + let naturalExtWidth = 0; + function updateExtCollapse() { + if (!extButtonsEl || !extDockEl) return; + const currentlyCollapsed = barEl.dataset.extcollapse === "1"; + if (!currentlyCollapsed) { + naturalExtWidth = Math.max(naturalExtWidth, extButtonsEl.scrollWidth); + // While expanded: collapse when .bar's contents overflow their slot + // (i.e. the row + everything else doesn't fit horizontally). + if (barEl.scrollWidth > barEl.clientWidth + 2 && barEl.dataset.responsive !== "3") { + barEl.dataset.extcollapse = "1"; + } + } else { + // While collapsed: re-expand only when there's demonstrably room in + // .bar for the buttons at their natural width. Sum the widths of all + // other .bar children (skipping display:none), add the collapsed + // dock's target size (natural), and compare to bar.clientWidth. 8px + // slack + require the strict "current bar isn't already overflowing" + // to keep the toggle from oscillating at the boundary. + if (barEl.scrollWidth > barEl.clientWidth + 2) return; // still cramped + const barStyle = getComputedStyle(barEl); + const gap = parseFloat(barStyle.gap) || 0; + let usedByOthers = 0; + let visibleKids = 0; + for (const k of barEl.children) { + if (getComputedStyle(k).display === "none") continue; + visibleKids += 1; + if (k === extDockEl) continue; + usedByOthers += k.getBoundingClientRect().width; + } + const gaps = gap * Math.max(0, visibleKids - 1); + // .bar has horizontal padding too — subtract it. + const padX = (parseFloat(barStyle.paddingLeft) || 0) + (parseFloat(barStyle.paddingRight) || 0); + const availableForDock = barEl.clientWidth - usedByOthers - gaps - padX; + if (availableForDock >= naturalExtWidth + 8) delete barEl.dataset.extcollapse; + } + } const ro = new ResizeObserver((entries) => { for (const e of entries) { const level = pickResponsive(e.contentRect.width); @@ -688,11 +732,18 @@ if (level !== "3") closeExtPop(); } } + // Re-check overflow every time the bar resizes — cheap. + updateExtCollapse(); }); ro.observe(barEl); + // Also observe #extbuttons directly so that a new extension being + // registered (which changes scrollWidth without a bar resize) triggers a + // re-check. + if (extButtonsEl) new ResizeObserver(updateExtCollapse).observe(extButtonsEl); // Initial pass — RO fires on subscribe, but only after the next frame; // set an initial value so first paint doesn't flash the wide layout. barEl.dataset.responsive = pickResponsive(barEl.getBoundingClientRect().width); + updateExtCollapse(); // ---- favorites bar (new-tab page only) ---- let current = { url: "", title: "", favicon: null }, bookmarks = []; @@ -1228,6 +1279,16 @@ const sw = Number(s.searchBoxWidthPx) || 0; if (sw > 0) { bar.dataset.searchwidth = "1"; bar.style.setProperty("--searchbox-w", sw + "px"); } else { delete bar.dataset.searchwidth; bar.style.removeProperty("--searchbox-w"); } + // The URL/search width change squeezes the extension dock; the RO on + // #extbuttons doesn't always fire (overflow:hidden parent), so re-check + // overflow explicitly after the next layout pass. requestAnimationFrame + // isn't always enough (the CSS var propagation + reflow can straddle + // frames), hence the belt-and-braces setTimeout too. + if (typeof updateExtCollapse === "function") { + requestAnimationFrame(updateExtCollapse); + setTimeout(updateExtCollapse, 30); + setTimeout(updateExtCollapse, 120); + } syncHeight(); } T.getSettings && T.getSettings().then(applyBarSizes); @@ -1268,11 +1329,13 @@ const barEl2 = $("bar"); makeDragHandle($("urldrag"), $("bar").querySelector(".urlwrap"), { min: 200, max: 1800, settingKey: "urlBarWidthPx", reverseX: false, - applyLive: (w) => { barEl2.dataset.urlwidth = "1"; barEl2.style.setProperty("--urlbar-w", w + "px"); }, + applyLive: (w) => { barEl2.dataset.urlwidth = "1"; barEl2.style.setProperty("--urlbar-w", w + "px"); + if (typeof updateExtCollapse === "function") updateExtCollapse(); }, }); makeDragHandle($("searchdrag"), $("bar").querySelector(".searchbox"), { min: 120, max: 800, settingKey: "searchBoxWidthPx", reverseX: true, - applyLive: (w) => { barEl2.dataset.searchwidth = "1"; barEl2.style.setProperty("--searchbox-w", w + "px"); }, + applyLive: (w) => { barEl2.dataset.searchwidth = "1"; barEl2.style.setProperty("--searchbox-w", w + "px"); + if (typeof updateExtCollapse === "function") updateExtCollapse(); }, }); // Self-heal: some menu-close paths (drag interruptions, focus flips) can // leave the chrome view taller than the natural body-scrollHeight. Every