2025-01-18 19:25:01 +01:00

449 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
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<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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<string | undefined>();
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<number>(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<WindowSize>(WINDOW_SIZES[0]);
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.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 = () => (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
pointerEvents: 'none',
}}
>
<div
style={{
color: 'rgba(0,0,0,0.5)',
fontSize: '10px',
lineHeight: '5px',
userSelect: 'none',
marginLeft: '1px',
}}
>
</div>
</div>
);
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 (
<div
ref={containerRef}
className={`w-full h-full flex flex-col relative ${isPreviewOnly ? 'fixed inset-0 z-50 bg-white' : ''}`}
>
{isPortDropdownOpen && (
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
)}
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-2">
<div className="flex items-center gap-2">
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
<IconButton
icon="i-ph:selection"
onClick={() => setIsSelectionMode(!isSelectionMode)}
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
/>
</div>
<div className="flex-grow flex items-center gap-1 bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive">
<input
title="URL"
ref={inputRef}
className="w-full bg-transparent outline-none"
type="text"
value={url}
onChange={(event) => {
setUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && validateUrl(url)) {
setIframeUrl(url);
if (inputRef.current) {
inputRef.current.blur();
}
}
}}
/>
</div>
<div className="flex items-center gap-2">
{previews.length > 1 && (
<PortDropdown
activePreviewIndex={activePreviewIndex}
setActivePreviewIndex={setActivePreviewIndex}
isDropdownOpen={isPortDropdownOpen}
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
setIsDropdownOpen={setIsPortDropdownOpen}
previews={previews}
/>
)}
<IconButton
icon="i-ph:devices"
onClick={toggleDeviceMode}
title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
/>
<IconButton
icon="i-ph:layout-light"
onClick={() => setIsPreviewOnly(!isPreviewOnly)}
title={isPreviewOnly ? 'Show Full Interface' : 'Show Preview Only'}
/>
<IconButton
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
/>
<div className="flex items-center relative">
<IconButton
icon="i-ph:arrow-square-out"
onClick={() => openInNewWindow(selectedWindowSize)}
title={`Open Preview in ${selectedWindowSize.name} Window`}
/>
<IconButton
icon="i-ph:caret-down"
onClick={() => setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)}
className="ml-1"
title="Select Window Size"
/>
{isWindowSizeDropdownOpen && (
<>
<div className="fixed inset-0 z-50" onClick={() => setIsWindowSizeDropdownOpen(false)} />
<div className="absolute right-0 top-full mt-2 z-50 min-w-[240px] bg-bolt-elements-background-depth-2 rounded-xl shadow-2xl border border-bolt-elements-borderColor overflow-hidden">
{WINDOW_SIZES.map((size) => (
<button
key={size.name}
className="w-full px-4 py-3.5 text-left hover:bg-bolt-elements-item-backgroundAccent text-bolt-elements-textSecondary text-sm whitespace-nowrap transition-all duration-200 flex items-center gap-3 group"
onClick={() => {
setSelectedWindowSize(size);
setIsWindowSizeDropdownOpen(false);
openInNewWindow(size);
}}
>
<div
className={`${size.icon} w-5 h-5 text-bolt-elements-textSecondary group-hover:text-bolt-elements-item-contentAccent transition-colors duration-200`}
/>
<div className="flex flex-col">
<span className="font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors duration-200">
{size.name}
</span>
<span className="text-xs text-bolt-elements-textSecondary group-hover:text-bolt-elements-textPrimary transition-colors duration-200">
{size.width} × {size.height}
</span>
</div>
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
<div className="flex-1 border-t border-bolt-elements-borderColor flex justify-center items-center overflow-auto">
<div
style={{
width: isDeviceModeOn ? `${widthPercent}%` : '100%',
height: '100%',
overflow: 'visible',
background: '#fff',
position: 'relative',
display: 'flex',
}}
>
{activePreview ? (
<>
<iframe
ref={iframeRef}
title="preview"
className="border-none w-full h-full bg-white"
src={iframeUrl}
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
allow="cross-origin-isolated"
/>
<ScreenshotSelector
isSelectionMode={isSelectionMode}
setIsSelectionMode={setIsSelectionMode}
containerRef={iframeRef}
/>
</>
) : (
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
)}
{isDeviceModeOn && (
<>
<div
onMouseDown={(e) => startResizing(e, 'left')}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '15px',
marginLeft: '-15px',
height: '100%',
cursor: 'ew-resize',
background: 'rgba(255,255,255,.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s',
userSelect: 'none',
}}
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
title="Drag to resize width"
>
<GripIcon />
</div>
<div
onMouseDown={(e) => startResizing(e, 'right')}
style={{
position: 'absolute',
top: 0,
right: 0,
width: '15px',
marginRight: '-15px',
height: '100%',
cursor: 'ew-resize',
background: 'rgba(255,255,255,.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s',
userSelect: 'none',
}}
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
title="Drag to resize width"
>
<GripIcon />
</div>
</>
)}
</div>
</div>
</div>
);
});