panelzoom / reader.html
nagashi's picture
Upload reader.html
1e33904 verified
Raw
History Blame Contribute Delete
23.5 kB
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>PanelZoom</title>
<style>
:root{--bg:#0a0a0f;--sur:#13131a;--bor:#1e1e2e;--acc:#7c6aff;--acc2:#ff6b6b;--txt:#e8e6f0;--mut:#6b6880;}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--bg);color:var(--txt);font-family:'Segoe UI',system-ui,sans-serif;
height:100dvh;display:flex;flex-direction:column;overflow:hidden;user-select:none;-webkit-user-select:none;}
/* UPLOAD */
#up{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;gap:20px;}
.logo-icon{width:72px;height:72px;background:linear-gradient(135deg,var(--acc),var(--acc2));
border-radius:20px;display:flex;align-items:center;justify-content:center;font-size:36px;
box-shadow:0 8px 28px rgba(124,106,255,.3);}
h1{font-size:28px;font-weight:800;letter-spacing:-1px;
background:linear-gradient(135deg,var(--acc),var(--acc2));
-webkit-background-clip:text;-webkit-text-fill-color:transparent;}
.subtitle{color:var(--mut);font-size:13px;text-align:center;}
#space-row{width:100%;max-width:420px;display:flex;flex-direction:column;gap:6px;}
#space-row label{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);font-weight:700;}
#space-input{background:var(--sur);border:1px solid var(--bor);border-radius:10px;
padding:11px 13px;color:var(--txt);font-size:13px;outline:none;width:100%;font-family:monospace;}
#space-input:focus{border-color:var(--acc);}
#space-hint{font-size:11px;color:var(--mut);}
.drop{width:100%;max-width:420px;border:2px dashed var(--bor);border-radius:16px;
padding:38px 20px;text-align:center;cursor:pointer;transition:all .2s;background:var(--sur);
display:flex;flex-direction:column;align-items:center;gap:10px;}
.drop:hover,.drop.over{border-color:var(--acc);background:rgba(124,106,255,.06);}
.drop-icon{font-size:40px;opacity:.8;}
.drop h3{font-size:16px;font-weight:700;}
.drop p{font-size:12px;color:var(--mut);}
.badges{display:flex;gap:5px;flex-wrap:wrap;justify-content:center;}
.badge{background:var(--bor);border-radius:5px;padding:2px 8px;font-size:11px;
font-weight:700;color:var(--mut);font-family:monospace;}
#fi{display:none;}
/* READER */
#rd{display:none;flex:1;flex-direction:column;overflow:hidden;}
#topbar{display:flex;align-items:center;justify-content:space-between;
padding:8px 11px;background:var(--sur);border-bottom:1px solid var(--bor);gap:7px;min-height:48px;}
.tl{display:flex;align-items:center;gap:7px;min-width:0;}
.tr{display:flex;align-items:center;gap:5px;flex-shrink:0;}
.bi{width:33px;height:33px;border:none;background:var(--bor);color:var(--txt);
border-radius:8px;cursor:pointer;font-size:15px;display:flex;align-items:center;
justify-content:center;flex-shrink:0;transition:background .15s;}
.bi:hover{background:#2a2a3e;}
.bi:active{transform:scale(.92);}
#btitle{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;max-width:140px;}
#pinfo{font-size:12px;color:var(--mut);white-space:nowrap;}
.tg{display:flex;background:var(--bor);border-radius:7px;padding:2px;gap:2px;}
.tb{border:none;background:transparent;color:var(--mut);padding:3px 8px;
border-radius:5px;cursor:pointer;font-size:11px;font-weight:700;transition:all .15s;}
.tb.on{background:var(--acc);color:#fff;}
#ca{flex:1;position:relative;overflow:hidden;touch-action:none;background:#050508;}
#pc{position:absolute;transform-origin:top left;}
#dc{position:absolute;top:0;left:0;transform-origin:top left;pointer-events:none;display:none;}
#hl{position:absolute;border:2.5px solid var(--acc);border-radius:9px;
box-shadow:0 0 0 9999px rgba(0,0,0,.58);pointer-events:none;
transition:all .38s cubic-bezier(.25,.46,.45,.94);opacity:0;z-index:10;}
#strip{display:flex;gap:6px;padding:7px 11px;overflow-x:auto;background:#16161f;
border-top:1px solid var(--bor);min-height:66px;max-height:84px;
scrollbar-width:thin;scrollbar-color:var(--bor) transparent;}
.th{width:46px;height:62px;flex-shrink:0;border-radius:6px;overflow:hidden;
border:2px solid transparent;cursor:pointer;transition:border-color .2s;position:relative;}
.th.on{border-color:var(--acc);}
.th img{width:100%;height:100%;object-fit:cover;display:block;}
.thn{position:absolute;bottom:2px;right:2px;font-size:9px;
background:rgba(0,0,0,.65);padding:1px 3px;border-radius:3px;color:#fff;}
#bb{display:flex;align-items:center;justify-content:space-between;
padding:8px 11px;background:var(--sur);border-top:1px solid var(--bor);gap:8px;min-height:56px;}
.nb{width:43px;height:43px;border:none;background:var(--bor);color:var(--txt);
border-radius:9px;cursor:pointer;font-size:22px;display:flex;align-items:center;
justify-content:center;transition:all .15s;flex-shrink:0;}
.nb:hover{background:#2a2a3e;}
.nb:active{transform:scale(.9);}
.nb:disabled{opacity:.25;cursor:default;transform:none;}
#bprog{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:0;}
#bcnt{font-size:12px;color:var(--mut);text-align:center;}
#bdots{display:flex;gap:5px;flex-wrap:wrap;justify-content:center;max-width:200px;}
.dot{width:7px;height:7px;border-radius:50%;background:var(--bor);
transition:all .2s;cursor:pointer;}
.dot.v{background:var(--mut);}
.dot.c{background:var(--acc);transform:scale(1.5);}
#dbtn{background:linear-gradient(135deg,var(--acc),#5a4fcf);border:none;color:#fff;
padding:8px 13px;border-radius:8px;font-size:12px;font-weight:700;
cursor:pointer;white-space:nowrap;flex-shrink:0;transition:opacity .15s;}
#dbtn:hover{opacity:.84;}
#dbtn:disabled{opacity:.35;cursor:default;}
#so{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
justify-content:center;background:rgba(10,10,15,.88);z-index:50;gap:12px;
pointer-events:none;opacity:0;transition:opacity .25s;}
#so.on{opacity:1;pointer-events:all;}
.sp{width:36px;height:36px;border:3px solid var(--bor);border-top-color:var(--acc);
border-radius:50%;animation:spin .75s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
#stxt{font-size:13px;color:var(--mut);text-align:center;max-width:220px;}
#spb{width:160px;height:4px;background:var(--bor);border-radius:2px;overflow:hidden;}
#sbar{height:100%;background:var(--acc);border-radius:2px;transition:width .3s;width:0%;}
#toast{position:fixed;bottom:68px;left:50%;
transform:translateX(-50%) translateY(14px);
background:#1e1e2e;border:1px solid var(--bor);color:var(--txt);
padding:9px 16px;border-radius:9px;font-size:13px;font-weight:500;
opacity:0;pointer-events:none;transition:all .28s;
z-index:200;max-width:290px;text-align:center;}
#toast.on{opacity:1;transform:translateX(-50%) translateY(0);}
#toast.err{background:rgba(255,80,80,.12);border-color:var(--acc2);}
::-webkit-scrollbar{width:4px;height:4px;}
::-webkit-scrollbar-thumb{background:var(--bor);border-radius:4px;}
</style>
</head>
<body>
<!-- UPLOAD -->
<div id="up">
<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
<div class="logo-icon">πŸ“–</div>
<h1>PanelZoom</h1>
<p class="subtitle">DetecciΓ³n de globos con YOLOv8 Β· Gratis Β· Sin instalar nada</p>
</div>
<div id="space-row">
<label>Tu Hugging Face Space (usuario/nombre-del-space)</label>
<input id="space-input" type="text" placeholder="tunombre/panelzoom" spellcheck="false">
<p id="space-hint">Ej: <code>maria/panelzoom-detector</code> β€” lo configurΓ‘s una vez y queda guardado</p>
</div>
<div class="drop" id="drop">
<div class="drop-icon">πŸ“‚</div>
<h3>Abrir comic o manga</h3>
<p>ArrastrΓ‘ o tocΓ‘ para seleccionar</p>
<div class="badges">
<span class="badge">CBZ</span>
<span class="badge">ZIP</span>
<span class="badge">PDF</span>
</div>
</div>
<input type="file" id="fi" accept=".cbz,.zip,.pdf">
</div>
<!-- READER -->
<div id="rd">
<div id="topbar">
<div class="tl">
<button class="bi" id="backbtn">←</button>
<span id="btitle">Comic</span>
</div>
<div class="tr">
<span id="pinfo">1/1</span>
<div class="tg">
<button class="tb on" id="mc">Comic</button>
<button class="tb" id="mm">Manga</button>
</div>
<button class="bi" id="debugbtn" title="Ver detecciones">πŸ”¬</button>
</div>
</div>
<div id="ca">
<canvas id="pc"></canvas>
<canvas id="dc"></canvas>
<div id="hl"></div>
<div id="so">
<div class="sp"></div>
<div id="stxt">Cargando…</div>
<div id="spb"><div id="sbar"></div></div>
</div>
</div>
<div id="strip"></div>
<div id="bb">
<button class="nb" id="prevb">β€Ή</button>
<div id="bprog">
<div id="bcnt">TocΓ‘ πŸ” para detectar globos</div>
<div id="bdots"></div>
</div>
<button class="nb" id="nextb">β€Ί</button>
<button id="dbtn">πŸ” Detectar</button>
</div>
</div>
<div id="toast"></div>
<script type="module">
import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.min.mjs';
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.worker.min.mjs';
// ── JSZip ──
let JSZip = null;
async function getZip() {
if (JSZip) return JSZip;
for (const u of [
'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js',
]) {
try {
await new Promise((r,j)=>{ const s=document.createElement('script');
s.src=u;s.onload=r;s.onerror=j;document.head.appendChild(s); });
if(window.JSZip){JSZip=window.JSZip;return JSZip;}
} catch(_){}
}
throw new Error('No se pudo cargar JSZip');
}
// ── State ──
const S = {
pages:[], currentPage:0, bubbles:{}, currentBubble:-1,
mangaMode:false, zoom:1, panX:0, panY:0,
zoomMargin:28, animSpeed:360, spaceUrl:'',
};
// ── DOM ──
const $=id=>document.getElementById(id);
const up=$('up'),rd=$('rd'),drop=$('drop'),fi=$('fi');
const pc=$('pc'),dc=$('dc'),hl=$('hl'),ca=$('ca'),so=$('so');
const ctx=pc.getContext('2d',{willReadFrequently:true});
const dctx=dc.getContext('2d');
const strip=$('strip'),bcnt=$('bcnt'),bdots=$('bdots');
const stxt=$('stxt'),sbar=$('sbar'),toast=$('toast');
const dbtn=$('dbtn'),spaceInput=$('space-input');
// ── Space URL ──
const STORAGE_KEY = 'panelzoom_space';
try { spaceInput.value = localStorage.getItem(STORAGE_KEY) || ''; } catch(_){}
spaceInput.addEventListener('blur', () => {
try { localStorage.setItem(STORAGE_KEY, spaceInput.value.trim()); } catch(_){}
});
function getApiUrl() {
const val = spaceInput.value.trim();
if (!val) return null;
// Accepts "user/space" or full URL
if (val.startsWith('http')) return val;
return `https://${val.replace('/','-')}.hf.space/call/detect`;
}
// ── Toast ──
let toastT;
function toast_(msg, type='', dur=3000) {
clearTimeout(toastT);
toast.textContent=msg; toast.className='on'+(type?' '+type:'');
toastT=setTimeout(()=>toast.className='',dur);
}
// ── Status ──
function showS(t,p=-1){stxt.textContent=t;sbar.style.width=p>=0?p+'%':'0%';so.classList.add('on');}
function hideS(){so.classList.remove('on');}
function setProg(p){sbar.style.width=p+'%';}
// ── File open ──
drop.addEventListener('click',()=>fi.click());
drop.addEventListener('dragover',e=>{e.preventDefault();drop.classList.add('over');});
drop.addEventListener('dragleave',()=>drop.classList.remove('over'));
drop.addEventListener('drop',e=>{e.preventDefault();drop.classList.remove('over');
if(e.dataTransfer.files[0])openFile(e.dataTransfer.files[0]);});
fi.addEventListener('change',()=>{ if(fi.files[0])openFile(fi.files[0]); });
async function openFile(file) {
up.style.display='none'; rd.style.display='flex';
showS('Cargando…',0);
$('btitle').textContent = file.name.replace(/\.[^.]+$/,'');
S.pages=[];S.bubbles={};S.currentPage=0;S.currentBubble=-1;
strip.innerHTML='';
try {
if (file.name.toLowerCase().endsWith('.pdf')) await loadPDF(file);
else await loadCBZ(file);
buildStrip();
await renderPage(0);
hideS();
toast_(`βœ“ ${S.pages.length} pΓ‘ginas. TocΓ‘ πŸ” Detectar.`);
} catch(e) {
console.error(e);
toast_('Error: '+e.message,'err',5000);
goBack();
}
}
async function loadPDF(file) {
const pdf = await pdfjsLib.getDocument({data: await file.arrayBuffer()}).promise;
const n = pdf.numPages;
for (let i=1;i<=n;i++) {
showS(`Renderizando ${i}/${n}…`,(i/n)*100);
const page = await pdf.getPage(i);
const vp = page.getViewport({scale:2});
const c = document.createElement('canvas');
c.width=vp.width; c.height=vp.height;
await page.render({canvasContext:c.getContext('2d',{willReadFrequently:true}),viewport:vp}).promise;
S.pages.push({url:c.toDataURL('image/jpeg',.88),width:vp.width,height:vp.height});
}
}
async function loadCBZ(file) {
const Z = await getZip();
const zip = await Z.loadAsync(await file.arrayBuffer());
const imgRx = /\.(jpe?g|png|webp|gif|bmp)$/i;
const files = Object.values(zip.files)
.filter(f=>!f.dir&&imgRx.test(f.name))
.sort((a,b)=>a.name.localeCompare(b.name,undefined,{numeric:true}));
if(!files.length) throw new Error('No hay imΓ‘genes en el archivo.');
const n=files.length;
for(let i=0;i<n;i++){
showS(`Extrayendo ${i+1}/${n}…`,(i/n)*100);
const blob=await files[i].async('blob');
const url=URL.createObjectURL(blob);
const {w,h}=await imgDims(url);
S.pages.push({url,width:w,height:h});
}
}
function imgDims(url){
return new Promise((res,rej)=>{
const img=new Image();
img.onload=()=>res({w:img.naturalWidth,h:img.naturalHeight});
img.onerror=rej; img.src=url;
});
}
// ── Page strip ──
function buildStrip(){
strip.innerHTML='';
S.pages.forEach((p,i)=>{
const t=document.createElement('div');
t.className='th'+(i===0?' on':'');
t.innerHTML=`<img src="${p.url}" loading="lazy"><span class="thn">${i+1}</span>`;
t.addEventListener('click',()=>goToPage(i));
strip.appendChild(t);
});
}
function updateStrip(){
document.querySelectorAll('.th').forEach((t,i)=>t.classList.toggle('on',i===S.currentPage));
const el=strip.children[S.currentPage];
if(el)el.scrollIntoView({behavior:'smooth',block:'nearest',inline:'center'});
}
// ── Render ──
async function renderPage(i){
if(i<0||i>=S.pages.length)return;
S.currentPage=i; S.currentBubble=-1;
await new Promise(res=>{
const img=new Image();
img.onload=()=>{
pc.width=img.naturalWidth; pc.height=img.naturalHeight;
dc.width=pc.width; dc.height=pc.height;
ctx.drawImage(img,0,0);
if(S.debugMode)drawDebug();else dctx.clearRect(0,0,dc.width,dc.height);
fitPage();
$('pinfo').textContent=`${i+1}/${S.pages.length}`;
updateStrip(); updateBubbleUI(); res();
};
img.src=S.pages[i].url;
});
}
function fitPage(){
const aw=ca.clientWidth,ah=ca.clientHeight;
const s=Math.min(aw/pc.width,ah/pc.height);
S.zoom=s; S.panX=(aw-pc.width*s)/2; S.panY=(ah-pc.height*s)/2;
applyT(false); hl.style.opacity='0';
}
function applyT(anim=true){
const t=anim?`transform ${S.animSpeed}ms cubic-bezier(.25,.46,.45,.94)`:'none';
[pc,dc].forEach(el=>{
el.style.transition=t;
el.style.transform=`translate(${S.panX}px,${S.panY}px) scale(${S.zoom})`;
el.style.left='0';el.style.top='0';
});
}
function goToPage(i){renderPage(i);}
function nextPage(){if(S.currentPage<S.pages.length-1)renderPage(S.currentPage+1);}
function prevPage(){if(S.currentPage>0)renderPage(S.currentPage-1);}
// ── Bubble UI ──
function updateBubbleUI(){
const bs=S.bubbles[S.currentPage];
bdots.innerHTML='';
if(!bs||!bs.length){
bcnt.textContent='TocΓ‘ πŸ” Detectar para analizar la pΓ‘gina';
return;
}
bcnt.textContent=S.currentBubble===-1
?`${bs.length} globo${bs.length!==1?'s':''} detectado${bs.length!==1?'s':''}`
:`Globo ${S.currentBubble+1} de ${bs.length}`;
bs.forEach((_,i)=>{
const d=document.createElement('div');
d.className='dot'+(i<S.currentBubble?' v':'')+(i===S.currentBubble?' c':'');
d.addEventListener('click',()=>goToBubble(i));
bdots.appendChild(d);
});
}
function goToBubble(i){
const bs=S.bubbles[S.currentPage];
if(!bs||!bs[i])return;
S.currentBubble=i; zoomTo(bs[i]); updateBubbleUI();
}
function zoomTo(b){
const aw=ca.clientWidth,ah=ca.clientHeight,m=S.zoomMargin;
const bx=b.x*pc.width,by=b.y*pc.height,bw=b.w*pc.width,bh=b.h*pc.height;
const nz=Math.min((aw-m*2)/bw,(ah-m*2)/bh,5);
S.zoom=nz; S.panX=aw/2-(bx+bw/2)*nz; S.panY=ah/2-(by+bh/2)*nz;
applyT(true);
setTimeout(()=>{
hl.style.transition=`all ${S.animSpeed}ms cubic-bezier(.25,.46,.45,.94)`;
hl.style.left=(S.panX+bx*nz)+'px'; hl.style.top=(S.panY+by*nz)+'px';
hl.style.width=(bw*nz)+'px'; hl.style.height=(bh*nz)+'px';
hl.style.opacity='1';
},16);
}
$('nextb').addEventListener('click',()=>{
const bs=S.bubbles[S.currentPage];
if(!bs||!bs.length){nextPage();return;}
S.currentBubble<bs.length-1?goToBubble(S.currentBubble+1):nextPage();
});
$('prevb').addEventListener('click',()=>{
const bs=S.bubbles[S.currentPage];
if(!bs||!bs.length){prevPage();return;}
if(S.currentBubble>0)goToBubble(S.currentBubble-1);
else if(S.currentBubble===0){S.currentBubble=-1;fitPage();updateBubbleUI();}
else prevPage();
});
// ── DETECT via HF Space ──
dbtn.addEventListener('click', detectPage);
async function detectPage(){
const apiUrl = getApiUrl();
if (!apiUrl) {
toast_('IngresΓ‘ tu Space de Hugging Face arriba primero.','err');
goBack(); return;
}
if (S.detecting) return;
S.detecting=true; dbtn.disabled=true; dbtn.textContent='⏳ Detectando…';
showS('Enviando pΓ‘gina al Space…',15);
try {
// Encode current page as base64 JPEG
const tmpC=document.createElement('canvas');
tmpC.width=pc.width; tmpC.height=pc.height;
tmpC.getContext('2d').drawImage(pc,0,0);
const dataUrl=tmpC.toDataURL('image/jpeg',.82);
const b64=dataUrl.split(',')[1];
setProg(30);
// Call Gradio API (queued endpoint)
const queueRes = await fetch(apiUrl, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({
data: [b64, 0.30, 0.45, S.mangaMode]
})
});
if (!queueRes.ok) throw new Error(`HTTP ${queueRes.status} β€” ΒΏEl Space estΓ‘ corriendo?`);
const queueData = await queueRes.json();
const eventId = queueData.event_id;
if (!eventId) throw new Error('El Space no devolviΓ³ event_id. VerificΓ‘ que estΓ© activo.');
setProg(50);
// Poll for result
const resultUrl = apiUrl.replace('/call/detect', `/call/detect/${eventId}`);
let result = null;
for (let i=0; i<60; i++) {
await new Promise(r=>setTimeout(r,1200));
setProg(50 + i);
const ev = await fetch(resultUrl);
const text = await ev.text();
const lines = text.trim().split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
const parsed = JSON.parse(line.slice(5));
if (Array.isArray(parsed) && parsed[0]) {
result = JSON.parse(parsed[0]);
break;
}
}
}
if (result) break;
}
if (!result) throw new Error('Timeout β€” el Space tardΓ³ demasiado.');
if (!result.ok) throw new Error(result.error || 'Error desconocido en el Space.');
const bubbles = result.bubbles || [];
S.bubbles[S.currentPage] = bubbles;
S.currentBubble = -1;
if (S.debugMode) drawDebug();
if (!bubbles.length) toast_('No se detectaron globos en esta pΓ‘gina.');
else { toast_(`βœ“ ${bubbles.length} globo${bubbles.length!==1?'s':''} detectado${bubbles.length!==1?'s':''}`); goToBubble(0); }
updateBubbleUI();
} catch(e) {
toast_('Error: '+e.message,'err',5000);
console.error(e);
} finally {
S.detecting=false; dbtn.disabled=false; dbtn.textContent='πŸ” Detectar';
hideS();
}
}
// ── Debug overlay ──
function drawDebug(){
const bs=S.bubbles[S.currentPage];
dctx.clearRect(0,0,dc.width,dc.height);
if(!bs||!bs.length)return;
bs.forEach((b,i)=>{
const x=b.x*dc.width,y=b.y*dc.height,w=b.w*dc.width,h=b.h*dc.height;
dctx.strokeStyle=`hsl(${i*53%360},80%,60%)`;
dctx.lineWidth=3; dctx.strokeRect(x,y,w,h);
dctx.fillStyle=dctx.strokeStyle;
dctx.font='bold 14px sans-serif';
dctx.fillText(`${i+1}`,x+4,y+16);
});
}
$('debugbtn').addEventListener('click',()=>{
S.debugMode=!S.debugMode;
dc.style.display=S.debugMode?'block':'none';
$('debugbtn').style.background=S.debugMode?'var(--acc)':'';
if(S.debugMode)drawDebug();else dctx.clearRect(0,0,dc.width,dc.height);
});
// ── Manga mode ──
function setManga(m){
S.mangaMode=m;
$('mc').classList.toggle('on',!m); $('mm').classList.toggle('on',m);
}
$('mc').addEventListener('click',()=>setManga(false));
$('mm').addEventListener('click',()=>setManga(true));
// ── Back ──
$('backbtn').addEventListener('click',goBack);
function goBack(){
S.pages.forEach(p=>{try{URL.revokeObjectURL(p.url);}catch(_){}});
S.pages=[];S.bubbles={};
up.style.display='';rd.style.display='none';
hl.style.opacity='0';fi.value='';
}
// ── Keyboard ──
document.addEventListener('keydown',e=>{
if(up.style.display!=='none')return;
switch(e.key){
case 'ArrowRight':case ' ':e.preventDefault();$('nextb').click();break;
case 'ArrowLeft':e.preventDefault();$('prevb').click();break;
case 'ArrowUp':prevPage();break;
case 'ArrowDown':nextPage();break;
case 'f':fitPage();break;
case 'Escape':S.currentBubble=-1;fitPage();updateBubbleUI();break;
}
});
// ── Touch ──
let tx=0,ty=0,tt=0;
ca.addEventListener('touchstart',e=>{tx=e.touches[0].clientX;ty=e.touches[0].clientY;tt=Date.now();},{passive:true});
ca.addEventListener('touchend',e=>{
const dx=e.changedTouches[0].clientX-tx,dy=e.changedTouches[0].clientY-ty;
const dt=Date.now()-tt,d=Math.hypot(dx,dy);
if(dt<300&&d<12){$('nextb').click();return;}
if(dt<500&&Math.abs(dx)>55&&Math.abs(dx)>Math.abs(dy))
dx<0?$('nextb').click():$('prevb').click();
},{passive:true});
let lp=0;
ca.addEventListener('touchstart',e=>{if(e.touches.length===2)lp=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);},{passive:true});
ca.addEventListener('touchmove',e=>{
if(e.touches.length!==2)return;
const d=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);
const r=d/lp;lp=d;
const cx=(e.touches[0].clientX+e.touches[1].clientX)/2,cy=(e.touches[0].clientY+e.touches[1].clientY)/2;
S.zoom=Math.max(.1,Math.min(7,S.zoom*r));
S.panX=cx-(cx-S.panX)*r;S.panY=cy-(cy-S.panY)*r;
applyT(false);
},{passive:true});
ca.addEventListener('wheel',e=>{
e.preventDefault();
const r=e.deltaY<0?1.13:1/1.13,nz=Math.max(.1,Math.min(7,S.zoom*r));
const rect=ca.getBoundingClientRect();
S.panX=(e.clientX-rect.left)-(e.clientX-rect.left-S.panX)*(nz/S.zoom);
S.panY=(e.clientY-rect.top)-(e.clientY-rect.top-S.panY)*(nz/S.zoom);
S.zoom=nz; applyT(false);
},{passive:false});
new ResizeObserver(()=>{if(S.pages.length&&S.currentBubble===-1)fitPage();}).observe(ca);
</script>
</body>
</html>