fix: canvas connections visibility, hover toolbar, i18n, selection box, copy/paste, undo/redo
Docker Build / Build and Push Docker Image (push) Successful in 3m56s

This commit is contained in:
2026-06-23 03:40:04 +08:00
parent 8f78ea9584
commit 47af7fc646
9 changed files with 305 additions and 46 deletions
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { nanoid } from 'nanoid'
import { PanelRightClose, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { SSE } from 'sse.js'
import { getCommonHeaders } from '@/lib/api'
import { canvasThemes, getCanvasTheme } from '../canvas-theme'
@@ -34,8 +35,8 @@ type CanvasAssistantPanelProps = {
onCollapse: () => void
}
function createSession(): CanvasAssistantSession {
return { id: nanoid(), title: 'New Chat', messages: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
function createSession(title = 'New Chat'): CanvasAssistantSession {
return { id: nanoid(), title, messages: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
}
export function CanvasAssistantPanel({
@@ -51,7 +52,8 @@ export function CanvasAssistantPanel({
}: CanvasAssistantPanelProps) {
const themeKey = getCanvasTheme()
const theme = canvasThemes[themeKey]
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]))
const { t } = useTranslation()
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession(t('New Chat'))]))
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId)
const [prompt, setPrompt] = useState('')
const [isRunning, setIsRunning] = useState(false)
@@ -93,7 +95,7 @@ export function CanvasAssistantPanel({
onSessionsChange(localSessions, localActiveSessionId)
}, [localActiveSessionId, localSessions, onSessionsChange])
const safeSessions = localSessions.length ? localSessions : [createSession()]
const safeSessions = localSessions.length ? localSessions : [createSession(t('New Chat'))]
const activeSession = useMemo(() => safeSessions.find((s) => s.id === localActiveSessionId) || safeSessions[0], [localActiveSessionId, safeSessions])
const messages = activeSession?.messages || []
@@ -104,30 +106,30 @@ export function CanvasAssistantPanel({
const appendMessage = useCallback((sessionId: string, message: CanvasAssistantMessage) => {
updateSession(sessionId, (session) => ({
...session,
title: session.messages.length ? session.title : message.text.slice(0, 18) || 'New Chat',
title: session.messages.length ? session.title : message.text.slice(0, 18) || t('New Chat'),
messages: [...session.messages, message],
updatedAt: new Date().toISOString(),
}))
}, [updateSession])
}, [updateSession, t])
const upsertMessage = useCallback((sessionId: string, message: CanvasAssistantMessage) => {
updateSession(sessionId, (session) => {
const exists = session.messages.some((item) => item.id === message.id)
return {
...session,
title: session.messages.length ? session.title : message.text.slice(0, 18) || 'New Chat',
title: session.messages.length ? session.title : message.text.slice(0, 18) || t('New Chat'),
messages: exists ? session.messages.map((item) => (item.id === message.id ? { ...item, ...message } : item)) : [...session.messages, message],
updatedAt: new Date().toISOString(),
}
})
}, [updateSession])
}, [updateSession, t])
const startChatSession = () => {
if (activeSession && activeSession.messages.length === 0) {
setLocalActiveSessionId(activeSession.id)
return
}
const session = createSession()
const session = createSession(t('New Chat'))
setLocalSessions((prev) => [session, ...prev])
setLocalActiveSessionId(session.id)
}
@@ -143,7 +145,7 @@ export function CanvasAssistantPanel({
const sendMessage = useCallback(async () => {
if (!prompt.trim() || isRunning) return
const text = prompt.trim()
const session = activeSession || createSession()
const session = activeSession || createSession(t('New Chat'))
// Build context about selected nodes
const selectedNodesInfo = Array.from(selectedNodeIds)
@@ -209,7 +211,7 @@ export function CanvasAssistantPanel({
})
source.addEventListener('error', (e: Event & { data?: string }) => {
let errorMessage = 'Generation failed'
let errorMessage = t('Generation failed')
if (e.data) {
try {
const parsed = JSON.parse(e.data) as { error?: { message?: string } }
@@ -226,7 +228,7 @@ export function CanvasAssistantPanel({
source.stream()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect'
const message = error instanceof Error ? error.message : t('Failed to connect')
upsertMessage(session.id, { id: assistantId, role: 'error', text: message })
setIsRunning(false)
}
@@ -270,7 +272,7 @@ export function CanvasAssistantPanel({
className="grid size-7 place-items-center rounded-md transition"
style={{ color: '#dc2626' }}
onClick={stopGeneration}
title="Stop"
title={t('Stop')}
>
<div className="size-3 rounded-sm bg-current" />
</button>
@@ -280,7 +282,7 @@ export function CanvasAssistantPanel({
className="grid size-7 place-items-center rounded-md transition"
style={{ color: theme.node.muted }}
onClick={startChatSession}
title="New Chat"
title={t('New Chat')}
>
<Plus className="size-4" />
</button>
@@ -289,7 +291,7 @@ export function CanvasAssistantPanel({
className="grid size-7 place-items-center rounded-md transition"
style={{ color: theme.node.muted }}
onClick={onCollapse}
title="Close Panel"
title={t('Close Panel')}
>
<PanelRightClose className="size-4" />
</button>
@@ -320,7 +322,7 @@ export function CanvasAssistantPanel({
prompt={prompt}
disabled={false}
sending={isRunning}
placeholder="Ask AI about your canvas..."
placeholder={t('Ask AI about your canvas...')}
theme={theme}
onPromptChange={setPrompt}
onSubmit={sendMessage}
@@ -25,9 +25,8 @@ export function CanvasConnections({
const nodeMap = new Map(nodes.map((n) => [n.id, n]))
return (
<div className="pointer-events-none absolute inset-0" style={{ overflow: 'visible' }}>
<svg className="h-full w-full" style={{ overflow: 'visible' }}>
{connections.map((connection) => {
<svg className="absolute left-0 top-0 overflow-visible" style={{ width: 10000, height: 10000, pointerEvents: 'none', transform: 'translateZ(0)', zIndex: 0 }}>
{connections.map((connection) => {
const fromNode = nodeMap.get(connection.fromNodeId)
const toNode = nodeMap.get(connection.toNodeId)
if (!fromNode || !toNode) return null
@@ -56,8 +55,7 @@ export function CanvasConnections({
viewport={viewport}
/>
)}
</svg>
</div>
</svg>
)
}
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { Copy, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { canvasThemes, getCanvasTheme } from '../canvas-theme'
import type { ContextMenuState } from '../types'
@@ -17,6 +18,7 @@ export function CanvasContextMenu({
}) {
const themeKey = getCanvasTheme()
const theme = canvasThemes[themeKey]
const { t } = useTranslation()
useEffect(() => {
const close = (event: PointerEvent) => {
@@ -33,9 +35,9 @@ export function CanvasContextMenu({
onPointerDown={(event) => event.stopPropagation()}
>
{menu.type === 'node' ? (
<MenuButton icon={<Copy className="size-4" />} label="Duplicate" onClick={onDuplicate} theme={theme} />
<MenuButton icon={<Copy className="size-4" />} label={t('Duplicate')} onClick={onDuplicate} theme={theme} />
) : null}
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger theme={theme} />
<MenuButton icon={<Trash2 className="size-4" />} label={t('Delete')} onClick={onDelete} danger theme={theme} />
</div>
)
}
@@ -1,5 +1,6 @@
import { useCallback, type ReactNode } from 'react'
import { Download, Image as ImageIcon, Pencil, RefreshCw, Trash2, Info, Video } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { canvasThemes, getCanvasTheme } from '../canvas-theme'
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from '../types'
@@ -39,6 +40,7 @@ export function CanvasNodeHoverToolbar({
}: CanvasNodeHoverToolbarProps) {
const themeKey = getCanvasTheme()
const theme = canvasThemes[themeKey]
const { t } = useTranslation()
if (!node) return null
@@ -52,12 +54,12 @@ export function CanvasNodeHoverToolbar({
const canRetry = node.metadata?.status === 'error'
const toolbarActions: ToolbarAction[] = [
{ id: 'info', title: 'Node Info', label: 'Info', icon: <Info className="size-4" />, onClick: () => onInfo(node) },
...(canRetry ? [{ id: 'retry', title: 'Retry', label: 'Retry', icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
...(hasImage || hasVideo ? [{ id: 'download', title: 'Download', label: 'Download', icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
...(isText ? [{ id: 'editText', title: 'Edit Text', label: 'Edit', icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
...(isText ? [{ id: 'generateImage', title: 'Generate Image', label: 'Generate', icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
{ id: 'delete', title: 'Delete Node', label: 'Delete', icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
{ id: 'info', title: t('Node Info'), label: t('Info'), icon: <Info className="size-4" />, onClick: () => onInfo(node) },
...(canRetry ? [{ id: 'retry', title: t('Retry'), label: t('Retry'), icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
...(hasImage || hasVideo ? [{ id: 'download', title: t('Download'), label: t('Download'), icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
...(isText ? [{ id: 'editText', title: t('Edit'), label: t('Edit'), icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
...(isText ? [{ id: 'generateImage', title: t('Generate'), label: t('Generate'), icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
{ id: 'delete', title: t('Delete Node'), label: t('Delete'), icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
]
return (
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { Image as ImageIcon, RefreshCw, Settings2, Video } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { canvasThemes, getCanvasTheme } from '../canvas-theme'
import { CanvasNodeType, type CanvasNodeData, type Position } from '../types'
@@ -55,6 +56,7 @@ export const CanvasNode = memo(function CanvasNode({
}: CanvasNodeProps) {
const themeKey = getCanvasTheme()
const theme = canvasThemes[themeKey]
const { t } = useTranslation()
const [hovered, setHovered] = useState(false)
const [isEditingContent, setIsEditingContent] = useState(false)
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content)
@@ -300,9 +302,10 @@ function LoadingContent({ theme }: { theme: (typeof canvasThemes)[keyof typeof c
}
function ErrorContent({ node, theme, onRetry }: { node: CanvasNodeData; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onRetry?: (node: CanvasNodeData) => void }) {
const { t } = useTranslation()
return (
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || 'Generation failed'}</div>
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || t('Generation failed')}</div>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition hover:scale-[1.02]"
@@ -338,7 +341,7 @@ function TextContent({ node, theme, isEditingContent, textareaRef, onContentChan
onClick={(event) => { event.stopPropagation(); onGenerateImage?.(node) }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
title="Generate image from text"
title={t('Generate image from text')}
>
<ImageIcon className="size-3.5" />
Generate
@@ -387,12 +390,13 @@ function ImageNodeContent({ node, theme }: { node: CanvasNodeData; theme: (typeo
}
function VideoNodeContent({ node, theme }: { node: CanvasNodeData; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const { t } = useTranslation()
const content = node.metadata?.content
if (!content) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<Video className="size-7 opacity-35" />
<span className="text-sm">Video Node</span>
<span className="text-sm">{t('Video Node')}</span>
</div>
)
}
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { canvasThemes, getCanvasTheme, type CanvasBackgroundMode } from '../canvas-theme'
import type { ViewportTransform } from '../types'
@@ -16,33 +17,34 @@ export function CanvasToolbar({
}: CanvasToolbarProps) {
const themeKey = getCanvasTheme()
const theme = canvasThemes[themeKey]
const { t } = useTranslation()
return (
<div
className="absolute bottom-5 left-1/2 z-50 flex h-14 -translate-x-1/2 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
>
<ToolbarButton theme={theme} onClick={() => onAddNode('text')} title="Add Text Node">
<ToolbarButton theme={theme} onClick={() => onAddNode('text')} title={t('Add Text Node')}>
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 7 4 4 20 4 20 7" />
<line x1="9" y1="20" x2="15" y2="20" />
<line x1="12" y1="4" x2="12" y2="20" />
</svg>
</ToolbarButton>
<ToolbarButton theme={theme} onClick={() => onAddNode('image')} title="Add Image Node">
<ToolbarButton theme={theme} onClick={() => onAddNode('image')} title={t('Add Image Node')}>
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
</ToolbarButton>
<ToolbarButton theme={theme} onClick={() => onAddNode('video')} title="Add Video Node">
<ToolbarButton theme={theme} onClick={() => onAddNode('video')} title={t('Add Video Node')}>
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
<rect x="2" y="6" width="14" height="12" rx="2" />
</svg>
</ToolbarButton>
<ToolbarButton theme={theme} onClick={() => onAddNode('config')} title="Add Config Node">
<ToolbarButton theme={theme} onClick={() => onAddNode('config')} title={t('Add Config Node')}>
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
@@ -66,7 +68,7 @@ export function CanvasToolbar({
<div className="mx-1 h-6 w-px" style={{ background: theme.toolbar.border }} />
<ToolbarButton theme={theme} onClick={onClearCanvas} title="Clear Canvas">
<ToolbarButton theme={theme} onClick={onClearCanvas} title={t('Clear Canvas')}>
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { canvasThemes, getCanvasTheme } from '../canvas-theme'
import type { ViewportTransform } from '../types'
@@ -9,6 +10,7 @@ type CanvasZoomControlsProps = {
export function CanvasZoomControls({ viewport, onViewportChange }: CanvasZoomControlsProps) {
const themeKey = getCanvasTheme()
const theme = canvasThemes[themeKey]
const { t } = useTranslation()
const zoomIn = () => {
onViewportChange({ ...viewport, k: Math.min(viewport.k * 1.2, 5) })
@@ -31,7 +33,7 @@ export function CanvasZoomControls({ viewport, onViewportChange }: CanvasZoomCon
className="absolute bottom-5 left-5 z-50 flex h-14 items-center gap-2 rounded-xl border px-2 shadow-lg backdrop-blur"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
>
<ZoomButton theme={theme} onClick={zoomOut} title="Zoom Out">
<ZoomButton theme={theme} onClick={zoomOut} title={t('Zoom Out')}>
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</ZoomButton>
<button
@@ -39,15 +41,15 @@ export function CanvasZoomControls({ viewport, onViewportChange }: CanvasZoomCon
className="min-w-[48px] px-1 text-center text-xs font-medium transition"
style={{ color: theme.toolbar.item }}
onClick={resetZoom}
title="Reset Zoom"
title={t('Reset Zoom')}
>
{Math.round(viewport.k * 100)}%
</button>
<ZoomButton theme={theme} onClick={zoomIn} title="Zoom In">
<ZoomButton theme={theme} onClick={zoomIn} title={t('Zoom In')}>
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
</ZoomButton>
<div className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<ZoomButton theme={theme} onClick={fitView} title="Fit View">
<ZoomButton theme={theme} onClick={fitView} title={t('Fit View')}>
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6" /><path d="M9 21H3v-6" /><path d="m21 3-7 7" /><path d="m3 21 7-7" />
</svg>
@@ -17,7 +17,7 @@ import { CanvasContextMenu } from './components/canvas-context-menu'
import { CanvasNodeHoverToolbar } from './components/canvas-node-hover-toolbar'
import { CanvasMiniMap } from './components/canvas-mini-map'
import { getNodeSpec } from './constants'
import { CanvasNodeType, type CanvasBackgroundMode, type CanvasConnection, type CanvasNodeData, type CanvasAssistantSession, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from './types'
import { CanvasNodeType, type CanvasBackgroundMode, type CanvasConnection, type CanvasNodeData, type CanvasAssistantSession, type ConnectionHandle, type ContextMenuState, type Position, type SelectionBox, type ViewportTransform } from './types'
import { generateImages, editImages } from '../api'
import type { ImageGenerationRequest, ImagePlaygroundConfig, ReferenceImage, GeneratedImage } from '../types'
import { STORAGE_KEYS } from '../types'
@@ -91,6 +91,13 @@ export function ImageCanvas() {
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const [hoverToolbarNodeId, setHoverToolbarNodeId] = useState<string | null>(null)
const [keepHoverToolbar, setKeepHoverToolbar] = useState(false)
const hoverToolbarTimerRef = useRef<ReturnType<typeof setTimeout>>()
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null)
const selectionBoxRef = useRef<SelectionBox | null>(null)
const clipboardRef = useRef<{ nodes: CanvasNodeData[]; connections: CanvasConnection[] } | null>(null)
const historyRef = useRef<{ past: { nodes: CanvasNodeData[]; connections: CanvasConnection[] }[]; future: { nodes: CanvasNodeData[]; connections: CanvasConnection[] }[] }>({ past: [], future: [] })
const lastHistoryRef = useRef<{ nodes: CanvasNodeData[]; connections: CanvasConnection[] } | null>(null)
const historyCommitTimerRef = useRef<ReturnType<typeof setTimeout>>()
const isDesktop = useMediaQuery('(min-width: 1024px)')
// Auto-show settings panel on desktop
@@ -219,6 +226,48 @@ export function ImageCanvas() {
useEffect(() => { saveProject() }, [saveProject])
// History (undo/redo)
const commitHistory = useCallback(() => {
if (historyCommitTimerRef.current) {
clearTimeout(historyCommitTimerRef.current)
historyCommitTimerRef.current = null
}
historyCommitTimerRef.current = setTimeout(() => {
const current = { nodes, connections }
const last = lastHistoryRef.current
if (!last) {
lastHistoryRef.current = current
return
}
if (last.nodes === current.nodes && last.connections === current.connections) return
historyRef.current.past = [...historyRef.current.past.slice(-49), last]
historyRef.current.future = []
lastHistoryRef.current = current
}, 180)
}, [nodes, connections])
useEffect(() => { commitHistory() }, [commitHistory])
const undoCanvas = useCallback(() => {
const previous = historyRef.current.past.pop()
const current = lastHistoryRef.current
if (!previous || !current) return
historyRef.current.future.push(current)
lastHistoryRef.current = previous
setNodes(previous.nodes)
setConnections(previous.connections)
}, [])
const redoCanvas = useCallback(() => {
const next = historyRef.current.future.pop()
const current = lastHistoryRef.current
if (!next || !current) return
historyRef.current.past.push(current)
lastHistoryRef.current = next
setNodes(next.nodes)
setConnections(next.connections)
}, [])
// Node operations
const addNode = useCallback((type: 'image' | 'text' | 'config' | 'video') => {
const nodeType = type === 'image' ? CanvasNodeType.Image : type === 'text' ? CanvasNodeType.Text : type === 'video' ? CanvasNodeType.Video : CanvasNodeType.Config
@@ -381,6 +430,77 @@ export function ImageCanvas() {
setShowPanelNodeId(null)
}, [])
const handleCanvasMouseDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
setContextMenu(null)
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return
const worldX = (event.clientX - rect.left - viewport.x) / viewport.k
const worldY = (event.clientY - rect.top - viewport.y) / viewport.k
const box: SelectionBox = {
startWorldX: worldX,
startWorldY: worldY,
currentWorldX: worldX,
currentWorldY: worldY,
additive: event.shiftKey,
initialSelectedNodeIds: event.shiftKey ? Array.from(selectedNodeIds) : [],
}
selectionBoxRef.current = box
setSelectionBox(box)
if (!event.shiftKey) {
setSelectedNodeIds(new Set())
}
}, [viewport, selectedNodeIds])
// Selection box move/up handlers
useEffect(() => {
if (!selectionBox) return
const handleMove = (e: MouseEvent) => {
const current = selectionBoxRef.current
if (!current) return
if (e.buttons === 0) {
selectionBoxRef.current = null
setSelectionBox(null)
return
}
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return
const worldX = (e.clientX - rect.left - viewport.x) / viewport.k
const worldY = (e.clientY - rect.top - viewport.y) / viewport.k
const rectX = Math.min(current.startWorldX, worldX)
const rectY = Math.min(current.startWorldY, worldY)
const rectW = Math.abs(worldX - current.startWorldX)
const rectH = Math.abs(worldY - current.startWorldY)
const nextSelected = new Set<string>(current.additive ? current.initialSelectedNodeIds : [])
nodes.forEach((node) => {
const intersects = rectX < node.position.x + node.width && rectX + rectW > node.position.x && rectY < node.position.y + node.height && rectY + rectH > node.position.y
if (intersects) nextSelected.add(node.id)
})
const nextBox = { ...current, currentWorldX: worldX, currentWorldY: worldY }
selectionBoxRef.current = nextBox
setSelectionBox(nextBox)
setSelectedNodeIds(nextSelected)
}
const handleUp = () => {
selectionBoxRef.current = null
setSelectionBox(null)
}
window.addEventListener('mousemove', handleMove)
window.addEventListener('mouseup', handleUp)
return () => {
window.removeEventListener('mousemove', handleMove)
window.removeEventListener('mouseup', handleUp)
}
}, [selectionBox, viewport, nodes])
const handleContextMenu = useCallback((event: React.MouseEvent) => {
event.preventDefault()
}, [])
@@ -658,6 +778,46 @@ export function ImageCanvas() {
setContextMenu(null)
}, [selectedNodeIds, nodes])
// Copy selected nodes
const copySelectedNodes = useCallback(() => {
const selectedNodes = nodes.filter((n) => selectedNodeIds.has(n.id))
if (selectedNodes.length === 0) return
const relatedConnections = connections.filter(
(c) => selectedNodeIds.has(c.fromNodeId) && selectedNodeIds.has(c.toNodeId)
)
clipboardRef.current = { nodes: selectedNodes, connections: relatedConnections }
}, [selectedNodeIds, nodes, connections])
// Paste copied nodes
const pasteCopiedNodes = useCallback(() => {
const clipboard = clipboardRef.current
if (!clipboard?.nodes.length) return
const idMap = new Map<string, string>()
const newNodes = clipboard.nodes.map((node) => {
const newId = nanoid()
idMap.set(node.id, newId)
return {
...node,
id: newId,
position: { x: node.position.x + 60, y: node.position.y + 60 },
}
})
const newConnections = clipboard.connections
.filter((c) => idMap.has(c.fromNodeId) && idMap.has(c.toNodeId))
.map((c) => ({
...c,
id: nanoid(),
fromNodeId: idMap.get(c.fromNodeId)!,
toNodeId: idMap.get(c.toNodeId)!,
}))
setNodes((prev) => [...prev, ...newNodes])
setConnections((prev) => [...prev, ...newConnections])
setSelectedNodeIds(new Set(newNodes.map((n) => n.id)))
}, [])
// Delete from context menu
const handleContextMenuDelete = useCallback(() => {
if (!contextMenu) return
@@ -683,11 +843,13 @@ export function ImageCanvas() {
// Hover toolbar handlers
const handleHoverToolbarKeep = useCallback((nodeId: string) => {
if (hoverToolbarTimerRef.current) clearTimeout(hoverToolbarTimerRef.current)
setKeepHoverToolbar(true)
setHoverToolbarNodeId(nodeId)
}, [])
const handleHoverToolbarLeave = useCallback(() => {
if (hoverToolbarTimerRef.current) clearTimeout(hoverToolbarTimerRef.current)
setKeepHoverToolbar(false)
setHoverToolbarNodeId(null)
}, [])
@@ -718,11 +880,27 @@ export function ImageCanvas() {
event.preventDefault()
handleDuplicateNodes()
}
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && selectedNodeIds.size > 0) {
event.preventDefault()
copySelectedNodes()
}
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
event.preventDefault()
pasteCopiedNodes()
}
if ((event.ctrlKey || event.metaKey) && event.key === 'z' && !event.shiftKey) {
event.preventDefault()
undoCanvas()
}
if ((event.ctrlKey || event.metaKey) && (event.key === 'y' || (event.key === 'z' && event.shiftKey))) {
event.preventDefault()
redoCanvas()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedNodeIds, deleteNodes, handleDuplicateNodes])
}, [selectedNodeIds, deleteNodes, handleDuplicateNodes, copySelectedNodes, pasteCopiedNodes, undoCanvas, redoCanvas])
// Chat sessions
const chatSessions = project?.chatSessions || []
@@ -751,6 +929,7 @@ export function ImageCanvas() {
backgroundMode={backgroundMode}
onViewportChange={setViewport}
onCanvasDeselect={handleCanvasDeselect}
onCanvasMouseDown={handleCanvasMouseDown}
onContextMenu={handleContextMenu}
>
{/* Connections layer */}
@@ -789,11 +968,16 @@ export function ImageCanvas() {
onMouseDown={handleNodeMouseDown}
onHoverStart={(id) => {
setHoveredNodeId(id)
if (!keepHoverToolbar) setHoverToolbarNodeId(id)
if (hoverToolbarTimerRef.current) clearTimeout(hoverToolbarTimerRef.current)
setHoverToolbarNodeId(id)
}}
onHoverEnd={() => {
setHoveredNodeId(null)
if (!keepHoverToolbar) setHoverToolbarNodeId(null)
if (keepHoverToolbar) return
// Delay clearing to allow mouse to reach the hover toolbar
hoverToolbarTimerRef.current = setTimeout(() => {
if (!keepHoverToolbar) setHoverToolbarNodeId(null)
}, 150)
}}
onConnectStart={handleConnectStart}
onResize={handleNodeResize}
@@ -804,6 +988,19 @@ export function ImageCanvas() {
onContextMenu={handleNodeContextMenu}
/>
))}
{/* Selection box */}
{selectionBox ? (
<div
className="pointer-events-none absolute border-2 border-blue-500 bg-blue-500/10"
style={{
left: Math.min(selectionBox.startWorldX, selectionBox.currentWorldX),
top: Math.min(selectionBox.startWorldY, selectionBox.currentWorldY),
width: Math.abs(selectionBox.currentWorldX - selectionBox.startWorldX),
height: Math.abs(selectionBox.currentWorldY - selectionBox.startWorldY),
}}
/>
) : null}
</InfiniteCanvas>
{/* Toolbar */}
+51 -1
View File
@@ -4897,6 +4897,56 @@
"Upload": "上传",
"Ref": "参考",
"Remove": "移除",
"Click or drag to upload reference images": "点击或拖拽上传参考图"
"Click or drag to upload reference images": "点击或拖拽上传参考图",
"Add Text Node": "添加文本节点",
"Add Image Node": "添加图片节点",
"Add Video Node": "添加视频节点",
"Add Config Node": "添加配置节点",
"Clear Canvas": "清空画布",
"Zoom In": "放大",
"Zoom Out": "缩小",
"Reset Zoom": "重置缩放",
"Fit View": "适应视图",
"Node Info": "节点信息",
"Delete Node": "删除节点",
"Video Node": "视频节点",
"New Chat": "新对话",
"Close Panel": "关闭面板",
"Generated Image": "生成的图片",
"Migrated Image": "迁移的图片",
"My Canvas": "我的画布",
"Generate image from text": "从文本生成图片",
"Ask AI about your canvas...": "向 AI 询问关于画布的问题...",
"Failed to connect": "连接失败",
"No images were generated": "未生成图片",
"Images generated successfully": "图片生成成功",
"Please enter a prompt": "请输入提示词",
"Please select a model": "请选择模型",
"Image Playground": "图片工坊",
"Image Settings": "图片设置",
"Model": "模型",
"Prompt": "提示词",
"Describe the image you want to generate...": "描述你想生成的图片...",
"Generating...": "生成中...",
"Generate": "生成",
"Ctrl+Enter to generate": "Ctrl+Enter 生成",
"Select a model": "选择模型",
"Loading models...": "加载模型中...",
"No image models available": "没有可用的图片模型",
"Size": "尺寸",
"Quality": "质量",
"Style": "风格",
"Count": "数量",
"Decrease": "减少",
"Increase": "增加",
"Image preview": "图片预览",
"Results": "结果",
"Retry failed": "重试失败",
"Clear failed": "清除失败",
"Clear all": "清除全部",
"Image generation service is temporarily unavailable. Please try again later.": "图片生成服务暂时不可用,请稍后重试。",
"Image generation timed out. The upstream server took too long to respond.": "图片生成超时,上游服务器响应时间过长。",
"Enter a prompt to generate images": "输入提示词生成图片",
"{{count}} failed": "{{count}} 个失败"
}
}