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