Widen the edge-tab tap target, split scroll from reposition #9

Merged
sulthan merged 5 commits from userscript-edge-tab-hitbox into main 2026-07-28 20:36:26 +07:00
2 changed files with 99 additions and 14 deletions
+8 -1
View File
@@ -96,7 +96,14 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
5. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS
(critical on mobile). Three tabs (All / Favourites / Archived) and a row of
link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG
block next to `API_BASE`.
block next to `API_BASE`. The FAB is a `7 × 44` edge tab whose *hit* area is
widened to `28 × 72` by an invisible `#hit` child; `#fab` must keep
`touch-action: none` and must **not** regain `overflow: hidden`. Because
`touch-action` is resolved at gesture start, the strip cannot be both
browser-scrolled and script-dragged, so `makeDraggable` splits by intent: a
swipe from `#hit` scrolls via `window.scrollBy`, a hold of `ARM_MS` arms a
reposition drag, and the visible sliver drags with no hold. See
`docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`.
6. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices).
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
+91 -13
View File
@@ -904,6 +904,7 @@
const FAB_KEY = "mangabm:fabpos";
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
const ARM_MS = 400; // hold this long on the invisible strip to arm a drag
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
@@ -947,23 +948,69 @@
applyFabPos(fab, side, y);
}
// A gesture that starts on the visible sliver drags immediately, as it always
// has. A gesture that starts on the invisible #hit strip scrolls the page
// instead — unless it is held for ARM_MS first, which arms a reposition drag.
// ponytail: the scroll is hand-rolled (no momentum/fling) and assumes the
// document is the scroller. That is the price of touch-action:none, which we
// need so a hold can claim pixels a swipe would otherwise scroll. If the
// stiffness annoys, give #hit touch-action:pan-y and accept that vertical
// repositioning must start on the 7px sliver.
function makeDraggable(fab) {
let down = false, moved = false, sx = 0, sy = 0, offX = 0, offY = 0;
let down = false, moved = false, armed = false, scrolling = false, fromHit = false;
let sx = 0, sy = 0, offX = 0, offY = 0, lastY = 0, armTimer = 0;
function reset() {
down = moved = armed = scrolling = fromHit = false;
clearTimeout(armTimer);
fab.classList.remove("armed");
fab.style.transition = "";
}
function scrollPage(e) {
e.preventDefault();
window.scrollBy(0, lastY - e.clientY);
lastY = e.clientY;
}
fab.addEventListener("pointerdown", (e) => {
delete fab.dataset.dragged; // stale flag would swallow this gesture's click
down = true;
moved = false;
armed = false;
scrolling = false;
fromHit = e.target.id === "hit";
sx = e.clientX;
sy = e.clientY;
lastY = e.clientY;
const r = fab.getBoundingClientRect();
offX = e.clientX - r.left;
offY = e.clientY - r.top;
fab.setPointerCapture(e.pointerId);
fab.style.transition = "none";
clearTimeout(armTimer);
armTimer = setTimeout(() => {
armed = true;
fab.classList.add("armed"); // visible confirmation the hold took
}, ARM_MS);
});
fab.addEventListener("pointermove", (e) => {
if (!down) return;
if (!moved && (Math.abs(e.clientX - sx) > 6 || Math.abs(e.clientY - sy) > 6)) moved = true;
if (!moved) return;
if (scrolling) {
scrollPage(e);
return;
}
if (!moved) {
if (Math.abs(e.clientX - sx) <= 6 && Math.abs(e.clientY - sy) <= 6) return;
clearTimeout(armTimer);
if (fromHit && !armed) {
scrolling = true;
scrollPage(e);
return;
}
moved = true;
}
e.preventDefault();
const w = fab.offsetWidth, h = fab.offsetHeight;
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - w) + "px";
@@ -971,12 +1018,8 @@
fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - h) + "px";
fab.style.bottom = "auto";
});
fab.addEventListener("pointerup", (e) => {
if (!down) return;
down = false;
fab.style.transition = "";
if (!moved) return; // tap — let the click handler toggle the panel
fab.dataset.dragged = "1"; // suppress the click that trails this drag
function snapToEdge() {
const w = fab.offsetWidth, h = fab.offsetHeight;
const r = fab.getBoundingClientRect();
const side = r.left + w / 2 < window.innerWidth / 2 ? "left" : "right";
@@ -984,7 +1027,33 @@
fab.style.transition = "left .2s ease, right .2s ease, top .2s ease";
applyFabPos(fab, side, y);
saveFabPos(side, y / window.innerHeight);
}
fab.addEventListener("pointerup", (e) => {
if (!down) return;
const didDrag = moved, didScroll = scrolling;
reset();
if (didScroll) {
fab.dataset.dragged = "1"; // suppress any click trailing the scroll
return;
}
if (!didDrag) return; // tap — let the click handler toggle the panel
fab.dataset.dragged = "1"; // suppress the click that trails this drag
snapToEdge();
});
fab.addEventListener("pointercancel", () => {
if (!down) return;
const didDrag = moved;
reset();
if (didDrag) snapToEdge(); // OS claimed the gesture mid-drag — don't strand the FAB
});
// touch-action: none stops panning and zooming but NOT Chromium's long-press
// gesture, which fires at ~500ms — just after ARM_MS — raises the context
// menu, and cancels the pointer stream. The hold armed the drag and the OS
// stole it back a frame later. Suppressing the menu keeps the pointer alive.
fab.addEventListener("contextmenu", (e) => e.preventDefault());
}
// ============================================================
@@ -1307,7 +1376,7 @@
// ============================================================
const TEMPLATE = `
<button id="fab" aria-label="Manga bookmarks"><span id="fill"></span></button>
<button id="fab" aria-label="Manga bookmarks"><span id="fill"></span><span id="hit"></span></button>
<div id="backdrop"></div>
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
<header>
@@ -1340,14 +1409,23 @@
border-radius: 4px 0 0 4px;
background: #6d28d9; opacity: .45;
box-shadow: 0 2px 8px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
touch-action: none; user-select: none; overflow: hidden;
touch-action: none; user-select: none; -webkit-touch-callout: none;
}
#fab.left { border-radius: 0 4px 4px 0; }
#fab:active { opacity: 1; }
#fab:active, #fab.armed { opacity: 1; }
#fab.armed { box-shadow: 0 0 0 2px #c4b5fd; }
#fill {
position: absolute; left: 0; right: 0; bottom: 0; height: 0;
background: #c4b5fd; pointer-events: none;
background: #c4b5fd; pointer-events: none; border-radius: inherit;
}
/* Invisible tap extension: the visible sliver is 7x44, this makes it 28x72.
It grows inward (toward the screen centre) because the tab is docked flush
against an edge, so outward is off-screen. */
#hit {
position: absolute; top: -14px; bottom: -14px;
left: -21px; right: 0;
}
#fab.left #hit { left: 0; right: -21px; }
#backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.5);
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;