feat(documents): 3-pane 중앙 리더에 절 목차 rail + 점프 + scroll-spy #32
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user