Widen the edge-tab tap target, split scroll from reposition (#9)
The 7px edge tab is well below a usable touch target on a phone. This widens only its *hit* area — the visible sliver still measures exactly 7 × 44 and `offsetWidth`/`offsetHeight` still report it, so `placeFab`, `applyFabPos` and the edge-snap maths are untouched. - An invisible `#hit` child extends the tappable box inward to `28 × 72`. `overflow: hidden` had to go (it would clip `#hit`), so the rounded-corner clip for the dwell-progress fill moves onto `#fill` via `border-radius: inherit`. - `touch-action` is resolved by the browser at gesture start, so the strip cannot be both browser-scrolled and script-dragged. `#fab` keeps `touch-action: none`, owns every gesture, and `makeDraggable` splits by intent: a plain swipe from `#hit` scrolls the page via `window.scrollBy`, a ~400ms hold arms a reposition drag (tab brightens and grows a ring), and the visible sliver still drags immediately with no hold. - Adds the previously missing `pointercancel` reset, and snaps + saves on cancel so an OS-claimed gesture (Android's swipe-back starts in exactly this screen region) cannot strand the tab mid-screen. - Clears a stale `dataset.dragged` on pointerdown, so a scroll or drag that produces no trailing click cannot swallow the *next* tap. ## Testing `node --check` clean, `node --test userscript/test/logic.test.js` 14/14 pass. The gesture code has no unit test — `logic.test.js` runs under node with no DOM and this branch deliberately does not add a DOM harness. Manual device checks (tap / swipe-to-scroll / hold-to-arm / sliver-drag on a chapter page) are the real verification and are pending. ## Known ceilings - The scroll is hand-rolled: no momentum or fling, and it assumes the document is the scroller rather than a nested container. Flagged in a `ponytail:` comment with the upgrade path. - Gesture state is not keyed by `pointerId`, so a second finger corrupts an in-progress gesture. Pre-existing, not a regression. - A >400ms still press on the *visible* sliver shows the armed ring even though the sliver never needs a hold. Cosmetic only. Reviewed-on: #9 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #9.
This commit is contained in:
@@ -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
|
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
|
(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
|
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).
|
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)
|
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
|
||||||
|
|||||||
@@ -904,6 +904,7 @@
|
|||||||
const FAB_KEY = "mangabm:fabpos";
|
const FAB_KEY = "mangabm:fabpos";
|
||||||
const FAB_MARGIN = 12; // vertical breathing room at the top and bottom
|
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 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) {
|
function clamp(v, lo, hi) {
|
||||||
return Math.max(lo, Math.min(hi, v));
|
return Math.max(lo, Math.min(hi, v));
|
||||||
@@ -947,23 +948,69 @@
|
|||||||
applyFabPos(fab, side, y);
|
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) {
|
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) => {
|
fab.addEventListener("pointerdown", (e) => {
|
||||||
|
delete fab.dataset.dragged; // stale flag would swallow this gesture's click
|
||||||
down = true;
|
down = true;
|
||||||
moved = false;
|
moved = false;
|
||||||
|
armed = false;
|
||||||
|
scrolling = false;
|
||||||
|
fromHit = e.target.id === "hit";
|
||||||
sx = e.clientX;
|
sx = e.clientX;
|
||||||
sy = e.clientY;
|
sy = e.clientY;
|
||||||
|
lastY = e.clientY;
|
||||||
const r = fab.getBoundingClientRect();
|
const r = fab.getBoundingClientRect();
|
||||||
offX = e.clientX - r.left;
|
offX = e.clientX - r.left;
|
||||||
offY = e.clientY - r.top;
|
offY = e.clientY - r.top;
|
||||||
fab.setPointerCapture(e.pointerId);
|
fab.setPointerCapture(e.pointerId);
|
||||||
fab.style.transition = "none";
|
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) => {
|
fab.addEventListener("pointermove", (e) => {
|
||||||
if (!down) return;
|
if (!down) return;
|
||||||
if (!moved && (Math.abs(e.clientX - sx) > 6 || Math.abs(e.clientY - sy) > 6)) moved = true;
|
if (scrolling) {
|
||||||
if (!moved) return;
|
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();
|
e.preventDefault();
|
||||||
const w = fab.offsetWidth, h = fab.offsetHeight;
|
const w = fab.offsetWidth, h = fab.offsetHeight;
|
||||||
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - w) + "px";
|
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.top = clamp(e.clientY - offY, 0, window.innerHeight - h) + "px";
|
||||||
fab.style.bottom = "auto";
|
fab.style.bottom = "auto";
|
||||||
});
|
});
|
||||||
fab.addEventListener("pointerup", (e) => {
|
|
||||||
if (!down) return;
|
function snapToEdge() {
|
||||||
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
|
|
||||||
const w = fab.offsetWidth, h = fab.offsetHeight;
|
const w = fab.offsetWidth, h = fab.offsetHeight;
|
||||||
const r = fab.getBoundingClientRect();
|
const r = fab.getBoundingClientRect();
|
||||||
const side = r.left + w / 2 < window.innerWidth / 2 ? "left" : "right";
|
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";
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
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>
|
<div id="backdrop"></div>
|
||||||
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
||||||
<header>
|
<header>
|
||||||
@@ -1340,14 +1409,23 @@
|
|||||||
border-radius: 4px 0 0 4px;
|
border-radius: 4px 0 0 4px;
|
||||||
background: #6d28d9; opacity: .45;
|
background: #6d28d9; opacity: .45;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
|
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.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 {
|
#fill {
|
||||||
position: absolute; left: 0; right: 0; bottom: 0; height: 0;
|
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 {
|
#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;
|
||||||
|
|||||||
Reference in New Issue
Block a user