import { useStore } from '@nanostores/react'; import { memo, useCallback, useEffect, useRef, useState } from '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; 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 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); // 375px assuming 1000px window width initially const resizingState = useRef({ isResizing: false, side: null as ResizeSide, startX: 0, startWidthPercent: 37.5, windowWidth: window.innerWidth, }); // Define the scaling factor const SCALING_FACTOR = 2; // Adjust this value to increase/decrease sensitivity 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; }, [], ); // When previews change, display the lowest port if user hasn't selected a preview 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; } // Prevent text selection 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(); // Prevent any text selection on mousedown }; const onMouseMove = (e: MouseEvent) => { if (!resizingState.current.isResizing) { return; } const dx = e.clientX - resizingState.current.startX; const windowWidth = resizingState.current.windowWidth; // Apply scaling factor to increase sensitivity 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; } // Clamp the width between 10% and 90% 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); // Restore text selection document.body.style.userSelect = ''; }; // Handle window resize to ensure widthPercent remains valid useEffect(() => { const handleWindowResize = () => { /* * Optional: Adjust widthPercent if necessary * For now, since widthPercent is relative, no action is needed */ }; window.addEventListener('resize', handleWindowResize); return () => { window.removeEventListener('resize', handleWindowResize); }; }, []); // A small helper component for the handle's "grip" icon const GripIcon = () => (
••• •••
); 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} /> )} {/* Device mode toggle button */} {/* Fullscreen toggle button */}
{activePreview ? ( <>