kingkont 0.20.12 → 0.20.14
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/package.json +1 -1
- package/renderer/board.js +87 -0
- package/renderer/cloudProjects.js +68 -3
- package/renderer/styles.css +18 -0
- package/skill/SKILL.md +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kingkont",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.14",
|
|
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
|
@@ -3144,6 +3144,65 @@ $('newLocation').addEventListener('click', async () => {
|
|
|
3144
3144
|
}
|
|
3145
3145
|
});
|
|
3146
3146
|
// =================== Board (универсально для серии и персонажа) ===================
|
|
3147
|
+
// === Link-to-scene: нода может содержать ссылку на другую сцену/борд. ====
|
|
3148
|
+
// Юзер: «из ноды можно сделать ссылку на сцену; ПКМ — сделать ссылку,
|
|
3149
|
+
// убрать ссылку; если есть ссылка — показываем значком, дабл-клик
|
|
3150
|
+
// открывает нужную сцену».
|
|
3151
|
+
// node.linkedBoard = { kind: 'episode'|'character'|'location', name: '<имя>' }
|
|
3152
|
+
// handle резолвится в момент перехода (через listEpisodes/listCharacters
|
|
3153
|
+
// /listLocations), хранить нельзя — FSAH-handle не сериализуется в JSON.
|
|
3154
|
+
function refreshNodeLinkBadge(el, node) {
|
|
3155
|
+
const badge = el.querySelector('.link-badge');
|
|
3156
|
+
if (!badge) return;
|
|
3157
|
+
if (node.linkedBoard?.name) {
|
|
3158
|
+
badge.style.display = '';
|
|
3159
|
+
badge.title = `Дабл-клик / клик: открыть → ${node.linkedBoard.name}`;
|
|
3160
|
+
} else {
|
|
3161
|
+
badge.style.display = 'none';
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
async function _resolveLinkedBoardHandle(target) {
|
|
3165
|
+
if (!state.filmHandle || !target?.name) return null;
|
|
3166
|
+
const list = target.kind === 'character' ? await listCharacters(state.filmHandle)
|
|
3167
|
+
: target.kind === 'location' ? await listLocations(state.filmHandle)
|
|
3168
|
+
: await listEpisodes(state.filmHandle);
|
|
3169
|
+
return list.find(b => b.name === target.name) || null;
|
|
3170
|
+
}
|
|
3171
|
+
async function goToLinkedBoard(node) {
|
|
3172
|
+
const t = node.linkedBoard;
|
|
3173
|
+
if (!t?.name) return;
|
|
3174
|
+
const found = await _resolveLinkedBoardHandle(t);
|
|
3175
|
+
if (!found) {
|
|
3176
|
+
alert(`Целевая сцена не найдена: ${t.name} (возможно, переименована или удалена)`);
|
|
3177
|
+
return;
|
|
3178
|
+
}
|
|
3179
|
+
await selectBoard({ kind: t.kind || 'episode', ...found });
|
|
3180
|
+
}
|
|
3181
|
+
async function pickBoardForLink(node) {
|
|
3182
|
+
if (!state.filmHandle) { alert('Сначала откройте проект'); return; }
|
|
3183
|
+
const eps = await listEpisodes(state.filmHandle);
|
|
3184
|
+
const chars = await listCharacters(state.filmHandle).catch(() => []);
|
|
3185
|
+
const locs = await listLocations(state.filmHandle).catch(() => []);
|
|
3186
|
+
// Объединённый picker: «Сцены» сверху (основной кейс), потом chars/locs.
|
|
3187
|
+
// Excluding текущей доски — линковать саму на себя бессмысленно.
|
|
3188
|
+
const items = [];
|
|
3189
|
+
const curKey = state.currentBoard ? `${state.currentBoard.kind}::${state.currentBoard.name}` : '';
|
|
3190
|
+
for (const b of eps) if (`episode::${b.name}` !== curKey) items.push({ kind: 'episode', name: b.name, label: '🎬 ' + b.name });
|
|
3191
|
+
for (const b of chars) if (`character::${b.name}` !== curKey) items.push({ kind: 'character', name: b.name, label: '👤 ' + b.name });
|
|
3192
|
+
for (const b of locs) if (`location::${b.name}` !== curKey) items.push({ kind: 'location', name: b.name, label: '📍 ' + b.name });
|
|
3193
|
+
if (!items.length) { alert('Нет других досок для ссылки'); return; }
|
|
3194
|
+
const choice = await askChoice('Куда ведёт ссылка?', items.map(i => i.label), null, { vertical: true });
|
|
3195
|
+
if (!choice) return;
|
|
3196
|
+
const picked = items.find(i => i.label === choice);
|
|
3197
|
+
if (!picked) return;
|
|
3198
|
+
node.linkedBoard = { kind: picked.kind, name: picked.name };
|
|
3199
|
+
scheduleSave();
|
|
3200
|
+
const el = canvas.querySelector(`.node[data-id="${node.id}"]`);
|
|
3201
|
+
if (el) refreshNodeLinkBadge(el, node);
|
|
3202
|
+
}
|
|
3203
|
+
window.goToLinkedBoard = goToLinkedBoard;
|
|
3204
|
+
window.pickBoardForLink = pickBoardForLink;
|
|
3205
|
+
|
|
3147
3206
|
// Выделить ноду + проскроллить view так, чтобы она была в центре viewport'а.
|
|
3148
3207
|
// Юзер: «после создания ноды (любой) нужно её выделять и перемещаться к ней».
|
|
3149
3208
|
// Используется после addText/addLabel/genNode'а — иначе если spot оказался
|
|
@@ -3776,9 +3835,25 @@ async function createNodeEl(node) {
|
|
|
3776
3835
|
el.appendChild(anchor);
|
|
3777
3836
|
attachAnchor(node, el, anchor);
|
|
3778
3837
|
attachDrag(el, node);
|
|
3838
|
+
// Link-badge: показывает 🔗 если у ноды есть linkedBoard (ссылка на сцену).
|
|
3839
|
+
// Сам badge кликабельный — переход выполняется также через dblclick по ноде.
|
|
3840
|
+
const linkBadge = document.createElement('div');
|
|
3841
|
+
linkBadge.className = 'link-badge';
|
|
3842
|
+
linkBadge.textContent = '🔗';
|
|
3843
|
+
el.appendChild(linkBadge);
|
|
3844
|
+
linkBadge.addEventListener('click', e => {
|
|
3845
|
+
e.stopPropagation();
|
|
3846
|
+
if (node.linkedBoard?.name) goToLinkedBoard(node);
|
|
3847
|
+
});
|
|
3848
|
+
refreshNodeLinkBadge(el, node);
|
|
3779
3849
|
el.addEventListener('dblclick', e => {
|
|
3780
3850
|
if (e.target.closest('textarea, input, video, button, .delete, .anchor, .resize-handle, [contenteditable]'))
|
|
3781
3851
|
return;
|
|
3852
|
+
// Если у ноды есть link на сцену — переход важнее остальных действий.
|
|
3853
|
+
if (node.linkedBoard?.name) {
|
|
3854
|
+
goToLinkedBoard(node);
|
|
3855
|
+
return;
|
|
3856
|
+
}
|
|
3782
3857
|
if (node.type === 'audio' && node.file) {
|
|
3783
3858
|
regenerateNode(node);
|
|
3784
3859
|
return;
|
|
@@ -3985,6 +4060,18 @@ function showNodeContextMenu(node, clientX, clientY) {
|
|
|
3985
4060
|
loadAllCharactersInfo().catch(() => { });
|
|
3986
4061
|
});
|
|
3987
4062
|
}
|
|
4063
|
+
// Link-to-scene
|
|
4064
|
+
if (node.linkedBoard?.name) {
|
|
4065
|
+
add(`🔗 Перейти → ${node.linkedBoard.name}`, () => goToLinkedBoard(node));
|
|
4066
|
+
add('⛓️💥 Убрать ссылку', () => {
|
|
4067
|
+
delete node.linkedBoard;
|
|
4068
|
+
scheduleSave();
|
|
4069
|
+
const el = canvas.querySelector(`.node[data-id="${node.id}"]`);
|
|
4070
|
+
if (el) refreshNodeLinkBadge(el, node);
|
|
4071
|
+
});
|
|
4072
|
+
} else {
|
|
4073
|
+
add('🔗 Сделать ссылку на сцену…', () => pickBoardForLink(node));
|
|
4074
|
+
}
|
|
3988
4075
|
add('⎘ Скопировать (Cmd+C)', () => copyNodeToClipboard(node));
|
|
3989
4076
|
add('✂ Вырезать (Cmd+X)', async () => {
|
|
3990
4077
|
await copyNodeToClipboard(node);
|
|
@@ -351,9 +351,11 @@
|
|
|
351
351
|
// canModify. Юзер видит лишние edit-кнопки на view-only-link.
|
|
352
352
|
if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
|
|
353
353
|
if (window.__KINGKONT_WEB__ && !/^#template=/.test(location.hash)) location.hash = '#project=' + projectId;
|
|
354
|
-
// Background — узнаём актуальный canModify
|
|
355
|
-
//
|
|
356
|
-
|
|
354
|
+
// Background — узнаём актуальный canModify + докачиваем тексты
|
|
355
|
+
// (если кэш был «частичный» из view-only-сессии, .md мог не быть).
|
|
356
|
+
// После download'а перечитываем text-ноды из FS и обновляем
|
|
357
|
+
// currentBoard, чтобы textarea-ноды показали свежий контент.
|
|
358
|
+
fetch('/api/projects/' + encodeURIComponent(projectId)).then(r => r.ok ? r.json() : null).then(async p => {
|
|
357
359
|
if (!p) return;
|
|
358
360
|
state.cloudPermission = p.permission || (p.mine ? 'owner' : null);
|
|
359
361
|
state.cloudMine = !!p.mine;
|
|
@@ -361,6 +363,10 @@
|
|
|
361
363
|
state.cloudIsPublic = !!p.isPublic;
|
|
362
364
|
setCloudButtonsVisibility();
|
|
363
365
|
if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
|
|
366
|
+
const bgBoards = Array.isArray(p.manifest?.boards) ? p.manifest.boards : [];
|
|
367
|
+
await _downloadAllTexts(projectId, bgBoards).catch(() => {});
|
|
368
|
+
// Перечитываем тексты ТЕКУЩЕЙ доски (другие — при switchBoard).
|
|
369
|
+
await _refreshCurrentBoardTexts().catch(() => {});
|
|
364
370
|
}).catch(() => {});
|
|
365
371
|
return;
|
|
366
372
|
}
|
|
@@ -399,6 +405,11 @@
|
|
|
399
405
|
new TextEncoder().encode(JSON.stringify(board.scene, null, 2)));
|
|
400
406
|
}
|
|
401
407
|
}
|
|
408
|
+
// a.5) Тексты (texts/*.md) — тоже синхронно ДО openFilm. loadBoardMetadata
|
|
409
|
+
// при открытии доски ходит к каждому n.file через resolveBoardFile —
|
|
410
|
+
// если файла нет локально, n.text=''. View-only (canModify=false) раньше
|
|
411
|
+
// вообще не докачивал → юзер видел пустые text-ноды.
|
|
412
|
+
await _downloadAllTexts(projectId, boards);
|
|
402
413
|
// CDN-карта — будет и в state, и в meta (чтобы пережила reload).
|
|
403
414
|
// Конвертируем /get/<hash> → /thumbnail/<hash>/1024x для лучшего
|
|
404
415
|
// кеширования на CDN (юзер: «/get плохо кешируется, /thumbnail
|
|
@@ -461,6 +472,60 @@
|
|
|
461
472
|
}
|
|
462
473
|
}
|
|
463
474
|
|
|
475
|
+
// Параллельная синхронная загрузка всех .md-контентов всех бордов.
|
|
476
|
+
// Тексты маленькие (1-10KB обычно), качаем сразу — иначе при открытии
|
|
477
|
+
// доски юзер видит пустые text-ноды до завершения _backgroundDownload
|
|
478
|
+
// (который не запускается в view-only).
|
|
479
|
+
async function _downloadAllTexts(projectId, boards) {
|
|
480
|
+
const tasks = [];
|
|
481
|
+
for (const board of boards) {
|
|
482
|
+
const boardDir = boardKindToDir(board.kind, board.name);
|
|
483
|
+
for (const [relPath, textId] of Object.entries(board.texts || {})) {
|
|
484
|
+
tasks.push({ boardDir, relPath, textId });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!tasks.length) return;
|
|
488
|
+
const CONCURRENCY = 8;
|
|
489
|
+
let i = 0;
|
|
490
|
+
async function worker() {
|
|
491
|
+
while (i < tasks.length) {
|
|
492
|
+
const t = tasks[i++];
|
|
493
|
+
try {
|
|
494
|
+
const tr = await fetch('/api/project-texts/' + encodeURIComponent(t.textId));
|
|
495
|
+
if (!tr.ok) continue;
|
|
496
|
+
const td = await tr.json();
|
|
497
|
+
const buf = new TextEncoder().encode(td.content || '');
|
|
498
|
+
await writeCloudFile(projectId, joinPath(t.boardDir, t.relPath), buf);
|
|
499
|
+
} catch {}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Перечитать .md-контент text-нод текущей доски из FS и обновить DOM.
|
|
506
|
+
// Используется после фонового _downloadAllTexts — если тексты пришли
|
|
507
|
+
// ПОСЛЕ openFilm, ноды показывают пустые textarea, пока этот хук не
|
|
508
|
+
// обновит node.text + refreshNodeDOM.
|
|
509
|
+
async function _refreshCurrentBoardTexts() {
|
|
510
|
+
const board = state.currentBoard;
|
|
511
|
+
if (!board?.handle) return;
|
|
512
|
+
const nodes = board.metadata?.nodes || [];
|
|
513
|
+
let changed = 0;
|
|
514
|
+
for (const n of nodes) {
|
|
515
|
+
if (n.type !== 'text' || !n.file) continue;
|
|
516
|
+
try {
|
|
517
|
+
const fh = await resolveBoardFile(board.handle, n.file);
|
|
518
|
+
const content = await (await fh.getFile()).text();
|
|
519
|
+
if (n.text !== content) {
|
|
520
|
+
n.text = content;
|
|
521
|
+
changed++;
|
|
522
|
+
if (typeof refreshNodeDOM === 'function') await refreshNodeDOM(n.id);
|
|
523
|
+
}
|
|
524
|
+
} catch {}
|
|
525
|
+
}
|
|
526
|
+
if (changed) console.log('[cloudProjects] refreshed', changed, 'text-нод после фоновой загрузки');
|
|
527
|
+
}
|
|
528
|
+
|
|
464
529
|
// Подложить CDN-URL'ы под state.currentBoard.urls — рендеру медиа-нод
|
|
465
530
|
// в settings.js достаточно непустого `urls[node.file]`, чтобы не лезть
|
|
466
531
|
// в FS (см. settings.js:405).
|
package/renderer/styles.css
CHANGED
|
@@ -110,6 +110,24 @@
|
|
|
110
110
|
width: 36px; height: 36px; flex-shrink: 0; object-fit: contain;
|
|
111
111
|
background: #1a1a1a; border-radius: 8px; padding: 4px;
|
|
112
112
|
}
|
|
113
|
+
/* Link-badge: значок 🔗 на ноде когда у неё есть linkedBoard. Лежит в
|
|
114
|
+
правом-верхнем углу, кликабельный (= dblclick: открыть target-сцену).
|
|
115
|
+
По умолчанию скрыт (display:none); refreshNodeLinkBadge показывает. */
|
|
116
|
+
.node .link-badge {
|
|
117
|
+
position: absolute; top: -10px; right: 8px;
|
|
118
|
+
width: 22px; height: 22px;
|
|
119
|
+
background: #2a3854; border: 1px solid #4a6a9a;
|
|
120
|
+
border-radius: 50%; cursor: pointer;
|
|
121
|
+
font-size: 12px; line-height: 20px; text-align: center;
|
|
122
|
+
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
|
123
|
+
z-index: 5;
|
|
124
|
+
transition: background 0.12s, transform 0.12s;
|
|
125
|
+
}
|
|
126
|
+
.node .link-badge:hover { background: #3a5a8a; transform: scale(1.1); }
|
|
127
|
+
/* В drawing-нодах кружок поверх stroke'а смотрится плохо — смещаем
|
|
128
|
+
к левому-верху самой bbox'ы. */
|
|
129
|
+
.node.drawing-node .link-badge { top: 4px; right: 4px; }
|
|
130
|
+
|
|
113
131
|
/* Brand-logo и welcome-logo всегда кликабельны (на главной → ничего,
|
|
114
132
|
в проекте → возврат на welcome, dblclick → настройки). cursor:pointer
|
|
115
133
|
ставится и в HTML inline, но в web chatium-минифайер иногда срывает
|
package/skill/SKILL.md
CHANGED
|
@@ -395,6 +395,10 @@ open -a "Google Chrome" "http://localhost:17893/"
|
|
|
395
395
|
},
|
|
396
396
|
"status": "generating" | "draft" | "error", // отсутствует если нода готова
|
|
397
397
|
"error": "...",
|
|
398
|
+
"linkedBoard": { // опц., ссылка на другую доску
|
|
399
|
+
"kind": "episode" | "character" | "location",
|
|
400
|
+
"name": "Сцена 2" // имя целевой доски (handle резолвится в runtime)
|
|
401
|
+
},
|
|
398
402
|
"history": [...], // история правок (через ⇄ Заменить)
|
|
399
403
|
"historyIndex": 0
|
|
400
404
|
}
|
|
@@ -475,6 +479,35 @@ open -a "Google Chrome" "http://localhost:17893/"
|
|
|
475
479
|
}
|
|
476
480
|
```
|
|
477
481
|
|
|
482
|
+
## Ссылки на доски (link-to-scene)
|
|
483
|
+
|
|
484
|
+
Нода может содержать ссылку на другую сцену/персонажа/локацию того же
|
|
485
|
+
проекта. Используется для нелинейной навигации — например, story-board
|
|
486
|
+
с миниатюрами сцен, по которым юзер кликает чтобы перейти.
|
|
487
|
+
|
|
488
|
+
**Формат** (поле в ноде):
|
|
489
|
+
```json5
|
|
490
|
+
"linkedBoard": {
|
|
491
|
+
"kind": "episode" | "character" | "location",
|
|
492
|
+
"name": "Сцена 2"
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
**UI**:
|
|
497
|
+
- ПКМ на ноде → «🔗 Сделать ссылку на сцену…» (picker всех досок проекта,
|
|
498
|
+
кроме текущей). Меняется на «🔗 Перейти → <имя>» + «⛓️💥 Убрать ссылку»
|
|
499
|
+
когда ссылка уже есть.
|
|
500
|
+
- На ноде со ссылкой — значок 🔗 в правом-верхнем углу.
|
|
501
|
+
- Дабл-клик на ноде = переход (перебивает обычное действие dblclick типа
|
|
502
|
+
fullscreen-просмотра).
|
|
503
|
+
- Если целевая доска переименована/удалена — алерт «не найдена».
|
|
504
|
+
|
|
505
|
+
**Handle не сериализуется** — хранится только `{kind, name}`. При переходе
|
|
506
|
+
редактор резолвит через `listEpisodes/listCharacters/listLocations`.
|
|
507
|
+
Поэтому ссылка переживает clone-проекта (новые директории, та же
|
|
508
|
+
структура). Но переименование целевой доски ломает ссылку — нужно
|
|
509
|
+
обновлять вручную в scene.json или пересоздавать через ПКМ.
|
|
510
|
+
|
|
478
511
|
## Файловые операции
|
|
479
512
|
|
|
480
513
|
- **Удаление ноды**: файл переезжает в `<root>/_deleted/<уникальное-имя>`.
|