feat: update main chat and system chat UI
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
import { CloseOutlined, DesktopOutlined, MinusOutlined, MobileOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
CloseOutlined,
|
||||
CodeOutlined,
|
||||
CopyOutlined,
|
||||
DesktopOutlined,
|
||||
MinusOutlined,
|
||||
MobileOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
@@ -9,7 +18,13 @@ import {
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PreviewAppWindow } from './PreviewAppWindow';
|
||||
import type { PreviewTargetDescriptor } from './previewRuntime';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import {
|
||||
resolvePreviewAppOrigin,
|
||||
type PreviewRuntimeConsoleBridgeMessage,
|
||||
type PreviewRuntimeConsoleLevel,
|
||||
type PreviewTargetDescriptor,
|
||||
} from './previewRuntime';
|
||||
|
||||
type PreviewAppOverlayProps = {
|
||||
pathname: string;
|
||||
@@ -23,16 +38,48 @@ type DragPosition = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
type DetachedConsoleSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const HEADER_HEIGHT = 44;
|
||||
const MINIMIZED_WIDTH = 168;
|
||||
const MOBILE_SHELL_WIDTH = 430;
|
||||
const MOBILE_SHELL_HEIGHT = 860;
|
||||
const VIEWPORT_PADDING = 12;
|
||||
const MAX_CONSOLE_ENTRIES = 300;
|
||||
const DETACHED_CONSOLE_WIDTH = 460;
|
||||
const DETACHED_CONSOLE_HEIGHT = 340;
|
||||
const DETACHED_CONSOLE_MIN_WIDTH = 360;
|
||||
const DETACHED_CONSOLE_MAX_WIDTH = 840;
|
||||
const DETACHED_CONSOLE_MIN_HEIGHT = 240;
|
||||
const DETACHED_CONSOLE_MAX_HEIGHT = 620;
|
||||
const DETACHED_CONSOLE_DRAGGING_CLASS = 'preview-app-overlay-console-dragging';
|
||||
const CONSOLE_LEVELS: PreviewRuntimeConsoleLevel[] = ['log', 'info', 'warn', 'error', 'debug'];
|
||||
|
||||
type PreviewConsoleEntry = PreviewRuntimeConsoleBridgeMessage & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function formatConsoleEntryCopyText(entry: PreviewConsoleEntry) {
|
||||
return [
|
||||
`${entry.level.toUpperCase()} ${new Date(entry.timestamp).toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}`,
|
||||
entry.args.join('\n'),
|
||||
]
|
||||
.filter((value) => value.trim().length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function getDefaultMinimizedPosition(): DragPosition {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
@@ -77,35 +124,154 @@ function getDefaultMobileShellPosition(): DragPosition {
|
||||
};
|
||||
}
|
||||
|
||||
function getDetachedConsoleMetrics() {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
width: DETACHED_CONSOLE_WIDTH,
|
||||
height: DETACHED_CONSOLE_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: Math.min(DETACHED_CONSOLE_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)),
|
||||
height: Math.min(DETACHED_CONSOLE_HEIGHT, Math.max(220, window.innerHeight - VIEWPORT_PADDING * 2)),
|
||||
};
|
||||
}
|
||||
|
||||
function getDetachedConsoleSizeBounds() {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
minWidth: DETACHED_CONSOLE_MIN_WIDTH,
|
||||
maxWidth: DETACHED_CONSOLE_MAX_WIDTH,
|
||||
minHeight: DETACHED_CONSOLE_MIN_HEIGHT,
|
||||
maxHeight: DETACHED_CONSOLE_MAX_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
minWidth: Math.min(DETACHED_CONSOLE_MIN_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)),
|
||||
maxWidth: Math.max(
|
||||
Math.min(DETACHED_CONSOLE_MAX_WIDTH, window.innerWidth - VIEWPORT_PADDING * 2),
|
||||
Math.min(DETACHED_CONSOLE_MIN_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)),
|
||||
),
|
||||
minHeight: Math.min(
|
||||
DETACHED_CONSOLE_MIN_HEIGHT,
|
||||
Math.max(220, window.innerHeight - VIEWPORT_PADDING * 2),
|
||||
),
|
||||
maxHeight: Math.max(
|
||||
Math.min(DETACHED_CONSOLE_MAX_HEIGHT, window.innerHeight - VIEWPORT_PADDING * 2),
|
||||
Math.min(DETACHED_CONSOLE_MIN_HEIGHT, Math.max(220, window.innerHeight - VIEWPORT_PADDING * 2)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDetachedConsoleSize(size: DetachedConsoleSize): DetachedConsoleSize {
|
||||
const { minWidth, maxWidth, minHeight, maxHeight } = getDetachedConsoleSizeBounds();
|
||||
|
||||
return {
|
||||
width: clamp(size.width, minWidth, maxWidth),
|
||||
height: clamp(size.height, minHeight, maxHeight),
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultDetachedConsoleSize(): DetachedConsoleSize {
|
||||
return normalizeDetachedConsoleSize({
|
||||
width: DETACHED_CONSOLE_WIDTH,
|
||||
height: DETACHED_CONSOLE_HEIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultDetachedConsolePosition(): DragPosition {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
x: VIEWPORT_PADDING,
|
||||
y: VIEWPORT_PADDING,
|
||||
};
|
||||
}
|
||||
|
||||
const { width, height } = getDetachedConsoleMetrics();
|
||||
|
||||
return {
|
||||
x: Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING),
|
||||
y: Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING),
|
||||
};
|
||||
}
|
||||
|
||||
export function PreviewAppOverlay({
|
||||
pathname,
|
||||
search = '',
|
||||
targetDescriptor = null,
|
||||
onClose,
|
||||
}: PreviewAppOverlayProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const minimizedPositionRef = useRef<DragPosition>(getDefaultMinimizedPosition());
|
||||
const mobileShellPositionRef = useRef<DragPosition>(getDefaultMobileShellPosition());
|
||||
const detachedConsolePositionRef = useRef<DragPosition>(getDefaultDetachedConsolePosition());
|
||||
const detachedConsoleSizeRef = useRef<DetachedConsoleSize>(getDefaultDetachedConsoleSize());
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const consoleBodyRef = useRef<HTMLDivElement>(null);
|
||||
const dragStateRef = useRef<{
|
||||
pointerId: number;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
captureTarget: HTMLDivElement;
|
||||
} | null>(null);
|
||||
const detachedConsoleDragStateRef = useRef<{
|
||||
pointerId: number;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
captureTarget: HTMLDivElement;
|
||||
} | null>(null);
|
||||
const detachedConsoleResizeStateRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
captureTarget: HTMLDivElement;
|
||||
} | null>(null);
|
||||
const dragMovedRef = useRef(false);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [deviceMode, setDeviceMode] = useState<'desktop' | 'mobile'>('mobile');
|
||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||
const [consoleDetached, setConsoleDetached] = useState(false);
|
||||
const [consoleEntries, setConsoleEntries] = useState<PreviewConsoleEntry[]>([]);
|
||||
const [consoleLevelFilter, setConsoleLevelFilter] = useState<PreviewRuntimeConsoleLevel[]>(CONSOLE_LEVELS);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.matchMedia('(max-width: 768px)').matches : false,
|
||||
);
|
||||
const [position, setPosition] = useState<DragPosition>(() => minimizedPositionRef.current);
|
||||
const [detachedConsolePosition, setDetachedConsolePosition] = useState<DragPosition>(
|
||||
() => detachedConsolePositionRef.current,
|
||||
);
|
||||
const [detachedConsoleSize, setDetachedConsoleSize] = useState<DetachedConsoleSize>(
|
||||
() => detachedConsoleSizeRef.current,
|
||||
);
|
||||
const isDesktopMobileShell = !minimized && deviceMode === 'mobile' && !isMobileViewport;
|
||||
const canDetachConsole = !isMobileViewport;
|
||||
const showDetachedConsole = consoleOpen && consoleDetached && canDetachConsole;
|
||||
const showAttachedConsole = consoleOpen && !showDetachedConsole;
|
||||
const previewOrigin = useMemo(() => resolvePreviewAppOrigin(), []);
|
||||
|
||||
const clearDetachedConsoleDraggingState = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.classList.remove(DETACHED_CONSOLE_DRAGGING_CLASS);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setConsoleEntries([]);
|
||||
}, [pathname, search, targetDescriptor, deviceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('preview-app-overlay-open');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('preview-app-overlay-open');
|
||||
document.body.classList.remove(DETACHED_CONSOLE_DRAGGING_CLASS);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -127,6 +293,12 @@ export function PreviewAppOverlay({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileViewport) {
|
||||
setConsoleDetached(false);
|
||||
}
|
||||
}, [isMobileViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!minimized && !isDesktopMobileShell) {
|
||||
return;
|
||||
@@ -288,6 +460,214 @@ export function PreviewAppOverlay({
|
||||
}
|
||||
}, [isDesktopMobileShell]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDetachedConsole) {
|
||||
detachedConsoleDragStateRef.current = null;
|
||||
detachedConsoleResizeStateRef.current = null;
|
||||
clearDetachedConsoleDraggingState();
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeListener = () => {
|
||||
const { width, height } = normalizeDetachedConsoleSize(detachedConsoleSizeRef.current);
|
||||
|
||||
setDetachedConsolePosition((current) => {
|
||||
const nextPosition = {
|
||||
x: clamp(
|
||||
current.x,
|
||||
VIEWPORT_PADDING,
|
||||
Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING),
|
||||
),
|
||||
y: clamp(
|
||||
current.y,
|
||||
VIEWPORT_PADDING,
|
||||
Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING),
|
||||
),
|
||||
};
|
||||
|
||||
detachedConsolePositionRef.current = nextPosition;
|
||||
return nextPosition;
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resizeListener);
|
||||
resizeListener();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeListener);
|
||||
};
|
||||
}, [showDetachedConsole]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDetachedConsole) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const dragState = detachedConsoleDragStateRef.current;
|
||||
const resizeState = detachedConsoleResizeStateRef.current;
|
||||
|
||||
if (dragState && dragState.pointerId === event.pointerId) {
|
||||
event.preventDefault();
|
||||
|
||||
const deltaX = event.clientX - dragState.lastX;
|
||||
const deltaY = event.clientY - dragState.lastY;
|
||||
|
||||
dragState.lastX = event.clientX;
|
||||
dragState.lastY = event.clientY;
|
||||
|
||||
const { width, height } = normalizeDetachedConsoleSize(detachedConsoleSizeRef.current);
|
||||
|
||||
setDetachedConsolePosition((current) => {
|
||||
const nextPosition = {
|
||||
x: clamp(
|
||||
current.x + deltaX,
|
||||
VIEWPORT_PADDING,
|
||||
Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING),
|
||||
),
|
||||
y: clamp(
|
||||
current.y + deltaY,
|
||||
VIEWPORT_PADDING,
|
||||
Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING),
|
||||
),
|
||||
};
|
||||
|
||||
detachedConsolePositionRef.current = nextPosition;
|
||||
return nextPosition;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (resizeState && resizeState.pointerId === event.pointerId) {
|
||||
event.preventDefault();
|
||||
|
||||
updateDetachedConsoleSize({
|
||||
width: resizeState.startWidth + (event.clientX - resizeState.startX),
|
||||
height: resizeState.startHeight + (event.clientY - resizeState.startY),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const finishPointerDrag = (event: PointerEvent) => {
|
||||
const dragState = detachedConsoleDragStateRef.current;
|
||||
const resizeState = detachedConsoleResizeStateRef.current;
|
||||
|
||||
if (dragState?.pointerId === event.pointerId) {
|
||||
if (dragState.captureTarget.hasPointerCapture(event.pointerId)) {
|
||||
dragState.captureTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
detachedConsoleDragStateRef.current = null;
|
||||
}
|
||||
|
||||
if (resizeState?.pointerId === event.pointerId) {
|
||||
if (resizeState.captureTarget.hasPointerCapture(event.pointerId)) {
|
||||
resizeState.captureTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
detachedConsoleResizeStateRef.current = null;
|
||||
}
|
||||
clearDetachedConsoleDraggingState();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', finishPointerDrag);
|
||||
window.addEventListener('pointercancel', finishPointerDrag);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', finishPointerDrag);
|
||||
window.removeEventListener('pointercancel', finishPointerDrag);
|
||||
};
|
||||
}, [showDetachedConsole]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== previewOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.source !== iframeRef.current?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = event.data as PreviewRuntimeConsoleBridgeMessage | null;
|
||||
|
||||
if (!payload || payload.source !== 'sm-home.preview-runtime.console' || !Array.isArray(payload.args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntry: PreviewConsoleEntry = {
|
||||
...payload,
|
||||
id: `${payload.timestamp}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
};
|
||||
|
||||
setConsoleEntries((current) => {
|
||||
const nextEntries = [...current, nextEntry];
|
||||
|
||||
if (nextEntries.length <= MAX_CONSOLE_ENTRIES) {
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
return nextEntries.slice(nextEntries.length - MAX_CONSOLE_ENTRIES);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [previewOrigin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!consoleOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFrame = window.requestAnimationFrame(() => {
|
||||
const container = consoleBodyRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(nextFrame);
|
||||
};
|
||||
}, [consoleEntries, consoleOpen]);
|
||||
|
||||
const latestConsoleEntry = consoleEntries.length > 0 ? consoleEntries[consoleEntries.length - 1] : null;
|
||||
const latestConsoleHref = latestConsoleEntry?.href?.trim() || iframeRef.current?.src || '';
|
||||
const consoleLevelCounts = useMemo(
|
||||
() =>
|
||||
CONSOLE_LEVELS.reduce<Record<PreviewRuntimeConsoleLevel, number>>(
|
||||
(counts, level) => ({
|
||||
...counts,
|
||||
[level]: consoleEntries.filter((entry) => entry.level === level).length,
|
||||
}),
|
||||
{
|
||||
log: 0,
|
||||
info: 0,
|
||||
warn: 0,
|
||||
error: 0,
|
||||
debug: 0,
|
||||
},
|
||||
),
|
||||
[consoleEntries],
|
||||
);
|
||||
const filteredConsoleEntries = useMemo(
|
||||
() => consoleEntries.filter((entry) => consoleLevelFilter.includes(entry.level)),
|
||||
[consoleEntries, consoleLevelFilter],
|
||||
);
|
||||
const errorEntryCount = consoleLevelCounts.error;
|
||||
const warnEntryCount = consoleLevelCounts.warn;
|
||||
const hasAllConsoleLevelsSelected = consoleLevelFilter.length === CONSOLE_LEVELS.length;
|
||||
const hasAnyConsoleLevelsSelected = consoleLevelFilter.length > 0;
|
||||
|
||||
const handleHeaderPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!minimized && !isDesktopMobileShell) {
|
||||
return;
|
||||
@@ -344,94 +724,417 @@ export function PreviewAppOverlay({
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`preview-app-overlay${minimized ? ' preview-app-overlay--minimized' : ''}${
|
||||
isDesktopMobileShell ? ' preview-app-overlay--mobile-shell' : ''
|
||||
const handleDetachedConsolePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!showDetachedConsole || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
detachedConsoleDragStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
lastX: event.clientX,
|
||||
lastY: event.clientY,
|
||||
captureTarget: event.currentTarget,
|
||||
};
|
||||
|
||||
document.body.classList.add(DETACHED_CONSOLE_DRAGGING_CLASS);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleDetachedConsolePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const dragState = detachedConsoleDragStateRef.current;
|
||||
|
||||
if (dragState?.pointerId === event.pointerId) {
|
||||
if (dragState.captureTarget.hasPointerCapture(event.pointerId)) {
|
||||
dragState.captureTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
detachedConsoleDragStateRef.current = null;
|
||||
}
|
||||
|
||||
clearDetachedConsoleDraggingState();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDetachedConsoleResizePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!showDetachedConsole || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
detachedConsoleResizeStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startWidth: detachedConsoleSizeRef.current.width,
|
||||
startHeight: detachedConsoleSizeRef.current.height,
|
||||
captureTarget: event.currentTarget,
|
||||
};
|
||||
|
||||
document.body.classList.add(DETACHED_CONSOLE_DRAGGING_CLASS);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleDetachedConsoleResizePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const resizeState = detachedConsoleResizeStateRef.current;
|
||||
|
||||
if (resizeState?.pointerId === event.pointerId) {
|
||||
if (resizeState.captureTarget.hasPointerCapture(event.pointerId)) {
|
||||
resizeState.captureTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
detachedConsoleResizeStateRef.current = null;
|
||||
}
|
||||
|
||||
clearDetachedConsoleDraggingState();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const updateDetachedConsoleSize = (nextSize: DetachedConsoleSize) => {
|
||||
const normalized = normalizeDetachedConsoleSize(nextSize);
|
||||
detachedConsoleSizeRef.current = normalized;
|
||||
setDetachedConsoleSize(normalized);
|
||||
setDetachedConsolePosition((current) => {
|
||||
const nextPosition = {
|
||||
x: clamp(
|
||||
current.x,
|
||||
VIEWPORT_PADDING,
|
||||
Math.max(VIEWPORT_PADDING, window.innerWidth - normalized.width - VIEWPORT_PADDING),
|
||||
),
|
||||
y: clamp(
|
||||
current.y,
|
||||
VIEWPORT_PADDING,
|
||||
Math.max(VIEWPORT_PADDING, window.innerHeight - normalized.height - VIEWPORT_PADDING),
|
||||
),
|
||||
};
|
||||
|
||||
detachedConsolePositionRef.current = nextPosition;
|
||||
return nextPosition;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleConsoleLevel = (level: PreviewRuntimeConsoleLevel) => {
|
||||
setConsoleLevelFilter((current) => {
|
||||
if (current.includes(level)) {
|
||||
if (current.length === 1) {
|
||||
return current;
|
||||
}
|
||||
return current.filter((item) => item !== level);
|
||||
}
|
||||
|
||||
return CONSOLE_LEVELS.filter((item) => item === level || current.includes(item));
|
||||
});
|
||||
};
|
||||
|
||||
const renderConsolePanel = (detached: boolean) => (
|
||||
<section
|
||||
className={`preview-app-overlay__console-panel${
|
||||
detached ? ' preview-app-overlay__console-panel--detached' : ''
|
||||
}`}
|
||||
aria-label="Preview 콘솔"
|
||||
style={
|
||||
minimized || isDesktopMobileShell
|
||||
detached
|
||||
? {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: `${detachedConsoleSize.width}px`,
|
||||
height: `${detachedConsoleSize.height}px`,
|
||||
left: `${detachedConsolePosition.x}px`,
|
||||
top: `${detachedConsolePosition.y}px`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="preview-app-overlay__header"
|
||||
onClick={() => {
|
||||
if (minimized && !dragMovedRef.current) {
|
||||
setMinimized(false);
|
||||
}
|
||||
}}
|
||||
onPointerDown={handleHeaderPointerDown}
|
||||
onPointerUp={handleHeaderPointerUp}
|
||||
onPointerCancel={handleHeaderPointerUp}
|
||||
className={`preview-app-overlay__console-head${
|
||||
detached ? ' preview-app-overlay__console-head--detached' : ''
|
||||
}`}
|
||||
onPointerDown={detached ? handleDetachedConsolePointerDown : undefined}
|
||||
onPointerUp={detached ? handleDetachedConsolePointerUp : undefined}
|
||||
onPointerCancel={detached ? handleDetachedConsolePointerUp : undefined}
|
||||
>
|
||||
<div className={`preview-app-overlay__title${minimized ? ' preview-app-overlay__title--minimized' : ''}`}>
|
||||
{minimized ? (
|
||||
<div className="preview-app-overlay__minimized-content">
|
||||
<span className="preview-app-overlay__minimized-dot" aria-hidden="true" />
|
||||
<span className="preview-app-overlay__minimized-label">Preview App</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="preview-app-overlay__title-badge" aria-hidden="true" />
|
||||
<span className="preview-app-overlay__title-copy">
|
||||
<strong>Preview App</strong>
|
||||
<span>모바일 컨테이너 미리보기</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="preview-app-overlay__console-head-copy">
|
||||
<strong>{detached ? 'Console Detached' : 'Console'}</strong>
|
||||
<div className="preview-app-overlay__console-summary" aria-label="Preview 콘솔 요약">
|
||||
<span>로그 {consoleEntries.length}</span>
|
||||
<span>경고 {warnEntryCount}</span>
|
||||
<span>오류 {errorEntryCount}</span>
|
||||
</div>
|
||||
<div className="preview-app-overlay__console-location" title={latestConsoleHref || 'Preview URL 확인 대기 중'}>
|
||||
{latestConsoleHref || 'Preview URL 확인 대기 중'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-app-overlay__actions">
|
||||
{!minimized && !isMobileViewport ? (
|
||||
<div
|
||||
className="preview-app-overlay__console-head-actions"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{canDetachConsole ? (
|
||||
<Button
|
||||
type="text"
|
||||
aria-label={deviceMode === 'mobile' ? '전체 폭으로 보기' : '모바일 폭으로 보기'}
|
||||
title={deviceMode === 'mobile' ? '전체 폭으로 보기' : '모바일 폭으로 보기'}
|
||||
icon={deviceMode === 'mobile' ? <DesktopOutlined /> : <MobileOutlined />}
|
||||
onClick={(event) => {
|
||||
handleActionButtonClick(event);
|
||||
setDeviceMode((current) => (current === 'mobile' ? 'desktop' : 'mobile'));
|
||||
size="small"
|
||||
aria-label={detached ? '콘솔 오버레이에 붙이기' : '콘솔 분리하기'}
|
||||
onClick={() => {
|
||||
setConsoleDetached((current) => !current);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!minimized ? (
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="Preview 최소화"
|
||||
icon={<MinusOutlined />}
|
||||
onClick={(event) => {
|
||||
handleActionButtonClick(event);
|
||||
handleMinimizeToggle();
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{detached ? 'Attach' : 'Detach'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
aria-label="Preview 닫기"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={(event) => {
|
||||
handleActionButtonClick(event);
|
||||
dragMovedRef.current = false;
|
||||
onClose();
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="Preview URL 복사"
|
||||
disabled={!latestConsoleHref}
|
||||
onClick={() => {
|
||||
if (!latestConsoleHref) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(latestConsoleHref);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
Copy URL
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="Preview 콘솔 비우기"
|
||||
onClick={() => {
|
||||
setConsoleEntries([]);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
{detached ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="Preview 콘솔 닫기"
|
||||
onClick={() => {
|
||||
setConsoleOpen(false);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`preview-app-overlay__body${minimized ? ' preview-app-overlay__body--hidden' : ''}`}>
|
||||
<PreviewAppWindow
|
||||
pathname={pathname}
|
||||
search={search}
|
||||
targetDescriptor={targetDescriptor}
|
||||
deviceMode={deviceMode}
|
||||
/>
|
||||
<div className="preview-app-overlay__console-filters" aria-label="Preview 콘솔 레벨 필터">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={`preview-app-overlay__console-filter${hasAllConsoleLevelsSelected ? ' is-active' : ''}`}
|
||||
aria-pressed={hasAllConsoleLevelsSelected}
|
||||
onClick={() => {
|
||||
setConsoleLevelFilter(CONSOLE_LEVELS);
|
||||
}}
|
||||
>
|
||||
All {consoleEntries.length}
|
||||
</Button>
|
||||
{CONSOLE_LEVELS.map((level) => (
|
||||
<Button
|
||||
key={level}
|
||||
type="text"
|
||||
size="small"
|
||||
className={`preview-app-overlay__console-filter preview-app-overlay__console-filter--${level}${
|
||||
consoleLevelFilter.includes(level) ? ' is-active' : ''
|
||||
}`}
|
||||
aria-pressed={consoleLevelFilter.includes(level)}
|
||||
onClick={() => {
|
||||
toggleConsoleLevel(level);
|
||||
}}
|
||||
>
|
||||
{level.toUpperCase()} {consoleLevelCounts[level]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
<div ref={consoleBodyRef} className="preview-app-overlay__console-body">
|
||||
{filteredConsoleEntries.length ? (
|
||||
filteredConsoleEntries.map((entry) => {
|
||||
const entryTimeLabel = new Date(entry.timestamp).toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<article
|
||||
key={entry.id}
|
||||
className={`preview-app-overlay__console-entry preview-app-overlay__console-entry--${entry.level}`}
|
||||
>
|
||||
<div className="preview-app-overlay__console-meta">
|
||||
<div className="preview-app-overlay__console-meta-copy">
|
||||
<span>{entry.level.toUpperCase()}</span>
|
||||
<time dateTime={entry.timestamp}>{entryTimeLabel}</time>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="preview-app-overlay__console-entry-copy"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="콘솔 항목 복사"
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(formatConsoleEntryCopyText(entry));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<pre>{entry.args.join('\n')}</pre>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="preview-app-overlay__console-empty">
|
||||
{consoleEntries.length && !hasAnyConsoleLevelsSelected
|
||||
? '표시할 콘솔 레벨이 선택되지 않았습니다.'
|
||||
: consoleEntries.length
|
||||
? '선택한 레벨에 해당하는 콘솔 로그가 아직 없습니다.'
|
||||
: 'iframe 콘솔 로그가 아직 없습니다.'}
|
||||
<br />
|
||||
{consoleEntries.length
|
||||
? '필터를 조정하거나 Preview 앱에서 동작을 다시 재현해 주세요.'
|
||||
: 'Preview 앱에서 동작을 재현하면 이 패널에서 바로 확인할 수 있습니다.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{detached ? (
|
||||
<div
|
||||
className="preview-app-overlay__console-resize-handle"
|
||||
aria-label="분리 콘솔 크기 조절"
|
||||
role="presentation"
|
||||
onPointerDown={handleDetachedConsoleResizePointerDown}
|
||||
onPointerUp={handleDetachedConsoleResizePointerUp}
|
||||
onPointerCancel={handleDetachedConsoleResizePointerUp}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`preview-app-overlay${minimized ? ' preview-app-overlay--minimized' : ''}${
|
||||
isDesktopMobileShell ? ' preview-app-overlay--mobile-shell' : ''
|
||||
}`}
|
||||
style={
|
||||
minimized || isDesktopMobileShell
|
||||
? {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="preview-app-overlay__header"
|
||||
onClick={() => {
|
||||
if (minimized && !dragMovedRef.current) {
|
||||
setMinimized(false);
|
||||
}
|
||||
}}
|
||||
onPointerDown={handleHeaderPointerDown}
|
||||
onPointerUp={handleHeaderPointerUp}
|
||||
onPointerCancel={handleHeaderPointerUp}
|
||||
>
|
||||
<div className={`preview-app-overlay__title${minimized ? ' preview-app-overlay__title--minimized' : ''}`}>
|
||||
{minimized ? (
|
||||
<div className="preview-app-overlay__minimized-content">
|
||||
<span className="preview-app-overlay__minimized-dot" aria-hidden="true" />
|
||||
<span className="preview-app-overlay__minimized-label">Preview App</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="preview-app-overlay__title-badge" aria-hidden="true" />
|
||||
<span className="preview-app-overlay__title-copy">
|
||||
<strong>Preview App</strong>
|
||||
<span>모바일 컨테이너 미리보기</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="preview-app-overlay__actions">
|
||||
{!minimized && !isMobileViewport ? (
|
||||
<Button
|
||||
type="text"
|
||||
aria-label={deviceMode === 'mobile' ? '전체 폭으로 보기' : '모바일 폭으로 보기'}
|
||||
title={deviceMode === 'mobile' ? '전체 폭으로 보기' : '모바일 폭으로 보기'}
|
||||
icon={deviceMode === 'mobile' ? <DesktopOutlined /> : <MobileOutlined />}
|
||||
onClick={(event) => {
|
||||
handleActionButtonClick(event);
|
||||
setDeviceMode((current) => (current === 'mobile' ? 'desktop' : 'mobile'));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!minimized ? (
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="Preview 새로고침"
|
||||
title="Preview 새로고침"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={(event) => {
|
||||
handleActionButtonClick(event);
|
||||
setConsoleEntries([]);
|
||||
setReloadKey((current) => current + 1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!minimized ? (
|
||||
<Button
|
||||
type="text"
|
||||
aria-label={consoleOpen ? 'Preview 콘솔 닫기' : 'Preview 콘솔 보기'}
|
||||
title={consoleOpen ? 'Preview 콘솔 닫기' : 'Preview 콘솔 보기'}
|
||||
className={`preview-app-overlay__console-toggle${consoleEntries.length ? ' preview-app-overlay__console-toggle--active' : ''}`}
|
||||
icon={<CodeOutlined />}
|
||||
onClick={(event) => {
|
||||
handleActionButtonClick(event);
|
||||
setConsoleOpen((current) => !current);
|
||||
}}
|
||||
>
|
||||
Console
|
||||
</Button>
|
||||
) : null}
|
||||
{!minimized ? (
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="Preview 최소화"
|
||||
icon={<MinusOutlined />}
|
||||
onClick={(event) => {
|
||||
handleActionButtonClick(event);
|
||||
handleMinimizeToggle();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
aria-label="Preview 닫기"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={(event) => {
|
||||
handleActionButtonClick(event);
|
||||
dragMovedRef.current = false;
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`preview-app-overlay__body${minimized ? ' preview-app-overlay__body--hidden' : ''}`}>
|
||||
<PreviewAppWindow
|
||||
ref={iframeRef}
|
||||
pathname={pathname}
|
||||
search={search}
|
||||
targetDescriptor={targetDescriptor}
|
||||
deviceMode={deviceMode}
|
||||
reloadKey={reloadKey}
|
||||
/>
|
||||
{showAttachedConsole ? renderConsolePanel(false) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showDetachedConsole ? renderConsolePanel(true) : null}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user