From 5776389ebb6e9ffaee7d1b40c39838cd24542f43 Mon Sep 17 00:00:00 2001 From: Nino Date: Mon, 2 Nov 2026 17:05:18 -0600 Subject: [PATCH] =?UTF-8?q?La=20vista=20de=20una=20p=C3=A1gina=20funciona?= =?UTF-8?q?=20como=20un=20libro=20abierto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/public/app.js | 153 ++++++++++++++++++++++++++++++++++++------- app/public/style.css | 6 +- 2 files changed, 131 insertions(+), 28 deletions(-) diff --git a/app/public/app.js b/app/public/app.js index 2bfa765..3f189d5 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -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() }) diff --git a/app/public/style.css b/app/public/style.css index b9fa4b4..5bcda77 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -398,7 +398,7 @@ body.reading #netbanner { display: none; } .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%); } /* Hendidura del lomo: la página se funde con el negro de detrás. Los puntos coinciden con gutter() en app.js. */ -.paged img[data-gutter] { +.paged img[data-gutter="l"], .paged img[data-gutter="r"] { --gutter-stops: rgba(0,0,0,.4) 0, rgba(0,0,0,.6) 1.2%, rgba(0,0,0,.8) 3%, rgba(0,0,0,.93) 6%, #000 10%; --gutter-mask: linear-gradient(var(--gutter-side), var(--gutter-stops)); -webkit-mask-image: var(--gutter-mask); @@ -406,9 +406,7 @@ body.reading #netbanner { display: none; } } .paged img[data-gutter="l"] { --gutter-side: to right; } .paged img[data-gutter="r"] { --gutter-side: to left; } -.paged img[data-gutter="m"] { - --gutter-mask: linear-gradient(to right, #000 45%, rgba(0,0,0,.93) 47%, rgba(0,0,0,.8) 48.5%, rgba(0,0,0,.6) 49.4%, rgba(0,0,0,.4) 50%, rgba(0,0,0,.6) 50.6%, rgba(0,0,0,.8) 51.5%, rgba(0,0,0,.93) 53%, #000 55%); -} +.paged .panmask { position: absolute; top: 0; bottom: 0; z-index: 4; pointer-events: none; background: #000; } .paged .leafkeepback { position: absolute; z-index: 2; pointer-events: none; background: #000; } .paged .leafkeep { position: absolute; z-index: 2; pointer-events: none; max-width: none; max-height: none; object-fit: fill; }