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