Files
hyungi_document_server/frontend/src/lib/components/DocumentViewer.svelte
T
hyungi 360871e9cf feat(documents): 3-pane 중앙 리더 markdown-first 일원화 (DocumentViewer)
메인 /documents 3-pane 의 중앙 리더(DocumentViewer)가 md_content 를 안 쓰고
PDF=raw iframe·md/txt=plain marked(extracted_text)만 렌더하던 이원화 제거.
"전부 MD화" 한 canonical markdown 이 전체보기 없이 메인에서 바로 보이게 함(불만①).

- viewerType.ts 신설: 분류 단일 source(상세페이지와 공유 예정, drift 차단).
  csv/json/xml/html→text(<pre>, 콤마 뭉침 회피), office→preview-pdf, hwp→hwp-markdown.
- DocumentViewer: 자체 getViewerType/renderMd(본문) 제거 → viewerType.ts + MarkdownDoc.
  - pdf: canShowMarkdown(isMdSuccess+md_content) 시 MarkdownDoc 기본 + [Markdown|PDF원본]
    토글 + MarkdownStatusBadge, 아니면 PDF iframe. lastDocId 가드는 fullDoc.id(prop) 키잉.
  - markdown(md/txt): MarkdownDoc(extracted_text=표시·편집 단일 필드), 편집 유지.
  - hwp-markdown/article: MarkdownDoc(앵커/KaTeX/이미지). 편집 미리보기만 plain marked 유지.
  - article/preview-pdf/image/text/cad/synology/unsupported 분기 보존(회귀 금지) + synology 신설.

