Files
ai-code-app/src/app/main/PreviewAppOverlay.tsx

1141 lines
36 KiB
TypeScript

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,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { createPortal } from 'react-dom';
import { PreviewAppWindow } from './PreviewAppWindow';
import { copyTextToClipboard } from '../../utils/clipboard';
import {
resolvePreviewAppOrigin,
type PreviewRuntimeConsoleBridgeMessage,
type PreviewRuntimeConsoleLevel,
type PreviewTargetDescriptor,
} from './previewRuntime';
type PreviewAppOverlayProps = {
pathname: string;
search?: string;
targetDescriptor?: PreviewTargetDescriptor;
onClose: () => void;
};
type DragPosition = {
x: number;
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 {
x: VIEWPORT_PADDING,
y: VIEWPORT_PADDING,
};
}
return {
x: Math.max(VIEWPORT_PADDING, window.innerWidth - MINIMIZED_WIDTH - VIEWPORT_PADDING),
y: Math.max(VIEWPORT_PADDING, window.innerHeight - HEADER_HEIGHT - VIEWPORT_PADDING),
};
}
function getMobileShellMetrics() {
if (typeof window === 'undefined') {
return {
width: MOBILE_SHELL_WIDTH,
height: MOBILE_SHELL_HEIGHT,
};
}
return {
width: Math.min(MOBILE_SHELL_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)),
height: Math.min(MOBILE_SHELL_HEIGHT, Math.max(520, window.innerHeight - VIEWPORT_PADDING * 2)),
};
}
function getDefaultMobileShellPosition(): DragPosition {
if (typeof window === 'undefined') {
return {
x: VIEWPORT_PADDING,
y: VIEWPORT_PADDING,
};
}
const { width, height } = getMobileShellMetrics();
return {
x: Math.max(VIEWPORT_PADDING, (window.innerWidth - width) / 2),
y: Math.max(VIEWPORT_PADDING, (window.innerHeight - height) / 2),
};
}
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);
};
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 768px)');
const handleChange = (event: MediaQueryListEvent) => {
setIsMobileViewport(event.matches);
};
setIsMobileViewport(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, []);
useEffect(() => {
if (isMobileViewport) {
setConsoleDetached(false);
}
}, [isMobileViewport]);
useEffect(() => {
if (!minimized && !isDesktopMobileShell) {
return;
}
const resizeListener = () => {
if (minimized) {
setPosition((current) => {
const nextPosition = {
x: clamp(
current.x,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerWidth - MINIMIZED_WIDTH - VIEWPORT_PADDING),
),
y: clamp(
current.y,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerHeight - HEADER_HEIGHT - VIEWPORT_PADDING),
),
};
minimizedPositionRef.current = nextPosition;
return nextPosition;
});
return;
}
const { width, height } = getMobileShellMetrics();
setPosition((current) => ({
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),
),
}));
};
window.addEventListener('resize', resizeListener);
resizeListener();
return () => {
window.removeEventListener('resize', resizeListener);
};
}, [isDesktopMobileShell, minimized]);
useEffect(() => {
if (minimized) {
minimizedPositionRef.current = position;
}
}, [minimized, position]);
useEffect(() => {
if (isDesktopMobileShell) {
mobileShellPositionRef.current = position;
}
}, [isDesktopMobileShell, position]);
useEffect(() => {
if (!minimized && !isDesktopMobileShell) {
dragStateRef.current = null;
dragMovedRef.current = false;
return;
}
const handlePointerMove = (event: PointerEvent) => {
const dragState = dragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
const deltaX = event.clientX - dragState.lastX;
const deltaY = event.clientY - dragState.lastY;
if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) {
dragMovedRef.current = true;
}
dragState.lastX = event.clientX;
dragState.lastY = event.clientY;
const maxX = minimized
? Math.max(VIEWPORT_PADDING, window.innerWidth - MINIMIZED_WIDTH - VIEWPORT_PADDING)
: Math.max(
VIEWPORT_PADDING,
window.innerWidth - getMobileShellMetrics().width - VIEWPORT_PADDING,
);
const maxY = minimized
? Math.max(VIEWPORT_PADDING, window.innerHeight - HEADER_HEIGHT - VIEWPORT_PADDING)
: Math.max(
VIEWPORT_PADDING,
window.innerHeight - getMobileShellMetrics().height - VIEWPORT_PADDING,
);
setPosition((current) => {
const nextPosition = {
x: clamp(current.x + deltaX, VIEWPORT_PADDING, maxX),
y: clamp(current.y + deltaY, VIEWPORT_PADDING, maxY),
};
if (minimized) {
minimizedPositionRef.current = nextPosition;
}
return nextPosition;
});
};
const finishPointerDrag = (event: PointerEvent) => {
const dragState = dragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
if (dragState.captureTarget.hasPointerCapture(event.pointerId)) {
dragState.captureTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
};
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);
};
}, [isDesktopMobileShell, minimized]);
useEffect(() => {
if (isDesktopMobileShell) {
const { width, height } = getMobileShellMetrics();
const nextPosition = {
x: clamp(
mobileShellPositionRef.current.x,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING),
),
y: clamp(
mobileShellPositionRef.current.y,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING),
),
};
mobileShellPositionRef.current = nextPosition;
setPosition(nextPosition);
}
}, [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;
}
const rootRect = rootRef.current?.getBoundingClientRect();
if (!rootRect) {
return;
}
dragStateRef.current = {
pointerId: event.pointerId,
lastX: event.clientX,
lastY: event.clientY,
captureTarget: event.currentTarget,
};
dragMovedRef.current = false;
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
};
const handleHeaderPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (dragState?.pointerId === event.pointerId) {
if (dragState.captureTarget.hasPointerCapture(event.pointerId)) {
dragState.captureTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
}
};
const handleMinimizeToggle = () => {
setMinimized((current) => {
if (current) {
if (deviceMode === 'mobile' && !isMobileViewport) {
setPosition(mobileShellPositionRef.current);
}
return false;
}
if (isDesktopMobileShell) {
mobileShellPositionRef.current = position;
}
setPosition(minimizedPositionRef.current);
return true;
});
};
const handleActionButtonClick = (event: ReactMouseEvent<HTMLElement>) => {
event.stopPropagation();
};
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={
detached
? {
width: `${detachedConsoleSize.width}px`,
height: `${detachedConsoleSize.height}px`,
left: `${detachedConsolePosition.x}px`,
top: `${detachedConsolePosition.y}px`,
}
: undefined
}
>
<div
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__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__console-head-actions"
onPointerDown={(event) => {
event.stopPropagation();
}}
>
{canDetachConsole ? (
<Button
type="text"
size="small"
aria-label={detached ? '콘솔 오버레이에 붙이기' : '콘솔 분리하기'}
onClick={() => {
setConsoleDetached((current) => !current);
}}
>
{detached ? 'Attach' : 'Detach'}
</Button>
) : null}
<Button
type="text"
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__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 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,
);
}