kingkont 0.7.39 → 0.7.41
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/assets/PROJECT_CLAUDE.md +31 -132
- package/bin/kingkont.js +59 -49
- package/index.html +9 -10137
- package/lib/cli.js +504 -0
- package/lib/projectFs.js +391 -0
- package/lib/providers.js +689 -0
- package/lib/settings.js +70 -0
- package/main.js +28 -0
- package/package.json +6 -1
- package/preload.js +7 -0
- package/renderer/board.js +1522 -0
- package/renderer/generate.js +1727 -0
- package/renderer/media.js +1413 -0
- package/renderer/settings.js +1128 -0
- package/renderer/state.js +566 -0
- package/renderer/styles.css +1018 -0
- package/renderer/timeline.js +2836 -0
- package/server.js +103 -787
- package/settings.html +56 -0
- package/skill/SKILL.md +160 -78
|
@@ -0,0 +1,1128 @@
|
|
|
1
|
+
// renderer/settings.js — Settings modal (двойной клик на ноду): preview, prompt edit, history, regenerate
|
|
2
|
+
//
|
|
3
|
+
// Этот модуль был выделен из index.html (раньше всё было в одном <script>
|
|
4
|
+
// блоке на 9123 строки). Все модули загружаются как plain <script>
|
|
5
|
+
// в одном глобальном scope — поэтому функции/переменные между файлами
|
|
6
|
+
// видят друг друга по именам, без import/export. Порядок загрузки
|
|
7
|
+
// важен: см. <script> теги внизу index.html.
|
|
8
|
+
|
|
9
|
+
// =================== Settings modal ===================
|
|
10
|
+
let settingsTargetNode = null;
|
|
11
|
+
function showNodeSettings(node) {
|
|
12
|
+
if (!node.generated) return;
|
|
13
|
+
settingsTargetNode = node;
|
|
14
|
+
const g = node.generated;
|
|
15
|
+
const body = $('settingsBody');
|
|
16
|
+
const rows = [];
|
|
17
|
+
const row = (label, val, muted = false) =>
|
|
18
|
+
`<div class="settings-row"><div class="lbl">${label}</div><div class="val${muted ? ' muted' : ''}">${val}</div></div>`;
|
|
19
|
+
rows.push(row('Тип', g.kind === 'image' ? 'Картинка' : 'Видео'));
|
|
20
|
+
rows.push(row('Модель', g.model || '—'));
|
|
21
|
+
rows.push(row('Промпт (как ввёл)', escapeHtml(g.rawPrompt || '—'), !g.rawPrompt));
|
|
22
|
+
if (g.prompt && g.prompt !== g.rawPrompt) {
|
|
23
|
+
rows.push(row('Промпт после @-резолва', escapeHtml(g.prompt)));
|
|
24
|
+
}
|
|
25
|
+
if (g.refs?.length) {
|
|
26
|
+
const chips = g.refs.map(r =>
|
|
27
|
+
`<span class="chip">@${escapeHtml(r.name)} <span style="color:#888">(${r.type})</span></span>`
|
|
28
|
+
).join('');
|
|
29
|
+
rows.push(`<div class="settings-row"><div class="lbl">Референсы</div><div class="chips">${chips}</div></div>`);
|
|
30
|
+
}
|
|
31
|
+
if (g.taskId) rows.push(row('Task ID', g.taskId));
|
|
32
|
+
if (g.state) rows.push(row('Состояние', g.state));
|
|
33
|
+
if (node.status === 'error') rows.push(row('Ошибка', escapeHtml(node.error || '—')));
|
|
34
|
+
body.innerHTML = rows.join('');
|
|
35
|
+
$('settingsModal').classList.remove('hidden');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function escapeHtml(s) {
|
|
39
|
+
return String(s).replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
$('settingsClose').addEventListener('click', () => {
|
|
43
|
+
$('settingsModal').classList.add('hidden');
|
|
44
|
+
settingsTargetNode = null;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
$('settingsRegen').addEventListener('click', () => {
|
|
48
|
+
const node = settingsTargetNode;
|
|
49
|
+
$('settingsModal').classList.add('hidden');
|
|
50
|
+
settingsTargetNode = null;
|
|
51
|
+
if (!node?.generated) return;
|
|
52
|
+
regenerateNode(node); // там редактируются ВСЕ поля (промпт, модель, голос)
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function renderNodeBody(node, body) {
|
|
56
|
+
body.innerHTML = '';
|
|
57
|
+
if (node.status === 'generating') {
|
|
58
|
+
const wrap = document.createElement('div');
|
|
59
|
+
wrap.className = 'gen-pending';
|
|
60
|
+
const sp = document.createElement('div'); sp.className = 'spinner lg';
|
|
61
|
+
const STATE_LABELS = {
|
|
62
|
+
'uploading-refs': 'Загружаю референсы в KIE...',
|
|
63
|
+
'submitting': 'Отправляю задачу...',
|
|
64
|
+
'queued': 'В очереди...',
|
|
65
|
+
'waiting': 'В очереди...',
|
|
66
|
+
'queuing': 'В очереди...',
|
|
67
|
+
'generating': 'Модель работает...',
|
|
68
|
+
};
|
|
69
|
+
const st = document.createElement('div'); st.className = 'state-text';
|
|
70
|
+
const sKey = node.generated?.state;
|
|
71
|
+
st.textContent = STATE_LABELS[sKey] || (sKey ? `Статус: ${sKey}` : 'Генерируется...');
|
|
72
|
+
const pp = document.createElement('div'); pp.className = 'prompt-preview';
|
|
73
|
+
pp.textContent = node.generated?.prompt || '';
|
|
74
|
+
wrap.append(sp, st, pp);
|
|
75
|
+
|
|
76
|
+
const btnRow = document.createElement('div');
|
|
77
|
+
btnRow.style.cssText = 'display:flex; gap:6px; margin-top:8px;';
|
|
78
|
+
const stopBtn = document.createElement('button');
|
|
79
|
+
stopBtn.textContent = '⏹ Остановить';
|
|
80
|
+
stopBtn.style.cssText = 'flex:1; font-size:11px; padding:4px;';
|
|
81
|
+
stopBtn.addEventListener('mousedown', e => e.stopPropagation());
|
|
82
|
+
stopBtn.addEventListener('click', e => { e.stopPropagation(); stopJob(node.id); });
|
|
83
|
+
const restartBtn = document.createElement('button');
|
|
84
|
+
restartBtn.textContent = '↻ Перезапустить';
|
|
85
|
+
restartBtn.style.cssText = 'flex:1; font-size:11px; padding:4px;';
|
|
86
|
+
restartBtn.addEventListener('mousedown', e => e.stopPropagation());
|
|
87
|
+
restartBtn.addEventListener('click', e => { e.stopPropagation(); restartJob(node.id); });
|
|
88
|
+
btnRow.appendChild(stopBtn);
|
|
89
|
+
btnRow.appendChild(restartBtn);
|
|
90
|
+
wrap.appendChild(btnRow);
|
|
91
|
+
|
|
92
|
+
body.appendChild(wrap);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (node.status === 'draft') {
|
|
96
|
+
const wrap = document.createElement('div');
|
|
97
|
+
wrap.className = 'gen-pending';
|
|
98
|
+
const ic = document.createElement('div');
|
|
99
|
+
ic.style.cssText = 'font-size:36px; opacity:0.7;';
|
|
100
|
+
ic.textContent = node.type === 'audio' ? '🎙'
|
|
101
|
+
: node.type === 'video' ? '🎬'
|
|
102
|
+
: node.type === 'image' ? '🖼' : '📝';
|
|
103
|
+
const st = document.createElement('div');
|
|
104
|
+
st.className = 'state-text';
|
|
105
|
+
st.textContent = 'Черновик: подведи входы и запусти';
|
|
106
|
+
const pp = document.createElement('div');
|
|
107
|
+
pp.className = 'prompt-preview';
|
|
108
|
+
pp.textContent = node.generated?.rawPrompt || node.generated?.prompt || '';
|
|
109
|
+
wrap.append(ic, st, pp);
|
|
110
|
+
const runBtn = document.createElement('button');
|
|
111
|
+
runBtn.textContent = '▶ Запустить генерацию';
|
|
112
|
+
runBtn.className = 'primary';
|
|
113
|
+
runBtn.style.cssText = 'margin-top:8px; font-size:12px; padding:6px 10px;';
|
|
114
|
+
runBtn.addEventListener('mousedown', e => e.stopPropagation());
|
|
115
|
+
runBtn.addEventListener('click', e => { e.stopPropagation(); regenerateNode(node); });
|
|
116
|
+
wrap.appendChild(runBtn);
|
|
117
|
+
body.appendChild(wrap);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (node.status === 'error') {
|
|
121
|
+
const wrap = document.createElement('div');
|
|
122
|
+
wrap.className = 'gen-error';
|
|
123
|
+
const errStr = String(node.error || '');
|
|
124
|
+
const errBlock = document.createElement('div');
|
|
125
|
+
// Делаем text selectable — для копирования при необходимости.
|
|
126
|
+
errBlock.style.cssText = 'user-select: text; -webkit-user-select: text; cursor: text; word-break: break-word; margin-bottom: 8px;';
|
|
127
|
+
errBlock.textContent = errStr || 'Ошибка генерации';
|
|
128
|
+
wrap.appendChild(errBlock);
|
|
129
|
+
// Кнопка «Скопировать» — для длинных server-ошибок.
|
|
130
|
+
if (errStr) {
|
|
131
|
+
const copyBtn = document.createElement('button');
|
|
132
|
+
copyBtn.textContent = '📋 Скопировать';
|
|
133
|
+
copyBtn.style.marginRight = '6px';
|
|
134
|
+
copyBtn.addEventListener('click', async e => {
|
|
135
|
+
e.stopPropagation();
|
|
136
|
+
try {
|
|
137
|
+
await navigator.clipboard.writeText(errStr);
|
|
138
|
+
const orig = copyBtn.textContent;
|
|
139
|
+
copyBtn.textContent = '✓ Скопировано';
|
|
140
|
+
setTimeout(() => { copyBtn.textContent = orig; }, 1500);
|
|
141
|
+
} catch {}
|
|
142
|
+
});
|
|
143
|
+
wrap.appendChild(copyBtn);
|
|
144
|
+
}
|
|
145
|
+
// Кнопка «Войти» — ТОЛЬКО когда сервер прислал 503 «Войдите в KingKont…»
|
|
146
|
+
// (т.е. реально auth-проблема, а не любая ошибка которая упоминает KingKont).
|
|
147
|
+
if (/Войдите в KingKont/i.test(errStr)) {
|
|
148
|
+
const loginBtn = document.createElement('button');
|
|
149
|
+
loginBtn.textContent = '🔑 Войти';
|
|
150
|
+
loginBtn.style.marginRight = '6px';
|
|
151
|
+
loginBtn.addEventListener('click', e => {
|
|
152
|
+
e.stopPropagation();
|
|
153
|
+
if (window.appSettings?.openSettingsWindow) {
|
|
154
|
+
window.appSettings.openSettingsWindow();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
wrap.appendChild(loginBtn);
|
|
158
|
+
}
|
|
159
|
+
if (node.generated?.prompt) {
|
|
160
|
+
const retry = document.createElement('button');
|
|
161
|
+
retry.textContent = 'Повторить';
|
|
162
|
+
retry.addEventListener('click', e => {
|
|
163
|
+
e.stopPropagation();
|
|
164
|
+
node.status = 'generating';
|
|
165
|
+
node.error = undefined;
|
|
166
|
+
scheduleSave();
|
|
167
|
+
renderNodeBody(node, body);
|
|
168
|
+
const kind = node.generated.kind;
|
|
169
|
+
const bH = state.currentBoard.handle;
|
|
170
|
+
const bK = state.currentBoard.key;
|
|
171
|
+
if (kind === 'audio') {
|
|
172
|
+
runTTSJob(node, node.generated.prompt, bH, bK, node.generated.voiceId);
|
|
173
|
+
} else if (kind === 'text') {
|
|
174
|
+
const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
|
|
175
|
+
const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
|
|
176
|
+
runTextJob(node, node.generated.prompt, model, bH, bK, imageRefs);
|
|
177
|
+
} else {
|
|
178
|
+
const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
|
|
179
|
+
startGenerationJob(node, kind, node.generated.prompt, refs, bH, bK, node.generated.modelKey);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
wrap.appendChild(retry);
|
|
183
|
+
}
|
|
184
|
+
body.appendChild(wrap);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (node.type === 'text') {
|
|
189
|
+
const ta = document.createElement('textarea');
|
|
190
|
+
ta.value = node.text || '';
|
|
191
|
+
ta.placeholder = 'Текст ноды...';
|
|
192
|
+
ta.addEventListener('input', () => { node.text = ta.value; scheduleSave(); });
|
|
193
|
+
ta.addEventListener('mousedown', e => e.stopPropagation());
|
|
194
|
+
body.appendChild(ta);
|
|
195
|
+
} else if (['video','audio','image'].includes(node.type) && node.file) {
|
|
196
|
+
const nodeEl = body.closest('.node');
|
|
197
|
+
nodeEl?.classList.toggle('image-node', node.type === 'image');
|
|
198
|
+
nodeEl?.classList.toggle('video-node', node.type === 'video');
|
|
199
|
+
// Re-render: снимаем старого hydrate-наблюдателя, иначе он может
|
|
200
|
+
// повторно стрельнуть на старом placeholder который уже удалён.
|
|
201
|
+
if (nodeEl) { _mediaHydrationObserver.unobserve(nodeEl); nodeEl.__hydrate = null; }
|
|
202
|
+
|
|
203
|
+
if (node.type === 'audio') {
|
|
204
|
+
const fname = document.createElement('div');
|
|
205
|
+
fname.className = 'filename';
|
|
206
|
+
fname.textContent = node.file;
|
|
207
|
+
body.appendChild(fname);
|
|
208
|
+
}
|
|
209
|
+
// Lazy-инстанциация media через IntersectionObserver.
|
|
210
|
+
// Раньше при selectBoard для 50 нод синхронно вычитывали 50 файлов с диска
|
|
211
|
+
// (resolveBoardFile + getFile + createObjectURL) и вставляли <img>/<video>/<audio>
|
|
212
|
+
// — браузер сразу декодировал. Теперь только видимые (+rootMargin для plавного
|
|
213
|
+
// pre-load) реально материализуются.
|
|
214
|
+
const placeholder = document.createElement('div');
|
|
215
|
+
placeholder.className = 'media-placeholder';
|
|
216
|
+
placeholder.textContent = node.type === 'image' ? '🖼' : node.type === 'video' ? '🎬' : '🎙';
|
|
217
|
+
body.appendChild(placeholder);
|
|
218
|
+
|
|
219
|
+
// Для image/video — пробуем показать thumbnail сразу (если он сохранён на диске).
|
|
220
|
+
// Это асинхронно, но дешёво (5KB jpg vs 2MB исходный) — placeholder ОЧЕНЬ быстро
|
|
221
|
+
// заменяется на нормальное превью, не дожидаясь IntersectionObserver-а.
|
|
222
|
+
if ((node.type === 'image' || node.type === 'video') && state.currentBoard) {
|
|
223
|
+
const bHandle = state.currentBoard.handle;
|
|
224
|
+
const bKey = state.currentBoard.key;
|
|
225
|
+
loadThumbnailURL(bHandle, bKey, node.file).then(url => {
|
|
226
|
+
if (!url) return;
|
|
227
|
+
if (!placeholder.parentNode) return; // уже заменили на full-media
|
|
228
|
+
const thumb = document.createElement('img');
|
|
229
|
+
thumb.src = url;
|
|
230
|
+
thumb.className = 'media-thumb';
|
|
231
|
+
thumb.decoding = 'async';
|
|
232
|
+
thumb.draggable = false;
|
|
233
|
+
thumb.addEventListener('mousedown', e => e.stopPropagation());
|
|
234
|
+
placeholder.replaceWith(thumb);
|
|
235
|
+
// Сохраняем ссылку в nodeEl, чтобы при hydrate заменить на full
|
|
236
|
+
if (nodeEl) nodeEl.__thumbEl = thumb;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const hydrate = async () => {
|
|
241
|
+
try {
|
|
242
|
+
let url = state.currentBoard.urls[node.file];
|
|
243
|
+
if (!url) {
|
|
244
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
|
|
245
|
+
url = URL.createObjectURL(await fh.getFile());
|
|
246
|
+
state.currentBoard.urls[node.file] = url;
|
|
247
|
+
}
|
|
248
|
+
let media;
|
|
249
|
+
if (node.type === 'image') {
|
|
250
|
+
media = document.createElement('img');
|
|
251
|
+
media.src = url; media.alt = node.file; media.draggable = false;
|
|
252
|
+
media.decoding = 'async';
|
|
253
|
+
} else {
|
|
254
|
+
media = document.createElement(node.type);
|
|
255
|
+
media.src = url;
|
|
256
|
+
if (node.type === 'video') media.controls = true;
|
|
257
|
+
// preload="none" — заголовки video/audio НЕ читаются пока юзер не запустит play.
|
|
258
|
+
// Раньше "metadata" грузил chunk для duration/dimensions с каждой ноды на selectBoard.
|
|
259
|
+
media.preload = 'none';
|
|
260
|
+
}
|
|
261
|
+
media.addEventListener('mousedown', e => e.stopPropagation());
|
|
262
|
+
// Заменяем placeholder ИЛИ ранее показанный thumbnail
|
|
263
|
+
const target = (nodeEl && nodeEl.__thumbEl && nodeEl.__thumbEl.parentNode)
|
|
264
|
+
? nodeEl.__thumbEl
|
|
265
|
+
: placeholder;
|
|
266
|
+
target.replaceWith(media);
|
|
267
|
+
if (nodeEl) nodeEl.__thumbEl = null;
|
|
268
|
+
// В фоне генерируем/обновляем thumbnail для следующего открытия проекта.
|
|
269
|
+
// Делаем lazy через rAF чтобы не задерживать первое отображение.
|
|
270
|
+
if (state.currentBoard && (node.type === 'image' || node.type === 'video')) {
|
|
271
|
+
const bH = state.currentBoard.handle, bK = state.currentBoard.key;
|
|
272
|
+
requestIdleCallback?.(() => generateThumbnailIfMissing(bH, bK, node, media))
|
|
273
|
+
?? setTimeout(() => generateThumbnailIfMissing(bH, bK, node, media), 1000);
|
|
274
|
+
}
|
|
275
|
+
return media;
|
|
276
|
+
} catch {
|
|
277
|
+
const m = document.createElement('div');
|
|
278
|
+
m.className = 'missing';
|
|
279
|
+
m.textContent = `Файл «${node.file}» не найден`;
|
|
280
|
+
const target = (nodeEl && nodeEl.__thumbEl && nodeEl.__thumbEl.parentNode) ? nodeEl.__thumbEl : placeholder;
|
|
281
|
+
target.replaceWith(m);
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
let mediaPromise = null;
|
|
287
|
+
const ensureMedia = () => mediaPromise || (mediaPromise = hydrate());
|
|
288
|
+
// Когда .node попадает в (расширенный) viewport — гидрируем фоном.
|
|
289
|
+
// Если юзер сразу нажал play — ensureMedia() вызовется через wire-handler.
|
|
290
|
+
if (nodeEl) {
|
|
291
|
+
_mediaHydrationObserver.observe(nodeEl);
|
|
292
|
+
nodeEl.__hydrate = ensureMedia;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (node.type === 'video') {
|
|
296
|
+
const speedRow = document.createElement('div');
|
|
297
|
+
speedRow.className = 'audio-speed';
|
|
298
|
+
const savedRate = parseFloat(localStorage.getItem('mediaPlaybackRate') || '1');
|
|
299
|
+
for (const s of [0.5, 0.75, 1, 1.25, 1.5, 2]) {
|
|
300
|
+
const btn = document.createElement('button');
|
|
301
|
+
btn.textContent = s + '×';
|
|
302
|
+
if (s === savedRate) btn.classList.add('active');
|
|
303
|
+
btn.addEventListener('mousedown', e => e.stopPropagation());
|
|
304
|
+
btn.addEventListener('click', async e => {
|
|
305
|
+
e.stopPropagation();
|
|
306
|
+
const media = await ensureMedia();
|
|
307
|
+
if (!media) return;
|
|
308
|
+
media.playbackRate = s;
|
|
309
|
+
localStorage.setItem('mediaPlaybackRate', String(s));
|
|
310
|
+
speedRow.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
|
311
|
+
btn.classList.add('active');
|
|
312
|
+
});
|
|
313
|
+
speedRow.appendChild(btn);
|
|
314
|
+
}
|
|
315
|
+
body.appendChild(speedRow);
|
|
316
|
+
}
|
|
317
|
+
if (node.type === 'audio') {
|
|
318
|
+
const playRow = document.createElement('div');
|
|
319
|
+
playRow.style.cssText = 'margin-top: 6px;';
|
|
320
|
+
const playBtn = document.createElement('button');
|
|
321
|
+
playBtn.style.cssText = 'width: 100%; font-size: 14px; padding: 6px;';
|
|
322
|
+
playBtn.textContent = '▶ Play';
|
|
323
|
+
playBtn.addEventListener('mousedown', e => e.stopPropagation());
|
|
324
|
+
playBtn.addEventListener('click', async e => {
|
|
325
|
+
e.stopPropagation();
|
|
326
|
+
const media = await ensureMedia();
|
|
327
|
+
if (!media) return;
|
|
328
|
+
const sync = () => { playBtn.textContent = media.paused ? '▶ Play' : '⏸ Pause'; };
|
|
329
|
+
// Wire-up sync только при первом play (lazy).
|
|
330
|
+
if (!media.__wired) {
|
|
331
|
+
media.__wired = true;
|
|
332
|
+
media.addEventListener('play', sync);
|
|
333
|
+
media.addEventListener('pause', sync);
|
|
334
|
+
media.addEventListener('ended', sync);
|
|
335
|
+
}
|
|
336
|
+
if (media.paused) media.play().catch(()=>{}); else media.pause();
|
|
337
|
+
sync();
|
|
338
|
+
});
|
|
339
|
+
playRow.appendChild(playBtn);
|
|
340
|
+
body.appendChild(playRow);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function attachAnchor(node, el, anchor) {
|
|
346
|
+
anchor.addEventListener('mousedown', e => {
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
e.stopPropagation();
|
|
349
|
+
anchor.classList.add('dragging');
|
|
350
|
+
const svg = $('connectionsSvg');
|
|
351
|
+
const tmp = document.createElementNS(SVG_NS, 'path');
|
|
352
|
+
tmp.setAttribute('class', 'temp-line');
|
|
353
|
+
svg.appendChild(tmp);
|
|
354
|
+
|
|
355
|
+
const start = nodeAnchorOut(node);
|
|
356
|
+
const update = (clientX, clientY) => {
|
|
357
|
+
const rect = canvas.getBoundingClientRect();
|
|
358
|
+
const x = (clientX - rect.left) / state.zoom;
|
|
359
|
+
const y = (clientY - rect.top) / state.zoom;
|
|
360
|
+
tmp.setAttribute('d', bezierPath(start.x, start.y, x, y));
|
|
361
|
+
};
|
|
362
|
+
update(e.clientX, e.clientY);
|
|
363
|
+
|
|
364
|
+
const onMove = ev => update(ev.clientX, ev.clientY);
|
|
365
|
+
const onUp = ev => {
|
|
366
|
+
document.removeEventListener('mousemove', onMove);
|
|
367
|
+
document.removeEventListener('mouseup', onUp);
|
|
368
|
+
tmp.remove();
|
|
369
|
+
anchor.classList.remove('dragging');
|
|
370
|
+
// Куда отпустили?
|
|
371
|
+
const target = document.elementFromPoint(ev.clientX, ev.clientY);
|
|
372
|
+
const targetEl = target?.closest('.node');
|
|
373
|
+
if (targetEl && targetEl.dataset.id !== node.id) {
|
|
374
|
+
addConnection(node.id, targetEl.dataset.id);
|
|
375
|
+
} else {
|
|
376
|
+
// Пустое место — показать меню «что генерировать» с привязкой к node.
|
|
377
|
+
showAnchorDropMenu(node, ev.clientX, ev.clientY);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
document.addEventListener('mousemove', onMove);
|
|
381
|
+
document.addEventListener('mouseup', onUp);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Показать addMenu рядом с курсором с привязкой к fromNode. Юзер выбирает
|
|
386
|
+
// тип генерации; дальше тот же flow что openGenerateForRef, но с заранее
|
|
387
|
+
// заданным kind.
|
|
388
|
+
function showAnchorDropMenu(fromNode, clientX, clientY) {
|
|
389
|
+
state.pendingConnectionFrom = fromNode.id;
|
|
390
|
+
state.addMenuPos = { clientX, clientY };
|
|
391
|
+
state.anchorFromNode = fromNode;
|
|
392
|
+
const menu = $('addMenu');
|
|
393
|
+
positionFloatingMenu(menu, clientX, clientY);
|
|
394
|
+
// Закрытие при клике ВНЕ меню. Проверяем target — клики по пунктам пропускаем
|
|
395
|
+
// (иначе меню скрывается до того как click на кнопке успеет отработать).
|
|
396
|
+
setTimeout(() => document.addEventListener('mousedown', closeAddMenuOnOutside, true), 0);
|
|
397
|
+
}
|
|
398
|
+
function closeAddMenuOnOutside(e) {
|
|
399
|
+
const menu = $('addMenu');
|
|
400
|
+
if (menu.contains(e.target)) return; // клик по пункту — пусть кнопка сработает
|
|
401
|
+
document.removeEventListener('mousedown', closeAddMenuOnOutside, true);
|
|
402
|
+
menu.classList.add('hidden');
|
|
403
|
+
state.anchorFromNode = null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
|
|
407
|
+
// forceKind — если из anchor-drop меню юзер выбрал конкретный тип.
|
|
408
|
+
if (forceKind) {
|
|
409
|
+
if (!await ensureApiKey(forceKind)) return;
|
|
410
|
+
state.genKind = forceKind;
|
|
411
|
+
document.querySelectorAll('#genModal [data-kind]').forEach(b =>
|
|
412
|
+
b.classList.toggle('active', b.dataset.kind === forceKind));
|
|
413
|
+
$('imageModelRow').style.display = forceKind === 'image' ? '' : 'none';
|
|
414
|
+
|
|
415
|
+
$('imageOptionsRow').style.display = forceKind === 'image' ? '' : 'none';
|
|
416
|
+
$('videoOptionsRow').style.display = forceKind === 'video' ? '' : 'none';
|
|
417
|
+
|
|
418
|
+
$('videoModelRow').style.display = forceKind === 'video' ? '' : 'none';
|
|
419
|
+
$('voiceRow').style.display = forceKind === 'audio' ? '' : 'none';
|
|
420
|
+
|
|
421
|
+
$('ttsModelRow').style.display = forceKind === 'audio' ? '' : 'none';
|
|
422
|
+
$('tonesRow').style.display = forceKind === 'audio' ? '' : 'none';
|
|
423
|
+
const titleEl = $('genTitle');
|
|
424
|
+
if (titleEl) {
|
|
425
|
+
titleEl.textContent =
|
|
426
|
+
forceKind === 'image' ? 'Сгенерировать картинку' :
|
|
427
|
+
forceKind === 'video' ? 'Сгенерировать видео' :
|
|
428
|
+
forceKind === 'audio' ? 'Сгенерировать голос' : 'Сгенерировать ноду';
|
|
429
|
+
}
|
|
430
|
+
if (forceKind === 'audio') loadVoices();
|
|
431
|
+
}
|
|
432
|
+
state.pendingConnectionFrom = fromNode.id;
|
|
433
|
+
if (clientX != null) state.addMenuPos = { clientX, clientY };
|
|
434
|
+
$('genStatus').textContent = '';
|
|
435
|
+
$('genStatus').className = 'status';
|
|
436
|
+
$('genSubmit').disabled = false;
|
|
437
|
+
|
|
438
|
+
resetPicks();
|
|
439
|
+
// НЕ вызываем presetPicksForBoard() — иначе при вытягивании ноды из ноды
|
|
440
|
+
// на доске персонажа авто-подставится этот персонаж в picked-список,
|
|
441
|
+
// а юзер ожидает чистую форму (он явно не выбирал персонажа).
|
|
442
|
+
renderCharsPickChips();
|
|
443
|
+
renderLocPickSelect();
|
|
444
|
+
syncCharLocRows();
|
|
445
|
+
|
|
446
|
+
// Если источник — картинка/видео, кладём его в "Исходный кадр".
|
|
447
|
+
// Для VIDEO thumbnail НЕ показываем (юзер просил), только имя.
|
|
448
|
+
if ((fromNode.type === 'image' || fromNode.type === 'video') && fromNode.file) {
|
|
449
|
+
state.sourceRef = {
|
|
450
|
+
file: fromNode.file,
|
|
451
|
+
type: fromNode.type,
|
|
452
|
+
boardHandle: state.currentBoard.handle,
|
|
453
|
+
use: true,
|
|
454
|
+
};
|
|
455
|
+
if (fromNode.type === 'image') {
|
|
456
|
+
let thumbUrl = state.currentBoard.urls[fromNode.file];
|
|
457
|
+
if (!thumbUrl) {
|
|
458
|
+
try {
|
|
459
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, fromNode.file);
|
|
460
|
+
thumbUrl = URL.createObjectURL(await fh.getFile());
|
|
461
|
+
state.currentBoard.urls[fromNode.file] = thumbUrl;
|
|
462
|
+
} catch {}
|
|
463
|
+
}
|
|
464
|
+
$('sourceRefThumb').src = thumbUrl || '';
|
|
465
|
+
$('sourceRefThumb').style.display = '';
|
|
466
|
+
} else {
|
|
467
|
+
// video — без thumbnail, чтобы не дёргать декодер видео ради превьюшки.
|
|
468
|
+
$('sourceRefThumb').src = '';
|
|
469
|
+
$('sourceRefThumb').style.display = 'none';
|
|
470
|
+
}
|
|
471
|
+
$('sourceRefName').textContent = fromNode.name || fromNode.file.split('/').pop();
|
|
472
|
+
applySourceRefVisuals();
|
|
473
|
+
$('genPrompt').value = '';
|
|
474
|
+
} else {
|
|
475
|
+
state.sourceRef = null;
|
|
476
|
+
$('genPrompt').value = '[@' + nodeRefKey(fromNode) + '] ';
|
|
477
|
+
}
|
|
478
|
+
syncSourceRefRow();
|
|
479
|
+
|
|
480
|
+
closeMentionPopup();
|
|
481
|
+
$('genModal').classList.remove('hidden');
|
|
482
|
+
setTimeout(() => {
|
|
483
|
+
const ta = $('genPrompt');
|
|
484
|
+
ta.focus();
|
|
485
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
486
|
+
}, 50);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function attachResize(el, node, handle) {
|
|
490
|
+
handle.addEventListener('mousedown', e => {
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
e.stopPropagation();
|
|
493
|
+
const startX = e.clientX, startY = e.clientY;
|
|
494
|
+
const startW = el.offsetWidth, startH = el.offsetHeight;
|
|
495
|
+
const onMove = ev => {
|
|
496
|
+
const w = Math.max(180, startW + (ev.clientX - startX) / state.zoom);
|
|
497
|
+
const h = Math.max(80, startH + (ev.clientY - startY) / state.zoom);
|
|
498
|
+
node.width = w;
|
|
499
|
+
node.height = h;
|
|
500
|
+
el.style.width = w + 'px';
|
|
501
|
+
el.style.height = h + 'px';
|
|
502
|
+
};
|
|
503
|
+
const onUp = () => {
|
|
504
|
+
document.removeEventListener('mousemove', onMove);
|
|
505
|
+
document.removeEventListener('mouseup', onUp);
|
|
506
|
+
scheduleSave();
|
|
507
|
+
};
|
|
508
|
+
document.addEventListener('mousemove', onMove);
|
|
509
|
+
document.addEventListener('mouseup', onUp);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function attachDrag(el, node) {
|
|
514
|
+
const header = el.querySelector('.node-header');
|
|
515
|
+
header.addEventListener('mousedown', e => {
|
|
516
|
+
if (e.target.closest('.delete')) return;
|
|
517
|
+
// Multi-select c модификаторами — без drag
|
|
518
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
|
519
|
+
e.preventDefault();
|
|
520
|
+
e.stopPropagation();
|
|
521
|
+
toggleSelection(node.id);
|
|
522
|
+
renderSelection();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
// Если кликнули по невыделенной — заменить выделение на эту одну
|
|
526
|
+
if (!state.selectedNodeIds.has(node.id)) {
|
|
527
|
+
clearSelection();
|
|
528
|
+
state.selectedNodeIds.add(node.id);
|
|
529
|
+
renderSelection();
|
|
530
|
+
}
|
|
531
|
+
e.preventDefault();
|
|
532
|
+
const startX = e.clientX, startY = e.clientY;
|
|
533
|
+
const origX = node.x, origY = node.y;
|
|
534
|
+
el.classList.add('selected');
|
|
535
|
+
canvas.appendChild(el);
|
|
536
|
+
const arr = state.currentBoard.metadata.nodes;
|
|
537
|
+
const idx = arr.indexOf(node);
|
|
538
|
+
if (idx >= 0 && idx < arr.length - 1) {
|
|
539
|
+
arr.splice(idx, 1);
|
|
540
|
+
arr.push(node);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Multi-drag: если выделено несколько нод и кликнули по одной из них —
|
|
544
|
+
// двигаем всю группу с одинаковым delta. dragTargets: { node, el, origX, origY }.
|
|
545
|
+
const isMulti = state.selectedNodeIds.size > 1 && state.selectedNodeIds.has(node.id);
|
|
546
|
+
const dragTargets = isMulti
|
|
547
|
+
? arr.filter(n => state.selectedNodeIds.has(n.id)).map(n => ({
|
|
548
|
+
node: n,
|
|
549
|
+
el: canvas.querySelector(`.node[data-id="${n.id}"]`),
|
|
550
|
+
origX: n.x,
|
|
551
|
+
origY: n.y,
|
|
552
|
+
})).filter(t => t.el)
|
|
553
|
+
: [{ node, el, origX, origY }];
|
|
554
|
+
if (isMulti) {
|
|
555
|
+
for (const t of dragTargets) t.el.classList.add('selected');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let dragInitialized = false; // снято на 1-м move ≥ 4px (избегаем «ложных» переносов)
|
|
559
|
+
let lastTimelineHover = null; // {trackEl, time} — чтобы рисовать индикатор drop
|
|
560
|
+
let nodePosChanged = false; // была ли реальная мутация node.x/y
|
|
561
|
+
const dropMarker = (() => {
|
|
562
|
+
const m = document.createElement('div');
|
|
563
|
+
m.style.cssText = 'position:absolute; top:0; bottom:0; width:2px; background:#5aa8ff; pointer-events:none; z-index:1000; box-shadow:0 0 6px #5aa8ff;';
|
|
564
|
+
m.style.display = 'none';
|
|
565
|
+
return m;
|
|
566
|
+
})();
|
|
567
|
+
const onMove = ev => {
|
|
568
|
+
const dx = ev.clientX - startX, dy = ev.clientY - startY;
|
|
569
|
+
if (!dragInitialized && Math.hypot(dx, dy) < 4) return;
|
|
570
|
+
dragInitialized = true;
|
|
571
|
+
// Проверяем — курсор над таймлайном?
|
|
572
|
+
const tlTrackEl = document.elementsFromPoint(ev.clientX, ev.clientY)
|
|
573
|
+
.find(n => n.classList?.contains('timeline-track'));
|
|
574
|
+
const isMedia = ['image','video','audio'].includes(node.type) && node.file;
|
|
575
|
+
// Drop-on-timeline для multi-drag не имеет смысла — отключаем.
|
|
576
|
+
if (tlTrackEl && isMedia && !isMulti) {
|
|
577
|
+
const kind = tlTrackEl.dataset.trackKind;
|
|
578
|
+
const compat = (kind === 'video' && (node.type === 'video' || node.type === 'image'))
|
|
579
|
+
|| (kind === 'audio' && node.type === 'audio');
|
|
580
|
+
if (compat) {
|
|
581
|
+
el.classList.add('dragging-to-timeline');
|
|
582
|
+
const clipsRow = tlTrackEl.querySelector('.track-clips');
|
|
583
|
+
if (clipsRow) {
|
|
584
|
+
const r = clipsRow.getBoundingClientRect();
|
|
585
|
+
const xLocal = ev.clientX - r.left;
|
|
586
|
+
clipsRow.appendChild(dropMarker);
|
|
587
|
+
dropMarker.style.left = Math.max(0, xLocal) + 'px';
|
|
588
|
+
dropMarker.style.display = 'block';
|
|
589
|
+
const time = Math.max(0, xLocal / state.timelineZoom);
|
|
590
|
+
lastTimelineHover = { track: kind, trackId: tlTrackEl.dataset.trackId, time };
|
|
591
|
+
}
|
|
592
|
+
// Возвращаем ноду на исходную позицию визуально
|
|
593
|
+
el.style.left = origX + 'px';
|
|
594
|
+
el.style.top = origY + 'px';
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
el.classList.remove('dragging-to-timeline');
|
|
599
|
+
dropMarker.style.display = 'none';
|
|
600
|
+
lastTimelineHover = null;
|
|
601
|
+
nodePosChanged = true;
|
|
602
|
+
const dxNode = (ev.clientX - startX) / state.zoom;
|
|
603
|
+
const dyNode = (ev.clientY - startY) / state.zoom;
|
|
604
|
+
for (const t of dragTargets) {
|
|
605
|
+
t.node.x = Math.max(0, t.origX + dxNode);
|
|
606
|
+
t.node.y = Math.max(0, t.origY + dyNode);
|
|
607
|
+
t.el.style.left = t.node.x + 'px';
|
|
608
|
+
t.el.style.top = t.node.y + 'px';
|
|
609
|
+
}
|
|
610
|
+
renderConnections();
|
|
611
|
+
};
|
|
612
|
+
const onUp = async () => {
|
|
613
|
+
document.removeEventListener('mousemove', onMove);
|
|
614
|
+
document.removeEventListener('mouseup', onUp);
|
|
615
|
+
for (const t of dragTargets) t.el.classList.remove('selected');
|
|
616
|
+
el.classList.remove('dragging-to-timeline');
|
|
617
|
+
dropMarker.remove();
|
|
618
|
+
if (lastTimelineHover) {
|
|
619
|
+
// Drop ноды как клипа на таймлайн
|
|
620
|
+
try {
|
|
621
|
+
pushHistory('Добавление клипа из ноды');
|
|
622
|
+
await addNodeToTimelineAt(node, lastTimelineHover.trackId, lastTimelineHover.time);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
console.error('drop node→tl failed', err);
|
|
625
|
+
alert('Не удалось положить ноду на таймлайн: ' + (err?.message || err));
|
|
626
|
+
}
|
|
627
|
+
// Восстановить позицию ноды (мы её и не трогали, но на всякий случай)
|
|
628
|
+
node.x = origX; node.y = origY;
|
|
629
|
+
el.style.left = origX + 'px';
|
|
630
|
+
el.style.top = origY + 'px';
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (nodePosChanged) {
|
|
634
|
+
// Реальное перемещение по холсту — фиксируем в history (snapshot ДО был неявно
|
|
635
|
+
// нужен, но мы не хотим засорять историю каждым jitter'ом). Пишем после move:
|
|
636
|
+
// origX/origY это snapshot для "одной операции move".
|
|
637
|
+
// Используем patched approach: добавим мини-snapshot в past руками.
|
|
638
|
+
const h = _getHistory();
|
|
639
|
+
if (h) {
|
|
640
|
+
// Снимаем перед-snapshot (без позиции — нет; делаем дешёвый: меняем x/y у копии)
|
|
641
|
+
const before = captureScene();
|
|
642
|
+
// НО captureScene уже захватил уже-перемещённую позицию, поэтому "до" = текущая; не годится.
|
|
643
|
+
// Откатываемся к простому шагу: берём snapshot уже после, и при undo просто
|
|
644
|
+
// запоминаем дельту. Самое простое — захватить ДО мутации в onMove.
|
|
645
|
+
// Чтобы не усложнять — оставим перетаскивание ноды по холсту вне history (как было).
|
|
646
|
+
}
|
|
647
|
+
scheduleSave();
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
document.addEventListener('mousemove', onMove);
|
|
651
|
+
document.addEventListener('mouseup', onUp);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Добавить ноду как клип в указанную дорожку с ripple-вставкой по времени
|
|
656
|
+
async function addNodeToTimelineAt(node, trackId, time) {
|
|
657
|
+
const tl = getTimeline();
|
|
658
|
+
if (!tl) return;
|
|
659
|
+
const target = tl.tracks.find(t => t.id === trackId);
|
|
660
|
+
if (!target) return;
|
|
661
|
+
const newClip = {
|
|
662
|
+
id: crypto.randomUUID(),
|
|
663
|
+
nodeId: node.id,
|
|
664
|
+
type: node.type,
|
|
665
|
+
file: node.file,
|
|
666
|
+
name: node.name || (node.file || '').split('/').pop(),
|
|
667
|
+
duration: defaultClipDuration(node),
|
|
668
|
+
start: 0,
|
|
669
|
+
};
|
|
670
|
+
if (target.kind === 'video') {
|
|
671
|
+
target.clips.push(newClip);
|
|
672
|
+
rippleInsertClip(target, newClip, target, Math.max(0, +time || 0));
|
|
673
|
+
} else {
|
|
674
|
+
newClip.start = Math.max(0, +time || 0);
|
|
675
|
+
target.clips.push(newClip);
|
|
676
|
+
}
|
|
677
|
+
scheduleSave();
|
|
678
|
+
$('timelinePanel').classList.remove('hidden');
|
|
679
|
+
await renderTimeline();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function deleteNode(node, el) {
|
|
683
|
+
const board = state.currentBoard;
|
|
684
|
+
if (!board) return;
|
|
685
|
+
const boardHandle = board.handle;
|
|
686
|
+
// Проверяем — есть ли нода в таймлайне; если да — спрашиваем подтверждение
|
|
687
|
+
let timelineRefs = 0;
|
|
688
|
+
if (board.metadata.timeline?.tracks) {
|
|
689
|
+
for (const t of board.metadata.timeline.tracks) {
|
|
690
|
+
for (const c of t.clips) {
|
|
691
|
+
if (c.nodeId === node.id || c.file === node.file) timelineRefs++;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (timelineRefs > 0) {
|
|
696
|
+
const ok = confirm(`Эта нода используется в таймлайне (${timelineRefs} клип${timelineRefs === 1 ? '' : 'ов'}). Удалить вместе с клипами?`);
|
|
697
|
+
if (!ok) return;
|
|
698
|
+
}
|
|
699
|
+
// Snapshot ДО мутации — чтобы undo вернул всё как было.
|
|
700
|
+
const snap = captureScene();
|
|
701
|
+
const movedFiles = [];
|
|
702
|
+
if (node.file) {
|
|
703
|
+
if (board.urls[node.file]) { URL.revokeObjectURL(board.urls[node.file]); delete board.urls[node.file]; }
|
|
704
|
+
try {
|
|
705
|
+
const delDir = await boardHandle.getDirectoryHandle('_deleted', { create: true });
|
|
706
|
+
const srcFh = await resolveBoardFile(boardHandle, node.file);
|
|
707
|
+
const file = await srcFh.getFile();
|
|
708
|
+
const baseName = node.file.split('/').pop();
|
|
709
|
+
const movedFilename = await uniqueName(delDir, baseName);
|
|
710
|
+
await writeFile(delDir, movedFilename, file);
|
|
711
|
+
await removeBoardFile(boardHandle, node.file);
|
|
712
|
+
movedFiles.push({ originalPath: node.file, movedFilename });
|
|
713
|
+
} catch (e) { console.error('move to _deleted failed', e); }
|
|
714
|
+
}
|
|
715
|
+
// Удаляем клипы таймлайна, которые ссылаются на эту ноду / файл.
|
|
716
|
+
if (board.metadata.timeline?.tracks) {
|
|
717
|
+
let changed = false;
|
|
718
|
+
for (const t of board.metadata.timeline.tracks) {
|
|
719
|
+
const before = t.clips.length;
|
|
720
|
+
t.clips = t.clips.filter(c => c.nodeId !== node.id && c.file !== node.file);
|
|
721
|
+
if (t.clips.length !== before) changed = true;
|
|
722
|
+
}
|
|
723
|
+
if (changed && !$('timelinePanel').classList.contains('hidden')) renderTimeline();
|
|
724
|
+
}
|
|
725
|
+
board.metadata.nodes = board.metadata.nodes.filter(n => n.id !== node.id);
|
|
726
|
+
const removedConns = (board.metadata.connections || []).filter(c => c.from === node.id || c.to === node.id);
|
|
727
|
+
if (removedConns.length) {
|
|
728
|
+
board.metadata.connections = board.metadata.connections.filter(c => c.from !== node.id && c.to !== node.id);
|
|
729
|
+
renderConnections();
|
|
730
|
+
}
|
|
731
|
+
if (el) el.remove();
|
|
732
|
+
// Записываем history ПОСЛЕ — но snapshot был снят ДО мутации
|
|
733
|
+
const h = _getHistory();
|
|
734
|
+
if (h) {
|
|
735
|
+
h.past.push({ ts: Date.now(), label: 'Удаление ноды', snap, movedFiles });
|
|
736
|
+
if (h.past.length > MAX_HISTORY) h.past.shift();
|
|
737
|
+
h.future.length = 0;
|
|
738
|
+
}
|
|
739
|
+
scheduleSave();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Удалить все выбранные ноды одним действием с поддержкой undo на уровне сцены
|
|
743
|
+
async function deleteSelectedNodes() {
|
|
744
|
+
const board = state.currentBoard;
|
|
745
|
+
if (!board || !state.selectedNodeIds.size) return;
|
|
746
|
+
const ids = new Set(state.selectedNodeIds);
|
|
747
|
+
const nodesToDelete = board.metadata.nodes.filter(n => ids.has(n.id));
|
|
748
|
+
if (!nodesToDelete.length) return;
|
|
749
|
+
|
|
750
|
+
// Сколько клипов на таймлайне ссылается на эти ноды/файлы
|
|
751
|
+
let timelineRefs = 0;
|
|
752
|
+
if (board.metadata.timeline?.tracks) {
|
|
753
|
+
const filesSet = new Set(nodesToDelete.map(n => n.file).filter(Boolean));
|
|
754
|
+
for (const t of board.metadata.timeline.tracks) {
|
|
755
|
+
for (const c of t.clips) {
|
|
756
|
+
if (ids.has(c.nodeId) || (c.file && filesSet.has(c.file))) timelineRefs++;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (timelineRefs > 0) {
|
|
761
|
+
const ok = confirm(`${nodesToDelete.length} нод. Используются в таймлайне (${timelineRefs} клип${timelineRefs === 1 ? '' : 'ов'}). Удалить вместе с клипами?`);
|
|
762
|
+
if (!ok) return;
|
|
763
|
+
} else if (nodesToDelete.length > 1) {
|
|
764
|
+
if (!confirm(`Удалить ${nodesToDelete.length} нод?`)) return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Снимок сцены ДО — для undo
|
|
768
|
+
const snapshot = JSON.stringify({
|
|
769
|
+
nodes: board.metadata.nodes,
|
|
770
|
+
connections: board.metadata.connections || [],
|
|
771
|
+
timeline: board.metadata.timeline || null,
|
|
772
|
+
character: board.metadata.character || null,
|
|
773
|
+
location: board.metadata.location || null,
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// Перемещаем файлы в _deleted, фиксируем мэппинг для восстановления
|
|
777
|
+
const movedFiles = [];
|
|
778
|
+
for (const node of nodesToDelete) {
|
|
779
|
+
if (!node.file) continue;
|
|
780
|
+
if (board.urls[node.file]) {
|
|
781
|
+
URL.revokeObjectURL(board.urls[node.file]);
|
|
782
|
+
delete board.urls[node.file];
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
const delDir = await board.handle.getDirectoryHandle('_deleted', { create: true });
|
|
786
|
+
const srcFh = await resolveBoardFile(board.handle, node.file);
|
|
787
|
+
const file = await srcFh.getFile();
|
|
788
|
+
const baseName = node.file.split('/').pop();
|
|
789
|
+
const movedFilename = await uniqueName(delDir, baseName);
|
|
790
|
+
await writeFile(delDir, movedFilename, file);
|
|
791
|
+
await removeBoardFile(board.handle, node.file);
|
|
792
|
+
movedFiles.push({ originalPath: node.file, movedFilename });
|
|
793
|
+
} catch (e) { console.error('move to _deleted failed', node.file, e); }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Чистим nodes / connections / timeline-клипы
|
|
797
|
+
board.metadata.nodes = board.metadata.nodes.filter(n => !ids.has(n.id));
|
|
798
|
+
board.metadata.connections = (board.metadata.connections || [])
|
|
799
|
+
.filter(c => !ids.has(c.from) && !ids.has(c.to));
|
|
800
|
+
if (board.metadata.timeline?.tracks) {
|
|
801
|
+
const filesSet = new Set(nodesToDelete.map(n => n.file).filter(Boolean));
|
|
802
|
+
for (const t of board.metadata.timeline.tracks) {
|
|
803
|
+
t.clips = t.clips.filter(c => !ids.has(c.nodeId) && !(c.file && filesSet.has(c.file)));
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// DOM cleanup
|
|
808
|
+
for (const id of ids) {
|
|
809
|
+
const el = canvas.querySelector(`.node[data-id="${id}"]`);
|
|
810
|
+
if (el) el.remove();
|
|
811
|
+
}
|
|
812
|
+
state.selectedNodeIds.clear();
|
|
813
|
+
renderConnections();
|
|
814
|
+
if (!$('timelinePanel').classList.contains('hidden')) renderTimeline();
|
|
815
|
+
scheduleSave();
|
|
816
|
+
|
|
817
|
+
// history записываем здесь — snapshot был снят ДО мутации
|
|
818
|
+
const h = _getHistory();
|
|
819
|
+
if (h) {
|
|
820
|
+
h.past.push({ ts: Date.now(), label: 'Удаление нод', snap: snapshot, movedFiles });
|
|
821
|
+
if (h.past.length > MAX_HISTORY) h.past.shift();
|
|
822
|
+
h.future.length = 0;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
document.addEventListener('keydown', async e => {
|
|
827
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
828
|
+
const tag = (e.target?.tagName || '').toLowerCase();
|
|
829
|
+
const inText = tag === 'textarea' || tag === 'input';
|
|
830
|
+
|
|
831
|
+
// Zoom shortcuts (работают даже в инпутах — это естественно)
|
|
832
|
+
if (mod && (e.key === '=' || e.key === '+')) { e.preventDefault(); applyZoom(state.zoom * 1.25); return; }
|
|
833
|
+
if (mod && e.key === '-') { e.preventDefault(); applyZoom(state.zoom / 1.25); return; }
|
|
834
|
+
if (mod && e.key === '0') { e.preventDefault(); applyZoom(1); return; }
|
|
835
|
+
|
|
836
|
+
// Undo (Cmd+Z) / Redo (Cmd+Shift+Z или Cmd+Y) — только не в текстовых полях
|
|
837
|
+
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
|
|
838
|
+
if (inText) return;
|
|
839
|
+
e.preventDefault();
|
|
840
|
+
await undo();
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (mod && ((e.key.toLowerCase() === 'z' && e.shiftKey) || e.key.toLowerCase() === 'y')) {
|
|
844
|
+
if (inText) return;
|
|
845
|
+
e.preventDefault();
|
|
846
|
+
await redo();
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
// Backspace/Delete — удалить выделенные ноды холста или клипы таймлайна
|
|
850
|
+
if ((e.key === 'Backspace' || e.key === 'Delete') && !inText && !mod && state.currentBoard) {
|
|
851
|
+
if (state.selectedClipIds.size) {
|
|
852
|
+
e.preventDefault();
|
|
853
|
+
await deleteSelectedClips();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (state.selectedNodeIds.size) {
|
|
857
|
+
e.preventDefault();
|
|
858
|
+
await deleteSelectedNodes();
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// Cmd+G / Cmd+Shift+G — группировать / разгруппировать выделенные клипы таймлайна
|
|
863
|
+
if (mod && e.key.toLowerCase() === 'g' && !inText) {
|
|
864
|
+
const tl = state.currentBoard?.metadata?.timeline;
|
|
865
|
+
if (!tl?.tracks) return;
|
|
866
|
+
if (e.shiftKey) {
|
|
867
|
+
// Разгруппировать все группы среди выделения
|
|
868
|
+
if (!state.selectedClipIds.size) return;
|
|
869
|
+
e.preventDefault();
|
|
870
|
+
const groupsToBreak = new Set();
|
|
871
|
+
for (const id of state.selectedClipIds) {
|
|
872
|
+
const f = findClipById(tl, id);
|
|
873
|
+
if (f?.clip.groupId) groupsToBreak.add(f.clip.groupId);
|
|
874
|
+
}
|
|
875
|
+
for (const t of tl.tracks) {
|
|
876
|
+
for (const c of t.clips) {
|
|
877
|
+
if (c.groupId && groupsToBreak.has(c.groupId)) delete c.groupId;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
scheduleSave();
|
|
881
|
+
renderTimeline();
|
|
882
|
+
return;
|
|
883
|
+
} else {
|
|
884
|
+
if (state.selectedClipIds.size < 2) return;
|
|
885
|
+
e.preventDefault();
|
|
886
|
+
const gid = crypto.randomUUID();
|
|
887
|
+
for (const id of state.selectedClipIds) {
|
|
888
|
+
const f = findClipById(tl, id);
|
|
889
|
+
if (f) f.clip.groupId = gid;
|
|
890
|
+
}
|
|
891
|
+
scheduleSave();
|
|
892
|
+
renderTimeline();
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Cmd+C / Cmd+V — копировать/вставить ноды (включая между досками)
|
|
897
|
+
if (mod && e.key.toLowerCase() === 'c' && !e.shiftKey && !inText) {
|
|
898
|
+
// Приоритет: выделение на таймлайне → выделенные ноды на холсте
|
|
899
|
+
if (state.selectedClipIds.size) {
|
|
900
|
+
e.preventDefault();
|
|
901
|
+
await copyTimelineClipsToClipboard([...state.selectedClipIds]);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (state.selectedTrackIds.size) {
|
|
905
|
+
const tl = getTimeline();
|
|
906
|
+
const ids = [];
|
|
907
|
+
for (const t of (tl?.tracks || [])) {
|
|
908
|
+
if (state.selectedTrackIds.has(t.id)) for (const c of t.clips) ids.push(c.id);
|
|
909
|
+
}
|
|
910
|
+
if (ids.length) {
|
|
911
|
+
e.preventDefault();
|
|
912
|
+
await copyTimelineClipsToClipboard(ids);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (state.selectedNodeIds.size) {
|
|
917
|
+
e.preventDefault();
|
|
918
|
+
await copySelectedNodes();
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (mod && e.key.toLowerCase() === 'v' && !e.shiftKey && !inText && state.clipboard?.length) {
|
|
922
|
+
e.preventDefault();
|
|
923
|
+
// Если буфер содержит клипы и фокус на таймлайне → вставить туда
|
|
924
|
+
const tl = getTimeline();
|
|
925
|
+
const cb = state.clipboard;
|
|
926
|
+
const cbHasMedia = cb.some(it => ['image','video','audio'].includes(it.node?.type));
|
|
927
|
+
const focusOnTimeline = !!document.activeElement?.closest?.('#timelinePanel');
|
|
928
|
+
if (focusOnTimeline && tl?.tracks && cbHasMedia) {
|
|
929
|
+
const isAudio = cb.every(it => it.node?.type === 'audio');
|
|
930
|
+
const wantedKind = isAudio ? 'audio' : 'video';
|
|
931
|
+
const target = tl.tracks.find(t => t.kind === wantedKind) || tl.tracks[0];
|
|
932
|
+
if (target) {
|
|
933
|
+
pushHistory('Вставка на таймлайн');
|
|
934
|
+
await pasteClipboardToTimeline(target, state.playheadTime || 0);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
pushHistory('Вставка нод');
|
|
939
|
+
await pasteClipboardNodes();
|
|
940
|
+
}
|
|
941
|
+
// Cmd+X — вырезать (как copy + удалить)
|
|
942
|
+
if (mod && e.key.toLowerCase() === 'x' && !e.shiftKey && !inText) {
|
|
943
|
+
if (state.selectedClipIds.size) {
|
|
944
|
+
e.preventDefault();
|
|
945
|
+
await copyTimelineClipsToClipboard([...state.selectedClipIds]);
|
|
946
|
+
await deleteSelectedClips();
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (state.selectedNodeIds.size) {
|
|
950
|
+
e.preventDefault();
|
|
951
|
+
await copySelectedNodes();
|
|
952
|
+
await deleteSelectedNodes();
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
// Пробел в capture-фазе — обходит native space на <video controls>
|
|
959
|
+
window.addEventListener('keydown', e => {
|
|
960
|
+
if (e.code !== 'Space' && e.key !== ' ') return;
|
|
961
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
962
|
+
const tag = (e.target?.tagName || '').toLowerCase();
|
|
963
|
+
if (tag === 'textarea' || tag === 'input' || tag === 'select') return;
|
|
964
|
+
if (e.target.isContentEditable) return;
|
|
965
|
+
if ($('timelinePanel').classList.contains('hidden')) return;
|
|
966
|
+
e.preventDefault();
|
|
967
|
+
e.stopImmediatePropagation();
|
|
968
|
+
if (e.target.tagName === 'VIDEO') e.target.blur();
|
|
969
|
+
$('timelinePlay').click();
|
|
970
|
+
}, true);
|
|
971
|
+
|
|
972
|
+
async function copySelectedNodes() {
|
|
973
|
+
if (!state.currentBoard || !state.selectedNodeIds.size) return;
|
|
974
|
+
state.clipboard = [];
|
|
975
|
+
state.clipboardPasteCount = 0; // сброс «накапливающего» сдвига
|
|
976
|
+
state.clipboardSourceBoardKey = state.currentBoard.key;
|
|
977
|
+
for (const id of state.selectedNodeIds) {
|
|
978
|
+
const n = state.currentBoard.metadata.nodes.find(x => x.id === id);
|
|
979
|
+
if (!n) continue;
|
|
980
|
+
let blob = null, textContent = null;
|
|
981
|
+
if (n.type === 'text') {
|
|
982
|
+
textContent = n.text || '';
|
|
983
|
+
} else if (n.file) {
|
|
984
|
+
try {
|
|
985
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, n.file);
|
|
986
|
+
blob = await fh.getFile();
|
|
987
|
+
} catch {}
|
|
988
|
+
}
|
|
989
|
+
state.clipboard.push({ node: { ...n }, blob, textContent });
|
|
990
|
+
}
|
|
991
|
+
console.log(`Скопировано в буфер: ${state.clipboard.length} нод`);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Скопировать одну ноду в буфер (для ПКМ → "Скопировать")
|
|
995
|
+
async function copyNodeToClipboard(node) {
|
|
996
|
+
if (!state.currentBoard) return;
|
|
997
|
+
let blob = null, textContent = null;
|
|
998
|
+
if (node.type === 'text') {
|
|
999
|
+
textContent = node.text || '';
|
|
1000
|
+
} else if (node.file) {
|
|
1001
|
+
try {
|
|
1002
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
|
|
1003
|
+
blob = await fh.getFile();
|
|
1004
|
+
} catch {}
|
|
1005
|
+
}
|
|
1006
|
+
state.clipboard = [{ node: { ...node }, blob, textContent }];
|
|
1007
|
+
state.clipboardPasteCount = 0;
|
|
1008
|
+
state.clipboardSourceBoardKey = state.currentBoard.key;
|
|
1009
|
+
console.log(`В буфер: ${node.name || node.id}`);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Заменить контент текущей ноды содержимым из буфера (текущее уходит в history)
|
|
1013
|
+
async function replaceNodeFromClipboard(node) {
|
|
1014
|
+
if (!state.currentBoard || !state.clipboard?.length) return;
|
|
1015
|
+
const item = state.clipboard[0];
|
|
1016
|
+
const src = item.node;
|
|
1017
|
+
// Текущее состояние ноды → history
|
|
1018
|
+
if (!Array.isArray(node.history) || !node.history.length) {
|
|
1019
|
+
node.history = [{
|
|
1020
|
+
file: node.file,
|
|
1021
|
+
generated: node.generated ? { ...node.generated } : undefined,
|
|
1022
|
+
status: undefined, error: undefined,
|
|
1023
|
+
}];
|
|
1024
|
+
node.historyIndex = 0;
|
|
1025
|
+
} else {
|
|
1026
|
+
syncHistorySlot(node);
|
|
1027
|
+
}
|
|
1028
|
+
// Сохраняем файл из буфера в текущей доске
|
|
1029
|
+
let newFile = null;
|
|
1030
|
+
if (src.type === 'text') {
|
|
1031
|
+
const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
|
|
1032
|
+
const baseName = await uniqueName(dir, (src.name || 'text') + '.md');
|
|
1033
|
+
await writeFile(dir, baseName, item.textContent || '');
|
|
1034
|
+
newFile = `texts/${baseName}`;
|
|
1035
|
+
node.text = item.textContent || '';
|
|
1036
|
+
} else if (item.blob) {
|
|
1037
|
+
const sub = boardSubdirForType(src.type);
|
|
1038
|
+
const baseName = src.file ? src.file.split('/').pop() : `replaced-${Date.now()}`;
|
|
1039
|
+
const dir = sub ? await getOrCreateBoardSubdir(state.currentBoard.handle, sub) : state.currentBoard.handle;
|
|
1040
|
+
const newName = await uniqueName(dir, baseName);
|
|
1041
|
+
await writeFile(dir, newName, item.blob);
|
|
1042
|
+
newFile = sub ? `${sub}/${newName}` : newName;
|
|
1043
|
+
} else {
|
|
1044
|
+
alert('В буфере нет файла для замены.');
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
const oldFile = node.file;
|
|
1048
|
+
node.type = src.type;
|
|
1049
|
+
node.file = newFile;
|
|
1050
|
+
node.generated = src.generated ? { ...src.generated } : undefined;
|
|
1051
|
+
node.status = undefined;
|
|
1052
|
+
node.error = undefined;
|
|
1053
|
+
// Новая history-запись и переход на неё
|
|
1054
|
+
node.history.push({
|
|
1055
|
+
file: newFile,
|
|
1056
|
+
generated: node.generated ? { ...node.generated } : undefined,
|
|
1057
|
+
status: undefined, error: undefined,
|
|
1058
|
+
});
|
|
1059
|
+
node.historyIndex = node.history.length - 1;
|
|
1060
|
+
// Обновить клипы таймлайна, ссылающиеся на ноду
|
|
1061
|
+
if (oldFile !== newFile) syncTimelineClipsForNode(node);
|
|
1062
|
+
scheduleSave();
|
|
1063
|
+
await refreshNodeDOM(node.id);
|
|
1064
|
+
refreshTimelineForNode(node.id);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async function pasteClipboardNodes() {
|
|
1068
|
+
if (!state.currentBoard || !state.clipboard?.length) return;
|
|
1069
|
+
const STEP = 30;
|
|
1070
|
+
const newIds = [];
|
|
1071
|
+
|
|
1072
|
+
// Накапливающий сдвиг: каждый Cmd+V увеличивает offset на STEP — копии
|
|
1073
|
+
// ложатся «лесенкой» вниз-вправо от оригинала, не друг на друга.
|
|
1074
|
+
state.clipboardPasteCount = (state.clipboardPasteCount || 0) + 1;
|
|
1075
|
+
const offset = STEP * state.clipboardPasteCount;
|
|
1076
|
+
|
|
1077
|
+
// Bbox оригиналов — нужен для cross-board paste, чтобы группа не теряла форму.
|
|
1078
|
+
let minX = Infinity, minY = Infinity;
|
|
1079
|
+
for (const it of state.clipboard) {
|
|
1080
|
+
if (it.node.x < minX) minX = it.node.x;
|
|
1081
|
+
if (it.node.y < minY) minY = it.node.y;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Если paste идёт в ту же доску, где копировали — кладём рядом с оригиналами
|
|
1085
|
+
// (oldX + offset, oldY + offset). При cross-board — переносим bbox в видимую область.
|
|
1086
|
+
const sameBoard = state.clipboardSourceBoardKey === state.currentBoard.key;
|
|
1087
|
+
const baseX = sameBoard ? offset
|
|
1088
|
+
: (canvasWrap.scrollLeft / state.zoom + 80 - minX);
|
|
1089
|
+
const baseY = sameBoard ? offset
|
|
1090
|
+
: (canvasWrap.scrollTop / state.zoom + 80 - minY);
|
|
1091
|
+
|
|
1092
|
+
for (const item of state.clipboard) {
|
|
1093
|
+
const oldNode = item.node;
|
|
1094
|
+
const id = crypto.randomUUID();
|
|
1095
|
+
let newFile = null;
|
|
1096
|
+
if (oldNode.type === 'text') {
|
|
1097
|
+
const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
|
|
1098
|
+
const baseName = await uniqueName(dir, (oldNode.name || 'text') + '.md');
|
|
1099
|
+
await writeFile(dir, baseName, item.textContent || '');
|
|
1100
|
+
newFile = `texts/${baseName}`;
|
|
1101
|
+
} else if (item.blob) {
|
|
1102
|
+
const sub = boardSubdirForType(oldNode.type);
|
|
1103
|
+
const baseName = oldNode.file ? oldNode.file.split('/').pop() : `pasted-${Date.now()}`;
|
|
1104
|
+
const dir = sub ? await getOrCreateBoardSubdir(state.currentBoard.handle, sub) : state.currentBoard.handle;
|
|
1105
|
+
const newName = await uniqueName(dir, baseName);
|
|
1106
|
+
await writeFile(dir, newName, item.blob);
|
|
1107
|
+
newFile = sub ? `${sub}/${newName}` : newName;
|
|
1108
|
+
}
|
|
1109
|
+
const newNode = {
|
|
1110
|
+
...oldNode, id,
|
|
1111
|
+
file: newFile,
|
|
1112
|
+
x: oldNode.x + baseX,
|
|
1113
|
+
y: oldNode.y + baseY,
|
|
1114
|
+
// не копируем job-state и историю
|
|
1115
|
+
status: undefined, error: undefined,
|
|
1116
|
+
history: undefined, historyIndex: undefined,
|
|
1117
|
+
};
|
|
1118
|
+
if (oldNode.type === 'text') newNode.text = item.textContent || '';
|
|
1119
|
+
state.currentBoard.metadata.nodes.push(newNode);
|
|
1120
|
+
canvas.appendChild(await createNodeEl(newNode));
|
|
1121
|
+
newIds.push(id);
|
|
1122
|
+
}
|
|
1123
|
+
clearSelection();
|
|
1124
|
+
for (const id of newIds) state.selectedNodeIds.add(id);
|
|
1125
|
+
renderSelection();
|
|
1126
|
+
scheduleSave();
|
|
1127
|
+
}
|
|
1128
|
+
|