@dickpy/dsh-imagegen 1.5.4 → 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.
@@ -2,15 +2,11 @@
2
2
 
3
3
  import { promises as fs } from 'node:fs'
4
4
  import { createHash, randomUUID } from 'node:crypto'
5
- import { homedir } from 'node:os'
6
5
  import path from 'node:path'
7
6
  import type { CanvasAssetRef, CanvasDocument, CanvasNode, CanvasSummary } from './protocol.ts'
7
+ import { imageDataRoot } from './image-storage-path.ts'
8
+
8
9
 
9
- const DATA_ROOT = (process.env.DSH_HOME?.trim() || path.join(homedir(), '.dsh'))
10
- const CANVAS_ROOT = path.join(DATA_ROOT, 'dsh-imagegen', 'canvas')
11
- const PAGES_DIR = path.join(CANVAS_ROOT, 'pages')
12
- const ASSETS_DIR = path.join(CANVAS_ROOT, 'assets')
13
- const INDEX_PATH = path.join(CANVAS_ROOT, 'index.json')
14
10
 
15
11
  export interface CanvasImageInput {
16
12
  data: Uint8Array
@@ -61,20 +57,21 @@ function safeId(value: string): string {
61
57
  }
62
58
 
63
59
  function pagePath(id: string): string {
64
- return path.join(PAGES_DIR, `${safeId(id)}.json`)
60
+ return path.join(path.join(imageDataRoot(), 'canvas', 'pages'), `${safeId(id)}.json`)
65
61
  }
66
62
 
67
63
  function assetFilePath(id: string): string | undefined {
68
64
  if (!/^[a-f0-9]{64}\.(png|jpg|jpeg|webp|gif)$/.test(id)) return undefined
69
- const file = path.join(ASSETS_DIR, id)
70
- const relative = path.relative(ASSETS_DIR, file)
65
+ const root = path.join(imageDataRoot(), 'canvas', 'assets')
66
+ const file = path.join(root, id)
67
+ const relative = path.relative(root, file)
71
68
  if (relative.startsWith('..') || path.isAbsolute(relative)) return undefined
72
69
  return file
73
70
  }
74
71
 
75
72
  async function ensureDirs(): Promise<void> {
76
- await fs.mkdir(PAGES_DIR, { recursive: true })
77
- await fs.mkdir(ASSETS_DIR, { recursive: true })
73
+ await fs.mkdir(path.join(imageDataRoot(), 'canvas', 'pages'), { recursive: true })
74
+ await fs.mkdir(path.join(imageDataRoot(), 'canvas', 'assets'), { recursive: true })
78
75
  }
79
76
 
80
77
  async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
@@ -138,7 +135,9 @@ function isDocument(value: unknown): value is CanvasDocument {
138
135
  && typeof (document.viewport as { x?: unknown }).x === 'number'
139
136
  && typeof (document.viewport as { y?: unknown }).y === 'number'
140
137
  && typeof (document.viewport as { k?: unknown }).k === 'number'
141
- && (document.background === 'dots' || document.background === 'lines' || document.background === 'blank')
138
+ && (document.background === 'dots' || document.background === 'lines' || document.background === 'diagonal'
139
+ || document.background === 'checker' || document.background === 'blank' || document.background === 'image')
140
+ && (document.backgroundImage === undefined || typeof document.backgroundImage === 'string')
142
141
  && Array.isArray(document.nodes) && document.nodes.every(isNode)
143
142
  && Array.isArray(document.connections)
144
143
  }
@@ -245,11 +244,11 @@ function serialize<T>(operation: () => Promise<T>): Promise<T> {
245
244
  }
246
245
 
247
246
  export class CanvasStore {
248
- constructor(private readonly root = CANVAS_ROOT) {}
247
+ constructor(private readonly root?: string) {}
249
248
 
250
- private pagesDir(): string { return path.join(this.root, 'pages') }
251
- private assetsDir(): string { return path.join(this.root, 'assets') }
252
- private indexPath(): string { return path.join(this.root, 'index.json') }
249
+ private pagesDir(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'pages') }
250
+ private assetsDir(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'assets') }
251
+ private indexPath(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'index.json') }
253
252
 
