diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte index 5aa4b5b..ee4f3f4 100644 --- a/frontend/src/lib/components/DocumentViewer.svelte +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -7,8 +7,11 @@ import Tabs from '$lib/components/ui/Tabs.svelte'; import MarkdownDoc from '$lib/components/MarkdownDoc.svelte'; import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte'; + import SectionOutline from '$lib/components/SectionOutline.svelte'; import { getViewerType } from '$lib/utils/viewerType'; import { isMdSuccess } from '$lib/utils/mdStatus'; + import { buildAnchorMap } from '$lib/utils/outlineAnchors'; + import { cleanHeading } from '$lib/utils/headingPath'; // 편집 미리보기 전용 plain marked (본문 렌더는 MarkdownDoc 가 담당). marked.use({ mangle: false, headerIds: false }); @@ -52,9 +55,11 @@ async function loadFullDoc(id) { loading = true; rawMarkdown = ''; + sections = []; try { fullDoc = await api(`/documents/${id}`); viewerType = getViewerType(fullDoc.file_format, fullDoc.source_channel); + loadSections(id); // 본문 markdown(md/txt) 인데 extracted_text 가 비면 원본 파일 직접 로드. if (viewerType === 'markdown' && !fullDoc.extracted_text) { @@ -88,6 +93,69 @@ if (!canShowMarkdown && pdfViewMode === 'markdown') pdfViewMode = 'pdf'; }); + // ── 절 목차(개요) rail + 점프 + scroll-spy (outlineAnchors, 경로 A) ── + let sections = $state([]); + async function loadSections(id) { + try { + const r = await api(`/documents/${id}/sections`); + if (id === doc?.id) sections = r?.sections ?? []; + } catch { + if (id === doc?.id) sections = []; + } + } + // window 빈제목(31% 노이즈) 등 표시 가능한 제목 없는 항목은 rail 에서 제외(클린업). + let outlineSections = $derived( + sections.filter( + (s) => !!(cleanHeading(s.section_title) || cleanHeading((s.heading_path || '').split('>').pop() || '')), + ), + ); + // MarkdownDoc 가 실제 렌더하는 텍스트(anchor offset 기준과 일치해야 함). + let mdRenderText = $derived.by(() => { + if (!fullDoc) return ''; + if (viewerType === 'pdf') return pdfViewMode === 'markdown' && canShowMarkdown ? (fullDoc.md_content || '') : ''; + if (viewerType === 'markdown') return fullDoc.extracted_text || rawMarkdown || ''; + if (viewerType === 'hwp-markdown' || viewerType === 'article') return fullDoc.md_content || fullDoc.extracted_text || ''; + return ''; + }); + let anchorMap = $derived(sections.length && mdRenderText ? buildAnchorMap(mdRenderText, sections).anchors : {}); + let showRail = $derived(outlineSections.length > 0 && !!mdRenderText); + + let scrollEl = $state(); + let activeKey = $state(null); + function jumpTo(chunkId) { + const el = scrollEl?.querySelector(`#sec-${chunkId}`); + if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' }); + } + // scroll-spy: scrollEl 내 .md-anchor 중 컨테이너 상단(+120) 지난 마지막 = 현재 절. + $effect(() => { + void anchorMap; + const el = scrollEl; + if (!el) return; + let raf = 0; + const onScroll = () => { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + const threshold = el.getBoundingClientRect().top + 120; + let cur = null; + el.querySelectorAll('.md-anchor').forEach((a) => { + if (a.getBoundingClientRect().top <= threshold) cur = a; + }); + if (cur) { + const m = cur.id.match(/^sec-(\d+)$/); + if (m) activeKey = Number(m[1]); + } + }); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + const t = setTimeout(onScroll, 0); + return () => { + el.removeEventListener('scroll', onScroll); + clearTimeout(t); + if (raf) cancelAnimationFrame(raf); + }; + }); + function startEdit() { editContent = fullDoc?.extracted_text || rawMarkdown || ''; editMode = true; @@ -152,8 +220,14 @@ {/if} - -
로딩 중...
미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})