feat: 使用contenteditable实现图片标签和文字混合输入

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name 2026-02-01 17:28:27 +08:00
parent de63528cc1
commit 923a2288da

View File

@ -121,7 +121,42 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src
); );
// 当选中画布中的图片时,自动添加到附件 // 🆕 辅助函数:在 contenteditable 中插入图片标签
const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv || !editableDiv.isContentEditable) return;
// 检查是否已存在该 URL 的标签
const existing = editableDiv.querySelector(`[data-attachment-url="${url}"]`);
if (existing) return;
const tagSpan = document.createElement('span');
tagSpan.contentEditable = 'false';
tagSpan.setAttribute('data-attachment-index', String(index));
tagSpan.setAttribute('data-attachment-url', url);
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name;
tagSpan.innerHTML = `
<img src="${url}" alt="" class="h-5 w-5 object-cover rounded-full border border-border/50" />
<span class="text-xs text-foreground/80">${shortName}</span>
`;
// 在光标位置插入,或添加到末尾
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && editableDiv.contains(selection.anchorNode)) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(tagSpan);
range.setStartAfter(tagSpan);
range.setEndAfter(tagSpan);
selection.removeAllRanges();
selection.addRange(range);
} else {
editableDiv.appendChild(tagSpan);
}
}, []);
// 当选中画布中的图片时,自动添加到附件并插入标签
useEffect(() => { useEffect(() => {
if (selectedImageNodes.length > 0) { if (selectedImageNodes.length > 0) {
const newAttachments: Attachment[] = selectedImageNodes.map(node => { const newAttachments: Attachment[] = selectedImageNodes.map(node => {
@ -138,12 +173,16 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const existingUrls = new Set(prev.map(a => a.url)); const existingUrls = new Set(prev.map(a => a.url));
const toAdd = newAttachments.filter(a => !existingUrls.has(a.url)); const toAdd = newAttachments.filter(a => !existingUrls.has(a.url));
if (toAdd.length > 0) { if (toAdd.length > 0) {
// 插入图片标签到编辑器
toAdd.forEach((att, i) => {
insertImageTagInEditor(att.url, att.name, prev.length + i);
});
return [...prev, ...toAdd]; return [...prev, ...toAdd];
} }
return prev; return prev;
}); });
} }
}, [selectedImageNodes.length, selectedNodeIds]); }, [selectedImageNodes.length, selectedNodeIds, insertImageTagInEditor]);
// 获取模型列表 // 获取模型列表
const { data: modelsData } = useSWR<{ const { data: modelsData } = useSWR<{
@ -534,12 +573,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
firstImageUrl = imageUrl; firstImageUrl = imageUrl;
} }
// 添加到附件列表 // 添加到附件列表并在编辑器中插入标签
setAttachments(prev => [...prev, { setAttachments(prev => {
url: imageUrl, const newIndex = prev.length;
name: file.name, // 在编辑器中插入图片标签
contentType: file.type, setTimeout(() => {
}]); insertImageTagInEditor(imageUrl, file.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [...prev, {
url: imageUrl,
name: file.name,
contentType: file.type,
}];
});
// 立即将图片添加到画布 // 立即将图片添加到画布
const existingNodes = nodes.length; const existingNodes = nodes.length;
@ -877,52 +924,59 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</div> </div>
</ScrollArea> </ScrollArea>
{/* 底部输入区域 - Redesigned */} {/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
<div className="p-4 bg-background/80 backdrop-blur-md border-t"> <div className="p-4 bg-background/80 backdrop-blur-md border-t">
<div className="relative bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background"> <div className="relative bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background">
{/* 混合输入区域:使用 contenteditable 实现图片标签和文字混合 */} {/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div className="relative px-4 pt-3 pb-12 min-h-[80px]"> <div className="relative px-4 pt-3 pb-12 min-h-[60px]">
{/* 图片标签显示在输入框上方 */} <div
{attachments.length > 0 && ( ref={textareaRef as any}
<div className="flex flex-wrap gap-2 mb-2"> contentEditable={!isLoading}
{attachments.map((att, index) => ( suppressContentEditableWarning
<div className="min-h-[40px] max-h-[120px] overflow-y-auto text-sm outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/50"
key={index} data-placeholder="请输入你的设计需求,例如:绿色椅子 替换 Image"
className="inline-flex items-center gap-1.5 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-1 shrink-0 transition-colors" onInput={(e) => {
> // 从 contenteditable 中提取纯文本(排除图片标签的 data-url)
<img const target = e.target as HTMLDivElement;
src={att.url} // 获取所有文本节点的内容
alt="" let textContent = '';
className="h-6 w-6 object-cover rounded-full border border-border/50" const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, null);
/> let node;
<span className="text-xs text-foreground/80 max-w-[100px] truncate"> while (node = walker.nextNode()) {
{att.name || 'Image'} textContent += node.textContent;
</span> }
<button setInput(textContent.trim());
className="h-4 w-4 flex items-center justify-center rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" }}
onClick={() => removeAttachment(index)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* 文本输入 */}
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
} }
// 处理 Backspace 删除图片标签
if (e.key === 'Backspace') {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.collapsed && range.startOffset === 0) {
// 检查前一个兄弟节点是否是图片标签
const prevSibling = range.startContainer.previousSibling as HTMLElement;
if (prevSibling && prevSibling.getAttribute?.('data-attachment-index')) {
e.preventDefault();
const index = parseInt(prevSibling.getAttribute('data-attachment-index') || '0');
removeAttachment(index);
prevSibling.remove();
}
}
}
}
}} }}
placeholder="请输入你的设计需求,例如:把椅子颜色改成红色" onPaste={(e) => {
className="min-h-[40px] max-h-[100px] w-full text-sm resize-none bg-transparent border-none focus-visible:ring-0 p-0 placeholder:text-muted-foreground/50" // 只粘贴纯文本
disabled={isLoading} e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
/> />
</div> </div>