kingkont 0.11.5 → 0.12.0
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/main.js +14 -0
- package/package.json +1 -1
- package/preload.js +10 -0
- package/renderer/board.js +69 -5
- package/renderer/chat.js +120 -3
- package/renderer/styles.css +25 -7
package/main.js
CHANGED
|
@@ -745,6 +745,20 @@ ipcMain.handle('cloudFs:openInFinder', async (_e, projectId) => {
|
|
|
745
745
|
}
|
|
746
746
|
});
|
|
747
747
|
|
|
748
|
+
// Универсальный shell.openPath — для папочных проектов где знаем абс-путь.
|
|
749
|
+
// Renderer выводит путь через webUtils.getPathForFile (см. appPath.pathForFile
|
|
750
|
+
// в preload). Безопасность: shell.openPath сам проверяет что path существует
|
|
751
|
+
// + macOS sandbox предотвращает «поход куда не следует».
|
|
752
|
+
ipcMain.handle('shell:openPath', async (_e, absPath) => {
|
|
753
|
+
if (!absPath || typeof absPath !== 'string') return { ok: false, error: 'no path' };
|
|
754
|
+
try {
|
|
755
|
+
const err = await shell.openPath(absPath);
|
|
756
|
+
return { ok: !err, error: err || null };
|
|
757
|
+
} catch (e) {
|
|
758
|
+
return { ok: false, error: e.message };
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
|
|
748
762
|
ipcMain.handle('window:close', () => {
|
|
749
763
|
if (win && !win.isDestroyed()) win.close();
|
|
750
764
|
});
|
package/package.json
CHANGED
package/preload.js
CHANGED
|
@@ -93,6 +93,16 @@ contextBridge.exposeInMainWorld('claudeMd', {
|
|
|
93
93
|
installSkill: () => ipcRenderer.invoke('claudeMd:installSkill'),
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
// Filesystem-path utils. Нужно для «Открыть в Finder» папочных проектов:
|
|
97
|
+
// FSAH directoryHandle не даёт абс-путь, но webUtils.getPathForFile(file)
|
|
98
|
+
// — даёт. Берём любой файл изнутри папки → парсим parent → openPath.
|
|
99
|
+
contextBridge.exposeInMainWorld('appPath', {
|
|
100
|
+
pathForFile: (file) => {
|
|
101
|
+
try { return webUtils.getPathForFile(file) || null; } catch { return null; }
|
|
102
|
+
},
|
|
103
|
+
openPath: (absPath) => ipcRenderer.invoke('shell:openPath', absPath),
|
|
104
|
+
});
|
|
105
|
+
|
|
96
106
|
// Cloud-projects: тонкая обёртка над userData/cloud-projects/<id>/.
|
|
97
107
|
// Используется renderer'ом в режиме «облачного проекта» (без showDirectoryPicker).
|
|
98
108
|
// На веб-версии (без preload) — undefined; web-shell использует in-memory модель
|
package/renderer/board.js
CHANGED
|
@@ -642,6 +642,7 @@ function makeRecentWelcomeCard(r) {
|
|
|
642
642
|
|
|
643
643
|
// ПКМ-меню для recent (folder) welcome-карточки. По аналогии с cloud:
|
|
644
644
|
// «Сохранить как шаблон» (открывает проект → saveCurrentProjectAsTemplate)
|
|
645
|
+
// + «Открыть в Finder» (абс-путь через webUtils.getPathForFile)
|
|
645
646
|
// + «Убрать из недавних».
|
|
646
647
|
function showRecentCardContextMenu(r, clientX, clientY) {
|
|
647
648
|
const menu = $('nodeMenu');
|
|
@@ -654,6 +655,21 @@ function showRecentCardContextMenu(r, clientX, clientY) {
|
|
|
654
655
|
b.addEventListener('click', () => { menu.classList.add('hidden'); fn(); });
|
|
655
656
|
menu.appendChild(b);
|
|
656
657
|
};
|
|
658
|
+
add('📂 Открыть в Finder', async () => {
|
|
659
|
+
if (!r.handle || !window.appPath?.pathForFile || !window.appPath?.openPath) {
|
|
660
|
+
alert('Доступно только в Electron-приложении');
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
let g = (await r.handle.queryPermission({ mode: 'read' })) === 'granted';
|
|
665
|
+
if (!g) g = (await r.handle.requestPermission({ mode: 'read' })) === 'granted';
|
|
666
|
+
if (!g) { alert('Доступ к папке не подтверждён.'); return; }
|
|
667
|
+
const folderPath = await deriveFolderAbsPath(r.handle);
|
|
668
|
+
if (!folderPath) { alert('Не удалось определить путь к папке (нет файлов внутри).'); return; }
|
|
669
|
+
const res = await window.appPath.openPath(folderPath);
|
|
670
|
+
if (!res?.ok) alert('Не удалось открыть: ' + (res?.error || 'unknown'));
|
|
671
|
+
} catch (e) { alert('Ошибка: ' + (e?.message || e)); }
|
|
672
|
+
});
|
|
657
673
|
add('💾 Сохранить как шаблон…', async () => {
|
|
658
674
|
if (!r.handle) { alert('Handle потерян, сначала открой проект и сохрани заново.'); return; }
|
|
659
675
|
try {
|
|
@@ -676,6 +692,43 @@ function showRecentCardContextMenu(r, clientX, clientY) {
|
|
|
676
692
|
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
677
693
|
}
|
|
678
694
|
|
|
695
|
+
// Получить абс-путь FSAH-папки. webUtils.getPathForFile работает только
|
|
696
|
+
// для File объектов (не для DirectoryHandle), поэтому ищем любой файл
|
|
697
|
+
// внутри и берём parent. Тестируем: scene.json в любом board → up 2 dirs.
|
|
698
|
+
// Или meta-файл в корне → up 1 dir. Если ничего нет — возвращаем null.
|
|
699
|
+
async function deriveFolderAbsPath(filmHandle) {
|
|
700
|
+
if (!window.appPath?.pathForFile) return null;
|
|
701
|
+
// 1) Любой файл в корне (например .kingkont-meta.json или .cover.*).
|
|
702
|
+
try {
|
|
703
|
+
for await (const [name, h] of filmHandle.entries()) {
|
|
704
|
+
if (h.kind === 'file' && !name.startsWith('.git') && !name.startsWith('node_modules')) {
|
|
705
|
+
const file = await h.getFile();
|
|
706
|
+
const p = window.appPath.pathForFile(file);
|
|
707
|
+
if (p) return p.replace(/[\/\\][^\/\\]+$/, '');
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
} catch {}
|
|
711
|
+
// 2) Файл из подпапки (любой board) — up 2 уровня.
|
|
712
|
+
try {
|
|
713
|
+
for await (const [, dirH] of filmHandle.entries()) {
|
|
714
|
+
if (dirH.kind !== 'directory' || dirH.name.startsWith('.')) continue;
|
|
715
|
+
try {
|
|
716
|
+
for await (const [name2, h2] of dirH.entries()) {
|
|
717
|
+
if (h2.kind === 'file') {
|
|
718
|
+
const file = await h2.getFile();
|
|
719
|
+
const p = window.appPath.pathForFile(file);
|
|
720
|
+
if (p) {
|
|
721
|
+
// up 2: removes filename + parent dir name
|
|
722
|
+
return p.replace(/[\/\\][^\/\\]+[\/\\][^\/\\]+$/, '');
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
} catch {}
|
|
727
|
+
}
|
|
728
|
+
} catch {}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
679
732
|
// Карточка облачного проекта. Визуально такая же как у папочного, плюс
|
|
680
733
|
// ☁ бейдж в углу обложки. Клик → cloudProjects.open() (использует local
|
|
681
734
|
// cache если синхронизирован, иначе скачивает manifest+файлы).
|
|
@@ -1772,7 +1825,11 @@ async function selectBoard(board) {
|
|
|
1772
1825
|
scheduleUpdatePreview();
|
|
1773
1826
|
}
|
|
1774
1827
|
|
|
1775
|
-
// Восстановить pan/zoom
|
|
1828
|
+
// Восстановить pan/zoom доски. Default-scroll выставляем на canvas-pad-x/y
|
|
1829
|
+
// (см. styles.css .canvas-frame), чтобы canvas (0,0) был виден в top-left.
|
|
1830
|
+
// Без этого пользователь видел бы пустой padding слева/сверху.
|
|
1831
|
+
const padX = parseInt(getComputedStyle(document.querySelector('.canvas-frame')).getPropertyValue('--canvas-pad-x')) || 2000;
|
|
1832
|
+
const padY = parseInt(getComputedStyle(document.querySelector('.canvas-frame')).getPropertyValue('--canvas-pad-y')) || 1500;
|
|
1776
1833
|
const view = state.currentBoard.metadata.view;
|
|
1777
1834
|
if (view) {
|
|
1778
1835
|
if (typeof view.zoom === 'number') {
|
|
@@ -1780,14 +1837,21 @@ async function selectBoard(board) {
|
|
|
1780
1837
|
applyZoomStyles(state.zoom);
|
|
1781
1838
|
$('zoomLabel').textContent = Math.round(state.zoom * 100) + '%';
|
|
1782
1839
|
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1840
|
+
// ВАЖНО: для backward-compat старых view (где scrollLeft/Top сохранены
|
|
1841
|
+
// БЕЗ padding'а — до этой фичи) добавляем padding к сохранённому
|
|
1842
|
+
// значению. Если view свежий и >= padding'а, считаем что padding уже учтён.
|
|
1843
|
+
if (typeof view.scrollLeft === 'number') {
|
|
1844
|
+
canvasWrap.scrollLeft = view.scrollLeft >= padX ? view.scrollLeft : view.scrollLeft + padX;
|
|
1845
|
+
} else canvasWrap.scrollLeft = padX;
|
|
1846
|
+
if (typeof view.scrollTop === 'number') {
|
|
1847
|
+
canvasWrap.scrollTop = view.scrollTop >= padY ? view.scrollTop : view.scrollTop + padY;
|
|
1848
|
+
} else canvasWrap.scrollTop = padY;
|
|
1785
1849
|
} else {
|
|
1786
1850
|
state.zoom = 1;
|
|
1787
1851
|
applyZoomStyles(1);
|
|
1788
1852
|
$('zoomLabel').textContent = '100%';
|
|
1789
|
-
canvasWrap.scrollLeft =
|
|
1790
|
-
canvasWrap.scrollTop =
|
|
1853
|
+
canvasWrap.scrollLeft = padX;
|
|
1854
|
+
canvasWrap.scrollTop = padY;
|
|
1791
1855
|
}
|
|
1792
1856
|
|
|
1793
1857
|
// Возобновить незавершённые джобы текущей доски
|
package/renderer/chat.js
CHANGED
|
@@ -104,10 +104,14 @@
|
|
|
104
104
|
async handler({ type, name, prompt, x, y, text, modelKey }) {
|
|
105
105
|
if (!state.currentBoard) throw new Error('доска не выбрана');
|
|
106
106
|
if (!['image','video','audio','text','label'].includes(type)) throw new Error(`unknown type: ${type}`);
|
|
107
|
-
// Авто-координаты — справа от последней ноды.
|
|
107
|
+
// Авто-координаты — справа от последней ноды. Clamp к >= 50, чтобы
|
|
108
|
+
// нода не ушла в отрицательные координаты (canvas начинается с 0,0,
|
|
109
|
+
// негативные позиции невидимы без скролла).
|
|
108
110
|
const last = state.currentBoard.metadata.nodes[state.currentBoard.metadata.nodes.length - 1];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
let nx = typeof x === 'number' ? x : (last ? last.x + 320 : 100);
|
|
112
|
+
let ny = typeof y === 'number' ? y : (last ? last.y : 100);
|
|
113
|
+
if (nx < 50) nx = 50;
|
|
114
|
+
if (ny < 50) ny = 50;
|
|
111
115
|
const id = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
|
|
112
116
|
const node = { id, type, x: nx, y: ny };
|
|
113
117
|
if (name) node.name = name;
|
|
@@ -181,6 +185,119 @@
|
|
|
181
185
|
},
|
|
182
186
|
},
|
|
183
187
|
|
|
188
|
+
list_timeline: {
|
|
189
|
+
description: 'Список дорожек и клипов таймлайна текущей доски. Возвращает {tracks: [{id, name, kind, clips: [{id, nodeId, file, start, duration, name}]}], totalDuration}.',
|
|
190
|
+
params: '{}',
|
|
191
|
+
async handler() {
|
|
192
|
+
const b = state.currentBoard;
|
|
193
|
+
if (!b) throw new Error('доска не выбрана');
|
|
194
|
+
if (typeof getTimeline !== 'function') throw new Error('getTimeline недоступен');
|
|
195
|
+
const tl = getTimeline();
|
|
196
|
+
const tracks = (tl?.tracks || []).map(t => ({
|
|
197
|
+
id: t.id, name: t.name, kind: t.kind,
|
|
198
|
+
clips: (t.clips || []).map(c => ({
|
|
199
|
+
id: c.id, nodeId: c.nodeId || null, type: c.type,
|
|
200
|
+
file: c.file || null, name: c.name || null,
|
|
201
|
+
start: c.start || 0, duration: c.duration || 0,
|
|
202
|
+
})),
|
|
203
|
+
}));
|
|
204
|
+
const totalDuration = tracks.reduce((max, t) => Math.max(
|
|
205
|
+
max,
|
|
206
|
+
(t.clips || []).reduce((m, c) => Math.max(m, (c.start || 0) + (c.duration || 0)), 0),
|
|
207
|
+
), 0);
|
|
208
|
+
return { tracks, totalDuration };
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
add_clip_to_timeline: {
|
|
213
|
+
description: 'Добавить ноду как клип в таймлайн. trackKind="video" для image/video, "audio" для audio. Если start не задан — добавляется в конец дорожки.',
|
|
214
|
+
params: '{"nodeId":"<node-id>","trackKind":"video|audio","start":<seconds (optional)>,"duration":<seconds (optional, default по типу)>}',
|
|
215
|
+
async handler({ nodeId, trackKind, start, duration }) {
|
|
216
|
+
const b = state.currentBoard;
|
|
217
|
+
if (!b) throw new Error('доска не выбрана');
|
|
218
|
+
const node = b.metadata.nodes.find(n => n.id === nodeId);
|
|
219
|
+
if (!node) throw new Error(`нода не найдена: ${nodeId}`);
|
|
220
|
+
if (!node.file) throw new Error('у ноды нет файла (нужно сначала сгенерировать)');
|
|
221
|
+
if (typeof getTimeline !== 'function') throw new Error('getTimeline недоступен');
|
|
222
|
+
const tl = getTimeline();
|
|
223
|
+
const tk = trackKind === 'audio' ? 'audio' : 'video';
|
|
224
|
+
let track = tl.tracks.find(t => t.kind === tk);
|
|
225
|
+
if (!track) {
|
|
226
|
+
track = { id: crypto.randomUUID(), name: tk === 'audio' ? 'Аудио' : 'Видео', kind: tk, clips: [] };
|
|
227
|
+
tl.tracks.push(track);
|
|
228
|
+
}
|
|
229
|
+
const trackEnd = track.clips.reduce((m, c) => Math.max(m, (+c.start || 0) + (+c.duration || 0)), 0);
|
|
230
|
+
const dur = typeof duration === 'number' && duration > 0
|
|
231
|
+
? duration
|
|
232
|
+
: (typeof defaultClipDuration === 'function' ? defaultClipDuration(node) : (node.type === 'image' ? 3 : 5));
|
|
233
|
+
const clip = {
|
|
234
|
+
id: crypto.randomUUID(),
|
|
235
|
+
nodeId: node.id,
|
|
236
|
+
type: node.type === 'audio' ? 'audio' : (node.type === 'video' ? 'video' : 'image'),
|
|
237
|
+
file: node.file,
|
|
238
|
+
name: node.name || null,
|
|
239
|
+
duration: dur,
|
|
240
|
+
start: typeof start === 'number' ? Math.max(0, start) : trackEnd,
|
|
241
|
+
};
|
|
242
|
+
track.clips.push(clip);
|
|
243
|
+
scheduleSave();
|
|
244
|
+
if (!document.getElementById('timelinePanel').classList.contains('hidden')) {
|
|
245
|
+
if (typeof renderTimeline === 'function') renderTimeline();
|
|
246
|
+
}
|
|
247
|
+
return { ok: true, clipId: clip.id, trackId: track.id, start: clip.start, duration: clip.duration };
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
remove_clip_from_timeline: {
|
|
252
|
+
description: 'Удалить клип из таймлайна по clipId.',
|
|
253
|
+
params: '{"clipId":"<clip-id>"}',
|
|
254
|
+
async handler({ clipId }) {
|
|
255
|
+
const b = state.currentBoard;
|
|
256
|
+
if (!b) throw new Error('доска не выбрана');
|
|
257
|
+
const tl = getTimeline();
|
|
258
|
+
if (!tl) throw new Error('нет таймлайна');
|
|
259
|
+
let removed = false;
|
|
260
|
+
for (const t of tl.tracks) {
|
|
261
|
+
const i = t.clips.findIndex(c => c.id === clipId);
|
|
262
|
+
if (i >= 0) { t.clips.splice(i, 1); removed = true; break; }
|
|
263
|
+
}
|
|
264
|
+
if (!removed) throw new Error(`клип не найден: ${clipId}`);
|
|
265
|
+
scheduleSave();
|
|
266
|
+
if (!document.getElementById('timelinePanel').classList.contains('hidden')) {
|
|
267
|
+
if (typeof renderTimeline === 'function') renderTimeline();
|
|
268
|
+
}
|
|
269
|
+
return { ok: true };
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
clear_timeline: {
|
|
274
|
+
description: 'Очистить таймлайн (все клипы со всех дорожек). Дорожки остаются.',
|
|
275
|
+
params: '{}',
|
|
276
|
+
async handler() {
|
|
277
|
+
const b = state.currentBoard;
|
|
278
|
+
if (!b) throw new Error('доска не выбрана');
|
|
279
|
+
const tl = getTimeline();
|
|
280
|
+
if (!tl) throw new Error('нет таймлайна');
|
|
281
|
+
for (const t of tl.tracks) t.clips = [];
|
|
282
|
+
scheduleSave();
|
|
283
|
+
if (!document.getElementById('timelinePanel').classList.contains('hidden')) {
|
|
284
|
+
if (typeof renderTimeline === 'function') renderTimeline();
|
|
285
|
+
}
|
|
286
|
+
return { ok: true };
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
open_timeline: {
|
|
291
|
+
description: 'Раскрыть панель таймлайна (если скрыта).',
|
|
292
|
+
params: '{}',
|
|
293
|
+
async handler() {
|
|
294
|
+
const panel = document.getElementById('timelinePanel');
|
|
295
|
+
if (!panel) throw new Error('timeline panel не найдена');
|
|
296
|
+
if (panel.classList.contains('hidden')) document.getElementById('timelineBtn')?.click();
|
|
297
|
+
return { ok: true };
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
|
|
184
301
|
generate_node: {
|
|
185
302
|
description: 'Запустить генерацию для draft-ноды (image/video/audio/text). Стартует напрямую в фоне БЕЗ показа диалога — не интерактивная UI-форма как regenerateNode.',
|
|
186
303
|
params: '{"id":"<node-id>"}',
|
package/renderer/styles.css
CHANGED
|
@@ -303,17 +303,25 @@
|
|
|
303
303
|
color: #e0e0e0; white-space: pre-wrap; word-break: break-word;
|
|
304
304
|
}
|
|
305
305
|
.chat-tool {
|
|
306
|
-
margin-top: 2px; background:
|
|
307
|
-
|
|
306
|
+
margin-top: 2px; background: transparent; border: none;
|
|
307
|
+
padding: 0; font-size: 10.5px; color: #666;
|
|
308
308
|
}
|
|
309
309
|
.chat-tool summary {
|
|
310
|
-
cursor: pointer; color: #
|
|
310
|
+
cursor: pointer; color: #666; outline: none; opacity: 0.75;
|
|
311
311
|
font-family: ui-monospace, 'SF Mono', monospace;
|
|
312
|
+
list-style: none; padding: 1px 4px;
|
|
313
|
+
transition: color 0.1s, opacity 0.1s;
|
|
312
314
|
}
|
|
315
|
+
.chat-tool summary::-webkit-details-marker { display: none; }
|
|
316
|
+
.chat-tool summary::before {
|
|
317
|
+
content: '▸ '; color: #555; font-size: 9px;
|
|
318
|
+
}
|
|
319
|
+
.chat-tool[open] summary::before { content: '▾ '; }
|
|
320
|
+
.chat-tool summary:hover { color: #aaa; opacity: 1; }
|
|
313
321
|
.chat-tool pre {
|
|
314
|
-
margin:
|
|
322
|
+
margin: 4px 0 4px 14px; color: #777; font-size: 10px;
|
|
315
323
|
white-space: pre-wrap; word-break: break-all; max-height: 200px;
|
|
316
|
-
overflow-y: auto;
|
|
324
|
+
overflow-y: auto; background: #161616; padding: 4px 6px; border-radius: 3px;
|
|
317
325
|
}
|
|
318
326
|
.chat-status {
|
|
319
327
|
color: #888; font-size: 11px; padding: 6px 10px;
|
|
@@ -511,12 +519,22 @@
|
|
|
511
519
|
background-image: radial-gradient(rgba(255,255,255,0.06) 1px, transparent 1px);
|
|
512
520
|
background-size: 24px 24px;
|
|
513
521
|
}
|
|
522
|
+
/* canvas-frame даёт virtual coord-system: canvas внутри сдвинут на
|
|
523
|
+
PADDING_LEFT/PADDING_TOP (см. CSS-переменные ниже). Это позволяет
|
|
524
|
+
юзеру скроллить в «отрицательные» координаты (нода с x=-500 видна
|
|
525
|
+
в frame-координатах = PADDING + (-500) = 1500). Без этого ноды с
|
|
526
|
+
отрицательным x/y оставались за левым краем и были недоступны. */
|
|
514
527
|
.canvas-frame {
|
|
515
528
|
position: relative;
|
|
516
|
-
|
|
529
|
+
--canvas-pad-x: 2000px;
|
|
530
|
+
--canvas-pad-y: 1500px;
|
|
531
|
+
width: calc(6000px + 2 * var(--canvas-pad-x));
|
|
532
|
+
height: calc(4000px + 2 * var(--canvas-pad-y));
|
|
517
533
|
}
|
|
518
534
|
.canvas {
|
|
519
|
-
position: absolute;
|
|
535
|
+
position: absolute;
|
|
536
|
+
left: var(--canvas-pad-x); top: var(--canvas-pad-y);
|
|
537
|
+
width: 6000px; height: 4000px;
|
|
520
538
|
transform-origin: 0 0;
|
|
521
539
|
/* `will-change: transform` — постоянная промоция в композитный слой.
|
|
522
540
|
Trade-off: hover/scroll иногда вызывают re-raster плитки (~200ms
|