@dickpy/dsh-imagegen 1.5.7 → 1.5.8

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.
@@ -6,6 +6,10 @@
6
6
  * as a reference, and results land as new image nodes on the right. */
7
7
 
8
8
  import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
9
+ import {
10
+ BookOpen, ChevronDown, Copy, Download, FolderX, Hand, Image as ImageIcon, Map as MapIcon, Maximize,
11
+ MousePointer2, Plus, Redo2, SendHorizonal, Sparkles, Trash2, Type, Undo2, Wallpaper, X,
12
+ } from 'lucide-react'
9
13
  import type { CanvasAssetRef, CanvasConnection, CanvasDocument, CanvasNode, GenerateRequest, GenerationTask, HistoryEntry } from '../protocol.ts'
10
14
  import type { ImageGenApi } from './api.ts'
11
15
  import { tt } from './helpers.ts'
@@ -18,7 +22,7 @@ type BackgroundMode = CanvasDocument['background']
18
22
  const MIN_SCALE = 0.05
19
23
  const MAX_SCALE = 5
20
24
  const GRID_SIZE = 48
21
- const IMAGE_NODE_SIZE = { width: 320, height: 320 }
25
+ const IMAGE_NODE_SIZE = { width: 240, height: 240 }
22
26
  const TEXT_NODE_SIZE = { width: 280, height: 150 }
23
27
  const CONFIG_NODE_SIZE = { width: 320, height: 190 }
24
28
  const LEGACY_CONFIG_NODE_SIZE = { width: 240, height: 96 }
@@ -70,6 +74,10 @@ interface ConnectState {
70
74
  handleType: 'source' | 'target'
71
75
  mouse: Point
72
76
  targetId: string | null
77
+ /** False for a plain click on the handle (opens the add-node menu), true
78
+ * once the pointer travels far enough that this is a drag-to-connect. */
79
+ moved: boolean
80
+ startClient: Point
73
81
  }
74
82
 
75
83
  interface ResizeState {
@@ -200,26 +208,30 @@ function nodeAnchor(node: CanvasNode, side: 'left' | 'right'): Point {
200
208
 
201
209
  type ToolbarIconName = 'new' | 'select' | 'pan' | 'image' | 'text' | 'trash' | 'undo' | 'redo' | 'fit' | 'minimap' | 'background' | 'template' | 'download' | 'duplicate' | 'sparkle' | 'send' | 'close' | 'deleteProject'
202
210
 
211
+ /** Lucide icons (stroke matches the DSH line style); one shared component so
212
+ * every dock/toolbar icon comes from the same well-drawn set. */
203
213
  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 }
205
- if (name === 'new') return <svg {...common}><path d="M8 3v10M3 8h10" /></svg>
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>
207
- if (name === 'pan') return <svg {...common}><path d="M8 2v12M2 8h12M5 5l3-3 3 3M5 11l3 3 3-3" /></svg>
208
- 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>
209
- if (name === 'text') return <svg {...common}><path d="M3 3h10M8 3v10M5.5 13h5" /></svg>
210
- 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>
211
- if (name === 'undo') return <svg {...common}><path d="M3 7a5 5 0 1 1 1.5 4M3 3v4h4" /></svg>
212
- if (name === 'redo') return <svg {...common}><path d="M13 7a5 5 0 1 0-1.5 4M13 3v4h-4" /></svg>
213
- if (name === 'fit') return <svg {...common}><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4" /></svg>
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>
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>
217
- if (name === 'download') return <svg {...common}><path d="M8 2.5v8M5 7.5l3 3 3-3M3 13.5h10" /></svg>
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>
219
- if (name === 'send') return <svg {...common}><path d="M14 2L7 9M14 2L9.5 14l-2.5-5L2 6.5 14 2z" /></svg>
220
- if (name === 'close') return <svg {...common}><path d="M4 4l8 8M12 4l-8 8" /></svg>
221
- 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>
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>
214
+ const common = { size, strokeWidth: 1.6, 'aria-hidden': true as const }
215
+ switch (name) {
216
+ case 'new': return <Plus {...common} />
217
+ case 'select': return <MousePointer2 {...common} />
218
+ case 'pan': return <Hand {...common} />
219
+ case 'image': return <ImageIcon {...common} />
220
+ case 'text': return <Type {...common} />
221
+ case 'trash': return <Trash2 {...common} />
222
+ case 'undo': return <Undo2 {...common} />
223
+ case 'redo': return <Redo2 {...common} />
224
+ case 'fit': return <Maximize {...common} />
225
+ case 'minimap': return <MapIcon {...common} />
226
+ case 'background': return <Wallpaper {...common} />
227
+ case 'template': return <BookOpen {...common} />
228
+ case 'download': return <Download {...common} />
229
+ case 'duplicate': return <Copy {...common} />
230
+ case 'sparkle': return <Sparkles {...common} />
231
+ case 'send': return <SendHorizonal {...common} />
232
+ case 'close': return <X {...common} />
233
+ case 'deleteProject': return <FolderX {...common} />
234
+ }
223
235
  }
224
236
 
