kingkont 0.12.0 → 0.13.1
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 +4 -3
- package/renderer/chat.js +218 -11
- package/renderer/media.js +37 -9
- package/renderer/styles.css +31 -2
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -1080,10 +1080,11 @@ async function closeProject() {
|
|
|
1080
1080
|
// Помечаем что юзер вышел явно — на следующем старте autoload пропускается.
|
|
1081
1081
|
// Cmd+R после close = welcome, а не реоткрытие.
|
|
1082
1082
|
try { localStorage.setItem('welcomeOnNextStart', '1'); } catch {}
|
|
1083
|
-
// Чат привязан к одному проекту —
|
|
1084
|
-
//
|
|
1083
|
+
// Чат привязан к одному проекту — flush pending-persist В ТЕКУЩИЙ
|
|
1084
|
+
// filmHandle, потом сбрасываем in-memory. Без flush'а debounced-write
|
|
1085
|
+
// мог бы попасть в новый проект (race на 600ms окно).
|
|
1085
1086
|
// Прячем панель — на welcome без проекта чат не имеет смысла.
|
|
1086
|
-
if (window.kingChat?.resetInMemory) window.kingChat.resetInMemory();
|
|
1087
|
+
if (window.kingChat?.resetInMemory) await window.kingChat.resetInMemory();
|
|
1087
1088
|
if (window.kingChat?.close) window.kingChat.close();
|
|
1088
1089
|
stopExternalWatcher();
|
|
1089
1090
|
// Сбрасываем UI таймлайна/превью — иначе при возврате через welcome
|
package/renderer/chat.js
CHANGED
|
@@ -373,6 +373,32 @@
|
|
|
373
373
|
return lines.join('\n');
|
|
374
374
|
}
|
|
375
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
|
+
|
|
376
402
|
// ============== TOOL-CALL PARSER ==============
|
|
377
403
|
function parseToolCalls(text) {
|
|
378
404
|
const out = [];
|
|
@@ -412,14 +438,22 @@
|
|
|
412
438
|
// Сохраняется debounced на каждое изменение history (см. persistDebounced).
|
|
413
439
|
const CHAT_FILE = '.kingkont-chat.json';
|
|
414
440
|
let _persistTimer = null;
|
|
415
|
-
|
|
416
|
-
|
|
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;
|
|
417
451
|
try {
|
|
418
|
-
const fh = await
|
|
452
|
+
const fh = await fh0.getFileHandle(CHAT_FILE, { create: true });
|
|
419
453
|
const w = await fh.createWritable();
|
|
420
454
|
// Не сохраняем role==='system' (буфер) и tool_result-турны (промежуточные,
|
|
421
455
|
// нужны только модели). При re-load восстановим только pure user/assistant.
|
|
422
|
-
const persistable =
|
|
456
|
+
const persistable = histSnap.filter(m =>
|
|
423
457
|
m.role !== 'system' &&
|
|
424
458
|
!(m.role === 'user' && m.content?.startsWith('<tool_result>')),
|
|
425
459
|
);
|
|
@@ -427,11 +461,29 @@
|
|
|
427
461
|
await w.close();
|
|
428
462
|
} catch (e) {
|
|
429
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
|
+
}
|
|
430
470
|
}
|
|
431
471
|
}
|
|
432
472
|
function persistDebounced() {
|
|
473
|
+
// Snapshot текущего filmHandle и копии history НА МОМЕНТ дебаунса.
|
|
474
|
+
// На момент срабатывания (через 600ms) state.filmHandle мог уже измениться.
|
|
475
|
+
_persistTargetHandle = state.filmHandle;
|
|
476
|
+
_persistTargetHistory = history.slice();
|
|
433
477
|
clearTimeout(_persistTimer);
|
|
434
|
-
_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
|
+
}
|
|
435
487
|
}
|
|
436
488
|
async function loadHistoryFromCurrentProject() {
|
|
437
489
|
if (!state.filmHandle) { history = []; renderHistory(); return; }
|
|
@@ -628,6 +680,93 @@
|
|
|
628
680
|
}
|
|
629
681
|
}
|
|
630
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
|
+
|
|
631
770
|
// ============== UI ==============
|
|
632
771
|
let history = []; // [{role: 'user'|'assistant'|'system', content: string, tools?: [...], results?: [...]}]
|
|
633
772
|
let busy = false;
|
|
@@ -768,7 +907,11 @@
|
|
|
768
907
|
renderHistory();
|
|
769
908
|
persistDebounced();
|
|
770
909
|
const status = appendStatus('KingKont думает…');
|
|
771
|
-
|
|
910
|
+
// Привязываем context-snapshot НА МОМЕНТ send: текущая сцена,
|
|
911
|
+
// выделенные ноды, прикреплённые файлы. Передаётся в system-prompt
|
|
912
|
+
// дополнительным блоком — модель видит что у юзера сейчас в фокусе.
|
|
913
|
+
const ctxSnap = buildContextSnapshot();
|
|
914
|
+
const system = buildSystemPrompt() + '\n\n' + buildContextBlock(ctxSnap);
|
|
772
915
|
try {
|
|
773
916
|
let iter = 0;
|
|
774
917
|
while (iter < MAX_TOOL_ITERATIONS) {
|
|
@@ -928,17 +1071,57 @@
|
|
|
928
1071
|
<div class="chat-header">
|
|
929
1072
|
<strong>💬 Чат</strong>
|
|
930
1073
|
<span class="spacer"></span>
|
|
1074
|
+
<button id="chatFontMinus" title="Меньше шрифт (Cmd+-)">A−</button>
|
|
1075
|
+
<button id="chatFontPlus" title="Больше шрифт (Cmd++)">A+</button>
|
|
931
1076
|
<button id="chatClear" title="Очистить историю">⌫</button>
|
|
932
1077
|
<button id="chatClose" title="Закрыть">×</button>
|
|
933
1078
|
</div>
|
|
934
1079
|
<div id="chatSnapshotBar" class="chat-snapshot-bar" style="display:none;"></div>
|
|
935
1080
|
<div id="chatList" class="chat-list"></div>
|
|
1081
|
+
<div id="chatContextRow" class="chat-context-row"></div>
|
|
936
1082
|
<div class="chat-input-row">
|
|
937
|
-
<textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter —
|
|
1083
|
+
<textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить, можно перетащить файл)" rows="3"></textarea>
|
|
938
1084
|
<button id="chatSend" class="primary" title="Отправить (Cmd+Enter)">▶</button>
|
|
939
1085
|
</div>
|
|
940
1086
|
`;
|
|
941
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
|
+
});
|
|
942
1125
|
$('chatClose').addEventListener('click', () => panel.classList.add('hidden'));
|
|
943
1126
|
$('chatClear').addEventListener('click', () => {
|
|
944
1127
|
if (!confirm('Очистить историю чата?')) return;
|
|
@@ -968,10 +1151,29 @@
|
|
|
968
1151
|
// Отрисовываем сохранённую историю при показе (на случай если она
|
|
969
1152
|
// была подгружена в фоне через loadFromCurrentProject до первого toggle).
|
|
970
1153
|
renderHistory();
|
|
1154
|
+
renderContextRow();
|
|
971
1155
|
setTimeout(() => $('chatInput')?.focus(), 50);
|
|
972
1156
|
}
|
|
973
1157
|
}
|
|
974
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
|
+
|
|
975
1177
|
// Public API.
|
|
976
1178
|
window.kingChat = {
|
|
977
1179
|
toggle,
|
|
@@ -980,12 +1182,17 @@
|
|
|
980
1182
|
send,
|
|
981
1183
|
// User-clear: чистит И на диске тоже (юзер явно нажал ⌫).
|
|
982
1184
|
clear: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); persistNow().catch(() => {}); },
|
|
983
|
-
// Reset-on-close:
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
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
|
+
},
|
|
987
1193
|
// board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
|
|
988
1194
|
loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
|
|
1195
|
+
flushPersist,
|
|
989
1196
|
tools: TOOLS,
|
|
990
1197
|
};
|
|
991
1198
|
|
package/renderer/media.js
CHANGED
|
@@ -14,31 +14,59 @@ const _canvasFrame = $('canvasFrame');
|
|
|
14
14
|
// layout-affecting свойство, его смена на каждом wheel-tick форсила 751ms Layout.
|
|
15
15
|
// Транформ на canvas идёт через GPU (will-change: transform → composited),
|
|
16
16
|
// scroll-bounds во время burst могут быть слегка off, на idle подстраиваются.
|
|
17
|
+
// Канвас-frame теперь с padding'ом вокруг .canvas (см. styles.css
|
|
18
|
+
// .canvas-frame --canvas-pad-x/y). Координаты:
|
|
19
|
+
// - frame-coords: 0..(6000*z + 2*padX). Лево-верх frame = (0,0).
|
|
20
|
+
// - canvas-coords: 0..6000 (до scale). Канвас расположен в frame'е
|
|
21
|
+
// по offset (padX, padY) и масштабирован transform: scale(z).
|
|
22
|
+
// - mouseInFrame = scrollLeft + (mouseClient - wrapClient)
|
|
23
|
+
// - contentCoord = (mouseInFrame - padX) / z
|
|
24
|
+
function _getFramePadding() {
|
|
25
|
+
const cs = getComputedStyle(_canvasFrame);
|
|
26
|
+
return {
|
|
27
|
+
padX: parseInt(cs.getPropertyValue('--canvas-pad-x')) || 0,
|
|
28
|
+
padY: parseInt(cs.getPropertyValue('--canvas-pad-y')) || 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
17
31
|
let _frameSizeTimer = null;
|
|
18
32
|
function applyZoomStyles(z) {
|
|
19
33
|
canvas.style.transform = `scale(${z})`;
|
|
20
34
|
clearTimeout(_frameSizeTimer);
|
|
21
35
|
_frameSizeTimer = setTimeout(() => {
|
|
22
36
|
_frameSizeTimer = null;
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
const { padX, padY } = _getFramePadding();
|
|
38
|
+
// Frame-размер = scaled-canvas + padding с обеих сторон.
|
|
39
|
+
// Раньше padding не учитывался → frame ужимался при zoom-out → весь
|
|
40
|
+
// padding-skill терялся, ноды с отрицательными coord'ами становились
|
|
41
|
+
// неперехватываемыми скроллом.
|
|
42
|
+
_canvasFrame.style.width = (6000 * z + 2 * padX) + 'px';
|
|
43
|
+
_canvasFrame.style.height = (4000 * z + 2 * padY) + 'px';
|
|
25
44
|
}, 200);
|
|
26
45
|
}
|
|
27
46
|
function applyZoom(nextZoom, anchorClientX, anchorClientY) {
|
|
28
47
|
nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, nextZoom));
|
|
29
48
|
if (nextZoom === state.zoom) return;
|
|
30
49
|
const rect = canvasWrap.getBoundingClientRect();
|
|
31
|
-
|
|
50
|
+
const { padX, padY } = _getFramePadding();
|
|
51
|
+
// Якорь = mouse client coord (или центр если не задан).
|
|
32
52
|
const ax = anchorClientX ?? (rect.left + rect.width / 2);
|
|
33
53
|
const ay = anchorClientY ?? (rect.top + rect.height / 2);
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
54
|
+
// mouse внутри wrap (без учёта scroll).
|
|
55
|
+
const mouseWrapX = ax - rect.left;
|
|
56
|
+
const mouseWrapY = ay - rect.top;
|
|
57
|
+
// mouse в frame-coords (со scroll).
|
|
58
|
+
const mouseFrameX = canvasWrap.scrollLeft + mouseWrapX;
|
|
59
|
+
const mouseFrameY = canvasWrap.scrollTop + mouseWrapY;
|
|
60
|
+
// mouse в canvas content-coords (вычитаем padding и делим на zoom).
|
|
61
|
+
const contentX = (mouseFrameX - padX) / state.zoom;
|
|
62
|
+
const contentY = (mouseFrameY - padY) / state.zoom;
|
|
38
63
|
state.zoom = nextZoom;
|
|
39
64
|
applyZoomStyles(nextZoom);
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
// После нового zoom: contentCoord должен оказаться там же где был курсор.
|
|
66
|
+
// newMouseFrame = contentCoord * nextZoom + padX
|
|
67
|
+
// newScrollLeft = newMouseFrame - mouseWrapX
|
|
68
|
+
canvasWrap.scrollLeft = contentX * nextZoom + padX - mouseWrapX;
|
|
69
|
+
canvasWrap.scrollTop = contentY * nextZoom + padY - mouseWrapY;
|
|
42
70
|
$('zoomLabel').textContent = Math.round(nextZoom * 100) + '%';
|
|
43
71
|
scheduleViewSave();
|
|
44
72
|
}
|
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;
|
|
282
|
+
}
|
|
283
|
+
.chat-chip-x {
|
|
284
|
+
background: transparent; border: none; color: #889;
|
|
285
|
+
cursor: pointer; font-size: 13px; padding: 0 2px; line-height: 1;
|
|
258
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; }
|
|
@@ -336,7 +365,7 @@
|
|
|
336
365
|
.chat-input-row textarea {
|
|
337
366
|
flex: 1; resize: vertical; min-height: 50px; max-height: 200px;
|
|
338
367
|
background: #0e0e0e; color: #e0e0e0; border: 1px solid #333;
|
|
339
|
-
border-radius: 4px; padding: 8px; font-size:
|
|
368
|
+
border-radius: 4px; padding: 8px; font-size: inherit; font-family: inherit;
|
|
340
369
|
}
|
|
341
370
|
.chat-input-row textarea:focus { outline: none; border-color: #4a6a9a; }
|
|
342
371
|
.chat-input-row button {
|