"use client"; import { forwardRef, useEffect, useImperativeHandle, useRef, } from "react"; import { useEditor, EditorContent } from "@tiptap/react"; import { cn } from "@/lib/utils"; import type { UploadResult } from "@/shared/hooks/use-file-upload"; import { createEditorExtensions } from "./extensions"; import { uploadAndInsertFile } from "./extensions/file-upload"; import { preprocessMarkdown } from "./utils/preprocess"; import "./content-editor.css"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ContentEditorProps { defaultValue?: string; onUpdate?: (markdown: string) => void; placeholder?: string; editable?: boolean; className?: string; debounceMs?: number; onSubmit?: () => void; onBlur?: () => void; onUploadFile?: (file: File) => Promise; } interface ContentEditorRef { getMarkdown: () => string; clearContent: () => void; focus: () => void; uploadFile: (file: File) => void; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- const ContentEditor = forwardRef( function ContentEditor( { defaultValue = "", onUpdate, placeholder: placeholderText = "", editable = true, className, debounceMs = 300, onSubmit, onBlur, onUploadFile, }, ref, ) { const debounceRef = useRef>(undefined); const onUpdateRef = useRef(onUpdate); const onSubmitRef = useRef(onSubmit); const onBlurRef = useRef(onBlur); const onUploadFileRef = useRef(onUploadFile); const prevContentRef = useRef(defaultValue); // Keep refs in sync without recreating editor onUpdateRef.current = onUpdate; onSubmitRef.current = onSubmit; onBlurRef.current = onBlur; onUploadFileRef.current = onUploadFile; const editor = useEditor({ immediatelyRender: false, editable, content: defaultValue ? preprocessMarkdown(defaultValue) : "", contentType: defaultValue ? "markdown" : undefined, extensions: createEditorExtensions({ editable, placeholder: placeholderText, onSubmitRef, onUploadFileRef, }), onUpdate: ({ editor: ed }) => { if (!onUpdateRef.current) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { onUpdateRef.current?.(ed.getMarkdown()); }, debounceMs); }, onBlur: () => { onBlurRef.current?.(); }, editorProps: { handleDOMEvents: { click(_view, event) { const target = event.target as HTMLElement; // Skip links inside NodeView wrappers — they handle their own clicks if (target.closest("[data-node-view-wrapper]")) return false; const link = target.closest("a"); const href = link?.getAttribute("href"); if (!href || href.startsWith("mention://")) return false; if (!editable) { // Readonly: any click on link opens new tab event.preventDefault(); window.open(href, "_blank", "noopener,noreferrer"); return true; } if (event.metaKey || event.ctrlKey) { // Edit mode: Cmd/Ctrl+click opens link window.open(href, "_blank", "noopener,noreferrer"); event.preventDefault(); return true; } return false; }, }, attributes: { class: cn( "rich-text-editor text-sm outline-none", !editable && "readonly", className, ), }, }, }); // Cleanup debounce on unmount useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []); // Readonly content update: when defaultValue changes and editor is readonly, // re-set the content (e.g. after editing a comment, the readonly view updates) useEffect(() => { if (!editor || editable) return; if (defaultValue === prevContentRef.current) return; prevContentRef.current = defaultValue; const processed = defaultValue ? preprocessMarkdown(defaultValue) : ""; if (processed) { editor.commands.setContent(processed, { contentType: "markdown" }); } else { editor.commands.clearContent(); } }, [editor, editable, defaultValue]); useImperativeHandle(ref, () => ({ getMarkdown: () => editor?.getMarkdown() ?? "", clearContent: () => { editor?.commands.clearContent(); }, focus: () => { editor?.commands.focus(); }, uploadFile: (file: File) => { if (!editor || !onUploadFileRef.current) return; const endPos = editor.state.doc.content.size; uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos); }, })); if (!editor) return null; return ; }, ); export { ContentEditor, type ContentEditorProps, type ContentEditorRef };