Recortes de paneles desde el lector, reescalados aparte, con galería y acceso directo a la página

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:38:12 -06:00
co-authored by Claude Opus 5.5
parent d48378aa49
commit 5d82a5e020
8 changed files with 466 additions and 12 deletions
+236 -2
View File
@@ -35,6 +35,8 @@ const ICONS = {
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
scroll: '<rect x="6" y="2" width="12" height="9" rx="1.5"/><rect x="6" y="13" width="12" height="9" rx="1.5"/>',
pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>',
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
}
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
@@ -138,6 +140,7 @@ async function router() {
if (parts[0] === 'history') return await viewHistory()
if (parts[0] === 'downloads') return await viewDownloads()
if (parts[0] === 'stats') return await viewStats()
if (parts[0] === 'clips') return await viewClips(params)
if (parts[0] === 'settings') return viewSettings()
return await viewHome(params)
} catch (e) {
@@ -302,7 +305,7 @@ async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>'
const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded)
const newSet = new Set(newKeys.map(String))
let isFollowing = following
@@ -336,7 +339,7 @@ async function viewManga(id, langOverride) {
<button class="btn" id="follow"></button>
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
</div>
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}</div>
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}${clipCount ? `<a href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div></div>
</div>
@@ -736,6 +739,7 @@ async function viewReader(mangaId, chapterId, startPage) {
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
<div class="ttl"><b>${esc(manga.title)}</b></div>
<select id="chap" style="max-width:40vw">${chapOpts}</select>
<button class="btn small" id="clipbtn" title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
<button class="btn small" id="modequick"></button>
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
</div>
@@ -1047,7 +1051,118 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
// --- Recortes: se arrastra un rectángulo sobre la página; se recorta del original (no de la versión HD)
// y el servidor lo reescala aparte. El rectángulo se guarda en coordenadas del documento para
// poder desplazarse (rueda) en vertical mientras se recorta.
let crop = null
function endCrop() {
if (!crop) return
crop.ov.remove()
window.removeEventListener('scroll', crop.draw)
crop = null
$('#clipbtn')?.classList.remove('on')
}
function startCrop() {
if (crop) return endCrop()
if (atEnd || !box.querySelector('img')) return toast('Vuelve a una página para recortar')
reader.classList.remove('hide-ui')
$('.rpanel', reader).hidden = true
const ov = document.createElement('div')
ov.className = 'cropov'
ov.innerHTML = `<div class="crop-bar"><span>${icon('scissors')} Arrastra sobre el panel que quieres guardar</span>
<button class="btn small" data-a="cancel">${icon('x')} Cancelar</button>
<button class="btn small primary" data-a="save" disabled>${icon('check')} Guardar</button></div>
<div class="crop-rect" hidden></div>`
reader.append(ov)
$('#clipbtn').classList.add('on')
const rectEl = $('.crop-rect', ov)
crop = { ov, r: null, start: null }
crop.draw = () => {
const r = crop?.r
if (!r) return
Object.assign(rectEl.style, { left: `${r.l - scrollX}px`, top: `${r.t - scrollY}px`, width: `${r.r - r.l}px`, height: `${r.b - r.t}px` })
rectEl.hidden = false
}
window.addEventListener('scroll', crop.draw, { passive: true })
ov.addEventListener('wheel', e => { e.preventDefault(); scrollBy(0, e.deltaY) }, { passive: false })
ov.addEventListener('pointerdown', e => {
if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0)) return
ov.setPointerCapture(e.pointerId)
crop.start = { x: e.clientX + scrollX, y: e.clientY + scrollY }
crop.r = null
rectEl.hidden = true
$('[data-a=save]', ov).disabled = true
})
ov.addEventListener('pointermove', e => {
if (!crop.start) return
const x = e.clientX + scrollX, y = e.clientY + scrollY
crop.r = { l: Math.min(x, crop.start.x), t: Math.min(y, crop.start.y), r: Math.max(x, crop.start.x), b: Math.max(y, crop.start.y) }
crop.draw()
})
const up = () => {
if (!crop?.start) return
crop.start = null
const ok = crop.r && crop.r.r - crop.r.l >= 12 && crop.r.b - crop.r.t >= 12
if (!ok) { crop.r = null; rectEl.hidden = true }
$('[data-a=save]', ov).disabled = !ok
}
ov.addEventListener('pointerup', up)
ov.addEventListener('pointercancel', up)
$('[data-a=cancel]', ov).addEventListener('click', endCrop)
$('[data-a=save]', ov).addEventListener('click', e => saveCrop(e.target.closest('button')))
}
// La imagen bajo el rectángulo (la de mayor intersección) y la zona en fracciones de esa imagen.
function cropTarget(r) {
const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY }
let best = null
for (const img of box.querySelectorAll('img')) {
if (!img.complete || !img.naturalWidth) continue
const b = img.getBoundingClientRect()
const w = Math.min(c.r, b.right) - Math.max(c.l, b.left), h = Math.min(c.b, b.bottom) - Math.max(c.t, b.top)
if (w > 0 && h > 0 && (!best || w * h > best.area)) best = { img, b, area: w * h }
}
if (!best) return null
const { img, b } = best
const x0 = Math.max(c.l, b.left) - b.left, y0 = Math.max(c.t, b.top) - b.top
const x1 = Math.min(c.r, b.right) - b.left, y1 = Math.min(c.b, b.bottom) - b.top
return { img, page: Number(img.dataset.i), fx: x0 / b.width, fy: y0 / b.height, fw: (x1 - x0) / b.width, fh: (y1 - y0) / b.height }
}
async function saveCrop(btn) {
const t = crop?.r && cropTarget(crop.r)
if (!t) return toast('Marca una zona sobre la página')
btn.disabled = true
try {
const u = new URL(t.img.currentSrc || t.img.src, location.href)
u.searchParams.delete('hd') // recortar del original: el servidor lo reescala él mismo
const src = await new Promise((resolve, reject) => {
const im = new Image()
im.onload = () => resolve(im)
im.onerror = () => reject(new Error('No se pudo cargar la página'))
im.src = u.href
})
const sx = Math.round(t.fx * src.naturalWidth), sy = Math.round(t.fy * src.naturalHeight)
const sw = Math.max(1, Math.round(t.fw * src.naturalWidth)), sh = Math.max(1, Math.round(t.fh * src.naturalHeight))
if (sw < 16 || sh < 16) throw new Error('La zona es demasiado pequeña')
const k = Math.min(1, 4000 / Math.max(sw, sh))
const cv = document.createElement('canvas')
cv.width = Math.round(sw * k); cv.height = Math.round(sh * k)
cv.getContext('2d').drawImage(src, sx, sy, sw, sh, 0, 0, cv.width, cv.height)
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
const qs = new URLSearchParams({ mangaId, chapterId, page: t.page, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' })
const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
endCrop()
toast(data.status === 'off' ? 'Recorte guardado' : 'Recorte guardado: se está mejorando a HD')
} catch (err) {
toast(err.message)
btn.disabled = false
}
}
$('#clipbtn').addEventListener('click', startCrop)
const onKey = e => {
if (crop) { if (e.key === 'Escape') endCrop(); return }
if (!isPaged() || e.target.matches('input,select')) return
const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
@@ -1080,6 +1195,7 @@ async function viewReader(mangaId, chapterId, startPage) {
render()
cleanup = () => {
endCrop()
if (saveTimer) saveNow()
observer?.disconnect()
clearTimeout(uiTimer)
@@ -1091,6 +1207,124 @@ async function viewReader(mangaId, chapterId, startPage) {
}
}
// ---------- Recortes ----------
const clipStatus = c => c.status === 'done' ? `<span class="hd" title="Reescalado ×${c.scale}">HD</span>`
: c.status === 'error' ? '<span class="hd pending" title="Falló el reescalado">HD!</span>'
: c.status === 'off' ? '' : '<span class="hd pending" title="Reescalando…">HD…</span>'
async function viewClips(params) {
const mangaId = params.get('manga')
const here = () => location.hash.split('?')[0] === '#/clips'
app.innerHTML = '<div class="loading">Cargando…</div>'
let data = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`)
if (!here()) return
const total = data.mangas.reduce((a, m) => a + m.n, 0)
app.innerHTML = `<h1>${icon('scissors')} Recortes</h1>
${data.mangas.length > 1 || mangaId ? `<div class="tabs">
<button data-m="" class="${mangaId ? '' : 'on'}">Todos (${total})</button>
${data.mangas.map(m => `<button data-m="${esc(m.id)}" class="${m.id === mangaId ? 'on' : ''}">${esc(m.title)} (${m.n})</button>`).join('')}</div>` : ''}
<div id="cgrid" class="clips"></div>
<div id="lb" class="lightbox" hidden></div>`
app.querySelectorAll('.tabs [data-m]').forEach(b => b.addEventListener('click', () => { location.hash = b.dataset.m ? `#/clips?manga=${encodeURIComponent(b.dataset.m)}` : '#/clips' }))
const grid = $('#cgrid'), lb = $('#lb')
let open = -1, showHd = true
function renderGrid() {
grid.innerHTML = data.clips.length ? data.clips.map((c, i) => `
<figure class="clip" data-i="${i}">
<img src="${c.hd || c.img}" alt="" loading="lazy" style="aspect-ratio:${c.width}/${c.height}">
<figcaption><b>${esc(c.mangaTitle)}</b><span>${esc((c.chapterLabel || '').split(' — ')[0])} · p. ${c.page + 1} ${clipStatus(c)}</span>
${c.note ? `<i>${esc(c.note)}</i>` : ''}</figcaption>
</figure>`).join('')
: `<p class="muted">Aún no tienes recortes. En el lector, toca ${icon('scissors')} y arrastra sobre un panel para guardarlo aquí; se mejora a HD automáticamente.</p>`
grid.querySelectorAll('.clip').forEach(f => f.addEventListener('click', () => openClip(Number(f.dataset.i))))
}
function openClip(i) {
open = i
const c = data.clips[i]
if (!c) return closeClip()
const src = c.hd && showHd ? c.hd : c.img
const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}`
lb.innerHTML = `
<div class="lb-img"><img src="${src}" alt=""></div>
<div class="lb-side">
<div class="row" style="justify-content:space-between">
<span class="muted">${i + 1} / ${data.clips.length}</span>
<span class="row" style="gap:6px">
<button class="btn small" data-a="prev" ${i ? '' : 'disabled'} title="Anterior">${icon('prev')}</button>
<button class="btn small" data-a="next" ${i < data.clips.length - 1 ? '' : 'disabled'} title="Siguiente">${icon('next')}</button>
<button class="btn small" data-a="close" title="Cerrar">${icon('x')}</button>
</span>
</div>
<h2 style="margin:4px 0 0"><a href="#/manga/${c.mangaId}">${esc(c.mangaTitle)}</a></h2>
<div class="muted">${esc(c.chapterLabel || '')} · página ${c.page + 1}</div>
<div class="muted" style="font-size:.85rem">${size} · ${timeAgo(c.createdAt)} ${clipStatus(c)}</div>
${c.status === 'error' ? `<p class="error" style="font-size:.85rem">${esc(c.error || 'Error al reescalar')}</p>` : ''}
${c.status === 'off' ? '<p class="muted" style="font-size:.85rem">Reescalado no disponible en este servidor.</p>' : ''}
<input id="note" placeholder="Nota (opcional)" maxlength="1000" value="${esc(c.note || '')}">
<a class="btn primary" href="${readUrl(c.mangaId, c.chapterId, c.page)}">${icon('book')} Ir a la página</a>
${c.hd ? `<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="showhd" ${showHd ? 'checked' : ''}> Ver versión HD</label>` : ''}
<div class="row" style="gap:6px;flex-wrap:wrap">
<a class="btn small" href="${(c.hd && showHd ? c.hd + '&' : c.img + '?')}dl=1" download>${icon('download')} Descargar</a>
${c.status === 'error' || (c.status === 'off' && data.available) ? `<button class="btn small" data-a="retry">${icon('refresh')} Reintentar HD</button>` : ''}
<button class="btn small danger" data-a="del">${icon('trash')} Eliminar</button>
</div>
</div>`
lb.hidden = false
document.body.classList.add('noscroll')
lb.querySelector('[data-a=prev]').addEventListener('click', () => openClip(open - 1))
lb.querySelector('[data-a=next]').addEventListener('click', () => openClip(open + 1))
lb.querySelector('[data-a=close]').addEventListener('click', closeClip)
$('#showhd', lb)?.addEventListener('change', e => { showHd = e.target.checked; openClip(open) })
$('#note', lb).addEventListener('change', async e => {
try { Object.assign(c, await api(`/api/clips/${c.id}`, { method: 'PATCH', body: { note: e.target.value } })); renderGrid(); toast('Nota guardada') } catch (err) { toast(err.message) }
})
lb.querySelector('[data-a=retry]')?.addEventListener('click', async () => {
try { Object.assign(c, await api(`/api/clips/${c.id}/retry`, { method: 'POST' })); renderGrid(); openClip(open); poll() } catch (err) { toast(err.message) }
})
lb.querySelector('[data-a=del]').addEventListener('click', async () => {
if (!confirm('¿Eliminar este recorte?')) return
try {
await api(`/api/clips/${c.id}`, { method: 'DELETE' })
data.clips.splice(open, 1)
renderGrid()
data.clips.length ? openClip(Math.min(open, data.clips.length - 1)) : closeClip()
} catch (err) { toast(err.message) }
})
}
function closeClip() { open = -1; lb.hidden = true; lb.innerHTML = ''; document.body.classList.remove('noscroll') }
lb.addEventListener('click', e => { if (e.target === lb || e.target.classList.contains('lb-img')) closeClip() })
const onKey = e => {
if (open < 0 || e.target.matches('input')) return
if (e.key === 'Escape') closeClip()
else if (e.key === 'ArrowLeft' && open > 0) openClip(open - 1)
else if (e.key === 'ArrowRight' && open < data.clips.length - 1) openClip(open + 1)
}
document.addEventListener('keydown', onKey)
// Mientras haya recortes en cola, refrescar su estado.
let timer = null
function poll() {
clearTimeout(timer)
if (!data.clips.some(c => c.status === 'queued' || c.status === 'running')) return
timer = setTimeout(async () => {
if (!here()) return
const fresh = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`).catch(() => null)
if (!fresh || !here()) return
const key = d => d.clips.map(c => `${c.id}:${c.status}`).join()
if (key(fresh) !== key(data)) {
const openId = data.clips[open]?.id
data = fresh
renderGrid()
if (openId != null) { const j = data.clips.findIndex(c => c.id === openId); j >= 0 ? openClip(j) : closeClip() }
}
poll()
}, 3000)
}
renderGrid()
poll()
cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') }
}
// ---------- Historial ----------
async function viewHistory() {
app.innerHTML = '<div class="loading">Cargando…</div>'
+2 -1
View File
@@ -10,11 +10,12 @@
</head>
<body>
<header id="topbar">
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> Manga Reader</a>
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> <span>Manga Reader</span></a>
<nav>
<a href="#/" data-icon="home">Inicio</a>
<a href="#/history" data-icon="history">Historial</a>
<a href="#/downloads" data-icon="download">Descargas</a>
<a href="#/clips" data-icon="scissors">Recortes</a>
<a href="#/stats" data-icon="chart">Estadísticas</a>
<a href="#/settings" data-icon="settings">Ajustes</a>
</nav>
+32
View File
@@ -271,3 +271,35 @@ input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; m
#selbar { left: 12px; right: 12px; transform: none; max-width: none; }
#selbar span { flex: 1; white-space: normal; font-size: .82rem; line-height: 1.3; }
}
/* Recortes: herramienta del lector */
.cropov { position: fixed; inset: 0; z-index: 40; cursor: crosshair; touch-action: none; background: rgba(0, 0, 0, .25); user-select: none; }
.crop-bar { position: fixed; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 1;
display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px; background: rgba(20, 18, 26, .94);
box-shadow: 0 6px 24px rgba(0,0,0,.5); cursor: default; white-space: nowrap; max-width: calc(100vw - 24px); }
.crop-bar span { font-size: .85rem; color: var(--muted); display: inline-flex; gap: 6px; align-items: center; overflow: hidden; text-overflow: ellipsis; }
.crop-rect { position: fixed; border: 2px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, .45); pointer-events: none; }
@media (max-width: 560px) { .crop-bar span { display: none; } }
/* Recortes: galería */
.clips { columns: 220px; column-gap: 12px; }
.clip { break-inside: avoid; margin: 0 0 12px; background: var(--surface); border-radius: var(--radius); overflow: hidden; cursor: pointer; }
.clip img { display: block; width: 100%; height: auto; background: var(--surface-2); }
.clip figcaption { padding: 6px 10px 8px; display: grid; font-size: .8rem; }
.clip figcaption b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; font-size: .85rem; }
.clip figcaption span { color: var(--muted); }
.clip figcaption i { color: var(--text); opacity: .85; }
.clip:hover { outline: 1px solid var(--accent); }
@media (max-width: 560px) { .clips { columns: 2; column-gap: 8px; } .clip { margin-bottom: 8px; } }
.lightbox { position: fixed; inset: 0; z-index: 45; background: rgba(8, 7, 12, .94); display: grid; grid-template-columns: 1fr 320px; }
.lightbox[hidden] { display: none; }
.lb-img { display: flex; align-items: center; justify-content: center; padding: 16px; min-height: 0; overflow: auto; }
.lb-img img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; }
.lb-side { background: var(--surface); padding: 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; }
.lb-side h2 a { color: var(--text); }
body.noscroll { overflow: hidden; }
@media (max-width: 760px) {
.lightbox { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; }
.lb-img { padding: 10px; }
.lb-side { max-height: 48vh; padding: 12px 14px max(12px, env(safe-area-inset-bottom)); }
}