feat: update main chat and system chat UI
This commit is contained in:
72
src/components/chatActivityExecutor/sampleShared.tsx
Normal file
72
src/components/chatActivityExecutor/sampleShared.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Card, Space, Typography } from 'antd';
|
||||
import type { ChatConversationRequest } from '../../app/main/mainChatPanel/types';
|
||||
import { ChatActivityChecklist } from '../../app/main/mainChatPanel/ChatActivityChecklist';
|
||||
import {
|
||||
describeExecutorCommand,
|
||||
} from '../../app/main/mainChatPanel/executorActivitySummary';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
export function ExecutorActivityCardSample({
|
||||
title,
|
||||
focus,
|
||||
command,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
focus: string;
|
||||
command: string | null;
|
||||
description: string;
|
||||
}) {
|
||||
const descriptor = describeExecutorCommand(command, focus);
|
||||
|
||||
return (
|
||||
<Card title={title} extra={<Text code>활동 로그 문구 샘플</Text>}>
|
||||
<Paragraph>{description}</Paragraph>
|
||||
<section className="app-chat-activity-checklist app-chat-activity-checklist--ticker" aria-label={`${title} 상태`}>
|
||||
<div className="app-chat-activity-checklist__header">
|
||||
<div className="app-chat-activity-checklist__title-group">
|
||||
<span className="app-chat-activity-checklist__title">작업관리자(gpt-5.4) 실행기</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-chat-activity-ticker" title={[descriptor.focusLabel, descriptor.message].filter(Boolean).join(' · ')}>
|
||||
<div className="app-chat-activity-ticker__section">
|
||||
<span className="app-chat-activity-ticker__label">대상 작업</span>
|
||||
<p className="app-chat-activity-ticker__body">{descriptor.focusLabel}</p>
|
||||
</div>
|
||||
<div className="app-chat-activity-ticker__section">
|
||||
<span className="app-chat-activity-ticker__label">현재 단계</span>
|
||||
<p className="app-chat-activity-ticker__body">{descriptor.kindLabel}</p>
|
||||
</div>
|
||||
<div className="app-chat-activity-ticker__section">
|
||||
<span className="app-chat-activity-ticker__label">실행 내용</span>
|
||||
<p className="app-chat-activity-ticker__body">{descriptor.detailLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChecklistActivityCardSample({
|
||||
title,
|
||||
request,
|
||||
lines,
|
||||
statusLabel,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
request: ChatConversationRequest;
|
||||
lines: string[];
|
||||
statusLabel: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<Card title={title} extra={<Text code>Plan 체크리스트 샘플</Text>}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Paragraph>{description}</Paragraph>
|
||||
<ChatActivityChecklist lines={lines} request={request} title="시스템 Plan 체크리스트" statusLabel={statusLabel} />
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SampleMeta } from '../../../widgets/core';
|
||||
import { ExecutorActivityCardSample } from '../sampleShared';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'chat-activity-executor-analysis',
|
||||
componentId: 'chat-activity-executor-analysis',
|
||||
title: 'Chat Activity Executor Analysis',
|
||||
description: '분석 단계 실행기 문구 샘플입니다.',
|
||||
category: 'Chat',
|
||||
kind: 'feature',
|
||||
order: 11,
|
||||
features: ['component-sample'],
|
||||
};
|
||||
|
||||
export function Sample() {
|
||||
return (
|
||||
<ExecutorActivityCardSample
|
||||
title="분석 문구"
|
||||
focus="시스템 Plan 체크리스트 문구 개선"
|
||||
command='rg -n "executor.commandSummary|checklist" src/app/main/mainChatPanel -S'
|
||||
description="코드 위치를 찾는 동안에도 현재 어떤 기능 범위를 분석 중인지 함께 보여줍니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SampleMeta } from '../../../widgets/core';
|
||||
import type { ChatConversationRequest } from '../../../../app/main/mainChatPanel/types';
|
||||
import { ChecklistActivityCardSample } from '../sampleShared';
|
||||
|
||||
const sampleRequest: ChatConversationRequest = {
|
||||
sessionId: 'sample-session',
|
||||
requestId: 'sample-request',
|
||||
status: 'started',
|
||||
statusMessage: null,
|
||||
userMessageId: 1,
|
||||
userText: '시스템 plan 체크리스트 실행 문구를 더 구체적으로 보여 주세요.',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-18T12:00:00.000Z',
|
||||
updatedAt: '2026-05-18T12:02:00.000Z',
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
};
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'chat-activity-checklist-overview',
|
||||
componentId: 'chat-activity-checklist-overview',
|
||||
title: 'Chat Activity Checklist Overview',
|
||||
description: 'Plan 체크리스트 헤더 문구 샘플입니다.',
|
||||
category: 'Chat',
|
||||
kind: 'feature',
|
||||
order: 14,
|
||||
features: ['component-sample'],
|
||||
};
|
||||
|
||||
export function Sample() {
|
||||
return (
|
||||
<ChecklistActivityCardSample
|
||||
title="체크리스트 문구"
|
||||
request={sampleRequest}
|
||||
lines={['☑ 요청 분석', '☑ 설계 정리', '☐ 문구 개선 코드 반영', '☐ 검증 스크린샷 확인']}
|
||||
statusLabel="현재 작업: 개발 · 시스템 Plan 체크리스트 문구 개선 기능을 코드에 반영하는 중"
|
||||
description="체크리스트 헤더에서도 현재 작업 기능과 작업 유형이 함께 보이도록 바꾼 예시입니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SampleMeta } from '../../../widgets/core';
|
||||
import { ExecutorActivityCardSample } from '../sampleShared';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'chat-activity-executor-development',
|
||||
componentId: 'chat-activity-executor-development',
|
||||
title: 'Chat Activity Executor Development',
|
||||
description: '개발 단계 실행기 문구 샘플입니다.',
|
||||
category: 'Chat',
|
||||
kind: 'feature',
|
||||
order: 10,
|
||||
features: ['component-sample'],
|
||||
};
|
||||
|
||||
export function Sample() {
|
||||
return (
|
||||
<ExecutorActivityCardSample
|
||||
title="개발 문구"
|
||||
focus="시스템 Plan 체크리스트 문구 개선"
|
||||
command="apply_patch"
|
||||
description="코드 반영 단계에서는 어떤 기능을 개발 중인지가 바로 보이도록 문구를 바꿨습니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/components/chatActivityExecutor/samples/TestSample.tsx
Normal file
24
src/components/chatActivityExecutor/samples/TestSample.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { SampleMeta } from '../../../widgets/core';
|
||||
import { ExecutorActivityCardSample } from '../sampleShared';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'chat-activity-executor-test',
|
||||
componentId: 'chat-activity-executor-test',
|
||||
title: 'Chat Activity Executor Test',
|
||||
description: '테스트 단계 실행기 문구 샘플입니다.',
|
||||
category: 'Chat',
|
||||
kind: 'feature',
|
||||
order: 12,
|
||||
features: ['component-sample'],
|
||||
};
|
||||
|
||||
export function Sample() {
|
||||
return (
|
||||
<ExecutorActivityCardSample
|
||||
title="테스트 문구"
|
||||
focus="시스템 Plan 체크리스트 문구 개선"
|
||||
command="npm exec playwright test src/app/main/mainChatPanel"
|
||||
description="동작 테스트 단계는 개발과 구분되도록 테스트 문구로 분리했습니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SampleMeta } from '../../../widgets/core';
|
||||
import { ExecutorActivityCardSample } from '../sampleShared';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'chat-activity-executor-verification',
|
||||
componentId: 'chat-activity-executor-verification',
|
||||
title: 'Chat Activity Executor Verification',
|
||||
description: '검증 단계 실행기 문구 샘플입니다.',
|
||||
category: 'Chat',
|
||||
kind: 'feature',
|
||||
order: 13,
|
||||
features: ['component-sample'],
|
||||
};
|
||||
|
||||
export function Sample() {
|
||||
return (
|
||||
<ExecutorActivityCardSample
|
||||
title="검증 문구"
|
||||
focus="시스템 Plan 체크리스트 문구 개선"
|
||||
command="npm run capture:component -- chat-activity-executor-verification 2026-05-18"
|
||||
description="스크린샷 캡처나 최종 확인 단계는 검증 문구로 명확하게 구분합니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -152,6 +152,49 @@ export function Sample() {
|
||||
onSubmit={async () => false}
|
||||
readOnly
|
||||
/>
|
||||
<ChatPromptCard
|
||||
target={{
|
||||
type: 'prompt',
|
||||
title: '기타 요청만 전달된 상태',
|
||||
description: '선택 없이 추가 요청만 전달된 경우에는 모든 preview를 바로 확인할 수 있습니다.',
|
||||
readOnly: true,
|
||||
resolvedBy: 'user',
|
||||
resultText: '선택 없이 기타 요청만 전달되어 다음 응답에서 범위를 다시 좁히기로 했습니다.',
|
||||
options: [
|
||||
{
|
||||
label: '확인안 A',
|
||||
value: 'free-text-a',
|
||||
description: '레이아웃 비교 이미지를 먼저 보여주는 안',
|
||||
preview: {
|
||||
type: 'image',
|
||||
url: 'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=900&q=80',
|
||||
alt: '확인안 A 샘플',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '확인안 B',
|
||||
value: 'free-text-b',
|
||||
description: '설명 문서를 바로 읽을 수 있는 안',
|
||||
preview: {
|
||||
type: 'markdown',
|
||||
content: '### 확인안 B\n선택 없이 요청이 들어오면 모든 preview를 펼쳐 놓고 비교합니다.',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '확인안 C',
|
||||
value: 'free-text-c',
|
||||
description: '외부 리소스 미리보기를 함께 여는 안',
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/docs/index.md',
|
||||
title: '문서 리소스 예시',
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSubmit={async () => false}
|
||||
readOnly
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Typography } from 'antd';
|
||||
import { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl';
|
||||
import { InlineImage } from '../common/InlineImage';
|
||||
import { CodexDiffBlock } from '../previewer';
|
||||
import { inferCodeLanguage, renderEditorBlock } from '../previewer/renderers';
|
||||
@@ -37,26 +38,26 @@ function resolveRelativePath(basePath: string, targetPath: string) {
|
||||
|
||||
function resolveImageSource(documentPath: string | undefined, imagePath: string) {
|
||||
if (/^(https?:)?\/\//.test(imagePath) || imagePath.startsWith('data:')) {
|
||||
return imagePath;
|
||||
return normalizeChatResourceUrl(imagePath);
|
||||
}
|
||||
|
||||
const normalizedPath = imagePath.startsWith('/')
|
||||
? imagePath
|
||||
: resolveRelativePath(documentPath ?? '/', imagePath);
|
||||
|
||||
return docsAssetUrls[normalizedPath] ?? normalizedPath;
|
||||
return normalizeChatResourceUrl(docsAssetUrls[normalizedPath] ?? normalizedPath);
|
||||
}
|
||||
|
||||
function resolveLinkHref(documentPath: string | undefined, href: string) {
|
||||
if (/^(https?:)?\/\//.test(href) || href.startsWith('mailto:') || href.startsWith('#')) {
|
||||
return href;
|
||||
return href.startsWith('#') ? href : normalizeChatResourceUrl(href);
|
||||
}
|
||||
|
||||
const normalizedPath = href.startsWith('/')
|
||||
? href
|
||||
: resolveRelativePath(documentPath ?? '/', href);
|
||||
|
||||
return docsAssetUrls[normalizedPath] ?? normalizedPath;
|
||||
return normalizeChatResourceUrl(docsAssetUrls[normalizedPath] ?? normalizedPath);
|
||||
}
|
||||
|
||||
function renderInline(text: string, documentPath?: string) {
|
||||
|
||||
@@ -25,16 +25,11 @@ export const CODEX_DIFF_STATUS_LABEL_MAP: Record<CodexDiffFileStatus, string> =
|
||||
unknown: '기타',
|
||||
};
|
||||
|
||||
function buildExpandedPathSet(paths: string[]) {
|
||||
return Array.from(new Set(paths));
|
||||
}
|
||||
|
||||
type CodexDiffBlockProps = {
|
||||
diffText?: string | null;
|
||||
summary?: string;
|
||||
emptyDescription?: string;
|
||||
showToolbar?: boolean;
|
||||
expandAll?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -101,6 +96,14 @@ function resolveDiffPath(lines: string[]) {
|
||||
};
|
||||
}
|
||||
|
||||
function isUnifiedDiffFileHeaderStart(line: string, nextLine: string | undefined) {
|
||||
return line.startsWith('--- ') && Boolean(nextLine?.startsWith('+++ '));
|
||||
}
|
||||
|
||||
function hasResolvedDiffHeader(lines: string[]) {
|
||||
return lines.some((line) => line.startsWith('diff --git ') || line.startsWith('+++ '));
|
||||
}
|
||||
|
||||
export function parseCodexDiffSections(diffText: string | null | undefined): CodexDiffSection[] {
|
||||
if (!diffText?.trim()) {
|
||||
return [];
|
||||
@@ -127,11 +130,18 @@ export function parseCodexDiffSections(diffText: string | null | undefined): Cod
|
||||
current = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? '';
|
||||
const nextLine = lines[index + 1];
|
||||
|
||||
if (line.startsWith('diff --git ') && current.length) {
|
||||
flush();
|
||||
}
|
||||
|
||||
if (current.length && isUnifiedDiffFileHeaderStart(line, nextLine) && hasResolvedDiffHeader(current)) {
|
||||
flush();
|
||||
}
|
||||
|
||||
current.push(line);
|
||||
}
|
||||
|
||||
@@ -175,30 +185,22 @@ export function CodexDiffBlock({
|
||||
summary,
|
||||
emptyDescription = '표시할 diff가 없습니다.',
|
||||
showToolbar = true,
|
||||
expandAll = false,
|
||||
className,
|
||||
}: CodexDiffBlockProps) {
|
||||
const diffSections = useMemo(() => parseCodexDiffSections(diffText), [diffText]);
|
||||
const [expandedDiffPaths, setExpandedDiffPaths] = useState<string[]>(() =>
|
||||
diffSections[0]?.path ? [diffSections[0].path] : [],
|
||||
);
|
||||
const hasToolbar = Boolean(summary) || showToolbar;
|
||||
const [expandedDiffKey, setExpandedDiffKey] = useState<string | null>(() => diffSections[0]?.key ?? null);
|
||||
const [isAllExpanded, setIsAllExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (expandAll) {
|
||||
setExpandedDiffPaths(diffSections.map((section) => section.path));
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedDiffPaths((currentPaths) => {
|
||||
const nextPaths = currentPaths.filter((path) => diffSections.some((section) => section.path === path));
|
||||
if (nextPaths.length > 0) {
|
||||
return buildExpandedPathSet(nextPaths);
|
||||
setExpandedDiffKey((currentKey) => {
|
||||
if (currentKey && diffSections.some((section) => section.key === currentKey)) {
|
||||
return currentKey;
|
||||
}
|
||||
|
||||
return diffSections[0]?.path ? [diffSections[0].path] : [];
|
||||
return diffSections[0]?.key ?? null;
|
||||
});
|
||||
}, [diffSections, expandAll]);
|
||||
|
||||
}, [diffSections]);
|
||||
if (!diffSections.length) {
|
||||
return <Empty description={emptyDescription} />;
|
||||
}
|
||||
@@ -207,39 +209,49 @@ export function CodexDiffBlock({
|
||||
<div
|
||||
className={[
|
||||
className ?? 'codex-diff-previewer__diff-list',
|
||||
expandAll ? 'codex-diff-previewer__diff-list--expand-all' : '',
|
||||
isAllExpanded ? 'codex-diff-previewer__diff-list--expand-all' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className="codex-diff-previewer__diff-toolbar">
|
||||
<Text type="secondary">
|
||||
{summary ?? `파일 ${diffSections.length}개 기준으로 diff를 분리해 표시합니다.`}
|
||||
</Text>
|
||||
{showToolbar ? (
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setExpandedDiffPaths(diffSections.map((section) => section.path));
|
||||
}}
|
||||
>
|
||||
전체 펼치기
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setExpandedDiffPaths([]);
|
||||
}}
|
||||
>
|
||||
전체 접기
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
{hasToolbar ? (
|
||||
<div className="codex-diff-previewer__diff-toolbar">
|
||||
{summary ? <Text type="secondary">{summary}</Text> : <span />}
|
||||
{showToolbar ? (
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setIsAllExpanded(true);
|
||||
}}
|
||||
>
|
||||
전체 펼치기
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setIsAllExpanded(false);
|
||||
setExpandedDiffKey(diffSections[0]?.key ?? null);
|
||||
}}
|
||||
>
|
||||
첫 파일 열기
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setIsAllExpanded(false);
|
||||
setExpandedDiffKey(null);
|
||||
}}
|
||||
>
|
||||
전체 접기
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{diffSections.map((section) => {
|
||||
const isExpanded = expandedDiffPaths.includes(section.path);
|
||||
const isExpanded = isAllExpanded || expandedDiffKey === section.key;
|
||||
const displayPath = section.previousPath ? `${section.previousPath} -> ${section.path}` : section.path;
|
||||
|
||||
return (
|
||||
@@ -248,11 +260,13 @@ export function CodexDiffBlock({
|
||||
type="button"
|
||||
className="codex-diff-previewer__diff-toggle"
|
||||
onClick={() => {
|
||||
setExpandedDiffPaths((currentPaths) =>
|
||||
currentPaths.includes(section.path)
|
||||
? currentPaths.filter((path) => path !== section.path)
|
||||
: [...currentPaths, section.path],
|
||||
);
|
||||
if (isAllExpanded) {
|
||||
setIsAllExpanded(false);
|
||||
setExpandedDiffKey(section.key);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedDiffKey((currentKey) => (currentKey === section.key ? null : section.key));
|
||||
}}
|
||||
>
|
||||
<Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main">
|
||||
|
||||
@@ -61,10 +61,13 @@
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #f8fafc;
|
||||
font-size: 14px;
|
||||
@@ -75,20 +78,28 @@
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__meta {
|
||||
overflow: hidden;
|
||||
color: rgba(226, 232, 240, 0.82);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__actions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__icon-button.ant-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
@@ -98,12 +109,88 @@
|
||||
color: #f8fafc;
|
||||
background: rgba(30, 41, 59, 0.82);
|
||||
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.16);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
touch-action: manipulation;
|
||||
transition:
|
||||
color 140ms ease,
|
||||
background-color 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__minimize-button.ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__icon-button.ant-btn > span,
|
||||
.fullscreen-preview-modal__icon-button.ant-btn .ant-btn-icon,
|
||||
.fullscreen-preview-modal__icon-button.ant-btn .anticon,
|
||||
.fullscreen-preview-modal__icon-button.ant-btn .anticon svg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__icon-button.ant-btn .ant-btn-icon,
|
||||
.fullscreen-preview-modal__icon-button.ant-btn .anticon {
|
||||
color: inherit;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__icon-button.ant-btn .anticon svg {
|
||||
fill: currentColor;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__minimize-button.ant-btn .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__close-button.ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__icon-button.ant-btn:hover,
|
||||
.fullscreen-preview-modal__icon-button.ant-btn:focus-visible {
|
||||
.fullscreen-preview-modal__icon-button.ant-btn:focus,
|
||||
.fullscreen-preview-modal__icon-button.ant-btn:focus-visible,
|
||||
.fullscreen-preview-modal__icon-button.ant-btn:active,
|
||||
.fullscreen-preview-modal__icon-button.ant-btn.ant-btn-text:not(:disabled):not(.ant-btn-disabled):active {
|
||||
color: #fff;
|
||||
background: rgba(51, 65, 85, 0.94);
|
||||
box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.24);
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__icon-button.ant-btn:active,
|
||||
.fullscreen-preview-modal__icon-button.ant-btn.ant-btn-text:not(:disabled):not(.ant-btn-disabled):active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fullscreen-preview-modal__header {
|
||||
gap: 10px;
|
||||
padding: max(10px, env(safe-area-inset-top, 0px)) 12px 10px;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__title-group {
|
||||
min-height: 34px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__meta {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__content {
|
||||
@@ -115,6 +202,15 @@
|
||||
background: #0b1220;
|
||||
}
|
||||
|
||||
.fullscreen-preview-modal__content--fill > * {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.zoomable-preview-surface {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { CloseOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
import { Button, Modal } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import './FullscreenPreviewModal.css';
|
||||
|
||||
type FullscreenPreviewModalProps = {
|
||||
@@ -8,8 +8,17 @@ type FullscreenPreviewModalProps = {
|
||||
title?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
hideHeader?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
fillContent?: boolean;
|
||||
modalStyle?: CSSProperties;
|
||||
shellStyle?: CSSProperties;
|
||||
zIndex?: number;
|
||||
getContainer?: HTMLElement | (() => HTMLElement) | false;
|
||||
maskClosable?: boolean;
|
||||
minimizeLabel?: string;
|
||||
onMinimize?: (() => void) | null;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -19,8 +28,17 @@ export function FullscreenPreviewModal({
|
||||
title,
|
||||
meta,
|
||||
actions,
|
||||
hideHeader = false,
|
||||
className,
|
||||
contentClassName,
|
||||
fillContent = false,
|
||||
modalStyle,
|
||||
shellStyle,
|
||||
zIndex = 1400,
|
||||
getContainer,
|
||||
maskClosable = true,
|
||||
minimizeLabel = '최소화',
|
||||
onMinimize,
|
||||
onClose,
|
||||
children,
|
||||
}: FullscreenPreviewModalProps) {
|
||||
@@ -30,27 +48,53 @@ export function FullscreenPreviewModal({
|
||||
footer={null}
|
||||
title={null}
|
||||
width="100vw"
|
||||
style={modalStyle}
|
||||
zIndex={zIndex}
|
||||
getContainer={getContainer}
|
||||
maskClosable={maskClosable}
|
||||
onCancel={onClose}
|
||||
className={['fullscreen-preview-modal', className ?? ''].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className="fullscreen-preview-modal__shell">
|
||||
<div className="fullscreen-preview-modal__header">
|
||||
<div className="fullscreen-preview-modal__title-group">
|
||||
{title ? <div className="fullscreen-preview-modal__title">{title}</div> : null}
|
||||
{meta ? <div className="fullscreen-preview-modal__meta">{meta}</div> : null}
|
||||
</div>
|
||||
<div className="fullscreen-preview-modal__actions">
|
||||
{actions}
|
||||
<Button
|
||||
type="text"
|
||||
className="fullscreen-preview-modal__icon-button"
|
||||
aria-label="닫기"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="fullscreen-preview-modal__shell" style={shellStyle}>
|
||||
{hideHeader ? null : (
|
||||
<div className="fullscreen-preview-modal__header">
|
||||
<div className="fullscreen-preview-modal__title-group">
|
||||
{title ? <div className="fullscreen-preview-modal__title">{title}</div> : null}
|
||||
{meta ? <div className="fullscreen-preview-modal__meta">{meta}</div> : null}
|
||||
</div>
|
||||
<div className="fullscreen-preview-modal__actions">
|
||||
{actions}
|
||||
{onMinimize ? (
|
||||
<Button
|
||||
type="text"
|
||||
className="fullscreen-preview-modal__icon-button fullscreen-preview-modal__minimize-button"
|
||||
aria-label={minimizeLabel}
|
||||
onClick={onMinimize}
|
||||
>
|
||||
<MinusOutlined aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="text"
|
||||
className="fullscreen-preview-modal__icon-button fullscreen-preview-modal__close-button"
|
||||
aria-label="닫기"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={[
|
||||
'fullscreen-preview-modal__content',
|
||||
fillContent ? 'fullscreen-preview-modal__content--fill' : '',
|
||||
contentClassName ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className={['fullscreen-preview-modal__content', contentClassName ?? ''].filter(Boolean).join(' ')}>{children}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.previewer-ui--light-surface .previewer-ui__body {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.previewer-ui__scroll {
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -155,6 +159,14 @@
|
||||
height: calc(100vh - 53px) !important;
|
||||
}
|
||||
|
||||
.previewer-ui--expanded.previewer-ui--light-surface {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.previewer-ui--expanded.previewer-ui--light-surface .previewer-ui__header {
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.98) 0%, rgba(241, 245, 249, 0.98) 100%);
|
||||
}
|
||||
|
||||
.previewer-ui__language-select {
|
||||
min-width: 108px;
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ export function PreviewerUI({
|
||||
const canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0);
|
||||
const canFind = type !== 'image' && type !== 'empty';
|
||||
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || canFind || Boolean(toolbar);
|
||||
const usesLightExpandedSurface = type === 'text' || type === 'json' || type === 'markdown' || type === 'empty';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded || typeof document === 'undefined') {
|
||||
@@ -467,6 +468,7 @@ export function PreviewerUI({
|
||||
'previewer-ui',
|
||||
!showHeader ? 'previewer-ui--headerless' : '',
|
||||
isExpanded ? 'previewer-ui--expanded' : '',
|
||||
usesLightExpandedSurface ? 'previewer-ui--light-surface' : '',
|
||||
className ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -17,6 +17,7 @@ type ZoomablePreviewSurfaceProps = {
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
showControls?: boolean;
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
};
|
||||
|
||||
type TouchPointLike = {
|
||||
@@ -49,6 +50,7 @@ export function ZoomablePreviewSurface({
|
||||
minZoom = 1,
|
||||
maxZoom = 3,
|
||||
showControls = true,
|
||||
onZoomChange,
|
||||
}: ZoomablePreviewSurfaceProps) {
|
||||
const shellRef = useRef<HTMLDivElement | null>(null);
|
||||
const gestureRef = useRef<
|
||||
@@ -59,6 +61,10 @@ export function ZoomablePreviewSurface({
|
||||
const [zoom, setZoom] = useState(minZoom);
|
||||
const [offset, setOffset] = useState<PreviewOffset>({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
onZoomChange?.(zoom);
|
||||
}, [onZoomChange, zoom]);
|
||||
|
||||
const clampOffset = (nextOffset: PreviewOffset, nextZoom = zoom) => {
|
||||
const shell = shellRef.current;
|
||||
if (!shell) {
|
||||
@@ -82,6 +88,7 @@ export function ZoomablePreviewSurface({
|
||||
|
||||
const handleTouchStart = (event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (event.touches.length >= 2) {
|
||||
event.stopPropagation();
|
||||
gestureRef.current = {
|
||||
mode: 'pinch',
|
||||
distance: calculateTouchDistance(event.touches[0], event.touches[1]),
|
||||
@@ -96,6 +103,7 @@ export function ZoomablePreviewSurface({
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
gestureRef.current = {
|
||||
mode: 'pan',
|
||||
touchX: touch.clientX,
|
||||
@@ -122,6 +130,7 @@ export function ZoomablePreviewSurface({
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setZoom(clampZoom(gesture.zoom * (nextDistance / gesture.distance), minZoom, maxZoom));
|
||||
return;
|
||||
}
|
||||
@@ -132,6 +141,7 @@ export function ZoomablePreviewSurface({
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOffset(
|
||||
clampOffset({
|
||||
x: gesture.offsetX + touch.clientX - gesture.touchX,
|
||||
@@ -141,6 +151,10 @@ export function ZoomablePreviewSurface({
|
||||
};
|
||||
|
||||
const handleTouchEnd = (event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (gestureRef.current) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.touches.length >= 2) {
|
||||
gestureRef.current = {
|
||||
mode: 'pinch',
|
||||
|
||||
Reference in New Issue
Block a user