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; 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(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 [currentWidth, setCurrentWidth] = useState(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(WINDOW_SIZES[0]); const [isLandscape, setIsLandscape] = useState(false); const [showDeviceFrame, setShowDeviceFrame] = useState(true); const [showDeviceFrameInPreview, setShowDeviceFrameInPreview] = useState(false); 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.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 (
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(--bolt-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(--bolt-elements-background-depth-4, rgba(0,0,0,.3))') } onMouseOut={(e) => (e.currentTarget.style.background = 'var(--bolt-elements-background-depth-3, rgba(0,0,0,.15))') } title="Drag to resize width" >
); }; 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 = () => (
••• •••
); 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}`; // 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('Failed to open new window'); 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'; // Create HTML content for the wrapper page const htmlContent = ` ${size.name} Preview
${size.name} ${isLandscape ? '(Landscape)' : '(Portrait)'}
`; // 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( previewUrl, '_blank', `width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no`, ); if (newWindow) { newWindow.focus(); } } } else { console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl); } } }; // 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 */ // Intentionally empty cleanup function - no cleanup needed return () => { // No cleanup needed }; }, [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 (
{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} /> )} {isDeviceModeOn && ( <> setIsLandscape(!isLandscape)} title={isLandscape ? 'Switch to Portrait' : 'Switch to Landscape'} /> setShowDeviceFrameInPreview(!showDeviceFrameInPreview)} title={showDeviceFrameInPreview ? 'Hide Device Frame' : 'Show Device Frame'} /> )} setIsPreviewOnly(!isPreviewOnly)} title={isPreviewOnly ? 'Show Full Interface' : 'Show Preview Only'} /> {/* Simple preview button */} { if (!activePreview?.baseUrl) { console.warn('[Preview] No active preview available'); return; } const match = activePreview.baseUrl.match( /^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/, ); if (!match) { console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl); return; } const previewId = match[1]; const previewUrl = `/webcontainer/preview/${previewId}`; // Open in a new window with simple parameters window.open( previewUrl, `preview-${previewId}`, 'width=1280,height=720,menubar=no,toolbar=no,location=no,status=no,resizable=yes', ); }} title="Open Preview in New Window" />
openInNewWindow(selectedWindowSize)} title={`Open Preview in ${selectedWindowSize.name} Window`} /> setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)} className="ml-1" title="Select Window Size" /> {isWindowSizeDropdownOpen && ( <>
setIsWindowSizeDropdownOpen(false)} />
Device Options
Show Device Frame
Landscape Mode
{WINDOW_SIZES.map((size) => ( ))}
)}
{activePreview ? ( <> {isDeviceModeOn && showDeviceFrameInPreview ? (
{/* Notch - positioned based on orientation */}
{/* Home button - positioned based on orientation */}