448 lines
12 KiB
TypeScript
Executable File
448 lines
12 KiB
TypeScript
Executable File
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(<h3 key={`h3-${index}`}>{line.slice(4)}</h3>);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('## ')) {
|
|
blocks.push(<h2 key={`h2-${index}`}>{line.slice(3)}</h2>);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('# ')) {
|
|
blocks.push(<h1 key={`h1-${index}`}>{line.slice(2)}</h1>);
|
|
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(
|
|
<ul key={`list-${index}`}>
|
|
{items.map((item, itemIndex) => (
|
|
<li key={`${item}-${itemIndex}`}>{item}</li>
|
|
))}
|
|
</ul>,
|
|
);
|
|
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' ? (
|
|
<div key={`code-${index}`}>
|
|
<CodexDiffBlock diffText={codeLines.join('\n')} />
|
|
</div>
|
|
) : (
|
|
<div key={`code-${index}`}>{renderEditorBlock(codeLines.join('\n'), codeLanguage, 'code')}</div>
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
blocks.push(<p key={`p-${index}`}>{line}</p>);
|
|
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<PreviewerUIProps, 'type' | 'value'>) {
|
|
switch (type) {
|
|
case 'json':
|
|
return stringifyValue(normalizeJsonValue(value));
|
|
case 'image':
|
|
return String(value ?? '');
|
|
default:
|
|
return stringifyValue(value);
|
|
}
|
|
}
|
|
|
|
function resolveDownloadValue({
|
|
type,
|
|
value,
|
|
downloadValue,
|
|
}: Pick<PreviewerUIProps, 'type' | 'value' | 'downloadValue'>) {
|
|
if (typeof downloadValue === 'string') {
|
|
return downloadValue;
|
|
}
|
|
|
|
if (type === 'image') {
|
|
return String(value ?? '');
|
|
}
|
|
|
|
return resolveCopyValue({ type, value });
|
|
}
|
|
|
|
function resolveDownloadFileName({
|
|
type,
|
|
language,
|
|
downloadFileName,
|
|
}: Pick<PreviewerUIProps, 'type' | 'language' | 'downloadFileName'>) {
|
|
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<PreviewerUIProps, 'type' | 'value' | 'language' | 'format' | 'imageAlt'>) {
|
|
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 <div className="previewer-ui__text">{textValue}</div>;
|
|
case 'json':
|
|
return renderEditorBlock(stringifyValue(normalizeJsonValue(value)), 'json', 'json');
|
|
case 'code':
|
|
return renderEditorBlock(textValue, language ?? 'text', 'code');
|
|
case 'image':
|
|
return (
|
|
<InlineImage
|
|
className="previewer-ui__image"
|
|
src={String(value ?? '')}
|
|
alt={imageAlt ?? 'preview'}
|
|
fallbackText="이미지 preview를 불러오지 못했습니다."
|
|
/>
|
|
);
|
|
case 'markdown':
|
|
return <div className="previewer-ui__markdown">{renderMarkdown(textValue)}</div>;
|
|
case 'empty':
|
|
return (
|
|
<div className="previewer-ui__empty">
|
|
<Empty description={textValue || '표시할 데이터가 없습니다.'} />
|
|
</div>
|
|
);
|
|
default:
|
|
return <div className="previewer-ui__text">{textValue}</div>;
|
|
}
|
|
}
|
|
|
|
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 ? (
|
|
<Select
|
|
size="small"
|
|
value={language}
|
|
options={languageOptions}
|
|
onChange={onLanguageChange}
|
|
className="previewer-ui__language-select"
|
|
/>
|
|
) : null}
|
|
{canCopy ? (
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
className="previewer-ui__action-button"
|
|
aria-label="복사"
|
|
icon={<CopyOutlined />}
|
|
onClick={() => void handleCopy()}
|
|
/>
|
|
) : null}
|
|
{canDownload ? (
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
className="previewer-ui__action-button"
|
|
aria-label="다운로드"
|
|
icon={<DownloadOutlined />}
|
|
onClick={handleDownload}
|
|
/>
|
|
) : null}
|
|
{maximizable ? (
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
className="previewer-ui__action-button"
|
|
aria-label={isExpanded ? '최대화 해제' : '최대화'}
|
|
icon={isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
|
onClick={() => void toggleFullscreen()}
|
|
/>
|
|
) : null}
|
|
{toolbar}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{contextHolder}
|
|
<div
|
|
className={[
|
|
'previewer-ui',
|
|
!showHeader ? 'previewer-ui--headerless' : '',
|
|
isExpanded ? 'previewer-ui--expanded' : '',
|
|
className ?? '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
{showHeader ? (
|
|
<div className="previewer-ui__header">
|
|
<div className="previewer-ui__meta">
|
|
<div className="previewer-ui__title">{title}</div>
|
|
{description ? <div className="previewer-ui__description">{description}</div> : null}
|
|
</div>
|
|
{shouldShowActions ? <div className="previewer-ui__toolbar">{actionContent}</div> : null}
|
|
</div>
|
|
) : null}
|
|
<div className="previewer-ui__body previewer-ui__scroll" style={{ height }}>
|
|
{!showHeader && shouldShowActions ? <div className="previewer-ui__floating-toolbar">{actionContent}</div> : null}
|
|
{renderContent({
|
|
type,
|
|
value,
|
|
language,
|
|
format,
|
|
imageAlt,
|
|
})}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|