@dickpy/dsh-imagegen 1.5.5 → 1.5.6

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.
@@ -9,16 +9,19 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
9
9
  import type { CanvasAssetRef, CanvasConnection, CanvasDocument, CanvasNode, GenerateRequest, GenerationTask, HistoryEntry } from '../protocol.ts'
10
10
  import type { ImageGenApi } from './api.ts'
11
11
  import { tt } from './helpers.ts'
12
+ import { TemplateLibrary } from './TemplateLibrary.tsx'
12
13
  import css from './canvas-workspace.module.css'
13
14
 
14
15
  type CanvasTool = 'select' | 'pan'
16
+ type BackgroundMode = CanvasDocument['background']
15
17
 
16
18
  const MIN_SCALE = 0.05
17
19
  const MAX_SCALE = 5
18
20
  const GRID_SIZE = 48
19
21
  const IMAGE_NODE_SIZE = { width: 320, height: 320 }
20
22
  const TEXT_NODE_SIZE = { width: 280, height: 150 }
21
- const CONFIG_NODE_SIZE = { width: 240, height: 96 }
23
+ const CONFIG_NODE_SIZE = { width: 320, height: 190 }
24
+ const LEGACY_CONFIG_NODE_SIZE = { width: 240, height: 96 }
22
25
  const HISTORY_LIMIT = 60
23
26
  const WORLD_PAD = 12000
24
27
 
@@ -151,6 +154,16 @@ function nodesBounds(nodes: CanvasNode[]): { minX: number; minY: number; maxX: n
151
154
  }), { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity })
152
155
  }
153
156
 
