Recortes con puntos en las esquinas y captura larga en lectura vertical
En lectura vertical la zona sigue creciendo al llegar al borde y une varias páginas en una sola imagen. El servidor admite hasta 8000 px y 64 MB, y la galería y el visor se adaptan a los recortes largos. La animación de la hoja dura 30 ms más. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -7,8 +7,8 @@ import os from 'node:os'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { BIN, MODELS, MODEL, run } from './upscale.js'
|
import { BIN, MODELS, MODEL, run } from './upscale.js'
|
||||||
|
|
||||||
const MAX_BYTES = 25 * 1024 * 1024
|
const MAX_BYTES = 64 * 1024 * 1024 // un recorte largo de lectura vertical en PNG
|
||||||
const MAX_SIDE = 4000
|
const MAX_SIDE = 8000 // a ×2 cabe en el máximo de WebP (16383 px)
|
||||||
const X4_MAX_SIDE = 1200 // a partir de aquí ×2: ×4 daría imágenes enormes sin ganar detalle
|
const X4_MAX_SIDE = 1200 // a partir de aquí ×2: ×4 daría imágenes enormes sin ganar detalle
|
||||||
const WEBP_QUALITY = 90
|
const WEBP_QUALITY = 90
|
||||||
|
|
||||||
|
|||||||
+119
-46
@@ -1969,7 +1969,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
const lift = curl.set(g, reverse, (spineLeft ? 1 : -1) * (reverse ? -1 : 1) * GRAB_TILT_Z * k, GRAB_TILT_X * k)
|
const lift = curl.set(g, reverse, (spineLeft ? 1 : -1) * (reverse ? -1 : 1) * GRAB_TILT_Z * k, GRAB_TILT_X * k)
|
||||||
drop.style.opacity = (lift * 0.8).toFixed(3)
|
drop.style.opacity = (lift * 0.8).toFixed(3)
|
||||||
}
|
}
|
||||||
dur = double ? 720 : 580
|
dur = double ? 750 : 610
|
||||||
auto = t => g0 + (g1 - g0) * t
|
auto = t => g0 + (g1 - g0) * t
|
||||||
// El borde libre va donde lo lleva el dedo: de su sitio al lomo (una página) o al otro lado (doble);
|
// El borde libre va donde lo lleva el dedo: de su sitio al lomo (una página) o al otro lado (doble);
|
||||||
// hacia atrás en una página, del lomo a su sitio.
|
// hacia atrás en una página, del lomo a su sitio.
|
||||||
@@ -2295,12 +2295,16 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
})
|
})
|
||||||
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
|
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
|
||||||
|
|
||||||
// --- Recortes: se arrastra un rectángulo sobre la página; se recorta del original (no de la versión HD)
|
// --- Recortes: se arrastra un rectángulo sobre la página y se ajusta con los puntos de sus esquinas. Se recorta
|
||||||
// y el servidor lo reescala aparte. El rectángulo se guarda en coordenadas del documento para
|
// del original (no de la versión HD) y el servidor lo reescala aparte. El rectángulo se guarda en coordenadas
|
||||||
// poder desplazarse (rueda) en vertical mientras se recorta.
|
// del documento: al llevar un punto al borde de la pantalla la lectura se desplaza y la zona sigue creciendo, y
|
||||||
|
// en lectura vertical puede abarcar varias páginas, que se unen en una sola imagen.
|
||||||
|
const CROP_MAX_SIDE = 8000 // el mismo límite que en el servidor (clips.js)
|
||||||
|
const CROP_MAX_AREA = 16e6 // por encima, Safari en iOS puede no generar el canvas
|
||||||
let crop = null
|
let crop = null
|
||||||
function endCrop() {
|
function endCrop() {
|
||||||
if (!crop) return
|
if (!crop) return
|
||||||
|
cancelAnimationFrame(crop.raf)
|
||||||
crop.ov.remove()
|
crop.ov.remove()
|
||||||
window.removeEventListener('scroll', crop.draw)
|
window.removeEventListener('scroll', crop.draw)
|
||||||
crop = null
|
crop = null
|
||||||
@@ -2313,86 +2317,155 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
$('.rpanel', reader).hidden = true
|
$('.rpanel', reader).hidden = true
|
||||||
const ov = document.createElement('div')
|
const ov = document.createElement('div')
|
||||||
ov.className = 'cropov'
|
ov.className = 'cropov'
|
||||||
ov.innerHTML = `<div class="crop-bar"><span>${icon('scissors')} Arrastra sobre el panel que quieres guardar</span>
|
ov.innerHTML = `<div class="crop-bar"><span>${icon('scissors')} <em>Arrastra sobre el panel que quieres guardar</em></span>
|
||||||
<button class="btn small" data-a="cancel">${icon('x')} Cancelar</button>
|
<button class="btn small" data-a="cancel">${icon('x')} Cancelar</button>
|
||||||
<button class="btn small primary" data-a="save" disabled>${icon('check')} Guardar</button></div>
|
<button class="btn small primary" data-a="save" disabled>${icon('check')} Guardar</button></div>
|
||||||
<div class="crop-rect" hidden></div>`
|
<div class="crop-rect" hidden>${['nw', 'ne', 'sw', 'se'].map(h => `<i class="crop-h" data-h="${h}"></i>`).join('')}</div>`
|
||||||
reader.append(ov)
|
reader.append(ov)
|
||||||
$('#clipbtn').classList.add('on')
|
$('#clipbtn').classList.add('on')
|
||||||
const rectEl = $('.crop-rect', ov)
|
const rectEl = $('.crop-rect', ov), saveBtn = $('[data-a=save]', ov), hint = $('.crop-bar em', ov)
|
||||||
crop = { ov, r: null, start: null }
|
crop = { ov, r: null, start: null, prev: null, pan: null, pid: null, off: { x: 0, y: 0 }, last: null, raf: 0 }
|
||||||
crop.draw = () => {
|
crop.draw = () => {
|
||||||
const r = crop?.r
|
const r = crop?.r
|
||||||
if (!r) return
|
if (!r) return
|
||||||
Object.assign(rectEl.style, { left: `${r.l - scrollX}px`, top: `${r.t - scrollY}px`, width: `${r.r - r.l}px`, height: `${r.b - r.t}px` })
|
Object.assign(rectEl.style, { left: `${r.l - scrollX}px`, top: `${r.t - scrollY}px`, width: `${r.r - r.l}px`, height: `${r.b - r.t}px` })
|
||||||
rectEl.hidden = false
|
rectEl.hidden = false
|
||||||
}
|
}
|
||||||
|
const canScroll = () => document.documentElement.scrollHeight > innerHeight + 1
|
||||||
|
// Lleva la esquina que se arrastra hasta el dedo; la opuesta (crop.start) se queda fija.
|
||||||
|
const stretch = () => {
|
||||||
|
const x = crop.last.x + scrollX + crop.off.x, y = crop.last.y + scrollY + crop.off.y
|
||||||
|
crop.r = { l: Math.min(x, crop.start.x), t: Math.min(y, crop.start.y), r: Math.max(x, crop.start.x), b: Math.max(y, crop.start.y) }
|
||||||
|
crop.draw()
|
||||||
|
}
|
||||||
|
// Con el dedo cerca del borde de arriba o de abajo, la lectura se desplaza y la zona sigue creciendo.
|
||||||
|
const EDGE = 80, EDGE_SPEED = 22
|
||||||
|
const autoScroll = () => {
|
||||||
|
if (!crop?.start) return
|
||||||
|
const y = crop.last.y
|
||||||
|
const v = y < EDGE ? -(EDGE - y) / EDGE : y > innerHeight - EDGE ? (y - innerHeight + EDGE) / EDGE : 0
|
||||||
|
if (v && crop.r) {
|
||||||
|
scrollBy(0, v * EDGE_SPEED)
|
||||||
|
stretch()
|
||||||
|
}
|
||||||
|
crop.raf = requestAnimationFrame(autoScroll)
|
||||||
|
}
|
||||||
window.addEventListener('scroll', crop.draw, { passive: true })
|
window.addEventListener('scroll', crop.draw, { passive: true })
|
||||||
ov.addEventListener('wheel', e => { e.preventDefault(); scrollBy(0, e.deltaY) }, { passive: false })
|
ov.addEventListener('wheel', e => { e.preventDefault(); scrollBy(0, e.deltaY) }, { passive: false })
|
||||||
ov.addEventListener('pointerdown', e => {
|
ov.addEventListener('pointerdown', e => {
|
||||||
if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0)) return
|
if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0) || crop.pid != null) return
|
||||||
|
const x = e.clientX + scrollX, y = e.clientY + scrollY, r = crop.r
|
||||||
|
const handle = e.target.closest('.crop-h')?.dataset.h
|
||||||
|
crop.pid = e.pointerId
|
||||||
ov.setPointerCapture(e.pointerId)
|
ov.setPointerCapture(e.pointerId)
|
||||||
crop.start = { x: e.clientX + scrollX, y: e.clientY + scrollY }
|
// Con el dedo la lectura no se puede desplazar de otro modo: deslizar dentro de la zona la mueve, para llegar
|
||||||
crop.r = null
|
// a las esquinas que quedan fuera de la pantalla.
|
||||||
rectEl.hidden = true
|
if (!handle && e.pointerType !== 'mouse' && r && canScroll() && x >= r.l && x <= r.r && y >= r.t && y <= r.b) {
|
||||||
$('[data-a=save]', ov).disabled = true
|
crop.pan = e.clientY
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (handle) {
|
||||||
|
const cx = handle[1] === 'w' ? r.l : r.r, cy = handle[0] === 'n' ? r.t : r.b
|
||||||
|
crop.start = { x: handle[1] === 'w' ? r.r : r.l, y: handle[0] === 'n' ? r.b : r.t }
|
||||||
|
crop.off = { x: cx - x, y: cy - y }
|
||||||
|
crop.prev = r
|
||||||
|
} else {
|
||||||
|
crop.start = { x, y }
|
||||||
|
crop.off = { x: 0, y: 0 }
|
||||||
|
crop.prev = null
|
||||||
|
crop.r = null
|
||||||
|
rectEl.hidden = true
|
||||||
|
saveBtn.disabled = true
|
||||||
|
}
|
||||||
|
crop.last = { x: e.clientX, y: e.clientY }
|
||||||
|
crop.raf = requestAnimationFrame(autoScroll)
|
||||||
})
|
})
|
||||||
ov.addEventListener('pointermove', e => {
|
ov.addEventListener('pointermove', e => {
|
||||||
|
if (e.pointerId !== crop.pid) return
|
||||||
|
if (crop.pan != null) {
|
||||||
|
scrollBy(0, crop.pan - e.clientY)
|
||||||
|
crop.pan = e.clientY
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!crop.start) return
|
if (!crop.start) return
|
||||||
const x = e.clientX + scrollX, y = e.clientY + scrollY
|
crop.last = { x: e.clientX, y: e.clientY }
|
||||||
crop.r = { l: Math.min(x, crop.start.x), t: Math.min(y, crop.start.y), r: Math.max(x, crop.start.x), b: Math.max(y, crop.start.y) }
|
stretch()
|
||||||
crop.draw()
|
|
||||||
})
|
})
|
||||||
const up = () => {
|
const up = e => {
|
||||||
if (!crop?.start) return
|
if (!crop || e.pointerId !== crop.pid) return
|
||||||
|
crop.pid = null
|
||||||
|
crop.pan = null
|
||||||
|
if (!crop.start) return
|
||||||
|
cancelAnimationFrame(crop.raf)
|
||||||
crop.start = null
|
crop.start = null
|
||||||
const ok = crop.r && crop.r.r - crop.r.l >= 12 && crop.r.b - crop.r.t >= 12
|
// Demasiado pequeña: al ajustar vuelve a como estaba; al marcarla de cero, no queda nada.
|
||||||
if (!ok) { crop.r = null; rectEl.hidden = true }
|
if (!crop.r || crop.r.r - crop.r.l < 12 || crop.r.b - crop.r.t < 12) crop.r = crop.prev
|
||||||
$('[data-a=save]', ov).disabled = !ok
|
if (crop.r) crop.draw()
|
||||||
|
else rectEl.hidden = true
|
||||||
|
saveBtn.disabled = !crop.r
|
||||||
|
if (crop.r) hint.textContent = canScroll() ? 'Mueve los puntos; llévalos al borde para seguir' : 'Ajusta la zona con los puntos'
|
||||||
}
|
}
|
||||||
ov.addEventListener('pointerup', up)
|
ov.addEventListener('pointerup', up)
|
||||||
ov.addEventListener('pointercancel', up)
|
ov.addEventListener('pointercancel', up)
|
||||||
$('[data-a=cancel]', ov).addEventListener('click', endCrop)
|
$('[data-a=cancel]', ov).addEventListener('click', endCrop)
|
||||||
$('[data-a=save]', ov).addEventListener('click', e => saveCrop(e.target.closest('button')))
|
saveBtn.addEventListener('click', e => saveCrop(e.target.closest('button')))
|
||||||
}
|
}
|
||||||
// La imagen bajo el rectángulo (la de mayor intersección) y la zona en fracciones de esa imagen.
|
// Las imágenes bajo el rectángulo y la zona de cada una, en píxeles de pantalla desde su esquina. Por páginas, solo
|
||||||
function cropTarget(r) {
|
// la de mayor intersección; en vertical, todas las que toca, de arriba abajo.
|
||||||
|
function cropParts(r) {
|
||||||
const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY }
|
const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY }
|
||||||
let best = null
|
const hits = []
|
||||||
for (const img of box.querySelectorAll('img')) {
|
for (const img of box.querySelectorAll('img')) {
|
||||||
if (!img.complete || !img.naturalWidth) continue
|
|
||||||
const b = img.getBoundingClientRect()
|
const b = img.getBoundingClientRect()
|
||||||
const w = Math.min(c.r, b.right) - Math.max(c.l, b.left), h = Math.min(c.b, b.bottom) - Math.max(c.t, b.top)
|
const w = Math.min(c.r, b.right) - Math.max(c.l, b.left), h = Math.min(c.b, b.bottom) - Math.max(c.t, b.top)
|
||||||
if (w > 0 && h > 0 && (!best || w * h > best.area)) best = { img, b, area: w * h }
|
if (w <= 0 || h <= 0) continue
|
||||||
|
hits.push({ img, b, area: w * h, x0: Math.max(c.l, b.left) - b.left, y0: Math.max(c.t, b.top) - b.top, x1: Math.min(c.r, b.right) - b.left, y1: Math.min(c.b, b.bottom) - b.top })
|
||||||
}
|
}
|
||||||
if (!best) return null
|
const ready = hit => hit.img.complete && hit.img.naturalWidth > 0
|
||||||
const { img, b } = best
|
if (isPaged()) {
|
||||||
const x0 = Math.max(c.l, b.left) - b.left, y0 = Math.max(c.t, b.top) - b.top
|
const loaded = hits.filter(ready)
|
||||||
const x1 = Math.min(c.r, b.right) - b.left, y1 = Math.min(c.b, b.bottom) - b.top
|
return loaded.length ? [loaded.reduce((a, h) => (h.area > a.area ? h : a))] : []
|
||||||
return { img, page: Number(img.dataset.i), fx: x0 / b.width, fy: y0 / b.height, fw: (x1 - x0) / b.width, fh: (y1 - y0) / b.height }
|
}
|
||||||
|
if (!hits.every(ready)) throw new Error('Espera a que carguen las páginas de la zona')
|
||||||
|
return hits
|
||||||
}
|
}
|
||||||
async function saveCrop(btn) {
|
async function saveCrop(btn) {
|
||||||
const t = crop?.r && cropTarget(crop.r)
|
|
||||||
if (!t) return toast('Marca una zona sobre la página')
|
|
||||||
btn.disabled = true
|
btn.disabled = true
|
||||||
try {
|
try {
|
||||||
const u = new URL(t.img.currentSrc || t.img.src, location.href)
|
const parts = crop?.r ? cropParts(crop.r) : []
|
||||||
u.searchParams.delete('hd') // recortar del original: el servidor lo reescala él mismo
|
if (!parts.length) throw new Error('Marca una zona sobre la página')
|
||||||
const src = await new Promise((resolve, reject) => {
|
// Del original: el servidor lo reescala él mismo.
|
||||||
|
const sources = await Promise.all(parts.map(({ img }) => new Promise((resolve, reject) => {
|
||||||
|
const u = new URL(img.currentSrc || img.src, location.href)
|
||||||
|
u.searchParams.delete('hd')
|
||||||
const im = new Image()
|
const im = new Image()
|
||||||
im.onload = () => resolve(im)
|
im.onload = () => resolve(im)
|
||||||
im.onerror = () => reject(new Error('No se pudo cargar la página'))
|
im.onerror = () => reject(new Error('No se pudo cargar la página'))
|
||||||
im.src = u.href
|
im.src = u.href
|
||||||
})
|
})))
|
||||||
const sx = Math.round(t.fx * src.naturalWidth), sy = Math.round(t.fy * src.naturalHeight)
|
// Misma escala para todas (la de la página con más detalle), una debajo de otra y sin los huecos de la lectura.
|
||||||
const sw = Math.max(1, Math.round(t.fw * src.naturalWidth)), sh = Math.max(1, Math.round(t.fh * src.naturalHeight))
|
const scale = Math.max(...parts.map((h, i) => sources[i].naturalWidth / h.b.width))
|
||||||
if (sw < 16 || sh < 16) throw new Error('La zona es demasiado pequeña')
|
const left = Math.min(...parts.map(h => h.b.left + h.x0)), right = Math.max(...parts.map(h => h.b.left + h.x1))
|
||||||
const k = Math.min(1, 4000 / Math.max(sw, sh))
|
const fullW = (right - left) * scale, fullH = parts.reduce((sum, h) => sum + h.y1 - h.y0, 0) * scale
|
||||||
|
if (fullW < 16 || fullH < 16) throw new Error('La zona es demasiado pequeña')
|
||||||
|
const k = scale * Math.min(1, CROP_MAX_SIDE / Math.max(fullW, fullH), Math.sqrt(CROP_MAX_AREA / (fullW * fullH)))
|
||||||
const cv = document.createElement('canvas')
|
const cv = document.createElement('canvas')
|
||||||
cv.width = Math.round(sw * k); cv.height = Math.round(sh * k)
|
cv.width = Math.round((right - left) * k)
|
||||||
cv.getContext('2d').drawImage(src, sx, sy, sw, sh, 0, 0, cv.width, cv.height)
|
cv.height = Math.round(parts.reduce((sum, h) => sum + h.y1 - h.y0, 0) * k)
|
||||||
|
const ctx = cv.getContext('2d')
|
||||||
|
ctx.imageSmoothingQuality = 'high'
|
||||||
|
if (parts.length > 1) { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, cv.width, cv.height) } // páginas de distinto ancho
|
||||||
|
let y = 0
|
||||||
|
parts.forEach((h, i) => {
|
||||||
|
const src = sources[i], fx = src.naturalWidth / h.b.width, fy = src.naturalHeight / h.b.height
|
||||||
|
const top = Math.round(y * k)
|
||||||
|
y += h.y1 - h.y0
|
||||||
|
ctx.drawImage(src, Math.round(h.x0 * fx), Math.round(h.y0 * fy), Math.max(1, Math.round((h.x1 - h.x0) * fx)), Math.max(1, Math.round((h.y1 - h.y0) * fy)),
|
||||||
|
Math.round((h.b.left + h.x0 - left) * k), top, Math.round((h.x1 - h.x0) * k), Math.round(y * k) - top)
|
||||||
|
})
|
||||||
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
|
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
|
||||||
const qs = new URLSearchParams({ mangaId, chapterId, page: t.page, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' })
|
if (!blob) throw new Error('La zona es demasiado grande para este dispositivo')
|
||||||
|
const qs = new URLSearchParams({ mangaId, chapterId, page: parts[0].img.dataset.i, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' })
|
||||||
const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob })
|
const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob })
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
||||||
@@ -2490,7 +2563,7 @@ async function viewClips(params) {
|
|||||||
const src = c.hd && showHd ? c.hd : c.img
|
const src = c.hd && showHd ? c.hd : c.img
|
||||||
const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}`
|
const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}`
|
||||||
lb.innerHTML = `
|
lb.innerHTML = `
|
||||||
<div class="lb-img"><img src="${src}" alt=""></div>
|
<div class="lb-img${c.height / c.width > 2.5 ? ' long' : ''}"><img src="${src}" alt=""></div>
|
||||||
<div class="lb-side">
|
<div class="lb-side">
|
||||||
<div class="row" style="justify-content:space-between">
|
<div class="row" style="justify-content:space-between">
|
||||||
<span class="muted">${i + 1} / ${data.clips.length}</span>
|
<span class="muted">${i + 1} / ${data.clips.length}</span>
|
||||||
|
|||||||
+11
-1
@@ -300,12 +300,19 @@ input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; m
|
|||||||
box-shadow: 0 6px 24px rgba(0,0,0,.5); cursor: default; white-space: nowrap; max-width: calc(100vw - 24px); }
|
box-shadow: 0 6px 24px rgba(0,0,0,.5); cursor: default; white-space: nowrap; max-width: calc(100vw - 24px); }
|
||||||
.crop-bar span { font-size: .85rem; color: var(--muted); display: inline-flex; gap: 6px; align-items: center; overflow: hidden; text-overflow: ellipsis; }
|
.crop-bar span { font-size: .85rem; color: var(--muted); display: inline-flex; gap: 6px; align-items: center; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.crop-rect { position: fixed; border: 2px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, .45); pointer-events: none; }
|
.crop-rect { position: fixed; border: 2px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, .45); pointer-events: none; }
|
||||||
|
.crop-h { position: absolute; width: 44px; height: 44px; pointer-events: auto; touch-action: none; }
|
||||||
|
.crop-h::after { content: ''; position: absolute; left: 15px; top: 15px; width: 14px; height: 14px; box-sizing: border-box; border-radius: 50%; background: var(--accent); border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.6); }
|
||||||
|
.crop-h[data-h="nw"] { left: -23px; top: -23px; cursor: nwse-resize; }
|
||||||
|
.crop-h[data-h="ne"] { right: -23px; top: -23px; cursor: nesw-resize; }
|
||||||
|
.crop-h[data-h="sw"] { left: -23px; bottom: -23px; cursor: nesw-resize; }
|
||||||
|
.crop-h[data-h="se"] { right: -23px; bottom: -23px; cursor: nwse-resize; }
|
||||||
|
.crop-bar em { font-style: normal; }
|
||||||
@media (max-width: 560px) { .crop-bar span { display: none; } }
|
@media (max-width: 560px) { .crop-bar span { display: none; } }
|
||||||
|
|
||||||
/* Recortes: galería */
|
/* Recortes: galería */
|
||||||
.clips { columns: 220px; column-gap: 12px; }
|
.clips { columns: 220px; column-gap: 12px; }
|
||||||
.clip { break-inside: avoid; margin: 0 0 12px; background: var(--surface); border-radius: var(--radius); overflow: hidden; cursor: pointer; }
|
.clip { break-inside: avoid; margin: 0 0 12px; background: var(--surface); border-radius: var(--radius); overflow: hidden; cursor: pointer; }
|
||||||
.clip img { display: block; width: 100%; height: auto; background: var(--surface-2); }
|
.clip img { display: block; width: 100%; height: auto; max-height: 520px; object-fit: cover; object-position: top; background: var(--surface-2); }
|
||||||
.clip figcaption { padding: 6px 10px 8px; display: grid; font-size: .8rem; }
|
.clip figcaption { padding: 6px 10px 8px; display: grid; font-size: .8rem; }
|
||||||
.clip figcaption b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; font-size: .85rem; }
|
.clip figcaption b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; font-size: .85rem; }
|
||||||
.clip figcaption span { color: var(--muted); }
|
.clip figcaption span { color: var(--muted); }
|
||||||
@@ -316,6 +323,9 @@ input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; m
|
|||||||
.lightbox[hidden] { display: none; }
|
.lightbox[hidden] { display: none; }
|
||||||
.lb-img { display: flex; align-items: center; justify-content: center; padding: 16px; min-height: 0; overflow: auto; }
|
.lb-img { display: flex; align-items: center; justify-content: center; padding: 16px; min-height: 0; overflow: auto; }
|
||||||
.lb-img img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; }
|
.lb-img img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; }
|
||||||
|
/* Recorte largo de lectura vertical: a su ancho y con desplazamiento; entero en pantalla sería una tira ilegible. */
|
||||||
|
.lb-img.long { align-items: flex-start; }
|
||||||
|
.lb-img.long img { max-height: none; height: auto; flex: none; }
|
||||||
.lb-side { background: var(--surface); padding: 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; }
|
.lb-side { background: var(--surface); padding: 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; }
|
||||||
.lb-side h2 a { color: var(--text); }
|
.lb-side h2 a { color: var(--text); }
|
||||||
body.noscroll { overflow: hidden; }
|
body.noscroll { overflow: hidden; }
|
||||||
|
|||||||
Reference in New Issue
Block a user