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