@dickpy/dsh-imagegen 1.5.7 → 1.5.9

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.
@@ -1,1871 +1,2130 @@
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 { TemplateLibrary } from './TemplateLibrary.tsx'
13
- import css from './canvas-workspace.module.css'
14
-
15
- type CanvasTool = 'select' | 'pan'
16
- type BackgroundMode = CanvasDocument['background']
17
-
18
- const MIN_SCALE = 0.05
19
- const MAX_SCALE = 5
20
- const GRID_SIZE = 48
21
- const IMAGE_NODE_SIZE = { width: 320, height: 320 }
22
- const TEXT_NODE_SIZE = { width: 280, height: 150 }
23
- const CONFIG_NODE_SIZE = { width: 320, height: 190 }
24
- const LEGACY_CONFIG_NODE_SIZE = { width: 240, height: 96 }
25
- const HISTORY_LIMIT = 60
26
- const WORLD_PAD = 12000
27
-
28
- interface CanvasWorkspaceProps {
29
- api: ImageGenApi
30
- imageModels: string[]
31
- defaultChannelId?: string
32
- connected: boolean
33
- history: HistoryEntry[]
34
- gallery: HistoryEntry[]
35
- tasks: GenerationTask[]
36
- importRequest?: { source: 'history' | 'gallery'; entryId: string; imageIndex: number }
37
- onImportRequestHandled?: () => void
38
- onOpenSettings?: () => void
39
- }
40
-
41
- type Point = { x: number; y: number }
42
-
43
- interface NodeDragState {
44
- pointerId: number
45
- startX: number
46
- startY: number
47
- moved: boolean
48
- snapshot: string | null
49
- origins: Map<string, Point>
50
- }
51
-
52
- interface PanState {
53
- startX: number
54
- startY: number
55
- viewportX: number
56
- viewportY: number
57
- hasMoved: boolean
58
- startedOnBackground: boolean
59
- }
60
-
61
- interface MarqueeState {
62
- start: Point
63
- current: Point
64
- additive: boolean
65
- initialIds: string[]
66
- }
67
-
68
- interface ConnectState {
69
- nodeId: string
70
- handleType: 'source' | 'target'
71
- mouse: Point
72
- targetId: string | null
73
- }
74
-
75
- interface ResizeState {
76
- nodeId: string
77
- corner: 'bottom-right' | 'bottom-left'
78
- startX: number
79
- startY: number
80
- width: number
81
- height: number
82
- x: number
83
- y: number
84
- ratio: number | null
85
- }
86
-
87
- type ContextMenuState =
88
- | { type: 'canvas'; screen: Point; world: Point }
89
- | { type: 'node'; screen: Point; nodeId: string }
90
- | { type: 'connection'; screen: Point; connectionId: string }
91
-
92
- type ProjectSummary = Awaited<ReturnType<ImageGenApi['canvasList']>>[number]
93
-
94
- function newId(prefix: string): string {
95
- const random = globalThis.crypto?.randomUUID?.()
96
- return `${prefix}-${random ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`
97
- }
98
-
99
- function imageDataUrl(image: { b64: string; mime: string }): string {
100
- return `data:${image.mime};base64,${image.b64}`
101
- }
102
-
103
- function readImageSize(src: string): Promise<{ width: number; height: number }> {
104
- return new Promise((resolve, reject) => {
105
- const image = new Image()
106
- image.onload = () => resolve({ width: image.naturalWidth || 1, height: image.naturalHeight || 1 })
107
- image.onerror = () => reject(new Error('无法读取图片尺寸'))
108
- image.src = src
109
- })
110
- }
111
-
112
- async function assetToDataUrl(asset: CanvasAssetRef): Promise<string> {
113
- if (asset.url.startsWith('data:')) return asset.url
114
- const response = await fetch(asset.url)
115
- if (!response.ok) throw new Error('读取画布图片失败')
116
- const blob = await response.blob()
117
- return await new Promise((resolve, reject) => {
118
- const reader = new FileReader()
119
- reader.onload = () => resolve(String(reader.result))
120
- reader.onerror = () => reject(new Error('读取画布图片失败'))
121
- reader.readAsDataURL(blob)
122
- })
123
- }
124
-
125
- function clampScale(scale: number): number {
126
- return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))
127
- }
128
-
129
- function sizeForAsset(asset: CanvasAssetRef): { width: number; height: number } {
130
- const ratio = asset.width > 0 && asset.height > 0 ? asset.width / asset.height : 1
131
- if (ratio >= 1) return { width: IMAGE_NODE_SIZE.width, height: Math.max(160, Math.round(IMAGE_NODE_SIZE.width / ratio)) }
132
- return { width: Math.max(200, Math.round(IMAGE_NODE_SIZE.height * ratio)), height: IMAGE_NODE_SIZE.height }
133
- }
134
-
135
- /** Node footprint for a generation size ratio such as '1:1' or '16:9'. */
136
- function nodeSizeFromRatio(size: string | undefined, spec: { width: number; height: number }): { width: number; height: number } {
137
- const match = /^(\d+):(\d+)$/.exec(size ?? '')
138
- if (match === null) return { ...spec }
139
- const width = Number(match[1])
140
- const height = Number(match[2])
141
- if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { ...spec }
142
- const ratio = width / height
143
- return ratio >= 1
144
- ? { width: spec.width, height: Math.max(160, Math.round(spec.width / ratio)) }
145
- : { width: Math.max(200, Math.round(spec.height * ratio)), height: spec.height }
146
- }
147
-
148
- function nodesBounds(nodes: CanvasNode[]): { minX: number; minY: number; maxX: number; maxY: number } {
149
- return nodes.reduce((acc, node) => ({
150
- minX: Math.min(acc.minX, node.x),
151
- minY: Math.min(acc.minY, node.y),
152
- maxX: Math.max(acc.maxX, node.x + node.width),
153
- maxY: Math.max(acc.maxY, node.y + node.height),
154
- }), { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity })
155
- }
156
-
157
- /** Enlarge config nodes still stored at the pre-expansion default so the
158
- * roomier layout applies to existing canvases too. */
159
- function normalizeConfigNodeSizes(document: CanvasDocument): CanvasDocument {
160
- const nodes = document.nodes.map(node => node.type === 'config'
161
- && node.width === LEGACY_CONFIG_NODE_SIZE.width && node.height === LEGACY_CONFIG_NODE_SIZE.height
162
- ? { ...node, width: CONFIG_NODE_SIZE.width, height: CONFIG_NODE_SIZE.height }
163
- : node)
164
- return nodes === document.nodes ? document : { ...document, nodes }
165
- }
166
-
167
- function summaryOf(document: CanvasDocument): ProjectSummary {
168
- return {
169
- id: document.id,
170
- title: document.title,
171
- revision: document.revision,
172
- nodeCount: document.nodes.length,
173
- createdAt: document.createdAt,
174
- updatedAt: document.updatedAt,
175
- }
176
- }
177
-
178
- function nodeMetadata(node: CanvasNode): NonNullable<CanvasNode['metadata']> {
179
- return node.metadata ?? {}
180
- }
181
-
182
- function assetOf(node: CanvasNode): CanvasAssetRef | undefined {
183
- return node.type === 'image' ? nodeMetadata(node).asset : undefined
184
- }
185
-
186
- function usableAsset(node: CanvasNode): CanvasAssetRef | undefined {
187
- const asset = assetOf(node)
188
- return asset !== undefined && asset.url !== '' ? asset : undefined
189
- }
190
-
191
- function bezierPath(from: Point, to: Point): string {
192
- const distance = Math.abs(to.x - from.x)
193
- const bend = Math.max(distance * 0.5, 50)
194
- return `M ${from.x} ${from.y} C ${from.x + bend} ${from.y}, ${to.x - bend} ${to.y}, ${to.x} ${to.y}`
195
- }
196
-
197
- function nodeAnchor(node: CanvasNode, side: 'left' | 'right'): Point {
198
- return { x: side === 'right' ? node.x + node.width : node.x, y: node.y + node.height / 2 }
199
- }
200
-
201
- type ToolbarIconName = 'new' | 'select' | 'pan' | 'image' | 'text' | 'trash' | 'undo' | 'redo' | 'fit' | 'minimap' | 'background' | 'template' | 'download' | 'duplicate' | 'sparkle' | 'send' | 'close' | 'deleteProject'
202
-
203
- function ToolbarIcon({ name, size = 16 }: { name: ToolbarIconName; size?: number }): React.JSX.Element {
204
- const common = { width: size, height: size, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.35, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, 'aria-hidden': true }
205
- if (name === 'new') return <svg {...common}><path d="M8 3v10M3 8h10" /></svg>
206
- if (name === 'select') return <svg {...common}><path d="M3 2.5l9.3 6.2-4 1.1-1.5 3.7L3 2.5z" /></svg>
207
- if (name === 'pan') return <svg {...common}><path d="M8 2v12M2 8h12M5 5l3-3 3 3M5 11l3 3 3-3" /></svg>
208
- 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>
209
- if (name === 'text') return <svg {...common}><path d="M3 3h10M8 3v10M5.5 13h5" /></svg>
210
- 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>
211
- if (name === 'undo') return <svg {...common}><path d="M3 7a5 5 0 1 1 1.5 4M3 3v4h4" /></svg>
212
- if (name === 'redo') return <svg {...common}><path d="M13 7a5 5 0 1 0-1.5 4M13 3v4h-4" /></svg>
213
- if (name === 'fit') return <svg {...common}><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4" /></svg>
214
- 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>
215
- if (name === 'background') return <svg {...common}><rect x="2" y="4.5" width="10.5" height="9" rx="1.5" /><path d="M4.5 2h10a1.5 1.5 0 0 1 1.5 1.5V11M4.5 10.5l2.6-2.8 2.1 2.2 2.3-2.4 1.5 1.5" /></svg>
216
- if (name === 'template') return <svg {...common}><path d="M8 3.6C6.9 2.5 5 2 2.5 2v10.6c2.5 0 4.4.5 5.5 1.6 1.1-1.1 3-1.6 5.5-1.6V2C11 2 9.1 2.5 8 3.6zM8 3.6V14" /></svg>
217
- if (name === 'download') return <svg {...common}><path d="M8 2.5v8M5 7.5l3 3 3-3M3 13.5h10" /></svg>
218
- 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>
219
- if (name === 'send') return <svg {...common}><path d="M14 2L7 9M14 2L9.5 14l-2.5-5L2 6.5 14 2z" /></svg>
220
- if (name === 'close') return <svg {...common}><path d="M4 4l8 8M12 4l-8 8" /></svg>
221
- 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>
222
- 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>
223
- }
224
-
225
- function IconButton(props: {
226
- name: ToolbarIconName
227
- label: string
228
- active?: boolean
229
- disabled?: boolean
230
- size?: number
231
- onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
232
- onMouseEnter?: (event: React.MouseEvent<HTMLButtonElement>) => void
233
- onMouseLeave?: () => void
234
- }): React.JSX.Element {
235
- return <button
236
- type="button"
237
- className={css.iconButton}
238
- data-active={props.active ? '' : undefined}
239
- aria-label={props.label}
240
- title={props.label}
241
- disabled={props.disabled}
242
- onClick={props.onClick}
243
- onMouseEnter={props.onMouseEnter}
244
- onMouseLeave={props.onMouseLeave}
245
- ><ToolbarIcon name={props.name} size={props.size} /></button>
246
- }
247
-
248
- export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element {
249
- const { api, imageModels, defaultChannelId, connected, history, gallery, tasks, importRequest, onImportRequestHandled, onOpenSettings } = props
250
- const [projects, setProjects] = useState<ProjectSummary[]>([])
251
- const [document, setDocument] = useState<CanvasDocument | null>(null)
252
- const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
253
- const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null)
254
- const [tool, setTool] = useState<CanvasTool>('select')
255
- const [spacePressed, setSpacePressed] = useState(false)
256
- const [ctrlPressed, setCtrlPressed] = useState(false)
257
- const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 })
258
- const [marquee, setMarquee] = useState<MarqueeState | null>(null)
259
- const [connecting, setConnecting] = useState<ConnectState | null>(null)
260
- const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
261
- const [createMenu, setCreateMenu] = useState<{ screen: Point; world: Point } | null>(null)
262
- const [minimapOpen, setMinimapOpen] = useState(true)
263
- const [pickerOpen, setPickerOpen] = useState(false)
264
- const [backgroundMenu, setBackgroundMenu] = useState<Point | null>(null)
265
- const [imageMenu, setImageMenu] = useState<Point | null>(null)
266
- const menuCloseTimer = useRef<number | null>(null)
267
- const clearMenuCloseTimer = (): void => {
268
- if (menuCloseTimer.current !== null) { window.clearTimeout(menuCloseTimer.current); menuCloseTimer.current = null }
269
- }
270
- const scheduleMenuClose = useCallback((): void => {
271
- clearMenuCloseTimer()
272
- menuCloseTimer.current = window.setTimeout(() => { setBackgroundMenu(null); setImageMenu(null) }, 280)
273
- }, [])
274
- /** Open one dock menu anchored to its button (root-relative) and close the
275
- * other: the two menus are mutually exclusive. */
276
- const openDockMenu = useCallback((kind: 'image' | 'background', button: HTMLElement): void => {
277
- clearMenuCloseTimer()
278
- const bounds = button.getBoundingClientRect()
279
- const rootRect = rootRef.current?.getBoundingClientRect()
280
- const screen: Point = { x: bounds.left + bounds.width / 2 - (rootRect?.left ?? 0), y: bounds.top - (rootRect?.top ?? 0) }
281
- if (kind === 'image') { setImageMenu(screen); setBackgroundMenu(null) }
282
- else { setBackgroundMenu(screen); setImageMenu(null) }
283
- }, [])
284
- const [libraryOpen, setLibraryOpen] = useState(false)
285
- const [pickerTab, setPickerTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>('upload')
286
- const backgroundFileRef = useRef<HTMLInputElement>(null)
287
- const imageFileRef = useRef<HTMLInputElement>(null)
288
- const [renamingTitle, setRenamingTitle] = useState(false)
289
- const [confirmDeleteProject, setConfirmDeleteProject] = useState(false)
290
- const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'error'>('loading')
291
- const [error, setError] = useState<string | null>(null)
292
- const [historyVersion, setHistoryVersion] = useState(0)
293
-
294
- // Floating generation composer state.
295
- const [composerPrompt, setComposerPrompt] = useState('')
296
- const [composerModel, setComposerModel] = useState(imageModels[0] ?? '')
297
- const [composerSize, setComposerSize] = useState('auto')
298
- const [composerQuality, setComposerQuality] = useState('auto')
299
- const [composerCount, setComposerCount] = useState(1)
300
- const [composerBusy, setComposerBusy] = useState(false)
301
-
302
- const rootRef = useRef<HTMLElement>(null)
303
- const viewportRef = useRef<HTMLDivElement>(null)
304
- const documentRef = useRef<CanvasDocument | null>(null)
305
- const selectedIdsRef = useRef<Set<string>>(selectedIds)
306
- const dragRef = useRef<NodeDragState | null>(null)
307
- const panRef = useRef<PanState | null>(null)
308
- const connectRef = useRef<ConnectState | null>(null)
309
- const resizeRef = useRef<ResizeState | null>(null)
310
- const marqueeRef = useRef<MarqueeState | null>(null)
311
- const panFrameRef = useRef<number | null>(null)
312
- const syncedRef = useRef('')
313
- const processedTasks = useRef(new Set<string>())
314
- const processedImport = useRef('')
315
- const localTaskIds = useRef(new Set<string>())
316
- const mountedAtRef = useRef(Date.now())
317
- const internalClipboard = useRef<{ nodes: CanvasNode[]; connections: Array<{ fromNodeId: string; toNodeId: string }> } | null>(null)
318
- const pastRef = useRef<string[]>([])
319
- const futureRef = useRef<string[]>([])
320
- const composerTargetRef = useRef<string | null>(null)
321
-
322
- documentRef.current = document
323
- selectedIdsRef.current = selectedIds
324
-
325
- // ------------------------------------------------------------ utilities
326
-
327
- const screenToWorld = useCallback((clientX: number, clientY: number): Point => {
328
- const bounds = viewportRef.current?.getBoundingClientRect()
329
- const current = documentRef.current
330
- if (bounds === undefined || current === null) return { x: clientX, y: clientY }
331
- return {
332
- x: (clientX - bounds.left - current.viewport.x) / current.viewport.k,
333
- y: (clientY - bounds.top - current.viewport.y) / current.viewport.k,
334
- }
335
- }, [])
336
-
337
- const canvasCenter = useCallback((): Point => {
338
- const bounds = viewportRef.current?.getBoundingClientRect()
339
- const current = documentRef.current
340
- if (bounds === undefined || current === null) return { x: 0, y: 0 }
341
- return screenToWorld(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
342
- }, [screenToWorld])
343
-
344
- const beginHistory = useCallback((): string | null => {
345
- const current = documentRef.current
346
- if (current === null) return null
347
- const snapshot = JSON.stringify(current)
348
- if (pastRef.current[pastRef.current.length - 1] === snapshot) return snapshot
349
- pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), snapshot]
350
- futureRef.current = []
351
- setHistoryVersion(version => version + 1)
352
- return snapshot
353
- }, [])
354
-
355
- const commitSnapshot = useCallback((snapshot: string | null): void => {
356
- if (snapshot === null) return
357
- const current = documentRef.current
358
- if (current === null || JSON.stringify(current) === snapshot) return
359
- pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), snapshot]
360
- futureRef.current = []
361
- setHistoryVersion(version => version + 1)
362
- }, [])
363
-
364
- const updateDocument = useCallback((updater: (previous: CanvasDocument) => CanvasDocument): void => {
365
- setDocument(previous => previous === null ? previous : updater(previous))
366
- }, [])
367
-
368
- const mutate = useCallback((updater: (previous: CanvasDocument) => CanvasDocument): void => {
369
- beginHistory()
370
- updateDocument(updater)
371
- }, [beginHistory, updateDocument])
372
-
373
- const undo = useCallback((): void => {
374
- const snapshot = pastRef.current[pastRef.current.length - 1]
375
- const current = documentRef.current
376
- if (snapshot === undefined || current === null) return
377
- pastRef.current = pastRef.current.slice(0, -1)
378
- futureRef.current = [...futureRef.current, JSON.stringify(current)]
379
- setDocument(JSON.parse(snapshot) as CanvasDocument)
380
- setHistoryVersion(version => version + 1)
381
- setSelectedIds(new Set()); setSelectedConnectionId(null)
382
- }, [])
383
-
384
- const redo = useCallback((): void => {
385
- const snapshot = futureRef.current[futureRef.current.length - 1]
386
- const current = documentRef.current
387
- if (snapshot === undefined || current === null) return
388
- futureRef.current = futureRef.current.slice(0, -1)
389
- pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), JSON.stringify(current)]
390
- setDocument(JSON.parse(snapshot) as CanvasDocument)
391
- setHistoryVersion(version => version + 1)
392
- setSelectedIds(new Set()); setSelectedConnectionId(null)
393
- }, [])
394
-
395
- const setViewport = useCallback((viewport: CanvasDocument['viewport']): void => {
396
- updateDocument(previous => ({ ...previous, viewport }))
397
- }, [updateDocument])
398
-
399
- // ------------------------------------------------------- node operations
400
-
401
- const placeNewNode = useCallback((node: CanvasNode): void => {
402
- mutate(previous => ({ ...previous, nodes: [...previous.nodes, node] }))
403
- setSelectedIds(new Set([node.id])); setSelectedConnectionId(null)
404
- }, [mutate])
405
-
406
- const createImageNode = useCallback((asset: CanvasAssetRef, position?: Point): CanvasNode => {
407
- const size = sizeForAsset(asset)
408
- const center = position ?? canvasCenter()
409
- return {
410
- id: newId('node'), type: 'image', title: asset.origin === 'gallery' ? tt('canvas.fromGallery') : asset.origin === 'history' ? tt('canvas.fromHistory') : tt('canvas.imageNode'),
411
- x: Math.round(center.x - size.width / 2), y: Math.round(center.y - size.height / 2),
412
- width: size.width, height: size.height,
413
- metadata: { asset, status: 'success' },
414
- }
415
- }, [canvasCenter])
416
-
417
- const createTextNode = useCallback((position?: Point): CanvasNode => {
418
- const center = position ?? canvasCenter()
419
- return {
420
- id: newId('node'), type: 'text', title: tt('canvas.textNode'),
421
- x: Math.round(center.x - TEXT_NODE_SIZE.width / 2), y: Math.round(center.y - TEXT_NODE_SIZE.height / 2),
422
- width: TEXT_NODE_SIZE.width, height: TEXT_NODE_SIZE.height,
423
- metadata: { text: '', fontSize: 14 },
424
- }
425
- }, [canvasCenter])
426
-
427
- const createConfigNode = useCallback((position?: Point): CanvasNode => {
428
- const center = position ?? canvasCenter()
429
- return {
430
- id: newId('node'), type: 'config', title: tt('canvas.configNode'),
431
- x: Math.round(center.x - CONFIG_NODE_SIZE.width / 2), y: Math.round(center.y - CONFIG_NODE_SIZE.height / 2),
432
- width: CONFIG_NODE_SIZE.width, height: CONFIG_NODE_SIZE.height,
433
- metadata: { status: 'idle' },
434
- }
435
- }, [canvasCenter])
436
-
437
- const updateNodes = useCallback((updater: (nodes: CanvasNode[]) => CanvasNode[]): void => {
438
- updateDocument(previous => ({ ...previous, nodes: updater(previous.nodes) }))
439
- }, [updateDocument])
440
-
441
- const patchNode = useCallback((nodeId: string, patch: Partial<NonNullable<CanvasNode['metadata']>> & Partial<Pick<CanvasNode, 'title' | 'width' | 'height' | 'x' | 'y'>>): void => {
442
- updateNodes(nodes => nodes.map(node => node.id === nodeId
443
- ? { ...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 } }
444
- : node))
445
- }, [updateNodes])
446
-
447
- const deleteSelection = useCallback((): void => {
448
- const ids = selectedIdsRef.current
449
- const connectionId = selectedConnectionId
450
- if (ids.size === 0 && connectionId === null) return
451
- mutate(previous => ({
452
- ...previous,
453
- nodes: previous.nodes.filter(node => !ids.has(node.id)),
454
- connections: previous.connections.filter(connection => !ids.has(connection.fromNodeId) && !ids.has(connection.toNodeId) && connection.id !== connectionId),
455
- }))
456
- setSelectedIds(new Set()); setSelectedConnectionId(null)
457
- }, [mutate, selectedConnectionId])
458
-
459
- const duplicateSelection = useCallback((): void => {
460
- const current = documentRef.current
461
- if (current === null || selectedIdsRef.current.size === 0) return
462
- 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) } }))
463
- if (clones.length === 0) return
464
- const idMap = new Map(current.nodes.filter(node => selectedIdsRef.current.has(node.id)).map((node, index) => [node.id, clones[index]!.id]))
465
- const connections = current.connections
466
- .filter(connection => idMap.has(connection.fromNodeId) && idMap.has(connection.toNodeId))
467
- .map(connection => ({ id: newId('edge'), fromNodeId: idMap.get(connection.fromNodeId)!, toNodeId: idMap.get(connection.toNodeId)! }))
468
- mutate(previous => ({ ...previous, nodes: [...previous.nodes, ...clones], connections: [...previous.connections, ...connections] }))
469
- setSelectedIds(new Set(clones.map(node => node.id)))
470
- }, [mutate])
471
-
472
- const copySelection = useCallback((): void => {
473
- const current = documentRef.current
474
- if (current === null || selectedIdsRef.current.size === 0) return
475
- internalClipboard.current = {
476
- nodes: current.nodes.filter(node => selectedIdsRef.current.has(node.id)).map(node => ({ ...node, metadata: { ...nodeMetadata(node) } })),
477
- connections: current.connections.filter(connection => selectedIdsRef.current.has(connection.fromNodeId) && selectedIdsRef.current.has(connection.toNodeId)).map(connection => ({ fromNodeId: connection.fromNodeId, toNodeId: connection.toNodeId })),
478
- }
479
- }, [])
480
-
481
- const pasteClipboard = useCallback((position?: Point): void => {
482
- const clipboard = internalClipboard.current
483
- if (clipboard === null || clipboard.nodes.length === 0) return
484
- const bounds = nodesBounds(clipboard.nodes)
485
- const target = position ?? canvasCenter()
486
- const dx = target.x - (bounds.minX + (bounds.maxX - bounds.minX) / 2)
487
- const dy = target.y - (bounds.minY + (bounds.maxY - bounds.minY) / 2)
488
- const idMap = new Map<string, string>()
489
- const clones = clipboard.nodes.map(node => {
490
- const id = newId('node'); idMap.set(node.id, id)
491
- return { ...node, id, x: Math.round(node.x + dx), y: Math.round(node.y + dy), metadata: { ...nodeMetadata(node) } }
492
- })
493
- const connections = clipboard.connections.map(connection => ({ id: newId('edge'), fromNodeId: idMap.get(connection.fromNodeId)!, toNodeId: idMap.get(connection.toNodeId)! }))
494
- mutate(previous => ({ ...previous, nodes: [...previous.nodes, ...clones], connections: [...previous.connections, ...connections] }))
495
- setSelectedIds(new Set(clones.map(node => node.id)))
496
- }, [canvasCenter, mutate])
497
-
498
- const connectNodes = useCallback((fromNodeId: string, toNodeId: string): void => {
499
- if (fromNodeId === toNodeId) return
500
- const current = documentRef.current
501
- if (current === null) return
502
- if (current.connections.some(connection => connection.fromNodeId === fromNodeId && connection.toNodeId === toNodeId)) return
503
- mutate(previous => ({ ...previous, connections: [...previous.connections, { id: newId('edge'), fromNodeId, toNodeId }] }))
504
- }, [mutate])
505
-
506
- const downloadNode = useCallback((node: CanvasNode): void => {
507
- const asset = assetOf(node)
508
- if (asset === undefined || asset.url === '') return
509
- const link = globalThis.document.createElement('a')
510
- link.href = asset.url
511
- link.download = `${node.title || 'canvas-image'}.${asset.assetId.split('.').pop() ?? 'png'}`
512
- link.target = '_blank'
513
- link.rel = 'noopener'
514
- link.click()
515
- }, [])
516
-
517
- const upstreamNodes = useCallback((canvasDocument: CanvasDocument, nodeId: string): CanvasNode[] => {
518
- const byId = new Map(canvasDocument.nodes.map(node => [node.id, node]))
519
- return canvasDocument.connections
520
- .filter(connection => connection.toNodeId === nodeId)
521
- .map(connection => byId.get(connection.fromNodeId))
522
- .filter((node): node is CanvasNode => node !== undefined)
523
- }, [])
524
-
525
- // ----------------------------------------------------------- generation
526
-
527
- const submitComposer = useCallback(async (target: CanvasNode | null): Promise<void> => {
528
- const current = documentRef.current
529
- if (current === null || composerBusy) return
530
- if (!connected) { setError(tt('canvas.needApi')); onOpenSettings?.(); return }
531
- const inputs = target === null ? [] : upstreamNodes(current, target.id)
532
- const referenceImages = inputs.filter(node => node.type === 'image' && usableAsset(node) !== undefined)
533
- const upstreamText = inputs.filter(node => node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '').map(node => nodeMetadata(node).text!.trim())
534
- const prompt = (composerPrompt.trim() !== '' ? composerPrompt.trim() : upstreamText.join('\n').trim())
535
- if (prompt === '') { setError(tt('canvas.needPrompt')); return }
536
- const model = imageModels.includes(composerModel) ? composerModel : imageModels[0] ?? ''
537
- if (model === '') { setError(tt('canvas.needModel')); return }
538
- const count = Math.min(4, Math.max(1, Math.round(composerCount)))
539
- const baseAsset = referenceImages[0] !== undefined ? usableAsset(referenceImages[0]!) : undefined
540
- setComposerBusy(true)
541
- try {
542
- let image: string | undefined
543
- let images: string[] | undefined
544
- let refName: string | undefined
545
- if (baseAsset !== undefined) {
546
- image = await assetToDataUrl(baseAsset)
547
- refName = 'canvas-reference.png'
548
- const extras: string[] = []
549
- for (const reference of referenceImages.slice(1, 4)) {
550
- const asset = usableAsset(reference)
551
- if (asset === undefined) continue
552
- try { extras.push(await assetToDataUrl(asset)) } catch { /* skip unreadable reference */ }
553
- }
554
- if (extras.length > 0) images = extras
555
- }
556
- const footprint = nodeSizeFromRatio(composerSize, IMAGE_NODE_SIZE)
557
- const request: GenerateRequest = {
558
- mode: image === undefined ? 'text' : 'edit', model, prompt, size: composerSize, quality: composerQuality, n: count, detail: '',
559
- ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
560
- ...(image === undefined ? {} : { image, refName }),
561
- ...(images === undefined ? {} : { images }),
562
- canvas: {
563
- canvasId: current.id,
564
- ...(target === null ? {} : { sourceNodeId: referenceImages[0]?.id ?? target.id, parentNodeId: target.id, placement: 'right' as const }),
565
- },
566
- }
567
- const task = await api.taskSubmit(request)
568
- localTaskIds.current.add(task.id)
569
- mutate(previous => {
570
- const nodes = [...previous.nodes]
571
- const connections = [...previous.connections]
572
- const anchor = target !== null ? previous.nodes.find(node => node.id === target.id) : undefined
573
- const originX = anchor !== undefined ? anchor.x + anchor.width + 80 : Math.round(canvasCenter().x - footprint.width / 2)
574
- const originY = anchor !== undefined ? anchor.y : Math.round(canvasCenter().y - footprint.height / 2)
575
- for (let index = 0; index < count; index += 1) {
576
- const id = newId('node')
577
- nodes.push({
578
- id, type: 'image', title: tt('canvas.imageNode'),
579
- x: Math.round(originX), y: Math.round(originY + index * (footprint.height + 48)),
580
- width: footprint.width, height: footprint.height,
581
- metadata: { status: 'generating', taskId: task.id, ...(anchor !== undefined ? { sourceNodeId: anchor.id } : {}), prompt, model },
582
- })
583
- if (anchor !== undefined) connections.push({ id: newId('edge'), fromNodeId: anchor.id, toNodeId: id })
584
- }
585
- return { ...previous, nodes, connections }
586
- })
587
- setComposerPrompt('')
588
- setError(null)
589
- } catch (caught) {
590
- setError(caught instanceof Error ? caught.message : String(caught))
591
- } finally {
592
- setComposerBusy(false)
593
- }
594
- }, [api, canvasCenter, composerBusy, composerCount, composerModel, composerPrompt, composerQuality, composerSize, connected, defaultChannelId, imageModels, mutate, onOpenSettings, upstreamNodes])
595
-
596
- // ---------------------------------------------------------- task intake
597
-
598
- /** Re-run a failed image node's generation from its recorded prompt/model,
599
- * re-deriving the edit base from the connected source config node. */
600
- const retryGeneration = useCallback(async (node: CanvasNode): Promise<void> => {
601
- const current = documentRef.current
602
- if (current === null || !connected) { setError(tt('canvas.needApi')); onOpenSettings?.(); return }
603
- const metadata = nodeMetadata(node)
604
- const prompt = (metadata.prompt ?? '').trim()
605
- if (prompt === '') { setError(tt('canvas.needPrompt')); return }
606
- const model = imageModels.includes(metadata.model ?? '') ? metadata.model! : imageModels[0] ?? ''
607
- if (model === '') { setError(tt('canvas.needModel')); return }
608
- const sourceId = metadata.sourceNodeId
609
- const references = sourceId === undefined ? [] : upstreamNodes(current, sourceId).filter(item => item.type === 'image' && usableAsset(item) !== undefined)
610
- const baseAsset = references[0] !== undefined ? usableAsset(references[0]!) : undefined
611
- try {
612
- let image: string | undefined
613
- let images: string[] | undefined
614
- let refName: string | undefined
615
- if (baseAsset !== undefined) {
616
- image = await assetToDataUrl(baseAsset)
617
- refName = 'canvas-reference.png'
618
- const extras: string[] = []
619
- for (const reference of references.slice(1, 4)) {
620
- const asset = usableAsset(reference)
621
- if (asset === undefined) continue
622
- try { extras.push(await assetToDataUrl(asset)) } catch { /* skip unreadable reference */ }
623
- }
624
- if (extras.length > 0) images = extras
625
- }
626
- const request: GenerateRequest = {
627
- mode: image === undefined ? 'text' : 'edit', model, prompt, size: metadata.size ?? 'auto', quality: metadata.quality ?? 'auto', n: 1, detail: '',
628
- ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
629
- ...(image === undefined ? {} : { image, refName }),
630
- ...(images === undefined ? {} : { images }),
631
- canvas: { canvasId: current.id, sourceNodeId: references[0]?.id ?? sourceId, parentNodeId: node.id, placement: 'right' as const },
632
- }
633
- const task = await api.taskSubmit(request)
634
- localTaskIds.current.add(task.id)
635
- patchNode(node.id, { status: 'generating', error: undefined, taskId: task.id })
636
- setError(null)
637
- } catch (caught) {
638
- setError(caught instanceof Error ? caught.message : String(caught))
639
- }
640
- }, [api, connected, defaultChannelId, imageModels, onOpenSettings, patchNode, upstreamNodes])
641
-
642
- // Orphan reconciliation: a generating placeholder whose task no longer exists
643
- // in the host feed (e.g. the host restarted) can never complete on its own.
644
- useEffect(() => {
645
- if (document === null) return
646
- const feedFresh = tasks.length > 0 || Date.now() - mountedAtRef.current > 8000
647
- if (!feedFresh) return
648
- const feedIds = new Set(tasks.map(task => task.id))
649
- const orphans = document.nodes.filter(node => {
650
- if (node.type !== 'image' || nodeMetadata(node).status !== 'generating') return false
651
- const taskId = nodeMetadata(node).taskId
652
- return taskId !== undefined && !feedIds.has(taskId) && !localTaskIds.current.has(taskId)
653
- })
654
- if (orphans.length === 0) return
655
- updateNodes(nodes => nodes.map(node => {
656
- const taskId = node.type === 'image' ? nodeMetadata(node).taskId : undefined
657
- if (node.type !== 'image' || nodeMetadata(node).status !== 'generating' || taskId === undefined
658
- || feedIds.has(taskId) || localTaskIds.current.has(taskId)) return node
659
- return { ...node, metadata: { ...nodeMetadata(node), status: 'error', error: tt('canvas.taskLost') } }
660
- }))
661
- }, [document, tasks, updateNodes])
662
-
663
- useEffect(() => {
664
- if (document === null) return
665
- const canvasTasks = tasks.filter(task => task.request.canvas?.canvasId === document.id)
666
- for (const task of canvasTasks) {
667
- if (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') continue
668
- if (processedTasks.current.has(task.id)) continue
669
- const targets = document.nodes.filter(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating')
670
- if (targets.length === 0) continue
671
- processedTasks.current.add(task.id)
672
- const sourceId = nodeMetadata(targets[0]!).sourceNodeId
673
- const fail = (message: string): void => {
674
- updateNodes(nodes => nodes.map(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating'
675
- ? { ...node, metadata: { ...nodeMetadata(node), status: 'error', error: message } }
676
- : node))
677
- }
678
- if (task.status !== 'completed' || task.result === undefined || task.result.images.length === 0) {
679
- fail(task.error ?? tt('canvas.generateFailed'))
680
- continue
681
- }
682
- void (async () => {
683
- const assets: CanvasAssetRef[] = []
684
- for (const image of task.result!.images) {
685
- const dataUrl = imageDataUrl(image)
686
- const dimensions = await readImageSize(dataUrl)
687
- assets.push(await api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'generated', originId: task.id }))
688
- }
689
- updateDocument(previous => {
690
- const ordered = previous.nodes.filter(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating')
691
- if (ordered.length === 0) return previous
692
- const last = ordered[ordered.length - 1]!
693
- const nodes = previous.nodes.map(node => {
694
- const index = ordered.indexOf(node)
695
- if (index < 0) return node
696
- const asset = assets[index]
697
- return asset === undefined
698
- ? { ...node, metadata: { ...nodeMetadata(node), status: 'error' as const, error: tt('canvas.generateFailed') } }
699
- : { ...node, metadata: { ...nodeMetadata(node), asset, status: 'success' as const, error: undefined } }
700
- })
701
- // More results than placeholders: append sibling nodes below the last one.
702
- const siblings: CanvasNode[] = []
703
- const connections: CanvasConnection[] = []
704
- assets.slice(ordered.length).forEach((asset, offset) => {
705
- const id = newId('node')
706
- siblings.push({
707
- id, type: 'image', title: tt('canvas.imageNode'),
708
- x: Math.round(last.x), y: Math.round(last.y + (ordered.length + offset) * (last.height + 48)),
709
- width: last.width, height: last.height,
710
- metadata: { status: 'success', asset, taskId: task.id, ...(sourceId === undefined ? {} : { sourceNodeId: sourceId }) },
711
- })
712
- if (sourceId !== undefined) connections.push({ id: newId('edge'), fromNodeId: sourceId, toNodeId: id })
713
- })
714
- return { ...previous, nodes: [...nodes, ...siblings], connections: [...previous.connections, ...connections] }
715
- })
716
- })().catch(caught => fail(caught instanceof Error ? caught.message : String(caught)))
717
- }
718
- }, [api, document, tasks, updateDocument, updateNodes])
719
-
720
- // ------------------------------------------------------- import intake
721
-
722
- const addAssets = useCallback((assets: CanvasAssetRef[], position?: Point): void => {
723
- if (assets.length === 0) return
724
- const center = position ?? canvasCenter()
725
- mutate(previous => {
726
- const nodes = assets.map((asset, index) => {
727
- const node = createImageNode(asset)
728
- return { ...node, x: node.x + (index % 3) * (IMAGE_NODE_SIZE.width + 40), y: node.y + Math.floor(index / 3) * (IMAGE_NODE_SIZE.height + 40) }
729
- })
730
- return { ...previous, nodes: [...previous.nodes, ...nodes] }
731
- })
732
- setSelectedIds(new Set())
733
- }, [canvasCenter, createImageNode, mutate])
734
-
735
- useEffect(() => {
736
- if (importRequest === undefined) {
737
- processedImport.current = ''
738
- return
739
- }
740
- if (document === null) return
741
- const requestKey = `${importRequest.source}:${importRequest.entryId}:${importRequest.imageIndex}`
742
- if (processedImport.current === requestKey) return
743
- const sourceEntries = importRequest.source === 'history' ? history : gallery
744
- const entry = sourceEntries.find(item => item.id === importRequest.entryId)
745
- const image = entry?.images[importRequest.imageIndex]
746
- if (entry === undefined || image === undefined) {
747
- processedImport.current = requestKey
748
- onImportRequestHandled?.()
749
- return
750
- }
751
- processedImport.current = requestKey
752
- void (async () => {
753
- const dimensions = await readImageSize(image.url)
754
- const asset = await api.canvasImport(importRequest.source, importRequest.entryId, importRequest.imageIndex, dimensions.width, dimensions.height)
755
- addAssets([asset])
756
- onImportRequestHandled?.()
757
- })().catch(caught => {
758
- setError(caught instanceof Error ? caught.message : String(caught))
759
- onImportRequestHandled?.()
760
- })
761
- }, [addAssets, api, document, gallery, history, importRequest, onImportRequestHandled])
762
-
763
- // -------------------------------------------------------------- loading
764
-
765
- useEffect(() => {
766
- let disposed = false
767
- void api.canvasList().then(async list => {
768
- if (disposed) return
769
- const first = list[0] === undefined ? await api.canvasCreate(tt('canvas.untitled')) : await api.canvasRead(list[0].id)
770
- if (disposed) return
771
- setProjects(list[0] === undefined ? [summaryOf(first)] : list)
772
- setDocument(normalizeConfigNodeSizes(first))
773
- syncedRef.current = JSON.stringify(first)
774
- setSaveState('saved')
775
- }).catch(caught => { if (!disposed) { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') } })
776
- return () => { disposed = true }
777
- }, [api])
778
-
779
- useEffect(() => {
780
- if (document === null || saveState === 'loading') return
781
- const key = JSON.stringify(document)
782
- if (key === syncedRef.current) return
783
- setSaveState('saving')
784
- const timer = window.setTimeout(() => {
785
- const saveWithRetry = async (): Promise<CanvasDocument> => {
786
- try {
787
- return await api.canvasSave(document, document.revision)
788
- } catch (caught) {
789
- // Another window saved the same canvas meanwhile: rebase on the
790
- // server revision and retry once so concurrent editing self-heals.
791
- const message = caught instanceof Error ? caught.message : String(caught)
792
- if (!message.includes('其他窗口')) throw caught
793
- const server = await api.canvasRead(document.id)
794
- return await api.canvasSave(document, server.revision)
795
- }
796
- }
797
- void saveWithRetry().then(next => {
798
- syncedRef.current = JSON.stringify(next)
799
- setDocument(next)
800
- setProjects(previous => [summaryOf(next), ...previous.filter(item => item.id !== next.id)])
801
- setSaveState('saved')
802
- }).catch(caught => { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') })
803
- }, 650)
804
- return () => window.clearTimeout(timer)
805
- }, [api, document, saveState])
806
-
807
- // ---------------------------------------------------------- composer sync
808
-
809
- const singleSelectedId = selectedIds.size === 1 ? [...selectedIds][0]! : null
810
- const singleSelected = useMemo(() => document?.nodes.find(node => node.id === singleSelectedId) ?? null, [document, singleSelectedId])
811
- const composerTarget = singleSelected !== null && singleSelected.type === 'config' ? singleSelected : null
812
- const composerInputs = useMemo(
813
- () => composerTarget === null || document === null ? [] : upstreamNodes(document, composerTarget.id),
814
- [composerTarget, document, upstreamNodes],
815
- )
816
- const composerReferenceCount = composerInputs.filter(node => node.type === 'image' && usableAsset(node) !== undefined).length
817
- const composerTextCount = composerInputs.filter(node => node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '').length
818
- const composerVisible = composerTarget !== null
819
-
820
- // Prefill the prompt from connected text nodes whenever the target changes.
821
- useEffect(() => {
822
- const targetId = composerTarget?.id ?? null
823
- if (targetId === composerTargetRef.current) return
824
- composerTargetRef.current = targetId
825
- if (composerTarget === null) return
826
- const texts = (document?.connections ?? [])
827
- .filter(connection => connection.toNodeId === composerTarget.id)
828
- .map(connection => document?.nodes.find(node => node.id === connection.fromNodeId))
829
- .filter((node): node is CanvasNode => node !== undefined && node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '')
830
- .map(node => nodeMetadata(node).text!.trim())
831
- setComposerPrompt(texts.join('\n'))
832
- }, [composerTarget, document])
833
-
834
- // ------------------------------------------------------------ keyboard
835
-
836
- useEffect(() => {
837
- const isEditingTarget = (target: EventTarget | null): boolean => target instanceof Element
838
- && (target.matches('input, textarea, select, [contenteditable="true"]'))
839
-
840
- const onKeyDown = (event: KeyboardEvent): void => {
841
- if (event.key === 'Control') setCtrlPressed(true)
842
- if (event.code === 'Space' && !isEditingTarget(event.target)) {
843
- event.preventDefault()
844
- setSpacePressed(true)
845
- }
846
- if (documentRef.current === null) return
847
- const mod = event.ctrlKey || event.metaKey
848
- if (event.key === 'Escape') {
849
- setContextMenu(null); setCreateMenu(null); setBackgroundMenu(null); setImageMenu(null)
850
- if (!isEditingTarget(event.target)) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
851
- return
852
- }
853
- if (isEditingTarget(event.target)) return
854
- if (mod && event.key.toLowerCase() === 'z') {
855
- event.preventDefault()
856
- if (event.shiftKey) redo(); else undo()
857
- } else if (mod && event.key.toLowerCase() === 'y') {
858
- event.preventDefault(); redo()
859
- } else if (mod && event.key.toLowerCase() === 'c') {
860
- copySelection()
861
- } else if (mod && event.key.toLowerCase() === 'v') {
862
- pasteClipboard()
863
- } else if (mod && event.key.toLowerCase() === 'd') {
864
- event.preventDefault(); duplicateSelection()
865
- } else if (mod && event.key.toLowerCase() === 'a') {
866
- event.preventDefault()
867
- const nodes = documentRef.current?.nodes ?? []
868
- setSelectedIds(new Set(nodes.map(node => node.id)))
869
- } else if (event.key === 'Delete' || event.key === 'Backspace') {
870
- event.preventDefault(); deleteSelection()
871
- }
872
- }
873
- const onKeyUp = (event: KeyboardEvent): void => {
874
- if (event.code === 'Space') setSpacePressed(false)
875
- if (event.key === 'Control') setCtrlPressed(false)
876
- }
877
- const onBlur = (): void => { setSpacePressed(false); setCtrlPressed(false) }
878
- const onPaste = (event: ClipboardEvent): void => {
879
- if (isEditingTarget(event.target)) return
880
- const files = [...(event.clipboardData?.files ?? [])].filter(file => file.type.startsWith('image/'))
881
- if (files.length > 0) {
882
- event.preventDefault()
883
- void Promise.all(files.map(async file => {
884
- 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) })
885
- const dimensions = await readImageSize(dataUrl)
886
- return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
887
- })).then(assets => addAssets(assets, canvasCenter())).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
888
- return
889
- }
890
- pasteClipboard()
891
- }
892
- window.addEventListener('keydown', onKeyDown)
893
- window.addEventListener('keyup', onKeyUp)
894
- window.addEventListener('blur', onBlur)
895
- window.addEventListener('paste', onPaste)
896
- return () => {
897
- window.removeEventListener('keydown', onKeyDown)
898
- window.removeEventListener('keyup', onKeyUp)
899
- window.removeEventListener('blur', onBlur)
900
- window.removeEventListener('paste', onPaste)
901
- }
902
- }, [addAssets, api, canvasCenter, copySelection, deleteSelection, duplicateSelection, pasteClipboard, redo, undo])
903
-
904
- // ------------------------------------------------------ viewport events
905
-
906
- useEffect(() => {
907
- const container = viewportRef.current
908
- if (container === null) return
909
- const measure = (): void => setViewportSize({ width: container.clientWidth, height: container.clientHeight })
910
- measure()
911
- const observer = new ResizeObserver(measure)
912
- observer.observe(container)
913
- const preventWheel = (event: WheelEvent): void => {
914
- if (event.target instanceof Element && event.target.closest(`[data-canvas-no-zoom]`)) return
915
- event.preventDefault()
916
- }
917
- container.addEventListener('wheel', preventWheel, { passive: false })
918
- return () => { observer.disconnect(); container.removeEventListener('wheel', preventWheel) }
919
- }, [])
920
-
921
- const temporaryPanTool = spacePressed || ctrlPressed
922
-
923
- const onViewportPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
924
- const target = event.target instanceof Element ? event.target : null
925
- setContextMenu(null); setCreateMenu(null)
926
- if (!target?.closest('[data-canvas-no-zoom]')) { setBackgroundMenu(null); setImageMenu(null) }
927
- const isBackground = target?.closest('[data-node-id],[data-connection-hit]') === null
928
- const shouldPan = event.button === 1 || (event.button === 0 && (tool === 'pan' || temporaryPanTool) && isBackground)
929
- if (shouldPan) {
930
- event.preventDefault()
931
- event.currentTarget.setPointerCapture(event.pointerId)
932
- const current = documentRef.current
933
- if (current !== null) {
934
- panRef.current = { startX: event.clientX, startY: event.clientY, viewportX: current.viewport.x, viewportY: current.viewport.y, hasMoved: false, startedOnBackground: isBackground }
935
- }
936
- return
937
- }
938
- if (event.button === 0 && isBackground && tool === 'select') {
939
- event.preventDefault()
940
- event.currentTarget.setPointerCapture(event.pointerId)
941
- const world = screenToWorld(event.clientX, event.clientY)
942
- const next: MarqueeState = { start: world, current: world, additive: event.shiftKey, initialIds: event.shiftKey ? [...selectedIdsRef.current] : [] }
943
- marqueeRef.current = next
944
- setMarquee(next)
945
- if (!event.shiftKey) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
946
- }
947
- }
948
-
949
- const onWheel = (event: React.WheelEvent<HTMLDivElement>): void => {
950
- const current = documentRef.current
951
- if (current === null) return
952
- if (event.target instanceof Element && event.target.closest('[data-canvas-no-zoom]')) return
953
- event.preventDefault()
954
- const bounds = viewportRef.current?.getBoundingClientRect()
955
- if (bounds === undefined) return
956
- const mouseX = event.clientX - bounds.left
957
- const mouseY = event.clientY - bounds.top
958
- const scale = clampScale(current.viewport.k * Math.pow(1.1, -event.deltaY / 100))
959
- const worldX = (mouseX - current.viewport.x) / current.viewport.k
960
- const worldY = (mouseY - current.viewport.y) / current.viewport.k
961
- setViewport({ x: mouseX - worldX * scale, y: mouseY - worldY * scale, k: scale })
962
- }
963
-
964
- const setZoomAtCenter = useCallback((scale: number): void => {
965
- const current = documentRef.current
966
- const bounds = viewportRef.current?.getBoundingClientRect()
967
- if (current === null || bounds === undefined) return
968
- const next = clampScale(scale)
969
- const centerX = bounds.width / 2
970
- const centerY = bounds.height / 2
971
- const worldX = (centerX - current.viewport.x) / current.viewport.k
972
- const worldY = (centerY - current.viewport.y) / current.viewport.k
973
- setViewport({ x: centerX - worldX * next, y: centerY - worldY * next, k: next })
974
- }, [setViewport])
975
-
976
- const fitView = useCallback((): void => {
977
- const current = documentRef.current
978
- const bounds = viewportRef.current?.getBoundingClientRect()
979
- if (current === null || bounds === undefined) return
980
- if (current.nodes.length === 0) {
981
- setViewport({ x: 0, y: 0, k: 1 })
982
- return
983
- }
984
- const content = nodesBounds(current.nodes)
985
- const padding = 80
986
- const contentWidth = Math.max(1, content.maxX - content.minX)
987
- const contentHeight = Math.max(1, content.maxY - content.minY)
988
- const scale = clampScale(Math.min((bounds.width - padding * 2) / contentWidth, (bounds.height - padding * 2) / contentHeight))
989
- setViewport({
990
- k: scale,
991
- x: (bounds.width - contentWidth * scale) / 2 - content.minX * scale,
992
- y: (bounds.height - contentHeight * scale) / 2 - content.minY * scale,
993
- })
994
- }, [setViewport])
995
-
996
- // -------------------------------------------------- global move / up
997
-
998
- useEffect(() => {
999
- const move = (event: PointerEvent): void => {
1000
- const drag = dragRef.current
1001
- if (drag !== null) {
1002
- const scale = documentRef.current?.viewport.k ?? 1
1003
- const dx = (event.clientX - drag.startX) / scale
1004
- const dy = (event.clientY - drag.startY) / scale
1005
- if (!drag.moved && Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 3) {
1006
- drag.moved = true
1007
- commitSnapshot(drag.snapshot)
1008
- }
1009
- if (drag.moved) {
1010
- updateNodes(nodes => nodes.map(node => {
1011
- const origin = drag.origins.get(node.id)
1012
- return origin === undefined ? node : { ...node, x: Math.round(origin.x + dx), y: Math.round(origin.y + dy) }
1013
- }))
1014
- }
1015
- return
1016
- }
1017
- const connect = connectRef.current
1018
- if (connect !== null) {
1019
- const world = screenToWorld(event.clientX, event.clientY)
1020
- const nodes = documentRef.current?.nodes ?? []
1021
- let targetId: string | null = null
1022
- for (let index = nodes.length - 1; index >= 0; index -= 1) {
1023
- const node = nodes[index]!
1024
- if (node.id === connect.nodeId) continue
1025
- if (world.x >= node.x && world.x <= node.x + node.width && world.y >= node.y && world.y <= node.y + node.height) {
1026
- targetId = node.id
1027
- break
1028
- }
1029
- }
1030
- const next = { ...connect, mouse: world, targetId }
1031
- connectRef.current = next
1032
- setConnecting(next)
1033
- return
1034
- }
1035
- const resize = resizeRef.current
1036
- if (resize !== null) {
1037
- const scale = documentRef.current?.viewport.k ?? 1
1038
- const dx = (event.clientX - resize.startX) / scale
1039
- const dy = (event.clientY - resize.startY) / scale
1040
- const minWidth = 140
1041
- const minHeight = 100
1042
- let width = Math.max(minWidth, resize.width + (resize.corner === 'bottom-right' ? dx : -dx))
1043
- let height = Math.max(minHeight, resize.height + dy)
1044
- if (resize.ratio !== null) height = Math.max(minHeight, Math.round(width * resize.ratio))
1045
- updateNodes(nodes => nodes.map(node => node.id === resize.nodeId
1046
- ? { ...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) }
1047
- : node))
1048
- return
1049
- }
1050
- const activeMarquee = marqueeRef.current
1051
- if (activeMarquee !== null) {
1052
- const next = { ...activeMarquee, current: screenToWorld(event.clientX, event.clientY) }
1053
- marqueeRef.current = next
1054
- setMarquee(next)
1055
- return
1056
- }
1057
- const pan = panRef.current
1058
- if (pan !== null) {
1059
- const dx = event.clientX - pan.startX
1060
- const dy = event.clientY - pan.startY
1061
- if (Math.abs(dx) > 3 || Math.abs(dy) > 3) pan.hasMoved = true
1062
- const next = { x: pan.viewportX + dx, y: pan.viewportY + dy }
1063
- if (panFrameRef.current !== null) return
1064
- panFrameRef.current = requestAnimationFrame(() => {
1065
- panFrameRef.current = null
1066
- updateDocument(previous => ({ ...previous, viewport: { ...previous.viewport, x: next.x, y: next.y } }))
1067
- })
1068
- }
1069
- }
1070
-
1071
- const up = (): void => {
1072
- const drag = dragRef.current
1073
- if (drag !== null) {
1074
- dragRef.current = null
1075
- return
1076
- }
1077
- const connect = connectRef.current
1078
- if (connect !== null) {
1079
- connectRef.current = null
1080
- setConnecting(null)
1081
- if (connect.targetId !== null) {
1082
- if (connect.handleType === 'source') connectNodes(connect.nodeId, connect.targetId)
1083
- else connectNodes(connect.targetId, connect.nodeId)
1084
- }
1085
- return
1086
- }
1087
- const resize = resizeRef.current
1088
- if (resize !== null) {
1089
- resizeRef.current = null
1090
- return
1091
- }
1092
- const activeMarquee = marqueeRef.current
1093
- if (activeMarquee !== null) {
1094
- marqueeRef.current = null
1095
- setMarquee(null)
1096
- const minX = Math.min(activeMarquee.start.x, activeMarquee.current.x)
1097
- const minY = Math.min(activeMarquee.start.y, activeMarquee.current.y)
1098
- const maxX = Math.max(activeMarquee.start.x, activeMarquee.current.x)
1099
- const maxY = Math.max(activeMarquee.start.y, activeMarquee.current.y)
1100
- const nodes = documentRef.current?.nodes ?? []
1101
- 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)
1102
- if (Math.abs(activeMarquee.current.x - activeMarquee.start.x) < 4 && Math.abs(activeMarquee.current.y - activeMarquee.start.y) < 4) {
1103
- setSelectedConnectionId(null)
1104
- return
1105
- }
1106
- const next = activeMarquee.additive
1107
- ? new Set([...activeMarquee.initialIds, ...hits])
1108
- : new Set(hits)
1109
- setSelectedIds(next)
1110
- setSelectedConnectionId(null)
1111
- return
1112
- }
1113
- const pan = panRef.current
1114
- if (pan !== null) {
1115
- panRef.current = null
1116
- if (!pan.hasMoved && pan.startedOnBackground) {
1117
- setSelectedIds(new Set()); setSelectedConnectionId(null)
1118
- }
1119
- }
1120
- }
1121
-
1122
- window.addEventListener('pointermove', move)
1123
- window.addEventListener('pointerup', up)
1124
- window.addEventListener('pointercancel', up)
1125
- return () => {
1126
- window.removeEventListener('pointermove', move)
1127
- window.removeEventListener('pointerup', up)
1128
- window.removeEventListener('pointercancel', up)
1129
- }
1130
- }, [commitSnapshot, connectNodes, screenToWorld, updateDocument, updateNodes])
1131
-
1132
- // --------------------------------------------------------- node events
1133
-
1134
- const handleNodePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, nodeId: string): void => {
1135
- if (event.button !== 0 || tool === 'pan' || temporaryPanTool) return
1136
- const current = documentRef.current
1137
- if (current === null) return
1138
- const node = current.nodes.find(item => item.id === nodeId)
1139
- if (node === undefined) return
1140
- event.stopPropagation()
1141
- const additive = event.shiftKey || event.ctrlKey || event.metaKey
1142
- let nextSelection = selectedIdsRef.current
1143
- if (additive) {
1144
- nextSelection = new Set(selectedIdsRef.current)
1145
- if (nextSelection.has(nodeId)) nextSelection.delete(nodeId)
1146
- else nextSelection.add(nodeId)
1147
- } else if (!nextSelection.has(nodeId)) {
1148
- nextSelection = new Set([nodeId])
1149
- }
1150
- setSelectedIds(nextSelection)
1151
- setSelectedConnectionId(null)
1152
- const origins = new Map<string, Point>()
1153
- for (const id of nextSelection) {
1154
- const item = current.nodes.find(candidate => candidate.id === id)
1155
- if (item !== undefined) origins.set(id, { x: item.x, y: item.y })
1156
- }
1157
- dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false, snapshot: JSON.stringify(current), origins }
1158
- }, [temporaryPanTool, tool])
1159
-
1160
- const handleConnectStart = useCallback((event: ReactPointerEvent<HTMLDivElement>, nodeId: string, handleType: 'source' | 'target'): void => {
1161
- if (event.button !== 0) return
1162
- event.stopPropagation(); event.preventDefault()
1163
- const world = screenToWorld(event.clientX, event.clientY)
1164
- const next: ConnectState = { nodeId, handleType, mouse: world, targetId: null }
1165
- connectRef.current = next
1166
- setConnecting(next)
1167
- setSelectedConnectionId(null)
1168
- }, [screenToWorld])
1169
-
1170
- const handleResizeStart = useCallback((event: ReactPointerEvent<HTMLDivElement>, node: CanvasNode, corner: 'bottom-right' | 'bottom-left'): void => {
1171
- if (event.button !== 0) return
1172
- event.stopPropagation(); event.preventDefault()
1173
- const asset = assetOf(node)
1174
- const ratio = node.type === 'image' && asset !== undefined && asset.width > 0 && asset.height > 0 ? asset.width / asset.height : null
1175
- 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 }
1176
- beginHistory()
1177
- }, [beginHistory])
1178
-
1179
- const handleConnectionSelect = useCallback((connectionId: string): void => {
1180
- setSelectedConnectionId(connectionId)
1181
- setSelectedIds(new Set())
1182
- }, [])
1183
-
1184
- // -------------------------------------------------------- file dropping
1185
-
1186
- const onDrop = useCallback((event: React.DragEvent<HTMLDivElement>): void => {
1187
- event.preventDefault()
1188
- const files = [...(event.dataTransfer.files ?? [])].filter(file => file.type.startsWith('image/'))
1189
- if (files.length === 0) return
1190
- const world = screenToWorld(event.clientX, event.clientY)
1191
- void Promise.all(files.map(async file => {
1192
- 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) })
1193
- const dimensions = await readImageSize(dataUrl)
1194
- return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
1195
- })).then(assets => addAssets(assets, world)).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
1196
- }, [addAssets, api, screenToWorld])
1197
-
1198
- // ------------------------------------------------------------ projects
1199
-
1200
- const newCanvas = useCallback(async (): Promise<void> => {
1201
- try {
1202
- const next = await api.canvasCreate(tt('canvas.untitled'))
1203
- setProjects(previous => [summaryOf(next), ...previous])
1204
- setDocument(next); setSelectedIds(new Set()); setSelectedConnectionId(null)
1205
- syncedRef.current = JSON.stringify(next); setSaveState('saved')
1206
- pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1207
- } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1208
- }, [api])
1209
-
1210
- const selectProject = useCallback(async (id: string): Promise<void> => {
1211
- try {
1212
- const next = await api.canvasRead(id)
1213
- setDocument(normalizeConfigNodeSizes(next)); setSelectedIds(new Set()); setSelectedConnectionId(null)
1214
- syncedRef.current = JSON.stringify(next); setSaveState('saved')
1215
- pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1216
- } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1217
- }, [api])
1218
-
1219
- const removeCurrentProject = useCallback(async (): Promise<void> => {
1220
- const current = documentRef.current
1221
- if (current === null) return
1222
- try {
1223
- const remaining = await api.canvasRemove(current.id)
1224
- setConfirmDeleteProject(false)
1225
- const nextId = remaining[0]?.id
1226
- if (nextId === undefined) {
1227
- const created = await api.canvasCreate(tt('canvas.untitled'))
1228
- setProjects([summaryOf(created)]); setDocument(created)
1229
- syncedRef.current = JSON.stringify(created); setSaveState('saved')
1230
- } else {
1231
- setProjects(remaining)
1232
- await selectProject(nextId)
1233
- }
1234
- setSelectedIds(new Set()); setSelectedConnectionId(null)
1235
- } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1236
- }, [api, selectProject])
1237
-
1238
- // -------------------------------------------------------------- derived
1239
-
1240
- const nodeById = useMemo(() => new Map((document?.nodes ?? []).map(node => [node.id, node])), [document])
1241
- const relatedIds = useMemo(() => {
1242
- const related = new Set<string>()
1243
- if (document === null) return related
1244
- for (const connection of document.connections) {
1245
- if (selectedIds.has(connection.fromNodeId)) related.add(connection.toNodeId)
1246
- if (selectedIds.has(connection.toNodeId)) related.add(connection.fromNodeId)
1247
- }
1248
- return related
1249
- }, [document, selectedIds])
1250
-
1251
- const isSpaceOrCtrl = temporaryPanTool
1252
- const cursorClass = tool === 'pan' || isSpaceOrCtrl ? css.panCursor : css.selectCursor
1253
-
1254
- const backgroundMode = document?.background ?? 'dots'
1255
- const setBackgroundMode = useCallback((mode: BackgroundMode): void => {
1256
- mutate(previous => ({
1257
- ...previous,
1258
- background: mode,
1259
- ...(mode === 'image' ? {} : { backgroundImage: undefined }),
1260
- }))
1261
- setBackgroundMenu(null)
1262
- }, [mutate])
1263
-
1264
- const uploadBackgroundImage = useCallback(async (file: File): Promise<void> => {
1265
- try {
1266
- 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) })
1267
- const dimensions = await readImageSize(dataUrl)
1268
- const asset = await api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: 'canvas-background' })
1269
- mutate(previous => ({ ...previous, background: 'image', backgroundImage: asset.url }))
1270
- setBackgroundMenu(null)
1271
- setError(null)
1272
- } catch (caught) {
1273
- setError(caught instanceof Error ? caught.message : String(caught))
1274
- }
1275
- }, [api, mutate])
1276
-
1277
- const removeBackgroundImage = useCallback((): void => {
1278
- mutate(previous => ({ ...previous, background: 'dots', backgroundImage: undefined }))
1279
- setBackgroundMenu(null)
1280
- }, [mutate])
1281
-
1282
- const applyTemplate = useCallback((prompt: string): void => {
1283
- const center = canvasCenter()
1284
- const config = createConfigNode(center)
1285
- const text = createTextNode()
1286
- const placed: CanvasNode = {
1287
- ...text,
1288
- x: Math.round(config.x - TEXT_NODE_SIZE.width - 80),
1289
- y: Math.round(config.y + (config.height - TEXT_NODE_SIZE.height) / 2),
1290
- metadata: { text: prompt, fontSize: 14 },
1291
- }
1292
- mutate(previous => ({
1293
- ...previous,
1294
- nodes: [...previous.nodes, placed, config],
1295
- connections: [...previous.connections, { id: newId('edge'), fromNodeId: placed.id, toNodeId: config.id }],
1296
- }))
1297
- setSelectedIds(new Set([config.id])); setSelectedConnectionId(null)
1298
- setLibraryOpen(false)
1299
- }, [canvasCenter, createConfigNode, createTextNode, mutate])
1300
-
1301
- const gridSize = GRID_SIZE * (document?.viewport.k ?? 1)
1302
- const gridOffsetX = (document?.viewport.x ?? 0) % gridSize
1303
- const gridOffsetY = (document?.viewport.y ?? 0) % gridSize
1304
-
1305
- // ------------------------------------------------------------- render
1306
-
1307
- const renderNode = (node: CanvasNode): React.JSX.Element => {
1308
- const metadata = nodeMetadata(node)
1309
- const isSelected = selectedIds.has(node.id)
1310
- const isRelated = relatedIds.has(node.id)
1311
- const asset = assetOf(node)
1312
- const isGenerating = node.type === 'image' && metadata.status === 'generating'
1313
- const isError = node.type === 'image' && metadata.status === 'error'
1314
- const isConnectTarget = connecting?.targetId === node.id
1315
- const hasImage = asset !== undefined && asset.url !== ''
1316
- const isConfig = node.type === 'config'
1317
- const isTextual = node.type === 'text' || isConfig
1318
- return <div
1319
- key={node.id}
1320
- data-node-id={node.id}
1321
- className={`${css.node} ${isConfig ? css.configNode : isTextual ? css.textNode : css.imageNode} ${isSelected ? css.nodeSelected : ''} ${isRelated ? css.nodeRelated : ''} ${isConnectTarget ? css.nodeConnectTarget : ''}`}
1322
- style={{ left: node.x, top: node.y, width: node.width, height: node.height }}
1323
- onPointerDown={event => handleNodePointerDown(event, node.id)}
1324
- onContextMenu={event => {
1325
- if ((event.target as Element).closest('textarea, input, select')) return
1326
- event.preventDefault(); event.stopPropagation()
1327
- if (!selectedIds.has(node.id)) setSelectedIds(new Set([node.id]))
1328
- setContextMenu({ type: 'node', screen: { x: event.clientX, y: event.clientY }, nodeId: node.id })
1329
- }}
1330
- >
1331
- <div className={css.nodeGlow} aria-hidden="true" />
1332
- {isTextual ? <header className={css.nodeHeader}>
1333
- <span className={css.nodeTitle}>{node.title}</span>
1334
- </header> : null}
1335
- {isConfig ? <div className={css.configLinks} data-config-links={node.id}>
1336
- <span className={css.composerChip}>{tt('canvas.composerLinked', { count: (document?.connections ?? []).filter(connection => connection.toNodeId === node.id).length })}</span>
1337
- </div> : null}
1338
- {isConfig
1339
- ? <p className={css.configHint}>{tt('canvas.configHint')}</p>
1340
- : isTextual
1341
- ? <textarea
1342
- className={css.textArea}
1343
- value={metadata.text ?? ''}
1344
- placeholder={tt('canvas.textPlaceholder')}
1345
- onPointerDown={event => event.stopPropagation()}
1346
- onChange={event => patchNode(node.id, { text: event.target.value })}
1347
- />
1348
- : <div className={css.nodeBody}>
1349
- {hasImage ? <div aria-hidden="true">
1350
- {metadata.model !== undefined && metadata.model !== ''
1351
- ? <span className={`${css.imageInfo} ${css.imageInfoLeft}`}>{metadata.model}</span>
1352
- : asset.origin === 'gallery' || asset.origin === 'history'
1353
- ? <span className={`${css.imageInfo} ${css.imageInfoLeft}`}>{asset.origin === 'gallery' ? tt('canvas.fromGallery') : tt('canvas.fromHistory')}</span>
1354
- : null}
1355
- {asset !== undefined && asset.width > 1 ? <span className={`${css.imageInfo} ${css.imageInfoRight}`}>{asset.width}×{asset.height}</span> : null}
1356
- </div> : null}
1357
- {isGenerating
1358
- ? <div className={css.nodeState}><span className={css.spinner} aria-hidden="true" /><span>{tt('canvas.generatingNode')}</span></div>
1359
- : isError
1360
- ? <div className={css.nodeStateError}>{metadata.error ?? tt('canvas.generateFailed')}<button type="button" onClick={() => { void retryGeneration(node) }}>{tt('canvas.retry')}</button></div>
1361
- : hasImage
1362
- ? <img src={asset.url} alt={node.title} draggable={false} onDragStart={event => event.preventDefault()} />
1363
- : <button type="button" className={css.nodeEmpty} onClick={() => imageFileRef.current?.click()}><ToolbarIcon name="image" /><span>{tt('canvas.emptyImageNode')}</span></button>}
1364
- </div>}
1365
- {isSelected
1366
- ? <div className={css.resizeHandle} onPointerDown={event => handleResizeStart(event, node, 'bottom-right')} title={tt('canvas.resizeHint')} />
1367
- : null}
1368
- <div className={`${css.handle} ${css.handleLeft}`} title={tt('canvas.connectHint')} onPointerDown={event => handleConnectStart(event, node.id, 'target')} />
1369
- <div className={`${css.handle} ${css.handleRight}`} title={tt('canvas.connectHint')} onPointerDown={event => handleConnectStart(event, node.id, 'source')} />
1370
- <div className={css.hoverToolbar} onPointerDown={event => event.stopPropagation()}>
1371
- {node.type === 'image' && hasImage ? <IconButton name="download" label={tt('canvas.download')} onClick={() => downloadNode(node)} /> : null}
1372
- <IconButton name="duplicate" label={tt('canvas.duplicate')} onClick={duplicateSelection} />
1373
- <IconButton name="trash" label={tt('canvas.delete')} onClick={deleteSelection} />
1374
- </div>
1375
- </div>
1376
- }
1377
-
1378
- const renderConnections = (): React.JSX.Element => (
1379
- <svg
1380
- className={css.connectionLayer}
1381
- width={WORLD_PAD * 2}
1382
- height={WORLD_PAD * 2}
1383
- style={{ left: -WORLD_PAD, top: -WORLD_PAD }}
1384
- aria-hidden="true"
1385
- >
1386
- <g transform={`translate(${WORLD_PAD},${WORLD_PAD})`}>
1387
- {(document?.connections ?? []).map(connection => {
1388
- const from = nodeById.get(connection.fromNodeId)
1389
- const to = nodeById.get(connection.toNodeId)
1390
- if (from === undefined || to === undefined) return null
1391
- const path = bezierPath(nodeAnchor(from, 'right'), nodeAnchor(to, 'left'))
1392
- const active = connection.id === selectedConnectionId
1393
- return <g key={connection.id}>
1394
- <path
1395
- data-connection-hit={connection.id}
1396
- d={path}
1397
- stroke="transparent"
1398
- strokeWidth={16}
1399
- fill="none"
1400
- style={{ cursor: 'pointer', pointerEvents: 'stroke' }}
1401
- onPointerDown={event => { event.stopPropagation(); handleConnectionSelect(connection.id) }}
1402
- onContextMenu={event => {
1403
- event.preventDefault(); event.stopPropagation()
1404
- handleConnectionSelect(connection.id)
1405
- setContextMenu({ type: 'connection', screen: { x: event.clientX, y: event.clientY }, connectionId: connection.id })
1406
- }}
1407
- />
1408
- <path d={path} className={`${css.connectionPath} ${active ? css.connectionActive : ''}`} />
1409
- </g>
1410
- })}
1411
- {connecting !== null ? (() => {
1412
- const node = nodeById.get(connecting.nodeId)
1413
- if (node === undefined) return null
1414
- const mouse = connecting.targetId !== undefined && connecting.targetId !== null && nodeById.has(connecting.targetId)
1415
- ? nodeAnchor(nodeById.get(connecting.targetId)!, connecting.handleType === 'source' ? 'left' : 'right')
1416
- : connecting.mouse
1417
- const path = connecting.handleType === 'source'
1418
- ? bezierPath(nodeAnchor(node, 'right'), mouse)
1419
- : bezierPath(mouse, nodeAnchor(node, 'left'))
1420
- return <path d={path} className={css.connectionPreview} />
1421
- })() : null}
1422
- </g>
1423
- </svg>
1424
- )
1425
-
1426
- const renderComposer = (): ReactNode => {
1427
- if (!composerVisible || document === null || composerTarget === null) return null
1428
- const linkedCount = composerReferenceCount + composerTextCount
1429
- const k = document.viewport.k
1430
- const topOffset = viewportRef.current?.offsetTop ?? 0
1431
- const centerX = topOffset * 0 + document.viewport.x + (composerTarget.x + composerTarget.width / 2) * k
1432
- const clampedX = Math.min(Math.max(centerX, 292), Math.max(292, viewportSize.width - 292))
1433
- const belowY = topOffset + document.viewport.y + (composerTarget.y + composerTarget.height) * k + 14
1434
- const top = belowY > viewportSize.height + topOffset - 170
1435
- ? Math.max(64, topOffset + document.viewport.y + composerTarget.y * k - 158)
1436
- : belowY
1437
- return <div className={css.composer} data-canvas-no-zoom="" style={{ left: clampedX - 280, top }}>
1438
- <textarea
1439
- className={css.composerPrompt}
1440
- value={composerPrompt}
1441
- placeholder={tt('canvas.composerPlaceholder')}
1442
- rows={1}
1443
- onPointerDown={event => event.stopPropagation()}
1444
- onChange={event => setComposerPrompt(event.target.value)}
1445
- onKeyDown={event => {
1446
- if (event.key === 'Enter' && !event.shiftKey) {
1447
- event.preventDefault()
1448
- void submitComposer(composerTarget)
1449
- }
1450
- }}
1451
- />
1452
- {linkedCount > 0 ? <div className={css.composerMeta}>
1453
- <span className={css.composerChip}>{tt('canvas.composerLinked', { count: linkedCount })}</span>
1454
- </div> : null}
1455
- <div className={css.composerControls}>
1456
- <select value={composerModel} onChange={(event: ChangeEvent<HTMLSelectElement>) => setComposerModel(event.target.value)} aria-label={tt('canvas.model')}>
1457
- <option value="">{tt('canvas.modelPlaceholder')}</option>
1458
- {imageModels.map(item => <option key={item} value={item}>{item}</option>)}
1459
- </select>
1460
- <select value={composerSize} onChange={event => setComposerSize(event.target.value)} aria-label={tt('canvas.size')}>
1461
- <option value="auto">{tt('canvas.sizeAuto')}</option>
1462
- <option value="1:1">1:1</option>
1463
- <option value="3:4">3:4</option>
1464
- <option value="16:9">16:9</option>
1465
- <option value="9:16">9:16</option>
1466
- </select>
1467
- <select value={composerQuality} onChange={event => setComposerQuality(event.target.value)} aria-label={tt('canvas.quality')}>
1468
- <option value="auto">{tt('canvas.qualityAuto')}</option>
1469
- <option value="1k">1K</option>
1470
- <option value="2k">2K</option>
1471
- <option value="4k">4K</option>
1472
- </select>
1473
- <select value={composerCount} onChange={event => setComposerCount(Number(event.target.value))} aria-label={tt('canvas.count')}>
1474
- {[1, 2, 3, 4].map(item => <option key={item} value={item}>{tt('canvas.countUnit', { count: item })}</option>)}
1475
- </select>
1476
- <button
1477
- type="button"
1478
- className={css.composerSend}
1479
- aria-label={tt('canvas.generate')}
1480
- title={tt('canvas.generate')}
1481
- disabled={!connected || composerBusy || (composerPrompt.trim() === '' && composerTextCount === 0)}
1482
- onClick={() => { void submitComposer(composerTarget) }}
1483
- >{composerBusy ? <span className={css.spinner} aria-hidden="true" /> : <ToolbarIcon name="send" />}</button>
1484
- </div>
1485
- </div>
1486
- }
1487
-
1488
- const renderMinimap = (): React.JSX.Element | null => {
1489
- if (document === null || viewportSize.width === 0) return null
1490
- const width = 220
1491
- const height = 150
1492
- const nodes = document.nodes
1493
- let worldBounds = { x: -600, y: -600, w: 1200, h: 1200 }
1494
- let scale = Math.min(width / worldBounds.w, height / worldBounds.h)
1495
- let offset = { x: (width - worldBounds.w * scale) / 2, y: (height - worldBounds.h * scale) / 2 }
1496
- if (nodes.length > 0) {
1497
- const content = nodesBounds(nodes)
1498
- worldBounds = { x: content.minX - 500, y: content.minY - 500, w: content.maxX - content.minX + 1000, h: content.maxY - content.minY + 1000 }
1499
- scale = Math.min(width / worldBounds.w, height / worldBounds.h)
1500
- offset = { x: (width - worldBounds.w * scale) / 2, y: (height - worldBounds.h * scale) / 2 }
1501
- }
1502
- const toMap = (worldX: number, worldY: number): Point => ({ x: (worldX - worldBounds.x) * scale + offset.x, y: (worldY - worldBounds.y) * scale + offset.y })
1503
- const toWorld = (mapX: number, mapY: number): Point => ({ x: (mapX - offset.x) / scale + worldBounds.x, y: (mapY - offset.y) / scale + worldBounds.y })
1504
- const viewportRect = (() => {
1505
- const vx = -document.viewport.x / document.viewport.k
1506
- const vy = -document.viewport.y / document.viewport.k
1507
- const p1 = toMap(vx, vy)
1508
- const p2 = toMap(vx + viewportSize.width / document.viewport.k, vy + viewportSize.height / document.viewport.k)
1509
- return { x: p1.x, y: p1.y, w: Math.max(p2.x - p1.x, 4), h: Math.max(p2.y - p1.y, 4) }
1510
- })()
1511
- const jump = (event: ReactPointerEvent<HTMLDivElement>): void => {
1512
- const bounds = event.currentTarget.getBoundingClientRect()
1513
- const world = toWorld(event.clientX - bounds.left, event.clientY - bounds.top)
1514
- setViewport({ k: document.viewport.k, x: viewportSize.width / 2 - world.x * document.viewport.k, y: viewportSize.height / 2 - world.y * document.viewport.k })
1515
- }
1516
- return <aside className={css.minimap} data-canvas-no-zoom="" aria-label={tt('canvas.minimap')}>
1517
- <div className={css.minimapCanvas} onPointerDown={event => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); jump(event) }}
1518
- onPointerMove={event => { if (event.buttons === 1) jump(event) }}>
1519
- {nodes.map(node => {
1520
- const position = toMap(node.x, node.y)
1521
- return <div key={node.id} className={`${css.minimapNode} ${node.type === 'image' ? css.minimapImage : css.minimapText} ${selectedIds.has(node.id) ? css.minimapSelected : ''}`}
1522
- style={{ left: position.x, top: position.y, width: Math.max(node.width * scale, 2), height: Math.max(node.height * scale, 2) }} />
1523
- })}
1524
- <div className={css.minimapViewport} style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h }} />
1525
- </div>
1526
- </aside>
1527
- }
1528
-
1529
- const renderContextMenu = (): ReactNode => {
1530
- if (contextMenu !== null) {
1531
- const close = (): void => setContextMenu(null)
1532
- const items: Array<{ label: string; action: () => void; danger?: boolean; icon: ToolbarIconName }> = []
1533
- if (contextMenu.type === 'node') {
1534
- const node = nodeById.get(contextMenu.nodeId)
1535
- if (node !== undefined && node.type === 'image' && (assetOf(node)?.url.length ?? 0) > 0) items.push({ label: tt('canvas.download'), icon: 'download', action: () => downloadNode(node) })
1536
- items.push({ label: tt('canvas.duplicate'), icon: 'duplicate', action: duplicateSelection })
1537
- items.push({ label: tt('canvas.delete'), icon: 'trash', action: deleteSelection, danger: true })
1538
- } else if (contextMenu.type === 'connection') {
1539
- items.push({
1540
- label: tt('canvas.deleteConnection'), icon: 'close', danger: true,
1541
- action: () => {
1542
- mutate(previous => ({ ...previous, connections: previous.connections.filter(connection => connection.id !== contextMenu.connectionId) }))
1543
- setSelectedConnectionId(null)
1544
- },
1545
- })
1546
- } else {
1547
- items.push({ label: tt('canvas.addImage'), icon: 'image', action: () => setPickerOpen(true) })
1548
- items.push({ label: tt('canvas.addTextNode'), icon: 'text', action: () => placeNewNode(createTextNode(contextMenu.world)) })
1549
- items.push({ label: tt('canvas.paste'), icon: 'duplicate', action: () => pasteClipboard(contextMenu.world) })
1550
- items.push({ label: tt('canvas.fitView'), icon: 'fit', action: fitView })
1551
- }
1552
- return <div className={css.contextMenu} style={{ left: contextMenu.screen.x, top: contextMenu.screen.y }} data-canvas-no-zoom="" role="menu">
1553
- {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>)}
1554
- </div>
1555
- }
1556
- if (createMenu !== null) {
1557
- return <div className={css.contextMenu} style={{ left: createMenu.screen.x, top: createMenu.screen.y }} data-canvas-no-zoom="" role="menu">
1558
- <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>
1559
- <button type="button" role="menuitem" onClick={() => { placeNewNode(createTextNode(createMenu.world)); setCreateMenu(null) }}><ToolbarIcon name="text" />{tt('canvas.addTextNode')}</button>
1560
- </div>
1561
- }
1562
- return null
1563
- }
1564
-
1565
- const emptyState = document !== null && document.nodes.length === 0
1566
- ? <div className={css.emptyHint} data-canvas-no-zoom="">
1567
- <strong>{tt('canvas.emptyTitle')}</strong>
1568
- <span>{tt('canvas.emptyHint')}</span>
1569
- </div>
1570
- : null
1571
-
1572
- const marqueeRect = marquee === null ? null : (() => {
1573
- const x1 = (Math.min(marquee.start.x, marquee.current.x) * (document?.viewport.k ?? 1)) + (document?.viewport.x ?? 0)
1574
- const y1 = (Math.min(marquee.start.y, marquee.current.y) * (document?.viewport.k ?? 1)) + (document?.viewport.y ?? 0)
1575
- const x2 = (Math.max(marquee.start.x, marquee.current.x) * (document?.viewport.k ?? 1)) + (document?.viewport.x ?? 0)
1576
- const y2 = (Math.max(marquee.start.y, marquee.current.y) * (document?.viewport.k ?? 1)) + (document?.viewport.y ?? 0)
1577
- return { left: x1, top: y1, width: x2 - x1, height: y2 - y1 }
1578
- })()
1579
-
1580
- return <section ref={rootRef} className={css.root} data-canvas-workspace="">
1581
- <header className={css.topBar} data-canvas-no-zoom="">
1582
- <select className={css.projectSelect} value={document?.id ?? ''} onChange={event => { void selectProject(event.target.value) }} aria-label={tt('canvas.project')}>
1583
- {projects.map(project => <option key={project.id} value={project.id}>{project.title}</option>)}
1584
- </select>
1585
- <IconButton name="new" label={tt('canvas.newCanvas')} onClick={() => { void newCanvas() }} />
1586
- <IconButton name="deleteProject" label={confirmDeleteProject ? tt('canvas.deleteCanvasConfirm') : tt('canvas.deleteCanvas')} active={confirmDeleteProject} disabled={document === null} onClick={() => {
1587
- if (confirmDeleteProject) { void removeCurrentProject() } else { setConfirmDeleteProject(true); window.setTimeout(() => setConfirmDeleteProject(false), 3000) }
1588
- }} />
1589
- {renamingTitle && document !== null
1590
- ? <input
1591
- className={css.titleInput}
1592
- value={document.title}
1593
- autoFocus
1594
- aria-label={tt('canvas.rename')}
1595
- onChange={event => updateDocument(previous => ({ ...previous, title: event.target.value }))}
1596
- onBlur={() => setRenamingTitle(false)}
1597
- onKeyDown={event => { if (event.key === 'Enter' || event.key === 'Escape') setRenamingTitle(false) }}
1598
- />
1599
- : <button type="button" className={css.titleButton} onDoubleClick={() => setRenamingTitle(true)} title={tt('canvas.renameHint')}>{document?.title ?? ''}</button>}
1600
- <span className={css.topBarSpacer} />
1601
- <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>
1602
- </header>
1603
-
1604
- <div
1605
- ref={viewportRef}
1606
- className={`${css.viewport} ${cursorClass}`}
1607
- onPointerDown={onViewportPointerDown}
1608
- onWheel={onWheel}
1609
- onDoubleClick={event => {
1610
- const target = event.target instanceof Element ? event.target : null
1611
- if (target?.closest('[data-node-id],[data-canvas-no-zoom]')) return
1612
- setCreateMenu({ screen: { x: event.clientX, y: event.clientY }, world: screenToWorld(event.clientX, event.clientY) })
1613
- }}
1614
- onContextMenu={event => {
1615
- const target = event.target instanceof Element ? event.target : null
1616
- if (target?.closest('[data-node-id],[data-connection-hit],[data-canvas-no-zoom]')) return
1617
- event.preventDefault()
1618
- setCreateMenu(null)
1619
- setContextMenu({ type: 'canvas', screen: { x: event.clientX, y: event.clientY }, world: screenToWorld(event.clientX, event.clientY) })
1620
- }}
1621
- onDragOver={event => event.preventDefault()}
1622
- onDrop={onDrop}
1623
- >
1624
- <div
1625
- className={css.grid}
1626
- style={backgroundMode === 'image' && document?.backgroundImage
1627
- ? { backgroundImage: `url(${document.backgroundImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
1628
- : { backgroundSize: `${gridSize}px ${gridSize}px`, backgroundPosition: `${gridOffsetX}px ${gridOffsetY}px` }}
1629
- data-mode={backgroundMode}
1630
- aria-hidden="true"
1631
- >
1632
- {backgroundMode === 'image' ? <div className={css.gridScrim} /> : null}
1633
- </div>
1634
- <div className={css.world} style={{ transform: `translate(${document?.viewport.x ?? 0}px, ${document?.viewport.y ?? 0}px) scale(${document?.viewport.k ?? 1})` }}>
1635
- {renderConnections()}
1636
- {document?.nodes.map(renderNode)}
1637
- </div>
1638
- {marqueeRect !== null ? <div className={css.marquee} style={marqueeRect} aria-hidden="true" /> : null}
1639
- {emptyState}
1640
- </div>
1641
-
1642
- <div
1643
- className={`${css.dock} ${cursorClass}`}
1644
- data-canvas-no-zoom=""
1645
- onPointerMove={event => {
1646
- const dock = event.currentTarget
1647
- const buttons = [...dock.querySelectorAll<HTMLButtonElement>('.iconButton')]
1648
- for (const button of buttons) {
1649
- const rect = button.getBoundingClientRect()
1650
- const distance = Math.abs(event.clientX - (rect.left + rect.width / 2)) / 40
1651
- button.style.setProperty('--dock-lift', `${Math.max(0, 4 - distance * 1.5)}px`)
1652
- }
1653
- }}
1654
- onPointerLeave={event => {
1655
- for (const button of event.currentTarget.querySelectorAll<HTMLButtonElement>('.iconButton')) button.style.removeProperty('--dock-lift')
1656
- }}
1657
- >
1658
- <IconButton name="select" size={18} label={tt('canvas.toolSelect')} active={tool === 'select'} onClick={() => setTool('select')} />
1659
- <IconButton name="pan" size={18} label={tt('canvas.toolPan')} active={tool === 'pan'} onClick={() => setTool('pan')} />
1660
- <span className={css.dockDivider} />
1661
- <IconButton
1662
- name="image"
1663
- size={18}
1664
- label={tt('canvas.addImage')}
1665
- active={imageMenu !== null}
1666
- onClick={event => openDockMenu('image', event.currentTarget)}
1667
- onMouseEnter={event => openDockMenu('image', event.currentTarget)}
1668
- onMouseLeave={scheduleMenuClose}
1669
- />
1670
- <IconButton name="text" size={18} label={tt('canvas.addText')} onClick={() => placeNewNode(createTextNode())} />
1671
- <IconButton name="sparkle" size={18} label={tt('canvas.addConfigNode')} onClick={() => placeNewNode(createConfigNode())} />
1672
- <IconButton name="template" size={18} label={tt('canvas.templateLibrary')} active={libraryOpen} onClick={() => setLibraryOpen(previous => !previous)} />
1673
- <span className={css.dockDivider} />
1674
- <IconButton
1675
- name="background"
1676
- size={18}
1677
- label={tt('canvas.background')}
1678
- active={backgroundMenu !== null}
1679
- onClick={event => openDockMenu('background', event.currentTarget)}
1680
- onMouseEnter={event => openDockMenu('background', event.currentTarget)}
1681
- onMouseLeave={scheduleMenuClose}
1682
- />
1683
- <IconButton name="undo" size={18} label={tt('canvas.undo')} disabled={pastRef.current.length === 0} onClick={undo} />
1684
- <IconButton name="redo" size={18} label={tt('canvas.redo')} disabled={futureRef.current.length === 0} onClick={redo} />
1685
- <span className={css.dockDivider} />
1686
- <IconButton name="trash" size={18} label={tt('canvas.delete')} disabled={selectedIds.size === 0 && selectedConnectionId === null} onClick={deleteSelection} />
1687
- </div>
1688
-
1689
- {imageMenu !== null ? <div
1690
- className={css.backgroundMenu}
1691
- style={{ left: imageMenu.x, top: imageMenu.y - 10 }}
1692
- data-canvas-no-zoom=""
1693
- role="menu"
1694
- onMouseEnter={clearMenuCloseTimer}
1695
- onMouseLeave={scheduleMenuClose}
1696
- >
1697
- <button type="button" role="menuitem" onClick={() => { imageFileRef.current?.click(); setImageMenu(null) }}><ToolbarIcon name="image" size={16} />{tt('canvas.imageMenuUpload')}</button>
1698
- <button type="button" role="menuitem" onClick={() => { setPickerTab('gallery'); setPickerOpen(true); setImageMenu(null) }}><ToolbarIcon name="template" size={16} />{tt('canvas.imageMenuAssets')}</button>
1699
- <button type="button" role="menuitem" onClick={() => { setPickerTab('history'); setPickerOpen(true); setImageMenu(null) }}><ToolbarIcon name="undo" size={16} />{tt('canvas.imageMenuHistory')}</button>
1700
- <button type="button" role="menuitem" onClick={() => { setPickerTab('generate'); setPickerOpen(true); setImageMenu(null) }}><ToolbarIcon name="sparkle" size={16} />{tt('canvas.imageMenuGenerate')}</button>
1701
- </div> : null}
1702
- <input
1703
- ref={imageFileRef}
1704
- type="file"
1705
- accept="image/png,image/jpeg,image/webp,image/gif"
1706
- multiple
1707
- hidden
1708
- onChange={event => {
1709
- const files = [...(event.target.files ?? [])].filter(file => file.type.startsWith('image/'))
1710
- event.target.value = ''
1711
- if (files.length === 0) return
1712
- const world = canvasCenter()
1713
- void Promise.all(files.map(async file => {
1714
- 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) })
1715
- const dimensions = await readImageSize(dataUrl)
1716
- return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
1717
- })).then(assets => {
1718
- const current = documentRef.current
1719
- const selectedId = selectedIdsRef.current.size === 1 ? [...selectedIdsRef.current][0] : undefined
1720
- const selectedNode = current?.nodes.find(node => node.id === selectedId)
1721
- if (selectedNode?.type === 'image' && usableAsset(selectedNode) === undefined && assets[0] !== undefined) {
1722
- mutate(previous => ({ ...previous, nodes: previous.nodes.map(node => node.id === selectedNode.id ? { ...node, width: sizeForAsset(assets[0]!).width, height: sizeForAsset(assets[0]!).height, metadata: { ...nodeMetadata(node), asset: assets[0], status: 'success' as const, error: undefined } } : node) }))
1723
- if (assets.length > 1) addAssets(assets.slice(1), world)
1724
- } else addAssets(assets, world)
1725
- }).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
1726
- }}
1727
- />
1728
- {backgroundMenu !== null ? <div
1729
- className={css.backgroundMenu}
1730
- style={{ left: backgroundMenu.x, top: backgroundMenu.y - 10 }}
1731
- data-canvas-no-zoom=""
1732
- role="menu"
1733
- onMouseEnter={clearMenuCloseTimer}
1734
- onMouseLeave={scheduleMenuClose}
1735
- >
1736
- {([
1737
- ['dots', tt('canvas.backgroundDots')],
1738
- ['lines', tt('canvas.backgroundLines')],
1739
- ['diagonal', tt('canvas.backgroundDiagonal')],
1740
- ['checker', tt('canvas.backgroundChecker')],
1741
- ['blank', tt('canvas.backgroundBlank')],
1742
- ] as const).map(([mode, label]) => <button key={mode} type="button" role="menuitem" data-active={backgroundMode === mode ? '' : undefined} onClick={() => setBackgroundMode(mode)}>{label}</button>)}
1743
- <span className={css.backgroundMenuDivider} />
1744
- <button type="button" role="menuitem" data-active={backgroundMode === 'image' ? '' : undefined} onClick={() => backgroundFileRef.current?.click()}>{tt('canvas.backgroundUpload')}</button>
1745
- {backgroundMode === 'image' && document?.backgroundImage ? <button type="button" role="menuitem" onClick={removeBackgroundImage}>{tt('canvas.backgroundRemove')}</button> : null}
1746
- <input
1747
- ref={backgroundFileRef}
1748
- type="file"
1749
- accept="image/png,image/jpeg,image/webp,image/gif"
1750
- hidden
1751
- onChange={event => {
1752
- const file = event.target.files?.[0]
1753
- if (file !== undefined) void uploadBackgroundImage(file)
1754
- event.target.value = ''
1755
- }}
1756
- />
1757
- </div> : null}
1758
-
1759
- <div className={css.zoomDock} data-canvas-no-zoom="">
1760
- <IconButton name="minimap" label={minimapOpen ? tt('canvas.minimapClose') : tt('canvas.minimapOpen')} active={minimapOpen} onClick={() => setMinimapOpen(previous => !previous)} />
1761
- <IconButton name="fit" label={tt('canvas.fitView')} onClick={fitView} />
1762
- <input
1763
- type="range"
1764
- min={5}
1765
- max={500}
1766
- step={1}
1767
- value={Math.round((document?.viewport.k ?? 1) * 100)}
1768
- onChange={event => setZoomAtCenter(Number(event.target.value) / 100)}
1769
- aria-label={tt('canvas.zoom')}
1770
- />
1771
- <span className={css.zoomValue}>{Math.round((document?.viewport.k ?? 1) * 100)}%</span>
1772
- </div>
1773
-
1774
- {minimapOpen ? renderMinimap() : null}
1775
- {renderComposer()}
1776
- {renderContextMenu()}
1777
- {libraryOpen ? <TemplateLibrary api={api} onClose={() => setLibraryOpen(false)} onUse={applyTemplate} /> : null}
1778
-
1779
- {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}
1780
-
1781
- {pickerOpen ? <ImagePicker
1782
- api={api}
1783
- history={history}
1784
- gallery={gallery}
1785
- imageModels={imageModels}
1786
- defaultChannelId={defaultChannelId}
1787
- canvasId={document?.id ?? ''}
1788
- connected={connected}
1789
- initialTab={pickerTab}
1790
- onClose={() => setPickerOpen(false)}
1791
- onAssets={assets => { addAssets(assets); setPickerOpen(false) }}
1792
- onTask={task => {
1793
- if (document === null) return
1794
- const center = canvasCenter()
1795
- const size = nodeSizeFromRatio(task.request.size, IMAGE_NODE_SIZE)
1796
- const node: CanvasNode = {
1797
- id: newId('node'), type: 'image', title: tt('canvas.imageNode'),
1798
- x: Math.round(center.x - size.width / 2), y: Math.round(center.y - size.height / 2),
1799
- width: size.width, height: size.height,
1800
- 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 },
1801
- }
1802
- placeNewNode(node)
1803
- setPickerOpen(false)
1804
- }}
1805
- /> : null}
1806
- </section>
1807
- }
1808
-
1809
- function ImagePicker(props: {
1810
- api: ImageGenApi
1811
- history: HistoryEntry[]
1812
- gallery: HistoryEntry[]
1813
- imageModels: string[]
1814
- defaultChannelId?: string
1815
- canvasId: string
1816
- connected: boolean
1817
- initialTab?: 'upload' | 'history' | 'gallery' | 'generate'
1818
- onClose: () => void
1819
- onAssets: (assets: CanvasAssetRef[]) => void
1820
- onTask: (task: GenerationTask) => void
1821
- }): React.JSX.Element {
1822
- const { api, history, gallery, imageModels, defaultChannelId, canvasId, connected, onClose, onAssets } = props
1823
- const [tab, setTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>(props.initialTab ?? 'upload')
1824
- const [selected, setSelected] = useState<string[]>([])
1825
- const [dimensions, setDimensions] = useState<Record<string, { width: number; height: number }>>({})
1826
- const [prompt, setPrompt] = useState('')
1827
- const [model, setModel] = useState(imageModels[0] ?? '')
1828
- const [size, setSize] = useState('auto')
1829
- const [quality, setQuality] = useState('auto')
1830
- const [busy, setBusy] = useState(false)
1831
- const toggle = (key: string): void => setSelected(previous => previous.includes(key) ? previous.filter(item => item !== key) : [...previous, key])
1832
- const items = (tab === 'history' ? history : gallery).flatMap(entry => entry.images.map((image, index) => ({ key: `${entry.id}:${index}`, entry, image, index })))
1833
- const uploadFiles = (files: File[]): void => {
1834
- setBusy(true)
1835
- void Promise.all(files.map(async file => {
1836
- 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) })
1837
- const sizeOf = await readImageSize(dataUrl)
1838
- return api.canvasUpload(dataUrl, sizeOf.width, sizeOf.height, { origin: 'upload', originId: file.name })
1839
- })).then(assets => { onAssets(assets) }).catch(() => {}).finally(() => setBusy(false))
1840
- }
1841
- const addSelected = async (): Promise<void> => {
1842
- setBusy(true)
1843
- try {
1844
- const assets: CanvasAssetRef[] = []
1845
- for (const key of selected) {
1846
- const [entryId, indexText] = key.split(':'); const index = Number(indexText); const item = items.find(candidate => candidate.key === key)
1847
- if (entryId === undefined || item === undefined) continue
1848
- const sizeOf = dimensions[key] ?? await readImageSize(item.image.url).catch(() => ({ width: 1024, height: 1024 }))
1849
- assets.push(await api.canvasImport(tab === 'history' ? 'history' : 'gallery', entryId, index, sizeOf.width, sizeOf.height))
1850
- }
1851
- if (assets.length > 0) onAssets(assets)
1852
- } finally { setBusy(false) }
1853
- }
1854
- const generate = async (): Promise<void> => {
1855
- if (!connected || prompt.trim() === '') return
1856
- setBusy(true)
1857
- try {
1858
- const task = await api.taskSubmit({ mode: 'text', model, prompt: prompt.trim(), size, quality, n: 1, detail: '', ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }), canvas: { canvasId } })
1859
- props.onTask(task)
1860
- } finally { setBusy(false) }
1861
- }
1862
- return <div className={css.modalBackdrop} role="dialog" aria-modal="true" data-canvas-no-zoom=""><section className={css.picker}>
1863
- <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>
1864
- <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>
1865
- <div className={css.pickerBody}>
1866
- {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}
1867
- {(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}
1868
- {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}
1869
- </div>
1870
- </section></div>
1871
- }
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 {
10
+ BookOpen, ChevronDown, Copy, Download, FolderX, Hand, Image as ImageIcon, Map as MapIcon, Maximize,
11
+ MousePointer2, Plus, Redo2, SendHorizonal, Sparkles, Trash2, Type, Undo2, Wallpaper, X,
12
+ } from 'lucide-react'
13
+ import type { CanvasAssetRef, CanvasConnection, CanvasDocument, CanvasNode, GenerateRequest, GenerationTask, HistoryEntry } from '../protocol.ts'
14
+ import type { ImageGenApi } from './api.ts'
15
+ import { tt } from './helpers.ts'
16
+ import { TemplateLibrary } from './TemplateLibrary.tsx'
17
+ import { DotFieldBackground, DotGridBackground, FaultyTerminalBackground, FloatingLinesBackground, FlowBackground, GalaxyBackground, LiquidEtherBackground, ShapeGridBackground, SilkBackground, WavesBackground } from './CanvasBackgrounds.tsx'
18
+ import css from './canvas-workspace.module.css'
19
+
20
+ type CanvasTool = 'select' | 'pan'
21
+ type BackgroundMode = CanvasDocument['background']
22
+
23
+ const MIN_SCALE = 0.05
24
+ const MAX_SCALE = 5
25
+ const GRID_SIZE = 48
26
+ const IMAGE_NODE_SIZE = { width: 240, height: 240 }
27
+ const TEXT_NODE_SIZE = { width: 280, height: 150 }
28
+ const CONFIG_NODE_SIZE = { width: 320, height: 190 }
29
+ const LEGACY_CONFIG_NODE_SIZE = { width: 240, height: 96 }
30
+ const HISTORY_LIMIT = 60
31
+ const WORLD_PAD = 12000
32
+
33
+ interface CanvasWorkspaceProps {
34
+ api: ImageGenApi
35
+ imageModels: string[]
36
+ defaultChannelId?: string
37
+ connected: boolean
38
+ history: HistoryEntry[]
39
+ gallery: HistoryEntry[]
40
+ tasks: GenerationTask[]
41
+ importRequest?: { source: 'history' | 'gallery'; entryId: string; imageIndex: number }
42
+ onImportRequestHandled?: () => void
43
+ onOpenSettings?: () => void
44
+ }
45
+
46
+ type Point = { x: number; y: number }
47
+
48
+ interface NodeDragState {
49
+ pointerId: number
50
+ startX: number
51
+ startY: number
52
+ moved: boolean
53
+ snapshot: string | null
54
+ origins: Map<string, Point>
55
+ }
56
+
57
+ interface PanState {
58
+ startX: number
59
+ startY: number
60
+ viewportX: number
61
+ viewportY: number
62
+ hasMoved: boolean
63
+ startedOnBackground: boolean
64
+ }
65
+
66
+ interface MarqueeState {
67
+ start: Point
68
+ current: Point
69
+ additive: boolean
70
+ initialIds: string[]
71
+ }
72
+
73
+ interface ConnectState {
74
+ nodeId: string
75
+ handleType: 'source' | 'target'
76
+ mouse: Point
77
+ targetId: string | null
78
+ /** False for a plain click on the handle (opens the add-node menu), true
79
+ * once the pointer travels far enough that this is a drag-to-connect. */
80
+ moved: boolean
81
+ startClient: Point
82
+ }
83
+
84
+ interface ResizeState {
85
+ nodeId: string
86
+ corner: 'bottom-right' | 'bottom-left'
87
+ startX: number
88
+ startY: number
89
+ width: number
90
+ height: number
91
+ x: number
92
+ y: number
93
+ ratio: number | null
94
+ }
95
+
96
+ type ContextMenuState =
97
+ | { type: 'canvas'; screen: Point; world: Point }
98
+ | { type: 'node'; screen: Point; nodeId: string }
99
+ | { type: 'connection'; screen: Point; connectionId: string }
100
+
101
+ type ProjectSummary = Awaited<ReturnType<ImageGenApi['canvasList']>>[number]
102
+
103
+ function newId(prefix: string): string {
104
+ const random = globalThis.crypto?.randomUUID?.()
105
+ return `${prefix}-${random ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`
106
+ }
107
+
108
+ function imageDataUrl(image: { b64: string; mime: string }): string {
109
+ return `data:${image.mime};base64,${image.b64}`
110
+ }
111
+
112
+ function readImageSize(src: string): Promise<{ width: number; height: number }> {
113
+ return new Promise((resolve, reject) => {
114
+ const image = new Image()
115
+ image.onload = () => resolve({ width: image.naturalWidth || 1, height: image.naturalHeight || 1 })
116
+ image.onerror = () => reject(new Error('无法读取图片尺寸'))
117
+ image.src = src
118
+ })
119
+ }
120
+
121
+ async function assetToDataUrl(asset: CanvasAssetRef): Promise<string> {
122
+ if (asset.url.startsWith('data:')) return asset.url
123
+ const response = await fetch(asset.url)
124
+ if (!response.ok) throw new Error('读取画布图片失败')
125
+ const blob = await response.blob()
126
+ return await new Promise((resolve, reject) => {
127
+ const reader = new FileReader()
128
+ reader.onload = () => resolve(String(reader.result))
129
+ reader.onerror = () => reject(new Error('读取画布图片失败'))
130
+ reader.readAsDataURL(blob)
131
+ })
132
+ }
133
+
134
+ function clampScale(scale: number): number {
135
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))
136
+ }
137
+
138
+ function sizeForAsset(asset: CanvasAssetRef): { width: number; height: number } {
139
+ const ratio = asset.width > 0 && asset.height > 0 ? asset.width / asset.height : 1
140
+ if (ratio >= 1) return { width: IMAGE_NODE_SIZE.width, height: Math.max(160, Math.round(IMAGE_NODE_SIZE.width / ratio)) }
141
+ return { width: Math.max(200, Math.round(IMAGE_NODE_SIZE.height * ratio)), height: IMAGE_NODE_SIZE.height }
142
+ }
143
+
144
+ /** Node footprint for a generation size ratio such as '1:1' or '16:9'. */
145
+ function nodeSizeFromRatio(size: string | undefined, spec: { width: number; height: number }): { width: number; height: number } {
146
+ const match = /^(\d+):(\d+)$/.exec(size ?? '')
147
+ if (match === null) return { ...spec }
148
+ const width = Number(match[1])
149
+ const height = Number(match[2])
150
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { ...spec }
151
+ const ratio = width / height
152
+ return ratio >= 1
153
+ ? { width: spec.width, height: Math.max(160, Math.round(spec.width / ratio)) }
154
+ : { width: Math.max(200, Math.round(spec.height * ratio)), height: spec.height }
155
+ }
156
+
157
+ function nodesBounds(nodes: CanvasNode[]): { minX: number; minY: number; maxX: number; maxY: number } {
158
+ return nodes.reduce((acc, node) => ({
159
+ minX: Math.min(acc.minX, node.x),
160
+ minY: Math.min(acc.minY, node.y),
161
+ maxX: Math.max(acc.maxX, node.x + node.width),
162
+ maxY: Math.max(acc.maxY, node.y + node.height),
163
+ }), { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity })
164
+ }
165
+
166
+ /** Enlarge config nodes still stored at the pre-expansion default so the
167
+ * roomier layout applies to existing canvases too. */
168
+ function normalizeConfigNodeSizes(document: CanvasDocument): CanvasDocument {
169
+ const nodes = document.nodes.map(node => node.type === 'config'
170
+ && node.width === LEGACY_CONFIG_NODE_SIZE.width && node.height === LEGACY_CONFIG_NODE_SIZE.height
171
+ ? { ...node, width: CONFIG_NODE_SIZE.width, height: CONFIG_NODE_SIZE.height }
172
+ : node)
173
+ return nodes === document.nodes ? document : { ...document, nodes }
174
+ }
175
+
176
+ function summaryOf(document: CanvasDocument): ProjectSummary {
177
+ return {
178
+ id: document.id,
179
+ title: document.title,
180
+ revision: document.revision,
181
+ nodeCount: document.nodes.length,
182
+ createdAt: document.createdAt,
183
+ updatedAt: document.updatedAt,
184
+ }
185
+ }
186
+
187
+ function nodeMetadata(node: CanvasNode): NonNullable<CanvasNode['metadata']> {
188
+ return node.metadata ?? {}
189
+ }
190
+
191
+ function assetOf(node: CanvasNode): CanvasAssetRef | undefined {
192
+ return node.type === 'image' ? nodeMetadata(node).asset : undefined
193
+ }
194
+
195
+ function usableAsset(node: CanvasNode): CanvasAssetRef | undefined {
196
+ const asset = assetOf(node)
197
+ return asset !== undefined && asset.url !== '' ? asset : undefined
198
+ }
199
+
200
+ function bezierPath(from: Point, to: Point): string {
201
+ const distance = Math.abs(to.x - from.x)
202
+ const bend = Math.max(distance * 0.5, 50)
203
+ return `M ${from.x} ${from.y} C ${from.x + bend} ${from.y}, ${to.x - bend} ${to.y}, ${to.x} ${to.y}`
204
+ }
205
+
206
+ function nodeAnchor(node: CanvasNode, side: 'left' | 'right'): Point {
207
+ return { x: side === 'right' ? node.x + node.width : node.x, y: node.y + node.height / 2 }
208
+ }
209
+
210
+ type ToolbarIconName = 'new' | 'select' | 'pan' | 'image' | 'text' | 'trash' | 'undo' | 'redo' | 'fit' | 'minimap' | 'background' | 'template' | 'download' | 'duplicate' | 'sparkle' | 'send' | 'close' | 'deleteProject'
211
+
212
+ /** Lucide icons (stroke matches the DSH line style); one shared component so
213
+ * every dock/toolbar icon comes from the same well-drawn set. */
214
+ function ToolbarIcon({ name, size = 16 }: { name: ToolbarIconName; size?: number }): React.JSX.Element {
215
+ const common = { size, strokeWidth: 1.6, 'aria-hidden': true as const }
216
+ switch (name) {
217
+ case 'new': return <Plus {...common} />
218
+ case 'select': return <MousePointer2 {...common} />
219
+ case 'pan': return <Hand {...common} />
220
+ case 'image': return <ImageIcon {...common} />
221
+ case 'text': return <Type {...common} />
222
+ case 'trash': return <Trash2 {...common} />
223
+ case 'undo': return <Undo2 {...common} />
224
+ case 'redo': return <Redo2 {...common} />
225
+ case 'fit': return <Maximize {...common} />
226
+ case 'minimap': return <MapIcon {...common} />
227
+ case 'background': return <Wallpaper {...common} />
228
+ case 'template': return <BookOpen {...common} />
229
+ case 'download': return <Download {...common} />
230
+ case 'duplicate': return <Copy {...common} />
231
+ case 'sparkle': return <Sparkles {...common} />
232
+ case 'send': return <SendHorizonal {...common} />
233
+ case 'close': return <X {...common} />
234
+ case 'deleteProject': return <FolderX {...common} />
235
+ }
236
+ }
237
+
238
+ function IconButton(props: {
239
+ name: ToolbarIconName
240
+ label: string
241
+ active?: boolean
242
+ disabled?: boolean
243
+ size?: number
244
+ onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
245
+ onMouseEnter?: (event: React.MouseEvent<HTMLButtonElement>) => void
246
+ onMouseLeave?: () => void
247
+ }): React.JSX.Element {
248
+ return <button
249
+ type="button"
250
+ className={css.iconButton}
251
+ data-active={props.active ? '' : undefined}
252
+ aria-label={props.label}
253
+ title={props.label}
254
+ disabled={props.disabled}
255
+ onClick={props.onClick}
256
+ onMouseEnter={props.onMouseEnter}
257
+ onMouseLeave={props.onMouseLeave}
258
+ ><ToolbarIcon name={props.name} size={props.size} /></button>
259
+ }
260
+
261
+ /** Styled dropdown standing in for a native <select> so the composer and the
262
+ * picker match the canvas visual language instead of the OS popup. */
263
+ function ComposerSelect(props: {
264
+ value: string
265
+ options: Array<{ value: string; label: string }>
266
+ ariaLabel: string
267
+ onChange: (value: string) => void
268
+ }): React.JSX.Element {
269
+ const [open, setOpen] = useState(false)
270
+ const [position, setPosition] = useState<{ left: number; top: number; minWidth: number } | null>(null)
271
+ const buttonRef = useRef<HTMLButtonElement>(null)
272
+ useEffect(() => {
273
+ if (!open) return
274
+ const close = (event: PointerEvent): void => {
275
+ if (event.target instanceof Element && buttonRef.current?.contains(event.target) === true) return
276
+ setOpen(false)
277
+ }
278
+ window.addEventListener('pointerdown', close, true)
279
+ return () => window.removeEventListener('pointerdown', close, true)
280
+ }, [open])
281
+ const selected = props.options.find(option => option.value === props.value) ?? props.options[0]
282
+ return <>
283
+ <button
284
+ type="button"
285
+ ref={buttonRef}
286
+ className={css.composerSelect}
287
+ data-open={open ? '' : undefined}
288
+ aria-label={props.ariaLabel}
289
+ aria-haspopup="listbox"
290
+ aria-expanded={open}
291
+ onClick={() => {
292
+ if (open) { setOpen(false); return }
293
+ const rect = buttonRef.current?.getBoundingClientRect()
294
+ if (rect !== undefined) setPosition({ left: rect.left, top: rect.bottom + 6, minWidth: rect.width })
295
+ setOpen(true)
296
+ }}
297
+ >
298
+ <span className={css.composerSelectValue}>{selected?.label ?? ''}</span>
299
+ <ChevronDown size={13} strokeWidth={2} aria-hidden="true" />
300
+ </button>
301
+ {open && position !== null ? <div className={css.composerSelectMenu} style={{ left: position.left, top: position.top, minWidth: position.minWidth }} role="listbox" aria-label={props.ariaLabel}>
302
+ {props.options.map(option => <button
303
+ key={option.value}
304
+ type="button"
305
+ role="option"
306
+ aria-selected={option.value === props.value}
307
+ data-selected={option.value === props.value ? '' : undefined}
308
+ onClick={() => { props.onChange(option.value); setOpen(false) }}
309
+ >{option.label}</button>)}
310
+ </div> : null}
311
+ </>
312
+ }
313
+
314
+ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element {
315
+ const { api, imageModels, defaultChannelId, connected, history, gallery, tasks, importRequest, onImportRequestHandled, onOpenSettings } = props
316
+ const [projects, setProjects] = useState<ProjectSummary[]>([])
317
+ const [document, setDocument] = useState<CanvasDocument | null>(null)
318
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
319
+ const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null)
320
+ const [tool, setTool] = useState<CanvasTool>('select')
321
+ const [spacePressed, setSpacePressed] = useState(false)
322
+ const [ctrlPressed, setCtrlPressed] = useState(false)
323
+ const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 })
324
+ const [marquee, setMarquee] = useState<MarqueeState | null>(null)
325
+ const [connecting, setConnecting] = useState<ConnectState | null>(null)
326
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
327
+ const [createMenu, setCreateMenu] = useState<{ screen: Point; world: Point } | null>(null)
328
+ const [nodeAddMenu, setNodeAddMenu] = useState<{ nodeId: string; nodeType: CanvasNode['type']; screen: Point } | null>(null)
329
+ const [minimapOpen, setMinimapOpen] = useState(true)
330
+ const [pickerOpen, setPickerOpen] = useState(false)
331
+ const [backgroundMenu, setBackgroundMenu] = useState<Point | null>(null)
332
+ const [imageMenu, setImageMenu] = useState<Point | null>(null)
333
+ const menuCloseTimer = useRef<number | null>(null)
334
+ const clearMenuCloseTimer = (): void => {
335
+ if (menuCloseTimer.current !== null) { window.clearTimeout(menuCloseTimer.current); menuCloseTimer.current = null }
336
+ }
337
+ const scheduleMenuClose = useCallback((): void => {
338
+ clearMenuCloseTimer()
339
+ menuCloseTimer.current = window.setTimeout(() => { setBackgroundMenu(null); setImageMenu(null) }, 280)
340
+ }, [])
341
+ /** Open one dock menu anchored to its button (root-relative) and close the
342
+ * other: the two menus are mutually exclusive. */
343
+ const openDockMenu = useCallback((kind: 'image' | 'background', button: HTMLElement): void => {
344
+ clearMenuCloseTimer()
345
+ const bounds = button.getBoundingClientRect()
346
+ const rootRect = rootRef.current?.getBoundingClientRect()
347
+ const screen: Point = { x: bounds.left + bounds.width / 2 - (rootRect?.left ?? 0), y: bounds.top - (rootRect?.top ?? 0) }
348
+ if (kind === 'image') { setImageMenu(screen); setBackgroundMenu(null) }
349
+ else { setBackgroundMenu(screen); setImageMenu(null) }
350
+ }, [])
351
+ const [libraryOpen, setLibraryOpen] = useState(false)
352
+ const [pickerTab, setPickerTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>('upload')
353
+ const backgroundFileRef = useRef<HTMLInputElement>(null)
354
+ const imageFileRef = useRef<HTMLInputElement>(null)
355
+ const [renamingTitle, setRenamingTitle] = useState(false)
356
+ const [confirmDeleteProject, setConfirmDeleteProject] = useState(false)
357
+ const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'error'>('loading')
358
+ const [error, setError] = useState<string | null>(null)
359
+ const [historyVersion, setHistoryVersion] = useState(0)
360
+
361
+ // Floating generation composer state.
362
+ const [composerPrompt, setComposerPrompt] = useState('')
363
+ const [composerModel, setComposerModel] = useState(imageModels[0] ?? '')
364
+ const [composerSize, setComposerSize] = useState('auto')
365
+ const [composerQuality, setComposerQuality] = useState('auto')
366
+ const [composerCount, setComposerCount] = useState(1)
367
+ const [composerBusy, setComposerBusy] = useState(false)
368
+
369
+ const rootRef = useRef<HTMLElement>(null)
370
+ const viewportRef = useRef<HTMLDivElement>(null)
371
+ const documentRef = useRef<CanvasDocument | null>(null)
372
+ const selectedIdsRef = useRef<Set<string>>(selectedIds)
373
+ const dragRef = useRef<NodeDragState | null>(null)
374
+ const panRef = useRef<PanState | null>(null)
375
+ const connectRef = useRef<ConnectState | null>(null)
376
+ const resizeRef = useRef<ResizeState | null>(null)
377
+ const marqueeRef = useRef<MarqueeState | null>(null)
378
+ const panFrameRef = useRef<number | null>(null)
379
+ const syncedRef = useRef('')
380
+ const processedTasks = useRef(new Set<string>())
381
+ const processedImport = useRef('')
382
+ const localTaskIds = useRef(new Set<string>())
383
+ const mountedAtRef = useRef(Date.now())
384
+ const internalClipboard = useRef<{ nodes: CanvasNode[]; connections: Array<{ fromNodeId: string; toNodeId: string }> } | null>(null)
385
+ const pastRef = useRef<string[]>([])
386
+ const futureRef = useRef<string[]>([])
387
+ const composerTargetRef = useRef<string | null>(null)
388
+
389
+ documentRef.current = document
390
+ selectedIdsRef.current = selectedIds
391
+
392
+ // ------------------------------------------------------------ utilities
393
+
394
+ const screenToWorld = useCallback((clientX: number, clientY: number): Point => {
395
+ const bounds = viewportRef.current?.getBoundingClientRect()
396
+ const current = documentRef.current
397
+ if (bounds === undefined || current === null) return { x: clientX, y: clientY }
398
+ return {
399
+ x: (clientX - bounds.left - current.viewport.x) / current.viewport.k,
400
+ y: (clientY - bounds.top - current.viewport.y) / current.viewport.k,
401
+ }
402
+ }, [])
403
+
404
+ const canvasCenter = useCallback((): Point => {
405
+ const bounds = viewportRef.current?.getBoundingClientRect()
406
+ const current = documentRef.current
407
+ if (bounds === undefined || current === null) return { x: 0, y: 0 }
408
+ return screenToWorld(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
409
+ }, [screenToWorld])
410
+
411
+ const beginHistory = useCallback((): string | null => {
412
+ const current = documentRef.current
413
+ if (current === null) return null
414
+ const snapshot = JSON.stringify(current)
415
+ if (pastRef.current[pastRef.current.length - 1] === snapshot) return snapshot
416
+ pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), snapshot]
417
+ futureRef.current = []
418
+ setHistoryVersion(version => version + 1)
419
+ return snapshot
420
+ }, [])
421
+
422
+ const commitSnapshot = useCallback((snapshot: string | null): void => {
423
+ if (snapshot === null) return
424
+ const current = documentRef.current
425
+ if (current === null || JSON.stringify(current) === snapshot) return
426
+ pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), snapshot]
427
+ futureRef.current = []
428
+ setHistoryVersion(version => version + 1)
429
+ }, [])
430
+
431
+ const updateDocument = useCallback((updater: (previous: CanvasDocument) => CanvasDocument): void => {
432
+ setDocument(previous => previous === null ? previous : updater(previous))
433
+ }, [])
434
+
435
+ const mutate = useCallback((updater: (previous: CanvasDocument) => CanvasDocument): void => {
436
+ beginHistory()
437
+ updateDocument(updater)
438
+ }, [beginHistory, updateDocument])
439
+
440
+ const undo = useCallback((): void => {
441
+ const snapshot = pastRef.current[pastRef.current.length - 1]
442
+ const current = documentRef.current
443
+ if (snapshot === undefined || current === null) return
444
+ pastRef.current = pastRef.current.slice(0, -1)
445
+ futureRef.current = [...futureRef.current, JSON.stringify(current)]
446
+ setDocument(JSON.parse(snapshot) as CanvasDocument)
447
+ setHistoryVersion(version => version + 1)
448
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
449
+ }, [])
450
+
451
+ const redo = useCallback((): void => {
452
+ const snapshot = futureRef.current[futureRef.current.length - 1]
453
+ const current = documentRef.current
454
+ if (snapshot === undefined || current === null) return
455
+ futureRef.current = futureRef.current.slice(0, -1)
456
+ pastRef.current = [...pastRef.current.slice(-HISTORY_LIMIT), JSON.stringify(current)]
457
+ setDocument(JSON.parse(snapshot) as CanvasDocument)
458
+ setHistoryVersion(version => version + 1)
459
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
460
+ }, [])
461
+
462
+ const setViewport = useCallback((viewport: CanvasDocument['viewport']): void => {
463
+ updateDocument(previous => ({ ...previous, viewport }))
464
+ }, [updateDocument])
465
+
466
+ // ------------------------------------------------------- node operations
467
+
468
+ const placeNewNode = useCallback((node: CanvasNode): void => {
469
+ mutate(previous => ({ ...previous, nodes: [...previous.nodes, node] }))
470
+ setSelectedIds(new Set([node.id])); setSelectedConnectionId(null)
471
+ }, [mutate])
472
+
473
+ const createImageNode = useCallback((asset: CanvasAssetRef, position?: Point): CanvasNode => {
474
+ const size = sizeForAsset(asset)
475
+ const center = position ?? canvasCenter()
476
+ return {
477
+ id: newId('node'), type: 'image', title: asset.origin === 'gallery' ? tt('canvas.fromGallery') : asset.origin === 'history' ? tt('canvas.fromHistory') : tt('canvas.imageNode'),
478
+ x: Math.round(center.x - size.width / 2), y: Math.round(center.y - size.height / 2),
479
+ width: size.width, height: size.height,
480
+ metadata: { asset, status: 'success' },
481
+ }
482
+ }, [canvasCenter])
483
+
484
+ const createTextNode = useCallback((position?: Point): CanvasNode => {
485
+ const center = position ?? canvasCenter()
486
+ return {
487
+ id: newId('node'), type: 'text', title: tt('canvas.textNode'),
488
+ x: Math.round(center.x - TEXT_NODE_SIZE.width / 2), y: Math.round(center.y - TEXT_NODE_SIZE.height / 2),
489
+ width: TEXT_NODE_SIZE.width, height: TEXT_NODE_SIZE.height,
490
+ metadata: { text: '', fontSize: 14 },
491
+ }
492
+ }, [canvasCenter])
493
+
494
+ const createConfigNode = useCallback((position?: Point): CanvasNode => {
495
+ const center = position ?? canvasCenter()
496
+ return {
497
+ id: newId('node'), type: 'config', title: tt('canvas.configNode'),
498
+ x: Math.round(center.x - CONFIG_NODE_SIZE.width / 2), y: Math.round(center.y - CONFIG_NODE_SIZE.height / 2),
499
+ width: CONFIG_NODE_SIZE.width, height: CONFIG_NODE_SIZE.height,
500
+ metadata: { status: 'idle' },
501
+ }
502
+ }, [canvasCenter])
503
+
504
+ /** A brand-new canvas starts with one text node wired into one config node,
505
+ * laid out around the visible viewport center so the workflow is obvious. */
506
+ const seedDocument = useCallback((created: CanvasDocument): CanvasDocument => {
507
+ if (created.nodes.length > 0) return created
508
+ const bounds = viewportRef.current?.getBoundingClientRect()
509
+ const viewport = created.viewport
510
+ const center = bounds !== undefined && bounds.width > 0 && bounds.height > 0
511
+ ? { x: (bounds.width / 2 - viewport.x) / viewport.k, y: (bounds.height / 2 - viewport.y) / viewport.k }
512
+ : { x: 480, y: 320 }
513
+ const config = createConfigNode(center)
514
+ const text: CanvasNode = {
515
+ ...createTextNode(),
516
+ x: Math.round(config.x - TEXT_NODE_SIZE.width - 80),
517
+ y: Math.round(config.y + (CONFIG_NODE_SIZE.height - TEXT_NODE_SIZE.height) / 2),
518
+ }
519
+ return {
520
+ ...created,
521
+ nodes: [text, config],
522
+ connections: [{ id: newId('edge'), fromNodeId: text.id, toNodeId: config.id }],
523
+ }
524
+ }, [createConfigNode, createTextNode])
525
+
526
+ const updateNodes = useCallback((updater: (nodes: CanvasNode[]) => CanvasNode[]): void => {
527
+ updateDocument(previous => ({ ...previous, nodes: updater(previous.nodes) }))
528
+ }, [updateDocument])
529
+
530
+ const patchNode = useCallback((nodeId: string, patch: Partial<NonNullable<CanvasNode['metadata']>> & Partial<Pick<CanvasNode, 'title' | 'width' | 'height' | 'x' | 'y'>>): void => {
531
+ updateNodes(nodes => nodes.map(node => node.id === nodeId
532
+ ? { ...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 } }
533
+ : node))
534
+ }, [updateNodes])
535
+
536
+ const deleteSelection = useCallback((): void => {
537
+ const ids = selectedIdsRef.current
538
+ const connectionId = selectedConnectionId
539
+ if (ids.size === 0 && connectionId === null) return
540
+ mutate(previous => ({
541
+ ...previous,
542
+ nodes: previous.nodes.filter(node => !ids.has(node.id)),
543
+ connections: previous.connections.filter(connection => !ids.has(connection.fromNodeId) && !ids.has(connection.toNodeId) && connection.id !== connectionId),
544
+ }))
545
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
546
+ }, [mutate, selectedConnectionId])
547
+
548
+ const duplicateSelection = useCallback((): void => {
549
+ const current = documentRef.current
550
+ if (current === null || selectedIdsRef.current.size === 0) return
551
+ 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) } }))
552
+ if (clones.length === 0) return
553
+ const idMap = new Map(current.nodes.filter(node => selectedIdsRef.current.has(node.id)).map((node, index) => [node.id, clones[index]!.id]))
554
+ const connections = current.connections
555
+ .filter(connection => idMap.has(connection.fromNodeId) && idMap.has(connection.toNodeId))
556
+ .map(connection => ({ id: newId('edge'), fromNodeId: idMap.get(connection.fromNodeId)!, toNodeId: idMap.get(connection.toNodeId)! }))
557
+ mutate(previous => ({ ...previous, nodes: [...previous.nodes, ...clones], connections: [...previous.connections, ...connections] }))
558
+ setSelectedIds(new Set(clones.map(node => node.id)))
559
+ }, [mutate])
560
+
561
+ const copySelection = useCallback((): void => {
562
+ const current = documentRef.current
563
+ if (current === null || selectedIdsRef.current.size === 0) return
564
+ internalClipboard.current = {
565
+ nodes: current.nodes.filter(node => selectedIdsRef.current.has(node.id)).map(node => ({ ...node, metadata: { ...nodeMetadata(node) } })),
566
+ connections: current.connections.filter(connection => selectedIdsRef.current.has(connection.fromNodeId) && selectedIdsRef.current.has(connection.toNodeId)).map(connection => ({ fromNodeId: connection.fromNodeId, toNodeId: connection.toNodeId })),
567
+ }
568
+ }, [])
569
+
570
+ const pasteClipboard = useCallback((position?: Point): void => {
571
+ const clipboard = internalClipboard.current
572
+ if (clipboard === null || clipboard.nodes.length === 0) return
573
+ const bounds = nodesBounds(clipboard.nodes)
574
+ const target = position ?? canvasCenter()
575
+ const dx = target.x - (bounds.minX + (bounds.maxX - bounds.minX) / 2)
576
+ const dy = target.y - (bounds.minY + (bounds.maxY - bounds.minY) / 2)
577
+ const idMap = new Map<string, string>()
578
+ const clones = clipboard.nodes.map(node => {
579
+ const id = newId('node'); idMap.set(node.id, id)
580
+ return { ...node, id, x: Math.round(node.x + dx), y: Math.round(node.y + dy), metadata: { ...nodeMetadata(node) } }
581
+ })
582
+ const connections = clipboard.connections.map(connection => ({ id: newId('edge'), fromNodeId: idMap.get(connection.fromNodeId)!, toNodeId: idMap.get(connection.toNodeId)! }))
583
+ mutate(previous => ({ ...previous, nodes: [...previous.nodes, ...clones], connections: [...previous.connections, ...connections] }))
584
+ setSelectedIds(new Set(clones.map(node => node.id)))
585
+ }, [canvasCenter, mutate])
586
+
587
+ const connectNodes = useCallback((fromNodeId: string, toNodeId: string): void => {
588
+ if (fromNodeId === toNodeId) return
589
+ const current = documentRef.current
590
+ if (current === null) return
591
+ if (current.connections.some(connection => connection.fromNodeId === fromNodeId && connection.toNodeId === toNodeId)) return
592
+ mutate(previous => ({ ...previous, connections: [...previous.connections, { id: newId('edge'), fromNodeId, toNodeId }] }))
593
+ }, [mutate])
594
+
595
+ /** Dify-style quick add: create a node to the right of `sourceId`, vertically
596
+ * centered against it, and wire source -> new node in one history step. The
597
+ * target spot walks right past any node already occupying it, and the
598
+ * viewport pans just enough to keep the new node visible. */
599
+ const addConnectedNode = useCallback((sourceId: string, factory: (position: Point) => CanvasNode): void => {
600
+ const current = documentRef.current
601
+ if (current === null) return
602
+ const source = current.nodes.find(item => item.id === sourceId)
603
+ if (source === undefined) return
604
+ const draft = factory({ x: 0, y: 0 })
605
+ const y = Math.round(source.y + (source.height - draft.height) / 2)
606
+ let x = source.x + source.width + 90
607
+ for (let guard = 0; guard < 24; guard += 1) {
608
+ const clash = current.nodes.find(node =>
609
+ Math.abs((y + draft.height / 2) - (node.y + node.height / 2)) < (draft.height + node.height) / 2 + 20
610
+ && x < node.x + node.width + 48
611
+ && x + draft.width > node.x - 48)
612
+ if (clash === undefined) break
613
+ x = clash.x + clash.width + 88
614
+ }
615
+ const node: CanvasNode = { ...draft, x, y }
616
+ mutate(previous => ({
617
+ ...previous,
618
+ nodes: [...previous.nodes, node],
619
+ connections: [...previous.connections, { id: newId('edge'), fromNodeId: sourceId, toNodeId: node.id }],
620
+ }))
621
+ const bounds = viewportRef.current?.getBoundingClientRect()
622
+ if (bounds === undefined) return
623
+ const viewport = current.viewport
624
+ const k = viewport.k
625
+ const left = viewport.x + x * k
626
+ const right = viewport.x + (x + draft.width) * k
627
+ const top = viewport.y + y * k
628
+ const bottom = viewport.y + (y + draft.height) * k
629
+ let dx = 0
630
+ let dy = 0
631
+ if (right > bounds.width - 24) dx = right - (bounds.width - 24)
632
+ if (bottom > bounds.height - 24) dy = bottom - (bounds.height - 24)
633
+ if (dx !== 0 || dy !== 0) setViewport({ x: viewport.x - dx, y: viewport.y - dy, k })
634
+ }, [mutate, setViewport])
635
+
636
+ /** Anchor the add-node menu at the source handle's on-screen position. The
637
+ * menu opens on hover (no click needed) and lingers briefly on leave. */
638
+ const nodeAddMenuTimer = useRef<number | null>(null)
639
+ const clearNodeAddMenuTimer = useCallback((): void => {
640
+ if (nodeAddMenuTimer.current !== null) { window.clearTimeout(nodeAddMenuTimer.current); nodeAddMenuTimer.current = null }
641
+ }, [])
642
+ const scheduleNodeAddMenuClose = useCallback((): void => {
643
+ clearNodeAddMenuTimer()
644
+ nodeAddMenuTimer.current = window.setTimeout(() => setNodeAddMenu(null), 260)
645
+ }, [clearNodeAddMenuTimer])
646
+ const openNodeAddMenu = useCallback((node: CanvasNode): void => {
647
+ const current = documentRef.current
648
+ const bounds = viewportRef.current?.getBoundingClientRect()
649
+ if (current === null || bounds === undefined) return
650
+ clearNodeAddMenuTimer()
651
+ const viewport = current.viewport
652
+ setNodeAddMenu({
653
+ nodeId: node.id,
654
+ nodeType: node.type,
655
+ screen: {
656
+ x: bounds.left + viewport.x + (node.x + node.width) * viewport.k,
657
+ y: bounds.top + viewport.y + (node.y + node.height / 2) * viewport.k,
658
+ },
659
+ })
660
+ }, [clearNodeAddMenuTimer])
661
+
662
+ const downloadNode = useCallback((node: CanvasNode): void => {
663
+ const asset = assetOf(node)
664
+ if (asset === undefined || asset.url === '') return
665
+ const link = globalThis.document.createElement('a')
666
+ link.href = asset.url
667
+ link.download = `${node.title || 'canvas-image'}.${asset.assetId.split('.').pop() ?? 'png'}`
668
+ link.target = '_blank'
669
+ link.rel = 'noopener'
670
+ link.click()
671
+ }, [])
672
+
673
+ const upstreamNodes = useCallback((canvasDocument: CanvasDocument, nodeId: string): CanvasNode[] => {
674
+ const byId = new Map(canvasDocument.nodes.map(node => [node.id, node]))
675
+ return canvasDocument.connections
676
+ .filter(connection => connection.toNodeId === nodeId)
677
+ .map(connection => byId.get(connection.fromNodeId))
678
+ .filter((node): node is CanvasNode => node !== undefined)
679
+ }, [])
680
+
681
+ // ----------------------------------------------------------- generation
682
+
683
+ const submitComposer = useCallback(async (target: CanvasNode | null): Promise<void> => {
684
+ const current = documentRef.current
685
+ if (current === null || composerBusy) return
686
+ if (!connected) { setError(tt('canvas.needApi')); onOpenSettings?.(); return }
687
+ const inputs = target === null ? [] : upstreamNodes(current, target.id)
688
+ const referenceImages = inputs.filter(node => node.type === 'image' && usableAsset(node) !== undefined)
689
+ const upstreamText = inputs.filter(node => node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '').map(node => nodeMetadata(node).text!.trim())
690
+ const prompt = (composerPrompt.trim() !== '' ? composerPrompt.trim() : upstreamText.join('\n').trim())
691
+ if (prompt === '') { setError(tt('canvas.needPrompt')); return }
692
+ const model = imageModels.includes(composerModel) ? composerModel : imageModels[0] ?? ''
693
+ if (model === '') { setError(tt('canvas.needModel')); return }
694
+ const count = Math.min(4, Math.max(1, Math.round(composerCount)))
695
+ const baseAsset = referenceImages[0] !== undefined ? usableAsset(referenceImages[0]!) : undefined
696
+ setComposerBusy(true)
697
+ try {
698
+ let image: string | undefined
699
+ let images: string[] | undefined
700
+ let refName: string | undefined
701
+ if (baseAsset !== undefined) {
702
+ image = await assetToDataUrl(baseAsset)
703
+ refName = 'canvas-reference.png'
704
+ const extras: string[] = []
705
+ for (const reference of referenceImages.slice(1, 4)) {
706
+ const asset = usableAsset(reference)
707
+ if (asset === undefined) continue
708
+ try { extras.push(await assetToDataUrl(asset)) } catch { /* skip unreadable reference */ }
709
+ }
710
+ if (extras.length > 0) images = extras
711
+ }
712
+ const footprint = nodeSizeFromRatio(composerSize, IMAGE_NODE_SIZE)
713
+ const request: GenerateRequest = {
714
+ mode: image === undefined ? 'text' : 'edit', model, prompt, size: composerSize, quality: composerQuality, n: count, detail: '',
715
+ ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
716
+ ...(image === undefined ? {} : { image, refName }),
717
+ ...(images === undefined ? {} : { images }),
718
+ canvas: {
719
+ canvasId: current.id,
720
+ ...(target === null ? {} : { sourceNodeId: referenceImages[0]?.id ?? target.id, parentNodeId: target.id, placement: 'right' as const }),
721
+ },
722
+ }
723
+ const task = await api.taskSubmit(request)
724
+ localTaskIds.current.add(task.id)
725
+ mutate(previous => {
726
+ const nodes = [...previous.nodes]
727
+ const connections = [...previous.connections]
728
+ const anchor = target !== null ? previous.nodes.find(node => node.id === target.id) : undefined
729
+ const originX = anchor !== undefined ? anchor.x + anchor.width + 80 : Math.round(canvasCenter().x - footprint.width / 2)
730
+ const originY = anchor !== undefined ? anchor.y : Math.round(canvasCenter().y - footprint.height / 2)
731
+ for (let index = 0; index < count; index += 1) {
732
+ const id = newId('node')
733
+ nodes.push({
734
+ id, type: 'image', title: tt('canvas.imageNode'),
735
+ x: Math.round(originX), y: Math.round(originY + index * (footprint.height + 48)),
736
+ width: footprint.width, height: footprint.height,
737
+ metadata: { status: 'generating', taskId: task.id, ...(anchor !== undefined ? { sourceNodeId: anchor.id } : {}), prompt, model },
738
+ })
739
+ if (anchor !== undefined) connections.push({ id: newId('edge'), fromNodeId: anchor.id, toNodeId: id })
740
+ }
741
+ return { ...previous, nodes, connections }
742
+ })
743
+ setComposerPrompt('')
744
+ setError(null)
745
+ } catch (caught) {
746
+ setError(caught instanceof Error ? caught.message : String(caught))
747
+ } finally {
748
+ setComposerBusy(false)
749
+ }
750
+ }, [api, canvasCenter, composerBusy, composerCount, composerModel, composerPrompt, composerQuality, composerSize, connected, defaultChannelId, imageModels, mutate, onOpenSettings, upstreamNodes])
751
+
752
+ // ---------------------------------------------------------- task intake
753
+
754
+ /** Re-run a failed image node's generation from its recorded prompt/model,
755
+ * re-deriving the edit base from the connected source config node. */
756
+ const retryGeneration = useCallback(async (node: CanvasNode): Promise<void> => {
757
+ const current = documentRef.current
758
+ if (current === null || !connected) { setError(tt('canvas.needApi')); onOpenSettings?.(); return }
759
+ const metadata = nodeMetadata(node)
760
+ const prompt = (metadata.prompt ?? '').trim()
761
+ if (prompt === '') { setError(tt('canvas.needPrompt')); return }
762
+ const model = imageModels.includes(metadata.model ?? '') ? metadata.model! : imageModels[0] ?? ''
763
+ if (model === '') { setError(tt('canvas.needModel')); return }
764
+ const sourceId = metadata.sourceNodeId
765
+ const references = sourceId === undefined ? [] : upstreamNodes(current, sourceId).filter(item => item.type === 'image' && usableAsset(item) !== undefined)
766
+ const baseAsset = references[0] !== undefined ? usableAsset(references[0]!) : undefined
767
+ try {
768
+ let image: string | undefined
769
+ let images: string[] | undefined
770
+ let refName: string | undefined
771
+ if (baseAsset !== undefined) {
772
+ image = await assetToDataUrl(baseAsset)
773
+ refName = 'canvas-reference.png'
774
+ const extras: string[] = []
775
+ for (const reference of references.slice(1, 4)) {
776
+ const asset = usableAsset(reference)
777
+ if (asset === undefined) continue
778
+ try { extras.push(await assetToDataUrl(asset)) } catch { /* skip unreadable reference */ }
779
+ }
780
+ if (extras.length > 0) images = extras
781
+ }
782
+ const request: GenerateRequest = {
783
+ mode: image === undefined ? 'text' : 'edit', model, prompt, size: metadata.size ?? 'auto', quality: metadata.quality ?? 'auto', n: 1, detail: '',
784
+ ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
785
+ ...(image === undefined ? {} : { image, refName }),
786
+ ...(images === undefined ? {} : { images }),
787
+ canvas: { canvasId: current.id, sourceNodeId: references[0]?.id ?? sourceId, parentNodeId: node.id, placement: 'right' as const },
788
+ }
789
+ const task = await api.taskSubmit(request)
790
+ localTaskIds.current.add(task.id)
791
+ patchNode(node.id, { status: 'generating', error: undefined, taskId: task.id })
792
+ setError(null)
793
+ } catch (caught) {
794
+ setError(caught instanceof Error ? caught.message : String(caught))
795
+ }
796
+ }, [api, connected, defaultChannelId, imageModels, onOpenSettings, patchNode, upstreamNodes])
797
+
798
+ // Orphan reconciliation: a generating placeholder whose task no longer exists
799
+ // in the host feed (e.g. the host restarted) can never complete on its own.
800
+ useEffect(() => {
801
+ if (document === null) return
802
+ const feedFresh = tasks.length > 0 || Date.now() - mountedAtRef.current > 8000
803
+ if (!feedFresh) return
804
+ const feedIds = new Set(tasks.map(task => task.id))
805
+ const orphans = document.nodes.filter(node => {
806
+ if (node.type !== 'image' || nodeMetadata(node).status !== 'generating') return false
807
+ const taskId = nodeMetadata(node).taskId
808
+ return taskId !== undefined && !feedIds.has(taskId) && !localTaskIds.current.has(taskId)
809
+ })
810
+ if (orphans.length === 0) return
811
+ updateNodes(nodes => nodes.map(node => {
812
+ const taskId = node.type === 'image' ? nodeMetadata(node).taskId : undefined
813
+ if (node.type !== 'image' || nodeMetadata(node).status !== 'generating' || taskId === undefined
814
+ || feedIds.has(taskId) || localTaskIds.current.has(taskId)) return node
815
+ return { ...node, metadata: { ...nodeMetadata(node), status: 'error', error: tt('canvas.taskLost') } }
816
+ }))
817
+ }, [document, tasks, updateNodes])
818
+
819
+ useEffect(() => {
820
+ if (document === null) return
821
+ const canvasTasks = tasks.filter(task => task.request.canvas?.canvasId === document.id)
822
+ for (const task of canvasTasks) {
823
+ if (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') continue
824
+ if (processedTasks.current.has(task.id)) continue
825
+ const targets = document.nodes.filter(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating')
826
+ if (targets.length === 0) continue
827
+ processedTasks.current.add(task.id)
828
+ const sourceId = nodeMetadata(targets[0]!).sourceNodeId
829
+ const fail = (message: string): void => {
830
+ updateNodes(nodes => nodes.map(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating'
831
+ ? { ...node, metadata: { ...nodeMetadata(node), status: 'error', error: message } }
832
+ : node))
833
+ }
834
+ if (task.status !== 'completed' || task.result === undefined || task.result.images.length === 0) {
835
+ fail(task.error ?? tt('canvas.generateFailed'))
836
+ continue
837
+ }
838
+ void (async () => {
839
+ const assets: CanvasAssetRef[] = []
840
+ for (const image of task.result!.images) {
841
+ const dataUrl = imageDataUrl(image)
842
+ const dimensions = await readImageSize(dataUrl)
843
+ assets.push(await api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'generated', originId: task.id }))
844
+ }
845
+ updateDocument(previous => {
846
+ const ordered = previous.nodes.filter(node => node.type === 'image' && nodeMetadata(node).taskId === task.id && nodeMetadata(node).status === 'generating')
847
+ if (ordered.length === 0) return previous
848
+ const last = ordered[ordered.length - 1]!
849
+ const nodes = previous.nodes.map(node => {
850
+ const index = ordered.indexOf(node)
851
+ if (index < 0) return node
852
+ const asset = assets[index]
853
+ return asset === undefined
854
+ ? { ...node, metadata: { ...nodeMetadata(node), status: 'error' as const, error: tt('canvas.generateFailed') } }
855
+ : { ...node, metadata: { ...nodeMetadata(node), asset, status: 'success' as const, error: undefined } }
856
+ })
857
+ // More results than placeholders: append sibling nodes below the last one.
858
+ const siblings: CanvasNode[] = []
859
+ const connections: CanvasConnection[] = []
860
+ assets.slice(ordered.length).forEach((asset, offset) => {
861
+ const id = newId('node')
862
+ siblings.push({
863
+ id, type: 'image', title: tt('canvas.imageNode'),
864
+ x: Math.round(last.x), y: Math.round(last.y + (ordered.length + offset) * (last.height + 48)),
865
+ width: last.width, height: last.height,
866
+ metadata: { status: 'success', asset, taskId: task.id, ...(sourceId === undefined ? {} : { sourceNodeId: sourceId }) },
867
+ })
868
+ if (sourceId !== undefined) connections.push({ id: newId('edge'), fromNodeId: sourceId, toNodeId: id })
869
+ })
870
+ return { ...previous, nodes: [...nodes, ...siblings], connections: [...previous.connections, ...connections] }
871
+ })
872
+ })().catch(caught => fail(caught instanceof Error ? caught.message : String(caught)))
873
+ }
874
+ }, [api, document, tasks, updateDocument, updateNodes])
875
+
876
+ // ------------------------------------------------------- import intake
877
+
878
+ const addAssets = useCallback((assets: CanvasAssetRef[], position?: Point): void => {
879
+ if (assets.length === 0) return
880
+ const center = position ?? canvasCenter()
881
+ mutate(previous => {
882
+ const nodes = assets.map((asset, index) => {
883
+ const node = createImageNode(asset)
884
+ return { ...node, x: node.x + (index % 3) * (IMAGE_NODE_SIZE.width + 40), y: node.y + Math.floor(index / 3) * (IMAGE_NODE_SIZE.height + 40) }
885
+ })
886
+ return { ...previous, nodes: [...previous.nodes, ...nodes] }
887
+ })
888
+ setSelectedIds(new Set())
889
+ }, [canvasCenter, createImageNode, mutate])
890
+
891
+ useEffect(() => {
892
+ if (importRequest === undefined) {
893
+ processedImport.current = ''
894
+ return
895
+ }
896
+ if (document === null) return
897
+ const requestKey = `${importRequest.source}:${importRequest.entryId}:${importRequest.imageIndex}`
898
+ if (processedImport.current === requestKey) return
899
+ const sourceEntries = importRequest.source === 'history' ? history : gallery
900
+ const entry = sourceEntries.find(item => item.id === importRequest.entryId)
901
+ const image = entry?.images[importRequest.imageIndex]
902
+ if (entry === undefined || image === undefined) {
903
+ processedImport.current = requestKey
904
+ onImportRequestHandled?.()
905
+ return
906
+ }
907
+ processedImport.current = requestKey
908
+ void (async () => {
909
+ const dimensions = await readImageSize(image.url)
910
+ const asset = await api.canvasImport(importRequest.source, importRequest.entryId, importRequest.imageIndex, dimensions.width, dimensions.height)
911
+ addAssets([asset])
912
+ onImportRequestHandled?.()
913
+ })().catch(caught => {
914
+ setError(caught instanceof Error ? caught.message : String(caught))
915
+ onImportRequestHandled?.()
916
+ })
917
+ }, [addAssets, api, document, gallery, history, importRequest, onImportRequestHandled])
918
+
919
+ // -------------------------------------------------------------- loading
920
+
921
+ useEffect(() => {
922
+ let disposed = false
923
+ void api.canvasList().then(async list => {
924
+ if (disposed) return
925
+ const created = list[0] === undefined ? await api.canvasCreate(tt('canvas.untitled')) : null
926
+ const first = created === null ? await api.canvasRead(list[0]!.id) : seedDocument(created)
927
+ if (disposed) return
928
+ setProjects(created === null ? list : [summaryOf(first)])
929
+ setDocument(normalizeConfigNodeSizes(first))
930
+ syncedRef.current = JSON.stringify(created ?? first)
931
+ setSaveState('saved')
932
+ }).catch(caught => { if (!disposed) { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') } })
933
+ return () => { disposed = true }
934
+ }, [api, seedDocument])
935
+
936
+ useEffect(() => {
937
+ if (document === null || saveState === 'loading') return
938
+ const key = JSON.stringify(document)
939
+ if (key === syncedRef.current) return
940
+ setSaveState('saving')
941
+ const timer = window.setTimeout(() => {
942
+ const saveWithRetry = async (): Promise<CanvasDocument> => {
943
+ try {
944
+ return await api.canvasSave(document, document.revision)
945
+ } catch (caught) {
946
+ // Another window saved the same canvas meanwhile: rebase on the
947
+ // server revision and retry once so concurrent editing self-heals.
948
+ const message = caught instanceof Error ? caught.message : String(caught)
949
+ if (!message.includes('其他窗口')) throw caught
950
+ const server = await api.canvasRead(document.id)
951
+ return await api.canvasSave(document, server.revision)
952
+ }
953
+ }
954
+ void saveWithRetry().then(next => {
955
+ syncedRef.current = JSON.stringify(next)
956
+ setDocument(next)
957
+ setProjects(previous => [summaryOf(next), ...previous.filter(item => item.id !== next.id)])
958
+ setSaveState('saved')
959
+ }).catch(caught => { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') })
960
+ }, 650)
961
+ return () => window.clearTimeout(timer)
962
+ }, [api, document, saveState])
963
+
964
+ // ---------------------------------------------------------- composer sync
965
+
966
+ const singleSelectedId = selectedIds.size === 1 ? [...selectedIds][0]! : null
967
+ const singleSelected = useMemo(() => document?.nodes.find(node => node.id === singleSelectedId) ?? null, [document, singleSelectedId])
968
+ const composerTarget = singleSelected !== null && singleSelected.type === 'config' ? singleSelected : null
969
+ const composerInputs = useMemo(
970
+ () => composerTarget === null || document === null ? [] : upstreamNodes(document, composerTarget.id),
971
+ [composerTarget, document, upstreamNodes],
972
+ )
973
+ const composerReferenceCount = composerInputs.filter(node => node.type === 'image' && usableAsset(node) !== undefined).length
974
+ const composerTextCount = composerInputs.filter(node => node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '').length
975
+ const composerVisible = composerTarget !== null
976
+
977
+ // Prefill the prompt from connected text nodes whenever the target changes.
978
+ useEffect(() => {
979
+ const targetId = composerTarget?.id ?? null
980
+ if (targetId === composerTargetRef.current) return
981
+ composerTargetRef.current = targetId
982
+ if (composerTarget === null) return
983
+ const texts = (document?.connections ?? [])
984
+ .filter(connection => connection.toNodeId === composerTarget.id)
985
+ .map(connection => document?.nodes.find(node => node.id === connection.fromNodeId))
986
+ .filter((node): node is CanvasNode => node !== undefined && node.type === 'text' && (nodeMetadata(node).text ?? '').trim() !== '')
987
+ .map(node => nodeMetadata(node).text!.trim())
988
+ setComposerPrompt(texts.join('\n'))
989
+ }, [composerTarget, document])
990
+
991
+ // ------------------------------------------------------------ keyboard
992
+
993
+ useEffect(() => {
994
+ const isEditingTarget = (target: EventTarget | null): boolean => target instanceof Element
995
+ && (target.matches('input, textarea, select, [contenteditable="true"]'))
996
+
997
+ const onKeyDown = (event: KeyboardEvent): void => {
998
+ if (event.key === 'Control') setCtrlPressed(true)
999
+ if (event.code === 'Space' && !isEditingTarget(event.target)) {
1000
+ event.preventDefault()
1001
+ setSpacePressed(true)
1002
+ }
1003
+ if (documentRef.current === null) return
1004
+ const mod = event.ctrlKey || event.metaKey
1005
+ if (event.key === 'Escape') {
1006
+ setContextMenu(null); setCreateMenu(null); setBackgroundMenu(null); setImageMenu(null); setNodeAddMenu(null)
1007
+ if (!isEditingTarget(event.target)) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
1008
+ return
1009
+ }
1010
+ if (isEditingTarget(event.target)) return
1011
+ if (mod && event.key.toLowerCase() === 'z') {
1012
+ event.preventDefault()
1013
+ if (event.shiftKey) redo(); else undo()
1014
+ } else if (mod && event.key.toLowerCase() === 'y') {
1015
+ event.preventDefault(); redo()
1016
+ } else if (mod && event.key.toLowerCase() === 'c') {
1017
+ copySelection()
1018
+ } else if (mod && event.key.toLowerCase() === 'v') {
1019
+ pasteClipboard()
1020
+ } else if (mod && event.key.toLowerCase() === 'd') {
1021
+ event.preventDefault(); duplicateSelection()
1022
+ } else if (mod && event.key.toLowerCase() === 'a') {
1023
+ event.preventDefault()
1024
+ const nodes = documentRef.current?.nodes ?? []
1025
+ setSelectedIds(new Set(nodes.map(node => node.id)))
1026
+ } else if (event.key === 'Delete' || event.key === 'Backspace') {
1027
+ event.preventDefault(); deleteSelection()
1028
+ }
1029
+ }
1030
+ const onKeyUp = (event: KeyboardEvent): void => {
1031
+ if (event.code === 'Space') setSpacePressed(false)
1032
+ if (event.key === 'Control') setCtrlPressed(false)
1033
+ }
1034
+ const onBlur = (): void => { setSpacePressed(false); setCtrlPressed(false) }
1035
+ const onPaste = (event: ClipboardEvent): void => {
1036
+ if (isEditingTarget(event.target)) return
1037
+ const files = [...(event.clipboardData?.files ?? [])].filter(file => file.type.startsWith('image/'))
1038
+ if (files.length > 0) {
1039
+ event.preventDefault()
1040
+ void Promise.all(files.map(async file => {
1041
+ 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) })
1042
+ const dimensions = await readImageSize(dataUrl)
1043
+ return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
1044
+ })).then(assets => addAssets(assets, canvasCenter())).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
1045
+ return
1046
+ }
1047
+ pasteClipboard()
1048
+ }
1049
+ window.addEventListener('keydown', onKeyDown)
1050
+ window.addEventListener('keyup', onKeyUp)
1051
+ window.addEventListener('blur', onBlur)
1052
+ window.addEventListener('paste', onPaste)
1053
+ return () => {
1054
+ window.removeEventListener('keydown', onKeyDown)
1055
+ window.removeEventListener('keyup', onKeyUp)
1056
+ window.removeEventListener('blur', onBlur)
1057
+ window.removeEventListener('paste', onPaste)
1058
+ }
1059
+ }, [addAssets, api, canvasCenter, copySelection, deleteSelection, duplicateSelection, pasteClipboard, redo, undo])
1060
+
1061
+ // ------------------------------------------------------ viewport events
1062
+
1063
+ useEffect(() => {
1064
+ const container = viewportRef.current
1065
+ if (container === null) return
1066
+ const measure = (): void => setViewportSize({ width: container.clientWidth, height: container.clientHeight })
1067
+ measure()
1068
+ const observer = new ResizeObserver(measure)
1069
+ observer.observe(container)
1070
+ const preventWheel = (event: WheelEvent): void => {
1071
+ if (event.target instanceof Element && event.target.closest(`[data-canvas-no-zoom]`)) return
1072
+ event.preventDefault()
1073
+ }
1074
+ container.addEventListener('wheel', preventWheel, { passive: false })
1075
+ return () => { observer.disconnect(); container.removeEventListener('wheel', preventWheel) }
1076
+ }, [])
1077
+
1078
+ const temporaryPanTool = spacePressed || ctrlPressed
1079
+
1080
+ const onViewportPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
1081
+ const target = event.target instanceof Element ? event.target : null
1082
+ setContextMenu(null); setCreateMenu(null); setNodeAddMenu(null)
1083
+ if (!target?.closest('[data-canvas-no-zoom]')) { setBackgroundMenu(null); setImageMenu(null) }
1084
+ const isBackground = target?.closest('[data-node-id],[data-connection-hit]') === null
1085
+ const shouldPan = event.button === 1 || (event.button === 0 && (tool === 'pan' || temporaryPanTool) && isBackground)
1086
+ if (shouldPan) {
1087
+ event.preventDefault()
1088
+ event.currentTarget.setPointerCapture(event.pointerId)
1089
+ const current = documentRef.current
1090
+ if (current !== null) {
1091
+ panRef.current = { startX: event.clientX, startY: event.clientY, viewportX: current.viewport.x, viewportY: current.viewport.y, hasMoved: false, startedOnBackground: isBackground }
1092
+ }
1093
+ return
1094
+ }
1095
+ if (event.button === 0 && isBackground && tool === 'select') {
1096
+ event.preventDefault()
1097
+ event.currentTarget.setPointerCapture(event.pointerId)
1098
+ const world = screenToWorld(event.clientX, event.clientY)
1099
+ const next: MarqueeState = { start: world, current: world, additive: event.shiftKey, initialIds: event.shiftKey ? [...selectedIdsRef.current] : [] }
1100
+ marqueeRef.current = next
1101
+ setMarquee(next)
1102
+ if (!event.shiftKey) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
1103
+ }
1104
+ }
1105
+
1106
+ const onWheel = (event: React.WheelEvent<HTMLDivElement>): void => {
1107
+ const current = documentRef.current
1108
+ if (current === null) return
1109
+ if (event.target instanceof Element && event.target.closest('[data-canvas-no-zoom]')) return
1110
+ event.preventDefault()
1111
+ const bounds = viewportRef.current?.getBoundingClientRect()
1112
+ if (bounds === undefined) return
1113
+ const mouseX = event.clientX - bounds.left
1114
+ const mouseY = event.clientY - bounds.top
1115
+ const scale = clampScale(current.viewport.k * Math.pow(1.1, -event.deltaY / 100))
1116
+ const worldX = (mouseX - current.viewport.x) / current.viewport.k
1117
+ const worldY = (mouseY - current.viewport.y) / current.viewport.k
1118
+ setViewport({ x: mouseX - worldX * scale, y: mouseY - worldY * scale, k: scale })
1119
+ }
1120
+
1121
+ const setZoomAtCenter = useCallback((scale: number): void => {
1122
+ const current = documentRef.current
1123
+ const bounds = viewportRef.current?.getBoundingClientRect()
1124
+ if (current === null || bounds === undefined) return
1125
+ const next = clampScale(scale)
1126
+ const centerX = bounds.width / 2
1127
+ const centerY = bounds.height / 2
1128
+ const worldX = (centerX - current.viewport.x) / current.viewport.k
1129
+ const worldY = (centerY - current.viewport.y) / current.viewport.k
1130
+ setViewport({ x: centerX - worldX * next, y: centerY - worldY * next, k: next })
1131
+ }, [setViewport])
1132
+
1133
+ const fitView = useCallback((): void => {
1134
+ const current = documentRef.current
1135
+ const bounds = viewportRef.current?.getBoundingClientRect()
1136
+ if (current === null || bounds === undefined) return
1137
+ if (current.nodes.length === 0) {
1138
+ setViewport({ x: 0, y: 0, k: 1 })
1139
+ return
1140
+ }
1141
+ const content = nodesBounds(current.nodes)
1142
+ const padding = 80
1143
+ const contentWidth = Math.max(1, content.maxX - content.minX)
1144
+ const contentHeight = Math.max(1, content.maxY - content.minY)
1145
+ const scale = clampScale(Math.min((bounds.width - padding * 2) / contentWidth, (bounds.height - padding * 2) / contentHeight))
1146
+ setViewport({
1147
+ k: scale,
1148
+ x: (bounds.width - contentWidth * scale) / 2 - content.minX * scale,
1149
+ y: (bounds.height - contentHeight * scale) / 2 - content.minY * scale,
1150
+ })
1151
+ }, [setViewport])
1152
+
1153
+ // -------------------------------------------------- global move / up
1154
+
1155
+ useEffect(() => {
1156
+ const move = (event: PointerEvent): void => {
1157
+ const drag = dragRef.current
1158
+ if (drag !== null) {
1159
+ const scale = documentRef.current?.viewport.k ?? 1
1160
+ const dx = (event.clientX - drag.startX) / scale
1161
+ const dy = (event.clientY - drag.startY) / scale
1162
+ if (!drag.moved && Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 3) {
1163
+ drag.moved = true
1164
+ commitSnapshot(drag.snapshot)
1165
+ }
1166
+ if (drag.moved) {
1167
+ updateNodes(nodes => nodes.map(node => {
1168
+ const origin = drag.origins.get(node.id)
1169
+ return origin === undefined ? node : { ...node, x: Math.round(origin.x + dx), y: Math.round(origin.y + dy) }
1170
+ }))
1171
+ }
1172
+ return
1173
+ }
1174
+ const connect = connectRef.current
1175
+ if (connect !== null) {
1176
+ const world = screenToWorld(event.clientX, event.clientY)
1177
+ if (!connect.moved && Math.hypot(event.clientX - connect.startClient.x, event.clientY - connect.startClient.y) > 4) {
1178
+ connect.moved = true
1179
+ setNodeAddMenu(null)
1180
+ }
1181
+ const nodes = documentRef.current?.nodes ?? []
1182
+ let targetId: string | null = null
1183
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
1184
+ const node = nodes[index]!
1185
+ if (node.id === connect.nodeId) continue
1186
+ if (world.x >= node.x && world.x <= node.x + node.width && world.y >= node.y && world.y <= node.y + node.height) {
1187
+ targetId = node.id
1188
+ break
1189
+ }
1190
+ }
1191
+ const next = { ...connect, mouse: world, targetId }
1192
+ connectRef.current = next
1193
+ setConnecting(next)
1194
+ return
1195
+ }
1196
+ const resize = resizeRef.current
1197
+ if (resize !== null) {
1198
+ const scale = documentRef.current?.viewport.k ?? 1
1199
+ const dx = (event.clientX - resize.startX) / scale
1200
+ const dy = (event.clientY - resize.startY) / scale
1201
+ const minWidth = 140
1202
+ const minHeight = 100
1203
+ let width = Math.max(minWidth, resize.width + (resize.corner === 'bottom-right' ? dx : -dx))
1204
+ let height = Math.max(minHeight, resize.height + dy)
1205
+ if (resize.ratio !== null) height = Math.max(minHeight, Math.round(width * resize.ratio))
1206
+ updateNodes(nodes => nodes.map(node => node.id === resize.nodeId
1207
+ ? { ...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) }
1208
+ : node))
1209
+ return
1210
+ }
1211
+ const activeMarquee = marqueeRef.current
1212
+ if (activeMarquee !== null) {
1213
+ const next = { ...activeMarquee, current: screenToWorld(event.clientX, event.clientY) }
1214
+ marqueeRef.current = next
1215
+ setMarquee(next)
1216
+ return
1217
+ }
1218
+ const pan = panRef.current
1219
+ if (pan !== null) {
1220
+ const dx = event.clientX - pan.startX
1221
+ const dy = event.clientY - pan.startY
1222
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) pan.hasMoved = true
1223
+ const next = { x: pan.viewportX + dx, y: pan.viewportY + dy }
1224
+ if (panFrameRef.current !== null) return
1225
+ panFrameRef.current = requestAnimationFrame(() => {
1226
+ panFrameRef.current = null
1227
+ updateDocument(previous => ({ ...previous, viewport: { ...previous.viewport, x: next.x, y: next.y } }))
1228
+ })
1229
+ }
1230
+ }
1231
+
1232
+ const up = (): void => {
1233
+ const drag = dragRef.current
1234
+ if (drag !== null) {
1235
+ dragRef.current = null
1236
+ return
1237
+ }
1238
+ const connect = connectRef.current
1239
+ if (connect !== null) {
1240
+ connectRef.current = null
1241
+ setConnecting(null)
1242
+ if (connect.targetId !== null) {
1243
+ if (connect.handleType === 'source') connectNodes(connect.nodeId, connect.targetId)
1244
+ else connectNodes(connect.targetId, connect.nodeId)
1245
+ }
1246
+ return
1247
+ }
1248
+ const resize = resizeRef.current
1249
+ if (resize !== null) {
1250
+ resizeRef.current = null
1251
+ return
1252
+ }
1253
+ const activeMarquee = marqueeRef.current
1254
+ if (activeMarquee !== null) {
1255
+ marqueeRef.current = null
1256
+ setMarquee(null)
1257
+ const minX = Math.min(activeMarquee.start.x, activeMarquee.current.x)
1258
+ const minY = Math.min(activeMarquee.start.y, activeMarquee.current.y)
1259
+ const maxX = Math.max(activeMarquee.start.x, activeMarquee.current.x)
1260
+ const maxY = Math.max(activeMarquee.start.y, activeMarquee.current.y)
1261
+ const nodes = documentRef.current?.nodes ?? []
1262
+ 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)
1263
+ if (Math.abs(activeMarquee.current.x - activeMarquee.start.x) < 4 && Math.abs(activeMarquee.current.y - activeMarquee.start.y) < 4) {
1264
+ setSelectedConnectionId(null)
1265
+ return
1266
+ }
1267
+ const next = activeMarquee.additive
1268
+ ? new Set([...activeMarquee.initialIds, ...hits])
1269
+ : new Set(hits)
1270
+ setSelectedIds(next)
1271
+ setSelectedConnectionId(null)
1272
+ return
1273
+ }
1274
+ const pan = panRef.current
1275
+ if (pan !== null) {
1276
+ panRef.current = null
1277
+ if (!pan.hasMoved && pan.startedOnBackground) {
1278
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
1279
+ }
1280
+ }
1281
+ }
1282
+
1283
+ window.addEventListener('pointermove', move)
1284
+ window.addEventListener('pointerup', up)
1285
+ window.addEventListener('pointercancel', up)
1286
+ return () => {
1287
+ window.removeEventListener('pointermove', move)
1288
+ window.removeEventListener('pointerup', up)
1289
+ window.removeEventListener('pointercancel', up)
1290
+ }
1291
+ }, [commitSnapshot, connectNodes, screenToWorld, updateDocument, updateNodes])
1292
+
1293
+ // --------------------------------------------------------- node events
1294
+
1295
+ const handleNodePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, nodeId: string): void => {
1296
+ if (event.button !== 0 || tool === 'pan' || temporaryPanTool) return
1297
+ const current = documentRef.current
1298
+ if (current === null) return
1299
+ const node = current.nodes.find(item => item.id === nodeId)
1300
+ if (node === undefined) return
1301
+ event.stopPropagation()
1302
+ const additive = event.shiftKey || event.ctrlKey || event.metaKey
1303
+ setNodeAddMenu(null)
1304
+ let nextSelection = selectedIdsRef.current
1305
+ if (additive) {
1306
+ nextSelection = new Set(selectedIdsRef.current)
1307
+ if (nextSelection.has(nodeId)) nextSelection.delete(nodeId)
1308
+ else nextSelection.add(nodeId)
1309
+ } else if (!nextSelection.has(nodeId)) {
1310
+ nextSelection = new Set([nodeId])
1311
+ }
1312
+ setSelectedIds(nextSelection)
1313
+ setSelectedConnectionId(null)
1314
+ const origins = new Map<string, Point>()
1315
+ for (const id of nextSelection) {
1316
+ const item = current.nodes.find(candidate => candidate.id === id)
1317
+ if (item !== undefined) origins.set(id, { x: item.x, y: item.y })
1318
+ }
1319
+ dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false, snapshot: JSON.stringify(current), origins }
1320
+ }, [temporaryPanTool, tool])
1321
+
1322
+ const handleConnectStart = useCallback((event: ReactPointerEvent<HTMLDivElement>, nodeId: string, handleType: 'source' | 'target'): void => {
1323
+ if (event.button !== 0) return
1324
+ event.stopPropagation(); event.preventDefault()
1325
+ clearNodeAddMenuTimer(); setNodeAddMenu(null)
1326
+ const world = screenToWorld(event.clientX, event.clientY)
1327
+ const next: ConnectState = { nodeId, handleType, mouse: world, targetId: null, moved: false, startClient: { x: event.clientX, y: event.clientY } }
1328
+ connectRef.current = next
1329
+ setConnecting(next)
1330
+ setSelectedConnectionId(null)
1331
+ }, [clearNodeAddMenuTimer, screenToWorld])
1332
+
1333
+ const handleResizeStart = useCallback((event: ReactPointerEvent<HTMLDivElement>, node: CanvasNode, corner: 'bottom-right' | 'bottom-left'): void => {
1334
+ if (event.button !== 0) return
1335
+ event.stopPropagation(); event.preventDefault()
1336
+ const asset = assetOf(node)
1337
+ const ratio = node.type === 'image' && asset !== undefined && asset.width > 0 && asset.height > 0 ? asset.width / asset.height : null
1338
+ 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 }
1339
+ beginHistory()
1340
+ }, [beginHistory])
1341
+
1342
+ const handleConnectionSelect = useCallback((connectionId: string): void => {
1343
+ setSelectedConnectionId(connectionId)
1344
+ setSelectedIds(new Set())
1345
+ }, [])
1346
+
1347
+ // -------------------------------------------------------- file dropping
1348
+
1349
+ const onDrop = useCallback((event: React.DragEvent<HTMLDivElement>): void => {
1350
+ event.preventDefault()
1351
+ const files = [...(event.dataTransfer.files ?? [])].filter(file => file.type.startsWith('image/'))
1352
+ if (files.length === 0) return
1353
+ const world = screenToWorld(event.clientX, event.clientY)
1354
+ void Promise.all(files.map(async file => {
1355
+ 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) })
1356
+ const dimensions = await readImageSize(dataUrl)
1357
+ return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
1358
+ })).then(assets => addAssets(assets, world)).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
1359
+ }, [addAssets, api, screenToWorld])
1360
+
1361
+ // ------------------------------------------------------------ projects
1362
+
1363
+ const newCanvas = useCallback(async (): Promise<void> => {
1364
+ try {
1365
+ const created = await api.canvasCreate(tt('canvas.untitled'))
1366
+ const next = seedDocument(created)
1367
+ setProjects(previous => [summaryOf(next), ...previous])
1368
+ setDocument(next); setSelectedIds(new Set()); setSelectedConnectionId(null)
1369
+ syncedRef.current = JSON.stringify(created); setSaveState('saved')
1370
+ pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1371
+ } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1372
+ }, [api, seedDocument])
1373
+
1374
+ const selectProject = useCallback(async (id: string): Promise<void> => {
1375
+ try {
1376
+ const next = await api.canvasRead(id)
1377
+ setDocument(normalizeConfigNodeSizes(next)); setSelectedIds(new Set()); setSelectedConnectionId(null)
1378
+ syncedRef.current = JSON.stringify(next); setSaveState('saved')
1379
+ pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1380
+ } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1381
+ }, [api])
1382
+
1383
+ const removeCurrentProject = useCallback(async (): Promise<void> => {
1384
+ const current = documentRef.current
1385
+ if (current === null) return
1386
+ try {
1387
+ const remaining = await api.canvasRemove(current.id)
1388
+ setConfirmDeleteProject(false)
1389
+ const nextId = remaining[0]?.id
1390
+ if (nextId === undefined) {
1391
+ const created = await api.canvasCreate(tt('canvas.untitled'))
1392
+ const created2 = seedDocument(created)
1393
+ setProjects([summaryOf(created2)]); setDocument(created2)
1394
+ syncedRef.current = JSON.stringify(created); setSaveState('saved')
1395
+ } else {
1396
+ setProjects(remaining)
1397
+ await selectProject(nextId)
1398
+ }
1399
+ setSelectedIds(new Set()); setSelectedConnectionId(null)
1400
+ } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1401
+ }, [api, selectProject, seedDocument])
1402
+
1403
+ // -------------------------------------------------------------- derived
1404
+
1405
+ const nodeById = useMemo(() => new Map((document?.nodes ?? []).map(node => [node.id, node])), [document])
1406
+ const relatedIds = useMemo(() => {
1407
+ const related = new Set<string>()
1408
+ if (document === null) return related
1409
+ for (const connection of document.connections) {
1410
+ if (selectedIds.has(connection.fromNodeId)) related.add(connection.toNodeId)
1411
+ if (selectedIds.has(connection.toNodeId)) related.add(connection.fromNodeId)
1412
+ }
1413
+ return related
1414
+ }, [document, selectedIds])
1415
+
1416
+ const isSpaceOrCtrl = temporaryPanTool
1417
+ const cursorClass = tool === 'pan' || isSpaceOrCtrl ? css.panCursor : css.selectCursor
1418
+
1419
+ const backgroundMode = document?.background ?? 'liquid'
1420
+ const setBackgroundMode = useCallback((mode: BackgroundMode): void => {
1421
+ mutate(previous => ({
1422
+ ...previous,
1423
+ background: mode,
1424
+ ...(mode === 'image' ? {} : { backgroundImage: undefined }),
1425
+ }))
1426
+ setBackgroundMenu(null)
1427
+ }, [mutate])
1428
+
1429
+ const uploadBackgroundImage = useCallback(async (file: File): Promise<void> => {
1430
+ try {
1431
+ 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) })
1432
+ const dimensions = await readImageSize(dataUrl)
1433
+ const asset = await api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: 'canvas-background' })
1434
+ mutate(previous => ({ ...previous, background: 'image', backgroundImage: asset.url }))
1435
+ setBackgroundMenu(null)
1436
+ setError(null)
1437
+ } catch (caught) {
1438
+ setError(caught instanceof Error ? caught.message : String(caught))
1439
+ }
1440
+ }, [api, mutate])
1441
+
1442
+ const removeBackgroundImage = useCallback((): void => {
1443
+ mutate(previous => ({ ...previous, background: 'dots', backgroundImage: undefined }))
1444
+ setBackgroundMenu(null)
1445
+ }, [mutate])
1446
+
1447
+ const applyTemplate = useCallback((prompt: string): void => {
1448
+ const center = canvasCenter()
1449
+ const config = createConfigNode(center)
1450
+ const text = createTextNode()
1451
+ const placed: CanvasNode = {
1452
+ ...text,
1453
+ x: Math.round(config.x - TEXT_NODE_SIZE.width - 80),
1454
+ y: Math.round(config.y + (config.height - TEXT_NODE_SIZE.height) / 2),
1455
+ metadata: { text: prompt, fontSize: 14 },
1456
+ }
1457
+ mutate(previous => ({
1458
+ ...previous,
1459
+ nodes: [...previous.nodes, placed, config],
1460
+ connections: [...previous.connections, { id: newId('edge'), fromNodeId: placed.id, toNodeId: config.id }],
1461
+ }))
1462
+ setSelectedIds(new Set([config.id])); setSelectedConnectionId(null)
1463
+ setLibraryOpen(false)
1464
+ }, [canvasCenter, createConfigNode, createTextNode, mutate])
1465
+
1466
+ const gridSize = GRID_SIZE * (document?.viewport.k ?? 1)
1467
+ const gridOffsetX = (document?.viewport.x ?? 0) % gridSize
1468
+ const gridOffsetY = (document?.viewport.y ?? 0) % gridSize
1469
+
1470
+ // ------------------------------------------------------------- render
1471
+
1472
+ const renderNode = (node: CanvasNode): React.JSX.Element => {
1473
+ const metadata = nodeMetadata(node)
1474
+ const isSelected = selectedIds.has(node.id)
1475
+ const isRelated = relatedIds.has(node.id)
1476
+ const asset = assetOf(node)
1477
+ const isGenerating = node.type === 'image' && metadata.status === 'generating'
1478
+ const isError = node.type === 'image' && metadata.status === 'error'
1479
+ const isConnectTarget = connecting?.targetId === node.id
1480
+ const hasImage = asset !== undefined && asset.url !== ''
1481
+ const isConfig = node.type === 'config'
1482
+ const isTextual = node.type === 'text' || isConfig
1483
+ return <div
1484
+ key={node.id}
1485
+ data-node-id={node.id}
1486
+ className={`${css.node} ${isConfig ? css.configNode : isTextual ? css.textNode : css.imageNode} ${isSelected ? css.nodeSelected : ''} ${isRelated ? css.nodeRelated : ''} ${isConnectTarget ? css.nodeConnectTarget : ''}`}
1487
+ style={{ left: node.x, top: node.y, width: node.width, height: node.height }}
1488
+ onPointerDown={event => handleNodePointerDown(event, node.id)}
1489
+ onContextMenu={event => {
1490
+ if ((event.target as Element).closest('textarea, input, select')) return
1491
+ event.preventDefault(); event.stopPropagation()
1492
+ if (!selectedIds.has(node.id)) setSelectedIds(new Set([node.id]))
1493
+ setContextMenu({ type: 'node', screen: { x: event.clientX, y: event.clientY }, nodeId: node.id })
1494
+ }}
1495
+ >
1496
+ <div className={css.nodeGlow} aria-hidden="true" />
1497
+ {isTextual ? <header className={css.nodeHeader}>
1498
+ <span className={css.nodeTitle}>{node.title}</span>
1499
+ </header> : null}
1500
+ {isConfig ? <div className={css.configLinks} data-config-links={node.id}>
1501
+ <span className={css.composerChip}>{tt('canvas.composerLinked', { count: (document?.connections ?? []).filter(connection => connection.toNodeId === node.id).length })}</span>
1502
+ </div> : null}
1503
+ {isConfig
1504
+ ? <p className={css.configHint}>{tt('canvas.configHint')}</p>
1505
+ : isTextual
1506
+ ? <textarea
1507
+ className={css.textArea}
1508
+ value={metadata.text ?? ''}
1509
+ placeholder={tt('canvas.textPlaceholder')}
1510
+ onPointerDown={event => event.stopPropagation()}
1511
+ onChange={event => patchNode(node.id, { text: event.target.value })}
1512
+ />
1513
+ : <div className={css.nodeBody}>
1514
+ {hasImage ? <div aria-hidden="true">
1515
+ {metadata.model !== undefined && metadata.model !== ''
1516
+ ? <span className={`${css.imageInfo} ${css.imageInfoLeft}`}>{metadata.model}</span>
1517
+ : asset.origin === 'gallery' || asset.origin === 'history'
1518
+ ? <span className={`${css.imageInfo} ${css.imageInfoLeft}`}>{asset.origin === 'gallery' ? tt('canvas.fromGallery') : tt('canvas.fromHistory')}</span>
1519
+ : null}
1520
+ {asset !== undefined && asset.width > 1 ? <span className={`${css.imageInfo} ${css.imageInfoRight}`}>{asset.width}×{asset.height}</span> : null}
1521
+ </div> : null}
1522
+ {isGenerating
1523
+ ? <div className={css.nodeState}><span className={css.spinner} aria-hidden="true" /><span>{tt('canvas.generatingNode')}</span></div>
1524
+ : isError
1525
+ ? <div className={css.nodeStateError}>{metadata.error ?? tt('canvas.generateFailed')}<button type="button" onClick={() => { void retryGeneration(node) }}>{tt('canvas.retry')}</button></div>
1526
+ : hasImage
1527
+ ? <img src={asset.url} alt={node.title} draggable={false} onDragStart={event => event.preventDefault()} />
1528
+ : <button type="button" className={css.nodeEmpty} onClick={() => imageFileRef.current?.click()}><ToolbarIcon name="image" /><span>{tt('canvas.emptyImageNode')}</span></button>}
1529
+ </div>}
1530
+ {isSelected
1531
+ ? <div className={css.resizeHandle} onPointerDown={event => handleResizeStart(event, node, 'bottom-right')} title={tt('canvas.resizeHint')} />
1532
+ : null}
1533
+ <div className={`${css.handle} ${css.handleLeft}`} title={tt('canvas.connectHint')} onPointerDown={event => handleConnectStart(event, node.id, 'target')} />
1534
+ <div
1535
+ className={`${css.handle} ${css.handleRight}`}
1536
+ title={tt('canvas.connectAddHint')}
1537
+ onPointerDown={event => handleConnectStart(event, node.id, 'source')}
1538
+ onMouseEnter={() => { window.setTimeout(() => { if (connectRef.current === null) openNodeAddMenu(node) }, 120) }}
1539
+ onMouseLeave={scheduleNodeAddMenuClose}
1540
+ />
1541
+ <div className={css.hoverToolbar} onPointerDown={event => event.stopPropagation()}>
1542
+ {node.type === 'image' && hasImage ? <IconButton name="download" label={tt('canvas.download')} onClick={() => downloadNode(node)} /> : null}
1543
+ <IconButton name="duplicate" label={tt('canvas.duplicate')} onClick={duplicateSelection} />
1544
+ <IconButton name="trash" label={tt('canvas.delete')} onClick={deleteSelection} />
1545
+ </div>
1546
+ </div>
1547
+ }
1548
+
1549
+ const renderConnections = (): React.JSX.Element => {
1550
+ const visible = (document?.connections ?? []).filter(connection => nodeById.has(connection.fromNodeId) && nodeById.has(connection.toNodeId))
1551
+ const gradientOf = (connection: CanvasConnection): React.JSX.Element => {
1552
+ const from = nodeById.get(connection.fromNodeId)!
1553
+ const to = nodeById.get(connection.toNodeId)!
1554
+ const start = nodeAnchor(from, 'right')
1555
+ const end = nodeAnchor(to, 'left')
1556
+ return <linearGradient
1557
+ key={connection.id}
1558
+ id={`conn-g-${connection.id}`}
1559
+ gradientUnits="userSpaceOnUse"
1560
+ x1={start.x} y1={start.y} x2={end.x} y2={end.y}
1561
+ >
1562
+ <stop offset="0" stopColor="var(--dsw-alias-brand-primary)" stopOpacity="0.08" />
1563
+ <stop offset="0.7" stopColor="var(--dsw-alias-brand-primary)" stopOpacity="0.4" />
1564
+ <stop offset="1" stopColor="var(--dsw-alias-brand-primary)" stopOpacity="0.85" />
1565
+ </linearGradient>
1566
+ }
1567
+ return <svg
1568
+ className={css.connectionLayer}
1569
+ width={WORLD_PAD * 2}
1570
+ height={WORLD_PAD * 2}
1571
+ style={{ left: -WORLD_PAD, top: -WORLD_PAD }}
1572
+ aria-hidden="true"
1573
+ >
1574
+ <defs>
1575
+ <marker id="conn-arrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7.5" markerHeight="7.5" orient="auto-start-reverse">
1576
+ <path d="M 0 1.6 L 8.4 5 L 0 8.4 Z" fill="color-mix(in srgb, var(--dsw-alias-brand-primary) 62%, transparent)" />
1577
+ </marker>
1578
+ <marker id="conn-arrow-active" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7.5" markerHeight="7.5" orient="auto-start-reverse">
1579
+ <path d="M 0 1.6 L 8.4 5 L 0 8.4 Z" fill="var(--dsw-alias-brand-primary)" />
1580
+ </marker>
1581
+ {visible.map(gradientOf)}
1582
+ </defs>
1583
+ <g transform={`translate(${WORLD_PAD},${WORLD_PAD})`}>
1584
+ {visible.map(connection => {
1585
+ const from = nodeById.get(connection.fromNodeId)!
1586
+ const to = nodeById.get(connection.toNodeId)!
1587
+ const path = bezierPath(nodeAnchor(from, 'right'), nodeAnchor(to, 'left'))
1588
+ const active = connection.id === selectedConnectionId
1589
+ return <g key={connection.id}>
1590
+ <path
1591
+ data-connection-hit={connection.id}
1592
+ d={path}
1593
+ stroke="transparent"
1594
+ strokeWidth={16}
1595
+ fill="none"
1596
+ style={{ cursor: 'pointer', pointerEvents: 'stroke' }}
1597
+ onPointerDown={event => { event.stopPropagation(); handleConnectionSelect(connection.id) }}
1598
+ onContextMenu={event => {
1599
+ event.preventDefault(); event.stopPropagation()
1600
+ handleConnectionSelect(connection.id)
1601
+ setContextMenu({ type: 'connection', screen: { x: event.clientX, y: event.clientY }, connectionId: connection.id })
1602
+ }}
1603
+ />
1604
+ <path
1605
+ d={path}
1606
+ stroke={`url(#conn-g-${connection.id})`}
1607
+ className={css.connectionPath}
1608
+ markerEnd={active ? 'url(#conn-arrow-active)' : 'url(#conn-arrow)'}
1609
+ />
1610
+ {/* A soft light band glides along the path (source -> target). */}
1611
+ <path d={path} className={`${css.connectionFlow} ${active ? css.connectionFlowActive : ''}`} />
1612
+ </g>
1613
+ })}
1614
+ {connecting !== null ? (() => {
1615
+ const node = nodeById.get(connecting.nodeId)
1616
+ if (node === undefined) return null
1617
+ const mouse = connecting.targetId !== undefined && connecting.targetId !== null && nodeById.has(connecting.targetId)
1618
+ ? nodeAnchor(nodeById.get(connecting.targetId)!, connecting.handleType === 'source' ? 'left' : 'right')
1619
+ : connecting.mouse
1620
+ const path = connecting.handleType === 'source'
1621
+ ? bezierPath(nodeAnchor(node, 'right'), mouse)
1622
+ : bezierPath(mouse, nodeAnchor(node, 'left'))
1623
+ return <path d={path} className={css.connectionPreview} />
1624
+ })() : null}
1625
+ </g>
1626
+ </svg>
1627
+ }
1628
+
1629
+ const renderComposer = (): ReactNode => {
1630
+ if (!composerVisible || document === null || composerTarget === null) return null
1631
+ const linkedCount = composerReferenceCount + composerTextCount
1632
+ const k = document.viewport.k
1633
+ const topOffset = viewportRef.current?.offsetTop ?? 0
1634
+ const centerX = topOffset * 0 + document.viewport.x + (composerTarget.x + composerTarget.width / 2) * k
1635
+ const clampedX = Math.min(Math.max(centerX, 292), Math.max(292, viewportSize.width - 292))
1636
+ const belowY = topOffset + document.viewport.y + (composerTarget.y + composerTarget.height) * k + 14
1637
+ const top = belowY > viewportSize.height + topOffset - 170
1638
+ ? Math.max(64, topOffset + document.viewport.y + composerTarget.y * k - 158)
1639
+ : belowY
1640
+ return <div className={css.composer} data-canvas-no-zoom="" style={{ left: clampedX - 280, top }}>
1641
+ <textarea
1642
+ className={css.composerPrompt}
1643
+ value={composerPrompt}
1644
+ placeholder={tt('canvas.composerPlaceholder')}
1645
+ rows={1}
1646
+ onPointerDown={event => event.stopPropagation()}
1647
+ onChange={event => setComposerPrompt(event.target.value)}
1648
+ onKeyDown={event => {
1649
+ if (event.key === 'Enter' && !event.shiftKey) {
1650
+ event.preventDefault()
1651
+ void submitComposer(composerTarget)
1652
+ }
1653
+ }}
1654
+ />
1655
+ {linkedCount > 0 ? <div className={css.composerMeta}>
1656
+ <span className={css.composerChip}>{tt('canvas.composerLinked', { count: linkedCount })}</span>
1657
+ </div> : null}
1658
+ <div className={css.composerControls}>
1659
+ <ComposerSelect
1660
+ ariaLabel={tt('canvas.model')}
1661
+ value={composerModel}
1662
+ options={[{ value: '', label: tt('canvas.modelPlaceholder') }, ...imageModels.map(item => ({ value: item, label: item }))]}
1663
+ onChange={setComposerModel}
1664
+ />
1665
+ <ComposerSelect
1666
+ ariaLabel={tt('canvas.size')}
1667
+ value={composerSize}
1668
+ options={[
1669
+ { value: 'auto', label: tt('canvas.sizeAuto') },
1670
+ { value: '1:1', label: '1:1' },
1671
+ { value: '3:4', label: '3:4' },
1672
+ { value: '16:9', label: '16:9' },
1673
+ { value: '9:16', label: '9:16' },
1674
+ ]}
1675
+ onChange={setComposerSize}
1676
+ />
1677
+ <ComposerSelect
1678
+ ariaLabel={tt('canvas.quality')}
1679
+ value={composerQuality}
1680
+ options={[
1681
+ { value: 'auto', label: tt('canvas.qualityAuto') },
1682
+ { value: '1k', label: '1K' },
1683
+ { value: '2k', label: '2K' },
1684
+ { value: '4k', label: '4K' },
1685
+ ]}
1686
+ onChange={setComposerQuality}
1687
+ />
1688
+ <ComposerSelect
1689
+ ariaLabel={tt('canvas.count')}
1690
+ value={String(composerCount)}
1691
+ options={[1, 2, 3, 4].map(item => ({ value: String(item), label: tt('canvas.countUnit', { count: item }) }))}
1692
+ onChange={value => setComposerCount(Number(value))}
1693
+ />
1694
+ <button
1695
+ type="button"
1696
+ className={css.composerSend}
1697
+ aria-label={tt('canvas.generate')}
1698
+ title={tt('canvas.generate')}
1699
+ disabled={!connected || composerBusy || (composerPrompt.trim() === '' && composerTextCount === 0)}
1700
+ onClick={() => { void submitComposer(composerTarget) }}
1701
+ >{composerBusy ? <span className={css.spinner} aria-hidden="true" /> : <ToolbarIcon name="send" />}</button>
1702
+ </div>
1703
+ </div>
1704
+ }
1705
+
1706
+ const renderMinimap = (): React.JSX.Element | null => {
1707
+ if (document === null || viewportSize.width === 0) return null
1708
+ const width = 220
1709
+ const height = 150
1710
+ const nodes = document.nodes
1711
+ let worldBounds = { x: -600, y: -600, w: 1200, h: 1200 }
1712
+ let scale = Math.min(width / worldBounds.w, height / worldBounds.h)
1713
+ let offset = { x: (width - worldBounds.w * scale) / 2, y: (height - worldBounds.h * scale) / 2 }
1714
+ if (nodes.length > 0) {
1715
+ const content = nodesBounds(nodes)
1716
+ worldBounds = { x: content.minX - 500, y: content.minY - 500, w: content.maxX - content.minX + 1000, h: content.maxY - content.minY + 1000 }
1717
+ scale = Math.min(width / worldBounds.w, height / worldBounds.h)
1718
+ offset = { x: (width - worldBounds.w * scale) / 2, y: (height - worldBounds.h * scale) / 2 }
1719
+ }
1720
+ const toMap = (worldX: number, worldY: number): Point => ({ x: (worldX - worldBounds.x) * scale + offset.x, y: (worldY - worldBounds.y) * scale + offset.y })
1721
+ const toWorld = (mapX: number, mapY: number): Point => ({ x: (mapX - offset.x) / scale + worldBounds.x, y: (mapY - offset.y) / scale + worldBounds.y })
1722
+ const viewportRect = (() => {
1723
+ const vx = -document.viewport.x / document.viewport.k
1724
+ const vy = -document.viewport.y / document.viewport.k
1725
+ const p1 = toMap(vx, vy)
1726
+ const p2 = toMap(vx + viewportSize.width / document.viewport.k, vy + viewportSize.height / document.viewport.k)
1727
+ return { x: p1.x, y: p1.y, w: Math.max(p2.x - p1.x, 4), h: Math.max(p2.y - p1.y, 4) }
1728
+ })()
1729
+ const jump = (event: ReactPointerEvent<HTMLDivElement>): void => {
1730
+ const bounds = event.currentTarget.getBoundingClientRect()
1731
+ const world = toWorld(event.clientX - bounds.left, event.clientY - bounds.top)
1732
+ setViewport({ k: document.viewport.k, x: viewportSize.width / 2 - world.x * document.viewport.k, y: viewportSize.height / 2 - world.y * document.viewport.k })
1733
+ }
1734
+ return <aside className={css.minimap} data-canvas-no-zoom="" aria-label={tt('canvas.minimap')}>
1735
+ <div className={css.minimapCanvas} onPointerDown={event => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); jump(event) }}
1736
+ onPointerMove={event => { if (event.buttons === 1) jump(event) }}>
1737
+ {nodes.map(node => {
1738
+ const position = toMap(node.x, node.y)
1739
+ return <div key={node.id} className={`${css.minimapNode} ${node.type === 'image' ? css.minimapImage : css.minimapText} ${selectedIds.has(node.id) ? css.minimapSelected : ''}`}
1740
+ style={{ left: position.x, top: position.y, width: Math.max(node.width * scale, 2), height: Math.max(node.height * scale, 2) }} />
1741
+ })}
1742
+ <div className={css.minimapViewport} style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h }} />
1743
+ </div>
1744
+ </aside>
1745
+ }
1746
+
1747
+ const renderContextMenu = (): ReactNode => {
1748
+ if (contextMenu !== null) {
1749
+ const close = (): void => setContextMenu(null)
1750
+ const items: Array<{ label: string; action: () => void; danger?: boolean; icon: ToolbarIconName }> = []
1751
+ if (contextMenu.type === 'node') {
1752
+ const node = nodeById.get(contextMenu.nodeId)
1753
+ if (node !== undefined && node.type === 'image' && (assetOf(node)?.url.length ?? 0) > 0) items.push({ label: tt('canvas.download'), icon: 'download', action: () => downloadNode(node) })
1754
+ items.push({ label: tt('canvas.duplicate'), icon: 'duplicate', action: duplicateSelection })
1755
+ items.push({ label: tt('canvas.delete'), icon: 'trash', action: deleteSelection, danger: true })
1756
+ } else if (contextMenu.type === 'connection') {
1757
+ items.push({
1758
+ label: tt('canvas.deleteConnection'), icon: 'close', danger: true,
1759
+ action: () => {
1760
+ mutate(previous => ({ ...previous, connections: previous.connections.filter(connection => connection.id !== contextMenu.connectionId) }))
1761
+ setSelectedConnectionId(null)
1762
+ },
1763
+ })
1764
+ } else {
1765
+ items.push({ label: tt('canvas.addImage'), icon: 'image', action: () => setPickerOpen(true) })
1766
+ items.push({ label: tt('canvas.addTextNode'), icon: 'text', action: () => placeNewNode(createTextNode(contextMenu.world)) })
1767
+ items.push({ label: tt('canvas.paste'), icon: 'duplicate', action: () => pasteClipboard(contextMenu.world) })
1768
+ items.push({ label: tt('canvas.fitView'), icon: 'fit', action: fitView })
1769
+ }
1770
+ return <div className={css.contextMenu} style={{ left: contextMenu.screen.x, top: contextMenu.screen.y }} data-canvas-no-zoom="" role="menu">
1771
+ {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>)}
1772
+ </div>
1773
+ }
1774
+ if (createMenu !== null) {
1775
+ return <div className={css.contextMenu} style={{ left: createMenu.screen.x, top: createMenu.screen.y }} data-canvas-no-zoom="" role="menu">
1776
+ <button type="button" role="menuitem" onClick={() => { placeNewNode(createTextNode(createMenu.world)); setCreateMenu(null) }}><ToolbarIcon name="text" size={16} />{tt('canvas.addTextNode')}</button>
1777
+ <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" size={16} />{tt('canvas.addImageNode')}</button>
1778
+ <button type="button" role="menuitem" onClick={() => { placeNewNode(createConfigNode(createMenu.world)); setCreateMenu(null) }}><ToolbarIcon name="sparkle" size={16} />{tt('canvas.addConfigNode')}</button>
1779
+ </div>
1780
+ }
1781
+ return null
1782
+ }
1783
+
1784
+ const emptyState = document !== null && document.nodes.length === 0
1785
+ ? <div className={css.emptyHint} data-canvas-no-zoom="">
1786
+ <strong>{tt('canvas.emptyTitle')}</strong>
1787
+ <span>{tt('canvas.emptyHint')}</span>
1788
+ </div>
1789
+ : null
1790
+
1791
+ const marqueeRect = marquee === null ? null : (() => {
1792
+ const x1 = (Math.min(marquee.start.x, marquee.current.x) * (document?.viewport.k ?? 1)) + (document?.viewport.x ?? 0)
1793
+ const y1 = (Math.min(marquee.start.y, marquee.current.y) * (document?.viewport.k ?? 1)) + (document?.viewport.y ?? 0)
1794
+ const x2 = (Math.max(marquee.start.x, marquee.current.x) * (document?.viewport.k ?? 1)) + (document?.viewport.x ?? 0)
1795
+ const y2 = (Math.max(marquee.start.y, marquee.current.y) * (document?.viewport.k ?? 1)) + (document?.viewport.y ?? 0)
1796
+ return { left: x1, top: y1, width: x2 - x1, height: y2 - y1 }
1797
+ })()
1798
+
1799
+ return <section ref={rootRef} className={css.root} data-canvas-workspace="">
1800
+ <header className={css.topBar} data-canvas-no-zoom="">
1801
+ <select className={css.projectSelect} value={document?.id ?? ''} onChange={event => { void selectProject(event.target.value) }} aria-label={tt('canvas.project')}>
1802
+ {projects.map(project => <option key={project.id} value={project.id}>{project.title}</option>)}
1803
+ </select>
1804
+ <IconButton name="new" label={tt('canvas.newCanvas')} onClick={() => { void newCanvas() }} />
1805
+ <IconButton name="deleteProject" label={confirmDeleteProject ? tt('canvas.deleteCanvasConfirm') : tt('canvas.deleteCanvas')} active={confirmDeleteProject} disabled={document === null} onClick={() => {
1806
+ if (confirmDeleteProject) { void removeCurrentProject() } else { setConfirmDeleteProject(true); window.setTimeout(() => setConfirmDeleteProject(false), 3000) }
1807
+ }} />
1808
+ {renamingTitle && document !== null
1809
+ ? <input
1810
+ className={css.titleInput}
1811
+ value={document.title}
1812
+ autoFocus
1813
+ aria-label={tt('canvas.rename')}
1814
+ onChange={event => updateDocument(previous => ({ ...previous, title: event.target.value }))}
1815
+ onBlur={() => setRenamingTitle(false)}
1816
+ onKeyDown={event => { if (event.key === 'Enter' || event.key === 'Escape') setRenamingTitle(false) }}
1817
+ />
1818
+ : <button type="button" className={css.titleButton} onDoubleClick={() => setRenamingTitle(true)} title={tt('canvas.renameHint')}>{document?.title ?? ''}</button>}
1819
+ <span className={css.topBarSpacer} />
1820
+ <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>
1821
+ </header>
1822
+
1823
+ <div
1824
+ ref={viewportRef}
1825
+ className={`${css.viewport} ${cursorClass}`}
1826
+ onPointerDown={onViewportPointerDown}
1827
+ onWheel={onWheel}
1828
+ onDoubleClick={event => {
1829
+ const target = event.target instanceof Element ? event.target : null
1830
+ if (target?.closest('[data-node-id],[data-canvas-no-zoom]')) return
1831
+ setCreateMenu({ screen: { x: event.clientX, y: event.clientY }, world: screenToWorld(event.clientX, event.clientY) })
1832
+ }}
1833
+ onContextMenu={event => {
1834
+ const target = event.target instanceof Element ? event.target : null
1835
+ if (target?.closest('[data-node-id],[data-connection-hit],[data-canvas-no-zoom]')) return
1836
+ event.preventDefault()
1837
+ setContextMenu({ type: 'canvas', screen: { x: event.clientX, y: event.clientY }, world: screenToWorld(event.clientX, event.clientY) })
1838
+ }}
1839
+ onDragOver={event => event.preventDefault()}
1840
+ onDrop={onDrop}
1841
+ >
1842
+ <div
1843
+ className={css.grid}
1844
+ style={backgroundMode === 'image' && document?.backgroundImage
1845
+ ? { backgroundImage: `url(${document.backgroundImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
1846
+ : backgroundMode === 'liquid' || backgroundMode === 'floatingLines' || backgroundMode === 'galaxy' || backgroundMode === 'silk' || backgroundMode === 'waves' || backgroundMode === 'faultyTerminal' || backgroundMode === 'dotField' || backgroundMode === 'dotGrid' || backgroundMode === 'shapeGrid'
1847
+ ? undefined
1848
+ : { backgroundSize: `${gridSize}px ${gridSize}px`, backgroundPosition: `${gridOffsetX}px ${gridOffsetY}px` }}
1849
+ data-mode={backgroundMode}
1850
+ aria-hidden="true"
1851
+ >
1852
+ {backgroundMode === 'image' ? <div className={css.gridScrim} /> : null}
1853
+ {backgroundMode === 'flow' ? <FlowBackground /> : null}
1854
+ {backgroundMode === 'liquid' ? <LiquidEtherBackground /> : null}
1855
+ {backgroundMode === 'floatingLines' ? <FloatingLinesBackground /> : null}
1856
+ {backgroundMode === 'galaxy' ? <GalaxyBackground /> : null}
1857
+ {backgroundMode === 'silk' ? <SilkBackground /> : null}
1858
+ {backgroundMode === 'waves' ? <WavesBackground /> : null}
1859
+ {backgroundMode === 'faultyTerminal' ? <FaultyTerminalBackground /> : null}
1860
+ {backgroundMode === 'dotField' ? <DotFieldBackground /> : null}
1861
+ {backgroundMode === 'dotGrid' ? <DotGridBackground /> : null}
1862
+ {backgroundMode === 'shapeGrid' ? <ShapeGridBackground /> : null}
1863
+ </div>
1864
+ <div className={css.world} style={{ transform: `translate(${document?.viewport.x ?? 0}px, ${document?.viewport.y ?? 0}px) scale(${document?.viewport.k ?? 1})` }}>
1865
+ {renderConnections()}
1866
+ {document?.nodes.map(renderNode)}
1867
+ </div>
1868
+ {marqueeRect !== null ? <div className={css.marquee} style={marqueeRect} aria-hidden="true" /> : null}
1869
+ {emptyState}
1870
+ </div>
1871
+
1872
+ <div
1873
+ className={`${css.dock} ${cursorClass}`}
1874
+ data-canvas-no-zoom=""
1875
+ onPointerMove={event => {
1876
+ if (globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true) return
1877
+ const dock = event.currentTarget
1878
+ const cursorX = event.clientX - dock.getBoundingClientRect().left
1879
+ // CSS-module class names are hashed in the DOM, so match by tag.
1880
+ for (const button of dock.querySelectorAll<HTMLButtonElement>('button')) {
1881
+ // offsetLeft is the layout position, unaffected by the scale transform,
1882
+ // so the magnification wave does not feed back into itself.
1883
+ const distance = Math.abs(cursorX - (button.offsetLeft + button.offsetWidth / 2))
1884
+ const influence = Math.exp(-(distance * distance) / (2 * 48 * 48))
1885
+ button.style.setProperty('--dock-scale', (1 + 0.24 * influence).toFixed(3))
1886
+ button.style.setProperty('--dock-lift', `${(-8 * influence).toFixed(2)}px`)
1887
+ }
1888
+ }}
1889
+ onPointerLeave={event => {
1890
+ for (const button of event.currentTarget.querySelectorAll<HTMLButtonElement>('button')) {
1891
+ button.style.setProperty('--dock-scale', '1')
1892
+ button.style.setProperty('--dock-lift', '0px')
1893
+ }
1894
+ }}
1895
+ >
1896
+ <IconButton name="select" size={18} label={tt('canvas.toolSelect')} active={tool === 'select'} onClick={() => setTool('select')} />
1897
+ <IconButton name="pan" size={18} label={tt('canvas.toolPan')} active={tool === 'pan'} onClick={() => setTool('pan')} />
1898
+ <span className={css.dockDivider} />
1899
+ <IconButton
1900
+ name="image"
1901
+ size={18}
1902
+ label={tt('canvas.addImage')}
1903
+ active={imageMenu !== null}
1904
+ onClick={event => openDockMenu('image', event.currentTarget)}
1905
+ onMouseEnter={event => openDockMenu('image', event.currentTarget)}
1906
+ onMouseLeave={scheduleMenuClose}
1907
+ />
1908
+ <IconButton name="text" size={18} label={tt('canvas.addText')} onClick={() => placeNewNode(createTextNode())} />
1909
+ <IconButton name="sparkle" size={18} label={tt('canvas.addConfigNode')} onClick={() => placeNewNode(createConfigNode())} />
1910
+ <IconButton name="template" size={18} label={tt('canvas.templateLibrary')} active={libraryOpen} onClick={() => setLibraryOpen(previous => !previous)} />
1911
+ <span className={css.dockDivider} />
1912
+ <IconButton
1913
+ name="background"
1914
+ size={18}
1915
+ label={tt('canvas.background')}
1916
+ active={backgroundMenu !== null}
1917
+ onClick={event => openDockMenu('background', event.currentTarget)}
1918
+ onMouseEnter={event => openDockMenu('background', event.currentTarget)}
1919
+ onMouseLeave={scheduleMenuClose}
1920
+ />
1921
+ <IconButton name="undo" size={18} label={tt('canvas.undo')} disabled={pastRef.current.length === 0} onClick={undo} />
1922
+ <IconButton name="redo" size={18} label={tt('canvas.redo')} disabled={futureRef.current.length === 0} onClick={redo} />
1923
+ <span className={css.dockDivider} />
1924
+ <IconButton name="trash" size={18} label={tt('canvas.delete')} disabled={selectedIds.size === 0 && selectedConnectionId === null} onClick={deleteSelection} />
1925
+ </div>
1926
+
1927
+ {imageMenu !== null ? <div
1928
+ className={css.backgroundMenu}
1929
+ style={{ left: imageMenu.x, top: imageMenu.y - 10 }}
1930
+ data-canvas-no-zoom=""
1931
+ role="menu"
1932
+ onMouseEnter={clearMenuCloseTimer}
1933
+ onMouseLeave={scheduleMenuClose}
1934
+ >
1935
+ <button type="button" role="menuitem" onClick={() => { imageFileRef.current?.click(); setImageMenu(null) }}>{tt('canvas.imageMenuUpload')}</button>
1936
+ <button type="button" role="menuitem" onClick={() => { setPickerTab('gallery'); setPickerOpen(true); setImageMenu(null) }}>{tt('canvas.imageMenuAssets')}</button>
1937
+ <button type="button" role="menuitem" onClick={() => { setPickerTab('history'); setPickerOpen(true); setImageMenu(null) }}>{tt('canvas.imageMenuHistory')}</button>
1938
+ <button type="button" role="menuitem" onClick={() => { setPickerTab('generate'); setPickerOpen(true); setImageMenu(null) }}>{tt('canvas.imageMenuGenerate')}</button>
1939
+ </div> : null}
1940
+ <input
1941
+ ref={imageFileRef}
1942
+ type="file"
1943
+ accept="image/png,image/jpeg,image/webp,image/gif"
1944
+ multiple
1945
+ hidden
1946
+ onChange={event => {
1947
+ const files = [...(event.target.files ?? [])].filter(file => file.type.startsWith('image/'))
1948
+ event.target.value = ''
1949
+ if (files.length === 0) return
1950
+ const world = canvasCenter()
1951
+ void Promise.all(files.map(async file => {
1952
+ 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) })
1953
+ const dimensions = await readImageSize(dataUrl)
1954
+ return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
1955
+ })).then(assets => {
1956
+ const current = documentRef.current
1957
+ const selectedId = selectedIdsRef.current.size === 1 ? [...selectedIdsRef.current][0] : undefined
1958
+ const selectedNode = current?.nodes.find(node => node.id === selectedId)
1959
+ if (selectedNode?.type === 'image' && usableAsset(selectedNode) === undefined && assets[0] !== undefined) {
1960
+ mutate(previous => ({ ...previous, nodes: previous.nodes.map(node => node.id === selectedNode.id ? { ...node, width: sizeForAsset(assets[0]!).width, height: sizeForAsset(assets[0]!).height, metadata: { ...nodeMetadata(node), asset: assets[0], status: 'success' as const, error: undefined } } : node) }))
1961
+ if (assets.length > 1) addAssets(assets.slice(1), world)
1962
+ } else addAssets(assets, world)
1963
+ }).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
1964
+ }}
1965
+ />
1966
+ {backgroundMenu !== null ? <div
1967
+ className={css.backgroundMenu}
1968
+ style={{ left: backgroundMenu.x, top: backgroundMenu.y - 10 }}
1969
+ data-canvas-no-zoom=""
1970
+ role="menu"
1971
+ onMouseEnter={clearMenuCloseTimer}
1972
+ onMouseLeave={scheduleMenuClose}
1973
+ >
1974
+ {([
1975
+ ['dots', tt('canvas.backgroundDots')],
1976
+ ['lines', tt('canvas.backgroundLines')],
1977
+ ['waves', tt('canvas.backgroundWaves')],
1978
+ ['shapeGrid', tt('canvas.backgroundShapeGrid')],
1979
+ ['dotField', tt('canvas.backgroundDotField')],
1980
+ ['dotGrid', tt('canvas.backgroundDotGrid')],
1981
+ ['floatingLines', tt('canvas.backgroundFloatingLines')],
1982
+ ['flow', tt('canvas.backgroundFlow')],
1983
+ ['liquid', tt('canvas.backgroundLiquid')],
1984
+ ['faultyTerminal', tt('canvas.backgroundFaultyTerminal')],
1985
+ ['silk', tt('canvas.backgroundSilk')],
1986
+ ['galaxy', tt('canvas.backgroundGalaxy')],
1987
+ ['blank', tt('canvas.backgroundBlank')],
1988
+ ] as const).map(([mode, label]) => <button key={mode} type="button" role="menuitem" data-active={backgroundMode === mode ? '' : undefined} onClick={() => setBackgroundMode(mode)}>{label}</button>)}
1989
+ <span className={css.backgroundMenuDivider} />
1990
+ <button type="button" role="menuitem" data-active={backgroundMode === 'image' ? '' : undefined} onClick={() => backgroundFileRef.current?.click()}>{tt('canvas.backgroundUpload')}</button>
1991
+ {backgroundMode === 'image' && document?.backgroundImage ? <button type="button" role="menuitem" onClick={removeBackgroundImage}>{tt('canvas.backgroundRemove')}</button> : null}
1992
+ <input
1993
+ ref={backgroundFileRef}
1994
+ type="file"
1995
+ accept="image/png,image/jpeg,image/webp,image/gif"
1996
+ hidden
1997
+ onChange={event => {
1998
+ const file = event.target.files?.[0]
1999
+ if (file !== undefined) void uploadBackgroundImage(file)
2000
+ event.target.value = ''
2001
+ }}
2002
+ />
2003
+ </div> : null}
2004
+
2005
+ {nodeAddMenu !== null ? <div
2006
+ className={css.contextMenu}
2007
+ style={{ left: nodeAddMenu.screen.x + 12, top: nodeAddMenu.screen.y, transform: 'translateY(-50%)' }}
2008
+ data-canvas-no-zoom=""
2009
+ role="menu"
2010
+ onMouseEnter={clearNodeAddMenuTimer}
2011
+ onMouseLeave={scheduleNodeAddMenuClose}
2012
+ >
2013
+ <button type="button" role="menuitem" onClick={() => { addConnectedNode(nodeAddMenu.nodeId, position => createTextNode(position)); setNodeAddMenu(null) }}><ToolbarIcon name="text" size={16} />{tt('canvas.addTextNode')}</button>
2014
+ <button type="button" role="menuitem" onClick={() => { addConnectedNode(nodeAddMenu.nodeId, position => createImageNode({ assetId: '', url: '', mime: 'image/png', bytes: 0, width: 1, height: 1, origin: 'upload' }, position)); setNodeAddMenu(null) }}><ToolbarIcon name="image" size={16} />{tt('canvas.addImageNode')}</button>
2015
+ {nodeAddMenu.nodeType !== 'config' ? <button type="button" role="menuitem" onClick={() => { addConnectedNode(nodeAddMenu.nodeId, position => createConfigNode(position)); setNodeAddMenu(null) }}><ToolbarIcon name="sparkle" size={16} />{tt('canvas.addConfigNode')}</button> : null}
2016
+ </div> : null}
2017
+
2018
+ <div className={css.zoomDock} data-canvas-no-zoom="">
2019
+ <IconButton name="minimap" label={minimapOpen ? tt('canvas.minimapClose') : tt('canvas.minimapOpen')} active={minimapOpen} onClick={() => setMinimapOpen(previous => !previous)} />
2020
+ <IconButton name="fit" label={tt('canvas.fitView')} onClick={fitView} />
2021
+ <input
2022
+ type="range"
2023
+ min={5}
2024
+ max={500}
2025
+ step={1}
2026
+ value={Math.round((document?.viewport.k ?? 1) * 100)}
2027
+ onChange={event => setZoomAtCenter(Number(event.target.value) / 100)}
2028
+ aria-label={tt('canvas.zoom')}
2029
+ />
2030
+ <span className={css.zoomValue}>{Math.round((document?.viewport.k ?? 1) * 100)}%</span>
2031
+ </div>
2032
+
2033
+ {minimapOpen ? renderMinimap() : null}
2034
+ {renderComposer()}
2035
+ {renderContextMenu()}
2036
+ {libraryOpen ? <TemplateLibrary api={api} onClose={() => setLibraryOpen(false)} onUse={applyTemplate} /> : null}
2037
+
2038
+ {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}
2039
+
2040
+ {pickerOpen ? <ImagePicker
2041
+ api={api}
2042
+ history={history}
2043
+ gallery={gallery}
2044
+ imageModels={imageModels}
2045
+ defaultChannelId={defaultChannelId}
2046
+ canvasId={document?.id ?? ''}
2047
+ connected={connected}
2048
+ initialTab={pickerTab}
2049
+ onClose={() => setPickerOpen(false)}
2050
+ onAssets={assets => { addAssets(assets); setPickerOpen(false) }}
2051
+ onTask={task => {
2052
+ if (document === null) return
2053
+ const center = canvasCenter()
2054
+ const size = nodeSizeFromRatio(task.request.size, IMAGE_NODE_SIZE)
2055
+ const node: CanvasNode = {
2056
+ id: newId('node'), type: 'image', title: tt('canvas.imageNode'),
2057
+ x: Math.round(center.x - size.width / 2), y: Math.round(center.y - size.height / 2),
2058
+ width: size.width, height: size.height,
2059
+ 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 },
2060
+ }
2061
+ placeNewNode(node)
2062
+ setPickerOpen(false)
2063
+ }}
2064
+ /> : null}
2065
+ </section>
2066
+ }
2067
+
2068
+ function ImagePicker(props: {
2069
+ api: ImageGenApi
2070
+ history: HistoryEntry[]
2071
+ gallery: HistoryEntry[]
2072
+ imageModels: string[]
2073
+ defaultChannelId?: string
2074
+ canvasId: string
2075
+ connected: boolean
2076
+ initialTab?: 'upload' | 'history' | 'gallery' | 'generate'
2077
+ onClose: () => void
2078
+ onAssets: (assets: CanvasAssetRef[]) => void
2079
+ onTask: (task: GenerationTask) => void
2080
+ }): React.JSX.Element {
2081
+ const { api, history, gallery, imageModels, defaultChannelId, canvasId, connected, onClose, onAssets } = props
2082
+ const [tab, setTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>(props.initialTab ?? 'upload')
2083
+ const [selected, setSelected] = useState<string[]>([])
2084
+ const [dimensions, setDimensions] = useState<Record<string, { width: number; height: number }>>({})
2085
+ const [prompt, setPrompt] = useState('')
2086
+ const [model, setModel] = useState(imageModels[0] ?? '')
2087
+ const [size, setSize] = useState('auto')
2088
+ const [quality, setQuality] = useState('auto')
2089
+ const [busy, setBusy] = useState(false)
2090
+ const toggle = (key: string): void => setSelected(previous => previous.includes(key) ? previous.filter(item => item !== key) : [...previous, key])
2091
+ const items = (tab === 'history' ? history : gallery).flatMap(entry => entry.images.map((image, index) => ({ key: `${entry.id}:${index}`, entry, image, index })))
2092
+ const uploadFiles = (files: File[]): void => {
2093
+ setBusy(true)
2094
+ void Promise.all(files.map(async file => {
2095
+ 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) })
2096
+ const sizeOf = await readImageSize(dataUrl)
2097
+ return api.canvasUpload(dataUrl, sizeOf.width, sizeOf.height, { origin: 'upload', originId: file.name })
2098
+ })).then(assets => { onAssets(assets) }).catch(() => {}).finally(() => setBusy(false))
2099
+ }
2100
+ const addSelected = async (): Promise<void> => {
2101
+ setBusy(true)
2102
+ try {
2103
+ const assets: CanvasAssetRef[] = []
2104
+ for (const key of selected) {
2105
+ const [entryId, indexText] = key.split(':'); const index = Number(indexText); const item = items.find(candidate => candidate.key === key)
2106
+ if (entryId === undefined || item === undefined) continue
2107
+ const sizeOf = dimensions[key] ?? await readImageSize(item.image.url).catch(() => ({ width: 1024, height: 1024 }))
2108
+ assets.push(await api.canvasImport(tab === 'history' ? 'history' : 'gallery', entryId, index, sizeOf.width, sizeOf.height))
2109
+ }
2110
+ if (assets.length > 0) onAssets(assets)
2111
+ } finally { setBusy(false) }
2112
+ }
2113
+ const generate = async (): Promise<void> => {
2114
+ if (!connected || prompt.trim() === '') return
2115
+ setBusy(true)
2116
+ try {
2117
+ const task = await api.taskSubmit({ mode: 'text', model, prompt: prompt.trim(), size, quality, n: 1, detail: '', ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }), canvas: { canvasId } })
2118
+ props.onTask(task)
2119
+ } finally { setBusy(false) }
2120
+ }
2121
+ return <div className={css.modalBackdrop} role="dialog" aria-modal="true" data-canvas-no-zoom=""><section className={css.picker}>
2122
+ <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>
2123
+ <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>
2124
+ <div className={css.pickerBody}>
2125
+ {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}
2126
+ {(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}
2127
+ {tab === 'generate' ? <div className={css.generateForm}><textarea value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('canvas.composerPlaceholder')} /><ComposerSelect value={model} options={imageModels.map(item => ({ value: item, label: item }))} ariaLabel={tt('canvas.model')} onChange={setModel} /><div className={css.inspectorRow}><ComposerSelect value={size} options={[{ value: 'auto', label: tt('canvas.sizeAuto') }, { value: '1:1', label: '1:1' }, { value: '3:4', label: '3:4' }, { value: '16:9', label: '16:9' }, { value: '9:16', label: '9:16' }]} ariaLabel={tt('canvas.size')} onChange={setSize} /><ComposerSelect value={quality} options={[{ value: 'auto', label: tt('canvas.qualityAuto') }, { value: '1k', label: '1K' }, { value: '2k', label: '2K' }, { value: '4k', label: '4K' }]} ariaLabel={tt('canvas.quality')} onChange={setQuality} /></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}
2128
+ </div>
2129
+ </section></div>
2130
+ }