From 24d9ed63fa264c803a0906115b5c3c72670da27b Mon Sep 17 00:00:00 2001 From: Stijnus <72551117+Stijnus@users.noreply.github.com> Date: Sun, 2 Mar 2025 15:02:18 +0100 Subject: [PATCH 1/2] Update Preview.tsx # Device Frame Feature Implementation ## Key Features Added 1. **Device Frames for Mobile and Tablet Previews** - Added visual frames for iPhone and iPad devices - Implemented both portrait and landscape orientations - Created realistic device bezels with notches and home buttons 2. **Device Options Panel** - Added toggle for showing/hiding device frames - Added toggle for landscape/portrait orientation - Updated device size information display 3. **External Window Preview Enhancements** - Fixed "about:blank" issue with external previews - Implemented reliable window creation with proper dimensions - Added device name and orientation labels ## Technical Implementation Details 1. **Frame Rendering Approach** - Created a complete HTML document with styling for device frames - Used document.write() to inject content into new window - Implemented proper iframe loading within the device frame 2. **Responsive Design** - Dynamic adjustment of frame elements based on device type - Proper handling of orientation changes (landscape/portrait) - Appropriate sizing for different device types (mobile vs tablet) 3. **Security and Reliability** - Avoided cross-origin manipulation issues - Implemented error handling for window creation - Maintained proper sandbox attributes for security ## UI/UX Improvements 1. **Visual Enhancements** - Added realistic device styling (rounded corners, notches, buttons) - Implemented proper shadows and depth for 3D appearance - Created clean, minimal interface for device controls 2. **User Controls** - Intuitive toggles for device frame and orientation - Clear labeling of device dimensions and frame status - Maintained existing preview functionality while adding new features ## Code Quality 1. **Maintainability** - Used TypeScript interfaces for proper typing - Implemented clean separation of concerns - Added appropriate comments for complex sections 2. **Performance** - Optimized rendering approach to avoid unnecessary reflows - Used efficient DOM manipulation techniques - Maintained responsive performance across different device sizes --- app/components/workbench/Preview.tsx | 351 +++++++++++++++++++++++++-- 1 file changed, 329 insertions(+), 22 deletions(-) diff --git a/app/components/workbench/Preview.tsx b/app/components/workbench/Preview.tsx index 93a0b85c44..326481b3bd 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: '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' }, { name: 'Laptop', width: 1366, height: 768, icon: 'i-ph:laptop' }, + { name: 'Large Laptop', width: 1440, height: 900, icon: 'i-ph:laptop' }, { name: 'Desktop', width: 1920, height: 1080, icon: 'i-ph:monitor' }, + { name: '4K Display', width: 3840, height: 2160, icon: 'i-ph:monitor' }, ]; 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, @@ -52,11 +77,15 @@ export const Preview = memo(() => { windowWidth: window.innerWidth, }); - 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); + useEffect(() => { if (!activePreview) { setUrl(''); @@ -170,9 +199,16 @@ export const Preview = memo(() => { newWidthPercent = resizingState.current.startWidthPercent - dxPercent; } + // Limit width percentage between 10% and 90% newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90)); setWidthPercent(newWidthPercent); + + // Calculate and update the actual pixel width + if (containerRef.current) { + const containerWidth = containerRef.current.clientWidth; + setCurrentWidth(Math.round((containerWidth * newWidthPercent) / 100)); + } }; const onMouseUp = () => { @@ -186,15 +222,36 @@ export const Preview = memo(() => { 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) { + // 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 more reliable approach by using a wrapper page + if (showDeviceFrame && size.hasFrame) { + // Calculate frame dimensions + const frameWidth = size.frameType === 'mobile' ? 40 : 60; // 20px or 30px on each side + const frameHeight = size.frameType === 'mobile' ? 80 : 100; // 40px or 50px 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 = '#111'; + const frameRadius = size.frameType === 'mobile' ? '36px' : '20px'; + const framePadding = size.frameType === 'mobile' ? '40px 20px' : '50px 30px'; + + // Position notch and home button based on orientation + const notchTop = isLandscape ? '50%' : '20px'; + const notchLeft = isLandscape ? '20px' : '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 ? '15px' : '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(); + newWindow.focus(); + } 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); @@ -328,7 +559,50 @@ export const Preview = memo(() => { {isWindowSizeDropdownOpen && ( <>
setIsWindowSizeDropdownOpen(false)} /> -
+
+
+
+ Device Options +
+
+
+ Show Device Frame + +
+
+ Landscape Mode + +
+
+
{WINDOW_SIZES.map((size) => (
@@ -394,6 +671,26 @@ export const Preview = memo(() => { {isDeviceModeOn && ( <> + {/* Width indicator */} +
+ {currentWidth}px +
+
startResizing(e, 'left')} style={{ @@ -401,18 +698,23 @@ export const Preview = memo(() => { top: 0, left: 0, width: '15px', - marginLeft: '-15px', + marginLeft: '-7px', // Move handle closer to the edge height: '100%', cursor: 'ew-resize', - background: 'rgba(255,255,255,.2)', + background: 'var(--bolt-elements-background-depth-3, rgba(0,0,0,.15))', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background 0.2s', userSelect: 'none', + zIndex: 10, }} - onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')} - onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')} + 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" > @@ -425,18 +727,23 @@ export const Preview = memo(() => { top: 0, right: 0, width: '15px', - marginRight: '-15px', + marginRight: '-7px', // Move handle closer to the edge height: '100%', cursor: 'ew-resize', - background: 'rgba(255,255,255,.2)', + background: 'var(--bolt-elements-background-depth-3, rgba(0,0,0,.15))', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background 0.2s', userSelect: 'none', + zIndex: 10, }} - onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')} - onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')} + 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" > From 7c08583b48e518abe30686690216a599d21346db Mon Sep 17 00:00:00 2001 From: Stijnus <72551117+Stijnus@users.noreply.github.com> Date: Sat, 8 Mar 2025 16:02:45 +0100 Subject: [PATCH 2/2] Update Preview.tsx --- app/components/workbench/Preview.tsx | 256 +++++++++++++-------------- 1 file changed, 124 insertions(+), 132 deletions(-) diff --git a/app/components/workbench/Preview.tsx b/app/components/workbench/Preview.tsx index 326481b3bd..7b80da9179 100644 --- a/app/components/workbench/Preview.tsx +++ b/app/components/workbench/Preview.tsx @@ -38,11 +38,11 @@ const WINDOW_SIZES: WindowSize[] = [ hasFrame: true, frameType: 'tablet', }, - { name: 'Small Laptop', width: 1280, height: 800, icon: 'i-ph:laptop' }, - { name: 'Laptop', width: 1366, height: 768, icon: 'i-ph:laptop' }, - { name: 'Large Laptop', width: 1440, height: 900, icon: 'i-ph:laptop' }, - { name: 'Desktop', width: 1920, height: 1080, icon: 'i-ph:monitor' }, - { name: '4K Display', width: 3840, height: 2160, icon: 'i-ph:monitor' }, + { 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(() => { @@ -75,6 +75,7 @@ export const Preview = memo(() => { startX: 0, startWidthPercent: 37.5, windowWidth: window.innerWidth, + pointerId: null as number | null, }); // Reduce scaling factor to make resizing less sensitive @@ -82,7 +83,6 @@ export const Preview = memo(() => { const [isWindowSizeDropdownOpen, setIsWindowSizeDropdownOpen] = useState(false); const [selectedWindowSize, setSelectedWindowSize] = useState(WINDOW_SIZES[0]); - const [isLandscape, setIsLandscape] = useState(false); const [showDeviceFrame, setShowDeviceFrame] = useState(true); @@ -162,63 +162,139 @@ export const Preview = memo(() => { setIsDeviceModeOn((prev) => !prev); }; - const startResizing = (e: React.MouseEvent, side: ResizeSide) => { + const startResizing = (e: React.PointerEvent, 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; + const target = e.currentTarget as HTMLElement; + target.setPointerCapture(e.pointerId); - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); + 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, + }; e.preventDefault(); }; - 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; + 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-3, rgba(0,0,0,.15))', + 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" + > + +
+ ); + }; - const dxPercent = (dx / windowWidth) * 100 * SCALING_FACTOR; + useEffect(() => { + const handlePointerMove = (e: PointerEvent) => { + const state = resizingState.current; - let newWidthPercent = resizingState.current.startWidthPercent; + if (!state.isResizing || e.pointerId !== state.pointerId) { + return; + } - if (resizingState.current.side === 'right') { - newWidthPercent = resizingState.current.startWidthPercent + dxPercent; - } else if (resizingState.current.side === 'left') { - newWidthPercent = resizingState.current.startWidthPercent - dxPercent; - } + const dx = e.clientX - state.startX; + const dxPercent = (dx / state.windowWidth) * 100 * SCALING_FACTOR; - // Limit width percentage between 10% and 90% - newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90)); + let newWidthPercent = state.startWidthPercent; - setWidthPercent(newWidthPercent); + if (state.side === 'right') { + newWidthPercent = state.startWidthPercent + dxPercent; + } else if (state.side === 'left') { + newWidthPercent = state.startWidthPercent - dxPercent; + } - // Calculate and update the actual pixel width - if (containerRef.current) { - const containerWidth = containerRef.current.clientWidth; - setCurrentWidth(Math.round((containerWidth * newWidthPercent) / 100)); - } - }; + // Limit width percentage between 10% and 90% + newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90)); - const onMouseUp = () => { - resizingState.current.isResizing = false; - resizingState.current.side = null; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); + setWidthPercent(newWidthPercent); - document.body.style.userSelect = ''; - }; + // Calculate and update the actual pixel width + if (containerRef.current) { + const containerWidth = containerRef.current.clientWidth; + setCurrentWidth(Math.round((containerWidth * newWidthPercent) / 100)); + } + + e.preventDefault(); + }; + + const handlePointerUp = (e: PointerEvent) => { + const state = resizingState.current; + + if (e.pointerId !== state.pointerId) { + return; + } + + const target = e.target as HTMLElement; + + if (target.hasPointerCapture(e.pointerId)) { + target.releasePointerCapture(e.pointerId); + } + + resizingState.current = { + ...resizingState.current, + isResizing: false, + side: null, + pointerId: null, + }; + + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + + e.preventDefault(); + }; + + if (resizingState.current.isResizing) { + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + document.addEventListener('pointercancel', handlePointerUp); + + return () => { + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + document.removeEventListener('pointercancel', handlePointerUp); + }; + } + + return undefined; + }, [SCALING_FACTOR]); useEffect(() => { const handleWindowResize = () => { @@ -295,7 +371,7 @@ export const Preview = memo(() => { height = size.width; } - // Create a more reliable approach by using a wrapper page + // Create a window with device frame if enabled if (showDeviceFrame && size.hasFrame) { // Calculate frame dimensions const frameWidth = size.frameType === 'mobile' ? 40 : 60; // 20px or 30px on each side @@ -409,38 +485,11 @@ export const Preview = memo(() => { background: white; display: block; } - - .controls { - position: absolute; - top: -60px; - left: 0; - right: 0; - display: flex; - justify-content: center; - gap: 10px; - } - - .button { - background: #6D28D9; - color: white; - border: none; - border-radius: 4px; - padding: 4px 10px; - font-size: 12px; - cursor: pointer; - transition: background 0.2s; - } - - .button:hover { - background: #5b21b6; - }
-
-
${size.name} ${isLandscape ? '(Landscape)' : '(Portrait)'}
-
+
${size.name} ${isLandscape ? '(Landscape)' : '(Portrait)'}
@@ -453,8 +502,6 @@ export const Preview = memo(() => { newWindow.document.open(); newWindow.document.write(htmlContent); newWindow.document.close(); - - newWindow.focus(); } else { // Standard window without frame const newWindow = window.open( @@ -691,63 +738,8 @@ export const Preview = memo(() => { {currentWidth}px
-
startResizing(e, 'left')} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '15px', - marginLeft: '-7px', // Move handle closer to the edge - height: '100%', - cursor: 'ew-resize', - background: 'var(--bolt-elements-background-depth-3, rgba(0,0,0,.15))', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - transition: 'background 0.2s', - userSelect: '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" - > - -
- -
startResizing(e, 'right')} - style={{ - position: 'absolute', - top: 0, - right: 0, - width: '15px', - marginRight: '-7px', // Move handle closer to the edge - height: '100%', - cursor: 'ew-resize', - background: 'var(--bolt-elements-background-depth-3, rgba(0,0,0,.15))', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - transition: 'background 0.2s', - userSelect: '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" - > - -
+ + )}