225
237
  function IconButton(props: {
@@ -245,6 +257,59 @@ function IconButton(props: {
245
257
  ><ToolbarIcon name={props.name} size={props.size} /></button>
246
258
  }
247
259
 
260
+ /** Styled dropdown standing in for a native <select> so the composer and the
261
+ * picker match the canvas visual language instead of the OS popup. */
262
+ function ComposerSelect(props: {
263
+ value: string
264
+ options: Array<{ value: string; label: string }>
265
+ ariaLabel: string
266
+ onChange: (value: string) => void
267
+ }): React.JSX.Element {
268
+ const [open, setOpen] = useState(false)
269
+ const [position, setPosition] = useState<{ left: number; top: number; minWidth: number } | null>(null)
270
+ const buttonRef = useRef<HTMLButtonElement>(null)
271
+ useEffect(() => {
272
+ if (!open) return
273
+ const close = (event: PointerEvent): void => {
274
+ if (event.target instanceof Element && buttonRef.current?.contains(event.target) === true) return
275
+ setOpen(false)
276
+ }
277
+ window.addEventListener('pointerdown', close, true)
278
+ return () => window.removeEventListener('pointerdown', close, true)
279
+ }, [open])
280
+ const selected = props.options.find(option => option.value === props.value) ?? props.options[0]
281
+ return <>
282
+ <button
283
+ type="button"
284
+ ref={buttonRef}
285
+ className={css.composerSelect}
286
+ data-open={open ? '' : undefined}
287
+ aria-label={props.ariaLabel}
288
+ aria-haspopup="listbox"
289
+ aria-expanded={open}
290
+ onClick={() => {
291
+ if (open) { setOpen(false); return }
292
+ const rect = buttonRef.current?.getBoundingClientRect()
293
+ if (rect !== undefined) setPosition({ left: rect.left, top: rect.bottom + 6, minWidth: rect.width })
294
+ setOpen(true)
295
+ }}
296
+ >
297
+ <span className={css.composerSelectValue}>{selected?.label ?? ''}</span>
298
+ <ChevronDown size={13} strokeWidth={2} aria-hidden="true" />
299
+ </button>
300
+ {open && position !== null ? <div className={css.composerSelectMenu} style={{ left: position.left, top: position.top, minWidth: position.minWidth }} role="listbox" aria-label={props.ariaLabel}>
301
+ {props.options.map(option => <button
302
+ key={option.value}
303
+ type="button"
304
+ role="option"
305
+ aria-selected={option.value === props.value}
306
+ data-selected={option.value === props.value ? '' : undefined}
307
+ onClick={() => { props.onChange(option.value); setOpen(false) }}
308
+ >{option.label}</button>)}
309
+ </div> : null}
310
+ </>
311
+ }
312
+
248
313
  export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element {
249
314
  const { api, imageModels, defaultChannelId, connected, history, gallery, tasks, importRequest, onImportRequestHandled, onOpenSettings } = props
250
315
  const [projects, setProjects] = useState<ProjectSummary[]>([])
@@ -259,6 +324,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
259
324
  const [connecting, setConnecting] = useState<ConnectState | null>(null)
260
325
  const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
261
326
  const [createMenu, setCreateMenu] = useState<{ screen: Point; world: Point } | null>(null)
327
+ const [nodeAddMenu, setNodeAddMenu] = useState<{ nodeId: string; nodeType: CanvasNode['type']; screen: Point } | null>(null)
262
328
  const [minimapOpen, setMinimapOpen] = useState(true)
263
329
  const [pickerOpen, setPickerOpen] = useState(false)
264
330
  const [backgroundMenu, setBackgroundMenu] = useState<Point | null>(null)
@@ -434,6 +500,28 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
434
500
  }
435
501
  }, [canvasCenter])
436
502
 
503
+ /** A brand-new canvas starts with one text node wired into one config node,
504
+ * laid out around the visible viewport center so the workflow is obvious. */
505
+ const seedDocument = useCallback((created: CanvasDocument): CanvasDocument => {
506
+ if (created.nodes.length > 0) return created
507
+ const bounds = viewportRef.current?.getBoundingClientRect()
508
+ const viewport = created.viewport
509
+ const center = bounds !== undefined && bounds.width > 0 && bounds.height > 0
510
+ ? { x: (bounds.width / 2 - viewport.x) / viewport.k, y: (bounds.height / 2 - viewport.y) / viewport.k }
511
+ : { x: 480, y: 320 }
512
+ const config = createConfigNode(center)
513
+ const text: CanvasNode = {
514
+ ...createTextNode(),
515
+ x: Math.round(config.x - TEXT_NODE_SIZE.width - 80),
516
+ y: Math.round(config.y + (CONFIG_NODE_SIZE.height - TEXT_NODE_SIZE.height) / 2),
517
+ }
518
+ return {
519
+ ...created,
520
+ nodes: [text, config],
521
+ connections: [{ id: newId('edge'), fromNodeId: text.id, toNodeId: config.id }],
522
+ }
523
+ }, [createConfigNode, createTextNode])
524
+
437
525
  const updateNodes = useCallback((updater: (nodes: CanvasNode[]) => CanvasNode[]): void => {
438
526
  updateDocument(previous => ({ ...previous, nodes: updater(previous.nodes) }))
439
527
  }, [updateDocument])
@@ -503,6 +591,73 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
503
591
  mutate(previous => ({ ...previous, connections: [...previous.connections, { id: newId('edge'), fromNodeId, toNodeId }] }))
504
592
  }, [mutate])
505
593
 
