The save sheet gets "+ Previous page" / "+ Next page" (right to left for rtl in Page/Book) for a picture that runs over several pages. Moments store a layout (row | column); pages the selected region does not touch are dropped. Crops, viewer and gallery draw both layouts. Dialogs now clean up directly instead of waiting for the "close" event, which a backgrounded page can deliver late (a closed sheet stayed in the page and M stopped working). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
78 lines
4.3 KiB
JavaScript
78 lines
4.3 KiB
JavaScript
'use strict';
|
||
/*
|
||
* Moments: a page, or a region across up to four neighbouring pages, that a reader saved with exactly one category.
|
||
* `layout` says how those pages sit together: side by side ("row", Page and Book mode) or stacked ("column", Flow).
|
||
* 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, MAX_PAGES = 4, LAYOUTS = ['row', 'column'];
|
||
|
||
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 all its pages put together in `layout`.
|
||
// 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, or top to bottom.
|
||
function cleanPages(pages, version) {
|
||
if (!Array.isArray(pages) || pages.length < 1 || pages.length > MAX_PAGES) fail(`A moment covers 1 to ${MAX_PAGES} pages.`);
|
||
const out = pages.map(Number);
|
||
if (out.some(i => !Number.isInteger(i) || i < 0 || i >= version.pages.length)) fail('Page not found.');
|
||
if (new Set(out).size !== out.length) fail('A page appears twice.');
|
||
return out;
|
||
}
|
||
const cleanLayout = layout => (layout == null ? 'row' : LAYOUTS.includes(layout) ? layout : fail('Invalid page layout.'));
|
||
|
||
// 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,
|
||
layout: moment.layout || 'row',
|
||
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, MAX_PAGES, MomentError, cleanRegion, cleanCategory, cleanNote, cleanPages, cleanLayout, locate, view, storyCompare };
|