import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react"; import { createPortal } from "react-dom"; import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, UserRound, Video, Workflow } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category"; import { useThemeStore } from "@/stores/use-theme-store"; import { buildAssetMentionReferences, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { useAssetStore, type AssetCategory } from "@/stores/use-asset-store"; import { CanvasNodeType } from "@/types/canvas"; import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references"; type MentionState = { start: number; end: number; query: string; }; type EditableSelection = { start: number; end: number; }; type MentionTextPart = | { type: "text"; text: string; } | { type: "mention"; token: string; reference: CanvasResourceReference; }; type Props = Omit, "onChange" | "value"> & { value: string; references: CanvasResourceReference[]; onChange: (value: string) => void; onSubmit?: () => void; containerClassName?: string; highlightLabels?: boolean; mentionMenuWidth?: number; sendOnEnter?: boolean; onContentSizeChange?: (height: number) => void; includeAssetLibrary?: boolean; activeDropReferenceId?: string | null; onReferenceFilesDrop?: (reference: CanvasResourceReference, files: File[]) => void; }; export const CanvasResourceMentionTextarea = forwardRef(function CanvasResourceMentionTextarea( { value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, activeDropReferenceId, onReferenceFilesDrop, ...props }, forwardedRef, ) { const rawTheme = useThemeStore((state) => state.theme); const assets = useAssetStore((state) => state.assets); const theme = canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark; const containerRef = useRef(null); const textareaRef = useRef(null); const editorRef = useRef(null); const composingRef = useRef(false); const pendingSelectionRef = useRef(null); const lastRenderedValueRef = useRef(""); const [mention, setMention] = useState(null); const [activeIndex, setActiveIndex] = useState(-1); const [nativeDropReferenceId, setNativeDropReferenceId] = useState(null); const canvasReferences = useResolvedCanvasResourceReferences(references); const rawAssetReferences = useMemo(() => includeAssetLibrary ? buildAssetMentionReferences(assets) : [], [assets, includeAssetLibrary]); const assetReferences = useResolvedCanvasResourceReferences(rawAssetReferences); const activeCanvasReferences = useMemo(() => canvasReferences.filter((item) => item.active), [canvasReferences]); const availableReferences = useMemo(() => [...activeCanvasReferences, ...assetReferences], [activeCanvasReferences, assetReferences]); const candidates = useMemo(() => { if (!mention) return []; const query = mention.query.trim().toLowerCase(); if (!query) return activeCanvasReferences; return availableReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.category || ""} ${item.text || ""}`.toLowerCase().includes(query)); }, [activeCanvasReferences, availableReferences, mention]); const activeReferences = useMemo(() => { if (!highlightLabels) return []; return [...activeCanvasReferences, ...assetReferences.filter((item) => value.includes(canvasResourceMentionToken(item)))]; }, [activeCanvasReferences, assetReferences, highlightLabels, value]); const useRichEditor = Boolean(activeReferences.length); const reportContentSize = useCallback((element: HTMLElement | null) => { if (!element || !onContentSizeChange) return; const previous = { height: element.style.height, minHeight: element.style.minHeight, maxHeight: element.style.maxHeight, overflow: element.style.overflow }; element.style.height = "1px"; element.style.minHeight = "0"; element.style.maxHeight = "none"; element.style.overflow = "hidden"; const height = Math.ceil(element.scrollHeight); element.style.height = previous.height; element.style.minHeight = previous.minHeight; element.style.maxHeight = previous.maxHeight; element.style.overflow = previous.overflow; onContentSizeChange(height); }, [onContentSizeChange]); useLayoutEffect(() => { if (!useRichEditor) return; const editor = editorRef.current; if (!editor || composingRef.current) return; const isFocused = document.activeElement === editor; const currentValue = serializeEditableValue(editor); if (currentValue === value && lastRenderedValueRef.current === value) { pendingSelectionRef.current = null; return; } const selection = pendingSelectionRef.current ?? (isFocused ? getEditableSelection(editor)?.start ?? null : null); renderEditableContent(editor, value, activeReferences); lastRenderedValueRef.current = value; if (isFocused && selection !== null) setEditableSelection(editor, selection); pendingSelectionRef.current = null; reportContentSize(editor); }, [activeReferences, reportContentSize, useRichEditor, value]); useLayoutEffect(() => { const editor = editorRef.current; if (!editor) return; const dropReferenceId = nativeDropReferenceId || activeDropReferenceId || ""; editor.querySelectorAll("[data-mention-reference-id]").forEach((chip) => { chip.classList.toggle("is-replace-target", Boolean(dropReferenceId) && chip.dataset.mentionReferenceId === dropReferenceId); }); }, [activeDropReferenceId, nativeDropReferenceId, useRichEditor, value]); useLayoutEffect(() => { const element = useRichEditor ? editorRef.current : textareaRef.current; const container = containerRef.current; if (!element || !container || !onContentSizeChange) return; reportContentSize(element); let width = container.clientWidth; const observer = new ResizeObserver(() => { const nextWidth = container.clientWidth; if (nextWidth === width) return; width = nextWidth; reportContentSize(element); }); observer.observe(container); return () => observer.disconnect(); }, [onContentSizeChange, reportContentSize, useRichEditor, value]); const focusEditor = (selectionStart?: number) => { requestAnimationFrame(() => { if (useRichEditor) { const editor = editorRef.current; if (!editor) return; editor.focus(); if (typeof selectionStart === "number") setEditableSelection(editor, selectionStart); return; } textareaRef.current?.focus(); if (typeof selectionStart === "number") textareaRef.current?.setSelectionRange(selectionStart, selectionStart); }); }; const updateValue = (next: string, selectionStart?: number) => { if (typeof selectionStart === "number") pendingSelectionRef.current = selectionStart; onChange(next); if (typeof selectionStart === "number") focusEditor(selectionStart); }; const closeMention = () => { setMention(null); setActiveIndex(-1); }; const syncMention = (nextValue: string, cursor: number) => { const prefix = nextValue.slice(0, cursor); const match = /@([^\s@,.;:!?,。;:!?、)\]}】)]*)$/.exec(prefix); if (!match || !availableReferences.length) { closeMention(); return; } const nextMention = { start: match.index, end: cursor, query: match[1] }; const isSameMention = mention?.start === nextMention.start && mention.end === nextMention.end && mention.query === nextMention.query; if (!isSameMention) { setMention(nextMention); setActiveIndex(-1); } }; const insertReference = (reference: CanvasResourceReference) => { if (!mention) return; const insertText = `${canvasResourceMentionToken(reference)} `; const next = `${value.slice(0, mention.start)}${insertText}${value.slice(mention.end)}`; closeMention(); updateValue(next, mention.start + insertText.length); }; const replaceEditableSelection = (insertText: string) => { const currentValue = editorRef.current ? serializeEditableValue(editorRef.current) : value; const selection = getEditableSelection(editorRef.current) || { start: currentValue.length, end: currentValue.length }; const next = `${currentValue.slice(0, selection.start)}${insertText}${currentValue.slice(selection.end)}`; const cursor = selection.start + insertText.length; updateValue(next, cursor); syncMention(next, cursor); }; const syncEditableValue = () => { if (composingRef.current) return; const editor = editorRef.current; if (!editor) return; const next = serializeEditableValue(editor); const cursor = getEditableSelection(editor)?.start ?? next.length; pendingSelectionRef.current = cursor; lastRenderedValueRef.current = next; onChange(next); syncMention(next, cursor); reportContentSize(editor); }; const syncEditableMentionFromSelection = () => { const editor = editorRef.current; if (!editor) return; const cursor = getEditableSelection(editor)?.start; if (typeof cursor === "number") syncMention(serializeEditableValue(editor), cursor); }; const referenceForDropTarget = (target: EventTarget | null) => { const element = target instanceof Element ? target.closest("[data-mention-reference-id]") : null; const referenceId = element?.dataset.mentionReferenceId; return referenceId ? activeReferences.find((reference) => reference.id === referenceId) : undefined; }; const imageFilesFromTransfer = (event: DragEvent) => Array.from(event.dataTransfer.files).filter((file) => file.type.startsWith("image/")); const mergedStyle = { ...(style || {}), caretColor: style?.color || theme.node.text, } as CSSProperties; const menuAnchor = useRichEditor ? editorRef.current : textareaRef.current; const menu = mention && availableReferences.length && menuAnchor ? ( = 0 ? candidates[Math.min(activeIndex, candidates.length - 1)]?.id : undefined} preferredWidth={mentionMenuWidth} onQueryChange={(query) => setMention((current) => current ? { ...current, query } : current)} onClose={closeMention} onSelect={insertReference} /> ) : null; if (useRichEditor) { return (
{!value && props.placeholder ? (
{props.placeholder}
) : null}
{ composingRef.current = true; props.onCompositionStart?.(event as unknown as React.CompositionEvent); }} onCompositionEnd={(event) => { composingRef.current = false; syncEditableValue(); props.onCompositionEnd?.(event as unknown as React.CompositionEvent); }} onPaste={(event: ClipboardEvent) => { event.preventDefault(); replaceEditableSelection(event.clipboardData.getData("text/plain")); }} onDragOver={(event: DragEvent) => { const reference = referenceForDropTarget(event.target); const hasImageFile = Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && (!item.type || item.type.startsWith("image/"))) || Array.from(event.dataTransfer.files).some((file) => file.type.startsWith("image/")); if (!onReferenceFilesDrop || reference?.kind !== "image" || !hasImageFile) { setNativeDropReferenceId(null); props.onDragOver?.(event as unknown as React.DragEvent); return; } event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setNativeDropReferenceId(reference.id); props.onDragOver?.(event as unknown as React.DragEvent); }} onDragLeave={(event: DragEvent) => { const bounds = event.currentTarget.getBoundingClientRect(); if (event.clientX <= bounds.left || event.clientX >= bounds.right || event.clientY <= bounds.top || event.clientY >= bounds.bottom) setNativeDropReferenceId(null); props.onDragLeave?.(event as unknown as React.DragEvent); }} onDrop={(event: DragEvent) => { const reference = referenceForDropTarget(event.target); const files = imageFilesFromTransfer(event); setNativeDropReferenceId(null); if (onReferenceFilesDrop && reference?.kind === "image" && files.length) { event.preventDefault(); onReferenceFilesDrop(reference, files); } props.onDrop?.(event as unknown as React.DragEvent); }} onKeyDown={(event: KeyboardEvent) => { if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return; if (mention && candidates.length) { if (event.key === "ArrowDown") { event.preventDefault(); setActiveIndex((index) => index < 0 ? 0 : (index + 1) % candidates.length); return; } if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((index) => index < 0 ? candidates.length - 1 : (index - 1 + candidates.length) % candidates.length); return; } if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); insertReference(candidates[activeIndex < 0 ? 0 : Math.min(activeIndex, candidates.length - 1)]); return; } if (event.key === "Escape") { event.preventDefault(); closeMention(); return; } } if (event.key === "Enter") { event.preventDefault(); const shouldSubmit = sendOnEnter ? !event.ctrlKey && !event.metaKey && !event.shiftKey : (event.ctrlKey || event.metaKey) && !event.shiftKey; if (onSubmit && shouldSubmit) { onSubmit(); return; } replaceEditableSelection("\n"); return; } onKeyDown?.(event as unknown as React.KeyboardEvent); }} onKeyUp={(event) => { syncEditableMentionFromSelection(); props.onKeyUp?.(event as unknown as React.KeyboardEvent); }} onMouseDown={(event) => props.onMouseDown?.(event as unknown as React.MouseEvent)} onPointerDown={(event) => props.onPointerDown?.(event as unknown as React.PointerEvent)} onPointerUp={(event) => { syncEditableMentionFromSelection(); props.onPointerUp?.(event as unknown as React.PointerEvent); }} onSelect={(event) => props.onSelect?.(event as unknown as React.SyntheticEvent)} onWheel={(event) => { event.stopPropagation(); props.onWheel?.(event as unknown as React.WheelEvent); }} onScroll={(event) => props.onScroll?.(event as unknown as React.UIEvent)} onFocus={(event) => props.onFocus?.(event as unknown as React.FocusEvent)} onBlur={(event) => { if (event.relatedTarget instanceof Element && event.relatedTarget.closest("[data-canvas-resource-mention-menu]")) return; window.setTimeout(() => { if (document.activeElement?.closest("[data-canvas-resource-mention-menu]")) return; closeMention(); }, 120); props.onBlur?.(event as unknown as React.FocusEvent); }} >
{menu}
); } return (