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>
75 lines
3.9 KiB
JavaScript
75 lines
3.9 KiB
JavaScript
'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 };
|