feat: replace the FAB with a 7x44 edge tab

The circular button covered artwork exactly while reading. The tab keeps the
drag-and-snap behaviour and the mangabm:fabpos key; only its box changes. The
dwell countdown becomes a bottom-up fill of the tab, since a sliver has no room
for a ring.

Also fixes a latent bug the new shape exposes: placeFab and makeDraggable used
offsetWidth for the vertical clamp too. Invisible on a 52px circle, off by 37px
on a 7x44 tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 15:36:31 +07:00
parent d93a94f1aa
commit 04359ea034
+36 -47
View File
@@ -897,7 +897,8 @@
// ============================================================ // ============================================================
const FAB_KEY = "mangabm:fabpos"; const FAB_KEY = "mangabm:fabpos";
const FAB_MARGIN = 12; const FAB_MARGIN = 12; // vertical breathing room at the top and bottom
const FAB_EDGE = 0; // horizontal: an edge tab sits flush against the side
function clamp(v, lo, hi) { function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v)); return Math.max(lo, Math.min(hi, v));
@@ -906,11 +907,12 @@
function applyFabPos(fab, side, y) { function applyFabPos(fab, side, y) {
fab.style.top = y + "px"; fab.style.top = y + "px";
fab.style.bottom = "auto"; fab.style.bottom = "auto";
fab.classList.toggle("left", side === "left");
if (side === "left") { if (side === "left") {
fab.style.left = FAB_MARGIN + "px"; fab.style.left = FAB_EDGE + "px";
fab.style.right = "auto"; fab.style.right = "auto";
} else { } else {
fab.style.right = FAB_MARGIN + "px"; fab.style.right = FAB_EDGE + "px";
fab.style.left = "auto"; fab.style.left = "auto";
} }
} }
@@ -932,9 +934,9 @@
} }
function placeFab(fab) { function placeFab(fab) {
const s = fab.offsetWidth || 52; const h = fab.offsetHeight || 44;
const p = loadFabPos(); const p = loadFabPos();
const maxY = window.innerHeight - s - FAB_MARGIN; const maxY = window.innerHeight - h - FAB_MARGIN;
const side = p && p.side === "left" ? "left" : "right"; const side = p && p.side === "left" ? "left" : "right";
const y = p ? clamp(p.yRatio * window.innerHeight, FAB_MARGIN, maxY) : maxY - 8; const y = p ? clamp(p.yRatio * window.innerHeight, FAB_MARGIN, maxY) : maxY - 8;
applyFabPos(fab, side, y); applyFabPos(fab, side, y);
@@ -958,10 +960,10 @@
if (!moved && (Math.abs(e.clientX - sx) > 6 || Math.abs(e.clientY - sy) > 6)) moved = true; if (!moved && (Math.abs(e.clientX - sx) > 6 || Math.abs(e.clientY - sy) > 6)) moved = true;
if (!moved) return; if (!moved) return;
e.preventDefault(); e.preventDefault();
const s = fab.offsetWidth; const w = fab.offsetWidth, h = fab.offsetHeight;
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - s) + "px"; fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - w) + "px";
fab.style.right = "auto"; fab.style.right = "auto";
fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - s) + "px"; fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - h) + "px";
fab.style.bottom = "auto"; fab.style.bottom = "auto";
}); });
fab.addEventListener("pointerup", (e) => { fab.addEventListener("pointerup", (e) => {
@@ -970,10 +972,10 @@
fab.style.transition = ""; fab.style.transition = "";
if (!moved) return; // tap — let the click handler toggle the panel if (!moved) return; // tap — let the click handler toggle the panel
fab.dataset.dragged = "1"; // suppress the click that trails this drag fab.dataset.dragged = "1"; // suppress the click that trails this drag
const s = fab.offsetWidth; const w = fab.offsetWidth, h = fab.offsetHeight;
const r = fab.getBoundingClientRect(); const r = fab.getBoundingClientRect();
const side = r.left + s / 2 < window.innerWidth / 2 ? "left" : "right"; const side = r.left + w / 2 < window.innerWidth / 2 ? "left" : "right";
const y = clamp(r.top, FAB_MARGIN, window.innerHeight - s - FAB_MARGIN); const y = clamp(r.top, FAB_MARGIN, window.innerHeight - h - FAB_MARGIN);
fab.style.transition = "left .2s ease, right .2s ease, top .2s ease"; fab.style.transition = "left .2s ease, right .2s ease, top .2s ease";
applyFabPos(fab, side, y); applyFabPos(fab, side, y);
saveFabPos(side, y / window.innerHeight); saveFabPos(side, y / window.innerHeight);
@@ -981,31 +983,26 @@
} }
// ============================================================ // ============================================================
// Countdown ring around the FAB (dwell-timer progress) // Dwell-timer progress: the tab fills bottom-up. A 7px sliver has no room
// for the ring this replaced.
// ============================================================ // ============================================================
const RING_C = 144.5; // circumference: 2π·23
function startRing(ms) { function startRing(ms) {
if (!root) return; if (!root) return;
const ring = root.getElementById("ring"); const fill = root.getElementById("fill");
const prog = ring.firstElementChild; fill.style.transition = "none";
ring.classList.add("active"); fill.style.height = "0";
prog.style.transition = "none"; fill.getBoundingClientRect(); // reflow so the next assignment animates
prog.style.strokeDashoffset = RING_C; // empty fill.style.transition = "height " + ms + "ms linear";
prog.getBoundingClientRect(); // reflow so the next assignment animates fill.style.height = "100%";
prog.style.transition = "stroke-dashoffset " + ms + "ms linear";
prog.style.strokeDashoffset = "0"; // fills over ms
} }
function stopRing() { function stopRing() {
if (!root) return; if (!root) return;
const ring = root.getElementById("ring"); const fill = root.getElementById("fill");
if (!ring) return; if (!fill) return;
const prog = ring.firstElementChild; fill.style.transition = "none";
ring.classList.remove("active"); fill.style.height = "0";
prog.style.transition = "none";
prog.style.strokeDashoffset = RING_C;
} }
function togglePanel() { function togglePanel() {
@@ -1227,10 +1224,7 @@
// ============================================================ // ============================================================
const TEMPLATE = ` const TEMPLATE = `
<button id="fab" aria-label="Manga bookmarks"> <button id="fab" aria-label="Manga bookmarks"><span id="fill"></span></button>
<svg id="ring" viewBox="0 0 52 52" aria-hidden="true"><circle class="prog" cx="26" cy="26" r="23"></circle></svg>
<span class="fab-icon">📑</span>
</button>
<div id="backdrop"></div> <div id="backdrop"></div>
<aside id="panel" role="dialog" aria-label="Manga bookmarks"> <aside id="panel" role="dialog" aria-label="Manga bookmarks">
<header> <header>
@@ -1259,23 +1253,18 @@
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; } * { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
#fab { #fab {
position: fixed; position: fixed;
width: 52px; height: 52px; border-radius: 50%; border: none; width: 7px; height: 44px; border: none; padding: 0;
background: #6d28d9; color: #fff; font-size: 22px; border-radius: 4px 0 0 4px;
box-shadow: 0 4px 14px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer; background: #6d28d9; opacity: .45;
touch-action: none; user-select: none; box-shadow: 0 2px 8px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
display: flex; align-items: center; justify-content: center; padding: 0; touch-action: none; user-select: none; overflow: hidden;
} }
#fab:active { transform: scale(.94); } #fab.left { border-radius: 0 4px 4px 0; }
#ring { #fab:active { opacity: 1; }
position: absolute; inset: 0; transform: rotate(-90deg); #fill {
opacity: 0; transition: opacity .15s; pointer-events: none; position: absolute; left: 0; right: 0; bottom: 0; height: 0;
background: #c4b5fd; pointer-events: none;
} }
#ring.active { opacity: 1; }
#ring .prog {
fill: none; stroke: #c4b5fd; stroke-width: 3; stroke-linecap: round;
stroke-dasharray: 144.5; stroke-dashoffset: 144.5;
}
.fab-icon { position: relative; z-index: 1; line-height: 1; }
#backdrop { #backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.5); position: fixed; inset: 0; background: rgba(0,0,0,.5);
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;