kingkont 0.20.6 → 0.20.8
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/index.html +1 -1
- package/package.json +1 -1
- package/renderer/board.js +237 -22
- package/renderer/cloudProjects.js +15 -0
- package/renderer/generate.js +67 -5
- package/renderer/settings.js +1 -1
package/index.html
CHANGED
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -17,7 +17,19 @@ const emptyState = $('emptyState');
|
|
|
17
17
|
|
|
18
18
|
// =================== Init ===================
|
|
19
19
|
window.addEventListener('DOMContentLoaded', async () => {
|
|
20
|
-
|
|
20
|
+
// FSAH (showDirectoryPicker) — Chrome-only API. Без него работают только
|
|
21
|
+
// облачные проекты. В web-mode (Safari/Firefox) → продолжаем init без
|
|
22
|
+
// папочного функционала; кнопки «Открыть папку» скрыты через body.web-mode CSS.
|
|
23
|
+
// В Electron / desktop Chrome — без FSAH вообще ничего не работало бы,
|
|
24
|
+
// показываем заглушку.
|
|
25
|
+
if (!('showDirectoryPicker' in window)) {
|
|
26
|
+
if (!window.__KINGKONT_WEB__) { showUnsupported(); return; }
|
|
27
|
+
// Прячем папочные UI-элементы — без FSAH они нерабочие.
|
|
28
|
+
for (const id of ['pickRoot', 'welcomeOpen']) {
|
|
29
|
+
const el = document.getElementById(id);
|
|
30
|
+
if (el) el.style.display = 'none';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
21
33
|
// По умолчанию проект не открыт — секции персонажей/локаций/сцен скрыты.
|
|
22
34
|
document.body.classList.add('no-project');
|
|
23
35
|
loadVoices().catch(() => {}); // preload голосов в фоне
|
|
@@ -711,23 +723,26 @@ async function _renderWelcomeRecentsInner() {
|
|
|
711
723
|
|
|
712
724
|
// Первой картой — «Открыть проект». Кликается → дёргает скрытый
|
|
713
725
|
// #pickRoot button (тот же, что использует app menu).
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
726
|
+
// Скрываем в браузерах без FSAH (Safari/Firefox) — там кнопка не сработает.
|
|
727
|
+
if ('showDirectoryPicker' in window) {
|
|
728
|
+
const openCard = document.createElement('div');
|
|
729
|
+
openCard.className = 'welcome-card open-card';
|
|
730
|
+
const openThumb = document.createElement('div');
|
|
731
|
+
openThumb.className = 'welcome-card-thumb';
|
|
732
|
+
openThumb.textContent = '+';
|
|
733
|
+
const openMeta = document.createElement('div');
|
|
734
|
+
openMeta.className = 'welcome-card-meta';
|
|
735
|
+
const openName = document.createElement('div');
|
|
736
|
+
openName.className = 'welcome-card-name';
|
|
737
|
+
openName.textContent = 'Открыть проект';
|
|
738
|
+
const openSub = document.createElement('div');
|
|
739
|
+
openSub.className = 'welcome-card-ts';
|
|
740
|
+
openSub.textContent = 'выбрать папку…';
|
|
741
|
+
openMeta.append(openName, openSub);
|
|
742
|
+
openCard.append(openThumb, openMeta);
|
|
743
|
+
openCard.addEventListener('click', () => $('pickRoot').click());
|
|
744
|
+
grid.appendChild(openCard);
|
|
745
|
+
}
|
|
731
746
|
|
|
732
747
|
// ☁ Облачные проекты — видна только если залогинен в Chatium.
|
|
733
748
|
// Создаёт серверную запись и открывает её как новый проект.
|
|
@@ -815,9 +830,12 @@ async function _renderWelcomeRecentsInner() {
|
|
|
815
830
|
// Каждый элемент: { type: 'cloud'|'recent', sortTs, item }.
|
|
816
831
|
// Для cloud берём максимум из (updatedAt | lastOpenedAt | createdAt) —
|
|
817
832
|
// юзер ожидает что недавно открытый проект (даже без сохранения) поднимется.
|
|
833
|
+
// Расшаренные мне НЕ кладём в основной grid — отдельная секция «Расшарено мне» ниже.
|
|
818
834
|
const merged = [];
|
|
835
|
+
const sharedItems = [];
|
|
819
836
|
for (const c of cloudItems) {
|
|
820
837
|
const ts = Math.max(c.lastOpenedAt || 0, c.updatedAt || 0, c.createdAt || 0);
|
|
838
|
+
if (c.shared && !c.mine) { sharedItems.push({ ...c, sortTs: ts }); continue; }
|
|
821
839
|
merged.push({ type: 'cloud', sortTs: ts, item: c });
|
|
822
840
|
}
|
|
823
841
|
// Дедуп: cloud-проекты не должны показываться ещё и как «недавние папки».
|
|
@@ -840,6 +858,28 @@ async function _renderWelcomeRecentsInner() {
|
|
|
840
858
|
if (m.type === 'cloud') grid.appendChild(makeCloudWelcomeCard(m.item));
|
|
841
859
|
else grid.appendChild(makeRecentWelcomeCard(m.item));
|
|
842
860
|
}
|
|
861
|
+
|
|
862
|
+
// ---- Расшарили мне — отдельной секцией под основным grid'ом. ----
|
|
863
|
+
// Лежит как отдельный block #welcomeSharedRecent. Если есть items — показываем,
|
|
864
|
+
// иначе скрываем.
|
|
865
|
+
let sharedWrap = document.getElementById('welcomeSharedRecent');
|
|
866
|
+
if (!sharedWrap) {
|
|
867
|
+
sharedWrap = document.createElement('div');
|
|
868
|
+
sharedWrap.id = 'welcomeSharedRecent';
|
|
869
|
+
sharedWrap.className = 'welcome-recent';
|
|
870
|
+
sharedWrap.style.marginTop = '32px';
|
|
871
|
+
sharedWrap.innerHTML = '<div class="welcome-recent-title">Расшарено мне</div><div class="welcome-recent-grid" id="welcomeSharedGrid"></div>';
|
|
872
|
+
wrap.parentNode?.insertBefore(sharedWrap, wrap.nextSibling);
|
|
873
|
+
}
|
|
874
|
+
const sharedGrid = sharedWrap.querySelector('#welcomeSharedGrid');
|
|
875
|
+
sharedGrid.innerHTML = '';
|
|
876
|
+
if (!sharedItems.length) {
|
|
877
|
+
sharedWrap.style.display = 'none';
|
|
878
|
+
} else {
|
|
879
|
+
sharedWrap.style.display = '';
|
|
880
|
+
sharedItems.sort((a, b) => b.sortTs - a.sortTs);
|
|
881
|
+
for (const it of sharedItems) sharedGrid.appendChild(makeCloudWelcomeCard(it));
|
|
882
|
+
}
|
|
843
883
|
}
|
|
844
884
|
|
|
845
885
|
// Бейдж «N в фоне» в верхнем-левом углу обложки welcome-карточки.
|
|
@@ -1159,8 +1199,16 @@ function makeCloudWelcomeCard(p) {
|
|
|
1159
1199
|
// ☁-бейдж в нижнем правом углу обложки.
|
|
1160
1200
|
const cloudBadge = document.createElement('div');
|
|
1161
1201
|
cloudBadge.className = 'welcome-card-cloud-badge';
|
|
1162
|
-
|
|
1163
|
-
|
|
1202
|
+
if (p.shared) {
|
|
1203
|
+
cloudBadge.textContent = '🤝';
|
|
1204
|
+
cloudBadge.title = 'Расшарили мне (' + (p.permission === 'edit' ? 'редактирование' : 'просмотр') + ')';
|
|
1205
|
+
} else if (p.isPublic) {
|
|
1206
|
+
cloudBadge.textContent = '🌐';
|
|
1207
|
+
cloudBadge.title = 'Публичный проект';
|
|
1208
|
+
} else {
|
|
1209
|
+
cloudBadge.textContent = '☁';
|
|
1210
|
+
cloudBadge.title = 'Облачный проект';
|
|
1211
|
+
}
|
|
1164
1212
|
thumb.appendChild(cloudBadge);
|
|
1165
1213
|
// Бейдж «N в фоне» — server-side jobsHub source-of-truth.
|
|
1166
1214
|
const bgList = _bgServerCache['cloud:' + p.id];
|
|
@@ -1188,6 +1236,123 @@ function makeCloudWelcomeCard(p) {
|
|
|
1188
1236
|
return card;
|
|
1189
1237
|
}
|
|
1190
1238
|
|
|
1239
|
+
// Floating-overlay: «Создать копию» внизу экрана, когда юзер открыл
|
|
1240
|
+
// чужой проект (расшаренный или публичный). После клона перебрасываем
|
|
1241
|
+
// на новую копию.
|
|
1242
|
+
function renderTemplateOverlay() {
|
|
1243
|
+
const existing = document.getElementById('templateOverlayBar');
|
|
1244
|
+
const isReadOnly = state.cloudProjectId && state.cloudCanModify === false;
|
|
1245
|
+
if (!isReadOnly) { if (existing) existing.remove(); return; }
|
|
1246
|
+
if (existing) return;
|
|
1247
|
+
const bar = document.createElement('div');
|
|
1248
|
+
bar.id = 'templateOverlayBar';
|
|
1249
|
+
bar.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#222;color:#eee;padding:12px 20px;border-radius:8px;display:flex;gap:14px;align-items:center;font-family:-apple-system,sans-serif;font-size:14px;z-index:9999;box-shadow:0 6px 24px rgba(0,0,0,0.4);';
|
|
1250
|
+
const lbl = document.createElement('div');
|
|
1251
|
+
lbl.innerHTML = '👁 Только просмотр' + (state.cloudPermission === 'view' ? ' (расшарили)' : (state.cloudMine === false ? ' (публичный)' : ''));
|
|
1252
|
+
lbl.style.opacity = '0.8';
|
|
1253
|
+
const btn = document.createElement('button');
|
|
1254
|
+
btn.textContent = '📋 Создать копию себе';
|
|
1255
|
+
btn.style.cssText = 'padding:8px 18px;background:#4a8ad6;color:#fff;border:none;border-radius:5px;cursor:pointer;font-weight:500;';
|
|
1256
|
+
btn.addEventListener('click', async () => {
|
|
1257
|
+
btn.disabled = true; btn.textContent = '… клонирую';
|
|
1258
|
+
try {
|
|
1259
|
+
const r = await fetch('/api/proj/clone?id=' + encodeURIComponent(state.cloudProjectId), {
|
|
1260
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1261
|
+
body: JSON.stringify({}),
|
|
1262
|
+
});
|
|
1263
|
+
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.error || 'HTTP ' + r.status); }
|
|
1264
|
+
const created = await r.json();
|
|
1265
|
+
// Чистим cache и открываем новый клон.
|
|
1266
|
+
try { localStorage.removeItem('cloudProjectsCache'); } catch {}
|
|
1267
|
+
if (window.cloudProjects?.open) {
|
|
1268
|
+
await closeProject();
|
|
1269
|
+
await window.cloudProjects.open(created.id, created.name, { forceRefresh: true });
|
|
1270
|
+
}
|
|
1271
|
+
} catch (e) { btn.disabled = false; btn.textContent = '📋 Создать копию себе'; alert('Не удалось: ' + (e?.message || e)); }
|
|
1272
|
+
});
|
|
1273
|
+
bar.append(lbl, btn);
|
|
1274
|
+
document.body.appendChild(bar);
|
|
1275
|
+
}
|
|
1276
|
+
window.renderTemplateOverlay = renderTemplateOverlay;
|
|
1277
|
+
|
|
1278
|
+
// Модалка расшаривания проекта (web-only).
|
|
1279
|
+
async function openShareModal(p) {
|
|
1280
|
+
// Сначала загружаем текущие shares (owner-only endpoint).
|
|
1281
|
+
let shares = [];
|
|
1282
|
+
try {
|
|
1283
|
+
const r = await fetch('/api/proj/shares?id=' + encodeURIComponent(p.id));
|
|
1284
|
+
if (r.ok) shares = await r.json();
|
|
1285
|
+
} catch {}
|
|
1286
|
+
const overlay = document.createElement('div');
|
|
1287
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:99998;display:flex;align-items:center;justify-content:center;';
|
|
1288
|
+
const box = document.createElement('div');
|
|
1289
|
+
box.style.cssText = 'background:#222;color:#eee;padding:24px;border-radius:8px;min-width:380px;max-width:520px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;';
|
|
1290
|
+
function render() {
|
|
1291
|
+
box.innerHTML = `
|
|
1292
|
+
<h2 style="margin:0 0 12px;font-size:18px;">🤝 Расшарить «${escapeHtml(p.name||'')}»</h2>
|
|
1293
|
+
<div style="margin-bottom:14px;">
|
|
1294
|
+
<input id="shareEmail" type="email" placeholder="email@example.com" style="width:100%;padding:8px;background:#1a1a1a;color:#eee;border:1px solid #333;border-radius:4px;font-size:14px;">
|
|
1295
|
+
<div style="display:flex;gap:8px;margin-top:8px;">
|
|
1296
|
+
<select id="sharePerm" style="flex:1;padding:8px;background:#1a1a1a;color:#eee;border:1px solid #333;border-radius:4px;">
|
|
1297
|
+
<option value="view">Только просмотр</option>
|
|
1298
|
+
<option value="edit">Редактирование</option>
|
|
1299
|
+
</select>
|
|
1300
|
+
<button id="shareAdd" style="padding:8px 16px;background:#4a8ad6;color:#fff;border:none;border-radius:4px;cursor:pointer;">Расшарить</button>
|
|
1301
|
+
</div>
|
|
1302
|
+
</div>
|
|
1303
|
+
<div id="shareList">
|
|
1304
|
+
${shares.length ? '' : '<div style="opacity:0.5;font-size:13px;text-align:center;padding:20px;">Никому не расшарено</div>'}
|
|
1305
|
+
${shares.map(s => `
|
|
1306
|
+
<div style="display:flex;align-items:center;padding:8px;background:#1a1a1a;border-radius:4px;margin-bottom:6px;">
|
|
1307
|
+
<div style="flex:1;">
|
|
1308
|
+
<div style="font-size:14px;">${escapeHtml(s.email)}</div>
|
|
1309
|
+
<div style="font-size:11px;opacity:0.6;">${s.permission === 'edit' ? '✎ редактирование' : '👁 просмотр'}</div>
|
|
1310
|
+
</div>
|
|
1311
|
+
<button data-unshare="${escapeHtml(s.email)}" style="padding:4px 10px;background:#553;color:#eee;border:none;border-radius:3px;cursor:pointer;font-size:12px;">убрать</button>
|
|
1312
|
+
</div>
|
|
1313
|
+
`).join('')}
|
|
1314
|
+
</div>
|
|
1315
|
+
<div style="text-align:right;margin-top:16px;">
|
|
1316
|
+
<button id="shareClose" style="padding:8px 16px;background:#444;color:#fff;border:none;border-radius:4px;cursor:pointer;">Закрыть</button>
|
|
1317
|
+
</div>
|
|
1318
|
+
`;
|
|
1319
|
+
box.querySelector('#shareAdd').addEventListener('click', async () => {
|
|
1320
|
+
const email = box.querySelector('#shareEmail').value.trim();
|
|
1321
|
+
const perm = box.querySelector('#sharePerm').value;
|
|
1322
|
+
if (!email || !email.includes('@')) { alert('Некорректный email'); return; }
|
|
1323
|
+
try {
|
|
1324
|
+
const r = await fetch('/api/proj/share?id=' + encodeURIComponent(p.id), {
|
|
1325
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1326
|
+
body: JSON.stringify({ email, permission: perm }),
|
|
1327
|
+
});
|
|
1328
|
+
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.error || 'HTTP ' + r.status); }
|
|
1329
|
+
const r2 = await fetch('/api/proj/shares?id=' + encodeURIComponent(p.id));
|
|
1330
|
+
if (r2.ok) shares = await r2.json();
|
|
1331
|
+
render();
|
|
1332
|
+
} catch (e) { alert('Не удалось: ' + (e?.message || e)); }
|
|
1333
|
+
});
|
|
1334
|
+
box.querySelectorAll('[data-unshare]').forEach(b => {
|
|
1335
|
+
b.addEventListener('click', async () => {
|
|
1336
|
+
const email = b.getAttribute('data-unshare');
|
|
1337
|
+
try {
|
|
1338
|
+
await fetch('/api/proj/unshare?id=' + encodeURIComponent(p.id), {
|
|
1339
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1340
|
+
body: JSON.stringify({ email }),
|
|
1341
|
+
});
|
|
1342
|
+
shares = shares.filter(s => s.email !== email);
|
|
1343
|
+
render();
|
|
1344
|
+
} catch (e) { alert('Не удалось: ' + (e?.message || e)); }
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
box.querySelector('#shareClose').addEventListener('click', () => overlay.remove());
|
|
1348
|
+
}
|
|
1349
|
+
render();
|
|
1350
|
+
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
|
1351
|
+
overlay.appendChild(box);
|
|
1352
|
+
document.body.appendChild(overlay);
|
|
1353
|
+
setTimeout(() => box.querySelector('#shareEmail')?.focus(), 50);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1191
1356
|
// ПКМ-меню для облачной welcome-карточки.
|
|
1192
1357
|
function showCloudCardContextMenu(p, clientX, clientY) {
|
|
1193
1358
|
const menu = $('nodeMenu');
|
|
@@ -1235,6 +1400,34 @@ function showCloudCardContextMenu(p, clientX, clientY) {
|
|
|
1235
1400
|
add('↻ Обновить из облака', () => {
|
|
1236
1401
|
if (window.cloudProjects?.open) window.cloudProjects.open(p.id, p.name, { forceRefresh: true });
|
|
1237
1402
|
});
|
|
1403
|
+
// Public/share — только в web-mode (chatium endpoints proj_*).
|
|
1404
|
+
if (window.__KINGKONT_WEB__ && p.mine !== false) {
|
|
1405
|
+
const isPub = !!p.isPublic;
|
|
1406
|
+
add(isPub ? '✓ Публичный (выключить)' : '🌐 Сделать публичным', async () => {
|
|
1407
|
+
try {
|
|
1408
|
+
const r = await fetch('/api/proj/setPublic?id=' + encodeURIComponent(p.id), {
|
|
1409
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1410
|
+
body: JSON.stringify({ isPublic: !isPub }),
|
|
1411
|
+
});
|
|
1412
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1413
|
+
try {
|
|
1414
|
+
const cached = JSON.parse(localStorage.getItem('cloudProjectsCache') || '[]');
|
|
1415
|
+
const i = cached.findIndex(c => c.id === p.id);
|
|
1416
|
+
if (i >= 0) cached[i].isPublic = !isPub;
|
|
1417
|
+
localStorage.setItem('cloudProjectsCache', JSON.stringify(cached));
|
|
1418
|
+
} catch {}
|
|
1419
|
+
await renderWelcomeRecents();
|
|
1420
|
+
if (!isPub) {
|
|
1421
|
+
// Показываем юзеру публичную ссылку
|
|
1422
|
+
const url = location.origin + '/app/spaces/client/index.html#template=' + p.id;
|
|
1423
|
+
if (confirm('Проект публичный. Скопировать ссылку?\n' + url)) {
|
|
1424
|
+
try { await navigator.clipboard.writeText(url); } catch {}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
} catch (e) { alert('Не удалось: ' + (e?.message || e)); }
|
|
1428
|
+
});
|
|
1429
|
+
add('🤝 Расшарить…', () => openShareModal(p));
|
|
1430
|
+
}
|
|
1238
1431
|
add('💾 Сохранить как шаблон…', async () => {
|
|
1239
1432
|
// Берём cloud-проект с сервера (manifest+files) и постим в /api/templates
|
|
1240
1433
|
// в template-формате (board.scene → board.manifest). Без открытия проекта.
|
|
@@ -1467,6 +1660,8 @@ async function openFilm(handle) {
|
|
|
1467
1660
|
// Подзаголовок шапки = имя открытого проекта (вместо «Видео-редактор»).
|
|
1468
1661
|
const sub = $('brandSub');
|
|
1469
1662
|
if (sub) { sub.textContent = handle.name; sub.classList.add('has-project'); }
|
|
1663
|
+
// Tab title = «<имя> — KingKont».
|
|
1664
|
+
document.title = (handle.name || 'Project') + ' — KingKont';
|
|
1470
1665
|
// Для cloud-проектов имя авторитетное на сервере (manifest.name). Если
|
|
1471
1666
|
// юзер пришёл по уведомлению / прямой ссылке — handle.name мог быть
|
|
1472
1667
|
// id или старое имя; подтягиваем актуальное и обновляем шапку +
|
|
@@ -1477,6 +1672,7 @@ async function openFilm(handle) {
|
|
|
1477
1672
|
.then(proj => {
|
|
1478
1673
|
if (!proj?.name || state.cloudProjectId !== proj.id) return;
|
|
1479
1674
|
if (sub && proj.name !== sub.textContent) sub.textContent = proj.name;
|
|
1675
|
+
document.title = proj.name + ' — KingKont';
|
|
1480
1676
|
// Patch handle.name (используется sidebar/recents и т.п.).
|
|
1481
1677
|
try { handle.name = proj.name; } catch {}
|
|
1482
1678
|
// Sync cache.
|
|
@@ -1518,6 +1714,7 @@ async function openFilm(handle) {
|
|
|
1518
1714
|
}
|
|
1519
1715
|
|
|
1520
1716
|
const raw = localStorage.getItem(`lastBoard:${handle.name}`);
|
|
1717
|
+
let restored = false;
|
|
1521
1718
|
if (raw) {
|
|
1522
1719
|
try {
|
|
1523
1720
|
const last = JSON.parse(raw);
|
|
@@ -1525,8 +1722,18 @@ async function openFilm(handle) {
|
|
|
1525
1722
|
: last.kind === 'location' ? await listLocations(handle)
|
|
1526
1723
|
: await listEpisodes(handle);
|
|
1527
1724
|
const found = list.find(x => x.name === last.name);
|
|
1528
|
-
if (found) await selectBoard({ kind: last.kind, ...found });
|
|
1529
|
-
} catch {}
|
|
1725
|
+
if (found) { await selectBoard({ kind: last.kind, ...found }); restored = true; }
|
|
1726
|
+
} catch (e) { console.warn('[openFilm] restore lastBoard failed:', e?.message || e); }
|
|
1727
|
+
}
|
|
1728
|
+
// Нет запомненной доски (первый open проекта) — авто-открываем первую сцену.
|
|
1729
|
+
// Без этого юзер видит пустой canvas с подсказкой «выбери сцену» и должен
|
|
1730
|
+
// лишний раз кликать в сайдбаре.
|
|
1731
|
+
if (!restored && !state.currentBoard) {
|
|
1732
|
+
try {
|
|
1733
|
+
const eps = await listEpisodes(handle);
|
|
1734
|
+
console.log('[openFilm] auto-select first scene:', eps.length, 'episodes');
|
|
1735
|
+
if (eps.length) await selectBoard({ kind: 'episode', ...eps[0] });
|
|
1736
|
+
} catch (e) { console.warn('[openFilm] auto-select failed:', e?.message || e); }
|
|
1530
1737
|
}
|
|
1531
1738
|
|
|
1532
1739
|
// Сканируем все доски на незавершённые задачи генерации (после перезагрузки)
|
|
@@ -1605,6 +1812,14 @@ async function closeProject() {
|
|
|
1605
1812
|
safe('dispatch project-changed', () => {
|
|
1606
1813
|
window.dispatchEvent(new CustomEvent('project-changed'));
|
|
1607
1814
|
});
|
|
1815
|
+
// Web: чистим hash (мы вернулись на welcome).
|
|
1816
|
+
if (window.__KINGKONT_WEB__ && location.hash.startsWith('#project=')) {
|
|
1817
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
1818
|
+
}
|
|
1819
|
+
// Reset tab title.
|
|
1820
|
+
document.title = 'KingKont';
|
|
1821
|
+
// Убираем floating «Создать копию» bar если был.
|
|
1822
|
+
document.getElementById('templateOverlayBar')?.remove();
|
|
1608
1823
|
state.charactersInfo = [];
|
|
1609
1824
|
state.locationsInfo = [];
|
|
1610
1825
|
state.selectedNodeIds.clear();
|
|
@@ -279,6 +279,16 @@
|
|
|
279
279
|
touchCloudOpened(projectId);
|
|
280
280
|
await openFilm(handle);
|
|
281
281
|
setCloudButtonsVisibility();
|
|
282
|
+
if (window.__KINGKONT_WEB__) location.hash = '#project=' + projectId;
|
|
283
|
+
// Background — узнаём permission (cache не хранил его). Если
|
|
284
|
+
// оказалось что не наш — рисуем «Создать копию» поверх.
|
|
285
|
+
fetch('/api/projects/' + encodeURIComponent(projectId)).then(r => r.ok ? r.json() : null).then(p => {
|
|
286
|
+
if (!p) return;
|
|
287
|
+
state.cloudPermission = p.permission || (p.mine ? 'owner' : null);
|
|
288
|
+
state.cloudMine = !!p.mine;
|
|
289
|
+
state.cloudCanModify = !!p.canModify;
|
|
290
|
+
if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
|
|
291
|
+
}).catch(() => {});
|
|
282
292
|
return;
|
|
283
293
|
}
|
|
284
294
|
}
|
|
@@ -395,9 +405,14 @@
|
|
|
395
405
|
// 5) Открываем.
|
|
396
406
|
state.cloudProjectId = projectId;
|
|
397
407
|
state.cloudDirty = false;
|
|
408
|
+
state.cloudPermission = proj.permission || (proj.mine ? 'owner' : null);
|
|
409
|
+
state.cloudMine = !!proj.mine;
|
|
410
|
+
state.cloudCanModify = !!proj.canModify;
|
|
398
411
|
touchCloudOpened(projectId);
|
|
399
412
|
await openFilm(handle);
|
|
400
413
|
setCloudButtonsVisibility();
|
|
414
|
+
if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
|
|
415
|
+
if (window.__KINGKONT_WEB__) location.hash = '#project=' + projectId;
|
|
401
416
|
PROGRESS.hide();
|
|
402
417
|
} catch (e) {
|
|
403
418
|
PROGRESS.hide();
|
package/renderer/generate.js
CHANGED
|
@@ -24,6 +24,14 @@ canvasWrap.addEventListener('contextmenu', e => {
|
|
|
24
24
|
// Rubber-band селектор + сброс выделения по клику в пустое место
|
|
25
25
|
canvasWrap.addEventListener('mousedown', e => {
|
|
26
26
|
if (!state.currentBoard) return;
|
|
27
|
+
// Middle-button (button=1) → pan-режим. Дефолтное поведение браузера —
|
|
28
|
+
// auto-scroll режим со специальным курсором; перехватываем чтобы пана
|
|
29
|
+
// через drag, без активации auto-scroll.
|
|
30
|
+
if (e.button === 1) {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
startMiddleMousePan(e);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
27
35
|
if (e.button !== 0) return;
|
|
28
36
|
if (e.target.closest('.node, .conn, .anchor, .resize-handle, button, input, textarea, select, .modal, #addMenu, #nodeMenu')) return;
|
|
29
37
|
// Если без модификатора — сбрасываем старое выделение
|
|
@@ -36,6 +44,29 @@ canvasWrap.addEventListener('mousedown', e => {
|
|
|
36
44
|
startRubberBand(e, additive);
|
|
37
45
|
});
|
|
38
46
|
|
|
47
|
+
// Pan через зажатую среднюю кнопку. Курсор меняем на grab во время дrag'а.
|
|
48
|
+
function startMiddleMousePan(e) {
|
|
49
|
+
const startX = e.clientX, startY = e.clientY;
|
|
50
|
+
const startScrollLeft = canvasWrap.scrollLeft;
|
|
51
|
+
const startScrollTop = canvasWrap.scrollTop;
|
|
52
|
+
const prevCursor = canvasWrap.style.cursor;
|
|
53
|
+
canvasWrap.style.cursor = 'grabbing';
|
|
54
|
+
document.body.style.userSelect = 'none';
|
|
55
|
+
const onMove = ev => {
|
|
56
|
+
canvasWrap.scrollLeft = startScrollLeft - (ev.clientX - startX);
|
|
57
|
+
canvasWrap.scrollTop = startScrollTop - (ev.clientY - startY);
|
|
58
|
+
};
|
|
59
|
+
const onUp = ev => {
|
|
60
|
+
if (ev.button !== 1) return;
|
|
61
|
+
document.removeEventListener('mousemove', onMove);
|
|
62
|
+
document.removeEventListener('mouseup', onUp);
|
|
63
|
+
canvasWrap.style.cursor = prevCursor;
|
|
64
|
+
document.body.style.userSelect = '';
|
|
65
|
+
};
|
|
66
|
+
document.addEventListener('mousemove', onMove);
|
|
67
|
+
document.addEventListener('mouseup', onUp);
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
function startRubberBand(e, additive) {
|
|
40
71
|
const rectStart = canvas.getBoundingClientRect();
|
|
41
72
|
const startC = { x: (e.clientX - rectStart.left) / state.zoom, y: (e.clientY - rectStart.top) / state.zoom };
|
|
@@ -506,10 +537,17 @@ function _mimeFromFilename(name) {
|
|
|
506
537
|
})[ext] || 'image/jpeg';
|
|
507
538
|
}
|
|
508
539
|
|
|
540
|
+
// Кэш URL'ов картинок-референсов: ключ = filename+size+mtime → CDN url.
|
|
541
|
+
// Чтобы не перезаливать одну и ту же картинку при каждом text-gen.
|
|
542
|
+
const _imageRefUploadCache = new Map();
|
|
543
|
+
|
|
509
544
|
async function _imageRefToDataUrl(ref) {
|
|
510
545
|
// Возвращает {url, size, mime} или {error}. Раньше тихо возвращал null
|
|
511
546
|
// на любую ошибку → дебаг был невозможен (юзер видел «картинка не
|
|
512
547
|
// подаётся», но в логах ничего).
|
|
548
|
+
// Большие картинки (>400KB) грузим в CDN через /api/upload вместо data URL —
|
|
549
|
+
// иначе base64-payload раздувает контекст модели и Claude возвращает
|
|
550
|
+
// «prompt too long: 215k tokens > 200k».
|
|
513
551
|
try {
|
|
514
552
|
if (!ref || !ref.file) return { error: 'no file in ref' };
|
|
515
553
|
if (!ref.boardHandle || typeof ref.boardHandle.getDirectoryHandle !== 'function') {
|
|
@@ -524,9 +562,34 @@ async function _imageRefToDataUrl(ref) {
|
|
|
524
562
|
const mime = file.type && file.type !== 'application/octet-stream'
|
|
525
563
|
? file.type
|
|
526
564
|
: _mimeFromFilename(ref.file);
|
|
565
|
+
|
|
566
|
+
// Большая картинка → upload to CDN, возвращаем https-url. Кэшируем по
|
|
567
|
+
// size+mtime — повторные text-gen с той же нодой не перезагружают.
|
|
568
|
+
if (file.size > 400 * 1024) {
|
|
569
|
+
const cacheKey = `${ref.file}|${file.size}|${file.lastModified || 0}`;
|
|
570
|
+
let cdnUrl = _imageRefUploadCache.get(cacheKey);
|
|
571
|
+
if (!cdnUrl) {
|
|
572
|
+
const buf = await file.arrayBuffer();
|
|
573
|
+
const filename = ref.file.split('/').pop() || 'ref.jpg';
|
|
574
|
+
const r = await fetch('/api/upload', {
|
|
575
|
+
method: 'POST',
|
|
576
|
+
headers: { 'Content-Type': mime, 'X-File-Name': encodeURIComponent(filename) },
|
|
577
|
+
body: buf,
|
|
578
|
+
});
|
|
579
|
+
if (!r.ok) {
|
|
580
|
+
return { error: 'upload failed: HTTP ' + r.status };
|
|
581
|
+
}
|
|
582
|
+
const j = await r.json();
|
|
583
|
+
if (!j.url) return { error: 'upload вернул без url' };
|
|
584
|
+
cdnUrl = j.url;
|
|
585
|
+
_imageRefUploadCache.set(cacheKey, cdnUrl);
|
|
586
|
+
}
|
|
587
|
+
return { url: cdnUrl, size: file.size, mime };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Маленькая картинка → data URL (1 round-trip меньше).
|
|
527
591
|
let url;
|
|
528
592
|
if (file.type === mime) {
|
|
529
|
-
// type уже правильный — FileReader даст правильный data URL.
|
|
530
593
|
url = await new Promise((res, rej) => {
|
|
531
594
|
const r = new FileReader();
|
|
532
595
|
r.onload = () => res(r.result);
|
|
@@ -534,7 +597,6 @@ async function _imageRefToDataUrl(ref) {
|
|
|
534
597
|
r.readAsDataURL(file);
|
|
535
598
|
});
|
|
536
599
|
} else {
|
|
537
|
-
// type пустой/неправильный — пересобираем Blob с явным type.
|
|
538
600
|
const buf = await file.arrayBuffer();
|
|
539
601
|
const blob = new Blob([buf], { type: mime });
|
|
540
602
|
url = await new Promise((res, rej) => {
|
|
@@ -1900,7 +1962,7 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
|
|
|
1900
1962
|
const submitTimer = setInterval(() => {
|
|
1901
1963
|
if (state.jobs.get(node.id) !== job) return;
|
|
1902
1964
|
const sec = Math.round((Date.now() - submitStart) / 1000);
|
|
1903
|
-
logJob(node.id, `submit still pending... ${sec}s
|
|
1965
|
+
logJob(node.id, `submit still pending... ${sec}s`);
|
|
1904
1966
|
mutateNode(bKey, boardHandle, node.id, n => {
|
|
1905
1967
|
n.generated = { ...(n.generated || {}), state: `submitting (${sec}s)` };
|
|
1906
1968
|
}).catch(() => {});
|
|
@@ -2126,7 +2188,7 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
|
|
|
2126
2188
|
return;
|
|
2127
2189
|
}
|
|
2128
2190
|
if (pd.status === 'error') {
|
|
2129
|
-
logJob(nodeId, `
|
|
2191
|
+
logJob(nodeId, `provider error: ${pd.error || 'unknown'}`);
|
|
2130
2192
|
throw new Error(pd.error || 'generation failed');
|
|
2131
2193
|
}
|
|
2132
2194
|
if (pd.state) {
|
|
@@ -2143,7 +2205,7 @@ function updateNodeStateText(nodeId, stateKey) {
|
|
|
2143
2205
|
const root = canvas.querySelector(`.node[data-id="${nodeId}"] .gen-pending .state-text`);
|
|
2144
2206
|
if (!root) return false;
|
|
2145
2207
|
const LABELS = {
|
|
2146
|
-
'uploading-refs': 'Загружаю
|
|
2208
|
+
'uploading-refs': 'Загружаю референсы…',
|
|
2147
2209
|
'submitting': 'Отправляю задачу...',
|
|
2148
2210
|
'queued': 'В очереди...',
|
|
2149
2211
|
'waiting': 'В очереди...',
|
package/renderer/settings.js
CHANGED
|
@@ -203,7 +203,7 @@ async function renderNodeBody(node, body) {
|
|
|
203
203
|
wrap.className = 'gen-pending';
|
|
204
204
|
const sp = document.createElement('div'); sp.className = 'spinner lg';
|
|
205
205
|
const STATE_LABELS = {
|
|
206
|
-
'uploading-refs': 'Загружаю
|
|
206
|
+
'uploading-refs': 'Загружаю референсы…',
|
|
207
207
|
'submitting': 'Отправляю задачу...',
|
|
208
208
|
'queued': 'В очереди...',
|
|
209
209
|
'waiting': 'В очереди...',
|