From 988631fdb6a05e888c9eb9211ac5de5cf37b039e Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 8 Jun 2026 21:26:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(documents):=203-pane=20=EC=A4=91=EC=95=99?= =?UTF-8?q?=20=EB=A6=AC=EB=8D=94=EC=97=90=20=EC=A0=88=20=EB=AA=A9=EC=B0=A8?= =?UTF-8?q?=20rail=20+=20=EC=A0=90=ED=94=84=20+=20scroll-spy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [id] 전체보기에만 있던 개요 rail/점프를 메인 /documents 3-pane 중앙 리더로 확장 (사용자 주 사용 표면). 경로 A anchor 인프라 그대로 재사용. - /documents/{id}/sections fetch(loadSections, doc.id 가드) → 좌측 SectionOutline rail (showRail = 표시가능 절 有 + markdown-ish 본문). window 빈제목 31% 노이즈는 outlineSections 필터로 표시 제외(클린업, 코퍼스 무터치). - anchorMap = buildAnchorMap(mdRenderText, sections) — 각 분기가 실제 렌더하는 텍스트 기준. MarkdownDoc(markdown/pdf/hwp/article)에 anchorMap 전달 → splice. - jumpTo = scrollEl 내 #sec-{id} scrollIntoView. scroll-spy = scrollEl scroll 리스너로 상단 통과 마지막 .md-anchor → activeKey(SectionOutline 강조). $effect cleanup. - 본문을 [rail | scrollEl] flex 로 래핑(비-섹션 문서는 rail 미표시=기존 그대로). pdf 분기는 자체 overflow 제거하고 scrollEl 단일 스크롤로 정리(iframe h-[80vh]). id↔id 점프라 중복제목·비-ATX 정확, anchor 없는 절=비활성(폴백). FE only, BE 무변. vite build + node test 10/10 + lint:tokens(신규0) PASS. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/components/DocumentViewer.svelte | 109 +++++++++++++++--- 1 file changed, 92 insertions(+), 17 deletions(-) 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} - -
+ +
+ {#if showRail} + + {/if} +
{#if loading}

로딩 중...

{: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} />
{/if} {:else if viewerType === 'pdf'} -
-
+
+
{#if canShowMarkdown}
{#if pdfViewMode === 'markdown' && canShowMarkdown} -
- -
+ {:else} - + {/if}
{:else if viewerType === 'hwp-markdown'} @@ -281,5 +355,6 @@

미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})

{/if} {/if} +
-- 2.52.0