594
+ /** Dify-style quick add: create a node to the right of `sourceId`, vertically
595
+ * centered against it, and wire source -> new node in one history step. The
596
+ * target spot walks right past any node already occupying it, and the
597
+ * viewport pans just enough to keep the new node visible. */
598
+ const addConnectedNode = useCallback((sourceId: string, factory: (position: Point) => CanvasNode): void => {
599
+ const current = documentRef.current
600
+ if (current === null) return
601
+ const source = current.nodes.find(item => item.id === sourceId)
602
+ if (source === undefined) return
603
+ const draft = factory({ x: 0, y: 0 })
604
+ const y = Math.round(source.y + (source.height - draft.height) / 2)
605
+ let x = source.x + source.width + 90
606
+ for (let guard = 0; guard < 24; guard += 1) {
607
+ const clash = current.nodes.find(node =>
608
+ Math.abs((y + draft.height / 2) - (node.y + node.height / 2)) < (draft.height + node.height) / 2 + 20
609
+ && x < node.x + node.width + 48
610
+ && x + draft.width > node.x - 48)
611
+ if (clash === undefined) break
612
+ x = clash.x + clash.width + 88
613
+ }
614
+ const node: CanvasNode = { ...draft, x, y }
615
+ mutate(previous => ({
616
+ ...previous,
617
+ nodes: [...previous.nodes, node],
618
+ connections: [...previous.connections, { id: newId('edge'), fromNodeId: sourceId, toNodeId: node.id }],
619
+ }))
620
+ const bounds = viewportRef.current?.getBoundingClientRect()
621
+ if (bounds === undefined) return
622
+ const viewport = current.viewport
623
+ const k = viewport.k
624
+ const left = viewport.x + x * k
625
+ const right = viewport.x + (x + draft.width) * k
626
+ const top = viewport.y + y * k
627
+ const bottom = viewport.y + (y + draft.height) * k
628
+ let dx = 0
629
+ let dy = 0
630
+ if (right > bounds.width - 24) dx = right - (bounds.width - 24)
631
+ if (bottom > bounds.height - 24) dy = bottom - (bounds.height - 24)
632
+ if (dx !== 0 || dy !== 0) setViewport({ x: viewport.x - dx, y: viewport.y - dy, k })
633
+ }, [mutate, setViewport])
634
+
635
+ /** Anchor the add-node menu at the source handle's on-screen position. The
636
+ * menu opens on hover (no click needed) and lingers briefly on leave. */
637
+ const nodeAddMenuTimer = useRef<number | null>(null)
638
+ const clearNodeAddMenuTimer = useCallback((): void => {
639
+ if (nodeAddMenuTimer.current !== null) { window.clearTimeout(nodeAddMenuTimer.current); nodeAddMenuTimer.current = null }
640
+ }, [])
641
+ const scheduleNodeAddMenuClose = useCallback((): void => {
642
+ clearNodeAddMenuTimer()
643
+ nodeAddMenuTimer.current = window.setTimeout(() => setNodeAddMenu(null), 260)
644
+ }, [clearNodeAddMenuTimer])
645
+ const openNodeAddMenu = useCallback((node: CanvasNode): void => {
646
+ const current = documentRef.current
647
+ const bounds = viewportRef.current?.getBoundingClientRect()
648
+ if (current === null || bounds === undefined) return
649
+ clearNodeAddMenuTimer()
650
+ const viewport = current.viewport
651
+ setNodeAddMenu({
652
+ nodeId: node.id,
653
+ nodeType: node.type,
654
+ screen: {
655
+ x: bounds.left + viewport.x + (node.x + node.width) * viewport.k,
656
+ y: bounds.top + viewport.y + (node.y + node.height / 2) * viewport.k,
657
+ },
658
+ })
659
+ }, [clearNodeAddMenuTimer])
660
+
506
661
  const downloadNode = useCallback((node: CanvasNode): void => {
507
662
  const asset = assetOf(node)
508
663
  if (asset === undefined || asset.url === '') return
@@ -766,15 +921,16 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
766
921
  let disposed = false
767
922
  void api.canvasList().then(async list => {
768
923
  if (disposed) return
769
- const first = list[0] === undefined ? await api.canvasCreate(tt('canvas.untitled')) : await api.canvasRead(list[0].id)
924
+ const created = list[0] === undefined ? await api.canvasCreate(tt('canvas.untitled')) : null
925
+ const first = created === null ? await api.canvasRead(list[0]!.id) : seedDocument(created)
770
926
  if (disposed) return
771
- setProjects(list[0] === undefined ? [summaryOf(first)] : list)
927
+ setProjects(created === null ? list : [summaryOf(first)])
772
928
  setDocument(normalizeConfigNodeSizes(first))
773
- syncedRef.current = JSON.stringify(first)
929
+ syncedRef.current = JSON.stringify(created ?? first)
774
930
  setSaveState('saved')
775
931
  }).catch(caught => { if (!disposed) { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') } })
776
932
  return () => { disposed = true }
777
- }, [api])
933
+ }, [api, seedDocument])
778
934
 
779
935
  useEffect(() => {
780
936
  if (document === null || saveState === 'loading') return
@@ -846,7 +1002,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
846
1002
  if (documentRef.current === null) return
847
1003
  const mod = event.ctrlKey || event.metaKey
848
1004
  if (event.key === 'Escape') {
849
- setContextMenu(null); setCreateMenu(null); setBackgroundMenu(null); setImageMenu(null)
1005
+ setContextMenu(null); setCreateMenu(null); setBackgroundMenu(null); setImageMenu(null); setNodeAddMenu(null)
850
1006
  if (!isEditingTarget(event.target)) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
851
1007
  return
852
1008
  }
@@ -922,7 +1078,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
922
1078
 
923
1079
  const onViewportPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
924
1080
  const target = event.target instanceof Element ? event.target : null
925
- setContextMenu(null); setCreateMenu(null)
1081
+ setContextMenu(null); setCreateMenu(null); setNodeAddMenu(null)
926
1082
  if (!target?.closest('[data-canvas-no-zoom]')) { setBackgroundMenu(null); setImageMenu(null) }
927
1083
  const isBackground = target?.closest('[data-node-id],[data-connection-hit]') === null
928
1084
  const shouldPan = event.button === 1 || (event.button === 0 && (tool === 'pan' || temporaryPanTool) && isBackground)
@@ -1017,6 +1173,10 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1017
1173
  const connect = connectRef.current
1018
1174
  if (connect !== null) {
1019
1175
  const world = screenToWorld(event.clientX, event.clientY)
1176
+ if (!connect.moved && Math.hypot(event.clientX - connect.startClient.x, event.clientY - connect.startClient.y) > 4) {
1177
+ connect.moved = true
1178
+ setNodeAddMenu(null)
1179
+ }
1020
1180
  const nodes = documentRef.current?.nodes ?? []
1021
1181
  let targetId: string | null = null
1022
1182
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
@@ -1139,6 +1299,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1139
1299
  if (node === undefined) return
1140
1300
  event.stopPropagation()
1141
1301
  const additive = event.shiftKey || event.ctrlKey || event.metaKey
1302
+ setNodeAddMenu(null)
1142
1303
  let nextSelection = selectedIdsRef.current
1143
1304
  if (additive) {
1144
1305
  nextSelection = new Set(selectedIdsRef.current)
@@ -1160,12 +1321,13 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1160
1321
  const handleConnectStart = useCallback((event: ReactPointerEvent<HTMLDivElement>, nodeId: string, handleType: 'source' | 'target'): void => {
1161
1322
  if (event.button !== 0) return
1162
1323
  event.stopPropagation(); event.preventDefault()
1324
+ clearNodeAddMenuTimer(); setNodeAddMenu(null)
1163
1325
  const world = screenToWorld(event.clientX, event.clientY)
1164
- const next: ConnectState = { nodeId, handleType, mouse: world, targetId: null }
1326
+ const next: ConnectState = { nodeId, handleType, mouse: world, targetId: null, moved: false, startClient: { x: event.clientX, y: event.clientY } }
1165
1327
  connectRef.current = next
1166
1328
  setConnecting(next)
1167
1329
  setSelectedConnectionId(null)
1168
- }, [screenToWorld])
1330
+ }, [clearNodeAddMenuTimer, screenToWorld])
1169
1331
 
1170
1332
  const handleResizeStart = useCallback((event: ReactPointerEvent<HTMLDivElement>, node: CanvasNode, corner: 'bottom-right' | 'bottom-left'): void => {
1171
1333
  if (event.button !== 0) return
@@ -1199,13 +1361,14 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1199
1361
 
1200
1362
  const newCanvas = useCallback(async (): Promise<void> => {
1201
1363
  try {
1202
- const next = await api.canvasCreate(tt('canvas.untitled'))
1364
+ const created = await api.canvasCreate(tt('canvas.untitled'))
1365
+ const next = seedDocument(created)
1203
1366
  setProjects(previous => [summaryOf(next), ...previous])
1204
1367
  setDocument(next); setSelectedIds(new Set()); setSelectedConnectionId(null)
1205
- syncedRef.current = JSON.stringify(next); setSaveState('saved')
1368
+ syncedRef.current = JSON.stringify(created); setSaveState('saved')
1206
1369
  pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
1207
1370
  } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1208
- }, [api])
1371
+ }, [api, seedDocument])
1209
1372
 
1210
1373
  const selectProject = useCallback(async (id: string): Promise<void> => {
1211
1374
  try {
@@ -1225,7 +1388,8 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1225
1388
  const nextId = remaining[0]?.id
1226
1389
  if (nextId === undefined) {
1227
1390
  const created = await api.canvasCreate(tt('canvas.untitled'))
1228
- setProjects([summaryOf(created)]); setDocument(created)
1391
+ const created2 = seedDocument(created)
1392
+ setProjects([summaryOf(created2)]); setDocument(created2)
1229
1393
  syncedRef.current = JSON.stringify(created); setSaveState('saved')
1230
1394
  } else {
1231
1395
  setProjects(remaining)
@@ -1233,7 +1397,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1233
1397
  }
1234
1398
  setSelectedIds(new Set()); setSelectedConnectionId(null)
1235
1399
  } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
1236
- }, [api, selectProject])
1400
+ }, [api, selectProject, seedDocument])
1237
1401
 
1238
1402
  // -------------------------------------------------------------- derived
1239
1403
 
@@ -1366,7 +1530,13 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1366
1530
  ? <div className={css.resizeHandle} onPointerDown={event => handleResizeStart(event, node, 'bottom-right')} title={tt('canvas.resizeHint')} />
1367
1531
  : null}
