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/<page>) 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 20:07:39 +07:00
parent c597bb5d5b
commit 2e4a4519f0
+184 -9
View File
@@ -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/<page>) 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 = `
<button id="fab" aria-label="Manga bookmarks">📑</button>
<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>
<div id="backdrop"></div>
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
<header>
@@ -533,13 +697,24 @@
:host { all: initial; }
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
#fab {
position: fixed; right: 16px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
position: fixed;
width: 52px; height: 52px; border-radius: 50%; border: none;
background: #6d28d9; color: #fff; font-size: 22px; line-height: 52px;
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;
}
#fab:active { transform: scale(.94); }
#ring {
position: absolute; inset: 0; transform: rotate(-90deg);
opacity: 0; transition: opacity .15s; 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;