From 5c065e6bec0db71228e5199b3469b944aca6dca7 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 8 Jun 2026 20:17:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(documents):=20=EA=B0=9C=EC=9A=94=20?= =?UTF-8?q?=EC=A0=90=ED=94=84=20=EA=B2=B0=EC=84=A0=20=E2=80=94=20anchor=20?= =?UTF-8?q?splice=20+=20id=E2=86=94id=20=EC=A0=90=ED=94=84=20+=20scroll-sp?= =?UTF-8?q?y=20([id])?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 불만② 개요→본문 점프를 deterministic 하게 결선(경로 A). 상세페이지([id], 개요 rail 보유). - MarkdownDoc: anchorMap prop 추가 → 렌더 전 md_content 의 각 offset(내림차순)에 splice(점프 타깃). DOMPurify span+id+class 통과. - SectionOutline: onJump(chunkId)/activeKey prop. 클릭=아코디언 toggle + onJump(점프). activeKey 일치 항목 좌측 accent border 강조(scroll-spy). - [id]: anchorMap=buildAnchorMap(md_content, sections)(canShowMarkdown 시) → MarkdownDoc 전달. jumpToSection=#sec-id scrollIntoView. scroll-spy(window scroll, 120px 상단 통과 마지막 anchor). SectionOutline 양쪽(xl rail·details)에 onJump/activeKey 배선. id↔id 직매칭이라 중복제목(표-1·Part UW 814건)·비-ATX(제N조) 정확. anchor 없는 절=점프 비활성(아코디언 폴백). node test 10/10, vite build + lint:tokens(신규0) PASS. 다음 = 3-pane(DocumentViewer) 개요 rail(commit 3, 레이아웃). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/components/MarkdownDoc.svelte | 22 ++++++++- .../src/lib/components/SectionOutline.svelte | 14 ++++-- .../src/routes/documents/[id]/+page.svelte | 45 ++++++++++++++++++- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/MarkdownDoc.svelte b/frontend/src/lib/components/MarkdownDoc.svelte index fcd7313..bf00d3f 100644 --- a/frontend/src/lib/components/MarkdownDoc.svelte +++ b/frontend/src/lib/components/MarkdownDoc.svelte @@ -28,6 +28,9 @@ mdStatus?: string | null; mdExtractionError?: string | null; mdExtractionQuality?: Record | null; + /** 개요 점프용 anchor: {chunk_id: md_content 내 char offset}. 렌더 전 해당 위치에 + * 주입(점프 타깃). buildAnchorMap(outlineAnchors) 산출물. */ + anchorMap?: Record | null; placeholder?: string; /** 추가 래퍼 클래스. tailwind prose-* / spacing 등을 호출 측에서 입혀야 할 때. */ class?: string; @@ -41,10 +44,27 @@ mdStatus = null, mdExtractionError = null, mdExtractionQuality = null, + anchorMap = null, placeholder = '*텍스트 추출 대기 중*', class: klass = '', }: Props = $props(); + // 개요 anchor 주입: body 의 각 offset(내림차순)에 빈 삽입(점프 타깃). + // offset 은 buildAnchorMap 이 body 와 동일 문자열 기준으로 산출했어야 함(호출측 책임). + function spliceAnchors(text: string, map: Record | null): string { + if (!map) return text; + const ents = Object.entries(map) + .map(([id, off]) => [id, Number(off)] as [string, number]) + .filter(([, o]) => Number.isFinite(o) && o >= 0 && o <= text.length) + .sort((a, b) => b[1] - a[1]); + if (!ents.length) return text; + let out = text; + for (const [id, off] of ents) { + out = out.slice(0, off) + `\n` + out.slice(off); + } + return out; + } + let usingMarkdown = $derived(!!(mdContent && mdContent.trim())); let body = $derived( usingMarkdown @@ -53,7 +73,7 @@ ? extractedText : placeholder, ); - let renderedHtml = $derived(renderDocMarkdown(body)); + let renderedHtml = $derived(renderDocMarkdown(spliceAnchors(body, anchorMap))); let frontmatterEntries = $derived.by(() => { if (!usingMarkdown || !mdFrontmatter) return [] as [string, unknown][]; diff --git a/frontend/src/lib/components/SectionOutline.svelte b/frontend/src/lib/components/SectionOutline.svelte index d63591f..e9a5709 100644 --- a/frontend/src/lib/components/SectionOutline.svelte +++ b/frontend/src/lib/components/SectionOutline.svelte @@ -15,8 +15,12 @@ interface Props { sections: DocumentSection[]; + /** 항목 클릭 시 본문 점프 콜백(부모가 #sec-{chunkId} scrollIntoView). 없으면 아코디언만. */ + onJump?: (chunkId: number) => void; + /** scroll-spy 현재 절(chunk_id) — 강조용. */ + activeKey?: number | null; } - let { sections }: Props = $props(); + let { sections, onJump, activeKey = null }: Props = $props(); let layout = $derived(groupOrFlat(sections)); let total = $derived(sections.length); @@ -37,15 +41,17 @@ {#snippet itemRow(item: OutlineItem)} {@const s = item.section} {@const open = selectedId === s.chunk_id} + {@const active = activeKey != null && activeKey === s.chunk_id} {@const typeLabel = sectionTypeLabel(s.section_type)}