diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte index 1bc32e8..5aa4b5b 100644 --- a/frontend/src/lib/components/DocumentViewer.svelte +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -3,10 +3,14 @@ import { addToast } from '$lib/stores/toast'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; - import { ExternalLink, Save, RefreshCw } from 'lucide-svelte'; + 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'; - // marked + sanitize + // 편집 미리보기 전용 plain marked (본문 렌더는 MarkdownDoc 가 담당). marked.use({ mangle: false, headerIds: false }); function renderMd(text) { return DOMPurify.sanitize(marked(text), { @@ -22,33 +26,19 @@ let loading = $state(true); let viewerType = $state('none'); - // Markdown 편집 + // Markdown 편집 (md/txt — extracted_text 가 표시·편집 단일 필드) let editMode = $state(false); let editContent = $state(''); let editTab = $state('edit'); let saving = $state(false); let rawMarkdown = $state(''); - function getViewerType(format) { - if (['md', 'txt'].includes(format)) return 'markdown'; - if (format === 'pdf') return 'pdf'; - if (['hwp', 'hwpx'].includes(format)) return 'preview-pdf'; - if (['odoc', 'osheet', 'docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp'].includes(format)) return 'preview-pdf'; - if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff'].includes(format)) return 'image'; - if (['csv', 'json', 'xml', 'html'].includes(format)) return 'text'; - if (['dwg', 'dxf'].includes(format)) return 'cad'; - return 'unsupported'; - } - const ODF_FORMATS = ['ods', 'odt', 'odp', 'odoc', 'osheet']; - function getEditInfo(doc) { - // DB에 저장된 편집 URL 우선 - if (doc.edit_url) return { url: doc.edit_url, label: '편집' }; - // ODF 포맷 → Synology Drive - if (ODF_FORMATS.includes(doc.file_format)) return { url: 'https://link.hyungi.net', label: 'Synology Drive에서 열기' }; - // CAD - if (['dwg', 'dxf'].includes(doc.file_format)) return { url: 'https://web.autocad.com', label: 'AutoCAD Web' }; + 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; } @@ -61,18 +51,17 @@ async function loadFullDoc(id) { loading = true; + rawMarkdown = ''; try { fullDoc = await api(`/documents/${id}`); - viewerType = fullDoc.source_channel === 'news' ? 'article' : getViewerType(fullDoc.file_format); + viewerType = getViewerType(fullDoc.file_format, fullDoc.source_channel); - // Markdown: extracted_text 없으면 원본 파일 직접 가져오기 + // 본문 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 = ''; } - } else { - rawMarkdown = ''; } } catch (err) { fullDoc = null; @@ -82,6 +71,23 @@ } } + // 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; @@ -113,6 +119,7 @@ } let editInfo = $derived(fullDoc ? getEditInfo(fullDoc) : null); + const PROSE = 'prose prose-invert prose-base max-w-none'; @@ -125,38 +132,22 @@
{#if viewerType === 'markdown'} {#if editMode} - - + {:else} - + {/if} {/if} {#if editInfo} - + {editInfo.label} {/if} - 전체 보기 + 전체 보기
{/if} @@ -164,109 +155,130 @@
{#if loading} -
-

로딩 중...

-
+

로딩 중...

{:else if fullDoc} {#if viewerType === 'markdown'} {#if editMode} -
- + {#snippet children(activeId)} {#if activeId === 'edit'} - + spellcheck="false" aria-label="마크다운 편집"> {:else} -
- {@html renderMd(editContent)} -
+
{@html renderMd(editContent)}
{/if} {/snippet}
{:else} -
- {@html renderMd(fullDoc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')} + +
+
{/if} {:else if viewerType === 'pdf'} - - {:else if viewerType === 'preview-pdf'} - - {:else if viewerType === 'image'} -
- {fullDoc.title} +
+ + {#if canShowMarkdown} + + + {/if} +
+ {#if pdfViewMode === 'markdown' && canShowMarkdown} +
+ +
+ {:else} + + {/if} +
+ {:else if viewerType === 'hwp-markdown'} +
+
+ {:else if viewerType === 'preview-pdf'} + + {:else if viewerType === 'image'} +
+ {fullDoc.title} +
{:else if viewerType === 'text'} -
-
{fullDoc.extracted_text || '텍스트 없음'}
+
{fullDoc.extracted_text || '텍스트 없음'}
+ {:else if viewerType === 'synology'} +
+

Synology Office 문서 — 외부 편집기에서 열어야 합니다.

+ + 새 창에서 열기 +
{:else if viewerType === 'cad'}

CAD 미리보기 (향후 지원 예정)

- AutoCAD Web에서 열기 + AutoCAD Web에서 열기
{:else if viewerType === 'article'} -
-

{fullDoc.title}

+

{fullDoc.title}

{#if fullDoc.ai_tags?.length} {#each fullDoc.ai_tags.filter(t => t.startsWith('News/')) as tag} - {tag.replace('News/', '')} + {tag.replace('News/', '')} {/each} {/if} {new Date(fullDoc.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
-
- {@html renderMd(fullDoc.extracted_text || '')} -
-
- {#if fullDoc.edit_url} - + + {#if fullDoc.edit_url} + +
+ {/if}
{:else} -
-

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

-
+

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

{/if} {/if}
diff --git a/frontend/src/lib/utils/viewerType.ts b/frontend/src/lib/utils/viewerType.ts new file mode 100644 index 0000000..3eacc36 --- /dev/null +++ b/frontend/src/lib/utils/viewerType.ts @@ -0,0 +1,46 @@ +// 뷰어 타입 분류 단일 source — 상세페이지(/documents/[id])와 3-pane 중앙 리더 +// (DocumentViewer)가 공유한다. 두 곳이 각자 getViewerType 을 두면 csv/hwp/office 분기가 +// drift 하므로(이원화 재발) 여기 하나로 수렴한다. +// +// ⚠ 소비 컴포넌트는 이 함수가 낼 수 있는 모든 ViewerType 에 render 분기가 있어야 한다. +// (분류 통합 ≠ render 통합 — 양쪽 컴포넌트의 {#if viewerType===...} 에 누락 없는지 확인.) + +export type ViewerType = + | 'article' + | 'markdown' + | 'hwp-markdown' + | 'pdf' + | 'preview-pdf' + | 'image' + | 'text' + | 'synology' + | 'cad' + | 'unsupported'; + +const MARKDOWN = new Set(['md', 'txt']); +// csv/json/xml/html 은 markdown 으로 렌더하면 콤마/행이 한 문단으로 뭉친다 →
 로 원형 보존.
+const TEXT = new Set(['csv', 'json', 'xml', 'html']);
+const HWP = new Set(['hwp', 'hwpx']);
+// LibreOffice headless → PDF preview (/preview) 로 인앱 표시.
+const OFFICE_PREVIEW = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
+// Synology Office 네이티브 — 인앱 변환 부적합, 외부 편집기로.
+const SYNOLOGY = new Set(['odoc', 'osheet']);
+const IMAGE = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff']);
+const CAD = new Set(['dwg', 'dxf']);
+
+export function getViewerType(
+  format: string | null | undefined,
+  sourceChannel?: string | null,
+): ViewerType {
+  if (sourceChannel === 'news') return 'article';
+  const f = (format ?? '').toLowerCase();
+  if (MARKDOWN.has(f)) return 'markdown';
+  if (f === 'pdf') return 'pdf';
+  if (HWP.has(f)) return 'hwp-markdown';
+  if (OFFICE_PREVIEW.has(f)) return 'preview-pdf';
+  if (SYNOLOGY.has(f)) return 'synology';
+  if (IMAGE.has(f)) return 'image';
+  if (TEXT.has(f)) return 'text';
+  if (CAD.has(f)) return 'cad';
+  return 'unsupported';
+}