Files
MangaCircus/moments.js
MaashkinoozheandClaude Opus 5.5 5497e8bfbc Moments across up to four pages: stacked in Flow, side by side in Page/Book (BL-0055)
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>
2026-10-27 12:38:22 +01:00

78 lines
4.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };