494 lines
14 KiB
TypeScript
494 lines
14 KiB
TypeScript
"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<string, { bg: string; fg: string }> = {
|
|
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<typeof useEditor>): {
|
|
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<ChatEditorHandle, ChatEditorProps>(
|
|
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<Editor | null>(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 (
|
|
<>
|
|
<EditorContent editor={editor} />
|
|
<style>{`
|
|
.chat-editor-content {
|
|
outline: none;
|
|
min-height: ${compact ? "16px" : "28px"};
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
padding: ${compact ? "10px 12px" : "14px 16px"};
|
|
font-size: ${compact ? "12px" : "14px"};
|
|
line-height: 1.5;
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
.chat-editor-content[contenteditable="false"] {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.chat-editor-content p {
|
|
margin: 0;
|
|
}
|
|
.chat-editor-content p.is-editor-empty:first-child::before {
|
|
content: attr(data-placeholder);
|
|
color: var(--color-text-muted);
|
|
float: left;
|
|
height: 0;
|
|
pointer-events: none;
|
|
}
|
|
/* File mention pill styles */
|
|
.chat-editor-content span[data-chat-file-mention] {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 1px 8px 1px 6px;
|
|
margin: 0 1px;
|
|
border-radius: 6px;
|
|
background: var(--mention-bg, rgba(59, 130, 246, 0.12));
|
|
color: var(--mention-fg, #3b82f6);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
line-height: 1.6;
|
|
vertical-align: baseline;
|
|
cursor: default;
|
|
user-select: all;
|
|
white-space: nowrap;
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
.chat-editor-content span[data-chat-file-mention]::before {
|
|
content: "@";
|
|
opacity: 0.5;
|
|
font-size: 11px;
|
|
}
|
|
.chat-editor-content span[data-chat-file-mention]:hover {
|
|
opacity: 0.85;
|
|
}
|
|
.chat-editor-content.chat-editor-compact {
|
|
min-height: 16px;
|
|
}
|
|
`}</style>
|
|
</>
|
|
);
|
|
},
|
|
);
|
|
|
|
/**
|
|
* 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;
|
|
}
|