1368
1532
  <div className={`${css.handle} ${css.handleLeft}`} title={tt('canvas.connectHint')} onPointerDown={event => handleConnectStart(event, node.id, 'target')} />
1369
- <div className={`${css.handle} ${css.handleRight}`} title={tt('canvas.connectHint')} onPointerDown={event => handleConnectStart(event, node.id, 'source')} />
1533
+ <div
1534
+ className={`${css.handle} ${css.handleRight}`}
1535
+ title={tt('canvas.connectAddHint')}
1536
+ onPointerDown={event => handleConnectStart(event, node.id, 'source')}
1537
+ onMouseEnter={() => { window.setTimeout(() => { if (connectRef.current === null) openNodeAddMenu(node) }, 120) }}
1538
+ onMouseLeave={scheduleNodeAddMenuClose}
1539
+ />
1370
1540
  <div className={css.hoverToolbar} onPointerDown={event => event.stopPropagation()}>
1371
1541
  {node.type === 'image' && hasImage ? <IconButton name="download" label={tt('canvas.download')} onClick={() => downloadNode(node)} /> : null}
1372
1542
  <IconButton name="duplicate" label={tt('canvas.duplicate')} onClick={duplicateSelection} />
@@ -1375,19 +1545,44 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1375
1545
  </div>
1376
1546
  }
1377
1547
 
