From 360871e9cf6e23557e36e7eb0e429a309241826c Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 8 Jun 2026 15:44:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(documents):=203-pane=20=EC=A4=91=EC=95=99?= =?UTF-8?q?=20=EB=A6=AC=EB=8D=94=20markdown-first=20=EC=9D=BC=EC=9B=90?= =?UTF-8?q?=ED=99=94=20(DocumentViewer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 /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(
, 콤마 뭉침 회피), 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) 
---
 .../src/lib/components/DocumentViewer.svelte  | 240 +++++++++---------
 frontend/src/lib/utils/viewerType.ts          |  46 ++++
 2 files changed, 172 insertions(+), 114 deletions(-)
 create mode 100644 frontend/src/lib/utils/viewerType.ts

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';
+}