mirror of
https://github.com/stackblitz/bolt.new
synced 2024-11-27 22:42:21 +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 (
|
return (
|
||||||
<div className="w-full h-full flex flex-col">
|
<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">
|
<div className="bg-white 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 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">
|
<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 { useStore } from '@nanostores/react';
|
||||||
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
import { AnimatePresence, motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
||||||
import { memo, useCallback, useEffect } from 'react';
|
import { computed } from 'nanostores';
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
import { memo, useCallback, useEffect, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import {
|
import {
|
||||||
type OnChangeCallback as OnEditorChange,
|
type OnChangeCallback as OnEditorChange,
|
||||||
type OnScrollCallback as OnEditorScroll,
|
type OnScrollCallback as OnEditorScroll,
|
||||||
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||||
import { IconButton } from '~/components/ui/IconButton';
|
import { IconButton } from '~/components/ui/IconButton';
|
||||||
|
import { Slider, type SliderOptions } from '~/components/ui/Slider';
|
||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
import { workbenchStore } from '~/lib/stores/workbench';
|
||||||
import { cubicEasingFn } from '~/utils/easings';
|
import { cubicEasingFn } from '~/utils/easings';
|
||||||
import { renderLogger } from '~/utils/logger';
|
import { renderLogger } from '~/utils/logger';
|
||||||
@ -19,6 +20,21 @@ interface WorkspaceProps {
|
|||||||
isStreaming?: boolean;
|
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 = {
|
const workbenchVariants = {
|
||||||
closed: {
|
closed: {
|
||||||
width: 0,
|
width: 0,
|
||||||
@ -39,13 +55,21 @@ const workbenchVariants = {
|
|||||||
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
||||||
renderLogger.trace('Workbench');
|
renderLogger.trace('Workbench');
|
||||||
|
|
||||||
|
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
|
||||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||||
const selectedFile = useStore(workbenchStore.selectedFile);
|
const selectedFile = useStore(workbenchStore.selectedFile);
|
||||||
const currentDocument = useStore(workbenchStore.currentDocument);
|
const currentDocument = useStore(workbenchStore.currentDocument);
|
||||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||||
|
|
||||||
const files = useStore(workbenchStore.files);
|
const files = useStore(workbenchStore.files);
|
||||||
|
|
||||||
|
const [selectedView, setSelectedView] = useState<ViewType>(hasPreview ? 'preview' : 'code');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasPreview) {
|
||||||
|
setSelectedView('preview');
|
||||||
|
}
|
||||||
|
}, [hasPreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
workbenchStore.setDocuments(files);
|
workbenchStore.setDocuments(files);
|
||||||
}, [files]);
|
}, [files]);
|
||||||
@ -79,7 +103,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|||||||
<motion.div initial="closed" animate="open" exit="closed" variants={workbenchVariants}>
|
<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="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="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
|
<IconButton
|
||||||
icon="i-ph:x-circle"
|
icon="i-ph:x-circle"
|
||||||
className="ml-auto -mr-1"
|
className="ml-auto -mr-1"
|
||||||
@ -89,27 +114,30 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
<PanelGroup direction="vertical">
|
<View
|
||||||
<Panel defaultSize={50} minSize={20}>
|
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||||
<EditorPanel
|
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||||
editorDocument={currentDocument}
|
>
|
||||||
isStreaming={isStreaming}
|
<EditorPanel
|
||||||
selectedFile={selectedFile}
|
editorDocument={currentDocument}
|
||||||
files={files}
|
isStreaming={isStreaming}
|
||||||
unsavedFiles={unsavedFiles}
|
selectedFile={selectedFile}
|
||||||
onFileSelect={onFileSelect}
|
files={files}
|
||||||
onEditorScroll={onEditorScroll}
|
unsavedFiles={unsavedFiles}
|
||||||
onEditorChange={onEditorChange}
|
onFileSelect={onFileSelect}
|
||||||
onFileSave={onFileSave}
|
onEditorScroll={onEditorScroll}
|
||||||
onFileReset={onFileReset}
|
onEditorChange={onEditorChange}
|
||||||
/>
|
onFileSave={onFileSave}
|
||||||
</Panel>
|
onFileReset={onFileReset}
|
||||||
<PanelResizeHandle />
|
/>
|
||||||
<Panel defaultSize={50} minSize={20}>
|
</View>
|
||||||
<Preview />
|
<View
|
||||||
</Panel>
|
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||||
</PanelGroup>
|
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||||
|
>
|
||||||
|
<Preview />
|
||||||
|
</View>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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