Dominic Elm commited on
refactor(workbench): add slider to switch between code or preview (#12)
Browse files
packages/bolt/app/components/ui/Slider.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from 'framer-motion';
|
| 2 |
+
import { memo } from 'react';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import { genericMemo } from '~/utils/react';
|
| 5 |
+
|
| 6 |
+
interface SliderOption<T> {
|
| 7 |
+
value: T;
|
| 8 |
+
text: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export interface SliderOptions<T> {
|
| 12 |
+
left: SliderOption<T>;
|
| 13 |
+
right: SliderOption<T>;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface SliderProps<T> {
|
| 17 |
+
selected: T;
|
| 18 |
+
options: SliderOptions<T>;
|
| 19 |
+
setSelected?: (selected: T) => void;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
|
| 23 |
+
const isLeftSelected = selected === options.left.value;
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="flex items-center flex-wrap gap-1 border rounded-lg p-1">
|
| 27 |
+
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
|
| 28 |
+
{options.left.text}
|
| 29 |
+
</SliderButton>
|
| 30 |
+
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
|
| 31 |
+
{options.right.text}
|
| 32 |
+
</SliderButton>
|
| 33 |
+
</div>
|
| 34 |
+
);
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
interface SliderButtonProps {
|
| 38 |
+
selected: boolean;
|
| 39 |
+
children: string | JSX.Element | Array<JSX.Element | string>;
|
| 40 |
+
setSelected: () => void;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const SliderButton = memo(({ selected, children, setSelected }: SliderButtonProps) => {
|
| 44 |
+
return (
|
| 45 |
+
<button
|
| 46 |
+
onClick={setSelected}
|
| 47 |
+
className={classNames(
|
| 48 |
+
'bg-transparent text-sm transition-colors px-2.5 py-0.5 rounded-md relative',
|
| 49 |
+
selected ? 'text-white' : 'text-gray-600 hover:text-accent-600 hover:bg-accent-600/10',
|
| 50 |
+
)}
|
| 51 |
+
>
|
| 52 |
+
<span className="relative z-10">{children}</span>
|
| 53 |
+
{selected && (
|
| 54 |
+
<motion.span
|
| 55 |
+
layoutId="pill-tab"
|
| 56 |
+
transition={{ type: 'spring', duration: 0.5 }}
|
| 57 |
+
className="absolute inset-0 z-0 bg-accent-600 rounded-md"
|
| 58 |
+
></motion.span>
|
| 59 |
+
)}
|
| 60 |
+
</button>
|
| 61 |
+
);
|
| 62 |
+
});
|
packages/bolt/app/components/workbench/Preview.tsx
CHANGED
|
@@ -36,13 +36,6 @@ export const Preview = memo(() => {
|
|
| 36 |
|
| 37 |
return (
|
| 38 |
<div className="w-full h-full flex flex-col">
|
| 39 |
-
<div className="bg-gray-100 rounded-t-lg p-2 flex items-center space-x-1.5">
|
| 40 |
-
<div className="flex items-center gap-2 text-gray-800">
|
| 41 |
-
<div className="i-ph:app-window-duotone scale-130 ml-1.5" />
|
| 42 |
-
<span className="text-sm">Preview</span>
|
| 43 |
-
</div>
|
| 44 |
-
<div className="flex-grow" />
|
| 45 |
-
</div>
|
| 46 |
<div className="bg-white p-2 flex items-center gap-1.5">
|
| 47 |
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
| 48 |
<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">
|
|
|
|
| 36 |
|
| 37 |
return (
|
| 38 |
<div className="w-full h-full flex flex-col">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
<div className="bg-white p-2 flex items-center gap-1.5">
|
| 40 |
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
| 41 |
<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">
|
packages/bolt/app/components/workbench/Workbench.client.tsx
CHANGED
|
@@ -1,13 +1,14 @@
|
|
| 1 |
import { useStore } from '@nanostores/react';
|
| 2 |
-
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
| 3 |
-
import {
|
| 4 |
-
import {
|
| 5 |
import { toast } from 'react-toastify';
|
| 6 |
import {
|
| 7 |
type OnChangeCallback as OnEditorChange,
|
| 8 |
type OnScrollCallback as OnEditorScroll,
|
| 9 |
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
| 10 |
import { IconButton } from '~/components/ui/IconButton';
|
|
|
|
| 11 |
import { workbenchStore } from '~/lib/stores/workbench';
|
| 12 |
import { cubicEasingFn } from '~/utils/easings';
|
| 13 |
import { renderLogger } from '~/utils/logger';
|
|
@@ -19,6 +20,21 @@ interface WorkspaceProps {
|
|
| 19 |
isStreaming?: boolean;
|
| 20 |
}
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
const workbenchVariants = {
|
| 23 |
closed: {
|
| 24 |
width: 0,
|
|
@@ -39,13 +55,21 @@ const workbenchVariants = {
|
|
| 39 |
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
| 40 |
renderLogger.trace('Workbench');
|
| 41 |
|
|
|
|
| 42 |
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
| 43 |
const selectedFile = useStore(workbenchStore.selectedFile);
|
| 44 |
const currentDocument = useStore(workbenchStore.currentDocument);
|
| 45 |
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
| 46 |
-
|
| 47 |
const files = useStore(workbenchStore.files);
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
useEffect(() => {
|
| 50 |
workbenchStore.setDocuments(files);
|
| 51 |
}, [files]);
|
|
@@ -79,7 +103,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|
| 79 |
<motion.div initial="closed" animate="open" exit="closed" variants={workbenchVariants}>
|
| 80 |
<div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-[calc(1.5rem-1px)] w-[50vw] mr-4 z-0">
|
| 81 |
<div className="flex flex-col bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8">
|
| 82 |
-
<div className="px-3 py-2 border-b border-gray-200">
|
|
|
|
| 83 |
<IconButton
|
| 84 |
icon="i-ph:x-circle"
|
| 85 |
className="ml-auto -mr-1"
|
|
@@ -89,27 +114,30 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|
| 89 |
}}
|
| 90 |
/>
|
| 91 |
</div>
|
| 92 |
-
<div className="flex-1 overflow-hidden">
|
| 93 |
-
<
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
| 113 |
</div>
|
| 114 |
</div>
|
| 115 |
</div>
|
|
@@ -119,3 +147,15 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|
| 119 |
)
|
| 120 |
);
|
| 121 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useStore } from '@nanostores/react';
|
| 2 |
+
import { AnimatePresence, motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
| 3 |
+
import { computed } from 'nanostores';
|
| 4 |
+
import { memo, useCallback, useEffect, useState } from 'react';
|
| 5 |
import { toast } from 'react-toastify';
|
| 6 |
import {
|
| 7 |
type OnChangeCallback as OnEditorChange,
|
| 8 |
type OnScrollCallback as OnEditorScroll,
|
| 9 |
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
| 10 |
import { IconButton } from '~/components/ui/IconButton';
|
| 11 |
+
import { Slider, type SliderOptions } from '~/components/ui/Slider';
|
| 12 |
import { workbenchStore } from '~/lib/stores/workbench';
|
| 13 |
import { cubicEasingFn } from '~/utils/easings';
|
| 14 |
import { renderLogger } from '~/utils/logger';
|
|
|
|
| 20 |
isStreaming?: boolean;
|
| 21 |
}
|
| 22 |
|
| 23 |
+
type ViewType = 'code' | 'preview';
|
| 24 |
+
|
| 25 |
+
const viewTransition = { ease: cubicEasingFn };
|
| 26 |
+
|
| 27 |
+
const sliderOptions: SliderOptions<ViewType> = {
|
| 28 |
+
left: {
|
| 29 |
+
value: 'code',
|
| 30 |
+
text: 'Code',
|
| 31 |
+
},
|
| 32 |
+
right: {
|
| 33 |
+
value: 'preview',
|
| 34 |
+
text: 'Preview',
|
| 35 |
+
},
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
const workbenchVariants = {
|
| 39 |
closed: {
|
| 40 |
width: 0,
|
|
|
|
| 55 |
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
| 56 |
renderLogger.trace('Workbench');
|
| 57 |
|
| 58 |
+
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
|
| 59 |
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
| 60 |
const selectedFile = useStore(workbenchStore.selectedFile);
|
| 61 |
const currentDocument = useStore(workbenchStore.currentDocument);
|
| 62 |
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
|
|
|
| 63 |
const files = useStore(workbenchStore.files);
|
| 64 |
|
| 65 |
+
const [selectedView, setSelectedView] = useState<ViewType>(hasPreview ? 'preview' : 'code');
|
| 66 |
+
|
| 67 |
+
useEffect(() => {
|
| 68 |
+
if (hasPreview) {
|
| 69 |
+
setSelectedView('preview');
|
| 70 |
+
}
|
| 71 |
+
}, [hasPreview]);
|
| 72 |
+
|
| 73 |
useEffect(() => {
|
| 74 |
workbenchStore.setDocuments(files);
|
| 75 |
}, [files]);
|
|
|
|
| 103 |
<motion.div initial="closed" animate="open" exit="closed" variants={workbenchVariants}>
|
| 104 |
<div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-[calc(1.5rem-1px)] w-[50vw] mr-4 z-0">
|
| 105 |
<div className="flex flex-col bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8">
|
| 106 |
+
<div className="flex items-center px-3 py-2 border-b border-gray-200">
|
| 107 |
+
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
|
| 108 |
<IconButton
|
| 109 |
icon="i-ph:x-circle"
|
| 110 |
className="ml-auto -mr-1"
|
|
|
|
| 114 |
}}
|
| 115 |
/>
|
| 116 |
</div>
|
| 117 |
+
<div className="relative flex-1 overflow-hidden">
|
| 118 |
+
<View
|
| 119 |
+
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
| 120 |
+
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
| 121 |
+
>
|
| 122 |
+
<EditorPanel
|
| 123 |
+
editorDocument={currentDocument}
|
| 124 |
+
isStreaming={isStreaming}
|
| 125 |
+
selectedFile={selectedFile}
|
| 126 |
+
files={files}
|
| 127 |
+
unsavedFiles={unsavedFiles}
|
| 128 |
+
onFileSelect={onFileSelect}
|
| 129 |
+
onEditorScroll={onEditorScroll}
|
| 130 |
+
onEditorChange={onEditorChange}
|
| 131 |
+
onFileSave={onFileSave}
|
| 132 |
+
onFileReset={onFileReset}
|
| 133 |
+
/>
|
| 134 |
+
</View>
|
| 135 |
+
<View
|
| 136 |
+
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
| 137 |
+
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
| 138 |
+
>
|
| 139 |
+
<Preview />
|
| 140 |
+
</View>
|
| 141 |
</div>
|
| 142 |
</div>
|
| 143 |
</div>
|
|
|
|
| 147 |
)
|
| 148 |
);
|
| 149 |
});
|
| 150 |
+
|
| 151 |
+
interface ViewProps extends HTMLMotionProps<'div'> {
|
| 152 |
+
children: JSX.Element;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const View = memo(({ children, ...props }: ViewProps) => {
|
| 156 |
+
return (
|
| 157 |
+
<motion.div className="absolute inset-0" transition={viewTransition} {...props}>
|
| 158 |
+
{children}
|
| 159 |
+
</motion.div>
|
| 160 |
+
);
|
| 161 |
+
});
|
packages/bolt/app/utils/react.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from 'react';
|
| 2 |
+
|
| 3 |
+
export const genericMemo: <T extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>>(
|
| 4 |
+
component: T,
|
| 5 |
+
propsAreEqual?: (prevProps: React.ComponentProps<T>, nextProps: React.ComponentProps<T>) => boolean,
|
| 6 |
+
) => T & { displayName?: string } = memo;
|