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
);
// 当选中画布中的图片时,自动添加到附件
// 🆕 辅助函数:在 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(() => {
if (selectedImageNodes.length > 0) {
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 toAdd = newAttachments.filter(a => !existingUrls.has(a.url));
if (toAdd.length > 0) {
// 插入图片标签到编辑器
toAdd.forEach((att, i) => {
insertImageTagInEditor(att.url, att.name, prev.length + i);
});
return [...prev, ...toAdd];
}
return prev;
});
}
}, [selectedImageNodes.length, selectedNodeIds]);
}, [selectedImageNodes.length, selectedNodeIds, insertImageTagInEditor]);
// 获取模型列表
const { data: modelsData } = useSWR<{
@ -534,12 +573,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
firstImageUrl = imageUrl;
}
// 添加到附件列表
setAttachments(prev => [...prev, {
url: imageUrl,
name: file.name,
contentType: file.type,
}]);
// 添加到附件列表并在编辑器中插入标签
setAttachments(prev => {
const newIndex = prev.length;
// 在编辑器中插入图片标签
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;
@ -877,52 +924,59 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</div>
</ScrollArea>
{/* 底部输入区域 - Redesigned */}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
<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">
{/* 混合输入区域:使用 contenteditable 实现图片标签和文字混合 */}
<div className="relative px-4 pt-3 pb-12 min-h-[80px]">
{/* 图片标签显示在输入框上方 */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{attachments.map((att, index) => (
<div
key={index}
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"
>
<img
src={att.url}
alt=""
className="h-6 w-6 object-cover rounded-full border border-border/50"
/>
<span className="text-xs text-foreground/80 max-w-[100px] truncate">
{att.name || 'Image'}
</span>
<button
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)}
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div className="relative px-4 pt-3 pb-12 min-h-[60px]">
<div
ref={textareaRef as any}
contentEditable={!isLoading}
suppressContentEditableWarning
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"
data-placeholder="请输入你的设计需求,例如:绿色椅子 替换 Image"
onInput={(e) => {
// 从 contenteditable 中提取纯文本(排除图片标签的 data-url)
const target = e.target as HTMLDivElement;
// 获取所有文本节点的内容
let textContent = '';
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, null);
let node;
while (node = walker.nextNode()) {
textContent += node.textContent;
}
setInput(textContent.trim());
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
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="请输入你的设计需求,例如:把椅子颜色改成红色"
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}
onPaste={(e) => {
// 只粘贴纯文本
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
/>
</div>