From 61d633851afa4bf7fe10beed769fc80b09309949 Mon Sep 17 00:00:00 2001 From: Dustin Loring Date: Sat, 18 Jan 2025 13:59:14 -0500 Subject: [PATCH] feat: improved popout improved popout options --- app/components/workbench/Preview.tsx | 410 ++++++++++++++++++++++----- 1 file changed, 338 insertions(+), 72 deletions(-) diff --git a/app/components/workbench/Preview.tsx b/app/components/workbench/Preview.tsx index abf44e5..a1633f4 100644 --- a/app/components/workbench/Preview.tsx +++ b/app/components/workbench/Preview.tsx @@ -1,14 +1,34 @@ -import { useStore } from '@nanostores/react'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; -import { PortDropdown } from './PortDropdown'; +import { useStore } from '@nanostores/react'; import { IconButton } from '~/components/ui/IconButton'; import { workbenchStore } from '~/lib/stores/workbench'; +import { PortDropdown } from './PortDropdown'; + +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]; @@ -16,6 +36,25 @@ export const Preview = memo(() => { const [url, setUrl] = useState(''); const [iframeUrl, setIframeUrl] = useState(); + // 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(''); @@ -25,10 +64,9 @@ export const Preview = memo(() => { } const { baseUrl } = activePreview; - setUrl(baseUrl); setIframeUrl(baseUrl); - }, [activePreview, iframeUrl]); + }, [activePreview]); const validateUrl = useCallback( (value: string) => { @@ -56,14 +94,12 @@ export const Preview = memo(() => { [], ); - // 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]); + }, [previews, findMinPortIndex]); const reloadPreview = () => { if (iframeRef.current) { @@ -71,62 +107,155 @@ export const Preview = memo(() => { } }; + 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)} /> )} -
- - { - console.log('Button clicked'); +
+
+ +
- if (activePreview?.baseUrl) { - console.log('Active preview baseUrl:', activePreview.baseUrl); - - // extract the preview ID from the WebContainer URL - const match = activePreview.baseUrl.match( - /^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/, - ); - - console.log('URL match:', match); - - if (match) { - const previewId = match[1]; - console.log('Preview ID:', previewId); - - /** - * Open in new tab using our route with absolute path. - * Use the current port for development. - */ - const port = window.location.port ? `:${window.location.port}` : ''; - const previewUrl = `${window.location.protocol}//${window.location.hostname}${port}/webcontainer/preview/${previewId}`; - console.log('Opening URL:', previewUrl); - - const newWindow = window.open(previewUrl, '_blank', 'noopener,noreferrer'); - - // force focus on the new window - if (newWindow) { - newWindow.focus(); - } else { - console.warn('Failed to open new window'); - } - } else { - console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl); - } - } else { - console.warn('No active preview available'); - } - }} - title="Open Preview in New Tab" - /> -
+
{ }} />
- {previews.length > 1 && ( - (hasSelectedPreview.current = value)} - setIsDropdownOpen={setIsPortDropdownOpen} - previews={previews} + +
+ {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 ? ( -