diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index fa7d1002e..b021b4b2c 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -1239,6 +1239,15 @@ type CanvasUploadProgressOptions = { target?: CanvasComposerUploadProgress["target"]; }; +type ComposerUploadedImage = { + imageUrl: string; + name: string; + contentType: string; + assetId?: string; + width?: number; + height?: number; +}; + function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) { switch (phase) { case "preparing": @@ -3723,7 +3732,7 @@ export function CanvasChatPanel({ // 🆕 辅助函数:在 contenteditable 中插入图片标签 const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => { const editableDiv = textareaRef.current as HTMLDivElement | null; - if (!editableDiv || !editableDiv.isContentEditable) return; + if (!editableDiv) return; if (!isValidChatHttpUrl(url)) { console.warn('[CanvasChat] 跳过非 HTTP 图片标签:', String(url || '').slice(0, 40)); return; @@ -3742,6 +3751,7 @@ export function CanvasChatPanel({ tagSpan.contentEditable = 'false'; tagSpan.setAttribute('data-attachment-index', String(index)); tagSpan.setAttribute('data-attachment-url', url); + tagSpan.setAttribute('data-attachment-name', name); tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 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; @@ -3754,8 +3764,16 @@ export function CanvasChatPanel({ label.className = 'text-xs text-foreground/80'; label.textContent = shortName; + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.contentEditable = 'false'; + removeButton.setAttribute('data-attachment-remove', 'true'); + removeButton.className = 'ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full text-[11px] leading-none text-foreground/45 hover:bg-black/10 hover:text-foreground dark:hover:bg-white/15'; + removeButton.textContent = '×'; + tagSpan.appendChild(image); tagSpan.appendChild(label); + tagSpan.appendChild(removeButton); // 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入 const selection = window.getSelection(); @@ -3810,7 +3828,7 @@ export function CanvasChatPanel({ index: number ) => { const editableDiv = textareaRef.current as HTMLDivElement | null; - if (!editableDiv || !editableDiv.isContentEditable) return; + if (!editableDiv) return; // 检查 DOM 中是否已存在该物品的标签 const existingTags = editableDiv.querySelectorAll('[data-picked-object="true"]'); @@ -3829,6 +3847,7 @@ export function CanvasChatPanel({ tagSpan.setAttribute('data-picked-object', 'true'); tagSpan.setAttribute('data-object-name', objectName); tagSpan.setAttribute('data-object-position', objectPosition); + tagSpan.setAttribute('data-attachment-name', objectName); // 样式与普通图片标签一致 tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none'; // 使用图片缩略图,不使用 emoji 或图标 @@ -3837,6 +3856,7 @@ export function CanvasChatPanel({ ${objectName} · ${objectPosition} + `; // 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入 @@ -4072,6 +4092,7 @@ export function CanvasChatPanel({ const url = tagSpan.getAttribute('data-attachment-url'); const isPickedObject = tagSpan.getAttribute('data-picked-object') === 'true'; const objectName = tagSpan.getAttribute('data-object-name'); + const attachmentName = tagSpan.getAttribute('data-attachment-name'); if (url) { const rect = tagSpan.getBoundingClientRect(); @@ -4096,7 +4117,7 @@ export function CanvasChatPanel({ setTagPreview({ url, - name: objectName || tagSpan.textContent || '图片', + name: objectName || attachmentName || tagSpan.textContent || '图片', x: rect.left + rect.width / 2, y: rect.top, containerBounds, @@ -11631,10 +11652,64 @@ export function CanvasChatPanel({ } }, [chatId]); + const addUploadedImageAttachmentToComposer = useCallback( + (uploaded: ComposerUploadedImage) => { + setAttachments((prev) => { + if (prev.some((attachment) => attachment.url === uploaded.imageUrl)) { + return prev; + } + const newIndex = prev.length; + setTimeout(() => { + insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex); + (textareaRef.current as HTMLDivElement | null)?.focus(); + }, 0); + + return [ + ...prev, + { + url: uploaded.imageUrl, + name: uploaded.name, + contentType: uploaded.contentType, + assetId: uploaded.assetId, + width: uploaded.width, + height: uploaded.height, + }, + ]; + }); + }, + [insertImageTagInEditor] + ); + + const updateUploadedImageAttachmentDimensions = useCallback( + ( + imageUrl: string, + dimensions: { + width?: number; + height?: number; + originalWidth?: number; + originalHeight?: number; + } + ) => { + setAttachments((prev) => + prev.map((attachment) => + attachment.url === imageUrl + ? { + ...attachment, + width: dimensions.originalWidth || dimensions.width || attachment.width, + height: dimensions.originalHeight || dimensions.height || attachment.height, + } + : attachment + ) + ); + }, + [] + ); + const uploadImageFileAndAddToCanvas = useCallback(async ( file: File, nodeIndex: number, - progressOptions?: CanvasUploadProgressOptions + progressOptions?: CanvasUploadProgressOptions, + onUploaded?: (uploaded: ComposerUploadedImage) => void ): Promise<{ imageUrl: string; name: string; @@ -11680,6 +11755,13 @@ export function CanvasChatPanel({ const imageUrl = data.url as string; const assetId = typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined; + const uploadedBase: ComposerUploadedImage = { + imageUrl, + name: fileName, + contentType: file.type || 'image/jpeg', + assetId, + }; + onUploaded?.(uploadedBase); clearComposerUploadProgress(250); const dimensions = await getImageDimensions(imageUrl); const spacing = 20; @@ -11705,10 +11787,7 @@ export function CanvasChatPanel({ }); return { - imageUrl, - name: fileName, - contentType: file.type || 'image/jpeg', - assetId, + ...uploadedBase, width: dimensions.originalWidth || dimensions.width, height: dimensions.originalHeight || dimensions.height, }; @@ -11769,15 +11848,12 @@ export function CanvasChatPanel({ ? data.assetId.trim() : undefined; clearComposerUploadProgress(250); - const dimensions = await getImageDimensions(imageUrl); return { imageUrl, name: fileName, contentType: file.type || "image/jpeg", assetId, - width: dimensions.originalWidth || dimensions.width, - height: dimensions.originalHeight || dimensions.height, }; } catch (_error) { toast.error("图片上传失败"); @@ -11814,25 +11890,7 @@ export function CanvasChatPanel({ if (!uploaded) continue; handledCount += 1; - setAttachments((prev) => { - const newIndex = prev.length; - setTimeout(() => { - insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex); - (textareaRef.current as HTMLDivElement | null)?.focus(); - }, 0); - - return [ - ...prev, - { - url: uploaded.imageUrl, - name: uploaded.name, - contentType: uploaded.contentType, - assetId: uploaded.assetId, - width: uploaded.width, - height: uploaded.height, - }, - ]; - }); + addUploadedImageAttachmentToComposer(uploaded); } } finally { clearComposerUploadProgress(450); @@ -11962,6 +12020,7 @@ export function CanvasChatPanel({ let handledCount = 0; try { for (const [index, file] of files.entries()) { + let addedToComposer = false; const uploaded = await uploadImageFileAndAddToCanvas( file, baseNodeCount + uploadIndex, @@ -11974,6 +12033,10 @@ export function CanvasChatPanel({ fileIndex: index, fileTotal: files.length, target: "composer-reference", + }, + (instantUploaded) => { + addedToComposer = true; + addUploadedImageAttachmentToComposer(instantUploaded); } ); if (!uploaded) continue; @@ -11984,25 +12047,10 @@ export function CanvasChatPanel({ firstImageUrl = uploaded.imageUrl; } - setAttachments((prev) => { - const newIndex = prev.length; - setTimeout(() => { - insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex); - (textareaRef.current as HTMLDivElement | null)?.focus(); - }, 0); - - return [ - ...prev, - { - url: uploaded.imageUrl, - name: uploaded.name, - contentType: uploaded.contentType, - assetId: uploaded.assetId, - width: uploaded.width, - height: uploaded.height, - }, - ]; - }); + if (!addedToComposer) { + addUploadedImageAttachmentToComposer(uploaded); + } + updateUploadedImageAttachmentDimensions(uploaded.imageUrl, uploaded); } } finally { clearComposerUploadProgress(450); @@ -12014,17 +12062,18 @@ export function CanvasChatPanel({ void maybeAutoSetCanvasTitle(firstImageUrl); }, [ + addUploadedImageAttachmentToComposer, agentUploadSlots, attachments, clearComposerUploadProgress, hasAgentUploadSlots, - insertImageTagInEditor, isClawdMode, isVideoModel, maybeAutoSetCanvasTitle, nodes.length, updateAttachmentAtAgentSlot, updateAttachmentAtVideoSlot, + updateUploadedImageAttachmentDimensions, uploadImageFileForComposerAttachment, uploadImageFileAndAddToCanvas, videoFrameSlots, @@ -12127,25 +12176,7 @@ export function CanvasChatPanel({ if (!uploaded) continue; handledImageCount += 1; - setAttachments((prev) => { - const newIndex = prev.length; - setTimeout(() => { - insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex); - (textareaRef.current as HTMLDivElement | null)?.focus(); - }, 0); - - return [ - ...prev, - { - url: uploaded.imageUrl, - name: uploaded.name, - contentType: uploaded.contentType, - assetId: uploaded.assetId, - width: uploaded.width, - height: uploaded.height, - }, - ]; - }); + addUploadedImageAttachmentToComposer(uploaded); } } finally { clearComposerUploadProgress(450); @@ -12280,30 +12311,21 @@ export function CanvasChatPanel({ const assetId = typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined; clearComposerUploadProgress(250); - const dimensions = await getImageDimensions(imageUrl); + const uploadedAttachment: ComposerUploadedImage = { + imageUrl, + name: file.name, + contentType: file.type, + assetId, + }; + addUploadedImageAttachmentToComposer(uploadedAttachment); // 记录第一张图片的 URL(用于自动命名和缩略图) if (!firstImageUrl) { firstImageUrl = imageUrl; } - - // 添加到附件列表并在编辑器中插入标签 - 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, - assetId, - width: dimensions.originalWidth || dimensions.width, - height: dimensions.originalHeight || dimensions.height, - }]; - }); + + const dimensions = await getImageDimensions(imageUrl); + updateUploadedImageAttachmentDimensions(imageUrl, dimensions); // 获取图片原始尺寸后添加到画布 // 使用 baseNodeCount + uploadIndex 计算位置,确保多张图片不重叠 @@ -15564,44 +15586,19 @@ export function CanvasChatPanel({ ) : null} {!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image" ? ( -
- {attachments.map((attachment, index) => ( -
- {attachment.name { - if (event.currentTarget.src !== attachment.url) { - event.currentTarget.src = attachment.url; - } - }} - /> -
- ))} +