openclaw/apps/web/app/components/tiptap/chat-editor.tsx

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;
}