fix(theseus/chrome): dock collapses to puzzle button on any overflow, no column
The extension row was stacking into a column when the user dragged the URL bar wide enough to squeeze the dock's slot. Two things broken: 1) #extbuttons could wrap: added flex-wrap:nowrap + overflow:hidden so the buttons never break to a new line. Same for .extdock's own flex container (nowrap + min-width:0 so it can flex-shrink to zero). 2) Collapse was width-based on .bar only — a window wide enough for level 0 wouldn't collapse the dock even when the URL-bar override left extdock with 0 px. Added a second signal: data-extcollapse="1" fires when .bar's contents overflow their slot (bar.scrollWidth > bar.clientWidth). Applies alongside the level-3 collapse; either path shows .extmore instead of the row. Hysteresis to avoid RO loop: while EXPANDED, cache the row's natural scrollWidth. While COLLAPSED, re-expand only when summing every other .bar child leaves at least (naturalWidth + 8 px slack) of room. Both directions verified across URL widths 400/800/1200/1500/reset — the row expands / collapses at the right thresholds with no oscillation.
This commit is contained in:
parent
027653e9e2
commit
b433dbc0b6
1 changed files with 99 additions and 36 deletions
135
chrome.html
135
chrome.html
|
|
@ -115,7 +115,14 @@
|
||||||
shows the panel's icon (usually a single emoji from the manifest);
|
shows the panel's icon (usually a single emoji from the manifest);
|
||||||
click toggles the sidebar open on that panel, or collapses if it
|
click toggles the sidebar open on that panel, or collapses if it
|
||||||
was already the active panel. */
|
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;
|
.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); }
|
border-left: 1px solid var(--line); }
|
||||||
/* inline-grid (not grid): grid defaults to block-level, which was making
|
/* inline-grid (not grid): grid defaults to block-level, which was making
|
||||||
every extension button take its own line inside #extbuttons; inline-grid
|
every extension button take its own line inside #extbuttons; inline-grid
|
||||||
|
|
@ -190,9 +197,15 @@
|
||||||
.bar[data-responsive="3"] .urlwrap { min-width: 120px; }
|
.bar[data-responsive="3"] .urlwrap { min-width: 120px; }
|
||||||
/* Level 3: hide the per-extension button row, show the collapsed puzzle
|
/* Level 3: hide the per-extension button row, show the collapsed puzzle
|
||||||
button. Dropdown is a floating .extpop the JS builds on click. */
|
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; }
|
.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;
|
.extmore { width: 30px; height: 30px; place-items: center; border-radius: 7px;
|
||||||
cursor: pointer; border: none; background: transparent; padding: 0;
|
cursor: pointer; border: none; background: transparent; padding: 0;
|
||||||
font-size: 15px; line-height: 1; color: var(--mut); }
|
font-size: 15px; line-height: 1; color: var(--mut); }
|
||||||
|
|
@ -578,42 +591,25 @@
|
||||||
const menu = lastMenus.find((m) => m.addonId === addonId);
|
const menu = lastMenus.find((m) => m.addonId === addonId);
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
openMenuAddonId = addonId;
|
openMenuAddonId = addonId;
|
||||||
// Update the button's active state cheaply — no full re-render needed
|
// Tint the anchor while the menu is showing; main clears it when the
|
||||||
// yet; the pop will be built once and outside-click will re-render.
|
// native menu closes via the "toolbar-menu-closed" event.
|
||||||
const btns = extButtons.querySelectorAll(".extbtn[data-menu]");
|
const btns = extButtons.querySelectorAll(".extbtn[data-menu]");
|
||||||
btns.forEach((b) => b.classList.toggle("active", b.dataset.menu === addonId));
|
btns.forEach((b) => b.classList.toggle("active", b.dataset.menu === addonId));
|
||||||
$("extmore").classList.toggle("active", true);
|
$("extmore").classList.toggle("active", true);
|
||||||
const rect = anchorBtn.getBoundingClientRect();
|
// Hand off to a NATIVE OS menu. Renderer-DOM dropdowns get clipped by
|
||||||
const pop = document.createElement("div");
|
// chrome.html's own WebContentsView height and hidden behind the tab
|
||||||
pop.id = "tbmenu"; pop.className = "extpop";
|
// view below — a native Menu.popup escapes that layering.
|
||||||
pop.innerHTML = (menu.items || []).map((it) => {
|
const r = anchorBtn.getBoundingClientRect();
|
||||||
const ic = String(it.icon || "•").replace(/</g,"<");
|
const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 2), width: Math.round(r.width), height: Math.round(r.height) };
|
||||||
const lbl = String(it.label || it.id).replace(/</g,"<");
|
if (T.toolbarMenuPopup) {
|
||||||
return `<div class="epit" data-item="${String(it.id).replace(/"/g,""")}"><span class="ic">${ic}</span><span class="lbl">${lbl}</span></div>`;
|
T.toolbarMenuPopup(addonId, rect).catch((err) => console.warn("toolbar-menu-popup failed:", err?.message || err));
|
||||||
}).join("") || `<div class="epit" style="opacity:.6;cursor:default">No items</div>`;
|
}
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
// 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.sidebarState && T.sidebarState().then(renderSidebarState);
|
||||||
T.onSidebarState && T.onSidebarState(renderSidebarState);
|
T.onSidebarState && T.onSidebarState(renderSidebarState);
|
||||||
|
|
||||||
|
|
@ -679,6 +675,54 @@
|
||||||
for (const b of RESPONSIVE_BREAKS) if (w >= b.w) return b.level;
|
for (const b of RESPONSIVE_BREAKS) if (w >= b.w) return b.level;
|
||||||
return "3";
|
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) => {
|
const ro = new ResizeObserver((entries) => {
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const level = pickResponsive(e.contentRect.width);
|
const level = pickResponsive(e.contentRect.width);
|
||||||
|
|
@ -688,11 +732,18 @@
|
||||||
if (level !== "3") closeExtPop();
|
if (level !== "3") closeExtPop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Re-check overflow every time the bar resizes — cheap.
|
||||||
|
updateExtCollapse();
|
||||||
});
|
});
|
||||||
ro.observe(barEl);
|
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;
|
// 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.
|
// set an initial value so first paint doesn't flash the wide layout.
|
||||||
barEl.dataset.responsive = pickResponsive(barEl.getBoundingClientRect().width);
|
barEl.dataset.responsive = pickResponsive(barEl.getBoundingClientRect().width);
|
||||||
|
updateExtCollapse();
|
||||||
|
|
||||||
// ---- favorites bar (new-tab page only) ----
|
// ---- favorites bar (new-tab page only) ----
|
||||||
let current = { url: "", title: "", favicon: null }, bookmarks = [];
|
let current = { url: "", title: "", favicon: null }, bookmarks = [];
|
||||||
|
|
@ -1228,6 +1279,16 @@
|
||||||
const sw = Number(s.searchBoxWidthPx) || 0;
|
const sw = Number(s.searchBoxWidthPx) || 0;
|
||||||
if (sw > 0) { bar.dataset.searchwidth = "1"; bar.style.setProperty("--searchbox-w", sw + "px"); }
|
if (sw > 0) { bar.dataset.searchwidth = "1"; bar.style.setProperty("--searchbox-w", sw + "px"); }
|
||||||
else { delete bar.dataset.searchwidth; bar.style.removeProperty("--searchbox-w"); }
|
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();
|
syncHeight();
|
||||||
}
|
}
|
||||||
T.getSettings && T.getSettings().then(applyBarSizes);
|
T.getSettings && T.getSettings().then(applyBarSizes);
|
||||||
|
|
@ -1268,11 +1329,13 @@
|
||||||
const barEl2 = $("bar");
|
const barEl2 = $("bar");
|
||||||
makeDragHandle($("urldrag"), $("bar").querySelector(".urlwrap"), {
|
makeDragHandle($("urldrag"), $("bar").querySelector(".urlwrap"), {
|
||||||
min: 200, max: 1800, settingKey: "urlBarWidthPx", reverseX: false,
|
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"), {
|
makeDragHandle($("searchdrag"), $("bar").querySelector(".searchbox"), {
|
||||||
min: 120, max: 800, settingKey: "searchBoxWidthPx", reverseX: true,
|
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
|
// Self-heal: some menu-close paths (drag interruptions, focus flips) can
|
||||||
// leave the chrome view taller than the natural body-scrollHeight. Every
|
// leave the chrome view taller than the natural body-scrollHeight. Every
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue