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). - 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. - 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). - 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 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. - **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. - **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", "notes": { "admin": [ { "id": "a1b2c3d4e5f6", "t": 1790797300000, "m": "Moon Garden", "c": "2/6",
"text": "Moon Garden · Ch. 6: Frost", "read": false } ] }, "text": "Moon Garden · Ch. 6: Frost", "read": false } ] },
"moments": { "admin": [ { "id": "9f1c2b3a4d5e", "m": "Moon Garden", "c": "1/2", "v": "c0002 Roots [MangaDex]", "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) "i": [4], "f": ["0005.png"], "sha": ["<sha256 of the file>"], // 1–4 pages in displayed order
"region": { "x": 0.1, "y": 0.5, "w": 0.5, "h": 0.25 }, // fractions of the page(s); null = whole page "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 } ] }, "cat": "awesome", "note": "", "t": 1790797400000 } ] },
"votes": { "Moon Garden": { "1/2": { "c0002 Roots [Official]": { "admin": 1, "reader": -1 } } } }, "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) "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/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 | | `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 | | `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`) | | `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/notifications` · `POST /api/notifications/read` `{id \| all}` | user | Notification list and read state |
| `GET /api/admin` | admin | Users, last scan result, library path | | `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. - 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. - 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. **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'; '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 * 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 * 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. * point at the wrong picture: a file that is gone shows as "page moved" instead.
*/ */
const CATEGORIES = ['twist', 'hot', 'awesome', 'cute', 'funny', 'feels']; 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; } } class MomentError extends Error { constructor(msg) { super(msg); this.code = 400; } }
const fail = msg => { throw new MomentError(msg); }; const fail = msg => { throw new MomentError(msg); };
const clamp01 = x => Math.min(1, Math.max(0, x)); 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. // Nothing or a rectangle covering (almost) everything means the whole page.
function cleanRegion(r) { function cleanRegion(r) {
if (r == null) return null; 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); 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) { 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); const out = pages.map(Number);
if (out.some(i => !Number.isInteger(i) || i < 0 || i >= version.pages.length)) fail('Page not found.'); 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; 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. // 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) { function locate(moment, manga) {
@@ -55,6 +57,7 @@ function view(moment, manga, url) {
const chapter = at ? at.chapter : manga && manga.chapterById.get(moment.c); const chapter = at ? at.chapter : manga && manga.chapterById.get(moment.c);
return { return {
id: moment.id, c: moment.c, v: moment.v, cat: moment.cat, note: moment.note || '', t: moment.t, region: moment.region || null, 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: '' }, 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. // 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], 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; 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; 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; } 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; } } @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(); 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 : ''}`, 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 }) }, 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-cat' }, momentIcon(m.cat, 14)),
h('span', { class: 'moment-thumb-label' }, `${m.chapter.label} · ${m.missing ? 'moved' : momentPageLabel(m.pages)}`)))); 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; el.style.color = momentCat(id).color;
return el; 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. // Displayed size after rotation, as in the reader.
const shapeOf = p => { const shapeOf = p => {
@@ -27,29 +30,40 @@ const shapeOf = p => {
}; };
const FULL = { x: 0, y: 0, w: 1, h: 1 }; const FULL = { x: 0, y: 0, w: 1, h: 1 };
// The pages side by side at height s (px), left to right as displayed. // Each page's box in units: side by side ("row", Page/Book) pages share a height of 1,
function composite(pages, s) { // stacked ("column", Flow) pages share a width of 1.
const shapes = pages.map(shapeOf), el = h('div', { class: 'moment-composite' }); function arrange(pages, layout) {
let x = 0; const column = layout === 'column';
for (const p of shapes) { let at = 0;
const w = p.a * s, img = h('img', { src: p.u, alt: '', draggable: 'false', decoding: 'async', loading: 'lazy' }); const boxes = pages.map(shapeOf).map((p, k) => {
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)`; 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 };
el.append(h('div', { class: 'moment-page', style: `left:${x}px;width:${w}px;height:${s}px` }, img)); at += column ? b.h : b.w;
x += 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.width = w * scale + 'px';
el.style.height = s + 'px'; el.style.height = hh * scale + 'px';
return el; 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. // 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'); 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 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, s = boxH / r.h; const boxW = Math.min(maxW, maxH * aspect), boxH = boxW / aspect, scale = boxW / (r.w * TW);
const inner = composite(pages, s); const inner = composite(pages, layout, scale);
inner.style.transform = `translate(${-r.x * total * s}px, ${-r.y * s}px)`; 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); 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); } }; 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. // Pages the region does not touch are dropped and the region is mapped onto the pages that remain,
function narrowToPage(pages, region) { // 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 }; 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 column = layout === 'column', { boxes, w: TW, h: TH } = arrange(pages, layout), total = column ? TH : TW;
const x0 = region.x, x1 = region.x + region.w; const from = (column ? region.y : region.x) * total, to = from + (column ? region.h : region.w) * total, eps = 0.002 * total;
if (x1 <= split + 0.002) return { pages: [pages[0]], region: { ...region, x: x0 / split, w: Math.min(1, region.w / split) } }; const startOf = b => (column ? b.y : b.x), endOf = b => startOf(b) + (column ? b.h : b.w);
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)) } }; const keep = boxes.filter(b => endOf(b) > from + eps && startOf(b) < to - eps);
return { pages, region }; 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) { function openDialog(className, onClose) {
const dialog = h('dialog', { class: 'moment-dialog ' + className }); const dialog = h('dialog', { class: 'moment-dialog ' + className });
dialog.addEventListener('close', () => { dialog.remove(); if (onClose) onClose(); }); let finished = false;
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); }); // click on the backdrop 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); document.body.append(dialog);
return dialog; return dialog;
} }
/* /*
* The save / edit sheet. `pages` [{i, u, w, h, r}] in displayed order; `context` lines; `initial` {region, cat, note}. * The save / edit sheet. `context` [manga title, chapter text]; `initial` {region, cat, note}.
* `save({pages, region, cat, note})` returns a promise. Resolves with its result, or null when cancelled. * 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 => { 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 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 pagesNow = () => {
const total = compositeAspect(pages), boxW = Math.min(maxW, maxH * total), s = boxW / total; if (fixed) return fixed;
const preview = h('div', { class: 'moment-preview' }, composite(pages, s)); const run = sequence.slice(lo, hi + 1);
const whole = h('button', { class: 'btn small', type: 'button', hidden: !region }, 'Whole page'); return layout === 'row' && rtl ? run.reverse() : run;
const picker = regionPicker(preview, region, r => { region = r; whole.hidden = !r; }); };
whole.onclick = () => picker.clear(); 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 hint = h('span', { class: 'muted small moment-hint' });
const saveBtn = h('button', { class: 'btn primary', type: 'submit' }, 'Save moment'); const saveBtn = h('button', { class: 'btn primary', type: 'submit' }, 'Save moment');
const chips = MOMENT_CATS.map(c => { 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 error = h('p', { class: 'error small', hidden: true });
const form = h('form', { method: 'dialog' }, const form = h('form', { method: 'dialog' },
h('div', { class: 'moment-head' }, h('h2', {}, heading), h('div', { class: 'moment-head' }, h('h2', {}, heading),
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() }, '×')),
context.map((line, k) => h('div', { class: k ? 'muted small' : 'moment-context' }, line)), h('div', { class: 'moment-context' }, context[0]), where,
h('div', { class: 'moment-preview-wrap' }, preview), wrap, extend,
h('div', { class: 'moment-region-row' }, h('div', { class: 'moment-region-row' }, help, whole),
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('div', { class: 'moment-chips', role: 'radiogroup', 'aria-label': 'Category' }, chips), h('div', { class: 'moment-chips', role: 'radiogroup', 'aria-label': 'Category' }, chips),
note, error, note, error,
h('div', { class: 'moment-actions' }, hint, h('div', { class: 'grow' }), 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 => { form.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
if (!cat) return; if (!cat) return;
saveBtn.disabled = true; error.hidden = true; saveBtn.disabled = true; error.hidden = true;
try { try {
const narrowed = lockPages ? { pages, region } : narrowToPage(pages, region); const kept = fixed ? { pages: fixed, region } : trimPages(pagesNow(), region, layout);
result = await save({ pages: narrowed.pages, region: narrowed.region, cat, note: note.value }); result = await save({ pages: kept.pages, layout, region: kept.region, cat, note: note.value });
dialog.close(); dialog.done();
} catch (err) { error.textContent = err.message; error.hidden = false; saveBtn.disabled = false; } } catch (err) { error.textContent = err.message; error.hidden = false; saveBtn.disabled = false; }
}); });
dialog.append(form); dialog.append(form);
drawPreview();
drawChips(); drawChips();
dialog.showModal(); 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 }) { function momentViewer({ mangaId, mangaTitle, list, index, onChange }) {
let k = index, full = false; 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 stage = h('div', { class: 'moment-viewer-stage' });
const info = h('div', { class: 'moment-viewer-info' }); 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) }, '‹'); 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('button', { class: 'btn small danger', type: 'button', onclick: remove }, 'Delete'),
h('div', { class: 'grow' }), readLink); h('div', { class: 'grow' }), readLink);
dialog.append( 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); h('div', { class: 'moment-viewer-body' }, prevBtn, stage, nextBtn), actions);
function step(d) { function step(d) {
@@ -190,10 +249,10 @@ function momentViewer({ mangaId, mangaTitle, list, index, onChange }) {
k = (k + d + list.length) % list.length; full = false; draw(); k = (k + d + list.length) % list.length; full = false; draw();
} }
function draw() { function draw() {
if (!list.length) return dialog.close(); if (!list.length) return dialog.done();
const m = list[k], c = momentCat(m.cat); const m = list[k], c = momentCat(m.cat);
const maxW = Math.min(1100, innerWidth - (innerWidth < 600 ? 32 : 140)), maxH = innerHeight - (innerWidth < 600 ? 230 : 200); 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), 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', {}, `${m.chapter.label}${m.chapter.title ? ' · ' + m.chapter.title : ''} · ${m.missing ? 'page moved' : momentPageLabel(m.pages)}`),
h('span', { class: 'muted small' }, ago(m.t)), 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}`; counter.textContent = `${k + 1} / ${list.length}`;
prevBtn.hidden = nextBtn.hidden = list.length < 2; prevBtn.hidden = nextBtn.hidden = list.length < 2;
fullBtn.hidden = !m.region || m.missing; 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 readLink.href = momentReadUrl(mangaId, m); // a moved page still opens its chapter
} }
async function edit() { async function edit() {
const m = list[k]; const m = list[k];
if (m.missing) return; 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)}`], const saved = await momentSheet({ heading: 'Edit moment', context: [mangaTitle, `${m.chapter.label}${m.chapter.title ? ' · ' + m.chapter.title : ''}`],
pages: m.pages, initial: m, lockPages: true, 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 }); save: async ({ region, cat, note }) => (await api('api/moments/update', { id: m.id, region, cat, note })).moment });
if (!saved) return; if (!saved) return;
list[k] = saved; draw(); onChange(list); 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); if (Math.abs(dx) >= 50 && Math.abs(dx) > 2 * Math.abs(dy)) step(dx < 0 ? 1 : -1);
}); });
addEventListener('resize', draw); addEventListener('resize', draw);
dialog.addEventListener('close', () => removeEventListener('resize', draw));
draw(); draw();
dialog.showModal(); 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); if (action) keyActions[action](e);
}); });
function showKeys() { 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('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 }]), keyTable([{ title: 'Reader', keys: READER_KEYS }]),
h('p', { class: 'muted small' }, 'Also listed in Settings → Keyboard shortcuts.')); 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(); dialog.showModal();
} }
// ---------- moments ---------- // ---------- moments ----------
const momentPages = new Set((D.moments || []).flatMap(x => x.pages)); 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() { function currentPages() {
if (atEnd && mode !== 'flow') return []; const range = currentRange();
const ks = mode === 'book' ? shown.slice() : [cur]; return range ? list.slice(range[0], range[1] + 1).map(momentPage) : [];
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 };
});
} }
let momentOpen = false; let momentOpen = false;
async function saveMoment() { async function saveMoment() {
const pages = currentPages(); const range = currentRange();
if (!pages.length) return toast('No page here'); if (!range) return toast('No page here');
if (momentOpen) return; if (momentOpen) return;
momentOpen = true; 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); 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({ const saved = await momentSheet({
heading: 'Save moment', pages, 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 : ''), momentPageLabel(pages)].filter(Boolean).join(' · ')], 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), region: v.region, cat: v.cat, note: v.note })).moment, 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; momentOpen = false;
stage.focus({ preventScroll: true }); 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 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 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), 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() }; region: moments.cleanRegion(b.region), cat: moments.cleanCategory(b.cat), note: moments.cleanNote(b.note), t: Date.now() };
const list = momentsOf(u); const list = momentsOf(u);
if (list.length >= moments.MAX_PER_USER) bad(409, `You already have ${moments.MAX_PER_USER} moments.`); 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 { spawn } = require('child_process');
const { Canvas } = require('../tools/png'); const { Canvas } = require('../tools/png');
const moments = require('../moments'); 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'); const { READER_KEYS, MOMENT_VIEWER_KEYS, keyAction } = require('../public/keys');
test('regions are clamped fractions; a whole-page rectangle means no region', () => { 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); 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 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.deepEqual(one.pages, [right]);
assert.ok(Math.abs(one.region.x - 0.2) < 1e-9 && Math.abs(one.region.w - 0.4) < 1e-9); assert.ok(near(one.region.x, 0.2) && near(one.region.w, 0.4) && one.region.y === 0.1);
const both = narrowToPage([left, right], { x: 0.3, y: 0.1, w: 0.4, h: 0.3 }); assert.equal(trimPages([left, right], { x: 0.3, y: 0.1, w: 0.4, h: 0.3 }).pages.length, 2);
assert.equal(both.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', () => { 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$/); 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; 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.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. // Story order: page 0/2 spread starts before page 1.
const listed = (await get(alice, '/api/moments?m=Moon%20Garden')).moments; const listed = (await get(alice, '/api/moments?m=Moon%20Garden')).moments;