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