428 lines
15 KiB
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.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>
|