Files
upage-git/app/components/webbuilder/Preview.tsx
2025-09-24 17:02:44 +08:00

1021 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useStore } from '@nanostores/react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import { webBuilderStore } from '~/lib/stores/web-builder';
import { PageDropdown } from './PageDropdown';
import { ScreenshotSelector } from './ScreenshotSelector';
type ResizeSide = 'left' | 'right' | null;
interface WindowSize {
name: string;
width: number;
height: number;
icon: string;
hasFrame?: boolean;
frameType?: 'mobile' | 'tablet' | 'laptop' | 'desktop';
}
const WINDOW_SIZES: WindowSize[] = [
{ name: 'iPhone SE', width: 375, height: 667, icon: 'i-ph:device-mobile', hasFrame: true, frameType: 'mobile' },
{ name: 'iPhone 12/13', width: 390, height: 844, icon: 'i-ph:device-mobile', hasFrame: true, frameType: 'mobile' },
{
name: 'iPhone 12/13 Pro Max',
width: 428,
height: 926,
icon: 'i-ph:device-mobile',
hasFrame: true,
frameType: 'mobile',
},
{ name: 'iPad Mini', width: 768, height: 1024, icon: 'i-ph:device-tablet', hasFrame: true, frameType: 'tablet' },
{ name: 'iPad Air', width: 820, height: 1180, icon: 'i-ph:device-tablet', hasFrame: true, frameType: 'tablet' },
{ name: 'iPad Pro 11"', width: 834, height: 1194, icon: 'i-ph:device-tablet', hasFrame: true, frameType: 'tablet' },
{
name: 'iPad Pro 12.9"',
width: 1024,
height: 1366,
icon: 'i-ph:device-tablet',
hasFrame: true,
frameType: 'tablet',
},
{ name: 'Small Laptop', width: 1280, height: 800, icon: 'i-ph:laptop', hasFrame: true, frameType: 'laptop' },
{ name: 'Laptop', width: 1366, height: 768, icon: 'i-ph:laptop', hasFrame: true, frameType: 'laptop' },
{ name: 'Large Laptop', width: 1440, height: 900, icon: 'i-ph:laptop', hasFrame: true, frameType: 'laptop' },
{ name: 'Desktop', width: 1920, height: 1080, icon: 'i-ph:monitor', hasFrame: true, frameType: 'desktop' },
{ name: '4K Display', width: 3840, height: 2160, icon: 'i-ph:monitor', hasFrame: true, frameType: 'desktop' },
];
export const Preview = memo(() => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const HASH_LINK_INTERCEPTOR_SCRIPT = `
document.addEventListener('click', function(e) {
var target = e.target;
// 向上查找到<a>标签
while(target && target.tagName !== 'A') {
target = target.parentNode;
if(!target) break;
}
// 获取href属性
if(target && target.tagName === 'A') {
const href = target.getAttribute('href');
// 只处理纯#链接、空链接或只有锚点的链接(即#section这种格式)
if(href === '#' || href === '' || href === null) {
// 纯#链接和空链接直接阻止跳转
e.preventDefault();
e.stopPropagation();
return false;
} else if(href && href.startsWith('#')) {
// 对于#section这种锚点链接处理页内平滑滚动
e.preventDefault();
e.stopPropagation();
const elementId = href.substring(1);
const element = document.getElementById(elementId);
if(element) {
element.scrollIntoView({behavior: 'smooth'});
}
return false;
}
// 对于其他链接包含路径的链接如page.html#section让其正常工作
// 不做任何处理
}
}, true);
`;
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isPreviewOnly, setIsPreviewOnly] = useState(false);
const hasSelectedPreview = useRef(false);
const currentPreview = useStore(webBuilderStore.previewsStore.currentPreview);
const previews = useStore(webBuilderStore.previewsStore.previews);
const activePreview = previews[activePreviewIndex];
const [iframeDoc, setIframeDoc] = useState<string | null>(null);
const [isSelectionMode, setIsSelectionMode] = useState(false);
// Toggle between responsive mode and device mode
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
// Use percentage for width
const [widthPercent, setWidthPercent] = useState<number>(37.5);
const [currentWidth, setCurrentWidth] = useState<number>(0);
const resizingState = useRef({
isResizing: false,
side: null as ResizeSide,
startX: 0,
startWidthPercent: 37.5,
windowWidth: window.innerWidth,
pointerId: null as number | null,
});
// Reduce scaling factor to make resizing less sensitive
const SCALING_FACTOR = 1;
const [isWindowSizeDropdownOpen, setIsWindowSizeDropdownOpen] = useState(false);
const [selectedWindowSize, setSelectedWindowSize] = useState<WindowSize>(WINDOW_SIZES[0]);
const [isLandscape, setIsLandscape] = useState(false);
const [showDeviceFrame, setShowDeviceFrame] = useState(true);
const [showDeviceFrameInPreview, setShowDeviceFrameInPreview] = useState(false);
const addWhiteBackground = (content: string): string => {
// 包含拦截#链接点击的脚本
const interceptScript = `
<script data-hash-link-interceptor="true">
${HASH_LINK_INTERCEPTOR_SCRIPT}
</script>
`;
if (content.includes('<head>')) {
return content.replace('<head>', `<head><style>body{background-color:white;}</style>${interceptScript}`);
}
if (content.includes('</body>')) {
return content.replace('</body>', `${interceptScript}</body>`);
}
if (content.includes('<!DOCTYPE')) {
return content.replace('<!DOCTYPE', `<style>body{background-color:white;}</style>${interceptScript}<!DOCTYPE`);
}
return `<style>body{background-color:white;}</style>${interceptScript}${content}`;
};
useEffect(() => {
if (previews.length > 1 && !hasSelectedPreview.current) {
const currentPreviewIndex = previews.findIndex((preview) => preview.filename === currentPreview);
setActivePreviewIndex(currentPreviewIndex);
}
}, [previews]);
useEffect(() => {
if (!activePreview) {
setIframeDoc(null);
return;
}
// 为内容添加白色背景样式
const { content } = activePreview;
const contentWithWhiteBackground = addWhiteBackground(content);
setIframeDoc(contentWithWhiteBackground);
}, [activePreview]);
const reloadPreview = () => {
if (iframeRef.current) {
iframeRef.current?.contentDocument?.open();
iframeRef.current?.contentDocument?.write(iframeDoc ?? '');
iframeRef.current?.contentDocument?.close();
// 在iframe加载完成后确保所有#链接都被正确处理
iframeRef.current.onload = () => {
if (iframeRef.current?.contentDocument) {
const doc = iframeRef.current.contentDocument;
// 如果iframe内还没有我们的拦截脚本手动添加一个
if (!doc.querySelector('script[data-hash-link-interceptor]')) {
const script = doc.createElement('script');
script.setAttribute('data-hash-link-interceptor', 'true');
script.textContent = HASH_LINK_INTERCEPTOR_SCRIPT;
doc.body.appendChild(script);
}
}
};
}
};
const toggleFullscreen = async () => {
if (!isFullscreen && containerRef.current) {
await containerRef.current.requestFullscreen();
} else if (document.fullscreenElement) {
await document.exitFullscreen();
}
};
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
const toggleDeviceMode = () => {
setIsDeviceModeOn((prev) => !prev);
};
const startResizing = (e: React.PointerEvent, side: ResizeSide) => {
if (!isDeviceModeOn) {
return;
}
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ew-resize';
resizingState.current = {
isResizing: true,
side,
startX: e.clientX,
startWidthPercent: widthPercent,
windowWidth: window.innerWidth,
pointerId: e.pointerId,
};
};
const ResizeHandle = ({ side }: { side: ResizeSide }) => {
if (!side) {
return null;
}
return (
<div
className={`resize-handle-${side}`}
onPointerDown={(e) => startResizing(e, side)}
style={{
position: 'absolute',
top: 0,
...(side === 'left' ? { left: 0, marginLeft: '-7px' } : { right: 0, marginRight: '-7px' }),
width: '15px',
height: '100%',
cursor: 'ew-resize',
background: 'var(--upage-elements-background-depth-4, rgba(0,0,0,.3))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s',
userSelect: 'none',
touchAction: 'none',
zIndex: 10,
}}
onMouseOver={(e) =>
(e.currentTarget.style.background = 'var(--upage-elements-background-depth-4, rgba(0,0,0,.3))')
}
onMouseOut={(e) =>
(e.currentTarget.style.background = 'var(--upage-elements-background-depth-3, rgba(0,0,0,.15))')
}
title="拖动调整宽度"
>
<GripIcon />
</div>
);
};
useEffect(() => {
// Skip if not in device mode
if (!isDeviceModeOn) {
return;
}
const handlePointerMove = (e: PointerEvent) => {
const state = resizingState.current;
if (!state.isResizing || e.pointerId !== state.pointerId) {
return;
}
const dx = e.clientX - state.startX;
const dxPercent = (dx / state.windowWidth) * 100 * SCALING_FACTOR;
let newWidthPercent = state.startWidthPercent;
if (state.side === 'right') {
newWidthPercent = state.startWidthPercent + dxPercent;
} else if (state.side === 'left') {
newWidthPercent = state.startWidthPercent - dxPercent;
}
// Limit width percentage between 10% and 90%
newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90));
// Force a synchronous update to ensure the UI reflects the change immediately
setWidthPercent(newWidthPercent);
// Calculate and update the actual pixel width
if (containerRef.current) {
const containerWidth = containerRef.current.clientWidth;
const newWidth = Math.round((containerWidth * newWidthPercent) / 100);
setCurrentWidth(newWidth);
// Apply the width directly to the container for immediate feedback
const previewContainer = containerRef.current.querySelector('div[style*="width"]');
if (previewContainer) {
(previewContainer as HTMLElement).style.width = `${newWidthPercent}%`;
}
}
};
const handlePointerUp = (e: PointerEvent) => {
const state = resizingState.current;
if (!state.isResizing || e.pointerId !== state.pointerId) {
return;
}
// Find all resize handles
const handles = document.querySelectorAll('.resize-handle-left, .resize-handle-right');
// Release pointer capture from any handle that has it
handles.forEach((handle) => {
if ((handle as HTMLElement).hasPointerCapture?.(e.pointerId)) {
(handle as HTMLElement).releasePointerCapture(e.pointerId);
}
});
// Reset state
resizingState.current = {
...resizingState.current,
isResizing: false,
side: null,
pointerId: null,
};
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
// Add event listeners
document.addEventListener('pointermove', handlePointerMove, { passive: false });
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('pointercancel', handlePointerUp);
// Define cleanup function
function cleanupResizeListeners() {
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.removeEventListener('pointercancel', handlePointerUp);
// Release any lingering pointer captures
if (resizingState.current.pointerId !== null) {
const handles = document.querySelectorAll('.resize-handle-left, .resize-handle-right');
handles.forEach((handle) => {
if ((handle as HTMLElement).hasPointerCapture?.(resizingState.current.pointerId!)) {
(handle as HTMLElement).releasePointerCapture(resizingState.current.pointerId!);
}
});
// Reset state
resizingState.current = {
...resizingState.current,
isResizing: false,
side: null,
pointerId: null,
};
document.body.style.userSelect = '';
document.body.style.cursor = '';
}
}
// Return the cleanup function
// eslint-disable-next-line consistent-return
return cleanupResizeListeners;
}, [isDeviceModeOn, SCALING_FACTOR]);
useEffect(() => {
const handleWindowResize = () => {
// Update the window width in the resizing state
resizingState.current.windowWidth = window.innerWidth;
// Update the current width in pixels
if (containerRef.current && isDeviceModeOn) {
const containerWidth = containerRef.current.clientWidth;
setCurrentWidth(Math.round((containerWidth * widthPercent) / 100));
}
};
window.addEventListener('resize', handleWindowResize);
// Initial calculation of current width
if (containerRef.current && isDeviceModeOn) {
const containerWidth = containerRef.current.clientWidth;
setCurrentWidth(Math.round((containerWidth * widthPercent) / 100));
}
return () => {
window.removeEventListener('resize', handleWindowResize);
};
}, [isDeviceModeOn, widthPercent]);
// Update current width when device mode is toggled
useEffect(() => {
if (containerRef.current && isDeviceModeOn) {
const containerWidth = containerRef.current.clientWidth;
setCurrentWidth(Math.round((containerWidth * widthPercent) / 100));
}
}, [isDeviceModeOn]);
const GripIcon = () => (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
pointerEvents: 'none',
}}
>
<div
style={{
color: 'var(--upage-elements-textSecondary, rgba(0,0,0,0.5))',
fontSize: '10px',
lineHeight: '5px',
userSelect: 'none',
marginLeft: '1px',
}}
>
</div>
</div>
);
const openInNewWindow = (size: WindowSize) => {
if (activePreview?.content) {
// Adjust dimensions for landscape mode if applicable
let width = size.width;
let height = size.height;
if (isLandscape && (size.frameType === 'mobile' || size.frameType === 'tablet')) {
// Swap width and height for landscape mode
width = size.height;
height = size.width;
}
// Create a window with device frame if enabled
if (showDeviceFrame && size.hasFrame) {
// Calculate frame dimensions
const frameWidth = size.frameType === 'mobile' ? (isLandscape ? 120 : 40) : 60; // Width padding on each side
const frameHeight = size.frameType === 'mobile' ? (isLandscape ? 80 : 80) : isLandscape ? 60 : 100; // Height padding on top and bottom
// Create a window with the correct dimensions first
const newWindow = window.open(
'',
'_blank',
`width=${width + frameWidth},height=${height + frameHeight + 40},menubar=no,toolbar=no,location=no,status=no`,
);
if (!newWindow) {
console.error('无法打开新窗口');
return;
}
// Create the HTML content for the frame
const frameColor = getFrameColor();
const frameRadius = size.frameType === 'mobile' ? '36px' : '20px';
const framePadding =
size.frameType === 'mobile'
? isLandscape
? '40px 60px'
: '40px 20px'
: isLandscape
? '30px 50px'
: '50px 30px';
// Position notch and home button based on orientation
const notchTop = isLandscape ? '50%' : '20px';
const notchLeft = isLandscape ? '30px' : '50%';
const notchTransform = isLandscape ? 'translateY(-50%)' : 'translateX(-50%)';
const notchWidth = isLandscape ? '8px' : size.frameType === 'mobile' ? '60px' : '80px';
const notchHeight = isLandscape ? (size.frameType === 'mobile' ? '60px' : '80px') : '8px';
const homeBottom = isLandscape ? '50%' : '15px';
const homeRight = isLandscape ? '30px' : '50%';
const homeTransform = isLandscape ? 'translateY(50%)' : 'translateX(50%)';
const homeWidth = isLandscape ? '4px' : '40px';
const homeHeight = isLandscape ? '40px' : '4px';
// 使用 base64 编码内容以避免中文乱码
const contentWithWhiteBackground = addWhiteBackground(activePreview.content);
const encodedContent = btoa(unescape(encodeURIComponent(contentWithWhiteBackground)));
const contentDataUrl = `data:text/html;charset=utf-8;base64,${encodedContent}`;
// Create HTML content for the wrapper page
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${size.name} Preview</title>
<style>
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f0f0f0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.device-container {
position: relative;
}
.device-name {
position: absolute;
top: -30px;
left: 0;
right: 0;
text-align: center;
font-size: 14px;
color: #333;
}
.device-frame {
position: relative;
border-radius: ${frameRadius};
background: ${frameColor};
padding: ${framePadding};
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
overflow: hidden;
}
/* Notch */
.device-frame:before {
content: '';
position: absolute;
top: ${notchTop};
left: ${notchLeft};
transform: ${notchTransform};
width: ${notchWidth};
height: ${notchHeight};
background: #333;
border-radius: 4px;
z-index: 2;
}
/* Home button */
.device-frame:after {
content: '';
position: absolute;
bottom: ${homeBottom};
right: ${homeRight};
transform: ${homeTransform};
width: ${homeWidth};
height: ${homeHeight};
background: #333;
border-radius: 50%;
z-index: 2;
}
iframe {
border: none;
width: ${width}px;
height: ${height}px;
background: white;
display: block;
}
</style>
</head>
<body>
<div class="device-container">
<div class="device-name">${size.name} ${isLandscape ? '(Landscape)' : '(Portrait)'}</div>
<div class="device-frame">
<iframe src="${contentDataUrl}" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin" allow="cross-origin-isolated"></iframe>
</div>
</div>
</body>
</html>
`;
// Write the HTML content to the new window
newWindow.document.open();
newWindow.document.write(htmlContent);
newWindow.document.close();
} else {
// Standard window without frame
const newWindow = window.open(
'',
'_blank',
`width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no`,
);
if (!newWindow) {
console.error('Failed to open new window');
return;
}
// 使用直接写入方式,确保设置正确的字符编码
newWindow.document.open();
newWindow.document.write(
'<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{background-color:white;}</style></head><body>',
);
newWindow.document.write(activePreview.content);
// 添加拦截脚本并关闭HTML
newWindow.document.write(`
<script data-hash-link-interceptor="true">
${HASH_LINK_INTERCEPTOR_SCRIPT}
</script>
</body></html>`);
newWindow.document.close();
if (newWindow) {
newWindow.focus();
}
}
}
};
// Function to get the correct frame padding based on orientation
const getFramePadding = useCallback(() => {
if (!selectedWindowSize) {
return '40px 20px';
}
const isMobile = selectedWindowSize.frameType === 'mobile';
if (isLandscape) {
// Increase horizontal padding in landscape mode to ensure full device frame is visible
return isMobile ? '40px 60px' : '30px 50px';
}
return isMobile ? '40px 20px' : '50px 30px';
}, [isLandscape, selectedWindowSize]);
// Function to get the scale factor for the device frame
const getDeviceScale = useCallback(() => {
// Always return 1 to ensure the device frame is shown at its exact size
return 1;
}, [isLandscape, selectedWindowSize, widthPercent]);
// Update the device scale when needed
useEffect(() => {
/*
* Intentionally disabled - we want to maintain scale of 1
* No dynamic scaling to ensure device frame matches external window exactly
*/
// biome-ignore lint: ignore empty return
return () => {};
}, [isDeviceModeOn, showDeviceFrameInPreview, getDeviceScale, isLandscape, selectedWindowSize]);
// Function to get the frame color based on dark mode
const getFrameColor = useCallback(() => {
// Check if the document has a dark class or data-theme="dark"
const isDarkMode =
document.documentElement.classList.contains('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark' ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
// Return a darker color for light mode, lighter color for dark mode
return isDarkMode ? '#555' : '#111';
}, []);
// Effect to handle color scheme changes
useEffect(() => {
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleColorSchemeChange = () => {
// Force a re-render when color scheme changes
if (showDeviceFrameInPreview) {
setShowDeviceFrameInPreview(true);
}
};
darkModeMediaQuery.addEventListener('change', handleColorSchemeChange);
return () => {
darkModeMediaQuery.removeEventListener('change', handleColorSchemeChange);
};
}, [showDeviceFrameInPreview]);
return (
<div
ref={containerRef}
className={`size-full flex flex-col relative ${isPreviewOnly ? 'fixed inset-0 z-50 bg-white' : ''}`}
>
{isPortDropdownOpen && (
<div className="z-iframe-overlay size-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
)}
<div className="bg-upage-elements-background-depth-2 p-2 flex items-center gap-2">
<div className="flex items-center gap-2">
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
<IconButton
icon="i-ph:selection"
onClick={() => setIsSelectionMode(!isSelectionMode)}
className={isSelectionMode ? 'bg-upage-elements-background-depth-3' : ''}
/>
</div>
<div className="flex items-center gap-2">
{previews.length > 1 && (
<PageDropdown
activePreviewIndex={activePreviewIndex}
setActivePreviewIndex={setActivePreviewIndex}
isDropdownOpen={isPortDropdownOpen}
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
setIsDropdownOpen={setIsPortDropdownOpen}
previews={previews}
/>
)}
<IconButton
icon="i-ph:devices"
onClick={toggleDeviceMode}
title={isDeviceModeOn ? '切换到响应式模式' : '切换到设备模式'}
/>
{isDeviceModeOn && (
<>
<IconButton
icon="i-mingcute:clockwise-alt-line"
onClick={() => setIsLandscape(!isLandscape)}
title={isLandscape ? '切换到竖屏' : '切换到横屏'}
/>
<IconButton
icon={showDeviceFrameInPreview ? 'i-ph:device-mobile' : 'i-ph:device-mobile-slash'}
onClick={() => setShowDeviceFrameInPreview(!showDeviceFrameInPreview)}
title={showDeviceFrameInPreview ? '隐藏设备框架' : '显示设备框架'}
/>
</>
)}
<IconButton
icon="i-ph:layout-light"
onClick={() => setIsPreviewOnly(!isPreviewOnly)}
title={isPreviewOnly ? '显示完整界面' : '只显示预览'}
/>
<IconButton
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
onClick={toggleFullscreen}
title={isFullscreen ? '退出全屏' : '全屏'}
/>
<div className="flex items-center relative">
<IconButton
icon="i-ph:arrow-square-out"
onClick={() => openInNewWindow(selectedWindowSize)}
title={`${selectedWindowSize.name} 窗口中打开预览`}
/>
<IconButton
icon="i-ph:caret-down"
onClick={() => setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)}
className="ml-1"
title="选择窗口大小"
/>
{isWindowSizeDropdownOpen && (
<>
<div className="fixed inset-0 z-50" onClick={() => setIsWindowSizeDropdownOpen(false)} />
<div className="absolute right-0 top-full mt-2 z-50 min-w-[240px] max-h-[400px] overflow-y-auto bg-white dark:bg-black rounded-xl shadow-2xl border border-[#E5E7EB] dark:border-[rgba(255,255,255,0.1)] overflow-hidden">
<div className="p-3 border-b border-[#E5E7EB] dark:border-[rgba(255,255,255,0.1)]">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[#111827] dark:text-gray-300"></span>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-[#6B7280] dark:text-gray-400"></span>
<button
className={`w-10 h-5 rounded-full transition-colors duration-200 ${
showDeviceFrame ? 'bg-[#6D28D9]' : 'bg-gray-300 dark:bg-gray-700'
} relative`}
onClick={(e) => {
e.stopPropagation();
setShowDeviceFrame(!showDeviceFrame);
}}
>
<span
className={`absolute top-0.5 left-0.5 size-4 rounded-full bg-white transition-transform duration-200 ${
showDeviceFrame ? 'transform translate-x-5' : ''
}`}
/>
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-[#6B7280] dark:text-gray-400"></span>
<button
className={`w-10 h-5 rounded-full transition-colors duration-200 ${
isLandscape ? 'bg-[#6D28D9]' : 'bg-gray-300 dark:bg-gray-700'
} relative`}
onClick={(e) => {
e.stopPropagation();
setIsLandscape(!isLandscape);
}}
>
<span
className={`absolute top-0.5 left-0.5 size-4 rounded-full bg-white transition-transform duration-200 ${
isLandscape ? 'transform translate-x-5' : ''
}`}
/>
</button>
</div>
</div>
</div>
{WINDOW_SIZES.map((size) => (
<button
key={size.name}
className="w-full px-4 py-3.5 text-left text-[#111827] dark:text-gray-300 text-sm whitespace-nowrap flex items-center gap-3 group hover:bg-[#F5EEFF] dark:hover:bg-gray-900 bg-white dark:bg-black"
onClick={() => {
setSelectedWindowSize(size);
setIsWindowSizeDropdownOpen(false);
openInNewWindow(size);
}}
>
<div
className={`${size.icon} size-5 text-[#6B7280] dark:text-gray-400 group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200`}
/>
<div className="flex-grow flex flex-col">
<span className="font-medium group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200">
{size.name}
</span>
<span className="text-xs text-[#6B7280] dark:text-gray-400 group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200">
{isLandscape && (size.frameType === 'mobile' || size.frameType === 'tablet')
? `${size.height} × ${size.width}`
: `${size.width} × ${size.height}`}
{size.hasFrame && showDeviceFrame ? ' (with frame)' : ''}
</span>
</div>
{selectedWindowSize.name === size.name && (
<div className="text-[#6D28D9] dark:text-[#6D28D9]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
)}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
<div className="flex-1 border-t border-upage-elements-borderColor flex justify-center items-center overflow-auto">
<div
style={{
width: isDeviceModeOn ? (showDeviceFrameInPreview ? '100%' : `${widthPercent}%`) : '100%',
height: '100%',
overflow: 'auto',
background: 'var(--upage-elements-background-depth-1)',
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
{activePreview ? (
<>
{isDeviceModeOn && showDeviceFrameInPreview ? (
<div
className="device-wrapper"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
padding: '0',
overflow: 'auto',
transition: 'all 0.3s ease',
position: 'relative',
}}
>
<div
className="device-frame-container"
style={{
position: 'relative',
borderRadius: selectedWindowSize.frameType === 'mobile' ? '36px' : '20px',
background: getFrameColor(),
padding: getFramePadding(),
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
overflow: 'hidden',
transform: 'scale(1)',
transformOrigin: 'center center',
transition: 'all 0.3s ease',
margin: '40px',
width: isLandscape
? `${selectedWindowSize.height + (selectedWindowSize.frameType === 'mobile' ? 120 : 60)}px`
: `${selectedWindowSize.width + (selectedWindowSize.frameType === 'mobile' ? 40 : 60)}px`,
height: isLandscape
? `${selectedWindowSize.width + (selectedWindowSize.frameType === 'mobile' ? 80 : 60)}px`
: `${selectedWindowSize.height + (selectedWindowSize.frameType === 'mobile' ? 80 : 100)}px`,
}}
>
{/* Notch - positioned based on orientation */}
<div
style={{
position: 'absolute',
top: isLandscape ? '50%' : '20px',
left: isLandscape ? '30px' : '50%',
transform: isLandscape ? 'translateY(-50%)' : 'translateX(-50%)',
width: isLandscape ? '8px' : selectedWindowSize.frameType === 'mobile' ? '60px' : '80px',
height: isLandscape ? (selectedWindowSize.frameType === 'mobile' ? '60px' : '80px') : '8px',
background: '#333',
borderRadius: '4px',
zIndex: 2,
}}
/>
{/* Home button - positioned based on orientation */}
<div
style={{
position: 'absolute',
bottom: isLandscape ? '50%' : '15px',
right: isLandscape ? '30px' : '50%',
transform: isLandscape ? 'translateY(50%)' : 'translateX(50%)',
width: isLandscape ? '4px' : '40px',
height: isLandscape ? '40px' : '4px',
background: '#333',
borderRadius: '50%',
zIndex: 2,
}}
/>
<iframe
ref={iframeRef}
title="preview"
style={{
border: 'none',
width: isLandscape ? `${selectedWindowSize.height}px` : `${selectedWindowSize.width}px`,
height: isLandscape ? `${selectedWindowSize.width}px` : `${selectedWindowSize.height}px`,
background: 'white',
display: 'block',
}}
srcDoc={iframeDoc || ''}
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
allow="cross-origin-isolated"
/>
</div>
</div>
) : (
<iframe
ref={iframeRef}
title="preview"
className="border-none size-full bg-upage-elements-background-depth-1"
srcDoc={iframeDoc || ''}
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
allow="cross-origin-isolated"
/>
)}
<ScreenshotSelector
isSelectionMode={isSelectionMode}
setIsSelectionMode={setIsSelectionMode}
containerRef={iframeRef}
/>
</>
) : (
<div className="flex size-full justify-center items-center bg-upage-elements-background-depth-1 text-upage-elements-textPrimary">
</div>
)}
{isDeviceModeOn && !showDeviceFrameInPreview && (
<>
{/* Width indicator */}
<div
style={{
position: 'absolute',
top: '-25px',
left: '50%',
transform: 'translateX(-50%)',
background: 'var(--upage-elements-background-depth-3, rgba(0,0,0,0.7))',
color: 'var(--upage-elements-textPrimary, white)',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
pointerEvents: 'none',
opacity: resizingState.current.isResizing ? 1 : 0,
transition: 'opacity 0.3s',
}}
>
{currentWidth}px
</div>
<ResizeHandle side="left" />
<ResizeHandle side="right" />
</>
)}
</div>
</div>
</div>
);
});