Moments: per-user saved pages and regions with one category (BL-0055)
Reader: bookmark button and M save the page (Flow: page at the reading line, Book: the spread) with an optional dragged region, exactly one category (Twist, Hot, Awesome, Cute, Funny, Feels) and a note. I now toggles the bars; ? lists all keys. Keys dispatch through public/keys.js, which also feeds the new Settings -> Keyboard shortcuts section. Manga page: "Your moments" strip of crops (story or recent order, category filters), viewer with step, full page, edit, delete and "Open in reader" (read.html ...&p=). Moments point at pages by file name and show "page moved" when the file is gone. Private per user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41e7ed063e
commit
74842c59e0
15 files changed
+734
-18
No files matched your search
+74
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
/*
|
||||
* Moments: pages, or a region of one or two facing pages, that a reader saved with exactly one category.
|
||||
* Stored per user in db.moments[user] as a list. A moment points at its pages by version folder and file
|
||||
* name (plus the file's SHA-256 for a later relocation), so a re-scan that shifts page numbers never makes it
|
||||
* point at the wrong picture: a file that is gone shows as "page moved" instead.
|
||||
*/
|
||||
const CATEGORIES = ['twist', 'hot', 'awesome', 'cute', 'funny', 'feels'];
|
||||
const NOTE_MAX = 200, MAX_PER_USER = 5000;
|
||||
|
||||
class MomentError extends Error { constructor(msg) { super(msg); this.code = 400; } }
|
||||
const fail = msg => { throw new MomentError(msg); };
|
||||
|
||||
const clamp01 = x => Math.min(1, Math.max(0, x));
|
||||
// A region is a rectangle in fractions (0–1) of the displayed page, or of both pages of a spread side by side.
|
||||
// Nothing or a rectangle covering (almost) everything means the whole page.
|
||||
function cleanRegion(r) {
|
||||
if (r == null) return null;
|
||||
const n = ['x', 'y', 'w', 'h'].map(k => Number(r[k]));
|
||||
if (n.some(v => !Number.isFinite(v))) fail('Invalid region.');
|
||||
const x = clamp01(n[0]), y = clamp01(n[1]), w = clamp01(Math.min(n[2], 1 - x)), h = clamp01(Math.min(n[3], 1 - y));
|
||||
if (w < 0.01 || h < 0.01) fail('The selected region is too small.');
|
||||
if (w > 0.995 && h > 0.995) return null;
|
||||
const round = v => Math.round(v * 1e4) / 1e4;
|
||||
return { x: round(x), y: round(y), w: round(w), h: round(h) };
|
||||
}
|
||||
function cleanCategory(cat) {
|
||||
return CATEGORIES.includes(cat) ? cat : fail('Pick one category: ' + CATEGORIES.join(', ') + '.');
|
||||
}
|
||||
const cleanNote = note => String(note == null ? '' : note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
|
||||
|
||||
// Page indexes (into the version's full page list) in displayed order, left to right. One page, or two facing pages.
|
||||
function cleanPages(pages, version) {
|
||||
if (!Array.isArray(pages) || pages.length < 1 || pages.length > 2) fail('A moment covers one page or two facing pages.');
|
||||
const out = pages.map(Number);
|
||||
if (out.some(i => !Number.isInteger(i) || i < 0 || i >= version.pages.length)) fail('Page not found.');
|
||||
if (out.length === 2 && out[0] === out[1]) fail('Page not found.');
|
||||
return out;
|
||||
}
|
||||
|
||||
// Where a stored moment's pages are now. Returns null when the chapter, the version or a page file is gone.
|
||||
function locate(moment, manga) {
|
||||
const chapter = manga && manga.chapterById.get(moment.c), version = chapter && chapter.versions.find(v => v.id === moment.v);
|
||||
if (!version) return null;
|
||||
const pages = moment.f.map((file, k) => {
|
||||
const at = moment.i[k];
|
||||
return version.pages[at] && version.pages[at].f === file ? at : version.pages.findIndex(p => p.f === file);
|
||||
});
|
||||
return pages.every(i => i >= 0) ? { chapter, version, pages } : null;
|
||||
}
|
||||
|
||||
// The browser's view of a moment. `url` turns a library-relative path into an image URL.
|
||||
function view(moment, manga, url) {
|
||||
const at = locate(moment, manga);
|
||||
const chapter = at ? at.chapter : manga && manga.chapterById.get(moment.c);
|
||||
return {
|
||||
id: moment.id, c: moment.c, v: moment.v, cat: moment.cat, note: moment.note || '', t: moment.t, region: moment.region || null,
|
||||
chapter: chapter ? { label: chapter.label, title: chapter.title, book: chapter.book } : { label: moment.c, title: '', book: '' },
|
||||
// Story order: chapter position in the manga, then page, then how far down the page the region starts.
|
||||
order: [chapter ? manga.chapters.indexOf(chapter) : 1e9, at ? Math.min(...at.pages) : moment.i[0], moment.region ? moment.region.y : 0],
|
||||
missing: !at,
|
||||
pages: at ? at.pages.map(i => {
|
||||
const p = at.version.pages[i];
|
||||
return { i, u: url(at.version.rel + '/' + p.f), w: p.w, h: p.h, r: p.r || 0 };
|
||||
}) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function storyCompare(a, b) {
|
||||
for (let k = 0; k < 3; k++) if (a.order[k] !== b.order[k]) return a.order[k] - b.order[k];
|
||||
return a.t - b.t;
|
||||
}
|
||||
|
||||
module.exports = { CATEGORIES, NOTE_MAX, MAX_PER_USER, MomentError, cleanRegion, cleanCategory, cleanNote, cleanPages, locate, view, storyCompare };
|
||||
Reference in new issue
Block a user