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
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 });