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
+186
View File
@@ -0,0 +1,186 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert");
// ============================================================
// Minimal browser stub
//
// manga-bookmark.user.js is a browser IIFE. At module scope it touches
// localStorage (loadCache, loadQueue, the key-migration IIFE) and, on its very
// last lines, document. Nothing else runs under test: document.body is left
// UNDEFINED, so the boot block takes its `else` branch and waits for a
// DOMContentLoaded that never fires. buildUI, init, and every network call stay
// dormant with no further stubbing.
// ============================================================
const store = new Map();
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
};
// location stub. The IIFE reads location.href during boot; set it to a dummy value.
globalThis.location = {
href: "about:blank",
hostname: "",
pathname: "/",
origin: "",
};
// og: meta tags the adapters read through meta(). Reassigned per test.
let metaTags = {};
globalThis.document = {
querySelector(sel) {
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
if (!m) return null;
const v = metaTags[m[1]];
return v == null ? null : { getAttribute: () => v };
},
addEventListener() {},
body: undefined,
};
const {
stripBuildHash,
asura,
demonic,
anchorsFromHTML,
statusOf,
} = require("../manga-bookmark.user.js");
// detect() reads only these four properties off location.
function loc(href) {
const u = new URL(href);
return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname };
}
// ============================================================
// stripBuildHash — Asura's slug suffix rotates on every redeploy, so it must
// not be part of the series identity. Must stay in sync with asuraBuildHash in
// backend/store.go.
// ============================================================
test("stripBuildHash removes a trailing 8-hex suffix", () => {
assert.equal(stripBuildHash("solo-leveling-059befe1"), "solo-leveling");
});
test("stripBuildHash leaves a slug with no suffix alone", () => {
assert.equal(stripBuildHash("solo-leveling"), "solo-leveling");
});
test("stripBuildHash ignores suffixes that are not exactly 8 hex chars", () => {
assert.equal(stripBuildHash("solo-leveling-abc1234"), "solo-leveling-abc1234");
assert.equal(stripBuildHash("solo-leveling-059befe1a"), "solo-leveling-059befe1a");
assert.equal(stripBuildHash("solo-leveling-059befeg1"), "solo-leveling-059befeg1");
});
// ============================================================
// Asura adapter
// ============================================================
test("asura.detect reads a series page, stripping the hash from the id only", () => {
metaTags = { "og:title": "Solo Leveling | Asura Scans", "og:image": "https://cdn.example/x.jpg" };
const p = asura.detect(loc("https://asurascans.com/comics/solo-leveling-059befe1"));
assert.equal(p.type, "series");
assert.equal(p.site, "asura");
assert.equal(p.seriesId, "solo-leveling");
// seriesUrl keeps the hash: navigation needs the current one (stale ones 302).
assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1");
assert.equal(p.title, "Solo Leveling");
assert.equal(p.cover, "https://cdn.example/x.jpg");
assert.equal(p.chapterNum, null);
});
test("asura.detect reads a chapter page including a decimal number", () => {
metaTags = { "og:title": "Solo Leveling Chapter 12.5 - Read Online | Asura Scans", "og:image": "" };
const url = "https://asurascans.com/comics/solo-leveling-059befe1/chapter/12.5";
const p = asura.detect(loc(url));
assert.equal(p.type, "chapter");
assert.equal(p.seriesId, "solo-leveling");
assert.equal(p.chapterNum, 12.5);
assert.equal(p.chapterLabel, "Chapter 12.5");
assert.equal(p.chapterUrl, url);
assert.equal(p.title, "Solo Leveling");
});
test("asura.detect returns other for non-series paths", () => {
metaTags = {};
assert.equal(asura.detect(loc("https://asurascans.com/")).type, "other");
assert.equal(asura.detect(loc("https://asurascans.com/bookmarks")).type, "other");
});
test("asura.latestChapterFromAnchors takes the highest and skips the First Chapter shortcut", () => {
const best = asura.latestChapterFromAnchors([
{ href: "/comics/solo-leveling-059befe1/chapter/1", text: "Chapter 1" },
{ href: "/comics/solo-leveling-059befe1/chapter/10", text: "Chapter 10" },
{ href: "/comics/solo-leveling-059befe1/chapter/2", text: "Chapter 2" },
// Points at a chapter but is not a list entry — the text check excludes it.
{ href: "/comics/solo-leveling-059befe1/chapter/1", text: "First Chapter" },
]);
assert.deepEqual(best, { num: 10, label: "Chapter 10" });
});
test("asura.latestChapterFromAnchors returns null when nothing matches", () => {
assert.equal(asura.latestChapterFromAnchors([{ href: "/about", text: "About" }]), null);
});
// ============================================================
// Demonic adapter
// ============================================================
test("demonic.detect reads a series page", () => {
metaTags = { "og:title": "The World After The Fall", "og:image": "https://cdn.example/y.jpg" };
const p = demonic.detect(loc("https://demonicscans.org/manga/the-world-after-the-fall"));
assert.equal(p.type, "series");
assert.equal(p.site, "demonic");
assert.equal(p.seriesId, "the-world-after-the-fall");
assert.equal(p.title, "The World After The Fall");
});
test("demonic.detect reads a chapter page and strips the suffix from the title", () => {
metaTags = { "og:title": "The World After The Fall Chapter 3", "og:image": "" };
const p = demonic.detect(loc("https://demonicscans.org/title/the-world-after-the-fall/chapter/3/1"));
assert.equal(p.type, "chapter");
assert.equal(p.chapterNum, 3);
assert.equal(p.chapterLabel, "Chapter 3");
assert.equal(p.seriesUrl, "https://demonicscans.org/manga/the-world-after-the-fall");
assert.equal(p.title, "The World After The Fall");
});
test("demonic percent-encoded slugs decode to the same id on /manga/ and /title/", () => {
metaTags = {};
const series = demonic.detect(loc("https://demonicscans.org/manga/i%2527m-the-max-level-newbie"));
const chapter = demonic.detect(loc("https://demonicscans.org/title/i%2527m-the-max-level-newbie/chapter/3/1"));
assert.equal(series.seriesId, chapter.seriesId);
assert.equal(series.seriesId, "i%27m-the-max-level-newbie");
});
test("demonic.latestChapterFromAnchors parses chaptered.php links, including &amp;", () => {
const best = demonic.latestChapterFromAnchors([
{ href: "chaptered.php?manga=5&chapter=3", text: "Chapter 3" },
{ href: "chaptered.php?manga=5&amp;chapter=12", text: "Chapter 12" },
{ href: "/manga/something", text: "Series" },
]);
assert.deepEqual(best, { num: 12, label: "Chapter 12" });
});
// ============================================================
// Shared helpers
// ============================================================
test("anchorsFromHTML pulls href/text pairs and flattens nested tags", () => {
const out = anchorsFromHTML('<a href="/a">One</a><a class="x" href="/b"><span>Two</span> more</a>');
assert.equal(out.length, 2);
assert.deepEqual(out[0], { href: "/a", text: "One" });
assert.equal(out[1].href, "/b");
assert.equal(out[1].text.replace(/\s+/g, " ").trim(), "Two more");
});
test("statusOf defaults a missing status to reading", () => {
assert.equal(statusOf({}), "reading");
assert.equal(statusOf({ status: "" }), "reading");
assert.equal(statusOf({ status: "archived" }), "archived");
assert.equal(statusOf({ status: "finished" }), "finished");
});