feat(ui): replace ChatInput textarea with Tiptap editor
Swap the plain <textarea> for a minimal Tiptap (ProseMirror) editor with IME-safe Enter-to-submit, Shift+Enter newline, placeholder support, and an optional imperative ref (getText/setText/focus/clear). All rich-text extensions are disabled — only plain text is allowed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fc6c3e30b3
commit
b74a5ea1a7
4 changed files with 721 additions and 41 deletions
18
packages/ui/src/components/chat-input.css
Normal file
18
packages/ui/src/components/chat-input.css
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
.chat-input-editor .ProseMirror {
|
||||
outline: none;
|
||||
min-height: 2.5rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-input-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chat-input-editor.is-disabled .ProseMirror {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -1,9 +1,20 @@
|
|||
"use client";
|
||||
import { useRef } from "react";
|
||||
import { useRef, useEffect, useImperativeHandle, forwardRef } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ArrowUpIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import "./chat-input.css";
|
||||
|
||||
export interface ChatInputRef {
|
||||
getText: () => string;
|
||||
setText: (text: string) => void;
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSubmit?: (value: string) => void;
|
||||
|
|
@ -11,45 +22,105 @@ interface ChatInputProps {
|
|||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSubmit, disabled, placeholder = "Type a message..." }: ChatInputProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
function ChatInput({ onSubmit, disabled, placeholder = "Type a message..." }, ref) {
|
||||
// Use ref to avoid stale closure in Tiptap keydown handler
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const value = textareaRef.current?.value ?? "";
|
||||
if (!value.trim()) return;
|
||||
onSubmit?.(value);
|
||||
textareaRef.current!.value = "";
|
||||
// reset height
|
||||
textareaRef.current!.style.height = "auto";
|
||||
};
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
// Disable all rich-text features — plain text only
|
||||
heading: false,
|
||||
bold: false,
|
||||
italic: false,
|
||||
strike: false,
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
blockquote: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
horizontalRule: false,
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"w-full resize-none bg-transparent px-1 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
|
||||
},
|
||||
handleKeyDown(_view, event) {
|
||||
// Guard for IME composition (Chinese/Japanese input)
|
||||
if (event.isComposing) return false;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"bg-card rounded-xl p-3 border border-border transition-colors",
|
||||
disabled && "cursor-not-allowed opacity-60"
|
||||
)}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const text = _view.state.doc.textContent;
|
||||
if (!text.trim()) return true;
|
||||
onSubmitRef.current?.(text);
|
||||
// Clear editor after submit
|
||||
_view.dispatch(
|
||||
_view.state.tr
|
||||
.delete(0, _view.state.doc.content.size)
|
||||
.setMeta("addToHistory", false),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}}
|
||||
className="w-full resize-none bg-transparent px-1 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button size="icon" onClick={handleSubmit} disabled={disabled}>
|
||||
<HugeiconsIcon strokeWidth={2.5} icon={ArrowUpIcon} />
|
||||
</Button>
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sync disabled state
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.setEditable(!disabled);
|
||||
}, [editor, disabled]);
|
||||
|
||||
// Sync placeholder
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "placeholder",
|
||||
)!.options.placeholder = placeholder;
|
||||
// Force view update so placeholder re-renders
|
||||
editor.view.dispatch(editor.state.tr);
|
||||
}, [editor, placeholder]);
|
||||
|
||||
// Expose imperative API
|
||||
useImperativeHandle(ref, () => ({
|
||||
getText: () => editor?.state.doc.textContent ?? "",
|
||||
setText: (text: string) => {
|
||||
editor?.commands.setContent(text ? `<p>${text}</p>` : "");
|
||||
},
|
||||
focus: () => editor?.commands.focus(),
|
||||
clear: () => editor?.commands.clearContent(),
|
||||
}), [editor]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!editor) return;
|
||||
const text = editor.state.doc.textContent;
|
||||
if (!text.trim()) return;
|
||||
onSubmit?.(text);
|
||||
editor.commands.clearContent();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"chat-input-editor bg-card rounded-xl p-3 border border-border transition-colors",
|
||||
disabled && "is-disabled cursor-not-allowed opacity-60",
|
||||
)}>
|
||||
<EditorContent editor={editor} />
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button size="icon" onClick={handleSubmit} disabled={disabled}>
|
||||
<HugeiconsIcon strokeWidth={2.5} icon={ArrowUpIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue