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 = ` - +