feat: update main chat and system chat UI

This commit is contained in:
2026-05-25 17:26:37 +09:00
parent fb5ec649cd
commit f59522ffc4
120 changed files with 43262 additions and 3325 deletions

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

View File

@@ -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="코드 위치를 찾는 동안에도 현재 어떤 기능 범위를 분석 중인지 함께 보여줍니다."
/>
);
}

View File

@@ -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="체크리스트 헤더에서도 현재 작업 기능과 작업 유형이 함께 보이도록 바꾼 예시입니다."
/>
);
}

View File

@@ -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="코드 반영 단계에서는 어떤 기능을 개발 중인지가 바로 보이도록 문구를 바꿨습니다."
/>
);
}

View 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="동작 테스트 단계는 개발과 구분되도록 테스트 문구로 분리했습니다."
/>
);
}

View File

@@ -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="스크린샷 캡처나 최종 확인 단계는 검증 문구로 명확하게 구분합니다."
/>
);
}

View File

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

View File

@@ -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) {

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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',