feat(documents): 개요 점프 결선 — anchor splice + id↔id 점프 + scroll-spy ([id])
불만② 개요→본문 점프를 deterministic 하게 결선(경로 A). 상세페이지([id], 개요 rail 보유).
- MarkdownDoc: anchorMap prop 추가 → 렌더 전 md_content 의 각 offset(내림차순)에
<span id="sec-{chunkId}" class="md-anchor"> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,9 @@
|
||||
mdStatus?: string | null;
|
||||
mdExtractionError?: string | null;
|
||||
mdExtractionQuality?: Record<string, unknown> | null;
|
||||
/** 개요 점프용 anchor: {chunk_id: md_content 내 char offset}. 렌더 전 해당 위치에
|
||||
* <span id="sec-{chunk_id}"> 주입(점프 타깃). buildAnchorMap(outlineAnchors) 산출물. */
|
||||
anchorMap?: Record<number, number> | 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(내림차순)에 빈 <span id="sec-N"> 삽입(점프 타깃).
|
||||
// offset 은 buildAnchorMap 이 body 와 동일 문자열 기준으로 산출했어야 함(호출측 책임).
|
||||
function spliceAnchors(text: string, map: Record<number, number> | 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) + `<span id="sec-${id}" class="md-anchor"></span>\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][];
|
||||
|
||||
@@ -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)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle(item)}
|
||||
onclick={() => { toggle(item); onJump?.(s.chunk_id); }}
|
||||
aria-expanded={open}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
class={[
|
||||
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors',
|
||||
open ? 'bg-surface-active text-text' : 'text-dim hover:bg-surface hover:text-text',
|
||||
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
|
||||
open ? 'bg-surface-active text-text border-accent' : active ? 'bg-surface text-accent-hover border-accent' : 'text-dim hover:bg-surface hover:text-text border-transparent',
|
||||
].join(' ')}
|
||||
>
|
||||
<span class="flex-1 min-w-0 leading-snug break-words">{title(s)}</span>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, getAccessToken } from '$lib/api';
|
||||
import { isMdSuccess } from '$lib/utils/mdStatus';
|
||||
import { buildAnchorMap } from '$lib/utils/outlineAnchors';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -163,6 +164,45 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── 개요 점프 (outlineAnchors, 경로 A) ──
|
||||
// anchorMap = md_content 의 각 절 heading offset. MarkdownDoc 가 <span id="sec-N"> 주입.
|
||||
let anchorMap = $derived(
|
||||
hasSections && canShowMarkdown && doc?.md_content
|
||||
? buildAnchorMap(doc.md_content, sections).anchors
|
||||
: {}
|
||||
);
|
||||
let activeKey = $state(null);
|
||||
function jumpToSection(chunkId) {
|
||||
const el = document.getElementById(`sec-${chunkId}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
// scroll-spy: 화면 상단(120px)을 지난 마지막 .md-anchor = 현재 절. [id] 는 window 스크롤.
|
||||
$effect(() => {
|
||||
void anchorMap; // 문서/섹션 변화 시 재바인딩
|
||||
if (typeof window === 'undefined') return;
|
||||
let raf = 0;
|
||||
const onScroll = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
let cur = null;
|
||||
document.querySelectorAll('.md-anchor').forEach((a) => {
|
||||
if (a.getBoundingClientRect().top <= 120) cur = a;
|
||||
});
|
||||
if (cur) {
|
||||
const m = cur.id.match(/^sec-(\d+)$/);
|
||||
if (m) activeKey = Number(m[1]);
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
});
|
||||
|
||||
function getViewerType(format) {
|
||||
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
|
||||
if (format === 'pdf') return 'pdf';
|
||||
@@ -229,7 +269,7 @@
|
||||
<!-- 좌측 절 목차 — xl+ sticky rail (그 아래 viewport 는 본문 상단 collapsible) -->
|
||||
<aside class="hidden xl:block xl:sticky xl:top-6 xl:self-start xl:max-h-[calc(100vh-3rem)] xl:overflow-y-auto">
|
||||
<Card>
|
||||
<SectionOutline {sections} />
|
||||
<SectionOutline {sections} onJump={jumpToSection} {activeKey} />
|
||||
</Card>
|
||||
</aside>
|
||||
{/if}
|
||||
@@ -240,7 +280,7 @@
|
||||
<!-- xl 미만: 절 목차 접이식 -->
|
||||
<details class="xl:hidden">
|
||||
<summary class="cursor-pointer text-sm text-dim px-1 py-2 select-none">절 목차 ({sections.length})</summary>
|
||||
<Card class="mt-2"><SectionOutline {sections} /></Card>
|
||||
<Card class="mt-2"><SectionOutline {sections} onJump={jumpToSection} {activeKey} /></Card>
|
||||
</details>
|
||||
{/if}
|
||||
<!-- Affordance row -->
|
||||
@@ -289,6 +329,7 @@
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
anchorMap={anchorMap}
|
||||
extractedText={doc.extracted_text || rawMarkdown}
|
||||
class="prose prose-invert prose-base lg:prose-sm max-w-none"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user