Animación de hoja de papel al pasar página y URLs versionadas para saltar la caché del proxy
- Lector por páginas: animaciones Pasar hoja (la hoja se curva al levantarla, con sombra), deslizar y fundido; hacia atrás la página anterior vuelve y se coloca encima. - app.js, style.css y sw.js se sirven con ?v=<hash> para que ni el proxy ni el navegador den una copia vieja. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+194
-11
@@ -204,7 +204,8 @@ const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${p
|
|||||||
// Requiere service worker, que el navegador solo permite con HTTPS (o en localhost).
|
// Requiere service worker, que el navegador solo permite con HTTPS (o en localhost).
|
||||||
const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window
|
const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window
|
||||||
const OFF_CACHE = 'offline-v1'
|
const OFF_CACHE = 'offline-v1'
|
||||||
if (OFFLINE_OK) navigator.serviceWorker.register('/sw.js').catch(() => {})
|
// Con versión en la URL: si un proxy guarda sw.js en caché, cada versión nueva tiene otra URL.
|
||||||
|
if (OFFLINE_OK) navigator.serviceWorker.register(`/sw.js?v=${document.querySelector('meta[name="sw-version"]')?.content || ''}`).catch(() => {})
|
||||||
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
|
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
|
||||||
const offHas = id => !!offIdx()[id]
|
const offHas = id => !!offIdx()[id]
|
||||||
const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null }
|
const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null }
|
||||||
@@ -1061,6 +1062,7 @@ async function viewManga(id, langOverride) {
|
|||||||
|
|
||||||
// ---------- Lector ----------
|
// ---------- Lector ----------
|
||||||
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
|
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
|
||||||
|
const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' }
|
||||||
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
|
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
|
||||||
function defaultPrefs(manga) {
|
function defaultPrefs(manga) {
|
||||||
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
|
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
|
||||||
@@ -1074,7 +1076,8 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
|
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
|
||||||
const lang = store.get(`lang:${mangaId}`, me.lang)
|
const lang = store.get(`lang:${mangaId}`, me.lang)
|
||||||
// Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor).
|
// Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor).
|
||||||
const globalSettings = { saver: false, filter: 'none', brightness: 100, ...store.get('readerSettings', {}) }
|
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', ...store.get('readerSettings', {}) }
|
||||||
const [info, chap] = await Promise.all([
|
const [info, chap] = await Promise.all([
|
||||||
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
|
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
|
||||||
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`),
|
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`),
|
||||||
@@ -1123,6 +1126,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
<select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
<select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||||
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||||||
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||||
|
<select id="anim" title="Animación al pasar página (modos por página)">${Object.entries(PAGE_ANIMS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||||
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
|
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
|
||||||
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||||
@@ -1200,11 +1204,16 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
im.src = pages[i]
|
im.src = pages[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function showPaged() {
|
// dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…).
|
||||||
let stage = $('.stage', box)
|
function showPaged(dir = 0) {
|
||||||
if (!stage) { box.innerHTML = '<div class="stage"></div>'; stage = $('.stage', box) }
|
finishTurn()
|
||||||
|
const old = $('.stage', box)
|
||||||
|
const stage = document.createElement('div')
|
||||||
|
stage.className = 'stage'
|
||||||
stage.classList.toggle('two', shown.length === 2)
|
stage.classList.toggle('two', shown.length === 2)
|
||||||
stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('')
|
stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('')
|
||||||
|
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir)
|
||||||
|
else { box.replaceChildren(stage) }
|
||||||
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
|
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
|
||||||
const i = Number(img.dataset.i)
|
const i = Number(img.dataset.i)
|
||||||
if (isWide(img) && !wide.has(i)) {
|
if (isWide(img) && !wide.has(i)) {
|
||||||
@@ -1216,24 +1225,191 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
preload()
|
preload()
|
||||||
}
|
}
|
||||||
|
// --- Animación al pasar página. La página nueva se pone debajo y la vieja (o una hoja) se anima encima;
|
||||||
|
// al terminar solo queda la nueva. Si se pasa otra página antes de acabar, la animación se completa ya.
|
||||||
|
let turning = null // { anims, cleanup }
|
||||||
|
function finishTurn() {
|
||||||
|
if (!turning) return
|
||||||
|
const t = turning
|
||||||
|
turning = null
|
||||||
|
t.anims.forEach(a => { try { a.cancel() } catch {} })
|
||||||
|
t.cleanup()
|
||||||
|
}
|
||||||
|
// Animación hecha a mano (requestAnimationFrame) con la misma forma que las de la Web Animations API
|
||||||
|
// que usa finishTurn: cancel() y la promesa finished.
|
||||||
|
function frameAnim(duration, step) {
|
||||||
|
let raf = 0, settle
|
||||||
|
const finished = new Promise((res, rej) => { settle = { res, rej } })
|
||||||
|
const t0 = performance.now()
|
||||||
|
const tick = now => {
|
||||||
|
const g = Math.min(1, (now - t0) / duration)
|
||||||
|
step(g)
|
||||||
|
if (g < 1) raf = requestAnimationFrame(tick)
|
||||||
|
else settle.res()
|
||||||
|
}
|
||||||
|
step(0)
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
return { finished, cancel() { cancelAnimationFrame(raf); settle.rej(new Error('cancelada')) } }
|
||||||
|
}
|
||||||
|
// Hoja de papel que se dobla: tiras verticales anidadas, cada una girada un poco respecto a la anterior,
|
||||||
|
// así que la hoja forma una curva. Cada tira enseña su trozo de la página por delante y del reverso
|
||||||
|
// (la página siguiente en doble página, o papel en blanco) por detrás.
|
||||||
|
function paperCurl(r, frontSrc, backSrc, spineLeft) {
|
||||||
|
const N = Math.max(10, Math.min(22, Math.round(r.w / 24)))
|
||||||
|
const w = r.w / N
|
||||||
|
const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'curl'
|
||||||
|
const strips = []
|
||||||
|
let parent = el
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const fc = spineLeft ? i : N - 1 - i // columna de la página (desde la izquierda) de esta tira
|
||||||
|
const bc = N - 1 - fc // en el reverso, la hoja está dada la vuelta
|
||||||
|
const s = document.createElement('div')
|
||||||
|
s.className = 'strip'
|
||||||
|
Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' })
|
||||||
|
const img = (src, col) => `<img src="${src}" alt="" style="width:${r.w}px;height:${r.h}px;left:${-col * w}px">`
|
||||||
|
s.innerHTML = `<div class="sf front">${img(frontSrc, fc)}<i></i></div><div class="sf back${backSrc ? '' : ' paper'}">${backSrc ? img(backSrc, bc) : ''}<i></i></div>`
|
||||||
|
parent.append(s)
|
||||||
|
strips.push({ s, f: s.firstChild.lastChild, b: s.children[1].lastChild })
|
||||||
|
parent = s
|
||||||
|
}
|
||||||
|
const step = spineLeft ? w : -w
|
||||||
|
// Ángulo (0 = en su sitio, 180 = pasada) de cada tira en el avance g (0 a 1). El borde libre va por
|
||||||
|
// delante del lomo y la curva es máxima a mitad de camino. reverse: la hoja vuelve a su sitio (el
|
||||||
|
// mismo gesto al revés, con el borde libre llegando primero).
|
||||||
|
function angles(g, reverse) {
|
||||||
|
const e = g < 0.5 ? 4 * g ** 3 : 1 - (-2 * g + 2) ** 3 / 2
|
||||||
|
const bend = Math.sin(Math.PI * g) * 125
|
||||||
|
return strips.map((t, i) => {
|
||||||
|
const a = Math.min(180, Math.max(0, 180 * e + bend * (((i + 0.5) / N) ** 1.4 - 0.45)))
|
||||||
|
return reverse ? 180 - a : a
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo.
|
||||||
|
function set(g, reverse) {
|
||||||
|
let prev = 0, lift = 0
|
||||||
|
angles(g, reverse).forEach((a, i) => {
|
||||||
|
const t = strips[i]
|
||||||
|
t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)`
|
||||||
|
prev = a
|
||||||
|
const c = Math.cos((a * Math.PI) / 180)
|
||||||
|
t.f.style.opacity = ((1 - c) * 0.3).toFixed(3) // se oscurece al inclinarse
|
||||||
|
t.b.style.opacity = ((1 + c) * 0.3).toFixed(3)
|
||||||
|
lift = Math.max(lift, Math.sin((a * Math.PI) / 180))
|
||||||
|
})
|
||||||
|
return lift
|
||||||
|
}
|
||||||
|
// Tramo de g en el que se ve algo de la hoja sobre la página (alguna tira por debajo de 90°): con una
|
||||||
|
// sola página, lo que pasa del lomo queda fuera de la pantalla y no vale la pena esperarlo.
|
||||||
|
function span(reverse) {
|
||||||
|
const seen = g => angles(g, reverse).some(a => a < 88)
|
||||||
|
let g0 = 0, g1 = 1
|
||||||
|
while (g0 < 1 && !seen(g0)) g0 += 0.01
|
||||||
|
while (g1 > g0 && !seen(g1)) g1 -= 0.01
|
||||||
|
return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)]
|
||||||
|
}
|
||||||
|
return { el, set, span }
|
||||||
|
}
|
||||||
|
function turnPage(old, stage, dir) {
|
||||||
|
// "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq.
|
||||||
|
const sign = (settings.dir === 'rtl' ? -1 : 1) * dir
|
||||||
|
const fitWidth = box.classList.contains('fit-width')
|
||||||
|
let kind = settings.anim
|
||||||
|
const ready = im => im.complete && im.naturalWidth > 0
|
||||||
|
const oldImgs = [...old.querySelectorAll('img')]
|
||||||
|
// La hoja necesita las páginas ya cargadas y la misma disposición (1↔1 o 2↔2); si no, deslizar.
|
||||||
|
if (kind === 'flip' && (fitWidth || !oldImgs.every(ready))) kind = 'slide'
|
||||||
|
if (fitWidth && kind === 'slide') kind = 'fade' // páginas largas con scroll: un fundido queda mejor
|
||||||
|
old.classList.add('leaving')
|
||||||
|
box.prepend(stage) // la nueva debajo (antes en el DOM), la vieja encima
|
||||||
|
const double = oldImgs.length === 2 && shown.length === 2
|
||||||
|
// Una página hacia atrás: la hoja que vuelve es la nueva, que tiene que estar ya cargada.
|
||||||
|
if (kind === 'flip' && !double && dir < 0 && !ready(stage.querySelector('img'))) kind = 'slide'
|
||||||
|
const anims = []
|
||||||
|
const extra = []
|
||||||
|
const cleanup = () => { old.remove(); extra.forEach(e => e.remove()) }
|
||||||
|
const ease = 'cubic-bezier(.2,.7,.2,1)'
|
||||||
|
if (kind === 'fade') {
|
||||||
|
anims.push(old.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, easing: 'ease-out', fill: 'forwards' }))
|
||||||
|
} else if (kind === 'slide') {
|
||||||
|
anims.push(old.animate([{ transform: 'translateX(0)' }, { transform: `translateX(${-sign * 100}%)` }], { duration: 260, easing: ease, fill: 'forwards' }))
|
||||||
|
anims.push(stage.animate([{ transform: `translateX(${sign * 100}%)` }, { transform: 'translateX(0)' }], { duration: 260, easing: ease }))
|
||||||
|
} else {
|
||||||
|
// Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue).
|
||||||
|
// Medidas relativas al contenedor.
|
||||||
|
const B = box.getBoundingClientRect()
|
||||||
|
const rel = im => { const r = im.getBoundingClientRect(); return { l: r.left - B.left, t: r.top - B.top, w: r.width, h: r.height } }
|
||||||
|
const byX = imgs => [...imgs].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
|
||||||
|
const place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` })
|
||||||
|
let rect, frontSrc, backSrc = null, spineLeft, reverse = false
|
||||||
|
if (double) {
|
||||||
|
// Doble página: gira la hoja del lado hacia el que se avanza (derecha si sign > 0); su reverso es
|
||||||
|
// la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia
|
||||||
|
// encima de la nueva) hasta que la hoja cae.
|
||||||
|
const oldX = byX(oldImgs)
|
||||||
|
const newX = byX(stage.querySelectorAll('img'))
|
||||||
|
const front = sign > 0 ? oldX.at(-1) : oldX[0]
|
||||||
|
const stay = sign > 0 ? oldX[0] : oldX.at(-1)
|
||||||
|
rect = rel(front)
|
||||||
|
frontSrc = front.src
|
||||||
|
backSrc = (sign > 0 ? newX[0] : newX.at(-1)).src
|
||||||
|
spineLeft = sign > 0
|
||||||
|
const keep = document.createElement('img')
|
||||||
|
keep.className = 'leafkeep'
|
||||||
|
keep.src = stay.src
|
||||||
|
place(keep, rel(stay))
|
||||||
|
box.append(keep)
|
||||||
|
extra.push(keep)
|
||||||
|
old.style.visibility = 'hidden' // la hoja y la copia ocupan su lugar
|
||||||
|
} else {
|
||||||
|
// Una página: el lomo está a la izquierda en izq→der y a la derecha en der→izq. Hacia delante, la
|
||||||
|
// página actual se levanta y se va; hacia atrás, la anterior vuelve y se coloca encima (el mismo
|
||||||
|
// gesto al revés, con el borde libre por delante), con la actual debajo hasta que la tapa.
|
||||||
|
const img = dir > 0 ? oldImgs[0] : stage.querySelector('img')
|
||||||
|
rect = rel(img)
|
||||||
|
frontSrc = img.src
|
||||||
|
spineLeft = settings.dir !== 'rtl'
|
||||||
|
reverse = dir < 0
|
||||||
|
if (dir > 0) old.style.visibility = 'hidden'
|
||||||
|
}
|
||||||
|
// Sombra que la hoja levantada proyecta sobre lo que hay debajo.
|
||||||
|
const drop = document.createElement('i')
|
||||||
|
drop.className = `curldrop ${spineLeft ? 'l' : 'r'}`
|
||||||
|
place(drop, rect)
|
||||||
|
const curl = paperCurl(rect, frontSrc, backSrc, spineLeft)
|
||||||
|
place(curl.el, rect)
|
||||||
|
box.append(drop, curl.el)
|
||||||
|
extra.push(drop, curl.el)
|
||||||
|
const [g0, g1] = double ? [0, 1] : curl.span(reverse)
|
||||||
|
anims.push(frameAnim(double ? 700 : 560, t => {
|
||||||
|
const lift = curl.set(g0 + (g1 - g0) * t, reverse)
|
||||||
|
drop.style.opacity = (lift * 0.8).toFixed(3)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
turning = { anims, cleanup }
|
||||||
|
Promise.all(anims.map(a => a.finished)).then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
|
||||||
|
}
|
||||||
function showEnd() {
|
function showEnd() {
|
||||||
|
finishTurn()
|
||||||
atEnd = true
|
atEnd = true
|
||||||
resetZoom()
|
resetZoom()
|
||||||
box.innerHTML = endBlock
|
box.innerHTML = endBlock
|
||||||
reader.classList.remove('hide-ui')
|
reader.classList.remove('hide-ui')
|
||||||
}
|
}
|
||||||
|
|
||||||
function goTo(n, fromScroll = false, force = false) {
|
function goTo(n, fromScroll = false, force = false, dir = 0) {
|
||||||
if (isPaged()) {
|
if (isPaged()) {
|
||||||
if (n >= total) return showEnd()
|
if (n >= total) return showEnd()
|
||||||
n = Math.min(Math.max(n, 0), total - 1)
|
n = Math.min(Math.max(n, 0), total - 1)
|
||||||
const g = groupOf(n)
|
const g = groupOf(n)
|
||||||
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
|
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
|
||||||
|
const wasEnd = atEnd
|
||||||
atEnd = false
|
atEnd = false
|
||||||
shown = g
|
shown = g
|
||||||
page = g[0]
|
page = g[0]
|
||||||
setPageLabel()
|
setPageLabel()
|
||||||
if (changed || force || !$('.stage img', box)) showPaged()
|
if (changed || force || !$('.stage img', box)) showPaged(changed && !wasEnd ? dir : 0)
|
||||||
if (changed) scheduleSave()
|
if (changed) scheduleSave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1245,10 +1421,10 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
if (!fromScroll) box.querySelectorAll('img')[page]?.scrollIntoView()
|
if (!fromScroll) box.querySelectorAll('img')[page]?.scrollIntoView()
|
||||||
if (changed) scheduleSave()
|
if (changed) scheduleSave()
|
||||||
}
|
}
|
||||||
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1) }
|
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) }
|
||||||
const backward = () => {
|
const backward = () => {
|
||||||
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
|
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
|
||||||
if (page > 0) goTo(groupOf(page - 1)[0])
|
if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -1296,7 +1472,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
|
|
||||||
// --- Zoom (modos por página): pellizco, doble toque/clic en el centro, Ctrl+rueda y teclas + - 0.
|
// --- Zoom (modos por página): pellizco, doble toque/clic en el centro, Ctrl+rueda y teclas + - 0.
|
||||||
const zoom = { s: 1, x: 0, y: 0 }
|
const zoom = { s: 1, x: 0, y: 0 }
|
||||||
const stageEl = () => $('.stage', box)
|
const stageEl = () => $('.stage:not(.leaving)', box)
|
||||||
function applyZoom() {
|
function applyZoom() {
|
||||||
const st = stageEl()
|
const st = stageEl()
|
||||||
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
|
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
|
||||||
@@ -1384,6 +1560,11 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
gesture = { type: 'pan', x: p.x, y: p.y }
|
gesture = { type: 'pan', x: p.x, y: p.y }
|
||||||
} else if (pts.size === 0) {
|
} else if (pts.size === 0) {
|
||||||
if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
|
if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
|
||||||
|
else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') {
|
||||||
|
// Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der).
|
||||||
|
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
|
||||||
|
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
|
||||||
|
}
|
||||||
gesture = null
|
gesture = null
|
||||||
tap = null
|
tap = null
|
||||||
}
|
}
|
||||||
@@ -1403,7 +1584,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
$('#zoomreset').addEventListener('click', resetZoom)
|
$('#zoomreset').addEventListener('click', resetZoom)
|
||||||
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) })
|
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) })
|
||||||
|
|
||||||
const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false })
|
const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim })
|
||||||
const savePrefs = () => {
|
const savePrefs = () => {
|
||||||
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
|
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
|
||||||
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message))
|
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message))
|
||||||
@@ -1417,6 +1598,8 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
|
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
|
||||||
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; box.dataset.dir = settings.dir; slider.dir = settings.dir; savePrefs() })
|
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; box.dataset.dir = settings.dir; slider.dir = settings.dir; savePrefs() })
|
||||||
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
|
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
|
||||||
|
$('#anim').value = settings.anim
|
||||||
|
$('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() })
|
||||||
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
|
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
|
||||||
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
|
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
|
||||||
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
|
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<html lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="sw-version" content="">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#14121a">
|
<meta name="theme-color" content="#14121a">
|
||||||
<title>Manga Reader</title>
|
<title>Manga Reader</title>
|
||||||
|
|||||||
@@ -352,3 +352,19 @@ body.noscroll { overflow: hidden; }
|
|||||||
background: #3a2a12; color: #ffd9a0; border-bottom: 1px solid #5a4220; font-size: .9rem; }
|
background: #3a2a12; color: #ffd9a0; border-bottom: 1px solid #5a4220; font-size: .9rem; }
|
||||||
#netbanner svg { flex: none; width: 18px; height: 18px; }
|
#netbanner svg { flex: none; width: 18px; height: 18px; }
|
||||||
body.reading #netbanner { display: none; }
|
body.reading #netbanner { display: none; }
|
||||||
|
|
||||||
|
/* Animación al pasar página (modos por página) */
|
||||||
|
.paged { position: relative; perspective: 2200px; }
|
||||||
|
.paged .stage.leaving { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: #000; }
|
||||||
|
.paged.fit-width .stage.leaving { bottom: auto; }
|
||||||
|
.paged .curl { position: absolute; z-index: 3; transform-style: preserve-3d; pointer-events: none; }
|
||||||
|
.paged .curl .strip { position: absolute; top: 0; transform-style: preserve-3d; will-change: transform; }
|
||||||
|
.paged .curl .sf { position: absolute; top: 0; bottom: 0; left: -0.5px; right: -0.5px; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
|
||||||
|
.paged .curl .sf img { position: absolute; top: 0; max-width: none; max-height: none; object-fit: fill; display: block; }
|
||||||
|
.paged .curl .back { transform: rotateY(180deg); }
|
||||||
|
.paged .curl .back.paper { background: #e9e5dc; }
|
||||||
|
.paged .curl .sf i { position: absolute; inset: 0; background: #000; opacity: 0; }
|
||||||
|
.paged .curldrop { position: absolute; z-index: 2; pointer-events: none; opacity: 0; }
|
||||||
|
.paged .curldrop.l { background: linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
|
||||||
|
.paged .curldrop.r { background: linear-gradient(270deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
|
||||||
|
.paged .leafkeep { position: absolute; z-index: 2; pointer-events: none; max-width: none; max-height: none; object-fit: fill; }
|
||||||
|
|||||||
+17
-3
@@ -4,8 +4,20 @@ const SHELL = 'shell-v1'
|
|||||||
const OFFLINE = 'offline-v1'
|
const OFFLINE = 'offline-v1'
|
||||||
const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
|
const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
|
||||||
|
|
||||||
|
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
|
||||||
|
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
|
||||||
|
async function installShell() {
|
||||||
|
const c = await caches.open(SHELL)
|
||||||
|
const html = await fetch('/', { cache: 'reload' })
|
||||||
|
const text = await html.clone().text()
|
||||||
|
const linked = [...text.matchAll(/(?:src|href)="(\/(?:app\.js|style\.css)\?v=[^"]+)"/g)].map(m => m[1])
|
||||||
|
const files = [...linked, ...SHELL_FILES.filter(f => f !== '/' && !linked.some(l => l.startsWith(f + '?')))]
|
||||||
|
for (const f of files) await c.delete(f, { ignoreSearch: true })
|
||||||
|
await c.addAll(files.map(u => new Request(u, { cache: 'reload' })))
|
||||||
|
await c.put('/', html)
|
||||||
|
}
|
||||||
self.addEventListener('install', e => {
|
self.addEventListener('install', e => {
|
||||||
e.waitUntil(caches.open(SHELL).then(c => c.addAll(SHELL_FILES)).then(() => self.skipWaiting()))
|
e.waitUntil(installShell().then(() => self.skipWaiting()))
|
||||||
})
|
})
|
||||||
self.addEventListener('activate', e => {
|
self.addEventListener('activate', e => {
|
||||||
e.waitUntil(caches.keys()
|
e.waitUntil(caches.keys()
|
||||||
@@ -25,11 +37,13 @@ async function fromCache(res) {
|
|||||||
headers.set('x-from-cache', '1')
|
headers.set('x-from-cache', '1')
|
||||||
return new Response(await res.blob(), { status: res.status, statusText: res.statusText, headers })
|
return new Response(await res.blob(), { status: res.status, statusText: res.statusText, headers })
|
||||||
}
|
}
|
||||||
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback } = {}) {
|
// single: guardar solo la última versión (app.js?v=… sustituye a la anterior).
|
||||||
|
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback, single = false } = {}) {
|
||||||
const cached = () => caches.match(req, { ignoreSearch, cacheName }).then(hit => hit || (fallback && caches.match(fallback)))
|
const cached = () => caches.match(req, { ignoreSearch, cacheName }).then(hit => hit || (fallback && caches.match(fallback)))
|
||||||
const net = fetch(req).then(async res => {
|
const net = fetch(req).then(async res => {
|
||||||
if (store && res.ok) {
|
if (store && res.ok) {
|
||||||
const c = await caches.open(cacheName)
|
const c = await caches.open(cacheName)
|
||||||
|
if (single) await c.delete(req, { ignoreSearch: true })
|
||||||
if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone())
|
if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone())
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
@@ -54,7 +68,7 @@ self.addEventListener('fetch', e => {
|
|||||||
if (url.origin !== location.origin) return
|
if (url.origin !== location.origin) return
|
||||||
const p = url.pathname
|
const p = url.pathname
|
||||||
if (req.mode === 'navigate') return e.respondWith(networkFirst(req, SHELL, { fallback: '/' }))
|
if (req.mode === 'navigate') return e.respondWith(networkFirst(req, SHELL, { fallback: '/' }))
|
||||||
if (SHELL_FILES.includes(p) || p === '/sw.js') return e.respondWith(networkFirst(req, SHELL))
|
if (SHELL_FILES.includes(p) || p === '/sw.js') return e.respondWith(networkFirst(req, SHELL, { ignoreSearch: true, single: true }))
|
||||||
if (p === '/api/me') return e.respondWith(networkFirst(req, SHELL))
|
if (p === '/api/me') return e.respondWith(networkFirst(req, SHELL))
|
||||||
// Ficha y capítulo: solo se guardan si el usuario los guardó (store: 'existing' = refrescar si ya estaban).
|
// Ficha y capítulo: solo se guardan si el usuario los guardó (store: 'existing' = refrescar si ya estaban).
|
||||||
if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true }))
|
if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true }))
|
||||||
|
|||||||
@@ -765,10 +765,19 @@ const requireUser = req => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' }
|
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' }
|
||||||
|
// index.html enlaza app.js y style.css con ?v=<hash del contenido>: si un proxy delante cambia el
|
||||||
|
// no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra
|
||||||
|
// URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive.
|
||||||
|
const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, name))).digest('hex').slice(0, 10)
|
||||||
|
const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
|
||||||
|
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
|
||||||
|
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
|
||||||
|
.replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`)
|
||||||
function serveStatic(res, pathname) {
|
function serveStatic(res, pathname) {
|
||||||
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
|
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
|
||||||
if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html')
|
if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html')
|
||||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' })
|
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' })
|
||||||
|
if (file === path.join(PUBLIC_DIR, 'index.html')) return res.end(INDEX_HTML)
|
||||||
fs.createReadStream(file).pipe(res)
|
fs.createReadStream(file).pipe(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user