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 CHANGED
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Видео-редактор сериала</title>
6
+ <title>KingKont</title>
7
7
  <link rel="stylesheet" href="renderer/styles.css">
8
8
  </head>
9
9
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.20.6",
3
+ "version": "0.20.8",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -17,7 +17,19 @@ const emptyState = $('emptyState');
17
17
 
18
18
  // =================== Init ===================
19
19
  window.addEventListener('DOMContentLoaded', async () => {
20
- if (!('showDirectoryPicker' in window)) { showUnsupported(); return; }
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
- const openCard = document.createElement('div');
715
- openCard.className = 'welcome-card open-card';
716
- const openThumb = document.createElement('div');
717
- openThumb.className = 'welcome-card-thumb';
718
- openThumb.textContent = '+';
719
- const openMeta = document.createElement('div');
720
- openMeta.className = 'welcome-card-meta';
721
- const openName = document.createElement('div');
722
- openName.className = 'welcome-card-name';
723
- openName.textContent = 'Открыть проект';
724
- const openSub = document.createElement('div');
725
- openSub.className = 'welcome-card-ts';
726
- openSub.textContent = 'выбрать папку…';
727
- openMeta.append(openName, openSub);
728
- openCard.append(openThumb, openMeta);
729
- openCard.addEventListener('click', () => $('pickRoot').click());
730
- grid.appendChild(openCard);
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
- cloudBadge.textContent = '☁';
1163
- cloudBadge.title = 'Облачный проект';
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();
@@ -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 (KIE может тормозить)`);
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, `KIE error: ${pd.error || 'unknown'}`);
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': 'Загружаю референсы в KIE...',
2208
+ 'uploading-refs': 'Загружаю референсы…',
2147
2209
  'submitting': 'Отправляю задачу...',
2148
2210
  'queued': 'В очереди...',
2149
2211
  'waiting': 'В очереди...',
@@ -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': 'Загружаю референсы в KIE...',
206
+ 'uploading-refs': 'Загружаю референсы…',
207
207
  'submitting': 'Отправляю задачу...',
208
208
  'queued': 'В очереди...',
209
209
  'waiting': 'В очереди...',