API md_status='completed'(S1 validator live) 대응 = isMdSuccess. FE only, BE/스키마 무변.
vite build + lint:tokens(신규 위반 0) PASS. 후속: 개요 rail·안전점프(commit 2), [id] 정합(commit 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:44:46 +09:00

286 lines
12 KiB
Svelte

<script>
import { api, getAccessToken } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { ExternalLink, Save } from 'lucide-svelte';
import Tabs from '$lib/components/ui/Tabs.svelte';
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
import { getViewerType } from '$lib/utils/viewerType';
import { isMdSuccess } from '$lib/utils/mdStatus';
// 편집 미리보기 전용 plain marked (본문 렌더는 MarkdownDoc 가 담당).
marked.use({ mangle: false, headerIds: false });
function renderMd(text) {
return DOMPurify.sanitize(marked(text), {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onerror', 'onclick'],
ALLOW_UNKNOWN_PROTOCOLS: false,
});
}
let { doc } = $props();
let fullDoc = $state(null);
let loading = $state(true);
let viewerType = $state('none');
// Markdown 편집 (md/txt — extracted_text 가 표시·편집 단일 필드)
let editMode = $state(false);
let editContent = $state('');
let editTab = $state('edit');
let saving = $state(false);
let rawMarkdown = $state('');
const ODF_FORMATS = ['ods', 'odt', 'odp', 'odoc', 'osheet'];
function getEditInfo(d) {
if (d.edit_url) return { url: d.edit_url, label: '편집' };
if (ODF_FORMATS.includes(d.file_format)) return { url: 'https://link.hyungi.net', label: 'Synology Drive에서 열기' };
if (['dwg', 'dxf'].includes(d.file_format)) return { url: 'https://web.autocad.com', label: 'AutoCAD Web' };
return null;
}
$effect(() => {
if (doc?.id) {
loadFullDoc(doc.id);
editMode = false;
}
});
async function loadFullDoc(id) {
loading = true;
rawMarkdown = '';
try {
fullDoc = await api(`/documents/${id}`);
viewerType = getViewerType(fullDoc.file_format, fullDoc.source_channel);
// 본문 markdown(md/txt) 인데 extracted_text 가 비면 원본 파일 직접 로드.
if (viewerType === 'markdown' && !fullDoc.extracted_text) {
try {
const resp = await fetch(`/api/documents/${id}/file?token=${getAccessToken()}`);
if (resp.ok) rawMarkdown = await resp.text();
} catch (e) { rawMarkdown = ''; }
}
} catch (err) {
fullDoc = null;
viewerType = 'none';
} finally {
loading = false;
}
}
// PDF markdown-first: marker 가 만든 canonical md_content 가 있으면 기본으로 그것을 보여주고
// "PDF 원본" 토글 제공. lastDocId 는 prop(fullDoc.id) 로 키잉 — 3-pane 은 라우트 리마운트가
// 없어 page.params 가드는 no-op 이 된다.
let pdfViewMode = $state('markdown');
let lastDocId = $state(null);
let canShowMarkdown = $derived(
!!(isMdSuccess(fullDoc?.md_status) && fullDoc?.md_content?.trim())
);
$effect(() => {
if (!fullDoc) return;
if (fullDoc.id !== lastDocId) {
lastDocId = fullDoc.id;
pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf';
}
if (!canShowMarkdown && pdfViewMode === 'markdown') pdfViewMode = 'pdf';
});
function startEdit() {
editContent = fullDoc?.extracted_text || rawMarkdown || '';
editMode = true;
editTab = 'edit';
}
async function saveContent() {
saving = true;
try {
await api(`/documents/${fullDoc.id}/content`, {
method: 'PUT',
body: JSON.stringify({ content: editContent }),
});
fullDoc.extracted_text = editContent;
editMode = false;
addToast('success', '저장됨');
} catch (err) {
addToast('error', '저장 실패');
} finally {
saving = false;
}
}
function handleKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 's' && editMode) {
e.preventDefault();
saveContent();
}
}
let editInfo = $derived(fullDoc ? getEditInfo(fullDoc) : null);
const PROSE = 'prose prose-invert prose-base max-w-none';
</script>
<svelte:window on:keydown={handleKeydown} />
<div class="h-full flex flex-col bg-surface border-t border-default">
<!-- 뷰어 툴바 -->
{#if fullDoc && !loading}
<div class="flex items-center justify-between px-3 py-1.5 border-b border-default bg-sidebar shrink-0">
<span class="text-xs text-dim truncate">{fullDoc.title || '제목 없음'}</span>
<div class="flex items-center gap-2">
{#if viewerType === 'markdown'}
{#if editMode}
<button onclick={saveContent} disabled={saving}
class="flex items-center gap-1 px-2 py-1 text-xs bg-accent text-white rounded hover:bg-accent-hover disabled:opacity-50">
<Save size={12} /> {saving ? '저장 중...' : '저장'}
</button>
<button onclick={() => editMode = false} class="px-2 py-1 text-xs text-dim hover:text-text">취소</button>
{:else}
<button onclick={startEdit} class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">편집</button>
{/if}
{/if}
{#if editInfo}
<a href={editInfo.url} target="_blank" rel="noopener"
class="flex items-center gap-1 px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">
<ExternalLink size={12} /> {editInfo.label}
</a>
{/if}
<a href="/documents/{fullDoc.id}" class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">전체 보기</a>
</div>
</div>
{/if}
<!-- 뷰어 본문 -->
<div class="flex-1 overflow-auto min-h-0">
{#if loading}
<div class="flex items-center justify-center h-full"><p class="text-sm text-dim">로딩 중...</p></div>
{:else if fullDoc}
{#if viewerType === 'markdown'}
{#if editMode}
<div class="flex flex-col h-full">
<Tabs tabs={[{ id: 'edit', label: '편집' }, { id: 'preview', label: '미리보기' }]} bind:value={editTab} class="flex flex-col h-full">
{#snippet children(activeId)}
{#if activeId === 'edit'}
<textarea bind:value={editContent}
class="flex-1 w-full p-4 bg-bg text-text text-sm font-mono resize-none outline-none min-h-[300px]"
spellcheck="false" aria-label="마크다운 편집"></textarea>
{:else}
<div class="flex-1 overflow-auto p-4 markdown-body">{@html renderMd(editContent)}</div>
{/if}
{/snippet}
</Tabs>
</div>
{:else}
<!-- md/txt = extracted_text 단일 필드(표시=편집), MarkdownDoc 로 앵커/KaTeX/이미지 렌더 -->
<div class="p-4">
<MarkdownDoc
documentId={fullDoc.id}
mdContent={null}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
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">
<MarkdownStatusBadge mdStatus={fullDoc.md_status} mdExtractionError={fullDoc.md_extraction_error} mdExtractionQuality={fullDoc.md_extraction_quality} />
{#if canShowMarkdown}
<button onclick={() => (pdfViewMode = 'markdown')}
class="px-2 py-1 text-xs rounded border {pdfViewMode === 'markdown' ? 'bg-accent text-white border-accent' : 'text-dim border-default hover:text-accent'}">Markdown</button>
<button onclick={() => (pdfViewMode = 'pdf')}
class="px-2 py-1 text-xs rounded border {pdfViewMode === 'pdf' ? 'bg-accent text-white border-accent' : 'text-dim border-default hover:text-accent'}">PDF 원본</button>
{/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>
{:else}
<iframe src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}" class="flex-1 w-full border-0 rounded" title={fullDoc.title}></iframe>
{/if}
</div>
{:else if viewerType === 'hwp-markdown'}
<div class="p-4">
<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>
{:else if viewerType === 'preview-pdf'}
<iframe src="/api/documents/{fullDoc.id}/preview?token={getAccessToken()}" class="w-full h-full border-0" title={fullDoc.title} onerror={() => {}}></iframe>
{:else if viewerType === 'image'}
<div class="flex items-center justify-center h-full p-4">
<img src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}" alt={fullDoc.title} class="max-w-full max-h-full object-contain rounded" />
</div>
{:else if viewerType === 'text'}
<div class="p-4"><pre class="text-sm text-text whitespace-pre-wrap font-mono">{fullDoc.extracted_text || '텍스트 없음'}</pre></div>
{:else if viewerType === 'synology'}
<div class="flex flex-col items-center justify-center h-full gap-3">
<p class="text-sm text-dim">Synology Office 문서 — 외부 편집기에서 열어야 합니다.</p>
<a href={fullDoc.edit_url || 'https://link.hyungi.net'} target="_blank" rel="noopener"
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover">
<ExternalLink size={14} /> 새 창에서 열기
</a>
</div>
{:else if viewerType === 'cad'}
<div class="flex flex-col items-center justify-center h-full gap-3">
<p class="text-sm text-dim">CAD 미리보기 (향후 지원 예정)</p>
<a href="https://web.autocad.com" target="_blank" rel="noopener" class="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent-hover">AutoCAD Web에서 열기</a>
</div>
{:else if viewerType === 'article'}
<div class="p-5 max-w-3xl mx-auto">
<h1 class="text-lg font-bold mb-2 text-text">{fullDoc.title}</h1>
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
{#if fullDoc.ai_tags?.length}
{#each fullDoc.ai_tags.filter(t => t.startsWith('News/')) as tag}
<span class="px-1.5 py-0.5 rounded bg-accent/15 text-accent-hover">{tag.replace('News/', '')}</span>
{/each}
{/if}
<span>{new Date(fullDoc.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
</div>
<MarkdownDoc
documentId={fullDoc.id}
mdContent={fullDoc.md_content}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
extractedText={fullDoc.extracted_text}
class="{PROSE} mb-6"
/>
{#if fullDoc.edit_url}
<div class="flex items-center gap-3 pt-4 border-t border-default">
<a href={fullDoc.edit_url} target="_blank" rel="noopener noreferrer"
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover">
<ExternalLink size={14} /> 원문 보기
</a>
</div>
{/if}
</div>
{:else}
<div class="flex items-center justify-center h-full"><p class="text-sm text-dim">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p></div>
{/if}
{/if}
</div>
</div>