refactor(workbench): add slider to switch between code or preview (#12)

This commit is contained in:
Dominic Elm 2024-07-25 16:34:27 +02:00 committed by GitHub
parent 5db834e2f7
commit a5ed695cb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 134 additions and 33 deletions

View 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>
);
});

View File

@ -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">

View File

@ -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,9 +114,11 @@ 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%' }}
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
>
<EditorPanel <EditorPanel
editorDocument={currentDocument} editorDocument={currentDocument}
isStreaming={isStreaming} isStreaming={isStreaming}
@ -104,12 +131,13 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
onFileSave={onFileSave} onFileSave={onFileSave}
onFileReset={onFileReset} onFileReset={onFileReset}
/> />
</Panel> </View>
<PanelResizeHandle /> <View
<Panel defaultSize={50} minSize={20}> initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
>
<Preview /> <Preview />
</Panel> </View>
</PanelGroup>
</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>
);
});

View 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;