diff --git a/packages/ui/package.json b/packages/ui/package.json index a1299e4d..3bad8caa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "type": "module", + "sideEffects": ["**/*.css"], "exports": { "./globals.css": "./src/styles/globals.css", "./postcss.config": "./postcss.config.mjs", @@ -18,6 +19,10 @@ "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.4", "@multica/store": "workspace:*", + "@tiptap/extension-placeholder": "^3.19.0", + "@tiptap/pm": "^3.19.0", + "@tiptap/react": "^3.19.0", + "@tiptap/starter-kit": "^3.19.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "linkify-it": "^5.0.0", diff --git a/packages/ui/src/components/chat-input.css b/packages/ui/src/components/chat-input.css new file mode 100644 index 00000000..f81b734e --- /dev/null +++ b/packages/ui/src/components/chat-input.css @@ -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; +} diff --git a/packages/ui/src/components/chat-input.tsx b/packages/ui/src/components/chat-input.tsx index ce5a3e94..2e4573b9 100644 --- a/packages/ui/src/components/chat-input.tsx +++ b/packages/ui/src/components/chat-input.tsx @@ -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(null); +export const ChatInput = forwardRef( + 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 ( -
-