Leaner userscript: edge tab, card-click continue, test harness (#7)

Drops the Continue and Edit buttons, makes the card body the continue target, adds a confirm to Remove, replaces the view-blocking circular FAB with a 7x44 edge tab plus a two-finger long-press, and adds a zero-dependency node:test harness for the adapter logic. Fixes a latent offsetWidth/offsetHeight bug in the FAB placement math. No backend or API change.

Verified live against real asurascans.com and demonicscans.org series and chapter pages: edge tab does not cover artwork, card-body click navigates, action buttons do not, Remove confirms and cancel preserves the row, the 25s dwell fill records progress, and the tab drags/snaps/clamps correctly.

Known and accepted: a deliberately slow two-finger pinch that stays inside the 15px slop for the full 500ms opens the panel. A normal-speed pinch (240px in ~240ms) cancels well before the timer. An absolute per-finger drift threshold cannot separate a slow pinch from two fingers resting, which is the gesture being detected.

Reviewed-on: #7
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-07-28 16:21:01 +07:00
committed by sulthan
parent 206c447f36
commit 324b2efad6
2 changed files with 351 additions and 94 deletions
+165 -94
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name Manga Bookmark Sync
// @namespace mangabm
// @version 1.4.0
// @version 1.5.0
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
// @author you
// @match https://asuracomic.net/*
@@ -677,19 +677,6 @@
await syncUpsert(bm, "Progress updated");
}
// Manual override from the panel input — forces any value.
async function setChapterManual(key, label, num, url) {
const existing = state.byKey[key];
if (!existing) return;
const bm = Object.assign({}, existing, {
last_chapter: label,
last_chapter_num: isNaN(num) ? existing.last_chapter_num : num,
last_chapter_url: url || existing.last_chapter_url,
updated_at: Date.now(),
});
await syncUpsert(bm, "Progress set");
}
async function syncUpsert(bm, okMsg) {
upsertLocal(bm); // optimistic
render();
@@ -808,8 +795,8 @@
}
// Dwell timer: don't advance progress the instant a newer chapter opens (guards
// against a misclick on "latest chapter"). Arm a 25s timer instead; the countdown
// ring around the FAB shows it running. The manual "Update to X" button still fires
// against a misclick on "latest chapter"). Arm a 25s timer instead; the tab fills
// bottom-up to show it counting down. The manual "Update to X" button still fires
// immediately. Timer is keyed to the chapter, not the URL, so turning pages within
// the same chapter (Demonic /chapter/N/<page>) keeps it counting rather than resetting.
const DWELL_MS = 25000;
@@ -910,7 +897,8 @@
// ============================================================
const FAB_KEY = "mangabm:fabpos";
const FAB_MARGIN = 12;
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
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
@@ -919,11 +907,12 @@
function applyFabPos(fab, side, y) {
fab.style.top = y + "px";
fab.style.bottom = "auto";
fab.classList.toggle("left", side === "left");
if (side === "left") {
fab.style.left = FAB_MARGIN + "px";
fab.style.left = FAB_EDGE + "px";
fab.style.right = "auto";
} else {
fab.style.right = FAB_MARGIN + "px";
fab.style.right = FAB_EDGE + "px";
fab.style.left = "auto";
}
}
@@ -945,9 +934,9 @@
}
function placeFab(fab) {
const s = fab.offsetWidth || 52;
const h = fab.offsetHeight || 44;
const p = loadFabPos();
const maxY = window.innerHeight - s - FAB_MARGIN;
const maxY = window.innerHeight - h - FAB_MARGIN;
const side = p && p.side === "left" ? "left" : "right";
const y = p ? clamp(p.yRatio * window.innerHeight, FAB_MARGIN, maxY) : maxY - 8;
applyFabPos(fab, side, y);
@@ -971,10 +960,10 @@
if (!moved && (Math.abs(e.clientX - sx) > 6 || Math.abs(e.clientY - sy) > 6)) moved = true;
if (!moved) return;
e.preventDefault();
const s = fab.offsetWidth;
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - s) + "px";
const w = fab.offsetWidth, h = fab.offsetHeight;
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - w) + "px";
fab.style.right = "auto";
fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - s) + "px";
fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - h) + "px";
fab.style.bottom = "auto";
});
fab.addEventListener("pointerup", (e) => {
@@ -983,10 +972,10 @@
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 s = fab.offsetWidth;
const w = fab.offsetWidth, h = fab.offsetHeight;
const r = fab.getBoundingClientRect();
const side = r.left + s / 2 < window.innerWidth / 2 ? "left" : "right";
const y = clamp(r.top, FAB_MARGIN, window.innerHeight - s - FAB_MARGIN);
const side = r.left + w / 2 < window.innerWidth / 2 ? "left" : "right";
const y = clamp(r.top, FAB_MARGIN, window.innerHeight - h - FAB_MARGIN);
fab.style.transition = "left .2s ease, right .2s ease, top .2s ease";
applyFabPos(fab, side, y);
saveFabPos(side, y / window.innerHeight);
@@ -994,31 +983,26 @@
}
// ============================================================
// Countdown ring around the FAB (dwell-timer progress)
// Dwell-timer progress: the tab fills bottom-up. A 7px sliver has no room
// for the ring this replaced.
// ============================================================
const RING_C = 144.5; // circumference: 2π·23
function startRing(ms) {
if (!root) return;
const ring = root.getElementById("ring");
const prog = ring.firstElementChild;
ring.classList.add("active");
prog.style.transition = "none";
prog.style.strokeDashoffset = RING_C; // empty
prog.getBoundingClientRect(); // reflow so the next assignment animates
prog.style.transition = "stroke-dashoffset " + ms + "ms linear";
prog.style.strokeDashoffset = "0"; // fills over ms
const fill = root.getElementById("fill");
fill.style.transition = "none";
fill.style.height = "0";
fill.getBoundingClientRect(); // reflow so the next assignment animates
fill.style.transition = "height " + ms + "ms linear";
fill.style.height = "100%";
}
function stopRing() {
if (!root) return;
const ring = root.getElementById("ring");
if (!ring) return;
const prog = ring.firstElementChild;
ring.classList.remove("active");
prog.style.transition = "none";
prog.style.strokeDashoffset = RING_C;
const fill = root.getElementById("fill");
if (!fill) return;
fill.style.transition = "none";
fill.style.height = "0";
}
function togglePanel() {
@@ -1108,40 +1092,47 @@
const sub = behind
? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter
: (b.last_chapter || "—") + " · " + b.site;
const row = el("div", { class: "item" }, [
b.cover
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
: el("div", { class: "cover ph" }),
el("div", { class: "meta" }, [
el("div", { class: "t", text: b.title || b.series_id }),
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
el("div", { class: "actions" }, [
el("button", {
class: "btn small star" + (b.favorite ? " on" : ""),
text: b.favorite ? "★" : "☆",
title: b.favorite ? "Remove from favourites" : "Add to favourites",
onclick: () => toggleFavorite(b.key),
}),
el("button", {
class: "btn small",
text: statusOf(b) === "archived" ? "Unarchive" : "Archive",
onclick: () => toggleArchive(b.key),
}),
el("a", { class: "btn small", href: cont, text: "Continue" }),
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
// Cover and text are one link: the card body *is* the continue button, so
// there is no separate one. The action row is a sibling of that link, never
// a child — a thumb that misses ★ must not land on a navigation target, and
// Remove must never be one.
return el("div", { class: "item" }, [
el("a", { class: "go", href: cont }, [
b.cover
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
: el("div", { class: "cover ph" }),
el("div", { class: "meta" }, [
el("div", { class: "t", text: b.title || b.series_id }),
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
]),
]),
el("div", { class: "actions" }, [
el("button", {
class: "btn small star" + (b.favorite ? " on" : ""),
text: b.favorite ? "★" : "☆",
title: b.favorite ? "Remove from favourites" : "Add to favourites",
onclick: () => toggleFavorite(b.key),
}),
el("button", {
class: "btn small",
text: statusOf(b) === "archived" ? "Unarchive" : "Archive",
onclick: () => toggleArchive(b.key),
}),
el("button", {
class: "btn small danger",
text: "Remove",
onclick: () => confirmRemove(b),
}),
]),
]);
return row;
}
function editChapter(b) {
const raw = prompt("Set last-read chapter for \"" + (b.title || b.series_id) + "\":", b.last_chapter || "");
if (raw == null) return;
const label = raw.trim();
const num = parseFloat((label.match(/[\d.]+/) || [])[0]);
setChapterManual(b.key, label, num, b.last_chapter_url);
// Removal syncs to every device, so it asks first. confirm() is the page's
// own dialog, not a shadow-root one — modal, and the site cannot style it.
function confirmRemove(b) {
if (confirm('Remove "' + (b.title || b.series_id) + '" from your bookmarks?')) {
removeBookmark(b.key);
}
}
// Toast inside the shadow root.
@@ -1209,6 +1200,76 @@
setInterval(fire, 1500); // catch routes that bypass history
}
// ============================================================
// Two-finger long-press — a way in that does not depend on finding a 7px tab
//
// Deliberately two fingers, not one: Chromium claims the one-finger long
// press for image context menus, text selection, and link previews, and on a
// manga reader the whole viewport is images. It assigns no two-finger long
// press, so these listeners stay passive and never preventDefault.
// ============================================================
const LONGPRESS_MS = 500;
const LONGPRESS_SLOP = 15; // px either finger may drift before it counts as a drag
function installLongPress() {
let timer = null;
let start = null; // [{x, y}, {x, y}] — where each finger landed
const cancel = () => {
if (timer) clearTimeout(timer);
timer = null;
start = null;
};
const opts = { passive: true, capture: true };
// Fires once per finger, so the first touch cancels and the second arms.
// A third finger cancels again.
document.addEventListener(
"touchstart",
(e) => {
cancel();
if (e.touches.length !== 2) return;
start = [
{ x: e.touches[0].clientX, y: e.touches[0].clientY },
{ x: e.touches[1].clientX, y: e.touches[1].clientY },
];
timer = setTimeout(() => {
cancel();
togglePanel();
}, LONGPRESS_MS);
},
opts
);
// A pinch-zoom is two fingers held down, so the slop check is what keeps a
// normal-speed one from opening the panel mid-gesture. Verified 2026-07-28:
// a pinch that reaches 240px in ~240ms cancels well before the timer. A
// deliberately slow pinch that stays inside the slop for the full 500ms
// does open the panel — accepted, since an absolute per-finger drift
// threshold cannot tell that apart from two fingers resting, which is the
// gesture this is for.
document.addEventListener(
"touchmove",
(e) => {
if (!start || e.touches.length !== 2) return cancel();
for (let i = 0; i < 2; i++) {
if (
Math.abs(e.touches[i].clientX - start[i].x) > LONGPRESS_SLOP ||
Math.abs(e.touches[i].clientY - start[i].y) > LONGPRESS_SLOP
) {
return cancel();
}
}
},
opts
);
document.addEventListener("touchend", cancel, opts);
document.addEventListener("touchcancel", cancel, opts);
}
// ============================================================
// Boot
// ============================================================
@@ -1218,6 +1279,7 @@
state.page = detect();
render();
installNavWatcher();
installLongPress();
window.addEventListener("online", drain); // signal returned while the page stayed open
// Sync first: both auto-record and the latest-chapter checks below need to
// know which series are bookmarked and how fresh they are.
@@ -1233,10 +1295,7 @@
// ============================================================
const TEMPLATE = `
<button id="fab" aria-label="Manga bookmarks">
<svg id="ring" viewBox="0 0 52 52" aria-hidden="true"><circle class="prog" cx="26" cy="26" r="23"></circle></svg>
<span class="fab-icon">📑</span>
</button>
<button id="fab" aria-label="Manga bookmarks"><span id="fill"></span></button>
<div id="backdrop"></div>
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
<header>
@@ -1265,23 +1324,18 @@
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
#fab {
position: fixed;
width: 52px; height: 52px; border-radius: 50%; border: none;
background: #6d28d9; color: #fff; font-size: 22px;
box-shadow: 0 4px 14px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
touch-action: none; user-select: none;
display: flex; align-items: center; justify-content: center; padding: 0;
width: 7px; height: 44px; border: none; padding: 0;
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;
}
#fab:active { transform: scale(.94); }
#ring {
position: absolute; inset: 0; transform: rotate(-90deg);
opacity: 0; transition: opacity .15s; pointer-events: none;
#fab.left { border-radius: 0 4px 4px 0; }
#fab:active { opacity: 1; }
#fill {
position: absolute; left: 0; right: 0; bottom: 0; height: 0;
background: #c4b5fd; pointer-events: none;
}
#ring.active { opacity: 1; }
#ring .prog {
fill: none; stroke: #c4b5fd; stroke-width: 3; stroke-linecap: round;
stroke-dasharray: 144.5; stroke-dashoffset: 144.5;
}
.fab-icon { position: relative; z-index: 1; line-height: 1; }
#backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.5);
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
@@ -1325,12 +1379,17 @@
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
.item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; }
.item {
display: flex; flex-direction: column; gap: 8px;
padding: 10px 16px; border-bottom: 1px solid #2a2a33;
}
.go { display: flex; gap: 10px; text-decoration: none; color: inherit; }
.go:active { opacity: .7; }
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; }
.cover.ph { display: block; }
.meta { min-width: 0; flex: 1; }
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 8px; }
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 0; }
.c.behind { color: #c4b5fd; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.btn {
@@ -1357,6 +1416,18 @@
.toast.err { background: #7f1d1d; }
`;
// ============================================================
// Test hook
//
// Fires only in Node, where window is undefined. The typeof window check keeps it
// inert on pages whose own scripts define a UMD module shim — under @grant none
// the userscript shares page globals, so we must not clobber the page's exports.
// Exposes pure logic only — see userscript/test/logic.test.js.
// ============================================================
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
module.exports = { stripBuildHash, asura, demonic, anchorsFromHTML, statusOf };
}
// ============================================================
// Boot — MUST run last: init() -> buildUI() reads TEMPLATE/CSS,
// which are const (TDZ) and only initialized above. At