1378
- const renderConnections = (): React.JSX.Element => (
1379
- <svg
1548
+ const renderConnections = (): React.JSX.Element => {
1549
+ const visible = (document?.connections ?? []).filter(connection => nodeById.has(connection.fromNodeId) && nodeById.has(connection.toNodeId))
1550
+ const gradientOf = (connection: CanvasConnection): React.JSX.Element => {
1551
+ const from = nodeById.get(connection.fromNodeId)!
1552
+ const to = nodeById.get(connection.toNodeId)!
1553
+ const start = nodeAnchor(from, 'right')
1554
+ const end = nodeAnchor(to, 'left')
1555
+ return <linearGradient
1556
+ key={connection.id}
1557
+ id={`conn-g-${connection.id}`}
1558
+ gradientUnits="userSpaceOnUse"
1559
+ x1={start.x} y1={start.y} x2={end.x} y2={end.y}
1560
+ >
1561
+ <stop offset="0" stopColor="var(--dsw-alias-brand-primary)" stopOpacity="0.08" />
1562
+ <stop offset="0.7" stopColor="var(--dsw-alias-brand-primary)" stopOpacity="0.4" />
1563
+ <stop offset="1" stopColor="var(--dsw-alias-brand-primary)" stopOpacity="0.85" />
1564
+ </linearGradient>
1565
+ }
1566
+ return <svg
1380
1567
  className={css.connectionLayer}
1381
1568
  width={WORLD_PAD * 2}
1382
1569
  height={WORLD_PAD * 2}
1383
1570
  style={{ left: -WORLD_PAD, top: -WORLD_PAD }}
1384
1571
  aria-hidden="true"
1385
1572
  >
1573
+ <defs>
1574
+ <marker id="conn-arrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7.5" markerHeight="7.5" orient="auto-start-reverse">
1575
+ <path d="M 0 1.6 L 8.4 5 L 0 8.4 Z" fill="color-mix(in srgb, var(--dsw-alias-brand-primary) 62%, transparent)" />
1576
+ </marker>
1577
+ <marker id="conn-arrow-active" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7.5" markerHeight="7.5" orient="auto-start-reverse">
1578
+ <path d="M 0 1.6 L 8.4 5 L 0 8.4 Z" fill="var(--dsw-alias-brand-primary)" />
1579
+ </marker>
1580
+ {visible.map(gradientOf)}
1581
+ </defs>
1386
1582
  <g transform={`translate(${WORLD_PAD},${WORLD_PAD})`}>
1387
- {(document?.connections ?? []).map(connection => {
1388
- const from = nodeById.get(connection.fromNodeId)
1389
- const to = nodeById.get(connection.toNodeId)
1390
- if (from === undefined || to === undefined) return null
1583
+ {visible.map(connection => {
1584
+ const from = nodeById.get(connection.fromNodeId)!
1585
+ const to = nodeById.get(connection.toNodeId)!
1391
1586
  const path = bezierPath(nodeAnchor(from, 'right'), nodeAnchor(to, 'left'))
1392
1587
  const active = connection.id === selectedConnectionId
1393
1588
  return <g key={connection.id}>
@@ -1405,7 +1600,14 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1405
1600
  setContextMenu({ type: 'connection', screen: { x: event.clientX, y: event.clientY }, connectionId: connection.id })
1406
1601
  }}
1407
1602
  />
1408
- <path d={path} className={`${css.connectionPath} ${active ? css.connectionActive : ''}`} />
1603
+ <path
1604
+ d={path}
1605
+ stroke={`url(#conn-g-${connection.id})`}
1606
+ className={css.connectionPath}
1607
+ markerEnd={active ? 'url(#conn-arrow-active)' : 'url(#conn-arrow)'}
1608
+ />
1609
+ {/* A soft light band glides along the path (source -> target). */}
1610
+ <path d={path} className={`${css.connectionFlow} ${active ? css.connectionFlowActive : ''}`} />
1409
1611
  </g>
1410
1612
  })}
1411
1613
  {connecting !== null ? (() => {
@@ -1421,7 +1623,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1421
1623
  })() : null}
1422
1624
  </g>
1423
1625
  </svg>
1424
- )
1626
+ }
1425
1627
 
1426
1628
  const renderComposer = (): ReactNode => {
1427
1629
  if (!composerVisible || document === null || composerTarget === null) return null
@@ -1453,26 +1655,41 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1453
1655
  <span className={css.composerChip}>{tt('canvas.composerLinked', { count: linkedCount })}</span>
1454
1656
  </div> : null}
1455
1657
  <div className={css.composerControls}>
1456
- <select value={composerModel} onChange={(event: ChangeEvent<HTMLSelectElement>) => setComposerModel(event.target.value)} aria-label={tt('canvas.model')}>
1457
- <option value="">{tt('canvas.modelPlaceholder')}</option>
1458
- {imageModels.map(item => <option key={item} value={item}>{item}</option>)}
1459
- </select>
1460
- <select value={composerSize} onChange={event => setComposerSize(event.target.value)} aria-label={tt('canvas.size')}>
1461
- <option value="auto">{tt('canvas.sizeAuto')}</option>
1462
- <option value="1:1">1:1</option>
1463
- <option value="3:4">3:4</option>
1464
- <option value="16:9">16:9</option>
1465
- <option value="9:16">9:16</option>
1466
- </select>
1467
- <select value={composerQuality} onChange={event => setComposerQuality(event.target.value)} aria-label={tt('canvas.quality')}>
1468
- <option value="auto">{tt('canvas.qualityAuto')}</option>
1469
- <option value="1k">1K</option>
1470
- <option value="2k">2K</option>
1471
- <option value="4k">4K</option>
1472
- </select>
1473
- <select value={composerCount} onChange={event => setComposerCount(Number(event.target.value))} aria-label={tt('canvas.count')}>
1474
- {[1, 2, 3, 4].map(item => <option key={item} value={item}>{tt('canvas.countUnit', { count: item })}</option>)}
1475
- </select>
1658
+ <ComposerSelect
1659
+ ariaLabel={tt('canvas.model')}
1660
+ value={composerModel}
1661
+ options={[{ value: '', label: tt('canvas.modelPlaceholder') }, ...imageModels.map(item => ({ value: item, label: item }))]}
1662
+ onChange={setComposerModel}
1663
+ />
1664
+ <ComposerSelect
1665
+ ariaLabel={tt('canvas.size')}
1666
+ value={composerSize}
1667
+ options={[
1668
+ { value: 'auto', label: tt('canvas.sizeAuto') },
1669
+ { value: '1:1', label: '1:1' },
1670
+ { value: '3:4', label: '3:4' },
1671
+ { value: '16:9', label: '16:9' },
1672
+ { value: '9:16', label: '9:16' },
1673
+ ]}
1674
+ onChange={setComposerSize}
1675
+ />
1676
+ <ComposerSelect
1677
+ ariaLabel={tt('canvas.quality')}
1678
+ value={composerQuality}
1679
+ options={[
1680
+ { value: 'auto', label: tt('canvas.qualityAuto') },
1681
+ { value: '1k', label: '1K' },
1682
+ { value: '2k', label: '2K' },
1683
+ { value: '4k', label: '4K' },
1684
+ ]}
1685
+ onChange={setComposerQuality}
1686
+ />
1687
+ <ComposerSelect
1688
+ ariaLabel={tt('canvas.count')}
1689
+ value={String(composerCount)}
1690
+ options={[1, 2, 3, 4].map(item => ({ value: String(item), label: tt('canvas.countUnit', { count: item }) }))}
1691
+ onChange={value => setComposerCount(Number(value))}
1692
+ />
1476
1693
  <button
1477
1694
  type="button"
1478
1695
  className={css.composerSend}
@@ -1555,8 +1772,9 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1555
1772
  }
