@dickpy/dsh-imagegen 1.5.3 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1628 @@
1
+ /** Infinite canvas workspace, rebuilt after the node-graph model of
2
+ * basketikun/infinite-canvas: free nodes (image/text), drag-to-connect edges,
3
+ * marquee + multi selection, context menus, minimap, undo/redo and a floating
4
+ * generation composer. Selecting a node pops the composer: the prompt is typed
5
+ * there (or supplied by connected text nodes), every upstream image node joins
6
+ * as a reference, and results land as new image nodes on the right. */
7
+
8
+ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
9
+ import type { CanvasAssetRef, CanvasConnection, CanvasDocument, CanvasNode, GenerateRequest, GenerationTask, HistoryEntry } from '../protocol.ts'
10
+ import type { ImageGenApi } from './api.ts'
11
+ import { tt } from './helpers.ts'
12
+ import css from './canvas-workspace.module.css'
13
+
14
+ type CanvasTool = 'select' | 'pan'
15
+
16
+ const MIN_SCALE = 0.05
17
+ const MAX_SCALE = 5
18
+ const GRID_SIZE = 48
19
+ const IMAGE_NODE_SIZE = { width: 320, height: 320 }
20
+ const TEXT_NODE_SIZE = { width: 280, height: 150 }
21
+ const CONFIG_NODE_SIZE = { width: 240, height: 96 }
22
+ const HISTORY_LIMIT = 60
23
+ const WORLD_PAD = 12000
24
+
25
+ interface CanvasWorkspaceProps {
26
+ api: ImageGenApi
27
+ imageModels: string[]
28
+ defaultChannelId?: string
29
+ connected: boolean
30
+ history: HistoryEntry[]
31
+ gallery: HistoryEntry[]
32
+ tasks: GenerationTask[]
33
+ importRequest?: { source: 'history' | 'gallery'; entryId: string; imageIndex: number }
34
+ onImportRequestHandled?: () => void
35
+ onOpenSettings?: () => void
36
+ }
37
+
38
+ type Point = { x: number; y: number }
39
+
40
+ interface NodeDragState {
41
+ pointerId: number
42
+ startX: number
43
+ startY: number
44
+ moved: boolean
45
+ snapshot: string | null
46
+ origins: Map<string, Point>
47
+ }
48
+
49
+ interface PanState {
50
+ startX: number
51
+ startY: number
52
+ viewportX: number
53
+ viewportY: number
54
+ hasMoved: boolean
55
+ startedOnBackground: boolean
56
+ }
57
+
58
+ interface MarqueeState {
59
+ start: Point
60
+ current: Point
61
+ additive: boolean
62
+ initialIds: string[]
63
+ }
64
+
65
+ interface ConnectState {
66
+ nodeId: string
67
+ handleType: 'source' | 'target'
68
+ mouse: Point
69
+ targetId: string | null
70
+ }
71
+
72
+ interface ResizeState {
73
+ nodeId: string
74
+ corner: 'bottom-right' | 'bottom-left'
75
+ startX: number
76
+ startY: number
77
+ width: number
78
+ height: number
79
+ x: number
80
+ y: number
81
+ ratio: number | null
82
+ }
83
+
84
+ type ContextMenuState =
85
+ | { type: 'canvas'; screen: Point; world: Point }
86
+ | { type: 'node'; screen: Point; nodeId: string }
87
+ | { type: 'connection'; screen: Point; connectionId: string }
88
+
89
+ type ProjectSummary = Awaited<ReturnType<ImageGenApi['canvasList']>>[number]
90
+
91
+ function newId(prefix: string): string {
92
+ const random = globalThis.crypto?.randomUUID?.()
93
+ return `${prefix}-${random ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`
94
+ }
95
+
96
+ function imageDataUrl(image: { b64: string; mime: string }): string {
97
+ return `data:${image.mime};base64,${image.b64}`
98
+ }
99
+
100
+ function readImageSize(src: string): Promise<{ width: number; height: number }> {
101
+ return new Promise((resolve, reject) => {
102
+ const image = new Image()
103
+ image.onload = () => resolve({ width: image.naturalWidth || 1, height: image.naturalHeight || 1 })
104
+ image.onerror = () => reject(new Error('无法读取图片尺寸'))
105
+ image.src = src
106
+ })
107
+ }
108
+
109
+ async function assetToDataUrl(asset: CanvasAssetRef): Promise<string> {
110
+ if (asset.url.startsWith('data:')) return asset.url
111
+ const response = await fetch(asset.url)
112
+ if (!response.ok) throw new Error('读取画布图片失败')
113
+ const blob = await response.blob()
114
+ return await new Promise((resolve, reject) => {
115
+ const reader = new FileReader()
116
+ reader.onload = () => resolve(String(reader.result))
117
+ reader.onerror = () => reject(new Error('读取画布图片失败'))
118
+ reader.readAsDataURL(blob)
119
+ })
120
+ }
121
+
122
+ function clampScale(scale: number): number {
123
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))
124
+ }
125
+
126
+ function sizeForAsset(asset: CanvasAssetRef): { width: number; height: number } {
127
+ const ratio = asset.width > 0 && asset.height > 0 ? asset.width / asset.height : 1
128
+ if (ratio >= 1) return { width: IMAGE_NODE_SIZE.width, height: Math.max(160, Math.round(IMAGE_NODE_SIZE.width / ratio)) }
129
+ return { width: Math.max(200, Math.round(IMAGE_NODE_SIZE.height * ratio)), height: IMAGE_NODE_SIZE.height }
130
+ }
131
+
132
+ /** Node footprint for a generation size ratio such as '1:1' or '16:9'. */
133
+ function nodeSizeFromRatio(size: string | undefined, spec: { width: number; height: number }): { width: number; height: number } {
134
+ const match = /^(\d+):(\d+)$/.exec(size ?? '')
135
+ if (match === null) return { ...spec }
136
+ const width = Number(match[1])
137
+ const height = Number(match[2])
138
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { ...spec }
139
+ const ratio = width / height
140
+ return ratio >= 1
141
+ ? { width: spec.width, height: Math.max(160, Math.round(spec.width / ratio)) }
142
+ : { width: Math.max(200, Math.round(spec.height * ratio)), height: spec.height }
143
+ }
144
+
145
+ function nodesBounds(nodes: CanvasNode[]): { minX: number; minY: number; maxX: number; maxY: number } {
146
+ return nodes.reduce((acc, node) => ({
147
+ minX: Math.min(acc.minX, node.x),
148
+ minY: Math.min(acc.minY, node.y),
149
+ maxX: Math.max(acc.maxX, node.x + node.width),
150
+ maxY: Math.max(acc.maxY, node.y + node.height),
151
+ }), { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity })
152
+ }
153
+
154
+ function summaryOf(document: CanvasDocument): ProjectSummary {
155
+ return {
156
+ id: document.id,
157
+ title: document.title,
158
+ revision: document.revision,
159
+ nodeCount: document.nodes.length,
160
+ createdAt: document.createdAt,
161
+ updatedAt: document.updatedAt,
162
+ }
163
+ }
164
+
165
+ function nodeMetadata(node: CanvasNode): NonNullable<CanvasNode['metadata']> {
166
+ return node.metadata ?? {}
167
+ }
168
+
169
+ function assetOf(node: CanvasNode): CanvasAssetRef | undefined {
170
+ return node.type === 'image' ? nodeMetadata(node).asset : undefined
171
+ }
172
+
173
+ function usableAsset(node: CanvasNode): CanvasAssetRef | undefined {
174
+ const asset = assetOf(node)
175
+ return asset !== undefined && asset.url !== '' ? asset : undefined
176
+ }
177
+
178
+ function bezierPath(from: Point, to: Point): string {
179
+ const distance = Math.abs(to.x - from.x)
180
+ const bend = Math.max(distance * 0.5, 50)
181
+ return `M ${from.x} ${from.y} C ${from.x + bend} ${from.y}, ${to.x - bend} ${to.y}, ${to.x} ${to.y}`
182
+ }
183
+
184
+ function nodeAnchor(node: CanvasNode, side: 'left' | 'right'): Point {
185
+ return { x: side === 'right' ? node.x + node.width : node.x, y: node.y + node.height / 2 }
186
+ }
187
+
188
+ type ToolbarIconName = 'new' | 'select' | 'pan' | 'image' | 'text' | 'trash' | 'undo' | 'redo' | 'fit' | 'minimap' | 'background' | 'download' | 'duplicate' | 'sparkle' | 'send' | 'close' | 'deleteProject'
189
+
190
+ function ToolbarIcon({ name }: { name: ToolbarIconName }): React.JSX.Element {
191
+ const common = { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.35, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, 'aria-hidden': true }
192
+ if (name === 'new') return <svg {...common}><path d="M8 3v10M3 8h10" /></svg>
193
+ if (name === 'select') return <svg {...common}><path d="M3 2.5l9.3 6.2-4 1.1-1.5 3.7L3 2.5z" /></svg>
194
+ if (name === 'pan') return <svg {...common}><path d="M8 2v12M2 8h12M5 5l3-3 3 3M5 11l3 3 3-3" /></svg>
195
+ if (name === 'image') return <svg {...common}><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><circle cx="5.5" cy="5.8" r="1" /><path d="M2.5 12.5l3.3-3.2 2.4 2.2 2.8-2.8 2.5 2.7M12 2v4M10 4h4" /></svg>
196
+ if (name === 'text') return <svg {...common}><path d="M3 3h10M8 3v10M5.5 13h5" /></svg>
197
+ if (name === 'trash') return <svg {...common}><path d="M3.5 4.5h9M6 2.5h4M5 4.5l.6 9h4.8l.6-9M6.5 6.5v4.5M9.5 6.5v4.5" /></svg>
198
+ if (name === 'undo') return <svg {...common}><path d="M3 7a5 5 0 1 1 1.5 4M3 3v4h4" /></svg>
199
+ if (name === 'redo') return <svg {...common}><path d="M13 7a5 5 0 1 0-1.5 4M13 3v4h-4" /></svg>
200
+ if (name === 'fit') return <svg {...common}><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4" /></svg>
201
+ if (name === 'minimap') return <svg {...common}><rect x="2" y="3" width="12" height="10" rx="1.5" /><path d="M5 6h3v4H5zM10 8h2v3h-2" /></svg>
202
+ if (name === 'background') return <svg {...common}><circle cx="8" cy="8" r="5.5" /><path d="M8 2.5v11M2.5 8h11M4 4.3c2.4 1.9 5.6 1.9 8 0M4 11.7c2.4-1.9 5.6-1.9 8 0" /></svg>
203
+ if (name === 'download') return <svg {...common}><path d="M8 2.5v8M5 7.5l3 3 3-3M3 13.5h10" /></svg>
204
+ if (name === 'duplicate') return <svg {...common}><rect x="5.5" y="5.5" width="8" height="8" rx="1.2" /><path d="M10.5 3h-7a.5.5 0 0 0-.5.5v7" /></svg>
205
+ if (name === 'send') return <svg {...common}><path d="M14 2L7 9M14 2L9.5 14l-2.5-5L2 6.5 14 2z" /></svg>
206
+ if (name === 'close') return <svg {...common}><path d="M4 4l8 8M12 4l-8 8" /></svg>
207
+ if (name === 'deleteProject') return <svg {...common}><path d="M2.5 5h11M6.5 5V3h3v2M4 5l.8 8.5h6.4L12 5M6.7 7.5v3.5M9.3 7.5v3.5" /></svg>
208
+ return <svg {...common}><path d="M8 2l1.2 4.2L13.5 8l-4.3 1.8L8 14l-1.2-4.2L2.5 8l4.3-1.8L8 2z" /></svg>
209
+ }
210
+
211
+ function IconButton(props: { name: ToolbarIconName; label: string; active?: boolean; disabled?: boolean; onClick?: () => void }): React.JSX.Element {
212
+ return <button type="button" className={css.iconButton} data-active={props.active ? '' : undefined} aria-label={props.label} title={props.label} disabled={props.disabled} onClick={props.onClick}><ToolbarIcon name={props.name} /></button>
213
+ }
214
+
215
+ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element {
216
+ const { api, imageModels, defaultChannelId, connected, history, gallery, tasks, importRequest, onImportRequestHandled, onOpenSettings } = props
217
+ const [projects, setProjects] = useState<ProjectSummary[]>([])
218
+ const [document, setDocument] = useState<CanvasDocument | null>(null)
219
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
220
+ const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null)
221
+ const [tool, setTool] = useState<CanvasTool>('select')
222
+ const [spacePressed, setSpacePressed] = useState(false)
223
+ const [ctrlPressed, setCtrlPressed] = useState(false)
224
+ const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 })
225
+ const [marquee, setMarquee] = useState<MarqueeState | null>(null)
226
+ const [connecting, setConnecting] = useState<ConnectState | null>(null)
227
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
228
+ const [createMenu, setCreateMenu] = useState<{ screen: Point; world: Point } | null>(null)
229
+ const [minimapOpen, setMinimapOpen] = useState(true)
230
+ const [pickerOpen, setPickerOpen] = useState(false)
231
+ const [renamingTitle, setRenamingTitle] = useState(false)
232
+ const [confirmDeleteProject, setConfirmDeleteProject] = useState(false)
233
+ const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'error'>('loading')
234
+ const [error, setError] = useState<string | null>(null)
235
+ const [historyVersion, setHistoryVersion] = useState(0)
236
+
237
+ // Floating generation composer state.
238
+ const [composerPrompt, setComposerPrompt] = useState('')
239
+ const [composerModel, setComposerModel] = useState(imageModels[0] ?? '')
240
+ const [composerSize, setComposerSize] = useState('auto')
241
+ const [composerQuality, setComposerQuality] = useState('auto')
242
+ const [composerCount, setComposerCount] = useState(1)
243
+ const [composerBusy, setComposerBusy] = useState(false)
244
+
245
+ const viewportRef = useRef<HTMLDivElement>(null)
246
+ const documentRef = useRef<CanvasDocument | null>(null)
247
+ const selectedIdsRef = useRef<Set<string>>(selectedIds)
248
+ const dragRef = useRef<NodeDragState | null>(null)
249
+ const panRef = useRef<PanState | null>(null)
250
+ const connectRef = useRef<ConnectState | null>(null)
251
+ const resizeRef = useRef<ResizeState | null>(null)
252
+ const marqueeRef = useRef<MarqueeState | null>(null)
253
+ const panFrameRef = useRef<number | null>(null)
254
+ const syncedRef = useRef('')
255
+ const processedTasks = useRef(new Set<string>())
256
+ const processedImport = useRef('')
257
+ const localTaskIds = useRef(new Set<string>())
258
+ const mountedAtRef = useRef(Date.now())
259
+ const internalClipboard = useRef<{ nodes: CanvasNode[]; connections: Array<{ fromNodeId: string; toNodeId: string }> } | null>(null)
260
+ const pastRef = useRef<string[]>([])
261
+ const futureRef = useRef<string[]>([])
262
+ const composerTargetRef = useRef<string | null>(null)
263
+
264
+ documentRef.current = document
265
+ selectedIdsRef.current = selectedIds
266
+
267
+ // ------------------------------------------------------------ utilities
268
+
269
+ const screenToWorld = useCallback((clientX: number, clientY: number): Point => {
270
+ const bounds = viewportRef.current?.getBoundingClientRect()
271
+ const current = documentRef.current
272
+ if (bounds === undefined || current === null) return { x: clientX, y: clientY }
273
+ return {
274
+ x: (clientX - bounds.left - current.viewport.x) / current.viewport.k,
275
+ y: (clientY - bounds.top - current.viewport.y) / current.viewport.k,
276
+ }
277
+ }, [])
278
+
279
+ const canvasCenter = useCallback((): Point => {
280
+ const bounds = viewportRef.current?.getBoundingClientRect()
281
+ const current = documentRef.current
282
+ if (bounds === undefined || current === null) return { x: 0, y: 0 }
283
+ return screenToWorld(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
284
+ }, [screenToWorld])
285
+
286
+ const beginHistory = useCallback((): string | null => {
287
+ const current = documentRef.current
288
+ if (current === null) return null
289
+ const snapshot = JSON.stringify(current)
290
+ if (pastRef.current[pastRef.current.length - 1] === snapshot) return snapshot
291
+ pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), snapshot]
292
+ futureRef.current = []
293
+ setHistoryVersion(version => version + 1)
294
+ return snapshot
295
+ }, [])
296
+
297
+ const commitSnapshot = useCallback((snapshot: string | null): void => {
298
+ if (snapshot === null) return
299
+ const current = documentRef.current
300
+ if (current === null || JSON.stringify(current) === snapshot) return
301
+ pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), snapshot]
302
+ futureRef.current = []
303
+ setHistoryVersion(version => version + 1)
304
+ }, [])
305
+
306
+ const updateDocument = useCallback((updater: (previous: CanvasDocument) => CanvasDocument): void => {
307
+ setDocument(previous => previous === null ? previous : updater(previous))
308
+ }, [])
309
+
310
+ const mutate = useCallback((updater: (previous: CanvasDocument) => CanvasDocument): void => {
311
+ beginHistory()
312
+ updateDocument(updater)
313
+ }, [beginHistory, updateDocument])
314
+
315
+ const undo = useCallback((): void => {
316
+ const snapshot = pastRef.current[pastRef.current.length - 1]
317
+ const current = documentRef.current
318
+ if (snapshot === undefined || current === null) return
319
+ pastRef.current = pastRef.current.slice(0, -1)
320
+ futureRef.current = [...futureRef.current, JSON.stringify(current)]
321
+ setDocument(JSON.parse(snapshot) as CanvasDocument)
322
+ setHistoryVersion(version => version + 1)
323
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
324
+ }, [])
325
+
326
+ const redo = useCallback((): void => {
327
+ const snapshot = futureRef.current[futureRef.current.length - 1]
328
+ const current = documentRef.current
329
+ if (snapshot === undefined || current === null) return
330
+ futureRef.current = futureRef.current.slice(0, -1)
331
+ pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), JSON.stringify(current)]
332
+ setDocument(JSON.parse(snapshot) as CanvasDocument)
333
+ setHistoryVersion(version => version + 1)
334
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
335
+ }, [])
336
+
337
+ const setViewport = useCallback((viewport: CanvasDocument['viewport']): void => {
338
+ updateDocument(previous => ({ ...previous, viewport }))
339
+ }, [updateDocument])
340
+
341
+ // ------------------------------------------------------- node operations
342
+
343
+ const placeNewNode = useCallback((node: CanvasNode): void => {
344
+ mutate(previous => ({ ...previous, nodes: [...previous.nodes, node] }))
345
+ setSelectedIds(new Set([node.id])); setSelectedConnectionId(null)
346
+ }, [mutate])
347
+
348
+ const createImageNode = useCallback((asset: CanvasAssetRef, position?: Point): CanvasNode => {
349
+ const size = sizeForAsset(asset)
350
+ const center = position ?? canvasCenter()
351
+ return {
352
+ id: newId('node'), type: 'image', title: asset.origin === 'gallery' ? tt('canvas.fromGallery') : asset.origin === 'history' ? tt('canvas.fromHistory') : tt('canvas.imageNode'),
353
+ x: Math.round(center.x - size.width / 2), y: Math.round(center.y - size.height / 2),
354
+ width: size.width, height: size.height,
355
+ metadata: { asset, status: 'success' },
356
+ }
357
+ }, [canvasCenter])
358
+
359
+ const createTextNode = useCallback((position?: Point): CanvasNode => {
360
+ const center = position ?? canvasCenter()
361
+ return {
362
+ id: newId('node'), type: 'text', title: tt('canvas.textNode'),
363
+ x: Math.round(center.x - TEXT_NODE_SIZE.width / 2), y: Math.round(center.y - TEXT_NODE_SIZE.height / 2),
364
+ width: TEXT_NODE_SIZE.width, height: TEXT_NODE_SIZE.height,
365
+ metadata: { text: '', fontSize: 14 },
366
+ }
367
+ }, [canvasCenter])
368
+
369
+ const createConfigNode = useCallback((position?: Point): CanvasNode => {
370
+ const center = position ?? canvasCenter()
371
+ return {
372
+ id: newId('node'), type: 'config', title: tt('canvas.configNode'),
373
+ x: Math.round(center.x - CONFIG_NODE_SIZE.width / 2), y: Math.round(center.y - CONFIG_NODE_SIZE.height / 2),
374
+ width: CONFIG_NODE_SIZE.width, height: CONFIG_NODE_SIZE.height,
375
+ metadata: { status: 'idle' },
376
+ }
377
+ }, [canvasCenter])
378
+
379
+ const updateNodes = useCallback((updater: (nodes: CanvasNode[]) => CanvasNode[]): void => {
380
+ updateDocument(previous => ({ ...previous, nodes: updater(previous.nodes) }))
381
+ }, [updateDocument])
382
+
383
+ const patchNode = useCallback((nodeId: string, patch: Partial<NonNullable<CanvasNode['metadata']>> & Partial<Pick<CanvasNode, 'title' | 'width' | 'height' | 'x' | 'y'>>): void => {
384
+ updateNodes(nodes => nodes.map(node => node.id === nodeId
385
+ ? { ...node, ...('title' in patch ? { title: patch.title ?? node.title } : {}), ...('x' in patch || 'y' in patch || 'width' in patch || 'height' in patch ? { x: patch.x ?? node.x, y: patch.y ?? node.y, width: patch.width ?? node.width, height: patch.height ?? node.height } : {}), metadata: { ...nodeMetadata(node), ...patch } }
386
+ : node))
387
+ }, [updateNodes])
388
+
389
+ const deleteSelection = useCallback((): void => {
390
+ const ids = selectedIdsRef.current
391
+ const connectionId = selectedConnectionId
392
+ if (ids.size === 0 && connectionId === null) return
393
+ mutate(previous => ({
394
+ ...previous,
395
+ nodes: previous.nodes.filter(node => !ids.has(node.id)),
396
+ connections: previous.connections.filter(connection => !ids.has(connection.fromNodeId) && !ids.has(connection.toNodeId) && connection.id !== connectionId),
397
+ }))
398
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
399
+ }, [mutate, selectedConnectionId])
400
+
401
+ const duplicateSelection = useCallback((): void => {
402
+ const current = documentRef.current
403
+ if (current === null || selectedIdsRef.current.size === 0) return
404
+ const clones = current.nodes.filter(node => selectedIdsRef.current.has(node.id)).map(node => ({ ...node, id: newId('node'), x: node.x + 40, y: node.y + 40, metadata: { ...nodeMetadata(node) } }))
405
+ if (clones.length === 0) return
406
+ const idMap = new Map(current.nodes.filter(node => selectedIdsRef.current.has(node.id)).map((node, index) => [node.id, clones[index]!.id]))
407
+ const connections = current.connections
408
+ .filter(connection => idMap.has(connection.fromNodeId) && idMap.has(connection.toNodeId))
409
+ .map(connection => ({ id: newId('edge'), fromNodeId: idMap.get(connection.fromNodeId)!, toNodeId: idMap.get(connection.toNodeId)! }))
410
+ mutate(previous => ({ ...previous, nodes: [...previous.nodes, ...clones], connections: [...previous.connections, ...connections] }))
411
+ setSelectedIds(new Set(clones.map(node => node.id)))
412
+ }, [mutate])
413
+
414
+ const copySelection = useCallback((): void => {
415
+ const current = documentRef.current
416
+ if (current === null || selectedIdsRef.current.size === 0) return
417
+ internalClipboard.current = {
418
+ nodes: current.nodes.filter(node => selectedIdsRef.current.has(node.id)).map(node => ({ ...node, metadata: { ...nodeMetadata(node) } })),
419
+ connections: current.connections.filter(connection => selectedIdsRef.current.has(connection.fromNodeId) && selectedIdsRef.current.has(connection.toNodeId)).map(connection => ({ fromNodeId: connection.fromNodeId, toNodeId: connection.toNodeId })),
420
+ }
421
+ }, [])
422
+
423
+ const pasteClipboard = useCallback((position?: Point): void => {
424
+ const clipboard = internalClipboard.current
425
+ if (clipboard === null || clipboard.nodes.length === 0) return
426
+ const bounds = nodesBounds(clipboard.nodes)
427
+ const target = position ?? canvasCenter()
428
+ const dx = target.x - (bounds.minX + (bounds.maxX - bounds.minX) / 2)
429
+ const dy = target.y - (bounds.minY + (bounds.maxY - bounds.minY) / 2)
430
+ const idMap = new Map<string, string>()
431
+ const clones = clipboard.nodes.map(node => {
432
+ const id = newId('node'); idMap.set(node.id, id)
433
+ return { ...node, id, x: Math.round(node.x + dx), y: Math.round(node.y + dy), metadata: { ...nodeMetadata(node) } }
434
+ })
435
+ const connections = clipboard.connections.map(connection => ({ id: newId('edge'), fromNodeId: idMap.get(connection.fromNodeId)!, toNodeId: idMap.get(connection.toNodeId)! }))
436
+ mutate(previous => ({ ...previous, nodes: [...previous.nodes, ...clones], connections: [...previous.connections, ...connections] }))
437
+ setSelectedIds(new Set(clones.map(node => node.id)))
438
+ }, [canvasCenter, mutate])
439
+
440
+ const connectNodes = useCallback((fromNodeId: string, toNodeId: string): void => {
441
+ if (fromNodeId === toNodeId) return
442
+ const current = documentRef.current
443
+ if (current === null) return
444
+ if (current.connections.some(connection => connection.fromNodeId === fromNodeId && connection.toNodeId === toNodeId)) return
445
+ mutate(previous => ({ ...previous, connections: [...previous.connections, { id: newId('edge'), fromNodeId, toNodeId }] }))
446
+ }, [mutate])
447
+
448
+ const downloadNode = useCallback((node: CanvasNode): void => {
449
+ const asset = assetOf(node)
450
+ if (asset === undefined || asset.url === '') return
451
+ const link = globalThis.document.createElement('a')
452
+ link.href = asset.url
453
+ link.download = `${node.title || 'canvas-image'}.${asset.assetId.split('.').pop() ?? 'png'}`
454
+ link.target = '_blank'
455
+ link.rel = 'noopener'
456
+ link.click()
457
+ }, [])
458
+
459
+ const upstreamNodes = useCallback((canvasDocument: CanvasDocument, nodeId: string): CanvasNode[] => {
460
+ const byId = new Map(canvasDocument.nodes.map(node => [node.id, node]))
461
+ return canvasDocument.connections
462
+ .filter(connection => connection.toNodeId === nodeId)
463
+ .map(connection => byId.get(connection.fromNodeId))
464
+ .filter((node): node is CanvasNode => node !== undefined)
465
+ }, [])
466
+
467
+ // ----------------------------------------------------------- generation
468
+
469
+ const submitComposer = useCallback(async (target: CanvasNode | null): Promise<void> => {
470
+ const current = documentRef.current
471
+ if (current === null || composerBusy) return
472
+ if (!connected) { setError(tt('canvas.needApi')); onOpenSettings?.(); return }
473
+ const inputs = target === null ? [] : upstreamNodes(current, target.id)
474
+ const referenceImages = inputs.filter(node => node.type === 'image' && usableAsset(node) !== undefined)
475
+ const upstreamText = inputs.filter(node => node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '').map(node => nodeMetadata(node).text!.trim())
476
+ const prompt = (composerPrompt.trim() !== '' ? composerPrompt.trim() : upstreamText.join('\n').trim())
477
+ if (prompt === '') { setError(tt('canvas.needPrompt')); return }
478
+ const model = imageModels.includes(composerModel) ? composerModel : imageModels[0] ?? ''
479
+ if (model === '') { setError(tt('canvas.needModel')); return }
480
+ const count = Math.min(4, Math.max(1, Math.round(composerCount)))
481
+ const baseAsset = referenceImages[0] !== undefined ? usableAsset(referenceImages[0]!) : undefined
482
+ setComposerBusy(true)
483
+ try {
484
+ let image: string | undefined
485
+ let refName: string | undefined
486
+ if (baseAsset !== undefined) {
487
+ image = await assetToDataUrl(baseAsset)
488
+ refName = 'canvas-reference.png'
489
+ }
490
+ const footprint = nodeSizeFromRatio(composerSize, IMAGE_NODE_SIZE)
491
+ const request: GenerateRequest = {
492
+ mode: image === undefined ? 'text' : 'edit', model, prompt, size: composerSize, quality: composerQuality, n: count, detail: '',
493
+ ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
494
+ ...(image === undefined ? {} : { image, refName }),
495
+ canvas: {
496
+ canvasId: current.id,
497
+ ...(target === null ? {} : { sourceNodeId: referenceImages[0]?.id ?? target.id, parentNodeId: target.id, placement: 'right' as const }),
498
+ },
499
+ }
500
+ const task = await api.taskSubmit(request)
501
+ localTaskIds.current.add(task.id)
502
+ mutate(previous => {
503
+ const nodes = [...previous.nodes]
504
+ const connections = [...previous.connections]
505
+ const anchor = target !== null ? previous.nodes.find(node => node.id === target.id) : undefined
506
+ const originX = anchor !== undefined ? anchor.x + anchor.width + 80 : Math.round(canvasCenter().x - footprint.width / 2)
507
+ const originY = anchor !== undefined ? anchor.y : Math.round(canvasCenter().y - footprint.height / 2)
508
+ for (let index = 0; index < count; index += 1) {
509
+ const id = newId('node')
510
+ nodes.push({
511
+ id, type: 'image', title: tt('canvas.imageNode'),
512
+ x: Math.round(originX), y: Math.round(originY + index * (footprint.height + 48)),
513
+ width: footprint.width, height: footprint.height,
514
+ metadata: { status: 'generating', taskId: task.id, ...(anchor !== undefined ? { sourceNodeId: anchor.id } : {}), prompt, model },
515
+ })
516
+ if (anchor !== undefined) connections.push({ id: newId('edge'), fromNodeId: anchor.id, toNodeId: id })
517
+ }
518
+ return { ...previous, nodes, connections }
519
+ })
520
+ setComposerPrompt('')
521
+ setError(null)
522
+ } catch (caught) {
523
+ setError(caught instanceof Error ? caught.message : String(caught))
524
+ } finally {
525
+ setComposerBusy(false)
526
+ }
527
+ }, [api, canvasCenter, composerBusy, composerCount, composerModel, composerPrompt, composerQuality, composerSize, connected, defaultChannelId, imageModels, mutate, onOpenSettings, upstreamNodes])
528
+
529
+ // ---------------------------------------------------------- task intake
530
+
531
+ /** Re-run a failed image node's generation from its recorded prompt/model,
532
+ * re-deriving the edit base from the connected source config node. */
533
+ const retryGeneration = useCallback(async (node: CanvasNode): Promise<void> => {
534
+ const current = documentRef.current
535
+ if (current === null || !connected) { setError(tt('canvas.needApi')); onOpenSettings?.(); return }
536
+ const metadata = nodeMetadata(node)
537
+ const prompt = (metadata.prompt ?? '').trim()
538
+ if (prompt === '') { setError(tt('canvas.needPrompt')); return }
539
+ const model = imageModels.includes(metadata.model ?? '') ? metadata.model! : imageModels[0] ?? ''
540
+ if (model === '') { setError(tt('canvas.needModel')); return }
541
+ const sourceId = metadata.sourceNodeId
542
+ const references = sourceId === undefined ? [] : upstreamNodes(current, sourceId).filter(item => item.type === 'image' && usableAsset(item) !== undefined)
543
+ const baseAsset = references[0] !== undefined ? usableAsset(references[0]!) : undefined
544
+ try {
545
+ let image: string | undefined
546
+ let refName: string | undefined
547
+ if (baseAsset !== undefined) {
548
+ image = await assetToDataUrl(baseAsset)
549
+ refName = 'canvas-reference.png'
550
+ }
551
+ const request: GenerateRequest = {
552
+ mode: image === undefined ? 'text' : 'edit', model, prompt, size: metadata.size ?? 'auto', quality: metadata.quality ?? 'auto', n: 1, detail: '',
553
+ ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
554
+ ...(image === undefined ? {} : { image, refName }),
555
+ canvas: { canvasId: current.id, sourceNodeId: references[0]?.id ?? sourceId, parentNodeId: node.id, placement: 'right' as const },
556
+ }
557
+ const task = await api.taskSubmit(request)
558
+ localTaskIds.current.add(task.id)
559
+ patchNode(node.id, { status: 'generating', error: undefined, taskId: task.id })
560
+ setError(null)
561
+ } catch (caught) {
562
+ setError(caught instanceof Error ? caught.message : String(caught))
563
+ }
564
+ }, [api, connected, defaultChannelId, imageModels, onOpenSettings, patchNode, upstreamNodes])
565
+
566
+ // Orphan reconciliation: a generating placeholder whose task no longer exists
567
+ // in the host feed (e.g. the host restarted) can never complete on its own.
568
+ useEffect(() => {
569
+ if (document === null) return
570
+ const feedFresh = tasks.length > 0 || Date.now() - mountedAtRef.current > 8000
571
+ if (!feedFresh) return
572
+ const feedIds = new Set(tasks.map(task => task.id))
573
+ const orphans = document.nodes.filter(node => {
574
+ if (node.type !== 'image' || nodeMetadata(node).status !== 'generating') return false
575
+ const taskId = nodeMetadata(node).taskId
576
+ return taskId !== undefined && !feedIds.has(taskId) && !localTaskIds.current.has(taskId)
577
+ })
578
+ if (orphans.length === 0) return
579
+ updateNodes(nodes => nodes.map(node => {
580
+ const taskId = node.type === 'image' ? nodeMetadata(node).taskId : undefined
581
+ if (node.type !== 'image' || nodeMetadata(node).status !== 'generating' || taskId === undefined
582
+ || feedIds.has(taskId) || localTaskIds.current.has(taskId)) return node
583
+ return { ...node, metadata: { ...nodeMetadata(node), status: 'error', error: tt('canvas.taskLost') } }
584
+ }))
585
+ }, [document, tasks, updateNodes])
586
+
587
+ useEffect(() => {
588
+ if (document === null) return
589
+ const canvasTasks = tasks.filter(task => task.request.canvas?.canvasId === document.id)
590
+ for (const task of canvasTasks) {
591
+ if (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') continue
592
+ if (processedTasks.current.has(task.id)) continue
593
+ const targets = document.nodes.filter(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating')
594
+ if (targets.length === 0) continue
595
+ processedTasks.current.add(task.id)
596
+ const sourceId = nodeMetadata(targets[0]!).sourceNodeId
597
+ const fail = (message: string): void => {
598
+ updateNodes(nodes => nodes.map(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating'
599
+ ? { ...node, metadata: { ...nodeMetadata(node), status: 'error', error: message } }
600
+ : node))
601
+ }
602
+ if (task.status !== 'completed' || task.result === undefined || task.result.images.length === 0) {
603
+ fail(task.error ?? tt('canvas.generateFailed'))
604
+ continue
605
+ }
606
+ void (async () => {
607
+ const assets: CanvasAssetRef[] = []
608
+ for (const image of task.result!.images) {
609
+ const dataUrl = imageDataUrl(image)
610
+ const dimensions = await readImageSize(dataUrl)
611
+ assets.push(await api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'generated', originId: task.id }))
612
+ }
613
+ updateDocument(previous => {
614
+ const ordered = previous.nodes.filter(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating')
615
+ if (ordered.length === 0) return previous
616
+ const last = ordered[ordered.length - 1]!
617
+ const nodes = previous.nodes.map(node => {
618
+ const index = ordered.indexOf(node)
619
+ if (index < 0) return node
620
+ const asset = assets[index]
621
+ return asset === undefined
622
+ ? { ...node, metadata: { ...nodeMetadata(node), status: 'error' as const, error: tt('canvas.generateFailed') } }
623
+ : { ...node, metadata: { ...nodeMetadata(node), asset, status: 'success' as const, error: undefined } }
624
+ })
625
+ // More results than placeholders: append sibling nodes below the last one.
626
+ const siblings: CanvasNode[] = []
627
+ const connections: CanvasConnection[] = []
628
+ assets.slice(ordered.length).forEach((asset, offset) => {
629
+ const id = newId('node')
630
+ siblings.push({
631
+ id, type: 'image', title: tt('canvas.imageNode'),
632
+ x: Math.round(last.x), y: Math.round(last.y + (ordered.length + offset) * (last.height + 48)),
633
+ width: last.width, height: last.height,
634
+ metadata: { status: 'success', asset, taskId: task.id, ...(sourceId === undefined ? {} : { sourceNodeId: sourceId }) },
635
+ })
636
+ if (sourceId !== undefined) connections.push({ id: newId('edge'), fromNodeId: sourceId, toNodeId: id })
637
+ })
638
+ return { ...previous, nodes: [...nodes, ...siblings], connections: [...previous.connections, ...connections] }
639
+ })
640
+ })().catch(caught => fail(caught instanceof Error ? caught.message : String(caught)))
641
+ }
642
+ }, [api, document, tasks, updateDocument, updateNodes])
643
+
644
+ // ------------------------------------------------------- import intake
645
+
646
+ const addAssets = useCallback((assets: CanvasAssetRef[], position?: Point): void => {
647
+ if (assets.length === 0) return
648
+ const center = position ?? canvasCenter()
649
+ mutate(previous => {
650
+ const nodes = assets.map((asset, index) => {
651
+ const node = createImageNode(asset)
652
+ return { ...node, x: node.x + (index % 3) * (IMAGE_NODE_SIZE.width + 40), y: node.y + Math.floor(index / 3) * (IMAGE_NODE_SIZE.height + 40) }
653
+ })
654
+ return { ...previous, nodes: [...previous.nodes, ...nodes] }
655
+ })
656
+ setSelectedIds(new Set())
657
+ }, [canvasCenter, createImageNode, mutate])
658
+
659
+ useEffect(() => {
660
+ if (importRequest === undefined) {
661
+ processedImport.current = ''
662
+ return
663
+ }
664
+ if (document === null) return
665
+ const requestKey = `${importRequest.source}:${importRequest.entryId}:${importRequest.imageIndex}`
666
+ if (processedImport.current === requestKey) return
667
+ const sourceEntries = importRequest.source === 'history' ? history : gallery
668
+ const entry = sourceEntries.find(item => item.id === importRequest.entryId)
669
+ const image = entry?.images[importRequest.imageIndex]
670
+ if (entry === undefined || image === undefined) {
671
+ processedImport.current = requestKey
672
+ onImportRequestHandled?.()
673
+ return
674
+ }
675
+ processedImport.current = requestKey
676
+ void (async () => {
677
+ const dimensions = await readImageSize(image.url)
678
+ const asset = await api.canvasImport(importRequest.source, importRequest.entryId, importRequest.imageIndex, dimensions.width, dimensions.height)
679
+ addAssets([asset])
680
+ onImportRequestHandled?.()
681
+ })().catch(caught => {
682
+ setError(caught instanceof Error ? caught.message : String(caught))
683
+ onImportRequestHandled?.()
684
+ })
685
+ }, [addAssets, api, document, gallery, history, importRequest, onImportRequestHandled])
686
+
687
+ // -------------------------------------------------------------- loading
688
+
689
+ useEffect(() => {
690
+ let disposed = false
691
+ void api.canvasList().then(async list => {
692
+ if (disposed) return
693
+ const first = list[0] === undefined ? await api.canvasCreate(tt('canvas.untitled')) : await api.canvasRead(list[0].id)
694
+ if (disposed) return
695
+ setProjects(list[0] === undefined ? [summaryOf(first)] : list)
696
+ setDocument(first)
697
+ syncedRef.current = JSON.stringify(first)
698
+ setSaveState('saved')
699
+ }).catch(caught => { if (!disposed) { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') } })
700
+ return () => { disposed = true }
701
+ }, [api])
702
+
703
+ useEffect(() => {
704
+ if (document === null || saveState === 'loading') return
705
+ const key = JSON.stringify(document)
706
+ if (key === syncedRef.current) return
707
+ setSaveState('saving')
708
+ const timer = window.setTimeout(() => {
709
+ const saveWithRetry = async (): Promise<CanvasDocument> => {
710
+ try {
711
+ return await api.canvasSave(document, document.revision)
712
+ } catch (caught) {
713
+ // Another window saved the same canvas meanwhile: rebase on the
714
+ // server revision and retry once so concurrent editing self-heals.
715
+ const message = caught instanceof Error ? caught.message : String(caught)
716
+ if (!message.includes('其他窗口')) throw caught
717
+ const server = await api.canvasRead(document.id)
718
+ return await api.canvasSave(document, server.revision)
719
+ }
720
+ }
721
+ void saveWithRetry().then(next => {
722
+ syncedRef.current = JSON.stringify(next)
723
+ setDocument(next)
724
+ setProjects(previous => [summaryOf(next), ...previous.filter(item => item.id !== next.id)])
725
+ setSaveState('saved')
726
+ }).catch(caught => { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') })
727
+ }, 650)
728
+ return () => window.clearTimeout(timer)
729
+ }, [api, document, saveState])
730
+
731
+ // ---------------------------------------------------------- composer sync
732
+
733
+ const singleSelectedId = selectedIds.size === 1 ? [...selectedIds][0]! : null
734
+ const singleSelected = useMemo(() => document?.nodes.find(node => node.id === singleSelectedId) ?? null, [document, singleSelectedId])
735
+ const composerTarget = singleSelected !== null && singleSelected.type === 'config' ? singleSelected : null
736
+ const composerInputs = useMemo(
737
+ () => composerTarget === null || document === null ? [] : upstreamNodes(document, composerTarget.id),
738
+ [composerTarget, document, upstreamNodes],
739
+ )
740
+ const composerReferenceCount = composerInputs.filter(node => node.type === 'image' && usableAsset(node) !== undefined).length
741
+ const composerTextCount = composerInputs.filter(node => node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '').length
742
+ const composerVisible = composerTarget !== null
743
+
744
+ // Prefill the prompt from connected text nodes whenever the target changes.
745
+ useEffect(() => {
746
+ const targetId = composerTarget?.id ?? null
747
+ if (targetId === composerTargetRef.current) return
748
+ composerTargetRef.current = targetId
749
+ if (composerTarget === null) return
750
+ const texts = (document?.connections ?? [])
751
+ .filter(connection => connection.toNodeId === composerTarget.id)
752
+ .map(connection => document?.nodes.find(node => node.id === connection.fromNodeId))
753
+ .filter((node): node is CanvasNode => node !== undefined && node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '')
754
+ .map(node => nodeMetadata(node).text!.trim())
755
+ setComposerPrompt(texts.join('\n'))
756
+ }, [composerTarget, document])
757
+
758
+ // ------------------------------------------------------------ keyboard
759
+
760
+ useEffect(() => {
761
+ const isEditingTarget = (target: EventTarget | null): boolean => target instanceof Element
762
+ && (target.matches('input, textarea, select, [contenteditable="true"]'))
763
+
764
+ const onKeyDown = (event: KeyboardEvent): void => {
765
+ if (event.key === 'Control') setCtrlPressed(true)
766
+ if (event.code === 'Space' && !isEditingTarget(event.target)) {
767
+ event.preventDefault()
768
+ setSpacePressed(true)
769
+ }
770
+ if (documentRef.current === null) return
771
+ const mod = event.ctrlKey || event.metaKey
772
+ if (event.key === 'Escape') {
773
+ setContextMenu(null); setCreateMenu(null)
774
+ if (!isEditingTarget(event.target)) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
775
+ return
776
+ }
777
+ if (isEditingTarget(event.target)) return
778
+ if (mod && event.key.toLowerCase() === 'z') {
779
+ event.preventDefault()
780
+ if (event.shiftKey) redo(); else undo()
781
+ } else if (mod && event.key.toLowerCase() === 'y') {
782
+ event.preventDefault(); redo()
783
+ } else if (mod && event.key.toLowerCase() === 'c') {
784
+ copySelection()
785
+ } else if (mod && event.key.toLowerCase() === 'v') {
786
+ pasteClipboard()
787
+ } else if (mod && event.key.toLowerCase() === 'd') {
788
+ event.preventDefault(); duplicateSelection()
789
+ } else if (mod && event.key.toLowerCase() === 'a') {
790
+ event.preventDefault()
791
+ const nodes = documentRef.current?.nodes ?? []
792
+ setSelectedIds(new Set(nodes.map(node => node.id)))
793
+ } else if (event.key === 'Delete' || event.key === 'Backspace') {
794
+ event.preventDefault(); deleteSelection()
795
+ }
796
+ }
797
+ const onKeyUp = (event: KeyboardEvent): void => {
798
+ if (event.code === 'Space') setSpacePressed(false)
799
+ if (event.key === 'Control') setCtrlPressed(false)
800
+ }
801
+ const onBlur = (): void => { setSpacePressed(false); setCtrlPressed(false) }
802
+ const onPaste = (event: ClipboardEvent): void => {
803
+ if (isEditingTarget(event.target)) return
804
+ const files = [...(event.clipboardData?.files ?? [])].filter(file => file.type.startsWith('image/'))
805
+ if (files.length > 0) {
806
+ event.preventDefault()
807
+ void Promise.all(files.map(async file => {
808
+ const dataUrl = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('读取图片失败')); reader.readAsDataURL(file) })
809
+ const dimensions = await readImageSize(dataUrl)
810
+ return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
811
+ })).then(assets => addAssets(assets, canvasCenter())).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
812
+ return
813
+ }
814
+ pasteClipboard()
815
+ }
816
+ window.addEventListener('keydown', onKeyDown)
817
+ window.addEventListener('keyup', onKeyUp)
818
+ window.addEventListener('blur', onBlur)
819
+ window.addEventListener('paste', onPaste)
820
+ return () => {
821
+ window.removeEventListener('keydown', onKeyDown)
822
+ window.removeEventListener('keyup', onKeyUp)
823
+ window.removeEventListener('blur', onBlur)
824
+ window.removeEventListener('paste', onPaste)
825
+ }
826
+ }, [addAssets, api, canvasCenter, copySelection, deleteSelection, duplicateSelection, pasteClipboard, redo, undo])
827
+
828
+ // ------------------------------------------------------ viewport events
829
+
830
+ useEffect(() => {
831
+ const container = viewportRef.current
832
+ if (container === null) return
833
+ const measure = (): void => setViewportSize({ width: container.clientWidth, height: container.clientHeight })
834
+ measure()
835
+ const observer = new ResizeObserver(measure)
836
+ observer.observe(container)
837
+ const preventWheel = (event: WheelEvent): void => {
838
+ if (event.target instanceof Element && event.target.closest(`[data-canvas-no-zoom]`)) return
839
+ event.preventDefault()
840
+ }
841
+ container.addEventListener('wheel', preventWheel, { passive: false })
842
+ return () => { observer.disconnect(); container.removeEventListener('wheel', preventWheel) }
843
+ }, [])
844
+
845
+ const temporaryPanTool = spacePressed || ctrlPressed
846
+
847
+ const onViewportPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
848
+ const target = event.target instanceof Element ? event.target : null
849
+ setContextMenu(null); setCreateMenu(null)
850
+ const isBackground = target?.closest('[data-node-id],[data-connection-hit]') === null
851
+ const shouldPan = event.button === 1 || (event.button === 0 && (tool === 'pan' || temporaryPanTool) && isBackground)
852
+ if (shouldPan) {
853
+ event.preventDefault()
854
+ event.currentTarget.setPointerCapture(event.pointerId)
855
+ const current = documentRef.current
856
+ if (current !== null) {
857
+ panRef.current = { startX: event.clientX, startY: event.clientY, viewportX: current.viewport.x, viewportY: current.viewport.y, hasMoved: false, startedOnBackground: isBackground }
858
+ }
859
+ return
860
+ }
861
+ if (event.button === 0 && isBackground && tool === 'select') {
862
+ event.preventDefault()
863
+ event.currentTarget.setPointerCapture(event.pointerId)
864
+ const world = screenToWorld(event.clientX, event.clientY)
865
+ const next: MarqueeState = { start: world, current: world, additive: event.shiftKey, initialIds: event.shiftKey ? [...selectedIdsRef.current] : [] }
866
+ marqueeRef.current = next
867
+ setMarquee(next)
868
+ if (!event.shiftKey) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
869
+ }
870
+ }
871
+
872
+ const onWheel = (event: React.WheelEvent<HTMLDivElement>): void => {
873
+ const current = documentRef.current
874
+ if (current === null) return
875
+ if (event.target instanceof Element && event.target.closest('[data-canvas-no-zoom]')) return
876
+ event.preventDefault()
877
+ const bounds = viewportRef.current?.getBoundingClientRect()
878
+ if (bounds === undefined) return
879
+ const mouseX = event.clientX - bounds.left
880
+ const mouseY = event.clientY - bounds.top
881
+ const scale = clampScale(current.viewport.k * Math.pow(1.1, -event.deltaY / 100))
882
+ const worldX = (mouseX - current.viewport.x) / current.viewport.k
883
+ const worldY = (mouseY - current.viewport.y) / current.viewport.k
884
+ setViewport({ x: mouseX - worldX * scale, y: mouseY - worldY * scale, k: scale })
885
+ }
886
+
887
+ const setZoomAtCenter = useCallback((scale: number): void => {
888
+ const current = documentRef.current
889
+ const bounds = viewportRef.current?.getBoundingClientRect()
890
+ if (current === null || bounds === undefined) return
891
+ const next = clampScale(scale)
892
+ const centerX = bounds.width / 2
893
+ const centerY = bounds.height / 2
894
+ const worldX = (centerX - current.viewport.x) / current.viewport.k
895
+ const worldY = (centerY - current.viewport.y) / current.viewport.k
896
+ setViewport({ x: centerX - worldX * next, y: centerY - worldY * next, k: next })
897
+ }, [setViewport])
898
+
899
+ const fitView = useCallback((): void => {
900
+ const current = documentRef.current
901
+ const bounds = viewportRef.current?.getBoundingClientRect()
902
+ if (current === null || bounds === undefined) return
903
+ if (current.nodes.length === 0) {
904
+ setViewport({ x: 0, y: 0, k: 1 })
905
+ return
906
+ }
907
+ const content = nodesBounds(current.nodes)
908
+ const padding = 80
909
+ const contentWidth = Math.max(1, content.maxX - content.minX)
910
+ const contentHeight = Math.max(1, content.maxY - content.minY)
911
+ const scale = clampScale(Math.min((bounds.width - padding * 2) / contentWidth, (bounds.height - padding * 2) / contentHeight))
912
+ setViewport({
913
+ k: scale,
914
+ x: (bounds.width - contentWidth * scale) / 2 - content.minX * scale,
915
+ y: (bounds.height - contentHeight * scale) / 2 - content.minY * scale,
916
+ })
917
+ }, [setViewport])
918
+
919
+ // -------------------------------------------------- global move / up
920
+
921
+ useEffect(() => {
922
+ const move = (event: PointerEvent): void => {
923
+ const drag = dragRef.current
924
+ if (drag !== null) {
925
+ const scale = documentRef.current?.viewport.k ?? 1
926
+ const dx = (event.clientX - drag.startX) / scale
927
+ const dy = (event.clientY - drag.startY) / scale
928
+ if (!drag.moved && Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 3) {
929
+ drag.moved = true
930
+ commitSnapshot(drag.snapshot)
931
+ }
932
+ if (drag.moved) {
933
+ updateNodes(nodes => nodes.map(node => {
934
+ const origin = drag.origins.get(node.id)
935
+ return origin === undefined ? node : { ...node, x: Math.round(origin.x + dx), y: Math.round(origin.y + dy) }
936
+ }))
937
+ }
938
+ return
939
+ }
940
+ const connect = connectRef.current
941
+ if (connect !== null) {
942
+ const world = screenToWorld(event.clientX, event.clientY)
943
+ const nodes = documentRef.current?.nodes ?? []
944
+ let targetId: string | null = null
945
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
946
+ const node = nodes[index]!
947
+ if (node.id === connect.nodeId) continue
948
+ if (world.x >= node.x && world.x <= node.x + node.width && world.y >= node.y && world.y <= node.y + node.height) {
949
+ targetId = node.id
950
+ break
951
+ }
952
+ }
953
+ const next = { ...connect, mouse: world, targetId }
954
+ connectRef.current = next
955
+ setConnecting(next)
956
+ return
957
+ }
958
+ const resize = resizeRef.current
959
+ if (resize !== null) {
960
+ const scale = documentRef.current?.viewport.k ?? 1
961
+ const dx = (event.clientX - resize.startX) / scale
962
+ const dy = (event.clientY - resize.startY) / scale
963
+ const minWidth = 140
964
+ const minHeight = 100
965
+ let width = Math.max(minWidth, resize.width + (resize.corner === 'bottom-right' ? dx : -dx))
966
+ let height = Math.max(minHeight, resize.height + dy)
967
+ if (resize.ratio !== null) height = Math.max(minHeight, Math.round(width * resize.ratio))
968
+ updateNodes(nodes => nodes.map(node => node.id === resize.nodeId
969
+ ? { ...node, x: Math.round(resize.corner === 'bottom-right' ? resize.x : resize.x + (resize.width - width)), y: Math.round(resize.y), width: Math.round(width), height: Math.round(height) }
970
+ : node))
971
+ return
972
+ }
973
+ const activeMarquee = marqueeRef.current
974
+ if (activeMarquee !== null) {
975
+ const next = { ...activeMarquee, current: screenToWorld(event.clientX, event.clientY) }
976
+ marqueeRef.current = next
977
+ setMarquee(next)
978
+ return
979
+ }
980
+ const pan = panRef.current
981
+ if (pan !== null) {
982
+ const dx = event.clientX - pan.startX
983
+ const dy = event.clientY - pan.startY
984
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) pan.hasMoved = true
985
+ const next = { x: pan.viewportX + dx, y: pan.viewportY + dy }
986
+ if (panFrameRef.current !== null) return
987
+ panFrameRef.current = requestAnimationFrame(() => {
988
+ panFrameRef.current = null
989
+ updateDocument(previous => ({ ...previous, viewport: { ...previous.viewport, x: next.x, y: next.y } }))
990
+ })
991
+ }
992
+ }
993
+
994
+ const up = (): void => {
995
+ const drag = dragRef.current
996
+ if (drag !== null) {
997
+ dragRef.current = null
998
+ return
999
+ }
1000
+ const connect = connectRef.current
1001
+ if (connect !== null) {
1002
+ connectRef.current = null
1003
+ setConnecting(null)
1004
+ if (connect.targetId !== null) {
1005
+ if (connect.handleType === 'source') connectNodes(connect.nodeId, connect.targetId)
1006
+ else connectNodes(connect.targetId, connect.nodeId)
1007
+ }
1008
+ return
1009
+ }
1010
+ const resize = resizeRef.current
1011
+ if (resize !== null) {
1012
+ resizeRef.current = null
1013
+ return
1014
+ }
1015
+ const activeMarquee = marqueeRef.current
1016
+ if (activeMarquee !== null) {
1017
+ marqueeRef.current = null
1018
+ setMarquee(null)
1019
+ const minX = Math.min(activeMarquee.start.x, activeMarquee.current.x)
1020
+ const minY = Math.min(activeMarquee.start.y, activeMarquee.current.y)
1021
+ const maxX = Math.max(activeMarquee.start.x, activeMarquee.current.x)
1022
+ const maxY = Math.max(activeMarquee.start.y, activeMarquee.current.y)
1023
+ const nodes = documentRef.current?.nodes ?? []
1024
+ const hits = nodes.filter(node => node.x < maxX && node.x + node.width > minX && node.y < maxY && node.y + node.height > minY).map(node => node.id)
1025
+ if (Math.abs(activeMarquee.current.x - activeMarquee.start.x) < 4 && Math.abs(activeMarquee.current.y - activeMarquee.start.y) < 4) {
1026
+ setSelectedConnectionId(null)
1027
+ return
1028
+ }
1029
+ const next = activeMarquee.additive
1030
+ ? new Set([...activeMarquee.initialIds, ...hits])
1031
+ : new Set(hits)
1032
+ setSelectedIds(next)
1033
+ setSelectedConnectionId(null)
1034
+ return
1035
+ }
1036
+ const pan = panRef.current
1037
+ if (pan !== null) {
1038
+ panRef.current = null
1039
+ if (!pan.hasMoved && pan.startedOnBackground) {
1040
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ window.addEventListener('pointermove', move)
1046
+ window.addEventListener('pointerup', up)
1047
+ window.addEventListener('pointercancel', up)
1048
+ return () => {
1049
+ window.removeEventListener('pointermove', move)
1050
+ window.removeEventListener('pointerup', up)
1051
+ window.removeEventListener('pointercancel', up)
1052
+ }
1053
+ }, [commitSnapshot, connectNodes, screenToWorld, updateDocument, updateNodes])
1054
+
1055
+ // --------------------------------------------------------- node events
1056
+
1057
+ const handleNodePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, nodeId: string): void => {
1058
+ if (event.button !== 0 || tool === 'pan' || temporaryPanTool) return
1059
+ const current = documentRef.current
1060
+ if (current === null) return
1061
+ const node = current.nodes.find(item => item.id === nodeId)
1062
+ if (node === undefined) return
1063
+ event.stopPropagation()
1064
+ const additive = event.shiftKey || event.ctrlKey || event.metaKey
1065
+ let nextSelection = selectedIdsRef.current
1066
+ if (additive) {
1067
+ nextSelection = new Set(selectedIdsRef.current)
1068
+ if (nextSelection.has(nodeId)) nextSelection.delete(nodeId)
1069
+ else nextSelection.add(nodeId)
1070
+ } else if (!nextSelection.has(nodeId)) {
1071
+ nextSelection = new Set([nodeId])
1072
+ }
1073
+ setSelectedIds(nextSelection)
1074
+ setSelectedConnectionId(null)
1075
+ const origins = new Map<string, Point>()
1076
+ for (const id of nextSelection) {
1077
+ const item = current.nodes.find(candidate => candidate.id === id)
1078
+ if (item !== undefined) origins.set(id, { x: item.x, y: item.y })
1079
+ }
1080
+ dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false, snapshot: JSON.stringify(current), origins }
1081
+ }, [temporaryPanTool, tool])
1082
+
1083
+ const handleConnectStart = useCallback((event: ReactPointerEvent<HTMLDivElement>, nodeId: string, handleType: 'source' | 'target'): void => {
1084
+ if (event.button !== 0) return
1085
+ event.stopPropagation(); event.preventDefault()
1086
+ const world = screenToWorld(event.clientX, event.clientY)
1087
+ const next: ConnectState = { nodeId, handleType, mouse: world, targetId: null }
1088
+ connectRef.current = next
1089
+ setConnecting(next)
1090
+ setSelectedConnectionId(null)
1091
+ }, [screenToWorld])
1092
+
1093
+ const handleResizeStart = useCallback((event: ReactPointerEvent<HTMLDivElement>, node: CanvasNode, corner: 'bottom-right' | 'bottom-left'): void => {
1094
+ if (event.button !== 0) return
1095
+ event.stopPropagation(); event.preventDefault()
1096
+ const asset = assetOf(node)
1097
+ const ratio = node.type === 'image' && asset !== undefined && asset.width > 0 && asset.height > 0 ? asset.width / asset.height : null
1098
+ resizeRef.current = { nodeId: node.id, corner, startX: event.clientX, startY: event.clientY, width: node.width, height: node.height, x: node.x, y: node.y, ratio }
1099
+ beginHistory()
1100
+ }, [beginHistory])
1101
+
1102
+ const handleConnectionSelect = useCallback((connectionId: string): void => {
1103
+ setSelectedConnectionId(connectionId)
1104
+ setSelectedIds(new Set())
1105
+ }, [])
1106
+
1107
+ // -------------------------------------------------------- file dropping
1108
+
1109
+ const onDrop = useCallback((event: React.DragEvent<HTMLDivElement>): void => {
1110
+ event.preventDefault()
1111
+ const files = [...(event.dataTransfer.files ?? [])].filter(file => file.type.startsWith('image/'))
1112
+ if (files.length === 0) return
1113
+ const world = screenToWorld(event.clientX, event.clientY)
1114
+ void Promise.all(files.map(async file => {
1115
+ const dataUrl = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('读取图片失败')); reader.readAsDataURL(file) })
1116
+ const dimensions = await readImageSize(dataUrl)
1117
+ return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
1118
+ })).then(assets => addAssets(assets, world)).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
1119
+ }, [addAssets, api, screenToWorld])
1120
+
1121
+ // ------------------------------------------------------------ projects
1122
+
1123
+ const newCanvas = useCallback(async (): Promise<void> => {
1124
+ try {
1125
+ const next = await api.canvasCreate(tt('canvas.untitled'))
1126
+ setProjects(previous => [summaryOf(next), ...previous])
1127
+ setDocument(next); setSelectedIds(new Set()); setSelectedConnectionId(null)
1128
+ syncedRef.current = JSON.stringify(next); setSaveState('saved')
1129
+ pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1130
+ } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1131
+ }, [api])
1132
+
1133
+ const selectProject = useCallback(async (id: string): Promise<void> => {
1134
+ try {
1135
+ const next = await api.canvasRead(id)
1136
+ setDocument(next); setSelectedIds(new Set()); setSelectedConnectionId(null)
1137
+ syncedRef.current = JSON.stringify(next); setSaveState('saved')
1138
+ pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1139
+ } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1140
+ }, [api])
1141
+
1142
+ const removeCurrentProject = useCallback(async (): Promise<void> => {
1143
+ const current = documentRef.current
1144
+ if (current === null) return
1145
+ try {
1146
+ const remaining = await api.canvasRemove(current.id)
1147
+ setConfirmDeleteProject(false)
1148
+ const nextId = remaining[0]?.id
1149
+ if (nextId === undefined) {
1150
+ const created = await api.canvasCreate(tt('canvas.untitled'))
1151
+ setProjects([summaryOf(created)]); setDocument(created)
1152
+ syncedRef.current = JSON.stringify(created); setSaveState('saved')
1153
+ } else {
1154
+ setProjects(remaining)
1155
+ await selectProject(nextId)
1156
+ }
1157
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
1158
+ } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1159
+ }, [api, selectProject])
1160
+
1161
+ // -------------------------------------------------------------- derived
1162
+
1163
+ const nodeById = useMemo(() => new Map((document?.nodes ?? []).map(node => [node.id, node])), [document])
1164
+ const relatedIds = useMemo(() => {
1165
+ const related = new Set<string>()
1166
+ if (document === null) return related
1167
+ for (const connection of document.connections) {
1168
+ if (selectedIds.has(connection.fromNodeId)) related.add(connection.toNodeId)
1169
+ if (selectedIds.has(connection.toNodeId)) related.add(connection.fromNodeId)
1170
+ }
1171
+ return related
1172
+ }, [document, selectedIds])
1173
+
1174
+ const isSpaceOrCtrl = temporaryPanTool
1175
+ const cursorClass = tool === 'pan' || isSpaceOrCtrl ? css.panCursor : css.selectCursor
1176
+
1177
+ const backgroundMode = document?.background ?? 'dots'
1178
+ const cycleBackground = useCallback((): void => {
1179
+ mutate(previous => ({ ...previous, background: previous.background === 'dots' ? 'lines' : previous.background === 'lines' ? 'blank' : 'dots' }))
1180
+ }, [mutate])
1181
+
1182
+ const gridSize = GRID_SIZE * (document?.viewport.k ?? 1)
1183
+ const gridOffsetX = (document?.viewport.x ?? 0) % gridSize
1184
+ const gridOffsetY = (document?.viewport.y ?? 0) % gridSize
1185
+
1186
+ // ------------------------------------------------------------- render
1187
+
1188
+ const renderNode = (node: CanvasNode): React.JSX.Element => {
1189
+ const metadata = nodeMetadata(node)
1190
+ const isSelected = selectedIds.has(node.id)
1191
+ const isRelated = relatedIds.has(node.id)
1192
+ const asset = assetOf(node)
1193
+ const isGenerating = node.type === 'image' && metadata.status === 'generating'
1194
+ const isError = node.type === 'image' && metadata.status === 'error'
1195
+ const isConnectTarget = connecting?.targetId === node.id
1196
+ const hasImage = asset !== undefined && asset.url !== ''
1197
+ const isConfig = node.type === 'config'
1198
+ const isTextual = node.type === 'text' || isConfig
1199
+ return <div
1200
+ key={node.id}
1201
+ data-node-id={node.id}
1202
+ className={`${css.node} ${isConfig ? css.configNode : isTextual ? css.textNode : css.imageNode} ${isSelected ? css.nodeSelected : ''} ${isRelated ? css.nodeRelated : ''} ${isConnectTarget ? css.nodeConnectTarget : ''}`}
1203
+ style={{ left: node.x, top: node.y, width: node.width, height: node.height }}
1204
+ onPointerDown={event => handleNodePointerDown(event, node.id)}
1205
+ onContextMenu={event => {
1206
+ if ((event.target as Element).closest('textarea, input, select')) return
1207
+ event.preventDefault(); event.stopPropagation()
1208
+ if (!selectedIds.has(node.id)) setSelectedIds(new Set([node.id]))
1209
+ setContextMenu({ type: 'node', screen: { x: event.clientX, y: event.clientY }, nodeId: node.id })
1210
+ }}
1211
+ >
1212
+ <div className={css.nodeGlow} aria-hidden="true" />
1213
+ {isTextual ? <header className={css.nodeHeader}>
1214
+ <span className={css.nodeTitle}>{node.title}</span>
1215
+ </header> : null}
1216
+ {isConfig ? <div className={css.configLinks} data-config-links={node.id}>
1217
+ <span className={css.composerChip}>{tt('canvas.composerLinked', { count: (document?.connections ?? []).filter(connection => connection.toNodeId === node.id).length })}</span>
1218
+ </div> : null}
1219
+ {isConfig
1220
+ ? <p className={css.configHint}>{tt('canvas.configHint')}</p>
1221
+ : isTextual
1222
+ ? <textarea
1223
+ className={css.textArea}
1224
+ value={metadata.text ?? ''}
1225
+ placeholder={tt('canvas.textPlaceholder')}
1226
+ onPointerDown={event => event.stopPropagation()}
1227
+ onChange={event => patchNode(node.id, { text: event.target.value })}
1228
+ />
1229
+ : <div className={css.nodeBody}>
1230
+ {isGenerating
1231
+ ? <div className={css.nodeState}><span className={css.spinner} aria-hidden="true" /><span>{tt('canvas.generatingNode')}</span></div>
1232
+ : isError
1233
+ ? <div className={css.nodeStateError}>{metadata.error ?? tt('canvas.generateFailed')}<button type="button" onClick={() => { void retryGeneration(node) }}>{tt('canvas.retry')}</button></div>
1234
+ : hasImage
1235
+ ? <img src={asset.url} alt={node.title} draggable={false} onDragStart={event => event.preventDefault()} />
1236
+ : <button type="button" className={css.nodeEmpty} onClick={() => setPickerOpen(true)}><ToolbarIcon name="image" /><span>{tt('canvas.emptyImageNode')}</span></button>}
1237
+ </div>}
1238
+ {isSelected
1239
+ ? <div className={css.resizeHandle} onPointerDown={event => handleResizeStart(event, node, 'bottom-right')} title={tt('canvas.resizeHint')} />
1240
+ : null}
1241
+ <div className={`${css.handle} ${css.handleLeft}`} title={tt('canvas.connectHint')} onPointerDown={event => handleConnectStart(event, node.id, 'target')} />
1242
+ <div className={`${css.handle} ${css.handleRight}`} title={tt('canvas.connectHint')} onPointerDown={event => handleConnectStart(event, node.id, 'source')} />
1243
+ <div className={css.hoverToolbar} onPointerDown={event => event.stopPropagation()}>
1244
+ {node.type === 'image' && hasImage ? <IconButton name="download" label={tt('canvas.download')} onClick={() => downloadNode(node)} /> : null}
1245
+ <IconButton name="duplicate" label={tt('canvas.duplicate')} onClick={duplicateSelection} />
1246
+ <IconButton name="trash" label={tt('canvas.delete')} onClick={deleteSelection} />
1247
+ </div>
1248
+ </div>
1249
+ }
1250
+
1251
+ const renderConnections = (): React.JSX.Element => (
1252
+ <svg
1253
+ className={css.connectionLayer}
1254
+ width={WORLD_PAD * 2}
1255
+ height={WORLD_PAD * 2}
1256
+ style={{ left: -WORLD_PAD, top: -WORLD_PAD }}
1257
+ aria-hidden="true"
1258
+ >
1259
+ <g transform={`translate(${WORLD_PAD},${WORLD_PAD})`}>
1260
+ {(document?.connections ?? []).map(connection => {
1261
+ const from = nodeById.get(connection.fromNodeId)
1262
+ const to = nodeById.get(connection.toNodeId)
1263
+ if (from === undefined || to === undefined) return null
1264
+ const path = bezierPath(nodeAnchor(from, 'right'), nodeAnchor(to, 'left'))
1265
+ const active = connection.id === selectedConnectionId
1266
+ return <g key={connection.id}>
1267
+ <path
1268
+ data-connection-hit={connection.id}
1269
+ d={path}
1270
+ stroke="transparent"
1271
+ strokeWidth={16}
1272
+ fill="none"
1273
+ style={{ cursor: 'pointer', pointerEvents: 'stroke' }}
1274
+ onPointerDown={event => { event.stopPropagation(); handleConnectionSelect(connection.id) }}
1275
+ onContextMenu={event => {
1276
+ event.preventDefault(); event.stopPropagation()
1277
+ handleConnectionSelect(connection.id)
1278
+ setContextMenu({ type: 'connection', screen: { x: event.clientX, y: event.clientY }, connectionId: connection.id })
1279
+ }}
1280
+ />
1281
+ <path d={path} className={`${css.connectionPath} ${active ? css.connectionActive : ''}`} />
1282
+ </g>
1283
+ })}
1284
+ {connecting !== null ? (() => {
1285
+ const node = nodeById.get(connecting.nodeId)
1286
+ if (node === undefined) return null
1287
+ const mouse = connecting.targetId !== undefined && connecting.targetId !== null && nodeById.has(connecting.targetId)
1288
+ ? nodeAnchor(nodeById.get(connecting.targetId)!, connecting.handleType === 'source' ? 'left' : 'right')
1289
+ : connecting.mouse
1290
+ const path = connecting.handleType === 'source'
1291
+ ? bezierPath(nodeAnchor(node, 'right'), mouse)
1292
+ : bezierPath(mouse, nodeAnchor(node, 'left'))
1293
+ return <path d={path} className={css.connectionPreview} />
1294
+ })() : null}
1295
+ </g>
1296
+ </svg>
1297
+ )
1298
+
1299
+ const renderComposer = (): ReactNode => {
1300
+ if (!composerVisible || document === null || composerTarget === null) return null
1301
+ const linkedCount = composerReferenceCount + composerTextCount
1302
+ const k = document.viewport.k
1303
+ const centerX = document.viewport.x + (composerTarget.x + composerTarget.width / 2) * k
1304
+ const clampedX = Math.min(Math.max(centerX, 292), Math.max(292, viewportSize.width - 292))
1305
+ const belowY = document.viewport.y + (composerTarget.y + composerTarget.height) * k + 14
1306
+ const top = belowY > viewportSize.height - 170
1307
+ ? Math.max(64, document.viewport.y + composerTarget.y * k - 158)
1308
+ : belowY
1309
+ return <div className={css.composer} data-canvas-no-zoom="" style={{ left: clampedX - 280, top }}>
1310
+ <textarea
1311
+ className={css.composerPrompt}
1312
+ value={composerPrompt}
1313
+ placeholder={tt('canvas.composerPlaceholder')}
1314
+ rows={1}
1315
+ onPointerDown={event => event.stopPropagation()}
1316
+ onChange={event => setComposerPrompt(event.target.value)}
1317
+ onKeyDown={event => {
1318
+ if (event.key === 'Enter' && !event.shiftKey) {
1319
+ event.preventDefault()
1320
+ void submitComposer(composerTarget)
1321
+ }
1322
+ }}
1323
+ />
1324
+ {linkedCount > 0 ? <div className={css.composerMeta}>
1325
+ <span className={css.composerChip}>{tt('canvas.composerLinked', { count: linkedCount })}</span>
1326
+ </div> : null}
1327
+ <div className={css.composerControls}>
1328
+ <select value={composerModel} onChange={(event: ChangeEvent<HTMLSelectElement>) => setComposerModel(event.target.value)} aria-label={tt('canvas.model')}>
1329
+ <option value="">{tt('canvas.modelPlaceholder')}</option>
1330
+ {imageModels.map(item => <option key={item} value={item}>{item}</option>)}
1331
+ </select>
1332
+ <select value={composerSize} onChange={event => setComposerSize(event.target.value)} aria-label={tt('canvas.size')}>
1333
+ <option value="auto">{tt('canvas.sizeAuto')}</option>
1334
+ <option value="1:1">1:1</option>
1335
+ <option value="3:4">3:4</option>
1336
+ <option value="16:9">16:9</option>
1337
+ <option value="9:16">9:16</option>
1338
+ </select>
1339
+ <select value={composerQuality} onChange={event => setComposerQuality(event.target.value)} aria-label={tt('canvas.quality')}>
1340
+ <option value="auto">{tt('canvas.qualityAuto')}</option>
1341
+ <option value="1k">1K</option>
1342
+ <option value="2k">2K</option>
1343
+ <option value="4k">4K</option>
1344
+ </select>
1345
+ <select value={composerCount} onChange={event => setComposerCount(Number(event.target.value))} aria-label={tt('canvas.count')}>
1346
+ {[1, 2, 3, 4].map(item => <option key={item} value={item}>{tt('canvas.countUnit', { count: item })}</option>)}
1347
+ </select>
1348
+ <button
1349
+ type="button"
1350
+ className={css.composerSend}
1351
+ aria-label={tt('canvas.generate')}
1352
+ title={tt('canvas.generate')}
1353
+ disabled={!connected || composerBusy || (composerPrompt.trim() === '' && composerTextCount === 0)}
1354
+ onClick={() => { void submitComposer(composerTarget) }}
1355
+ >{composerBusy ? <span className={css.spinner} aria-hidden="true" /> : <ToolbarIcon name="send" />}</button>
1356
+ </div>
1357
+ </div>
1358
+ }
1359
+
1360
+ const renderMinimap = (): React.JSX.Element | null => {
1361
+ if (document === null || viewportSize.width === 0) return null
1362
+ const width = 220
1363
+ const height = 150
1364
+ const nodes = document.nodes
1365
+ let worldBounds = { x: -600, y: -600, w: 1200, h: 1200 }
1366
+ let scale = Math.min(width / worldBounds.w, height / worldBounds.h)
1367
+ let offset = { x: (width - worldBounds.w * scale) / 2, y: (height - worldBounds.h * scale) / 2 }
1368
+ if (nodes.length > 0) {
1369
+ const content = nodesBounds(nodes)
1370
+ worldBounds = { x: content.minX - 500, y: content.minY - 500, w: content.maxX - content.minX + 1000, h: content.maxY - content.minY + 1000 }
1371
+ scale = Math.min(width / worldBounds.w, height / worldBounds.h)
1372
+ offset = { x: (width - worldBounds.w * scale) / 2, y: (height - worldBounds.h * scale) / 2 }
1373
+ }
1374
+ const toMap = (worldX: number, worldY: number): Point => ({ x: (worldX - worldBounds.x) * scale + offset.x, y: (worldY - worldBounds.y) * scale + offset.y })
1375
+ const toWorld = (mapX: number, mapY: number): Point => ({ x: (mapX - offset.x) / scale + worldBounds.x, y: (mapY - offset.y) / scale + worldBounds.y })
1376
+ const viewportRect = (() => {
1377
+ const vx = -document.viewport.x / document.viewport.k
1378
+ const vy = -document.viewport.y / document.viewport.k
1379
+ const p1 = toMap(vx, vy)
1380
+ const p2 = toMap(vx + viewportSize.width / document.viewport.k, vy + viewportSize.height / document.viewport.k)
1381
+ return { x: p1.x, y: p1.y, w: Math.max(p2.x - p1.x, 4), h: Math.max(p2.y - p1.y, 4) }
1382
+ })()
1383
+ const jump = (event: ReactPointerEvent<HTMLDivElement>): void => {
1384
+ const bounds = event.currentTarget.getBoundingClientRect()
1385
+ const world = toWorld(event.clientX - bounds.left, event.clientY - bounds.top)
1386
+ setViewport({ k: document.viewport.k, x: viewportSize.width / 2 - world.x * document.viewport.k, y: viewportSize.height / 2 - world.y * document.viewport.k })
1387
+ }
1388
+ return <aside className={css.minimap} data-canvas-no-zoom="" aria-label={tt('canvas.minimap')}>
1389
+ <div className={css.minimapCanvas} onPointerDown={event => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); jump(event) }}
1390
+ onPointerMove={event => { if (event.buttons === 1) jump(event) }}>
1391
+ {nodes.map(node => {
1392
+ const position = toMap(node.x, node.y)
1393
+ return <div key={node.id} className={`${css.minimapNode} ${node.type === 'image' ? css.minimapImage : css.minimapText} ${selectedIds.has(node.id) ? css.minimapSelected : ''}`}
1394
+ style={{ left: position.x, top: position.y, width: Math.max(node.width * scale, 2), height: Math.max(node.height * scale, 2) }} />
1395
+ })}
1396
+ <div className={css.minimapViewport} style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h }} />
1397
+ </div>
1398
+ </aside>
1399
+ }
1400
+
1401
+ const renderContextMenu = (): ReactNode => {
1402
+ if (contextMenu !== null) {
1403
+ const close = (): void => setContextMenu(null)
1404
+ const items: Array<{ label: string; action: () => void; danger?: boolean; icon: ToolbarIconName }> = []
1405
+ if (contextMenu.type === 'node') {
1406
+ const node = nodeById.get(contextMenu.nodeId)
1407
+ if (node !== undefined && node.type === 'image' && (assetOf(node)?.url.length ?? 0) > 0) items.push({ label: tt('canvas.download'), icon: 'download', action: () => downloadNode(node) })
1408
+ items.push({ label: tt('canvas.duplicate'), icon: 'duplicate', action: duplicateSelection })
1409
+ items.push({ label: tt('canvas.delete'), icon: 'trash', action: deleteSelection, danger: true })
1410
+ } else if (contextMenu.type === 'connection') {
1411
+ items.push({
1412
+ label: tt('canvas.deleteConnection'), icon: 'close', danger: true,
1413
+ action: () => {
1414
+ mutate(previous => ({ ...previous, connections: previous.connections.filter(connection => connection.id !== contextMenu.connectionId) }))
1415
+ setSelectedConnectionId(null)
1416
+ },
1417
+ })
1418
+ } else {
1419
+ items.push({ label: tt('canvas.addImage'), icon: 'image', action: () => setPickerOpen(true) })
1420
+ items.push({ label: tt('canvas.addTextNode'), icon: 'text', action: () => placeNewNode(createTextNode(contextMenu.world)) })
1421
+ items.push({ label: tt('canvas.paste'), icon: 'duplicate', action: () => pasteClipboard(contextMenu.world) })
1422
+ items.push({ label: tt('canvas.fitView'), icon: 'fit', action: fitView })
1423
+ }
1424
+ return <div className={css.contextMenu} style={{ left: contextMenu.screen.x, top: contextMenu.screen.y }} data-canvas-no-zoom="" role="menu">
1425
+ {items.map(item => <button key={item.label} type="button" role="menuitem" data-danger={item.danger ? '' : undefined} onClick={() => { item.action(); close() }}><ToolbarIcon name={item.icon} />{item.label}</button>)}
1426
+ </div>
1427
+ }
1428
+ if (createMenu !== null) {
1429
+ return <div className={css.contextMenu} style={{ left: createMenu.screen.x, top: createMenu.screen.y }} data-canvas-no-zoom="" role="menu">
1430
+ <button type="button" role="menuitem" onClick={() => { placeNewNode(createImageNode({ assetId: '', url: '', mime: 'image/png', bytes: 0, width: 1, height: 1, origin: 'upload' }, createMenu.world)); setCreateMenu(null) }}><ToolbarIcon name="image" />{tt('canvas.addImageNode')}</button>
1431
+ <button type="button" role="menuitem" onClick={() => { placeNewNode(createTextNode(createMenu.world)); setCreateMenu(null) }}><ToolbarIcon name="text" />{tt('canvas.addTextNode')}</button>
1432
+ </div>
1433
+ }
1434
+ return null
1435
+ }
1436
+
1437
+ const emptyState = document !== null && document.nodes.length === 0
1438
+ ? <div className={css.emptyHint} data-canvas-no-zoom="">
1439
+ <strong>{tt('canvas.emptyTitle')}</strong>
1440
+ <span>{tt('canvas.emptyHint')}</span>
1441
+ </div>
1442
+ : null
1443
+
1444
+ const marqueeRect = marquee === null ? null : (() => {
1445
+ const x1 = (Math.min(marquee.start.x, marquee.current.x) * (document?.viewport.k ?? 1)) + (document?.viewport.x ?? 0)
1446
+ const y1 = (Math.min(marquee.start.y, marquee.current.y) * (document?.viewport.k ?? 1)) + (document?.viewport.y ?? 0)
1447
+ const x2 = (Math.max(marquee.start.x, marquee.current.x) * (document?.viewport.k ?? 1)) + (document?.viewport.x ?? 0)
1448
+ const y2 = (Math.max(marquee.start.y, marquee.current.y) * (document?.viewport.k ?? 1)) + (document?.viewport.y ?? 0)
1449
+ return { left: x1, top: y1, width: x2 - x1, height: y2 - y1 }
1450
+ })()
1451
+
1452
+ return <section className={css.root} data-canvas-workspace="">
1453
+ <header className={css.topBar} data-canvas-no-zoom="">
1454
+ <select className={css.projectSelect} value={document?.id ?? ''} onChange={event => { void selectProject(event.target.value) }} aria-label={tt('canvas.project')}>
1455
+ {projects.map(project => <option key={project.id} value={project.id}>{project.title}</option>)}
1456
+ </select>
1457
+ <IconButton name="new" label={tt('canvas.newCanvas')} onClick={() => { void newCanvas() }} />
1458
+ <IconButton name="deleteProject" label={confirmDeleteProject ? tt('canvas.deleteCanvasConfirm') : tt('canvas.deleteCanvas')} active={confirmDeleteProject} disabled={document === null} onClick={() => {
1459
+ if (confirmDeleteProject) { void removeCurrentProject() } else { setConfirmDeleteProject(true); window.setTimeout(() => setConfirmDeleteProject(false), 3000) }
1460
+ }} />
1461
+ {renamingTitle && document !== null
1462
+ ? <input
1463
+ className={css.titleInput}
1464
+ value={document.title}
1465
+ autoFocus
1466
+ aria-label={tt('canvas.rename')}
1467
+ onChange={event => updateDocument(previous => ({ ...previous, title: event.target.value }))}
1468
+ onBlur={() => setRenamingTitle(false)}
1469
+ onKeyDown={event => { if (event.key === 'Enter' || event.key === 'Escape') setRenamingTitle(false) }}
1470
+ />
1471
+ : <button type="button" className={css.titleButton} onDoubleClick={() => setRenamingTitle(true)} title={tt('canvas.renameHint')}>{document?.title ?? ''}</button>}
1472
+ <span className={css.topBarSpacer} />
1473
+ <IconButton name="background" label={tt('canvas.background')} onClick={cycleBackground} />
1474
+ <IconButton name="undo" label={tt('canvas.undo')} disabled={pastRef.current.length === 0} onClick={undo} />
1475
+ <IconButton name="redo" label={tt('canvas.redo')} disabled={futureRef.current.length === 0} onClick={redo} />
1476
+ <span className={css.saveState} data-state={saveState}>{saveState === 'saving' ? tt('canvas.saving') : saveState === 'saved' ? tt('canvas.saved') : saveState === 'error' ? tt('canvas.saveFailed') : tt('canvas.loading')}</span>
1477
+ </header>
1478
+
1479
+ <div
1480
+ ref={viewportRef}
1481
+ className={`${css.viewport} ${cursorClass}`}
1482
+ onPointerDown={onViewportPointerDown}
1483
+ onWheel={onWheel}
1484
+ onDoubleClick={event => {
1485
+ const target = event.target instanceof Element ? event.target : null
1486
+ if (target?.closest('[data-node-id],[data-canvas-no-zoom]')) return
1487
+ setCreateMenu({ screen: { x: event.clientX, y: event.clientY }, world: screenToWorld(event.clientX, event.clientY) })
1488
+ }}
1489
+ onContextMenu={event => {
1490
+ const target = event.target instanceof Element ? event.target : null
1491
+ if (target?.closest('[data-node-id],[data-connection-hit],[data-canvas-no-zoom]')) return
1492
+ event.preventDefault()
1493
+ setCreateMenu(null)
1494
+ setContextMenu({ type: 'canvas', screen: { x: event.clientX, y: event.clientY }, world: screenToWorld(event.clientX, event.clientY) })
1495
+ }}
1496
+ onDragOver={event => event.preventDefault()}
1497
+ onDrop={onDrop}
1498
+ >
1499
+ <div className={css.grid} style={{ backgroundSize: `${gridSize}px ${gridSize}px`, backgroundPosition: `${gridOffsetX}px ${gridOffsetY}px` }} data-mode={backgroundMode} aria-hidden="true" />
1500
+ <div className={css.world} style={{ transform: `translate(${document?.viewport.x ?? 0}px, ${document?.viewport.y ?? 0}px) scale(${document?.viewport.k ?? 1})` }}>
1501
+ {renderConnections()}
1502
+ {document?.nodes.map(renderNode)}
1503
+ </div>
1504
+ {marqueeRect !== null ? <div className={css.marquee} style={marqueeRect} aria-hidden="true" /> : null}
1505
+ {emptyState}
1506
+ </div>
1507
+
1508
+ <div className={`${css.dock} ${cursorClass}`} data-canvas-no-zoom="">
1509
+ <IconButton name="select" label={tt('canvas.toolSelect')} active={tool === 'select'} onClick={() => setTool('select')} />
1510
+ <IconButton name="pan" label={tt('canvas.toolPan')} active={tool === 'pan'} onClick={() => setTool('pan')} />
1511
+ <span className={css.dockDivider} />
1512
+ <IconButton name="image" label={tt('canvas.addImage')} onClick={() => setPickerOpen(true)} />
1513
+ <IconButton name="text" label={tt('canvas.addText')} onClick={() => placeNewNode(createTextNode())} />
1514
+ <IconButton name="sparkle" label={tt('canvas.addConfigNode')} onClick={() => placeNewNode(createConfigNode())} />
1515
+ <span className={css.dockDivider} />
1516
+ <IconButton name="trash" label={tt('canvas.delete')} disabled={selectedIds.size === 0 && selectedConnectionId === null} onClick={deleteSelection} />
1517
+ </div>
1518
+
1519
+ <div className={css.zoomDock} data-canvas-no-zoom="">
1520
+ <IconButton name="minimap" label={minimapOpen ? tt('canvas.minimapClose') : tt('canvas.minimapOpen')} active={minimapOpen} onClick={() => setMinimapOpen(previous => !previous)} />
1521
+ <IconButton name="fit" label={tt('canvas.fitView')} onClick={fitView} />
1522
+ <input
1523
+ type="range"
1524
+ min={5}
1525
+ max={500}
1526
+ step={1}
1527
+ value={Math.round((document?.viewport.k ?? 1) * 100)}
1528
+ onChange={event => setZoomAtCenter(Number(event.target.value) / 100)}
1529
+ aria-label={tt('canvas.zoom')}
1530
+ />
1531
+ <span className={css.zoomValue}>{Math.round((document?.viewport.k ?? 1) * 100)}%</span>
1532
+ </div>
1533
+
1534
+ {minimapOpen ? renderMinimap() : null}
1535
+ {renderComposer()}
1536
+ {renderContextMenu()}
1537
+
1538
+ {error !== null ? <div className={css.errorToast} role="status" data-canvas-no-zoom="">{error}<button type="button" aria-label={tt('canvas.dismiss')} onClick={() => setError(null)}><ToolbarIcon name="close" /></button></div> : null}
1539
+
1540
+ {pickerOpen ? <ImagePicker
1541
+ api={api}
1542
+ history={history}
1543
+ gallery={gallery}
1544
+ imageModels={imageModels}
1545
+ defaultChannelId={defaultChannelId}
1546
+ canvasId={document?.id ?? ''}
1547
+ connected={connected}
1548
+ onClose={() => setPickerOpen(false)}
1549
+ onAssets={assets => { addAssets(assets); setPickerOpen(false) }}
1550
+ onTask={task => {
1551
+ if (document === null) return
1552
+ const center = canvasCenter()
1553
+ const size = nodeSizeFromRatio(task.request.size, IMAGE_NODE_SIZE)
1554
+ const node: CanvasNode = {
1555
+ id: newId('node'), type: 'image', title: tt('canvas.imageNode'),
1556
+ x: Math.round(center.x - size.width / 2), y: Math.round(center.y - size.height / 2),
1557
+ width: size.width, height: size.height,
1558
+ metadata: { status: 'generating', prompt: task.request.prompt, model: task.request.model, size: task.request.size, quality: task.request.quality, taskId: task.id, sourceNodeId: task.request.canvas?.sourceNodeId },
1559
+ }
1560
+ placeNewNode(node)
1561
+ setPickerOpen(false)
1562
+ }}
1563
+ /> : null}
1564
+ </section>
1565
+ }
1566
+
1567
+ function ImagePicker(props: {
1568
+ api: ImageGenApi
1569
+ history: HistoryEntry[]
1570
+ gallery: HistoryEntry[]
1571
+ imageModels: string[]
1572
+ defaultChannelId?: string
1573
+ canvasId: string
1574
+ connected: boolean
1575
+ onClose: () => void
1576
+ onAssets: (assets: CanvasAssetRef[]) => void
1577
+ onTask: (task: GenerationTask) => void
1578
+ }): React.JSX.Element {
1579
+ const { api, history, gallery, imageModels, defaultChannelId, canvasId, connected, onClose, onAssets } = props
1580
+ const [tab, setTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>('upload')
1581
+ const [selected, setSelected] = useState<string[]>([])
1582
+ const [dimensions, setDimensions] = useState<Record<string, { width: number; height: number }>>({})
1583
+ const [prompt, setPrompt] = useState('')
1584
+ const [model, setModel] = useState(imageModels[0] ?? '')
1585
+ const [size, setSize] = useState('auto')
1586
+ const [quality, setQuality] = useState('auto')
1587
+ const [busy, setBusy] = useState(false)
1588
+ const toggle = (key: string): void => setSelected(previous => previous.includes(key) ? previous.filter(item => item !== key) : [...previous, key])
1589
+ const items = (tab === 'history' ? history : gallery).flatMap(entry => entry.images.map((image, index) => ({ key: `${entry.id}:${index}`, entry, image, index })))
1590
+ const uploadFiles = (files: File[]): void => {
1591
+ setBusy(true)
1592
+ void Promise.all(files.map(async file => {
1593
+ const dataUrl = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('读取图片失败')); reader.readAsDataURL(file) })
1594
+ const sizeOf = await readImageSize(dataUrl)
1595
+ return api.canvasUpload(dataUrl, sizeOf.width, sizeOf.height, { origin: 'upload', originId: file.name })
1596
+ })).then(assets => { onAssets(assets) }).catch(() => {}).finally(() => setBusy(false))
1597
+ }
1598
+ const addSelected = async (): Promise<void> => {
1599
+ setBusy(true)
1600
+ try {
1601
+ const assets: CanvasAssetRef[] = []
1602
+ for (const key of selected) {
1603
+ const [entryId, indexText] = key.split(':'); const index = Number(indexText); const item = items.find(candidate => candidate.key === key)
1604
+ if (entryId === undefined || item === undefined) continue
1605
+ const sizeOf = dimensions[key] ?? await readImageSize(item.image.url).catch(() => ({ width: 1024, height: 1024 }))
1606
+ assets.push(await api.canvasImport(tab === 'history' ? 'history' : 'gallery', entryId, index, sizeOf.width, sizeOf.height))
1607
+ }
1608
+ if (assets.length > 0) onAssets(assets)
1609
+ } finally { setBusy(false) }
1610
+ }
1611
+ const generate = async (): Promise<void> => {
1612
+ if (!connected || prompt.trim() === '') return
1613
+ setBusy(true)
1614
+ try {
1615
+ const task = await api.taskSubmit({ mode: 'text', model, prompt: prompt.trim(), size, quality, n: 1, detail: '', ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }), canvas: { canvasId } })
1616
+ props.onTask(task)
1617
+ } finally { setBusy(false) }
1618
+ }
1619
+ return <div className={css.modalBackdrop} role="dialog" aria-modal="true" data-canvas-no-zoom=""><section className={css.picker}>
1620
+ <header className={css.pickerHeader}><strong>{tt('canvas.addImage')}</strong><button type="button" aria-label={tt('canvas.close')} title={tt('canvas.close')} onClick={onClose}>×</button></header>
1621
+ <nav className={css.pickerTabs} role="tablist">{(['upload', 'history', 'gallery', 'generate'] as const).map(item => <button key={item} type="button" role="tab" aria-selected={tab === item} data-active={tab === item ? '' : undefined} onClick={() => { setTab(item); setSelected([]) }}>{item === 'upload' ? tt('canvas.tabUpload') : item === 'history' ? tt('canvas.tabHistory') : item === 'gallery' ? tt('canvas.tabGallery') : tt('canvas.tabGenerate')}</button>)}</nav>
1622
+ <div className={css.pickerBody}>
1623
+ {tab === 'upload' ? <label className={css.uploadBox} onDragOver={event => event.preventDefault()} onDrop={event => { event.preventDefault(); const files = [...(event.dataTransfer.files ?? [])].filter(file => file.type.startsWith('image/')); if (files.length === 0) return; uploadFiles(files) }}><input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple disabled={busy} onChange={event => { const files = [...(event.target.files ?? [])]; if (files.length > 0) uploadFiles(files) }} /><span className={css.uploadIcon}><ToolbarIcon name="image" /></span><strong>{tt('canvas.dropHint')}</strong><small>{tt('canvas.dropSub')}</small></label> : null}
1624
+ {(tab === 'history' || tab === 'gallery') ? <><div className={css.pickerGrid}>{items.map(item => <button key={item.key} type="button" role="option" aria-selected={selected.includes(item.key)} className={css.pickerCard} data-selected={selected.includes(item.key) ? '' : undefined} onClick={() => toggle(item.key)}><img draggable={false} src={item.image.url} alt={item.entry.prompt} onLoad={event => { const image = event.currentTarget; setDimensions(previous => ({ ...previous, [item.key]: { width: image.naturalWidth || 1, height: image.naturalHeight || 1 } })) }} /><span className={css.pickerCardPrompt}>{item.entry.prompt || tt('canvas.untitledWork')}</span><small>{item.entry.model} · {item.index + 1}/{item.entry.images.length}</small></button>)}</div><footer className={css.pickerFooter}><span>{tt('canvas.picked', { count: selected.length })}</span><button type="button" disabled={busy || selected.length === 0} onClick={() => { void addSelected() }}>{tt('canvas.addToCanvas')}</button></footer></> : null}
1625
+ {tab === 'generate' ? <div className={css.generateForm}><textarea value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('canvas.composerPlaceholder')} /><select value={model} onChange={event => setModel(event.target.value)}>{imageModels.map(item => <option key={item} value={item}>{item}</option>)}</select><div className={css.inspectorRow}><select value={size} onChange={event => setSize(event.target.value)}><option value="auto">{tt('canvas.sizeAuto')}</option><option value="1:1">1:1</option><option value="3:4">3:4</option><option value="16:9">16:9</option><option value="9:16">9:16</option></select><select value={quality} onChange={event => setQuality(event.target.value)}><option value="auto">{tt('canvas.qualityAuto')}</option><option value="1k">1K</option><option value="2k">2K</option><option value="4k">4K</option></select></div><button type="button" disabled={!connected || busy || prompt.trim() === ''} onClick={() => { void generate() }}><ToolbarIcon name="sparkle" />{tt('canvas.generateAndAdd')}</button>{!connected ? <small>{tt('canvas.needApi')}</small> : null}</div> : null}
1626
+ </div>
1627
+ </section></div>
1628
+ }