kingkont 0.11.5 → 0.13.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 +73 -8
- package/renderer/chat.js +338 -14
- package/renderer/styles.css +56 -9
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+файлы).
|
|
@@ -1027,10 +1080,11 @@ async function closeProject() {
|
|
|
1027
1080
|
// Помечаем что юзер вышел явно — на следующем старте autoload пропускается.
|
|
1028
1081
|
// Cmd+R после close = welcome, а не реоткрытие.
|
|
1029
1082
|
try { localStorage.setItem('welcomeOnNextStart', '1'); } catch {}
|
|
1030
|
-
// Чат привязан к одному проекту —
|
|
1031
|
-
//
|
|
1083
|
+
// Чат привязан к одному проекту — flush pending-persist В ТЕКУЩИЙ
|
|
1084
|
+
// filmHandle, потом сбрасываем in-memory. Без flush'а debounced-write
|
|
1085
|
+
// мог бы попасть в новый проект (race на 600ms окно).
|
|
1032
1086
|
// Прячем панель — на welcome без проекта чат не имеет смысла.
|
|
1033
|
-
if (window.kingChat?.resetInMemory) window.kingChat.resetInMemory();
|
|
1087
|
+
if (window.kingChat?.resetInMemory) await window.kingChat.resetInMemory();
|
|
1034
1088
|
if (window.kingChat?.close) window.kingChat.close();
|
|
1035
1089
|
stopExternalWatcher();
|
|
1036
1090
|
// Сбрасываем UI таймлайна/превью — иначе при возврате через welcome
|
|
@@ -1772,7 +1826,11 @@ async function selectBoard(board) {
|
|
|
1772
1826
|
scheduleUpdatePreview();
|
|
1773
1827
|
}
|
|
1774
1828
|
|
|
1775
|
-
// Восстановить pan/zoom
|
|
1829
|
+
// Восстановить pan/zoom доски. Default-scroll выставляем на canvas-pad-x/y
|
|
1830
|
+
// (см. styles.css .canvas-frame), чтобы canvas (0,0) был виден в top-left.
|
|
1831
|
+
// Без этого пользователь видел бы пустой padding слева/сверху.
|
|
1832
|
+
const padX = parseInt(getComputedStyle(document.querySelector('.canvas-frame')).getPropertyValue('--canvas-pad-x')) || 2000;
|
|
1833
|
+
const padY = parseInt(getComputedStyle(document.querySelector('.canvas-frame')).getPropertyValue('--canvas-pad-y')) || 1500;
|
|
1776
1834
|
const view = state.currentBoard.metadata.view;
|
|
1777
1835
|
if (view) {
|
|
1778
1836
|
if (typeof view.zoom === 'number') {
|
|
@@ -1780,14 +1838,21 @@ async function selectBoard(board) {
|
|
|
1780
1838
|
applyZoomStyles(state.zoom);
|
|
1781
1839
|
$('zoomLabel').textContent = Math.round(state.zoom * 100) + '%';
|
|
1782
1840
|
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1841
|
+
// ВАЖНО: для backward-compat старых view (где scrollLeft/Top сохранены
|
|
1842
|
+
// БЕЗ padding'а — до этой фичи) добавляем padding к сохранённому
|
|
1843
|
+
// значению. Если view свежий и >= padding'а, считаем что padding уже учтён.
|
|
1844
|
+
if (typeof view.scrollLeft === 'number') {
|
|
1845
|
+
canvasWrap.scrollLeft = view.scrollLeft >= padX ? view.scrollLeft : view.scrollLeft + padX;
|
|
1846
|
+
} else canvasWrap.scrollLeft = padX;
|
|
1847
|
+
if (typeof view.scrollTop === 'number') {
|
|
1848
|
+
canvasWrap.scrollTop = view.scrollTop >= padY ? view.scrollTop : view.scrollTop + padY;
|
|
1849
|
+
} else canvasWrap.scrollTop = padY;
|
|
1785
1850
|
} else {
|
|
1786
1851
|
state.zoom = 1;
|
|
1787
1852
|
applyZoomStyles(1);
|
|
1788
1853
|
$('zoomLabel').textContent = '100%';
|
|
1789
|
-
canvasWrap.scrollLeft =
|
|
1790
|
-
canvasWrap.scrollTop =
|
|
1854
|
+
canvasWrap.scrollLeft = padX;
|
|
1855
|
+
canvasWrap.scrollTop = padY;
|
|
1791
1856
|
}
|
|
1792
1857
|
|
|
1793
1858
|
// Возобновить незавершённые джобы текущей доски
|
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>"}',
|
|
@@ -256,6 +373,32 @@
|
|
|
256
373
|
return lines.join('\n');
|
|
257
374
|
}
|
|
258
375
|
|
|
376
|
+
// Контекст-блок для system-prompt: текущая сцена + выделенные ноды +
|
|
377
|
+
// прикреплённые файлы. Перерисовывается на каждый send().
|
|
378
|
+
function buildContextBlock(ctx) {
|
|
379
|
+
const lines = ['=== ТЕКУЩИЙ КОНТЕКСТ ==='];
|
|
380
|
+
if (ctx.scene) {
|
|
381
|
+
lines.push(`Активная сцена: ${ctx.scene.kind}/${ctx.scene.name}`);
|
|
382
|
+
} else {
|
|
383
|
+
lines.push('Сцена не выбрана.');
|
|
384
|
+
}
|
|
385
|
+
if (ctx.selected?.length) {
|
|
386
|
+
lines.push(`Выделено нод (${ctx.selected.length}):`);
|
|
387
|
+
for (const s of ctx.selected) {
|
|
388
|
+
lines.push(` - id=${s.id} type=${s.type}${s.name ? ` name="${s.name}"` : ''}`);
|
|
389
|
+
}
|
|
390
|
+
lines.push('Если юзер говорит «эта», «эти», «выделенные» — он имеет в виду эти ноды.');
|
|
391
|
+
}
|
|
392
|
+
if (ctx.attachments?.length) {
|
|
393
|
+
lines.push(`Прикреплённые файлы (${ctx.attachments.length}):`);
|
|
394
|
+
for (const a of ctx.attachments) {
|
|
395
|
+
lines.push(` - ${a.relPath} (${a.mime || '?'}, ${a.size || '?'} bytes)`);
|
|
396
|
+
}
|
|
397
|
+
lines.push('Файлы лежат в текущей сцене по этим путям. Можешь использовать их в add_node как "file" поле или как референс.');
|
|
398
|
+
}
|
|
399
|
+
return lines.join('\n');
|
|
400
|
+
}
|
|
401
|
+
|
|
259
402
|
// ============== TOOL-CALL PARSER ==============
|
|
260
403
|
function parseToolCalls(text) {
|
|
261
404
|
const out = [];
|
|
@@ -295,14 +438,22 @@
|
|
|
295
438
|
// Сохраняется debounced на каждое изменение history (см. persistDebounced).
|
|
296
439
|
const CHAT_FILE = '.kingkont-chat.json';
|
|
297
440
|
let _persistTimer = null;
|
|
298
|
-
|
|
299
|
-
|
|
441
|
+
// Snapshot filmHandle В МОМЕНТ дебаунса — иначе если юзер переключил
|
|
442
|
+
// проект пока таймер ждал, persistNow запишет историю A в B.
|
|
443
|
+
let _persistTargetHandle = null;
|
|
444
|
+
let _persistTargetHistory = null;
|
|
445
|
+
async function persistNow(opts = {}) {
|
|
446
|
+
// По умолчанию пишем в текущий filmHandle (если debounce не успел)
|
|
447
|
+
// или в targetHandle, который snapshot'нут на момент debounce.
|
|
448
|
+
const fh0 = opts.handle || _persistTargetHandle || state.filmHandle;
|
|
449
|
+
const histSnap = opts.history || _persistTargetHistory || history;
|
|
450
|
+
if (!fh0) return;
|
|
300
451
|
try {
|
|
301
|
-
const fh = await
|
|
452
|
+
const fh = await fh0.getFileHandle(CHAT_FILE, { create: true });
|
|
302
453
|
const w = await fh.createWritable();
|
|
303
454
|
// Не сохраняем role==='system' (буфер) и tool_result-турны (промежуточные,
|
|
304
455
|
// нужны только модели). При re-load восстановим только pure user/assistant.
|
|
305
|
-
const persistable =
|
|
456
|
+
const persistable = histSnap.filter(m =>
|
|
306
457
|
m.role !== 'system' &&
|
|
307
458
|
!(m.role === 'user' && m.content?.startsWith('<tool_result>')),
|
|
308
459
|
);
|
|
@@ -310,11 +461,29 @@
|
|
|
310
461
|
await w.close();
|
|
311
462
|
} catch (e) {
|
|
312
463
|
console.warn('[chat] persist failed:', e?.message || e);
|
|
464
|
+
} finally {
|
|
465
|
+
// Если только что сохранили pending-дебаунс, чистим target.
|
|
466
|
+
if (!opts.handle && _persistTargetHandle) {
|
|
467
|
+
_persistTargetHandle = null;
|
|
468
|
+
_persistTargetHistory = null;
|
|
469
|
+
}
|
|
313
470
|
}
|
|
314
471
|
}
|
|
315
472
|
function persistDebounced() {
|
|
473
|
+
// Snapshot текущего filmHandle и копии history НА МОМЕНТ дебаунса.
|
|
474
|
+
// На момент срабатывания (через 600ms) state.filmHandle мог уже измениться.
|
|
475
|
+
_persistTargetHandle = state.filmHandle;
|
|
476
|
+
_persistTargetHistory = history.slice();
|
|
316
477
|
clearTimeout(_persistTimer);
|
|
317
|
-
_persistTimer = setTimeout(persistNow, 600);
|
|
478
|
+
_persistTimer = setTimeout(() => persistNow(), 600);
|
|
479
|
+
}
|
|
480
|
+
// Sync-flush: ждём pending-дебаунс прямо сейчас (перед project switch).
|
|
481
|
+
async function flushPersist() {
|
|
482
|
+
if (_persistTimer) {
|
|
483
|
+
clearTimeout(_persistTimer);
|
|
484
|
+
_persistTimer = null;
|
|
485
|
+
await persistNow();
|
|
486
|
+
}
|
|
318
487
|
}
|
|
319
488
|
async function loadHistoryFromCurrentProject() {
|
|
320
489
|
if (!state.filmHandle) { history = []; renderHistory(); return; }
|
|
@@ -511,6 +680,93 @@
|
|
|
511
680
|
}
|
|
512
681
|
}
|
|
513
682
|
|
|
683
|
+
// ============== CONTEXT (выделенные ноды + сцена + приложенные файлы) ==============
|
|
684
|
+
// Контекст-чипы видны над input'ом и автоматически инжектятся в system-prompt
|
|
685
|
+
// при send(). Файлы сохраняются в <currentBoard>/inbox/<name>.
|
|
686
|
+
let manualAttachments = []; // [{relPath, name, size, mime}] — файлы из drag-drop
|
|
687
|
+
|
|
688
|
+
function buildContextSnapshot() {
|
|
689
|
+
const ctx = { scene: null, selected: [], attachments: manualAttachments.slice() };
|
|
690
|
+
if (state.currentBoard) {
|
|
691
|
+
ctx.scene = { kind: state.currentBoard.kind, name: state.currentBoard.name };
|
|
692
|
+
}
|
|
693
|
+
if (state.selectedNodeIds && state.currentBoard) {
|
|
694
|
+
const ids = Array.from(state.selectedNodeIds);
|
|
695
|
+
for (const id of ids) {
|
|
696
|
+
const n = state.currentBoard.metadata.nodes.find(x => x.id === id);
|
|
697
|
+
if (n) ctx.selected.push({ id: n.id, type: n.type, name: n.name || null });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return ctx;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function renderContextRow() {
|
|
704
|
+
const row = document.getElementById('chatContextRow');
|
|
705
|
+
if (!row) return;
|
|
706
|
+
row.innerHTML = '';
|
|
707
|
+
const ctx = buildContextSnapshot();
|
|
708
|
+
const parts = [];
|
|
709
|
+
if (ctx.scene) parts.push({ key: 'scene', label: `🎬 ${ctx.scene.name}`, removable: false });
|
|
710
|
+
for (const sel of ctx.selected) {
|
|
711
|
+
parts.push({ key: 'sel:' + sel.id, label: `◉ ${sel.type === 'image' ? '🖼' : sel.type === 'video' ? '🎬' : sel.type === 'audio' ? '🎙' : '📝'} ${sel.name || sel.id.slice(0, 6)}`, removable: false });
|
|
712
|
+
}
|
|
713
|
+
for (const a of ctx.attachments) {
|
|
714
|
+
parts.push({ key: 'att:' + a.relPath, label: `📎 ${a.name}`, removable: true, onRemove: () => { manualAttachments = manualAttachments.filter(x => x.relPath !== a.relPath); renderContextRow(); } });
|
|
715
|
+
}
|
|
716
|
+
if (!parts.length) { row.style.display = 'none'; return; }
|
|
717
|
+
row.style.display = '';
|
|
718
|
+
for (const p of parts) {
|
|
719
|
+
const chip = document.createElement('span');
|
|
720
|
+
chip.className = 'chat-chip';
|
|
721
|
+
chip.textContent = p.label;
|
|
722
|
+
if (p.removable) {
|
|
723
|
+
const x = document.createElement('button');
|
|
724
|
+
x.textContent = '×';
|
|
725
|
+
x.className = 'chat-chip-x';
|
|
726
|
+
x.addEventListener('click', e => { e.stopPropagation(); p.onRemove?.(); });
|
|
727
|
+
chip.appendChild(x);
|
|
728
|
+
}
|
|
729
|
+
row.appendChild(chip);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Drag-drop: сохраняем файл в <currentBoard>/inbox/<unique-name>, регистрируем
|
|
734
|
+
// как attachment. Чат-tools и Claude увидят его в context-snapshot.
|
|
735
|
+
async function attachFileToChat(file) {
|
|
736
|
+
if (!state.currentBoard?.handle) {
|
|
737
|
+
alert('Сначала открой сцену — файл некуда положить');
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
const inbox = await state.currentBoard.handle.getDirectoryHandle('inbox', { create: true });
|
|
742
|
+
// Уникальное имя — если файл уже есть, добавим (1)/(2).
|
|
743
|
+
const baseName = (file.name || 'file.bin').replace(/[^\w.\-а-яА-Я]+/g, '_');
|
|
744
|
+
let name = baseName;
|
|
745
|
+
let i = 1;
|
|
746
|
+
while (true) {
|
|
747
|
+
try { await inbox.getFileHandle(name); }
|
|
748
|
+
catch { break; }
|
|
749
|
+
const dot = baseName.lastIndexOf('.');
|
|
750
|
+
name = dot > 0 ? `${baseName.slice(0, dot)}(${i})${baseName.slice(dot)}` : `${baseName}(${i})`;
|
|
751
|
+
i++;
|
|
752
|
+
}
|
|
753
|
+
const fh = await inbox.getFileHandle(name, { create: true });
|
|
754
|
+
const w = await fh.createWritable();
|
|
755
|
+
await w.write(file);
|
|
756
|
+
await w.close();
|
|
757
|
+
manualAttachments.push({
|
|
758
|
+
relPath: `inbox/${name}`,
|
|
759
|
+
name,
|
|
760
|
+
size: file.size,
|
|
761
|
+
mime: file.type || 'application/octet-stream',
|
|
762
|
+
});
|
|
763
|
+
renderContextRow();
|
|
764
|
+
appendStatus(`📎 Прикреплён файл: inbox/${name} (${(file.size/1024).toFixed(0)} KB)`);
|
|
765
|
+
} catch (e) {
|
|
766
|
+
alert('Не удалось прикрепить файл: ' + (e?.message || e));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
514
770
|
// ============== UI ==============
|
|
515
771
|
let history = []; // [{role: 'user'|'assistant'|'system', content: string, tools?: [...], results?: [...]}]
|
|
516
772
|
let busy = false;
|
|
@@ -651,7 +907,11 @@
|
|
|
651
907
|
renderHistory();
|
|
652
908
|
persistDebounced();
|
|
653
909
|
const status = appendStatus('KingKont думает…');
|
|
654
|
-
|
|
910
|
+
// Привязываем context-snapshot НА МОМЕНТ send: текущая сцена,
|
|
911
|
+
// выделенные ноды, прикреплённые файлы. Передаётся в system-prompt
|
|
912
|
+
// дополнительным блоком — модель видит что у юзера сейчас в фокусе.
|
|
913
|
+
const ctxSnap = buildContextSnapshot();
|
|
914
|
+
const system = buildSystemPrompt() + '\n\n' + buildContextBlock(ctxSnap);
|
|
655
915
|
try {
|
|
656
916
|
let iter = 0;
|
|
657
917
|
while (iter < MAX_TOOL_ITERATIONS) {
|
|
@@ -811,17 +1071,57 @@
|
|
|
811
1071
|
<div class="chat-header">
|
|
812
1072
|
<strong>💬 Чат</strong>
|
|
813
1073
|
<span class="spacer"></span>
|
|
1074
|
+
<button id="chatFontMinus" title="Меньше шрифт (Cmd+-)">A−</button>
|
|
1075
|
+
<button id="chatFontPlus" title="Больше шрифт (Cmd++)">A+</button>
|
|
814
1076
|
<button id="chatClear" title="Очистить историю">⌫</button>
|
|
815
1077
|
<button id="chatClose" title="Закрыть">×</button>
|
|
816
1078
|
</div>
|
|
817
1079
|
<div id="chatSnapshotBar" class="chat-snapshot-bar" style="display:none;"></div>
|
|
818
1080
|
<div id="chatList" class="chat-list"></div>
|
|
1081
|
+
<div id="chatContextRow" class="chat-context-row"></div>
|
|
819
1082
|
<div class="chat-input-row">
|
|
820
|
-
<textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter —
|
|
1083
|
+
<textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить, можно перетащить файл)" rows="3"></textarea>
|
|
821
1084
|
<button id="chatSend" class="primary" title="Отправить (Cmd+Enter)">▶</button>
|
|
822
1085
|
</div>
|
|
823
1086
|
`;
|
|
824
1087
|
document.body.appendChild(panel);
|
|
1088
|
+
// Font scaling: Cmd+/Cmd- ВНУТРИ panel'а меняет --chat-font (px). Сохраняется
|
|
1089
|
+
// в localStorage. Дефолт 13. Ограничиваем 10..22.
|
|
1090
|
+
const _fontInit = parseInt(localStorage.getItem('chatFontPx') || '13', 10);
|
|
1091
|
+
panel.style.setProperty('--chat-font', _fontInit + 'px');
|
|
1092
|
+
function adjustFont(delta) {
|
|
1093
|
+
const cur = parseInt(panel.style.getPropertyValue('--chat-font') || '13', 10);
|
|
1094
|
+
const next = Math.max(10, Math.min(22, cur + delta));
|
|
1095
|
+
panel.style.setProperty('--chat-font', next + 'px');
|
|
1096
|
+
try { localStorage.setItem('chatFontPx', String(next)); } catch {}
|
|
1097
|
+
}
|
|
1098
|
+
$('chatFontPlus')?.addEventListener('click', () => adjustFont(1));
|
|
1099
|
+
$('chatFontMinus')?.addEventListener('click', () => adjustFont(-1));
|
|
1100
|
+
// Cmd+/Cmd- (и Cmd++) пока чат-панель в фокусе — внутри-панельный font scale.
|
|
1101
|
+
panel.addEventListener('keydown', e => {
|
|
1102
|
+
if (!(e.metaKey || e.ctrlKey)) return;
|
|
1103
|
+
if (e.key === '+' || e.key === '=') { e.preventDefault(); adjustFont(1); }
|
|
1104
|
+
else if (e.key === '-') { e.preventDefault(); adjustFont(-1); }
|
|
1105
|
+
else if (e.key === '0') { e.preventDefault(); panel.style.setProperty('--chat-font', '13px'); localStorage.setItem('chatFontPx', '13'); }
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// === Drag-and-drop файлов в чат: сохраняем в <currentBoard>/inbox/<name>,
|
|
1109
|
+
// добавляем в context, юзер потом может ссылаться при send. ===
|
|
1110
|
+
panel.addEventListener('dragover', e => {
|
|
1111
|
+
if (!e.dataTransfer?.types?.includes?.('Files')) return;
|
|
1112
|
+
e.preventDefault();
|
|
1113
|
+
panel.classList.add('chat-drag');
|
|
1114
|
+
});
|
|
1115
|
+
panel.addEventListener('dragleave', e => {
|
|
1116
|
+
if (e.target === panel) panel.classList.remove('chat-drag');
|
|
1117
|
+
});
|
|
1118
|
+
panel.addEventListener('drop', async e => {
|
|
1119
|
+
panel.classList.remove('chat-drag');
|
|
1120
|
+
const files = Array.from(e.dataTransfer?.files || []);
|
|
1121
|
+
if (!files.length) return;
|
|
1122
|
+
e.preventDefault();
|
|
1123
|
+
for (const f of files) await attachFileToChat(f);
|
|
1124
|
+
});
|
|
825
1125
|
$('chatClose').addEventListener('click', () => panel.classList.add('hidden'));
|
|
826
1126
|
$('chatClear').addEventListener('click', () => {
|
|
827
1127
|
if (!confirm('Очистить историю чата?')) return;
|
|
@@ -851,10 +1151,29 @@
|
|
|
851
1151
|
// Отрисовываем сохранённую историю при показе (на случай если она
|
|
852
1152
|
// была подгружена в фоне через loadFromCurrentProject до первого toggle).
|
|
853
1153
|
renderHistory();
|
|
1154
|
+
renderContextRow();
|
|
854
1155
|
setTimeout(() => $('chatInput')?.focus(), 50);
|
|
855
1156
|
}
|
|
856
1157
|
}
|
|
857
1158
|
|
|
1159
|
+
// Поллинг изменений выделения / текущей сцены для авто-update context-row.
|
|
1160
|
+
// Селекшен меняется в множестве мест без единого event'а — проще опрашивать.
|
|
1161
|
+
// Комфортная частота 500ms — overhead nil, но реактивно для юзера.
|
|
1162
|
+
let _ctxLastSig = '';
|
|
1163
|
+
setInterval(() => {
|
|
1164
|
+
const panel = document.getElementById('chatPanel');
|
|
1165
|
+
if (!panel || panel.classList.contains('hidden')) return;
|
|
1166
|
+
const ctx = buildContextSnapshot();
|
|
1167
|
+
const sig = JSON.stringify({
|
|
1168
|
+
s: ctx.scene?.name || null,
|
|
1169
|
+
sel: ctx.selected.map(x => x.id),
|
|
1170
|
+
att: ctx.attachments.map(x => x.relPath),
|
|
1171
|
+
});
|
|
1172
|
+
if (sig === _ctxLastSig) return;
|
|
1173
|
+
_ctxLastSig = sig;
|
|
1174
|
+
renderContextRow();
|
|
1175
|
+
}, 500);
|
|
1176
|
+
|
|
858
1177
|
// Public API.
|
|
859
1178
|
window.kingChat = {
|
|
860
1179
|
toggle,
|
|
@@ -863,12 +1182,17 @@
|
|
|
863
1182
|
send,
|
|
864
1183
|
// User-clear: чистит И на диске тоже (юзер явно нажал ⌫).
|
|
865
1184
|
clear: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); persistNow().catch(() => {}); },
|
|
866
|
-
// Reset-on-close:
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
resetInMemory: () => {
|
|
1185
|
+
// Reset-on-close: ФЛАШИМ pending-дебаунс В ТЕКУЩИЙ filmHandle (чтобы
|
|
1186
|
+
// история не утекла в следующий проект), потом чистим in-memory.
|
|
1187
|
+
// На диске история того проекта остаётся — re-open подгрузит.
|
|
1188
|
+
resetInMemory: async () => {
|
|
1189
|
+
try { await flushPersist(); } catch {}
|
|
1190
|
+
history = []; snapshots = [];
|
|
1191
|
+
renderHistory(); renderSnapshotBar();
|
|
1192
|
+
},
|
|
870
1193
|
// board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
|
|
871
1194
|
loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
|
|
1195
|
+
flushPersist,
|
|
872
1196
|
tools: TOOLS,
|
|
873
1197
|
};
|
|
874
1198
|
|
package/renderer/styles.css
CHANGED
|
@@ -255,7 +255,36 @@
|
|
|
255
255
|
display: flex; flex-direction: column;
|
|
256
256
|
z-index: 80;
|
|
257
257
|
box-shadow: -8px 0 32px rgba(0,0,0,0.4);
|
|
258
|
+
/* --chat-font задаётся inline в chat.js (Cmd+/Cmd-). Дефолт 13px. */
|
|
259
|
+
font-size: var(--chat-font, 13px);
|
|
260
|
+
}
|
|
261
|
+
.chat-panel.chat-drag::before {
|
|
262
|
+
content: '📎 Отпусти, чтобы прикрепить файл к чату';
|
|
263
|
+
position: absolute; inset: 0; z-index: 100;
|
|
264
|
+
background: rgba(227, 51, 119, 0.18);
|
|
265
|
+
border: 2px dashed #e33377; border-radius: 8px;
|
|
266
|
+
display: flex; align-items: center; justify-content: center;
|
|
267
|
+
color: #fff; font-size: 16px; font-weight: 600;
|
|
268
|
+
pointer-events: none;
|
|
269
|
+
}
|
|
270
|
+
.chat-context-row {
|
|
271
|
+
display: flex; flex-wrap: wrap; gap: 4px;
|
|
272
|
+
padding: 6px 10px;
|
|
273
|
+
background: #1a1a1a; border-top: 1px solid #2a2a2a;
|
|
274
|
+
}
|
|
275
|
+
.chat-context-row:empty { display: none; }
|
|
276
|
+
.chat-chip {
|
|
277
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
278
|
+
background: #232a36; border: 1px solid #2c3848;
|
|
279
|
+
color: #aac; font-size: 11px;
|
|
280
|
+
padding: 2px 8px; border-radius: 999px;
|
|
281
|
+
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
258
282
|
}
|
|
283
|
+
.chat-chip-x {
|
|
284
|
+
background: transparent; border: none; color: #889;
|
|
285
|
+
cursor: pointer; font-size: 13px; padding: 0 2px; line-height: 1;
|
|
286
|
+
}
|
|
287
|
+
.chat-chip-x:hover { color: #f88; }
|
|
259
288
|
.chat-panel.hidden { display: none; }
|
|
260
289
|
.chat-header {
|
|
261
290
|
display: flex; align-items: center; gap: 6px;
|
|
@@ -284,7 +313,7 @@
|
|
|
284
313
|
.chat-msg {
|
|
285
314
|
display: flex; flex-direction: column; gap: 2px;
|
|
286
315
|
padding: 6px 10px; border-radius: 6px;
|
|
287
|
-
font-size:
|
|
316
|
+
font-size: inherit; line-height: 1.45; /* наследуем --chat-font от .chat-panel */
|
|
288
317
|
}
|
|
289
318
|
.chat-msg-user { background: #1f2a3a; border: 1px solid #2a3a4a; }
|
|
290
319
|
.chat-msg-assistant { background: #1f1f1f; border: 1px solid #2a2a2a; }
|
|
@@ -303,17 +332,25 @@
|
|
|
303
332
|
color: #e0e0e0; white-space: pre-wrap; word-break: break-word;
|
|
304
333
|
}
|
|
305
334
|
.chat-tool {
|
|
306
|
-
margin-top: 2px; background:
|
|
307
|
-
|
|
335
|
+
margin-top: 2px; background: transparent; border: none;
|
|
336
|
+
padding: 0; font-size: 10.5px; color: #666;
|
|
308
337
|
}
|
|
309
338
|
.chat-tool summary {
|
|
310
|
-
cursor: pointer; color: #
|
|
339
|
+
cursor: pointer; color: #666; outline: none; opacity: 0.75;
|
|
311
340
|
font-family: ui-monospace, 'SF Mono', monospace;
|
|
341
|
+
list-style: none; padding: 1px 4px;
|
|
342
|
+
transition: color 0.1s, opacity 0.1s;
|
|
343
|
+
}
|
|
344
|
+
.chat-tool summary::-webkit-details-marker { display: none; }
|
|
345
|
+
.chat-tool summary::before {
|
|
346
|
+
content: '▸ '; color: #555; font-size: 9px;
|
|
312
347
|
}
|
|
348
|
+
.chat-tool[open] summary::before { content: '▾ '; }
|
|
349
|
+
.chat-tool summary:hover { color: #aaa; opacity: 1; }
|
|
313
350
|
.chat-tool pre {
|
|
314
|
-
margin:
|
|
351
|
+
margin: 4px 0 4px 14px; color: #777; font-size: 10px;
|
|
315
352
|
white-space: pre-wrap; word-break: break-all; max-height: 200px;
|
|
316
|
-
overflow-y: auto;
|
|
353
|
+
overflow-y: auto; background: #161616; padding: 4px 6px; border-radius: 3px;
|
|
317
354
|
}
|
|
318
355
|
.chat-status {
|
|
319
356
|
color: #888; font-size: 11px; padding: 6px 10px;
|
|
@@ -328,7 +365,7 @@
|
|
|
328
365
|
.chat-input-row textarea {
|
|
329
366
|
flex: 1; resize: vertical; min-height: 50px; max-height: 200px;
|
|
330
367
|
background: #0e0e0e; color: #e0e0e0; border: 1px solid #333;
|
|
331
|
-
border-radius: 4px; padding: 8px; font-size:
|
|
368
|
+
border-radius: 4px; padding: 8px; font-size: inherit; font-family: inherit;
|
|
332
369
|
}
|
|
333
370
|
.chat-input-row textarea:focus { outline: none; border-color: #4a6a9a; }
|
|
334
371
|
.chat-input-row button {
|
|
@@ -511,12 +548,22 @@
|
|
|
511
548
|
background-image: radial-gradient(rgba(255,255,255,0.06) 1px, transparent 1px);
|
|
512
549
|
background-size: 24px 24px;
|
|
513
550
|
}
|
|
551
|
+
/* canvas-frame даёт virtual coord-system: canvas внутри сдвинут на
|
|
552
|
+
PADDING_LEFT/PADDING_TOP (см. CSS-переменные ниже). Это позволяет
|
|
553
|
+
юзеру скроллить в «отрицательные» координаты (нода с x=-500 видна
|
|
554
|
+
в frame-координатах = PADDING + (-500) = 1500). Без этого ноды с
|
|
555
|
+
отрицательным x/y оставались за левым краем и были недоступны. */
|
|
514
556
|
.canvas-frame {
|
|
515
557
|
position: relative;
|
|
516
|
-
|
|
558
|
+
--canvas-pad-x: 2000px;
|
|
559
|
+
--canvas-pad-y: 1500px;
|
|
560
|
+
width: calc(6000px + 2 * var(--canvas-pad-x));
|
|
561
|
+
height: calc(4000px + 2 * var(--canvas-pad-y));
|
|
517
562
|
}
|
|
518
563
|
.canvas {
|
|
519
|
-
position: absolute;
|
|
564
|
+
position: absolute;
|
|
565
|
+
left: var(--canvas-pad-x); top: var(--canvas-pad-y);
|
|
566
|
+
width: 6000px; height: 4000px;
|
|
520
567
|
transform-origin: 0 0;
|
|
521
568
|
/* `will-change: transform` — постоянная промоция в композитный слой.
|
|
522
569
|
Trade-off: hover/scroll иногда вызывают re-raster плитки (~200ms
|