mirror of
https://github.com/stackblitz/bolt.new
synced 2024-11-27 14:32:46 +00:00
refactor(workbench): add slider to switch between code or preview (#12)
This commit is contained in:
parent
5db834e2f7
commit
a5ed695cb3
62
packages/bolt/app/components/ui/Slider.tsx
Normal file
62
packages/bolt/app/components/ui/Slider.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { memo } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { genericMemo } from '~/utils/react';
|
||||
|
||||
interface SliderOption<T> {
|
||||
value: T;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SliderOptions<T> {
|
||||
left: SliderOption<T>;
|
||||
right: SliderOption<T>;
|
||||
}
|
||||
|
||||
interface SliderProps<T> {
|
||||
selected: T;
|
||||
options: SliderOptions<T>;
|
||||
setSelected?: (selected: T) => void;
|
||||
}
|
||||
|
||||
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
|
||||
const isLeftSelected = selected === options.left.value;
|
||||
|
||||
return (
|
||||
<div className="flex items-center flex-wrap gap-1 border rounded-lg p-1">
|
||||
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
|
||||
{options.left.text}
|
||||
</SliderButton>
|
||||
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
|
||||
{options.right.text}
|
||||
</SliderButton>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface SliderButtonProps {
|
||||
selected: boolean;
|
||||
children: string | JSX.Element | Array<JSX.Element | string>;
|
||||
setSelected: () => void;
|
||||
}
|
||||
|
||||
const SliderButton = memo(({ selected, children, setSelected }: SliderButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
onClick={setSelected}
|
||||
className={classNames(
|
||||
'bg-transparent text-sm transition-colors px-2.5 py-0.5 rounded-md relative',
|
||||
selected ? 'text-white' : 'text-gray-600 hover:text-accent-600 hover:bg-accent-600/10',
|
||||
)}
|
||||
>
|
||||
<span className="relative z-10">{children}</span>
|
||||
{selected && (
|
||||
<motion.span
|
||||
layoutId="pill-tab"
|
||||
transition={{ type: 'spring', duration: 0.5 }}
|
||||
className="absolute inset-0 z-0 bg-accent-600 rounded-md"
|
||||
></motion.span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
@ -36,13 +36,6 @@ export const Preview = memo(() => {
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div className="bg-gray-100 rounded-t-lg p-2 flex items-center space-x-1.5">
|
||||
<div className="flex items-center gap-2 text-gray-800">
|
||||
<div className="i-ph:app-window-duotone scale-130 ml-1.5" />
|
||||
<span className="text-sm">Preview</span>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
</div>
|
||||
<div className="bg-white p-2 flex items-center gap-1.5">
|
||||
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
||||
<div className="flex items-center gap-1 flex-grow bg-gray-100 rounded-full px-3 py-1 text-sm text-gray-600 hover:bg-gray-200 hover:focus-within:bg-white focus-within:bg-white focus-within:ring-2 focus-within:ring-accent">
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
||||
import { memo, useCallback, useEffect } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
||||
import { computed } from 'nanostores';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
type OnChangeCallback as OnEditorChange,
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { Slider, type SliderOptions } from '~/components/ui/Slider';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
@ -19,6 +20,21 @@ interface WorkspaceProps {
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
type ViewType = 'code' | 'preview';
|
||||
|
||||
const viewTransition = { ease: cubicEasingFn };
|
||||
|
||||
const sliderOptions: SliderOptions<ViewType> = {
|
||||
left: {
|
||||
value: 'code',
|
||||
text: 'Code',
|
||||
},
|
||||
right: {
|
||||
value: 'preview',
|
||||
text: 'Preview',
|
||||
},
|
||||
};
|
||||
|
||||
const workbenchVariants = {
|
||||
closed: {
|
||||
width: 0,
|
||||
@ -39,13 +55,21 @@ const workbenchVariants = {
|
||||
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
||||
renderLogger.trace('Workbench');
|
||||
|
||||
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
const selectedFile = useStore(workbenchStore.selectedFile);
|
||||
const currentDocument = useStore(workbenchStore.currentDocument);
|
||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||
|
||||
const files = useStore(workbenchStore.files);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ViewType>(hasPreview ? 'preview' : 'code');
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPreview) {
|
||||
setSelectedView('preview');
|
||||
}
|
||||
}, [hasPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
workbenchStore.setDocuments(files);
|
||||
}, [files]);
|
||||
@ -79,7 +103,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
<motion.div initial="closed" animate="open" exit="closed" variants={workbenchVariants}>
|
||||
<div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-[calc(1.5rem-1px)] w-[50vw] mr-4 z-0">
|
||||
<div className="flex flex-col bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8">
|
||||
<div className="px-3 py-2 border-b border-gray-200">
|
||||
<div className="flex items-center px-3 py-2 border-b border-gray-200">
|
||||
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
|
||||
<IconButton
|
||||
icon="i-ph:x-circle"
|
||||
className="ml-auto -mr-1"
|
||||
@ -89,27 +114,30 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel defaultSize={50} minSize={20}>
|
||||
<EditorPanel
|
||||
editorDocument={currentDocument}
|
||||
isStreaming={isStreaming}
|
||||
selectedFile={selectedFile}
|
||||
files={files}
|
||||
unsavedFiles={unsavedFiles}
|
||||
onFileSelect={onFileSelect}
|
||||
onEditorScroll={onEditorScroll}
|
||||
onEditorChange={onEditorChange}
|
||||
onFileSave={onFileSave}
|
||||
onFileReset={onFileReset}
|
||||
/>
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel defaultSize={50} minSize={20}>
|
||||
<Preview />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<View
|
||||
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||
>
|
||||
<EditorPanel
|
||||
editorDocument={currentDocument}
|
||||
isStreaming={isStreaming}
|
||||
selectedFile={selectedFile}
|
||||
files={files}
|
||||
unsavedFiles={unsavedFiles}
|
||||
onFileSelect={onFileSelect}
|
||||
onEditorScroll={onEditorScroll}
|
||||
onEditorChange={onEditorChange}
|
||||
onFileSave={onFileSave}
|
||||
onFileReset={onFileReset}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||
>
|
||||
<Preview />
|
||||
</View>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -119,3 +147,15 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
interface ViewProps extends HTMLMotionProps<'div'> {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
const View = memo(({ children, ...props }: ViewProps) => {
|
||||
return (
|
||||
<motion.div className="absolute inset-0" transition={viewTransition} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
6
packages/bolt/app/utils/react.ts
Normal file
6
packages/bolt/app/utils/react.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export const genericMemo: <T extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>>(
|
||||
component: T,
|
||||
propsAreEqual?: (prevProps: React.ComponentProps<T>, nextProps: React.ComponentProps<T>) => boolean,
|
||||
) => T & { displayName?: string } = memo;
|
Loading…
Reference in New Issue
Block a user