Libros de texto: fuente Project Gutenberg y lectura continua
Los EPUB se convierten en el servidor en una lista cerrada de bloques y el navegador los maqueta en páginas según su pantalla. Cada página se dibuja en un canvas y llega al lector como una imagen más, así que hoja, sombra, hendidura y sonido no cambian. El libro se lee de corrido: cada capítulo empieza debajo del anterior y el progreso se sigue guardando por capítulo. El catálogo sale del OPDS de gutenberg.org, con reintentos. Ajustes: mostrar solo manga, solo libros o ambos, por usuario. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+596
-49
@@ -50,8 +50,14 @@ const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="cu
|
||||
// Iconos del menú superior (index.html).
|
||||
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` })
|
||||
|
||||
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)' }
|
||||
const NO_CATS = new Set(['mi']) // fuentes sin listado por categorías
|
||||
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)', gb: 'Project Gutenberg (libros)' }
|
||||
const NO_CATS = new Set(['mi', 'gb']) // fuentes sin listado por categorías
|
||||
const BOOK_SOURCES = new Set(['gb'])
|
||||
const isBookId = id => /^(gb|ws|ub)~/.test(id || '')
|
||||
// Ajuste del usuario: ver solo manga, solo libros o ambos. Solo filtra lo que se ofrece; un enlace directo sigue abriendo.
|
||||
const contentMode = () => me?.content_mode || 'both'
|
||||
const wantsId = id => contentMode() === 'both' || (contentMode() === 'books') === isBookId(id)
|
||||
const visibleSources = () => Object.entries(SOURCES).filter(([k]) => contentMode() === 'both' || (contentMode() === 'books') === BOOK_SOURCES.has(k))
|
||||
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
|
||||
const FILTERS = {
|
||||
none: ['Sin filtro', ''],
|
||||
@@ -431,6 +437,7 @@ function continueCard(h) {
|
||||
}
|
||||
// Sección "Novedades": mangas seguidos con capítulos nuevos.
|
||||
function newsSection(fol) {
|
||||
fol = { ...fol, follows: fol.follows.filter(f => wantsId(f.manga_id)) }
|
||||
const fresh = fol.follows.filter(f => f.newCount > 0)
|
||||
const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h`
|
||||
if (!fol.follows.length) return ''
|
||||
@@ -484,7 +491,8 @@ async function viewHomeOffline(params) {
|
||||
<h2>${icon('download')} Descargado en el servidor</h2>
|
||||
<form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form>
|
||||
<div id="results" class="loading">Cargando…</div>`
|
||||
const [list, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
|
||||
const [all, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
|
||||
const list = all.filter(m => wantsId(m.id))
|
||||
const have = new Set(list.map(m => m.id))
|
||||
const cont = hist.filter(h => !h.finished && have.has(h.manga_id))
|
||||
if (cont.length) $('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
|
||||
@@ -517,7 +525,9 @@ async function viewHome(params) {
|
||||
const tag = params.get('tag') || ''
|
||||
const tagName = params.get('tagName') || tag
|
||||
const anyLang = params.get('any') === '1'
|
||||
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
|
||||
const shownSources = visibleSources()
|
||||
const isShown = k => shownSources.some(([s]) => s === k)
|
||||
const source = isShown(params.get('source')) ? params.get('source') : isShown(store.get('source', 'lc')) ? store.get('source', 'lc') : shownSources[0][0]
|
||||
let mode = tag ? 'cats' : params.get('mode') || 'popular'
|
||||
if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular'
|
||||
store.set('source', source)
|
||||
@@ -525,12 +535,12 @@ async function viewHome(params) {
|
||||
app.innerHTML = `
|
||||
<section id="cont"></section>
|
||||
<section id="recs"></section>
|
||||
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
|
||||
<div class="tabs" id="sources">${shownSources.map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
|
||||
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
|
||||
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
|
||||
${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
|
||||
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button>
|
||||
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} Actualizados recientemente</button>
|
||||
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} ${BOOK_SOURCES.has(source) ? 'Añadidos recientemente' : 'Actualizados recientemente'}</button>
|
||||
${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button>`}</div>`}
|
||||
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
|
||||
<div id="results" class="loading">Cargando…</div>
|
||||
@@ -548,10 +558,10 @@ async function viewHome(params) {
|
||||
if (!q && !tag && !showCats) {
|
||||
Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => {
|
||||
if (!$('#cont')) return
|
||||
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
|
||||
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished && wantsId(h.manga_id)), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
|
||||
bindNews(fol)
|
||||
}).catch(() => {})
|
||||
loadRecs()
|
||||
if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga
|
||||
}
|
||||
|
||||
const res = $('#results')
|
||||
@@ -564,7 +574,7 @@ async function viewHome(params) {
|
||||
}
|
||||
|
||||
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan).
|
||||
const pageable = source === 'md' || !!tag || (source === 'mi' && mode === 'latest' && !q)
|
||||
const pageable = source === 'md' || BOOK_SOURCES.has(source) || !!tag || (source === 'mi' && mode === 'latest' && !q)
|
||||
const seen = new Set()
|
||||
let page = 1
|
||||
const load = async () => {
|
||||
@@ -650,6 +660,7 @@ async function viewManga(id, langOverride) {
|
||||
let isFollowing = following
|
||||
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
|
||||
const isLc = manga.source !== 'md' // sitios en español sin selector de idioma
|
||||
const isBook = isBookId(id)
|
||||
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
|
||||
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
|
||||
const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}`
|
||||
@@ -663,8 +674,8 @@ async function viewManga(id, langOverride) {
|
||||
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
|
||||
|
||||
const menuItems = [
|
||||
manga.source === 'lc' ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
manga.source === 'mi' ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
||||
manga.source === 'lc' || isBook ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
manga.source === 'mi' || isBook ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
||||
progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||||
].join('')
|
||||
|
||||
@@ -697,7 +708,7 @@ async function viewManga(id, langOverride) {
|
||||
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
|
||||
${menuItems}
|
||||
</div>
|
||||
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
|
||||
<div class="libpanel" ${isBook ? 'style="display:none"' : ''}><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
|
||||
<div class="libpanel" id="mdata"><div class="psec"><div class="psec-title">${icon('book')} Datos de la obra</div><dl class="kv" id="mkv"></dl></div></div>
|
||||
</aside>
|
||||
<div class="list-head">
|
||||
@@ -735,7 +746,7 @@ async function viewManga(id, langOverride) {
|
||||
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(manga.title)}" target="_blank" rel="noopener">AniList</a>.</p>` : '')
|
||||
}
|
||||
paintInfo(savedInfo)
|
||||
if (!savedInfo) {
|
||||
if (!savedInfo && !isBook) {
|
||||
$('#mextra').innerHTML = '<p class="muted note">Buscando información de la obra…</p>'
|
||||
api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' })
|
||||
}
|
||||
@@ -1116,7 +1127,7 @@ async function viewManga(id, langOverride) {
|
||||
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
|
||||
b.classList.toggle('on', isFollowing)
|
||||
b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos'
|
||||
b.hidden = offline
|
||||
b.hidden = offline || isBook
|
||||
}
|
||||
renderFollow()
|
||||
$('#follow').addEventListener('click', async () => {
|
||||
@@ -1224,7 +1235,7 @@ async function viewManga(id, langOverride) {
|
||||
updateSummary()
|
||||
})
|
||||
renderList()
|
||||
api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
|
||||
if (!isBook) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
|
||||
renderDl(download)
|
||||
renderHd(upscale)
|
||||
renderOff()
|
||||
@@ -1330,12 +1341,418 @@ function turnSound(level, pan = 0) {
|
||||
}
|
||||
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
|
||||
function defaultPrefs(manga) {
|
||||
if (manga.type === 'Libro') return { mode: 'paged', fit: 'height', dir: 'ltr' }
|
||||
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
|
||||
if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' }
|
||||
const legacy = store.get('readerSettings', {})
|
||||
return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr' }
|
||||
}
|
||||
|
||||
// ---------- Libros: el libro entero se maqueta como un texto continuo (cada capítulo empieza justo debajo del
|
||||
// anterior) en páginas del tamaño de la pantalla, y cada página se dibuja en un canvas. Al lector le llega como
|
||||
// una imagen más, así que hoja, sombra, hendidura y sonido no cambian.
|
||||
const BOOK_DEFAULTS = { size: 100, font: 'serif', margin: 'mid', paper: 'light' }
|
||||
const BOOK_FONTS = { serif: "Georgia, 'Iowan Old Style', 'Noto Serif', 'Times New Roman', serif", sans: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
|
||||
const BOOK_PAPERS = { light: { bg: '#fbf9f3', fg: '#22201c', dim: '#8b867b' }, sepia: { bg: '#f3e9d3', fg: '#3a2f21', dim: '#8f7f66' }, dark: { bg: '#1b1a18', fg: '#d8d4ca', dim: '#7c786f' } }
|
||||
const BOOK_MARGINS = { small: 0.055, mid: 0.085, large: 0.12 }
|
||||
const BOOK_KEEP_PAGES = 16 // páginas dibujadas que se conservan a cada lado de la actual
|
||||
const bookSettings = () => ({ ...BOOK_DEFAULTS, ...store.get('bookSettings', {}) })
|
||||
let bookResume = null // al volver a maquetar (rotar, cambiar la letra): seguir en la misma palabra
|
||||
let bookCache = null // { id, text } el último libro, para no pedirlo otra vez al reabrir
|
||||
let bookLayoutCache = null // { key, layout } y su maquetado, para esta pantalla y esta letra
|
||||
|
||||
// El libro como una sola lista de bloques. Cada capítulo lleva su división y su título, aunque el texto no lo traiga.
|
||||
function bookFlatten(chapters) {
|
||||
const flat = [], chapBlock = []
|
||||
chapters.forEach((c, i) => {
|
||||
chapBlock.push(flat.length)
|
||||
if (i > 0) flat.push({ k: 'div' })
|
||||
if (c.blocks[0]?.k !== 'h' && !/^Sección \d+$/.test(c.title)) flat.push({ k: 'h', l: 2, r: [[c.title, 0]] })
|
||||
for (const b of c.blocks) flat.push(b)
|
||||
})
|
||||
return { flat, chapBlock }
|
||||
}
|
||||
|
||||
// Una primera pasada solo busca dónde empieza cada página; lo que se dibuja se calcula al pedir esa página. Así un
|
||||
// libro de miles de páginas no ocupa memoria con la posición de cada palabra.
|
||||
function makeBookLayout(text, W, H, opts) {
|
||||
const { flat, chapBlock } = bookFlatten(text.chapters)
|
||||
const measure = document.createElement('canvas').getContext('2d')
|
||||
const family = BOOK_FONTS[opts.font] || BOOK_FONTS.serif
|
||||
const px = Math.min(22, Math.max(15, W / 24)) * (opts.size / 100)
|
||||
const lineH = Math.round(px * 1.45)
|
||||
const marginX = Math.round(W * (BOOK_MARGINS[opts.margin] || BOOK_MARGINS.mid))
|
||||
const top = Math.round(Math.max(px * 2.6, 30)), bottom = Math.round(H - Math.max(px * 2.6, 30))
|
||||
const cw = W - marginX * 2
|
||||
const widths = new Map()
|
||||
const fontOf = (size, style, forceBold) => `${style & 1 ? 'italic ' : ''}${style & 2 || forceBold ? 'bold ' : ''}${size}px ${family}`
|
||||
const widthOf = (t, font) => {
|
||||
const key = `${font}|${t}`
|
||||
let w = widths.get(key)
|
||||
if (w === undefined) {
|
||||
if (measure.font !== font) measure.font = font
|
||||
w = measure.measureText(t).width
|
||||
widths.set(key, w)
|
||||
}
|
||||
return w
|
||||
}
|
||||
// Un bloque en líneas: cada línea son cajas (palabras, que pueden cambiar de estilo por dentro).
|
||||
function breakLines(runs, width, indent, size, forceBold) {
|
||||
const spaceW = widthOf(' ', fontOf(size, 0, forceBold))
|
||||
const lines = []
|
||||
let line = { boxes: [], w: 0, hard: false }
|
||||
let box = null, space = false
|
||||
const room = () => width - (lines.length ? 0 : indent)
|
||||
const endLine = hard => { line.hard = hard; lines.push(line); line = { boxes: [], w: 0, hard: false } }
|
||||
const place = b => {
|
||||
if (line.boxes.length && line.w + spaceW + b.w > room()) endLine(false)
|
||||
line.w += (line.boxes.length ? spaceW : 0) + b.w
|
||||
line.boxes.push(b)
|
||||
}
|
||||
const flushBox = () => {
|
||||
if (!box) return
|
||||
// Una palabra más ancha que la línea se corta por letras.
|
||||
if (box.w > room() && box.frags.length === 1) {
|
||||
const { font } = box.frags[0]
|
||||
let cur = ''
|
||||
for (const ch of Array.from(box.frags[0].t)) {
|
||||
if (cur && widthOf(cur + ch, font) > width) {
|
||||
place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) })
|
||||
endLine(false)
|
||||
cur = ''
|
||||
}
|
||||
cur += ch
|
||||
}
|
||||
if (cur) place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) })
|
||||
} else place(box)
|
||||
box = null
|
||||
}
|
||||
for (const [t0, style] of runs) {
|
||||
const font = fontOf(size, style, forceBold)
|
||||
for (const m of t0.matchAll(/\n| +|[^ \n]+/g)) {
|
||||
const t = m[0]
|
||||
if (t === '\n') { flushBox(); space = false; endLine(true) }
|
||||
else if (t[0] === ' ') { flushBox(); space = true }
|
||||
else {
|
||||
if (space) { flushBox(); space = false }
|
||||
const w = widthOf(t, font)
|
||||
box ??= { frags: [], w: 0 }
|
||||
box.frags.push({ t, font, w })
|
||||
box.w += w
|
||||
}
|
||||
}
|
||||
}
|
||||
flushBox()
|
||||
if (line.boxes.length) endLine(true)
|
||||
return { lines, spaceW }
|
||||
}
|
||||
const headSize = b => Math.round(px * ({ 1: 1.5, 2: 1.35, 3: 1.2 }[b.l] || 1.08))
|
||||
const headLines = b => { const size = headSize(b), height = Math.round(size * 1.3); return { ...breakLines(b.r, cw, 0, size, true), height } }
|
||||
|
||||
const pages = [{ block: 0, line: 0, word: 0, after: true }]
|
||||
const chapStarts = [0]
|
||||
// Recorre el libro desde el inicio de una página. Sin items, anota dónde empieza cada página; con items, recoge lo
|
||||
// que hay que dibujar en esa página y termina.
|
||||
function* walk(cp, items) {
|
||||
let y = top, used = false, words = cp.word, after = cp.after
|
||||
const newPage = (block, line) => {
|
||||
if (items) return true
|
||||
pages.push({ block, line, word: words, after })
|
||||
y = top
|
||||
used = false
|
||||
return false
|
||||
}
|
||||
const remaining = () => bottom - y
|
||||
const placeLine = (l, { x, width, indent, center, justify, spaceW, height }) => {
|
||||
if (items) {
|
||||
const avail = width - (center ? 0 : indent)
|
||||
let gap = spaceW
|
||||
if (justify && !l.hard && l.boxes.length > 1) {
|
||||
const stretched = spaceW + (avail - l.w) / (l.boxes.length - 1)
|
||||
if (stretched < spaceW + px * 1.2) gap = stretched // una línea muy suelta se queda sin justificar
|
||||
}
|
||||
let cx = center ? x + (width - l.w) / 2 : x + indent
|
||||
const segs = []
|
||||
for (const b of l.boxes) {
|
||||
for (const f of b.frags) { segs.push({ t: f.t, font: f.font, x: cx }); cx += f.w }
|
||||
cx += gap
|
||||
}
|
||||
items.push({ line: segs, y: y + height * 0.78 })
|
||||
}
|
||||
y += height
|
||||
words += l.boxes.length
|
||||
used = true
|
||||
}
|
||||
for (let b = cp.block; b < flat.length; b++) {
|
||||
if (!items && !(b & 127)) yield
|
||||
const block = flat[b]
|
||||
const from = b === cp.block ? cp.line : 0
|
||||
if (block.k === 'div') {
|
||||
// División de capítulo: el capítulo sigue en la misma página si caben la línea, su título y algo de texto.
|
||||
const nx = flat[b + 1]
|
||||
let need = lineH * 5
|
||||
if (nx?.k === 'h') { const h = headLines(nx); need += h.lines.length * h.height }
|
||||
if (used && remaining() < need && newPage(b, 0)) return
|
||||
if (!items) chapStarts.push(pages.length - 1)
|
||||
if (used) {
|
||||
y += lineH * 1.2
|
||||
items?.push({ rule: true, y })
|
||||
y += lineH * 0.6
|
||||
}
|
||||
after = true
|
||||
continue
|
||||
}
|
||||
if (block.k === 'hr') {
|
||||
if (remaining() < lineH * 3 && used && newPage(b, 0)) return
|
||||
y += lineH * 0.7
|
||||
items?.push({ rule: true, y })
|
||||
y += lineH * 0.8
|
||||
used = true
|
||||
after = true
|
||||
continue
|
||||
}
|
||||
if (block.k === 'img') {
|
||||
if (!block.w || !block.h) continue
|
||||
const fit = Math.min(cw / block.w, (bottom - top) / block.h, 2)
|
||||
const w = block.w * fit, h = block.h * fit
|
||||
if (h > remaining() && used && newPage(b, 0)) return
|
||||
items?.push({ n: block.n, x: (W - w) / 2, y, w, h })
|
||||
y += h + lineH * 0.5
|
||||
used = true
|
||||
after = true
|
||||
continue
|
||||
}
|
||||
if (block.k === 'h') {
|
||||
const { lines, spaceW, height } = headLines(block)
|
||||
if (!from) {
|
||||
const need = lines.length * height + lineH * 3 + (used ? lineH * 1.2 : 0)
|
||||
if (used && remaining() < need && newPage(b, 0)) return // el título no se queda solo al pie
|
||||
if (used) y += lineH * 1.2
|
||||
}
|
||||
for (let i = from; i < lines.length; i++) {
|
||||
if (remaining() < height && used && newPage(b, i)) return
|
||||
placeLine(lines[i], { x: marginX, width: cw, indent: 0, center: true, justify: false, spaceW, height })
|
||||
}
|
||||
y += lineH * 0.8
|
||||
after = true
|
||||
continue
|
||||
}
|
||||
const quote = block.k === 'q'
|
||||
const verse = !!block.v
|
||||
const center = !!block.c
|
||||
const inset = quote ? Math.round(px * 1.4) : 0
|
||||
const width = cw - inset * 2
|
||||
const indent = !after && !quote && !verse && !center ? Math.round(px * 1.3) : 0
|
||||
const { lines, spaceW } = breakLines(block.r, width, indent, px, false)
|
||||
if (quote && used && !from) y += lineH * 0.3
|
||||
for (let i = from; i < lines.length;) {
|
||||
const fits = Math.floor(remaining() / lineH)
|
||||
let k = Math.min(fits, lines.length - i)
|
||||
if (!verse) {
|
||||
// Ni una primera línea sola al pie ni una última línea sola arriba de la página siguiente.
|
||||
if (i === 0 && k === 1 && lines.length > 1) k = 0
|
||||
else if (i + k === lines.length - 1) k = k >= 2 ? k - 1 : 0
|
||||
}
|
||||
if (k <= 0) {
|
||||
if (!used) k = Math.max(1, Math.min(fits, lines.length - i)) // no cabe ni en una página vacía: no dar vueltas
|
||||
else { if (newPage(b, i)) return; continue }
|
||||
}
|
||||
for (let n = 0; n < k; n++) placeLine(lines[i + n], { x: marginX + inset, width, indent: i + n === 0 ? indent : 0, center, justify: !verse && !center, spaceW, height: lineH })
|
||||
i += k
|
||||
if (i < lines.length && newPage(b, i)) return
|
||||
}
|
||||
if (quote) y += lineH * 0.3
|
||||
after = quote
|
||||
}
|
||||
}
|
||||
return {
|
||||
pages, chapStarts, chapBlock, flat, top, cw, family, footPx: Math.max(11, Math.round(px * 0.68)),
|
||||
// Reparte el libro cediendo el paso de vez en cuando, para que la pantalla no se quede congelada.
|
||||
async build() {
|
||||
let t = performance.now()
|
||||
for (const _ of walk(pages[0], null)) {
|
||||
if (performance.now() - t > 40) { await new Promise(r => setTimeout(r)); t = performance.now() }
|
||||
}
|
||||
},
|
||||
capture(i) {
|
||||
const items = []
|
||||
const run = walk(pages[i], items)
|
||||
while (!run.next().done);
|
||||
return items
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookText(mangaId) {
|
||||
if (bookCache?.id === mangaId) return bookCache.text
|
||||
const text = await api(`/api/book/${mangaId}/text`)
|
||||
bookCache = { id: mangaId, text }
|
||||
return text
|
||||
}
|
||||
|
||||
async function createBook(text, mangaId, size) {
|
||||
const opts = bookSettings()
|
||||
const colors = BOOK_PAPERS[opts.paper] || BOOK_PAPERS.light
|
||||
const { w: W, h: H } = size
|
||||
const key = JSON.stringify([mangaId, W, H, opts])
|
||||
let layout = bookLayoutCache?.key === key ? bookLayoutCache.layout : null
|
||||
if (!layout) {
|
||||
layout = makeBookLayout(text, W, H, opts)
|
||||
await layout.build()
|
||||
bookLayoutCache = { key, layout }
|
||||
}
|
||||
const { pages, chapStarts, chapBlock, flat } = layout
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 3, 4096 / Math.max(W, H))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.round(W * dpr)
|
||||
canvas.height = Math.round(H * dpr)
|
||||
const ctx = canvas.getContext('2d', { alpha: false })
|
||||
const urls = new Map()
|
||||
const waiting = new Map()
|
||||
const queue = []
|
||||
const images = new Map() // n -> { im, ready }
|
||||
let released = false, timer = 0
|
||||
const imageOf = n => {
|
||||
let e = images.get(n)
|
||||
if (!e) {
|
||||
const im = new Image()
|
||||
e = { im, ok: false, ready: new Promise(r => { im.onload = () => { e.ok = im.naturalWidth > 0; e.failed = !e.ok; r() }; im.onerror = () => { e.failed = true; r() } }) }
|
||||
images.set(n, e)
|
||||
im.src = `/api/book/${mangaId}/img/${n}`
|
||||
}
|
||||
return e
|
||||
}
|
||||
const chapterOfBlock = block => {
|
||||
let lo = 0, hi = chapBlock.length - 1
|
||||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapBlock[mid] <= block) lo = mid; else hi = mid - 1 }
|
||||
return lo
|
||||
}
|
||||
function draw(i, items) {
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.fillStyle = colors.bg
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillStyle = colors.fg
|
||||
let font = ''
|
||||
for (const it of items) {
|
||||
if (it.line) {
|
||||
for (const s of it.line) {
|
||||
if (s.font !== font) { ctx.font = font = s.font }
|
||||
ctx.fillText(s.t, s.x, it.y)
|
||||
}
|
||||
} else if (it.rule) {
|
||||
ctx.fillStyle = colors.dim
|
||||
ctx.fillRect(W / 2 - W * 0.08, it.y, W * 0.16, 1)
|
||||
ctx.fillStyle = colors.fg
|
||||
} else {
|
||||
const e = imageOf(it.n)
|
||||
if (e.ok) ctx.drawImage(e.im, it.x, it.y, it.w, it.h)
|
||||
}
|
||||
}
|
||||
// Título del capítulo arriba (salvo donde empieza) y número abajo: guías de lectura, no parte del texto.
|
||||
ctx.fillStyle = colors.dim
|
||||
ctx.textAlign = 'center'
|
||||
ctx.font = `${layout.footPx}px ${layout.family}`
|
||||
const p = pages[i]
|
||||
const c = chapterOfBlock(p.block)
|
||||
const title = text.chapters[c].title
|
||||
const atStart = p.line === 0 && (p.block === chapBlock[c] || (p.block === chapBlock[c] + 1 && flat[p.block].k === 'h'))
|
||||
if (!atStart && title && !/^Sección \d+$/.test(title)) {
|
||||
let t = title
|
||||
if (ctx.measureText(t).width > layout.cw) {
|
||||
while (t && ctx.measureText(`${t}…`).width > layout.cw) t = t.slice(0, -1)
|
||||
t += '…'
|
||||
}
|
||||
ctx.fillText(t, W / 2, layout.top - layout.footPx * 1.2)
|
||||
}
|
||||
ctx.fillText(`${i + 1} / ${pages.length}`, W / 2, H - layout.footPx * 1.4)
|
||||
}
|
||||
const pendingImages = items => items.filter(it => it.n != null && !images.get(it.n)?.ok && !images.get(it.n)?.failed).map(it => imageOf(it.n).ready)
|
||||
const settle = (i, url) => {
|
||||
if (!urls.has(i)) urls.set(i, url)
|
||||
for (const cb of waiting.get(i) || []) cb(urls.get(i))
|
||||
waiting.delete(i)
|
||||
}
|
||||
const forget = i => {
|
||||
const u = urls.get(i)
|
||||
if (u?.startsWith('blob:')) URL.revokeObjectURL(u)
|
||||
urls.delete(i)
|
||||
}
|
||||
async function tick() {
|
||||
timer = 0
|
||||
if (released || !queue.length) return
|
||||
if (book.busy?.()) { timer = setTimeout(tick, 150); return } // no dibujar mientras gira la hoja
|
||||
const i = queue.shift()
|
||||
if (urls.has(i)) { settle(i, urls.get(i)); timer = setTimeout(tick); return }
|
||||
const items = layout.capture(i)
|
||||
timer = -1 // ocupado: que nadie arranque otra vuelta mientras llegan las imágenes
|
||||
await Promise.all(pendingImages(items))
|
||||
if (released) return
|
||||
draw(i, items)
|
||||
canvas.toBlob(blob => {
|
||||
if (released) return
|
||||
if (blob && !urls.has(i)) settle(i, URL.createObjectURL(blob))
|
||||
else settle(i, urls.get(i) ?? book.src(i))
|
||||
timer = setTimeout(tick, 16)
|
||||
}, 'image/png')
|
||||
}
|
||||
const book = {
|
||||
count: pages.length, W, H, busy: null, onChange: null,
|
||||
// Síncrono: la página hace falta ya (la URL no cambia una vez entregada; identifica su textura). Si aún no han
|
||||
// llegado sus imágenes, se dibuja sin ellas y se avisa para cambiarla al llegar.
|
||||
src(i) {
|
||||
if (urls.has(i)) return urls.get(i)
|
||||
const items = layout.capture(i)
|
||||
const pending = pendingImages(items)
|
||||
draw(i, items)
|
||||
urls.set(i, canvas.toDataURL('image/png'))
|
||||
if (waiting.has(i)) settle(i, urls.get(i))
|
||||
if (pending.length) {
|
||||
Promise.all(pending).then(() => {
|
||||
if (released || !urls.has(i)) return
|
||||
urls.delete(i)
|
||||
book.onChange?.(i)
|
||||
})
|
||||
}
|
||||
return urls.get(i)
|
||||
},
|
||||
// Asíncrono: se dibuja cuando haya un momento libre, en el orden en que se pidió.
|
||||
whenReady(i, cb) {
|
||||
if (urls.has(i)) return cb(urls.get(i))
|
||||
if (!waiting.has(i)) { waiting.set(i, []); queue.push(i) }
|
||||
waiting.get(i).push(cb)
|
||||
if (!timer) timer = setTimeout(tick)
|
||||
},
|
||||
// Lo dibujado lejos de la página actual se suelta; si se vuelve, se dibuja otra vez.
|
||||
trim(center) {
|
||||
for (const i of [...urls.keys()]) if (Math.abs(i - center) > BOOK_KEEP_PAGES) forget(i)
|
||||
},
|
||||
near: (i, center) => Math.abs(i - center) <= BOOK_KEEP_PAGES,
|
||||
wordAt: i => pages[Math.min(i, pages.length - 1)]?.word ?? 0,
|
||||
pageAtWord(word) {
|
||||
let lo = 0, hi = pages.length - 1
|
||||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (pages[mid].word <= word) lo = mid; else hi = mid - 1 }
|
||||
return lo
|
||||
},
|
||||
chapterStart: c => chapStarts[c] ?? 0,
|
||||
// Si varios capítulos empiezan en una página, cuenta el último: ahí es donde sigue la lectura.
|
||||
chapterAt(p) {
|
||||
let lo = 0, hi = chapStarts.length - 1
|
||||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapStarts[mid] <= p) lo = mid; else hi = mid - 1 }
|
||||
return lo
|
||||
},
|
||||
chapterPages: c => Math.max(1, (chapStarts[c + 1] ?? pages.length) - chapStarts[c]),
|
||||
release() {
|
||||
released = true
|
||||
clearTimeout(timer)
|
||||
for (const i of [...urls.keys()]) forget(i)
|
||||
canvas.width = canvas.height = 0
|
||||
},
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
async function viewReader(mangaId, chapterId, startPage) {
|
||||
document.body.classList.add('reading')
|
||||
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
|
||||
@@ -1353,17 +1770,54 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
|
||||
const prev = idx > 0 ? chapters[idx - 1] : null
|
||||
const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
|
||||
const pages = chap.pages
|
||||
const total = pages.length
|
||||
let page = Math.min(Math.max(startPage >= 0 ? startPage : chap.savedPage || 0, 0), total - 1)
|
||||
let shown = [page] // páginas visibles (2 en doble página)
|
||||
const isPaged = () => settings.mode !== 'vertical'
|
||||
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight
|
||||
// Libro: las páginas salen de maquetar el texto para esta pantalla (con forma de página, para que nunca cuenten como anchas).
|
||||
const isBook = !!chap.text
|
||||
const noun = isBook ? 'libro' : 'manga'
|
||||
const bookSize = () => {
|
||||
const vw = window.innerWidth, vh = window.innerHeight
|
||||
return { w: Math.floor(Math.min(isDouble() ? vw / 2 : vw, vh * (vh < 600 ? 1.05 : 0.8))), h: vh }
|
||||
}
|
||||
let book = null
|
||||
if (isBook) {
|
||||
settings.fit = 'height'
|
||||
const opening = location.hash
|
||||
app.innerHTML = '<div class="reader"><div class="loading">Preparando el libro…</div></div>'
|
||||
book = await createBook(await loadBookText(mangaId), mangaId, bookSize())
|
||||
if (location.hash !== opening) { book.release(); return }
|
||||
book.busy = () => turning
|
||||
}
|
||||
const pages = chap.pages
|
||||
const pageSrc = book ? i => book.src(i) : i => pages[i]
|
||||
const total = book ? book.count : pages.length
|
||||
let wanted = startPage >= 0 ? startPage : chap.savedPage || 0
|
||||
if (book) {
|
||||
// El progreso de un libro se guarda por capítulo. Con otra pantalla o tamaño de letra un capítulo tiene otro
|
||||
// número de páginas: ir a la equivalente.
|
||||
const c = Math.max(idx, 0)
|
||||
const pagesHere = book.chapterPages(c)
|
||||
if (chap.savedTotal > 1 && chap.savedTotal !== pagesHere) wanted = Math.round((wanted / (chap.savedTotal - 1)) * (pagesHere - 1))
|
||||
wanted = book.chapterStart(c) + Math.min(wanted, pagesHere - 1)
|
||||
if (bookResume?.mangaId === mangaId) wanted = book.pageAtWord(bookResume.word)
|
||||
bookResume = null
|
||||
}
|
||||
let page = Math.min(Math.max(wanted, 0), total - 1)
|
||||
let shown = [page] // páginas visibles (2 en doble página)
|
||||
const lastShown = () => (isPaged() ? shown.at(-1) : page)
|
||||
|
||||
// Guardado del progreso (con debounce y al salir de la página).
|
||||
let saveTimer = null
|
||||
const payload = () => ({
|
||||
const bookPayload = (c, local, count) => ({
|
||||
mangaId, chapterId: chapters[c].id, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapters[c].num ?? null,
|
||||
chapterTitle: chapters[c].title, lang: manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
|
||||
})
|
||||
const payload = () => {
|
||||
if (!book) return mangaPayload()
|
||||
const p = lastShown(), c = book.chapterAt(p)
|
||||
return bookPayload(c, p - book.chapterStart(c), book.chapterPages(c))
|
||||
}
|
||||
const mangaPayload = () => ({
|
||||
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
|
||||
chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
|
||||
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
|
||||
@@ -1376,21 +1830,22 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
window.addEventListener('pagehide', beacon)
|
||||
saveNow()
|
||||
|
||||
const chapOpts = chapters.map(c => `<option value="${c.id}" ${c.id === chapterId ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
|
||||
let curChap = book ? book.chapterAt(page) : idx
|
||||
const chapOpts = chapters.map((c, i) => `<option value="${c.id}" ${i === curChap ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
|
||||
app.innerHTML = `
|
||||
<div class="reader">
|
||||
<div class="rbar top">
|
||||
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
|
||||
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
||||
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
||||
<button class="btn small" id="clipbtn" title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
|
||||
<button class="btn small" id="clipbtn" ${isBook ? 'hidden' : ''} title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
|
||||
<button class="btn small" id="modequick"></button>
|
||||
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
|
||||
</div>
|
||||
<div class="rpanel" hidden>
|
||||
<select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||||
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||
<select id="mode" title="Modo de lectura (se recuerda para este ${noun})">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="fit" ${isBook ? 'hidden' : ''} title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||||
<select id="dir" title="Dirección (se recuerda para este ${noun})"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||
<select id="anim" title="Animación al pasar página (modos por página)">${Object.entries(PAGE_ANIMS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="sfx" title="Sonido al pasar página (modos por página)">${Object.entries(SOUND_LEVELS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
@@ -1398,16 +1853,20 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
|
||||
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
|
||||
${OFFLINE_OK ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
|
||||
<p class="muted">Modo, ajuste y dirección se recuerdan para este manga. ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
${isBook ? `<select id="bsize" title="Tamaño de letra">${[80, 90, 100, 110, 120, 135, 150, 175].map(n => `<option value="${n}">Letra ${n}%</option>`).join('')}</select>
|
||||
<select id="bfont" title="Tipo de letra"><option value="serif">Con remates</option><option value="sans">Sin remates</option></select>
|
||||
<select id="bmargin" title="Márgenes"><option value="small">Margen pequeño</option><option value="mid">Margen medio</option><option value="large">Margen grande</option></select>
|
||||
<select id="bpaper" title="Papel"><option value="light">Papel claro</option><option value="sepia">Papel sepia</option><option value="dark">Papel oscuro</option></select>` : ''}
|
||||
${OFFLINE_OK && !isBook ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
|
||||
<p class="muted">${isBook ? 'Modo y dirección se recuerdan para este libro; la letra y el papel, en este dispositivo.' : 'Modo, ajuste y dirección se recuerdan para este manga.'} ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
</div>
|
||||
<div id="pages"></div>
|
||||
<div class="rbar bottom">
|
||||
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
|
||||
${book ? `<a class="btn small" id="bprev" href="#">${icon('prev')} Cap. ant.</a>` : prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
|
||||
<span id="pnum" class="muted"></span>
|
||||
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
|
||||
<button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
|
||||
${next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
|
||||
${book ? `<a class="btn small chnext" id="bnext" href="#">Cap. sig. ${icon('next')}</a>` : next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
const reader = $('.reader', app)
|
||||
@@ -1434,7 +1893,8 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
applyFilter()
|
||||
if ($('#saver')) $('#saver').checked = settings.saver
|
||||
if ($('#hd')) $('#hd').checked = settings.hd !== false
|
||||
const endBlock = `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
|
||||
const endBlock = book ? `<div class="chapter-end"><p class="muted">Fin del libro</p>
|
||||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>` : `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
|
||||
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
|
||||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
|
||||
|
||||
@@ -1443,12 +1903,53 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
if (manga.type) toast(`${manga.type}: ${READ_MODES[settings.mode].toLowerCase()}${settings.mode === 'vertical' ? '' : `, ${settings.dir === 'rtl' ? 'der → izq' : 'izq → der'}`}. Lo que cambies se recordará.`)
|
||||
}
|
||||
|
||||
// Libro: al cruzar a otro capítulo leyendo, los que quedan atrás cuentan como leídos; a uno lejano (la barra, el
|
||||
// selector) se salta sin marcar nada.
|
||||
let lastBookPage = page
|
||||
const syncBookNav = () => {
|
||||
$('#bprev').style.display = curChap > 0 || (shown[0] ?? page) > 0 ? '' : 'none'
|
||||
$('#bnext').style.display = curChap < chapters.length - 1 ? '' : 'none'
|
||||
$('#chap').value = chapters[curChap].id
|
||||
}
|
||||
const syncBookChapter = () => {
|
||||
const p = lastShown()
|
||||
book.trim(p)
|
||||
if (!isPaged()) {
|
||||
box.querySelectorAll('img[src]').forEach(im => {
|
||||
if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
|
||||
})
|
||||
}
|
||||
const c = book.chapterAt(p)
|
||||
if (c !== curChap) {
|
||||
if (c > curChap && Math.abs(p - lastBookPage) <= 2) {
|
||||
for (let j = curChap; j < c; j++) {
|
||||
const count = book.chapterPages(j)
|
||||
api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {})
|
||||
}
|
||||
}
|
||||
curChap = c
|
||||
history.replaceState(null, '', readUrl(mangaId, chapters[c].id))
|
||||
}
|
||||
lastBookPage = p
|
||||
syncBookNav()
|
||||
}
|
||||
const setPageLabel = () => {
|
||||
if (book) syncBookChapter()
|
||||
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
|
||||
slider.value = lastShown() + 1
|
||||
}
|
||||
let observer = null
|
||||
let atEnd = false
|
||||
// Libro: ¿la pantalla ya pide otro tamaño de página? La barra del navegador móvil al aparecer no cuenta.
|
||||
const sizeChanged = () => {
|
||||
const s = bookSize()
|
||||
return s.w !== book.W || Math.abs(s.h - book.H) > 120
|
||||
}
|
||||
function reopenBook() {
|
||||
bookResume = { mangaId, word: book.wordAt(shown[0] ?? page) }
|
||||
history.replaceState(null, '', readUrl(mangaId, chapters[curChap].id))
|
||||
router()
|
||||
}
|
||||
|
||||
// --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también.
|
||||
const wide = new Set()
|
||||
@@ -1479,9 +1980,10 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
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)
|
||||
const load = src => { im.src = src; im.decode?.().catch(() => {}) }
|
||||
if (book) book.whenReady(i, load) // un libro dibuja sus páginas poco a poco, sin frenar la lectura
|
||||
else load(pages[i])
|
||||
}
|
||||
for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i)
|
||||
scheduleLeafTextures()
|
||||
@@ -1517,7 +2019,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const stage = document.createElement('div')
|
||||
stage.className = 'stage'
|
||||
stage.classList.toggle('two', shown.length === 2)
|
||||
stage.innerHTML = shown.map((i, n) => `<img data-i="${i}" data-gutter="${gutterOf(i, n)}" src="${pages[i]}" alt="" draggable="false">`).join('')
|
||||
stage.innerHTML = shown.map((i, n) => `<img data-i="${i}" data-gutter="${gutterOf(i, n)}" src="${pageSrc(i)}" alt="" draggable="false">`).join('')
|
||||
const imgs = [...stage.querySelectorAll('img')]
|
||||
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
|
||||
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
|
||||
@@ -2100,7 +2602,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
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 im = preloaded.get(i) || Object.assign(new Image(), { src: pageSrc(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'
|
||||
@@ -2286,6 +2788,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
function render() {
|
||||
observer?.disconnect()
|
||||
const keep = lastShown()
|
||||
if (book && sizeChanged()) return reopenBook() // cambiar a doble página cambia el tamaño de página
|
||||
resetZoom()
|
||||
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
|
||||
renderModeQuick()
|
||||
@@ -2302,10 +2805,16 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
}
|
||||
page = keep
|
||||
shown = [keep]
|
||||
box.innerHTML = pages.map((src, i) => `<img data-i="${i}" ${i < page + 3 ? `src="${src}"` : `data-src="${src}"`} alt="">`).join('') + endBlock
|
||||
// Las páginas de un libro tienen tamaño conocido: no hace falta cargar las anteriores para saltar a la guardada.
|
||||
const near = i => (book ? Math.abs(i - page) <= 2 : i < page + 3)
|
||||
box.innerHTML = Array.from({ length: total }, (_, i) => `<img data-i="${i}" ${near(i) ? `src="${pageSrc(i)}"` : 'data-lazy="1"'} ${book ? `style="aspect-ratio:${book.W}/${book.H}"` : ''} alt="">`).join('') + endBlock
|
||||
box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
|
||||
const lazy = new IntersectionObserver(entries => entries.forEach(e => {
|
||||
if (e.isIntersecting && e.target.dataset.src) { e.target.src = e.target.dataset.src; delete e.target.dataset.src }
|
||||
if (!e.isIntersecting || !e.target.dataset.lazy) return
|
||||
delete e.target.dataset.lazy
|
||||
const i = Number(e.target.dataset.i)
|
||||
if (book) book.whenReady(i, url => { if (!e.target.dataset.lazy) e.target.src = url })
|
||||
else e.target.src = pageSrc(i)
|
||||
}), { rootMargin: '2000px 0px' })
|
||||
// No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0.
|
||||
let ready = false
|
||||
@@ -2317,7 +2826,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
imgs.forEach(img => { lazy.observe(img); observer.observe(img) })
|
||||
const lazyDisconnect = observer.disconnect.bind(observer)
|
||||
observer.disconnect = () => { lazyDisconnect(); lazy.disconnect() }
|
||||
const before = imgs.slice(0, page + 1).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) }))
|
||||
const before = (book ? [] : imgs.slice(0, page + 1)).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) }))
|
||||
Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => {
|
||||
imgs[page]?.scrollIntoView()
|
||||
lastY = window.scrollY
|
||||
@@ -2493,14 +3002,15 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
|
||||
const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx })
|
||||
const savePrefs = () => {
|
||||
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
|
||||
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message))
|
||||
return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
|
||||
.then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message))
|
||||
}
|
||||
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefs(); render() })
|
||||
// En un libro, volver a maquetar reabre el lector: antes hay que esperar a que el modo nuevo esté guardado.
|
||||
const savePrefsThenRender = () => { const saved = savePrefs(); if (book) saved.finally(render); else render() }
|
||||
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefsThenRender() })
|
||||
$('#modequick').addEventListener('click', () => {
|
||||
settings.mode = isPaged() ? 'vertical' : lastPaged
|
||||
savePrefs()
|
||||
render()
|
||||
savePrefsThenRender()
|
||||
})
|
||||
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
|
||||
$('#dir').addEventListener('change', e => {
|
||||
@@ -2523,7 +3033,21 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
e.target.closest('button').disabled = true
|
||||
try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false }
|
||||
})
|
||||
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
|
||||
$('#chap').addEventListener('change', e => {
|
||||
if (book) goTo(book.chapterStart(chapters.findIndex(c => c.id === e.target.value)))
|
||||
else location.hash = readUrl(mangaId, e.target.value)
|
||||
})
|
||||
if (book) {
|
||||
$('#bnext').addEventListener('click', e => { e.preventDefault(); goTo(book.chapterStart(curChap + 1)) })
|
||||
$('#bprev').addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
// Al inicio del capítulo actual o, si ya se está ahí, al anterior que empiece en otra página.
|
||||
let c = curChap
|
||||
while (c > 0 && book.chapterStart(c) >= (shown[0] ?? page)) c--
|
||||
goTo(book.chapterStart(c))
|
||||
})
|
||||
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }) }
|
||||
}
|
||||
$('#offone')?.addEventListener('click', e => {
|
||||
const b = e.target.closest('button')
|
||||
b.disabled = true
|
||||
@@ -2742,6 +3266,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const onResize = () => {
|
||||
clearTimeout(resizeTimer)
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (book && sizeChanged()) return reopenBook()
|
||||
if (!isPaged() || atEnd) return
|
||||
box.classList.toggle('double', isDouble())
|
||||
goTo(lastShown(), false, true)
|
||||
@@ -2749,8 +3274,19 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
if (book) {
|
||||
const opts = bookSettings()
|
||||
for (const [id, key] of [['bsize', 'size'], ['bfont', 'font'], ['bmargin', 'margin'], ['bpaper', 'paper']]) {
|
||||
$(`#${id}`).value = String(opts[key])
|
||||
$(`#${id}`).addEventListener('change', e => {
|
||||
store.set('bookSettings', { ...bookSettings(), [key]: key === 'size' ? Number(e.target.value) : e.target.value })
|
||||
reopenBook()
|
||||
})
|
||||
}
|
||||
}
|
||||
render()
|
||||
cleanup = () => {
|
||||
book?.release()
|
||||
freeLeafGL()
|
||||
endCrop()
|
||||
if (saveTimer) saveNow()
|
||||
@@ -2900,16 +3436,16 @@ async function viewLibrary(params) {
|
||||
: [null, null, []]
|
||||
if (!location.hash.startsWith('#/library')) return
|
||||
const online = !!hist
|
||||
const dev = OFFLINE_OK ? offByManga() : []
|
||||
const reading = (hist || []).filter(h => !h.finished)
|
||||
const finished = (hist || []).filter(h => h.finished)
|
||||
const follows = fol?.follows || []
|
||||
const dev = OFFLINE_OK ? offByManga().filter(m => wantsId(m.mangaId)) : []
|
||||
const reading = (hist || []).filter(h => !h.finished && wantsId(h.manga_id))
|
||||
const finished = (hist || []).filter(h => h.finished && wantsId(h.manga_id))
|
||||
const follows = (fol?.follows || []).filter(f => wantsId(f.manga_id))
|
||||
const tabs = [
|
||||
['reading', 'Leyendo', 'book', reading.length],
|
||||
['following', 'Siguiendo', 'bell', follows.length],
|
||||
['finished', 'Terminados', 'check', finished.length],
|
||||
...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []),
|
||||
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', l.count]),
|
||||
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado
|
||||
]
|
||||
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
|
||||
app.innerHTML = `<h1>Librería</h1>
|
||||
@@ -2952,7 +3488,7 @@ async function viewLibrary(params) {
|
||||
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «⋯ → En este dispositivo» guarda capítulos para leerlos sin internet.')
|
||||
} else if (curList) {
|
||||
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
|
||||
body.innerHTML = grid(l.items.map(it => libCard({
|
||||
body.innerHTML = grid(l.items.filter(it => wantsId(it.manga_id)).map(it => libCard({
|
||||
href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`,
|
||||
del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' },
|
||||
})), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».')
|
||||
@@ -3292,6 +3828,9 @@ function viewSettings() {
|
||||
<button class="btn" id="logout">${icon('logout')} Cerrar sesión</button></section>
|
||||
<section><b>Idioma preferido de los capítulos</b>
|
||||
<select id="lang">${Object.entries(LANGS).map(([k, v]) => `<option value="${k}" ${k === me.lang ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
|
||||
<section><b>${icon('book')} Qué mostrar</b>
|
||||
<p class="muted" style="margin:0;font-size:.88rem">Elige qué aparece en el inicio y en tu librería. No se borra nada: lo oculto vuelve al cambiar este ajuste.</p>
|
||||
<select id="contentmode">${[['both', 'Manga y libros'], ['manga', 'Solo manga'], ['books', 'Solo libros']].map(([k, v]) => `<option value="${k}" ${k === contentMode() ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
|
||||
<section><b>Cambiar contraseña</b>
|
||||
<form id="pw" style="display:grid;gap:8px">
|
||||
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
|
||||
@@ -3382,6 +3921,14 @@ function viewSettings() {
|
||||
})
|
||||
$('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() })
|
||||
$('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') })
|
||||
$('#contentmode').addEventListener('change', async e => {
|
||||
const prev = contentMode()
|
||||
try {
|
||||
await api('/api/settings', { method: 'POST', body: { contentMode: e.target.value } })
|
||||
me.content_mode = e.target.value
|
||||
toast('Guardado')
|
||||
} catch (err) { e.target.value = prev; toast(err.message) }
|
||||
})
|
||||
$('#pw').addEventListener('submit', async e => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user