La vista de una página funciona como un libro abierto

Entre las dos caras de una pareja la vista se recorre, con las páginas unidas
por el lomo. Entre parejas gira la hoja, con la página nueva en el reverso, la
siguiente debajo y la vista acompañando al giro. La hendidura alterna de lado
según la cara, y las páginas anchas ya no la llevan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 17:05:18 -06:00
co-authored by Claude Opus 5.5
parent eabf82ad90
commit 5776389ebb
2 changed files with 131 additions and 28 deletions
+129 -24
View File
@@ -1453,8 +1453,10 @@ async function viewReader(mangaId, chapterId, startPage) {
// --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también.
const wide = new Set()
const isWide = img => img.naturalWidth > img.naturalHeight * 1.1
function groupOf(i) {
if (!isDouble()) return [i]
const groupOf = i => (isDouble() ? spreadOf(i) : [i])
// Las páginas que quedan frente a frente en el libro abierto: la portada sola, luego de dos en dos; una página
// ancha ocupa las dos caras.
function spreadOf(i) {
for (let s = 0; s < total;) {
const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
if (g.includes(i)) return g
@@ -1472,7 +1474,11 @@ async function viewReader(mangaId, chapterId, startPage) {
for (const i of want) {
if (preloaded.has(i)) continue
const im = new Image()
im.onload = () => isWide(im) && wide.add(i)
im.onload = () => {
if (!isWide(im) || wide.has(i)) return
wide.add(i)
refreshGutters() // una página ancha cambia las parejas de las páginas de delante
}
im.src = pages[i]
im.decode?.().catch(() => {})
preloaded.set(i, im)
@@ -1481,11 +1487,22 @@ async function viewReader(mangaId, chapterId, startPage) {
scheduleLeafTextures()
}
const imgReady = im => im.complete && im.naturalWidth > 0
// Lado de la hendidura del lomo: l o r (borde de la página) o m (página doble, en el centro).
// Lado de la hendidura del lomo: l o r (borde de la página); una página ancha no lleva (none).
// En la vista de una página, la cara de delante de cada pareja lo tiene al lado contrario que la hoja que gira.
const gutterOf = (i, n) => {
if (wide.has(i)) return 'm'
if (wide.has(i)) return 'none'
const rtl = settings.dir === 'rtl'
return shown.length === 2 ? (rtl === (n === 0) ? 'l' : 'r') : rtl ? 'r' : 'l'
if (shown.length === 2) return rtl === (n === 0) ? 'l' : 'r'
const spread = spreadOf(i)
return !isDouble() && spread.length === 2 && spread[0] === i ? (rtl ? 'l' : 'r') : rtl ? 'r' : 'l'
}
const refreshGutters = () => box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) })
// Vista de una página con «pasar hoja»: entre las dos caras de una pareja no se gira la hoja, se recorre con la vista.
function isPan(old) {
const imgs = old.querySelectorAll('img')
if (settings.anim !== 'flip' || isDouble() || box.classList.contains('fit-width') || imgs.length !== 1 || shown.length !== 1) return false
const from = Number(imgs[0].dataset.i)
return from !== shown[0] && spreadOf(from).includes(shown[0])
}
// Hacia una página que aún no ha llegado, la actual se queda (con un indicador tras un momento) hasta que
// llegue o pasen 4 s, en vez de pasar la hoja y dejar negro debajo. Si se pide otra página mientras,
@@ -1535,12 +1552,12 @@ async function viewReader(mangaId, chapterId, startPage) {
})
stage.classList.toggle('loading', pending.length > 0)
// Al arrastrar, el sonido sale al soltar (si la página llega a pasar).
if (old && dir && !wantDrag) turnSound(settings.sfx, turnPan(dir))
if (old && dir && !wantDrag && !isPan(old)) turnSound(settings.sfx, turnPan(dir))
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
else { box.replaceChildren(stage) }
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
const i = Number(img.dataset.i)
if (isWide(img)) img.dataset.gutter = 'm'
if (isWide(img)) img.dataset.gutter = 'none'
if (isWide(img) && !wide.has(i)) {
wide.add(i)
if (shown.length === 2 && shown.includes(i)) goTo(shown[0], false, true) // reagrupar
@@ -1550,6 +1567,15 @@ async function viewReader(mangaId, chapterId, startPage) {
window.scrollTo(0, 0)
preload()
}
// Giro entre dos parejas, en la vista de una página: la hoja lleva la página vieja por una cara y la nueva por la otra.
function isBookTurn(old, dir) {
const imgs = old.querySelectorAll('img')
if (settings.anim !== 'flip' || isDouble() || box.classList.contains('fit-width') || imgs.length !== 1 || shown.length !== 1) return false
const from = Number(imgs[0].dataset.i), to = shown[0]
if (Math.abs(from - to) !== 1) return false
const [opening, turned] = dir > 0 ? [to, from] : [from, to]
return spreadOf(opening)[0] === opening && spreadOf(opening).length === 2 && !wide.has(turned)
}
// --- 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 } (y move/release si sigue al dedo)
@@ -1622,6 +1648,7 @@ async function viewReader(mangaId, chapterId, startPage) {
// --- Hoja con WebGL: una malla con la página como textura, en un solo canvas. Hace lo mismo que paperCurl
// (que queda para cuando no hay WebGL2) sin una capa por cara, que es lo que le pesa a la GPU.
const LEAF_COLS = 32, LEAF_ROWS = 8
const BOOK_FOLLOW_FROM = 0.25 // parte del giro antes de que la vista empiece a acompañar a la hoja
const LEAF_SHADOW_STEP = 2 // columnas de la hoja por cada tramo de sombra
const SHADOW_DARK = 0.95, SHADOW_SOFT = 0.4, SHADOW_FALL = 3 // oscuridad junto al papel, ancho del borde y caída con la altura
let leafGL = null, leafGLOff = false, leafTimer = 0
@@ -1652,7 +1679,7 @@ async function viewReader(mangaId, chapterId, startPage) {
gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
uniform sampler2D uFront, uBack;
uniform bool uPaper, uSpineLeft, uSpread;
uniform bool uPaper, uSpineLeft, uWide;
in vec2 vUV;
in float vC;
out vec4 color;
@@ -1670,7 +1697,7 @@ async function viewReader(mangaId, chapterId, startPage) {
float fromSpine = uSpineLeft ? vUV.x : 1.0 - vUV.x;
if (gl_FrontFacing) {
t = texture(uFront, vUV, -0.5);
b = (1.0 - (1.0 - vC) * 0.3) * gutter(uSpread ? abs(vUV.x - 0.5) * 2.0 : fromSpine);
b = (1.0 - (1.0 - vC) * 0.3) * (uWide ? 1.0 : gutter(fromSpine));
} else {
t = uPaper ? vec4(0.914, 0.898, 0.863, 1.0) : texture(uBack, vec2(1.0 - vUV.x, vUV.y), -0.5);
b = (1.0 - (1.0 + vC) * 0.3) * gutter(fromSpine);
@@ -1759,7 +1786,7 @@ async function viewReader(mangaId, chapterId, startPage) {
canvas.addEventListener('webglcontextlost', () => { if (leafGL?.gl === gl) { leafGL = null; leafTextures.clear() } })
leafGL = { canvas, gl, prog, posBuf, uvBuf, aniso, shadowProg, shadowVao, shadowBuf, shadowCount: m, shadowData: new Float32Array(shadowSegs * 16 * 5),
uSize: gl.getUniformLocation(shadowProg, 'uSize'), positions: new Float32Array(count * 4), uvs: new Float32Array(count * 2), indexCount: n,
uView: gl.getUniformLocation(prog, 'uView'), uPaper: gl.getUniformLocation(prog, 'uPaper'), uSpineLeft: gl.getUniformLocation(prog, 'uSpineLeft'), uSpread: gl.getUniformLocation(prog, 'uSpread'), maxSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) }
uView: gl.getUniformLocation(prog, 'uView'), uPaper: gl.getUniformLocation(prog, 'uPaper'), uSpineLeft: gl.getUniformLocation(prog, 'uSpineLeft'), uWide: gl.getUniformLocation(prog, 'uWide'), maxSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) }
return leafGL
}
function leafTexture(im) {
@@ -1834,7 +1861,7 @@ async function viewReader(mangaId, chapterId, startPage) {
gl.scissor(Math.round(paper.l * scale), Math.round((H - paper.t - paper.h) * scale), Math.round(paper.w * scale), Math.round(paper.h * scale))
gl.uniform1i(L.uPaper, back ? 0 : 1)
gl.uniform1i(L.uSpineLeft, spineLeft ? 1 : 0)
gl.uniform1i(L.uSpread, frontIm.dataset.gutter === 'm' ? 1 : 0)
gl.uniform1i(L.uWide, frontIm.dataset.gutter === 'none' ? 1 : 0)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, front)
gl.activeTexture(gl.TEXTURE1)
@@ -1901,13 +1928,13 @@ async function viewReader(mangaId, chapterId, startPage) {
}
// Devuelve cuánto está levantada la hoja (0 a 1). Encadena los giros de tira en
// tira como lo hacen las capas anidadas de paperCurl.
function set(g, reverse, tiltZ = 0, tiltX = 0) {
function set(g, reverse, tiltZ = 0, tiltX = 0, cam = 0) {
const a = curve.angles(g, reverse)
let m = [1, 0, 0, 0, 1, 0, 0, 0, 1], x = 0, y = 0, z = 0, prev = 0, prevC = 1, lift = 0, n = 0
const column = c => {
for (let f = 0; f <= R; f++) {
const v = (r.h * f) / R - pivotY
positions[n++] = spineX + x + m[1] * v
positions[n++] = spineX + cam + x + m[1] * v
positions[n++] = r.t + pivotY + y + m[4] * v
positions[n++] = z + m[7] * v
positions[n++] = c
@@ -1997,7 +2024,8 @@ async function viewReader(mangaId, chapterId, startPage) {
// "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 pan = isPan(old)
let kind = pan ? 'pan' : 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.
@@ -2014,9 +2042,33 @@ async function viewReader(mangaId, chapterId, startPage) {
const extra = []
const cleanup = () => { old.remove(); extra.forEach(e => e.remove()); stage.style.transform = '' }
// Al soltar el dedo sin pasar la página: queda la vieja como estaba.
const revert = () => { stage.remove(); extra.forEach(e => e.remove()); old.classList.remove('leaving'); Object.assign(old.style, { visibility: '', opacity: '', transform: '' }) }
const revert = () => { stage.remove(); extra.forEach(e => e.remove()); old.classList.remove('leaving'); Object.assign(old.style, { visibility: '', opacity: '', transform: '', background: '' }) }
const easeOut = t => 1 - (1 - t) ** 3
// Tapa lo que hay a los lados de la página, para que las vecinas no aparezcan de golpe en una pantalla más ancha.
const addSideMasks = width => {
const side = (box.clientWidth - width) / 2
if (side <= 0.5) return
for (const edge of ['left', 'right']) {
const mask = document.createElement('i')
mask.className = 'panmask'
Object.assign(mask.style, { [edge]: '0', width: `${side}px` })
box.append(mask)
extra.push(mask)
}
}
// step(g) dibuja el avance g; toG(q) convierte lo recorrido por el dedo (0-1 de range px) en avance.
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 place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` })
const grabOf = r => {
const at = grabY != null ? (grabY - B.top - r.t) / r.h : 0.5
return at < 1 / 3 ? -1 : at > 2 / 3 ? 1 : 0
}
const newIm = stage.querySelector('img')
let bookCurl = null
if (kind === 'flip' && ready(newIm) && isBookTurn(old, dir)) {
bookCurl = glCurl(rel(oldImgs[0]), oldImgs[0], newIm, sign > 0, { l: 0, t: 0, w: box.clientWidth, h: box.clientHeight })
}
let step, dur, range = box.clientWidth, toG = q => q, auto = easeOut, g0 = 0, g1 = 1
if (kind === 'fade') {
step = g => { old.style.opacity = String(1 - g) }
@@ -2024,13 +2076,67 @@ async function viewReader(mangaId, chapterId, startPage) {
} else if (kind === 'slide') {
step = g => { old.style.transform = `translateX(${-sign * 100 * g}%)`; stage.style.transform = `translateX(${sign * 100 * (1 - g)}%)` }
dur = 260
} else if (kind === 'pan') {
// Las dos páginas van juntas, lomo con lomo: la nueva empuja a la vieja. Lo que se vea a los lados de la página
// queda tapado, para que la nueva no aparezca de golpe si la pantalla es más ancha que ella.
const oldW = oldImgs[0].getBoundingClientRect().width
const newW = ready(newIm) ? newIm.getBoundingClientRect().width : oldW
const shift = (oldW + newW) / 2
addSideMasks(Math.max(oldW, newW))
old.style.background = 'transparent'
step = g => { old.style.transform = `translateX(${-sign * shift * g}px)`; stage.style.transform = `translateX(${sign * shift * (1 - g)}px)` }
step(0)
dur = 460
range = shift
auto = t => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) ** 2)
} else if (bookCurl) {
// Como un libro: la hoja gira sobre el lomo y descubre la página que tiene debajo, la vista la acompaña y la
// nueva, en el reverso de la hoja, cae en su sitio. Alrededor están las páginas vecinas, lomo con lomo.
const rect = rel(oldImgs[0]), spineLeft = sign > 0
const hinge = spineLeft ? rect.l : rect.l + rect.w
const newRect = rel(newIm)
const travel = (spineLeft ? newRect.l + newRect.w : newRect.l) - hinge
const from = Number(oldImgs[0].dataset.i), to = shown[0]
const under = []
const neighbour = (i, onHingeSide) => {
if (i < 0 || i >= total || wide.has(i)) return
const im = preloaded.get(i) || Object.assign(new Image(), { src: pages[i] })
const fit = imgReady(im) ? Math.min(box.clientWidth / im.naturalWidth, box.clientHeight / im.naturalHeight) : 0
const w = fit ? im.naturalWidth * fit : rect.w, h = fit ? im.naturalHeight * fit : rect.h
im.className = 'leafkeep'
im.alt = ''
im.dataset.gutter = spineLeft === onHingeSide ? 'l' : 'r'
place(im, { l: spineLeft === onHingeSide ? hinge : hinge - w, t: (box.clientHeight - h) / 2, w, h })
im.style.willChange = 'transform'
under.push(im)
}
const back = document.createElement('i')
back.className = 'leafkeepback'
place(back, { l: 0, t: 0, w: box.clientWidth, h: box.clientHeight })
neighbour(to + dir, true)
neighbour(from - dir, false)
box.append(back, ...under, bookCurl.el)
extra.push(back, ...under, bookCurl.el)
addSideMasks(Math.max(rect.w, newRect.w))
old.style.visibility = 'hidden'
const grab = grabOf(rect)
if (grab) bookCurl.pivot(grab < 0 ? 'bottom' : 'top')
step = g => {
const follow = Math.min(1, Math.max(0, (g - BOOK_FOLLOW_FROM) / (1 - BOOK_FOLLOW_FROM)))
const cam = travel * follow * follow * (3 - 2 * follow)
for (const im of under) im.style.transform = `translateX(${cam}px)`
const k = grab ? grab * Math.sin(Math.PI * g) : 0
bookCurl.set(g, false, (spineLeft ? 1 : -1) * GRAB_TILT_Z * k, GRAB_TILT_X * k, cam)
}
step(0)
dur = 850
auto = t => t
range = rect.w
toG = q => bookCurl.solve(1 - q * 2, false)
} 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, paper, frontIm, backIm = 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
@@ -2072,8 +2178,7 @@ async function viewReader(mangaId, chapterId, startPage) {
reverse = dir < 0
if (dir > 0) old.style.visibility = 'hidden'
}
const grabAt = grabY != null ? (grabY - B.top - rect.t) / rect.h : 0.5
const grab = grabAt < 1 / 3 ? -1 : grabAt > 2 / 3 ? 1 : 0
const grab = grabOf(rect)
const curl = glCurl(rect, frontIm, backIm, spineLeft, paper) || paperCurl(rect, frontIm.src, backIm?.src || null, spineLeft, !!grab)
// Sin WebGL, la sombra de debajo es un degradado desde el lomo.
let drop = null
@@ -2108,7 +2213,7 @@ async function viewReader(mangaId, chapterId, startPage) {
let g = toG(0)
step(g)
const ctl = {
anims: [], cleanup, range,
anims: [], cleanup, range, pan,
move(q) { g = toG(Math.min(1, Math.max(0, q))); step(g) },
// Termina de pasar la página (commit) o la devuelve a su sitio; done(commit) al acabar.
release(commit, done) {
@@ -2347,7 +2452,7 @@ async function viewReader(mangaId, chapterId, startPage) {
if (turning !== g.ctl) return
const q = g.d / g.ctl.range
const commit = e.type === 'pointerup' && (g.v > 0.35 || (q > 0.3 && g.v > -0.15))
if (commit) turnSound(settings.sfx, turnPan(g.dir))
if (commit && !g.ctl.pan) turnSound(settings.sfx, turnPan(g.dir))
g.ctl.release(commit, ok => {
if (ok) return
shown = g.prev
@@ -2401,7 +2506,7 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#dir').addEventListener('change', e => {
settings.dir = e.target.value
box.dataset.dir = settings.dir
box.querySelectorAll('.stage img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) })
refreshGutters()
syncDirUi()
savePrefs()
})