import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react"; import { createPortal } from "react-dom"; import { Image } from "antd"; 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 Props = { value: string; references: CanvasResourceReference[]; onChange: (value: string) => void; onSubmit?: () => void; className?: string; style?: CSSProperties; placeholder?: string; }; type MentionState = { query: string; rect: DOMRect | null; }; type Token = | { type: "text"; value: string } | { type: "reference"; label: string }; // 提示词面板专用的 contentEditable 输入框:@ 引用图片时直接内嵌真实缩略图 chip,而不是「图片1」文字。 // 序列化时 chip → 引用 label 文本(如「图片1」),保证发给生成的 value 语义与旧 textarea 版一致。 export function CanvasPromptChipInput({ value, references, onChange, onSubmit, className, style, placeholder }: Props) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const editorRef = useRef(null); const composingRef = useRef(false); // 记录我们最近一次向父级 emit 的 value。聚焦时若 value 与它一致,说明是本组件输入的回声, // 跳过重建以免打断光标 / IME;若不一致(如发送后父级把 prompt 清空、或从提示词库插入),即使聚焦也要重建。 const lastEmittedRef = useRef(value); const [mention, setMention] = useState(null); const [activeIndex, setActiveIndex] = useState(0); const [imagePreview, setImagePreview] = useState(null); const activeReferences = useMemo(() => references.filter((item) => item.active), [references]); const referenceByLabel = useMemo(() => new Map(activeReferences.map((item) => [item.label, item])), [activeReferences]); // 长 label 优先匹配,避免「图片1」把「图片10」切坏。 const activeLabels = useMemo(() => Array.from(new Set(activeReferences.map((item) => item.label))).sort((a, b) => b.length - a.length), [activeReferences]); const tokens = useMemo(() => parseTokens(value, activeLabels), [value, activeLabels]); const candidates = useMemo(() => { if (!mention) return []; const query = mention.query.trim().toLowerCase(); if (!query) return activeReferences; return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query)); }, [mention, activeReferences]); // DOM ← value:未聚焦时按 value 重建;聚焦时仅当 value 是外部改动(非本组件回声)才重建。 useEffect(() => { const editor = editorRef.current; if (!editor) return; if (document.activeElement === editor && value === lastEmittedRef.current) return; editor.textContent = ""; tokens.forEach((token) => { if (token.type === "text") { editor.append(document.createTextNode(token.value)); return; } const reference = referenceByLabel.get(token.label); if (reference) editor.append(createReferenceChip(reference, theme, setImagePreview)); else editor.append(document.createTextNode(token.label)); }); lastEmittedRef.current = value; }, [tokens, referenceByLabel, theme, value]); const emit = (next: string) => { lastEmittedRef.current = next; onChange(next); }; const syncFromEditor = () => { const editor = editorRef.current; if (!editor) return; emit(serializeEditor(editor)); syncMention(); }; const syncMention = () => { const text = textBeforeCaret(); const match = /@([^\s@]*)$/.exec(text); if (!match || !activeReferences.length) { closeMention(); return; } setMention({ query: match[1] || "", rect: caretRect() }); setActiveIndex(0); }; const closeMention = () => { setMention(null); setActiveIndex(0); }; const insertReference = (reference: CanvasResourceReference) => { const editor = editorRef.current; if (!editor) return; removeActiveMention(); const chip = createReferenceChip(reference, theme, setImagePreview); const space = document.createTextNode(" "); const selection = window.getSelection(); const range = selection?.rangeCount ? selection.getRangeAt(0) : null; if (range) { range.insertNode(space); range.insertNode(chip); range.setStartAfter(space); range.collapse(true); selection?.removeAllRanges(); selection?.addRange(range); } else { editor.append(chip, space); placeCaretAtEnd(editor); } closeMention(); emit(serializeEditor(editor)); }; const showPlaceholder = !value.trim(); return (
{showPlaceholder && placeholder ? (
{placeholder}
) : null}
{ if (!composingRef.current) syncFromEditor(); }} onCompositionStart={() => { composingRef.current = true; }} onCompositionEnd={() => { composingRef.current = false; syncFromEditor(); }} onKeyDown={(event: KeyboardEvent) => { event.stopPropagation(); if (isImeComposing(event)) return; if (mention && candidates.length) { if (event.key === "ArrowDown") { event.preventDefault(); setActiveIndex((index) => (index + 1) % candidates.length); return; } if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length); return; } if (event.key === "Enter") { event.preventDefault(); insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]); return; } if (event.key === "Escape") { event.preventDefault(); closeMention(); return; } } if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) { event.preventDefault(); requestAnimationFrame(syncFromEditor); return; } if (isPlainEnterKey(event) && onSubmit) { event.preventDefault(); onSubmit(); return; } requestAnimationFrame(syncMention); }} onBlur={() => window.setTimeout(closeMention, 120)} /> {mention && candidates.length ? ( ) : null} {imagePreview ? 引用图片预览 !visible && setImagePreview(null) }} /> : null}
); } function MentionMenu({ rect, references, activeIndex, theme, onSelect }: { rect: DOMRect | null; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) { const selectedRef = useRef(false); const activeItemRef = useRef(null); useEffect(() => { activeItemRef.current?.scrollIntoView({ block: "nearest" }); }, [activeIndex, references]); const selectReference = (reference: CanvasResourceReference) => { if (selectedRef.current) return; selectedRef.current = true; onSelect(reference); }; const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation(); const menuWidth = 256; const maxMenuHeight = 224; const gap = 6; const anchor = rect || new DOMRect(16, 16, 0, 0); const left = clamp(anchor.left, 8, window.innerWidth - menuWidth - 8); const showAbove = anchor.bottom + gap + maxMenuHeight > window.innerHeight && anchor.top - gap - maxMenuHeight >= 0; const top = showAbove ? anchor.top - gap - maxMenuHeight : anchor.bottom + gap; return createPortal(
event.stopPropagation()} > {references.map((reference, index) => ( ))}
, document.body, ); } function ReferencePreview({ reference }: { reference: CanvasResourceReference }) { if (reference.kind === "image" && reference.previewUrl) return ; if (reference.kind === "video" && reference.previewUrl) return