@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.
- package/README.md +5 -4
- package/lib/client.js +36358 -845
- package/lib/client.js.map +1 -1
- package/lib/index.js +3 -3
- package/package.json +82 -81
- package/src/canvas-store.ts +376 -375
- package/src/client/CanvasWorkspace.tsx +417 -77
- package/src/client/canvas-workspace.module.css +157 -33
- package/src/client/locales.ts +21 -12
- package/src/protocol.ts +580 -580
|
@@ -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:
|
|
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 = {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
<
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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(
|
|
1559
|
-
<button type="button" role="menuitem" onClick={() => { placeNewNode(
|
|
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
|
-
:
|
|
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
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
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>('
|
|
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) }}
|
|
1698
|
-
<button type="button" role="menuitem" onClick={() => { setPickerTab('gallery'); setPickerOpen(true); setImageMenu(null) }}
|
|
1699
|
-
<button type="button" role="menuitem" onClick={() => { setPickerTab('history'); setPickerOpen(true); setImageMenu(null) }}
|
|
1700
|
-
<button type="button" role="menuitem" onClick={() => { setPickerTab('generate'); setPickerOpen(true); setImageMenu(null) }}
|
|
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')} /><
|
|
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
|
}
|