diff --git a/app/components/workbench/Preview.tsx b/app/components/workbench/Preview.tsx index 93a0b85c..aba8a24c 100644 --- a/app/components/workbench/Preview.tsx +++ b/app/components/workbench/Preview.tsx @@ -12,13 +12,37 @@ interface WindowSize { width: number; height: number; icon: string; + hasFrame?: boolean; + frameType?: 'mobile' | 'tablet' | 'laptop' | 'desktop'; } 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' }, + { 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(() => { @@ -43,6 +67,7 @@ export const Preview = memo(() => { // Use percentage for width const [widthPercent, setWidthPercent] = useState(37.5); + const [currentWidth, setCurrentWidth] = useState(0); const resizingState = useRef({ isResizing: false, @@ -50,12 +75,17 @@ export const Preview = memo(() => { startX: 0, startWidthPercent: 37.5, windowWidth: window.innerWidth, + pointerId: null as number | null, }); - const SCALING_FACTOR = 2; + // 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) { @@ -133,68 +163,209 @@ export const Preview = memo(() => { setIsDeviceModeOn((prev) => !prev); }; - const startResizing = (e: React.MouseEvent, side: ResizeSide) => { + 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; - 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(); + resizingState.current = { + isResizing: true, + side, + startX: e.clientX, + startWidthPercent: widthPercent, + windowWidth: window.innerWidth, + pointerId: e.pointerId, + }; }; - const onMouseMove = (e: MouseEvent) => { - if (!resizingState.current.isResizing) { - return; + const ResizeHandle = ({ side }: { side: ResizeSide }) => { + if (!side) { + return null; } - 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 = ''; + 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 = () => { - // Optional: Adjust widthPercent if necessary + // 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 = () => (
{ >
{ 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(); + // 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); @@ -242,6 +565,67 @@ export const Preview = memo(() => { } }; + // 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 + */ + 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 (
{ title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'} /> + {isDeviceModeOn && ( + <> + setIsLandscape(!isLandscape)} + title={isLandscape ? 'Switch to Portrait' : 'Switch to Landscape'} + /> + setShowDeviceFrameInPreview(!showDeviceFrameInPreview)} + title={showDeviceFrameInPreview ? 'Hide Device Frame' : 'Show Device Frame'} + /> + + )} + setIsPreviewOnly(!isPreviewOnly)} @@ -328,7 +727,50 @@ export const Preview = memo(() => { {isWindowSizeDropdownOpen && ( <>
setIsWindowSizeDropdownOpen(false)} /> -
+
+
+
+ Device Options +
+
+
+ Show Device Frame + +
+
+ Landscape Mode + +
+
+
{WINDOW_SIZES.map((size) => ( ))}
@@ -362,24 +824,110 @@ export const Preview = memo(() => {
{activePreview ? ( <> -