Files
ai-code-app/src/components/previewer/PreviewerUI.tsx

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>
</>
);
}