import { Attachment, ChatRequestOptions, CreateMessage, Message } from 'ai'; import cx from 'classnames'; import { formatDistance, isAfter } from 'date-fns'; import { AnimatePresence, motion } from 'framer-motion'; import { Dispatch, SetStateAction, useCallback, useEffect, useState, } from 'react'; import useSWR, { useSWRConfig } from 'swr'; import { Document, Suggestion } from '@/db/schema'; import { fetcher } from '@/lib/utils'; import { DiffView } from './diffview'; import { DocumentSkeleton } from './document-skeleton'; import { Editor } from './editor'; import { CrossIcon, DeltaIcon, RedoIcon, UndoIcon } from './icons'; import { Message as PreviewMessage } from './message'; import { MultimodalInput } from './multimodal-input'; import { Toolbar } from './toolbar'; import { useDebounce } from './use-debounce'; import { useScrollToBottom } from './use-scroll-to-bottom'; import useWindowSize from './use-window-size'; import { Button } from '../ui/button'; export interface UICanvas { title: string; documentId: string; content: string; isVisible: boolean; status: 'streaming' | 'idle'; boundingBox: { top: number; left: number; width: number; height: number; }; } export function Canvas({ input, setInput, handleSubmit, isLoading, stop, attachments, setAttachments, append, canvas, setCanvas, messages, setMessages, }: { input: string; setInput: (input: string) => void; isLoading: boolean; stop: () => void; attachments: Array; setAttachments: Dispatch>>; canvas: UICanvas; setCanvas: Dispatch>; messages: Array; setMessages: Dispatch>>; append: ( message: Message | CreateMessage, chatRequestOptions?: ChatRequestOptions ) => Promise; handleSubmit: ( event?: { preventDefault?: () => void; }, chatRequestOptions?: ChatRequestOptions ) => void; }) { const [messagesContainerRef, messagesEndRef] = useScrollToBottom(); const { data: documents, isLoading: isDocumentsFetching, isValidating: isDocumentsValidating, mutate: mutateDocuments, } = useSWR>( canvas && canvas.status !== 'streaming' ? `/api/document?id=${canvas.documentId}` : null, fetcher ); const { data: suggestions } = useSWR>( documents && canvas && canvas.status !== 'streaming' ? `/api/suggestions?documentId=${canvas.documentId}` : null, fetcher, { dedupingInterval: 5000, } ); const [mode, setMode] = useState<'edit' | 'diff'>('edit'); const [document, setDocument] = useState(null); const [currentVersionIndex, setCurrentVersionIndex] = useState(-1); useEffect(() => { if (documents && documents.length > 0) { const mostRecentDocument = documents.at(-1); if (mostRecentDocument) { setDocument(mostRecentDocument); setCurrentVersionIndex(documents.length - 1); setCanvas((currentCanvas) => currentCanvas ? { ...currentCanvas, content: mostRecentDocument.content ?? '', } : null ); } } }, [documents, setCanvas]); useEffect(() => { mutateDocuments(); }, [canvas.status, mutateDocuments]); const { mutate } = useSWRConfig(); const [isContentDirty, setIsContentDirty] = useState(false); const handleContentChange = useCallback( (updatedContent: string) => { if (!canvas) return; mutate>( `/api/document?id=${canvas.documentId}`, async (currentDocuments) => { if (!currentDocuments) return undefined; const currentDocument = currentDocuments.at(-1); if (!currentDocument || !currentDocument.content) { setIsContentDirty(false); return currentDocuments; } if (currentDocument.content !== updatedContent) { await fetch(`/api/document?id=${canvas.documentId}`, { method: 'POST', body: JSON.stringify({ title: canvas.title, content: updatedContent, }), }); setIsContentDirty(false); const newDocument = { ...currentDocument, content: updatedContent, createdAt: new Date(), }; return [...currentDocuments, newDocument]; } else { return currentDocuments; } }, { revalidate: false } ); }, [canvas, mutate] ); const debouncedHandleEditorChange = useCallback( useDebounce(handleContentChange, 4000), [handleContentChange] ); const handleEditorChange = useCallback( (updatedContent: string) => { if (document && updatedContent !== document.content) { debouncedHandleEditorChange(updatedContent); setIsContentDirty(true); } }, [document, debouncedHandleEditorChange] ); function getDocumentContentById(index: number) { if (!documents) return ''; if (!documents[index]) return ''; return documents[index].content ?? ''; } function getDocumentTimestampById(index: number) { if (!documents) return ''; return documents[index]?.createdAt ?? ''; } const handleVersionChange = (type: 'next' | 'prev' | 'toggle' | 'latest') => { if (!documents) return; if (type === 'latest') { setCurrentVersionIndex(documents.length - 1); setMode('edit'); } if (type === 'toggle') { setMode((mode) => (mode === 'edit' ? 'diff' : 'edit')); } if (type === 'prev') { if (currentVersionIndex > 0) { setCurrentVersionIndex((index) => index - 1); } } else if (type === 'next') { if (currentVersionIndex < documents.length - 1) { setCurrentVersionIndex((index) => index + 1); } } }; const [isToolbarVisible, setIsToolbarVisible] = useState(false); /* * NOTE: if there are no documents, or if * the documents are being fetched, then * we mark it as the current version. */ const isCurrentVersion = isDocumentsFetching || isDocumentsValidating ? true : documents && documents.length > 0 ? currentVersionIndex === documents.length - 1 : true; const { width: windowWidth, height: windowHeight } = useWindowSize(); const isMobile = windowWidth ? windowWidth < 768 : false; return ( {!isMobile && (
{messages.map((message) => ( ))}
)}
{ setCanvas(null); }} >
{document?.title ?? canvas.title}
{isContentDirty ? (
Saving changes...
) : document ? (
{`Updated ${formatDistance( new Date(document.createdAt), new Date(), { addSuffix: true, } )}`}
) : null}
{ handleVersionChange('prev'); }} >
{ handleVersionChange('next'); }} >
{ handleVersionChange('toggle'); }} >
{isDocumentsFetching && !canvas.content ? ( ) : mode === 'edit' ? ( ) : ( )} {suggestions ? (
) : null}
{!isCurrentVersion && (
You are viewing a previous version
Restore this version to make edits
)}
{isCurrentVersion && ( )} ); }