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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user