chore: exclude local resource artifacts from main sync

This commit is contained in:
2026-05-15 10:16:45 +09:00
parent 442879313f
commit d38d022872
504 changed files with 17074 additions and 3642 deletions

View 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,
);
}