1556
1773
  if (createMenu !== null) {
1557
1774
  return <div className={css.contextMenu} style={{ left: createMenu.screen.x, top: createMenu.screen.y }} data-canvas-no-zoom="" role="menu">
1558
- <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>
1559
- <button type="button" role="menuitem" onClick={() => { placeNewNode(createTextNode(createMenu.world)); setCreateMenu(null) }}><ToolbarIcon name="text" />{tt('canvas.addTextNode')}</button>
1775
+ <button type="button" role="menuitem" onClick={() => { placeNewNode(createTextNode(createMenu.world)); setCreateMenu(null) }}><ToolbarIcon name="text" size={16} />{tt('canvas.addTextNode')}</button>
1776
+ <button type="button" role="menuitem" onClick={() => { placeNewNode(createImageNode({ assetId: '', url: '', mime: 'image/png', bytes: 0, width: 1, height: 1, origin: 'upload' }, createMenu.world)); setCreateMenu(null) }}><ToolbarIcon name="image" size={16} />{tt('canvas.addImageNode')}</button>
1777
+ <button type="button" role="menuitem" onClick={() => { placeNewNode(createConfigNode(createMenu.world)); setCreateMenu(null) }}><ToolbarIcon name="sparkle" size={16} />{tt('canvas.addConfigNode')}</button>
1560
1778
  </div>
1561
1779
  }
1562
1780
  return null
@@ -1615,7 +1833,6 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1615
1833
  const target = event.target instanceof Element ? event.target : null
1616
1834
  if (target?.closest('[data-node-id],[data-connection-hit],[data-canvas-no-zoom]')) return
1617
1835
  event.preventDefault()
1618
- setCreateMenu(null)
1619
1836
  setContextMenu({ type: 'canvas', screen: { x: event.clientX, y: event.clientY }, world: screenToWorld(event.clientX, event.clientY) })
1620
1837
  }}
1621
1838
  onDragOver={event => event.preventDefault()}
@@ -1625,11 +1842,14 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1625
1842
  className={css.grid}
1626
1843
  style={backgroundMode === 'image' && document?.backgroundImage
1627
1844
  ? { backgroundImage: `url(${document.backgroundImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
1628
- : { backgroundSize: `${gridSize}px ${gridSize}px`, backgroundPosition: `${gridOffsetX}px ${gridOffsetY}px` }}
1845
+ : backgroundMode === 'aurora'
1846
+ ? undefined
1847
+ : { backgroundSize: `${gridSize}px ${gridSize}px`, backgroundPosition: `${gridOffsetX}px ${gridOffsetY}px` }}
1629
1848
  data-mode={backgroundMode}
1630
1849
  aria-hidden="true"
1631
1850
  >
1632
1851
  {backgroundMode === 'image' ? <div className={css.gridScrim} /> : null}
1852
+ {backgroundMode === 'flow' ? <FlowBackground /> : null}
1633
1853
  </div>
1634
1854
  <div className={css.world} style={{ transform: `translate(${document?.viewport.x ?? 0}px, ${document?.viewport.y ?? 0}px) scale(${document?.viewport.k ?? 1})` }}>
1635
1855
  {renderConnections()}
@@ -1643,16 +1863,24 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1643
1863
  className={`${css.dock} ${cursorClass}`}
1644
1864
  data-canvas-no-zoom=""
1645
1865
  onPointerMove={event => {
1866
+ if (globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true) return
1646
1867
  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`)
1868
+ const cursorX = event.clientX - dock.getBoundingClientRect().left
1869
+ // CSS-module class names are hashed in the DOM, so match by tag.
1870
+ for (const button of dock.querySelectorAll<HTMLButtonElement>('button')) {
1871
+ // offsetLeft is the layout position, unaffected by the scale transform,
1872
+ // so the magnification wave does not feed back into itself.
1873
+ const distance = Math.abs(cursorX - (button.offsetLeft + button.offsetWidth / 2))
1874
+ const influence = Math.exp(-(distance * distance) / (2 * 48 * 48))
1875
+ button.style.setProperty('--dock-scale', (1 + 0.24 * influence).toFixed(3))
1876
+ button.style.setProperty('--dock-lift', `${(-8 * influence).toFixed(2)}px`)
1652
1877
  }
1653
1878
  }}
1654
1879
  onPointerLeave={event => {
1655
- for (const button of event.currentTarget.querySelectorAll<HTMLButtonElement>('.iconButton')) button.style.removeProperty('--dock-lift')
1880
+ for (const button of event.currentTarget.querySelectorAll<HTMLButtonElement>('button')) {
1881
+ button.style.setProperty('--dock-scale', '1')
1882
+ button.style.setProperty('--dock-lift', '0px')
1883
+ }
1656
1884
  }}
1657
1885
  >
1658
1886
  <IconButton name="select" size={18} label={tt('canvas.toolSelect')} active={tool === 'select'} onClick={() => setTool('select')} />
@@ -1694,10 +1922,10 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1694
1922
  onMouseEnter={clearMenuCloseTimer}
1695
1923
  onMouseLeave={scheduleMenuClose}
1696
1924
  >
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>
1925
+ <button type="button" role="menuitem" onClick={() => { imageFileRef.current?.click(); setImageMenu(null) }}>{tt('canvas.imageMenuUpload')}</button>
1926
+ <button type="button" role="menuitem" onClick={() => { setPickerTab('gallery'); setPickerOpen(true); setImageMenu(null) }}>{tt('canvas.imageMenuAssets')}</button>
1927
+ <button type="button" role="menuitem" onClick={() => { setPickerTab('history'); setPickerOpen(true); setImageMenu(null) }}>{tt('canvas.imageMenuHistory')}</button>
1928
+ <button type="button" role="menuitem" onClick={() => { setPickerTab('generate'); setPickerOpen(true); setImageMenu(null) }}>{tt('canvas.imageMenuGenerate')}</button>
1701
1929
  </div> : null}
