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