254
253
  private async ensure(): Promise<void> {
255
254
  await fs.mkdir(this.pagesDir(), { recursive: true })
@@ -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)
@@ -254,6 +312,8 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
254
312
  const syncedRef = useRef('')
255
313
  const processedTasks = useRef(new Set<string>())
256
314
  const processedImport = useRef('')
315
+ const localTaskIds = useRef(new Set<string>())
316
+ const mountedAtRef = useRef(Date.now())
257
317
  const internalClipboard = useRef<{ nodes: CanvasNode[]; connections: Array<{ fromNodeId: string; toNodeId: string }> } | null>(null)
258
318
  const pastRef = useRef<string[]>([])
259
319
  const futureRef = useRef<string[]>([])
@@ -480,22 +540,32 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
480
540
  setComposerBusy(true)
481
541
  try {
482
542
  let image: string | undefined
543
+ let images: string[] | undefined
483
544
  let refName: string | undefined
484
545
  if (baseAsset !== undefined) {
485
546
  image = await assetToDataUrl(baseAsset)
486
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
487
555
  }
488
556
  const footprint = nodeSizeFromRatio(composerSize, IMAGE_NODE_SIZE)
489
557
  const request: GenerateRequest = {
490
558
  mode: image === undefined ? 'text' : 'edit', model, prompt, size: composerSize, quality: composerQuality, n: count, detail: '',
491
559
  ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
492
560
  ...(image === undefined ? {} : { image, refName }),
561
+ ...(images === undefined ? {} : { images }),
493
562
  canvas: {
494
563
  canvasId: current.id,
495
564
  ...(target === null ? {} : { sourceNodeId: referenceImages[0]?.id ?? target.id, parentNodeId: target.id, placement: 'right' as const }),
496
565
  },
497
566
  }
498
567
  const task = await api.taskSubmit(request)
568
+ localTaskIds.current.add(task.id)
499
569
  mutate(previous => {
500
570
  const nodes = [...previous.nodes]
501
571
  const connections = [...previous.connections]
@@ -525,6 +595,71 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
525
595
 
526
596
  // ---------------------------------------------------------- task intake
527
597
 
598
+ /** Re-run a failed image node's generation from its recorded prompt/model,
599
+ * re-deriving the edit base from the connected source config node. */
600
+ const retryGeneration = useCallback(async (node: CanvasNode): Promise<void> => {
601
+ const current = documentRef.current
602
+ if (current === null || !connected) { setError(tt('canvas.needApi')); onOpenSettings?.(); return }
603
+ const metadata = nodeMetadata(node)
604
+ const prompt = (metadata.prompt ?? '').trim()
605
+ if (prompt === '') { setError(tt('canvas.needPrompt')); return }
606
+ const model = imageModels.includes(metadata.model ?? '') ? metadata.model! : imageModels[0] ?? ''
607
+ if (model === '') { setError(tt('canvas.needModel')); return }
608
+ const sourceId = metadata.sourceNodeId
609
+ const references = sourceId === undefined ? [] : upstreamNodes(current, sourceId).filter(item => item.type === 'image' && usableAsset(item) !== undefined)
610
+ const baseAsset = references[0] !== undefined ? usableAsset(references[0]!) : undefined
611
+ try {
612
+ let image: string | undefined
613
+ let images: string[] | undefined
614
+ let refName: string | undefined
615
+ if (baseAsset !== undefined) {
616
+ image = await assetToDataUrl(baseAsset)
617
+ refName = 'canvas-reference.png'
618
+ const extras: string[] = []
619
+ for (const reference of references.slice(1, 4)) {
620
+ const asset = usableAsset(reference)
621
+ if (asset === undefined) continue
622
+ try { extras.push(await assetToDataUrl(asset)) } catch { /* skip unreadable reference */ }
623
+ }
624
+ if (extras.length > 0) images = extras
625
+ }
626
+ const request: GenerateRequest = {
627
+ mode: image === undefined ? 'text' : 'edit', model, prompt, size: metadata.size ?? 'auto', quality: metadata.quality ?? 'auto', n: 1, detail: '',
628
+ ...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
629
+ ...(image === undefined ? {} : { image, refName }),
630
+ ...(images === undefined ? {} : { images }),
631
+ canvas: { canvasId: current.id, sourceNodeId: references[0]?.id ?? sourceId, parentNodeId: node.id, placement: 'right' as const },
632
+ }
633
+ const task = await api.taskSubmit(request)
634
+ localTaskIds.current.add(task.id)
635
+ patchNode(node.id, { status: 'generating', error: undefined, taskId: task.id })
636
+ setError(null)
637
+ } catch (caught) {
638
+ setError(caught instanceof Error ? caught.message : String(caught))
639
+ }
640
+ }, [api, connected, defaultChannelId, imageModels, onOpenSettings, patchNode, upstreamNodes])
641
+
642
+ // Orphan reconciliation: a generating placeholder whose task no longer exists
643
+ // in the host feed (e.g. the host restarted) can never complete on its own.
644
+ useEffect(() => {
645
+ if (document === null) return
646
+ const feedFresh = tasks.length > 0 || Date.now() - mountedAtRef.current > 8000
647
+ if (!feedFresh) return
648
+ const feedIds = new Set(tasks.map(task => task.id))
649
+ const orphans = document.nodes.filter(node => {
650
+ if (node.type !== 'image' || nodeMetadata(node).status !== 'generating') return false
651
+ const taskId = nodeMetadata(node).taskId
652
+ return taskId !== undefined && !feedIds.has(taskId) && !localTaskIds.current.has(taskId)
653
+ })
654
+ if (orphans.length === 0) return
655
+ updateNodes(nodes => nodes.map(node => {
656
+ const taskId = node.type === 'image' ? nodeMetadata(node).taskId : undefined
657
+ if (node.type !== 'image' || nodeMetadata(node).status !== 'generating' || taskId === undefined
658
+ || feedIds.has(taskId) || localTaskIds.current.has(taskId)) return node
659
+ return { ...node, metadata: { ...nodeMetadata(node), status: 'error', error: tt('canvas.taskLost') } }
660
+ }))
661
+ }, [document, tasks, updateNodes])
662
+
528
663
  useEffect(() => {
529
664
  if (document === null) return
530
665
  const canvasTasks = tasks.filter(task => task.request.canvas?.canvasId === document.id)
@@ -634,7 +769,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
634
769
  const first = list[0] === undefined ? await api.canvasCreate(tt('canvas.untitled')) : await api.canvasRead(list[0].id)
635
770
  if (disposed) return
636
771
  setProjects(list[0] === undefined ? [summaryOf(first)] : list)
637
- setDocument(first)
772
+ setDocument(normalizeConfigNodeSizes(first))
638
773
  syncedRef.current = JSON.stringify(first)
639
774
  setSaveState('saved')
640
775
  }).catch(caught => { if (!disposed) { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') } })
@@ -711,7 +846,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
711
846
  if (documentRef.current === null) return
712
847
  const mod = event.ctrlKey || event.metaKey
713
848
  if (event.key === 'Escape') {
714
- setContextMenu(null); setCreateMenu(null)
849
+ setContextMenu(null); setCreateMenu(null); setBackgroundMenu(null); setImageMenu(null)
715
850
  if (!isEditingTarget(event.target)) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
716
851
  return
717
852
  }
@@ -788,6 +923,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
788
923
  const onViewportPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
789
924
  const target = event.target instanceof Element ? event.target : null
790
925
  setContextMenu(null); setCreateMenu(null)
926
+ if (!target?.closest('[data-canvas-no-zoom]')) { setBackgroundMenu(null); setImageMenu(null) }
791
927
  const isBackground = target?.closest('[data-node-id],[data-connection-hit]') === null
792
928
  const shouldPan = event.button === 1 || (event.button === 0 && (tool === 'pan' || temporaryPanTool) && isBackground)
793
929
  if (shouldPan) {
@@ -1074,7 +1210,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1074
1210
  const selectProject = useCallback(async (id: string): Promise<void> => {
1075
1211
  try {
1076
1212
  const next = await api.canvasRead(id)
1077
- setDocument(next); setSelectedIds(new Set()); setSelectedConnectionId(null)
1213
+ setDocument(normalizeConfigNodeSizes(next)); setSelectedIds(new Set()); setSelectedConnectionId(null)
1078
1214
  syncedRef.current = JSON.stringify(next); setSaveState('saved')
1079
1215
  pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1080
1216
  } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
@@ -1116,10 +1252,52 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1116
1252
  const cursorClass = tool === 'pan' || isSpaceOrCtrl ? css.panCursor : css.selectCursor
1117
1253
 
1118
1254
  const backgroundMode = document?.background ?? 'dots'
1119
- const cycleBackground = useCallback((): void => {
1120
- 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)
1262
+ }, [mutate])
1263
+
1264
+ const uploadBackgroundImage = useCallback(async (file: File): Promise<void> => {
1265
+ try {
1266
+ const dataUrl = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('读取图片失败')); reader.readAsDataURL(file) })
1267
+ const dimensions = await readImageSize(dataUrl)
1268
+ const asset = await api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: 'canvas-background' })
1269
+ mutate(previous => ({ ...previous, background: 'image', backgroundImage: asset.url }))
1270
+ setBackgroundMenu(null)
1271
+ setError(null)
1272
+ } catch (caught) {
1273
+ setError(caught instanceof Error ? caught.message : String(caught))
1274
+ }
1275
+ }, [api, mutate])
1276
+
1277
+ const removeBackgroundImage = useCallback((): void => {
1278
+ mutate(previous => ({ ...previous, background: 'dots', backgroundImage: undefined }))
1279
+ setBackgroundMenu(null)
1121
1280
  }, [mutate])
1122
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
+
1123
1301
  const gridSize = GRID_SIZE * (document?.viewport.k ?? 1)
1124
1302
  const gridOffsetX = (document?.viewport.x ?? 0) % gridSize
1125
1303
  const gridOffsetY = (document?.viewport.y ?? 0) % gridSize
@@ -1168,13 +1346,21 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1168
1346
  onChange={event => patchNode(node.id, { text: event.target.value })}
1169
1347
  />
1170
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}
1171
1357
  {isGenerating
1172
1358
  ? <div className={css.nodeState}><span className={css.spinner} aria-hidden="true" /><span>{tt('canvas.generatingNode')}</span></div>
1173
1359
  : 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>
1360
+ ? <div className={css.nodeStateError}>{metadata.error ?? tt('canvas.generateFailed')}<button type="button" onClick={() => { void retryGeneration(node) }}>{tt('canvas.retry')}</button></div>
1175
1361
  : hasImage
1176
1362
  ? <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>}
1363
+ : <button type="button" className={css.nodeEmpty} onClick={() => imageFileRef.current?.click()}><ToolbarIcon name="image" /><span>{tt('canvas.emptyImageNode')}</span></button>}
1178
1364
  </div>}