1702
1930
  <input
1703
1931
  ref={imageFileRef}
@@ -1738,6 +1966,8 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1738
1966
  ['lines', tt('canvas.backgroundLines')],
1739
1967
  ['diagonal', tt('canvas.backgroundDiagonal')],
1740
1968
  ['checker', tt('canvas.backgroundChecker')],
1969
+ ['flow', tt('canvas.backgroundFlow')],
1970
+ ['aurora', tt('canvas.backgroundAurora')],
1741
1971
  ['blank', tt('canvas.backgroundBlank')],
1742
1972
  ] as const).map(([mode, label]) => <button key={mode} type="button" role="menuitem" data-active={backgroundMode === mode ? '' : undefined} onClick={() => setBackgroundMode(mode)}>{label}</button>)}
1743
1973
  <span className={css.backgroundMenuDivider} />
@@ -1756,6 +1986,19 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1756
1986
  />
1757
1987
  </div> : null}
1758
1988
 
1989
+ {nodeAddMenu !== null ? <div
1990
+ className={css.contextMenu}
1991
+ style={{ left: nodeAddMenu.screen.x + 12, top: nodeAddMenu.screen.y, transform: 'translateY(-50%)' }}
1992
+ data-canvas-no-zoom=""
1993
+ role="menu"
1994
+ onMouseEnter={clearNodeAddMenuTimer}
1995
+ onMouseLeave={scheduleNodeAddMenuClose}
1996
+ >
1997
+ <button type="button" role="menuitem" onClick={() => { addConnectedNode(nodeAddMenu.nodeId, position => createTextNode(position)); setNodeAddMenu(null) }}><ToolbarIcon name="text" size={16} />{tt('canvas.addTextNode')}</button>
1998
+ <button type="button" role="menuitem" onClick={() => { addConnectedNode(nodeAddMenu.nodeId, position => createImageNode({ assetId: '', url: '', mime: 'image/png', bytes: 0, width: 1, height: 1, origin: 'upload' }, position)); setNodeAddMenu(null) }}><ToolbarIcon name="image" size={16} />{tt('canvas.addImageNode')}</button>
1999
+ {nodeAddMenu.nodeType !== 'config' ? <button type="button" role="menuitem" onClick={() => { addConnectedNode(nodeAddMenu.nodeId, position => createConfigNode(position)); setNodeAddMenu(null) }}><ToolbarIcon name="sparkle" size={16} />{tt('canvas.addConfigNode')}</button> : null}
2000
+ </div> : null}
2001
+
1759
2002
  <div className={css.zoomDock} data-canvas-no-zoom="">
1760
2003
  <IconButton name="minimap" label={minimapOpen ? tt('canvas.minimapClose') : tt('canvas.minimapOpen')} active={minimapOpen} onClick={() => setMinimapOpen(previous => !previous)} />
1761
2004
  <IconButton name="fit" label={tt('canvas.fitView')} onClick={fitView} />
@@ -1806,6 +2049,103 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
1806
2049
  </section>
1807
2050
  }
1808
2051
 
