From ed3f0fefb7dd600471fabb3b9c5d5f0068f6c7b1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 6 Mar 2026 08:06:26 +0800 Subject: [PATCH] feat: polish canvas chat dock and agent model label --- app/canvas/[id]/page.tsx | 139 ++++++++------------------ components/canvas/CanvasChatPanel.tsx | 15 +-- components/canvas/nodes/ImageNode.tsx | 115 +++++++++++++++++++-- 3 files changed, 155 insertions(+), 114 deletions(-) diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index c40a9c278..bff15d394 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -4,7 +4,6 @@ import { useEffect, useState, useCallback, use, useRef } from "react"; import { useRouter, useParams } from "next/navigation"; import useSWR from "swr"; import { - ArrowLeft, Save, Undo2, Redo2, @@ -16,8 +15,7 @@ import { ImagePlus, Type, Loader2, - PanelRightClose, - PanelRight, + MessageCircle, Download, Upload, Wand2, @@ -52,7 +50,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; import { Slider } from "@/components/ui/slider"; import { ScrollArea } from "@/components/ui/scroll-area"; import { toast } from "sonner"; @@ -65,7 +62,6 @@ import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay"; import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn, fetcher } from "@/lib/utils"; -import { Grid2X2 } from "lucide-react"; interface PageProps { params: Promise<{ id: string }>; @@ -84,6 +80,7 @@ const SIZE_PRESETS = [ const FLOATING_PANEL_WIDTH = 460; const FLOATING_PANEL_INSET = 16; +const DOCKED_PANEL_INSET = 0; const FLOATING_UI_GAP = 16; const FLOATING_BOTTOM_INSET = 20; @@ -104,7 +101,6 @@ export default function CanvasEditorPage({ params }: PageProps) { const tool = useCanvasStore(canvasSelectors.tool); const canUndo = useCanvasStore(canvasSelectors.canUndo); const canRedo = useCanvasStore(canvasSelectors.canRedo); - const isGenerating = useCanvasStore(canvasSelectors.isGenerating); const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds); const nodes = useCanvasStore(canvasSelectors.nodes); @@ -157,9 +153,9 @@ export default function CanvasEditorPage({ params }: PageProps) { : null; const resolvedPanelWidth = viewportWidth > 0 - ? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - FLOATING_PANEL_INSET, 0)) + ? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0)) : FLOATING_PANEL_WIDTH; - const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + FLOATING_PANEL_INSET : 0; + const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0; const floatingToolbarOffset = isPanelOpen ? -(panelOcclusionRight / 2) : 0; const compactControlsLeft = FLOATING_UI_GAP; @@ -650,95 +646,38 @@ export default function CanvasEditorPage({ params }: PageProps) { {/* Brand Entry (Top Left) */}
- - - - - 返回首页 - -
- - {/* 2. Floating Header (Top Left) */} -
- - - - - 返回首页 - - - - - {/* 文件列表按钮 & 标题展示 */} - - - - - 查看文件列表 - - - {(isGenerating || isSaving) && ( - <> - -
- - - {isGenerating ? "生成中..." : "保存中..."} - -
- +
- {/* 3. Floating Actions (Top Right) */} + {/* 2. Floating Actions (Top Right) */} {!isPanelOpen && (
- - - - - 打开聊天面板 - +
)} @@ -748,13 +687,13 @@ export default function CanvasEditorPage({ params }: PageProps) { isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none" )} style={{ - top: FLOATING_PANEL_INSET, - right: FLOATING_PANEL_INSET, - bottom: FLOATING_PANEL_INSET, + top: DOCKED_PANEL_INSET, + right: DOCKED_PANEL_INSET, + bottom: DOCKED_PANEL_INSET, width: FLOATING_PANEL_WIDTH, - maxWidth: `calc(100vw - ${FLOATING_PANEL_INSET}px)`, + maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`, }}> -
+
{panelMode === 'chat' ? ( // AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
@@ -775,7 +714,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
{/* 5. Floating Toolbar (Bottom) */} -
+
- +
@@ -6826,6 +6826,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF extractVideoUrls(part.text || "").length > 0 ); const hasAssistantMedia = hasAssistantImages || hasAssistantVideos; + const isAgentAutoMode = selectedModelId === "auto"; + const displayAssistantModelName = + isAgentAutoMode && hasAssistantMedia ? "agent model" : assistantModelName; const hasAssistantText = assistantTextParts.some((part: any) => stripCanvasModelRoutingLines(stripMediaMarkup(part.text || "")).length > 0 ); @@ -6851,10 +6854,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF message.role === "assistant" && ((!hasAssistantMedia && nextAssistantHasMedia) || isLatestAssistantDuringGeneration); const showVoteActions = message.role === "assistant" && !hideVoteActions; - const showModelMeta = hideVoteActions && Boolean(assistantModelName); + const showModelMeta = hideVoteActions && Boolean(displayAssistantModelName); const mediaModelName = - assistantModelName || - (message.role === "assistant" && selectedModelId !== "auto" + displayAssistantModelName || + (message.role === "assistant" && !isAgentAutoMode ? currentModel?.displayName || "" : ""); const shouldTypeAssistantMessage = @@ -7218,7 +7221,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF {showModelMeta ? ( - {assistantModelName} + {displayAssistantModelName} ) : null} {showVoteActions ? ( diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 8ece8f2e0..429d4ad1b 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -286,11 +286,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const [isAddingToChat, setIsAddingToChat] = useState(false); const [isCompareMode, setIsCompareMode] = useState(false); const [compareSplitPercent, setCompareSplitPercent] = useState(50); + const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 }); // 文件输入的 ref const fileInputRef = useRef(null); const containerRef = useRef(null); const compareFrameRef = useRef(null); + const toolbarRef = useRef(null); const updateNode = useCanvasStore((state) => state.updateNode); const deleteNode = useCanvasStore((state) => state.deleteNode); @@ -515,6 +517,51 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { setDraftNote(data.note || ""); } }, [data.note, notePanelOpen]); + + useEffect(() => { + if ( + typeof window === "undefined" || + !isSelected || + isMultiSelected || + !data.src || + isEditingTitle || + sceneFusionDialogOpen + ) { + return; + } + + const toolbarEl = toolbarRef.current; + if (!toolbarEl) return; + + let rafId: number | null = null; + const measure = () => { + const rect = toolbarEl.getBoundingClientRect(); + const next = { width: rect.width, height: rect.height }; + setToolbarSize((prev) => + Math.abs(prev.width - next.width) < 0.5 && Math.abs(prev.height - next.height) < 0.5 + ? prev + : next + ); + }; + + const scheduleMeasure = () => { + if (rafId !== null) window.cancelAnimationFrame(rafId); + rafId = window.requestAnimationFrame(measure); + }; + + scheduleMeasure(); + window.addEventListener("resize", scheduleMeasure); + + const resizeObserver = + typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleMeasure) : null; + resizeObserver?.observe(toolbarEl); + + return () => { + window.removeEventListener("resize", scheduleMeasure); + resizeObserver?.disconnect(); + if (rafId !== null) window.cancelAnimationFrame(rafId); + }; + }, [data.src, isEditingTitle, isMultiSelected, isSelected, sceneFusionDialogOpen]); // 🆕 处理图片点击标注 const handlePickerClick = useCallback(async (e: React.MouseEvent) => { @@ -818,18 +865,60 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const getToolbarPosition = () => { if (containerRef.current && typeof window !== "undefined") { const rect = containerRef.current.getBoundingClientRect(); - const toolbarHeight = 48; + const viewportPadding = 12; + const toolbarScale = 0.8; + const toolbarWidth = Math.max(280, toolbarSize.width * toolbarScale || 980); + const toolbarHeight = Math.max(42, toolbarSize.height * toolbarScale || 48); const titleSafeHeight = 34; const gap = 12; - let top = rect.top - toolbarHeight - titleSafeHeight - gap; - if (top < 12) { - const maxTop = Math.max(12, window.innerHeight - toolbarHeight - 12); - top = Math.min(rect.bottom + 10, maxTop); + + let unsafeBottomTop = window.innerHeight; + const bottomOverlaySelectors = [ + '[data-canvas-chat-input-anchor="true"]', + '[data-canvas-floating-toolbar="true"]', + ]; + + for (const selector of bottomOverlaySelectors) { + const elements = Array.from(document.querySelectorAll(selector)); + for (const element of elements) { + const style = window.getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden") continue; + + const overlayRect = element.getBoundingClientRect(); + if (overlayRect.width <= 0 || overlayRect.height <= 0) continue; + if (overlayRect.bottom <= 0 || overlayRect.top >= window.innerHeight) continue; + if (overlayRect.top < window.innerHeight * 0.55) continue; + + unsafeBottomTop = Math.min(unsafeBottomTop, overlayRect.top); + } + } + + const safeBottom = Math.max( + viewportPadding, + Math.min(window.innerHeight - viewportPadding, unsafeBottomTop - viewportPadding) + ); + const maxTop = Math.max(viewportPadding, safeBottom - toolbarHeight); + + let top = rect.top - toolbarHeight - titleSafeHeight - gap; + if (top < viewportPadding) { + top = rect.bottom + gap; + } + top = Math.min(Math.max(top, viewportPadding), maxTop); + + const maxToolbarWidth = window.innerWidth - viewportPadding * 2; + const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth); + const halfToolbar = effectiveToolbarWidth / 2; + + let left = rect.left + rect.width / 2; + if (effectiveToolbarWidth >= maxToolbarWidth - 1) { + left = window.innerWidth / 2; + } else { + left = Math.min( + Math.max(left, viewportPadding + halfToolbar), + window.innerWidth - viewportPadding - halfToolbar + ); } - const minLeft = 80; - const maxLeft = window.innerWidth - 80; - const left = Math.min(Math.max(rect.left + rect.width / 2, minLeft), maxLeft); return { top, left, @@ -1649,6 +1738,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { } }; + const toolbarPosition = getToolbarPosition(); + return ( <> {/* 选中时在图片上方显示标题 */} @@ -2085,13 +2176,17 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { {isSelected && !isMultiSelected && data.src && !isEditingTitle && !sceneFusionDialogOpen && typeof document !== 'undefined' && createPortal(
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}