1141 lines
36 KiB
TypeScript
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,
|
|
);
|
|
}
|