chore: test deploy snapshot

This commit is contained in:
2026-05-29 16:11:46 +09:00
parent b242d91ecb
commit 262ce4b627
16 changed files with 2766 additions and 165 deletions

View File

@@ -7,6 +7,7 @@ import { PhotoPuzzleAppView } from '../../views/play/apps/photo-puzzle/PhotoPuzz
import { PhotoPrismAppView } from '../../views/play/apps/photoprism/PhotoPrismAppView';
import { TheQuestAppView } from '../../views/play/apps/the-quest/TheQuestAppView';
import { TetrisAppView } from '../../views/play/apps/tetris/TetrisAppView';
import { Template1PlayAppView } from '../../views/play/apps/template1/Template1PlayAppView';
type PlayAppOverlayProps = {
appId: string;
@@ -41,6 +42,10 @@ function renderPlayApp(appId: string, onClose: () => void) {
return <TheQuestAppView onBack={onClose} launchContext="embedded" />;
}
if (appId === 'template1') {
return <Template1PlayAppView onBack={onClose} launchContext="embedded" />;
}
return null;
}

View File

@@ -1,4 +1,5 @@
import {
CopyOutlined,
CodeOutlined,
CheckCircleOutlined,
CloseOutlined,
@@ -21,7 +22,6 @@ import { StepperUI } from '../../../components/stepper';
import { InlineImage } from '../../../components/common/InlineImage';
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
import { FullscreenPreviewModal, ZoomablePreviewSurface } from '../../../components/previewer';
import { renderEditorBlock } from '../../../components/previewer/renderers';
import { ChatPreviewBody } from './ChatPreviewBody';
import { isMarkdownContentType, isMarkdownResourceUrl, normalizeChatResourceUrl } from './chatResourceUrl';
import { sharePreviewLink } from './chatUtils';
@@ -507,7 +507,7 @@ function canShowHtmlPreviewActions(preview: PromptPreview | null | undefined) {
return false;
}
if (preview.type === 'html') {
if (preview.type === 'editable' || preview.type === 'markdown' || preview.type === 'html') {
return true;
}
@@ -516,7 +516,10 @@ function canShowHtmlPreviewActions(preview: PromptPreview | null | undefined) {
}
if (preview.content?.trim()) {
return /<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(preview.content);
return (
/<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(preview.content) ||
isMarkdownPromptPreview(preview)
);
}
return Boolean(preview.url && isHtmlLikeUrl(preview.url));
@@ -651,7 +654,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
setLoadError('');
const shouldFetchTextPreview =
previewType === 'markdown' || previewType === 'html';
previewType === 'markdown' || previewType === 'html' || previewType === 'editable';
const shouldInspectResourcePreview =
previewType === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
@@ -732,15 +735,37 @@ export function PromptPreviewSurface({
preview,
compact = false,
htmlMode = 'preview',
sourceContent,
onSourceContentChange,
}: {
preview: PromptPreview;
compact?: boolean;
htmlMode?: 'preview' | 'source';
sourceContent?: string;
onSourceContentChange?: (content: string) => void;
}) {
const { remoteContent, remoteContentType, isLoading, loadError } = usePromptPreviewContent(preview);
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview.url);
const shouldRenderAsHtml = shouldRenderAsHtmlDocument(preview, remoteContentType, remoteContent);
const htmlDocument = shouldRenderAsHtml ? buildHtmlFrameDocument(remoteContent || '', normalizedPreviewUrl || preview.url) : null;
const defaultSourceContent = remoteContent ?? preview.content ?? '';
const sourceValue = sourceContent ?? defaultSourceContent;
const markdownValue = remoteContent ?? '표시할 markdown preview가 없습니다.';
const [editableSourceContent, setEditableSourceContent] = useState(defaultSourceContent);
useEffect(() => {
if (!onSourceContentChange || htmlMode !== 'source') {
return;
}
if (sourceContent === undefined || sourceContent.length === 0) {
onSourceContentChange(defaultSourceContent);
}
}, [defaultSourceContent, htmlMode, onSourceContentChange, sourceContent]);
useEffect(() => {
setEditableSourceContent(defaultSourceContent);
}, [defaultSourceContent]);
if (preview.type === 'image' && normalizedPreviewUrl) {
const imageNode = (
@@ -776,9 +801,41 @@ export function PromptPreviewSurface({
}
if (preview.type === 'markdown') {
if (htmlMode === 'source') {
return (
<div className="app-chat-prompt-card__preview-source">
<Input.TextArea
value={sourceValue}
onChange={(event) => onSourceContentChange?.(event.currentTarget.value)}
autoSize={false}
className="app-chat-prompt-card__preview-source-editor"
placeholder="표시할 markdown 소스가 없습니다."
/>
</div>
);
}
return (
<div className="app-chat-prompt-card__preview-markdown">
<MarkdownPreviewContent content={remoteContent || '표시할 markdown preview가 없습니다.'} maxBlocks={compact ? 5 : undefined} />
<MarkdownPreviewContent content={markdownValue} maxBlocks={compact ? 5 : undefined} />
</div>
);
}
if (preview.type === 'editable') {
return (
<div className="app-chat-prompt-card__preview-source">
<Input.TextArea
value={editableSourceContent}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setEditableSourceContent(nextValue);
onSourceContentChange?.(nextValue);
}}
autoSize={false}
className="app-chat-prompt-card__preview-source-editor"
placeholder="편집 가능한 소스 블록입니다."
/>
</div>
);
}
@@ -804,8 +861,14 @@ export function PromptPreviewSurface({
if (htmlMode === 'source') {
return (
<div className="app-chat-prompt-card__preview-code">
{renderEditorBlock(htmlContent || '표시할 HTML 코드가 없습니다.', 'html', 'code')}
<div className="app-chat-prompt-card__preview-source">
<Input.TextArea
value={sourceValue}
onChange={(event) => onSourceContentChange?.(event.currentTarget.value)}
autoSize={false}
className="app-chat-prompt-card__preview-source-editor"
placeholder="표시할 HTML 소스가 없습니다."
/>
</div>
);
}
@@ -1200,6 +1263,7 @@ export function ChatPromptCard({
const [submittedFreeText, setSubmittedFreeText] = useState('');
const [expandedOptionValue, setExpandedOptionValue] = useState<string | null>(null);
const [expandedHtmlMode, setExpandedHtmlMode] = useState<'preview' | 'source'>('preview');
const [expandedPreviewSource, setExpandedPreviewSource] = useState('');
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [attachments, setAttachments] = useState<ChatComposerAttachment[]>([]);
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false);
@@ -1242,6 +1306,7 @@ export function ChatPromptCard({
useEffect(() => {
setExpandedHtmlMode('preview');
setExpandedPreviewSource(expandedOption?.preview?.content ?? '');
}, [expandedOptionValue]);
const activeStep = steps[Math.min(activeStepIndex, Math.max(steps.length - 1, 0))] ?? steps[0];
const activeSelection = activeStep ? stepSelections[activeStep.key] : undefined;
@@ -1274,6 +1339,7 @@ export function ChatPromptCard({
const progressPayload = buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments);
const expandAllOptionPreviews = shouldExpandAllPromptPreviews(activeStep, activeSelection, isLocked);
const expandedOptionPreviewUrl = expandedOption?.preview ? resolvePromptPreviewUrl(expandedOption.preview.url) : '';
const isExpandedEditablePreview = expandedOption?.preview?.type === 'editable';
const onSelectionChangeRef = useRef(onSelectionChange);
useEffect(() => {
@@ -1364,6 +1430,19 @@ export function ChatPromptCard({
});
};
const handleCopyExpandedEditablePreview = async () => {
if (!expandedOption?.preview || !isExpandedEditablePreview) {
return;
}
try {
await navigator.clipboard.writeText(expandedPreviewSource);
message.success('편집 내용을 복사했습니다.');
} catch {
message.error('편집 내용을 복사할 수 없습니다.');
}
};
const uploadAttachments = async (files: File[]) => {
if (!allowAttachments || !onUploadAttachment || isLocked || isSubmitting || isUploadingAttachment || files.length === 0) {
return;
@@ -1914,7 +1993,7 @@ export function ChatPromptCard({
<FullscreenPreviewModal
open={Boolean(expandedOption?.preview)}
onClose={() => setExpandedOptionValue(null)}
title={expandedOption?.preview?.title?.trim() || expandedOption?.label || 'preview'}
title={expandedOption?.preview?.title?.trim() || expandedOption?.label || 'preview'}
actions={
canShowHtmlPreviewActions(expandedOption?.preview) ? (
<>
@@ -1925,20 +2004,32 @@ export function ChatPromptCard({
icon={<ShareAltOutlined />}
onClick={handleShareExpandedPreview}
/>
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="HTML 실행 미리보기"
icon={<EyeOutlined />}
onClick={() => setExpandedHtmlMode('preview')}
/>
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="HTML 코드 보기"
icon={<CodeOutlined />}
onClick={() => setExpandedHtmlMode('source')}
/>
{isExpandedEditablePreview ? (
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="편집 내용 복사"
icon={<CopyOutlined />}
onClick={handleCopyExpandedEditablePreview}
/>
) : (
<>
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="미리보기 화면"
icon={<EyeOutlined />}
onClick={() => setExpandedHtmlMode('preview')}
/>
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="소스 보기"
icon={<CodeOutlined />}
onClick={() => setExpandedHtmlMode('source')}
/>
</>
)}
</>
) : (
<Button
@@ -1955,7 +2046,14 @@ export function ChatPromptCard({
}`}
>
<div className="app-chat-prompt-card__preview-modal-surface">
{expandedOption?.preview ? <PromptPreviewSurface preview={expandedOption.preview} htmlMode={expandedHtmlMode} /> : null}
{expandedOption?.preview ? (
<PromptPreviewSurface
preview={expandedOption.preview}
htmlMode={expandedHtmlMode}
sourceContent={expandedPreviewSource}
onSourceContentChange={setExpandedPreviewSource}
/>
) : null}
</div>
</FullscreenPreviewModal>
</>

View File

@@ -256,6 +256,47 @@ type PromptPreview = NonNullable<
type PromptOption = Extract<ChatMessagePart, { type: 'prompt' }>['options'][number];
type PromptStep = NonNullable<Extract<ChatMessagePart, { type: 'prompt' }>['steps']>[number];
function normalizePromptPreviewType(typeValue: string | null | undefined, url: string, content: string) {
const normalizedType = normalizeOptionalText(typeValue).toLowerCase();
if (normalizedType === 'image' || normalizedType === 'markdown' || normalizedType === 'html' || normalizedType === 'resource') {
return normalizedType;
}
if (normalizedType === 'md' || normalizedType === 'text' || normalizedType === 'txt' || normalizedType === 'plain') {
return 'markdown';
}
if (normalizedType === 'htm') {
return 'html';
}
const normalizedContent = normalizeOptionalText(content).trim();
const normalizedUrl = normalizeOptionalText(url).toLowerCase();
if (normalizedUrl.endsWith('.md') || normalizedUrl.endsWith('.markdown')) {
return 'markdown';
}
if (normalizedUrl.endsWith('.html') || normalizedUrl.endsWith('.htm')) {
return 'html';
}
if (!normalizedContent) {
return null;
}
if (/<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(normalizedContent)) {
return 'html';
}
if (/^#{1,6}\s|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s+|\[[^\]]+\]\([^)]+\)/m.test(normalizedContent)) {
return 'markdown';
}
return 'resource';
}
function normalizePromptPreview(
preview: {
type?: string | null;
@@ -269,9 +310,9 @@ function normalizePromptPreview(
return null;
}
const type = preview.type === 'image' || preview.type === 'markdown' || preview.type === 'html' || preview.type === 'resource'
? preview.type
: null;
const normalizedUrl = normalizeOptionalText(preview.url);
const normalizedContent = normalizeOptionalText(preview.content);
const type = normalizePromptPreviewType(preview.type, normalizedUrl, normalizedContent);
if (!type) {
return null;
@@ -279,8 +320,8 @@ function normalizePromptPreview(
return {
type,
url: normalizeOptionalText(preview.url),
content: normalizeOptionalText(preview.content),
url: normalizedUrl,
content: normalizedContent,
alt: normalizeOptionalText(preview.alt),
title: normalizeOptionalText(preview.title),
};

View File

@@ -43,6 +43,68 @@ function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function normalizePromptPreviewType(typeValue: unknown, url: string, content: string, editableMode = false) {
const normalizedType = normalizeText(typeValue).toLowerCase();
if (
normalizedType === 'image' ||
normalizedType === 'md' ||
normalizedType === 'markdown' ||
normalizedType === 'html' ||
normalizedType === 'resource' ||
normalizedType === 'editable' ||
normalizedType === 'editor' ||
normalizedType === 'code'
) {
if (normalizedType === 'editable' || normalizedType === 'editor' || normalizedType === 'code') {
return 'editable';
}
return normalizedType;
}
if (normalizedType === 'text' || normalizedType === 'plain' || normalizedType === 'txt') {
return editableMode ? 'editable' : 'markdown';
}
if (normalizedType === 'htm') {
return 'html';
}
if (editableMode && (url || content)) {
return 'editable';
}
if (editableMode) {
return null;
}
const normalizedContent = String(content ?? '').trim();
const normalizedUrl = normalizeUrl(url).toLowerCase();
if (normalizedUrl.endsWith('.md') || normalizedUrl.endsWith('.markdown')) {
return 'markdown';
}
if (normalizedUrl.endsWith('.html') || normalizedUrl.endsWith('.htm')) {
return 'html';
}
if (!normalizedContent) {
return null;
}
if (/<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(normalizedContent)) {
return 'html';
}
if (/^#{1,6}\s|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s+|\[[^\]]+\]\([^)]+\)/m.test(normalizedContent)) {
return 'markdown';
}
return 'resource';
}
function unwrapMarkdownLinkTarget(value: string) {
const normalized = normalizeText(value);
@@ -308,24 +370,26 @@ function normalizePromptPreview(value: unknown): PromptPreview | null {
}
const record = value as Record<string, unknown>;
const type =
record.type === 'image' || record.type === 'markdown' || record.type === 'html' || record.type === 'resource'
? record.type
: null;
const url = normalizeUrl(normalizeText(record.url));
const content = String(record.content ?? '').trim() || null;
const alt = normalizeText(record.alt) || null;
const title = normalizeText(record.title) || null;
const editable = record.editable === true;
const type = normalizePromptPreviewType(record.type, url, content || '', editable);
if (!type) {
return null;
}
if (type === 'image' || type === 'resource') {
if (type === 'image' || (type === 'resource' && !editable)) {
if (!url) {
return null;
}
} else if (!content && !url) {
} else if (type !== 'editable' && !content && !url) {
return null;
}
if (type === 'editable' && !content && !url) {
return null;
}
@@ -335,6 +399,7 @@ function normalizePromptPreview(value: unknown): PromptPreview | null {
content,
alt,
title,
editable: editable || type === 'editable' ? true : null,
};
}

View File

@@ -2605,6 +2605,37 @@
width: 100%;
}
.app-chat-prompt-card__preview-source {
display: flex;
flex: 1 1 auto;
min-height: 0;
width: 100%;
}
.app-chat-prompt-card__preview-source-editor {
width: 100%;
min-height: 100%;
height: 100%;
min-height: 140px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 0;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.45;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
box-shadow: none;
resize: none;
}
.app-chat-prompt-card__preview-source-editor:hover,
.app-chat-prompt-card__preview-source-editor:focus,
.app-chat-prompt-card__preview-source-editor:focus-visible {
border-color: rgba(13, 148, 136, 0.58);
box-shadow: 0 0 0 2px rgba(13, 148, 136, 0.16);
}
.app-chat-prompt-card__preview-code .previewer-ui__editor,
.app-chat-prompt-card__preview-code .previewer-ui__editor-body {
height: 100%;

View File

@@ -10,11 +10,12 @@ export type ChatComposerAttachment = {
};
export type ChatStructuredPreview = {
type: 'image' | 'markdown' | 'html' | 'resource';
type: 'image' | 'markdown' | 'html' | 'resource' | 'editable';
url?: string | null;
content?: string | null;
alt?: string | null;
title?: string | null;
editable?: boolean | null;
};
export type ChatPromptContextRef = {

View File

@@ -1,4 +1,5 @@
import { AppstoreOutlined, CheckOutlined, CloseOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons';
import * as AntdIcons from '@ant-design/icons';
import { AppstoreOutlined, CheckOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons';
import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { Suspense, lazy, startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
@@ -19,6 +20,7 @@ import { PhotoPuzzleAppView } from '../../../views/play/apps/photo-puzzle/PhotoP
import { PhotoPrismAppView } from '../../../views/play/apps/photoprism/PhotoPrismAppView';
import { TetrisAppView } from '../../../views/play/apps/tetris/TetrisAppView';
import { TheQuestAppView } from '../../../views/play/apps/the-quest/TheQuestAppView';
import { Template1PlayAppView } from '../../../views/play/apps/template1/Template1PlayAppView';
import { SharedResourceManagementPage } from '../SharedResourceManagementPage';
import { SharedAppSettingsPage } from '../SharedAppSettingsPage';
import { TokenSettingManagementPage } from '../TokenSettingManagementPage';
@@ -96,6 +98,8 @@ import './ChatSharePage.css';
const { Paragraph, Text, Title } = Typography;
const ShareCloseIcon = AntdIcons.CloseOutlined || AntdIcons.CloseCircleOutlined || (() => <span aria-hidden="true">×</span>);
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources/';
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
const CHAT_PUBLIC_DOT_CODEX_PREFIX = '/public/.codex_chat/';
@@ -114,11 +118,11 @@ const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6;
const SHARE_ROOM_SWITCH_CACHE_REQUEST_LIMIT = 12;
const SHARE_ROOM_SWITCH_CACHE_MESSAGE_LIMIT = 32;
const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000;
const SHARE_EDGE_NAVIGATION_HOTZONE_PX = 28;
const SHARE_APPS_EDGE_MIDDLE_BAND_RATIO = 0.2;
const SHARE_EDGE_NAVIGATION_HOTZONE_PX = 38;
const SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX = 16;
const SHARE_EDGE_GESTURE_OPEN_APPS_PX = 96;
const SHARE_EDGE_GESTURE_MAX_VERTICAL_DRIFT_PX = 64;
const SHARE_EDGE_GESTURE_OPEN_APPS_PX = 100;
const SHARE_EDGE_APPS_GESTURE_HOTZONE_PX = 110;
const SHARE_MINIMIZED_ACTION_TAP_TOLERANCE_PX = 8;
const SHARE_HISTORY_PAGE_SIZE = 40;
const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [
{ value: 'always', label: '매번 묻기', minutes: 0 },
@@ -191,6 +195,16 @@ type ShareMinimizedProgramItem = {
y: number;
};
};
type ShareMinimizedProgramAction = 'restore' | 'close';
type ShareMinimizedProgramActionTracking = {
key: string;
pointerId: number;
startX: number;
startY: number;
action: ShareMinimizedProgramAction;
captureTarget: HTMLElement;
cancelled: boolean;
};
type ShareAppEnvironment = PlayAppEnvironment;
type ShareSearchResult = {
key: string;
@@ -214,11 +228,13 @@ type ShareEdgeGestureTracking =
| {
startX: number;
startY: number;
startTime: number;
direction: 'back';
}
| {
startX: number;
startY: number;
startTime: number;
direction: 'apps';
opened: boolean;
};
@@ -1512,6 +1528,8 @@ function resolveSharePlayAppInstallThemeColor(appId: string) {
return '#d97706';
case 'the-quest':
return '#7c3aed';
case 'template1':
return '#2f8dff';
case 'tetris':
return '#0f172a';
default:
@@ -1618,6 +1636,10 @@ function renderEmbeddedSharePlayApp(appId: string | undefined, onBack: () => voi
return <TheQuestAppView onBack={onBack} launchContext="embedded" />;
}
if (appId === 'template1') {
return <Template1PlayAppView onBack={onBack} launchContext="embedded" />;
}
return null;
}
@@ -3968,7 +3990,7 @@ function ShareRequestCard({
danger
className="chat-share-page__message-action-button"
loading={isRequestCancellationSaving}
icon={<CloseOutlined />}
icon={<ShareCloseIcon />}
aria-label="취소 처리"
title="취소 처리"
onClick={() => {
@@ -3997,7 +4019,7 @@ function ShareRequestCard({
danger
className="chat-share-page__message-action-button"
loading={isActiveRequestCancellationSaving}
icon={<CloseOutlined />}
icon={<ShareCloseIcon />}
aria-label="취소"
title="취소"
onClick={() => {
@@ -4224,6 +4246,7 @@ export function ChatSharePage() {
const requestedRoomSessionIdRef = useRef(requestedRoomSessionId);
const skipNextRequestedRoomRefreshRef = useRef(false);
const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null);
const [hasLoadedShareData, setHasLoadedShareData] = useState(initialHasCachedSnapshot);
const [, setIsRefreshing] = useState(false);
const [isLoadingFullSnapshot, setIsLoadingFullSnapshot] = useState(false);
const [isLoadingOlderShareHistory, setIsLoadingOlderShareHistory] = useState(false);
@@ -4344,6 +4367,8 @@ export function ChatSharePage() {
lastY: number;
captureTarget: HTMLDivElement;
} | null>(null);
const programMinimizedActionTrackingRef = useRef<ShareMinimizedProgramActionTracking | null>(null);
const programMinimizedActionClickIgnoreUntilRef = useRef(0);
const programMinimizedMovedRef = useRef(false);
const minimizedProgramsRef = useRef<ShareMinimizedProgramItem[]>([]);
const minimizedProgramPositionByKeyRef = useRef<Record<string, ShareMinimizedProgramItem['position']>>({});
@@ -5156,6 +5181,7 @@ export function ChatSharePage() {
snapshotRefreshInFlightRoomSessionIdRef.current = '';
snapshotRefreshInFlightViewRef.current = null;
}
setHasLoadedShareData(true);
setIsLoading(false);
setIsRefreshing(false);
@@ -5739,6 +5765,19 @@ export function ChatSharePage() {
const handlePointerMove = (event: PointerEvent) => {
const dragState = programMinimizedDragStateRef.current;
const actionTracking = programMinimizedActionTrackingRef.current;
if (actionTracking && actionTracking.pointerId === event.pointerId) {
const deltaX = Math.abs(event.clientX - actionTracking.startX);
const deltaY = Math.abs(event.clientY - actionTracking.startY);
if (Math.max(deltaX, deltaY) > SHARE_MINIMIZED_ACTION_TAP_TOLERANCE_PX) {
actionTracking.cancelled = true;
if (actionTracking.captureTarget.hasPointerCapture(actionTracking.pointerId)) {
actionTracking.captureTarget.releasePointerCapture(actionTracking.pointerId);
}
}
}
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
@@ -5768,8 +5807,12 @@ export function ChatSharePage() {
const finishPointerDrag = (event: PointerEvent) => {
const dragState = programMinimizedDragStateRef.current;
const actionTracking = programMinimizedActionTrackingRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
if (actionTracking?.pointerId === event.pointerId) {
clearProgramMinimizedActionTracking();
}
return;
}
@@ -5778,6 +5821,9 @@ export function ChatSharePage() {
}
programMinimizedDragStateRef.current = null;
if (actionTracking?.pointerId === event.pointerId) {
clearProgramMinimizedActionTracking();
}
};
window.addEventListener('resize', handleResize);
@@ -5791,6 +5837,7 @@ export function ChatSharePage() {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', finishPointerDrag);
window.removeEventListener('pointercancel', finishPointerDrag);
clearProgramMinimizedActionTracking();
};
}, [minimizedPrograms.length]);
@@ -6002,11 +6049,42 @@ export function ChatSharePage() {
programMinimizedMovedRef.current = false;
}, []);
const handleProgramMinimizedActionPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const clearProgramMinimizedActionTracking = useCallback(() => {
const actionTracking = programMinimizedActionTrackingRef.current;
if (actionTracking?.captureTarget.hasPointerCapture(actionTracking.pointerId)) {
actionTracking.captureTarget.releasePointerCapture(actionTracking.pointerId);
}
programMinimizedActionTrackingRef.current = null;
}, []);
const handleProgramMinimizedActionPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>, action: ShareMinimizedProgramAction, targetKey: string) => {
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
const normalizedTargetKey = targetKey.trim();
if (!normalizedTargetKey) {
return;
}
clearProgramMinimizedDragState();
clearProgramMinimizedActionTracking();
programMinimizedActionClickIgnoreUntilRef.current = 0;
event.preventDefault();
event.stopPropagation();
clearProgramMinimizedDragState();
}, [clearProgramMinimizedDragState]);
event.currentTarget.setPointerCapture(event.pointerId);
programMinimizedActionTrackingRef.current = {
key: normalizedTargetKey,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
action,
captureTarget: event.currentTarget,
cancelled: false,
};
}, [clearProgramMinimizedDragState, clearProgramMinimizedActionTracking]);
const runProgramMinimizedActionAfterPointerCycle = useCallback((action: () => void) => {
if (typeof window === 'undefined') {
@@ -6067,6 +6145,66 @@ export function ChatSharePage() {
setMinimizedPrograms((current) => current.filter((item) => item.target.key !== targetKey));
}, []);
const runProgramMinimizedAction = useCallback((targetKey: string, action: ShareMinimizedProgramAction) => {
if (action === 'restore') {
runProgramMinimizedActionAfterPointerCycle(() => {
handleRestoreProgram(targetKey);
});
return;
}
runProgramMinimizedActionAfterPointerCycle(() => {
handleCloseMinimizedProgram(targetKey);
});
}, [handleRestoreProgram, handleCloseMinimizedProgram, runProgramMinimizedActionAfterPointerCycle]);
const handleProgramMinimizedActionPointerUp = useCallback((event: ReactPointerEvent<HTMLElement>, action: ShareMinimizedProgramAction, targetKey: string) => {
const normalizedTargetKey = targetKey.trim();
const tracking = programMinimizedActionTrackingRef.current;
if (
!tracking
|| tracking.pointerId !== event.pointerId
|| tracking.key !== normalizedTargetKey
|| tracking.action !== action
|| tracking.cancelled
) {
return;
}
event.preventDefault();
event.stopPropagation();
const now = typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now();
programMinimizedActionClickIgnoreUntilRef.current = now + 500;
clearProgramMinimizedActionTracking();
runProgramMinimizedAction(normalizedTargetKey, action);
}, [runProgramMinimizedAction]);
const handleProgramMinimizedActionClick = useCallback((event: ReactMouseEvent<HTMLElement>, action: ShareMinimizedProgramAction, targetKey: string) => {
if (programMinimizedActionTrackingRef.current) {
return;
}
const now = typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now();
if (programMinimizedActionClickIgnoreUntilRef.current > now) {
return;
}
event.preventDefault();
event.stopPropagation();
runProgramMinimizedAction(targetKey.trim(), action);
}, [runProgramMinimizedAction]);
const handleProgramMinimizedActionKeyDown = useCallback((event: KeyboardEvent<HTMLElement>, action: ShareMinimizedProgramAction, targetKey: string) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
event.stopPropagation();
runProgramMinimizedAction(targetKey.trim(), action);
}, [runProgramMinimizedAction]);
const minimizedProgramCards = minimizedPrograms.map((item, index) => (
<div
key={item.target.key}
@@ -6098,13 +6236,14 @@ export function ChatSharePage() {
size="small"
icon={<AppstoreOutlined />}
className="chat-share-page__program-minimized-button"
onPointerDown={handleProgramMinimizedActionPointerDown}
onPointerDown={(event: ReactPointerEvent<HTMLElement>) => {
handleProgramMinimizedActionPointerDown(event, 'restore', item.target.key);
}}
onPointerUp={(event: ReactPointerEvent<HTMLElement>) => {
handleProgramMinimizedActionPointerUp(event, 'restore', item.target.key);
}}
onClick={(event: ReactMouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
runProgramMinimizedActionAfterPointerCycle(() => {
handleRestoreProgram(item.target.key);
});
handleProgramMinimizedActionClick(event, 'restore', item.target.key);
}}
>
@@ -6113,15 +6252,16 @@ export function ChatSharePage() {
type="text"
size="small"
className="chat-share-page__program-minimized-icon chat-share-page__program-minimized-close"
icon={<CloseOutlined />}
icon={<ShareCloseIcon />}
aria-label="프로그램 닫기"
onPointerDown={handleProgramMinimizedActionPointerDown}
onPointerDown={(event: ReactPointerEvent<HTMLElement>) => {
handleProgramMinimizedActionPointerDown(event, 'close', item.target.key);
}}
onPointerUp={(event: ReactPointerEvent<HTMLElement>) => {
handleProgramMinimizedActionPointerUp(event, 'close', item.target.key);
}}
onClick={(event: ReactMouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
runProgramMinimizedActionAfterPointerCycle(() => {
handleCloseMinimizedProgram(item.target.key);
});
handleProgramMinimizedActionClick(event, 'close', item.target.key);
}}
/>
</div>
@@ -6342,6 +6482,7 @@ export function ChatSharePage() {
if (!normalizedToken) {
setErrorMessage('공유 링크가 없습니다.');
setIsLoading(false);
setHasLoadedShareData(false);
return undefined;
}
@@ -6364,6 +6505,7 @@ export function ChatSharePage() {
deferredSnapshotRef.current = null;
setSnapshot(cachedSnapshot);
setIsLoading(cachedSnapshot == null);
setHasLoadedShareData(cachedSnapshot != null);
setIsRoomSwitching(false);
setRequestedRoomSessionId(restoredRoomSessionId);
}, [normalizedToken]);
@@ -8209,7 +8351,7 @@ export function ChatSharePage() {
type="text"
size="small"
className="chat-share-page__process-inspector-window-button"
icon={<CloseOutlined />}
icon={<ShareCloseIcon />}
aria-label="상세 과정 닫기"
onClick={closeProcessInspector}
/>
@@ -9607,6 +9749,7 @@ export function ChatSharePage() {
|| Boolean(activeProcessInspectorRequestId);
let tracking: ShareEdgeGestureTracking | null = null;
let lastTouch: Touch | null = null;
const resetTracking = () => {
tracking = null;
@@ -9620,27 +9763,28 @@ export function ChatSharePage() {
return;
}
const centerBandStart = window.innerHeight * SHARE_APPS_EDGE_MIDDLE_BAND_RATIO;
const centerBandEnd = window.innerHeight * (1 - SHARE_APPS_EDGE_MIDDLE_BAND_RATIO);
if (
touch.clientX >= window.innerWidth - SHARE_EDGE_NAVIGATION_HOTZONE_PX
&& touch.clientY >= centerBandStart
&& touch.clientY <= centerBandEnd
touch.clientX >= window.innerWidth - SHARE_EDGE_APPS_GESTURE_HOTZONE_PX
) {
const now = performance.now();
event.preventDefault();
tracking = {
startX: touch.clientX,
startY: touch.clientY,
startTime: now,
direction: 'apps',
opened: false,
};
lastTouch = touch;
return;
}
if (touch.clientX <= SHARE_EDGE_NAVIGATION_HOTZONE_PX) {
const now = performance.now();
tracking = {
startX: touch.clientX,
startY: touch.clientY,
startTime: now,
direction: 'back',
};
return;
@@ -9656,41 +9800,71 @@ export function ChatSharePage() {
return;
}
lastTouch = touch;
const deltaX = touch.clientX - tracking.startX;
const deltaY = touch.clientY - tracking.startY;
if (Math.abs(deltaY) > SHARE_EDGE_GESTURE_MAX_VERTICAL_DRIFT_PX) {
tracking = null;
return;
}
if (tracking.direction === 'apps') {
if (deltaX <= SHARE_EDGE_GESTURE_OPEN_APPS_PX * -1) {
if (deltaX <= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX * -1) {
event.preventDefault();
}
if (tracking.direction === 'back') {
if (deltaX >= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX) {
event.preventDefault();
if (!tracking.opened) {
tracking.opened = true;
openShareAppsPanel();
}
}
return;
}
if (deltaX <= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX * -1) {
event.preventDefault();
return;
}
if (!tracking.opened && deltaX <= SHARE_EDGE_GESTURE_OPEN_APPS_PX * -1) {
tracking.opened = true;
openShareAppsPanel();
if (tracking.direction === 'back' && deltaX >= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX) {
event.preventDefault();
}
};
scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: true, capture: true });
const handleTouchEnd = (event: TouchEvent) => {
const touch = event.changedTouches[0] ?? lastTouch;
if (!tracking || !touch) {
resetTracking();
lastTouch = null;
return;
}
if (tracking.direction === 'apps' && !tracking.opened) {
const deltaX = touch.clientX - tracking.startX;
if (deltaX <= SHARE_EDGE_GESTURE_OPEN_APPS_PX * -1) {
tracking.opened = true;
openShareAppsPanel();
}
}
resetTracking();
lastTouch = null;
};
const handleTouchCancel = () => {
resetTracking();
lastTouch = null;
};
scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: false, capture: true });
scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true });
scrollContainer.addEventListener('touchend', resetTracking, { passive: true, capture: true });
scrollContainer.addEventListener('touchcancel', resetTracking, { passive: true, capture: true });
scrollContainer.addEventListener('touchend', handleTouchEnd, { passive: true, capture: true });
scrollContainer.addEventListener('touchcancel', handleTouchCancel, { passive: true, capture: true });
return () => {
scrollContainer.removeEventListener('touchstart', handleTouchStart, true);
scrollContainer.removeEventListener('touchmove', handleTouchMove, true);
scrollContainer.removeEventListener('touchend', resetTracking, true);
scrollContainer.removeEventListener('touchcancel', resetTracking, true);
scrollContainer.removeEventListener('touchend', handleTouchEnd, true);
scrollContainer.removeEventListener('touchcancel', handleTouchCancel, true);
};
}, [
activeProcessInspectorRequestId,
@@ -9951,7 +10125,7 @@ export function ChatSharePage() {
);
}
if (errorMessage || !snapshot) {
if (errorMessage || (!snapshot && hasLoadedShareData)) {
return (
<div className="chat-share-page chat-share-page--centered">
<div className="chat-share-page__panel chat-share-page__empty-card">
@@ -9962,6 +10136,14 @@ export function ChatSharePage() {
);
}
if (!snapshot) {
return (
<div className="chat-share-page chat-share-page--centered">
<Spin size="large" />
</div>
);
}
return (
<>
<div
@@ -10555,7 +10737,7 @@ export function ChatSharePage() {
type="text"
size="small"
className="app-chat-panel__composer-attachment-remove"
icon={<CloseOutlined />}
icon={<ShareCloseIcon />}
aria-label={`${attachment.name} 첨부 제거`}
onClick={() => {
setComposerAttachments((current) => current.filter((item) => item.id !== attachment.id));