chore: exclude local resource artifacts from main sync
This commit is contained in:
437
src/app/main/PreviewAppOverlay.tsx
Normal file
437
src/app/main/PreviewAppOverlay.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { CloseOutlined, DesktopOutlined, MinusOutlined, MobileOutlined } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PreviewAppWindow } from './PreviewAppWindow';
|
||||
import type { PreviewTargetDescriptor } from './previewRuntime';
|
||||
|
||||
type PreviewAppOverlayProps = {
|
||||
pathname: string;
|
||||
search?: string;
|
||||
targetDescriptor?: PreviewTargetDescriptor;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type DragPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const HEADER_HEIGHT = 44;
|
||||
const MINIMIZED_WIDTH = 168;
|
||||
const MOBILE_SHELL_WIDTH = 430;
|
||||
const MOBILE_SHELL_HEIGHT = 860;
|
||||
const VIEWPORT_PADDING = 12;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
export function PreviewAppOverlay({
|
||||
pathname,
|
||||
search = '',
|
||||
targetDescriptor = null,
|
||||
onClose,
|
||||
}: PreviewAppOverlayProps) {
|
||||
const minimizedPositionRef = useRef<DragPosition>(getDefaultMinimizedPosition());
|
||||
const mobileShellPositionRef = useRef<DragPosition>(getDefaultMobileShellPosition());
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const dragStateRef = useRef<{
|
||||
pointerId: number;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
captureTarget: HTMLDivElement;
|
||||
} | null>(null);
|
||||
const dragMovedRef = useRef(false);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [deviceMode, setDeviceMode] = useState<'desktop' | 'mobile'>('mobile');
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.matchMedia('(max-width: 768px)').matches : false,
|
||||
);
|
||||
const [position, setPosition] = useState<DragPosition>(() => minimizedPositionRef.current);
|
||||
const isDesktopMobileShell = !minimized && deviceMode === 'mobile' && !isMobileViewport;
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('preview-app-overlay-open');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('preview-app-overlay-open');
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 (!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]);
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
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 최소화"
|
||||
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
|
||||
pathname={pathname}
|
||||
search={search}
|
||||
targetDescriptor={targetDescriptor}
|
||||
deviceMode={deviceMode}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user