@dickpy/dsh-imagegen 1.5.2 → 1.5.4

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,1569 @@
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 internalClipboard = useRef<{ nodes: CanvasNode[]; connections: Array<{ fromNodeId: string; toNodeId: string }> } | null>(null)
258
+ const pastRef = useRef<string[]>([])
259
+ const futureRef = useRef<string[]>([])
260
+ const composerTargetRef = useRef<string | null>(null)
261
+
262
+ documentRef.current = document
263
+ selectedIdsRef.current = selectedIds
264
+
265
+ // ------------------------------------------------------------ utilities
266
+
267
+ const screenToWorld = useCallback((clientX: number, clientY: number): Point => {
268
+ const bounds = viewportRef.current?.getBoundingClientRect()
269
+ const current = documentRef.current
270
+ if (bounds === undefined || current === null) return { x: clientX, y: clientY }
271
+ return {
272
+ x: (clientX - bounds.left - current.viewport.x) / current.viewport.k,
273
+ y: (clientY - bounds.top - current.viewport.y) / current.viewport.k,
274
+ }
275
+ }, [])
276
+
277
+ const canvasCenter = useCallback((): Point => {
278
+ const bounds = viewportRef.current?.getBoundingClientRect()
279
+ const current = documentRef.current
280
+ if (bounds === undefined || current === null) return { x: 0, y: 0 }
281
+ return screenToWorld(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
282
+ }, [screenToWorld])
283
+
284
+ const beginHistory = useCallback((): string | null => {
285
+ const current = documentRef.current
286
+ if (current === null) return null
287
+ const snapshot = JSON.stringify(current)
288
+ if (pastRef.current[pastRef.current.length - 1] === snapshot) return snapshot
289
+ pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), snapshot]
290
+ futureRef.current = []
291
+ setHistoryVersion(version => version + 1)
292
+ return snapshot
293
+ }, [])
294
+
295
+ const commitSnapshot = useCallback((snapshot: string | null): void => {
296
+ if (snapshot === null) return
297
+ const current = documentRef.current
298
+ if (current === null || JSON.stringify(current) === snapshot) return
299
+ pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), snapshot]
300
+ futureRef.current = []
301
+ setHistoryVersion(version => version + 1)
302
+ }, [])
303
+
304
+ const updateDocument = useCallback((updater: (previous: CanvasDocument) => CanvasDocument): void => {
305
+ setDocument(previous => previous === null ? previous : updater(previous))
306
+ }, [])
307
+
308
+ const mutate = useCallback((updater: (previous: CanvasDocument) => CanvasDocument): void => {
309
+ beginHistory()
310
+ updateDocument(updater)
311
+ }, [beginHistory, updateDocument])
312
+
313
+ const undo = useCallback((): void => {
314
+ const snapshot = pastRef.current[pastRef.current.length - 1]
315
+ const current = documentRef.current
316
+ if (snapshot === undefined || current === null) return
317
+ pastRef.current = pastRef.current.slice(0, -1)
318
+ futureRef.current = [...futureRef.current, JSON.stringify(current)]
319
+ setDocument(JSON.parse(snapshot) as CanvasDocument)
320
+ setHistoryVersion(version => version + 1)
321
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
322
+ }, [])
323
+
324
+ const redo = useCallback((): void => {
325
+ const snapshot = futureRef.current[futureRef.current.length - 1]
326
+ const current = documentRef.current
327
+ if (snapshot === undefined || current === null) return
328
+ futureRef.current = futureRef.current.slice(0, -1)
329
+ pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), JSON.stringify(current)]
330
+ setDocument(JSON.parse(snapshot) as CanvasDocument)
331
+ setHistoryVersion(version => version + 1)
332
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
333
+ }, [])
334
+
335
+ const setViewport = useCallback((viewport: CanvasDocument['viewport']): void => {
336
+ updateDocument(previous => ({ ...previous, viewport }))
337
+ }, [updateDocument])
338
+
339
+ // ------------------------------------------------------- node operations
340
+
341
+ const placeNewNode = useCallback((node: CanvasNode): void => {
342
+ mutate(previous => ({ ...previous, nodes: [...previous.nodes, node] }))
343
+ setSelectedIds(new Set([node.id])); setSelectedConnectionId(null)
344
+ }, [mutate])
345
+
346
+ const createImageNode = useCallback((asset: CanvasAssetRef, position?: Point): CanvasNode => {
347
+ const size = sizeForAsset(asset)
348
+ const center = position ?? canvasCenter()
349
+ return {
350
+ id: newId('node'), type: 'image', title: asset.origin === 'gallery' ? tt('canvas.fromGallery') : asset.origin === 'history' ? tt('canvas.fromHistory') : tt('canvas.imageNode'),
351
+ x: Math.round(center.x - size.width / 2), y: Math.round(center.y - size.height / 2),
352
+ width: size.width, height: size.height,
353
+ metadata: { asset, status: 'success' },
354
+ }
355
+ }, [canvasCenter])
356
+
357
+ const createTextNode = useCallback((position?: Point): CanvasNode => {
358
+ const center = position ?? canvasCenter()
359
+ return {
360
+ id: newId('node'), type: 'text', title: tt('canvas.textNode'),
361
+ x: Math.round(center.x - TEXT_NODE_SIZE.width / 2), y: Math.round(center.y - TEXT_NODE_SIZE.height / 2),
362
+ width: TEXT_NODE_SIZE.width, height: TEXT_NODE_SIZE.height,
363
+ metadata: { text: '', fontSize: 14 },
364
+ }
365
+ }, [canvasCenter])
366
+
367
+ const createConfigNode = useCallback((position?: Point): CanvasNode => {
368
+ const center = position ?? canvasCenter()
369
+ return {
370
+ id: newId('node'), type: 'config', title: tt('canvas.configNode'),
371
+ x: Math.round(center.x - CONFIG_NODE_SIZE.width / 2), y: Math.round(center.y - CONFIG_NODE_SIZE.height / 2),
372
+ width: CONFIG_NODE_SIZE.width, height: CONFIG_NODE_SIZE.height,
373
+ metadata: { status: 'idle' },
374
+ }
375
+ }, [canvasCenter])
376
+
377
+ const updateNodes = useCallback((updater: (nodes: CanvasNode[]) => CanvasNode[]): void => {
378
+ updateDocument(previous => ({ ...previous, nodes: updater(previous.nodes) }))
379
+ }, [updateDocument])
380
+
381
+ const patchNode = useCallback((nodeId: string, patch: Partial<NonNullable<CanvasNode['metadata']>> & Partial<Pick<CanvasNode, 'title' | 'width' | 'height' | 'x' | 'y'>>): void => {
382
+ updateNodes(nodes => nodes.map(node => node.id === nodeId
383
+ ? { ...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 } }
384
+ : node))
385
+ }, [updateNodes])
386
+
387
+ const deleteSelection = useCallback((): void => {
388
+ const ids = selectedIdsRef.current
389
+ const connectionId = selectedConnectionId
390
+ if (ids.size === 0 && connectionId === null) return
391
+ mutate(previous => ({
392
+ ...previous,
393
+ nodes: previous.nodes.filter(node => !ids.has(node.id)),
394
+ connections: previous.connections.filter(connection => !ids.has(connection.fromNodeId) && !ids.has(connection.toNodeId) && connection.id !== connectionId),
395
+ }))
396
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
397
+ }, [mutate, selectedConnectionId])
398
+
399
+ const duplicateSelection = useCallback((): void => {
400
+ const current = documentRef.current
401
+ if (current === null || selectedIdsRef.current.size === 0) return
402
+ 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) } }))
403
+ if (clones.length === 0) return
404
+ const idMap = new Map(current.nodes.filter(node => selectedIdsRef.current.has(node.id)).map((node, index) => [node.id, clones[index]!.id]))
405
+ const connections = current.connections
406
+ .filter(connection => idMap.has(connection.fromNodeId) && idMap.has(connection.toNodeId))
407
+ .map(connection => ({ id: newId('edge'), fromNodeId: idMap.get(connection.fromNodeId)!, toNodeId: idMap.get(connection.toNodeId)! }))
408
+ mutate(previous => ({ ...previous, nodes: [...previous.nodes, ...clones], connections: [...previous.connections, ...connections] }))
409
+ setSelectedIds(new Set(clones.map(node => node.id)))
410
+ }, [mutate])
411
+
412
+ const copySelection = useCallback((): void => {
413
+ const current = documentRef.current
414
+ if (current === null || selectedIdsRef.current.size === 0) return
415
+ internalClipboard.current = {
416
+ nodes: current.nodes.filter(node => selectedIdsRef.current.has(node.id)).map(node => ({ ...node, metadata: { ...nodeMetadata(node) } })),
417
+ connections: current.connections.filter(connection => selectedIdsRef.current.has(connection.fromNodeId) && selectedIdsRef.current.has(connection.toNodeId)).map(connection => ({ fromNodeId: connection.fromNodeId, toNodeId: connection.toNodeId })),
418
+ }
419
+ }, [])
420
+
421
+ const pasteClipboard = useCallback((position?: Point): void => {
422
+ const clipboard = internalClipboard.current
423
+ if (clipboard === null || clipboard.nodes.length === 0) return
424
+ const bounds = nodesBounds(clipboard.nodes)
425
+ const target = position ?? canvasCenter()
426
+ const dx = target.x - (bounds.minX + (bounds.maxX - bounds.minX) / 2)
427
+ const dy = target.y - (bounds.minY + (bounds.maxY - bounds.minY) / 2)
428
+ const idMap = new Map<string, string>()
429
+ const clones = clipboard.nodes.map(node => {
430
+ const id = newId('node'); idMap.set(node.id, id)
431
+ return { ...node, id, x: Math.round(node.x + dx), y: Math.round(node.y + dy), metadata: { ...nodeMetadata(node) } }
432
+ })
433
+ const connections = clipboard.connections.map(connection => ({ id: newId('edge'), fromNodeId: idMap.get(connection.fromNodeId)!, toNodeId: idMap.get(connection.toNodeId)! }))
434
+ mutate(previous => ({ ...previous, nodes: [...previous.nodes, ...clones], connections: [...previous.connections, ...connections] }))
435
+ setSelectedIds(new Set(clones.map(node => node.id)))
436
+ }, [canvasCenter, mutate])
437
+
438
+ const connectNodes = useCallback((fromNodeId: string, toNodeId: string): void => {
439
+ if (fromNodeId === toNodeId) return
440
+ const current = documentRef.current
441
+ if (current === null) return
442
+ if (current.connections.some(connection => connection.fromNodeId === fromNodeId && connection.toNodeId === toNodeId)) return
443
+ mutate(previous => ({ ...previous, connections: [...previous.connections, { id: newId('edge'), fromNodeId, toNodeId }] }))
444
+ }, [mutate])
445
+
446
+ const downloadNode = useCallback((node: CanvasNode): void => {
447
+ const asset = assetOf(node)
448
+ if (asset === undefined || asset.url === '') return
449
+ const link = globalThis.document.createElement('a')
450
+ link.href = asset.url
451
+ link.download = `${node.title || 'canvas-image'}.${asset.assetId.split('.').pop() ?? 'png'}`
452
+ link.target = '_blank'
453
+ link.rel = 'noopener'
454
+ link.click()
455
+ }, [])
456
+
457
+ const upstreamNodes = useCallback((canvasDocument: CanvasDocument, nodeId: string): CanvasNode[] => {
458
+ const byId = new Map(canvasDocument.nodes.map(node => [node.id, node]))
459
+ return canvasDocument.connections
460
+ .filter(connection => connection.toNodeId === nodeId)
461
+ .map(connection => byId.get(connection.fromNodeId))
462
+ .filter((node): node is CanvasNode => node !== undefined)
463
+ }, [])
464
+
465
+ // ----------------------------------------------------------- generation
466
+
467
+ const submitComposer = useCallback(async (target: CanvasNode | null): Promise<void> => {
468
+ const current = documentRef.current
469
+ if (current === null || composerBusy) return
470
+ if (!connected) { setError(tt('canvas.needApi')); onOpenSettings?.(); return }
471
+ const inputs = target === null ? [] : upstreamNodes(current, target.id)
472
+ const referenceImages = inputs.filter(node => node.type === 'image' && usableAsset(node) !== undefined)
473
+ const upstreamText = inputs.filter(node => node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '').map(node => nodeMetadata(node).text!.trim())
474
+ const prompt = (composerPrompt.trim() !== '' ? composerPrompt.trim() : upstreamText.join('\n').trim())
475
+ if (prompt === '') { setError(tt('canvas.needPrompt')); return }
476
+ const model = imageModels.includes(composerModel) ? composerModel : imageModels[0] ?? ''
477
+ if (model === '') { setError(tt('canvas.needModel')); return }
478
+ const count = Math.min(4, Math.max(1, Math.round(composerCount)))
479
+ const baseAsset = referenceImages[0] !== undefined ? usableAsset(referenceImages[0]!) : undefined
480
+ setComposerBusy(true)
481
+ try {
482
+ let image: string | undefined
483
+ let refName: string | undefined
484
+ if (baseAsset !== undefined) {
485
+ image = await assetToDataUrl(baseAsset)
486
+ refName = 'canvas-reference.png'
487
+ }
488
+ const footprint = nodeSizeFromRatio(composerSize, IMAGE_NODE_SIZE)
489
+ const request: GenerateRequest = {
490
+ mode: image === undefined ? 'text' : 'edit', model, prompt, size: composerSize, quality: composerQuality, n: count, detail: '',
491
+ ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
492
+ ...(image === undefined ? {} : { image, refName }),
493
+ canvas: {
494
+ canvasId: current.id,
495
+ ...(target === null ? {} : { sourceNodeId: referenceImages[0]?.id ?? target.id, parentNodeId: target.id, placement: 'right' as const }),
496
+ },
497
+ }
498
+ const task = await api.taskSubmit(request)
499
+ mutate(previous => {
500
+ const nodes = [...previous.nodes]
501
+ const connections = [...previous.connections]
502
+ const anchor = target !== null ? previous.nodes.find(node => node.id === target.id) : undefined
503
+ const originX = anchor !== undefined ? anchor.x + anchor.width + 80 : Math.round(canvasCenter().x - footprint.width / 2)
504
+ const originY = anchor !== undefined ? anchor.y : Math.round(canvasCenter().y - footprint.height / 2)
505
+ for (let index = 0; index < count; index += 1) {
506
+ const id = newId('node')
507
+ nodes.push({
508
+ id, type: 'image', title: tt('canvas.imageNode'),
509
+ x: Math.round(originX), y: Math.round(originY + index * (footprint.height + 48)),
510
+ width: footprint.width, height: footprint.height,
511
+ metadata: { status: 'generating', taskId: task.id, ...(anchor !== undefined ? { sourceNodeId: anchor.id } : {}), prompt, model },
512
+ })
513
+ if (anchor !== undefined) connections.push({ id: newId('edge'), fromNodeId: anchor.id, toNodeId: id })
514
+ }
515
+ return { ...previous, nodes, connections }
516
+ })
517
+ setComposerPrompt('')
518
+ setError(null)
519
+ } catch (caught) {
520
+ setError(caught instanceof Error ? caught.message : String(caught))
521
+ } finally {
522
+ setComposerBusy(false)
523
+ }
524
+ }, [api, canvasCenter, composerBusy, composerCount, composerModel, composerPrompt, composerQuality, composerSize, connected, defaultChannelId, imageModels, mutate, onOpenSettings, upstreamNodes])
525
+
526
+ // ---------------------------------------------------------- task intake
527
+
528
+ useEffect(() => {
529
+ if (document === null) return
530
+ const canvasTasks = tasks.filter(task => task.request.canvas?.canvasId === document.id)
531
+ for (const task of canvasTasks) {
532
+ if (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') continue
533
+ if (processedTasks.current.has(task.id)) continue
534
+ const targets = document.nodes.filter(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating')
535
+ if (targets.length === 0) continue
536
+ processedTasks.current.add(task.id)
537
+ const sourceId = nodeMetadata(targets[0]!).sourceNodeId
538
+ const fail = (message: string): void => {
539
+ updateNodes(nodes => nodes.map(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating'
540
+ ? { ...node, metadata: { ...nodeMetadata(node), status: 'error', error: message } }
541
+ : node))
542
+ }
543
+ if (task.status !== 'completed' || task.result === undefined || task.result.images.length === 0) {
544
+ fail(task.error ?? tt('canvas.generateFailed'))
545
+ continue
546
+ }
547
+ void (async () => {
548
+ const assets: CanvasAssetRef[] = []
549
+ for (const image of task.result!.images) {
550
+ const dataUrl = imageDataUrl(image)
551
+ const dimensions = await readImageSize(dataUrl)
552
+ assets.push(await api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'generated', originId: task.id }))
553
+ }
554
+ updateDocument(previous => {
555
+ const ordered = previous.nodes.filter(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating')
556
+ if (ordered.length === 0) return previous
557
+ const last = ordered[ordered.length - 1]!
558
+ const nodes = previous.nodes.map(node => {
559
+ const index = ordered.indexOf(node)
560
+ if (index < 0) return node
561
+ const asset = assets[index]
562
+ return asset === undefined
563
+ ? { ...node, metadata: { ...nodeMetadata(node), status: 'error' as const, error: tt('canvas.generateFailed') } }
564
+ : { ...node, metadata: { ...nodeMetadata(node), asset, status: 'success' as const, error: undefined } }
565
+ })
566
+ // More results than placeholders: append sibling nodes below the last one.
567
+ const siblings: CanvasNode[] = []
568
+ const connections: CanvasConnection[] = []
569
+ assets.slice(ordered.length).forEach((asset, offset) => {
570
+ const id = newId('node')
571
+ siblings.push({
572
+ id, type: 'image', title: tt('canvas.imageNode'),
573
+ x: Math.round(last.x), y: Math.round(last.y + (ordered.length + offset) * (last.height + 48)),
574
+ width: last.width, height: last.height,
575
+ metadata: { status: 'success', asset, taskId: task.id, ...(sourceId === undefined ? {} : { sourceNodeId: sourceId }) },
576
+ })
577
+ if (sourceId !== undefined) connections.push({ id: newId('edge'), fromNodeId: sourceId, toNodeId: id })
578
+ })
579
+ return { ...previous, nodes: [...nodes, ...siblings], connections: [...previous.connections, ...connections] }
580
+ })
581
+ })().catch(caught => fail(caught instanceof Error ? caught.message : String(caught)))
582
+ }
583
+ }, [api, document, tasks, updateDocument, updateNodes])
584
+
585
+ // ------------------------------------------------------- import intake
586
+
587
+ const addAssets = useCallback((assets: CanvasAssetRef[], position?: Point): void => {
588
+ if (assets.length === 0) return
589
+ const center = position ?? canvasCenter()
590
+ mutate(previous => {
591
+ const nodes = assets.map((asset, index) => {
592
+ const node = createImageNode(asset)
593
+ return { ...node, x: node.x + (index % 3) * (IMAGE_NODE_SIZE.width + 40), y: node.y + Math.floor(index / 3) * (IMAGE_NODE_SIZE.height + 40) }
594
+ })
595
+ return { ...previous, nodes: [...previous.nodes, ...nodes] }
596
+ })
597
+ setSelectedIds(new Set())
598
+ }, [canvasCenter, createImageNode, mutate])
599
+
600
+ useEffect(() => {
601
+ if (importRequest === undefined) {
602
+ processedImport.current = ''
603
+ return
604
+ }
605
+ if (document === null) return
606
+ const requestKey = `${importRequest.source}:${importRequest.entryId}:${importRequest.imageIndex}`
607
+ if (processedImport.current === requestKey) return
608
+ const sourceEntries = importRequest.source === 'history' ? history : gallery
609
+ const entry = sourceEntries.find(item => item.id === importRequest.entryId)
610
+ const image = entry?.images[importRequest.imageIndex]
611
+ if (entry === undefined || image === undefined) {
612
+ processedImport.current = requestKey
613
+ onImportRequestHandled?.()
614
+ return
615
+ }
616
+ processedImport.current = requestKey
617
+ void (async () => {
618
+ const dimensions = await readImageSize(image.url)
619
+ const asset = await api.canvasImport(importRequest.source, importRequest.entryId, importRequest.imageIndex, dimensions.width, dimensions.height)
620
+ addAssets([asset])
621
+ onImportRequestHandled?.()
622
+ })().catch(caught => {
623
+ setError(caught instanceof Error ? caught.message : String(caught))
624
+ onImportRequestHandled?.()
625
+ })
626
+ }, [addAssets, api, document, gallery, history, importRequest, onImportRequestHandled])
627
+
628
+ // -------------------------------------------------------------- loading
629
+
630
+ useEffect(() => {
631
+ let disposed = false
632
+ void api.canvasList().then(async list => {
633
+ if (disposed) return
634
+ const first = list[0] === undefined ? await api.canvasCreate(tt('canvas.untitled')) : await api.canvasRead(list[0].id)
635
+ if (disposed) return
636
+ setProjects(list[0] === undefined ? [summaryOf(first)] : list)
637
+ setDocument(first)
638
+ syncedRef.current = JSON.stringify(first)
639
+ setSaveState('saved')
640
+ }).catch(caught => { if (!disposed) { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') } })
641
+ return () => { disposed = true }
642
+ }, [api])
643
+
644
+ useEffect(() => {
645
+ if (document === null || saveState === 'loading') return
646
+ const key = JSON.stringify(document)
647
+ if (key === syncedRef.current) return
648
+ setSaveState('saving')
649
+ const timer = window.setTimeout(() => {
650
+ const saveWithRetry = async (): Promise<CanvasDocument> => {
651
+ try {
652
+ return await api.canvasSave(document, document.revision)
653
+ } catch (caught) {
654
+ // Another window saved the same canvas meanwhile: rebase on the
655
+ // server revision and retry once so concurrent editing self-heals.
656
+ const message = caught instanceof Error ? caught.message : String(caught)
657
+ if (!message.includes('其他窗口')) throw caught
658
+ const server = await api.canvasRead(document.id)
659
+ return await api.canvasSave(document, server.revision)
660
+ }
661
+ }
662
+ void saveWithRetry().then(next => {
663
+ syncedRef.current = JSON.stringify(next)
664
+ setDocument(next)
665
+ setProjects(previous => [summaryOf(next), ...previous.filter(item => item.id !== next.id)])
666
+ setSaveState('saved')
667
+ }).catch(caught => { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') })
668
+ }, 650)
669
+ return () => window.clearTimeout(timer)
670
+ }, [api, document, saveState])
671
+
672
+ // ---------------------------------------------------------- composer sync
673
+
674
+ const singleSelectedId = selectedIds.size === 1 ? [...selectedIds][0]! : null
675
+ const singleSelected = useMemo(() => document?.nodes.find(node => node.id === singleSelectedId) ?? null, [document, singleSelectedId])
676
+ const composerTarget = singleSelected !== null && singleSelected.type === 'config' ? singleSelected : null
677
+ const composerInputs = useMemo(
678
+ () => composerTarget === null || document === null ? [] : upstreamNodes(document, composerTarget.id),
679
+ [composerTarget, document, upstreamNodes],
680
+ )
681
+ const composerReferenceCount = composerInputs.filter(node => node.type === 'image' && usableAsset(node) !== undefined).length
682
+ const composerTextCount = composerInputs.filter(node => node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '').length
683
+ const composerVisible = composerTarget !== null
684
+
685
+ // Prefill the prompt from connected text nodes whenever the target changes.
686
+ useEffect(() => {
687
+ const targetId = composerTarget?.id ?? null
688
+ if (targetId === composerTargetRef.current) return
689
+ composerTargetRef.current = targetId
690
+ if (composerTarget === null) return
691
+ const texts = (document?.connections ?? [])
692
+ .filter(connection => connection.toNodeId === composerTarget.id)
693
+ .map(connection => document?.nodes.find(node => node.id === connection.fromNodeId))
694
+ .filter((node): node is CanvasNode => node !== undefined && node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '')
695
+ .map(node => nodeMetadata(node).text!.trim())
696
+ setComposerPrompt(texts.join('\n'))
697
+ }, [composerTarget, document])
698
+
699
+ // ------------------------------------------------------------ keyboard
700
+
701
+ useEffect(() => {
702
+ const isEditingTarget = (target: EventTarget | null): boolean => target instanceof Element
703
+ && (target.matches('input, textarea, select, [contenteditable="true"]'))
704
+
705
+ const onKeyDown = (event: KeyboardEvent): void => {
706
+ if (event.key === 'Control') setCtrlPressed(true)
707
+ if (event.code === 'Space' && !isEditingTarget(event.target)) {
708
+ event.preventDefault()
709
+ setSpacePressed(true)
710
+ }
711
+ if (documentRef.current === null) return
712
+ const mod = event.ctrlKey || event.metaKey
713
+ if (event.key === 'Escape') {
714
+ setContextMenu(null); setCreateMenu(null)
715
+ if (!isEditingTarget(event.target)) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
716
+ return
717
+ }
718
+ if (isEditingTarget(event.target)) return
719
+ if (mod && event.key.toLowerCase() === 'z') {
720
+ event.preventDefault()
721
+ if (event.shiftKey) redo(); else undo()
722
+ } else if (mod && event.key.toLowerCase() === 'y') {
723
+ event.preventDefault(); redo()
724
+ } else if (mod && event.key.toLowerCase() === 'c') {
725
+ copySelection()
726
+ } else if (mod && event.key.toLowerCase() === 'v') {
727
+ pasteClipboard()
728
+ } else if (mod && event.key.toLowerCase() === 'd') {
729
+ event.preventDefault(); duplicateSelection()
730
+ } else if (mod && event.key.toLowerCase() === 'a') {
731
+ event.preventDefault()
732
+ const nodes = documentRef.current?.nodes ?? []
733
+ setSelectedIds(new Set(nodes.map(node => node.id)))
734
+ } else if (event.key === 'Delete' || event.key === 'Backspace') {
735
+ event.preventDefault(); deleteSelection()
736
+ }
737
+ }
738
+ const onKeyUp = (event: KeyboardEvent): void => {
739
+ if (event.code === 'Space') setSpacePressed(false)
740
+ if (event.key === 'Control') setCtrlPressed(false)
741
+ }
742
+ const onBlur = (): void => { setSpacePressed(false); setCtrlPressed(false) }
743
+ const onPaste = (event: ClipboardEvent): void => {
744
+ if (isEditingTarget(event.target)) return
745
+ const files = [...(event.clipboardData?.files ?? [])].filter(file => file.type.startsWith('image/'))
746
+ if (files.length > 0) {
747
+ event.preventDefault()
748
+ void Promise.all(files.map(async file => {
749
+ 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) })
750
+ const dimensions = await readImageSize(dataUrl)
751
+ return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
752
+ })).then(assets => addAssets(assets, canvasCenter())).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
753
+ return
754
+ }
755
+ pasteClipboard()
756
+ }
757
+ window.addEventListener('keydown', onKeyDown)
758
+ window.addEventListener('keyup', onKeyUp)
759
+ window.addEventListener('blur', onBlur)
760
+ window.addEventListener('paste', onPaste)
761
+ return () => {
762
+ window.removeEventListener('keydown', onKeyDown)
763
+ window.removeEventListener('keyup', onKeyUp)
764
+ window.removeEventListener('blur', onBlur)
765
+ window.removeEventListener('paste', onPaste)
766
+ }
767
+ }, [addAssets, api, canvasCenter, copySelection, deleteSelection, duplicateSelection, pasteClipboard, redo, undo])
768
+
769
+ // ------------------------------------------------------ viewport events
770
+
771
+ useEffect(() => {
772
+ const container = viewportRef.current
773
+ if (container === null) return
774
+ const measure = (): void => setViewportSize({ width: container.clientWidth, height: container.clientHeight })
775
+ measure()
776
+ const observer = new ResizeObserver(measure)
777
+ observer.observe(container)
778
+ const preventWheel = (event: WheelEvent): void => {
779
+ if (event.target instanceof Element && event.target.closest(`[data-canvas-no-zoom]`)) return
780
+ event.preventDefault()
781
+ }
782
+ container.addEventListener('wheel', preventWheel, { passive: false })
783
+ return () => { observer.disconnect(); container.removeEventListener('wheel', preventWheel) }
784
+ }, [])
785
+
786
+ const temporaryPanTool = spacePressed || ctrlPressed
787
+
788
+ const onViewportPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
789
+ const target = event.target instanceof Element ? event.target : null
790
+ setContextMenu(null); setCreateMenu(null)
791
+ const isBackground = target?.closest('[data-node-id],[data-connection-hit]') === null
792
+ const shouldPan = event.button === 1 || (event.button === 0 && (tool === 'pan' || temporaryPanTool) && isBackground)
793
+ if (shouldPan) {
794
+ event.preventDefault()
795
+ event.currentTarget.setPointerCapture(event.pointerId)
796
+ const current = documentRef.current
797
+ if (current !== null) {
798
+ panRef.current = { startX: event.clientX, startY: event.clientY, viewportX: current.viewport.x, viewportY: current.viewport.y, hasMoved: false, startedOnBackground: isBackground }
799
+ }
800
+ return
801
+ }
802
+ if (event.button === 0 && isBackground && tool === 'select') {
803
+ event.preventDefault()
804
+ event.currentTarget.setPointerCapture(event.pointerId)
805
+ const world = screenToWorld(event.clientX, event.clientY)
806
+ const next: MarqueeState = { start: world, current: world, additive: event.shiftKey, initialIds: event.shiftKey ? [...selectedIdsRef.current] : [] }
807
+ marqueeRef.current = next
808
+ setMarquee(next)
809
+ if (!event.shiftKey) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
810
+ }
811
+ }
812
+
813
+ const onWheel = (event: React.WheelEvent<HTMLDivElement>): void => {
814
+ const current = documentRef.current
815
+ if (current === null) return
816
+ if (event.target instanceof Element && event.target.closest('[data-canvas-no-zoom]')) return
817
+ event.preventDefault()
818
+ const bounds = viewportRef.current?.getBoundingClientRect()
819
+ if (bounds === undefined) return
820
+ const mouseX = event.clientX - bounds.left
821
+ const mouseY = event.clientY - bounds.top
822
+ const scale = clampScale(current.viewport.k * Math.pow(1.1, -event.deltaY / 100))
823
+ const worldX = (mouseX - current.viewport.x) / current.viewport.k
824
+ const worldY = (mouseY - current.viewport.y) / current.viewport.k
825
+ setViewport({ x: mouseX - worldX * scale, y: mouseY - worldY * scale, k: scale })
826
+ }
827
+
828
+ const setZoomAtCenter = useCallback((scale: number): void => {
829
+ const current = documentRef.current
830
+ const bounds = viewportRef.current?.getBoundingClientRect()
831
+ if (current === null || bounds === undefined) return
832
+ const next = clampScale(scale)
833
+ const centerX = bounds.width / 2
834
+ const centerY = bounds.height / 2
835
+ const worldX = (centerX - current.viewport.x) / current.viewport.k
836
+ const worldY = (centerY - current.viewport.y) / current.viewport.k
837
+ setViewport({ x: centerX - worldX * next, y: centerY - worldY * next, k: next })
838
+ }, [setViewport])
839
+
840
+ const fitView = useCallback((): void => {
841
+ const current = documentRef.current
842
+ const bounds = viewportRef.current?.getBoundingClientRect()
843
+ if (current === null || bounds === undefined) return
844
+ if (current.nodes.length === 0) {
845
+ setViewport({ x: 0, y: 0, k: 1 })
846
+ return
847
+ }
848
+ const content = nodesBounds(current.nodes)
849
+ const padding = 80
850
+ const contentWidth = Math.max(1, content.maxX - content.minX)
851
+ const contentHeight = Math.max(1, content.maxY - content.minY)
852
+ const scale = clampScale(Math.min((bounds.width - padding * 2) / contentWidth, (bounds.height - padding * 2) / contentHeight))
853
+ setViewport({
854
+ k: scale,
855
+ x: (bounds.width - contentWidth * scale) / 2 - content.minX * scale,
856
+ y: (bounds.height - contentHeight * scale) / 2 - content.minY * scale,
857
+ })
858
+ }, [setViewport])
859
+
860
+ // -------------------------------------------------- global move / up
861
+
862
+ useEffect(() => {
863
+ const move = (event: PointerEvent): void => {
864
+ const drag = dragRef.current
865
+ if (drag !== null) {
866
+ const scale = documentRef.current?.viewport.k ?? 1
867
+ const dx = (event.clientX - drag.startX) / scale
868
+ const dy = (event.clientY - drag.startY) / scale
869
+ if (!drag.moved && Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 3) {
870
+ drag.moved = true
871
+ commitSnapshot(drag.snapshot)
872
+ }
873
+ if (drag.moved) {
874
+ updateNodes(nodes => nodes.map(node => {
875
+ const origin = drag.origins.get(node.id)
876
+ return origin === undefined ? node : { ...node, x: Math.round(origin.x + dx), y: Math.round(origin.y + dy) }
877
+ }))
878
+ }
879
+ return
880
+ }
881
+ const connect = connectRef.current
882
+ if (connect !== null) {
883
+ const world = screenToWorld(event.clientX, event.clientY)
884
+ const nodes = documentRef.current?.nodes ?? []
885
+ let targetId: string | null = null
886
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
887
+ const node = nodes[index]!
888
+ if (node.id === connect.nodeId) continue
889
+ if (world.x >= node.x && world.x <= node.x + node.width && world.y >= node.y && world.y <= node.y + node.height) {
890
+ targetId = node.id
891
+ break
892
+ }
893
+ }
894
+ const next = { ...connect, mouse: world, targetId }
895
+ connectRef.current = next
896
+ setConnecting(next)
897
+ return
898
+ }
899
+ const resize = resizeRef.current
900
+ if (resize !== null) {
901
+ const scale = documentRef.current?.viewport.k ?? 1
902
+ const dx = (event.clientX - resize.startX) / scale
903
+ const dy = (event.clientY - resize.startY) / scale
904
+ const minWidth = 140
905
+ const minHeight = 100
906
+ let width = Math.max(minWidth, resize.width + (resize.corner === 'bottom-right' ? dx : -dx))
907
+ let height = Math.max(minHeight, resize.height + dy)
908
+ if (resize.ratio !== null) height = Math.max(minHeight, Math.round(width * resize.ratio))
909
+ updateNodes(nodes => nodes.map(node => node.id === resize.nodeId
910
+ ? { ...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) }
911
+ : node))
912
+ return
913
+ }
914
+ const activeMarquee = marqueeRef.current
915
+ if (activeMarquee !== null) {
916
+ const next = { ...activeMarquee, current: screenToWorld(event.clientX, event.clientY) }
917
+ marqueeRef.current = next
918
+ setMarquee(next)
919
+ return
920
+ }
921
+ const pan = panRef.current
922
+ if (pan !== null) {
923
+ const dx = event.clientX - pan.startX
924
+ const dy = event.clientY - pan.startY
925
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) pan.hasMoved = true
926
+ const next = { x: pan.viewportX + dx, y: pan.viewportY + dy }
927
+ if (panFrameRef.current !== null) return
928
+ panFrameRef.current = requestAnimationFrame(() => {
929
+ panFrameRef.current = null
930
+ updateDocument(previous => ({ ...previous, viewport: { ...previous.viewport, x: next.x, y: next.y } }))
931
+ })
932
+ }
933
+ }
934
+
935
+ const up = (): void => {
936
+ const drag = dragRef.current
937
+ if (drag !== null) {
938
+ dragRef.current = null
939
+ return
940
+ }
941
+ const connect = connectRef.current
942
+ if (connect !== null) {
943
+ connectRef.current = null
944
+ setConnecting(null)
945
+ if (connect.targetId !== null) {
946
+ if (connect.handleType === 'source') connectNodes(connect.nodeId, connect.targetId)
947
+ else connectNodes(connect.targetId, connect.nodeId)
948
+ }
949
+ return
950
+ }
951
+ const resize = resizeRef.current
952
+ if (resize !== null) {
953
+ resizeRef.current = null
954
+ return
955
+ }
956
+ const activeMarquee = marqueeRef.current
957
+ if (activeMarquee !== null) {
958
+ marqueeRef.current = null
959
+ setMarquee(null)
960
+ const minX = Math.min(activeMarquee.start.x, activeMarquee.current.x)
961
+ const minY = Math.min(activeMarquee.start.y, activeMarquee.current.y)
962
+ const maxX = Math.max(activeMarquee.start.x, activeMarquee.current.x)
963
+ const maxY = Math.max(activeMarquee.start.y, activeMarquee.current.y)
964
+ const nodes = documentRef.current?.nodes ?? []
965
+ 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)
966
+ if (Math.abs(activeMarquee.current.x - activeMarquee.start.x) < 4 && Math.abs(activeMarquee.current.y - activeMarquee.start.y) < 4) {
967
+ setSelectedConnectionId(null)
968
+ return
969
+ }
970
+ const next = activeMarquee.additive
971
+ ? new Set([...activeMarquee.initialIds, ...hits])
972
+ : new Set(hits)
973
+ setSelectedIds(next)
974
+ setSelectedConnectionId(null)
975
+ return
976
+ }
977
+ const pan = panRef.current
978
+ if (pan !== null) {
979
+ panRef.current = null
980
+ if (!pan.hasMoved && pan.startedOnBackground) {
981
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
982
+ }
983
+ }
984
+ }
985
+
986
+ window.addEventListener('pointermove', move)
987
+ window.addEventListener('pointerup', up)
988
+ window.addEventListener('pointercancel', up)
989
+ return () => {
990
+ window.removeEventListener('pointermove', move)
991
+ window.removeEventListener('pointerup', up)
992
+ window.removeEventListener('pointercancel', up)
993
+ }
994
+ }, [commitSnapshot, connectNodes, screenToWorld, updateDocument, updateNodes])
995
+
996
+ // --------------------------------------------------------- node events
997
+
998
+ const handleNodePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, nodeId: string): void => {
999
+ if (event.button !== 0 || tool === 'pan' || temporaryPanTool) return
1000
+ const current = documentRef.current
1001
+ if (current === null) return
1002
+ const node = current.nodes.find(item => item.id === nodeId)
1003
+ if (node === undefined) return
1004
+ event.stopPropagation()
1005
+ const additive = event.shiftKey || event.ctrlKey || event.metaKey
1006
+ let nextSelection = selectedIdsRef.current
1007
+ if (additive) {
1008
+ nextSelection = new Set(selectedIdsRef.current)
1009
+ if (nextSelection.has(nodeId)) nextSelection.delete(nodeId)
1010
+ else nextSelection.add(nodeId)
1011
+ } else if (!nextSelection.has(nodeId)) {
1012
+ nextSelection = new Set([nodeId])
1013
+ }
1014
+ setSelectedIds(nextSelection)
1015
+ setSelectedConnectionId(null)
1016
+ const origins = new Map<string, Point>()
1017
+ for (const id of nextSelection) {
1018
+ const item = current.nodes.find(candidate => candidate.id === id)
1019
+ if (item !== undefined) origins.set(id, { x: item.x, y: item.y })
1020
+ }
1021
+ dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false, snapshot: JSON.stringify(current), origins }
1022
+ }, [temporaryPanTool, tool])
1023
+
1024
+ const handleConnectStart = useCallback((event: ReactPointerEvent<HTMLDivElement>, nodeId: string, handleType: 'source' | 'target'): void => {
1025
+ if (event.button !== 0) return
1026
+ event.stopPropagation(); event.preventDefault()
1027
+ const world = screenToWorld(event.clientX, event.clientY)
1028
+ const next: ConnectState = { nodeId, handleType, mouse: world, targetId: null }
1029
+ connectRef.current = next
1030
+ setConnecting(next)
1031
+ setSelectedConnectionId(null)
1032
+ }, [screenToWorld])
1033
+
1034
+ const handleResizeStart = useCallback((event: ReactPointerEvent<HTMLDivElement>, node: CanvasNode, corner: 'bottom-right' | 'bottom-left'): void => {
1035
+ if (event.button !== 0) return
1036
+ event.stopPropagation(); event.preventDefault()
1037
+ const asset = assetOf(node)
1038
+ const ratio = node.type === 'image' && asset !== undefined && asset.width > 0 && asset.height > 0 ? asset.width / asset.height : null
1039
+ 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 }
1040
+ beginHistory()
1041
+ }, [beginHistory])
1042
+
1043
+ const handleConnectionSelect = useCallback((connectionId: string): void => {
1044
+ setSelectedConnectionId(connectionId)
1045
+ setSelectedIds(new Set())
1046
+ }, [])
1047
+
1048
+ // -------------------------------------------------------- file dropping
1049
+
1050
+ const onDrop = useCallback((event: React.DragEvent<HTMLDivElement>): void => {
1051
+ event.preventDefault()
1052
+ const files = [...(event.dataTransfer.files ?? [])].filter(file => file.type.startsWith('image/'))
1053
+ if (files.length === 0) return
1054
+ const world = screenToWorld(event.clientX, event.clientY)
1055
+ void Promise.all(files.map(async file => {
1056
+ 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) })
1057
+ const dimensions = await readImageSize(dataUrl)
1058
+ return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
1059
+ })).then(assets => addAssets(assets, world)).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
1060
+ }, [addAssets, api, screenToWorld])
1061
+
1062
+ // ------------------------------------------------------------ projects
1063
+
1064
+ const newCanvas = useCallback(async (): Promise<void> => {
1065
+ try {
1066
+ const next = await api.canvasCreate(tt('canvas.untitled'))
1067
+ setProjects(previous => [summaryOf(next), ...previous])
1068
+ setDocument(next); setSelectedIds(new Set()); setSelectedConnectionId(null)
1069
+ syncedRef.current = JSON.stringify(next); setSaveState('saved')
1070
+ pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1071
+ } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1072
+ }, [api])
1073
+
1074
+ const selectProject = useCallback(async (id: string): Promise<void> => {
1075
+ try {
1076
+ const next = await api.canvasRead(id)
1077
+ setDocument(next); setSelectedIds(new Set()); setSelectedConnectionId(null)
1078
+ syncedRef.current = JSON.stringify(next); setSaveState('saved')
1079
+ pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1080
+ } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1081
+ }, [api])
1082
+
1083
+ const removeCurrentProject = useCallback(async (): Promise<void> => {
1084
+ const current = documentRef.current
1085
+ if (current === null) return
1086
+ try {
1087
+ const remaining = await api.canvasRemove(current.id)
1088
+ setConfirmDeleteProject(false)
1089
+ const nextId = remaining[0]?.id
1090
+ if (nextId === undefined) {
1091
+ const created = await api.canvasCreate(tt('canvas.untitled'))
1092
+ setProjects([summaryOf(created)]); setDocument(created)
1093
+ syncedRef.current = JSON.stringify(created); setSaveState('saved')
1094
+ } else {
1095
+ setProjects(remaining)
1096
+ await selectProject(nextId)
1097
+ }
1098
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
1099
+ } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1100
+ }, [api, selectProject])
1101
+
1102
+ // -------------------------------------------------------------- derived
1103
+
1104
+ const nodeById = useMemo(() => new Map((document?.nodes ?? []).map(node => [node.id, node])), [document])
1105
+ const relatedIds = useMemo(() => {
1106
+ const related = new Set<string>()
1107
+ if (document === null) return related
1108
+ for (const connection of document.connections) {
1109
+ if (selectedIds.has(connection.fromNodeId)) related.add(connection.toNodeId)
1110
+ if (selectedIds.has(connection.toNodeId)) related.add(connection.fromNodeId)
1111
+ }
1112
+ return related
1113
+ }, [document, selectedIds])
1114
+
1115
+ const isSpaceOrCtrl = temporaryPanTool
1116
+ const cursorClass = tool === 'pan' || isSpaceOrCtrl ? css.panCursor : css.selectCursor
1117
+
1118
+ const backgroundMode = document?.background ?? 'dots'
1119
+ const cycleBackground = useCallback((): void => {
1120
+ mutate(previous => ({ ...previous, background: previous.background === 'dots' ? 'lines' : previous.background === 'lines' ? 'blank' : 'dots' }))
1121
+ }, [mutate])
1122
+
1123
+ const gridSize = GRID_SIZE * (document?.viewport.k ?? 1)
1124
+ const gridOffsetX = (document?.viewport.x ?? 0) % gridSize
1125
+ const gridOffsetY = (document?.viewport.y ?? 0) % gridSize
1126
+
1127
+ // ------------------------------------------------------------- render
1128
+
1129
+ const renderNode = (node: CanvasNode): React.JSX.Element => {
1130
+ const metadata = nodeMetadata(node)
1131
+ const isSelected = selectedIds.has(node.id)
1132
+ const isRelated = relatedIds.has(node.id)
1133
+ const asset = assetOf(node)
1134
+ const isGenerating = node.type === 'image' && metadata.status === 'generating'
1135
+ const isError = node.type === 'image' && metadata.status === 'error'
1136
+ const isConnectTarget = connecting?.targetId === node.id
1137
+ const hasImage = asset !== undefined && asset.url !== ''
1138
+ const isConfig = node.type === 'config'
1139
+ const isTextual = node.type === 'text' || isConfig
1140
+ return <div
1141
+ key={node.id}
1142
+ data-node-id={node.id}
1143
+ className={`${css.node} ${isConfig ? css.configNode : isTextual ? css.textNode : css.imageNode} ${isSelected ? css.nodeSelected : ''} ${isRelated ? css.nodeRelated : ''} ${isConnectTarget ? css.nodeConnectTarget : ''}`}
1144
+ style={{ left: node.x, top: node.y, width: node.width, height: node.height }}
1145
+ onPointerDown={event => handleNodePointerDown(event, node.id)}
1146
+ onContextMenu={event => {
1147
+ if ((event.target as Element).closest('textarea, input, select')) return
1148
+ event.preventDefault(); event.stopPropagation()
1149
+ if (!selectedIds.has(node.id)) setSelectedIds(new Set([node.id]))
1150
+ setContextMenu({ type: 'node', screen: { x: event.clientX, y: event.clientY }, nodeId: node.id })
1151
+ }}
1152
+ >
1153
+ <div className={css.nodeGlow} aria-hidden="true" />
1154
+ {isTextual ? <header className={css.nodeHeader}>
1155
+ <span className={css.nodeTitle}>{node.title}</span>
1156
+ </header> : null}
1157
+ {isConfig ? <div className={css.configLinks} data-config-links={node.id}>
1158
+ <span className={css.composerChip}>{tt('canvas.composerLinked', { count: (document?.connections ?? []).filter(connection => connection.toNodeId === node.id).length })}</span>
1159
+ </div> : null}
1160
+ {isConfig
1161
+ ? <p className={css.configHint}>{tt('canvas.configHint')}</p>
1162
+ : isTextual
1163
+ ? <textarea
1164
+ className={css.textArea}
1165
+ value={metadata.text ?? ''}
1166
+ placeholder={tt('canvas.textPlaceholder')}
1167
+ onPointerDown={event => event.stopPropagation()}
1168
+ onChange={event => patchNode(node.id, { text: event.target.value })}
1169
+ />
1170
+ : <div className={css.nodeBody}>
1171
+ {isGenerating
1172
+ ? <div className={css.nodeState}><span className={css.spinner} aria-hidden="true" /><span>{tt('canvas.generatingNode')}</span></div>
1173
+ : isError
1174
+ ? <div className={css.nodeStateError}>{metadata.error ?? tt('canvas.generateFailed')}<button type="button" onClick={() => patchNode(node.id, { status: 'idle', error: undefined })}>{tt('canvas.dismiss')}</button></div>
1175
+ : hasImage
1176
+ ? <img src={asset.url} alt={node.title} draggable={false} onDragStart={event => event.preventDefault()} />
1177
+ : <button type="button" className={css.nodeEmpty} onClick={() => setPickerOpen(true)}><ToolbarIcon name="image" /><span>{tt('canvas.emptyImageNode')}</span></button>}
1178
+ </div>}
1179
+ {isSelected
1180
+ ? <div className={css.resizeHandle} onPointerDown={event => handleResizeStart(event, node, 'bottom-right')} title={tt('canvas.resizeHint')} />
1181
+ : null}
1182
+ <div className={`${css.handle} ${css.handleLeft}`} title={tt('canvas.connectHint')} onPointerDown={event => handleConnectStart(event, node.id, 'target')} />
1183
+ <div className={`${css.handle} ${css.handleRight}`} title={tt('canvas.connectHint')} onPointerDown={event => handleConnectStart(event, node.id, 'source')} />
1184
+ <div className={css.hoverToolbar} onPointerDown={event => event.stopPropagation()}>
1185
+ {node.type === 'image' && hasImage ? <IconButton name="download" label={tt('canvas.download')} onClick={() => downloadNode(node)} /> : null}
1186
+ <IconButton name="duplicate" label={tt('canvas.duplicate')} onClick={duplicateSelection} />
1187
+ <IconButton name="trash" label={tt('canvas.delete')} onClick={deleteSelection} />
1188
+ </div>
1189
+ </div>
1190
+ }
1191
+
1192
+ const renderConnections = (): React.JSX.Element => (
1193
+ <svg
1194
+ className={css.connectionLayer}
1195
+ width={WORLD_PAD * 2}
1196
+ height={WORLD_PAD * 2}
1197
+ style={{ left: -WORLD_PAD, top: -WORLD_PAD }}
1198
+ aria-hidden="true"
1199
+ >
1200
+ <g transform={`translate(${WORLD_PAD},${WORLD_PAD})`}>
1201
+ {(document?.connections ?? []).map(connection => {
1202
+ const from = nodeById.get(connection.fromNodeId)
1203
+ const to = nodeById.get(connection.toNodeId)
1204
+ if (from === undefined || to === undefined) return null
1205
+ const path = bezierPath(nodeAnchor(from, 'right'), nodeAnchor(to, 'left'))
1206
+ const active = connection.id === selectedConnectionId
1207
+ return <g key={connection.id}>
1208
+ <path
1209
+ data-connection-hit={connection.id}
1210
+ d={path}
1211
+ stroke="transparent"
1212
+ strokeWidth={16}
1213
+ fill="none"
1214
+ style={{ cursor: 'pointer', pointerEvents: 'stroke' }}
1215
+ onPointerDown={event => { event.stopPropagation(); handleConnectionSelect(connection.id) }}
1216
+ onContextMenu={event => {
1217
+ event.preventDefault(); event.stopPropagation()
1218
+ handleConnectionSelect(connection.id)
1219
+ setContextMenu({ type: 'connection', screen: { x: event.clientX, y: event.clientY }, connectionId: connection.id })
1220
+ }}
1221
+ />
1222
+ <path d={path} className={`${css.connectionPath} ${active ? css.connectionActive : ''}`} />
1223
+ </g>
1224
+ })}
1225
+ {connecting !== null ? (() => {
1226
+ const node = nodeById.get(connecting.nodeId)
1227
+ if (node === undefined) return null
1228
+ const mouse = connecting.targetId !== undefined && connecting.targetId !== null && nodeById.has(connecting.targetId)
1229
+ ? nodeAnchor(nodeById.get(connecting.targetId)!, connecting.handleType === 'source' ? 'left' : 'right')
1230
+ : connecting.mouse
1231
+ const path = connecting.handleType === 'source'
1232
+ ? bezierPath(nodeAnchor(node, 'right'), mouse)
1233
+ : bezierPath(mouse, nodeAnchor(node, 'left'))
1234
+ return <path d={path} className={css.connectionPreview} />
1235
+ })() : null}
1236
+ </g>
1237
+ </svg>
1238
+ )
1239
+
1240
+ const renderComposer = (): ReactNode => {
1241
+ if (!composerVisible || document === null || composerTarget === null) return null
1242
+ const linkedCount = composerReferenceCount + composerTextCount
1243
+ const k = document.viewport.k
1244
+ const centerX = document.viewport.x + (composerTarget.x + composerTarget.width / 2) * k
1245
+ const clampedX = Math.min(Math.max(centerX, 292), Math.max(292, viewportSize.width - 292))
1246
+ const belowY = document.viewport.y + (composerTarget.y + composerTarget.height) * k + 14
1247
+ const top = belowY > viewportSize.height - 170
1248
+ ? Math.max(64, document.viewport.y + composerTarget.y * k - 158)
1249
+ : belowY
1250
+ return <div className={css.composer} data-canvas-no-zoom="" style={{ left: clampedX - 280, top }}>
1251
+ <textarea
1252
+ className={css.composerPrompt}
1253
+ value={composerPrompt}
1254
+ placeholder={tt('canvas.composerPlaceholder')}
1255
+ rows={1}
1256
+ onPointerDown={event => event.stopPropagation()}
1257
+ onChange={event => setComposerPrompt(event.target.value)}
1258
+ onKeyDown={event => {
1259
+ if (event.key === 'Enter' && !event.shiftKey) {
1260
+ event.preventDefault()
1261
+ void submitComposer(composerTarget)
1262
+ }
1263
+ }}
1264
+ />
1265
+ {linkedCount > 0 ? <div className={css.composerMeta}>
1266
+ <span className={css.composerChip}>{tt('canvas.composerLinked', { count: linkedCount })}</span>
1267
+ </div> : null}
1268
+ <div className={css.composerControls}>
1269
+ <select value={composerModel} onChange={(event: ChangeEvent<HTMLSelectElement>) => setComposerModel(event.target.value)} aria-label={tt('canvas.model')}>
1270
+ <option value="">{tt('canvas.modelPlaceholder')}</option>
1271
+ {imageModels.map(item => <option key={item} value={item}>{item}</option>)}
1272
+ </select>
1273
+ <select value={composerSize} onChange={event => setComposerSize(event.target.value)} aria-label={tt('canvas.size')}>
1274
+ <option value="auto">{tt('canvas.sizeAuto')}</option>
1275
+ <option value="1:1">1:1</option>
1276
+ <option value="3:4">3:4</option>
1277
+ <option value="16:9">16:9</option>
1278
+ <option value="9:16">9:16</option>
1279
+ </select>
1280
+ <select value={composerQuality} onChange={event => setComposerQuality(event.target.value)} aria-label={tt('canvas.quality')}>
1281
+ <option value="auto">{tt('canvas.qualityAuto')}</option>
1282
+ <option value="1k">1K</option>
1283
+ <option value="2k">2K</option>
1284
+ <option value="4k">4K</option>
1285
+ </select>
1286
+ <select value={composerCount} onChange={event => setComposerCount(Number(event.target.value))} aria-label={tt('canvas.count')}>
1287
+ {[1, 2, 3, 4].map(item => <option key={item} value={item}>{tt('canvas.countUnit', { count: item })}</option>)}
1288
+ </select>
1289
+ <button
1290
+ type="button"
1291
+ className={css.composerSend}
1292
+ aria-label={tt('canvas.generate')}
1293
+ title={tt('canvas.generate')}
1294
+ disabled={!connected || composerBusy || (composerPrompt.trim() === '' && composerTextCount === 0)}
1295
+ onClick={() => { void submitComposer(composerTarget) }}
1296
+ >{composerBusy ? <span className={css.spinner} aria-hidden="true" /> : <ToolbarIcon name="send" />}</button>
1297
+ </div>
1298
+ </div>
1299
+ }
1300
+
1301
+ const renderMinimap = (): React.JSX.Element | null => {
1302
+ if (document === null || viewportSize.width === 0) return null
1303
+ const width = 220
1304
+ const height = 150
1305
+ const nodes = document.nodes
1306
+ let worldBounds = { x: -600, y: -600, w: 1200, h: 1200 }
1307
+ let scale = Math.min(width / worldBounds.w, height / worldBounds.h)
1308
+ let offset = { x: (width - worldBounds.w * scale) / 2, y: (height - worldBounds.h * scale) / 2 }
1309
+ if (nodes.length > 0) {
1310
+ const content = nodesBounds(nodes)
1311
+ worldBounds = { x: content.minX - 500, y: content.minY - 500, w: content.maxX - content.minX + 1000, h: content.maxY - content.minY + 1000 }
1312
+ scale = Math.min(width / worldBounds.w, height / worldBounds.h)
1313
+ offset = { x: (width - worldBounds.w * scale) / 2, y: (height - worldBounds.h * scale) / 2 }
1314
+ }
1315
+ const toMap = (worldX: number, worldY: number): Point => ({ x: (worldX - worldBounds.x) * scale + offset.x, y: (worldY - worldBounds.y) * scale + offset.y })
1316
+ const toWorld = (mapX: number, mapY: number): Point => ({ x: (mapX - offset.x) / scale + worldBounds.x, y: (mapY - offset.y) / scale + worldBounds.y })
1317
+ const viewportRect = (() => {
1318
+ const vx = -document.viewport.x / document.viewport.k
1319
+ const vy = -document.viewport.y / document.viewport.k
1320
+ const p1 = toMap(vx, vy)
1321
+ const p2 = toMap(vx + viewportSize.width / document.viewport.k, vy + viewportSize.height / document.viewport.k)
1322
+ return { x: p1.x, y: p1.y, w: Math.max(p2.x - p1.x, 4), h: Math.max(p2.y - p1.y, 4) }
1323
+ })()
1324
+ const jump = (event: ReactPointerEvent<HTMLDivElement>): void => {
1325
+ const bounds = event.currentTarget.getBoundingClientRect()
1326
+ const world = toWorld(event.clientX - bounds.left, event.clientY - bounds.top)
1327
+ setViewport({ k: document.viewport.k, x: viewportSize.width / 2 - world.x * document.viewport.k, y: viewportSize.height / 2 - world.y * document.viewport.k })
1328
+ }
1329
+ return <aside className={css.minimap} data-canvas-no-zoom="" aria-label={tt('canvas.minimap')}>
1330
+ <div className={css.minimapCanvas} onPointerDown={event => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); jump(event) }}
1331
+ onPointerMove={event => { if (event.buttons === 1) jump(event) }}>
1332
+ {nodes.map(node => {
1333
+ const position = toMap(node.x, node.y)
1334
+ return <div key={node.id} className={`${css.minimapNode} ${node.type === 'image' ? css.minimapImage : css.minimapText} ${selectedIds.has(node.id) ? css.minimapSelected : ''}`}
1335
+ style={{ left: position.x, top: position.y, width: Math.max(node.width * scale, 2), height: Math.max(node.height * scale, 2) }} />
1336
+ })}
1337
+ <div className={css.minimapViewport} style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h }} />
1338
+ </div>
1339
+ </aside>
1340
+ }
1341
+
1342
+ const renderContextMenu = (): ReactNode => {
1343
+ if (contextMenu !== null) {
1344
+ const close = (): void => setContextMenu(null)
1345
+ const items: Array<{ label: string; action: () => void; danger?: boolean; icon: ToolbarIconName }> = []
1346
+ if (contextMenu.type === 'node') {
1347
+ const node = nodeById.get(contextMenu.nodeId)
1348
+ if (node !== undefined && node.type === 'image' && (assetOf(node)?.url.length ?? 0) > 0) items.push({ label: tt('canvas.download'), icon: 'download', action: () => downloadNode(node) })
1349
+ items.push({ label: tt('canvas.duplicate'), icon: 'duplicate', action: duplicateSelection })
1350
+ items.push({ label: tt('canvas.delete'), icon: 'trash', action: deleteSelection, danger: true })
1351
+ } else if (contextMenu.type === 'connection') {
1352
+ items.push({
1353
+ label: tt('canvas.deleteConnection'), icon: 'close', danger: true,
1354
+ action: () => {
1355
+ mutate(previous => ({ ...previous, connections: previous.connections.filter(connection => connection.id !== contextMenu.connectionId) }))
1356
+ setSelectedConnectionId(null)
1357
+ },
1358
+ })
1359
+ } else {
1360
+ items.push({ label: tt('canvas.addImage'), icon: 'image', action: () => setPickerOpen(true) })
1361
+ items.push({ label: tt('canvas.addTextNode'), icon: 'text', action: () => placeNewNode(createTextNode(contextMenu.world)) })
1362
+ items.push({ label: tt('canvas.paste'), icon: 'duplicate', action: () => pasteClipboard(contextMenu.world) })
1363
+ items.push({ label: tt('canvas.fitView'), icon: 'fit', action: fitView })
1364
+ }
1365
+ return <div className={css.contextMenu} style={{ left: contextMenu.screen.x, top: contextMenu.screen.y }} data-canvas-no-zoom="" role="menu">
1366
+ {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>)}
1367
+ </div>
1368
+ }
1369
+ if (createMenu !== null) {
1370
+ return <div className={css.contextMenu} style={{ left: createMenu.screen.x, top: createMenu.screen.y }} data-canvas-no-zoom="" role="menu">
1371
+ <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>
1372
+ <button type="button" role="menuitem" onClick={() => { placeNewNode(createTextNode(createMenu.world)); setCreateMenu(null) }}><ToolbarIcon name="text" />{tt('canvas.addTextNode')}</button>
1373
+ </div>
1374
+ }
1375
+ return null
1376
+ }
1377
+
1378
+ const emptyState = document !== null && document.nodes.length === 0
1379
+ ? <div className={css.emptyHint} data-canvas-no-zoom="">
1380
+ <strong>{tt('canvas.emptyTitle')}</strong>
1381
+ <span>{tt('canvas.emptyHint')}</span>
1382
+ </div>
1383
+ : null
1384
+
1385
+ const marqueeRect = marquee === null ? null : (() => {
1386
+ const x1 = (Math.min(marquee.start.x, marquee.current.x) * (document?.viewport.k ?? 1)) + (document?.viewport.x ?? 0)
1387
+ const y1 = (Math.min(marquee.start.y, marquee.current.y) * (document?.viewport.k ?? 1)) + (document?.viewport.y ?? 0)
1388
+ const x2 = (Math.max(marquee.start.x, marquee.current.x) * (document?.viewport.k ?? 1)) + (document?.viewport.x ?? 0)
1389
+ const y2 = (Math.max(marquee.start.y, marquee.current.y) * (document?.viewport.k ?? 1)) + (document?.viewport.y ?? 0)
1390
+ return { left: x1, top: y1, width: x2 - x1, height: y2 - y1 }
1391
+ })()
1392
+
1393
+ return <section className={css.root} data-canvas-workspace="">
1394
+ <header className={css.topBar} data-canvas-no-zoom="">
1395
+ <select className={css.projectSelect} value={document?.id ?? ''} onChange={event => { void selectProject(event.target.value) }} aria-label={tt('canvas.project')}>
1396
+ {projects.map(project => <option key={project.id} value={project.id}>{project.title}</option>)}
1397
+ </select>
1398
+ <IconButton name="new" label={tt('canvas.newCanvas')} onClick={() => { void newCanvas() }} />
1399
+ <IconButton name="deleteProject" label={confirmDeleteProject ? tt('canvas.deleteCanvasConfirm') : tt('canvas.deleteCanvas')} active={confirmDeleteProject} disabled={document === null} onClick={() => {
1400
+ if (confirmDeleteProject) { void removeCurrentProject() } else { setConfirmDeleteProject(true); window.setTimeout(() => setConfirmDeleteProject(false), 3000) }
1401
+ }} />
1402
+ {renamingTitle && document !== null
1403
+ ? <input
1404
+ className={css.titleInput}
1405
+ value={document.title}
1406
+ autoFocus
1407
+ aria-label={tt('canvas.rename')}
1408
+ onChange={event => updateDocument(previous => ({ ...previous, title: event.target.value }))}
1409
+ onBlur={() => setRenamingTitle(false)}
1410
+ onKeyDown={event => { if (event.key === 'Enter' || event.key === 'Escape') setRenamingTitle(false) }}
1411
+ />
1412
+ : <button type="button" className={css.titleButton} onDoubleClick={() => setRenamingTitle(true)} title={tt('canvas.renameHint')}>{document?.title ?? ''}</button>}
1413
+ <span className={css.topBarSpacer} />
1414
+ <IconButton name="background" label={tt('canvas.background')} onClick={cycleBackground} />
1415
+ <IconButton name="undo" label={tt('canvas.undo')} disabled={pastRef.current.length === 0} onClick={undo} />
1416
+ <IconButton name="redo" label={tt('canvas.redo')} disabled={futureRef.current.length === 0} onClick={redo} />
1417
+ <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>
1418
+ </header>
1419
+
1420
+ <div
1421
+ ref={viewportRef}
1422
+ className={`${css.viewport} ${cursorClass}`}
1423
+ onPointerDown={onViewportPointerDown}
1424
+ onWheel={onWheel}
1425
+ onDoubleClick={event => {
1426
+ const target = event.target instanceof Element ? event.target : null
1427
+ if (target?.closest('[data-node-id],[data-canvas-no-zoom]')) return
1428
+ setCreateMenu({ screen: { x: event.clientX, y: event.clientY }, world: screenToWorld(event.clientX, event.clientY) })
1429
+ }}
1430
+ onContextMenu={event => {
1431
+ const target = event.target instanceof Element ? event.target : null
1432
+ if (target?.closest('[data-node-id],[data-connection-hit],[data-canvas-no-zoom]')) return
1433
+ event.preventDefault()
1434
+ setCreateMenu(null)
1435
+ setContextMenu({ type: 'canvas', screen: { x: event.clientX, y: event.clientY }, world: screenToWorld(event.clientX, event.clientY) })
1436
+ }}
1437
+ onDragOver={event => event.preventDefault()}
1438
+ onDrop={onDrop}
1439
+ >
1440
+ <div className={css.grid} style={{ backgroundSize: `${gridSize}px ${gridSize}px`, backgroundPosition: `${gridOffsetX}px ${gridOffsetY}px` }} data-mode={backgroundMode} aria-hidden="true" />
1441
+ <div className={css.world} style={{ transform: `translate(${document?.viewport.x ?? 0}px, ${document?.viewport.y ?? 0}px) scale(${document?.viewport.k ?? 1})` }}>
1442
+ {renderConnections()}
1443
+ {document?.nodes.map(renderNode)}
1444
+ </div>
1445
+ {marqueeRect !== null ? <div className={css.marquee} style={marqueeRect} aria-hidden="true" /> : null}
1446
+ {emptyState}
1447
+ </div>
1448
+
1449
+ <div className={`${css.dock} ${cursorClass}`} data-canvas-no-zoom="">
1450
+ <IconButton name="select" label={tt('canvas.toolSelect')} active={tool === 'select'} onClick={() => setTool('select')} />
1451
+ <IconButton name="pan" label={tt('canvas.toolPan')} active={tool === 'pan'} onClick={() => setTool('pan')} />
1452
+ <span className={css.dockDivider} />
1453
+ <IconButton name="image" label={tt('canvas.addImage')} onClick={() => setPickerOpen(true)} />
1454
+ <IconButton name="text" label={tt('canvas.addText')} onClick={() => placeNewNode(createTextNode())} />
1455
+ <IconButton name="sparkle" label={tt('canvas.addConfigNode')} onClick={() => placeNewNode(createConfigNode())} />
1456
+ <span className={css.dockDivider} />
1457
+ <IconButton name="trash" label={tt('canvas.delete')} disabled={selectedIds.size === 0 && selectedConnectionId === null} onClick={deleteSelection} />
1458
+ </div>
1459
+
1460
+ <div className={css.zoomDock} data-canvas-no-zoom="">
1461
+ <IconButton name="minimap" label={minimapOpen ? tt('canvas.minimapClose') : tt('canvas.minimapOpen')} active={minimapOpen} onClick={() => setMinimapOpen(previous => !previous)} />
1462
+ <IconButton name="fit" label={tt('canvas.fitView')} onClick={fitView} />
1463
+ <input
1464
+ type="range"
1465
+ min={5}
1466
+ max={500}
1467
+ step={1}
1468
+ value={Math.round((document?.viewport.k ?? 1) * 100)}
1469
+ onChange={event => setZoomAtCenter(Number(event.target.value) / 100)}
1470
+ aria-label={tt('canvas.zoom')}
1471
+ />
1472
+ <span className={css.zoomValue}>{Math.round((document?.viewport.k ?? 1) * 100)}%</span>
1473
+ </div>
1474
+
1475
+ {minimapOpen ? renderMinimap() : null}
1476
+ {renderComposer()}
1477
+ {renderContextMenu()}
1478
+
1479
+ {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}
1480
+
1481
+ {pickerOpen ? <ImagePicker
1482
+ api={api}
1483
+ history={history}
1484
+ gallery={gallery}
1485
+ imageModels={imageModels}
1486
+ defaultChannelId={defaultChannelId}
1487
+ canvasId={document?.id ?? ''}
1488
+ connected={connected}
1489
+ onClose={() => setPickerOpen(false)}
1490
+ onAssets={assets => { addAssets(assets); setPickerOpen(false) }}
1491
+ onTask={task => {
1492
+ if (document === null) return
1493
+ const center = canvasCenter()
1494
+ const size = nodeSizeFromRatio(task.request.size, IMAGE_NODE_SIZE)
1495
+ const node: CanvasNode = {
1496
+ id: newId('node'), type: 'image', title: tt('canvas.imageNode'),
1497
+ x: Math.round(center.x - size.width / 2), y: Math.round(center.y - size.height / 2),
1498
+ width: size.width, height: size.height,
1499
+ 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 },
1500
+ }
1501
+ placeNewNode(node)
1502
+ setPickerOpen(false)
1503
+ }}
1504
+ /> : null}
1505
+ </section>
1506
+ }
1507
+
1508
+ function ImagePicker(props: {
1509
+ api: ImageGenApi
1510
+ history: HistoryEntry[]
1511
+ gallery: HistoryEntry[]
1512
+ imageModels: string[]
1513
+ defaultChannelId?: string
1514
+ canvasId: string
1515
+ connected: boolean
1516
+ onClose: () => void
1517
+ onAssets: (assets: CanvasAssetRef[]) => void
1518
+ onTask: (task: GenerationTask) => void
1519
+ }): React.JSX.Element {
1520
+ const { api, history, gallery, imageModels, defaultChannelId, canvasId, connected, onClose, onAssets } = props
1521
+ const [tab, setTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>('upload')
1522
+ const [selected, setSelected] = useState<string[]>([])
1523
+ const [dimensions, setDimensions] = useState<Record<string, { width: number; height: number }>>({})
1524
+ const [prompt, setPrompt] = useState('')
1525
+ const [model, setModel] = useState(imageModels[0] ?? '')
1526
+ const [size, setSize] = useState('auto')
1527
+ const [quality, setQuality] = useState('auto')
1528
+ const [busy, setBusy] = useState(false)
1529
+ const toggle = (key: string): void => setSelected(previous => previous.includes(key) ? previous.filter(item => item !== key) : [...previous, key])
1530
+ const items = (tab === 'history' ? history : gallery).flatMap(entry => entry.images.map((image, index) => ({ key: `${entry.id}:${index}`, entry, image, index })))
1531
+ const uploadFiles = (files: File[]): void => {
1532
+ setBusy(true)
1533
+ void Promise.all(files.map(async file => {
1534
+ 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) })
1535
+ const sizeOf = await readImageSize(dataUrl)
1536
+ return api.canvasUpload(dataUrl, sizeOf.width, sizeOf.height, { origin: 'upload', originId: file.name })
1537
+ })).then(assets => { onAssets(assets) }).catch(() => {}).finally(() => setBusy(false))
1538
+ }
1539
+ const addSelected = async (): Promise<void> => {
1540
+ setBusy(true)
1541
+ try {
1542
+ const assets: CanvasAssetRef[] = []
1543
+ for (const key of selected) {
1544
+ const [entryId, indexText] = key.split(':'); const index = Number(indexText); const item = items.find(candidate => candidate.key === key)
1545
+ if (entryId === undefined || item === undefined) continue
1546
+ const sizeOf = dimensions[key] ?? await readImageSize(item.image.url).catch(() => ({ width: 1024, height: 1024 }))
1547
+ assets.push(await api.canvasImport(tab === 'history' ? 'history' : 'gallery', entryId, index, sizeOf.width, sizeOf.height))
1548
+ }
1549
+ if (assets.length > 0) onAssets(assets)
1550
+ } finally { setBusy(false) }
1551
+ }
1552
+ const generate = async (): Promise<void> => {
1553
+ if (!connected || prompt.trim() === '') return
1554
+ setBusy(true)
1555
+ try {
1556
+ const task = await api.taskSubmit({ mode: 'text', model, prompt: prompt.trim(), size, quality, n: 1, detail: '', ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }), canvas: { canvasId } })
1557
+ props.onTask(task)
1558
+ } finally { setBusy(false) }
1559
+ }
1560
+ return <div className={css.modalBackdrop} role="dialog" aria-modal="true" data-canvas-no-zoom=""><section className={css.picker}>
1561
+ <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>
1562
+ <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>
1563
+ <div className={css.pickerBody}>
1564
+ {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}
1565
+ {(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}
1566
+ {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}
1567
+ </div>
1568
+ </section></div>
1569
+ }