fix: canvas connections visibility, hover toolbar, i18n, selection box, copy/paste, undo/redo
Docker Build / Build and Push Docker Image (push) Successful in 3m56s
Docker Build / Build and Push Docker Image (push) Successful in 3m56s
This commit is contained in:
+18
-16
@@ -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}
|
||||
|
||||
+3
-5
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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 (
|
||||
|
||||
+7
-3
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+7
-5
@@ -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" />
|
||||
|
||||
+6
-4
@@ -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 */}
|
||||
|
||||
Vendored
+51
-1
@@ -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}} 个失败"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user