@dickpy/dsh-imagegen 1.5.8 → 1.5.10

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