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==
|
// ==UserScript==
|
||||||
// @name Manga Bookmark Sync
|
// @name Manga Bookmark Sync
|
||||||
// @namespace mangabm
|
// @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).
|
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||||
// @author you
|
// @author you
|
||||||
// @match https://asuracomic.net/*
|
// @match https://asuracomic.net/*
|
||||||
@@ -259,6 +259,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateToCurrentChapter() {
|
async function updateToCurrentChapter() {
|
||||||
|
cancelDwell(); // manual button + timer-fire both clear any pending dwell
|
||||||
const p = state.page;
|
const p = state.page;
|
||||||
if (p.type !== "chapter") return;
|
if (p.type !== "chapter") return;
|
||||||
const key = keyOf(p);
|
const key = keyOf(p);
|
||||||
@@ -315,9 +316,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-record on opening a chapter of a bookmarked series.
|
// Dwell timer: don't advance progress the instant a newer chapter opens (guards
|
||||||
async function maybeAutoUpdate() {
|
// 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 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;
|
if (p.type !== "chapter") return;
|
||||||
const existing = state.byKey[keyOf(p)];
|
const existing = state.byKey[keyOf(p)];
|
||||||
if (!existing) return; // only track series the user bookmarked
|
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) {
|
if (existing.last_chapter_url === p.chapterUrl && existing.last_chapter === p.chapterLabel) {
|
||||||
return; // already recorded
|
return; // already recorded
|
||||||
}
|
}
|
||||||
await updateToCurrentChapter();
|
armDwell(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -355,9 +389,136 @@
|
|||||||
wrap.innerHTML = TEMPLATE;
|
wrap.innerHTML = TEMPLATE;
|
||||||
root.appendChild(wrap);
|
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("backdrop").addEventListener("click", togglePanel);
|
||||||
root.getElementById("closeBtn").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() {
|
function togglePanel() {
|
||||||
@@ -516,7 +677,10 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const TEMPLATE = `
|
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>
|
<div id="backdrop"></div>
|
||||||
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
||||||
<header>
|
<header>
|
||||||
@@ -533,13 +697,24 @@
|
|||||||
:host { all: initial; }
|
:host { all: initial; }
|
||||||
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
||||||
#fab {
|
#fab {
|
||||||
position: fixed; right: 16px;
|
position: fixed;
|
||||||
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
|
||||||
width: 52px; height: 52px; border-radius: 50%; border: none;
|
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;
|
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); }
|
#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 {
|
#backdrop {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||||
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
|
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
|
||||||
|
|||||||
Reference in New Issue
Block a user