diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 8502f8c..6aa83ad 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -9,7 +9,7 @@ import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; import { CanvasPromptLibrary } from "./canvas-prompt-library"; import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; -import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; +import { CanvasPromptChipInput } from "./canvas-prompt-chip-input"; import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover"; import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; @@ -63,7 +63,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} > - {showImageInfo && hasImageContent ? : null} - {resourceLabel ? : null} {!isGroup && !hasImageContent && !hasVideoContent && !hasAudioContent ?
: null} @@ -568,14 +565,6 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere ); } -function ResourceLabelBadge({ reference }: { reference: CanvasResourceReference }) { - return ( - - {reference.label} - - ); -} - function ImageNodeContent(props: NodeContentRendererProps) { if (!props.node.metadata?.content && props.isBatchRoot) { const content = diff --git a/web/src/components/canvas/canvas-prompt-chip-input.tsx b/web/src/components/canvas/canvas-prompt-chip-input.tsx new file mode 100644 index 0000000..27d9357 --- /dev/null +++ b/web/src/components/canvas/canvas-prompt-chip-input.tsx @@ -0,0 +1,425 @@ +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