@clazic/urban 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/e2e-mcp.js +214 -0
- package/scripts/postinstall.js +56 -0
- package/scripts/setup-graphify.js +107 -0
- package/scripts/update_css.js +799 -0
- package/web/old/scripts/app.js +2618 -0
- package/web/old/scripts/router.js +15 -0
- package/web/old/scripts/tailwind.min.js +83 -0
- package/web/scripts/api.js +41 -0
- package/web/scripts/app.js +76 -0
- package/web/scripts/components/command-palette.js +88 -0
- package/web/scripts/components/drawer.js +9 -0
- package/web/scripts/components/file-viewer.js +120 -0
- package/web/scripts/components/pdf-viewer.js +188 -0
- package/web/scripts/components/pipeline-footer.js +511 -0
- package/web/scripts/components/pipeline-strip.js +19 -0
- package/web/scripts/components/stat-cards.js +6 -0
- package/web/scripts/components/table.js +9 -0
- package/web/scripts/components/toast.js +12 -0
- package/web/scripts/dom.js +14 -0
- package/web/scripts/format.js +20 -0
- package/web/scripts/pages/catalog.js +134 -0
- package/web/scripts/pages/failures.js +72 -0
- package/web/scripts/pages/graph.js +521 -0
- package/web/scripts/pages/home.js +231 -0
- package/web/scripts/pages/pending.js +503 -0
- package/web/scripts/pages/schedules.js +134 -0
- package/web/scripts/pages/search.js +655 -0
- package/web/scripts/pages/settings.js +196 -0
- package/web/scripts/pages/wiki.js +135 -0
- package/web/scripts/router.js +58 -0
- package/web/scripts/shell.js +81 -0
- package/web/scripts/sse.js +36 -0
- package/web/scripts/store.js +32 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export async function getJson(url) {
|
|
2
|
+
const response = await fetch(url);
|
|
3
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
4
|
+
return response.json();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function postJson(url, body = {}) {
|
|
8
|
+
const response = await fetch(url, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "Content-Type": "application/json" },
|
|
11
|
+
body: JSON.stringify(body),
|
|
12
|
+
});
|
|
13
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
14
|
+
return response.json().catch(() => ({}));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function patchJson(url, body = {}) {
|
|
18
|
+
const response = await fetch(url, {
|
|
19
|
+
method: "PATCH",
|
|
20
|
+
headers: { "Content-Type": "application/json" },
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
24
|
+
return response.json().catch(() => ({}));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function putJson(url, body = {}) {
|
|
28
|
+
const response = await fetch(url, {
|
|
29
|
+
method: "PUT",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
34
|
+
return response.json().catch(() => ({}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function deleteJson(url) {
|
|
38
|
+
const response = await fetch(url, { method: "DELETE" });
|
|
39
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
40
|
+
return response.json().catch(() => ({}));
|
|
41
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { connect, subscribe } from "./sse.js";
|
|
2
|
+
import { bindShell, setSseStatus, syncShell } from "./shell.js";
|
|
3
|
+
import { navigateTo } from "./router.js";
|
|
4
|
+
import { store } from "./store.js";
|
|
5
|
+
import { getJson } from "./api.js";
|
|
6
|
+
import { showCommandPalette, hideCommandPalette } from "./components/command-palette.js";
|
|
7
|
+
import { mountPipelineFooter } from "./components/pipeline-footer.js";
|
|
8
|
+
|
|
9
|
+
async function loadGlobalHealth() {
|
|
10
|
+
try {
|
|
11
|
+
const [failures, stats] = await Promise.all([
|
|
12
|
+
getJson("/api/failures/count"),
|
|
13
|
+
getJson("/api/stats"),
|
|
14
|
+
]);
|
|
15
|
+
store.failuresCount = failures.count || 0;
|
|
16
|
+
|
|
17
|
+
const set = (id, val) => {
|
|
18
|
+
const el = document.getElementById(id);
|
|
19
|
+
if (!el) return;
|
|
20
|
+
el.textContent = val != null && val !== "" ? String(val) : "";
|
|
21
|
+
el.style.display = val != null && val !== "" ? "" : "none";
|
|
22
|
+
};
|
|
23
|
+
set("nav-failures-count", store.failuresCount || null);
|
|
24
|
+
set("nav-reports-count", stats.total_reports || null);
|
|
25
|
+
set("nav-pending-count", stats.pending_count || null);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error("global health load failed", error);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function bindGlobalEvents() {
|
|
32
|
+
window.addEventListener("popstate", (event) => {
|
|
33
|
+
const path = event.state?.path || window.location.pathname || "/";
|
|
34
|
+
navigateTo(path, false).then(syncShell);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
document.addEventListener("keydown", (event) => {
|
|
38
|
+
if (event.key === "Escape") {
|
|
39
|
+
hideCommandPalette();
|
|
40
|
+
document.getElementById("drawer-overlay")?.classList.add("hidden");
|
|
41
|
+
document.getElementById("report-drawer")?.classList.remove("open");
|
|
42
|
+
document.getElementById("stage-track-panel")?.classList.remove("open");
|
|
43
|
+
document.getElementById("stage-toast")?.classList.remove("open");
|
|
44
|
+
}
|
|
45
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
showCommandPalette();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function init() {
|
|
53
|
+
bindShell();
|
|
54
|
+
bindGlobalEvents();
|
|
55
|
+
connect();
|
|
56
|
+
subscribe("connection", ({ connected }) => setSseStatus(connected));
|
|
57
|
+
subscribe("message", async (data) => {
|
|
58
|
+
if (["pending", "item_stage", "failure", "done", "session:done", "wiki:updated"].includes(data.type)) {
|
|
59
|
+
await loadGlobalHealth();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
await loadGlobalHealth();
|
|
63
|
+
await navigateTo(window.location.pathname || "/", false);
|
|
64
|
+
syncShell();
|
|
65
|
+
mountPipelineFooter().catch((err) => console.error("[pipeline-footer] mount failed", err));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const originalNavigate = navigateTo;
|
|
69
|
+
window.app = {
|
|
70
|
+
navigateTo: async (path, push = true) => {
|
|
71
|
+
await originalNavigate(path, push);
|
|
72
|
+
syncShell();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
init();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { qs, escapeHtml } from "../dom.js";
|
|
2
|
+
import { store } from "../store.js";
|
|
3
|
+
|
|
4
|
+
let debounceTimer = null;
|
|
5
|
+
|
|
6
|
+
export function showCommandPalette() {
|
|
7
|
+
const overlay = qs("#cmd-overlay");
|
|
8
|
+
const input = qs("#cmd-input");
|
|
9
|
+
if (!overlay || !input) return;
|
|
10
|
+
overlay.classList.remove("hidden");
|
|
11
|
+
input.value = "";
|
|
12
|
+
renderResults([]);
|
|
13
|
+
input.focus();
|
|
14
|
+
input.addEventListener("input", onInput);
|
|
15
|
+
input.addEventListener("keydown", onKeydown);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function hideCommandPalette() {
|
|
19
|
+
const overlay = qs("#cmd-overlay");
|
|
20
|
+
const input = qs("#cmd-input");
|
|
21
|
+
overlay?.classList.add("hidden");
|
|
22
|
+
if (input) {
|
|
23
|
+
input.removeEventListener("input", onInput);
|
|
24
|
+
input.removeEventListener("keydown", onKeydown);
|
|
25
|
+
}
|
|
26
|
+
clearTimeout(debounceTimer);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function onInput(event) {
|
|
30
|
+
const q = event.target.value.trim();
|
|
31
|
+
clearTimeout(debounceTimer);
|
|
32
|
+
if (!q) { renderResults([]); return; }
|
|
33
|
+
debounceTimer = setTimeout(() => fetchSuggestions(q), 200);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function onKeydown(event) {
|
|
37
|
+
if (event.key === "Escape") hideCommandPalette();
|
|
38
|
+
if (event.key === "Enter") {
|
|
39
|
+
const q = event.target.value.trim();
|
|
40
|
+
if (q) {
|
|
41
|
+
hideCommandPalette();
|
|
42
|
+
window.app?.navigateTo(`/search?q=${encodeURIComponent(q)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchSuggestions(q) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`/api/search/suggest?q=${encodeURIComponent(q)}&limit=12`);
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
renderResults(data.suggestions || []);
|
|
52
|
+
} catch {
|
|
53
|
+
renderResults([]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderResults(items) {
|
|
58
|
+
const el = qs("#cmd-results");
|
|
59
|
+
if (!el) return;
|
|
60
|
+
if (!items.length) {
|
|
61
|
+
el.innerHTML = "";
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
el.innerHTML = items.map((item) => {
|
|
65
|
+
const meta = item.type === "wiki"
|
|
66
|
+
? (item.source ? `링크 ${item.source}개` : "")
|
|
67
|
+
: (item.source ? `<span class="cmd-result-source">${escapeHtml(item.source)}</span>` : "");
|
|
68
|
+
const date = item.created_at ? `<span class="cmd-result-date">${new Date(item.created_at).toLocaleDateString("ko-KR")}</span>` : "";
|
|
69
|
+
return `
|
|
70
|
+
<button class="cmd-result" type="button" data-id="${escapeHtml(item.id)}" data-label="${escapeHtml(item.label)}" data-type="${escapeHtml(item.type)}">
|
|
71
|
+
<span class="cmd-result-type">${item.type === "wiki" ? "위키" : "보고서"}</span>
|
|
72
|
+
<span class="cmd-result-body">
|
|
73
|
+
<span class="cmd-result-label">${escapeHtml(item.label)}</span>
|
|
74
|
+
<span class="cmd-result-meta">${meta}${date}</span>
|
|
75
|
+
</span>
|
|
76
|
+
</button>`;
|
|
77
|
+
}).join("");
|
|
78
|
+
el.querySelectorAll(".cmd-result").forEach((btn) => {
|
|
79
|
+
btn.addEventListener("click", () => {
|
|
80
|
+
hideCommandPalette();
|
|
81
|
+
if (btn.dataset.type === "wiki") {
|
|
82
|
+
window.app?.navigateTo(`/wiki#${encodeURIComponent(btn.dataset.id)}`);
|
|
83
|
+
} else {
|
|
84
|
+
window.app?.navigateTo(`/search?q=${encodeURIComponent(btn.dataset.label || btn.dataset.id)}`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function openDrawer(id) {
|
|
2
|
+
document.getElementById("drawer-overlay")?.classList.remove("hidden");
|
|
3
|
+
document.getElementById(id)?.classList.add("open");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function closeDrawer(id) {
|
|
7
|
+
document.getElementById("drawer-overlay")?.classList.add("hidden");
|
|
8
|
+
document.getElementById(id)?.classList.remove("open");
|
|
9
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { renderPdfViewer } from './pdf-viewer.js';
|
|
2
|
+
|
|
3
|
+
function openFileDrawer() {
|
|
4
|
+
document.getElementById('report-drawer')?.classList.add('open');
|
|
5
|
+
let backdrop = document.getElementById('file-viewer-backdrop');
|
|
6
|
+
if (!backdrop) {
|
|
7
|
+
backdrop = document.createElement('div');
|
|
8
|
+
backdrop.id = 'file-viewer-backdrop';
|
|
9
|
+
backdrop.className = 'file-backdrop';
|
|
10
|
+
document.body.appendChild(backdrop);
|
|
11
|
+
// 다음 프레임에 show 클래스 부여 → opacity 트랜지션 활성화
|
|
12
|
+
requestAnimationFrame(() => backdrop.classList.add('show'));
|
|
13
|
+
} else {
|
|
14
|
+
backdrop.classList.add('show');
|
|
15
|
+
}
|
|
16
|
+
backdrop.onclick = closeFileDrawer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function closeFileDrawer() {
|
|
20
|
+
const rd = document.getElementById('report-drawer');
|
|
21
|
+
rd?.classList.remove('open', 'drawer-wide');
|
|
22
|
+
document.getElementById('file-viewer-backdrop')?.remove();
|
|
23
|
+
const tb = document.getElementById('drawer-pdf-toolbar');
|
|
24
|
+
if (tb) { tb.innerHTML = ''; tb.classList.add('hidden'); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let _rhwp = null;
|
|
28
|
+
|
|
29
|
+
async function getRhwp() {
|
|
30
|
+
if (_rhwp) return _rhwp;
|
|
31
|
+
const mod = await import('/rhwp.js');
|
|
32
|
+
await mod.default('/rhwp_bg.wasm');
|
|
33
|
+
_rhwp = mod;
|
|
34
|
+
return _rhwp;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function loadMd(hash) {
|
|
38
|
+
const resp = await fetch(`/api/reports/${hash}/md`);
|
|
39
|
+
if (!resp.ok) throw new Error('MD 파일 없음');
|
|
40
|
+
const text = await resp.text();
|
|
41
|
+
return window.marked?.parse(text) || `<pre>${text.replace(/</g, '<')}</pre>`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function loadRaw(hash, ext, body) {
|
|
45
|
+
if (ext === 'pdf') {
|
|
46
|
+
await renderPdfViewer(body, `/api/reports/${hash}/preview`);
|
|
47
|
+
} else if (ext === 'hwp' || ext === 'hwpx') {
|
|
48
|
+
body.innerHTML = '<div class="viewer-loading"><i class="fa-solid fa-spinner fa-spin"></i> HWP 파서 초기화 중...</div>';
|
|
49
|
+
const rhwp = await getRhwp();
|
|
50
|
+
const resp = await fetch(`/api/reports/${hash}/preview`);
|
|
51
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
52
|
+
const doc = new rhwp.HwpDocument(bytes);
|
|
53
|
+
const total = doc.pageCount();
|
|
54
|
+
const limit = Math.min(total, 10);
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (let i = 0; i < limit; i++) parts.push(doc.renderPageHtml(i));
|
|
57
|
+
doc.free();
|
|
58
|
+
body.innerHTML = `<div class="hwp-viewer">${parts.map(p => `<div class="hwp-page">${p}</div>`).join('')}</div>`;
|
|
59
|
+
if (total > 10) body.insertAdjacentHTML('beforeend', `<p class="viewer-note">최초 10페이지만 표시 (전체 ${total}페이지)</p>`);
|
|
60
|
+
} else {
|
|
61
|
+
body.innerHTML = `<div class="viewer-unsupported"><i class="fa-regular fa-file"></i><p>미리보기 미지원 형식 (${ext || '알 수 없음'})</p><p>아래 버튼으로 다운로드하세요.</p></div>`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function openFileViewer(hash, rawPath, mdPath) {
|
|
66
|
+
const drawerTitle = document.getElementById('drawer-title');
|
|
67
|
+
const drawerBody = document.getElementById('drawer-body');
|
|
68
|
+
const drawerActions = document.getElementById('drawer-actions');
|
|
69
|
+
if (!drawerBody) return;
|
|
70
|
+
|
|
71
|
+
const rawExt = (rawPath || '').split('.').pop().toLowerCase();
|
|
72
|
+
const hasMd = !!mdPath;
|
|
73
|
+
const hasRaw = !!rawPath;
|
|
74
|
+
|
|
75
|
+
document.getElementById('report-drawer')?.classList.add('drawer-wide');
|
|
76
|
+
drawerTitle.textContent = '보고서 원문';
|
|
77
|
+
|
|
78
|
+
// 탭 헤더
|
|
79
|
+
const tabs = [];
|
|
80
|
+
if (hasMd) tabs.push({ id: 'md', label: 'MD 변환본' });
|
|
81
|
+
if (hasRaw) tabs.push({ id: 'raw', label: `원본 (${rawExt.toUpperCase()})` });
|
|
82
|
+
|
|
83
|
+
const tabsHtml = tabs.length > 1
|
|
84
|
+
? `<div class="viewer-tabs">${tabs.map((t, i) => `<button class="viewer-tab${i === 0 ? ' active' : ''}" data-tab="${t.id}">${t.label}</button>`).join('')}</div>`
|
|
85
|
+
: '';
|
|
86
|
+
|
|
87
|
+
drawerBody.innerHTML = `${tabsHtml}<div class="viewer-content viewer-loading"><i class="fa-solid fa-spinner fa-spin"></i> 불러오는 중...</div>`;
|
|
88
|
+
drawerActions.innerHTML = [
|
|
89
|
+
rawPath ? `<a class="btn ghost" href="/api/reports/${hash}/file" download>원본 다운로드</a>` : '',
|
|
90
|
+
mdPath ? `<a class="btn ghost" href="/api/reports/${hash}/md" download>MD 다운로드</a>` : '',
|
|
91
|
+
].join('');
|
|
92
|
+
openFileDrawer();
|
|
93
|
+
|
|
94
|
+
const content = drawerBody.querySelector('.viewer-content');
|
|
95
|
+
|
|
96
|
+
async function showTab(tabId) {
|
|
97
|
+
drawerBody.querySelectorAll('.viewer-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tabId));
|
|
98
|
+
content.innerHTML = '<div class="viewer-loading"><i class="fa-solid fa-spinner fa-spin"></i> 불러오는 중...</div>';
|
|
99
|
+
try {
|
|
100
|
+
if (tabId === 'md') {
|
|
101
|
+
const html = await loadMd(hash);
|
|
102
|
+
content.innerHTML = `<div class="viewer-prose">${html}</div>`;
|
|
103
|
+
} else {
|
|
104
|
+
await loadRaw(hash, rawExt, content);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (tabId === 'md' && hasRaw) {
|
|
108
|
+
await showTab('raw');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
content.innerHTML = `<div class="viewer-error"><i class="fa-solid fa-circle-exclamation"></i> 로드 실패: ${err.message}</div>`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
drawerBody.querySelectorAll('.viewer-tab').forEach(btn => {
|
|
116
|
+
btn.addEventListener('click', () => showTab(btn.dataset.tab));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await showTab(tabs[0]?.id || 'raw');
|
|
120
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
let _pdfjsLib = null;
|
|
2
|
+
|
|
3
|
+
async function getPdfjs() {
|
|
4
|
+
if (_pdfjsLib) return _pdfjsLib;
|
|
5
|
+
const mod = await import('/pdfjs/pdf.min.mjs');
|
|
6
|
+
mod.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.mjs';
|
|
7
|
+
_pdfjsLib = mod;
|
|
8
|
+
return mod;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function renderPdfViewer(container, pdfUrl) {
|
|
12
|
+
container.innerHTML = '<div class="viewer-loading"><i class="fa-solid fa-spinner fa-spin"></i> PDF 로딩 중...</div>';
|
|
13
|
+
|
|
14
|
+
const pdfjsLib = await getPdfjs();
|
|
15
|
+
const resp = await fetch(pdfUrl);
|
|
16
|
+
const data = new Uint8Array(await resp.arrayBuffer());
|
|
17
|
+
const pdf = await pdfjsLib.getDocument({ data }).promise;
|
|
18
|
+
const totalPages = pdf.numPages;
|
|
19
|
+
|
|
20
|
+
let scale = 1.2;
|
|
21
|
+
let currentPage = 1;
|
|
22
|
+
const rendered = new Set();
|
|
23
|
+
const visibleRatios = new Map(); // page → intersectionRatio
|
|
24
|
+
let observer = null;
|
|
25
|
+
|
|
26
|
+
// ── 툴바 ──────────────────────────────────────────────────────────────
|
|
27
|
+
const toolbarSlot = document.getElementById('drawer-pdf-toolbar');
|
|
28
|
+
if (toolbarSlot) {
|
|
29
|
+
toolbarSlot.innerHTML = `
|
|
30
|
+
<div class="pdf-toolbar">
|
|
31
|
+
<div class="pdf-toolbar-left">
|
|
32
|
+
<button class="btn sm" id="pdf-prev"><i class="fa-solid fa-chevron-left"></i></button>
|
|
33
|
+
<span class="pdf-page-info">
|
|
34
|
+
<input class="pdf-page-input" id="pdf-page-num" type="number" min="1" max="${totalPages}" value="1" />
|
|
35
|
+
<span class="pdf-page-sep">/ ${totalPages}</span>
|
|
36
|
+
</span>
|
|
37
|
+
<button class="btn sm" id="pdf-next"><i class="fa-solid fa-chevron-right"></i></button>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="pdf-toolbar-right">
|
|
40
|
+
<button class="btn sm" id="pdf-zoom-out"><i class="fa-solid fa-minus"></i></button>
|
|
41
|
+
<span class="pdf-zoom-label" id="pdf-zoom-label">${Math.round(scale * 100)}%</span>
|
|
42
|
+
<button class="btn sm" id="pdf-zoom-in"><i class="fa-solid fa-plus"></i></button>
|
|
43
|
+
<button class="btn sm" id="pdf-fit">맞춤</button>
|
|
44
|
+
</div>
|
|
45
|
+
</div>`;
|
|
46
|
+
toolbarSlot.classList.remove('hidden');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 스크롤 컨테이너 + 페이지 플레이스홀더 ───────────────────────────
|
|
50
|
+
container.innerHTML = `<div class="pdf-scroll" id="pdf-scroll"></div>`;
|
|
51
|
+
const scroll = container.querySelector('#pdf-scroll');
|
|
52
|
+
|
|
53
|
+
// 페이지 플레이스홀더 생성 (높이는 첫 페이지 렌더 후 조정)
|
|
54
|
+
for (let i = 1; i <= totalPages; i++) {
|
|
55
|
+
const item = document.createElement('div');
|
|
56
|
+
item.className = 'pdf-page-item';
|
|
57
|
+
item.dataset.page = String(i);
|
|
58
|
+
// 임시 최소 높이 — 실제 렌더 시 캔버스로 대체
|
|
59
|
+
item.style.minHeight = '800px';
|
|
60
|
+
scroll.appendChild(item);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pageInput = document.getElementById('pdf-page-num');
|
|
64
|
+
const zoomLabel = document.getElementById('pdf-zoom-label');
|
|
65
|
+
|
|
66
|
+
// ── 단일 페이지 렌더 ─────────────────────────────────────────────────
|
|
67
|
+
async function renderPage(num) {
|
|
68
|
+
const item = scroll.querySelector(`[data-page="${num}"]`);
|
|
69
|
+
if (!item) return;
|
|
70
|
+
|
|
71
|
+
const dpr = window.devicePixelRatio || 1;
|
|
72
|
+
const page = await pdf.getPage(num);
|
|
73
|
+
const viewport = page.getViewport({ scale: scale * dpr });
|
|
74
|
+
|
|
75
|
+
// 기존 캔버스 제거 후 새로 생성
|
|
76
|
+
item.innerHTML = '';
|
|
77
|
+
const canvas = document.createElement('canvas');
|
|
78
|
+
canvas.width = viewport.width;
|
|
79
|
+
canvas.height = viewport.height;
|
|
80
|
+
const cssW = viewport.width / dpr;
|
|
81
|
+
const cssH = viewport.height / dpr;
|
|
82
|
+
canvas.style.width = `${cssW}px`;
|
|
83
|
+
canvas.style.height = `${cssH}px`;
|
|
84
|
+
item.style.minHeight = '';
|
|
85
|
+
item.style.height = `${cssH + 24}px`; // 24px 상하 패딩 포함
|
|
86
|
+
item.appendChild(canvas);
|
|
87
|
+
|
|
88
|
+
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
|
|
89
|
+
rendered.add(num);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── IntersectionObserver 설정 ────────────────────────────────────────
|
|
93
|
+
function setupObserver() {
|
|
94
|
+
if (observer) observer.disconnect();
|
|
95
|
+
observer = new IntersectionObserver(
|
|
96
|
+
(entries) => {
|
|
97
|
+
entries.forEach((entry) => {
|
|
98
|
+
const num = parseInt(entry.target.dataset.page, 10);
|
|
99
|
+
if (entry.isIntersecting) {
|
|
100
|
+
visibleRatios.set(num, entry.intersectionRatio);
|
|
101
|
+
if (!rendered.has(num)) renderPage(num);
|
|
102
|
+
} else {
|
|
103
|
+
visibleRatios.delete(num);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
// 현재 가장 많이 보이는 페이지를 현재 페이지로 표시
|
|
107
|
+
let bestPage = null;
|
|
108
|
+
let bestRatio = -1;
|
|
109
|
+
visibleRatios.forEach((ratio, num) => {
|
|
110
|
+
if (ratio > bestRatio) { bestRatio = ratio; bestPage = num; }
|
|
111
|
+
});
|
|
112
|
+
if (bestPage && bestPage !== currentPage) {
|
|
113
|
+
currentPage = bestPage;
|
|
114
|
+
if (pageInput) pageInput.value = currentPage;
|
|
115
|
+
const prevBtn = document.getElementById('pdf-prev');
|
|
116
|
+
const nextBtn = document.getElementById('pdf-next');
|
|
117
|
+
if (prevBtn) prevBtn.disabled = currentPage <= 1;
|
|
118
|
+
if (nextBtn) nextBtn.disabled = currentPage >= totalPages;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{ root: scroll, threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0] }
|
|
122
|
+
);
|
|
123
|
+
scroll.querySelectorAll('.pdf-page-item').forEach((el) => observer.observe(el));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 줌 변경 시 전체 재렌더 ──────────────────────────────────────────
|
|
127
|
+
async function reRenderAll() {
|
|
128
|
+
rendered.clear();
|
|
129
|
+
visibleRatios.clear();
|
|
130
|
+
// 현재 뷰포트에 보이는 페이지들 즉시 재렌더
|
|
131
|
+
const items = scroll.querySelectorAll('.pdf-page-item');
|
|
132
|
+
const scrollRect = scroll.getBoundingClientRect();
|
|
133
|
+
for (const item of items) {
|
|
134
|
+
const rect = item.getBoundingClientRect();
|
|
135
|
+
const visible = rect.top < scrollRect.bottom && rect.bottom > scrollRect.top;
|
|
136
|
+
if (visible) {
|
|
137
|
+
const num = parseInt(item.dataset.page, 10);
|
|
138
|
+
await renderPage(num);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
setupObserver();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── 특정 페이지로 스크롤 ────────────────────────────────────────────
|
|
145
|
+
function scrollToPage(num) {
|
|
146
|
+
const item = scroll.querySelector(`[data-page="${num}"]`);
|
|
147
|
+
if (item) item.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── 너비 맞춤 ───────────────────────────────────────────────────────
|
|
151
|
+
async function fitWidth() {
|
|
152
|
+
const availWidth = scroll.clientWidth - 40;
|
|
153
|
+
const page1 = await pdf.getPage(1);
|
|
154
|
+
const vp = page1.getViewport({ scale: 1.0 });
|
|
155
|
+
// vp.width = PDF pt × 1.0 → CSS px at 96dpi; canvas는 scale*dpr로 렌더링되고 CSS는 scale*page_width
|
|
156
|
+
scale = Math.min(availWidth / vp.width, 2.5);
|
|
157
|
+
if (zoomLabel) zoomLabel.textContent = `${Math.round(scale * 100)}%`;
|
|
158
|
+
await reRenderAll();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 툴바 이벤트 ─────────────────────────────────────────────────────
|
|
162
|
+
document.getElementById('pdf-prev')?.addEventListener('click', () => {
|
|
163
|
+
if (currentPage > 1) scrollToPage(--currentPage);
|
|
164
|
+
});
|
|
165
|
+
document.getElementById('pdf-next')?.addEventListener('click', () => {
|
|
166
|
+
if (currentPage < totalPages) scrollToPage(++currentPage);
|
|
167
|
+
});
|
|
168
|
+
document.getElementById('pdf-zoom-in')?.addEventListener('click', () => {
|
|
169
|
+
scale = Math.min(scale + 0.2, 4);
|
|
170
|
+
if (zoomLabel) zoomLabel.textContent = `${Math.round(scale * 100)}%`;
|
|
171
|
+
reRenderAll();
|
|
172
|
+
});
|
|
173
|
+
document.getElementById('pdf-zoom-out')?.addEventListener('click', () => {
|
|
174
|
+
scale = Math.max(scale - 0.2, 0.4);
|
|
175
|
+
if (zoomLabel) zoomLabel.textContent = `${Math.round(scale * 100)}%`;
|
|
176
|
+
reRenderAll();
|
|
177
|
+
});
|
|
178
|
+
document.getElementById('pdf-fit')?.addEventListener('click', fitWidth);
|
|
179
|
+
pageInput?.addEventListener('change', () => {
|
|
180
|
+
const n = Math.max(1, Math.min(totalPages, parseInt(pageInput.value) || 1));
|
|
181
|
+
currentPage = n;
|
|
182
|
+
scrollToPage(n);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ── 초기 렌더 ───────────────────────────────────────────────────────
|
|
186
|
+
// 120% 고정 스케일로 첫 번째 보이는 페이지 렌더 → Observer 시작
|
|
187
|
+
requestAnimationFrame(() => reRenderAll());
|
|
188
|
+
}
|