From 1dc5b2ab6531950d3dc4c53194d51866f9df4b6b Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 20:36:25 +0700 Subject: [PATCH] Widen the edge-tab tap target, split scroll from reposition (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://gitea.violetcrown.my.id/sulthan/mangaBookmark/pulls/9 Co-authored-by: Sulthan Zaki Co-committed-by: Sulthan Zaki --- CLAUDE.md | 9 ++- userscript/manga-bookmark.user.js | 104 ++++++++++++++++++++++++++---- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0d1f491..a8f80dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 35f360e..ba21e96 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -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 = ` - +