ChoiPDFV-for-WebOS23/ChoiPDFv/index.html

428 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="screen-orientation" content="landscape" />
<title>Choi PDF Viewer</title>
<!-- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" /> -->
<link rel="stylesheet" href="webOSTVjs-1.2.10/fontawesome/css/all.min.css">
<style>
:root{
--primary-bg:#1e1e1e;--secondary-bg:#2d2d2d;--tertiary-bg:#3c3c3c;
--text-primary:#e0e0e0;--text-secondary:#a0a0a0;--accent:#0099ff;--border:#4a4a4a
}
/* webOS TV 전체 화면 설정 */
html,body{
margin:0;
padding:0;
width:100vw;
height:100vh;
min-height:100vh;
overflow:hidden;
background:var(--primary-bg);
color:var(--text-primary);
font-family:'Pretendard', sans-serif,system-ui,Roboto,-apple-system,sans-serif;
display:flex;
position:fixed;
top:0;
left:0;
right:0;
bottom:0;
}
/* webOS TV 해상도에 맞는 사이드바 크기 */
#file-sidebar,#page-sidebar{
width:420px;
max-width:25vw;
background:var(--secondary-bg);
display:flex;
flex-direction:column;
height:100vh;
}
#file-sidebar{border-right:1px solid var(--border)}
#page-sidebar{border-left:1px solid var(--border)}
main{
flex:1;
display:flex;
flex-direction:column;
position:relative;
height:100vh;
min-width:0;
}
#viewer-container{
flex:1;
display:flex;
justify-content:center;
align-items:center;
background:var(--primary-bg);
overflow:hidden;
touch-action:none;
user-select:none;
position:relative;
width:100%;
height:100%;
}
/* 확대 모드시 벽(좌상단) 붙이기 */
.zoomed #viewer-container{justify-content:flex-start!important;align-items:flex-start!important}
.sidebar-hidden{display:none!important}
#viewer-header,#viewer-footer{
position:absolute;
left:0;
right:0;
z-index:10;
display:flex;
justify-content:space-between;
align-items:center;
background:rgba(45,45,45,.9);
backdrop-filter:blur(5px);
padding:15px 20px;
font-size:1.1em;
}
#viewer-header{top:0;border-bottom:1px solid var(--border)}
#viewer-footer{bottom:0;border-top:1px solid var(--border)}
.nav-btn{background:none;border:0;color:var(--text-primary);font-size:2rem;cursor:pointer;padding:0 14px}
.nav-btn:disabled{color:#666;cursor:not-allowed}
.sidebar-header{
padding:18px 20px;
font-weight:700;
font-size:1.3em;
background:var(--tertiary-bg);
border-bottom:1px solid var(--border);
}
.sidebar-content{
flex:1;
overflow:auto;
height:calc(100vh - 70px);
}
.sidebar-content ul{margin:0;padding:0;list-style:none}
.sidebar-content li{
padding:22px 26px;
font-size:1.3em;
min-height:70px;
border-bottom:1.5px solid var(--border);
cursor:pointer;
display:flex;
align-items:center;
}
.sidebar-content li:hover{background:#383838}
.sidebar-content li.active{background:var(--accent);font-weight: 700;color:#fff}
#file-list .folder>.row{font-weight:700}
#file-list .file{padding-left:20px}
#file-list .row{display:flex;align-items:center;gap:10px}
#file-list .icon{width:18px;text-align:center;color:#9ad1ff}
canvas{
background:#fff;
box-shadow:0 15px 40px rgba(0,0,0,.4);
border-radius:8px;
will-change:transform;
max-width:calc(100vw - 900px);
max-height:calc(100vh - 150px);
}
</style>
</head>
<body>
<aside id="file-sidebar">
<div class="sidebar-header">PDF 목록</div>
<div class="sidebar-content"><ul id="file-list"></ul></div>
</aside>
<main>
<header id="viewer-header"><span id="file-name-display">파일을 선택하세요</span></header>
<div id="viewer-container"><canvas id="pdf-canvas"></canvas></div>
<footer id="viewer-footer">
<button id="prev-page-btn" class="nav-btn">이전페이지</button>
<span id="page-info-display">- / -</span>
<button id="next-page-btn" class="nav-btn">다음페이지</button>
</footer>
</main>
<aside id="page-sidebar">
<div class="sidebar-header">페이지</div>
<div class="sidebar-content"><ul id="page-list"></ul></div>
</aside>
<!-- 로컬 포함본: pdf.js(UMD) / hammer.js -->
<script src="webOSTVjs-1.2.10/pdf.js"></script>
<script src="webOSTVjs-1.2.10/hammer.min.js"></script>
<script>
// ---------- DOM refs ----------
const canvas = document.getElementById('pdf-canvas');
const ctx = canvas.getContext('2d');
const viewer = document.getElementById('viewer-container');
const fileListEl = document.getElementById('file-list');
const pageListEl = document.getElementById('page-list');
const fileNameEl = document.getElementById('file-name-display');
const pageInfoEl = document.getElementById('page-info-display');
const prevBtn = document.getElementById('prev-page-btn');
const nextBtn = document.getElementById('next-page-btn');
const fileSidebar = document.getElementById('file-sidebar');
const pageSidebar = document.getElementById('page-sidebar');
// ---------- PDF state ----------
let pdfDoc = null;
let currentPage = 1;
let currentFileName = '';
let scale = 1.0; // 렌더 스케일(품질)
let posX = 0, posY = 0; // CSS translate 위치
const SCALE_STEPS = [1.0, 1.5, 2.5, 3.0];
let scaleStep = 0;
// worker 경로
pdfjsLib.GlobalWorkerOptions.workerSrc = "webOSTVjs-1.2.10/pdf.worker.js";
// ---------- Helpers ----------
function setZoomUI(enabled){
if(enabled){
document.body.classList.add('zoomed');
fileSidebar.classList.add('sidebar-hidden');
pageSidebar.classList.add('sidebar-hidden');
}else{
document.body.classList.remove('zoomed');
fileSidebar.classList.remove('sidebar-hidden');
pageSidebar.classList.remove('sidebar-hidden');
}
}
function resetView(){
scale = 1.0; scaleStep = 0; posX = 0; posY = 0; setZoomUI(false);
}
function safeName(item){ return item.displayName || item.fileName || item.name || ''; }
function safeUri(item){ return item.uri || item.url || ''; }
// ---------- UI 업데이트 ----------
function updateUI(){
fileNameEl.textContent = currentFileName || '파일을 선택하세요';
if(!pdfDoc){
pageInfoEl.textContent = '- / -';
prevBtn.disabled = true; nextBtn.disabled = true;
return;
}
pageInfoEl.textContent = `${currentPage} / ${pdfDoc.numPages}`;
prevBtn.disabled = (currentPage<=1);
nextBtn.disabled = (currentPage>=pdfDoc.numPages);
[...pageListEl.children].forEach((li,i)=>li.classList.toggle('active', i+1===currentPage));
}
// ---------- 렌더링 ----------
async function renderPage(n){
if(!pdfDoc) return;
const page = await pdfDoc.getPage(n);
const viewport = page.getViewport({ scale });
canvas.width = viewport.width;
canvas.height = viewport.height;
// 위치 적용
canvas.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
await page.render({ canvasContext: ctx, viewport }).promise;
updateUI();
}
// ---------- 페이지 목록 ----------
function buildPageList(){
pageListEl.innerHTML = '';
for(let i=1;i<=pdfDoc.numPages;i++){
const li = document.createElement('li');
li.textContent = `페이지 ${i}`;
li.onclick = async () => { currentPage = i; resetView(); await renderPage(currentPage); };
pageListEl.appendChild(li);
}
updateUI();
}
// ---------- 파일 목록 (폴더/파일 혼합 지원) ----------
function renderTree(items, parent){
items.forEach(item=>{
if(item.type==='folder'){
const li = document.createElement('li'); li.className='folder';
const row = document.createElement('div'); row.className='row';
row.innerHTML = `<span class="icon"><i class="fa-solid fa-folder"></i></span><span>${safeName(item)}</span>`;
li.appendChild(row);
const ul = document.createElement('ul'); ul.style.display='none';
row.onclick = ()=>{ ul.style.display = ul.style.display==='none'?'block':'none'; };
li.appendChild(ul);
parent.appendChild(li);
renderTree(item.children||[], ul);
}else{
const uri = safeUri(item);
if(!uri){ console.warn('누락된 uri 항목', item); return; }
const li = document.createElement('li'); li.className='file';
const row = document.createElement('div'); row.className='row';
row.innerHTML = `<span class="icon"><i class="fa-solid fa-file-pdf"></i></span><span>${safeName(item)}</span>`;
li.appendChild(row);
row.onclick = async (e)=>{
[...fileListEl.querySelectorAll('li')].forEach(x=>x.classList.remove('active'));
li.classList.add('active');
await loadPdf(uri, safeName(item));
};
parent.appendChild(li);
}
});
}
async function loadFileList(){
try{
const res = await fetch('assets/pdf-list.json');
const data = await res.json();
fileListEl.innerHTML = '';
// 평면 배열도 허용: {fileName,uri}[]
const items = Array.isArray(data) ? data : [];
// 폴더/파일 혼합 트리 렌더
renderTree(items, fileListEl);
}catch(e){
console.error('파일 목록 로드 실패', e);
fileListEl.innerHTML = '<li>파일 목록을 불러올 수 없습니다.</li>';
}
}
// ---------- PDF 로드 ----------
async function loadPdf(url, fileName){
if(!url){ console.error('PDF url 없음'); return; }
try{
const loadingTask = pdfjsLib.getDocument({
url, cMapUrl: 'webOSTVjs-1.2.10/cmaps/', cMapPacked: true
});
pdfDoc = await loadingTask.promise;
currentFileName = fileName || decodeURIComponent(url.split('/').pop()||'');
currentPage = 1;
resetView();
buildPageList();
await renderPage(currentPage);
}catch(e){
console.error('PDF 로드 오류', e);
alert('PDF를 불러올 수 없습니다.');
}
}
// ---------- 버튼 네비 ----------
prevBtn.onclick = async ()=>{
if(!pdfDoc || currentPage<=1) return;
currentPage--; resetView(); await renderPage(currentPage);
};
nextBtn.onclick = async ()=>{
if(!pdfDoc || currentPage>=pdfDoc.numPages) return;
currentPage++; resetView(); await renderPage(currentPage);
};
// ---------- 제스처(Hammer) ----------
const hm = new Hammer.Manager(viewer);
const pinch = new Hammer.Pinch();
const pan = new Hammer.Pan({ direction: Hammer.DIRECTION_ALL, threshold: 0 });
const dTap = new Hammer.Tap({ event:'doubletap', taps:2 });
const swipe = new Hammer.Swipe({ direction: Hammer.DIRECTION_HORIZONTAL, velocity:0.3 });
hm.add([pinch, pan, dTap, swipe]);
// // 더블탭: 탭한 지점을 기준으로 단계 확대/축소
// hm.on("doubletap", async (ev) => {
// const oldScale = scale;
// currentScaleStep = (currentScaleStep + 1) % SCALE_STEPS.length;
// scale = SCALE_STEPS[currentScaleStep];
// const rect = viewerContainer.getBoundingClientRect();
// const tapX = ev.center.x - rect.left;
// const tapY = ev.center.y - rect.top;
// // 캔버스 중심 기준 좌표 변환
// const canvasCenterX = rect.width / 2 - posX;
// const canvasCenterY = rect.height / 2 - posY;
// const offsetX = (tapX - canvasCenterX) / oldScale;
// const offsetY = (tapY - canvasCenterY) / oldScale;
// // 새 스케일에서 터치 위치를 화면 중심으로 맞춤
// posX -= offsetX * (scale - oldScale);
// posY -= offsetY * (scale - oldScale);
// await renderPage(currentPageNum);
// });
// 더블탭: 탭한 지점을 기준으로 단계 확대/축소
hm.on('doubletap', async ev=>{
if(!pdfDoc) return;
const rect = viewer.getBoundingClientRect();
const tapX = ev.center.x - rect.left;
const tapY = ev.center.y - rect.top;
const oldScale = scale;
scaleStep = (scaleStep + 1) % SCALE_STEPS.length;
scale = SCALE_STEPS[scaleStep];
if(scale>1){
setZoomUI(true);
// 동일 화면 좌표를 유지하도록 pos 보정 (비율 기반)
// 새 pos = tap - (tap - oldPos) * (scale/oldScale)
const ratio = scale/oldScale;
posX = tapX - (tapX - posX) * ratio;
posY = tapY - (tapY - posY) * ratio;
}else{
resetView();
}
await renderPage(currentPage);
});
// 핀치: 끝에서만 고해상도 재렌더, 중간에는 CSS로 미리보기
let pinchStart = { scale:1, posX:0, posY:0, centerX:0, centerY:0 };
hm.on('pinchstart', ev=>{
pinchStart.scale = scale;
pinchStart.posX = posX; pinchStart.posY = posY;
const r = viewer.getBoundingClientRect();
pinchStart.centerX = ev.center.x - r.left;
pinchStart.centerY = ev.center.y - r.top;
setZoomUI(true);
});
hm.on('pinchmove', ev=>{
if(!pdfDoc) return;
const tmpScale = Math.min(Math.max(0.5, pinchStart.scale*ev.scale), 4);
const ratio = tmpScale/scale; // 현재 캔버스에 적용된 스케일 대비
// 임시 미리보기: 스케일은 CSS scale로, 기준점 근처 유지
const dx = pinchStart.centerX - posX;
const dy = pinchStart.centerY - posY;
const previewX = pinchStart.centerX - dx*ratio;
const previewY = pinchStart.centerY - dy*ratio;
canvas.style.transform = `translate(${previewX}px, ${previewY}px) scale(${ratio})`;
});
hm.on('pinchend', async ev=>{
if(!pdfDoc) return;
const newScale = Math.min(Math.max(0.5, pinchStart.scale*ev.scale), 4);
// 최종 pos 보정 (더블탭과 동일 로직)
const ratio = newScale/scale;
posX = pinchStart.centerX - (pinchStart.centerX - posX) * ratio;
posY = pinchStart.centerY - (pinchStart.centerY - posY) * ratio;
scale = newScale;
if(scale<=1){ resetView(); }
await renderPage(currentPage);
});
// 드래그: 확대 상태에서만 이동
let panStart = {x:0,y:0};
hm.on('panstart', ()=>{ panStart.x=posX; panStart.y=posY; });
hm.on('panmove', ev=>{
if(scale<=1 || !pdfDoc) return;
posX = panStart.x + ev.deltaX;
posY = panStart.y + ev.deltaY;
canvas.style.transform = `translate(${posX}px, ${posY}px)`;
});
// 스와이프: 확대가 아닐 때만 페이지 넘김
hm.on('swipeleft', async ()=>{
if(scale!==1 || !pdfDoc) return;
if(currentPage<pdfDoc.numPages){ currentPage++; resetView(); await renderPage(currentPage); }
});
hm.on('swiperight', async ()=>{
if(scale!==1 || !pdfDoc) return;
if(currentPage>1){ currentPage--; resetView(); await renderPage(currentPage); }
});
// ---------- 시작 ----------
window.onload = ()=>{ loadFileList(); updateUI(); };
</script>
</body>
</html>