import { CopyOutlined, DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; import { Button, Empty, Select, message } from 'antd'; import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { InlineImage } from '../common/InlineImage'; import { CodexDiffBlock } from './CodexDiffBlock'; import type { PreviewerUIProps } from './types'; import { inferCodeLanguage, renderEditorBlock } from './renderers'; import './PreviewerUI.css'; function stringifyValue(value: unknown) { if (typeof value === 'string') { return value; } if (value === undefined || value === null) { return ''; } try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function normalizeJsonValue(value: unknown) { if (typeof value !== 'string') { return value; } try { return JSON.parse(value); } catch { return value; } } function renderMarkdown(markdown: string) { const lines = markdown.split('\n'); const blocks: ReactNode[] = []; let index = 0; while (index < lines.length) { const line = lines[index].trimEnd(); if (!line.trim()) { index += 1; continue; } if (line.startsWith('### ')) { blocks.push(

{line.slice(4)}

); index += 1; continue; } if (line.startsWith('## ')) { blocks.push(

{line.slice(3)}

); index += 1; continue; } if (line.startsWith('# ')) { blocks.push(

{line.slice(2)}

); index += 1; continue; } if (line.startsWith('- ')) { const items: string[] = []; while (index < lines.length && lines[index].trim().startsWith('- ')) { items.push(lines[index].trim().slice(2)); index += 1; } blocks.push( , ); continue; } if (line.startsWith('```')) { const codeLanguage = inferCodeLanguage(line.slice(3).trim() || 'text'); const codeLines: string[] = []; index += 1; while (index < lines.length && !lines[index].trim().startsWith('```')) { codeLines.push(lines[index]); index += 1; } index += 1; blocks.push( codeLanguage === 'diff' ? (
) : (
{renderEditorBlock(codeLines.join('\n'), codeLanguage, 'code')}
), ); continue; } blocks.push(

{line}

); index += 1; } return blocks; } async function copyText(text: string) { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } if (typeof document === 'undefined') { throw new Error('클립보드 API를 사용할 수 없습니다.'); } const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', 'true'); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } function downloadBlob(content: BlobPart, fileName: string, mimeType = 'text/plain;charset=utf-8') { if (typeof document === 'undefined') { throw new Error('다운로드를 사용할 수 없습니다.'); } const blob = new Blob([content], { type: mimeType }); const objectUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = objectUrl; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(objectUrl); } function resolveCopyValue({ type, value }: Pick) { switch (type) { case 'json': return stringifyValue(normalizeJsonValue(value)); case 'image': return String(value ?? ''); default: return stringifyValue(value); } } function resolveDownloadValue({ type, value, downloadValue, }: Pick) { if (typeof downloadValue === 'string') { return downloadValue; } if (type === 'image') { return String(value ?? ''); } return resolveCopyValue({ type, value }); } function resolveDownloadFileName({ type, language, downloadFileName, }: Pick) { if (downloadFileName?.trim()) { return downloadFileName.trim(); } switch (type) { case 'json': return 'preview.json'; case 'markdown': return 'preview.md'; case 'code': return `preview.${language || 'txt'}`; case 'image': return 'preview'; default: return 'preview.txt'; } } function renderContent({ type, value, language, format, imageAlt, }: Pick) { const textValue = stringifyValue(value); const lines = textValue.split('\n').map((line) => line.trim()).filter(Boolean); const detectedFormat = format && format !== 'auto' ? format : lines.length > 0 && lines.every((line) => /^([A-Za-z]:\\|\.{0,2}\/|\/)?[\w@./-]+(?:\/[\w@./-]+)*$/.test(line)) ? 'paths' : lines.length > 0 && lines.filter((line) => /^(?:[$>#]\s*)?[a-z0-9_.-]+(?:\s+.+)?$/i.test(line) || /^#/.test(line)).length >= Math.max(1, Math.ceil(lines.length * 0.6)) ? 'terminal' : 'plain'; switch (type) { case 'text': if (detectedFormat === 'terminal') { return renderEditorBlock(textValue, 'bash', 'code'); } if (detectedFormat === 'paths') { return renderEditorBlock(textValue, 'paths', 'code'); } return
{textValue}
; case 'json': return renderEditorBlock(stringifyValue(normalizeJsonValue(value)), 'json', 'json'); case 'code': return renderEditorBlock(textValue, language ?? 'text', 'code'); case 'image': return ( ); case 'markdown': return
{renderMarkdown(textValue)}
; case 'empty': return (
); default: return
{textValue}
; } } export function PreviewerUI({ type, title = 'Preview', description, showHeader = true, value, copyValue, copyable = true, downloadable = true, downloadValue, downloadUrl, downloadFileName, maximizable = true, language = 'text', format = 'auto', languageOptions, onLanguageChange, imageAlt, height = 280, toolbar, className, }: PreviewerUIProps) { const [messageApi, contextHolder] = message.useMessage(); const [isExpanded, setIsExpanded] = useState(false); const hasLanguageSelector = type === 'code' && languageOptions && languageOptions.length > 0; const resolvedCopyValue = copyValue ?? resolveCopyValue({ type, value }); const resolvedDownloadValue = resolveDownloadValue({ type, value, downloadValue }); const resolvedDownloadFileName = resolveDownloadFileName({ type, language, downloadFileName }); const canCopy = copyable && resolvedCopyValue.trim().length > 0; const canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0); const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || Boolean(toolbar); useEffect(() => { if (!isExpanded || typeof document === 'undefined') { return; } const previousOverflow = document.body.style.overflow; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setIsExpanded(false); } }; document.body.style.overflow = 'hidden'; document.addEventListener('keydown', handleKeyDown); return () => { document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', handleKeyDown); }; }, [isExpanded]); async function handleCopy() { if (!canCopy) { return; } try { await copyText(resolvedCopyValue); messageApi.success('복사했습니다.'); } catch { messageApi.error('복사에 실패했습니다.'); } } async function toggleFullscreen() { if (!maximizable) { return; } setIsExpanded((previous) => !previous); } function handleDownload() { if (!canDownload) { return; } try { if (downloadUrl) { if (typeof document === 'undefined') { throw new Error('다운로드를 사용할 수 없습니다.'); } const link = document.createElement('a'); link.href = downloadUrl; link.download = resolvedDownloadFileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); } else { const mimeType = type === 'json' ? 'application/json;charset=utf-8' : type === 'markdown' ? 'text/markdown;charset=utf-8' : 'text/plain;charset=utf-8'; downloadBlob(resolvedDownloadValue, resolvedDownloadFileName, mimeType); } } catch { messageApi.error('다운로드에 실패했습니다.'); } } const actionContent = ( <> {hasLanguageSelector ? (