La hoja se dibuja con WebGL y el toque elige la animación según su altura

Una sola malla en un canvas en lugar de una capa con filtro por cara. Las
páginas se suben a la GPU en reposo; sin WebGL2 se usa la hoja de capas.
Tocar arriba, al centro o abajo levanta la hoja por esa parte, como al
arrastrarla, y el giro dura 20 ms más.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:27:56 -06:00
co-authored by Claude Opus 5.5
parent 811df85349
commit 28aa0b4051
2 changed files with 269 additions and 44 deletions
+268 -44
View File
@@ -1478,6 +1478,7 @@ async function viewReader(mangaId, chapterId, startPage) {
preloaded.set(i, im)
}
for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i)
scheduleLeafTextures()
}
const imgReady = im => im.complete && im.naturalWidth > 0
// Hacia una página que aún no ha llegado, la actual se queda (con un indicador tras un momento) hasta que
@@ -1497,12 +1498,16 @@ async function viewReader(mangaId, chapterId, startPage) {
const imgs = [...stage.querySelectorAll('img')]
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
const touchedAt = grabY // el toque ya habrá acabado cuando llegue la página
const arrived = Promise.all(imgs.map(im => (im.decode ? im.decode() : new Promise(r => { im.onload = im.onerror = r })).catch(() => {})))
Promise.race([arrived, new Promise(r => setTimeout(r, 4000))]).then(() => {
if (seq !== showSeq) return
clearTimeout(waitTimer)
box.classList.remove('waiting')
if (!atEnd && isPaged() && box.isConnected) presentPaged(stage, dir)
if (!atEnd && isPaged() && box.isConnected) {
grabY = touchedAt
try { presentPaged(stage, dir) } finally { grabY = null }
}
})
return
}
@@ -1542,7 +1547,7 @@ async function viewReader(mangaId, chapterId, startPage) {
// 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)
let wantDrag = false
let grabY = null // altura de la pantalla por la que el dedo agarró la página
let grabY = null // altura de la pantalla por la que el dedo tocó o agarró la página
// Lado por el que se mueve la hoja: el de la página que pasa (más marcado en doble página).
const turnPan = dir => (settings.dir === 'rtl' ? -1 : 1) * dir * (shown.length === 2 ? 0.3 : 0.12)
function finishTurn() {
@@ -1571,6 +1576,253 @@ async function viewReader(mangaId, chapterId, startPage) {
// 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.
// Ángulos de la hoja dividida en N tiras, común a la versión WebGL y a la de capas.
function curlCurve(N) {
// Á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 Array.from({ length: N }, (_, 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
})
}
// 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)]
}
// Posición del borde libre (fracción del ancho desde el lomo: 1 = en su sitio, -1 = pasada del todo)
// y el avance g que la pone donde está el dedo.
const edge = (g, reverse) => angles(g, reverse).reduce((sum, a) => sum + Math.cos((a * Math.PI) / 180), 0) / N
function solve(target, reverse) {
let lo = 0, hi = 1
for (let k = 0; k < 18; k++) {
const m = (lo + hi) / 2
if ((edge(m, reverse) > target) !== reverse) lo = m
else hi = m
}
return (lo + hi) / 2
}
return { angles, span, solve }
}
// --- 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
let leafGL = null, leafGLOff = false, leafTimer = 0
const leafTextures = new Map() // URL de la página → textura
const leafNoTexture = new Set() // las que no se pudieron subir (demasiado grandes, sin permiso…)
function initLeafGL() {
if (leafGL || leafGLOff) return leafGL
const canvas = document.createElement('canvas')
canvas.className = 'curlgl'
const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: true, depth: true, powerPreference: 'high-performance' })
const shader = (type, src) => { const sh = gl.createShader(type); gl.shaderSource(sh, src); gl.compileShader(sh); return sh }
const prog = gl?.createProgram()
if (gl) {
// Misma proyección que perspective en CSS: el origen en el centro y la escala 1 / (1 - z / perspectiva).
gl.attachShader(prog, shader(gl.VERTEX_SHADER, `#version 300 es
uniform vec3 uView;
layout(location = 0) in vec4 aPos;
layout(location = 1) in vec2 aUV;
out vec2 vUV;
out float vC;
void main() {
float w = 1.0 - aPos.z / uView.z;
gl_Position = vec4(aPos.x / uView.x * 2.0 - 1.0, 1.0 - aPos.y / uView.y * 2.0, 1.05128 * w - 0.10256, w);
vUV = aUV;
vC = aPos.w;
}`))
// La cara se oscurece al inclinarse. El sesgo negativo evita que la página se vea blanda con los mipmaps.
gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
uniform sampler2D uFront, uBack;
uniform bool uPaper;
in vec2 vUV;
in float vC;
out vec4 color;
void main() {
vec4 t;
float b;
if (gl_FrontFacing) {
t = texture(uFront, vUV, -0.5);
b = 1.0 - (1.0 - vC) * 0.3;
} 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;
}
color = vec4(t.rgb * b, t.a);
}`))
gl.linkProgram(prog)
}
if (!gl || !gl.getProgramParameter(prog, gl.LINK_STATUS)) { leafGLOff = true; return null }
gl.useProgram(prog)
gl.uniform1i(gl.getUniformLocation(prog, 'uFront'), 0)
gl.uniform1i(gl.getUniformLocation(prog, 'uBack'), 1)
gl.enable(gl.DEPTH_TEST)
gl.depthFunc(gl.LEQUAL)
gl.clearColor(0, 0, 0, 0)
const count = (LEAF_COLS + 1) * (LEAF_ROWS + 1)
const posBuf = gl.createBuffer(), uvBuf = gl.createBuffer(), indexBuf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf)
gl.bufferData(gl.ARRAY_BUFFER, count * 16, gl.DYNAMIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf)
gl.bufferData(gl.ARRAY_BUFFER, count * 8, gl.DYNAMIC_DRAW)
gl.enableVertexAttribArray(1)
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0)
const at = (c, f) => c * (LEAF_ROWS + 1) + f
const index = new Uint16Array(LEAF_COLS * LEAF_ROWS * 6)
let n = 0
for (let c = 0; c < LEAF_COLS; c++) for (let f = 0; f < LEAF_ROWS; f++) {
index.set([at(c, f), at(c, f + 1), at(c + 1, f), at(c + 1, f), at(c, f + 1), at(c + 1, f + 1)], n)
n += 6
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuf)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, index, gl.STATIC_DRAW)
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true)
const aniso = gl.getExtension('EXT_texture_filter_anisotropic')
// Sin el contexto (iOS puede quitarlo con la app en segundo plano), el siguiente giro crea otro.
canvas.addEventListener('webglcontextlost', () => { if (leafGL?.gl === gl) { leafGL = null; leafTextures.clear() } })
leafGL = { canvas, gl, posBuf, uvBuf, aniso, positions: new Float32Array(count * 4), uvs: new Float32Array(count * 2), indexCount: n,
uView: gl.getUniformLocation(prog, 'uView'), uPaper: gl.getUniformLocation(prog, 'uPaper'), maxSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) }
return leafGL
}
function leafTexture(im) {
const known = leafTextures.get(im.src)
if (known) return known
const { gl, aniso, maxSize } = leafGL
if (Math.max(im.naturalWidth, im.naturalHeight) > maxSize) throw new Error('página demasiado grande')
const tex = gl.createTexture()
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, tex)
try {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, im)
gl.generateMipmap(gl.TEXTURE_2D)
if (gl.getError() !== gl.NO_ERROR) throw new Error('sin memoria para la textura')
} catch (e) { gl.deleteTexture(tex); throw e }
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(4, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)))
leafTextures.set(im.src, tex)
return tex
}
// Sube con antelación, en reposo y de una en una, las páginas de los giros posibles: subirla al empezar el
// giro daría un tirón. Una página que no llegó a subirse se sube al girar.
function scheduleLeafTextures(delay = 300) {
clearTimeout(leafTimer)
if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width')) leafTimer = setTimeout(nextLeafTexture, delay)
}
function nextLeafTexture() {
if (turning) return scheduleLeafTextures(400)
const n = shown.length
const near = []
for (let k = 1; k <= n; k++) near.push(shown.at(-1) + k, shown[0] - k)
const wanted = [...box.querySelectorAll('.stage img'), ...near.map(i => preloaded.get(i)).filter(Boolean)]
const keep = new Set(wanted.map(im => im.src))
for (const [src, tex] of leafTextures) if (!keep.has(src)) { leafGL?.gl.deleteTexture(tex); leafTextures.delete(src) }
const missing = wanted.filter(im => !leafTextures.has(im.src) && !leafNoTexture.has(im.src))
const im = missing.find(imgReady)
if (!im) { if (missing.length) scheduleLeafTextures(800); return }
if (!initLeafGL()) return
try { leafTexture(im) } catch { leafNoTexture.add(im.src) }
scheduleLeafTextures(120)
}
function freeLeafGL() {
leafGLOff = true
clearTimeout(leafTimer)
leafGL?.gl.getExtension('WEBGL_lose_context')?.loseContext()
leafGL = null
leafTextures.clear()
}
// Devuelve null si no se puede (sin WebGL2 o sin textura): entonces se usa paperCurl.
function glCurl(r, frontIm, backIm, spineLeft) {
if (!initLeafGL() || leafNoTexture.has(frontIm.src) || (backIm && leafNoTexture.has(backIm.src))) return null
const L = leafGL
const { gl, canvas, positions, uvs } = L
let front, back
try {
front = leafTexture(frontIm)
back = backIm ? leafTexture(backIm) : null
} catch { leafNoTexture.add(frontIm.src); if (backIm) leafNoTexture.add(backIm.src); return null }
const W = box.clientWidth, H = box.clientHeight, N = LEAF_COLS, R = LEAF_ROWS
const pw = Math.round(W * devicePixelRatio), ph = Math.round(H * devicePixelRatio)
if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph }
Object.assign(canvas.style, { width: `${W}px`, height: `${H}px` })
gl.viewport(0, 0, pw, ph)
gl.uniform3f(L.uView, W, H, parseFloat(getComputedStyle(box).perspective) || 2200)
gl.uniform1i(L.uPaper, back ? 0 : 1)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, front)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, back)
// Las columnas van del lomo al borde libre: con el lomo a la derecha, la malla queda al revés.
gl.frontFace(spineLeft ? gl.CCW : gl.CW)
for (let c = 0, n = 0; c <= N; c++) for (let f = 0; f <= R; f++) {
uvs[n++] = spineLeft ? c / N : 1 - c / N
uvs[n++] = f / R
}
gl.bindBuffer(gl.ARRAY_BUFFER, L.uvBuf)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, uvs)
gl.bindBuffer(gl.ARRAY_BUFFER, L.posBuf)
const curve = curlCurve(N)
const step = (spineLeft ? r.w : -r.w) / N
const sgn = spineLeft ? -1 : 1
const spineX = r.l + (spineLeft ? 0 : r.w)
let pivotY = r.h / 2
const rad = Math.PI / 180
const mul = (a, b) => [
a[0] * b[0] + a[1] * b[3] + a[2] * b[6], a[0] * b[1] + a[1] * b[4] + a[2] * b[7], a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
a[3] * b[0] + a[4] * b[3] + a[5] * b[6], a[3] * b[1] + a[4] * b[4] + a[5] * b[7], a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
a[6] * b[0] + a[7] * b[3] + a[8] * b[6], a[6] * b[1] + a[7] * b[4] + a[8] * b[7], a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
]
// Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo. Encadena los giros de tira en
// tira como lo hacen las capas anidadas de paperCurl.
function set(g, reverse, tiltZ = 0, tiltX = 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++] = r.t + pivotY + y + m[4] * v
positions[n++] = z + m[7] * v
positions[n++] = c
}
}
for (let i = 0; i < N; i++) {
const c = Math.cos(a[i] * rad)
if (i) { x += m[0] * step; y += m[3] * step; z += m[6] * step }
const t = sgn * (a[i] - prev) * rad
m = mul(m, [Math.cos(t), 0, Math.sin(t), 0, 1, 0, -Math.sin(t), 0, Math.cos(t)])
if (i && (tiltZ || tiltX)) {
const tz = ((tiltZ * c) / (N - 1)) * rad, tx = ((tiltX * c) / (N - 1)) * rad
m = mul(mul(m, [Math.cos(tz), -Math.sin(tz), 0, Math.sin(tz), Math.cos(tz), 0, 0, 0, 1]), [1, 0, 0, 0, Math.cos(tx), -Math.sin(tx), 0, Math.sin(tx), Math.cos(tx)])
}
column(i ? (c + prevC) / 2 : c)
prev = a[i]
prevC = c
lift = Math.max(lift, Math.sin(a[i] * rad))
}
x += m[0] * step; y += m[3] * step; z += m[6] * step
column(prevC)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, positions)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
gl.drawElements(gl.TRIANGLES, L.indexCount, gl.UNSIGNED_SHORT, 0)
return lift
}
const pivot = y => { pivotY = y === 'bottom' ? r.h : y === 'top' ? 0 : r.h / 2 }
return { el: canvas, gl: true, set, span: curve.span, solve: curve.solve, pivot }
}
function paperCurl(r, frontSrc, backSrc, spineLeft, bent = false) {
const N = Math.max(10, Math.min(22, Math.round(r.w / 24)))
const w = r.w / N
@@ -1599,17 +1851,7 @@ async function viewReader(mangaId, chapterId, startPage) {
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
})
}
const { angles, span, solve } = curlCurve(N)
// Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo.
// tiltZ/tiltX: curvatura total de la hoja agarrada por arriba o por abajo. Se reparte entre las tiras salvo la
// del lomo, que no se curva, y el borde libre se lleva todo: la hoja sigue pegada al libro. Cambia de
@@ -1630,27 +1872,6 @@ async function viewReader(mangaId, chapterId, startPage) {
})
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)]
}
// Posición del borde libre (fracción del ancho desde el lomo: 1 = en su sitio, -1 = pasada del todo)
// y el avance g que la pone donde está el dedo.
const edge = (g, reverse) => angles(g, reverse).reduce((sum, a) => sum + Math.cos((a * Math.PI) / 180), 0) / N
function solve(target, reverse) {
let lo = 0, hi = 1
for (let k = 0; k < 18; k++) {
const m = (lo + hi) / 2
if ((edge(m, reverse) > target) !== reverse) lo = m
else hi = m
}
return (lo + hi) / 2
}
// Punto fijo de la curvatura: el borde opuesto al agarrado (no afecta al giro sobre el eje vertical).
const pivot = y => strips.forEach(t => { t.s.style.transformOrigin = `${spineLeft ? 'left' : 'right'} ${y}` })
return { el, set, span, solve, pivot }
@@ -1695,7 +1916,7 @@ async function viewReader(mangaId, chapterId, startPage) {
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
let rect, 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
// la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia
@@ -1707,8 +1928,8 @@ async function viewReader(mangaId, chapterId, startPage) {
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
frontIm = front
backIm = sign > 0 ? newX[0] : newX.at(-1)
spineLeft = sign > 0
const keep = document.createElement('img')
keep.className = 'leafkeep'
@@ -1723,7 +1944,7 @@ async function viewReader(mangaId, chapterId, startPage) {
// 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
frontIm = img
spineLeft = settings.dir !== 'rtl'
reverse = dir < 0
if (dir > 0) old.style.visibility = 'hidden'
@@ -1732,14 +1953,14 @@ async function viewReader(mangaId, chapterId, startPage) {
const drop = document.createElement('i')
drop.className = `curldrop ${spineLeft ? 'l' : 'r'}`
place(drop, rect)
const grabAt = drag && grabY != null ? (grabY - B.top - rect.t) / rect.h : 0.5
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 curl = paperCurl(rect, frontSrc, backSrc, spineLeft, !!grab)
place(curl.el, rect)
const curl = glCurl(rect, frontIm, backIm, spineLeft) || paperCurl(rect, frontIm.src, backIm?.src || null, spineLeft, !!grab)
if (!curl.gl) place(curl.el, rect)
box.append(drop, curl.el)
extra.push(drop, curl.el)
;[g0, g1] = double ? [0, 1] : curl.span(reverse)
// Agarrada por arriba o por abajo (tercio superior o inferior), esa parte va por delante: la hoja se curva
// Tocada o agarrada por arriba o por abajo (tercio superior o inferior), esa parte va por delante: la hoja se curva
// sin separarse del lomo. Sin capas nuevas; es 0 al principio y al final del giro, así que cae exacta.
// Por el centro, como siempre.
if (grab) curl.pivot(grab < 0 ? 'bottom' : 'top')
@@ -1748,7 +1969,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const lift = curl.set(g, reverse, (spineLeft ? 1 : -1) * (reverse ? -1 : 1) * GRAB_TILT_Z * k, GRAB_TILT_X * k)
drop.style.opacity = (lift * 0.8).toFixed(3)
}
dur = double ? 700 : 560
dur = double ? 720 : 580
auto = t => g0 + (g1 - g0) * t
// El borde libre va donde lo lleva el dedo: de su sitio al lomo (una página) o al otro lado (doble);
// hacia atrás en una página, del lomo a su sitio.
@@ -1919,7 +2140,9 @@ async function viewReader(mangaId, chapterId, startPage) {
}
if (zoom.s > 1) return // con zoom, un toque no pasa página
if (center) { clearTimeout(uiTimer); uiTimer = setTimeout(toggleUi, 280); return }
;(xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward()
// La hoja se levanta por la altura del toque, igual que al arrastrarla.
grabY = cy
try { (xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward() } finally { grabY = null }
}
box.addEventListener('pointerdown', e => {
if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return
@@ -2216,6 +2439,7 @@ async function viewReader(mangaId, chapterId, startPage) {
render()
cleanup = () => {
freeLeafGL()
endCrop()
if (saveTimer) saveNow()
observer?.disconnect()