157
+ /** Enlarge config nodes still stored at the pre-expansion default so the
158
+ * roomier layout applies to existing canvases too. */
159
+ function normalizeConfigNodeSizes(document: CanvasDocument): CanvasDocument {
160
+ const nodes = document.nodes.map(node => node.type === 'config'
161
+ && node.width === LEGACY_CONFIG_NODE_SIZE.width && node.height === LEGACY_CONFIG_NODE_SIZE.height
162
+ ? { ...node, width: CONFIG_NODE_SIZE.width, height: CONFIG_NODE_SIZE.height }
163
+ : node)
164
+ return nodes === document.nodes ? document : { ...document, nodes }
165
+ }
166
+
154
167
  function summaryOf(document: CanvasDocument): ProjectSummary {
155
168
  return {
156
169
  id: document.id,
@@ -185,10 +198,10 @@ function nodeAnchor(node: CanvasNode, side: 'left' | 'right'): Point {
185
198
  return { x: side === 'right' ? node.x + node.width : node.x, y: node.y + node.height / 2 }
186
199
  }
187
200
 
188
- type ToolbarIconName = 'new' | 'select' | 'pan' | 'image' | 'text' | 'trash' | 'undo' | 'redo' | 'fit' | 'minimap' | 'background' | 'download' | 'duplicate' | 'sparkle' | 'send' | 'close' | 'deleteProject'
201
+ type ToolbarIconName = 'new' | 'select' | 'pan' | 'image' | 'text' | 'trash' | 'undo' | 'redo' | 'fit' | 'minimap' | 'background' | 'template' | 'download' | 'duplicate' | 'sparkle' | 'send' | 'close' | 'deleteProject'
189
202
 
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 }
203
+ function ToolbarIcon({ name, size = 16 }: { name: ToolbarIconName; size?: number }): React.JSX.Element {
204
+ const common = { width: size, height: size, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.35, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, 'aria-hidden': true }
192
205
  if (name === 'new') return <svg {...common}><path d="M8 3v10M3 8h10" /></svg>
193
206
  if (name === 'select') return <svg {...common}><path d="M3 2.5l9.3 6.2-4 1.1-1.5 3.7L3 2.5z" /></svg>
194
207
  if (name === 'pan') return <svg {...common}><path d="M8 2v12M2 8h12M5 5l3-3 3 3M5 11l3 3 3-3" /></svg>
@@ -199,7 +212,8 @@ function ToolbarIcon({ name }: { name: ToolbarIconName }): React.JSX.Element {
199
212
  if (name === 'redo') return <svg {...common}><path d="M13 7a5 5 0 1 0-1.5 4M13 3v4h-4" /></svg>
200
213
  if (name === 'fit') return <svg {...common}><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4" /></svg>
201
214
  if (name === 'minimap') return <svg {...common}><rect x="2" y="3" width="12" height="10" rx="1.5" /><path d="M5 6h3v4H5zM10 8h2v3h-2" /></svg>
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>
215
+ if (name === 'background') return <svg {...common}><rect x="2" y="4.5" width="10.5" height="9" rx="1.5" /><path d="M4.5 2h10a1.5 1.5 0 0 1 1.5 1.5V11M4.5 10.5l2.6-2.8 2.1 2.2 2.3-2.4 1.5 1.5" /></svg>
216
+ if (name === 'template') return <svg {...common}><path d="M8 3.6C6.9 2.5 5 2 2.5 2v10.6c2.5 0 4.4.5 5.5 1.6 1.1-1.1 3-1.6 5.5-1.6V2C11 2 9.1 2.5 8 3.6zM8 3.6V14" /></svg>
203
217
  if (name === 'download') return <svg {...common}><path d="M8 2.5v8M5 7.5l3 3 3-3M3 13.5h10" /></svg>
204
218
  if (name === 'duplicate') return <svg {...common}><rect x="5.5" y="5.5" width="8" height="8" rx="1.2" /><path d="M10.5 3h-7a.5.5 0 0 0-.5.5v7" /></svg>
205
219
  if (name === 'send') return <svg {...common}><path d="M14 2L7 9M14 2L9.5 14l-2.5-5L2 6.5 14 2z" /></svg>
@@ -208,8 +222,27 @@ function ToolbarIcon({ name }: { name: ToolbarIconName }): React.JSX.Element {
208
222
  return <svg {...common}><path d="M8 2l1.2 4.2L13.5 8l-4.3 1.8L8 14l-1.2-4.2L2.5 8l4.3-1.8L8 2z" /></svg>
209
223
  }
210
224
 
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>
225
+ function IconButton(props: {
226
+ name: ToolbarIconName
227
+ label: string
228
+ active?: boolean
229
+ disabled?: boolean
230
+ size?: number
231
+ onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
232
+ onMouseEnter?: (event: React.MouseEvent<HTMLButtonElement>) => void
233
+ onMouseLeave?: () => void
234
+ }): React.JSX.Element {
235
+ return <button
236
+ type="button"
237
+ className={css.iconButton}
238
+ data-active={props.active ? '' : undefined}
239
+ aria-label={props.label}
240
+ title={props.label}
241
+ disabled={props.disabled}
242
+ onClick={props.onClick}
243
+ onMouseEnter={props.onMouseEnter}
244
+ onMouseLeave={props.onMouseLeave}
245
+ ><ToolbarIcon name={props.name} size={props.size} /></button>
213
246
  }
214
247
 
215
248
  export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element {
@@ -228,6 +261,30 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
228
261
  const [createMenu, setCreateMenu] = useState<{ screen: Point; world: Point } | null>(null)
229
262
  const [minimapOpen, setMinimapOpen] = useState(true)
230
263
  const [pickerOpen, setPickerOpen] = useState(false)
264
+ const [backgroundMenu, setBackgroundMenu] = useState<Point | null>(null)
265
+ const [imageMenu, setImageMenu] = useState<Point | null>(null)
266
+ const menuCloseTimer = useRef<number | null>(null)
267
+ const clearMenuCloseTimer = (): void => {
268
+ if (menuCloseTimer.current !== null) { window.clearTimeout(menuCloseTimer.current); menuCloseTimer.current = null }
269
+ }
270
+ const scheduleMenuClose = useCallback((): void => {
271
+ clearMenuCloseTimer()
272
+ menuCloseTimer.current = window.setTimeout(() => { setBackgroundMenu(null); setImageMenu(null) }, 280)
273
+ }, [])
274
+ /** Open one dock menu anchored to its button (root-relative) and close the
275
+ * other: the two menus are mutually exclusive. */
276
+ const openDockMenu = useCallback((kind: 'image' | 'background', button: HTMLElement): void => {
277
+ clearMenuCloseTimer()
278
+ const bounds = button.getBoundingClientRect()
279
+ const rootRect = rootRef.current?.getBoundingClientRect()
280
+ const screen: Point = { x: bounds.left + bounds.width / 2 - (rootRect?.left ?? 0), y: bounds.top - (rootRect?.top ?? 0) }
281
+ if (kind === 'image') { setImageMenu(screen); setBackgroundMenu(null) }
282
+ else { setBackgroundMenu(screen); setImageMenu(null) }
283
+ }, [])
284
+ const [libraryOpen, setLibraryOpen] = useState(false)
285
+ const [pickerTab, setPickerTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>('upload')
286
+ const backgroundFileRef = useRef<HTMLInputElement>(null)
287
+ const imageFileRef = useRef<HTMLInputElement>(null)
231
288
  const [renamingTitle, setRenamingTitle] = useState(false)
232
289
  const [confirmDeleteProject, setConfirmDeleteProject] = useState(false)
233
290
  const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'error'>('loading')
@@ -242,6 +299,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
242
299
  const [composerCount, setComposerCount] = useState(1)
243
300
  const [composerBusy, setComposerBusy] = useState(false)
244
301
 
302
+ const rootRef = useRef<HTMLElement>(null)
245
303
  const viewportRef = useRef<HTMLDivElement>(null)
246
304
  const documentRef = useRef<CanvasDocument | null>(null)
247
305
  const selectedIdsRef = useRef<Set<string>>(selectedIds)
@@ -482,16 +540,25 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
482
540
  setComposerBusy(true)
483
541
  try {
484
542
  let image: string | undefined
543
+ let images: string[] | undefined
485
544
  let refName: string | undefined
486
545
  if (baseAsset !== undefined) {
487
546
  image = await assetToDataUrl(baseAsset)
488
547
  refName = 'canvas-reference.png'
548
+ const extras: string[] = []
549
+ for (const reference of referenceImages.slice(1, 4)) {
550
+ const asset = usableAsset(reference)
551
+ if (asset === undefined) continue
552
+ try { extras.push(await assetToDataUrl(asset)) } catch { /* skip unreadable reference */ }
553
+ }
554
+ if (extras.length > 0) images = extras
489
555
  }
490
556
  const footprint = nodeSizeFromRatio(composerSize, IMAGE_NODE_SIZE)
491
557
  const request: GenerateRequest = {
492
558
  mode: image === undefined ? 'text' : 'edit', model, prompt, size: composerSize, quality: composerQuality, n: count, detail: '',
493
559
  ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
494
560
  ...(image === undefined ? {} : { image, refName }),
561
+ ...(images === undefined ? {} : { images }),
495
562
  canvas: {
496
563
  canvasId: current.id,
497
564
  ...(target === null ? {} : { sourceNodeId: referenceImages[0]?.id ?? target.id, parentNodeId: target.id, placement: 'right' as const }),
@@ -543,15 +610,24 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
543
610
  const baseAsset = references[0] !== undefined ? usableAsset(references[0]!) : undefined
544
611
  try {
545
612
  let image: string | undefined
613
+ let images: string[] | undefined
546
614
  let refName: string | undefined
547
615
  if (baseAsset !== undefined) {
548
616
  image = await assetToDataUrl(baseAsset)
549
617
  refName = 'canvas-reference.png'
618
+ const extras: string[] = []
619
+ for (const reference of references.slice(1, 4)) {
620
+ const asset = usableAsset(reference)
621
+ if (asset === undefined) continue
622
+ try { extras.push(await assetToDataUrl(asset)) } catch { /* skip unreadable reference */ }
623
+ }
624
+ if (extras.length > 0) images = extras
550
625
  }
551
626
  const request: GenerateRequest = {
552
627
  mode: image === undefined ? 'text' : 'edit', model, prompt, size: metadata.size ?? 'auto', quality: metadata.quality ?? 'auto', n: 1, detail: '',
553
628
  ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
554
629
  ...(image === undefined ? {} : { image, refName }),
630
+ ...(images === undefined ? {} : { images }),
555
631
  canvas: { canvasId: current.id, sourceNodeId: references[0]?.id ?? sourceId, parentNodeId: node.id, placement: 'right' as const },
556
632
  }
557
633
  const task = await api.taskSubmit(request)
@@ -693,7 +769,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
693
769
  const first = list[0] === undefined ? await api.canvasCreate(tt('canvas.untitled')) : await api.canvasRead(list[0].id)
694
770
  if (disposed) return
695
771
  setProjects(list[0] === undefined ? [summaryOf(first)] : list)
696
- setDocument(first)
772
+ setDocument(normalizeConfigNodeSizes(first))
697
773
  syncedRef.current = JSON.stringify(first)
698
774
  setSaveState('saved')
699
775
  }).catch(caught => { if (!disposed) { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') } })
@@ -770,7 +846,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
770
846
  if (documentRef.current === null) return
771
847
  const mod = event.ctrlKey || event.metaKey
772
848
  if (event.key === 'Escape') {
773
- setContextMenu(null); setCreateMenu(null)
849
+ setContextMenu(null); setCreateMenu(null); setBackgroundMenu(null); setImageMenu(null)
774
850
  if (!isEditingTarget(event.target)) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
775
851
  return
776
852
  }
@@ -847,6 +923,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
847
923
  const onViewportPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
848
924
  const target = event.target instanceof Element ? event.target : null
849
925
  setContextMenu(null); setCreateMenu(null)
926
+ if (!target?.closest('[data-canvas-no-zoom]')) { setBackgroundMenu(null); setImageMenu(null) }
850
927
  const isBackground = target?.closest('[data-node-id],[data-connection-hit]') === null
851
928
  const shouldPan = event.button === 1 || (event.button === 0 && (tool === 'pan' || temporaryPanTool) && isBackground)
852
929
  if (shouldPan) {
@@ -1133,7 +1210,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1133
1210
  const selectProject = useCallback(async (id: string): Promise<void> => {
1134
1211
  try {
1135
1212
  const next = await api.canvasRead(id)
1136
- setDocument(next); setSelectedIds(new Set()); setSelectedConnectionId(null)
1213
+ setDocument(normalizeConfigNodeSizes(next)); setSelectedIds(new Set()); setSelectedConnectionId(null)
1137
1214
  syncedRef.current = JSON.stringify(next); setSaveState('saved')
1138
1215
  pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1139
1216
  } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
@@ -1175,10 +1252,52 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1175
1252
  const cursorClass = tool === 'pan' || isSpaceOrCtrl ? css.panCursor : css.selectCursor
1176
1253
 
1177
1254
  const backgroundMode = document?.background ?? 'dots'
1178
- const cycleBackground = useCallback((): void => {
1179
- mutate(previous => ({ ...previous, background: previous.background === 'dots' ? 'lines' : previous.background === 'lines' ? 'blank' : 'dots' }))
1255
+ const setBackgroundMode = useCallback((mode: BackgroundMode): void => {
1256
+ mutate(previous => ({
1257
+ ...previous,
1258
+ background: mode,
1259
+ ...(mode === 'image' ? {} : { backgroundImage: undefined }),
1260
+ }))
1261
+ setBackgroundMenu(null)
1180
1262
  }, [mutate])
1181
1263
 
1264
+ const uploadBackgroundImage = useCallback(async (file: File): Promise<void> => {
1265
+ try {
1266
+ const dataUrl = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('读取图片失败')); reader.readAsDataURL(file) })
1267
+ const dimensions = await readImageSize(dataUrl)
1268
+ const asset = await api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: 'canvas-background' })
1269
+ mutate(previous => ({ ...previous, background: 'image', backgroundImage: asset.url }))
1270
+ setBackgroundMenu(null)
1271
+ setError(null)
1272
+ } catch (caught) {
1273
+ setError(caught instanceof Error ? caught.message : String(caught))
1274
+ }
1275
+ }, [api, mutate])
1276
+
1277
+ const removeBackgroundImage = useCallback((): void => {
1278
+ mutate(previous => ({ ...previous, background: 'dots', backgroundImage: undefined }))
1279
+ setBackgroundMenu(null)
1280
+ }, [mutate])
1281
+
1282
+ const applyTemplate = useCallback((prompt: string): void => {
1283
+ const center = canvasCenter()
1284
+ const config = createConfigNode(center)
1285
+ const text = createTextNode()
1286
+ const placed: CanvasNode = {
1287
+ ...text,
1288
+ x: Math.round(config.x - TEXT_NODE_SIZE.width - 80),
1289
+ y: Math.round(config.y + (config.height - TEXT_NODE_SIZE.height) / 2),
1290
+ metadata: { text: prompt, fontSize: 14 },
1291
+ }
1292
+ mutate(previous => ({
1293
+ ...previous,
1294
+ nodes: [...previous.nodes, placed, config],
1295
+ connections: [...previous.connections, { id: newId('edge'), fromNodeId: placed.id, toNodeId: config.id }],
1296
+ }))
1297
+ setSelectedIds(new Set([config.id])); setSelectedConnectionId(null)
1298
+ setLibraryOpen(false)
1299
+ }, [canvasCenter, createConfigNode, createTextNode, mutate])
1300
+
1182
1301
  const gridSize = GRID_SIZE * (document?.viewport.k ?? 1)
1183
1302
  const gridOffsetX = (document?.viewport.x ?? 0) % gridSize
1184
1303
  const gridOffsetY = (document?.viewport.y ?? 0) % gridSize
@@ -1227,13 +1346,21 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1227
1346
  onChange={event => patchNode(node.id, { text: event.target.value })}
1228
1347
  />
1229
1348
  : <div className={css.nodeBody}>
1349
+ {hasImage ? <div aria-hidden="true">
1350
+ {metadata.model !== undefined && metadata.model !== ''
1351
+ ? <span className={`${css.imageInfo} ${css.imageInfoLeft}`}>{metadata.model}</span>
1352
+ : asset.origin === 'gallery' || asset.origin === 'history'
1353
+ ? <span className={`${css.imageInfo} ${css.imageInfoLeft}`}>{asset.origin === 'gallery' ? tt('canvas.fromGallery') : tt('canvas.fromHistory')}</span>
1354
+ : null}
1355
+ {asset !== undefined && asset.width > 1 ? <span className={`${css.imageInfo} ${css.imageInfoRight}`}>{asset.width}×{asset.height}</span> : null}
1356
+ </div> : null}
1230
1357
  {isGenerating
1231
1358
  ? <div className={css.nodeState}><span className={css.spinner} aria-hidden="true" /><span>{tt('canvas.generatingNode')}</span></div>
1232
1359
  : isError
1233
1360
  ? <div className={css.nodeStateError}>{metadata.error ?? tt('canvas.generateFailed')}<button type="button" onClick={() => { void retryGeneration(node) }}>{tt('canvas.retry')}</button></div>
1234
1361
  : hasImage
1235
1362
  ? <img src={asset.url} alt={node.title} draggable={false} onDragStart={event => event.preventDefault()} />
1236
- : <button type="button" className={css.nodeEmpty} onClick={() => setPickerOpen(true)}><ToolbarIcon name="image" /><span>{tt('canvas.emptyImageNode')}</span></button>}
1363
+ : <button type="button" className={css.nodeEmpty} onClick={() => imageFileRef.current?.click()}><ToolbarIcon name="image" /><span>{tt('canvas.emptyImageNode')}</span></button>}
1237
1364
  </div>}
1238
1365
  {isSelected
1239
1366
  ? <div className={css.resizeHandle} onPointerDown={event => handleResizeStart(event, node, 'bottom-right')} title={tt('canvas.resizeHint')} />
@@ -1300,11 +1427,12 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1300
1427
  if (!composerVisible || document === null || composerTarget === null) return null
1301
1428
  const linkedCount = composerReferenceCount + composerTextCount
1302
1429
  const k = document.viewport.k
1303
- const centerX = document.viewport.x + (composerTarget.x + composerTarget.width / 2) * k
1430
+ const topOffset = viewportRef.current?.offsetTop ?? 0
1431
+ const centerX = topOffset * 0 + document.viewport.x + (composerTarget.x + composerTarget.width / 2) * k
1304
1432
  const clampedX = Math.min(Math.max(centerX, 292), Math.max(292, viewportSize.width - 292))
1305
- const belowY = document.viewport.y + (composerTarget.y + composerTarget.height) * k + 14
1306
- const top = belowY > viewportSize.height - 170
1307
- ? Math.max(64, document.viewport.y + composerTarget.y * k - 158)
1433
+ const belowY = topOffset + document.viewport.y + (composerTarget.y + composerTarget.height) * k + 14
1434
+ const top = belowY > viewportSize.height + topOffset - 170
1435
+ ? Math.max(64, topOffset + document.viewport.y + composerTarget.y * k - 158)
1308
1436
  : belowY
1309
1437
  return <div className={css.composer} data-canvas-no-zoom="" style={{ left: clampedX - 280, top }}>
1310
1438
  <textarea
@@ -1449,7 +1577,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1449
1577
  return { left: x1, top: y1, width: x2 - x1, height: y2 - y1 }
1450
1578
  })()
1451
1579
 
1452
- return <section className={css.root} data-canvas-workspace="">
1580
+ return <section ref={rootRef} className={css.root} data-canvas-workspace="">
1453
1581
  <header className={css.topBar} data-canvas-no-zoom="">
1454
1582
  <select className={css.projectSelect} value={document?.id ?? ''} onChange={event => { void selectProject(event.target.value) }} aria-label={tt('canvas.project')}>
1455
1583
  {projects.map(project => <option key={project.id} value={project.id}>{project.title}</option>)}
@@ -1470,9 +1598,6 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1470
1598
  />
1471
1599
  : <button type="button" className={css.titleButton} onDoubleClick={() => setRenamingTitle(true)} title={tt('canvas.renameHint')}>{document?.title ?? ''}</button>}
1472
1600
  <span className={css.topBarSpacer} />
1473
- <IconButton name="background" label={tt('canvas.background')} onClick={cycleBackground} />
1474
- <IconButton name="undo" label={tt('canvas.undo')} disabled={pastRef.current.length === 0} onClick={undo} />
1475
- <IconButton name="redo" label={tt('canvas.redo')} disabled={futureRef.current.length === 0} onClick={redo} />
1476
1601
  <span className={css.saveState} data-state={saveState}>{saveState === 'saving' ? tt('canvas.saving') : saveState === 'saved' ? tt('canvas.saved') : saveState === 'error' ? tt('canvas.saveFailed') : tt('canvas.loading')}</span>
1477
1602
  </header>
1478
1603
 
@@ -1496,7 +1621,16 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1496
1621
  onDragOver={event => event.preventDefault()}
1497
1622
  onDrop={onDrop}
1498
1623
  >
1499
- <div className={css.grid} style={{ backgroundSize: `${gridSize}px ${gridSize}px`, backgroundPosition: `${gridOffsetX}px ${gridOffsetY}px` }} data-mode={backgroundMode} aria-hidden="true" />
1624
+ <div
1625
+ className={css.grid}
1626
+ style={backgroundMode === 'image' && document?.backgroundImage
1627
+ ? { backgroundImage: `url(${document.backgroundImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
1628
+ : { backgroundSize: `${gridSize}px ${gridSize}px`, backgroundPosition: `${gridOffsetX}px ${gridOffsetY}px` }}
1629
+ data-mode={backgroundMode}
1630
+ aria-hidden="true"
1631
+ >
1632
+ {backgroundMode === 'image' ? <div className={css.gridScrim} /> : null}
1633
+ </div>
1500
1634
  <div className={css.world} style={{ transform: `translate(${document?.viewport.x ?? 0}px, ${document?.viewport.y ?? 0}px) scale(${document?.viewport.k ?? 1})` }}>
1501
1635
  {renderConnections()}
1502
1636
  {document?.nodes.map(renderNode)}
@@ -1505,17 +1639,123 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1505
1639
  {emptyState}
1506
1640
  </div>
1507
1641
 
1508
- <div className={`${css.dock} ${cursorClass}`} data-canvas-no-zoom="">
1509
- <IconButton name="select" label={tt('canvas.toolSelect')} active={tool === 'select'} onClick={() => setTool('select')} />
1510
- <IconButton name="pan" label={tt('canvas.toolPan')} active={tool === 'pan'} onClick={() => setTool('pan')} />
1642
+ <div
1643
+ className={`${css.dock} ${cursorClass}`}
1644
+ data-canvas-no-zoom=""
1645
+ onPointerMove={event => {
1646
+ const dock = event.currentTarget
1647
+ const buttons = [...dock.querySelectorAll<HTMLButtonElement>('.iconButton')]
1648
+ for (const button of buttons) {
1649
+ const rect = button.getBoundingClientRect()
1650
+ const distance = Math.abs(event.clientX - (rect.left + rect.width / 2)) / 40
1651
+ button.style.setProperty('--dock-lift', `${Math.max(0, 4 - distance * 1.5)}px`)
1652
+ }
1653
+ }}
1654
+ onPointerLeave={event => {
1655
+ for (const button of event.currentTarget.querySelectorAll<HTMLButtonElement>('.iconButton')) button.style.removeProperty('--dock-lift')
1656
+ }}
1657
+ >
1658
+ <IconButton name="select" size={18} label={tt('canvas.toolSelect')} active={tool === 'select'} onClick={() => setTool('select')} />
1659
+ <IconButton name="pan" size={18} label={tt('canvas.toolPan')} active={tool === 'pan'} onClick={() => setTool('pan')} />
1511
1660
  <span className={css.dockDivider} />
1512
- <IconButton name="image" label={tt('canvas.addImage')} onClick={() => setPickerOpen(true)} />
1513
- <IconButton name="text" label={tt('canvas.addText')} onClick={() => placeNewNode(createTextNode())} />
1514
- <IconButton name="sparkle" label={tt('canvas.addConfigNode')} onClick={() => placeNewNode(createConfigNode())} />
1661
+ <IconButton
1662
+ name="image"
1663
+ size={18}
1664
+ label={tt('canvas.addImage')}
1665
+ active={imageMenu !== null}
1666
+ onClick={event => openDockMenu('image', event.currentTarget)}
1667
+ onMouseEnter={event => openDockMenu('image', event.currentTarget)}
1668
+ onMouseLeave={scheduleMenuClose}
1669
+ />
1670
+ <IconButton name="text" size={18} label={tt('canvas.addText')} onClick={() => placeNewNode(createTextNode())} />
1671
+ <IconButton name="sparkle" size={18} label={tt('canvas.addConfigNode')} onClick={() => placeNewNode(createConfigNode())} />
1672
+ <IconButton name="template" size={18} label={tt('canvas.templateLibrary')} active={libraryOpen} onClick={() => setLibraryOpen(previous => !previous)} />
1673
+ <span className={css.dockDivider} />
1674
+ <IconButton
1675
+ name="background"
1676
+ size={18}
1677
+ label={tt('canvas.background')}
1678
+ active={backgroundMenu !== null}
1679
+ onClick={event => openDockMenu('background', event.currentTarget)}
1680
+ onMouseEnter={event => openDockMenu('background', event.currentTarget)}
1681
+ onMouseLeave={scheduleMenuClose}
1682
+ />
1683
+ <IconButton name="undo" size={18} label={tt('canvas.undo')} disabled={pastRef.current.length === 0} onClick={undo} />
1684
+ <IconButton name="redo" size={18} label={tt('canvas.redo')} disabled={futureRef.current.length === 0} onClick={redo} />
1515
1685
  <span className={css.dockDivider} />
1516
- <IconButton name="trash" label={tt('canvas.delete')} disabled={selectedIds.size === 0 && selectedConnectionId === null} onClick={deleteSelection} />
1686
+ <IconButton name="trash" size={18} label={tt('canvas.delete')} disabled={selectedIds.size === 0 && selectedConnectionId === null} onClick={deleteSelection} />
1517
1687
  </div>
1518
1688
 
1689
+ {imageMenu !== null ? <div
1690
+ className={css.backgroundMenu}
1691
+ style={{ left: imageMenu.x, top: imageMenu.y - 10 }}
1692
+ data-canvas-no-zoom=""
1693
+ role="menu"
1694
+ onMouseEnter={clearMenuCloseTimer}
1695
+ onMouseLeave={scheduleMenuClose}
1696
+ >
1697
+ <button type="button" role="menuitem" onClick={() => { imageFileRef.current?.click(); setImageMenu(null) }}><ToolbarIcon name="image" size={16} />{tt('canvas.imageMenuUpload')}</button>
1698
+ <button type="button" role="menuitem" onClick={() => { setPickerTab('gallery'); setPickerOpen(true); setImageMenu(null) }}><ToolbarIcon name="template" size={16} />{tt('canvas.imageMenuAssets')}</button>
1699
+ <button type="button" role="menuitem" onClick={() => { setPickerTab('history'); setPickerOpen(true); setImageMenu(null) }}><ToolbarIcon name="undo" size={16} />{tt('canvas.imageMenuHistory')}</button>
1700
+ <button type="button" role="menuitem" onClick={() => { setPickerTab('generate'); setPickerOpen(true); setImageMenu(null) }}><ToolbarIcon name="sparkle" size={16} />{tt('canvas.imageMenuGenerate')}</button>
1701
+ </div> : null}
1702
+ <input
1703
+ ref={imageFileRef}
1704
+ type="file"
1705
+ accept="image/png,image/jpeg,image/webp,image/gif"
1706
+ multiple
1707
+ hidden
1708
+ onChange={event => {
1709
+ const files = [...(event.target.files ?? [])].filter(file => file.type.startsWith('image/'))
1710
+ event.target.value = ''
1711
+ if (files.length === 0) return
1712
+ const world = canvasCenter()
1713
+ void Promise.all(files.map(async file => {
1714
+ const dataUrl = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('读取图片失败')); reader.readAsDataURL(file) })
1715
+ const dimensions = await readImageSize(dataUrl)
1716
+ return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
1717
+ })).then(assets => {
1718
+ const current = documentRef.current
1719
+ const selectedId = selectedIdsRef.current.size === 1 ? [...selectedIdsRef.current][0] : undefined
1720
+ const selectedNode = current?.nodes.find(node => node.id === selectedId)
1721
+ if (selectedNode?.type === 'image' && usableAsset(selectedNode) === undefined && assets[0] !== undefined) {
1722
+ mutate(previous => ({ ...previous, nodes: previous.nodes.map(node => node.id === selectedNode.id ? { ...node, width: sizeForAsset(assets[0]!).width, height: sizeForAsset(assets[0]!).height, metadata: { ...nodeMetadata(node), asset: assets[0], status: 'success' as const, error: undefined } } : node) }))
1723
+ if (assets.length > 1) addAssets(assets.slice(1), world)
1724
+ } else addAssets(assets, world)
1725
+ }).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
1726
+ }}
1727
+ />
1728
+ {backgroundMenu !== null ? <div
1729
+ className={css.backgroundMenu}
1730
+ style={{ left: backgroundMenu.x, top: backgroundMenu.y - 10 }}
1731
+ data-canvas-no-zoom=""
1732
+ role="menu"
1733
+ onMouseEnter={clearMenuCloseTimer}
1734
+ onMouseLeave={scheduleMenuClose}
1735
+ >
1736
+ {([
1737
+ ['dots', tt('canvas.backgroundDots')],
1738
+ ['lines', tt('canvas.backgroundLines')],
1739
+ ['diagonal', tt('canvas.backgroundDiagonal')],
1740
+ ['checker', tt('canvas.backgroundChecker')],
1741
+ ['blank', tt('canvas.backgroundBlank')],
1742
+ ] as const).map(([mode, label]) => <button key={mode} type="button" role="menuitem" data-active={backgroundMode === mode ? '' : undefined} onClick={() => setBackgroundMode(mode)}>{label}</button>)}
1743
+ <span className={css.backgroundMenuDivider} />
1744
+ <button type="button" role="menuitem" data-active={backgroundMode === 'image' ? '' : undefined} onClick={() => backgroundFileRef.current?.click()}>{tt('canvas.backgroundUpload')}</button>
1745
+ {backgroundMode === 'image' && document?.backgroundImage ? <button type="button" role="menuitem" onClick={removeBackgroundImage}>{tt('canvas.backgroundRemove')}</button> : null}
1746
+ <input
1747
+ ref={backgroundFileRef}
1748
+ type="file"
1749
+ accept="image/png,image/jpeg,image/webp,image/gif"
1750
+ hidden
1751
+ onChange={event => {
1752
+ const file = event.target.files?.[0]
1753
+ if (file !== undefined) void uploadBackgroundImage(file)
1754
+ event.target.value = ''
1755
+ }}
1756
+ />
1757
+ </div> : null}
1758
+
1519
1759
  <div className={css.zoomDock} data-canvas-no-zoom="">
1520
1760
  <IconButton name="minimap" label={minimapOpen ? tt('canvas.minimapClose') : tt('canvas.minimapOpen')} active={minimapOpen} onClick={() => setMinimapOpen(previous => !previous)} />
1521
1761
  <IconButton name="fit" label={tt('canvas.fitView')} onClick={fitView} />
@@ -1534,6 +1774,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1534
1774
  {minimapOpen ? renderMinimap() : null}
1535
1775
  {renderComposer()}
1536
1776
  {renderContextMenu()}
1777
+ {libraryOpen ? <TemplateLibrary api={api} onClose={() => setLibraryOpen(false)} onUse={applyTemplate} /> : null}
1537
1778
 
1538
1779
  {error !== null ? <div className={css.errorToast} role="status" data-canvas-no-zoom="">{error}<button type="button" aria-label={tt('canvas.dismiss')} onClick={() => setError(null)}><ToolbarIcon name="close" /></button></div> : null}
1539
1780
 
@@ -1545,6 +1786,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1545
1786
  defaultChannelId={defaultChannelId}
1546
1787
  canvasId={document?.id ?? ''}
1547
1788
  connected={connected}
1789
+ initialTab={pickerTab}
1548
1790
  onClose={() => setPickerOpen(false)}
1549
1791
  onAssets={assets => { addAssets(assets); setPickerOpen(false) }}
1550
1792
  onTask={task => {
@@ -1572,12 +1814,13 @@ function ImagePicker(props: {
1572
1814
  defaultChannelId?: string
1573
1815
  canvasId: string
1574
1816
  connected: boolean
1817
+ initialTab?: 'upload' | 'history' | 'gallery' | 'generate'
1575
1818
  onClose: () => void
1576
1819
  onAssets: (assets: CanvasAssetRef[]) => void
1577
1820
  onTask: (task: GenerationTask) => void
1578
1821
  }): React.JSX.Element {
1579
1822
  const { api, history, gallery, imageModels, defaultChannelId, canvasId, connected, onClose, onAssets } = props
1580
- const [tab, setTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>('upload')
1823
+ const [tab, setTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>(props.initialTab ?? 'upload')
1581
1824
  const [selected, setSelected] = useState<string[]>([])
1582
1825
  const [dimensions, setDimensions] = useState<Record<string, { width: number; height: number }>>({})
1583
1826
  const [prompt, setPrompt] = useState('')