@exccoder/dsh-media-workbench 0.0.0-stage → 0.1.2
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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.en.md +111 -0
- package/README.md +141 -2
- package/SECURITY.md +29 -0
- package/cordis.patch.yml +4 -0
- package/docs/screenshot-graph.webp +0 -0
- package/docs/screenshot-image.webp +0 -0
- package/docs/screenshot-video.webp +0 -0
- package/docs/screenshot-write.webp +0 -0
- package/lib/client.js +110 -0
- package/package.json +79 -4
- package/src/graph.js +304 -0
- package/src/image.js +137 -0
- package/src/index.js +769 -0
- package/src/llm.js +60 -0
- package/src/settings.js +187 -0
- package/src/store.js +274 -0
- package/src/tools.js +354 -0
- package/src/util.js +217 -0
- package/src/video.js +219 -0
- package/src/writing.js +112 -0
- package/web/app.js +268 -0
- package/web/favicon.svg +12 -0
- package/web/index.html +16 -0
- package/web/lib/api.js +79 -0
- package/web/lib/constants.js +69 -0
- package/web/lib/dom.js +99 -0
- package/web/lib/icons.js +65 -0
- package/web/lib/state.js +42 -0
- package/web/styles.css +1096 -0
- package/web/views/constellation-model.js +63 -0
- package/web/views/constellation.js +1110 -0
- package/web/views/image.js +488 -0
- package/web/views/settings.js +244 -0
- package/web/views/video.js +363 -0
- package/web/views/write.js +513 -0
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
import { api, fileUrl } from '../lib/api.js'
|
|
2
|
+
import { $, $$, clamp, debounce, downloadJson, esc, fmtTime } from '../lib/dom.js'
|
|
3
|
+
import { icon } from '../lib/icons.js'
|
|
4
|
+
import { app, t } from '../lib/state.js'
|
|
5
|
+
import { NODE_TYPES } from './constellation-model.js'
|
|
6
|
+
|
|
7
|
+
const W = 252
|
|
8
|
+
const PORT_TOP = 62
|
|
9
|
+
const PORT_GAP = 26
|
|
10
|
+
|
|
11
|
+
export function mount(root) {
|
|
12
|
+
const graphs = () => app.state?.graphs ?? []
|
|
13
|
+
let graph = null
|
|
14
|
+
let selected = null
|
|
15
|
+
let selectedEdge = null
|
|
16
|
+
let transform = { x: 60, y: 40, k: 1 }
|
|
17
|
+
let dirty = false
|
|
18
|
+
let drag = null
|
|
19
|
+
let pan = null
|
|
20
|
+
let connect = null
|
|
21
|
+
let undoStack = []
|
|
22
|
+
let redoStack = []
|
|
23
|
+
let saveTimer = null
|
|
24
|
+
let mounted = true
|
|
25
|
+
|
|
26
|
+
root.innerHTML = `
|
|
27
|
+
<section class="bench bench-graph">
|
|
28
|
+
<aside class="graph-palette">
|
|
29
|
+
<div class="palette-head">
|
|
30
|
+
<span class="eyebrow">${esc(t('节点库', 'NODES'))}</span>
|
|
31
|
+
<p>${esc(t('点一下加到画布', 'Click to add'))}</p>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="palette-list" id="palette">
|
|
34
|
+
${Object.entries(NODE_TYPES).map(([type, def]) => `<button type="button" class="palette-item" data-add="${type}" style="--c:${def.color}">
|
|
35
|
+
<span class="palette-ic">${icon(def.icon, 17)}</span>
|
|
36
|
+
<span><b>${esc(def.label)}</b><small>${esc(def.hint)}</small></span>
|
|
37
|
+
<span class="palette-plus">${icon('plus', 13)}</span>
|
|
38
|
+
</button>`).join('')}
|
|
39
|
+
</div>
|
|
40
|
+
<div class="palette-foot">
|
|
41
|
+
<span class="field-label">${esc(t('从模板开始', 'START FROM'))}</span>
|
|
42
|
+
<div class="template-list" id="templates">
|
|
43
|
+
<button type="button" data-template="inspiration"><b>${esc(t('灵感成片', 'Spark to frame'))}</b><small>${esc(t('提示词 → 写作 → 画面', 'Prompt → writing → image'))}</small></button>
|
|
44
|
+
<button type="button" data-template="card"><b>${esc(t('图文卡片', 'Postcard'))}</b><small>${esc(t('短句 + 极简配图', 'Line + minimal image'))}</small></button>
|
|
45
|
+
<button type="button" data-template="video"><b>${esc(t('短片分镜', 'Short film'))}</b><small>${esc(t('旁白 → 首帧 → 视频', 'Voice → frame → video'))}</small></button>
|
|
46
|
+
</div>
|
|
47
|
+
<p class="palette-tip">${icon('info', 13)} ${esc(t('星图必须是 DAG,连线时自动检查类型和环路。', 'The graph must stay a DAG; connections check type and cycles.'))}</p>
|
|
48
|
+
</div>
|
|
49
|
+
</aside>
|
|
50
|
+
|
|
51
|
+
<section class="graph-main">
|
|
52
|
+
<header class="graph-toolbar">
|
|
53
|
+
<div class="graph-id">
|
|
54
|
+
<details class="menu graph-switch" id="graph-switch">
|
|
55
|
+
<summary class="icon-btn" title="${esc(t('切换或新建星图', 'Switch or create graph'))}">${icon('layers', 15)}</summary>
|
|
56
|
+
<div class="menu-panel menu-graphs" id="graph-menu"></div>
|
|
57
|
+
</details>
|
|
58
|
+
<input id="graph-name" class="graph-name" maxlength="40" aria-label="${esc(t('星图名称', 'Graph name'))}">
|
|
59
|
+
</div>
|
|
60
|
+
<div class="graph-actions">
|
|
61
|
+
<span class="graph-status" id="graph-status"></span>
|
|
62
|
+
<button class="btn btn-small btn-primary" id="run">${icon('play', 13)}${esc(t('运行', 'Run'))}</button>
|
|
63
|
+
<button class="btn btn-small btn-ghost" id="stop" hidden>${icon('stop', 13)}${esc(t('停止', 'Stop'))}</button>
|
|
64
|
+
<span class="tool-sep"></span>
|
|
65
|
+
<button class="icon-btn" id="undo" title="${esc(t('撤销', 'Undo'))}">${icon('undo', 15)}</button>
|
|
66
|
+
<button class="icon-btn" id="redo" title="${esc(t('重做', 'Redo'))}">${icon('redo', 15)}</button>
|
|
67
|
+
<button class="icon-btn inspector-toggle" id="toggle-inspector" title="${esc(t('节点参数', 'Node settings'))}">${icon('settings', 15)}</button>
|
|
68
|
+
<button class="icon-btn" id="fit" title="${esc(t('适应画布 (F)', 'Fit (F)'))}">${icon('fit', 15)}</button>
|
|
69
|
+
<details class="menu" id="more-menu">
|
|
70
|
+
<summary class="icon-btn" title="${esc(t('更多', 'More'))}">${icon('more', 16)}</summary>
|
|
71
|
+
<div class="menu-panel">
|
|
72
|
+
<button type="button" data-menu="new">${icon('plus', 14)}${esc(t('新建空白星图', 'New blank graph'))}</button>
|
|
73
|
+
<button type="button" data-menu="save-blueprint">${icon('layers', 14)}${esc(t('保存为蓝图', 'Save as blueprint'))}</button>
|
|
74
|
+
<span class="menu-label">${esc(t('我的蓝图', 'MY BLUEPRINTS'))}</span>
|
|
75
|
+
<div id="blueprint-list" class="menu-blueprints"></div>
|
|
76
|
+
<div class="menu-sep"></div>
|
|
77
|
+
<button type="button" data-menu="import">${icon('upload', 14)}${esc(t('导入 JSON', 'Import JSON'))}</button>
|
|
78
|
+
<button type="button" data-menu="export">${icon('download', 14)}${esc(t('导出 JSON', 'Export JSON'))}</button>
|
|
79
|
+
<div class="menu-sep"></div>
|
|
80
|
+
<button type="button" data-menu="delete" class="danger-quiet">${icon('trash', 14)}${esc(t('删除这张星图', 'Delete graph'))}</button>
|
|
81
|
+
</div>
|
|
82
|
+
</details>
|
|
83
|
+
</div>
|
|
84
|
+
</header>
|
|
85
|
+
|
|
86
|
+
<div class="graph-canvas" id="canvas">
|
|
87
|
+
<div class="world" id="world">
|
|
88
|
+
<svg class="edge-layer" id="edges" width="12000" height="12000" aria-hidden="true"></svg>
|
|
89
|
+
<div class="node-layer" id="nodes"></div>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="canvas-empty" id="canvas-empty">
|
|
92
|
+
<div class="empty-art">${icon('star', 30)}</div>
|
|
93
|
+
<h3>${esc(t('把一句话,接成一条线', 'Wire a line into a flow'))}</h3>
|
|
94
|
+
<p>${esc(t('从左边加节点,或选一个模板。从节点右侧的圆点拖到另一个节点,就连起来了。', 'Add nodes from the left, or pick a template. Drag from a dot on the right edge to connect.'))}</p>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="canvas-hints">
|
|
97
|
+
<span>${esc(t('滚轮缩放', 'Wheel to zoom'))}</span><span>${esc(t('拖动画布', 'Drag to pan'))}</span><span>${esc(t('双击加提示词', 'Double-click for a prompt'))}</span>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="zoom-tools">
|
|
100
|
+
<button class="icon-btn" id="zoom-in" title="+">${icon('zoomIn', 15)}</button>
|
|
101
|
+
<span id="zoom-label">100%</span>
|
|
102
|
+
<button class="icon-btn" id="zoom-out" title="-">${icon('zoomOut', 15)}</button>
|
|
103
|
+
</div>
|
|
104
|
+
<input type="file" id="import-file" accept="application/json,.json" hidden>
|
|
105
|
+
</div>
|
|
106
|
+
</section>
|
|
107
|
+
|
|
108
|
+
<aside class="graph-inspector" id="inspector"></aside>
|
|
109
|
+
</section>
|
|
110
|
+
`
|
|
111
|
+
|
|
112
|
+
const canvas = $('#canvas')
|
|
113
|
+
const world = $('#world')
|
|
114
|
+
const nodesEl = $('#nodes')
|
|
115
|
+
const edgesEl = $('#edges')
|
|
116
|
+
|
|
117
|
+
// 选择初始星图
|
|
118
|
+
const savedId = sessionStorage.getItem('media-workbench-modern:graph')
|
|
119
|
+
graph = graphs().find(row => row.id === savedId) ?? graphs()[0] ?? null
|
|
120
|
+
if (graph) {
|
|
121
|
+
graph = clone(graph)
|
|
122
|
+
sessionStorage.setItem('media-workbench-modern:graph', graph.id)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clone(value) {
|
|
126
|
+
return JSON.parse(JSON.stringify(value))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function activeGraph() {
|
|
130
|
+
return graph
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function bound() {
|
|
134
|
+
return { width: canvas.clientWidth, height: canvas.clientHeight }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function applyTransform() {
|
|
138
|
+
world.style.transform = `translate(${transform.x}px, ${transform.y}px) scale(${transform.k})`
|
|
139
|
+
$('#zoom-label').textContent = `${Math.round(transform.k * 100)}%`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function screenToWorld(clientX, clientY) {
|
|
143
|
+
const rect = canvas.getBoundingClientRect()
|
|
144
|
+
return {
|
|
145
|
+
x: (clientX - rect.left - transform.x) / transform.k,
|
|
146
|
+
y: (clientY - rect.top - transform.y) / transform.k,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- 渲染 ----------
|
|
151
|
+
function nodeHtml(node) {
|
|
152
|
+
const def = NODE_TYPES[node.type]
|
|
153
|
+
const status = node.status ?? 'idle'
|
|
154
|
+
const isSelected = selected === node.id
|
|
155
|
+
const inputs = def.inputs.map((port, index) => `
|
|
156
|
+
<span class="port port-in" data-node="${node.id}" data-port="${port.id}" data-dir="in" style="top:${PORT_TOP + index * PORT_GAP}px" title="${esc(port.type)}">
|
|
157
|
+
<i></i><em>${esc(portLabel(port.type))}</em>
|
|
158
|
+
</span>`).join('')
|
|
159
|
+
const outputs = def.outputs.map((port, index) => `
|
|
160
|
+
<span class="port port-out" data-node="${node.id}" data-port="${port.id}" data-dir="out" style="top:${PORT_TOP + index * PORT_GAP}px" title="${esc(port.type)}">
|
|
161
|
+
<em>${esc(portLabel(port.type))}</em><i></i>
|
|
162
|
+
</span>`).join('')
|
|
163
|
+
return `<article class="gnode ${isSelected ? 'is-selected' : ''}" data-node-id="${node.id}" data-type="${node.type}" data-status="${status}" style="left:${node.x}px;top:${node.y}px;width:${W}px;--c:${def.color}">
|
|
164
|
+
<header class="gnode-head" data-drag="1">
|
|
165
|
+
<span class="gnode-ic">${icon(def.icon, 14)}</span>
|
|
166
|
+
<span class="gnode-title">${esc(def.label)}</span>
|
|
167
|
+
<span class="gnode-status" title="${esc(status)}"></span>
|
|
168
|
+
</header>
|
|
169
|
+
<div class="gnode-body">${bodyHtml(node)}</div>
|
|
170
|
+
<div class="gnode-foot"><span>${esc(footText(node))}</span>${node.output?.kind === 'text' ? `<button class="gnode-open" data-open-text="${node.id}">${esc(t('看全文', 'Open'))}</button>` : ''}</div>
|
|
171
|
+
${inputs}${outputs}
|
|
172
|
+
</article>`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function portLabel(type) {
|
|
176
|
+
return { text: t('文本', 'text'), image: t('图像', 'image'), video: t('视频', 'video'), media: t('作品', 'media') }[type] ?? type
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function bodyHtml(node) {
|
|
180
|
+
const config = node.config ?? {}
|
|
181
|
+
if (node.status === 'running') return `<div class="node-running"><i></i><span>${esc(t('正在运行…', 'Running…'))}</span></div>`
|
|
182
|
+
if (node.status === 'error') return `<p class="node-error">${esc(node.error || t('运行失败', 'Failed'))}</p>`
|
|
183
|
+
if (node.type === 'prompt' || node.type === 'note') {
|
|
184
|
+
return `<p class="node-text">${esc(config.text || (node.type === 'prompt' ? t('双击写一句提示…', 'Write a prompt…') : t('写点备注…', 'A note…')))}</p>`
|
|
185
|
+
}
|
|
186
|
+
if (node.type === 'write') {
|
|
187
|
+
const out = node.output?.text
|
|
188
|
+
return `<p class="node-text">${esc(out || config.instruction || '')}</p><div class="node-tags"><span>${esc(config.tone || '自然叙述')}</span><span>${esc(lengthLabel(config.length))}</span></div>`
|
|
189
|
+
}
|
|
190
|
+
if (node.type === 'image') {
|
|
191
|
+
if (node.output?.file) return `<div class="node-media"><img src="${fileUrl(node.output.file)}" alt="" loading="lazy"></div>`
|
|
192
|
+
return `<div class="node-placeholder">${icon('image', 18)}<span>${esc(config.style || '电影感')} · ${esc(config.ratio || '16:9')}</span></div>${config.prompt ? `<p class="node-text">${esc(config.prompt)}</p>` : ''}`
|
|
193
|
+
}
|
|
194
|
+
if (node.type === 'video') {
|
|
195
|
+
if (node.output?.file) return `<div class="node-media"><video src="${fileUrl(node.output.file)}" muted preload="metadata" playsinline></video><span class="media-badge">${icon('film', 12)}</span></div>`
|
|
196
|
+
if (node.output?.frames?.length) return `<div class="node-strip">${node.output.frames.slice(0, 4).map(file => `<img src="${fileUrl(file)}" alt="">`).join('')}</div>`
|
|
197
|
+
return `<div class="node-placeholder">${icon('film', 18)}<span>${esc(config.duration || 5)}s · ${esc(config.ratio || '16:9')} · ${esc(modeLabel(config.mode))}</span></div>`
|
|
198
|
+
}
|
|
199
|
+
if (node.type === 'preview') {
|
|
200
|
+
const out = node.output
|
|
201
|
+
if (out?.kind === 'image') return `<div class="node-media"><img src="${fileUrl(out.file)}" alt=""></div>`
|
|
202
|
+
if (out?.kind === 'video') {
|
|
203
|
+
if (out.file) return `<div class="node-media"><video src="${fileUrl(out.file)}" muted preload="metadata" playsinline controls></video></div>`
|
|
204
|
+
if (out.frames?.length) return `<div class="node-strip">${out.frames.slice(0, 4).map(file => `<img src="${fileUrl(file)}" alt="">`).join('')}</div>`
|
|
205
|
+
}
|
|
206
|
+
if (out?.kind === 'text') return `<p class="node-text">${esc(out.text)}</p>`
|
|
207
|
+
return `<div class="node-placeholder">${icon('eye', 18)}<span>${esc(t('运行后在这里看作品', 'Output appears here'))}</span></div>`
|
|
208
|
+
}
|
|
209
|
+
return ''
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function lengthLabel(value) {
|
|
213
|
+
return { short: '短', medium: '中', long: '长' }[value] ?? '中'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function modeLabel(value) {
|
|
217
|
+
return { auto: t('自动', 'auto'), text: t('文生', 'text'), storyboard: t('分镜', 'storyboard'), image: t('图生', 'image') }[value] ?? t('自动', 'auto')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function footText(node) {
|
|
221
|
+
if (node.status === 'running') return t('进行中', 'Running')
|
|
222
|
+
if (node.status === 'error') return t('失败', 'Error')
|
|
223
|
+
if (node.output) return t('已完成', 'Done') + (node.output.kind === 'image' ? ' · image' : node.output.kind === 'video' ? ' · video' : '')
|
|
224
|
+
const def = NODE_TYPES[node.type]
|
|
225
|
+
return def?.hint ?? ''
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function nodePortY(node, portId, dir) {
|
|
229
|
+
const def = NODE_TYPES[node.type]
|
|
230
|
+
const index = (dir === 'in' ? def.inputs : def.outputs).findIndex(port => port.id === portId)
|
|
231
|
+
return node.y + PORT_TOP + Math.max(0, index) * PORT_GAP
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function edgePath(edge) {
|
|
235
|
+
const from = graph.nodes.find(node => node.id === edge.from.node)
|
|
236
|
+
const to = graph.nodes.find(node => node.id === edge.to.node)
|
|
237
|
+
if (!from || !to) return ''
|
|
238
|
+
const x1 = from.x + W
|
|
239
|
+
const y1 = nodePortY(from, edge.from.port, 'out')
|
|
240
|
+
const x2 = to.x
|
|
241
|
+
const y2 = nodePortY(to, edge.to.port, 'in')
|
|
242
|
+
const dx = Math.max(36, Math.abs(x2 - x1) * 0.45)
|
|
243
|
+
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderEdges() {
|
|
247
|
+
if (!graph) { edgesEl.innerHTML = ''; return }
|
|
248
|
+
const defs = `<defs><marker id="edge-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="edge-arrow" d="M 0 0 L 10 5 L 0 10 z"></path></marker></defs>`
|
|
249
|
+
edgesEl.innerHTML = defs + graph.edges.map(edge => {
|
|
250
|
+
const path = edgePath(edge)
|
|
251
|
+
if (!path) return ''
|
|
252
|
+
return `<g class="edge ${selectedEdge === edge.id ? 'is-selected' : ''}" data-edge-id="${edge.id}">
|
|
253
|
+
<path class="edge-hit" d="${path}"></path>
|
|
254
|
+
<path class="edge-line" d="${path}" marker-end="url(#edge-arrow)"></path>
|
|
255
|
+
</g>`
|
|
256
|
+
}).join('') + (connect?.temp ? `<path class="edge-temp" d="${connect.temp}"></path>` : '')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderNodes() {
|
|
260
|
+
if (!graph) { nodesEl.innerHTML = ''; return }
|
|
261
|
+
nodesEl.innerHTML = graph.nodes.map(nodeHtml).join('')
|
|
262
|
+
renderEdges()
|
|
263
|
+
$('#canvas-empty').hidden = graph.nodes.length > 0
|
|
264
|
+
const nameEl = $('#graph-name')
|
|
265
|
+
if (nameEl && document.activeElement !== nameEl) nameEl.value = graph.name ?? ''
|
|
266
|
+
paintGraphSelect()
|
|
267
|
+
paintStatus()
|
|
268
|
+
renderInspector()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function paintGraphSelect() {
|
|
272
|
+
const menu = $('#graph-menu')
|
|
273
|
+
if (!menu) return
|
|
274
|
+
menu.innerHTML = graphs().map(row => `<button type="button" data-graph="${row.id}" class="${row.id === graph?.id ? 'is-current' : ''}">${icon(row.id === graph?.id ? 'check' : 'layers', 13)}<span>${esc(row.name)}</span></button>`).join('')
|
|
275
|
+
+ `<div class="menu-sep"></div><button type="button" data-new-graph>${icon('plus', 13)}${esc(t('新建空白星图', 'New blank graph'))}</button>`
|
|
276
|
+
const blueprints = app.state?.blueprints ?? []
|
|
277
|
+
const list = $('#blueprint-list')
|
|
278
|
+
if (list) {
|
|
279
|
+
list.innerHTML = blueprints.length
|
|
280
|
+
? blueprints.slice(0, 8).map(row => `<button type="button" data-blueprint="${row.id}">${icon('layers', 13)}<span>${esc(row.name || '蓝图')}</span></button>`).join('')
|
|
281
|
+
: `<p class="menu-empty">${esc(t('还没有保存过蓝图', 'No blueprints yet'))}</p>`
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function paintStatus() {
|
|
286
|
+
const el = $('#graph-status')
|
|
287
|
+
if (!graph) { el.textContent = ''; return }
|
|
288
|
+
const run = graph.run
|
|
289
|
+
if (run?.status === 'running') {
|
|
290
|
+
el.className = 'graph-status is-running'
|
|
291
|
+
el.innerHTML = `<i></i>${esc(t('运行中', 'Running'))} ${run.wave ?? 1}/${run.waves ?? 1}`
|
|
292
|
+
} else if (run?.status === 'done') {
|
|
293
|
+
el.className = 'graph-status is-done'
|
|
294
|
+
el.innerHTML = `${icon('check', 12)}${esc(t('运行完成', 'Finished'))}`
|
|
295
|
+
} else if (run?.status === 'error') {
|
|
296
|
+
el.className = 'graph-status is-error'
|
|
297
|
+
el.innerHTML = `${icon('alert', 12)}${esc(run.error || t('有节点失败', 'Some nodes failed'))}`
|
|
298
|
+
} else if (run?.status === 'stopped') {
|
|
299
|
+
el.className = 'graph-status'
|
|
300
|
+
el.textContent = t('已停止', 'Stopped')
|
|
301
|
+
} else if (dirty) {
|
|
302
|
+
el.className = 'graph-status is-saving'
|
|
303
|
+
el.textContent = t('保存中…', 'Saving…')
|
|
304
|
+
} else {
|
|
305
|
+
el.className = 'graph-status'
|
|
306
|
+
el.textContent = ''
|
|
307
|
+
}
|
|
308
|
+
const running = run?.status === 'running'
|
|
309
|
+
$('#run').disabled = running
|
|
310
|
+
$('#stop').hidden = !running
|
|
311
|
+
$('#graph-name').disabled = running
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------- 检查器 ----------
|
|
315
|
+
function renderInspector() {
|
|
316
|
+
const host = $('#inspector')
|
|
317
|
+
if (selectedEdge) {
|
|
318
|
+
host.innerHTML = `<div class="inspect-head"><span class="eyebrow">${esc(t('连线', 'EDGE'))}</span><button class="icon-btn" data-inspect-close>${icon('x', 15)}</button></div>
|
|
319
|
+
<p class="inspect-help">${esc(t('这条线把上游的输出接到下游的输入。按 Delete 删除。', 'This edge feeds an upstream output into a downstream input. Press Delete to remove.'))}</p>
|
|
320
|
+
<button class="btn btn-small btn-ghost danger-quiet" data-delete-edge>${icon('trash', 13)}${esc(t('删除连线', 'Delete edge'))}</button>`
|
|
321
|
+
host.querySelector('[data-inspect-close]').addEventListener('click', () => {
|
|
322
|
+
selectedEdge = null
|
|
323
|
+
document.querySelector('.bench-graph')?.classList.remove('is-inspector-open')
|
|
324
|
+
renderNodes()
|
|
325
|
+
})
|
|
326
|
+
host.querySelector('[data-delete-edge]').addEventListener('click', () => deleteEdge(selectedEdge))
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const node = graph?.nodes.find(item => item.id === selected)
|
|
330
|
+
if (!node) {
|
|
331
|
+
host.innerHTML = `<div class="inspect-empty"><span class="inspect-art">${icon('star', 24)}</span>
|
|
332
|
+
<h3>${esc(t('选中一个节点', 'Select a node'))}</h3>
|
|
333
|
+
<p>${esc(t('在画布上点节点,这里会出现参数;点空白处取消选择。', 'Click a node to edit its settings. Click empty space to deselect.'))}</p>
|
|
334
|
+
<div class="inspect-legend">
|
|
335
|
+
<span><i style="background:#d97757"></i>${esc(t('提示词', 'Prompt'))}</span>
|
|
336
|
+
<span><i style="background:#b4833e"></i>${esc(t('写作', 'Writing'))}</span>
|
|
337
|
+
<span><i style="background:#8a6fd1"></i>${esc(t('图像', 'Image'))}</span>
|
|
338
|
+
<span><i style="background:#3f8f92"></i>${esc(t('视频', 'Video'))}</span>
|
|
339
|
+
</div></div>`
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const def = NODE_TYPES[node.type]
|
|
343
|
+
host.innerHTML = `<div class="inspect-head">
|
|
344
|
+
<div><span class="eyebrow">${esc(def.label)}</span><h3>${esc(t('节点参数', 'Node settings'))}</h3></div>
|
|
345
|
+
<button class="icon-btn" data-inspect-close>${icon('x', 15)}</button>
|
|
346
|
+
</div>
|
|
347
|
+
<div class="inspect-fields" id="inspect-fields">${fieldsHtml(node)}</div>
|
|
348
|
+
${node.error ? `<p class="inspect-error">${esc(node.error)}</p>` : ''}
|
|
349
|
+
${outputHtml(node)}
|
|
350
|
+
<div class="inspect-actions">
|
|
351
|
+
<button class="btn btn-small btn-primary" data-run-node>${icon('play', 13)}${esc(t('运行到这里', 'Run to here'))}</button>
|
|
352
|
+
<button class="btn btn-small btn-ghost" data-dup-node>${icon('duplicate', 13)}${esc(t('复制', 'Duplicate'))}</button>
|
|
353
|
+
<button class="btn btn-small btn-ghost danger-quiet" data-del-node>${icon('trash', 13)}</button>
|
|
354
|
+
</div>`
|
|
355
|
+
host.querySelector('[data-inspect-close]').addEventListener('click', () => {
|
|
356
|
+
selected = null
|
|
357
|
+
document.querySelector('.bench-graph')?.classList.remove('is-inspector-open')
|
|
358
|
+
renderNodes()
|
|
359
|
+
})
|
|
360
|
+
host.querySelector('[data-run-node]').addEventListener('click', () => run([node.id]))
|
|
361
|
+
host.querySelector('[data-dup-node]').addEventListener('click', () => duplicateNode(node.id))
|
|
362
|
+
host.querySelector('[data-del-node]').addEventListener('click', () => deleteNode(node.id))
|
|
363
|
+
bindFields(node)
|
|
364
|
+
const open = host.querySelector('[data-open-output]')
|
|
365
|
+
if (open) open.addEventListener('click', () => openOutput(node))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function fieldsHtml(node) {
|
|
369
|
+
const c = node.config ?? {}
|
|
370
|
+
if (node.type === 'prompt') {
|
|
371
|
+
return `<label class="field"><span class="field-label">${esc(t('提示词', 'Prompt'))}</span>
|
|
372
|
+
<textarea rows="5" data-field="text">${esc(c.text ?? '')}</textarea></label>
|
|
373
|
+
<p class="inspect-help">${esc(t('这是流程的起点。把一句话写清楚,下游的写作和画面都会读到它。', 'The start of the flow. One clear line feeds everything downstream.'))}</p>`
|
|
374
|
+
}
|
|
375
|
+
if (node.type === 'note') {
|
|
376
|
+
return `<label class="field"><span class="field-label">${esc(t('便签', 'Note'))}</span><textarea rows="4" data-field="text">${esc(c.text ?? '')}</textarea></label>`
|
|
377
|
+
}
|
|
378
|
+
if (node.type === 'write') {
|
|
379
|
+
return `<label class="field"><span class="field-label">${esc(t('写作指令', 'Instruction'))}</span>
|
|
380
|
+
<textarea rows="4" data-field="instruction">${esc(c.instruction ?? '')}</textarea></label>
|
|
381
|
+
<div class="field-row">
|
|
382
|
+
<label class="field"><span class="field-label">${esc(t('文风', 'Voice'))}</span>
|
|
383
|
+
<select class="select" data-field="tone">${['自然叙述', '克制冷静', '热烈', '幽默', '诗意', '硬朗'].map(v => `<option ${v === c.tone ? 'selected' : ''}>${v}</option>`).join('')}</select></label>
|
|
384
|
+
<label class="field"><span class="field-label">${esc(t('篇幅', 'Length'))}</span>
|
|
385
|
+
<select class="select" data-field="length">${[['short', '短 · 400 字'], ['medium', '中 · 900 字'], ['long', '长 · 1600 字']].map(([v, label]) => `<option value="${v}" ${v === c.length ? 'selected' : ''}>${label}</option>`).join('')}</select></label>
|
|
386
|
+
</div>
|
|
387
|
+
<p class="inspect-help">${esc(t('会读取所有接进来的文本,再按指令写。', 'Reads every incoming text connection, then writes to the instruction.'))}</p>`
|
|
388
|
+
}
|
|
389
|
+
if (node.type === 'image') {
|
|
390
|
+
return `<label class="field"><span class="field-label">${esc(t('补充画面描述', 'Extra prompt'))}</span>
|
|
391
|
+
<textarea rows="3" data-field="prompt" placeholder="${esc(t('可选:补充上游没有说到的细节', 'Optional details to add'))}">${esc(c.prompt ?? '')}</textarea></label>
|
|
392
|
+
<div class="field-row">
|
|
393
|
+
<label class="field"><span class="field-label">${esc(t('风格', 'Style'))}</span>
|
|
394
|
+
<select class="select" data-field="style">${['电影感', '插画', '水墨', '赛博朋克', '胶片', '3D', '极简', '浮世绘', '概念艺术'].map(v => `<option ${v === c.style ? 'selected' : ''}>${v}</option>`).join('')}</select></label>
|
|
395
|
+
<label class="field"><span class="field-label">${esc(t('画幅', 'Aspect'))}</span>
|
|
396
|
+
<select class="select" data-field="ratio">${['1:1', '4:3', '3:2', '16:9', '9:16', '2:3'].map(v => `<option ${v === c.ratio ? 'selected' : ''}>${v}</option>`).join('')}</select></label>
|
|
397
|
+
</div>
|
|
398
|
+
<p class="inspect-help">${esc(t('上游的文本会作为提示词,上游的图像会作为参考图。', 'Incoming text becomes the prompt; an incoming image becomes the reference.'))}</p>`
|
|
399
|
+
}
|
|
400
|
+
if (node.type === 'video') {
|
|
401
|
+
return `<label class="field"><span class="field-label">${esc(t('补充描述', 'Extra prompt'))}</span>
|
|
402
|
+
<textarea rows="3" data-field="prompt">${esc(c.prompt ?? '')}</textarea></label>
|
|
403
|
+
<div class="field-row">
|
|
404
|
+
<label class="field"><span class="field-label">${esc(t('时长', 'Seconds'))}</span>
|
|
405
|
+
<select class="select" data-field="duration">${[5, 8, 10, 15].map(v => `<option value="${v}" ${Number(c.duration) === v ? 'selected' : ''}>${v}s</option>`).join('')}</select></label>
|
|
406
|
+
<label class="field"><span class="field-label">${esc(t('画幅', 'Aspect'))}</span>
|
|
407
|
+
<select class="select" data-field="ratio">${['1:1', '4:3', '3:2', '16:9', '9:16', '2:3'].map(v => `<option ${v === c.ratio ? 'selected' : ''}>${v}</option>`).join('')}</select></label>
|
|
408
|
+
</div>
|
|
409
|
+
<label class="field"><span class="field-label">${esc(t('模式', 'Mode'))}</span>
|
|
410
|
+
<select class="select" data-field="mode">
|
|
411
|
+
<option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>${esc(t('自动(有首帧就走图生)', 'Auto'))}</option>
|
|
412
|
+
<option value="text" ${c.mode === 'text' ? 'selected' : ''}>${esc(t('文生视频', 'Text to video'))}</option>
|
|
413
|
+
<option value="storyboard" ${c.mode === 'storyboard' ? 'selected' : ''}>${esc(t('分镜短片(用生图)', 'Storyboard (image provider)'))}</option>
|
|
414
|
+
</select></label>`
|
|
415
|
+
}
|
|
416
|
+
if (node.type === 'preview') {
|
|
417
|
+
return `<p class="inspect-help">${esc(t('把接进来的作品显示出来。可以同时接图像、视频和文本。', 'Shows whatever is connected: image, video or text.'))}</p>`
|
|
418
|
+
}
|
|
419
|
+
return ''
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function outputHtml(node) {
|
|
423
|
+
if (!node.output) return ''
|
|
424
|
+
const out = node.output
|
|
425
|
+
if (out.kind === 'image') {
|
|
426
|
+
return `<div class="inspect-output"><span class="field-label">${esc(t('产出', 'Output'))}</span><img src="${fileUrl(out.file)}" alt=""><button class="text-btn" data-open-output>${esc(t('查看 / 下载', 'Open / download'))}</button></div>`
|
|
427
|
+
}
|
|
428
|
+
if (out.kind === 'video') {
|
|
429
|
+
return `<div class="inspect-output"><span class="field-label">${esc(t('产出', 'Output'))}</span>${out.file ? `<video src="${fileUrl(out.file)}" controls preload="metadata" playsinline></video>` : `<div class="node-strip">${(out.frames ?? []).map(file => `<img src="${fileUrl(file)}" alt="">`).join('')}</div>`}<button class="text-btn" data-open-output>${esc(t('查看 / 下载', 'Open / download'))}</button></div>`
|
|
430
|
+
}
|
|
431
|
+
if (out.kind === 'text') {
|
|
432
|
+
return `<div class="inspect-output"><span class="field-label">${esc(t('产出', 'Output'))}</span><p class="inspect-text">${esc(out.text)}</p></div>`
|
|
433
|
+
}
|
|
434
|
+
return ''
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function bindFields(node) {
|
|
438
|
+
$('#inspect-fields')?.querySelectorAll('[data-field]').forEach(el => {
|
|
439
|
+
const field = el.dataset.field
|
|
440
|
+
const handler = () => {
|
|
441
|
+
if (!node.config) node.config = {}
|
|
442
|
+
node.config[field] = el.type === 'number' ? Number(el.value) : el.value
|
|
443
|
+
dirty = true
|
|
444
|
+
paintStatus()
|
|
445
|
+
scheduleSave()
|
|
446
|
+
// 只更新该节点的预览,避免输入框失焦
|
|
447
|
+
const nodeEl = nodesEl.querySelector(`[data-node-id="${node.id}"] .gnode-body`)
|
|
448
|
+
if (nodeEl) nodeEl.innerHTML = bodyHtml(node)
|
|
449
|
+
const foot = nodesEl.querySelector(`[data-node-id="${node.id}"] .gnode-foot span`)
|
|
450
|
+
if (foot) foot.textContent = footText(node)
|
|
451
|
+
}
|
|
452
|
+
el.addEventListener(el.tagName === 'SELECT' ? 'change' : 'input', handler)
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function openOutput(node) {
|
|
457
|
+
const out = node.output
|
|
458
|
+
if (!out) return
|
|
459
|
+
if (out.kind === 'image') {
|
|
460
|
+
const layer = document.createElement('div')
|
|
461
|
+
layer.className = 'output-lightbox'
|
|
462
|
+
layer.innerHTML = `<img src="${fileUrl(out.file)}" alt=""><div class="output-bar"><span>${out.width ? `${out.width} × ${out.height}` : ''}</span><a class="btn btn-small btn-ghost" href="${fileUrl(out.file)}" download>${icon('download', 13)}${esc(t('下载', 'Download'))}</a></div>`
|
|
463
|
+
app.showLayer(layer, { className: 'layer-lightbox' })
|
|
464
|
+
} else if (out.kind === 'video') {
|
|
465
|
+
window.open(fileUrl(out.file), '_blank', 'noopener')
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------- 图操作 ----------
|
|
470
|
+
function addNode(type, x, y) {
|
|
471
|
+
if (!graph) return
|
|
472
|
+
const def = NODE_TYPES[type]
|
|
473
|
+
const node = {
|
|
474
|
+
id: `n_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`,
|
|
475
|
+
type,
|
|
476
|
+
x: Math.round(x),
|
|
477
|
+
y: Math.round(y),
|
|
478
|
+
config: { ...def.defaults },
|
|
479
|
+
status: 'idle',
|
|
480
|
+
output: null,
|
|
481
|
+
error: '',
|
|
482
|
+
}
|
|
483
|
+
pushHistory()
|
|
484
|
+
graph.nodes.push(node)
|
|
485
|
+
selected = node.id
|
|
486
|
+
selectedEdge = null
|
|
487
|
+
dirty = true
|
|
488
|
+
renderNodes()
|
|
489
|
+
scheduleSave()
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function deleteNode(id) {
|
|
493
|
+
if (!graph) return
|
|
494
|
+
const node = graph.nodes.find(item => item.id === id)
|
|
495
|
+
if (!node) return
|
|
496
|
+
pushHistory()
|
|
497
|
+
graph.nodes = graph.nodes.filter(item => item.id !== id)
|
|
498
|
+
graph.edges = graph.edges.filter(edge => edge.from.node !== id && edge.to.node !== id)
|
|
499
|
+
if (selected === id) selected = null
|
|
500
|
+
dirty = true
|
|
501
|
+
renderNodes()
|
|
502
|
+
scheduleSave()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function duplicateNode(id) {
|
|
506
|
+
if (!graph) return
|
|
507
|
+
const source = graph.nodes.find(item => item.id === id)
|
|
508
|
+
if (!source) return
|
|
509
|
+
pushHistory()
|
|
510
|
+
const copy = clone(source)
|
|
511
|
+
copy.id = `n_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`
|
|
512
|
+
copy.x += 36
|
|
513
|
+
copy.y += 36
|
|
514
|
+
copy.status = 'idle'
|
|
515
|
+
copy.output = null
|
|
516
|
+
copy.error = ''
|
|
517
|
+
graph.nodes.push(copy)
|
|
518
|
+
selected = copy.id
|
|
519
|
+
dirty = true
|
|
520
|
+
renderNodes()
|
|
521
|
+
scheduleSave()
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function deleteEdge(id) {
|
|
525
|
+
if (!graph) return
|
|
526
|
+
pushHistory()
|
|
527
|
+
graph.edges = graph.edges.filter(edge => edge.id !== id)
|
|
528
|
+
selectedEdge = null
|
|
529
|
+
dirty = true
|
|
530
|
+
renderNodes()
|
|
531
|
+
scheduleSave()
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function connectPorts(from, to) {
|
|
535
|
+
if (!graph) return
|
|
536
|
+
const check = canConnectLocal(graph, from, to)
|
|
537
|
+
if (!check.ok) {
|
|
538
|
+
app.toast(check.reason, 'error')
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
pushHistory()
|
|
542
|
+
graph.edges.push({
|
|
543
|
+
id: `e_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`,
|
|
544
|
+
from,
|
|
545
|
+
to,
|
|
546
|
+
})
|
|
547
|
+
dirty = true
|
|
548
|
+
renderNodes()
|
|
549
|
+
scheduleSave()
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function canConnectLocal(g, from, to) {
|
|
553
|
+
if (!from || !to) return { ok: false, reason: '连接不完整' }
|
|
554
|
+
if (from.node === to.node) return { ok: false, reason: t('不能连接自己', 'Cannot connect a node to itself') }
|
|
555
|
+
const source = g.nodes.find(n => n.id === from.node)
|
|
556
|
+
const target = g.nodes.find(n => n.id === to.node)
|
|
557
|
+
if (!source || !target) return { ok: false, reason: t('节点不存在', 'Node missing') }
|
|
558
|
+
const out = (NODE_TYPES[source.type].outputs ?? []).find(p => p.id === from.port)
|
|
559
|
+
const input = (NODE_TYPES[target.type].inputs ?? []).find(p => p.id === to.port)
|
|
560
|
+
if (!out || !input) return { ok: false, reason: t('端口不存在', 'Port missing') }
|
|
561
|
+
if (out.type !== input.type && input.type !== 'media') return { ok: false, reason: t('端口类型不匹配', 'Port types do not match') }
|
|
562
|
+
if (g.edges.some(e => e.from.node === from.node && e.from.port === from.port && e.to.node === to.node && e.to.port === to.port)) return { ok: false, reason: t('已经连过了', 'Already connected') }
|
|
563
|
+
if (!input.multi && g.edges.some(e => e.to.node === to.node && e.to.port === to.port)) return { ok: false, reason: t('这个输入只能接一条线', 'This input takes one connection') }
|
|
564
|
+
if (createsCycle(g, from.node, to.node)) return { ok: false, reason: t('会形成环路', 'This would create a cycle') }
|
|
565
|
+
return { ok: true }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function createsCycle(g, fromNode, toNode) {
|
|
569
|
+
const adj = new Map()
|
|
570
|
+
for (const edge of g.edges) {
|
|
571
|
+
if (!adj.has(edge.from.node)) adj.set(edge.from.node, [])
|
|
572
|
+
adj.get(edge.from.node).push(edge.to.node)
|
|
573
|
+
}
|
|
574
|
+
if (!adj.has(fromNode)) adj.set(fromNode, [])
|
|
575
|
+
adj.get(fromNode).push(toNode)
|
|
576
|
+
const seen = new Set()
|
|
577
|
+
const stack = [toNode]
|
|
578
|
+
while (stack.length) {
|
|
579
|
+
const cur = stack.pop()
|
|
580
|
+
if (cur === fromNode) return true
|
|
581
|
+
if (seen.has(cur)) continue
|
|
582
|
+
seen.add(cur)
|
|
583
|
+
for (const next of adj.get(cur) ?? []) stack.push(next)
|
|
584
|
+
}
|
|
585
|
+
return false
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ---------- 撤销 ----------
|
|
589
|
+
function snapshot() {
|
|
590
|
+
return JSON.stringify({ nodes: graph.nodes, edges: graph.edges })
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function pushHistory() {
|
|
594
|
+
if (!graph) return
|
|
595
|
+
undoStack.push(snapshot())
|
|
596
|
+
if (undoStack.length > 60) undoStack.shift()
|
|
597
|
+
redoStack = []
|
|
598
|
+
paintHistoryButtons()
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function restore(json) {
|
|
602
|
+
const data = JSON.parse(json)
|
|
603
|
+
graph.nodes = data.nodes
|
|
604
|
+
graph.edges = data.edges
|
|
605
|
+
selected = null
|
|
606
|
+
selectedEdge = null
|
|
607
|
+
dirty = true
|
|
608
|
+
renderNodes()
|
|
609
|
+
scheduleSave()
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function undo() {
|
|
613
|
+
if (!undoStack.length) return
|
|
614
|
+
redoStack.push(snapshot())
|
|
615
|
+
restore(undoStack.pop())
|
|
616
|
+
paintHistoryButtons()
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function redo() {
|
|
620
|
+
if (!redoStack.length) return
|
|
621
|
+
undoStack.push(snapshot())
|
|
622
|
+
restore(redoStack.pop())
|
|
623
|
+
paintHistoryButtons()
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function paintHistoryButtons() {
|
|
627
|
+
$('#undo').disabled = !undoStack.length
|
|
628
|
+
$('#redo').disabled = !redoStack.length
|
|
629
|
+
}
|
|
630
|
+
paintHistoryButtons()
|
|
631
|
+
|
|
632
|
+
// ---------- 保存与切换 ----------
|
|
633
|
+
const scheduleSave = debounce(() => { void saveNow() }, 700)
|
|
634
|
+
|
|
635
|
+
async function saveNow() {
|
|
636
|
+
if (!graph) return
|
|
637
|
+
paintStatus()
|
|
638
|
+
try {
|
|
639
|
+
const payload = {
|
|
640
|
+
id: graph.id,
|
|
641
|
+
name: graph.name,
|
|
642
|
+
nodes: graph.nodes.map(node => ({ id: node.id, type: node.type, x: node.x, y: node.y, config: node.config })),
|
|
643
|
+
edges: graph.edges.map(edge => ({ id: edge.id, from: edge.from, to: edge.to })),
|
|
644
|
+
}
|
|
645
|
+
const data = await api('graph/save', { graph: payload })
|
|
646
|
+
graph.updatedAt = data.graph.updatedAt
|
|
647
|
+
const index = app.state.graphs.findIndex(row => row.id === graph.id)
|
|
648
|
+
if (index >= 0) app.state.graphs[index] = { ...app.state.graphs[index], ...data.graph, nodes: app.state.graphs[index].nodes }
|
|
649
|
+
dirty = false
|
|
650
|
+
paintStatus()
|
|
651
|
+
} catch (err) {
|
|
652
|
+
app.toast(err.message, 'error')
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function switchGraph(id, { force = false } = {}) {
|
|
657
|
+
if (!force && id === graph?.id) return
|
|
658
|
+
if (dirty) await saveNow()
|
|
659
|
+
const next = graphs().find(row => row.id === id)
|
|
660
|
+
if (!next) return
|
|
661
|
+
graph = clone(next)
|
|
662
|
+
selected = null
|
|
663
|
+
selectedEdge = null
|
|
664
|
+
undoStack = []
|
|
665
|
+
redoStack = []
|
|
666
|
+
paintHistoryButtons()
|
|
667
|
+
sessionStorage.setItem('media-workbench-modern:graph', graph.id)
|
|
668
|
+
fitView(false)
|
|
669
|
+
renderNodes()
|
|
670
|
+
updateRunTimer()
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
$('#graph-menu').addEventListener('click', event => {
|
|
674
|
+
const item = event.target.closest('[data-graph]')
|
|
675
|
+
if (item) {
|
|
676
|
+
$('#graph-switch').removeAttribute('open')
|
|
677
|
+
switchGraph(item.dataset.graph)
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
if (event.target.closest('[data-new-graph]')) {
|
|
681
|
+
$('#graph-switch').removeAttribute('open')
|
|
682
|
+
createGraph({})
|
|
683
|
+
}
|
|
684
|
+
})
|
|
685
|
+
$('#graph-name').addEventListener('input', () => {
|
|
686
|
+
if (!graph) return
|
|
687
|
+
graph.name = $('#graph-name').value.slice(0, 40) || '未命名星图'
|
|
688
|
+
dirty = true
|
|
689
|
+
paintStatus()
|
|
690
|
+
scheduleSave()
|
|
691
|
+
paintGraphSelect()
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
$('#palette').addEventListener('click', event => {
|
|
695
|
+
const btn = event.target.closest('[data-add]')
|
|
696
|
+
if (!btn) return
|
|
697
|
+
const rect = canvas.getBoundingClientRect()
|
|
698
|
+
const point = screenToWorld(rect.left + rect.width / 2, rect.top + rect.height / 2)
|
|
699
|
+
addNode(btn.dataset.add, point.x - W / 2 + (Math.random() * 60 - 30), point.y - 80 + (Math.random() * 60 - 30))
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
$('#templates').addEventListener('click', async event => {
|
|
703
|
+
const btn = event.target.closest('[data-template]')
|
|
704
|
+
if (!btn) return
|
|
705
|
+
await createGraph({ template: btn.dataset.template })
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
$('#more-menu').addEventListener('click', async event => {
|
|
709
|
+
const blueprint = event.target.closest('[data-blueprint]')
|
|
710
|
+
if (blueprint) {
|
|
711
|
+
$('#more-menu').removeAttribute('open')
|
|
712
|
+
try {
|
|
713
|
+
const data = await api('blueprint/use', { id: blueprint.dataset.blueprint })
|
|
714
|
+
await app.refresh()
|
|
715
|
+
await switchGraph(data.graph.id, { force: true })
|
|
716
|
+
app.toast(t('蓝图已展开成新的星图', 'Blueprint opened as a new graph'), 'success')
|
|
717
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
const btn = event.target.closest('[data-menu]')
|
|
721
|
+
if (!btn) return
|
|
722
|
+
$('#more-menu').removeAttribute('open')
|
|
723
|
+
const action = btn.dataset.menu
|
|
724
|
+
if (action === 'new') await createGraph({})
|
|
725
|
+
if (action === 'export') {
|
|
726
|
+
if (!graph) return
|
|
727
|
+
downloadJson({ schemaVersion: 1, name: graph.name, nodes: graph.nodes, edges: graph.edges }, `${graph.name || 'modern-media'}-star-map.json`)
|
|
728
|
+
}
|
|
729
|
+
if (action === 'import') $('#import-file').click()
|
|
730
|
+
if (action === 'save-blueprint') {
|
|
731
|
+
if (!graph) return
|
|
732
|
+
try {
|
|
733
|
+
await saveNow()
|
|
734
|
+
await api('blueprint/save', { graph: { name: graph.name, nodes: graph.nodes, edges: graph.edges } })
|
|
735
|
+
await app.refresh()
|
|
736
|
+
app.toast(t('已保存为蓝图,下次可以整张复用', 'Saved as a blueprint'), 'success')
|
|
737
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
738
|
+
}
|
|
739
|
+
if (action === 'delete') {
|
|
740
|
+
if (!graph) return
|
|
741
|
+
const ok = await app.confirm({ title: t('删除这张星图?', 'Delete this star map?'), message: t('画布和运行结果都会被移除。', 'The graph and its run results will be removed.'), confirmText: t('删除', 'Delete'), danger: true })
|
|
742
|
+
if (!ok) return
|
|
743
|
+
try {
|
|
744
|
+
await api('graph/action', { id: graph.id, action: 'delete' })
|
|
745
|
+
await app.refresh()
|
|
746
|
+
const next = graphs()[0] ?? null
|
|
747
|
+
if (next) { graph = clone(next); renderNodes(); fitView(false) } else { graph = null; renderNodes() }
|
|
748
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
$('#import-file').addEventListener('change', async () => {
|
|
753
|
+
const file = $('#import-file').files?.[0]
|
|
754
|
+
if (!file) return
|
|
755
|
+
try {
|
|
756
|
+
const text = await file.text()
|
|
757
|
+
const payload = JSON.parse(text)
|
|
758
|
+
const data = await api('graph/import', { payload })
|
|
759
|
+
await app.refresh()
|
|
760
|
+
await switchGraph(data.graph.id, { force: true })
|
|
761
|
+
app.toast(t('星图已导入', 'Graph imported'), 'success')
|
|
762
|
+
} catch (err) { app.toast(t('导入失败:', 'Import failed: ') + err.message, 'error') }
|
|
763
|
+
$('#import-file').value = ''
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
async function createGraph(options) {
|
|
767
|
+
try {
|
|
768
|
+
if (dirty) await saveNow()
|
|
769
|
+
const data = await api('graph/create', options)
|
|
770
|
+
await app.refresh()
|
|
771
|
+
await switchGraph(data.graph.id, { force: true })
|
|
772
|
+
app.toast(t('星图已创建', 'Graph created'), 'success')
|
|
773
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ---------- 运行 ----------
|
|
777
|
+
let runTimer = null
|
|
778
|
+
|
|
779
|
+
async function run(targets = []) {
|
|
780
|
+
if (!graph) return
|
|
781
|
+
if (!graph.nodes.length) {
|
|
782
|
+
app.toast(t('先加几个节点', 'Add some nodes first'), 'error')
|
|
783
|
+
return
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
await api('graph/run', { id: graph.id, targetIds: targets })
|
|
787
|
+
graph.run = { status: 'running', wave: 0, waves: 1, targets }
|
|
788
|
+
for (const node of graph.nodes) if (!targets.length || targets.includes(node.id)) { node.status = 'idle'; node.output = null; node.error = '' }
|
|
789
|
+
selected = null
|
|
790
|
+
renderNodes()
|
|
791
|
+
paintStatus()
|
|
792
|
+
app.toast(targets.length ? t('正在运行到选中的节点', 'Running to the selected node') : t('星图开始运行', 'Star map started'), 'success')
|
|
793
|
+
updateRunTimer()
|
|
794
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function updateRunTimer() {
|
|
798
|
+
if (runTimer) { clearInterval(runTimer); runTimer = null }
|
|
799
|
+
if (graph?.run?.status !== 'running') return
|
|
800
|
+
runTimer = setInterval(() => {
|
|
801
|
+
if (!mounted) return
|
|
802
|
+
if (document.hidden) return
|
|
803
|
+
app.refresh().catch(() => {})
|
|
804
|
+
}, 2000)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
$('#run').addEventListener('click', () => run())
|
|
808
|
+
$('#stop').addEventListener('click', async () => {
|
|
809
|
+
try { await api('graph/stop', { id: graph.id }); app.toast(t('已停止', 'Stopped'), 'success') } catch (err) { app.toast(err.message, 'error') }
|
|
810
|
+
})
|
|
811
|
+
$('#undo').addEventListener('click', undo)
|
|
812
|
+
$('#redo').addEventListener('click', redo)
|
|
813
|
+
$('#fit').addEventListener('click', () => fitView(true))
|
|
814
|
+
$('#toggle-inspector').addEventListener('click', () => {
|
|
815
|
+
document.querySelector('.bench-graph')?.classList.toggle('is-inspector-open')
|
|
816
|
+
})
|
|
817
|
+
$('#zoom-in').addEventListener('click', () => zoomBy(1.2))
|
|
818
|
+
$('#zoom-out').addEventListener('click', () => zoomBy(1 / 1.2))
|
|
819
|
+
|
|
820
|
+
function zoomBy(factor) {
|
|
821
|
+
const rect = canvas.getBoundingClientRect()
|
|
822
|
+
zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, factor)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function zoomAt(clientX, clientY, factor) {
|
|
826
|
+
const next = clamp(transform.k * factor, 0.35, 2)
|
|
827
|
+
const rect = canvas.getBoundingClientRect()
|
|
828
|
+
const px = clientX - rect.left
|
|
829
|
+
const py = clientY - rect.top
|
|
830
|
+
const wx = (px - transform.x) / transform.k
|
|
831
|
+
const wy = (py - transform.y) / transform.k
|
|
832
|
+
transform = { x: px - wx * next, y: py - wy * next, k: next }
|
|
833
|
+
applyTransform()
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function fitView(animate = true) {
|
|
837
|
+
if (!graph || !graph.nodes.length) {
|
|
838
|
+
transform = { x: 60, y: 40, k: 1 }
|
|
839
|
+
applyTransform()
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
const minX = Math.min(...graph.nodes.map(n => n.x)) - 28
|
|
843
|
+
const minY = Math.min(...graph.nodes.map(n => n.y)) - 64
|
|
844
|
+
const maxX = Math.max(...graph.nodes.map(n => n.x + W)) + 28
|
|
845
|
+
const maxY = Math.max(...graph.nodes.map(n => n.y + 230)) + 36
|
|
846
|
+
const bw = maxX - minX
|
|
847
|
+
const bh = maxY - minY
|
|
848
|
+
const { width, height } = bound()
|
|
849
|
+
let k = Math.min((width - 44) / bw, (height - 80) / bh)
|
|
850
|
+
k = clamp(k, 0.42, 1.1)
|
|
851
|
+
// 小图别缩得太小;但必须先保证放得下,宁可小一点也不要裁掉两端
|
|
852
|
+
if (graph.nodes.length <= 4) {
|
|
853
|
+
const ceiling = Math.min((width - 24) / bw, (height - 40) / bh, 0.66)
|
|
854
|
+
k = Math.max(k, ceiling)
|
|
855
|
+
}
|
|
856
|
+
let x = (width - bw * k) / 2 - minX * k
|
|
857
|
+
const leftLimit = 8 - minX * k
|
|
858
|
+
const rightLimit = width - 8 - (minX + bw) * k
|
|
859
|
+
if (rightLimit >= leftLimit) x = Math.max(leftLimit, Math.min(x, rightLimit))
|
|
860
|
+
transform = { x, y: (height - bh * k) / 2 - minY * k, k }
|
|
861
|
+
if (animate) world.classList.add('is-animating')
|
|
862
|
+
applyTransform()
|
|
863
|
+
if (animate) setTimeout(() => world.classList.remove('is-animating'), 420)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ---------- 画布交互 ----------
|
|
867
|
+
canvas.addEventListener('wheel', event => {
|
|
868
|
+
if (event.target.closest('.graph-inspector')) return
|
|
869
|
+
event.preventDefault()
|
|
870
|
+
const factor = Math.exp(-event.deltaY * 0.0012)
|
|
871
|
+
zoomAt(event.clientX, event.clientY, factor)
|
|
872
|
+
}, { passive: false })
|
|
873
|
+
|
|
874
|
+
canvas.addEventListener('dblclick', event => {
|
|
875
|
+
if (event.target.closest('.gnode')) return
|
|
876
|
+
const point = screenToWorld(event.clientX, event.clientY)
|
|
877
|
+
addNode('prompt', point.x - W / 2, point.y - 60)
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
canvas.addEventListener('pointerdown', event => {
|
|
881
|
+
const port = event.target.closest('.port-out')
|
|
882
|
+
if (port) {
|
|
883
|
+
event.preventDefault()
|
|
884
|
+
event.stopPropagation()
|
|
885
|
+
selected = port.dataset.node
|
|
886
|
+
selectedEdge = null
|
|
887
|
+
renderInspector()
|
|
888
|
+
connect = { from: { node: port.dataset.node, port: port.dataset.port }, temp: '', pointer: { x: event.clientX, y: event.clientY } }
|
|
889
|
+
world.classList.add('is-connecting')
|
|
890
|
+
canvas.setPointerCapture?.(event.pointerId)
|
|
891
|
+
return
|
|
892
|
+
}
|
|
893
|
+
const head = event.target.closest('.gnode-head')
|
|
894
|
+
if (head) {
|
|
895
|
+
const nodeEl = head.closest('.gnode')
|
|
896
|
+
const node = graph?.nodes.find(item => item.id === nodeEl?.dataset.nodeId)
|
|
897
|
+
if (!node) return
|
|
898
|
+
event.preventDefault()
|
|
899
|
+
selected = node.id
|
|
900
|
+
selectedEdge = null
|
|
901
|
+
nodesEl.querySelectorAll('.gnode').forEach(el => el.classList.toggle('is-selected', el.dataset.nodeId === node.id))
|
|
902
|
+
if (window.innerWidth <= 1200) document.querySelector('.bench-graph')?.classList.add('is-inspector-open')
|
|
903
|
+
renderInspector()
|
|
904
|
+
pushHistory()
|
|
905
|
+
const point = screenToWorld(event.clientX, event.clientY)
|
|
906
|
+
drag = { id: node.id, dx: point.x - node.x, dy: point.y - node.y, moved: false }
|
|
907
|
+
world.classList.add('is-dragging')
|
|
908
|
+
canvas.setPointerCapture?.(event.pointerId)
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
const nodeEl = event.target.closest('.gnode')
|
|
912
|
+
if (nodeEl) {
|
|
913
|
+
selected = nodeEl.dataset.nodeId
|
|
914
|
+
selectedEdge = null
|
|
915
|
+
if (window.innerWidth <= 1200) document.querySelector('.bench-graph')?.classList.add('is-inspector-open')
|
|
916
|
+
renderNodes()
|
|
917
|
+
return
|
|
918
|
+
}
|
|
919
|
+
const edge = event.target.closest('.edge')
|
|
920
|
+
if (edge) {
|
|
921
|
+
selectedEdge = edge.dataset.edgeId
|
|
922
|
+
selected = null
|
|
923
|
+
renderNodes()
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
// 空白:平移;顺带取消选择
|
|
927
|
+
if (event.button === 0) {
|
|
928
|
+
selected = null
|
|
929
|
+
selectedEdge = null
|
|
930
|
+
renderNodes()
|
|
931
|
+
}
|
|
932
|
+
pan = { x: event.clientX, y: event.clientY, tx: transform.x, ty: transform.y, moved: false }
|
|
933
|
+
world.classList.add('is-panning')
|
|
934
|
+
canvas.setPointerCapture?.(event.pointerId)
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
function onPointerMove(event) {
|
|
938
|
+
if (connect) {
|
|
939
|
+
const point = screenToWorld(event.clientX, event.clientY)
|
|
940
|
+
const from = graph.nodes.find(node => node.id === connect.from.node)
|
|
941
|
+
if (!from) return
|
|
942
|
+
const x1 = from.x + W
|
|
943
|
+
const y1 = nodePortY(from, connect.from.port, 'out')
|
|
944
|
+
const x2 = point.x
|
|
945
|
+
const y2 = point.y
|
|
946
|
+
const dx = Math.max(36, Math.abs(x2 - x1) * 0.45)
|
|
947
|
+
connect.temp = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
|
|
948
|
+
renderEdges()
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
if (drag) {
|
|
952
|
+
const point = screenToWorld(event.clientX, event.clientY)
|
|
953
|
+
const node = graph.nodes.find(item => item.id === drag.id)
|
|
954
|
+
if (!node) return
|
|
955
|
+
node.x = Math.max(-200, Math.round(point.x - drag.dx))
|
|
956
|
+
node.y = Math.max(-200, Math.round(point.y - drag.dy))
|
|
957
|
+
const el = nodesEl.querySelector(`[data-node-id="${node.id}"]`)
|
|
958
|
+
if (el) { el.style.left = `${node.x}px`; el.style.top = `${node.y}px` }
|
|
959
|
+
drag.moved = true
|
|
960
|
+
renderEdges()
|
|
961
|
+
return
|
|
962
|
+
}
|
|
963
|
+
if (pan) {
|
|
964
|
+
transform.x = pan.tx + (event.clientX - pan.x)
|
|
965
|
+
transform.y = pan.ty + (event.clientY - pan.y)
|
|
966
|
+
pan.moved = true
|
|
967
|
+
applyTransform()
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function onPointerUp(event) {
|
|
972
|
+
if (connect) {
|
|
973
|
+
const target = document.elementFromPoint(event.clientX, event.clientY)?.closest('.port-in')
|
|
974
|
+
const from = connect.from
|
|
975
|
+
connect = null
|
|
976
|
+
world.classList.remove('is-connecting')
|
|
977
|
+
if (target && target.dataset.node) {
|
|
978
|
+
connectPorts(from, { node: target.dataset.node, port: target.dataset.port })
|
|
979
|
+
} else {
|
|
980
|
+
renderEdges()
|
|
981
|
+
}
|
|
982
|
+
return
|
|
983
|
+
}
|
|
984
|
+
if (drag) {
|
|
985
|
+
const moved = drag.moved
|
|
986
|
+
drag = null
|
|
987
|
+
world.classList.remove('is-dragging')
|
|
988
|
+
if (moved) {
|
|
989
|
+
dirty = true
|
|
990
|
+
scheduleSave()
|
|
991
|
+
paintStatus()
|
|
992
|
+
}
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
if (pan) {
|
|
996
|
+
const moved = pan.moved
|
|
997
|
+
pan = null
|
|
998
|
+
world.classList.remove('is-panning')
|
|
999
|
+
if (moved) {
|
|
1000
|
+
// 平移不写回文档
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
1006
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
1007
|
+
window.addEventListener('pointercancel', onPointerUp)
|
|
1008
|
+
|
|
1009
|
+
canvas.addEventListener('click', event => {
|
|
1010
|
+
const openText = event.target.closest('[data-open-text]')
|
|
1011
|
+
if (openText) {
|
|
1012
|
+
const node = graph.nodes.find(item => item.id === openText.dataset.openText)
|
|
1013
|
+
const text = node?.output?.text ?? ''
|
|
1014
|
+
const layer = document.createElement('div')
|
|
1015
|
+
layer.className = 'text-layer'
|
|
1016
|
+
layer.innerHTML = `<div class="text-layer-head"><span class="eyebrow">${esc(t('节点全文', 'NODE TEXT'))}</span><button class="icon-btn" data-close>${icon('x', 15)}</button></div><pre>${esc(text)}</pre>`
|
|
1017
|
+
const close = app.showLayer(layer, { className: 'layer-text' })
|
|
1018
|
+
layer.querySelector('[data-close]').addEventListener('click', close)
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
const nodeEl = event.target.closest('.gnode')
|
|
1022
|
+
if (nodeEl && selected !== nodeEl.dataset.nodeId) {
|
|
1023
|
+
selected = nodeEl.dataset.nodeId
|
|
1024
|
+
selectedEdge = null
|
|
1025
|
+
if (window.innerWidth <= 1200) document.querySelector('.bench-graph')?.classList.add('is-inspector-open')
|
|
1026
|
+
renderNodes()
|
|
1027
|
+
}
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
function onKey(event) {
|
|
1031
|
+
const tag = event.target.tagName
|
|
1032
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
|
1033
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
1034
|
+
if (selected) { event.preventDefault(); deleteNode(selected) }
|
|
1035
|
+
else if (selectedEdge) { event.preventDefault(); deleteEdge(selectedEdge) }
|
|
1036
|
+
}
|
|
1037
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') {
|
|
1038
|
+
event.preventDefault()
|
|
1039
|
+
event.shiftKey ? redo() : undo()
|
|
1040
|
+
}
|
|
1041
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); run() }
|
|
1042
|
+
if (event.key.toLowerCase() === 'f' && !event.metaKey && !event.ctrlKey) { event.preventDefault(); fitView(true) }
|
|
1043
|
+
if (event.key === 'Escape') {
|
|
1044
|
+
if (connect) { connect = null; world.classList.remove('is-connecting'); renderEdges() }
|
|
1045
|
+
else { selected = null; selectedEdge = null; renderNodes() }
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
window.addEventListener('keydown', onKey)
|
|
1049
|
+
|
|
1050
|
+
// ---------- 与全局状态同步 ----------
|
|
1051
|
+
function update() {
|
|
1052
|
+
if (!mounted) return
|
|
1053
|
+
const server = graphs().find(row => row.id === graph?.id)
|
|
1054
|
+
if (!server) return
|
|
1055
|
+
// 运行态同步:状态、产出、进度
|
|
1056
|
+
const runChanged = JSON.stringify(server.run ?? null) !== JSON.stringify(graph.run ?? null)
|
|
1057
|
+
let nodesChanged = false
|
|
1058
|
+
for (const node of graph.nodes) {
|
|
1059
|
+
const remote = server.nodes.find(item => item.id === node.id)
|
|
1060
|
+
if (!remote) continue
|
|
1061
|
+
if (node.status !== remote.status || (remote.output && JSON.stringify(node.output) !== JSON.stringify(remote.output)) || node.error !== remote.error) {
|
|
1062
|
+
node.status = remote.status
|
|
1063
|
+
node.output = remote.output ?? null
|
|
1064
|
+
node.error = remote.error ?? ''
|
|
1065
|
+
nodesChanged = true
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (runChanged) { graph.run = server.run ?? null; paintStatus(); updateRunTimer() }
|
|
1069
|
+
const editingInspector = Boolean(document.activeElement?.closest?.('#inspector'))
|
|
1070
|
+
if (nodesChanged && !drag && !editingInspector) {
|
|
1071
|
+
const scroller = nodesEl.scrollTop
|
|
1072
|
+
renderNodes()
|
|
1073
|
+
nodesEl.scrollTop = scroller
|
|
1074
|
+
} else if (runChanged && !editingInspector) {
|
|
1075
|
+
renderNodes()
|
|
1076
|
+
}
|
|
1077
|
+
if (!dirty) paintGraphSelect()
|
|
1078
|
+
if (server.updatedAt && !dirty && server.updatedAt !== graph.updatedAt) {
|
|
1079
|
+
// 其他窗口改过这张图:只在当前没有本地修改时接管
|
|
1080
|
+
if (JSON.stringify(server.nodes.map(n => n.id)) === JSON.stringify(graph.nodes.map(n => n.id))) {
|
|
1081
|
+
graph.updatedAt = server.updatedAt
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ---------- 初始化 ----------
|
|
1087
|
+
if (!graph) {
|
|
1088
|
+
$('#canvas-empty').hidden = false
|
|
1089
|
+
$('#inspector').innerHTML = `<div class="inspect-empty"><p>${esc(t('还没有星图', 'No star map yet'))}</p><button class="btn btn-small btn-primary" id="first-graph">${esc(t('新建一张', 'Create one'))}</button></div>`
|
|
1090
|
+
$('#first-graph')?.addEventListener('click', () => createGraph({}))
|
|
1091
|
+
} else {
|
|
1092
|
+
fitView(false)
|
|
1093
|
+
renderNodes()
|
|
1094
|
+
updateRunTimer()
|
|
1095
|
+
}
|
|
1096
|
+
applyTransform()
|
|
1097
|
+
|
|
1098
|
+
return {
|
|
1099
|
+
update,
|
|
1100
|
+
unmount() {
|
|
1101
|
+
mounted = false
|
|
1102
|
+
if (runTimer) clearInterval(runTimer)
|
|
1103
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
1104
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
1105
|
+
window.removeEventListener('pointercancel', onPointerUp)
|
|
1106
|
+
window.removeEventListener('keydown', onKey)
|
|
1107
|
+
if (dirty) saveNow().catch(() => {})
|
|
1108
|
+
},
|
|
1109
|
+
}
|
|
1110
|
+
}
|