import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@nanostores/react'; import { IconButton } from '~/components/ui/IconButton'; import { workbenchStore } from '~/lib/stores/workbench'; import { PortDropdown } from './PortDropdown'; import { ScreenshotSelector } from './ScreenshotSelector'; type ResizeSide = 'left' | 'right' | null; interface WindowSize { name: string; width: number; height: number; icon: string; } const WINDOW_SIZES: WindowSize[] = [ { name: 'Mobile', width: 375, height: 667, icon: 'i-ph:device-mobile' }, { name: 'Tablet', width: 768, height: 1024, icon: 'i-ph:device-tablet' }, { name: 'Laptop', width: 1366, height: 768, icon: 'i-ph:laptop' }, { name: 'Desktop', width: 1920, height: 1080, icon: 'i-ph:monitor' }, ]; export const Preview = memo(() => { const iframeRef = useRef(null); const containerRef = useRef(null); const inputRef = useRef(null); 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 previews = useStore(workbenchStore.previews); const activePreview = previews[activePreviewIndex]; const [url, setUrl] = useState(''); const [iframeUrl, setIframeUrl] = useState(); 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(37.5); const resizingState = useRef({ isResizing: false, side: null as ResizeSide, startX: 0, startWidthPercent: 37.5, windowWidth: window.innerWidth, }); const SCALING_FACTOR = 2; const [isWindowSizeDropdownOpen, setIsWindowSizeDropdownOpen] = useState(false); const [selectedWindowSize, setSelectedWindowSize] = useState(WINDOW_SIZES[0]); useEffect(() => { if (!activePreview) { setUrl(''); setIframeUrl(undefined); return; } const { baseUrl } = activePreview; setUrl(baseUrl); setIframeUrl(baseUrl); }, [activePreview]); const validateUrl = useCallback( (value: string) => { if (!activePreview) { return false; } const { baseUrl } = activePreview; if (value === baseUrl) { return true; } else if (value.startsWith(baseUrl)) { return ['/', '?', '#'].includes(value.charAt(baseUrl.length)); } return false; }, [activePreview], ); const findMinPortIndex = useCallback( (minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => { return preview.port < array[minIndex].port ? index : minIndex; }, [], ); useEffect(() => { if (previews.length > 1 && !hasSelectedPreview.current) { const minPortIndex = previews.reduce(findMinPortIndex, 0); setActivePreviewIndex(minPortIndex); } }, [previews, findMinPortIndex]); const reloadPreview = () => { if (iframeRef.current) { iframeRef.current.src = iframeRef.current.src; } }; 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.MouseEvent, side: ResizeSide) => { if (!isDeviceModeOn) { return; } document.body.style.userSelect = 'none'; resizingState.current.isResizing = true; resizingState.current.side = side; resizingState.current.startX = e.clientX; resizingState.current.startWidthPercent = widthPercent; resizingState.current.windowWidth = window.innerWidth; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); e.preventDefault(); }; const onMouseMove = (e: MouseEvent) => { if (!resizingState.current.isResizing) { return; } const dx = e.clientX - resizingState.current.startX; const windowWidth = resizingState.current.windowWidth; const dxPercent = (dx / windowWidth) * 100 * SCALING_FACTOR; let newWidthPercent = resizingState.current.startWidthPercent; if (resizingState.current.side === 'right') { newWidthPercent = resizingState.current.startWidthPercent + dxPercent; } else if (resizingState.current.side === 'left') { newWidthPercent = resizingState.current.startWidthPercent - dxPercent; } newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90)); setWidthPercent(newWidthPercent); }; const onMouseUp = () => { resizingState.current.isResizing = false; resizingState.current.side = null; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.body.style.userSelect = ''; }; useEffect(() => { const handleWindowResize = () => { // Optional: Adjust widthPercent if necessary }; window.addEventListener('resize', handleWindowResize); return () => { window.removeEventListener('resize', handleWindowResize); }; }, []); const GripIcon = () => (
••• •••
); const openInNewWindow = (size: WindowSize) => { if (activePreview?.baseUrl) { const match = activePreview.baseUrl.match(/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/); if (match) { const previewId = match[1]; const previewUrl = `/webcontainer/preview/${previewId}`; const newWindow = window.open( previewUrl, '_blank', `noopener,noreferrer,width=${size.width},height=${size.height},menubar=no,toolbar=no,location=no,status=no`, ); if (newWindow) { newWindow.focus(); } } else { console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl); } } }; return (
{isPortDropdownOpen && (
setIsPortDropdownOpen(false)} /> )}
setIsSelectionMode(!isSelectionMode)} className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''} />
{ setUrl(event.target.value); }} onKeyDown={(event) => { if (event.key === 'Enter' && validateUrl(url)) { setIframeUrl(url); if (inputRef.current) { inputRef.current.blur(); } } }} />
{previews.length > 1 && ( (hasSelectedPreview.current = value)} setIsDropdownOpen={setIsPortDropdownOpen} previews={previews} /> )} setIsPreviewOnly(!isPreviewOnly)} title={isPreviewOnly ? 'Show Full Interface' : 'Show Preview Only'} />
openInNewWindow(selectedWindowSize)} title={`Open Preview in ${selectedWindowSize.name} Window`} /> setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)} className="ml-1" title="Select Window Size" /> {isWindowSizeDropdownOpen && ( <>
setIsWindowSizeDropdownOpen(false)} />
{WINDOW_SIZES.map((size) => ( ))}
)}
{activePreview ? ( <>