import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react"; import { Button, Image } from "antd"; import { FileText, Image as ImageIcon, Music2, Pencil, Sparkles, Video, X } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { NodeGenerationInput } from "./canvas-node-generation"; import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools"; import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker"; import type { CanvasGenerationMode, CanvasNodeMetadata, CanvasWorkspaceMode } from "@/types/canvas"; type CanvasConfigComposerProps = { value: string; inputs: NodeGenerationInput[]; skillReferences?: CanvasResourceReference[]; generationMode?: CanvasGenerationMode; metadata?: CanvasNodeMetadata; onChange: (value: string) => void; onMetadataChange?: (patch: Partial) => void; onClose: () => void; workspaceMode?: CanvasWorkspaceMode; }; type Token = | { type: "text"; value: string } | { type: "reference"; nodeId: string }; type MentionState = { query: string; }; type ComposerCandidate = | { kind: "input"; input: NodeGenerationInput; } | { kind: "skill"; reference: CanvasResourceReference; }; export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g; export function CanvasConfigComposer({ value, inputs, skillReferences = [], generationMode, metadata, onChange, onMetadataChange, onClose, workspaceMode = "professional" }: CanvasConfigComposerProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const editorRef = useRef(null); const composingRef = useRef(false); const [mention, setMention] = useState(null); const [activeIndex, setActiveIndex] = useState(0); const [imagePreview, setImagePreview] = useState(null); const [presetOpen, setPresetOpen] = useState(false); const simpleMode = workspaceMode === "simple"; const tokens = useMemo(() => parseComposerTokens(value), [value]); const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]); const videoFrameOptions = useMemo( () => inputs .filter((input) => input.type === "image" && input.image) .map((input) => ({ nodeId: input.nodeId, label: resourceLabel(input, inputs), title: input.title, previewUrl: input.image?.dataUrl })), [inputs], ); const candidates = useMemo(() => { if (!mention) return []; const query = (mention.query || "").trim().toLowerCase(); const items: ComposerCandidate[] = [...skillReferences.map((reference) => ({ kind: "skill" as const, reference })), ...inputs.map((input) => ({ kind: "input" as const, input }))]; if (!query) return items; return items.filter((item) => candidateSearchText(item, inputs).toLowerCase().includes(query)); }, [inputs, mention, skillReferences]); useEffect(() => { if (document.activeElement === editorRef.current) return; const editor = editorRef.current; if (!editor) return; editor.textContent = ""; tokens.forEach((token) => { if (token.type === "text") { editor.append(document.createTextNode(token.value)); return; } const input = referenceById.get(token.nodeId); if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview)); }); }, [inputs, referenceById, theme, tokens]); const syncFromEditor = () => { const editor = editorRef.current; if (!editor) return; const next = serializeEditor(editor); onChange(next); syncMention(); }; const syncMention = () => { const text = textBeforeCaret(); const match = /@([^\s@]*)$/.exec(text); if (!match || (!inputs.length && !skillReferences.length)) { closeMention(); return; } const nextQuery = match[1] || ""; if (mention?.query !== nextQuery) { setMention({ query: nextQuery }); setActiveIndex(0); } }; const closeMention = () => { setMention(null); setActiveIndex(0); }; const insertCandidate = (candidate: ComposerCandidate) => { const editor = editorRef.current; if (!editor) return; removeActiveMention(); const space = document.createTextNode(" "); const node = candidate.kind === "skill" ? document.createTextNode(`@${candidate.reference.label}`) : createReferenceChip(candidate.input, inputs, theme, setImagePreview); const selection = window.getSelection(); const range = selection?.rangeCount ? selection.getRangeAt(0) : null; if (range) { range.insertNode(space); range.insertNode(node); range.setStartAfter(space); range.collapse(true); selection?.removeAllRanges(); selection?.addRange(range); } else { editor.append(node, space); placeCaretAtEnd(editor); } closeMention(); onChange(serializeEditor(editor)); }; const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation(); const insertPreset = (preset: CanvasPromptPreset) => { const editor = editorRef.current; if (!editor) return; removeActiveSlash(editor); const selection = window.getSelection(); const range = selection?.rangeCount ? selection.getRangeAt(0) : null; const text = document.createTextNode(`${preset.prompt} `); if (range && editor.contains(range.commonAncestorContainer)) { range.insertNode(text); range.setStartAfter(text); range.collapse(true); selection?.removeAllRanges(); selection?.addRange(range); } else { editor.append(text); placeCaretAtEnd(editor); } onChange(serializeEditor(editor)); }; return (
event.stopPropagation()} >
{simpleMode ? "快速生成" : "组装提示词"}
{simpleMode ? "已连接素材会自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}
{simpleMode ? null : }
{generationMode === "video" && onMetadataChange && !simpleMode ? (
) : null}
{!value.trim() ?
输入提示词,按 @ 引用连接素材或技能
: null}
{ if (!composingRef.current) syncFromEditor(); }} onCompositionStart={() => { composingRef.current = true; }} onCompositionEnd={() => { composingRef.current = false; syncFromEditor(); }} onKeyDown={(event: KeyboardEvent) => { event.stopPropagation(); if (event.key === "/" && !mention) window.setTimeout(() => setPresetOpen(true)); 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(); insertCandidate(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; } requestAnimationFrame(syncMention); }} onBlur={() => window.setTimeout(closeMention, 120)} /> {mention && candidates.length ? : null}
{imagePreview ? 引用图片预览 !visible && setImagePreview(null) }} /> : null}
); } function removeActiveSlash(editor: HTMLDivElement) { const selection = window.getSelection(); const range = selection?.rangeCount ? selection.getRangeAt(0) : null; if (!range || !editor.contains(range.commonAncestorContainer)) return; const node = range.startContainer; if (node.nodeType !== Node.TEXT_NODE) return; const value = node.textContent || ""; const before = value.slice(0, range.startOffset); const match = /(^|\s)\/[\p{L}\p{N}_-]*$/u.exec(before); if (!match) return; node.textContent = `${before.slice(0, match.index)}${match[1]}${value.slice(range.startOffset)}`; range.setStart(node, match.index + match[1].length); range.collapse(true); selection?.removeAllRanges(); selection?.addRange(range); } function MentionMenu({ candidates, allInputs, activeIndex, theme, onSelect }: { candidates: ComposerCandidate[]; allInputs: NodeGenerationInput[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (candidate: ComposerCandidate) => void }) { const selectedRef = useRef(false); const activeItemRef = useRef(null); useEffect(() => { activeItemRef.current?.scrollIntoView({ block: "nearest" }); }, [activeIndex, candidates]); const selectInput = (candidate: ComposerCandidate) => { if (selectedRef.current) return; selectedRef.current = true; onSelect(candidate); }; return (
{candidates.map((candidate, index) => ( ))}
); } function ResourcePreview({ candidate }: { candidate: ComposerCandidate }) { if (candidate.kind === "skill") { return ( ); } const input = candidate.input; if (input.sourceKind === "drawing") { return ( ); } if (input.type === "image" && input.image) return ; if (input.type === "video" && input.video) return