Moments: per-user saved pages and regions with one category (BL-0055)
Reader: bookmark button and M save the page (Flow: page at the reading line, Book: the spread) with an optional dragged region, exactly one category (Twist, Hot, Awesome, Cute, Funny, Feels) and a note. I now toggles the bars; ? lists all keys. Keys dispatch through public/keys.js, which also feeds the new Settings -> Keyboard shortcuts section. Manga page: "Your moments" strip of crops (story or recent order, category filters), viewer with step, full page, edit, delete and "Open in reader" (read.html ...&p=). Moments point at pages by file name and show "page moved" when the file is gone. Private per user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41e7ed063e
commit
74842c59e0
15 files changed
+734
-18
No files matched your search
@@ -0,0 +1,244 @@
|
||||
'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: '<path d="M12 1.5L13.8 4.6L17.3 3.2L17.4 6.8L21 7.6L19.2 10.8L22 13.2L18.8 14.9L19.6 18.5L16 18.2L14.6 21.6L12 19.2L9.4 21.6L8 18.2L4.4 18.5L5.2 14.9L2 13.2L4.8 10.8L3 7.6L6.6 6.8L6.7 3.2L10.2 4.6Z"/><path d="M9.3 8.3v4.4"/><path d="M12.6 9.2a1.9 1.9 0 1 1 2.6 1.8c-.6.3-.8.7-.8 1.5"/><circle cx="9.3" cy="15.3" r=".5" fill="currentColor"/><circle cx="14.4" cy="15.3" r=".5" fill="currentColor"/>' },
|
||||
{ id: 'hot', label: 'Hot', color: '#f0845c', svg: '<path d="M12 2.5c.5 3 3 4.5 4.5 7a6.5 6.5 0 1 1-11 1.5c.8 1.2 1.8 1.8 2.8 1.8C7.5 9 9.5 5.5 12 2.5z"/><path d="M12 21a3 3 0 0 1-3-3c0-1.8 1.5-3 3-5c1.5 2 3 3.2 3 5a3 3 0 0 1-3 3z"/><path d="M19.5 3l-1 1.6M21.5 5.5l-1.6.8"/>' },
|
||||
{ id: 'awesome', label: 'Awesome', color: '#f2b443', svg: '<path d="M12 5.5L13.5 10.5L18.5 12L13.5 13.5L12 18.5L10.5 13.5L5.5 12L10.5 10.5Z"/><path d="M3 3l2.6 2.6M21 3l-2.6 2.6M3 21l2.6-2.6M21 21l-2.6-2.6M12 1.5v1.8M12 20.7v1.8M1.5 12h1.8M20.7 12h1.8"/>' },
|
||||
{ id: 'cute', label: 'Cute', color: '#f07aa5', svg: '<path d="M11 21.5s-7-4.4-7-9.5a4 4 0 0 1 7-2.6a4 4 0 0 1 7 2.6c0 5.1-7 9.5-7 9.5z"/><path d="M8 13.5l1 1M13 13.5l1 1"/><path d="M19.5 2v4M17.5 4h4"/><path d="M4 3.5v2.4M2.8 4.7h2.4"/>' },
|
||||
{ id: 'funny', label: 'Funny', color: '#4fcfa0', svg: '<circle cx="11" cy="13" r="8"/><path d="M6.8 11.6l1.6-1.6l1.6 1.6M12 11.6l1.6-1.6l1.6 1.6"/><path d="M7.3 14.5h7.4a3.7 3.7 0 0 1-7.4 0z"/><path d="M20.2 2.5c-1 1.6-1.6 2.6-1.6 3.4a1.6 1.6 0 0 0 3.2 0c0-.8-.6-1.8-1.6-3.4z"/>' },
|
||||
{ id: 'feels', label: 'Feels', color: '#6aa8ec', svg: '<path d="M2.5 10c2.5-4 6-5.5 9.5-5.5S19 6 21.5 10"/><path d="M5 11.5a7 7 0 0 0 14 0"/><circle cx="12" cy="11" r="4"/><circle cx="13.5" cy="9.5" r="1" fill="currentColor"/><path d="M18 16.5c-1 1.5-1.5 2.4-1.5 3.1a1.5 1.5 0 0 0 3 0c0-.7-.5-1.6-1.5-3.1z"/>' },
|
||||
];
|
||||
const momentCat = id => MOMENT_CATS.find(c => c.id === id) || MOMENT_CATS[0];
|
||||
function momentIcon(id, size = 18) {
|
||||
const el = icon(`<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${size < 20 ? 2 : 1.6}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${momentCat(id).svg}</svg>`);
|
||||
el.style.color = momentCat(id).color;
|
||||
return el;
|
||||
}
|
||||
const momentPageLabel = pages => 'p. ' + pages.map(p => p.i + 1).sort((a, b) => a - b).join('–');
|
||||
|
||||
// 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 };
|
||||
|
||||
// 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;
|
||||
}
|
||||
el.style.width = x + 'px';
|
||||
el.style.height = s + '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) {
|
||||
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)`;
|
||||
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); } };
|
||||
}
|
||||
|
||||
// A region on a spread that lies within one page becomes a moment of that page alone.
|
||||
function narrowToPage(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 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 };
|
||||
}
|
||||
|
||||
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
|
||||
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.
|
||||
*/
|
||||
function momentSheet({ heading, context, pages, initial = {}, lockPages = false, save }) {
|
||||
return new Promise(resolve => {
|
||||
let result = null, cat = initial.cat || null, region = initial.region || null;
|
||||
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 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.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('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));
|
||||
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();
|
||||
} catch (err) { error.textContent = err.message; error.hidden = false; saveBtn.disabled = false; }
|
||||
});
|
||||
dialog.append(form);
|
||||
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');
|
||||
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.close() }, '×')),
|
||||
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.close();
|
||||
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));
|
||||
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' : 'Show full ' + (m.pages.length > 1 ? 'spread' : '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,
|
||||
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);
|
||||
dialog.addEventListener('close', () => removeEventListener('resize', draw));
|
||||
draw();
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = { MOMENT_CATS, narrowToPage, compositeAspect };
|
||||
Reference in new issue
Block a user