'use strict'; /* * Moments (BL-0055): a page, or a region of one or two facing pages, saved with exactly one category. * Shared by the reader (save sheet) and the manga page (gallery and viewer). Crops are drawn by positioning the * full page images inside a clipping box, so no thumbnails are generated on the server. */ const MOMENT_CATS = [ { id: 'twist', label: 'Twist', color: '#a29bf2', svg: '' }, { id: 'hot', label: 'Hot', color: '#f0845c', svg: '' }, { id: 'awesome', label: 'Awesome', color: '#f2b443', svg: '' }, { id: 'cute', label: 'Cute', color: '#f07aa5', svg: '' }, { id: 'funny', label: 'Funny', color: '#4fcfa0', svg: '' }, { id: 'feels', label: 'Feels', color: '#6aa8ec', svg: '' }, ]; const momentCat = id => MOMENT_CATS.find(c => c.id === id) || MOMENT_CATS[0]; function momentIcon(id, size = 18) { const el = icon(``); el.style.color = momentCat(id).color; return el; } 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 => { const turned = (p.r || 0) % 180, w = (turned ? p.h : p.w) || 1000, hh = (turned ? p.w : p.h) || 1414; return { ...p, a: w / hh }; }; const FULL = { x: 0, y: 0, w: 1, h: 1 }; // 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 = w * scale + 'px'; el.style.height = hh * scale + 'px'; return el; } // A box of at most maxW × maxH showing `region` (or everything) of the pages. 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, { 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); } // Drag a rectangle over `target` (position: relative). Coordinates are fractions of the target box. function regionPicker(target, initial, onChange) { let region = initial || null, start = null; const box = h('div', { class: 'region-box', hidden: true }); const layer = h('div', { class: 'region-layer' }, box); const draw = () => { box.hidden = !region; if (region) Object.assign(box.style, { left: region.x * 100 + '%', top: region.y * 100 + '%', width: region.w * 100 + '%', height: region.h * 100 + '%' }); layer.classList.toggle('has-region', !!region); }; const at = e => { const rect = layer.getBoundingClientRect(); return { x: Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)), y: Math.min(1, Math.max(0, (e.clientY - rect.top) / rect.height)) }; }; layer.addEventListener('pointerdown', e => { e.preventDefault(); layer.setPointerCapture(e.pointerId); start = at(e); }); layer.addEventListener('pointermove', e => { if (!start) return; const p = at(e); region = { x: Math.min(start.x, p.x), y: Math.min(start.y, p.y), w: Math.abs(p.x - start.x), h: Math.abs(p.y - start.y) }; draw(); }); const end = () => { if (!start) return; start = null; if (region && (region.w < 0.03 || region.h < 0.03)) region = null; // a tap, not a selection draw(); onChange(region); }; layer.addEventListener('pointerup', end); layer.addEventListener('pointercancel', end); target.append(layer); draw(); return { get: () => region, clear() { region = null; draw(); onChange(null); } }; } // 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 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 }); 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. `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: fixed, sequence, range, layout = 'row', rtl = false, initial = {}, save }) { return new Promise(resolve => { 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 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 => { const chip = h('button', { class: 'moment-chip', type: 'button', role: 'radio', 'data-cat': c.id, style: `--cat:${c.color}` }, momentIcon(c.id, 18), c.label); chip.onclick = () => { cat = c.id; drawChips(); }; return chip; }); const drawChips = () => { for (const chip of chips) chip.setAttribute('aria-checked', String(chip.dataset.cat === cat)); saveBtn.disabled = !cat; hint.textContent = cat ? '' : 'Pick a category to save'; }; const note = h('input', { type: 'text', maxlength: '200', placeholder: 'Optional note', 'aria-label': 'Note', value: initial.note || '' }); 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.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.done() }, 'Cancel'), saveBtn)); form.addEventListener('submit', async e => { e.preventDefault(); if (!cat) return; saveBtn.disabled = true; error.hidden = true; try { 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(); }); } const momentReadUrl = (mangaId, m) => 'read.html?' + qsa({ m: mangaId, c: m.c, v: m.v, p: m.pages.length ? Math.min(...m.pages.map(p => p.i)) : null }); /* * The large viewer: `list` of moment views, starting at `index`. `onChange(list)` after an edit or a delete. */ function momentViewer({ mangaId, mangaTitle, list, index, onChange }) { let k = index, full = false; 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) }, '‹'); const nextBtn = h('button', { class: 'btn icon moment-nav next', type: 'button', 'aria-label': 'Next moment', onclick: () => step(1) }, '›'); const counter = h('span', { class: 'muted small' }); const fullBtn = h('button', { class: 'btn small', type: 'button', onclick: () => { full = !full; draw(); } }); const readLink = h('a', { class: 'btn primary small' }, 'Open in reader'); const actions = h('div', { class: 'moment-actions' }, fullBtn, h('button', { class: 'btn small', type: 'button', onclick: edit }, 'Edit'), 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.done() }, '×')), h('div', { class: 'moment-viewer-body' }, prevBtn, stage, nextBtn), actions); function step(d) { if (!list.length) return; k = (k + d + list.length) % list.length; full = false; draw(); } function draw() { 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, 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)), ...(m.note ? [h('span', { class: 'moment-note' }, m.note)] : [])); counter.textContent = `${k + 1} / ${list.length}`; prevBtn.hidden = nextBtn.hidden = list.length < 2; fullBtn.hidden = !m.region || m.missing; 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 : ''}`], 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); } async function remove() { if (!confirm('Delete this moment?')) return; await api('api/moments/delete', { id: list[k].id }); list.splice(k, 1); k = Math.min(k, list.length - 1); onChange(list); draw(); } dialog.addEventListener('keydown', e => { if (e.target.closest('input, textarea') || dialog.querySelector('dialog[open]')) return; const action = keyAction(MOMENT_VIEWER_KEYS, e.key); if (action === 'step') { e.preventDefault(); step(e.key === 'ArrowRight' ? 1 : -1); } }); let swipe = null; stage.addEventListener('pointerdown', e => { swipe = { x: e.clientX, y: e.clientY, t: Date.now() }; }); stage.addEventListener('pointerup', e => { const s = swipe; swipe = null; if (!s || Date.now() - s.t > 800) return; const dx = e.clientX - s.x, dy = e.clientY - s.y; if (Math.abs(dx) >= 50 && Math.abs(dx) > 2 * Math.abs(dy)) step(dx < 0 ? 1 : -1); }); addEventListener('resize', draw); draw(); dialog.showModal(); } if (typeof module !== 'undefined') module.exports = { MOMENT_CATS, trimPages, arrange, momentPageLabel };