1179
1365
  {isSelected
1180
1366
  ? <div className={css.resizeHandle} onPointerDown={event => handleResizeStart(event, node, 'bottom-right')} title={tt('canvas.resizeHint')} />
@@ -1241,11 +1427,12 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1241
1427
  if (!composerVisible || document === null || composerTarget === null) return null
1242
1428
  const linkedCount = composerReferenceCount + composerTextCount
1243
1429
  const k = document.viewport.k
1244
- 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
1245
1432
  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)
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)
1249
1436
  : belowY
1250
1437
  return <div className={css.composer} data-canvas-no-zoom="" style={{ left: clampedX - 280, top }}>
1251
1438
  <textarea
@@ -1390,7 +1577,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1390
1577
  return { left: x1, top: y1, width: x2 - x1, height: y2 - y1 }
1391
1578
  })()
1392
1579
 
1393
- return <section className={css.root} data-canvas-workspace="">
1580
+ return <section ref={rootRef} className={css.root} data-canvas-workspace="">
1394
1581
  <header className={css.topBar} data-canvas-no-zoom="">
1395
1582
  <select className={css.projectSelect} value={document?.id ?? ''} onChange={event => { void selectProject(event.target.value) }} aria-label={tt('canvas.project')}>
1396
1583
  {projects.map(project => <option key={project.id} value={project.id}>{project.title}</option>)}
