diff --git a/app/globals.css b/app/globals.css index ba22c32..15af66c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -140,3 +140,7 @@ .ProseMirror { outline: none; } + +.suggestion-highlight { + @apply bg-yellow-100 hover:bg-yellow-200 dark:hover:bg-yellow-400/50 dark:text-yellow-50 dark:bg-yellow-400/40; +} diff --git a/components/custom/canvas-stream-handler.tsx b/components/custom/canvas-stream-handler.tsx index 23109cb..29a624f 100644 --- a/components/custom/canvas-stream-handler.tsx +++ b/components/custom/canvas-stream-handler.tsx @@ -5,7 +5,7 @@ import { UICanvas } from './canvas'; import { useCanvasStream } from './use-canvas-stream'; interface CanvasStreamHandlerProps { - setCanvas: Dispatch>; + setCanvas: Dispatch>; streamingData: JSONValue[] | undefined; } diff --git a/components/custom/canvas.tsx b/components/custom/canvas.tsx index 3f444f0..32d45d6 100644 --- a/components/custom/canvas.tsx +++ b/components/custom/canvas.tsx @@ -1,6 +1,6 @@ import { Attachment, ChatRequestOptions, CreateMessage, Message } from 'ai'; import cx from 'classnames'; -import { formatDistance, isAfter } from 'date-fns'; +import { formatDistance } from 'date-fns'; import { AnimatePresence, motion } from 'framer-motion'; import { Dispatch, @@ -10,6 +10,7 @@ import { useState, } from 'react'; import useSWR, { useSWRConfig } from 'swr'; +import { useDebounceCallback, useWindowSize } from 'usehooks-ts'; import { Document, Suggestion } from '@/db/schema'; import { fetcher } from '@/lib/utils'; @@ -18,14 +19,12 @@ import { DiffView } from './diffview'; import { DocumentSkeleton } from './document-skeleton'; import { Editor } from './editor'; import { CrossIcon, DeltaIcon, RedoIcon, UndoIcon } from './icons'; +import { Markdown } from './markdown'; 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'; - +import { VersionFooter } from './version-footer'; export interface UICanvas { title: string; documentId: string; @@ -61,7 +60,7 @@ export function Canvas({ attachments: Array; setAttachments: Dispatch>>; canvas: UICanvas; - setCanvas: Dispatch>; + setCanvas: Dispatch>; messages: Array; setMessages: Dispatch>>; append: ( @@ -81,7 +80,6 @@ export function Canvas({ const { data: documents, isLoading: isDocumentsFetching, - isValidating: isDocumentsValidating, mutate: mutateDocuments, } = useSWR>( canvas && canvas.status !== 'streaming' @@ -111,14 +109,10 @@ export function Canvas({ if (mostRecentDocument) { setDocument(mostRecentDocument); setCurrentVersionIndex(documents.length - 1); - setCanvas((currentCanvas) => - currentCanvas - ? { - ...currentCanvas, - content: mostRecentDocument.content ?? '', - } - : null - ); + setCanvas((currentCanvas) => ({ + ...currentCanvas, + content: mostRecentDocument.content ?? '', + })); } } }, [documents, setCanvas]); @@ -174,24 +168,24 @@ export function Canvas({ [canvas, mutate] ); - const debouncedHandleEditorChange = useCallback( - useDebounce(handleContentChange, 4000), - [handleContentChange] + const debouncedHandleContentChange = useDebounceCallback( + handleContentChange, + 2000 ); - const handleEditorChange = useCallback( + const saveContent = useCallback( (updatedContent: string, debounce: boolean) => { if (document && updatedContent !== document.content) { + setIsContentDirty(true); + if (debounce) { - debouncedHandleEditorChange(updatedContent); + debouncedHandleContentChange(updatedContent); } else { handleContentChange(updatedContent); } - - setIsContentDirty(true); } }, - [document, debouncedHandleEditorChange, handleContentChange] + [document, debouncedHandleContentChange, handleContentChange] ); function getDocumentContentById(index: number) { @@ -200,11 +194,6 @@ export function Canvas({ 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; @@ -237,11 +226,9 @@ export function Canvas({ */ const isCurrentVersion = - isDocumentsFetching || isDocumentsValidating - ? true - : documents && documents.length > 0 - ? currentVersionIndex === documents.length - 1 - : true; + documents && documents.length > 0 + ? currentVersionIndex === documents.length - 1 + : true; const { width: windowWidth, height: windowHeight } = useWindowSize(); const isMobile = windowWidth ? windowWidth < 768 : false; @@ -391,7 +378,10 @@ export function Canvas({
{ - setCanvas(null); + setCanvas((currentCanvas) => ({ + ...currentCanvas, + isVisible: false, + })); }} > @@ -462,9 +452,10 @@ export function Canvas({ ? canvas.content : getDocumentContentById(currentVersionIndex) } + isCurrentVersion={isCurrentVersion} currentVersionIndex={currentVersionIndex} - status={canvas.status ?? 'idle'} - onChange={handleEditorChange} + status={canvas.status} + saveContent={saveContent} suggestions={isCurrentVersion ? (suggestions ?? []) : []} /> ) : ( @@ -482,63 +473,12 @@ export function Canvas({ {!isCurrentVersion && ( - -
-
You are viewing a previous version
-
- Restore this version to make edits -
-
- -
- - -
-
+ )}
diff --git a/components/custom/chat.tsx b/components/custom/chat.tsx index f432ff4..4edda69 100644 --- a/components/custom/chat.tsx +++ b/components/custom/chat.tsx @@ -4,8 +4,8 @@ import { Attachment, Message } from 'ai'; import { useChat } from 'ai/react'; import { AnimatePresence } from 'framer-motion'; import { useState } from 'react'; +import { useWindowSize } from 'usehooks-ts'; -import { Model } from '@/ai/models'; import { ChatHeader } from '@/components/custom/chat-header'; import { Message as PreviewMessage } from '@/components/custom/message'; import { useScrollToBottom } from '@/components/custom/use-scroll-to-bottom'; @@ -42,7 +42,22 @@ export function Chat({ }, }); - const [canvas, setCanvas] = useState(null); + const { width: windowWidth = 1920, height: windowHeight = 1080 } = + useWindowSize(); + + const [canvas, setCanvas] = useState({ + documentId: 'init', + content: '', + title: '', + status: 'idle', + isVisible: false, + boundingBox: { + top: windowHeight / 4, + left: windowWidth / 4, + width: 250, + height: 50, + }, + }); const [messagesContainerRef, messagesEndRef] = useScrollToBottom(); diff --git a/components/custom/document.tsx b/components/custom/document.tsx index c7f5803..491c13e 100644 --- a/components/custom/document.tsx +++ b/components/custom/document.tsx @@ -19,8 +19,8 @@ const getActionText = (type: 'create' | 'update' | 'request-suggestions') => { interface DocumentToolResultProps { type: 'create' | 'update' | 'request-suggestions'; result: any; - canvas: UICanvas | null; - setCanvas: (value: SetStateAction) => void; + canvas: UICanvas; + setCanvas: (value: SetStateAction) => void; } export function DocumentToolResult({ @@ -42,27 +42,14 @@ export function DocumentToolResult({ height: rect.height, }; - if (!canvas) { - setCanvas({ - documentId: result.id, - content: '', - title: result.title, - isVisible: true, - status: 'idle', - boundingBox, - }); - } else { - if (canvas.documentId !== result.id) { - setCanvas({ - documentId: result.id, - content: '', - title: result.title, - isVisible: true, - status: 'idle', - boundingBox, - }); - } - } + setCanvas({ + documentId: result.id, + content: '', + title: result.title, + isVisible: true, + status: 'idle', + boundingBox, + }); }} >
diff --git a/components/custom/editor.tsx b/components/custom/editor.tsx index e289dab..64822a4 100644 --- a/components/custom/editor.tsx +++ b/components/custom/editor.tsx @@ -1,213 +1,170 @@ 'use client'; import { exampleSetup } from 'prosemirror-example-setup'; -import { inputRules, textblockTypeInputRule } from 'prosemirror-inputrules'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; -import { Schema, DOMParser } from 'prosemirror-model'; -import { schema } from 'prosemirror-schema-basic'; -import { addListNodes } from 'prosemirror-schema-list'; +import { inputRules } from 'prosemirror-inputrules'; import { EditorState } from 'prosemirror-state'; -import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; +import { EditorView } from 'prosemirror-view'; import React, { memo, useEffect, useRef } from 'react'; -import { renderToString } from 'react-dom/server'; import { Suggestion } from '@/db/schema'; import { - createSuggestionWidget, - projectWithHighlights, + documentSchema, + handleTransaction, + headingRule, +} from '@/lib/editor/config'; +import { + buildContentFromDocument, + buildDocumentFromContent, + createDecorations, +} from '@/lib/editor/functions'; +import { + projectWithPositions, suggestionsPlugin, suggestionsPluginKey, - UISuggestion, } from '@/lib/editor/suggestions'; -import { Markdown } from './markdown'; - -const mySchema = new Schema({ - nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'), - // @ts-expect-error: TODO need to fix type mismatch - marks: { - ...schema.spec.marks, - }, -}); - -function headingRule(level: number) { - return textblockTypeInputRule( - new RegExp(`^(#{1,${level}})\\s$`), - mySchema.nodes.heading, - () => ({ level }) - ); -} - -interface WidgetRoot { - destroy: () => void; -} - type EditorProps = { content: string; - onChange: (updatedContent: string, debounce: boolean) => void; + saveContent: (updatedContent: string, debounce: boolean) => void; status: 'streaming' | 'idle'; + isCurrentVersion: boolean; currentVersionIndex: number; suggestions: Array; }; function PureEditor({ content, - onChange, - suggestions: suggestionsWithoutHighlights, + saveContent, + suggestions, + status, }: EditorProps) { - const editorRef = useRef(null); - const viewRef = useRef(null); - const widgetRootsRef = useRef>(new Map()); + const containerRef = useRef(null); + const editorRef = useRef(null); useEffect(() => { - if (editorRef.current && !viewRef.current) { - if (mySchema) { - const parser = DOMParser.fromSchema(mySchema); + if (containerRef.current && !editorRef.current) { + const state = EditorState.create({ + doc: buildDocumentFromContent(content), + plugins: [ + ...exampleSetup({ schema: documentSchema, menuBar: false }), + inputRules({ + rules: [ + headingRule(1), + headingRule(2), + headingRule(3), + headingRule(4), + headingRule(5), + headingRule(6), + ], + }), + suggestionsPlugin, + ], + }); - const htmlContent = renderToString({content}); - - const container = document.createElement('div'); - container.innerHTML = htmlContent; - - const state = EditorState.create({ - doc: parser.parse(container), - plugins: [ - ...exampleSetup({ schema: mySchema, menuBar: false }), - inputRules({ - rules: [ - headingRule(1), - headingRule(2), - headingRule(3), - headingRule(4), - headingRule(5), - headingRule(6), - ], - }), - suggestionsPlugin, - ], - }); - - viewRef.current = new EditorView(editorRef.current, { - state, - dispatchTransaction: (transaction) => { - const newState = viewRef.current!.state.apply(transaction); - viewRef.current!.updateState(newState); - - if (transaction.docChanged) { - const content = defaultMarkdownSerializer.serialize(newState.doc); - - if (transaction.getMeta('no-debounce')) { - onChange(content, false); - } else { - onChange(content, true); - } - } - }, - }); - } else { - console.error('Schema is not properly initialized'); - } + editorRef.current = new EditorView(containerRef.current, { + state, + }); } return () => { - if (viewRef.current) { - viewRef.current.destroy(); - viewRef.current = null; + if (editorRef.current) { + editorRef.current.destroy(); + editorRef.current = null; } }; - }, [content, onChange]); + // NOTE: we only want to run this effect once + // eslint-disable-next-line + }, []); useEffect(() => { - if (viewRef.current && viewRef.current.state.doc && content) { - const computedSuggestions = projectWithHighlights( - viewRef.current.state.doc, - suggestionsWithoutHighlights + if (editorRef.current) { + editorRef.current.setProps({ + dispatchTransaction: (transaction) => { + handleTransaction({ transaction, editorRef, saveContent }); + }, + }); + } + }, [saveContent]); + + useEffect(() => { + if (editorRef.current && content) { + const currentContent = buildContentFromDocument( + editorRef.current.state.doc + ); + + if (status === 'streaming') { + const newDocument = buildDocumentFromContent(content); + + const transaction = editorRef.current.state.tr.replaceWith( + 0, + editorRef.current.state.doc.content.size, + newDocument.content + ); + + transaction.setMeta('no-save', true); + editorRef.current.dispatch(transaction); + return; + } + + if (currentContent !== content) { + const newDocument = buildDocumentFromContent(content); + + const transaction = editorRef.current.state.tr.replaceWith( + 0, + editorRef.current.state.doc.content.size, + newDocument.content + ); + + transaction.setMeta('no-save', true); + editorRef.current.dispatch(transaction); + } + } + }, [content, status]); + + useEffect(() => { + if (editorRef.current && editorRef.current.state.doc && content) { + const projectedSuggestions = projectWithPositions( + editorRef.current.state.doc, + suggestions ).filter( - (suggestion) => - suggestion.selectionStart !== 0 && suggestion.selectionEnd !== 0 + (suggestion) => suggestion.selectionStart && suggestion.selectionEnd ); const decorations = createDecorations( - computedSuggestions, - viewRef.current + projectedSuggestions, + editorRef.current ); - const transaction = viewRef.current.state.tr; + + const transaction = editorRef.current.state.tr; transaction.setMeta(suggestionsPluginKey, { decorations }); - viewRef.current.dispatch(transaction); + editorRef.current.dispatch(transaction); } - }, [suggestionsWithoutHighlights, content]); + }, [suggestions, content]); - const createDecorations = (suggestions: UISuggestion[], view: EditorView) => { - const decorations: Decoration[] = []; - - suggestions.forEach((suggestion) => { - decorations.push( - Decoration.inline( - suggestion.selectionStart, - suggestion.selectionEnd, - { - class: - 'suggestion-highlight bg-yellow-100 hover:bg-yellow-200 dark:hover:bg-yellow-400/50 dark:text-yellow-50 dark:bg-yellow-400/40', - }, - { - suggestionId: suggestion.id, - type: 'highlight', - } - ) - ); - - decorations.push( - Decoration.widget( - suggestion.selectionStart, - (view) => { - const { dom, destroy } = createSuggestionWidget(suggestion, view); - const key = `widget-${suggestion.id}`; - widgetRootsRef.current.set(key, { destroy }); - return dom; - }, - { - suggestionId: suggestion.id, - type: 'widget', - } - ) - ); - }); - - return DecorationSet.create(view.state.doc, decorations); - }; - - useEffect(() => { - return () => { - widgetRootsRef.current.forEach((root) => { - root.destroy(); - }); - - widgetRootsRef.current.clear(); - - if (viewRef.current) { - viewRef.current.destroy(); - viewRef.current = null; - } - }; - }, []); - - return
; + return ( +
+ ); } function areEqual(prevProps: EditorProps, nextProps: EditorProps) { if (prevProps.suggestions !== nextProps.suggestions) { return false; - } else if (prevProps.content === '' && nextProps.content !== '') { - return false; } else if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) { return false; - } else if (prevProps.onChange !== nextProps.onChange) { + } else if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) { + return false; + } else if ( + prevProps.status === 'streaming' && + nextProps.status === 'streaming' + ) { + return false; + } else if (prevProps.content !== nextProps.content) { + return false; + } else if (prevProps.saveContent !== nextProps.saveContent) { return false; - } else if (prevProps.status === 'idle') { - return true; - } else { - return prevProps.content === nextProps.content; } + + return true; } export const Editor = memo(PureEditor, areEqual); diff --git a/components/custom/message.tsx b/components/custom/message.tsx index d986348..284b52e 100644 --- a/components/custom/message.tsx +++ b/components/custom/message.tsx @@ -24,8 +24,8 @@ export const Message = ({ content: string | ReactNode; toolInvocations: Array | undefined; attachments?: Array; - canvas: UICanvas | null; - setCanvas: Dispatch>; + canvas: UICanvas; + setCanvas: Dispatch>; }) => { return ( { - setIsExpanded(true); - }} - > - -
- ) : ( - -
-
-
-
Assistant
-
-
+ {!isExpanded ? ( + { - setIsExpanded(false); + setIsExpanded(true); }} + whileHover={{ scale: 1.1 }} > - -
-
-
{suggestion.description}
- - + + + ) : ( + +
+
+
+
Assistant
+
+
{ + setIsExpanded(false); + }} + > + +
+
+
{suggestion.description}
+ + + )} + ); }; diff --git a/components/custom/toolbar.tsx b/components/custom/toolbar.tsx index f9e095b..12013b8 100644 --- a/components/custom/toolbar.tsx +++ b/components/custom/toolbar.tsx @@ -9,6 +9,7 @@ import { useTransform, } from 'framer-motion'; import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; +import { useOnClickOutside } from 'usehooks-ts'; import { Tooltip, @@ -25,7 +26,6 @@ import { StopIcon, SummarizeIcon, } from './icons'; -import useClickOutside from './use-click-outside'; type ToolProps = { type: 'final-polish' | 'request-suggestions' | 'adjust-reading-level'; @@ -265,7 +265,7 @@ export const Toolbar = ({ const [selectedTool, setSelectedTool] = useState(null); const [isAnimating, setIsAnimating] = useState(false); - useClickOutside(toolbarRef, () => { + useOnClickOutside(toolbarRef, () => { setIsToolbarVisible(false); setSelectedTool(null); }); diff --git a/components/custom/use-canvas-stream.tsx b/components/custom/use-canvas-stream.tsx index ddd2857..9b2d226 100644 --- a/components/custom/use-canvas-stream.tsx +++ b/components/custom/use-canvas-stream.tsx @@ -5,7 +5,6 @@ import { useSWRConfig } from 'swr'; import { Suggestion } from '@/db/schema'; import { UICanvas } from './canvas'; -import useWindowSize from './use-window-size'; type StreamingDelta = { type: 'text-delta' | 'title' | 'id' | 'suggestion' | 'clear' | 'finish'; @@ -17,7 +16,7 @@ export function useCanvasStream({ setCanvas, }: { streamingData: JSONValue[] | undefined; - setCanvas: Dispatch>; + setCanvas: Dispatch>; }) { const { mutate } = useSWRConfig(); const [optimisticSuggestions, setOptimisticSuggestions] = useState< @@ -32,9 +31,6 @@ export function useCanvasStream({ } }, [optimisticSuggestions, mutate]); - const { width: windowWidth = 1920, height: windowHeight = 1080 } = - useWindowSize(); - useEffect(() => { const mostRecentDelta = streamingData?.at(-1); if (!mostRecentDelta) return; @@ -42,23 +38,19 @@ export function useCanvasStream({ const delta = mostRecentDelta as StreamingDelta; setCanvas((draftCanvas) => { - if (!draftCanvas) { - return { - content: '', - title: '', - isVisible: false, - documentId: delta.type === 'id' ? (delta.content as string) : '', - status: 'idle', - boundingBox: { - top: windowHeight / 4, - left: windowWidth / 4, - width: 250, - height: 50, - }, - }; - } - switch (delta.type) { + case 'id': + return { + ...draftCanvas, + documentId: delta.content as string, + }; + + case 'title': + return { + ...draftCanvas, + title: delta.content as string, + }; + case 'text-delta': return { ...draftCanvas, @@ -70,12 +62,6 @@ export function useCanvasStream({ status: 'streaming', }; - case 'title': - return { - ...draftCanvas, - title: delta.content as string, - }; - case 'suggestion': setTimeout(() => { setOptimisticSuggestions((currentSuggestions) => [ diff --git a/components/custom/use-click-outside.tsx b/components/custom/use-click-outside.tsx deleted file mode 100644 index 6477fce..0000000 --- a/components/custom/use-click-outside.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect, RefObject } from 'react'; - -type Handler = (event: MouseEvent | TouchEvent) => void; - -function useClickOutside( - ref: RefObject, - handler: Handler, - mouseEvent: 'mousedown' | 'mouseup' = 'mousedown' -): void { - useEffect(() => { - const listener = (event: MouseEvent | TouchEvent) => { - const el = ref?.current; - - if (!el || el.contains((event?.target as Node) || null)) { - return; - } - - handler(event); - }; - - document.addEventListener(mouseEvent, listener); - document.addEventListener('touchstart', listener); - - return () => { - document.removeEventListener(mouseEvent, listener); - document.removeEventListener('touchstart', listener); - }; - }, [ref, handler, mouseEvent]); -} - -export default useClickOutside; diff --git a/components/custom/use-debounce.tsx b/components/custom/use-debounce.tsx deleted file mode 100644 index e3d2547..0000000 --- a/components/custom/use-debounce.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useCallback, useRef } from "react"; - -export function useDebounce any>( - callback: T, - delay: number, -): (...args: Parameters) => void { - const timeoutRef = useRef(null); - - return useCallback( - (...args: Parameters) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - callback(...args); - }, delay); - }, - [callback, delay], - ); -} diff --git a/components/custom/use-window-size.tsx b/components/custom/use-window-size.tsx deleted file mode 100644 index c0934ff..0000000 --- a/components/custom/use-window-size.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; - -interface WindowSize { - width: number | undefined; - height: number | undefined; -} - -function useWindowSize(): WindowSize { - const [windowSize, setWindowSize] = useState({ - width: undefined, - height: undefined, - }); - - useEffect(() => { - // Handler to call on window resize - function handleResize() { - // Set window width/height to state - setWindowSize({ - width: window.innerWidth, - height: window.innerHeight, - }); - } - - // Add event listener - window.addEventListener("resize", handleResize); - - // Call handler right away so state gets updated with initial window size - handleResize(); - - // Remove event listener on cleanup - return () => window.removeEventListener("resize", handleResize); - }, []); // Empty array ensures that effect is only run on mount and unmount - - return windowSize; -} - -export default useWindowSize; diff --git a/components/custom/version-footer.tsx b/components/custom/version-footer.tsx new file mode 100644 index 0000000..5440c6c --- /dev/null +++ b/components/custom/version-footer.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { isAfter } from 'date-fns'; +import { motion } from 'framer-motion'; +import { useState } from 'react'; +import { useSWRConfig } from 'swr'; +import { useWindowSize } from 'usehooks-ts'; + +import { Document } from '@/db/schema'; +import { getDocumentTimestampByIndex } from '@/lib/utils'; + +import { UICanvas } from './canvas'; +import { LoaderIcon } from './icons'; +import { Button } from '../ui/button'; + +interface VersionFooterProps { + canvas: UICanvas; + handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void; + documents: Array | undefined; + currentVersionIndex: number; +} + +export const VersionFooter = ({ + canvas, + handleVersionChange, + documents, + currentVersionIndex, +}: VersionFooterProps) => { + const { width } = useWindowSize(); + const isMobile = width < 768; + + const { mutate } = useSWRConfig(); + const [isMutating, setIsMutating] = useState(false); + + if (!documents) return; + + return ( + +
+
You are viewing a previous version
+
+ Restore this version to make edits +
+
+ +
+ + +
+
+ ); +}; diff --git a/lib/editor/config.ts b/lib/editor/config.ts new file mode 100644 index 0000000..6219555 --- /dev/null +++ b/lib/editor/config.ts @@ -0,0 +1,47 @@ +import { textblockTypeInputRule } from 'prosemirror-inputrules'; +import { Schema } from 'prosemirror-model'; +import { schema } from 'prosemirror-schema-basic'; +import { addListNodes } from 'prosemirror-schema-list'; +import { Transaction } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { MutableRefObject } from 'react'; + +import { buildContentFromDocument } from './functions'; + +export const documentSchema = new Schema({ + nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'), + marks: schema.spec.marks, +}); + +export function headingRule(level: number) { + return textblockTypeInputRule( + new RegExp(`^(#{1,${level}})\\s$`), + documentSchema.nodes.heading, + () => ({ level }) + ); +} + +export const handleTransaction = ({ + transaction, + editorRef, + saveContent, +}: { + transaction: Transaction; + editorRef: MutableRefObject; + saveContent: (updatedContent: string, debounce: boolean) => void; +}) => { + if (!editorRef || !editorRef.current) return; + + const newState = editorRef.current.state.apply(transaction); + editorRef.current.updateState(newState); + + if (transaction.docChanged && !transaction.getMeta('no-save')) { + const updatedContent = buildContentFromDocument(newState.doc); + + if (transaction.getMeta('no-debounce')) { + saveContent(updatedContent, false); + } else { + saveContent(updatedContent, true); + } + } +}; diff --git a/lib/editor/functions.tsx b/lib/editor/functions.tsx new file mode 100644 index 0000000..eb58ac5 --- /dev/null +++ b/lib/editor/functions.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; +import { DOMParser, Node } from 'prosemirror-model'; +import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; +import { renderToString } from 'react-dom/server'; + +import { Markdown } from '@/components/custom/markdown'; + +import { documentSchema } from './config'; +import { createSuggestionWidget, UISuggestion } from './suggestions'; + +export const buildDocumentFromContent = (content: string) => { + const parser = DOMParser.fromSchema(documentSchema); + const stringFromMarkdown = renderToString({content}); + const tempContainer = document.createElement('div'); + tempContainer.innerHTML = stringFromMarkdown; + return parser.parse(tempContainer); +}; + +export const buildContentFromDocument = (document: Node) => { + return defaultMarkdownSerializer.serialize(document); +}; + +export const createDecorations = ( + suggestions: Array, + view: EditorView +) => { + const decorations: Array = []; + + suggestions.forEach((suggestion) => { + decorations.push( + Decoration.inline( + suggestion.selectionStart, + suggestion.selectionEnd, + { + class: 'suggestion-highlight', + }, + { + suggestionId: suggestion.id, + type: 'highlight', + } + ) + ); + + decorations.push( + Decoration.widget( + suggestion.selectionStart, + (view) => { + const { dom } = createSuggestionWidget(suggestion, view); + return dom; + }, + { + suggestionId: suggestion.id, + type: 'widget', + } + ) + ); + }); + + return DecorationSet.create(view.state.doc, decorations); +}; diff --git a/lib/editor/suggestions.tsx b/lib/editor/suggestions.tsx index badbf72..9a74936 100644 --- a/lib/editor/suggestions.tsx +++ b/lib/editor/suggestions.tsx @@ -1,4 +1,3 @@ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Node } from 'prosemirror-model'; import { PluginKey, Plugin } from 'prosemirror-state'; import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; @@ -40,7 +39,7 @@ function findPositionsInDoc(doc: Node, searchText: string): Position | null { return positions; } -export function projectWithHighlights( +export function projectWithPositions( doc: Node, suggestions: Array ): Array { diff --git a/lib/utils.ts b/lib/utils.ts index 2a92bb8..e99f363 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -9,7 +9,7 @@ import { import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import { Chat } from '@/db/schema'; +import { Chat, Document } from '@/db/schema'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -221,3 +221,13 @@ export function sanitizeUIMessages(messages: Array): Array { (message.toolInvocations && message.toolInvocations.length > 0) ); } + +export function getDocumentTimestampByIndex( + documents: Array, + index: number +) { + if (!documents) return new Date(); + if (index > documents.length) return new Date(); + + return documents[index].createdAt; +}