import { useEffect, useRef, useCallback, useState } from 'react'; import { Paperclip } from 'lucide-react'; import { cn } from '@/lib/utils'; import { getImmersiveTextStyle } from '@/lib/immersive-text'; import { hasMarkdownFormatting } from '@/lib/markdown'; import { metaKey } from '@/lib/platform'; import { useAllLinkMetadata } from '@/hooks/use-link-metadata'; import { AttachmentStrip } from '@/features/compose/attachment-strip'; import type { PendingAttachment } from '@/features/compose/attachment-strip'; import { Button } from '@/components/ui/button'; import { KeyHint } from '@/components/key-hint'; import { MarkdownEditor } from '@/features/compose/markdown-editor'; export interface TextEditorAttachmentProps { attachments: PendingAttachment[]; onRemoveAttachment: (id: string) => void; onAddFiles: () => void; isDragging: boolean; dropZoneProps: { onDragOver: (e: React.DragEvent) => void; onDragEnter: (e: React.DragEvent) => void; onDragLeave: (e: React.DragEvent) => void; onDrop: (e: React.DragEvent) => void; }; } interface TextEditorProps { textContent: string; onTextChange: (text: string) => void; onSubmit: () => void; onCancel: () => void; /** Label on the ⌘+Enter hint. Defaults to "next". */ submitHint?: string; /** When omitted, the editor renders without attachment support (no attach button, no drop zone, no strip). */ attachmentProps?: TextEditorAttachmentProps; } const IMMERSIVE_CHAR_LIMIT = 120; export function TextEditor({ textContent, onTextChange, onSubmit, onCancel, submitHint = 'next', attachmentProps, }: TextEditorProps) { const textareaRef = useRef(null); // Card mode latches: once the message needs the full editor, snapping back // to immersive mid-edit would be jarring, so it stays for the session. const [carded, setCarded] = useState(false); const [debouncedText, setDebouncedText] = useState(textContent); useEffect(() => { const t = setTimeout(() => setDebouncedText(textContent), 500); return () => clearTimeout(t); }, [textContent]); const linkPreviews = useAllLinkMetadata(debouncedText); const attachmentCount = attachmentProps?.attachments.length ?? 0; const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0; const shouldCard = !carded && (textContent.length >= IMMERSIVE_CHAR_LIMIT || hasEnrichments || hasMarkdownFormatting(textContent)); if (shouldCard) setCarded(true); const immersive = !carded; // Keep the immersive textarea focused with the caret at the end on mount. // The card-mode editor manages its own focus. useEffect(() => { if (!immersive) return; const t = setTimeout(() => { const el = textareaRef.current; if (el) { el.focus(); el.selectionStart = el.selectionEnd = el.value.length; } }, 0); return () => clearTimeout(t); }, [immersive]); // Attached in the capture phase so our shortcuts win before the card-mode // editor's own key handling (e.g. ⌘+Enter must submit, not insert a break). const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel(); } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.stopPropagation(); if (textContent.trim()) onSubmit(); } else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.stopPropagation(); setCarded(true); } }, [onCancel, onSubmit, textContent], ); const strip = attachmentProps && ( ); const keyboardHints = (
cancel { if (textContent.trim()) onSubmit(); }} title={`Submit (or press ${metaKey}+Enter)`} > {submitHint} {immersive && ( setCarded(true)} title={`Switch to markdown editor (or press ${metaKey}+M)`} > markdown )} {attachmentProps && ( )}
); const dropZoneProps = attachmentProps?.dropZoneProps; const isDragging = attachmentProps?.isDragging ?? false; if (immersive) { const style = getImmersiveTextStyle(textContent.length); return (