@@ -1411,9 +1598,6 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1411
1598
  />
1412
1599
  : <button type="button" className={css.titleButton} onDoubleClick={() => setRenamingTitle(true)} title={tt('canvas.renameHint')}>{document?.title ?? ''}</button>}
1413
1600
  <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
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>
1418
1602
  </header>
1419
1603
 
@@ -1437,7 +1621,16 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1437
1621
  onDragOver={event => event.preventDefault()}
1438
1622
  onDrop={onDrop}
1439
1623
  >
1440
- <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>
1441
1634
  <div className={css.world} style={{ transform: `translate(${document?.viewport.x ?? 0}px, ${document?.viewport.y ?? 0}px) scale(${document?.viewport.k ?? 1})` }}>
1442
1635
  {renderConnections()}
1443
1636
  {document?.nodes.map(renderNode)}
@@ -1446,17 +1639,123 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1446
1639
  {emptyState}
1447
1640
  </div>
1448
1641
 
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')} />
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')} />
1452
1660
  <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())} />
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)} />
1456
1673
  <span className={css.dockDivider} />
1457
- <IconButton name="trash" label={tt('canvas.delete')} disabled={selectedIds.size === 0 && selectedConnectionId === null} onClick={deleteSelection} />
1674
+ <IconButton
1675
+ name="background"
1676
+ size={18}
1677
+ label={tt('canvas.background')}
1678
+ active={backgroundMenu !== null}
1679
+ onClick={event => openDockMenu('background', event.currentTarget)}
1680
+ onMouseEnter={event => openDockMenu('background', event.currentTarget)}
1681
+ onMouseLeave={scheduleMenuClose}
1682
+ />
1683
+ <IconButton name="undo" size={18} label={tt('canvas.undo')} disabled={pastRef.current.length === 0} onClick={undo} />
1684
+ <IconButton name="redo" size={18} label={tt('canvas.redo')} disabled={futureRef.current.length === 0} onClick={redo} />
1685
+ <span className={css.dockDivider} />
1686
+ <IconButton name="trash" size={18} label={tt('canvas.delete')} disabled={selectedIds.size === 0 && selectedConnectionId === null} onClick={deleteSelection} />
1458
1687
  </div>
