From 2e4a4519f01a034a711d11845d51814a2359e5c9 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Fri, 24 Jul 2026 20:07:39 +0700 Subject: [PATCH] feat(userscript): 25s dwell before auto-update + draggable edge-snap FAB Auto-update no longer fires the instant a newer chapter opens (guards against a misclick on "latest chapter"). Instead a 25s dwell timer arms, shown by a countdown ring filling around the FAB; 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. FAB is now draggable: drag anywhere, release snaps it to the nearer left/right edge keeping its vertical position, persisted in localStorage across sessions and re-clamped on rotation. A drag no longer opens the panel; a tap still does. Co-Authored-By: Claude Opus 4.8 --- userscript/manga-bookmark.user.js | 193 ++++++++++++++++++++++++++++-- 1 file changed, 184 insertions(+), 9 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 7255d1c..865ccb2 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.0.0 +// @version 1.1.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/* @@ -259,6 +259,7 @@ } async function updateToCurrentChapter() { + cancelDwell(); // manual button + timer-fire both clear any pending dwell const p = state.page; if (p.type !== "chapter") return; const key = keyOf(p); @@ -315,9 +316,42 @@ } } - // Auto-record on opening a chapter of a bookmarked series. - async function maybeAutoUpdate() { + // 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 + // 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; + let dwell = null; // { id, timerId } + + function chapterIdentity(p) { + return p && p.type === "chapter" ? p.site + ":" + p.seriesId + "@" + p.chapterNum : null; + } + + function armDwell(id) { + dwell = { + id, + timerId: setTimeout(() => { + dwell = null; + stopRing(); + updateToCurrentChapter(); + }, DWELL_MS), + }; + startRing(DWELL_MS); + } + + function cancelDwell() { + if (dwell) clearTimeout(dwell.timerId); + dwell = null; + stopRing(); + } + + // Auto-record on opening a chapter of a bookmarked series (after the dwell delay). + function maybeAutoUpdate() { const p = state.page; + const id = chapterIdentity(p); + if (dwell && id === dwell.id) return; // same chapter, page turn — keep timer running + cancelDwell(); // navigated elsewhere — drop any stale dwell if (p.type !== "chapter") return; const existing = state.byKey[keyOf(p)]; if (!existing) return; // only track series the user bookmarked @@ -330,7 +364,7 @@ if (existing.last_chapter_url === p.chapterUrl && existing.last_chapter === p.chapterLabel) { return; // already recorded } - await updateToCurrentChapter(); + armDwell(id); } // ============================================================ @@ -355,9 +389,136 @@ wrap.innerHTML = TEMPLATE; root.appendChild(wrap); - root.getElementById("fab").addEventListener("click", togglePanel); + const fab = root.getElementById("fab"); + fab.addEventListener("click", () => { + if (fab.dataset.dragged) { + delete fab.dataset.dragged; // this click is the tail of a drag — swallow it + return; + } + togglePanel(); + }); root.getElementById("backdrop").addEventListener("click", togglePanel); root.getElementById("closeBtn").addEventListener("click", togglePanel); + + placeFab(fab); + makeDraggable(fab); + window.addEventListener("resize", () => placeFab(fab)); // re-clamp on rotation + } + + // ============================================================ + // FAB placement + dragging (snaps to nearest left/right edge) + // ============================================================ + + const FAB_KEY = "mangabm:fabpos"; + const FAB_MARGIN = 12; + + function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); + } + + function applyFabPos(fab, side, y) { + fab.style.top = y + "px"; + fab.style.bottom = "auto"; + if (side === "left") { + fab.style.left = FAB_MARGIN + "px"; + fab.style.right = "auto"; + } else { + fab.style.right = FAB_MARGIN + "px"; + fab.style.left = "auto"; + } + } + + function loadFabPos() { + try { + return JSON.parse(localStorage.getItem(FAB_KEY)); + } catch (e) { + return null; + } + } + + function saveFabPos(side, yRatio) { + try { + localStorage.setItem(FAB_KEY, JSON.stringify({ side, yRatio })); + } catch (e) { + /* quota / private mode — ignore */ + } + } + + function placeFab(fab) { + const s = fab.offsetWidth || 52; + const p = loadFabPos(); + const maxY = window.innerHeight - s - 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); + } + + function makeDraggable(fab) { + let down = false, moved = false, sx = 0, sy = 0, offX = 0, offY = 0; + fab.addEventListener("pointerdown", (e) => { + down = true; + moved = false; + sx = e.clientX; + sy = e.clientY; + const r = fab.getBoundingClientRect(); + offX = e.clientX - r.left; + offY = e.clientY - r.top; + fab.setPointerCapture(e.pointerId); + fab.style.transition = "none"; + }); + 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; + e.preventDefault(); + const s = fab.offsetWidth; + fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - s) + "px"; + fab.style.right = "auto"; + fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - s) + "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 + const s = fab.offsetWidth; + 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); + fab.style.transition = "left .2s ease, right .2s ease, top .2s ease"; + applyFabPos(fab, side, y); + saveFabPos(side, y / window.innerHeight); + }); + } + + // ============================================================ + // Countdown ring around the FAB (dwell-timer progress) + // ============================================================ + + 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 + } + + 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; } function togglePanel() { @@ -516,7 +677,10 @@ // ============================================================ const TEMPLATE = ` - +