488 lines
17 KiB
HTML
488 lines
17 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">
|
|
<title>Modern 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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<style>
|
|
:root {
|
|
--primary-bg: #1e1e1e;
|
|
--secondary-bg: #2d2d2d;
|
|
--tertiary-bg: #3c3c3c;
|
|
--text-primary: #e0e0e0;
|
|
--text-secondary: #a0a0a0;
|
|
--accent-color: #0099ff;
|
|
--border-color: #4a4a4a;
|
|
}
|
|
|
|
body, html {
|
|
margin: 0;
|
|
padding: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
font-family: 'Noto Sans KR', sans-serif;
|
|
background: var(--primary-bg);
|
|
color: var(--text-primary);
|
|
display: flex;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* --- 레이아웃 --- */
|
|
#file-sidebar {
|
|
width: 300px;
|
|
background: var(--secondary-bg);
|
|
border-right: 1px solid var(--border-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
main {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: relative;
|
|
}
|
|
|
|
#viewer-container {
|
|
flex: 1;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
background: var(--primary-bg);
|
|
overflow: hidden;
|
|
touch-action: none;
|
|
user-select: none;
|
|
}
|
|
|
|
#page-sidebar {
|
|
width: 250px;
|
|
background: var(--secondary-bg);
|
|
border-left: 1px solid var(--border-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* 세로 모드 (Portrait) 레이아웃 */
|
|
body.portrait {
|
|
flex-direction: column;
|
|
}
|
|
body.portrait #file-sidebar, body.portrait #page-sidebar {
|
|
width: 100%;
|
|
border: none;
|
|
border-bottom: 1px solid var(--border-color);
|
|
max-height: 25vh; /* 세로 모드에서는 사이드바 높이 제한 */
|
|
}
|
|
body.portrait main {
|
|
flex: 1;
|
|
}
|
|
|
|
|
|
/* --- 헤더 및 푸터 (파일 정보, 페이지네이션) --- */
|
|
#viewer-header, #viewer-footer {
|
|
background: rgba(45, 45, 45, 0.9);
|
|
padding: 12px 20px;
|
|
text-align: center;
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 10;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
backdrop-filter: blur(5px);
|
|
}
|
|
#viewer-header {
|
|
top: 0;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
#viewer-footer {
|
|
bottom: 0;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
#file-name-display {
|
|
font-size: 1.1em;
|
|
font-weight: 500;
|
|
}
|
|
#page-info-display {
|
|
font-size: 1.1em;
|
|
}
|
|
.nav-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
font-size: 2em; /* 터치를 위해 아이콘 키움 */
|
|
cursor: pointer;
|
|
padding: 0 15px;
|
|
transition: color 0.2s;
|
|
}
|
|
.nav-btn:hover {
|
|
color: var(--accent-color);
|
|
}
|
|
.nav-btn:disabled {
|
|
color: var(--tertiary-bg);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* --- 사이드바 콘텐츠 --- */
|
|
.sidebar-header {
|
|
padding: 20px;
|
|
font-size: 1.4em;
|
|
font-weight: 700;
|
|
background: var(--tertiary-bg);
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
.sidebar-content {
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
}
|
|
.sidebar-content ul {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.sidebar-content li {
|
|
padding: 15px 20px;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid var(--border-color);
|
|
transition: background-color 0.2s;
|
|
font-size: 1.1em;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.sidebar-content li:hover {
|
|
background: var(--tertiary-bg);
|
|
}
|
|
/* 현재 선택된 항목 스타일 */
|
|
.sidebar-content li.active {
|
|
background-color: var(--accent-color);
|
|
color: white;
|
|
font-weight: 700;
|
|
}
|
|
.sidebar-content li.active:hover {
|
|
background-color: #007acc;
|
|
}
|
|
|
|
|
|
/* 파일 목록 폴더/파일 아이콘 */
|
|
#file-list .folder > .item-content::before {
|
|
font-family: "Font Awesome 6 Free";
|
|
content: '\f07b'; /* folder icon */
|
|
margin-right: 15px;
|
|
font-weight: 900;
|
|
}
|
|
#file-list .file > .item-content::before {
|
|
font-family: "Font Awesome 6 Free";
|
|
content: '\f15c'; /* file-alt icon */
|
|
margin-right: 15px;
|
|
font-weight: 900;
|
|
}
|
|
/* 폴더 구조 들여쓰기 */
|
|
#file-list ul {
|
|
padding-left: 20px;
|
|
}
|
|
|
|
|
|
/* --- 캔버스 --- */
|
|
canvas {
|
|
background: white;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
|
transition: transform 0.1s ease-out;
|
|
}
|
|
|
|
</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"><i class="fas fa-chevron-left"></i></button>
|
|
<span id="page-info-display">- / -</span>
|
|
<button id="next-page-btn" class="nav-btn"><i class="fas fa-chevron-right"></i></button>
|
|
</footer>
|
|
</main>
|
|
|
|
<aside id="page-sidebar">
|
|
<div class="sidebar-header">페이지</div>
|
|
<div class="sidebar-content">
|
|
<ul id="page-list"></ul>
|
|
</div>
|
|
</aside>
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js"></script>
|
|
|
|
<script>
|
|
// 전역 변수 및 DOM 요소
|
|
const canvas = document.getElementById('pdf-canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const viewerContainer = document.getElementById('viewer-container');
|
|
const pageListEl = document.getElementById('page-list');
|
|
const fileListEl = document.getElementById('file-list');
|
|
const fileNameDisplay = document.getElementById('file-name-display');
|
|
const pageInfoDisplay = document.getElementById('page-info-display');
|
|
const prevPageBtn = document.getElementById('prev-page-btn');
|
|
const nextPageBtn = document.getElementById('next-page-btn');
|
|
|
|
let pdfDoc = null;
|
|
let currentPageNum = 1;
|
|
let currentFileName = '';
|
|
let scale = 1.0;
|
|
let posX = 0, posY = 0;
|
|
const SCALE_STEPS = [1.0, 1.5, 2.5];
|
|
let currentScaleStep = 0;
|
|
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js`;
|
|
|
|
// ===================================================================
|
|
// 1. 페이지 이동 시 스케일 100%로 초기화
|
|
// ===================================================================
|
|
function resetScaleAndPosition() {
|
|
scale = 1.0;
|
|
currentScaleStep = 0;
|
|
posX = 0;
|
|
posY = 0;
|
|
}
|
|
|
|
// ===================================================================
|
|
// 2. 현재 페이지를 페이지 목록에 가독성 있게 표시
|
|
// ===================================================================
|
|
function updateActivePageLink() {
|
|
const pageLinks = pageListEl.querySelectorAll('li');
|
|
pageLinks.forEach((link, index) => {
|
|
if (index + 1 === currentPageNum) {
|
|
link.classList.add('active');
|
|
// 현재 페이지가 보이도록 스크롤
|
|
link.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
} else {
|
|
link.classList.remove('active');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ===================================================================
|
|
// 3. 파일 이름 및 페이지 번호 표시
|
|
// ===================================================================
|
|
function updateViewerInfo() {
|
|
fileNameDisplay.textContent = currentFileName;
|
|
if(pdfDoc) {
|
|
pageInfoDisplay.textContent = `${currentPageNum} / ${pdfDoc.numPages}`;
|
|
} else {
|
|
pageInfoDisplay.textContent = `- / -`;
|
|
}
|
|
}
|
|
|
|
// PDF 로드 함수
|
|
async function loadPdf(url, fileName) {
|
|
try {
|
|
const loadingTask = pdfjsLib.getDocument({ url });
|
|
pdfDoc = await loadingTask.promise;
|
|
currentFileName = fileName;
|
|
currentPageNum = 1;
|
|
|
|
resetScaleAndPosition();
|
|
generatePageList();
|
|
renderPage(currentPageNum);
|
|
updateViewerInfo();
|
|
} catch (err) {
|
|
alert("PDF를 불러올 수 없습니다.");
|
|
console.error("PDF 로드 오류:", err);
|
|
}
|
|
}
|
|
|
|
// 페이지 렌더링 함수
|
|
async function renderPage(num) {
|
|
if (!pdfDoc) return;
|
|
|
|
const page = await pdfDoc.getPage(num);
|
|
const viewport = page.getViewport({ scale: scale });
|
|
canvas.width = viewport.width;
|
|
canvas.height = viewport.height;
|
|
|
|
const renderContext = {
|
|
canvasContext: ctx,
|
|
viewport: viewport
|
|
};
|
|
await page.render(renderContext).promise;
|
|
|
|
// 위치 초기화 후 적용
|
|
canvas.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
|
|
|
|
updateActivePageLink();
|
|
updateViewerInfo();
|
|
|
|
// 버튼 상태 업데이트
|
|
prevPageBtn.disabled = (currentPageNum <= 1);
|
|
nextPageBtn.disabled = (currentPageNum >= pdfDoc.numPages);
|
|
}
|
|
|
|
// 페이지 목록 생성
|
|
function generatePageList() {
|
|
pageListEl.innerHTML = '';
|
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
|
const li = document.createElement('li');
|
|
li.textContent = `페이지 ${i}`;
|
|
li.dataset.pageNum = i;
|
|
li.onclick = () => {
|
|
currentPageNum = i;
|
|
resetScaleAndPosition(); // 페이지 목록 클릭 시 스케일 초기화
|
|
renderPage(currentPageNum);
|
|
};
|
|
pageListEl.appendChild(li);
|
|
}
|
|
}
|
|
|
|
// 네비게이션 버튼 이벤트
|
|
prevPageBtn.onclick = () => {
|
|
if (currentPageNum <= 1) return;
|
|
currentPageNum--;
|
|
resetScaleAndPosition(); // 페이지 이동 시 스케일 초기화
|
|
renderPage(currentPageNum);
|
|
};
|
|
nextPageBtn.onclick = () => {
|
|
if (currentPageNum >= pdfDoc.numPages) return;
|
|
currentPageNum++;
|
|
resetScaleAndPosition(); // 페이지 이동 시 스케일 초기화
|
|
renderPage(currentPageNum);
|
|
};
|
|
|
|
// ===================================================================
|
|
// 4. PDF 파일 목록을 폴더 구조로 표시 및 현재 파일 강조
|
|
// ===================================================================
|
|
function createFileList(items, container) {
|
|
items.forEach(item => {
|
|
const li = document.createElement('li');
|
|
|
|
// 터치 영역을 넓히기 위해 내부 div 추가
|
|
const itemContent = document.createElement('div');
|
|
itemContent.className = 'item-content';
|
|
itemContent.textContent = item.name;
|
|
li.appendChild(itemContent);
|
|
|
|
if (item.type === 'folder') {
|
|
li.classList.add('folder');
|
|
if (item.children) {
|
|
const sublist = document.createElement('ul');
|
|
createFileList(item.children, sublist);
|
|
li.appendChild(sublist);
|
|
}
|
|
// 폴더 클릭 시 하위 목록 토글 (선택적 기능)
|
|
itemContent.onclick = (e) => {
|
|
e.stopPropagation();
|
|
li.querySelector('ul')?.classList.toggle('collapsed');
|
|
};
|
|
|
|
} else {
|
|
li.classList.add('file');
|
|
itemContent.onclick = (e) => {
|
|
e.stopPropagation();
|
|
// 기존 active 클래스 제거
|
|
document.querySelectorAll('#file-list li.active').forEach(el => el.classList.remove('active'));
|
|
// 현재 클릭된 파일에 active 클래스 추가
|
|
li.classList.add('active');
|
|
loadPdf(item.uri, item.name);
|
|
};
|
|
}
|
|
container.appendChild(li);
|
|
});
|
|
}
|
|
|
|
async function loadFileList() {
|
|
try {
|
|
// pdf-list.json 파일을 폴더 구조로 수정해야 합니다. (아래 예시 참고)
|
|
const response = await fetch("assets/pdf-list.json");
|
|
const files = await response.json();
|
|
fileListEl.innerHTML = '';
|
|
createFileList(files, fileListEl);
|
|
} catch (err) {
|
|
console.error("파일 목록 로드 실패:", err);
|
|
fileListEl.innerHTML = '<li>파일 목록을 불러올 수 없습니다.</li>';
|
|
}
|
|
}
|
|
|
|
|
|
// ===================================================================
|
|
// 5. 터치 인터페이스 (Hammer.js)
|
|
// ===================================================================
|
|
const manager = new Hammer.Manager(viewerContainer, {
|
|
inputClass: Hammer.PointerEventInput
|
|
});
|
|
const pinch = new Hammer.Pinch();
|
|
const pan = new Hammer.Pan({ direction: Hammer.DIRECTION_ALL });
|
|
const doubleTap = new Hammer.Tap({ event: 'doubletap', taps: 2 });
|
|
|
|
manager.add([pinch, pan, doubleTap]);
|
|
|
|
// 더블탭: 확대/축소
|
|
manager.on("doubletap", ev => {
|
|
currentScaleStep = (currentScaleStep + 1) % SCALE_STEPS.length;
|
|
scale = SCALE_STEPS[currentScaleStep];
|
|
posX = 0; // 더블탭 시 위치도 초기화하여 중앙에서 확대되도록
|
|
posY = 0;
|
|
renderPage(currentPageNum);
|
|
});
|
|
|
|
// 핀치: 확대/축소
|
|
let startScale = 1.0;
|
|
manager.on("pinchstart", () => {
|
|
startScale = scale;
|
|
});
|
|
manager.on("pinchmove", ev => {
|
|
scale = Math.min(Math.max(0.5, startScale * ev.scale), 4);
|
|
canvas.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
|
|
});
|
|
|
|
// 드래그 (Pan)
|
|
let startPosX = 0, startPosY = 0;
|
|
manager.on("panstart", () => {
|
|
startPosX = posX;
|
|
startPosY = posY;
|
|
});
|
|
manager.on("panmove", ev => {
|
|
if(scale <= 1) return; // 100% 스케일 이하에서는 이동 방지
|
|
posX = startPosX + ev.deltaX;
|
|
posY = startPosY + ev.deltaY;
|
|
canvas.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
|
|
});
|
|
|
|
// 방향 감지 및 레이아웃 조정 (webOS 용 코드는 그대로 유지)
|
|
function applyOrientationLayout() {
|
|
// webOS 환경이 아닐 경우 브라우저 창 크기로 판단
|
|
if (window.innerHeight > window.innerWidth) {
|
|
document.body.classList.add('portrait');
|
|
} else {
|
|
document.body.classList.remove('portrait');
|
|
}
|
|
}
|
|
|
|
window.addEventListener('resize', applyOrientationLayout);
|
|
|
|
// 초기화
|
|
window.onload = function() {
|
|
applyOrientationLayout();
|
|
loadFileList();
|
|
updateViewerInfo();
|
|
// 초기 버튼 상태
|
|
prevPageBtn.disabled = true;
|
|
nextPageBtn.disabled = true;
|
|
};
|
|
|
|
</script>
|
|
</body>
|
|
</html> |