feat: 使用contenteditable实现图片标签和文字混合输入
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
de63528cc1
commit
923a2288da
@ -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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user