supermemory/apps/web/components/text-editor/index.tsx

177 lines
5.2 KiB
TypeScript

"use client"
import { useEditor, EditorContent } from "@tiptap/react"
import { BubbleMenu } from "@tiptap/react/menus"
import type { Editor } from "@tiptap/core"
import { Markdown } from "@tiptap/markdown"
import { useRef, useEffect, useCallback } from "react"
import { defaultExtensions } from "./extensions"
import { slashCommand } from "./suggestions"
import { Bold, Italic, Code } from "lucide-react"
import { useDebouncedCallback } from "use-debounce"
import { cn } from "@lib/utils"
const extensions = [...defaultExtensions, slashCommand, Markdown]
export function TextEditor({
content: initialContent,
onContentChange,
onSubmit,
debounceMs = 500,
}: {
content: string | undefined
onContentChange: (content: string) => void
onSubmit: () => void
debounceMs?: number
}) {
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Editor | null>(null)
const onSubmitRef = useRef(onSubmit)
const hasUserEditedRef = useRef(false)
useEffect(() => {
onSubmitRef.current = onSubmit
}, [onSubmit])
const debouncedUpdates = useDebouncedCallback((editor: Editor) => {
if (!hasUserEditedRef.current) return
const json = editor.getJSON()
const markdown = editor.storage.markdown?.manager?.serialize(json) ?? ""
onContentChange?.(markdown)
}, debounceMs)
const editor = useEditor({
extensions,
content: initialContent,
contentType: "markdown",
immediatelyRender: true,
onCreate: ({ editor }) => {
editorRef.current = editor
},
onUpdate: ({ editor }) => {
editorRef.current = editor
if (!hasUserEditedRef.current) return
if (debounceMs === 0) {
const json = editor.getJSON()
const markdown = editor.storage.markdown?.manager?.serialize(json) ?? ""
onContentChange?.(markdown)
return
}
debouncedUpdates(editor)
},
editorProps: {
handleKeyDown: (_view, event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault()
debouncedUpdates.flush()
onSubmitRef.current?.()
return true
}
hasUserEditedRef.current = true
return false
},
handleTextInput: () => {
hasUserEditedRef.current = true
return false
},
handlePaste: () => {
hasUserEditedRef.current = true
return false
},
handleDrop: () => {
hasUserEditedRef.current = true
return false
},
},
})
useEffect(() => {
if (editor && initialContent) {
hasUserEditedRef.current = false
editor.commands.setContent(initialContent, { contentType: "markdown" })
}
}, [editor, initialContent])
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
if (target.closest(".ProseMirror")) {
return
}
if (target.closest("button, a")) {
return
}
const proseMirror = containerRef.current?.querySelector(
".ProseMirror",
) as HTMLElement
if (proseMirror && editorRef.current) {
setTimeout(() => {
proseMirror.focus()
editorRef.current?.commands.focus("end")
}, 0)
}
}, [])
useEffect(() => {
return () => {
// Flush any pending debounced updates before destroying editor
debouncedUpdates.flush()
editor?.destroy()
}
}, [editor, debouncedUpdates])
return (
<>
{/* biome-ignore lint/a11y/useSemanticElements: div is needed as container for editor, cannot use button */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: we need to use a div to get the focus on the editor */}
<div
role="button"
tabIndex={0}
ref={containerRef}
onClick={handleClick}
className="size-full cursor-text outline-none prose prose-invert max-w-none text-editor-prose [&_.ProseMirror]:min-h-full [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-[15px] [&_.ProseMirror]:leading-6 [&_.ProseMirror]:text-[#D7DEE8] [&_.ProseMirror-focused]:outline-none [&_.ProseMirror]:focus:outline-none"
>
<EditorContent editor={editor} />
</div>
{editor && (
<BubbleMenu
editor={editor}
options={{ placement: "bottom-start", offset: 8 }}
>
<div className="flex items-center gap-1 rounded-[8px] bg-[#1b1f24] p-2 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.25),inset_1px_1px_1px_0px_rgba(255,255,255,0.1)]">
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={cn(
"flex items-center justify-center rounded-[4px] p-1.5 hover:bg-[#2e353d] cursor-pointer text-[#fafafa]",
editor.isActive("bold") && "bg-[#2e353d]",
)}
>
<Bold size={16} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={cn(
"flex items-center justify-center rounded-[4px] p-1.5 hover:bg-[#2e353d] cursor-pointer text-[#fafafa]",
editor.isActive("italic") && "bg-[#2e353d]",
)}
>
<Italic size={16} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleCode().run()}
className={cn(
"flex items-center justify-center rounded-[4px] p-1.5 hover:bg-[#2e353d] cursor-pointer text-[#fafafa]",
editor.isActive("code") && "bg-[#2e353d]",
)}
>
<Code size={16} />
</button>
</div>
</BubbleMenu>
)}
</>
)
}