From 324b2efad6823b3f90cd13186c6f3517275c03ec Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 16:21:01 +0700 Subject: [PATCH] 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: https://gitea.violetcrown.my.id/sulthan/mangaBookmark/pulls/7 Co-authored-by: Sulthan Zaki Co-committed-by: Sulthan Zaki --- userscript/manga-bookmark.user.js | 259 +++++++++++++++++++----------- userscript/test/logic.test.js | 186 +++++++++++++++++++++ 2 files changed, 351 insertions(+), 94 deletions(-) create mode 100644 userscript/test/logic.test.js diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 625ac0c..d598fcb 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -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/) 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 = ` - +