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; }