import { forwardRef, useMemo, useRef, useState } from "react"; import type { CSSProperties, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react"; import { createPortal } from "react-dom"; import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { isImeComposing, isPlainEnterKey } from "@/lib/keyboard-event"; import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; type MentionState = { start: number; query: string; }; type Props = Omit, "onChange" | "value"> & { value: string; references: CanvasResourceReference[]; onChange: (value: string) => void; onSubmit?: () => void; containerClassName?: string; highlightLabels?: boolean; }; export const CanvasResourceMentionTextarea = forwardRef(function CanvasResourceMentionTextarea({ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, ...props }, forwardedRef) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const textareaRef = useRef(null); const overlayRef = useRef(null); const [mention, setMention] = useState(null); const [activeIndex, setActiveIndex] = useState(0); const [hasSelection, setHasSelection] = useState(false); const candidates = useMemo(() => { if (!mention) return []; const query = mention.query.trim().toLowerCase(); const activeReferences = references.filter((item) => item.active); if (!query) return activeReferences; return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query)); }, [mention, references]); const activeLabels = useMemo(() => (highlightLabels ? Array.from(new Set(references.filter((item) => item.active).map((item) => item.label))).sort((a, b) => b.length - a.length) : []), [highlightLabels, references]); const updateValue = (next: string, selectionStart?: number) => { onChange(next); if (typeof selectionStart !== "number") return; requestAnimationFrame(() => { textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(selectionStart, selectionStart); }); }; const closeMention = () => { setMention(null); setActiveIndex(0); }; const syncMention = (nextValue: string, cursor: number) => { const prefix = nextValue.slice(0, cursor); const match = /(^|\s)@([^\s@]*)$/.exec(prefix); if (!match || !references.some((item) => item.active)) { closeMention(); return; } setMention({ start: cursor - match[2].length - 1, query: match[2] }); setActiveIndex(0); }; const insertReference = (reference: CanvasResourceReference) => { if (!mention) return; const textarea = textareaRef.current; const end = textarea?.selectionStart ?? value.length; const insertText = `${reference.label} `; const next = `${value.slice(0, mention.start)}${insertText}${value.slice(end)}`; closeMention(); updateValue(next, mention.start + insertText.length); }; const syncOverlayScroll = () => { if (!overlayRef.current || !textareaRef.current) return; overlayRef.current.scrollTop = textareaRef.current.scrollTop; overlayRef.current.scrollLeft = textareaRef.current.scrollLeft; }; const updateSelectionState = () => { const textarea = textareaRef.current; setHasSelection(Boolean(textarea && textarea.selectionStart !== textarea.selectionEnd)); }; const showOverlay = Boolean(activeLabels.length && !hasSelection); const mergedStyle = { ...(style || {}), color: showOverlay ? "transparent" : style?.color, caretColor: style?.color || theme.node.text, ...(showOverlay ? { background: "transparent", backgroundColor: "transparent" } : {}), } as CSSProperties; const menu = mention && candidates.length && textareaRef.current ? : null; return (
{showOverlay ? (
) : null}