1459
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
+
1460
1759
  <div className={css.zoomDock} data-canvas-no-zoom="">
1461
1760
  <IconButton name="minimap" label={minimapOpen ? tt('canvas.minimapClose') : tt('canvas.minimapOpen')} active={minimapOpen} onClick={() => setMinimapOpen(previous => !previous)} />
1462
1761
  <IconButton name="fit" label={tt('canvas.fitView')} onClick={fitView} />
@@ -1475,6 +1774,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1475
1774
  {minimapOpen ? renderMinimap() : null}
1476
1775
  {renderComposer()}
1477
1776
  {renderContextMenu()}
1777
+ {libraryOpen ? <TemplateLibrary api={api} onClose={() => setLibraryOpen(false)} onUse={applyTemplate} /> : null}
1478
1778
 
1479
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}
1480
1780
 
@@ -1486,6 +1786,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1486
1786
  defaultChannelId={defaultChannelId}
1487
1787
  canvasId={document?.id ?? ''}
1488
1788
  connected={connected}
1789
+ initialTab={pickerTab}
1489
1790
  onClose={() => setPickerOpen(false)}
1490
1791
  onAssets={assets => { addAssets(assets); setPickerOpen(false) }}
1491
1792
  onTask={task => {
@@ -1513,12 +1814,13 @@ function ImagePicker(props: {
1513
1814
  defaultChannelId?: string
1514
1815
  canvasId: string
1515
1816
  connected: boolean
1817
+ initialTab?: 'upload' | 'history' | 'gallery' | 'generate'
1516
1818
  onClose: () => void
1517
1819
  onAssets: (assets: CanvasAssetRef[]) => void
1518
1820
  onTask: (task: GenerationTask) => void
1519
1821
  }): React.JSX.Element {
1520
1822
  const { api, history, gallery, imageModels, defaultChannelId, canvasId, connected, onClose, onAssets } = props
1521
- const [tab, setTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>('upload')
1823
+ const [tab, setTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>(props.initialTab ?? 'upload')
1522
1824
  const [selected, setSelected] = useState<string[]>([])
1523
1825
  const [dimensions, setDimensions] = useState<Record<string, { width: number; height: number }>>({})
1524
1826
  const [prompt, setPrompt] = useState('')