2052
+ /** Interactive flowmap-style dot field ("fluid distortion"): the pointer's
2053
+ * velocity pushes dots sideways like a fluid; they spring back home when it
2054
+ * moves on, with a barely-visible idle drift keeping the field alive. */
2055
+ function FlowBackground(): React.JSX.Element {
2056
+ const canvasRef = useRef<HTMLCanvasElement>(null)
2057
+ useEffect(() => {
2058
+ const canvas = canvasRef.current
2059
+ // canvas lives inside the grid layer; events must be observed on the
2060
+ // viewport container itself (the grid never receives pointer events).
2061
+ const layer = canvas?.parentElement
2062
+ const viewport = layer?.parentElement
2063
+ if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
2064
+ const ctx = canvas.getContext('2d')
2065
+ if (ctx === null) return
2066
+ let disposed = false
2067
+ const pointer = { x: -1e4, y: -1e4, vx: 0, vy: 0, seen: false }
2068
+ const SPACING = 44
2069
+ const RADIUS = 120
2070
+ let width = 0
2071
+ let height = 0
2072
+ let points: Array<{ hx: number; hy: number; x: number; y: number; vx: number; vy: number }> = []
2073
+ const rebuild = (): void => {
2074
+ const rect = viewport.getBoundingClientRect()
2075
+ const dpr = Math.max(1, Math.min(2, window.devicePixelRatio ?? 1))
2076
+ width = Math.max(1, Math.round(rect.width))
2077
+ height = Math.max(1, Math.round(rect.height))
2078
+ canvas.width = Math.round(width * dpr)
2079
+ canvas.height = Math.round(height * dpr)
2080
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
2081
+ points = []
2082
+ for (let y = SPACING / 2; y < height; y += SPACING) {
2083
+ for (let x = SPACING / 2; x < width; x += SPACING) points.push({ hx: x, hy: y, x, y, vx: 0, vy: 0 })
2084
+ }
2085
+ }
2086
+ rebuild()
2087
+ const observer = new ResizeObserver(rebuild)
2088
+ observer.observe(viewport)
2089
+ const onMove = (event: PointerEvent): void => {
2090
+ const rect = viewport.getBoundingClientRect()
2091
+ const x = event.clientX - rect.left
2092
+ const y = event.clientY - rect.top
2093
+ if (pointer.seen) {
2094
+ pointer.vx = pointer.vx * 0.6 + (x - pointer.x) * 0.4
2095
+ pointer.vy = pointer.vy * 0.6 + (y - pointer.y) * 0.4
2096
+ }
2097
+ pointer.x = x
2098
+ pointer.y = y
2099
+ pointer.seen = true
2100
+ }
2101
+ const onLeave = (): void => { pointer.x = -1e4; pointer.y = -1e4; pointer.vx = 0; pointer.vy = 0 }
2102
+ viewport.addEventListener('pointermove', onMove, true)
2103
+ viewport.addEventListener('pointerleave', onLeave)
2104
+ let frame = 0
2105
+ let time = 0
2106
+ const tick = (): void => {
2107
+ time += 0.016
2108
+ const r2 = RADIUS * RADIUS
2109
+ for (const p of points) {
2110
+ // A barely-visible idle drift keeps the field alive without the pointer.
2111
+ p.vx += (p.hx + Math.sin(time * 1.3 + p.hy * 0.055) * 0.5 - p.x) * 0.03
2112
+ p.vy += (p.hy + Math.cos(time * 1.1 + p.hx * 0.055) * 0.5 - p.y) * 0.03
2113
+ const dx = p.x - pointer.x
2114
+ const dy = p.y - pointer.y
2115
+ const d2 = dx * dx + dy * dy
2116
+ if (d2 < RADIUS * RADIUS && d2 > 0.01) {
2117
+ const d = Math.sqrt(d2)
2118
+ const force = (1 - d / RADIUS) * 0.9
2119
+ p.vx += pointer.vx * force + (dx / d) * force * 2.2
2120
+ p.vy += pointer.vy * force + (dy / d) * force * 2.2
2121
+ }
2122
+ p.vx *= 0.86
2123
+ p.vy *= 0.86
2124
+ p.x += p.vx
2125
+ p.y += p.vy
2126
+ }
2127
+ ctx.clearRect(0, 0, width, height)
2128
+ for (const p of points) {
2129
+ const speed = Math.min(4, Math.hypot(p.vx, p.vy))
2130
+ ctx.fillStyle = `rgba(96, 125, 255, ${(0.16 + speed * 0.16).toFixed(3)})`
2131
+ ctx.beginPath()
2132
+ ctx.arc(p.x, p.y, 1.4 + Math.min(1.8, speed * 0.5), 0, Math.PI * 2)
2133
+ ctx.fill()
2134
+ }
2135
+ frame = window.requestAnimationFrame(tick)
2136
+ }
2137
+ frame = window.requestAnimationFrame(tick)
2138
+ return () => {
2139
+ disposed = true
2140
+ window.cancelAnimationFrame(frame)
2141
+ observer.disconnect()
2142
+ viewport.removeEventListener('pointermove', onMove)
2143
+ viewport.removeEventListener('pointerleave', onLeave)
2144
+ }
2145
+ }, [])
2146
+ return <canvas ref={canvasRef} className={css.flowCanvas} aria-hidden="true" />
2147
+ }
2148
+
1809
2149
  function ImagePicker(props: {
1810
2150
  api: ImageGenApi
1811
2151
  history: HistoryEntry[]
@@ -1865,7 +2205,7 @@ function ImagePicker(props: {
1865
2205
  <div className={css.pickerBody}>
1866
2206
  {tab === 'upload' ? <label className={css.uploadBox} onDragOver={event => event.preventDefault()} onDrop={event => { event.preventDefault(); const files = [...(event.dataTransfer.files ?? [])].filter(file => file.type.startsWith('image/')); if (files.length === 0) return; uploadFiles(files) }}><input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple disabled={busy} onChange={event => { const files = [...(event.target.files ?? [])]; if (files.length > 0) uploadFiles(files) }} /><span className={css.uploadIcon}><ToolbarIcon name="image" /></span><strong>{tt('canvas.dropHint')}</strong><small>{tt('canvas.dropSub')}</small></label> : null}
1867
2207
  {(tab === 'history' || tab === 'gallery') ? <><div className={css.pickerGrid}>{items.map(item => <button key={item.key} type="button" role="option" aria-selected={selected.includes(item.key)} className={css.pickerCard} data-selected={selected.includes(item.key) ? '' : undefined} onClick={() => toggle(item.key)}><img draggable={false} src={item.image.url} alt={item.entry.prompt} onLoad={event => { const image = event.currentTarget; setDimensions(previous => ({ ...previous, [item.key]: { width: image.naturalWidth || 1, height: image.naturalHeight || 1 } })) }} /><span className={css.pickerCardPrompt}>{item.entry.prompt || tt('canvas.untitledWork')}</span><small>{item.entry.model} · {item.index + 1}/{item.entry.images.length}</small></button>)}</div><footer className={css.pickerFooter}><span>{tt('canvas.picked', { count: selected.length })}</span><button type="button" disabled={busy || selected.length === 0} onClick={() => { void addSelected() }}>{tt('canvas.addToCanvas')}</button></footer></> : null}
1868
- {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}
2208
+ {tab === 'generate' ? <div className={css.generateForm}><textarea value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('canvas.composerPlaceholder')} /><ComposerSelect value={model} options={imageModels.map(item => ({ value: item, label: item }))} ariaLabel={tt('canvas.model')} onChange={setModel} /><div className={css.inspectorRow}><ComposerSelect value={size} options={[{ value: 'auto', label: tt('canvas.sizeAuto') }, { value: '1:1', label: '1:1' }, { value: '3:4', label: '3:4' }, { value: '16:9', label: '16:9' }, { value: '9:16', label: '9:16' }]} ariaLabel={tt('canvas.size')} onChange={setSize} /><ComposerSelect value={quality} options={[{ value: 'auto', label: tt('canvas.qualityAuto') }, { value: '1k', label: '1K' }, { value: '2k', label: '2K' }, { value: '4k', label: '4K' }]} ariaLabel={tt('canvas.quality')} onChange={setQuality} /></div><button type="button" disabled={!connected || busy || prompt.trim() === ''} onClick={() => { void generate() }}><ToolbarIcon name="sparkle" />{tt('canvas.generateAndAdd')}</button>{!connected ? <small>{tt('canvas.needApi')}</small> : null}</div> : null}
1869
2209
  </div>
1870
2210
  </section></div>
1871
2211
  }