@dickpy/dsh-imagegen 1.5.4 → 1.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/lib/client.js +1141 -508
- package/lib/client.js.map +1 -1
- package/lib/index.js +217 -170
- package/package.json +1 -1
- package/src/canvas-store.ts +15 -16
- package/src/client/CanvasWorkspace.tsx +333 -31
- 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 +82 -25
- package/src/client/panel.module.css +20 -26
- package/src/engine.ts +181 -146
- 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
package/src/canvas-store.ts
CHANGED
|
@@ -2,15 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { promises as fs } from 'node:fs'
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
-
import { homedir } from 'node:os'
|
|
6
5
|
import path from 'node:path'
|
|
7
6
|
import type { CanvasAssetRef, CanvasDocument, CanvasNode, CanvasSummary } from './protocol.ts'
|
|
7
|
+
import { imageDataRoot } from './image-storage-path.ts'
|
|
8
|
+
|
|
8
9
|
|
|
9
|
-
const DATA_ROOT = (process.env.DSH_HOME?.trim() || path.join(homedir(), '.dsh'))
|
|
10
|
-
const CANVAS_ROOT = path.join(DATA_ROOT, 'dsh-imagegen', 'canvas')
|
|
11
|
-
const PAGES_DIR = path.join(CANVAS_ROOT, 'pages')
|
|
12
|
-
const ASSETS_DIR = path.join(CANVAS_ROOT, 'assets')
|
|
13
|
-
const INDEX_PATH = path.join(CANVAS_ROOT, 'index.json')
|
|
14
10
|
|
|
15
11
|
export interface CanvasImageInput {
|
|
16
12
|
data: Uint8Array
|
|
@@ -61,20 +57,21 @@ function safeId(value: string): string {
|
|
|
61
57
|
}
|
|
62
58
|
|
|
63
59
|
function pagePath(id: string): string {
|
|
64
|
-
return path.join(
|
|
60
|
+
return path.join(path.join(imageDataRoot(), 'canvas', 'pages'), `${safeId(id)}.json`)
|
|
65
61
|
}
|
|
66
62
|
|
|
67
63
|
function assetFilePath(id: string): string | undefined {
|
|
68
64
|
if (!/^[a-f0-9]{64}\.(png|jpg|jpeg|webp|gif)$/.test(id)) return undefined
|
|
69
|
-
const
|
|
70
|
-
const
|
|
65
|
+
const root = path.join(imageDataRoot(), 'canvas', 'assets')
|
|
66
|
+
const file = path.join(root, id)
|
|
67
|
+
const relative = path.relative(root, file)
|
|
71
68
|
if (relative.startsWith('..') || path.isAbsolute(relative)) return undefined
|
|
72
69
|
return file
|
|
73
70
|
}
|
|
74
71
|
|
|
75
72
|
async function ensureDirs(): Promise<void> {
|
|
76
|
-
await fs.mkdir(
|
|
77
|
-
await fs.mkdir(
|
|
73
|
+
await fs.mkdir(path.join(imageDataRoot(), 'canvas', 'pages'), { recursive: true })
|
|
74
|
+
await fs.mkdir(path.join(imageDataRoot(), 'canvas', 'assets'), { recursive: true })
|
|
78
75
|
}
|
|
79
76
|
|
|
80
77
|
async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
|
|
@@ -138,7 +135,9 @@ function isDocument(value: unknown): value is CanvasDocument {
|
|
|
138
135
|
&& typeof (document.viewport as { x?: unknown }).x === 'number'
|
|
139
136
|
&& typeof (document.viewport as { y?: unknown }).y === 'number'
|
|
140
137
|
&& typeof (document.viewport as { k?: unknown }).k === 'number'
|
|
141
|
-
&& (document.background === 'dots' || document.background === 'lines' || document.background === '
|
|
138
|
+
&& (document.background === 'dots' || document.background === 'lines' || document.background === 'diagonal'
|
|
139
|
+
|| document.background === 'checker' || document.background === 'blank' || document.background === 'image')
|
|
140
|
+
&& (document.backgroundImage === undefined || typeof document.backgroundImage === 'string')
|
|
142
141
|
&& Array.isArray(document.nodes) && document.nodes.every(isNode)
|
|
143
142
|
&& Array.isArray(document.connections)
|
|
144
143
|
}
|
|
@@ -245,11 +244,11 @@ function serialize<T>(operation: () => Promise<T>): Promise<T> {
|
|
|
245
244
|
}
|
|
246
245
|
|
|
247
246
|
export class CanvasStore {
|
|
248
|
-
constructor(private readonly root
|
|
247
|
+
constructor(private readonly root?: string) {}
|
|
249
248
|
|
|
250
|
-
private pagesDir(): string { return path.join(this.root, 'pages') }
|
|
251
|
-
private assetsDir(): string { return path.join(this.root, 'assets') }
|
|
252
|
-
private indexPath(): string { return path.join(this.root, 'index.json') }
|
|
249
|
+
private pagesDir(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'pages') }
|
|
250
|
+
private assetsDir(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'assets') }
|
|
251
|
+
private indexPath(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'index.json') }
|
|
253
252
|
|
|
254
253
|
private async ensure(): Promise<void> {
|
|
255
254
|
await fs.mkdir(this.pagesDir(), { recursive: true })
|
|
@@ -9,16 +9,19 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
|
|
|
9
9
|
import type { CanvasAssetRef, CanvasConnection, CanvasDocument, CanvasNode, GenerateRequest, GenerationTask, HistoryEntry } from '../protocol.ts'
|
|
10
10
|
import type { ImageGenApi } from './api.ts'
|
|
11
11
|
import { tt } from './helpers.ts'
|
|
12
|
+
import { TemplateLibrary } from './TemplateLibrary.tsx'
|
|
12
13
|
import css from './canvas-workspace.module.css'
|
|
13
14
|
|
|
14
15
|
type CanvasTool = 'select' | 'pan'
|
|
16
|
+
type BackgroundMode = CanvasDocument['background']
|
|
15
17
|
|
|
16
18
|
const MIN_SCALE = 0.05
|
|
17
19
|
const MAX_SCALE = 5
|
|
18
20
|
const GRID_SIZE = 48
|
|
19
21
|
const IMAGE_NODE_SIZE = { width: 320, height: 320 }
|
|
20
22
|
const TEXT_NODE_SIZE = { width: 280, height: 150 }
|
|
21
|
-
const CONFIG_NODE_SIZE = { width:
|
|
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)
|
|
@@ -254,6 +312,8 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
254
312
|
const syncedRef = useRef('')
|
|
255
313
|
const processedTasks = useRef(new Set<string>())
|
|
256
314
|
const processedImport = useRef('')
|
|
315
|
+
const localTaskIds = useRef(new Set<string>())
|
|
316
|
+
const mountedAtRef = useRef(Date.now())
|
|
257
317
|
const internalClipboard = useRef<{ nodes: CanvasNode[]; connections: Array<{ fromNodeId: string; toNodeId: string }> } | null>(null)
|
|
258
318
|
const pastRef = useRef<string[]>([])
|
|
259
319
|
const futureRef = useRef<string[]>([])
|
|
@@ -480,22 +540,32 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
480
540
|
setComposerBusy(true)
|
|
481
541
|
try {
|
|
482
542
|
let image: string | undefined
|
|
543
|
+
let images: string[] | undefined
|
|
483
544
|
let refName: string | undefined
|
|
484
545
|
if (baseAsset !== undefined) {
|
|
485
546
|
image = await assetToDataUrl(baseAsset)
|
|
486
547
|
refName = 'canvas-reference.png'
|
|
548
|
+
const extras: string[] = []
|
|
549
|
+
for (const reference of referenceImages.slice(1, 4)) {
|
|
550
|
+
const asset = usableAsset(reference)
|
|
551
|
+
if (asset === undefined) continue
|
|
552
|
+
try { extras.push(await assetToDataUrl(asset)) } catch { /* skip unreadable reference */ }
|
|
553
|
+
}
|
|
554
|
+
if (extras.length > 0) images = extras
|
|
487
555
|
}
|
|
488
556
|
const footprint = nodeSizeFromRatio(composerSize, IMAGE_NODE_SIZE)
|
|
489
557
|
const request: GenerateRequest = {
|
|
490
558
|
mode: image === undefined ? 'text' : 'edit', model, prompt, size: composerSize, quality: composerQuality, n: count, detail: '',
|
|
491
559
|
...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
|
|
492
560
|
...(image === undefined ? {} : { image, refName }),
|
|
561
|
+
...(images === undefined ? {} : { images }),
|
|
493
562
|
canvas: {
|
|
494
563
|
canvasId: current.id,
|
|
495
564
|
...(target === null ? {} : { sourceNodeId: referenceImages[0]?.id ?? target.id, parentNodeId: target.id, placement: 'right' as const }),
|
|
496
565
|
},
|
|
497
566
|
}
|
|
498
567
|
const task = await api.taskSubmit(request)
|
|
568
|
+
localTaskIds.current.add(task.id)
|
|
499
569
|
mutate(previous => {
|
|
500
570
|
const nodes = [...previous.nodes]
|
|
501
571
|
const connections = [...previous.connections]
|
|
@@ -525,6 +595,71 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
525
595
|
|
|
526
596
|
// ---------------------------------------------------------- task intake
|
|
527
597
|
|
|
598
|
+
/** Re-run a failed image node's generation from its recorded prompt/model,
|
|
599
|
+
* re-deriving the edit base from the connected source config node. */
|
|
600
|
+
const retryGeneration = useCallback(async (node: CanvasNode): Promise<void> => {
|
|
601
|
+
const current = documentRef.current
|
|
602
|
+
if (current === null || !connected) { setError(tt('canvas.needApi')); onOpenSettings?.(); return }
|
|
603
|
+
const metadata = nodeMetadata(node)
|
|
604
|
+
const prompt = (metadata.prompt ?? '').trim()
|
|
605
|
+
if (prompt === '') { setError(tt('canvas.needPrompt')); return }
|
|
606
|
+
const model = imageModels.includes(metadata.model ?? '') ? metadata.model! : imageModels[0] ?? ''
|
|
607
|
+
if (model === '') { setError(tt('canvas.needModel')); return }
|
|
608
|
+
const sourceId = metadata.sourceNodeId
|
|
609
|
+
const references = sourceId === undefined ? [] : upstreamNodes(current, sourceId).filter(item => item.type === 'image' && usableAsset(item) !== undefined)
|
|
610
|
+
const baseAsset = references[0] !== undefined ? usableAsset(references[0]!) : undefined
|
|
611
|
+
try {
|
|
612
|
+
let image: string | undefined
|
|
613
|
+
let images: string[] | undefined
|
|
614
|
+
let refName: string | undefined
|
|
615
|
+
if (baseAsset !== undefined) {
|
|
616
|
+
image = await assetToDataUrl(baseAsset)
|
|
617
|
+
refName = 'canvas-reference.png'
|
|
618
|
+
const extras: string[] = []
|
|
619
|
+
for (const reference of references.slice(1, 4)) {
|
|
620
|
+
const asset = usableAsset(reference)
|
|
621
|
+
if (asset === undefined) continue
|
|
622
|
+
try { extras.push(await assetToDataUrl(asset)) } catch { /* skip unreadable reference */ }
|
|
623
|
+
}
|
|
624
|
+
if (extras.length > 0) images = extras
|
|
625
|
+
}
|
|
626
|
+
const request: GenerateRequest = {
|
|
627
|
+
mode: image === undefined ? 'text' : 'edit', model, prompt, size: metadata.size ?? 'auto', quality: metadata.quality ?? 'auto', n: 1, detail: '',
|
|
628
|
+
...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
|
|
629
|
+
...(image === undefined ? {} : { image, refName }),
|
|
630
|
+
...(images === undefined ? {} : { images }),
|
|
631
|
+
canvas: { canvasId: current.id, sourceNodeId: references[0]?.id ?? sourceId, parentNodeId: node.id, placement: 'right' as const },
|
|
632
|
+
}
|
|
633
|
+
const task = await api.taskSubmit(request)
|
|
634
|
+
localTaskIds.current.add(task.id)
|
|
635
|
+
patchNode(node.id, { status: 'generating', error: undefined, taskId: task.id })
|
|
636
|
+
setError(null)
|
|
637
|
+
} catch (caught) {
|
|
638
|
+
setError(caught instanceof Error ? caught.message : String(caught))
|
|
639
|
+
}
|
|
640
|
+
}, [api, connected, defaultChannelId, imageModels, onOpenSettings, patchNode, upstreamNodes])
|
|
641
|
+
|
|
642
|
+
// Orphan reconciliation: a generating placeholder whose task no longer exists
|
|
643
|
+
// in the host feed (e.g. the host restarted) can never complete on its own.
|
|
644
|
+
useEffect(() => {
|
|
645
|
+
if (document === null) return
|
|
646
|
+
const feedFresh = tasks.length > 0 || Date.now() - mountedAtRef.current > 8000
|
|
647
|
+
if (!feedFresh) return
|
|
648
|
+
const feedIds = new Set(tasks.map(task => task.id))
|
|
649
|
+
const orphans = document.nodes.filter(node => {
|
|
650
|
+
if (node.type !== 'image' || nodeMetadata(node).status !== 'generating') return false
|
|
651
|
+
const taskId = nodeMetadata(node).taskId
|
|
652
|
+
return taskId !== undefined && !feedIds.has(taskId) && !localTaskIds.current.has(taskId)
|
|
653
|
+
})
|
|
654
|
+
if (orphans.length === 0) return
|
|
655
|
+
updateNodes(nodes => nodes.map(node => {
|
|
656
|
+
const taskId = node.type === 'image' ? nodeMetadata(node).taskId : undefined
|
|
657
|
+
if (node.type !== 'image' || nodeMetadata(node).status !== 'generating' || taskId === undefined
|
|
658
|
+
|| feedIds.has(taskId) || localTaskIds.current.has(taskId)) return node
|
|
659
|
+
return { ...node, metadata: { ...nodeMetadata(node), status: 'error', error: tt('canvas.taskLost') } }
|
|
660
|
+
}))
|
|
661
|
+
}, [document, tasks, updateNodes])
|
|
662
|
+
|
|
528
663
|
useEffect(() => {
|
|
529
664
|
if (document === null) return
|
|
530
665
|
const canvasTasks = tasks.filter(task => task.request.canvas?.canvasId === document.id)
|
|
@@ -634,7 +769,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
634
769
|
const first = list[0] === undefined ? await api.canvasCreate(tt('canvas.untitled')) : await api.canvasRead(list[0].id)
|
|
635
770
|
if (disposed) return
|
|
636
771
|
setProjects(list[0] === undefined ? [summaryOf(first)] : list)
|
|
637
|
-
setDocument(first)
|
|
772
|
+
setDocument(normalizeConfigNodeSizes(first))
|
|
638
773
|
syncedRef.current = JSON.stringify(first)
|
|
639
774
|
setSaveState('saved')
|
|
640
775
|
}).catch(caught => { if (!disposed) { setError(caught instanceof Error ? caught.message : String(caught)); setSaveState('error') } })
|
|
@@ -711,7 +846,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
711
846
|
if (documentRef.current === null) return
|
|
712
847
|
const mod = event.ctrlKey || event.metaKey
|
|
713
848
|
if (event.key === 'Escape') {
|
|
714
|
-
setContextMenu(null); setCreateMenu(null)
|
|
849
|
+
setContextMenu(null); setCreateMenu(null); setBackgroundMenu(null); setImageMenu(null)
|
|
715
850
|
if (!isEditingTarget(event.target)) { setSelectedIds(new Set()); setSelectedConnectionId(null) }
|
|
716
851
|
return
|
|
717
852
|
}
|
|
@@ -788,6 +923,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
788
923
|
const onViewportPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
|
|
789
924
|
const target = event.target instanceof Element ? event.target : null
|
|
790
925
|
setContextMenu(null); setCreateMenu(null)
|
|
926
|
+
if (!target?.closest('[data-canvas-no-zoom]')) { setBackgroundMenu(null); setImageMenu(null) }
|
|
791
927
|
const isBackground = target?.closest('[data-node-id],[data-connection-hit]') === null
|
|
792
928
|
const shouldPan = event.button === 1 || (event.button === 0 && (tool === 'pan' || temporaryPanTool) && isBackground)
|
|
793
929
|
if (shouldPan) {
|
|
@@ -1074,7 +1210,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1074
1210
|
const selectProject = useCallback(async (id: string): Promise<void> => {
|
|
1075
1211
|
try {
|
|
1076
1212
|
const next = await api.canvasRead(id)
|
|
1077
|
-
setDocument(next); setSelectedIds(new Set()); setSelectedConnectionId(null)
|
|
1213
|
+
setDocument(normalizeConfigNodeSizes(next)); setSelectedIds(new Set()); setSelectedConnectionId(null)
|
|
1078
1214
|
syncedRef.current = JSON.stringify(next); setSaveState('saved')
|
|
1079
1215
|
pastRef.current = []; futureRef.current = []; setHistoryVersion(version => version + 1)
|
|
1080
1216
|
} catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) }
|
|
@@ -1116,10 +1252,52 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1116
1252
|
const cursorClass = tool === 'pan' || isSpaceOrCtrl ? css.panCursor : css.selectCursor
|
|
1117
1253
|
|
|
1118
1254
|
const backgroundMode = document?.background ?? 'dots'
|
|
1119
|
-
const
|
|
1120
|
-
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)
|
|
1262
|
+
}, [mutate])
|
|
1263
|
+
|
|
1264
|
+
const uploadBackgroundImage = useCallback(async (file: File): Promise<void> => {
|
|
1265
|
+
try {
|
|
1266
|
+
const dataUrl = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('读取图片失败')); reader.readAsDataURL(file) })
|
|
1267
|
+
const dimensions = await readImageSize(dataUrl)
|
|
1268
|
+
const asset = await api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: 'canvas-background' })
|
|
1269
|
+
mutate(previous => ({ ...previous, background: 'image', backgroundImage: asset.url }))
|
|
1270
|
+
setBackgroundMenu(null)
|
|
1271
|
+
setError(null)
|
|
1272
|
+
} catch (caught) {
|
|
1273
|
+
setError(caught instanceof Error ? caught.message : String(caught))
|
|
1274
|
+
}
|
|
1275
|
+
}, [api, mutate])
|
|
1276
|
+
|
|
1277
|
+
const removeBackgroundImage = useCallback((): void => {
|
|
1278
|
+
mutate(previous => ({ ...previous, background: 'dots', backgroundImage: undefined }))
|
|
1279
|
+
setBackgroundMenu(null)
|
|
1121
1280
|
}, [mutate])
|
|
1122
1281
|
|
|
1282
|
+
const applyTemplate = useCallback((prompt: string): void => {
|
|
1283
|
+
const center = canvasCenter()
|
|
1284
|
+
const config = createConfigNode(center)
|
|
1285
|
+
const text = createTextNode()
|
|
1286
|
+
const placed: CanvasNode = {
|
|
1287
|
+
...text,
|
|
1288
|
+
x: Math.round(config.x - TEXT_NODE_SIZE.width - 80),
|
|
1289
|
+
y: Math.round(config.y + (config.height - TEXT_NODE_SIZE.height) / 2),
|
|
1290
|
+
metadata: { text: prompt, fontSize: 14 },
|
|
1291
|
+
}
|
|
1292
|
+
mutate(previous => ({
|
|
1293
|
+
...previous,
|
|
1294
|
+
nodes: [...previous.nodes, placed, config],
|
|
1295
|
+
connections: [...previous.connections, { id: newId('edge'), fromNodeId: placed.id, toNodeId: config.id }],
|
|
1296
|
+
}))
|
|
1297
|
+
setSelectedIds(new Set([config.id])); setSelectedConnectionId(null)
|
|
1298
|
+
setLibraryOpen(false)
|
|
1299
|
+
}, [canvasCenter, createConfigNode, createTextNode, mutate])
|
|
1300
|
+
|
|
1123
1301
|
const gridSize = GRID_SIZE * (document?.viewport.k ?? 1)
|
|
1124
1302
|
const gridOffsetX = (document?.viewport.x ?? 0) % gridSize
|
|
1125
1303
|
const gridOffsetY = (document?.viewport.y ?? 0) % gridSize
|
|
@@ -1168,13 +1346,21 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1168
1346
|
onChange={event => patchNode(node.id, { text: event.target.value })}
|
|
1169
1347
|
/>
|
|
1170
1348
|
: <div className={css.nodeBody}>
|
|
1349
|
+
{hasImage ? <div aria-hidden="true">
|
|
1350
|
+
{metadata.model !== undefined && metadata.model !== ''
|
|
1351
|
+
? <span className={`${css.imageInfo} ${css.imageInfoLeft}`}>{metadata.model}</span>
|
|
1352
|
+
: asset.origin === 'gallery' || asset.origin === 'history'
|
|
1353
|
+
? <span className={`${css.imageInfo} ${css.imageInfoLeft}`}>{asset.origin === 'gallery' ? tt('canvas.fromGallery') : tt('canvas.fromHistory')}</span>
|
|
1354
|
+
: null}
|
|
1355
|
+
{asset !== undefined && asset.width > 1 ? <span className={`${css.imageInfo} ${css.imageInfoRight}`}>{asset.width}×{asset.height}</span> : null}
|
|
1356
|
+
</div> : null}
|
|
1171
1357
|
{isGenerating
|
|
1172
1358
|
? <div className={css.nodeState}><span className={css.spinner} aria-hidden="true" /><span>{tt('canvas.generatingNode')}</span></div>
|
|
1173
1359
|
: isError
|
|
1174
|
-
? <div className={css.nodeStateError}>{metadata.error ?? tt('canvas.generateFailed')}<button type="button" onClick={() =>
|
|
1360
|
+
? <div className={css.nodeStateError}>{metadata.error ?? tt('canvas.generateFailed')}<button type="button" onClick={() => { void retryGeneration(node) }}>{tt('canvas.retry')}</button></div>
|
|
1175
1361
|
: hasImage
|
|
1176
1362
|
? <img src={asset.url} alt={node.title} draggable={false} onDragStart={event => event.preventDefault()} />
|
|
1177
|
-
: <button type="button" className={css.nodeEmpty} onClick={() =>
|
|
1363
|
+
: <button type="button" className={css.nodeEmpty} onClick={() => imageFileRef.current?.click()}><ToolbarIcon name="image" /><span>{tt('canvas.emptyImageNode')}</span></button>}
|
|
1178
1364
|
</div>}
|
|
1179
1365
|
{isSelected
|
|
1180
1366
|
? <div className={css.resizeHandle} onPointerDown={event => handleResizeStart(event, node, 'bottom-right')} title={tt('canvas.resizeHint')} />
|
|
@@ -1241,11 +1427,12 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1241
1427
|
if (!composerVisible || document === null || composerTarget === null) return null
|
|
1242
1428
|
const linkedCount = composerReferenceCount + composerTextCount
|
|
1243
1429
|
const k = document.viewport.k
|
|
1244
|
-
const
|
|
1430
|
+
const topOffset = viewportRef.current?.offsetTop ?? 0
|
|
1431
|
+
const centerX = topOffset * 0 + document.viewport.x + (composerTarget.x + composerTarget.width / 2) * k
|
|
1245
1432
|
const clampedX = Math.min(Math.max(centerX, 292), Math.max(292, viewportSize.width - 292))
|
|
1246
|
-
const belowY = document.viewport.y + (composerTarget.y + composerTarget.height) * k + 14
|
|
1247
|
-
const top = belowY > viewportSize.height - 170
|
|
1248
|
-
? Math.max(64, document.viewport.y + composerTarget.y * k - 158)
|
|
1433
|
+
const belowY = topOffset + document.viewport.y + (composerTarget.y + composerTarget.height) * k + 14
|
|
1434
|
+
const top = belowY > viewportSize.height + topOffset - 170
|
|
1435
|
+
? Math.max(64, topOffset + document.viewport.y + composerTarget.y * k - 158)
|
|
1249
1436
|
: belowY
|
|
1250
1437
|
return <div className={css.composer} data-canvas-no-zoom="" style={{ left: clampedX - 280, top }}>
|
|
1251
1438
|
<textarea
|
|
@@ -1390,7 +1577,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1390
1577
|
return { left: x1, top: y1, width: x2 - x1, height: y2 - y1 }
|
|
1391
1578
|
})()
|
|
1392
1579
|
|
|
1393
|
-
return <section className={css.root} data-canvas-workspace="">
|
|
1580
|
+
return <section ref={rootRef} className={css.root} data-canvas-workspace="">
|
|
1394
1581
|
<header className={css.topBar} data-canvas-no-zoom="">
|
|
1395
1582
|
<select className={css.projectSelect} value={document?.id ?? ''} onChange={event => { void selectProject(event.target.value) }} aria-label={tt('canvas.project')}>
|
|
1396
1583
|
{projects.map(project => <option key={project.id} value={project.id}>{project.title}</option>)}
|
|
@@ -1411,9 +1598,6 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1411
1598
|
/>
|
|
1412
1599
|
: <button type="button" className={css.titleButton} onDoubleClick={() => setRenamingTitle(true)} title={tt('canvas.renameHint')}>{document?.title ?? ''}</button>}
|
|
1413
1600
|
<span className={css.topBarSpacer} />
|
|
1414
|
-
<IconButton name="background" label={tt('canvas.background')} onClick={cycleBackground} />
|
|
1415
|
-
<IconButton name="undo" label={tt('canvas.undo')} disabled={pastRef.current.length === 0} onClick={undo} />
|
|
1416
|
-
<IconButton name="redo" label={tt('canvas.redo')} disabled={futureRef.current.length === 0} onClick={redo} />
|
|
1417
1601
|
<span className={css.saveState} data-state={saveState}>{saveState === 'saving' ? tt('canvas.saving') : saveState === 'saved' ? tt('canvas.saved') : saveState === 'error' ? tt('canvas.saveFailed') : tt('canvas.loading')}</span>
|
|
1418
1602
|
</header>
|
|
1419
1603
|
|
|
@@ -1437,7 +1621,16 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1437
1621
|
onDragOver={event => event.preventDefault()}
|
|
1438
1622
|
onDrop={onDrop}
|
|
1439
1623
|
>
|
|
1440
|
-
<div
|
|
1624
|
+
<div
|
|
1625
|
+
className={css.grid}
|
|
1626
|
+
style={backgroundMode === 'image' && document?.backgroundImage
|
|
1627
|
+
? { backgroundImage: `url(${document.backgroundImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
|
1628
|
+
: { backgroundSize: `${gridSize}px ${gridSize}px`, backgroundPosition: `${gridOffsetX}px ${gridOffsetY}px` }}
|
|
1629
|
+
data-mode={backgroundMode}
|
|
1630
|
+
aria-hidden="true"
|
|
1631
|
+
>
|
|
1632
|
+
{backgroundMode === 'image' ? <div className={css.gridScrim} /> : null}
|
|
1633
|
+
</div>
|
|
1441
1634
|
<div className={css.world} style={{ transform: `translate(${document?.viewport.x ?? 0}px, ${document?.viewport.y ?? 0}px) scale(${document?.viewport.k ?? 1})` }}>
|
|
1442
1635
|
{renderConnections()}
|
|
1443
1636
|
{document?.nodes.map(renderNode)}
|
|
@@ -1446,17 +1639,123 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1446
1639
|
{emptyState}
|
|
1447
1640
|
</div>
|
|
1448
1641
|
|
|
1449
|
-
<div
|
|
1450
|
-
|
|
1451
|
-
|
|
1642
|
+
<div
|
|
1643
|
+
className={`${css.dock} ${cursorClass}`}
|
|
1644
|
+
data-canvas-no-zoom=""
|
|
1645
|
+
onPointerMove={event => {
|
|
1646
|
+
const dock = event.currentTarget
|
|
1647
|
+
const buttons = [...dock.querySelectorAll<HTMLButtonElement>('.iconButton')]
|
|
1648
|
+
for (const button of buttons) {
|
|
1649
|
+
const rect = button.getBoundingClientRect()
|
|
1650
|
+
const distance = Math.abs(event.clientX - (rect.left + rect.width / 2)) / 40
|
|
1651
|
+
button.style.setProperty('--dock-lift', `${Math.max(0, 4 - distance * 1.5)}px`)
|
|
1652
|
+
}
|
|
1653
|
+
}}
|
|
1654
|
+
onPointerLeave={event => {
|
|
1655
|
+
for (const button of event.currentTarget.querySelectorAll<HTMLButtonElement>('.iconButton')) button.style.removeProperty('--dock-lift')
|
|
1656
|
+
}}
|
|
1657
|
+
>
|
|
1658
|
+
<IconButton name="select" size={18} label={tt('canvas.toolSelect')} active={tool === 'select'} onClick={() => setTool('select')} />
|
|
1659
|
+
<IconButton name="pan" size={18} label={tt('canvas.toolPan')} active={tool === 'pan'} onClick={() => setTool('pan')} />
|
|
1452
1660
|
<span className={css.dockDivider} />
|
|
1453
|
-
<IconButton
|
|
1454
|
-
|
|
1455
|
-
|
|
1661
|
+
<IconButton
|
|
1662
|
+
name="image"
|
|
1663
|
+
size={18}
|
|
1664
|
+
label={tt('canvas.addImage')}
|
|
1665
|
+
active={imageMenu !== null}
|
|
1666
|
+
onClick={event => openDockMenu('image', event.currentTarget)}
|
|
1667
|
+
onMouseEnter={event => openDockMenu('image', event.currentTarget)}
|
|
1668
|
+
onMouseLeave={scheduleMenuClose}
|
|
1669
|
+
/>
|
|
1670
|
+
<IconButton name="text" size={18} label={tt('canvas.addText')} onClick={() => placeNewNode(createTextNode())} />
|
|
1671
|
+
<IconButton name="sparkle" size={18} label={tt('canvas.addConfigNode')} onClick={() => placeNewNode(createConfigNode())} />
|
|
1672
|
+
<IconButton name="template" size={18} label={tt('canvas.templateLibrary')} active={libraryOpen} onClick={() => setLibraryOpen(previous => !previous)} />
|
|
1456
1673
|
<span className={css.dockDivider} />
|
|
1457
|
-
<IconButton
|
|
1674
|
+
<IconButton
|
|
1675
|
+
name="background"
|
|
1676
|
+
size={18}
|
|
1677
|
+
label={tt('canvas.background')}
|
|
1678
|
+
active={backgroundMenu !== null}
|
|
1679
|
+
onClick={event => openDockMenu('background', event.currentTarget)}
|
|
1680
|
+
onMouseEnter={event => openDockMenu('background', event.currentTarget)}
|
|
1681
|
+
onMouseLeave={scheduleMenuClose}
|
|
1682
|
+
/>
|
|
1683
|
+
<IconButton name="undo" size={18} label={tt('canvas.undo')} disabled={pastRef.current.length === 0} onClick={undo} />
|
|
1684
|
+
<IconButton name="redo" size={18} label={tt('canvas.redo')} disabled={futureRef.current.length === 0} onClick={redo} />
|
|
1685
|
+
<span className={css.dockDivider} />
|
|
1686
|
+
<IconButton name="trash" size={18} label={tt('canvas.delete')} disabled={selectedIds.size === 0 && selectedConnectionId === null} onClick={deleteSelection} />
|
|
1458
1687
|
</div>
|
|
1459
1688
|
|
|
1689
|
+
{imageMenu !== null ? <div
|
|
1690
|
+
className={css.backgroundMenu}
|
|
1691
|
+
style={{ left: imageMenu.x, top: imageMenu.y - 10 }}
|
|
1692
|
+
data-canvas-no-zoom=""
|
|
1693
|
+
role="menu"
|
|
1694
|
+
onMouseEnter={clearMenuCloseTimer}
|
|
1695
|
+
onMouseLeave={scheduleMenuClose}
|
|
1696
|
+
>
|
|
1697
|
+
<button type="button" role="menuitem" onClick={() => { imageFileRef.current?.click(); setImageMenu(null) }}><ToolbarIcon name="image" size={16} />{tt('canvas.imageMenuUpload')}</button>
|
|
1698
|
+
<button type="button" role="menuitem" onClick={() => { setPickerTab('gallery'); setPickerOpen(true); setImageMenu(null) }}><ToolbarIcon name="template" size={16} />{tt('canvas.imageMenuAssets')}</button>
|
|
1699
|
+
<button type="button" role="menuitem" onClick={() => { setPickerTab('history'); setPickerOpen(true); setImageMenu(null) }}><ToolbarIcon name="undo" size={16} />{tt('canvas.imageMenuHistory')}</button>
|
|
1700
|
+
<button type="button" role="menuitem" onClick={() => { setPickerTab('generate'); setPickerOpen(true); setImageMenu(null) }}><ToolbarIcon name="sparkle" size={16} />{tt('canvas.imageMenuGenerate')}</button>
|
|
1701
|
+
</div> : null}
|
|
1702
|
+
<input
|
|
1703
|
+
ref={imageFileRef}
|
|
1704
|
+
type="file"
|
|
1705
|
+
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
1706
|
+
multiple
|
|
1707
|
+
hidden
|
|
1708
|
+
onChange={event => {
|
|
1709
|
+
const files = [...(event.target.files ?? [])].filter(file => file.type.startsWith('image/'))
|
|
1710
|
+
event.target.value = ''
|
|
1711
|
+
if (files.length === 0) return
|
|
1712
|
+
const world = canvasCenter()
|
|
1713
|
+
void Promise.all(files.map(async file => {
|
|
1714
|
+
const dataUrl = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('读取图片失败')); reader.readAsDataURL(file) })
|
|
1715
|
+
const dimensions = await readImageSize(dataUrl)
|
|
1716
|
+
return api.canvasUpload(dataUrl, dimensions.width, dimensions.height, { origin: 'upload', originId: file.name })
|
|
1717
|
+
})).then(assets => {
|
|
1718
|
+
const current = documentRef.current
|
|
1719
|
+
const selectedId = selectedIdsRef.current.size === 1 ? [...selectedIdsRef.current][0] : undefined
|
|
1720
|
+
const selectedNode = current?.nodes.find(node => node.id === selectedId)
|
|
1721
|
+
if (selectedNode?.type === 'image' && usableAsset(selectedNode) === undefined && assets[0] !== undefined) {
|
|
1722
|
+
mutate(previous => ({ ...previous, nodes: previous.nodes.map(node => node.id === selectedNode.id ? { ...node, width: sizeForAsset(assets[0]!).width, height: sizeForAsset(assets[0]!).height, metadata: { ...nodeMetadata(node), asset: assets[0], status: 'success' as const, error: undefined } } : node) }))
|
|
1723
|
+
if (assets.length > 1) addAssets(assets.slice(1), world)
|
|
1724
|
+
} else addAssets(assets, world)
|
|
1725
|
+
}).catch(caught => setError(caught instanceof Error ? caught.message : String(caught)))
|
|
1726
|
+
}}
|
|
1727
|
+
/>
|
|
1728
|
+
{backgroundMenu !== null ? <div
|
|
1729
|
+
className={css.backgroundMenu}
|
|
1730
|
+
style={{ left: backgroundMenu.x, top: backgroundMenu.y - 10 }}
|
|
1731
|
+
data-canvas-no-zoom=""
|
|
1732
|
+
role="menu"
|
|
1733
|
+
onMouseEnter={clearMenuCloseTimer}
|
|
1734
|
+
onMouseLeave={scheduleMenuClose}
|
|
1735
|
+
>
|
|
1736
|
+
{([
|
|
1737
|
+
['dots', tt('canvas.backgroundDots')],
|
|
1738
|
+
['lines', tt('canvas.backgroundLines')],
|
|
1739
|
+
['diagonal', tt('canvas.backgroundDiagonal')],
|
|
1740
|
+
['checker', tt('canvas.backgroundChecker')],
|
|
1741
|
+
['blank', tt('canvas.backgroundBlank')],
|
|
1742
|
+
] as const).map(([mode, label]) => <button key={mode} type="button" role="menuitem" data-active={backgroundMode === mode ? '' : undefined} onClick={() => setBackgroundMode(mode)}>{label}</button>)}
|
|
1743
|
+
<span className={css.backgroundMenuDivider} />
|
|
1744
|
+
<button type="button" role="menuitem" data-active={backgroundMode === 'image' ? '' : undefined} onClick={() => backgroundFileRef.current?.click()}>{tt('canvas.backgroundUpload')}</button>
|
|
1745
|
+
{backgroundMode === 'image' && document?.backgroundImage ? <button type="button" role="menuitem" onClick={removeBackgroundImage}>{tt('canvas.backgroundRemove')}</button> : null}
|
|
1746
|
+
<input
|
|
1747
|
+
ref={backgroundFileRef}
|
|
1748
|
+
type="file"
|
|
1749
|
+
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
1750
|
+
hidden
|
|
1751
|
+
onChange={event => {
|
|
1752
|
+
const file = event.target.files?.[0]
|
|
1753
|
+
if (file !== undefined) void uploadBackgroundImage(file)
|
|
1754
|
+
event.target.value = ''
|
|
1755
|
+
}}
|
|
1756
|
+
/>
|
|
1757
|
+
</div> : null}
|
|
1758
|
+
|
|
1460
1759
|
<div className={css.zoomDock} data-canvas-no-zoom="">
|
|
1461
1760
|
<IconButton name="minimap" label={minimapOpen ? tt('canvas.minimapClose') : tt('canvas.minimapOpen')} active={minimapOpen} onClick={() => setMinimapOpen(previous => !previous)} />
|
|
1462
1761
|
<IconButton name="fit" label={tt('canvas.fitView')} onClick={fitView} />
|
|
@@ -1475,6 +1774,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1475
1774
|
{minimapOpen ? renderMinimap() : null}
|
|
1476
1775
|
{renderComposer()}
|
|
1477
1776
|
{renderContextMenu()}
|
|
1777
|
+
{libraryOpen ? <TemplateLibrary api={api} onClose={() => setLibraryOpen(false)} onUse={applyTemplate} /> : null}
|
|
1478
1778
|
|
|
1479
1779
|
{error !== null ? <div className={css.errorToast} role="status" data-canvas-no-zoom="">{error}<button type="button" aria-label={tt('canvas.dismiss')} onClick={() => setError(null)}><ToolbarIcon name="close" /></button></div> : null}
|
|
1480
1780
|
|
|
@@ -1486,6 +1786,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1486
1786
|
defaultChannelId={defaultChannelId}
|
|
1487
1787
|
canvasId={document?.id ?? ''}
|
|
1488
1788
|
connected={connected}
|
|
1789
|
+
initialTab={pickerTab}
|
|
1489
1790
|
onClose={() => setPickerOpen(false)}
|
|
1490
1791
|
onAssets={assets => { addAssets(assets); setPickerOpen(false) }}
|
|
1491
1792
|
onTask={task => {
|
|
@@ -1513,12 +1814,13 @@ function ImagePicker(props: {
|
|
|
1513
1814
|
defaultChannelId?: string
|
|
1514
1815
|
canvasId: string
|
|
1515
1816
|
connected: boolean
|
|
1817
|
+
initialTab?: 'upload' | 'history' | 'gallery' | 'generate'
|
|
1516
1818
|
onClose: () => void
|
|
1517
1819
|
onAssets: (assets: CanvasAssetRef[]) => void
|
|
1518
1820
|
onTask: (task: GenerationTask) => void
|
|
1519
1821
|
}): React.JSX.Element {
|
|
1520
1822
|
const { api, history, gallery, imageModels, defaultChannelId, canvasId, connected, onClose, onAssets } = props
|
|
1521
|
-
const [tab, setTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>('upload')
|
|
1823
|
+
const [tab, setTab] = useState<'upload' | 'history' | 'gallery' | 'generate'>(props.initialTab ?? 'upload')
|
|
1522
1824
|
const [selected, setSelected] = useState<string[]>([])
|
|
1523
1825
|
const [dimensions, setDimensions] = useState<Record<string, { width: number; height: number }>>({})
|
|
1524
1826
|
const [prompt, setPrompt] = useState('')
|