kingkont 0.20.9 → 0.20.11
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 -3
- package/package.json +1 -1
- package/renderer/board.js +192 -74
- package/renderer/cloudProjects.js +43 -0
- package/renderer/drawings.js +28 -26
- package/renderer/generate.js +29 -6
- package/renderer/media.js +9 -3
- package/renderer/settings.js +32 -4
- package/renderer/styles.css +34 -21
package/index.html
CHANGED
|
@@ -134,9 +134,7 @@
|
|
|
134
134
|
<button id="genSfx" class="kk-edit-only" disabled title="Сгенерировать звуковой эффект (ElevenLabs)">💥 SFX</button>
|
|
135
135
|
<button id="genMusic" class="kk-edit-only" disabled title="Сгенерировать музыку (ElevenLabs)">🎵 Музыка</button>
|
|
136
136
|
<span class="toolbar-sep kk-edit-only"></span>
|
|
137
|
-
<button data-draw-tool="
|
|
138
|
-
<button data-draw-tool="lead" class="kk-edit-only" title="Грифель — средняя стрелка">𓇼</button>
|
|
139
|
-
<button data-draw-tool="paint" class="kk-edit-only" title="Краска — толстая стрелка">🖌</button>
|
|
137
|
+
<button data-draw-tool="paint" class="kk-edit-only" title="Рисование краской — нарисуй линию на холсте. Esc — отмена. Удаление: Backspace или ×.">🖌</button>
|
|
140
138
|
<!-- View-only banner: in-place в toolbar'е (вместо edit-кнопок).
|
|
141
139
|
Видна только в body.view-only-mode (см. styles.css .kk-view-only-banner). -->
|
|
142
140
|
<div id="viewOnlyBanner" class="kk-view-only-banner">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kingkont",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.11",
|
|
4
4
|
"description": "KingKont \u00b7 Chatium \u2014 \u043d\u043e\u0434-\u0440\u0435\u0434\u0430\u043a\u0442\u043e\u0440 \u0441\u0446\u0435\u043d \u0441 AI-\u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0435\u0439 (\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438/\u0432\u0438\u0434\u0435\u043e/\u0433\u043e\u043b\u043e\u0441/SFX/\u043c\u0443\u0437\u044b\u043a\u0430/\u0442\u0435\u043a\u0441\u0442)",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"bin": {
|
package/renderer/board.js
CHANGED
|
@@ -227,6 +227,16 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|
|
227
227
|
brandLogo._dblTimer = null;
|
|
228
228
|
if (state.filmHandle && typeof closeProject === 'function')
|
|
229
229
|
closeProject();
|
|
230
|
+
try {
|
|
231
|
+
if (location.hash)
|
|
232
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
233
|
+
} catch {}
|
|
234
|
+
// Аноним пришёл по #template= → нажал на логотип → попал бы
|
|
235
|
+
// на welcome без auth. Принудительно отправляем на signin
|
|
236
|
+
// (welcome бесполезен для анона: cloudProjects.list → 401).
|
|
237
|
+
if (window.__KINGKONT_WEB__ && !window.__KINGKONT_USER__) {
|
|
238
|
+
location.replace('/app/spaces/client/signin');
|
|
239
|
+
}
|
|
230
240
|
}, 250);
|
|
231
241
|
});
|
|
232
242
|
brandLogo.addEventListener('dblclick', () => {
|
|
@@ -310,6 +320,41 @@ async function refreshBalance() {
|
|
|
310
320
|
_writeBalancesCache(data);
|
|
311
321
|
_renderBalancesInto(data);
|
|
312
322
|
}
|
|
323
|
+
// Параллельно обновляем identity (web-only) — без этого __KINGKONT_USER__
|
|
324
|
+
// оставался стейлом после logout в chatium, identity-pill показывала
|
|
325
|
+
// старого юзера до перезагрузки страницы. /me — единственный надёжный
|
|
326
|
+
// источник свежей сессии (cookie уже мог быть инвалидирован).
|
|
327
|
+
if (window.__KINGKONT_WEB__)
|
|
328
|
+
_refreshWebIdentity().catch(() => { });
|
|
329
|
+
}
|
|
330
|
+
async function _refreshWebIdentity() {
|
|
331
|
+
let fresh = null;
|
|
332
|
+
try {
|
|
333
|
+
const r = await fetch('/app/spaces/client/me', { credentials: 'include' });
|
|
334
|
+
if (r.ok) {
|
|
335
|
+
const j = await r.json();
|
|
336
|
+
fresh = j?.user || null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch { return; }
|
|
340
|
+
const prevId = window.__KINGKONT_USER__?.id || null;
|
|
341
|
+
const newId = fresh?.id || null;
|
|
342
|
+
window.__KINGKONT_USER__ = fresh;
|
|
343
|
+
if (prevId === newId) return;
|
|
344
|
+
// Identity сменилась → инвалидируем status-кэш и перерисовываем.
|
|
345
|
+
// Если юзер разлогинился (newId=null) — show «войти». Если зашёл
|
|
346
|
+
// как другой — переключаем display, но не делаем hard reload (welcome
|
|
347
|
+
// переотрисуется сам). welcome-list проектов протухнет тоже:
|
|
348
|
+
// localStorage.removeItem дает renderWelcomeRecents обновиться при
|
|
349
|
+
// следующем заходе.
|
|
350
|
+
try {
|
|
351
|
+
localStorage.removeItem(_STATUS_CACHE_KEY);
|
|
352
|
+
localStorage.removeItem('cloudProjectsCache');
|
|
353
|
+
}
|
|
354
|
+
catch { }
|
|
355
|
+
renderWelcomeIdentity({ force: true }).catch(() => { });
|
|
356
|
+
if (typeof renderWelcomeRecents === 'function')
|
|
357
|
+
renderWelcomeRecents().catch(() => { });
|
|
313
358
|
}
|
|
314
359
|
function _renderBalancesInto(data) {
|
|
315
360
|
// Один pill на провайдер. Если у провайдера нет данных (выключен или
|
|
@@ -376,6 +421,12 @@ function _readStatusCache() {
|
|
|
376
421
|
const obj = JSON.parse(raw);
|
|
377
422
|
if (Date.now() - (obj.ts || 0) > _STATUS_HARD_TTL_MS)
|
|
378
423
|
return null; // 24h hard max
|
|
424
|
+
// Никогда не доверяем cached «не залогинен» — пользователь мог
|
|
425
|
+
// войти в другом табе/окне chatium, а welcome всё ещё крутил
|
|
426
|
+
// stale `{connected: false}` до истечения 10-мин окна. Если кэш
|
|
427
|
+
// негативный, читаем как «нет кэша» → форсим fresh fetch.
|
|
428
|
+
if (obj.status && obj.status.connected === false)
|
|
429
|
+
return null;
|
|
379
430
|
return { status: obj.status || null, ts: obj.ts || 0 };
|
|
380
431
|
}
|
|
381
432
|
catch {
|
|
@@ -384,6 +435,10 @@ function _readStatusCache() {
|
|
|
384
435
|
}
|
|
385
436
|
function _writeStatusCache(status) {
|
|
386
437
|
try {
|
|
438
|
+
// Пишем кэш только для positive-state. Negative («не залогинен»)
|
|
439
|
+
// не кэшируем — он копеечен в пересчёте и часто врёт после login
|
|
440
|
+
// в другом табе (см. _readStatusCache комментарий).
|
|
441
|
+
if (!status || status.connected === false) return;
|
|
387
442
|
localStorage.setItem(_STATUS_CACHE_KEY, JSON.stringify({ ts: Date.now(), status }));
|
|
388
443
|
}
|
|
389
444
|
catch { }
|
|
@@ -518,12 +573,26 @@ function _renderIdentity(wrap, status) {
|
|
|
518
573
|
logoutBtn.addEventListener('click', async () => {
|
|
519
574
|
if (!confirm('Выйти из KingKont?'))
|
|
520
575
|
return;
|
|
521
|
-
// Electron: preload IPC. Web:
|
|
576
|
+
// Electron: preload IPC сбрасывает settings.json. Web:
|
|
577
|
+
// POST /s/auth/sign-out — chatium-нативный endpoint, инвалидирует
|
|
578
|
+
// session-cookie. /api/auth/logout стаба в web-shim ничего не делал.
|
|
522
579
|
try {
|
|
523
|
-
if (window.appChatium?.logout)
|
|
580
|
+
if (window.appChatium?.logout) {
|
|
524
581
|
await window.appChatium.logout();
|
|
525
|
-
|
|
582
|
+
}
|
|
583
|
+
else if (window.__KINGKONT_WEB__) {
|
|
584
|
+
await fetch('/s/auth/sign-out', { method: 'POST', credentials: 'include' });
|
|
585
|
+
// Чистим всё клиентское состояние и редиректим на signin —
|
|
586
|
+
// welcome пытается отрисовать своё-чужое без auth и ломается.
|
|
587
|
+
try { localStorage.clear(); } catch {}
|
|
588
|
+
try { sessionStorage.clear(); } catch {}
|
|
589
|
+
window.__KINGKONT_USER__ = null;
|
|
590
|
+
location.replace('/app/spaces/client/signin');
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
526
594
|
await fetch('/api/auth/logout', { method: 'POST' });
|
|
595
|
+
}
|
|
527
596
|
}
|
|
528
597
|
catch { }
|
|
529
598
|
// Очистим status-кэш и форсим re-render.
|
|
@@ -839,30 +908,6 @@ async function _renderWelcomeRecentsInner() {
|
|
|
839
908
|
}
|
|
840
909
|
})().catch(() => { });
|
|
841
910
|
const list = await getRecents();
|
|
842
|
-
// Первой картой — «Открыть проект». Кликается → дёргает скрытый
|
|
843
|
-
// #pickRoot button (тот же, что использует app menu).
|
|
844
|
-
// Скрываем в браузерах без FSAH (Safari/Firefox) — там кнопка не сработает.
|
|
845
|
-
if ('showDirectoryPicker' in window) {
|
|
846
|
-
const openCard = document.createElement('div');
|
|
847
|
-
openCard.className = 'welcome-card open-card';
|
|
848
|
-
const openThumb = document.createElement('div');
|
|
849
|
-
openThumb.className = 'welcome-card-thumb';
|
|
850
|
-
openThumb.textContent = '+';
|
|
851
|
-
const openMeta = document.createElement('div');
|
|
852
|
-
openMeta.className = 'welcome-card-meta';
|
|
853
|
-
const openName = document.createElement('div');
|
|
854
|
-
openName.className = 'welcome-card-name';
|
|
855
|
-
openName.textContent = 'Открыть проект';
|
|
856
|
-
const openSub = document.createElement('div');
|
|
857
|
-
openSub.className = 'welcome-card-ts';
|
|
858
|
-
openSub.textContent = 'выбрать папку…';
|
|
859
|
-
openMeta.append(openName, openSub);
|
|
860
|
-
openCard.append(openThumb, openMeta);
|
|
861
|
-
openCard.addEventListener('click', () => $('pickRoot').click());
|
|
862
|
-
grid.appendChild(openCard);
|
|
863
|
-
}
|
|
864
|
-
// ☁ Облачные проекты — видна только если залогинен в Chatium.
|
|
865
|
-
// Создаёт серверную запись и открывает её как новый проект.
|
|
866
911
|
// Web fallback: если preload appSettings нет — спрашиваем сервер
|
|
867
912
|
// через /api/auth/status (тот же chatium-токен из settings.json).
|
|
868
913
|
let isLoggedIn = false;
|
|
@@ -878,17 +923,18 @@ async function _renderWelcomeRecentsInner() {
|
|
|
878
923
|
}
|
|
879
924
|
}
|
|
880
925
|
catch { }
|
|
926
|
+
// 1) «Новый проект» (+ иконка) — облачный. Видна если залогинен.
|
|
881
927
|
if (isLoggedIn) {
|
|
882
928
|
const cloudCard = document.createElement('div');
|
|
883
929
|
cloudCard.className = 'welcome-card open-card';
|
|
884
930
|
const cloudThumb = document.createElement('div');
|
|
885
931
|
cloudThumb.className = 'welcome-card-thumb';
|
|
886
|
-
cloudThumb.textContent = '
|
|
932
|
+
cloudThumb.textContent = '+';
|
|
887
933
|
const cloudMeta = document.createElement('div');
|
|
888
934
|
cloudMeta.className = 'welcome-card-meta';
|
|
889
935
|
const cloudName = document.createElement('div');
|
|
890
936
|
cloudName.className = 'welcome-card-name';
|
|
891
|
-
cloudName.textContent = 'Новый проект
|
|
937
|
+
cloudName.textContent = 'Новый проект';
|
|
892
938
|
const cloudSub = document.createElement('div');
|
|
893
939
|
cloudSub.className = 'welcome-card-ts';
|
|
894
940
|
cloudSub.textContent = 'хранится на сервере';
|
|
@@ -899,8 +945,26 @@ async function _renderWelcomeRecentsInner() {
|
|
|
899
945
|
window.cloudProjects.createNew();
|
|
900
946
|
});
|
|
901
947
|
grid.appendChild(cloudCard);
|
|
902
|
-
|
|
903
|
-
|
|
948
|
+
}
|
|
949
|
+
// 2) «Открыть папку» — только в FSAH-средах (Electron/Chrome).
|
|
950
|
+
if ('showDirectoryPicker' in window) {
|
|
951
|
+
const openCard = document.createElement('div');
|
|
952
|
+
openCard.className = 'welcome-card open-card';
|
|
953
|
+
const openThumb = document.createElement('div');
|
|
954
|
+
openThumb.className = 'welcome-card-thumb';
|
|
955
|
+
openThumb.textContent = '📁';
|
|
956
|
+
const openMeta = document.createElement('div');
|
|
957
|
+
openMeta.className = 'welcome-card-meta';
|
|
958
|
+
const openName = document.createElement('div');
|
|
959
|
+
openName.className = 'welcome-card-name';
|
|
960
|
+
openName.textContent = 'Открыть папку';
|
|
961
|
+
const openSub = document.createElement('div');
|
|
962
|
+
openSub.className = 'welcome-card-ts';
|
|
963
|
+
openSub.textContent = 'выбрать с диска…';
|
|
964
|
+
openMeta.append(openName, openSub);
|
|
965
|
+
openCard.append(openThumb, openMeta);
|
|
966
|
+
openCard.addEventListener('click', () => $('pickRoot').click());
|
|
967
|
+
grid.appendChild(openCard);
|
|
904
968
|
}
|
|
905
969
|
// Сразу за «Открыть проект» — карточка «Шаблоны» (открывает библиотеку
|
|
906
970
|
// шаблонов с серверa). Стиль такой же как у open-card — иконка + meta.
|
|
@@ -948,62 +1012,73 @@ async function _renderWelcomeRecentsInner() {
|
|
|
948
1012
|
renderWelcomeRecents().catch(() => { });
|
|
949
1013
|
}).catch(() => { });
|
|
950
1014
|
}
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1015
|
+
// Юзер: «постоянные кнопки — первый ряд, recent — второй, shared — третий».
|
|
1016
|
+
// Главный grid выше (welcomeRecentGrid) теперь содержит ТОЛЬКО постоянные
|
|
1017
|
+
// кнопки (open / new cloud / templates), уже добавленные выше. Title
|
|
1018
|
+
// первого ряда меняем на «Действия».
|
|
1019
|
+
if (titleEl) titleEl.textContent = 'Действия';
|
|
1020
|
+
|
|
1021
|
+
// ---- Разделение проектов на «мои» и «расшарили мне» ----
|
|
1022
|
+
const myProjects = []; // cloud (mine) + folder recents, отсортированы
|
|
1023
|
+
const sharedItems = []; // cloud (shared, !mine)
|
|
958
1024
|
for (const c of cloudItems) {
|
|
959
1025
|
const ts = Math.max(c.lastOpenedAt || 0, c.updatedAt || 0, c.createdAt || 0);
|
|
960
1026
|
if (c.shared && !c.mine) {
|
|
961
1027
|
sharedItems.push({ ...c, sortTs: ts });
|
|
962
1028
|
continue;
|
|
963
1029
|
}
|
|
964
|
-
|
|
1030
|
+
myProjects.push({ type: 'cloud', sortTs: ts, item: c });
|
|
965
1031
|
}
|
|
966
1032
|
// Дедуп: cloud-проекты не должны показываться ещё и как «недавние папки».
|
|
967
|
-
// Источники дубля:
|
|
968
|
-
// 1) handle — cloud-shim (window.cloudFsShim.isCloudHandle) — фильтруем в touchRecent,
|
|
969
|
-
// но старые записи могли остаться от предыдущих версий.
|
|
970
|
-
// 2) имя совпадает с именем cloud-проекта — heuristic для legacy-записей.
|
|
971
1033
|
const cloudNames = new Set(cloudItems.map(c => c.name).filter(Boolean));
|
|
972
1034
|
for (const r of list) {
|
|
973
|
-
if (window.cloudFsShim?.isCloudHandle?.(r.handle))
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
if (
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1035
|
+
if (window.cloudFsShim?.isCloudHandle?.(r.handle)) continue;
|
|
1036
|
+
if (cloudNames.has(r.name)) continue;
|
|
1037
|
+
myProjects.push({ type: 'recent', sortTs: r.ts || 0, item: r });
|
|
1038
|
+
}
|
|
1039
|
+
myProjects.sort((a, b) => b.sortTs - a.sortTs);
|
|
1040
|
+
|
|
1041
|
+
// ---- Ряд 2: «Мои проекты» ----
|
|
1042
|
+
let myWrap = document.getElementById('welcomeMyProjectsRecent');
|
|
1043
|
+
if (!myWrap) {
|
|
1044
|
+
myWrap = document.createElement('div');
|
|
1045
|
+
myWrap.id = 'welcomeMyProjectsRecent';
|
|
1046
|
+
myWrap.className = 'welcome-recent';
|
|
1047
|
+
myWrap.innerHTML = '<div class="welcome-recent-title">Мои проекты</div><div class="welcome-recent-grid" id="welcomeMyProjectsGrid"></div>';
|
|
1048
|
+
wrap.parentNode?.insertBefore(myWrap, wrap.nextSibling);
|
|
1049
|
+
}
|
|
1050
|
+
const myGrid = myWrap.querySelector('#welcomeMyProjectsGrid');
|
|
1051
|
+
myGrid.innerHTML = '';
|
|
1052
|
+
if (!myProjects.length) {
|
|
1053
|
+
myWrap.style.display = 'none';
|
|
1054
|
+
} else {
|
|
1055
|
+
myWrap.style.display = '';
|
|
1056
|
+
for (const m of myProjects) {
|
|
1057
|
+
if (m.type === 'cloud') myGrid.appendChild(makeCloudWelcomeCard(m.item));
|
|
1058
|
+
else myGrid.appendChild(makeRecentWelcomeCard(m.item));
|
|
1059
|
+
}
|
|
988
1060
|
}
|
|
989
|
-
|
|
990
|
-
//
|
|
991
|
-
//
|
|
1061
|
+
|
|
1062
|
+
// ---- Ряд 3: «Расшарено мне» ----
|
|
1063
|
+
// Вставляется ПОСЛЕ welcomeMyProjectsRecent (insertBefore по nextSibling
|
|
1064
|
+
// у myWrap, чтобы сохранить порядок при первом создании).
|
|
992
1065
|
let sharedWrap = document.getElementById('welcomeSharedRecent');
|
|
993
1066
|
if (!sharedWrap) {
|
|
994
1067
|
sharedWrap = document.createElement('div');
|
|
995
1068
|
sharedWrap.id = 'welcomeSharedRecent';
|
|
996
1069
|
sharedWrap.className = 'welcome-recent';
|
|
997
|
-
sharedWrap.style.marginTop = '32px';
|
|
998
1070
|
sharedWrap.innerHTML = '<div class="welcome-recent-title">Расшарено мне</div><div class="welcome-recent-grid" id="welcomeSharedGrid"></div>';
|
|
999
|
-
|
|
1071
|
+
myWrap.parentNode?.insertBefore(sharedWrap, myWrap.nextSibling);
|
|
1072
|
+
} else if (sharedWrap.previousElementSibling !== myWrap) {
|
|
1073
|
+
// Если порядок съехал (например myWrap создан позже sharedWrap),
|
|
1074
|
+
// переставляем sharedWrap после myWrap.
|
|
1075
|
+
myWrap.parentNode?.insertBefore(sharedWrap, myWrap.nextSibling);
|
|
1000
1076
|
}
|
|
1001
1077
|
const sharedGrid = sharedWrap.querySelector('#welcomeSharedGrid');
|
|
1002
1078
|
sharedGrid.innerHTML = '';
|
|
1003
1079
|
if (!sharedItems.length) {
|
|
1004
1080
|
sharedWrap.style.display = 'none';
|
|
1005
|
-
}
|
|
1006
|
-
else {
|
|
1081
|
+
} else {
|
|
1007
1082
|
sharedWrap.style.display = '';
|
|
1008
1083
|
sharedItems.sort((a, b) => b.sortTs - a.sortTs);
|
|
1009
1084
|
for (const it of sharedItems)
|
|
@@ -1473,8 +1548,13 @@ function renderTemplateOverlay() {
|
|
|
1473
1548
|
// URL-forced: #template=<id> ВСЕГДА view-only, даже для владельца.
|
|
1474
1549
|
// Это «preview-link» — отдельный URL который шарится наружу.
|
|
1475
1550
|
// #project=<id> — обычный режим: view-only только если canModify=false.
|
|
1476
|
-
|
|
1477
|
-
|
|
1551
|
+
//
|
|
1552
|
+
// ВАЖНО: НЕ используем __KINGKONT_VIEW_ONLY_URL__ — оно ставится web-shim'ом
|
|
1553
|
+
// ОДИН раз по initial-URL и не обновляется при hash-navigation. Если юзер
|
|
1554
|
+
// зашёл по #template= shared-link, открыл welcome и потом свой проект
|
|
1555
|
+
// (#project=) — переменная остаётся true → renderTemplateOverlay включал
|
|
1556
|
+
// view-only на собственном проекте. Проверяем АКТУАЛЬНЫЙ hash.
|
|
1557
|
+
const isTemplateUrl = /^#template=/.test(location.hash || '');
|
|
1478
1558
|
const isReadOnly = !isElectron && state.cloudProjectId
|
|
1479
1559
|
&& (state.cloudCanModify === false || isTemplateUrl);
|
|
1480
1560
|
// edit-mode реveal'ит .kk-edit-only элементы (sidebar секции "Персонажи"/
|
|
@@ -1499,6 +1579,14 @@ function renderTemplateOverlay() {
|
|
|
1499
1579
|
btn.dataset.wired = '1';
|
|
1500
1580
|
btn.addEventListener('click', async () => {
|
|
1501
1581
|
if (btn.disabled) return;
|
|
1582
|
+
// Аноним в вебе → отправляем через auth-gate. После auth chatium
|
|
1583
|
+
// вернёт юзера на /app/spaces/client/clone?id=, тот редиректнет
|
|
1584
|
+
// на /static/web.html?clone=, и init-код ниже завершит клон.
|
|
1585
|
+
// Electron здесь не задет — __KINGKONT_WEB__ только в web-shim.
|
|
1586
|
+
if (window.__KINGKONT_WEB__ && !window.__KINGKONT_USER__) {
|
|
1587
|
+
location.href = '/app/spaces/client/clone?id=' + encodeURIComponent(state.cloudProjectId);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1502
1590
|
btn.disabled = true;
|
|
1503
1591
|
const orig = btn.textContent;
|
|
1504
1592
|
btn.textContent = '… клонирую';
|
|
@@ -1555,12 +1643,11 @@ async function openShareModal(p) {
|
|
|
1555
1643
|
// В Electron location.origin === http://localhost:17893 — кому
|
|
1556
1644
|
// отправлять такую ссылку? Принимающий открывает в обычном браузере,
|
|
1557
1645
|
// там приложение живёт на kingkont.ru. В web используем origin.
|
|
1558
|
-
// Канонический путь — /static/client/index.html
|
|
1559
|
-
//
|
|
1560
|
-
// backwards-compat через redirect-stub.
|
|
1646
|
+
// Канонический путь — /static/web.html. /app/spaces/client/index.html
|
|
1647
|
+
// остался как auth-aware redirect-stub (для backwards-compat).
|
|
1561
1648
|
const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1)/.test(location.origin);
|
|
1562
1649
|
const base = isLocal ? 'https://kingkont.ru' : location.origin;
|
|
1563
|
-
return base + '/static/
|
|
1650
|
+
return base + '/static/web.html#template=' + proj.id;
|
|
1564
1651
|
}
|
|
1565
1652
|
function render() {
|
|
1566
1653
|
const isPub = !!proj.isPublic;
|
|
@@ -1769,9 +1856,9 @@ function showCloudCardContextMenu(p, clientX, clientY) {
|
|
|
1769
1856
|
if (window.cloudProjects?.open)
|
|
1770
1857
|
window.cloudProjects.open(p.id, p.name, { forceRefresh: true });
|
|
1771
1858
|
});
|
|
1772
|
-
//
|
|
1773
|
-
//
|
|
1774
|
-
if (
|
|
1859
|
+
// Public-toggle живёт внутри Share-модалки, отдельной кнопки нет.
|
|
1860
|
+
// Electron-сторону /api/proj/* проксирует server.js (см. handleChatiumProjProxy).
|
|
1861
|
+
if (p.mine !== false) {
|
|
1775
1862
|
add('🤝 Расшарить…', () => openShareModal(p));
|
|
1776
1863
|
}
|
|
1777
1864
|
add('💾 Сохранить как шаблон…', async () => {
|
|
@@ -2366,6 +2453,8 @@ function makeBoardItem(it, kind) {
|
|
|
2366
2453
|
el.addEventListener('click', () => selectBoard({ kind, ...it }));
|
|
2367
2454
|
el.addEventListener('contextmenu', e => {
|
|
2368
2455
|
e.preventDefault();
|
|
2456
|
+
// View-only: меню (rename/delete) бесполезно, см. node-comment выше.
|
|
2457
|
+
if (document.body.classList.contains('view-only-mode')) return;
|
|
2369
2458
|
e.stopPropagation();
|
|
2370
2459
|
showBoardContextMenu(kind, it, e.clientX, e.clientY);
|
|
2371
2460
|
});
|
|
@@ -3055,6 +3144,26 @@ $('newLocation').addEventListener('click', async () => {
|
|
|
3055
3144
|
}
|
|
3056
3145
|
});
|
|
3057
3146
|
// =================== Board (универсально для серии и персонажа) ===================
|
|
3147
|
+
// Выделить ноду + проскроллить view так, чтобы она была в центре viewport'а.
|
|
3148
|
+
// Юзер: «после создания ноды (любой) нужно её выделять и перемещаться к ней».
|
|
3149
|
+
// Используется после addText/addLabel/genNode'а — иначе если spot оказался
|
|
3150
|
+
// за пределами текущего scroll'а, юзер не понимает что что-то создалось.
|
|
3151
|
+
function selectAndPanToNode(node) {
|
|
3152
|
+
if (!node || !state.currentBoard) return;
|
|
3153
|
+
try {
|
|
3154
|
+
clearSelection();
|
|
3155
|
+
state.selectedNodeIds.add(node.id);
|
|
3156
|
+
renderSelection();
|
|
3157
|
+
} catch {}
|
|
3158
|
+
const { padX, padY } = _getFramePadding();
|
|
3159
|
+
const z = state.zoom || 1;
|
|
3160
|
+
const w = node.width || 280, h = node.height || 220;
|
|
3161
|
+
const cx = node.x + w / 2;
|
|
3162
|
+
const cy = node.y + h / 2;
|
|
3163
|
+
canvasWrap.scrollLeft = cx * z + padX - canvasWrap.clientWidth / 2;
|
|
3164
|
+
canvasWrap.scrollTop = cy * z + padY - canvasWrap.clientHeight / 2;
|
|
3165
|
+
}
|
|
3166
|
+
window.selectAndPanToNode = selectAndPanToNode;
|
|
3058
3167
|
// Если ни одна нода не попадает в видимую область canvas-wrap, скроллим
|
|
3059
3168
|
// view на центр bbox всех нод. Используется после selectBoard / openFilm
|
|
3060
3169
|
// — типичный кейс: юзер открывает старый проект, scroll был сохранён в
|
|
@@ -3680,6 +3789,15 @@ async function createNodeEl(node) {
|
|
|
3680
3789
|
el.addEventListener('contextmenu', e => {
|
|
3681
3790
|
if (e.target.closest('textarea, input, .anchor, .resize-handle, [contenteditable]'))
|
|
3682
3791
|
return;
|
|
3792
|
+
// В view-only режиме (#template=) меню содержит destructive-actions
|
|
3793
|
+
// (удалить, переименовать, regen) — все они упадут на сервере по
|
|
3794
|
+
// canModify. Показывать их без возможности применить запутывает юзера;
|
|
3795
|
+
// просто блокируем ПКМ. Native-меню браузера тоже подавляем, чтобы не
|
|
3796
|
+
// путать с собственным.
|
|
3797
|
+
if (document.body.classList.contains('view-only-mode')) {
|
|
3798
|
+
e.preventDefault();
|
|
3799
|
+
return;
|
|
3800
|
+
}
|
|
3683
3801
|
e.preventDefault();
|
|
3684
3802
|
e.stopPropagation();
|
|
3685
3803
|
showNodeContextMenu(node, e.clientX, e.clientY);
|
|
@@ -234,6 +234,18 @@
|
|
|
234
234
|
setCloudButtonsVisibility();
|
|
235
235
|
if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
|
|
236
236
|
if (window.__KINGKONT_WEB__) location.hash = '#project=' + created.id;
|
|
237
|
+
// Auto-создание первой сцены: юзер ожидает что новый проект сразу
|
|
238
|
+
// открывает доску, а не показывает пустой sidebar с предложением
|
|
239
|
+
// «создать сцену». Имя «Сцена 1» — конвенция newEpisode'а из board.js.
|
|
240
|
+
try {
|
|
241
|
+
const sceneName = 'Сцена 1';
|
|
242
|
+
const sceneHandle = await handle.getDirectoryHandle(sceneName, { create: true });
|
|
243
|
+
if (typeof refreshEpisodes === 'function') await refreshEpisodes();
|
|
244
|
+
if (typeof selectBoard === 'function')
|
|
245
|
+
await selectBoard({ kind: 'episode', name: sceneName, handle: sceneHandle });
|
|
246
|
+
} catch (e) {
|
|
247
|
+
console.warn('[createNewCloudProject] auto-scene failed:', e?.message || e);
|
|
248
|
+
}
|
|
237
249
|
} catch (e) {
|
|
238
250
|
alert('Не удалось создать облачный проект: ' + (e?.message || e));
|
|
239
251
|
}
|
|
@@ -917,8 +929,39 @@
|
|
|
917
929
|
// Переинициализируем видимость кнопок раз в 5 сек — для случая когда юзер
|
|
918
930
|
// login/logout в Chatium через настройки (нет других сигналов).
|
|
919
931
|
setInterval(setCloudButtonsVisibility, 5000);
|
|
932
|
+
// ?clone=<sourceId> — приход от /app/spaces/client/clone после авторизации.
|
|
933
|
+
// Юзер был аноним, нажал «Скопировать себе», ушёл в auth, вернулся сюда.
|
|
934
|
+
// Делаем POST clone, чистим query-параметр и открываем новый проект.
|
|
935
|
+
autoCloneFromUrl().catch(e => console.warn('[autoClone] failed:', e?.message || e));
|
|
920
936
|
});
|
|
921
937
|
|
|
938
|
+
async function autoCloneFromUrl() {
|
|
939
|
+
const m = location.search.match(/[?&]clone=([^&]+)/);
|
|
940
|
+
if (!m) return;
|
|
941
|
+
const sourceId = decodeURIComponent(m[1]);
|
|
942
|
+
// Сразу подчищаем URL чтобы reload не повторял клон.
|
|
943
|
+
try {
|
|
944
|
+
const params = new URLSearchParams(location.search);
|
|
945
|
+
params.delete('clone');
|
|
946
|
+
const search = params.toString();
|
|
947
|
+
history.replaceState(null, '', location.pathname + (search ? '?' + search : '') + location.hash);
|
|
948
|
+
} catch {}
|
|
949
|
+
const r = await fetch('/api/proj/clone?id=' + encodeURIComponent(sourceId), {
|
|
950
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
951
|
+
body: JSON.stringify({}),
|
|
952
|
+
});
|
|
953
|
+
if (!r.ok) {
|
|
954
|
+
const e = await r.json().catch(() => ({}));
|
|
955
|
+
alert('Не удалось клонировать: ' + (e.error || 'HTTP ' + r.status));
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const created = await r.json();
|
|
959
|
+
try { localStorage.removeItem('cloudProjectsCache'); } catch {}
|
|
960
|
+
// #project=<newId> — edit-mode для нового владельца.
|
|
961
|
+
history.replaceState(null, '', location.pathname + location.search + '#project=' + created.id);
|
|
962
|
+
await openCloudProject(created.id, created.name, { forceRefresh: true });
|
|
963
|
+
}
|
|
964
|
+
|
|
922
965
|
// Вызывается из openFilm / closeProject (через хук в board.js).
|
|
923
966
|
window.cloudProjects = {
|
|
924
967
|
setVisibility: setCloudButtonsVisibility,
|
package/renderer/drawings.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
// renderer/drawings.js — рисование
|
|
1
|
+
// renderer/drawings.js — рисование краской на холсте.
|
|
2
|
+
//
|
|
3
|
+
// По просьбе юзера: «у стрелки убери наконечник. пусть будет просто
|
|
4
|
+
// рисование краской». arrowHead() и связанный код мёртвый, но оставлен
|
|
5
|
+
// для совместимости со старыми node-данными (node.arrow !== false теперь
|
|
6
|
+
// всегда трактуется как false при рендере).
|
|
7
|
+
//
|
|
8
|
+
// Доисторический комментарий ниже:
|
|
2
9
|
//
|
|
3
10
|
// Юзерская команда: «стрелка это нода». Поэтому drag по холсту создаёт
|
|
4
11
|
// обычную ноду `type:'drawing'` со своим bbox + points. Дальше всё что
|
|
5
12
|
// можно делать с нодой работает «бесплатно»: select, drag, Backspace/×,
|
|
6
13
|
// Cmd-Z (через pushHistory ДО мутации), сохранение в scene.json.
|
|
7
14
|
//
|
|
8
|
-
//
|
|
9
|
-
// •
|
|
10
|
-
//
|
|
11
|
-
//
|
|
15
|
+
// 1 инструмент (toolbar [data-draw-tool="paint"]):
|
|
16
|
+
// • paint — толстая жёлтая стрелка
|
|
17
|
+
// (Раньше было 3 — pencil/lead/paint; по просьбе юзера оставили только
|
|
18
|
+
// краску. toolStyle всё-равно содержит остальные стили на случай если
|
|
19
|
+
// старые ноды в манифесте имеют tool:'pencil'|'lead' — они отрендерятся.)
|
|
12
20
|
//
|
|
13
21
|
// Хранение в node:
|
|
14
22
|
// { id, type:'drawing', tool, x, y, width, height, points: [[relX, relY],…] }
|
|
@@ -106,17 +114,6 @@
|
|
|
106
114
|
line.setAttribute('stroke-linecap', 'round');
|
|
107
115
|
line.setAttribute('stroke-linejoin', 'round');
|
|
108
116
|
svg.appendChild(line);
|
|
109
|
-
if (node.arrow !== false) {
|
|
110
|
-
const head = arrowHead(node.points || [], st.headSize);
|
|
111
|
-
if (head) {
|
|
112
|
-
const tri = document.createElementNS(SVG_NS, 'polygon');
|
|
113
|
-
tri.setAttribute('class', 'head');
|
|
114
|
-
tri.setAttribute('points', head);
|
|
115
|
-
tri.setAttribute('fill', color);
|
|
116
|
-
tri.setAttribute('opacity', st.opacity);
|
|
117
|
-
svg.appendChild(tri);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
117
|
el.appendChild(svg);
|
|
121
118
|
return svg;
|
|
122
119
|
}
|
|
@@ -152,14 +149,6 @@
|
|
|
152
149
|
line.setAttribute('stroke-linecap', 'round');
|
|
153
150
|
line.setAttribute('stroke-linejoin', 'round');
|
|
154
151
|
g.appendChild(line);
|
|
155
|
-
const head = arrowHead(points, st.headSize);
|
|
156
|
-
if (head) {
|
|
157
|
-
const tri = document.createElementNS(SVG_NS, 'polygon');
|
|
158
|
-
tri.setAttribute('points', head);
|
|
159
|
-
tri.setAttribute('fill', st.color);
|
|
160
|
-
tri.setAttribute('opacity', st.opacity);
|
|
161
|
-
g.appendChild(tri);
|
|
162
|
-
}
|
|
163
152
|
svg.appendChild(g);
|
|
164
153
|
_previewEl = g;
|
|
165
154
|
}
|
|
@@ -168,7 +157,10 @@
|
|
|
168
157
|
}
|
|
169
158
|
|
|
170
159
|
function setTool(tool) {
|
|
171
|
-
|
|
160
|
+
// НЕ toggle (раньше клик той же кнопки → tool=null; ломалось когда
|
|
161
|
+
// _draw.tool оставался stuck после потерянного mouseup → следующий
|
|
162
|
+
// клик кнопки toggle'ил в null, юзер думал что активен).
|
|
163
|
+
// Деактивация: Esc, или автоматически после draw'а в _onMouseUp.
|
|
172
164
|
_draw.tool = tool;
|
|
173
165
|
const c = document.getElementById('canvas');
|
|
174
166
|
if (c) c.style.cursor = tool ? 'crosshair' : '';
|
|
@@ -180,7 +172,13 @@
|
|
|
180
172
|
function _onMouseDown(e) {
|
|
181
173
|
if (!_draw.tool) return;
|
|
182
174
|
if (e.button !== 0) return;
|
|
183
|
-
if (e.target.closest('.node')) return
|
|
175
|
+
// Раньше: `if (e.target.closest('.node')) return` — это блокировало
|
|
176
|
+
// draw'ы внутри bbox существующей drawing-ноды (вокруг нарисованного
|
|
177
|
+
// штриха PAD=22px пустого пространства, всё ловит mousedown drawing-
|
|
178
|
+
// ноды для drag). Юзер: «если нарисовал стрелку, а потом что-то сделал,
|
|
179
|
+
// клик по краске не даёт рисовать дальше». Теперь когда draw-tool
|
|
180
|
+
// активен — любой клик на canvas начинает draw, e.stopPropagation
|
|
181
|
+
// ниже гасит bubble в node.drag-handler.
|
|
184
182
|
e.preventDefault();
|
|
185
183
|
e.stopPropagation();
|
|
186
184
|
const { x, y } = _canvasCoord(e);
|
|
@@ -206,6 +204,10 @@
|
|
|
206
204
|
const pts = _draw.current.points;
|
|
207
205
|
const tool = _draw.current.tool;
|
|
208
206
|
_draw.current = null;
|
|
207
|
+
// Юзер: «после рисования стрелки отпускай кнопку рисования». Возврат
|
|
208
|
+
// в обычный select-mode после каждого draw — снимает active-стейт
|
|
209
|
+
// с toolbar-кнопки и убирает crosshair-курсор.
|
|
210
|
+
setTool(null);
|
|
209
211
|
if (pts.length < 3) return; // случайный клик → игнор
|
|
210
212
|
// BBox + relative-points + padding под линию и arrowhead.
|
|
211
213
|
const PAD = 22;
|
package/renderer/generate.js
CHANGED
|
@@ -228,9 +228,18 @@ document.querySelectorAll('#addMenu button').forEach(btn => {
|
|
|
228
228
|
|
|
229
229
|
async function addTextAt(pos) {
|
|
230
230
|
if (!state.currentBoard) return;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
let x, y;
|
|
232
|
+
if (pos) {
|
|
233
|
+
const rect = canvas.getBoundingClientRect();
|
|
234
|
+
x = (pos.clientX - rect.left) / state.zoom;
|
|
235
|
+
y = (pos.clientY - rect.top) / state.zoom;
|
|
236
|
+
} else {
|
|
237
|
+
// Без позиции (клик по «Написать» в toolbar) — кладём в свободное место
|
|
238
|
+
// ВИДИМОЙ зоны. Раньше использовали scrollLeft+80 без учёта canvas-padding
|
|
239
|
+
// → нода уезжала за вьюпорт (padX=2000 в canvas-frame).
|
|
240
|
+
const spot = findFreeSpot();
|
|
241
|
+
x = spot.x; y = spot.y;
|
|
242
|
+
}
|
|
234
243
|
const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
|
|
235
244
|
const mdName = await uniqueName(dir, 'text.md');
|
|
236
245
|
await writeFile(dir, mdName, '');
|
|
@@ -238,6 +247,7 @@ async function addTextAt(pos) {
|
|
|
238
247
|
state.currentBoard.metadata.nodes.push(node);
|
|
239
248
|
canvas.appendChild(await createNodeEl(node));
|
|
240
249
|
scheduleSave();
|
|
250
|
+
if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
|
|
241
251
|
}
|
|
242
252
|
|
|
243
253
|
// Label-нода: лёгкая надпись поверх холста, без файла. Текст хранится
|
|
@@ -245,9 +255,15 @@ async function addTextAt(pos) {
|
|
|
245
255
|
// удаляется при сериализации).
|
|
246
256
|
async function addLabelAt(pos) {
|
|
247
257
|
if (!state.currentBoard) return;
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
258
|
+
let x, y;
|
|
259
|
+
if (pos) {
|
|
260
|
+
const rect = canvas.getBoundingClientRect();
|
|
261
|
+
x = (pos.clientX - rect.left) / state.zoom;
|
|
262
|
+
y = (pos.clientY - rect.top) / state.zoom;
|
|
263
|
+
} else {
|
|
264
|
+
const spot = findFreeSpot();
|
|
265
|
+
x = spot.x; y = spot.y;
|
|
266
|
+
}
|
|
251
267
|
const node = {
|
|
252
268
|
id: crypto.randomUUID(),
|
|
253
269
|
type: 'label',
|
|
@@ -261,6 +277,7 @@ async function addLabelAt(pos) {
|
|
|
261
277
|
const el = await createNodeEl(node);
|
|
262
278
|
canvas.appendChild(el);
|
|
263
279
|
scheduleSave();
|
|
280
|
+
if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
|
|
264
281
|
// Сразу входим в режим редактирования и выделяем весь текст —
|
|
265
282
|
// юзер начинает печатать, и «Подпись» заменяется на введённое.
|
|
266
283
|
setTimeout(() => {
|
|
@@ -457,6 +474,7 @@ async function openGenModal(kind) {
|
|
|
457
474
|
setTimeout(() => $('genPrompt').focus(), 50);
|
|
458
475
|
}
|
|
459
476
|
|
|
477
|
+
$('addText').addEventListener('click', () => addTextAt(null));
|
|
460
478
|
$('genImage').addEventListener('click', () => openGenModal('image'));
|
|
461
479
|
$('genVideo').addEventListener('click', () => openGenModal('video'));
|
|
462
480
|
$('genAudio').addEventListener('click', () => openGenModal('audio'));
|
|
@@ -523,6 +541,7 @@ $('textGenSubmit').addEventListener('click', async () => {
|
|
|
523
541
|
canvas.appendChild(await createNodeEl(node));
|
|
524
542
|
if (pendingFrom) addConnection(pendingFrom, node.id);
|
|
525
543
|
scheduleSave();
|
|
544
|
+
if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
|
|
526
545
|
$('textGenModal').classList.add('hidden');
|
|
527
546
|
// Фоновая генерация — модалку уже закрыли.
|
|
528
547
|
runTextJob(node, resolvedPrompt, model, state.currentBoard.handle, state.currentBoard.key, imageRefs);
|
|
@@ -730,6 +749,7 @@ $('sfxSubmit').addEventListener('click', async () => {
|
|
|
730
749
|
state.currentBoard.metadata.nodes.push(node);
|
|
731
750
|
canvas.appendChild(await createNodeEl(node));
|
|
732
751
|
scheduleSave();
|
|
752
|
+
if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
|
|
733
753
|
runSfxJob(node, text, durationSeconds, state.currentBoard.handle, state.currentBoard.key);
|
|
734
754
|
});
|
|
735
755
|
|
|
@@ -862,6 +882,7 @@ $('musicSubmit').addEventListener('click', async () => {
|
|
|
862
882
|
state.currentBoard.metadata.nodes.push(node);
|
|
863
883
|
canvas.appendChild(await createNodeEl(node));
|
|
864
884
|
scheduleSave();
|
|
885
|
+
if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
|
|
865
886
|
runMusicJob(node, prompt, durationMs, state.currentBoard.handle, state.currentBoard.key);
|
|
866
887
|
});
|
|
867
888
|
|
|
@@ -1632,6 +1653,7 @@ $('genSubmit').addEventListener('click', async () => {
|
|
|
1632
1653
|
addConnection(state.pendingConnectionFrom, node.id);
|
|
1633
1654
|
state.pendingConnectionFrom = null;
|
|
1634
1655
|
}
|
|
1656
|
+
if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
|
|
1635
1657
|
if (saveOnly) {
|
|
1636
1658
|
scheduleSave();
|
|
1637
1659
|
$('genModal').classList.add('hidden');
|
|
@@ -1784,6 +1806,7 @@ $('genSubmit').addEventListener('click', async () => {
|
|
|
1784
1806
|
if (saveOnly) node.status = 'draft';
|
|
1785
1807
|
state.currentBoard.metadata.nodes.push(node);
|
|
1786
1808
|
canvas.appendChild(await createNodeEl(node));
|
|
1809
|
+
if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
|
|
1787
1810
|
|
|
1788
1811
|
// Async auto-name через LLM. НЕ блокируем — нода уже на холсте.
|
|
1789
1812
|
// Когда LLM ответит — обновим node.name + DOM. Дедуп через uniqueNodeName.
|
package/renderer/media.js
CHANGED
|
@@ -1035,7 +1035,10 @@ function getReferenceableNodes() {
|
|
|
1035
1035
|
function getMentionSuggestions(kind) {
|
|
1036
1036
|
if (!state.currentBoard) return [];
|
|
1037
1037
|
// Текстовые ноды разрешены везде: resolveMentions инлайнит их .md прямо в промпт.
|
|
1038
|
-
|
|
1038
|
+
// Видео-рефы разрешены и для image-генерации (нек-рые модели принимают
|
|
1039
|
+
// видео-фреймы как референс; даже если модель проигнорирует — лучше дать
|
|
1040
|
+
// юзеру выбор, чем не пускать в попап).
|
|
1041
|
+
const allowed = kind === 'image' ? ['image', 'video', 'text']
|
|
1039
1042
|
: kind === 'video' ? ['image', 'video', 'audio', 'text']
|
|
1040
1043
|
: ['text', 'image', 'video', 'audio'];
|
|
1041
1044
|
const out = [];
|
|
@@ -1287,7 +1290,8 @@ function updateMentionPopup() {
|
|
|
1287
1290
|
const imgQuery = query.slice(dotIdx + 1);
|
|
1288
1291
|
if (ownerName === '__scene') {
|
|
1289
1292
|
// Drilldown в ноды ТЕКУЩЕЙ доски (тип фильтруем по genKind).
|
|
1290
|
-
|
|
1293
|
+
// image-gen теперь принимает видео-рефы тоже.
|
|
1294
|
+
const allowed = state.genKind === 'image' ? ['image', 'video', 'text']
|
|
1291
1295
|
: state.genKind === 'video' ? ['image', 'video', 'audio', 'text']
|
|
1292
1296
|
: ['text', 'image', 'video', 'audio'];
|
|
1293
1297
|
const seen = new Set();
|
|
@@ -1373,7 +1377,9 @@ function updateMentionPopup() {
|
|
|
1373
1377
|
// лишний шум когда в проекте много персонажей/локаций. Click →
|
|
1374
1378
|
// selectMention вставит [@__scene. и реоткроет popup в drilldown.
|
|
1375
1379
|
// Если у текущей доски НЕТ нод вообще — пункт не показываем.
|
|
1376
|
-
|
|
1380
|
+
// image-gen теперь тоже принимает видео-рефы (см. комментарий в
|
|
1381
|
+
// getMentionSuggestions). Держим в синхроне с тем filter'ом.
|
|
1382
|
+
const allowed = state.genKind === 'image' ? ['image', 'video', 'text']
|
|
1377
1383
|
: state.genKind === 'video' ? ['image', 'video', 'audio', 'text']
|
|
1378
1384
|
: ['text', 'image', 'video', 'audio'];
|
|
1379
1385
|
const boardHasNodes = (state.currentBoard?.metadata?.nodes || []).some(n =>
|
package/renderer/settings.js
CHANGED
|
@@ -273,13 +273,38 @@ async function renderNodeBody(node, body) {
|
|
|
273
273
|
const wrap = document.createElement('div');
|
|
274
274
|
wrap.className = 'gen-error';
|
|
275
275
|
const errStr = String(node.error || '');
|
|
276
|
+
// Спец-кейс: «Недостаточно кредитов» от chatium-billing.
|
|
277
|
+
// Шапка ошибки от chatium громоздкая («Неизвестная ошибка при обращении
|
|
278
|
+
// к https://kingkont.ru/spaces/server/api/text … runUgcRequest …»).
|
|
279
|
+
// Заменяем на короткое «Не хватает баланса» + bright-green «Пополнить».
|
|
280
|
+
const isInsufficient = /Недостаточно кредитов|Insufficient (credits|balance)/i.test(errStr);
|
|
276
281
|
const errBlock = document.createElement('div');
|
|
277
|
-
// Делаем text selectable — для копирования при необходимости.
|
|
278
282
|
errBlock.style.cssText = 'user-select: text; -webkit-user-select: text; cursor: text; word-break: break-word; margin-bottom: 8px;';
|
|
279
|
-
|
|
283
|
+
if (isInsufficient) {
|
|
284
|
+
errBlock.style.fontSize = '14px';
|
|
285
|
+
errBlock.style.fontWeight = '600';
|
|
286
|
+
errBlock.innerHTML = '💳 Не хватает баланса';
|
|
287
|
+
} else {
|
|
288
|
+
errBlock.textContent = errStr || 'Ошибка генерации';
|
|
289
|
+
}
|
|
280
290
|
wrap.appendChild(errBlock);
|
|
281
|
-
|
|
282
|
-
|
|
291
|
+
if (isInsufficient) {
|
|
292
|
+
const topup = document.createElement('button');
|
|
293
|
+
topup.textContent = '💚 Пополнить';
|
|
294
|
+
topup.style.cssText = 'background:#16a34a;color:#fff;border:none;padding:8px 16px;border-radius:5px;font-weight:600;cursor:pointer;margin-right:6px;box-shadow:0 1px 4px rgba(0,0,0,0.2);';
|
|
295
|
+
topup.addEventListener('mouseenter', () => { topup.style.background = '#15803d'; });
|
|
296
|
+
topup.addEventListener('mouseleave', () => { topup.style.background = '#16a34a'; });
|
|
297
|
+
topup.addEventListener('click', e => {
|
|
298
|
+
e.stopPropagation();
|
|
299
|
+
// Web: новая вкладка с billing-страницей.
|
|
300
|
+
// Electron: setWindowOpenHandler в main.js перенаправит в shell.openExternal.
|
|
301
|
+
window.open('https://kingkont.ru/app/spaces/billing', '_blank', 'noopener');
|
|
302
|
+
});
|
|
303
|
+
wrap.appendChild(topup);
|
|
304
|
+
}
|
|
305
|
+
// Кнопка «Скопировать» — для длинных server-ошибок. Для insufficient-кейса
|
|
306
|
+
// не показываем (юзер не дебажит, ему нужно пополниться).
|
|
307
|
+
if (errStr && !isInsufficient) {
|
|
283
308
|
const copyBtn = document.createElement('button');
|
|
284
309
|
copyBtn.textContent = '📋 Скопировать';
|
|
285
310
|
copyBtn.style.marginRight = '6px';
|
|
@@ -892,6 +917,9 @@ function attachDrag(el, node) {
|
|
|
892
917
|
// шрифта/размера через ПКМ refreshNodeDOM пересоздаёт DOM-элемент
|
|
893
918
|
// .label-text, и старый listener умирает вместе со старым элементом.
|
|
894
919
|
if (header) header.addEventListener('mousedown', makeDragHandler(el, node));
|
|
920
|
+
// Drawing-нода (стрелка) — header нет, .node сам и есть hit-target.
|
|
921
|
+
// Без этого нода-стрелка не двигалась с момента создания.
|
|
922
|
+
else if (node.type === 'drawing') el.addEventListener('mousedown', makeDragHandler(el, node));
|
|
895
923
|
}
|
|
896
924
|
|
|
897
925
|
// Создаёт drag-handler для конкретной (el, node) пары. Вынесено отдельно
|
package/renderer/styles.css
CHANGED
|
@@ -110,6 +110,12 @@
|
|
|
110
110
|
width: 36px; height: 36px; flex-shrink: 0; object-fit: contain;
|
|
111
111
|
background: #1a1a1a; border-radius: 8px; padding: 4px;
|
|
112
112
|
}
|
|
113
|
+
/* Brand-logo и welcome-logo всегда кликабельны (на главной → ничего,
|
|
114
|
+
в проекте → возврат на welcome, dblclick → настройки). cursor:pointer
|
|
115
|
+
ставится и в HTML inline, но в web chatium-минифайер иногда срывает
|
|
116
|
+
inline-стили на тегах внутри минифицированного HTML — CSS-правило
|
|
117
|
+
надёжнее. */
|
|
118
|
+
#brandLogo, #welcomeLogo { cursor: pointer; }
|
|
113
119
|
.sidebar-header .recent-film {
|
|
114
120
|
display: block; font-size: 12px; color: #888; text-decoration: none;
|
|
115
121
|
padding: 6px 10px; border-radius: 4px; word-break: break-all;
|
|
@@ -205,11 +211,11 @@
|
|
|
205
211
|
.welcome { display: none; }
|
|
206
212
|
body.no-project .welcome {
|
|
207
213
|
display: flex; position: fixed; inset: 0; z-index: 50;
|
|
208
|
-
flex-direction: column;
|
|
214
|
+
flex-direction: column;
|
|
209
215
|
background: #1a1a1a;
|
|
210
216
|
-webkit-app-region: drag;
|
|
211
|
-
padding-top:
|
|
212
|
-
overflow:
|
|
217
|
+
padding-top: 48px;
|
|
218
|
+
overflow-y: auto; /* вся страница скроллится, не отдельные блоки */
|
|
213
219
|
}
|
|
214
220
|
body.no-project .sidebar, body.no-project .main, body.no-project .preview-panel { display: none !important; }
|
|
215
221
|
/* Preview-панель видна ТОЛЬКО когда таймлайн открыт. Закрыли таймлайн —
|
|
@@ -217,28 +223,33 @@
|
|
|
217
223
|
торчащий тёмный «остаток» справа, который мешает. */
|
|
218
224
|
body:has(.timeline-panel.hidden) .preview-panel { display: none !important; }
|
|
219
225
|
.welcome-inner {
|
|
220
|
-
display: flex; flex-direction: column;
|
|
226
|
+
display: flex; flex-direction: column;
|
|
221
227
|
width: 100%; max-width: none; padding: 0;
|
|
222
|
-
flex: 1; min-height: 0; /* нужно для child overflow */
|
|
223
228
|
-webkit-app-region: no-drag;
|
|
224
229
|
}
|
|
225
230
|
.welcome-header {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
231
|
+
/* Юзер: «логотип выровняй по левой границе, название KingKont и версию —
|
|
232
|
+
справа от логотипа». Раньше column (logo сверху, title под ним по центру). */
|
|
233
|
+
display: flex; flex-direction: row; align-items: center; gap: 16px;
|
|
234
|
+
flex-shrink: 0;
|
|
235
|
+
padding: 0 52px;
|
|
236
|
+
margin-bottom: 24px;
|
|
229
237
|
}
|
|
230
238
|
.welcome-logo {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
239
|
+
/* Юзер: «убери тень и padding». Лого как простая иконка, без подложки. */
|
|
240
|
+
width: 56px; height: 56px; object-fit: contain;
|
|
241
|
+
background: transparent; border-radius: 0; padding: 0;
|
|
242
|
+
box-shadow: none;
|
|
234
243
|
}
|
|
235
|
-
.welcome-title { font-size:
|
|
244
|
+
.welcome-title { font-size: 24px; font-weight: 600; color: #e0e0e0; margin: 0; }
|
|
236
245
|
.welcome-version {
|
|
237
246
|
font-size: 13px; font-weight: 400; color: #666;
|
|
238
247
|
font-family: ui-monospace, 'SF Mono', monospace;
|
|
239
248
|
vertical-align: middle; margin-left: 6px;
|
|
240
249
|
}
|
|
241
|
-
|
|
250
|
+
/* В row-layout «Видео-редактор» рядом с title выглядит избыточно —
|
|
251
|
+
KingKont сам по себе говорит достаточно. */
|
|
252
|
+
.welcome-sub { display: none; }
|
|
242
253
|
/* Топ-правый блок welcome-экрана: identity + balances. Положение fixed,
|
|
243
254
|
чтобы не зависеть от центрированной .welcome-inner колонки. */
|
|
244
255
|
.welcome-status {
|
|
@@ -480,18 +491,22 @@
|
|
|
480
491
|
}
|
|
481
492
|
.welcome-open:hover { background: #4a6a9a; }
|
|
482
493
|
.welcome-recent {
|
|
483
|
-
|
|
484
|
-
|
|
494
|
+
/* Расстояние между секциями (Действия / Мои проекты / Расшарено мне).
|
|
495
|
+
Сначала уменьшили с 36 до 18, юзер: «чуть больше» → 28px. */
|
|
496
|
+
margin-top: 28px; width: 100%;
|
|
485
497
|
display: flex; flex-direction: column;
|
|
486
498
|
}
|
|
487
499
|
.welcome-recent-title {
|
|
500
|
+
/* Юзер: «заголовки перенеси налево, а не в центре». Padding-left
|
|
501
|
+
совпадает с padding'ом grid'а (52px) для визуального выравнивания
|
|
502
|
+
с первой карточкой. */
|
|
488
503
|
font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.6px;
|
|
489
|
-
margin-bottom:
|
|
504
|
+
margin-bottom: 8px; text-align: left;
|
|
505
|
+
padding: 0 52px;
|
|
490
506
|
flex-shrink: 0;
|
|
491
507
|
}
|
|
492
508
|
/* Recents — плитка (responsive grid). Карточки фиксированной ширины,
|
|
493
|
-
заполняют ряд слева направо, при нехватке места переносятся.
|
|
494
|
-
вертикально, если карточек много. */
|
|
509
|
+
заполняют ряд слева направо, при нехватке места переносятся. */
|
|
495
510
|
.welcome-recent-grid {
|
|
496
511
|
display: grid;
|
|
497
512
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
@@ -501,11 +516,9 @@
|
|
|
501
516
|
визуально наезжают друг на друга — именно это юзер видел в Electron. */
|
|
502
517
|
grid-auto-rows: max-content;
|
|
503
518
|
gap: 16px;
|
|
504
|
-
|
|
505
|
-
padding: 8px 52px 24px;
|
|
519
|
+
padding: 0 52px 8px;
|
|
506
520
|
align-content: start;
|
|
507
521
|
}
|
|
508
|
-
.welcome-recent-grid::-webkit-scrollbar { width: 8px; }
|
|
509
522
|
|
|
510
523
|
/* === Глобальные dark-scrollbars === */
|
|
511
524
|
/* Firefox */
|