"use client"; import { forwardRef, useEffect, useImperativeHandle, useRef, } from "react"; import { useEditor, EditorContent } from "@tiptap/react"; import type { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import Suggestion from "@tiptap/suggestion"; import { Extension } from "@tiptap/core"; import { FileMentionNode, chatFileMentionPluginKey } from "./file-mention-extension"; import { createFileMentionRenderer, type SuggestItem, } from "./file-mention-list"; // ── Types ── export type ChatEditorHandle = { /** Insert a file mention node programmatically. */ insertFileMention: (name: string, path: string) => void; /** Clear the editor content. */ clear: () => void; /** Focus the editor. */ focus: () => void; /** Check if the editor is empty (no text, no mentions). */ isEmpty: () => boolean; /** Programmatically submit the current content. */ submit: () => void; }; type ChatEditorProps = { /** Called when user presses Enter (without Shift). */ onSubmit: (text: string, mentionedFiles: Array<{ name: string; path: string }>) => void; /** Called on every content change. */ onChange?: (isEmpty: boolean) => void; /** Called when native files (e.g. from Finder/Desktop) are dropped onto the editor. */ onNativeFileDrop?: (files: FileList) => void; placeholder?: string; disabled?: boolean; compact?: boolean; }; // ── Helpers ── function getFileCategory(name: string): string { const ext = name.split(".").pop()?.toLowerCase() ?? ""; if ( ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext) ) {return "image";} if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";} if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";} if (ext === "pdf") {return "pdf";} if ( [ "js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java", "cpp", "c", "h", "css", "html", "json", "yaml", "yml", "toml", "md", "sh", "bash", "sql", "swift", "kt", ].includes(ext) ) {return "code";} if (["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv"].includes(ext)) {return "document";} return "other"; } const categoryColors: Record = { image: { bg: "rgba(16, 185, 129, 0.15)", fg: "#10b981" }, video: { bg: "rgba(139, 92, 246, 0.15)", fg: "#8b5cf6" }, audio: { bg: "rgba(245, 158, 11, 0.15)", fg: "#f59e0b" }, pdf: { bg: "rgba(239, 68, 68, 0.15)", fg: "#ef4444" }, code: { bg: "rgba(59, 130, 246, 0.15)", fg: "#3b82f6" }, document: { bg: "rgba(107, 114, 128, 0.15)", fg: "#6b7280" }, folder: { bg: "rgba(245, 158, 11, 0.15)", fg: "#f59e0b" }, other: { bg: "rgba(107, 114, 128, 0.10)", fg: "#9ca3af" }, }; function shortenPath(path: string): string { return path .replace(/^\/Users\/[^/]+/, "~") .replace(/^\/home\/[^/]+/, "~") .replace(/^[A-Z]:\\Users\\[^\\]+/, "~"); } /** * Serialize the editor content to plain text with mention markers. * Returns { text, mentionedFiles }. * Objects serialize as `[object: name]`, entries as `[entry: objectName/label]`, * and files as `[file: path]`. */ function serializeContent(editor: ReturnType): { text: string; mentionedFiles: Array<{ name: string; path: string }>; } { if (!editor) {return { text: "", mentionedFiles: [] };} const mentionedFiles: Array<{ name: string; path: string }> = []; const parts: string[] = []; editor.state.doc.descendants((node) => { if (node.type.name === "chatFileMention") { const label = node.attrs.label as string; const path = node.attrs.path as string; const mType = node.attrs.mentionType as string; const objectName = node.attrs.objectName as string; mentionedFiles.push({ name: label, path }); if (mType === "object") { parts.push(`[object: ${label}]`); } else if (mType === "entry") { parts.push(`[entry: ${objectName ? `${objectName}/` : ""}${label}]`); } else { parts.push(`[file: ${path}]`); } return false; } if (node.isText && node.text) { parts.push(node.text); } if (node.type.name === "paragraph" && parts.length > 0) { const lastPart = parts[parts.length - 1]; if (lastPart !== undefined && lastPart !== "\n") { parts.push("\n"); } } return true; }); return { text: parts.join("").trim(), mentionedFiles }; } // ── File mention suggestion extension (wired to the async popup) ── function createChatFileMentionSuggestion() { return Extension.create({ name: "chatFileMentionSuggestion", addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: "@", pluginKey: chatFileMentionPluginKey, startOfLine: false, allowSpaces: true, command: ({ editor, range, props, }: { editor: Editor; range: { from: number; to: number }; props: SuggestItem; }) => { // For folders: update the query text to navigate into the folder if (props.type === "folder") { const shortPath = shortenPath(props.path); editor .chain() .focus() .deleteRange(range) .insertContent(`@${shortPath}/`) .run(); return; } // Determine mention type for objects/entries const mentionType = props.type === "object" ? "object" : props.type === "entry" ? "entry" : "file"; editor .chain() .focus() .deleteRange(range) .insertContent([ { type: "chatFileMention", attrs: { label: props.name, path: props.path, mentionType, objectName: props.objectName ?? "", }, }, { type: "text", text: " " }, ]) .run(); }, items: ({ query }: { query: string }) => { // Items are fetched async by the renderer, return empty here void query; return []; }, render: createFileMentionRenderer(), }), ]; }, }); } // ── Main component ── export const ChatEditor = forwardRef( function ChatEditor({ onSubmit, onChange, onNativeFileDrop, placeholder, disabled, compact }, ref) { const submitRef = useRef(onSubmit); submitRef.current = onSubmit; const nativeFileDropRef = useRef(onNativeFileDrop); nativeFileDropRef.current = onNativeFileDrop; // Ref to access the TipTap editor from within ProseMirror's handleDOMEvents // (the handlers are defined at useEditor() call time, before the editor exists). const editorRefInternal = useRef(null); const editor = useEditor({ immediatelyRender: false, extensions: [ StarterKit.configure({ heading: false, codeBlock: false, blockquote: false, horizontalRule: false, bulletList: false, orderedList: false, listItem: false, }), Placeholder.configure({ placeholder: placeholder ?? "Ask anything...", showOnlyWhenEditable: false, }), FileMentionNode, createChatFileMentionSuggestion(), ], editorProps: { attributes: { class: `chat-editor-content ${compact ? "chat-editor-compact" : ""}`, style: `color: var(--color-text);`, }, handleKeyDown: (_view, event) => { // Enter without shift = submit if (event.key === "Enter" && !event.shiftKey) { // Don't submit if suggestion popup is active // The suggestion plugin handles Enter in that case return false; } return false; }, // Handle drag-and-drop of files from the sidebar. // Using handleDOMEvents ensures our handler runs BEFORE // ProseMirror's built-in drop processing, which would // otherwise consume the event or insert the text/plain // fallback data as raw text. handleDOMEvents: { paste: (_view, event) => { const clipboardData = event.clipboardData; if (!clipboardData) {return false;} // Collect files from clipboard (images, screenshots, etc.) const pastedFiles: File[] = []; if (clipboardData.items) { for (const item of Array.from(clipboardData.items)) { if (item.kind === "file") { const file = item.getAsFile(); if (file) {pastedFiles.push(file);} } } } if (pastedFiles.length > 0) { event.preventDefault(); const dt = new DataTransfer(); for (const f of pastedFiles) {dt.items.add(f);} nativeFileDropRef.current?.(dt.files); return true; } return false; }, dragover: (_view, event) => { const de = event; if (de.dataTransfer?.types.includes("application/x-file-mention")) { de.preventDefault(); de.dataTransfer.dropEffect = "copy"; return true; } // Accept native file drops (e.g. from Finder/Desktop) if (de.dataTransfer?.types.includes("Files")) { de.preventDefault(); de.dataTransfer.dropEffect = "copy"; return true; } return false; }, drop: (_view, event) => { const de = event; // Sidebar file mention drop const data = de.dataTransfer?.getData("application/x-file-mention"); if (data) { de.preventDefault(); de.stopPropagation(); try { const { name, path } = JSON.parse(data) as { name: string; path: string }; if (name && path) { editorRefInternal.current ?.chain() .focus() .insertContent([ { type: "chatFileMention", attrs: { label: name, path }, }, { type: "text", text: " " }, ]) .run(); } } catch { // ignore malformed data } return true; } // Native file drop (from OS file manager) const files = de.dataTransfer?.files; if (files && files.length > 0) { de.preventDefault(); de.stopPropagation(); nativeFileDropRef.current?.(files); return true; } return false; }, }, }, onUpdate: ({ editor: ed }) => { onChange?.(ed.isEmpty); }, }); // Keep internal ref in sync so handleDOMEvents handlers can access the editor useEffect(() => { editorRefInternal.current = editor ?? null; }, [editor]); // Handle Enter-to-submit via a keydown listener on the editor DOM useEffect(() => { if (!editor) {return;} const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { // Check if suggestion popup is active by checking if the plugin has active state const suggestState = chatFileMentionPluginKey.getState(editor.state); if (suggestState?.active) {return;} // Let suggestion handle it event.preventDefault(); const { text, mentionedFiles } = serializeContent(editor); if (text.trim() || mentionedFiles.length > 0) { submitRef.current(text, mentionedFiles); editor.commands.clearContent(true); } } }; const el = editor.view.dom; el.addEventListener("keydown", handleKeyDown); return () => el.removeEventListener("keydown", handleKeyDown); }, [editor]); // Disable/enable editor useEffect(() => { if (editor) { editor.setEditable(!disabled); } }, [editor, disabled]); useImperativeHandle(ref, () => ({ insertFileMention: (name: string, path: string) => { editor ?.chain() .focus() .insertContent([ { type: "chatFileMention", attrs: { label: name, path }, }, { type: "text", text: " " }, ]) .run(); }, clear: () => { editor?.commands.clearContent(true); }, focus: () => { editor?.commands.focus(); }, isEmpty: () => { return editor?.isEmpty ?? true; }, submit: () => { if (!editor) {return;} const { text, mentionedFiles } = serializeContent(editor); if (text.trim() || mentionedFiles.length > 0) { submitRef.current(text, mentionedFiles); editor.commands.clearContent(true); } }, })); return ( <> ); }, ); /** * Helper to extract file mention info for styling (used by renderHTML). * Returns CSS custom properties for the mention pill. */ export function getMentionStyle(label: string): React.CSSProperties { const category = getFileCategory(label); const colors = categoryColors[category] ?? categoryColors.other; return { "--mention-bg": colors.bg, "--mention-fg": colors.fg, } as React.CSSProperties; }