mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-17 12:20:04 +00:00
177 lines
5.2 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|