Initial import
This commit is contained in:
342
src/components/previewer/PreviewerUI.tsx
Executable file
342
src/components/previewer/PreviewerUI.tsx
Executable file
@@ -0,0 +1,342 @@
|
||||
import { CopyOutlined, 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 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 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,
|
||||
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 canCopy = copyable && resolvedCopyValue.trim().length > 0;
|
||||
const shouldShowActions = hasLanguageSelector || canCopy || 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);
|
||||
}
|
||||
|
||||
const actionContent = (
|
||||
<>
|
||||
{hasLanguageSelector ? (
|
||||
<Select
|
||||
size="small"
|
||||
value={language}
|
||||
options={languageOptions}
|
||||
onChange={onLanguageChange}
|
||||
className="previewer-ui__language-select"
|
||||
/>
|
||||
) : null}
|
||||
{canCopy ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
aria-label="복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => void handleCopy()}
|
||||
/>
|
||||
) : null}
|
||||
{maximizable ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user