kingkont 0.7.39 → 0.7.40
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 +22 -0
- package/package.json +5 -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 +46 -0
- package/skill/SKILL.md +160 -78
|
@@ -0,0 +1,1522 @@
|
|
|
1
|
+
// renderer/board.js — DOM init, sidebar, board open/render, SVG connections, node context menu, fullscreen viewer
|
|
2
|
+
//
|
|
3
|
+
// Этот модуль был выделен из index.html (раньше всё было в одном <script>
|
|
4
|
+
// блоке на 9123 строки). Все модули загружаются как plain <script>
|
|
5
|
+
// в одном глобальном scope — поэтому функции/переменные между файлами
|
|
6
|
+
// видят друг друга по именам, без import/export. Порядок загрузки
|
|
7
|
+
// важен: см. <script> теги внизу index.html.
|
|
8
|
+
|
|
9
|
+
// =================== DOM ===================
|
|
10
|
+
const $ = id => document.getElementById(id);
|
|
11
|
+
const canvas = $('canvas');
|
|
12
|
+
const canvasWrap = $('canvasWrap');
|
|
13
|
+
const characterList = $('characterList');
|
|
14
|
+
const locationList = $('locationList');
|
|
15
|
+
const episodeList = $('episodeList');
|
|
16
|
+
const emptyState = $('emptyState');
|
|
17
|
+
|
|
18
|
+
// =================== Init ===================
|
|
19
|
+
window.addEventListener('DOMContentLoaded', async () => {
|
|
20
|
+
if (!('showDirectoryPicker' in window)) { showUnsupported(); return; }
|
|
21
|
+
// По умолчанию проект не открыт — секции персонажей/локаций/сцен скрыты.
|
|
22
|
+
document.body.classList.add('no-project');
|
|
23
|
+
loadVoices().catch(() => {}); // preload голосов в фоне
|
|
24
|
+
// Application menu → действия в renderer
|
|
25
|
+
if (window.appMenu) {
|
|
26
|
+
window.appMenu.on('open-film', () => $('pickRoot').click());
|
|
27
|
+
window.appMenu.on('new-episode', () => $('newEpisode').click());
|
|
28
|
+
window.appMenu.on('new-character', () => $('newCharacter').click());
|
|
29
|
+
window.appMenu.on('new-location', () => $('newLocation').click());
|
|
30
|
+
window.appMenu.on('undo', () => undo());
|
|
31
|
+
window.appMenu.on('redo', () => redo());
|
|
32
|
+
window.appMenu.on('toggle-timeline', () => $('timelineBtn').click());
|
|
33
|
+
window.appMenu.on('close-project', () => closeProject());
|
|
34
|
+
window.appMenu.on('close-window-or-project', () => {
|
|
35
|
+
if (state.filmHandle) closeProject();
|
|
36
|
+
else window.appWindow?.minimize();
|
|
37
|
+
});
|
|
38
|
+
window.appMenu.on('open-settings', () => openSettings());
|
|
39
|
+
}
|
|
40
|
+
// Восстановить состояние панелей таймлайна/превью/реплик
|
|
41
|
+
const tlOpen = localStorage.getItem('timelineOpen') === '1';
|
|
42
|
+
const pvOpen = localStorage.getItem('previewOpen') === '1';
|
|
43
|
+
const rqOpen = false; // localStorage.getItem('repliquesOpen') === '1'; — панель реплик скрыта
|
|
44
|
+
if (tlOpen) $('timelinePanel').classList.remove('hidden');
|
|
45
|
+
// Превью больше не скрывается — состояние управляется previewCollapsed
|
|
46
|
+
if (rqOpen) $('repliquesPanel').classList.remove('hidden');
|
|
47
|
+
// Welcome-кнопка дублирует pickRoot click (с user-gesture от пользователя).
|
|
48
|
+
$('welcomeOpen').addEventListener('click', () => $('pickRoot').click());
|
|
49
|
+
// Sidebar search — фильтрует персонажей/локации/сцены.
|
|
50
|
+
$('sidebarSearch').addEventListener('input', e => {
|
|
51
|
+
const q = e.target.value.trim().toLowerCase();
|
|
52
|
+
for (const id of ['characterList','locationList','episodeList']) {
|
|
53
|
+
const list = $(id);
|
|
54
|
+
if (!list) continue;
|
|
55
|
+
for (const item of list.querySelectorAll('.item')) {
|
|
56
|
+
const name = (item.querySelector('.item-name')?.textContent || '').toLowerCase();
|
|
57
|
+
item.classList.toggle('search-hidden', q && !name.includes(q));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
// Тихий autoload: первый recent с granted-permission (если есть).
|
|
62
|
+
try {
|
|
63
|
+
const recents = await getRecents();
|
|
64
|
+
if (recents.length && recents[0].handle) {
|
|
65
|
+
const first = recents[0];
|
|
66
|
+
let q = 'prompt';
|
|
67
|
+
try { q = await first.handle.queryPermission({ mode: 'readwrite' }); } catch {}
|
|
68
|
+
vlog('info', `restore: handle=${first.name} queryPermission=${q}`);
|
|
69
|
+
if (q === 'granted') {
|
|
70
|
+
await openFilm(first.handle);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
vlog('err', 'restore failed: ' + (e?.message || e));
|
|
76
|
+
}
|
|
77
|
+
await renderWelcomeRecents();
|
|
78
|
+
// Стартуем обновление баланса. Сразу + каждые 60 сек.
|
|
79
|
+
refreshBalance().catch(() => {});
|
|
80
|
+
setInterval(() => { refreshBalance().catch(() => {}); }, 60_000);
|
|
81
|
+
|
|
82
|
+
// Дабл-клик на логотипе (sidebar или welcome) → открытие окна настроек.
|
|
83
|
+
const openSettingsFromLogo = () => {
|
|
84
|
+
if (window.appSettings?.openSettingsWindow) window.appSettings.openSettingsWindow();
|
|
85
|
+
};
|
|
86
|
+
document.getElementById('brandLogo')?.addEventListener('dblclick', openSettingsFromLogo);
|
|
87
|
+
document.getElementById('welcomeLogo')?.addEventListener('dblclick', openSettingsFromLogo);
|
|
88
|
+
|
|
89
|
+
// Версия приложения на welcome-экране и в шапке проекта (после слова
|
|
90
|
+
// "KingKont"). appInfo.version() — IPC к main → app.getVersion().
|
|
91
|
+
// На веб-версии (без preload) — пропускаем, версия не показывается.
|
|
92
|
+
if (window.appInfo?.version) {
|
|
93
|
+
window.appInfo.version().then(v => {
|
|
94
|
+
if (!v) return;
|
|
95
|
+
const text = 'v' + v;
|
|
96
|
+
for (const id of ['welcomeVersion', 'brandVersion']) {
|
|
97
|
+
const el = document.getElementById(id);
|
|
98
|
+
if (el) el.textContent = text;
|
|
99
|
+
}
|
|
100
|
+
}).catch(() => {});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// === Баланс Chatium ===
|
|
105
|
+
// Раз в минуту дёргает GET /api/balance (server.js → kingkont.ru). Если
|
|
106
|
+
// chatium отключён или ключа нет — pill в sidebar-footer'е скрывается.
|
|
107
|
+
// Цвет dot'а меняется в зависимости от баланса: green / yellow (<100) /
|
|
108
|
+
// red (≤0). Также экспортирована глобально как window.refreshBalance —
|
|
109
|
+
// чтобы settings-окно могло триггерить обновление после login/logout.
|
|
110
|
+
async function refreshBalance() {
|
|
111
|
+
const wrap = document.getElementById('balancesAll');
|
|
112
|
+
if (!wrap) return;
|
|
113
|
+
let data = {};
|
|
114
|
+
try {
|
|
115
|
+
const r = await fetch('/api/balance/all');
|
|
116
|
+
if (r.ok) data = await r.json();
|
|
117
|
+
} catch {}
|
|
118
|
+
wrap.innerHTML = '';
|
|
119
|
+
// Один pill на провайдер. Если у провайдера нет данных (выключен или
|
|
120
|
+
// API не дал баланс) — pill не рендерим.
|
|
121
|
+
const pills = [
|
|
122
|
+
{ key: 'kingkont', label: 'KingKont', onClick: () => window.openTxLog?.(), low: 100, fmt: (a) => `<b>${a.toLocaleString('ru-RU')}</b> credits` },
|
|
123
|
+
{ key: 'openrouter', label: 'OpenRouter', low: 0.5, fmt: (a) => `<b>$${a.toFixed(2)}</b>` },
|
|
124
|
+
{ key: 'elevenlabs', label: 'ElevenLabs', low: 1000, fmt: (a) => `<b>${a.toLocaleString('ru-RU')}</b> chars` },
|
|
125
|
+
];
|
|
126
|
+
for (const p of pills) {
|
|
127
|
+
const d = data[p.key];
|
|
128
|
+
if (!d || typeof d.amount !== 'number') continue;
|
|
129
|
+
const pill = document.createElement('span');
|
|
130
|
+
pill.className = 'balance-info';
|
|
131
|
+
pill.title = `Баланс ${p.label}` + (p.onClick ? ' · клик — лог списаний' : '');
|
|
132
|
+
if (d.amount > 0 && d.amount < (p.low || 0)) pill.classList.add('low');
|
|
133
|
+
if (d.amount <= 0) pill.classList.add('empty');
|
|
134
|
+
pill.innerHTML = `<span class="dot"></span><span style="color:#888;font-size:10px;margin-right:4px;">${p.label}</span><span>${p.fmt(d.amount)}</span>`;
|
|
135
|
+
if (p.onClick) {
|
|
136
|
+
pill.style.cursor = 'pointer';
|
|
137
|
+
pill.addEventListener('click', p.onClick);
|
|
138
|
+
}
|
|
139
|
+
wrap.appendChild(pill);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
window.refreshBalance = refreshBalance;
|
|
143
|
+
|
|
144
|
+
// === Лог списаний (модал с историей кредитов) ===
|
|
145
|
+
async function openTxLog() {
|
|
146
|
+
const modal = document.getElementById('txLogModal');
|
|
147
|
+
if (!modal) return;
|
|
148
|
+
modal.classList.remove('hidden');
|
|
149
|
+
await loadTxLog();
|
|
150
|
+
}
|
|
151
|
+
window.openTxLog = openTxLog;
|
|
152
|
+
|
|
153
|
+
async function loadTxLog() {
|
|
154
|
+
const body = document.getElementById('txLogBody');
|
|
155
|
+
const balanceLine = document.getElementById('txBalanceLine');
|
|
156
|
+
if (!body) return;
|
|
157
|
+
body.innerHTML = '<div style="padding:24px; text-align:center; color:#888;">Загрузка…</div>';
|
|
158
|
+
if (balanceLine) balanceLine.textContent = '';
|
|
159
|
+
try {
|
|
160
|
+
const [txR, balR] = await Promise.all([
|
|
161
|
+
fetch('/api/transactions'),
|
|
162
|
+
fetch('/api/balance').catch(() => null),
|
|
163
|
+
]);
|
|
164
|
+
if (!txR.ok) {
|
|
165
|
+
const t = await txR.text().catch(() => '');
|
|
166
|
+
body.innerHTML = `<div class="tx-empty">Ошибка: HTTP ${txR.status} ${escapeHtmlSafe(t.slice(0, 200))}</div>`;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const txs = await txR.json();
|
|
170
|
+
if (balR && balR.ok) {
|
|
171
|
+
const b = await balR.json();
|
|
172
|
+
const n = Number(b.balance) || 0;
|
|
173
|
+
balanceLine.textContent = `· баланс ${n.toLocaleString('ru-RU')} credits`;
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(txs) || txs.length === 0) {
|
|
176
|
+
body.innerHTML = '<div class="tx-empty">Списаний нет</div>';
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const rows = txs.map(t => {
|
|
180
|
+
const amount = Number(t.amount) || 0;
|
|
181
|
+
const isNeg = amount < 0;
|
|
182
|
+
const amountStr = (isNeg ? '' : '+') + amount.toLocaleString('ru-RU');
|
|
183
|
+
const dateStr = formatTxDate(t.createdAt);
|
|
184
|
+
const typeLabel = formatTxType(t.type);
|
|
185
|
+
return `
|
|
186
|
+
<tr>
|
|
187
|
+
<td style="white-space:nowrap; color:#aaa; font-family:ui-monospace,monospace;">${escapeHtmlSafe(dateStr)}</td>
|
|
188
|
+
<td>
|
|
189
|
+
<div>${escapeHtmlSafe(t.description || '—')}</div>
|
|
190
|
+
<div class="tx-type">${escapeHtmlSafe(typeLabel)}${t.model ? ' · <span class="tx-model">' + escapeHtmlSafe(t.model) + '</span>' : ''}</div>
|
|
191
|
+
</td>
|
|
192
|
+
<td class="${isNeg ? 'tx-amount-neg' : 'tx-amount-pos'}">${escapeHtmlSafe(amountStr)}</td>
|
|
193
|
+
</tr>
|
|
194
|
+
`;
|
|
195
|
+
}).join('');
|
|
196
|
+
body.innerHTML = `
|
|
197
|
+
<table>
|
|
198
|
+
<thead>
|
|
199
|
+
<tr>
|
|
200
|
+
<th style="width:140px;">Время</th>
|
|
201
|
+
<th>Описание</th>
|
|
202
|
+
<th style="width:110px; text-align:right;">Кредиты</th>
|
|
203
|
+
</tr>
|
|
204
|
+
</thead>
|
|
205
|
+
<tbody>${rows}</tbody>
|
|
206
|
+
</table>
|
|
207
|
+
`;
|
|
208
|
+
} catch (e) {
|
|
209
|
+
body.innerHTML = `<div class="tx-empty">Ошибка: ${escapeHtmlSafe(e?.message || String(e))}</div>`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function formatTxDate(ts) {
|
|
214
|
+
if (!ts) return '—';
|
|
215
|
+
const d = new Date(ts);
|
|
216
|
+
return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatTxType(t) {
|
|
220
|
+
return ({
|
|
221
|
+
deduct: 'списание',
|
|
222
|
+
reserve: 'резерв',
|
|
223
|
+
reserve_cancelled: 'резерв отменён',
|
|
224
|
+
refund: 'возврат',
|
|
225
|
+
topup: 'пополнение',
|
|
226
|
+
bonus: 'бонус',
|
|
227
|
+
subscription: 'подписка',
|
|
228
|
+
renewal: 'продление',
|
|
229
|
+
correction: 'корректировка',
|
|
230
|
+
})[t] || t || '—';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function escapeHtmlSafe(s) {
|
|
234
|
+
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
document.getElementById('txLogClose')?.addEventListener('click', () => {
|
|
238
|
+
document.getElementById('txLogModal').classList.add('hidden');
|
|
239
|
+
});
|
|
240
|
+
document.getElementById('txLogRefresh')?.addEventListener('click', () => loadTxLog());
|
|
241
|
+
|
|
242
|
+
// === Recents store ===
|
|
243
|
+
// Метаданные ({ name, thumbDataUrl, ts }) — в JSON-файле через recentsStore
|
|
244
|
+
// (переживают quota-reset IDB). FSAH-handle — параллельно в IDB под ключом
|
|
245
|
+
// `handle:<name>`. При load merge'им: если handle нет, карточка
|
|
246
|
+
// показывается без действия — клик дёргает showDirectoryPicker заново.
|
|
247
|
+
const MAX_RECENTS = 12;
|
|
248
|
+
|
|
249
|
+
async function _readRecentsMeta() {
|
|
250
|
+
try {
|
|
251
|
+
if (window.recentsStore?.read) return await window.recentsStore.read();
|
|
252
|
+
} catch {}
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
async function _writeRecentsMeta(arr) {
|
|
256
|
+
try {
|
|
257
|
+
if (window.recentsStore?.write) await window.recentsStore.write(arr);
|
|
258
|
+
} catch (e) { console.warn('recents write failed', e); }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function _blobToDataUrl(blob) {
|
|
262
|
+
if (!blob) return null;
|
|
263
|
+
return await new Promise((res, rej) => {
|
|
264
|
+
const r = new FileReader();
|
|
265
|
+
r.onload = () => res(r.result);
|
|
266
|
+
r.onerror = () => rej(r.error);
|
|
267
|
+
r.readAsDataURL(blob);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function _dataUrlToBlob(url) {
|
|
271
|
+
if (!url || typeof url !== 'string' || !url.startsWith('data:')) return null;
|
|
272
|
+
const [head, b64] = url.split(',');
|
|
273
|
+
const mime = (head.match(/data:([^;]+);base64/) || [])[1] || 'image/jpeg';
|
|
274
|
+
const bin = atob(b64);
|
|
275
|
+
const buf = new Uint8Array(bin.length);
|
|
276
|
+
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
|
277
|
+
return new Blob([buf], { type: mime });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function getRecents() {
|
|
281
|
+
const meta = await _readRecentsMeta();
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const m of meta) {
|
|
284
|
+
let handle = null;
|
|
285
|
+
try { handle = await idbGet(`handle:${m.name}`); } catch {}
|
|
286
|
+
out.push({
|
|
287
|
+
name: m.name,
|
|
288
|
+
ts: m.ts || 0,
|
|
289
|
+
thumb: _dataUrlToBlob(m.thumbDataUrl),
|
|
290
|
+
handle,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function touchRecent(handle, thumbBlob) {
|
|
297
|
+
if (!handle) return;
|
|
298
|
+
const list = await _readRecentsMeta();
|
|
299
|
+
const i = list.findIndex(r => r.name === handle.name);
|
|
300
|
+
let thumbDataUrl = (i >= 0 ? list[i].thumbDataUrl : null);
|
|
301
|
+
if (thumbBlob) thumbDataUrl = await _blobToDataUrl(thumbBlob);
|
|
302
|
+
const entry = { name: handle.name, ts: Date.now(), thumbDataUrl };
|
|
303
|
+
if (i >= 0) list.splice(i, 1);
|
|
304
|
+
list.unshift(entry);
|
|
305
|
+
while (list.length > MAX_RECENTS) list.pop();
|
|
306
|
+
await _writeRecentsMeta(list);
|
|
307
|
+
// Handle отдельно в IDB — переживает большинство сценариев. Если IDB
|
|
308
|
+
// ресетится — handle потеряется, метаданные останутся (карточка кликнется
|
|
309
|
+
// и попросит выбрать ту же папку через showDirectoryPicker).
|
|
310
|
+
try { await idbSet(`handle:${handle.name}`, handle); } catch {}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function removeRecent(name) {
|
|
314
|
+
const list = await _readRecentsMeta();
|
|
315
|
+
await _writeRecentsMeta(list.filter(r => r.name !== name));
|
|
316
|
+
// handle в idb тоже удалим
|
|
317
|
+
try {
|
|
318
|
+
const db = await openDB();
|
|
319
|
+
const tx = db.transaction(STORE, 'readwrite');
|
|
320
|
+
tx.objectStore(STORE).delete(`handle:${name}`);
|
|
321
|
+
} catch {}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function fmtRelativeTime(ts) {
|
|
325
|
+
const dt = (Date.now() - ts) / 1000;
|
|
326
|
+
if (dt < 60) return 'только что';
|
|
327
|
+
if (dt < 3600) return `${Math.floor(dt / 60)} мин назад`;
|
|
328
|
+
if (dt < 86400) return `${Math.floor(dt / 3600)} ч назад`;
|
|
329
|
+
if (dt < 86400 * 7) return `${Math.floor(dt / 86400)} дн назад`;
|
|
330
|
+
return new Date(ts).toLocaleDateString();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function renderWelcomeRecents() {
|
|
334
|
+
const grid = $('welcomeRecentGrid');
|
|
335
|
+
const wrap = $('welcomeRecent');
|
|
336
|
+
const titleEl = $('welcomeRecentTitle');
|
|
337
|
+
if (!grid || !wrap) return;
|
|
338
|
+
grid.innerHTML = '';
|
|
339
|
+
wrap.style.display = '';
|
|
340
|
+
const list = await getRecents();
|
|
341
|
+
|
|
342
|
+
// Первой картой — «Открыть проект». Кликается → дёргает скрытый
|
|
343
|
+
// #pickRoot button (тот же, что использует app menu).
|
|
344
|
+
const openCard = document.createElement('div');
|
|
345
|
+
openCard.className = 'welcome-card open-card';
|
|
346
|
+
const openThumb = document.createElement('div');
|
|
347
|
+
openThumb.className = 'welcome-card-thumb';
|
|
348
|
+
openThumb.textContent = '+';
|
|
349
|
+
const openMeta = document.createElement('div');
|
|
350
|
+
openMeta.className = 'welcome-card-meta';
|
|
351
|
+
const openName = document.createElement('div');
|
|
352
|
+
openName.className = 'welcome-card-name';
|
|
353
|
+
openName.textContent = 'Открыть проект';
|
|
354
|
+
const openSub = document.createElement('div');
|
|
355
|
+
openSub.className = 'welcome-card-ts';
|
|
356
|
+
openSub.textContent = 'выбрать папку…';
|
|
357
|
+
openMeta.append(openName, openSub);
|
|
358
|
+
openCard.append(openThumb, openMeta);
|
|
359
|
+
openCard.addEventListener('click', () => $('pickRoot').click());
|
|
360
|
+
grid.appendChild(openCard);
|
|
361
|
+
|
|
362
|
+
// Title меняем в зависимости от наличия recents.
|
|
363
|
+
if (titleEl) titleEl.textContent = list.length ? 'Открыть проект · недавние' : 'Открыть проект';
|
|
364
|
+
|
|
365
|
+
for (const r of list) {
|
|
366
|
+
const card = document.createElement('div');
|
|
367
|
+
card.className = 'welcome-card';
|
|
368
|
+
const thumb = document.createElement('div');
|
|
369
|
+
thumb.className = 'welcome-card-thumb';
|
|
370
|
+
if (r.thumb) {
|
|
371
|
+
const img = document.createElement('img');
|
|
372
|
+
img.src = URL.createObjectURL(r.thumb);
|
|
373
|
+
img.onload = () => setTimeout(() => URL.revokeObjectURL(img.src), 60_000);
|
|
374
|
+
thumb.appendChild(img);
|
|
375
|
+
} else {
|
|
376
|
+
thumb.textContent = '🎬';
|
|
377
|
+
}
|
|
378
|
+
const meta = document.createElement('div');
|
|
379
|
+
meta.className = 'welcome-card-meta';
|
|
380
|
+
const nameEl = document.createElement('div');
|
|
381
|
+
nameEl.className = 'welcome-card-name';
|
|
382
|
+
nameEl.textContent = r.name;
|
|
383
|
+
const tsEl = document.createElement('div');
|
|
384
|
+
tsEl.className = 'welcome-card-ts';
|
|
385
|
+
tsEl.textContent = fmtRelativeTime(r.ts);
|
|
386
|
+
meta.append(nameEl, tsEl);
|
|
387
|
+
card.append(thumb, meta);
|
|
388
|
+
const del = document.createElement('div');
|
|
389
|
+
del.className = 'welcome-card-del';
|
|
390
|
+
del.textContent = '×';
|
|
391
|
+
del.title = 'Удалить из недавних';
|
|
392
|
+
del.addEventListener('click', async e => {
|
|
393
|
+
e.stopPropagation();
|
|
394
|
+
if (!confirm(`Убрать «${r.name}» из недавних? (папка не удаляется)`)) return;
|
|
395
|
+
await removeRecent(r.name);
|
|
396
|
+
await renderWelcomeRecents();
|
|
397
|
+
});
|
|
398
|
+
card.appendChild(del);
|
|
399
|
+
card.addEventListener('click', async () => {
|
|
400
|
+
try {
|
|
401
|
+
if (r.handle) {
|
|
402
|
+
let g = (await r.handle.queryPermission({ mode: 'readwrite' })) === 'granted';
|
|
403
|
+
if (!g) g = (await r.handle.requestPermission({ mode: 'readwrite' })) === 'granted';
|
|
404
|
+
vlog('info', `welcome card click ${r.name}: granted=${g}`);
|
|
405
|
+
if (g) await openFilm(r.handle);
|
|
406
|
+
else alert('Доступ к папке не подтверждён.');
|
|
407
|
+
} else {
|
|
408
|
+
// Handle потерялся (IDB сбросилась) — открываем picker прямо здесь,
|
|
409
|
+
// чтобы сохранить user-gesture (через $('pickRoot').click() он
|
|
410
|
+
// теряется и showDirectoryPicker валится SecurityError).
|
|
411
|
+
try {
|
|
412
|
+
const handle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'video-editor-film' });
|
|
413
|
+
await openFilm(handle);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
if (err.name === 'AbortError') return;
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch (err) {
|
|
420
|
+
vlog('err', 'welcome card failed: ' + (err?.message || err));
|
|
421
|
+
alert('Ошибка: ' + (err?.message || err));
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
grid.appendChild(card);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Сгенерировать превью: первый image-нод любого board'а проекта,
|
|
429
|
+
// resize в 320×180, JPEG. Возвращает Blob или null.
|
|
430
|
+
async function generateProjectThumb(filmHandle) {
|
|
431
|
+
try {
|
|
432
|
+
// Собрать кандидатов: characters + locations + episodes (в этом приоритете).
|
|
433
|
+
const candidates = [];
|
|
434
|
+
const collect = async (subdir) => {
|
|
435
|
+
try {
|
|
436
|
+
const root = await filmHandle.getDirectoryHandle(subdir);
|
|
437
|
+
for await (const [name, h] of root.entries()) {
|
|
438
|
+
if (h.kind === 'directory') candidates.push(h);
|
|
439
|
+
}
|
|
440
|
+
} catch {}
|
|
441
|
+
};
|
|
442
|
+
await collect(CHAR_DIR);
|
|
443
|
+
await collect(LOC_DIR);
|
|
444
|
+
// Эпизоды лежат прямо в корне — как папки кроме _* служебных
|
|
445
|
+
for await (const [name, h] of filmHandle.entries()) {
|
|
446
|
+
if (h.kind !== 'directory') continue;
|
|
447
|
+
if (name.startsWith('_') || name.startsWith('.')) continue;
|
|
448
|
+
if (name === CHAR_DIR || name === LOC_DIR) continue;
|
|
449
|
+
candidates.push(h);
|
|
450
|
+
}
|
|
451
|
+
// В каждом board'е смотрим scene.json и берём первую image-ноду
|
|
452
|
+
for (const board of candidates) {
|
|
453
|
+
let nodes = [];
|
|
454
|
+
try {
|
|
455
|
+
const fh = await board.getFileHandle('scene.json');
|
|
456
|
+
const data = JSON.parse(await (await fh.getFile()).text());
|
|
457
|
+
nodes = data.nodes || [];
|
|
458
|
+
} catch { continue; }
|
|
459
|
+
const imgNode = nodes.find(n => n.type === 'image' && n.file);
|
|
460
|
+
if (!imgNode) continue;
|
|
461
|
+
try {
|
|
462
|
+
const fh = await resolveBoardFile(board, imgNode.file);
|
|
463
|
+
const file = await fh.getFile();
|
|
464
|
+
return await blobToThumbJpeg(file, 320, 180);
|
|
465
|
+
} catch { continue; }
|
|
466
|
+
}
|
|
467
|
+
} catch (e) { vlog('err', 'thumb gen failed: ' + (e?.message || e)); }
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function blobToThumbJpeg(blob, w, h) {
|
|
472
|
+
const bmp = await createImageBitmap(blob);
|
|
473
|
+
const canvas = new OffscreenCanvas(w, h);
|
|
474
|
+
const ctx = canvas.getContext('2d');
|
|
475
|
+
// contain + центрирование на тёмной подложке
|
|
476
|
+
ctx.fillStyle = '#1a1a1a';
|
|
477
|
+
ctx.fillRect(0, 0, w, h);
|
|
478
|
+
const r = Math.min(w / bmp.width, h / bmp.height);
|
|
479
|
+
const dw = bmp.width * r, dh = bmp.height * r;
|
|
480
|
+
ctx.drawImage(bmp, (w - dw) / 2, (h - dh) / 2, dw, dh);
|
|
481
|
+
bmp.close();
|
|
482
|
+
return await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.78 });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function showUnsupported() {
|
|
486
|
+
document.body.innerHTML = `
|
|
487
|
+
<div class="unsupported">
|
|
488
|
+
<h1>Браузер не поддерживается</h1>
|
|
489
|
+
<p>Этот редактор использует <code>File System Access API</code> для работы с папками на диске.</p>
|
|
490
|
+
<p>Открой страницу в Chrome, Edge, Brave или Arc. Safari и Firefox пока не поддерживают эту функцию.</p>
|
|
491
|
+
<p>Запускай через <code>node server.js</code> и заходи на <code>http://localhost:8000</code>.</p>
|
|
492
|
+
</div>`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
$('pickRoot').addEventListener('click', async () => {
|
|
496
|
+
try {
|
|
497
|
+
const handle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'video-editor-film' });
|
|
498
|
+
await openFilm(handle); // openFilm сам touchRecent → персистит в JSON
|
|
499
|
+
} catch (e) {
|
|
500
|
+
if (e.name === 'AbortError') return;
|
|
501
|
+
if (e.name === 'SecurityError') {
|
|
502
|
+
alert('API не работает с file://. Запусти:\n\nnode server.js\n\nи открой http://localhost:8000');
|
|
503
|
+
} else alert('Ошибка: ' + e.message);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Кладём CLAUDE.md в корень проекта при каждом открытии (overwrite).
|
|
508
|
+
// Содержимое — короткий указатель на скилл `kingkont`, без user-data, не
|
|
509
|
+
// требует preserve. Апгрейды приложения автоматически донесут свежий текст.
|
|
510
|
+
// Тихо (ошибки не блокируют openFilm). Если юзер хочет свою документацию —
|
|
511
|
+
// в шаблоне написано «переименуй в NOTES.md».
|
|
512
|
+
async function ensureClaudeMd(handle) {
|
|
513
|
+
if (!handle || !window.claudeMd?.template) return;
|
|
514
|
+
try {
|
|
515
|
+
const tpl = await window.claudeMd.template();
|
|
516
|
+
if (!tpl) return;
|
|
517
|
+
// Сравним с тем что лежит сейчас, чтобы не плодить лишних writes
|
|
518
|
+
// (FSAH triggers'нет permission-prompt'ы при первой записи в эту сессию).
|
|
519
|
+
let existing = null;
|
|
520
|
+
try {
|
|
521
|
+
const fh = await handle.getFileHandle('CLAUDE.md');
|
|
522
|
+
existing = await (await fh.getFile()).text();
|
|
523
|
+
} catch {}
|
|
524
|
+
if (existing === tpl) return; // уже актуальный — не трогаем
|
|
525
|
+
const fh = await handle.getFileHandle('CLAUDE.md', { create: true });
|
|
526
|
+
const w = await fh.createWritable();
|
|
527
|
+
await w.write(tpl);
|
|
528
|
+
await w.close();
|
|
529
|
+
vlog('info', existing == null ? 'CLAUDE.md создан в корне проекта' : 'CLAUDE.md обновлён');
|
|
530
|
+
} catch (e) { vlog('err', 'CLAUDE.md write failed: ' + (e?.message || e)); }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function openFilm(handle) {
|
|
534
|
+
state.filmHandle = handle;
|
|
535
|
+
state.currentBoard = null;
|
|
536
|
+
document.body.classList.remove('no-project');
|
|
537
|
+
window.appProject?.notifyState(true);
|
|
538
|
+
ensureClaudeMd(handle).catch(() => {});
|
|
539
|
+
// Подзаголовок шапки = имя открытого проекта (вместо «Видео-редактор»).
|
|
540
|
+
const sub = $('brandSub');
|
|
541
|
+
if (sub) { sub.textContent = handle.name; sub.classList.add('has-project'); }
|
|
542
|
+
// Третья строка (имя board) пока пустая — заполнится при selectBoard.
|
|
543
|
+
const boardEl = $('brandBoard');
|
|
544
|
+
if (boardEl) { boardEl.style.display = 'none'; boardEl.innerHTML = ''; }
|
|
545
|
+
$('rootInfo').textContent = '';
|
|
546
|
+
// recents: обновляем timestamp + сохраняем handle. Thumb сгенерим в фоне ниже.
|
|
547
|
+
touchRecent(handle).catch(e => vlog('err', 'touchRecent: ' + e.message));
|
|
548
|
+
// Лениво — после рендера sidebar'а попробуем сгенерить превью и обновить recent.
|
|
549
|
+
setTimeout(async () => {
|
|
550
|
+
try {
|
|
551
|
+
const thumb = await generateProjectThumb(handle);
|
|
552
|
+
if (thumb) await touchRecent(handle, thumb);
|
|
553
|
+
} catch (e) { vlog('err', 'thumb refresh: ' + e.message); }
|
|
554
|
+
}, 1500);
|
|
555
|
+
$('newEpisode').disabled = false;
|
|
556
|
+
$('newCharacter').disabled = false;
|
|
557
|
+
$('newLocation').disabled = false;
|
|
558
|
+
$('repliquesBtn').disabled = false;
|
|
559
|
+
loadUploadCache().catch(() => {});
|
|
560
|
+
await refreshSidebar();
|
|
561
|
+
showEmpty();
|
|
562
|
+
|
|
563
|
+
const raw = localStorage.getItem(`lastBoard:${handle.name}`);
|
|
564
|
+
if (raw) {
|
|
565
|
+
try {
|
|
566
|
+
const last = JSON.parse(raw);
|
|
567
|
+
const list = last.kind === 'character' ? await listCharacters(handle)
|
|
568
|
+
: last.kind === 'location' ? await listLocations(handle)
|
|
569
|
+
: await listEpisodes(handle);
|
|
570
|
+
const found = list.find(x => x.name === last.name);
|
|
571
|
+
if (found) await selectBoard({ kind: last.kind, ...found });
|
|
572
|
+
} catch {}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Сканируем все доски на незавершённые задачи генерации (после перезагрузки)
|
|
576
|
+
scanAllBoardsForPendingJobs(handle).catch(e => console.warn('scan failed', e));
|
|
577
|
+
// Подгружаем настройки всех локаций
|
|
578
|
+
loadAllLocationsInfo().catch(() => {});
|
|
579
|
+
// Подгружаем настройки всех персонажей (для add-menu и т.д.)
|
|
580
|
+
loadAllCharactersInfo().then(() => {
|
|
581
|
+
// Если replicas-panel был открыт при перезагрузке — заполняем
|
|
582
|
+
if (!$('repliquesPanel').classList.contains('hidden') && state.charactersInfo.length) {
|
|
583
|
+
const sel = $('repliquesChar');
|
|
584
|
+
sel.innerHTML = '';
|
|
585
|
+
for (const c of state.charactersInfo) {
|
|
586
|
+
const opt = document.createElement('option');
|
|
587
|
+
opt.value = c.name;
|
|
588
|
+
opt.textContent = c.name + (c.voice ? '' : ' — голос не задан');
|
|
589
|
+
sel.appendChild(opt);
|
|
590
|
+
}
|
|
591
|
+
const last = localStorage.getItem('lastReplicaChar');
|
|
592
|
+
if (last && state.charactersInfo.some(c => c.name === last)) sel.value = last;
|
|
593
|
+
const ch = getSelectedReplicaChar();
|
|
594
|
+
if (ch) renderRepliquesList(ch);
|
|
595
|
+
}
|
|
596
|
+
}).catch(() => {});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Закрыть текущий проект: выгрузить state, скрыть секции, восстановить
|
|
600
|
+
// шапку, оставить запись в idb (чтобы Recent работал).
|
|
601
|
+
async function closeProject() {
|
|
602
|
+
if (state.currentBoard?.urls) {
|
|
603
|
+
for (const u of Object.values(state.currentBoard.urls)) URL.revokeObjectURL(u);
|
|
604
|
+
}
|
|
605
|
+
state.filmHandle = null;
|
|
606
|
+
state.currentBoard = null;
|
|
607
|
+
window.appProject?.notifyState(false);
|
|
608
|
+
state.charactersInfo = [];
|
|
609
|
+
state.locationsInfo = [];
|
|
610
|
+
state.selectedNodeIds.clear();
|
|
611
|
+
state.selectedClipIds.clear();
|
|
612
|
+
state.selectedTrackIds.clear();
|
|
613
|
+
document.body.classList.add('no-project');
|
|
614
|
+
const sub = $('brandSub');
|
|
615
|
+
if (sub) { sub.textContent = 'Видео-редактор'; sub.classList.remove('has-project'); }
|
|
616
|
+
const boardEl = $('brandBoard');
|
|
617
|
+
if (boardEl) { boardEl.style.display = 'none'; boardEl.innerHTML = ''; }
|
|
618
|
+
$('rootInfo').textContent = '';
|
|
619
|
+
// Очистить sidebar-списки
|
|
620
|
+
for (const id of ['characterList','locationList','episodeList']) {
|
|
621
|
+
const el = $(id); if (el) el.innerHTML = '';
|
|
622
|
+
}
|
|
623
|
+
$('newEpisode').disabled = true;
|
|
624
|
+
$('newCharacter').disabled = true;
|
|
625
|
+
$('newLocation').disabled = true;
|
|
626
|
+
$('repliquesBtn').disabled = true;
|
|
627
|
+
$('boardBadge').style.display = 'none';
|
|
628
|
+
$('charSettingsBtn').style.display = 'none';
|
|
629
|
+
// Очистить холст
|
|
630
|
+
if (typeof clearCanvasKeepSvg === 'function') clearCanvasKeepSvg();
|
|
631
|
+
$('addText').disabled = true; $('genText').disabled = true; $('genAudio').disabled = true; $('genImage').disabled = true; $('genVideo').disabled = true; $('genSfx').disabled = true; $('genMusic').disabled = true;
|
|
632
|
+
|
|
633
|
+
await renderWelcomeRecents();
|
|
634
|
+
showEmpty();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// =================== Sidebar ===================
|
|
638
|
+
async function refreshSidebar() {
|
|
639
|
+
await Promise.all([refreshCharacters(), refreshLocations(), refreshEpisodes()]);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function makeBoardItem(it, kind) {
|
|
643
|
+
const el = document.createElement('div');
|
|
644
|
+
el.className = 'item';
|
|
645
|
+
if (state.currentBoard?.kind === kind && state.currentBoard.name === it.name) el.classList.add('active');
|
|
646
|
+
const nameSpan = document.createElement('span');
|
|
647
|
+
nameSpan.className = 'item-name';
|
|
648
|
+
nameSpan.textContent = it.name;
|
|
649
|
+
el.appendChild(nameSpan);
|
|
650
|
+
el.addEventListener('click', () => selectBoard({ kind, ...it }));
|
|
651
|
+
el.addEventListener('contextmenu', e => {
|
|
652
|
+
e.preventDefault();
|
|
653
|
+
e.stopPropagation();
|
|
654
|
+
showBoardContextMenu(kind, it, e.clientX, e.clientY);
|
|
655
|
+
});
|
|
656
|
+
return el;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Контекст-меню для board-item (sidebar): Переименовать / Свойства / Удалить.
|
|
660
|
+
function showBoardContextMenu(kind, item, clientX, clientY) {
|
|
661
|
+
const menu = $('nodeMenu');
|
|
662
|
+
menu.innerHTML = '';
|
|
663
|
+
const add = (label, fn, opts = {}) => {
|
|
664
|
+
const b = document.createElement('button');
|
|
665
|
+
b.textContent = label;
|
|
666
|
+
if (opts.danger) b.style.color = '#f88';
|
|
667
|
+
b.addEventListener('click', () => { menu.classList.add('hidden'); fn(); });
|
|
668
|
+
menu.appendChild(b);
|
|
669
|
+
};
|
|
670
|
+
add('✎ Переименовать', async () => {
|
|
671
|
+
const newName = await askName('Новое название:', '', item.name);
|
|
672
|
+
if (!newName || newName === item.name) return;
|
|
673
|
+
try { await renameBoard(kind, item.name, newName); }
|
|
674
|
+
catch (err) { alert('Не удалось переименовать: ' + (err?.message || err)); }
|
|
675
|
+
});
|
|
676
|
+
if (kind === 'character') {
|
|
677
|
+
add('⚙ Свойства персонажа', () => {
|
|
678
|
+
// Открываем панель этого персонажа и потом её character-modal
|
|
679
|
+
selectBoard({ kind, ...item }).then(() => openCharacterSettings()).catch(() => {});
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
add('🗑 Удалить', async () => {
|
|
683
|
+
if (!confirm(`Удалить «${item.name}»? Папка переедет в _deleted, Cmd+Z восстановит.`)) return;
|
|
684
|
+
try { await deleteBoard(kind, item.name); }
|
|
685
|
+
catch (err) { alert('Не удалось удалить: ' + (err?.message || err)); }
|
|
686
|
+
}, { danger: true });
|
|
687
|
+
positionFloatingMenu(menu, clientX, clientY);
|
|
688
|
+
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Переименовать board (папку): создать новую папку с тем же именем, перенести
|
|
692
|
+
// содержимое, удалить старую. На macOS-FSAH прямого rename нет, делаем
|
|
693
|
+
// copy+remove. Если board сейчас открыт — переоткрываем после переноса.
|
|
694
|
+
async function renameBoard(kind, oldName, newName) {
|
|
695
|
+
newName = newName.trim();
|
|
696
|
+
if (!newName) throw new Error('пустое имя');
|
|
697
|
+
if (newName === oldName) return;
|
|
698
|
+
// /<>:|?* плюс слеши — недопустимы в имени папки
|
|
699
|
+
if (/[\\/<>:|?*]/.test(newName)) throw new Error('недопустимые символы в имени');
|
|
700
|
+
let parent;
|
|
701
|
+
if (kind === 'character') parent = await state.filmHandle.getDirectoryHandle(CHAR_DIR);
|
|
702
|
+
else if (kind === 'location') parent = await state.filmHandle.getDirectoryHandle(LOC_DIR);
|
|
703
|
+
else parent = state.filmHandle;
|
|
704
|
+
// Проверяем что новое имя не занято
|
|
705
|
+
try { await parent.getDirectoryHandle(newName); throw new Error('папка с таким именем уже есть'); }
|
|
706
|
+
catch (e) { if (e.message === 'папка с таким именем уже есть') throw e; }
|
|
707
|
+
// Запоминаем был ли он активным
|
|
708
|
+
const wasActive = state.currentBoard?.kind === kind && state.currentBoard.name === oldName;
|
|
709
|
+
// Переносим
|
|
710
|
+
const src = await parent.getDirectoryHandle(oldName);
|
|
711
|
+
const dst = await parent.getDirectoryHandle(newName, { create: true });
|
|
712
|
+
await copyDirContents(src, dst);
|
|
713
|
+
await parent.removeEntry(oldName, { recursive: true });
|
|
714
|
+
// Обновить sidebar
|
|
715
|
+
if (kind === 'character') await refreshCharacters();
|
|
716
|
+
else if (kind === 'location') await refreshLocations();
|
|
717
|
+
else await refreshEpisodes();
|
|
718
|
+
// Если был активен — переоткрыть с новым именем
|
|
719
|
+
if (wasActive) {
|
|
720
|
+
const list = kind === 'character' ? await listCharacters(state.filmHandle)
|
|
721
|
+
: kind === 'location' ? await listLocations(state.filmHandle)
|
|
722
|
+
: await listEpisodes(state.filmHandle);
|
|
723
|
+
const found = list.find(x => x.name === newName);
|
|
724
|
+
if (found) await selectBoard({ kind, ...found });
|
|
725
|
+
}
|
|
726
|
+
// Восстановить lastBoard для autoload
|
|
727
|
+
if (state.filmHandle) {
|
|
728
|
+
const last = JSON.parse(localStorage.getItem(`lastBoard:${state.filmHandle.name}`) || 'null');
|
|
729
|
+
if (last && last.kind === kind && last.name === oldName) {
|
|
730
|
+
localStorage.setItem(`lastBoard:${state.filmHandle.name}`,
|
|
731
|
+
JSON.stringify({ kind, name: newName }));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function copyDirContents(src, dst) {
|
|
737
|
+
for await (const [name, h] of src.entries()) {
|
|
738
|
+
if (h.kind === 'file') {
|
|
739
|
+
const file = await h.getFile();
|
|
740
|
+
const fh = await dst.getFileHandle(name, { create: true });
|
|
741
|
+
const w = await fh.createWritable();
|
|
742
|
+
await w.write(file);
|
|
743
|
+
await w.close();
|
|
744
|
+
} else if (h.kind === 'directory') {
|
|
745
|
+
const subDst = await dst.getDirectoryHandle(name, { create: true });
|
|
746
|
+
await copyDirContents(h, subDst);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function refreshCharacters() {
|
|
752
|
+
characterList.innerHTML = '';
|
|
753
|
+
if (!state.filmHandle) return;
|
|
754
|
+
const items = await listCharacters(state.filmHandle);
|
|
755
|
+
for (const it of items) characterList.appendChild(makeBoardItem(it, 'character'));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function refreshLocations() {
|
|
759
|
+
locationList.innerHTML = '';
|
|
760
|
+
if (!state.filmHandle) return;
|
|
761
|
+
const items = await listLocations(state.filmHandle);
|
|
762
|
+
for (const it of items) locationList.appendChild(makeBoardItem(it, 'location'));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function refreshEpisodes() {
|
|
766
|
+
episodeList.innerHTML = '';
|
|
767
|
+
if (!state.filmHandle) return;
|
|
768
|
+
const items = await listEpisodes(state.filmHandle);
|
|
769
|
+
for (const it of items) episodeList.appendChild(makeBoardItem(it, 'episode'));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// === Удаление досок (move в <film>/_deleted/) ===
|
|
773
|
+
async function dirExists(parent, name) {
|
|
774
|
+
try { await parent.getDirectoryHandle(name); return true; } catch { return false; }
|
|
775
|
+
}
|
|
776
|
+
async function uniqueSubdirName(parent, baseName) {
|
|
777
|
+
let n = baseName, i = 2;
|
|
778
|
+
while (await dirExists(parent, n)) { n = `${baseName} (${i})`; i++; }
|
|
779
|
+
return n;
|
|
780
|
+
}
|
|
781
|
+
async function copyDirContents(src, dst) {
|
|
782
|
+
for await (const [name, h] of src.entries()) {
|
|
783
|
+
if (h.kind === 'file') {
|
|
784
|
+
const file = await h.getFile();
|
|
785
|
+
const fh = await dst.getFileHandle(name, { create: true });
|
|
786
|
+
const w = await fh.createWritable();
|
|
787
|
+
await w.write(file);
|
|
788
|
+
await w.close();
|
|
789
|
+
} else if (h.kind === 'directory') {
|
|
790
|
+
const child = await dst.getDirectoryHandle(name, { create: true });
|
|
791
|
+
await copyDirContents(h, child);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async function moveDirectory(srcParent, srcName, dstParent, dstName) {
|
|
796
|
+
const src = await srcParent.getDirectoryHandle(srcName);
|
|
797
|
+
if (typeof src.move === 'function') {
|
|
798
|
+
try { await src.move(dstParent, dstName); return; } catch {}
|
|
799
|
+
}
|
|
800
|
+
const dst = await dstParent.getDirectoryHandle(dstName, { create: true });
|
|
801
|
+
await copyDirContents(src, dst);
|
|
802
|
+
await srcParent.removeEntry(srcName, { recursive: true });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function deleteBoard(kind, name) {
|
|
806
|
+
const dstRoot = await state.filmHandle.getDirectoryHandle('_deleted', { create: true });
|
|
807
|
+
const dstName = await uniqueSubdirName(dstRoot, name);
|
|
808
|
+
if (kind === 'character') {
|
|
809
|
+
const charsRoot = await state.filmHandle.getDirectoryHandle(CHAR_DIR);
|
|
810
|
+
await moveDirectory(charsRoot, name, dstRoot, dstName);
|
|
811
|
+
} else if (kind === 'location') {
|
|
812
|
+
const locsRoot = await state.filmHandle.getDirectoryHandle(LOC_DIR);
|
|
813
|
+
await moveDirectory(locsRoot, name, dstRoot, dstName);
|
|
814
|
+
} else {
|
|
815
|
+
await moveDirectory(state.filmHandle, name, dstRoot, dstName);
|
|
816
|
+
}
|
|
817
|
+
// Если эта доска сейчас открыта — закрыть
|
|
818
|
+
const wasActive = state.currentBoard?.kind === kind && state.currentBoard.name === name;
|
|
819
|
+
if (wasActive) {
|
|
820
|
+
if (state.currentBoard.urls) {
|
|
821
|
+
for (const u of Object.values(state.currentBoard.urls)) URL.revokeObjectURL(u);
|
|
822
|
+
}
|
|
823
|
+
state.currentBoard = null;
|
|
824
|
+
showEmpty();
|
|
825
|
+
localStorage.removeItem(`lastBoard:${state.filmHandle.name}`);
|
|
826
|
+
}
|
|
827
|
+
if (kind === 'character') await refreshCharacters();
|
|
828
|
+
else if (kind === 'location') await refreshLocations();
|
|
829
|
+
else await refreshEpisodes();
|
|
830
|
+
// Запоминаем для Cmd+Z (board-level undo, в памяти).
|
|
831
|
+
if (!Array.isArray(state.boardUndoStack)) state.boardUndoStack = [];
|
|
832
|
+
state.boardUndoStack.push({ kind, name, dstName, wasActive, ts: Date.now() });
|
|
833
|
+
if (state.boardUndoStack.length > 20) state.boardUndoStack.shift();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Восстанавливает последний удалённый board из _deleted в исходную папку.
|
|
837
|
+
async function undoBoardDelete() {
|
|
838
|
+
if (!state.filmHandle) return false;
|
|
839
|
+
if (!Array.isArray(state.boardUndoStack) || !state.boardUndoStack.length) return false;
|
|
840
|
+
const op = state.boardUndoStack.pop();
|
|
841
|
+
try {
|
|
842
|
+
const delRoot = await state.filmHandle.getDirectoryHandle('_deleted');
|
|
843
|
+
let parent;
|
|
844
|
+
if (op.kind === 'character') parent = await state.filmHandle.getDirectoryHandle(CHAR_DIR, { create: true });
|
|
845
|
+
else if (op.kind === 'location') parent = await state.filmHandle.getDirectoryHandle(LOC_DIR, { create: true });
|
|
846
|
+
else parent = state.filmHandle;
|
|
847
|
+
// Если в исходной папке уже есть та же name — придумаем уникальное.
|
|
848
|
+
let restoreName = op.name;
|
|
849
|
+
try { await parent.getDirectoryHandle(restoreName); restoreName = await uniqueSubdirName(parent, op.name); } catch {}
|
|
850
|
+
await moveDirectory(delRoot, op.dstName, parent, restoreName);
|
|
851
|
+
if (op.kind === 'character') await refreshCharacters();
|
|
852
|
+
else if (op.kind === 'location') await refreshLocations();
|
|
853
|
+
else await refreshEpisodes();
|
|
854
|
+
if (op.wasActive) {
|
|
855
|
+
const list = op.kind === 'character' ? await listCharacters(state.filmHandle)
|
|
856
|
+
: op.kind === 'location' ? await listLocations(state.filmHandle)
|
|
857
|
+
: await listEpisodes(state.filmHandle);
|
|
858
|
+
const found = list.find(x => x.name === restoreName);
|
|
859
|
+
if (found) await selectBoard({ kind: op.kind, ...found });
|
|
860
|
+
}
|
|
861
|
+
return true;
|
|
862
|
+
} catch (e) {
|
|
863
|
+
console.warn('undoBoardDelete failed', e);
|
|
864
|
+
state.boardUndoStack.push(op); // вернуть на место чтобы повторить
|
|
865
|
+
alert('Не удалось восстановить: ' + (e?.message || e));
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Собственный prompt — нативный prompt() в Electron подавлен (deprecated в Chromium).
|
|
871
|
+
// Возвращает строку или null. Esc/Cancel = null. Enter = подтвердить.
|
|
872
|
+
function askName(title, placeholder = '', initialValue = '') {
|
|
873
|
+
return new Promise(resolve => {
|
|
874
|
+
const overlay = document.createElement('div');
|
|
875
|
+
overlay.className = 'modal';
|
|
876
|
+
overlay.style.cssText = 'position:fixed; inset:0; background:rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center; z-index:9999;';
|
|
877
|
+
const box = document.createElement('div');
|
|
878
|
+
box.style.cssText = 'background:#222; border:1px solid #444; border-radius:8px; padding:18px 20px; min-width:360px; box-shadow:0 8px 32px rgba(0,0,0,0.6);';
|
|
879
|
+
const h = document.createElement('h3');
|
|
880
|
+
h.textContent = title;
|
|
881
|
+
h.style.cssText = 'margin:0 0 12px; font-size:14px; color:#e0e0e0;';
|
|
882
|
+
const inp = document.createElement('input');
|
|
883
|
+
inp.type = 'text';
|
|
884
|
+
inp.placeholder = placeholder;
|
|
885
|
+
if (initialValue) inp.value = initialValue;
|
|
886
|
+
inp.style.cssText = 'width:100%; padding:8px 10px; background:#1a1a1a; color:#e0e0e0; border:1px solid #444; border-radius:4px; font-size:14px; margin-bottom:14px;';
|
|
887
|
+
const row = document.createElement('div');
|
|
888
|
+
row.style.cssText = 'display:flex; gap:8px; justify-content:flex-end;';
|
|
889
|
+
const cancel = document.createElement('button');
|
|
890
|
+
cancel.textContent = 'Отмена';
|
|
891
|
+
const ok = document.createElement('button');
|
|
892
|
+
// Если поле уже заполнено — это редактирование существующего объекта,
|
|
893
|
+
// показываем «Сохранить». Иначе создание — «Создать».
|
|
894
|
+
ok.textContent = initialValue ? 'Сохранить' : 'Создать';
|
|
895
|
+
ok.className = 'primary';
|
|
896
|
+
row.append(cancel, ok);
|
|
897
|
+
box.append(h, inp, row); overlay.append(box);
|
|
898
|
+
document.body.append(overlay);
|
|
899
|
+
setTimeout(() => { inp.focus(); inp.select(); }, 30);
|
|
900
|
+
const close = (val) => { overlay.remove(); resolve(val); };
|
|
901
|
+
cancel.addEventListener('click', () => close(null));
|
|
902
|
+
ok.addEventListener('click', () => close(inp.value.trim() || null));
|
|
903
|
+
inp.addEventListener('keydown', e => {
|
|
904
|
+
if (e.key === 'Enter') { e.preventDefault(); close(inp.value.trim() || null); }
|
|
905
|
+
if (e.key === 'Escape') { e.preventDefault(); close(null); }
|
|
906
|
+
});
|
|
907
|
+
overlay.addEventListener('mousedown', e => { if (e.target === overlay) close(null); });
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Лог-буфер: всё что пишем сюда — попадает в console и в window.veLog.
|
|
912
|
+
// Достать из DevTools одной строкой:
|
|
913
|
+
// copy(JSON.stringify(window.veLog, null, 2)) // в буфер
|
|
914
|
+
// window.veLog.map(x=>`[${x.ts}] [${x.level}] ${x.msg}`).join('\n') // как текст
|
|
915
|
+
window.veLog = [];
|
|
916
|
+
function vlog(level, ...parts) {
|
|
917
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
918
|
+
const msg = parts.map(p => typeof p === 'object' ? JSON.stringify(p) : String(p)).join(' ');
|
|
919
|
+
window.veLog.push({ ts, level, msg });
|
|
920
|
+
if (window.veLog.length > 500) window.veLog.shift();
|
|
921
|
+
const fn = level === 'err' ? console.error : (level === 'warn' ? console.warn : console.log);
|
|
922
|
+
fn(`[${ts}] [VE] ${msg}`);
|
|
923
|
+
}
|
|
924
|
+
// Глобальный перехват — нашли что-то в console, что не идёт через vlog.
|
|
925
|
+
window.addEventListener('error', e => vlog('err', 'window error:', e.message, 'at', e.filename + ':' + e.lineno));
|
|
926
|
+
window.addEventListener('unhandledrejection', e => vlog('err', 'unhandled rejection:', e.reason?.message || String(e.reason)));
|
|
927
|
+
// Удобный шорткат: window.veDump() выводит лог одним блоком и копирует в буфер.
|
|
928
|
+
window.veDump = () => {
|
|
929
|
+
const txt = window.veLog.map(x => `[${x.ts}] [${x.level}] ${x.msg}`).join('\n');
|
|
930
|
+
console.log(txt || '(пусто)');
|
|
931
|
+
// copyText определён ниже — используем typeof guard на случай вызова до его инициализации.
|
|
932
|
+
if (typeof copyText === 'function') copyText(txt);
|
|
933
|
+
else if (navigator.clipboard?.writeText) navigator.clipboard.writeText(txt).catch(() => {});
|
|
934
|
+
return txt;
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
$('newEpisode').addEventListener('click', async () => {
|
|
938
|
+
vlog('info', 'newEpisode click; hasFilm=' + !!state.filmHandle);
|
|
939
|
+
if (!state.filmHandle) { alert('Сначала открой проект (папку).'); return; }
|
|
940
|
+
try {
|
|
941
|
+
const name = await askName('Название сцены:', 'например, Сцена 1');
|
|
942
|
+
vlog('info', 'newEpisode name=' + JSON.stringify(name));
|
|
943
|
+
if (!name) return;
|
|
944
|
+
await state.filmHandle.getDirectoryHandle(name, { create: true });
|
|
945
|
+
await refreshEpisodes();
|
|
946
|
+
vlog('info', 'newEpisode created: ' + name);
|
|
947
|
+
} catch (e) {
|
|
948
|
+
vlog('err', 'newEpisode failed: ' + (e?.message || e));
|
|
949
|
+
alert('Ошибка: ' + (e?.message || e));
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
$('newCharacter').addEventListener('click', async () => {
|
|
954
|
+
vlog('info', 'newCharacter click; hasFilm=' + !!state.filmHandle);
|
|
955
|
+
if (!state.filmHandle) { alert('Сначала открой проект (папку).'); return; }
|
|
956
|
+
try {
|
|
957
|
+
const name = await askName('Имя персонажа:', 'например, Анна');
|
|
958
|
+
vlog('info', 'newCharacter name=' + JSON.stringify(name));
|
|
959
|
+
if (!name) return;
|
|
960
|
+
const root = await state.filmHandle.getDirectoryHandle(CHAR_DIR, { create: true });
|
|
961
|
+
await root.getDirectoryHandle(name, { create: true });
|
|
962
|
+
await refreshCharacters();
|
|
963
|
+
vlog('info', 'newCharacter created: ' + name);
|
|
964
|
+
} catch (e) {
|
|
965
|
+
vlog('err', 'newCharacter failed: ' + (e?.message || e));
|
|
966
|
+
alert('Ошибка: ' + (e?.message || e));
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
$('newLocation').addEventListener('click', async () => {
|
|
971
|
+
vlog('info', 'newLocation click; hasFilm=' + !!state.filmHandle);
|
|
972
|
+
if (!state.filmHandle) { alert('Сначала открой проект (папку).'); return; }
|
|
973
|
+
try {
|
|
974
|
+
const name = await askName('Название локации:', 'например, Кухня');
|
|
975
|
+
vlog('info', 'newLocation name=' + JSON.stringify(name));
|
|
976
|
+
if (!name) return;
|
|
977
|
+
const root = await state.filmHandle.getDirectoryHandle(LOC_DIR, { create: true });
|
|
978
|
+
await root.getDirectoryHandle(name, { create: true });
|
|
979
|
+
await refreshLocations();
|
|
980
|
+
vlog('info', 'newLocation created: ' + name);
|
|
981
|
+
} catch (e) {
|
|
982
|
+
vlog('err', 'newLocation failed: ' + (e?.message || e));
|
|
983
|
+
alert('Ошибка: ' + (e?.message || e));
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// =================== Board (универсально для серии и персонажа) ===================
|
|
988
|
+
async function selectBoard(board) {
|
|
989
|
+
if (state.currentBoard?.urls) {
|
|
990
|
+
for (const url of Object.values(state.currentBoard.urls)) URL.revokeObjectURL(url);
|
|
991
|
+
}
|
|
992
|
+
clearSelection();
|
|
993
|
+
state.selectedClipIds.clear();
|
|
994
|
+
state.selectedTrackIds.clear();
|
|
995
|
+
const meta = await loadBoardMetadata(board.handle);
|
|
996
|
+
state.currentBoard = {
|
|
997
|
+
...board,
|
|
998
|
+
key: boardKey(board.kind, board.name),
|
|
999
|
+
metadata: {
|
|
1000
|
+
nodes: meta.nodes,
|
|
1001
|
+
connections: meta.connections || [],
|
|
1002
|
+
view: meta.view || null,
|
|
1003
|
+
character: meta.character || null,
|
|
1004
|
+
location: meta.location || null,
|
|
1005
|
+
timeline: meta.timeline || [],
|
|
1006
|
+
history: meta.history || { past: [], future: [] },
|
|
1007
|
+
},
|
|
1008
|
+
urls: {},
|
|
1009
|
+
};
|
|
1010
|
+
if (meta._migrated) await saveBoardMetadata(board.handle, state.currentBoard.metadata);
|
|
1011
|
+
|
|
1012
|
+
$('addText').disabled = false; $('genText').disabled = false; $('genAudio').disabled = false; $('genImage').disabled = false; $('genVideo').disabled = false; $('genSfx').disabled = false; $('genMusic').disabled = false;
|
|
1013
|
+
|
|
1014
|
+
$('path').textContent = `${state.filmHandle.name} / ${board.name}`;
|
|
1015
|
+
// Тип board (Сцена/Персонаж/Локация) убрали из верхней панели —
|
|
1016
|
+
// достаточно того что выделено в sidebar. Badge оставлен в DOM как
|
|
1017
|
+
// hidden — на случай если где-то логика проверяет его наличие.
|
|
1018
|
+
$('boardBadge').style.display = 'none';
|
|
1019
|
+
// Имя выбранного board в шапке sidebar
|
|
1020
|
+
const boardEl = $('brandBoard');
|
|
1021
|
+
if (boardEl) {
|
|
1022
|
+
boardEl.textContent = board.name;
|
|
1023
|
+
boardEl.style.display = '';
|
|
1024
|
+
}
|
|
1025
|
+
emptyState.classList.add('hidden');
|
|
1026
|
+
$('charSettingsBtn').style.display = board.kind === 'character' ? '' : 'none';
|
|
1027
|
+
// Панель реплик скрыта (юзер попросил убрать из карточки персонажа).
|
|
1028
|
+
// Сами данные реплик в scene.json не трогаем — таймлайн и character-озвучка
|
|
1029
|
+
// продолжают работать. Если понадобится вернуть — расскомментируй ниже.
|
|
1030
|
+
$('repliquesPanel').classList.add('hidden');
|
|
1031
|
+
localStorage.setItem('repliquesOpen', '0');
|
|
1032
|
+
// if (board.kind === 'character') {
|
|
1033
|
+
// openRepliquesFor(board.name).catch(e => console.warn('replicas open failed', e));
|
|
1034
|
+
// }
|
|
1035
|
+
localStorage.setItem(`lastBoard:${state.filmHandle.name}`,
|
|
1036
|
+
JSON.stringify({ kind: board.kind, name: board.name }));
|
|
1037
|
+
await refreshSidebar();
|
|
1038
|
+
await renderCanvas();
|
|
1039
|
+
// Сбрасываем кэш URL клипов и декодированных AudioBuffer от прошлой доски
|
|
1040
|
+
for (const url of _clipURLCache.values()) URL.revokeObjectURL(url);
|
|
1041
|
+
_clipURLCache.clear();
|
|
1042
|
+
_audioBufferCache.clear();
|
|
1043
|
+
// Thumbnails — тоже revoke предыдущей доски (иначе утечка blob URLs)
|
|
1044
|
+
for (const url of _thumbUrls.values()) URL.revokeObjectURL(url);
|
|
1045
|
+
_thumbUrls.clear();
|
|
1046
|
+
// Восстанавливаем playhead из scene.json (если был сохранён)
|
|
1047
|
+
const tlMeta = state.currentBoard.metadata.timeline;
|
|
1048
|
+
state.playheadTime = (tlMeta && typeof tlMeta.playhead === 'number') ? tlMeta.playhead : 0;
|
|
1049
|
+
// Если таймлайн уже открыт — перерендерить и обновить превью
|
|
1050
|
+
if (!$('timelinePanel').classList.contains('hidden')) {
|
|
1051
|
+
renderTimeline();
|
|
1052
|
+
scheduleUpdatePreview();
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Восстановить pan/zoom доски
|
|
1056
|
+
const view = state.currentBoard.metadata.view;
|
|
1057
|
+
if (view) {
|
|
1058
|
+
if (typeof view.zoom === 'number') {
|
|
1059
|
+
state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, view.zoom));
|
|
1060
|
+
applyZoomStyles(state.zoom);
|
|
1061
|
+
$('zoomLabel').textContent = Math.round(state.zoom * 100) + '%';
|
|
1062
|
+
}
|
|
1063
|
+
if (typeof view.scrollLeft === 'number') canvasWrap.scrollLeft = view.scrollLeft;
|
|
1064
|
+
if (typeof view.scrollTop === 'number') canvasWrap.scrollTop = view.scrollTop;
|
|
1065
|
+
} else {
|
|
1066
|
+
state.zoom = 1;
|
|
1067
|
+
applyZoomStyles(1);
|
|
1068
|
+
$('zoomLabel').textContent = '100%';
|
|
1069
|
+
canvasWrap.scrollLeft = 0;
|
|
1070
|
+
canvasWrap.scrollTop = 0;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Возобновить незавершённые джобы текущей доски
|
|
1074
|
+
for (const n of state.currentBoard.metadata.nodes) {
|
|
1075
|
+
if (n.status === 'generating' && n.generated?.taskId && !state.jobs.has(n.id)) {
|
|
1076
|
+
resumeJob(n, state.currentBoard.key, state.currentBoard.handle);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function ensureConnectionsSvg() {
|
|
1082
|
+
let svg = $('connectionsSvg');
|
|
1083
|
+
if (svg && svg.parentNode === canvas) return svg;
|
|
1084
|
+
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
1085
|
+
svg.id = 'connectionsSvg';
|
|
1086
|
+
svg.setAttribute('class', 'connections-layer');
|
|
1087
|
+
svg.setAttribute('width', '6000');
|
|
1088
|
+
svg.setAttribute('height', '4000');
|
|
1089
|
+
canvas.appendChild(svg);
|
|
1090
|
+
return svg;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function clearCanvasKeepSvg() {
|
|
1094
|
+
const svg = ensureConnectionsSvg();
|
|
1095
|
+
for (const child of [...canvas.children]) {
|
|
1096
|
+
if (child !== svg) child.remove();
|
|
1097
|
+
}
|
|
1098
|
+
if (canvas.firstChild !== svg) canvas.insertBefore(svg, canvas.firstChild);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function showEmpty() {
|
|
1102
|
+
clearCanvasKeepSvg();
|
|
1103
|
+
emptyState.classList.remove('hidden');
|
|
1104
|
+
$('addText').disabled = true; $('genText').disabled = true; $('genAudio').disabled = true; $('genImage').disabled = true; $('genVideo').disabled = true; $('genSfx').disabled = true; $('genMusic').disabled = true;
|
|
1105
|
+
|
|
1106
|
+
$('path').textContent = '';
|
|
1107
|
+
$('boardBadge').style.display = 'none';
|
|
1108
|
+
$('charSettingsBtn').style.display = 'none';
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async function renderCanvas() {
|
|
1112
|
+
clearCanvasKeepSvg();
|
|
1113
|
+
for (const node of state.currentBoard.metadata.nodes) {
|
|
1114
|
+
canvas.appendChild(await createNodeEl(node));
|
|
1115
|
+
}
|
|
1116
|
+
renderConnections();
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// =================== Connections (SVG) ===================
|
|
1120
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
1121
|
+
|
|
1122
|
+
function bezierPath(x1, y1, x2, y2) {
|
|
1123
|
+
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
|
1124
|
+
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// БЕЗ чтения offsetWidth/Height из DOM:
|
|
1128
|
+
// - offsetWidth/Height на content-visibility:auto-элементе ФОРСИТ материализацию
|
|
1129
|
+
// (Chrome обязан посчитать реальные размеры → теряем весь смысл оффскрин-кулинга,
|
|
1130
|
+
// selectBoard 338ms layout с dirty=45 of 784 — все 784 объекта пробуждались)
|
|
1131
|
+
// - Используем кэш в n.width/n.height; обновляется ResizeObserver'ом ниже.
|
|
1132
|
+
// Дефолт по типу — на первый рендер пока размер не измерен.
|
|
1133
|
+
function _defaultH(n) {
|
|
1134
|
+
if (n.type === 'text') return n.height || 120;
|
|
1135
|
+
if (n.type === 'audio') return n.height || 110;
|
|
1136
|
+
if (n.type === 'image') return n.height || 220;
|
|
1137
|
+
if (n.type === 'video') return n.height || 220;
|
|
1138
|
+
return n.height || 80;
|
|
1139
|
+
}
|
|
1140
|
+
function nodeAnchorOut(n) {
|
|
1141
|
+
return { x: n.x + (n.width || 280), y: n.y + _defaultH(n) / 2 };
|
|
1142
|
+
}
|
|
1143
|
+
function nodeAnchorIn(n) {
|
|
1144
|
+
return { x: n.x, y: n.y + _defaultH(n) / 2 };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function renderConnections() {
|
|
1148
|
+
const svg = $('connectionsSvg');
|
|
1149
|
+
if (!svg) return;
|
|
1150
|
+
// Удаляем все, кроме временной линии
|
|
1151
|
+
for (const ch of [...svg.children]) if (!ch.classList.contains('temp-line')) ch.remove();
|
|
1152
|
+
if (!state.currentBoard) return;
|
|
1153
|
+
const nodes = state.currentBoard.metadata.nodes;
|
|
1154
|
+
const conns = state.currentBoard.metadata.connections || [];
|
|
1155
|
+
for (const c of conns) {
|
|
1156
|
+
const f = nodes.find(n => n.id === c.from);
|
|
1157
|
+
const t = nodes.find(n => n.id === c.to);
|
|
1158
|
+
if (!f || !t) continue;
|
|
1159
|
+
const p1 = nodeAnchorOut(f);
|
|
1160
|
+
const p2 = nodeAnchorIn(t);
|
|
1161
|
+
const d = bezierPath(p1.x, p1.y, p2.x, p2.y);
|
|
1162
|
+
const g = document.createElementNS(SVG_NS, 'g');
|
|
1163
|
+
g.setAttribute('class', 'conn');
|
|
1164
|
+
g.dataset.id = c.id;
|
|
1165
|
+
const hit = document.createElementNS(SVG_NS, 'path');
|
|
1166
|
+
hit.setAttribute('class', 'hit');
|
|
1167
|
+
hit.setAttribute('d', d);
|
|
1168
|
+
const line = document.createElementNS(SVG_NS, 'path');
|
|
1169
|
+
line.setAttribute('class', 'line');
|
|
1170
|
+
line.setAttribute('d', d);
|
|
1171
|
+
g.appendChild(hit);
|
|
1172
|
+
g.appendChild(line);
|
|
1173
|
+
g.addEventListener('click', () => removeConnection(c.id));
|
|
1174
|
+
svg.appendChild(g);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function addConnection(fromId, toId) {
|
|
1179
|
+
if (!fromId || !toId || fromId === toId) return;
|
|
1180
|
+
const conns = state.currentBoard.metadata.connections;
|
|
1181
|
+
if (conns.some(c => c.from === fromId && c.to === toId)) return;
|
|
1182
|
+
conns.push({ id: crypto.randomUUID(), from: fromId, to: toId });
|
|
1183
|
+
renderConnections();
|
|
1184
|
+
scheduleSave();
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function removeConnection(id) {
|
|
1188
|
+
const conns = state.currentBoard.metadata.connections;
|
|
1189
|
+
const i = conns.findIndex(c => c.id === id);
|
|
1190
|
+
if (i < 0) return;
|
|
1191
|
+
conns.splice(i, 1);
|
|
1192
|
+
renderConnections();
|
|
1193
|
+
scheduleSave();
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function createNodeEl(node) {
|
|
1197
|
+
const el = document.createElement('div');
|
|
1198
|
+
el.className = 'node';
|
|
1199
|
+
if (node.type === 'text') el.classList.add('text-node');
|
|
1200
|
+
el.dataset.id = node.id;
|
|
1201
|
+
el.style.left = node.x + 'px';
|
|
1202
|
+
el.style.top = node.y + 'px';
|
|
1203
|
+
if (node.width) el.style.width = node.width + 'px';
|
|
1204
|
+
if (node.height) el.style.height = node.height + 'px';
|
|
1205
|
+
|
|
1206
|
+
const header = document.createElement('div');
|
|
1207
|
+
header.className = 'node-header';
|
|
1208
|
+
header.title = 'Тяни, чтобы переместить. Двойной клик по имени или ПКМ → Переименовать.';
|
|
1209
|
+
|
|
1210
|
+
// Имя ноды (если задано). Узкий header — обрезаем ellipsis'ом.
|
|
1211
|
+
const nameEl = document.createElement('span');
|
|
1212
|
+
nameEl.className = 'name';
|
|
1213
|
+
if (node.name) nameEl.textContent = node.name;
|
|
1214
|
+
else { nameEl.textContent = '—'; nameEl.classList.add('empty'); }
|
|
1215
|
+
// Дабл-клик по имени → переименовать. Не блокируем drag-mousedown.
|
|
1216
|
+
nameEl.addEventListener('dblclick', e => {
|
|
1217
|
+
e.stopPropagation();
|
|
1218
|
+
renameNode(node);
|
|
1219
|
+
});
|
|
1220
|
+
header.appendChild(nameEl);
|
|
1221
|
+
|
|
1222
|
+
const del = document.createElement('span');
|
|
1223
|
+
del.className = 'delete'; del.textContent = '×'; del.title = 'Удалить ноду';
|
|
1224
|
+
header.appendChild(del);
|
|
1225
|
+
el.appendChild(header);
|
|
1226
|
+
|
|
1227
|
+
const body = document.createElement('div');
|
|
1228
|
+
body.className = 'node-body';
|
|
1229
|
+
el.appendChild(body);
|
|
1230
|
+
|
|
1231
|
+
await renderNodeBody(node, body);
|
|
1232
|
+
|
|
1233
|
+
del.addEventListener('click', e => {
|
|
1234
|
+
e.stopPropagation();
|
|
1235
|
+
deleteNode(node, el);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
if (['image','video','audio'].includes(node.type) && node.generated) {
|
|
1239
|
+
const footer = document.createElement('div');
|
|
1240
|
+
footer.className = 'node-footer';
|
|
1241
|
+
el.appendChild(footer);
|
|
1242
|
+
updateNodeFooter(node, footer);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const rh = document.createElement('div');
|
|
1246
|
+
rh.className = 'resize-handle';
|
|
1247
|
+
el.appendChild(rh);
|
|
1248
|
+
attachResize(el, node, rh);
|
|
1249
|
+
|
|
1250
|
+
const anchor = document.createElement('div');
|
|
1251
|
+
anchor.className = 'anchor';
|
|
1252
|
+
anchor.title = 'Тяни, чтобы сослаться или сгенерировать ноду со ссылкой';
|
|
1253
|
+
el.appendChild(anchor);
|
|
1254
|
+
attachAnchor(node, el, anchor);
|
|
1255
|
+
|
|
1256
|
+
attachDrag(el, node);
|
|
1257
|
+
|
|
1258
|
+
el.addEventListener('dblclick', e => {
|
|
1259
|
+
if (e.target.closest('textarea, input, video, button, .delete, .anchor, .resize-handle')) return;
|
|
1260
|
+
if (node.type === 'audio' && node.file) {
|
|
1261
|
+
regenerateNode(node);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
if ((node.type === 'image' || node.type === 'video') && node.file) {
|
|
1265
|
+
openFullscreen(node);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (node.generated) showNodeSettings(node);
|
|
1269
|
+
});
|
|
1270
|
+
el.addEventListener('contextmenu', e => {
|
|
1271
|
+
if (e.target.closest('textarea, input, .anchor, .resize-handle')) return;
|
|
1272
|
+
e.preventDefault();
|
|
1273
|
+
e.stopPropagation();
|
|
1274
|
+
showNodeContextMenu(node, e.clientX, e.clientY);
|
|
1275
|
+
});
|
|
1276
|
+
return el;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// =================== Контекстное меню ноды (ПКМ) ===================
|
|
1280
|
+
async function renameNode(node) {
|
|
1281
|
+
const current = node.name || '';
|
|
1282
|
+
const newName = await askName('Имя ноды (для @-ссылок):', '', current);
|
|
1283
|
+
if (newName == null) return;
|
|
1284
|
+
const trimmed = newName.trim();
|
|
1285
|
+
// Текст-нода: переименовать соответствующий .md файл
|
|
1286
|
+
if (trimmed && node.type === 'text' && node.file) {
|
|
1287
|
+
const baseDesired = trimmed + '.md';
|
|
1288
|
+
const currentBase = node.file.split('/').pop();
|
|
1289
|
+
if (baseDesired !== currentBase) {
|
|
1290
|
+
try {
|
|
1291
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
|
|
1292
|
+
const content = await (await fh.getFile()).text();
|
|
1293
|
+
const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
|
|
1294
|
+
const newBase = await uniqueName(dir, baseDesired);
|
|
1295
|
+
await writeFile(dir, newBase, content);
|
|
1296
|
+
await removeBoardFile(state.currentBoard.handle, node.file);
|
|
1297
|
+
node.file = `texts/${newBase}`;
|
|
1298
|
+
} catch (e) { console.error('rename .md failed', e); }
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
node.name = trimmed;
|
|
1302
|
+
scheduleSave();
|
|
1303
|
+
// Обновим header — имя ноды показывается в .node-header .name.
|
|
1304
|
+
const nodeEl = canvas.querySelector(`.node[data-id="${node.id}"]`);
|
|
1305
|
+
const nameEl = nodeEl?.querySelector('.node-header .name');
|
|
1306
|
+
if (nameEl) {
|
|
1307
|
+
if (trimmed) {
|
|
1308
|
+
nameEl.textContent = trimmed;
|
|
1309
|
+
nameEl.classList.remove('empty');
|
|
1310
|
+
} else {
|
|
1311
|
+
nameEl.textContent = '—';
|
|
1312
|
+
nameEl.classList.add('empty');
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function showNodeContextMenu(node, clientX, clientY) {
|
|
1318
|
+
const menu = $('nodeMenu');
|
|
1319
|
+
menu.innerHTML = '';
|
|
1320
|
+
const add = (label, fn, opts = {}) => {
|
|
1321
|
+
const b = document.createElement('button');
|
|
1322
|
+
b.textContent = label;
|
|
1323
|
+
if (opts.danger) b.style.color = '#f88';
|
|
1324
|
+
if (opts.disabled) { b.disabled = true; b.style.opacity = '0.4'; b.style.cursor = 'default'; }
|
|
1325
|
+
b.addEventListener('click', () => { if (b.disabled) return; menu.classList.add('hidden'); fn(); });
|
|
1326
|
+
menu.appendChild(b);
|
|
1327
|
+
};
|
|
1328
|
+
add(node.name ? `✏ Переименовать (${node.name})` : '✏ Переименовать', () => renameNode(node));
|
|
1329
|
+
add('📋 Логи', () => showNodeLogs(node));
|
|
1330
|
+
if (node.generated) add('⚙ Параметры', () => showNodeSettings(node));
|
|
1331
|
+
if (node.status === 'generating') {
|
|
1332
|
+
add('⏹ Остановить', () => stopJob(node.id));
|
|
1333
|
+
add('↻ Перезапустить', () => restartJob(node.id));
|
|
1334
|
+
} else if (node.status === 'draft') {
|
|
1335
|
+
add('▶ Запустить генерацию', () => regenerateNode(node));
|
|
1336
|
+
add('✎ Изменить и запустить', () => regenerateNode(node));
|
|
1337
|
+
} else if (node.generated) {
|
|
1338
|
+
add('↻ Перегенерировать (правка)', () => regenerateNode(node));
|
|
1339
|
+
}
|
|
1340
|
+
if (node.type === 'image' || node.type === 'video' || node.type === 'audio') {
|
|
1341
|
+
add('➕ В таймлайн', () => addToTimeline(node));
|
|
1342
|
+
}
|
|
1343
|
+
// Локация: пометить картинку как location sheet
|
|
1344
|
+
if (node.type === 'image' && node.file && state.currentBoard?.kind === 'location') {
|
|
1345
|
+
const isSheet = state.currentBoard.metadata.location?.sheet === node.file;
|
|
1346
|
+
add(isSheet ? '⭐ Это sheet локации' : '⭐ Сделать sheet локации', () => {
|
|
1347
|
+
if (!state.currentBoard.metadata.location) state.currentBoard.metadata.location = {};
|
|
1348
|
+
state.currentBoard.metadata.location.sheet = isSheet ? null : node.file;
|
|
1349
|
+
scheduleSave();
|
|
1350
|
+
loadAllLocationsInfo().catch(() => {});
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
// Персонаж: пометить картинку как character sheet
|
|
1354
|
+
if (node.type === 'image' && node.file && state.currentBoard?.kind === 'character') {
|
|
1355
|
+
const isSheet = state.currentBoard.metadata.character?.characterSheet === node.file;
|
|
1356
|
+
add(isSheet ? '⭐ Это character sheet' : '⭐ Сделать character sheet', () => {
|
|
1357
|
+
if (!state.currentBoard.metadata.character) state.currentBoard.metadata.character = {};
|
|
1358
|
+
state.currentBoard.metadata.character.characterSheet = isSheet ? null : node.file;
|
|
1359
|
+
scheduleSave();
|
|
1360
|
+
loadAllCharactersInfo().catch(() => {});
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
add('⎘ Скопировать (Cmd+C)', () => copyNodeToClipboard(node));
|
|
1364
|
+
add('✂ Вырезать (Cmd+X)', async () => {
|
|
1365
|
+
await copyNodeToClipboard(node);
|
|
1366
|
+
state.selectedNodeIds = new Set([node.id]);
|
|
1367
|
+
await deleteSelectedNodes();
|
|
1368
|
+
});
|
|
1369
|
+
const canPaste = !!(state.clipboard?.length);
|
|
1370
|
+
add('📥 Вставить (Cmd+V)', () => pasteClipboardNodes(), { disabled: !canPaste });
|
|
1371
|
+
const canReplace = !!(state.clipboard?.length);
|
|
1372
|
+
add('⇄ Заменить из буфера', () => replaceNodeFromClipboard(node), { disabled: !canReplace });
|
|
1373
|
+
if (node.type === 'audio' && node.file) {
|
|
1374
|
+
add('⬇ Скачать аудио', async () => {
|
|
1375
|
+
try {
|
|
1376
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
|
|
1377
|
+
const file = await fh.getFile();
|
|
1378
|
+
await downloadAudioAtSpeed(file, node.file, 1);
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
console.error('Download failed:', err);
|
|
1381
|
+
alert('Не удалось скачать: ' + (err?.message || err));
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
add('🗑 Удалить', () => {
|
|
1386
|
+
const el = canvas.querySelector(`.node[data-id="${node.id}"]`);
|
|
1387
|
+
deleteNode(node, el);
|
|
1388
|
+
}, { danger: true });
|
|
1389
|
+
// Позиционируем не выходя за экран
|
|
1390
|
+
positionFloatingMenu(menu, clientX, clientY);
|
|
1391
|
+
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function closeNodeMenu(e) {
|
|
1395
|
+
// Клик по активной кнопке меню — даём её click-обработчику сработать (он сам закроет меню),
|
|
1396
|
+
// а пока перевзводим listener, чтобы случайно не закрыть до click-а.
|
|
1397
|
+
if (e && e.target.closest('#nodeMenu button:not([disabled])')) {
|
|
1398
|
+
document.addEventListener('mousedown', closeNodeMenu, { once: true });
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
// Любой другой клик (по фону меню, по холсту, по тексту-заголовку и т.д.) — закрываем
|
|
1402
|
+
$('nodeMenu').classList.add('hidden');
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function showNodeLogs(node) {
|
|
1406
|
+
const log = state.jobLogs.get(node.id) || [];
|
|
1407
|
+
const meta = [];
|
|
1408
|
+
meta.push(`id: ${node.id}`);
|
|
1409
|
+
meta.push(`type: ${node.type}`);
|
|
1410
|
+
meta.push(`name: ${node.name || '(без имени)'}`);
|
|
1411
|
+
meta.push(`file: ${node.file || '—'}`);
|
|
1412
|
+
meta.push(`status: ${node.status || 'ok'}`);
|
|
1413
|
+
if (node.error) meta.push(`error: ${node.error}`);
|
|
1414
|
+
if (node.generated) {
|
|
1415
|
+
meta.push(`gen.kind: ${node.generated.kind}`);
|
|
1416
|
+
meta.push(`gen.model: ${node.generated.model || node.generated.modelKey || '—'}`);
|
|
1417
|
+
meta.push(`gen.taskId: ${node.generated.taskId || '—'}`);
|
|
1418
|
+
meta.push(`gen.state: ${node.generated.state || '—'}`);
|
|
1419
|
+
if (typeof node.generated.creditsCharged === 'number') {
|
|
1420
|
+
meta.push(`gen.cost: ${node.generated.creditsCharged} credits`);
|
|
1421
|
+
}
|
|
1422
|
+
if (node.generated.voiceId) meta.push(`gen.voice: ${node.generated.voiceName || node.generated.voiceId}`);
|
|
1423
|
+
if (node.generated.tones?.length) meta.push(`gen.tones: ${node.generated.tones.join(', ')}`);
|
|
1424
|
+
if (node.generated.refs?.length) {
|
|
1425
|
+
meta.push(`gen.refs: ${node.generated.refs.map(r => `@${r.name}(${r.type})`).join(', ')}`);
|
|
1426
|
+
}
|
|
1427
|
+
if (node.generated.prompt) meta.push(`gen.prompt: ${node.generated.prompt}`);
|
|
1428
|
+
if (node.generated.rawPrompt && node.generated.rawPrompt !== node.generated.prompt) {
|
|
1429
|
+
meta.push(`gen.rawPrompt: ${node.generated.rawPrompt}`);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
if (node.history?.length) {
|
|
1433
|
+
meta.push(`history: ${node.history.length} версий, текущая ${(node.historyIndex ?? 0) + 1}`);
|
|
1434
|
+
}
|
|
1435
|
+
meta.push('');
|
|
1436
|
+
meta.push('--- timeline ---');
|
|
1437
|
+
const start = log[0]?.ts || Date.now();
|
|
1438
|
+
for (const e of log) {
|
|
1439
|
+
const dt = ((e.ts - start) / 1000).toFixed(2).padStart(7);
|
|
1440
|
+
meta.push(`+${dt}s ${e.msg}`);
|
|
1441
|
+
}
|
|
1442
|
+
if (!log.length) meta.push('(нет записей)');
|
|
1443
|
+
$('logsContent').textContent = meta.join('\n');
|
|
1444
|
+
$('logsModal').classList.remove('hidden');
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
$('logsClose').addEventListener('click', () => $('logsModal').classList.add('hidden'));
|
|
1448
|
+
|
|
1449
|
+
// =================== Полноэкранный просмотр ===================
|
|
1450
|
+
async function openFullscreen(node) {
|
|
1451
|
+
const stage = $('fsStage');
|
|
1452
|
+
stage.innerHTML = '';
|
|
1453
|
+
try {
|
|
1454
|
+
let url = state.currentBoard?.urls?.[node.file];
|
|
1455
|
+
if (!url && state.currentBoard?.handle) {
|
|
1456
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
|
|
1457
|
+
url = URL.createObjectURL(await fh.getFile());
|
|
1458
|
+
state.currentBoard.urls[node.file] = url;
|
|
1459
|
+
}
|
|
1460
|
+
if (!url) return;
|
|
1461
|
+
if (node.type === 'image') {
|
|
1462
|
+
const img = document.createElement('img');
|
|
1463
|
+
img.src = url;
|
|
1464
|
+
stage.appendChild(img);
|
|
1465
|
+
} else if (node.type === 'video') {
|
|
1466
|
+
const v = document.createElement('video');
|
|
1467
|
+
v.src = url;
|
|
1468
|
+
v.controls = true;
|
|
1469
|
+
v.autoplay = true;
|
|
1470
|
+
stage.appendChild(v);
|
|
1471
|
+
}
|
|
1472
|
+
$('fsModal').classList.remove('hidden');
|
|
1473
|
+
} catch (e) {
|
|
1474
|
+
console.error('openFullscreen failed:', e);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
function closeFullscreen() {
|
|
1478
|
+
const modal = $('fsModal');
|
|
1479
|
+
if (modal.classList.contains('hidden')) return;
|
|
1480
|
+
const v = $('fsStage').querySelector('video');
|
|
1481
|
+
if (v) { try { v.pause(); } catch {} }
|
|
1482
|
+
$('fsStage').innerHTML = '';
|
|
1483
|
+
modal.classList.add('hidden');
|
|
1484
|
+
}
|
|
1485
|
+
$('fsClose').addEventListener('click', closeFullscreen);
|
|
1486
|
+
$('fsModal').addEventListener('click', e => {
|
|
1487
|
+
if (e.target.id === 'fsModal' || e.target.id === 'fsStage') closeFullscreen();
|
|
1488
|
+
});
|
|
1489
|
+
window.addEventListener('keydown', e => {
|
|
1490
|
+
if (e.key === 'Escape' && !$('fsModal').classList.contains('hidden')) {
|
|
1491
|
+
e.stopPropagation();
|
|
1492
|
+
closeFullscreen();
|
|
1493
|
+
}
|
|
1494
|
+
}, true);
|
|
1495
|
+
// Универсальный copy-helper: clipboard API → fallback на execCommand.
|
|
1496
|
+
async function copyText(text) {
|
|
1497
|
+
try {
|
|
1498
|
+
if (navigator.clipboard?.writeText) {
|
|
1499
|
+
await navigator.clipboard.writeText(text);
|
|
1500
|
+
return true;
|
|
1501
|
+
}
|
|
1502
|
+
} catch {}
|
|
1503
|
+
// Fallback: execCommand работает в Electron без permission.
|
|
1504
|
+
try {
|
|
1505
|
+
const ta = document.createElement('textarea');
|
|
1506
|
+
ta.value = text;
|
|
1507
|
+
ta.style.cssText = 'position:fixed; opacity:0; pointer-events:none;';
|
|
1508
|
+
document.body.appendChild(ta);
|
|
1509
|
+
ta.select();
|
|
1510
|
+
const ok = document.execCommand('copy');
|
|
1511
|
+
ta.remove();
|
|
1512
|
+
return !!ok;
|
|
1513
|
+
} catch { return false; }
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
$('logsCopy').addEventListener('click', async () => {
|
|
1517
|
+
const orig = $('logsCopy').textContent;
|
|
1518
|
+
const ok = await copyText($('logsContent').textContent);
|
|
1519
|
+
$('logsCopy').textContent = ok ? '✓ Скопировано' : '✕ Не получилось';
|
|
1520
|
+
setTimeout(() => { $('logsCopy').textContent = orig; }, 1500);
|
|
1521
|
+
});
|
|
1522
|
+
|