feat(documents): 3-pane 중앙 리더에 절 목차 rail + 점프 + scroll-spy #32

Merged
hyungi merged 1 commits from feat/documents-outline-rail into main 2026-06-08 21:27:51 +09:00
@@ -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 @@
</div>
{/if}
<!-- 뷰어 본문 -->
<div class="flex-1 overflow-auto min-h-0">
<!-- 뷰어 본문 (+ 절 목차 rail) -->
<div class="flex-1 flex min-h-0">
{#if showRail}
<aside class="hidden lg:block w-[176px] shrink-0 overflow-y-auto border-r border-default p-2 bg-sidebar">
<SectionOutline sections={outlineSections} onJump={jumpTo} {activeKey} />
</aside>
{/if}
<div class="flex-1 overflow-auto min-h-0" bind:this={scrollEl}>
{#if loading}
<div class="flex items-center justify-center h-full"><p class="text-sm text-dim">로딩 중...</p></div>
{:else if fullDoc}
@@ -181,14 +255,15 @@
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
anchorMap={anchorMap}
extractedText={fullDoc.extracted_text || rawMarkdown}
class={PROSE}
/>
</div>
{/if}
{:else if viewerType === 'pdf'}
<div class="p-4 flex flex-col h-full">
<div class="mb-2 flex items-center gap-2 shrink-0">
<div class="p-4">
<div class="mb-2 flex items-center gap-2">
<MarkdownStatusBadge mdStatus={fullDoc.md_status} mdExtractionError={fullDoc.md_extraction_error} mdExtractionQuality={fullDoc.md_extraction_quality} />
{#if canShowMarkdown}
<button onclick={() => (pdfViewMode = 'markdown')}
@@ -198,20 +273,19 @@
{/if}
</div>
{#if pdfViewMode === 'markdown' && canShowMarkdown}
<div class="flex-1 overflow-auto">
<MarkdownDoc
documentId={fullDoc.id}
mdContent={fullDoc.md_content}
mdFrontmatter={fullDoc.md_frontmatter}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
extractedText={fullDoc.extracted_text}
class={PROSE}
/>
</div>
<MarkdownDoc
documentId={fullDoc.id}
mdContent={fullDoc.md_content}
mdFrontmatter={fullDoc.md_frontmatter}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
anchorMap={anchorMap}
extractedText={fullDoc.extracted_text}
class={PROSE}
/>
{:else}
<iframe src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}" class="flex-1 w-full border-0 rounded" title={fullDoc.title}></iframe>
<iframe src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}" class="w-full h-[80vh] border-0 rounded" title={fullDoc.title}></iframe>
{/if}
</div>
{:else if viewerType === 'hwp-markdown'}
@@ -281,5 +355,6 @@
<div class="flex items-center justify-center h-full"><p class="text-sm text-dim">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p></div>
{/if}
{/if}
</div>
</div>
</div>