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>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-27 12:38:22 +01:00
1 parent 74842c59e0
commit 5497e8bfbc
9 files changed
+175 -92

No files matched your search

+1 -1
View File
@@ -151,7 +151,7 @@ completeness.
- The middle of the page also toggles the controls (useful when a zoomed page fills the screen).
- A horizontal swipe also turns pages on touch screens.
- Keys: arrows, Space / PageDown, `i` = controls, `m` = save a moment, `f` = fullscreen, `?` = all shortcuts (also under Settings → User → Keyboard shortcuts).
- **Moments:** the bookmark button in the top bar (or `m`) saves the page you are on (in Book mode the spread) as a moment. Drag over the preview to keep only a region, and pick exactly one category: Twist, Hot, Awesome, Cute, Funny or Feels, plus an optional note. The button is filled on pages that already have one. Each manga page shows *Your moments* as a strip of crops, sorted by story or by when you saved them; click one to step through them, see the full page, edit, delete or jump into the reader at that page. Moments are private to each user.
- **Moments:** the bookmark button in the top bar (or `m`) saves the page you are on (in Book mode the spread) as a moment. A picture that runs over several pages can take up to four: *+ Previous page* / *+ Next page* add them, stacked in Flow and side by side in Page and Book. Drag over the preview to keep only a region (pages it doesn't touch are left out), and pick exactly one category: Twist, Hot, Awesome, Cute, Funny or Feels, plus an optional note. The button is filled on pages that already have one. Each manga page shows *Your moments* as a strip of crops, sorted by story or by when you saved them; click one to step through them, see the full page, edit, delete or jump into the reader at that page. Moments are private to each user.
- **Controls stay out of the way:** they hide as soon as you turn a page or scroll, and come back only with a tap (middle of the page or beside it) or `i`. Scrolling up never brings them back. The first time they hide, a short hint says so.
- **Controls:** the top bar has a chapter picker with every chapter of the manga, grouped by book, with ✓ for read ones. The bottom bar has the page slider, the mode switch, Shift, zoom, the version picker and previous/next chapter.
- **Loading:** the next pages (next 3 in Page mode, next 2 spreads in Book mode) are downloaded and decoded in advance, so page turns don't flash. A page that still isn't there shows a spinner. On phones, "Add to home screen" opens the app without browser bars, and the ⛶ button (where supported) goes fullscreen.
+5 -4
View File
@@ -152,8 +152,9 @@ This keeps ids human-readable and derivable without a database. The price: renam
"notes": { "admin": [ { "id": "a1b2c3d4e5f6", "t": 1790797300000, "m": "Moon Garden", "c": "2/6",
"text": "Moon Garden · Ch. 6: Frost", "read": false } ] },
"moments": { "admin": [ { "id": "9f1c2b3a4d5e", "m": "Moon Garden", "c": "1/2", "v": "c0002 Roots [MangaDex]",
"i": [4], "f": ["0005.png"], "sha": ["<sha256 of the file>"], // pages, left to right as displayed (1 or 2)
"region": { "x": 0.1, "y": 0.5, "w": 0.5, "h": 0.25 }, // fractions of the page(s); null = whole page
"i": [4], "f": ["0005.png"], "sha": ["<sha256 of the file>"], // 1–4 pages in displayed order
"layout": "row", // "row" side by side (Page/Book), "column" stacked (Flow)
"region": { "x": 0.1, "y": 0.5, "w": 0.5, "h": 0.25 }, // fractions of the pages put together; null = all
"cat": "awesome", "note": "", "t": 1790797400000 } ] },
"votes": { "Moon Garden": { "1/2": { "c0002 Roots [Official]": { "admin": 1, "reader": -1 } } } },
"known": { "Moon Garden": ["1/1", "1/2", "1/2.5", "..."] } // every chapter id ever seen (append-only)
@@ -206,7 +207,7 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
| `POST /api/vote` `{m, c, v, value: 1\|-1\|0}` | user | Vote a version; returns the re-ranked versions |
| `POST /api/follow` `{m, on}` | user | Follow a manga for notifications |
| `GET /api/moments?m=` | user | The caller's moments of that manga in story order: category, note, region, chapter, `order`, page images, `missing` when a page file is gone. `/api/chapter` also lists the caller's moments on the opened version |
| `POST /api/moments` `{m, c, v, pages, region, cat, note}` | user | Save a moment: `pages` are 1–2 indexes left to right as displayed, `cat` one of `twist hot awesome cute funny feels` (required), `region` fractions or null |
| `POST /api/moments` `{m, c, v, pages, layout, region, cat, note}` | user | Save a moment: `pages` are 1–4 distinct indexes in displayed order, `layout` `row` (default) or `column`, `cat` one of `twist hot awesome cute funny feels` (required), `region` fractions of the pages put together, or null |
| `POST /api/moments/update` `{id, cat?, region?, note?}` · `POST /api/moments/delete` `{id}` | user | Edit or delete one of the caller's own moments (another user's id is `404`) |
| `GET /api/notifications` · `POST /api/notifications/read` `{id \| all}` | user | Notification list and read state |
| `GET /api/admin` | admin | Users, last scan result, library path |
@@ -359,7 +360,7 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended
- Pulses are sent at most every 10 seconds and on pause/page exit. The server validates the manga/chapter/version and page indices, de-duplicates `(session, seq)`, clamps time to server-observed intervals and stores compact page ranges separately from ordinary progress.
- The bottom toolbar chip shows active time and the current state; its panel adds pass time, verified pages, predominant input and loading wait.
**Moments** (`public/moments.js`): the top bar's bookmark button (or `m`) opens a sheet for the page(s) on screen: in Flow the page at the reading line, in Book the spread. A rectangle dragged over the preview keeps only that region; a region inside one page of a spread saves that page alone. Exactly one category is required. The button is filled when the page already has a moment. The manga page shows the user's moments as a strip of crops (sort by story or by date saved, category filters from five moments), with a viewer to step through them, edit, delete or open the reader at `read.html?…&p=<page index>`. Crops position the full page images inside a clipping box; nothing is generated on the server.
**Moments** (`public/moments.js`): the top bar's bookmark button (or `m`) opens a sheet for the page(s) on screen: in Flow the page at the reading line, in Book the spread. "+ Previous page" / "+ Next page" add neighbours (up to four pages) for a picture that runs over several pages: stacked in Flow (`column`), side by side in Page and Book (`row`, right to left for `rtl`). A rectangle dragged over the preview keeps only that region, and pages it does not touch are left out. Exactly one category is required. The button is filled when the page already has a moment. The manga page shows the user's moments as a strip of crops (sort by story or by date saved, category filters from five moments), with a viewer to step through them, edit, delete or open the reader at `read.html?…&p=<page index>`. Crops position the full page images inside a clipping box; nothing is generated on the server.
**Zoom persistence:** per mode key (`flow`, `page`, `book`, `strip`), saved 800 ms after the last change.
+10 -7
View File
@@ -1,18 +1,19 @@
'use strict';
/*
* Moments: pages, or a region of one or two facing pages, that a reader saved with exactly one category.
* 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;
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 both pages of a spread side by side.
// 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;
@@ -29,14 +30,15 @@ function cleanCategory(cat) {
}
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.
// 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 > 2) fail('A moment covers one page or two facing pages.');
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 (out.length === 2 && out[0] === out[1]) 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) {
@@ -55,6 +57,7 @@ function view(moment, manga, url) {
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],
@@ -71,4 +74,4 @@ function storyCompare(a, b) {
return a.t - b.t;
}
module.exports = { CATEGORIES, NOTE_MAX, MAX_PER_USER, MomentError, cleanRegion, cleanCategory, cleanNote, cleanPages, locate, view, storyCompare };
module.exports = { CATEGORIES, NOTE_MAX, MAX_PER_USER, MAX_PAGES, MomentError, cleanRegion, cleanCategory, cleanNote, cleanPages, cleanLayout, locate, view, storyCompare };
+1
View File
@@ -564,3 +564,4 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none
kbd { display: inline-block; min-width: 24px; padding: 1px 7px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px;
background: var(--card2); font: 13px/1.5 ui-monospace, monospace; text-align: center; }
@media (max-width: 480px) { .moment-dialog { padding: 12px; } .moment-actions .moment-hint { flex-basis: 100%; } .moment-actions .moment-hint:empty { display: none; } }
.moment-extend { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 8px; }
+1 -1
View File
@@ -350,7 +350,7 @@
const items = shown();
strip.replaceChildren(...items.map((m, k) => h('button', { type: 'button', class: 'moment-thumb', title: `${momentCat(m.cat).label} · ${m.chapter.label} · ${m.missing ? 'page moved' : momentPageLabel(m.pages)}${m.note ? '\n' + m.note : ''}`,
onclick: () => momentViewer({ mangaId: M.id, mangaTitle: M.title, list: items, index: k, onChange: changed }) },
momentCrop(m.pages, m.region, 200, 120),
momentCrop(m.pages, m.region, 200, 120, m.layout),
h('span', { class: 'moment-thumb-cat' }, momentIcon(m.cat, 14)),
h('span', { class: 'moment-thumb-label' }, `${m.chapter.label} · ${m.missing ? 'moved' : momentPageLabel(m.pages)}`))));
}
+114 -56
View File
@@ -18,7 +18,10 @@ function momentIcon(id, size = 18) {
el.style.color = momentCat(id).color;
return el;
}
const momentPageLabel = pages => 'p. ' + pages.map(p => p.i + 1).sort((a, b) => a - b).join('–');
const momentPageLabel = pages => {
const ns = pages.map(p => p.i + 1).sort((a, b) => a - b);
return 'p. ' + (ns.length > 1 ? ns[0] + '–' + ns[ns.length - 1] : ns[0]);
};
// Displayed size after rotation, as in the reader.
const shapeOf = p => {
@@ -27,29 +30,40 @@ const shapeOf = p => {
};
const FULL = { x: 0, y: 0, w: 1, h: 1 };
// The pages side by side at height s (px), left to right as displayed.
function composite(pages, s) {
const shapes = pages.map(shapeOf), el = h('div', { class: 'moment-composite' });
let x = 0;
for (const p of shapes) {
const w = p.a * s, img = h('img', { src: p.u, alt: '', draggable: 'false', decoding: 'async', loading: 'lazy' });
if (p.r) img.style.cssText = `position:absolute;left:50%;top:50%;max-width:none;width:${p.r % 180 ? s : w}px;height:${p.r % 180 ? w : s}px;transform:translate(-50%,-50%) rotate(${p.r}deg)`;
el.append(h('div', { class: 'moment-page', style: `left:${x}px;width:${w}px;height:${s}px` }, img));
x += w;
// Each page's box in units: side by side ("row", Page/Book) pages share a height of 1,
// stacked ("column", Flow) pages share a width of 1.
function arrange(pages, layout) {
const column = layout === 'column';
let at = 0;
const boxes = pages.map(shapeOf).map((p, k) => {
const b = column ? { k, p, x: 0, y: at, w: 1, h: 1 / p.a } : { k, p, x: at, y: 0, w: p.a, h: 1 };
at += column ? b.h : b.w;
return b;
});
return { boxes, w: column ? 1 : at, h: column ? at : 1 };
}
// The pages put together at `scale` px per unit.
function composite(pages, layout, scale) {
const { boxes, w, h: hh } = arrange(pages, layout), el = h('div', { class: 'moment-composite' });
for (const b of boxes) {
const W = b.w * scale, H = b.h * scale, r = b.p.r || 0;
const img = h('img', { src: b.p.u, alt: '', draggable: 'false', decoding: 'async', loading: 'lazy' });
if (r) img.style.cssText = `position:absolute;left:50%;top:50%;max-width:none;width:${r % 180 ? H : W}px;height:${r % 180 ? W : H}px;transform:translate(-50%,-50%) rotate(${r}deg)`;
el.append(h('div', { class: 'moment-page', style: `left:${b.x * scale}px;top:${b.y * scale}px;width:${W}px;height:${H}px` }, img));
}
el.style.width = x + 'px';
el.style.height = s + 'px';
el.style.width = w * scale + 'px';
el.style.height = hh * scale + 'px';
return el;
}
const compositeAspect = pages => pages.map(shapeOf).reduce((sum, p) => sum + p.a, 0);
// A box of at most maxW × maxH showing `region` (or everything) of the pages.
function momentCrop(pages, region, maxW, maxH) {
function momentCrop(pages, region, maxW, maxH, layout = 'row') {
if (!pages.length) return h('div', { class: 'moment-crop missing', style: `width:${Math.round(maxH * 0.7)}px;height:${maxH}px` }, 'Page moved');
const r = region || FULL, total = compositeAspect(pages), aspect = (r.w * total) / r.h;
const boxW = Math.min(maxW, maxH * aspect), boxH = boxW / aspect, s = boxH / r.h;
const inner = composite(pages, s);
inner.style.transform = `translate(${-r.x * total * s}px, ${-r.y * s}px)`;
const r = region || FULL, { w: TW, h: TH } = arrange(pages, layout), aspect = (r.w * TW) / (r.h * TH);
const boxW = Math.min(maxW, maxH * aspect), boxH = boxW / aspect, scale = boxW / (r.w * TW);
const inner = composite(pages, layout, scale);
inner.style.transform = `translate(${-r.x * TW * scale}px, ${-r.y * TH * scale}px)`;
return h('div', { class: 'moment-crop', style: `width:${Math.round(boxW)}px;height:${Math.round(boxH)}px` }, inner);
}
@@ -88,38 +102,84 @@ function regionPicker(target, initial, onChange) {
return { get: () => region, clear() { region = null; draw(); onChange(null); } };
}
// A region on a spread that lies within one page becomes a moment of that page alone.
function narrowToPage(pages, region) {
// Pages the region does not touch are dropped and the region is mapped onto the pages that remain,
// so a selection inside one page of a spread or a stack saves that page alone.
function trimPages(pages, region, layout = 'row') {
if (pages.length < 2 || !region) return { pages, region };
const shapes = pages.map(shapeOf), total = shapes[0].a + shapes[1].a, split = shapes[0].a / total;
const x0 = region.x, x1 = region.x + region.w;
if (x1 <= split + 0.002) return { pages: [pages[0]], region: { ...region, x: x0 / split, w: Math.min(1, region.w / split) } };
if (x0 >= split - 0.002) return { pages: [pages[1]], region: { ...region, x: Math.max(0, (x0 - split) / (1 - split)), w: Math.min(1, region.w / (1 - split)) } };
return { pages, region };
const column = layout === 'column', { boxes, w: TW, h: TH } = arrange(pages, layout), total = column ? TH : TW;
const from = (column ? region.y : region.x) * total, to = from + (column ? region.h : region.w) * total, eps = 0.002 * total;
const startOf = b => (column ? b.y : b.x), endOf = b => startOf(b) + (column ? b.h : b.w);
const keep = boxes.filter(b => endOf(b) > from + eps && startOf(b) < to - eps);
if (!keep.length || keep.length === boxes.length) return { pages, region };
const s0 = startOf(keep[0]), len = endOf(keep[keep.length - 1]) - s0;
const a = Math.max(0, (from - s0) / len), b = Math.min(1, (to - s0) / len);
return { pages: keep.map(x => pages[x.k]), region: column ? { ...region, y: a, h: b - a } : { ...region, x: a, w: b - a } };
}
// `dialog.done()` closes and cleans up at once. The "close" event (Esc) does the same, but it is a queued task
// that a backgrounded page may run late, so the app never waits for it.
function openDialog(className, onClose) {
const dialog = h('dialog', { class: 'moment-dialog ' + className });
dialog.addEventListener('close', () => { dialog.remove(); if (onClose) onClose(); });
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); }); // click on the backdrop
let finished = false;
dialog.done = () => {
if (finished) return;
finished = true;
if (dialog.open) dialog.close();
dialog.remove();
if (onClose) onClose();
};
dialog.addEventListener('close', dialog.done);
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.done(); }); // click on the backdrop
document.body.append(dialog);
return dialog;
}
/*
* The save / edit sheet. `pages` [{i, u, w, h, r}] in displayed order; `context` lines; `initial` {region, cat, note}.
* `save({pages, region, cat, note})` returns a promise. Resolves with its result, or null when cancelled.
* The save / edit sheet. `context` [manga title, chapter text]; `initial` {region, cat, note}.
* Pages come either fixed (`pages` in displayed order, for an edit) or from `sequence` (pages in reading order)
* with the shown `range` [first, last], which "+ Previous page" / "+ Next page" widen up to four pages.
* `layout` "row" puts them side by side (right to left when `rtl`), "column" stacks them as in Flow.
* `save({pages, layout, region, cat, note})` returns a promise. Resolves with its result, or null when cancelled.
*/
function momentSheet({ heading, context, pages, initial = {}, lockPages = false, save }) {
function momentSheet({ heading, context, pages: fixed, sequence, range, layout = 'row', rtl = false, initial = {}, save }) {
return new Promise(resolve => {
let result = null, cat = initial.cat || null, region = initial.region || null;
let result = null, cat = initial.cat || null, region = initial.region || null, picker = null;
let [lo, hi] = range || [0, 0];
const start = [lo, hi], MAX = 4;
const dialog = openDialog('moment-sheet', () => resolve(result));
const maxW = Math.min(560, innerWidth - 48), maxH = Math.min(innerHeight * (innerWidth < 600 ? 0.42 : 0.5), 520);
const total = compositeAspect(pages), boxW = Math.min(maxW, maxH * total), s = boxW / total;
const preview = h('div', { class: 'moment-preview' }, composite(pages, s));
const whole = h('button', { class: 'btn small', type: 'button', hidden: !region }, 'Whole page');
const picker = regionPicker(preview, region, r => { region = r; whole.hidden = !r; });
whole.onclick = () => picker.clear();
const pagesNow = () => {
if (fixed) return fixed;
const run = sequence.slice(lo, hi + 1);
return layout === 'row' && rtl ? run.reverse() : run;
};
const wrap = h('div', { class: 'moment-preview-wrap' });
const where = h('div', { class: 'muted small' });
const whole = h('button', { class: 'btn small', type: 'button', hidden: true, onclick: () => picker.clear() }, 'Whole selection');
const help = h('span', { class: 'muted small' });
const earlier = h('button', { class: 'btn small', type: 'button', onclick: () => { lo--; drawPreview(); } }, '+ Previous page');
const later = h('button', { class: 'btn small', type: 'button', onclick: () => { hi++; drawPreview(); } }, '+ Next page');
const reset = h('button', { class: 'btn small', type: 'button', onclick: () => { [lo, hi] = start; drawPreview(); } }, 'Reset pages');
const extend = h('div', { class: 'moment-extend', hidden: !sequence },
layout === 'row' && rtl ? [later, reset, earlier] : [earlier, reset, later]);
// Changing the pages changes the geometry, so a selection starts over.
function drawPreview() {
const pages = pagesNow(), { w: TW, h: TH } = arrange(pages, layout);
const maxW = Math.min(560, innerWidth - 48);
const maxH = Math.min(innerHeight * (layout === 'column' ? 0.62 : innerWidth < 600 ? 0.42 : 0.5), layout === 'column' ? 900 : 520);
const preview = h('div', { class: 'moment-preview' }, composite(pages, layout, Math.min(maxW / TW, maxH / TH)));
if (picker) region = null;
picker = regionPicker(preview, region, r => { region = r; whole.hidden = !r; });
whole.hidden = !region;
wrap.replaceChildren(preview);
where.textContent = `${context[1]} · ${momentPageLabel(pages)}`;
help.textContent = 'Drag over the picture to keep just a part; pages it does not touch are left out. Without a selection everything shown is saved.';
if (sequence) {
const count = hi - lo + 1;
earlier.disabled = lo === 0 || count >= MAX;
later.disabled = hi === sequence.length - 1 || count >= MAX;
reset.hidden = lo === start[0] && hi === start[1];
}
}
const hint = h('span', { class: 'muted small moment-hint' });
const saveBtn = h('button', { class: 'btn primary', type: 'submit' }, 'Save moment');
const chips = MOMENT_CATS.map(c => {
@@ -136,27 +196,26 @@ function momentSheet({ heading, context, pages, initial = {}, lockPages = false,
const error = h('p', { class: 'error small', hidden: true });
const form = h('form', { method: 'dialog' },
h('div', { class: 'moment-head' }, h('h2', {}, heading),
h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.close() }, '×')),
context.map((line, k) => h('div', { class: k ? 'muted small' : 'moment-context' }, line)),
h('div', { class: 'moment-preview-wrap' }, preview),
h('div', { class: 'moment-region-row' },
h('span', { class: 'muted small' }, 'Drag over the picture to keep just a part. Without a selection the whole ' + (pages.length > 1 ? 'spread' : 'page') + ' is saved.'),
whole),
h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.done() }, '×')),
h('div', { class: 'moment-context' }, context[0]), where,
wrap, extend,
h('div', { class: 'moment-region-row' }, help, whole),
h('div', { class: 'moment-chips', role: 'radiogroup', 'aria-label': 'Category' }, chips),
note, error,
h('div', { class: 'moment-actions' }, hint, h('div', { class: 'grow' }),
h('button', { class: 'btn', type: 'button', onclick: () => dialog.close() }, 'Cancel'), saveBtn));
h('button', { class: 'btn', type: 'button', onclick: () => dialog.done() }, 'Cancel'), saveBtn));
form.addEventListener('submit', async e => {
e.preventDefault();
if (!cat) return;
saveBtn.disabled = true; error.hidden = true;
try {
const narrowed = lockPages ? { pages, region } : narrowToPage(pages, region);
result = await save({ pages: narrowed.pages, region: narrowed.region, cat, note: note.value });
dialog.close();
const kept = fixed ? { pages: fixed, region } : trimPages(pagesNow(), region, layout);
result = await save({ pages: kept.pages, layout, region: kept.region, cat, note: note.value });
dialog.done();
} catch (err) { error.textContent = err.message; error.hidden = false; saveBtn.disabled = false; }
});
dialog.append(form);
drawPreview();
drawChips();
dialog.showModal();
});
@@ -169,7 +228,7 @@ const momentReadUrl = (mangaId, m) => 'read.html?' + qsa({ m: mangaId, c: m.c, v
*/
function momentViewer({ mangaId, mangaTitle, list, index, onChange }) {
let k = index, full = false;
const dialog = openDialog('moment-viewer');
const dialog = openDialog('moment-viewer', () => removeEventListener('resize', draw));
const stage = h('div', { class: 'moment-viewer-stage' });
const info = h('div', { class: 'moment-viewer-info' });
const prevBtn = h('button', { class: 'btn icon moment-nav prev', type: 'button', 'aria-label': 'Previous moment', onclick: () => step(-1) }, '‹');
@@ -182,7 +241,7 @@ function momentViewer({ mangaId, mangaTitle, list, index, onChange }) {
h('button', { class: 'btn small danger', type: 'button', onclick: remove }, 'Delete'),
h('div', { class: 'grow' }), readLink);
dialog.append(
h('div', { class: 'moment-head' }, info, counter, h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.close() }, '×')),
h('div', { class: 'moment-head' }, info, counter, h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.done() }, '×')),
h('div', { class: 'moment-viewer-body' }, prevBtn, stage, nextBtn), actions);
function step(d) {
@@ -190,10 +249,10 @@ function momentViewer({ mangaId, mangaTitle, list, index, onChange }) {
k = (k + d + list.length) % list.length; full = false; draw();
}
function draw() {
if (!list.length) return dialog.close();
if (!list.length) return dialog.done();
const m = list[k], c = momentCat(m.cat);
const maxW = Math.min(1100, innerWidth - (innerWidth < 600 ? 32 : 140)), maxH = innerHeight - (innerWidth < 600 ? 230 : 200);
stage.replaceChildren(momentCrop(m.pages, full ? null : m.region, maxW, maxH));
stage.replaceChildren(momentCrop(m.pages, full ? null : m.region, maxW, maxH, m.layout));
info.replaceChildren(h('span', { class: 'moment-tag', style: `--cat:${c.color}` }, momentIcon(m.cat, 16), c.label),
h('span', {}, `${m.chapter.label}${m.chapter.title ? ' · ' + m.chapter.title : ''} · ${m.missing ? 'page moved' : momentPageLabel(m.pages)}`),
h('span', { class: 'muted small' }, ago(m.t)),
@@ -201,14 +260,14 @@ function momentViewer({ mangaId, mangaTitle, list, index, onChange }) {
counter.textContent = `${k + 1} / ${list.length}`;
prevBtn.hidden = nextBtn.hidden = list.length < 2;
fullBtn.hidden = !m.region || m.missing;
fullBtn.textContent = full ? 'Show selection' : 'Show full ' + (m.pages.length > 1 ? 'spread' : 'page');
fullBtn.textContent = full ? 'Show selection' : (m.pages.length > 1 ? 'Show all pages' : 'Show full page');
readLink.href = momentReadUrl(mangaId, m); // a moved page still opens its chapter
}
async function edit() {
const m = list[k];
if (m.missing) return;
const saved = await momentSheet({ heading: 'Edit moment', context: [mangaTitle, `${m.chapter.label}${m.chapter.title ? ' · ' + m.chapter.title : ''} · ${momentPageLabel(m.pages)}`],
pages: m.pages, initial: m, lockPages: true,
const saved = await momentSheet({ heading: 'Edit moment', context: [mangaTitle, `${m.chapter.label}${m.chapter.title ? ' · ' + m.chapter.title : ''}`],
pages: m.pages, layout: m.layout, initial: m,
save: async ({ region, cat, note }) => (await api('api/moments/update', { id: m.id, region, cat, note })).moment });
if (!saved) return;
list[k] = saved; draw(); onChange(list);
@@ -236,9 +295,8 @@ function momentViewer({ mangaId, mangaTitle, list, index, onChange }) {
if (Math.abs(dx) >= 50 && Math.abs(dx) > 2 * Math.abs(dy)) step(dx < 0 ? 1 : -1);
});
addEventListener('resize', draw);
dialog.addEventListener('close', () => removeEventListener('resize', draw));
draw();
dialog.showModal();
}
if (typeof module !== 'undefined') module.exports = { MOMENT_CATS, narrowToPage, compositeAspect };
if (typeof module !== 'undefined') module.exports = { MOMENT_CATS, trimPages, arrange, momentPageLabel };
+19 -17
View File
@@ -502,39 +502,41 @@
if (action) keyActions[action](e);
});
function showKeys() {
const dialog = h('dialog', { class: 'moment-dialog key-dialog' },
const dialog = openDialog('key-dialog', () => stage.focus({ preventScroll: true }));
dialog.append(
h('div', { class: 'moment-head' }, h('h2', {}, 'Keyboard shortcuts'),
h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.close() }, '×')),
h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.done() }, '×')),
keyTable([{ title: 'Reader', keys: READER_KEYS }]),
h('p', { class: 'muted small' }, 'Also listed in Settings → Keyboard shortcuts.'));
dialog.addEventListener('close', () => { dialog.remove(); stage.focus({ preventScroll: true }); });
document.body.append(dialog);
dialog.showModal();
}
// ---------- moments ----------
const momentPages = new Set((D.moments || []).flatMap(x => x.pages));
// The page(s) on screen, left to right as displayed. In Flow the page at the reading line.
const momentPage = p => ({ i: p.i, u: p.u, w: D.pages[p.i].w, h: D.pages[p.i].h, r: p.r });
// Positions in `list` of the page(s) on screen, in reading order. In Flow the page at the reading line.
function currentRange() {
if (atEnd && mode !== 'flow') return null;
const ks = (mode === 'book' ? shown : [cur]).filter(k => list[k]);
return ks.length ? [Math.min(...ks), Math.max(...ks)] : null;
}
function currentPages() {
if (atEnd && mode !== 'flow') return [];
const ks = mode === 'book' ? shown.slice() : [cur];
if (mode === 'book' && rtl) ks.reverse();
return ks.filter(k => list[k]).map(k => {
const p = list[k], raw = D.pages[p.i];
return { i: p.i, u: p.u, w: raw.w, h: raw.h, r: p.r };
});
const range = currentRange();
return range ? list.slice(range[0], range[1] + 1).map(momentPage) : [];
}
let momentOpen = false;
async function saveMoment() {
const pages = currentPages();
if (!pages.length) return toast('No page here');
const range = currentRange();
if (!range) return toast('No page here');
if (momentOpen) return;
momentOpen = true;
const chapter = D.chapters.find(c => c.id === D.chapter.id), book = D.books.length > 1 && chapter && D.books.find(b => b.key === chapter.book);
// A picture can run over several pages: the sheet can add neighbours, stacked in Flow, side by side in Page/Book.
const saved = await momentSheet({
heading: 'Save moment', pages,
context: [D.manga.title, [book && book.label, D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : ''), momentPageLabel(pages)].filter(Boolean).join(' · ')],
save: async v => (await api('api/moments', { m: D.manga.id, c: D.chapter.id, v: D.version, pages: v.pages.map(p => p.i), region: v.region, cat: v.cat, note: v.note })).moment,
heading: 'Save moment', sequence: list.map(momentPage), range, layout: mode === 'flow' ? 'column' : 'row', rtl,
context: [D.manga.title, [book && book.label, D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')].filter(Boolean).join(' · ')],
save: async v => (await api('api/moments', { m: D.manga.id, c: D.chapter.id, v: D.version, pages: v.pages.map(p => p.i), layout: v.layout,
region: v.region, cat: v.cat, note: v.note })).moment,
});
momentOpen = false;
stage.focus({ preventScroll: true });
+1
View File
@@ -850,6 +850,7 @@ const routes = {
const m = mangaOf(b.m), ch = chapterOf(m, b.c), v = ch.versions.find(x => x.id === b.v) || bad(404, 'Version not found');
const pages = moments.cleanPages(b.pages, v);
const moment = { id: crypto.randomBytes(6).toString('hex'), m: m.id, c: ch.id, v: v.id, i: pages, f: pages.map(i => v.pages[i].f),
layout: moments.cleanLayout(b.layout),
region: moments.cleanRegion(b.region), cat: moments.cleanCategory(b.cat), note: moments.cleanNote(b.note), t: Date.now() };
const list = momentsOf(u);
if (list.length >= moments.MAX_PER_USER) bad(409, `You already have ${moments.MAX_PER_USER} moments.`);
+23 -6
View File
@@ -5,7 +5,7 @@ const fs = require('fs'), os = require('os'), path = require('path'), net = requ
const { spawn } = require('child_process');
const { Canvas } = require('../tools/png');
const moments = require('../moments');
const { narrowToPage } = require('../public/moments');
const { trimPages, momentPageLabel } = require('../public/moments');
const { READER_KEYS, MOMENT_VIEWER_KEYS, keyAction } = require('../public/keys');
test('regions are clamped fractions; a whole-page rectangle means no region', () => {
@@ -25,13 +25,23 @@ test('exactly one known category is required; notes are trimmed and capped', ()
assert.equal(moments.cleanNote('x'.repeat(500)).length, moments.NOTE_MAX);
});
test('a spread region inside one page becomes that page alone', () => {
const near = (a, b) => Math.abs(a - b) < 1e-9;
test('side by side: a region inside one page of a spread becomes that page alone', () => {
const left = { i: 5, w: 700, h: 1000 }, right = { i: 4, w: 700, h: 1000 };
const one = narrowToPage([left, right], { x: 0.6, y: 0.1, w: 0.2, h: 0.3 });
const one = trimPages([left, right], { x: 0.6, y: 0.1, w: 0.2, h: 0.3 });
assert.deepEqual(one.pages, [right]);
assert.ok(Math.abs(one.region.x - 0.2) < 1e-9 && Math.abs(one.region.w - 0.4) < 1e-9);
const both = narrowToPage([left, right], { x: 0.3, y: 0.1, w: 0.4, h: 0.3 });
assert.equal(both.pages.length, 2);
assert.ok(near(one.region.x, 0.2) && near(one.region.w, 0.4) && one.region.y === 0.1);
assert.equal(trimPages([left, right], { x: 0.3, y: 0.1, w: 0.4, h: 0.3 }).pages.length, 2);
});
test('stacked (Flow): a picture across the break keeps only the pages it touches', () => {
const pages = [0, 1, 2, 3].map(i => ({ i, w: 800, h: 400 })); // each page is half as tall as wide: 0.5 of the width
// The stack is 2 units tall; y 0.4–0.6 is the lower half of page 1 and the upper half of page 2.
const cut = trimPages(pages, { x: 0.1, y: 0.4, w: 0.8, h: 0.2 }, 'column');
assert.deepEqual(cut.pages.map(p => p.i), [1, 2]);
assert.ok(near(cut.region.y, 0.3) && near(cut.region.h, 0.4) && cut.region.x === 0.1);
assert.deepEqual(trimPages(pages, null, 'column').pages.length, 4);
assert.equal(momentPageLabel(cut.pages), 'p. 2–3');
});
test('every reader key is bound once and the M / I split holds', () => {
@@ -99,6 +109,13 @@ test('moments are private, validated, editable and survive page renumbering as "
assert.match(created.moment.pages[0].u, /^lib\/Moon%20Garden\/v01\/c001%20Roots\/002\.png$/);
const spread = (await (await post(alice, '/api/moments', { ...base3, pages: [2, 0], cat: 'twist' })).json()).moment;
assert.deepEqual(spread.pages.map(p => p.i), [2, 0]);
assert.equal(spread.layout, 'row');
// Flow: a picture stacked over three pages; more than four pages or an unknown layout is refused.
const stacked = (await (await post(alice, '/api/moments', { ...base3, pages: [0, 1, 2], layout: 'column', cat: 'awesome', region: { x: 0, y: 0.2, w: 1, h: 0.5 } })).json()).moment;
assert.equal(stacked.layout, 'column');
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [0, 1, 2, 0], cat: 'hot' })).status, 400);
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [0], layout: 'grid', cat: 'hot' })).status, 400);
assert.equal((await post(alice, '/api/moments/delete', { id: stacked.id })).status, 200);
// Story order: page 0/2 spread starts before page 1.
const listed = (await get(alice, '/api/moments?m=Moon%20Garden')).moments;