mirror of
https://github.com/stackblitz/bolt.new
synced 2024-11-27 14:32:46 +00:00
feat: add dropdown to select preview port (#17)
This commit is contained in:
parent
5f06f508cd
commit
f55b4e5ab9
83
packages/bolt/app/components/workbench/PortDropdown.tsx
Normal file
83
packages/bolt/app/components/workbench/PortDropdown.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { memo, useEffect, useRef } from 'react';
|
||||||
|
import { IconButton } from '~/components/ui/IconButton';
|
||||||
|
import type { PreviewInfo } from '~/lib/stores/previews';
|
||||||
|
|
||||||
|
interface PortDropdownProps {
|
||||||
|
activePreviewIndex: number;
|
||||||
|
setActivePreviewIndex: (index: number) => void;
|
||||||
|
isDropdownOpen: boolean;
|
||||||
|
setIsDropdownOpen: (value: boolean) => void;
|
||||||
|
setHasSelectedPreview: (value: boolean) => void;
|
||||||
|
previews: PreviewInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PortDropdown = memo(
|
||||||
|
({
|
||||||
|
activePreviewIndex,
|
||||||
|
setActivePreviewIndex,
|
||||||
|
isDropdownOpen,
|
||||||
|
setIsDropdownOpen,
|
||||||
|
setHasSelectedPreview,
|
||||||
|
previews,
|
||||||
|
}: PortDropdownProps) => {
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// sort previews, preserving original index
|
||||||
|
const sortedPreviews = previews
|
||||||
|
.map((previewInfo, index) => ({ ...previewInfo, index }))
|
||||||
|
.sort((a, b) => a.port - b.port);
|
||||||
|
|
||||||
|
// close dropdown if user clicks outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDropdownOpen) {
|
||||||
|
window.addEventListener('mousedown', handleClickOutside);
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isDropdownOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-port-dropdown" ref={dropdownRef}>
|
||||||
|
<IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded shadow-sm min-w-[140px] dropdown-animation">
|
||||||
|
<div className="px-4 py-2 border-b border-bolt-elements-borderColor text-sm font-semibold text-bolt-elements-textPrimary">
|
||||||
|
Ports
|
||||||
|
</div>
|
||||||
|
{sortedPreviews.map((preview) => (
|
||||||
|
<div
|
||||||
|
key={preview.port}
|
||||||
|
className="flex items-center px-4 py-2 cursor-pointer hover:bg-bolt-elements-item-backgroundActive"
|
||||||
|
onClick={() => {
|
||||||
|
setActivePreviewIndex(preview.index);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
setHasSelectedPreview(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
activePreviewIndex === preview.index
|
||||||
|
? 'text-bolt-elements-item-contentAccent'
|
||||||
|
: 'text-bolt-elements-item-contentDefault group-hover:text-bolt-elements-item-contentActive'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{preview.port}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
@ -2,11 +2,14 @@ import { useStore } from '@nanostores/react';
|
|||||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { IconButton } from '~/components/ui/IconButton';
|
import { IconButton } from '~/components/ui/IconButton';
|
||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
import { workbenchStore } from '~/lib/stores/workbench';
|
||||||
|
import { PortDropdown } from './PortDropdown';
|
||||||
|
|
||||||
export const Preview = memo(() => {
|
export const Preview = memo(() => {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [activePreviewIndex] = useState(0);
|
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
||||||
|
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
||||||
|
const hasSelectedPreview = useRef(false);
|
||||||
const previews = useStore(workbenchStore.previews);
|
const previews = useStore(workbenchStore.previews);
|
||||||
const activePreview = previews[activePreviewIndex];
|
const activePreview = previews[activePreviewIndex];
|
||||||
|
|
||||||
@ -21,12 +24,10 @@ export const Preview = memo(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!iframeUrl) {
|
const { baseUrl } = activePreview;
|
||||||
const { baseUrl } = activePreview;
|
|
||||||
|
|
||||||
setUrl(baseUrl);
|
setUrl(baseUrl);
|
||||||
setIframeUrl(baseUrl);
|
setIframeUrl(baseUrl);
|
||||||
}
|
|
||||||
}, [activePreview, iframeUrl]);
|
}, [activePreview, iframeUrl]);
|
||||||
|
|
||||||
const validateUrl = useCallback(
|
const validateUrl = useCallback(
|
||||||
@ -48,6 +49,22 @@ export const Preview = memo(() => {
|
|||||||
[activePreview],
|
[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]);
|
||||||
|
|
||||||
const reloadPreview = () => {
|
const reloadPreview = () => {
|
||||||
if (iframeRef.current) {
|
if (iframeRef.current) {
|
||||||
iframeRef.current.src = iframeRef.current.src;
|
iframeRef.current.src = iframeRef.current.src;
|
||||||
@ -56,6 +73,9 @@ export const Preview = memo(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col">
|
<div className="w-full h-full flex flex-col">
|
||||||
|
{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-1.5">
|
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
|
||||||
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
||||||
<div
|
<div
|
||||||
@ -81,6 +101,16 @@ export const Preview = memo(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{previews.length > 1 && (
|
||||||
|
<PortDropdown
|
||||||
|
activePreviewIndex={activePreviewIndex}
|
||||||
|
setActivePreviewIndex={setActivePreviewIndex}
|
||||||
|
isDropdownOpen={isPortDropdownOpen}
|
||||||
|
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
|
||||||
|
setIsDropdownOpen={setIsPortDropdownOpen}
|
||||||
|
previews={previews}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 border-t border-bolt-elements-borderColor">
|
<div className="flex-1 border-t border-bolt-elements-borderColor">
|
||||||
{activePreview ? (
|
{activePreview ? (
|
||||||
|
@ -34,3 +34,16 @@
|
|||||||
transform: translate3d(100%, 0, 0);
|
transform: translate3d(100%, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-animation {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeMoveDown 0.15s forwards;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeMoveDown {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -8,6 +8,14 @@ $zIndexMax: 999;
|
|||||||
z-index: $zIndexMax - 2;
|
z-index: $zIndexMax - 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.z-port-dropdown {
|
||||||
|
z-index: $zIndexMax - 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-iframe-overlay {
|
||||||
|
z-index: $zIndexMax - 4;
|
||||||
|
}
|
||||||
|
|
||||||
.z-prompt {
|
.z-prompt {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user