diff --git a/app/public/app.js b/app/public/app.js index 98446b4..9544da0 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -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() diff --git a/app/public/style.css b/app/public/style.css index 725155e..82e2c5f 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -377,6 +377,7 @@ body.reading #netbanner { display: none; } .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 .curlgl { position: absolute; left: 0; top: 0; z-index: 3; pointer-events: none; filter: var(--page-filter, none); } .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; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; }