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
|
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>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user