kingkont 0.20.7 → 0.20.9

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.
@@ -0,0 +1,259 @@
1
+ // renderer/drawings.js — рисование стрелок на холсте.
2
+ //
3
+ // Юзерская команда: «стрелка это нода». Поэтому drag по холсту создаёт
4
+ // обычную ноду `type:'drawing'` со своим bbox + points. Дальше всё что
5
+ // можно делать с нодой работает «бесплатно»: select, drag, Backspace/×,
6
+ // Cmd-Z (через pushHistory ДО мутации), сохранение в scene.json.
7
+ //
8
+ // 3 инструмента (toolbar [data-draw-tool="…"]):
9
+ // • pencil — тонкая серая линия
10
+ // • lead — средне-чёрная
11
+ // • paint — толстая жёлтая
12
+ //
13
+ // Хранение в node:
14
+ // { id, type:'drawing', tool, x, y, width, height, points: [[relX, relY],…] }
15
+ // points в системе bbox (0,0 = node.x, node.y). При drag меняется только x/y.
16
+ //
17
+ // API (window.boardDrawings):
18
+ // setTool(name) — включить/выключить режим
19
+ // renderInto(node, el) — добавить <svg> внутрь node-элемента (зовёт board.js
20
+ // из createNodeEl)
21
+ // smoothPath / arrowHead — экспортированы для возможной кастомизации.
22
+
23
+ (function () {
24
+ const SVG_NS = 'http://www.w3.org/2000/svg';
25
+ const _draw = { tool: null, current: null };
26
+
27
+ function _state() { try { return state; } catch { return null; } }
28
+
29
+ function _canvasCoord(ev) {
30
+ const c = document.getElementById('canvas');
31
+ if (!c) return { x: ev.clientX, y: ev.clientY };
32
+ const r = c.getBoundingClientRect();
33
+ const sx = c.offsetWidth ? r.width / c.offsetWidth : 1;
34
+ const sy = c.offsetHeight ? r.height / c.offsetHeight : 1;
35
+ return { x: (ev.clientX - r.left) / (sx || 1), y: (ev.clientY - r.top) / (sy || 1) };
36
+ }
37
+
38
+ // Catmull-Rom → cubic bezier для мягких линий.
39
+ function smoothPath(pts) {
40
+ if (!pts || pts.length === 0) return '';
41
+ if (pts.length === 1) return `M ${pts[0][0]} ${pts[0][1]}`;
42
+ if (pts.length === 2) return `M ${pts[0][0]} ${pts[0][1]} L ${pts[1][0]} ${pts[1][1]}`;
43
+ let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
44
+ for (let i = 0; i < pts.length - 1; i++) {
45
+ const p0 = pts[i - 1] || pts[i];
46
+ const p1 = pts[i];
47
+ const p2 = pts[i + 1];
48
+ const p3 = pts[i + 2] || p2;
49
+ const c1x = p1[0] + (p2[0] - p0[0]) / 6;
50
+ const c1y = p1[1] + (p2[1] - p0[1]) / 6;
51
+ const c2x = p2[0] - (p3[0] - p1[0]) / 6;
52
+ const c2y = p2[1] - (p3[1] - p1[1]) / 6;
53
+ d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;
54
+ }
55
+ return d;
56
+ }
57
+
58
+ function arrowHead(pts, size) {
59
+ if (!pts || pts.length < 2) return null;
60
+ const last = pts[pts.length - 1];
61
+ let prev = null;
62
+ for (let i = pts.length - 2; i >= 0; i--) {
63
+ if (Math.hypot(last[0] - pts[i][0], last[1] - pts[i][1]) > 12) { prev = pts[i]; break; }
64
+ }
65
+ if (!prev) prev = pts[0];
66
+ const ang = Math.atan2(last[1] - prev[1], last[0] - prev[0]);
67
+ const len = size || 14, w = (size || 14) * 0.55;
68
+ const ax = last[0] - len * Math.cos(ang);
69
+ const ay = last[1] - len * Math.sin(ang);
70
+ const lx = ax + w * Math.cos(ang + Math.PI / 2);
71
+ const ly = ay + w * Math.sin(ang + Math.PI / 2);
72
+ const rx = ax - w * Math.cos(ang + Math.PI / 2);
73
+ const ry = ay - w * Math.sin(ang + Math.PI / 2);
74
+ return `${last[0].toFixed(1)},${last[1].toFixed(1)} ${lx.toFixed(1)},${ly.toFixed(1)} ${rx.toFixed(1)},${ry.toFixed(1)}`;
75
+ }
76
+
77
+ function toolStyle(tool) {
78
+ return ({
79
+ pencil: { color: '#bbb', width: 1.5, opacity: 0.8, headSize: 10 },
80
+ lead: { color: '#2b2b2b', width: 2.6, opacity: 0.92, headSize: 13 },
81
+ paint: { color: '#e8b730', width: 5, opacity: 0.95, headSize: 18 },
82
+ })[tool] || { color: '#888', width: 2, opacity: 1, headSize: 12 };
83
+ }
84
+
85
+ // Рендер SVG внутрь node-элемента. Зовётся из board.js createNodeEl
86
+ // (когда node.type === 'drawing'). Идемпотентно — старый <svg.drawing-svg>
87
+ // удаляется перед вставкой нового.
88
+ function renderInto(node, el) {
89
+ const old = el.querySelector('svg.drawing-svg');
90
+ if (old) old.remove();
91
+ const w = node.width || 200, h = node.height || 100;
92
+ const svg = document.createElementNS(SVG_NS, 'svg');
93
+ svg.setAttribute('class', 'drawing-svg');
94
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
95
+ svg.setAttribute('preserveAspectRatio', 'none');
96
+ svg.style.cssText = 'display:block;width:100%;height:100%;overflow:visible;pointer-events:none;';
97
+ const st = toolStyle(node.tool);
98
+ const color = node.color || st.color;
99
+ const line = document.createElementNS(SVG_NS, 'path');
100
+ line.setAttribute('class', 'line');
101
+ line.setAttribute('d', smoothPath(node.points || []));
102
+ line.setAttribute('fill', 'none');
103
+ line.setAttribute('stroke', color);
104
+ line.setAttribute('stroke-width', st.width);
105
+ line.setAttribute('stroke-opacity', st.opacity);
106
+ line.setAttribute('stroke-linecap', 'round');
107
+ line.setAttribute('stroke-linejoin', 'round');
108
+ svg.appendChild(line);
109
+ if (node.arrow !== false) {
110
+ const head = arrowHead(node.points || [], st.headSize);
111
+ if (head) {
112
+ const tri = document.createElementNS(SVG_NS, 'polygon');
113
+ tri.setAttribute('class', 'head');
114
+ tri.setAttribute('points', head);
115
+ tri.setAttribute('fill', color);
116
+ tri.setAttribute('opacity', st.opacity);
117
+ svg.appendChild(tri);
118
+ }
119
+ }
120
+ el.appendChild(svg);
121
+ return svg;
122
+ }
123
+
124
+ // === Temp-preview во время draw'а. SVG-overlay вне node-системы. ========
125
+ let _previewEl = null;
126
+ function _ensurePreview() {
127
+ let svg = document.getElementById('drawing-preview-svg');
128
+ if (svg) return svg;
129
+ const c = document.getElementById('canvas');
130
+ if (!c) return null;
131
+ svg = document.createElementNS(SVG_NS, 'svg');
132
+ svg.id = 'drawing-preview-svg';
133
+ svg.setAttribute('width', '6000');
134
+ svg.setAttribute('height', '4000');
135
+ svg.setAttribute('class', 'drawing-preview');
136
+ svg.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:1000;';
137
+ c.appendChild(svg);
138
+ return svg;
139
+ }
140
+ function _updatePreview(points, tool) {
141
+ const svg = _ensurePreview();
142
+ if (!svg) return;
143
+ if (_previewEl) _previewEl.remove();
144
+ const st = toolStyle(tool);
145
+ const g = document.createElementNS(SVG_NS, 'g');
146
+ const line = document.createElementNS(SVG_NS, 'path');
147
+ line.setAttribute('d', smoothPath(points));
148
+ line.setAttribute('fill', 'none');
149
+ line.setAttribute('stroke', st.color);
150
+ line.setAttribute('stroke-width', st.width);
151
+ line.setAttribute('stroke-opacity', st.opacity);
152
+ line.setAttribute('stroke-linecap', 'round');
153
+ line.setAttribute('stroke-linejoin', 'round');
154
+ g.appendChild(line);
155
+ const head = arrowHead(points, st.headSize);
156
+ if (head) {
157
+ const tri = document.createElementNS(SVG_NS, 'polygon');
158
+ tri.setAttribute('points', head);
159
+ tri.setAttribute('fill', st.color);
160
+ tri.setAttribute('opacity', st.opacity);
161
+ g.appendChild(tri);
162
+ }
163
+ svg.appendChild(g);
164
+ _previewEl = g;
165
+ }
166
+ function _clearPreview() {
167
+ if (_previewEl) { _previewEl.remove(); _previewEl = null; }
168
+ }
169
+
170
+ function setTool(tool) {
171
+ if (_draw.tool === tool) tool = null;
172
+ _draw.tool = tool;
173
+ const c = document.getElementById('canvas');
174
+ if (c) c.style.cursor = tool ? 'crosshair' : '';
175
+ document.querySelectorAll('[data-draw-tool]').forEach(b => {
176
+ b.classList.toggle('active', !!tool && b.dataset.drawTool === tool);
177
+ });
178
+ }
179
+
180
+ function _onMouseDown(e) {
181
+ if (!_draw.tool) return;
182
+ if (e.button !== 0) return;
183
+ if (e.target.closest('.node')) return;
184
+ e.preventDefault();
185
+ e.stopPropagation();
186
+ const { x, y } = _canvasCoord(e);
187
+ _draw.current = { tool: _draw.tool, points: [[x, y]] };
188
+ _updatePreview(_draw.current.points, _draw.current.tool);
189
+ document.addEventListener('mousemove', _onMouseMove);
190
+ document.addEventListener('mouseup', _onMouseUp, { once: true });
191
+ }
192
+
193
+ function _onMouseMove(e) {
194
+ if (!_draw.current) return;
195
+ const { x, y } = _canvasCoord(e);
196
+ const last = _draw.current.points[_draw.current.points.length - 1];
197
+ if (Math.hypot(x - last[0], y - last[1]) < 1.5) return;
198
+ _draw.current.points.push([x, y]);
199
+ _updatePreview(_draw.current.points, _draw.current.tool);
200
+ }
201
+
202
+ async function _onMouseUp() {
203
+ document.removeEventListener('mousemove', _onMouseMove);
204
+ _clearPreview();
205
+ if (!_draw.current) return;
206
+ const pts = _draw.current.points;
207
+ const tool = _draw.current.tool;
208
+ _draw.current = null;
209
+ if (pts.length < 3) return; // случайный клик → игнор
210
+ // BBox + relative-points + padding под линию и arrowhead.
211
+ const PAD = 22;
212
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
213
+ for (const [x, y] of pts) {
214
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
215
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
216
+ }
217
+ const x = Math.round(minX - PAD);
218
+ const y = Math.round(minY - PAD);
219
+ const width = Math.round(maxX - minX + PAD * 2);
220
+ const height = Math.round(maxY - minY + PAD * 2);
221
+ const rel = pts.map(([px, py]) => [+(px - x).toFixed(1), +(py - y).toFixed(1)]);
222
+ const node = {
223
+ id: crypto.randomUUID(),
224
+ type: 'drawing',
225
+ tool, x, y, width, height, points: rel,
226
+ };
227
+ const board = _state()?.currentBoard;
228
+ if (!board) return;
229
+ // Snapshot ДО мутации → Cmd-Z вернёт состояние без этой ноды.
230
+ if (typeof window.pushHistory === 'function') {
231
+ window.pushHistory('Рисование стрелки');
232
+ }
233
+ board.metadata.nodes.push(node);
234
+ // Рендерим ноду без полного renderCanvas (быстрее).
235
+ if (typeof window.createNodeEl === 'function') {
236
+ const el = await window.createNodeEl(node);
237
+ const canvas = document.getElementById('canvas');
238
+ if (canvas) canvas.appendChild(el);
239
+ }
240
+ if (typeof window.scheduleSave === 'function') window.scheduleSave();
241
+ }
242
+
243
+ function _onKeyDown(e) {
244
+ if (e.key === 'Escape' && _draw.tool) setTool(null);
245
+ }
246
+
247
+ window.boardDrawings = { setTool, renderInto, smoothPath, arrowHead, toolStyle };
248
+
249
+ document.addEventListener('DOMContentLoaded', () => {
250
+ const canvas = document.getElementById('canvas');
251
+ if (canvas) canvas.addEventListener('mousedown', _onMouseDown, true);
252
+ document.addEventListener('keydown', _onKeyDown);
253
+ document.querySelectorAll('[data-draw-tool]').forEach(b => {
254
+ b.addEventListener('click', () => setTool(b.dataset.drawTool));
255
+ });
256
+ // 🗑 «Очистить» из старой версии больше нет — каждая стрелка теперь
257
+ // нода, удаляется через Backspace/× или Cmd-Z прямо после draw'а.
258
+ });
259
+ })();
@@ -7,14 +7,19 @@
7
7
  // важен: см. <script> теги внизу index.html.
8
8
 
9
9
  // =================== Add-menu (dblclick / ПКМ по пустому холсту) ===================
10
+ // В view-only режиме (расшаренный/публичный шаблон) меню «создать ноду»
11
+ // бессмысленно — юзер всё равно не может ничего сохранить. Не показываем.
12
+ function _isViewOnly() { return document.body.classList.contains('view-only-mode'); }
10
13
  canvasWrap.addEventListener('dblclick', e => {
11
14
  if (!state.currentBoard) return;
15
+ if (_isViewOnly()) return;
12
16
  if (e.target.closest('.node, .conn, .resize-handle, .anchor')) return;
13
17
  if (e.target.closest('#addMenu, .modal')) return;
14
18
  showAddMenu(e.clientX, e.clientY);
15
19
  });
16
20
  canvasWrap.addEventListener('contextmenu', e => {
17
21
  if (!state.currentBoard) return;
22
+ if (_isViewOnly()) return;
18
23
  if (e.target.closest('.node, .conn, .resize-handle, .anchor, button, input, textarea, select')) return;
19
24
  if (e.target.closest('#addMenu, .modal')) return;
20
25
  e.preventDefault();
@@ -537,10 +542,17 @@ function _mimeFromFilename(name) {
537
542
  })[ext] || 'image/jpeg';
538
543
  }
539
544
 
545
+ // Кэш URL'ов картинок-референсов: ключ = filename+size+mtime → CDN url.
546
+ // Чтобы не перезаливать одну и ту же картинку при каждом text-gen.
547
+ const _imageRefUploadCache = new Map();
548
+
540
549
  async function _imageRefToDataUrl(ref) {
541
550
  // Возвращает {url, size, mime} или {error}. Раньше тихо возвращал null
542
551
  // на любую ошибку → дебаг был невозможен (юзер видел «картинка не
543
552
  // подаётся», но в логах ничего).
553
+ // Большие картинки (>400KB) грузим в CDN через /api/upload вместо data URL —
554
+ // иначе base64-payload раздувает контекст модели и Claude возвращает
555
+ // «prompt too long: 215k tokens > 200k».
544
556
  try {
545
557
  if (!ref || !ref.file) return { error: 'no file in ref' };
546
558
  if (!ref.boardHandle || typeof ref.boardHandle.getDirectoryHandle !== 'function') {
@@ -555,9 +567,34 @@ async function _imageRefToDataUrl(ref) {
555
567
  const mime = file.type && file.type !== 'application/octet-stream'
556
568
  ? file.type
557
569
  : _mimeFromFilename(ref.file);
570
+
571
+ // Большая картинка → upload to CDN, возвращаем https-url. Кэшируем по
572
+ // size+mtime — повторные text-gen с той же нодой не перезагружают.
573
+ if (file.size > 400 * 1024) {
574
+ const cacheKey = `${ref.file}|${file.size}|${file.lastModified || 0}`;
575
+ let cdnUrl = _imageRefUploadCache.get(cacheKey);
576
+ if (!cdnUrl) {
577
+ const buf = await file.arrayBuffer();
578
+ const filename = ref.file.split('/').pop() || 'ref.jpg';
579
+ const r = await fetch('/api/upload', {
580
+ method: 'POST',
581
+ headers: { 'Content-Type': mime, 'X-File-Name': encodeURIComponent(filename) },
582
+ body: buf,
583
+ });
584
+ if (!r.ok) {
585
+ return { error: 'upload failed: HTTP ' + r.status };
586
+ }
587
+ const j = await r.json();
588
+ if (!j.url) return { error: 'upload вернул без url' };
589
+ cdnUrl = j.url;
590
+ _imageRefUploadCache.set(cacheKey, cdnUrl);
591
+ }
592
+ return { url: cdnUrl, size: file.size, mime };
593
+ }
594
+
595
+ // Маленькая картинка → data URL (1 round-trip меньше).
558
596
  let url;
559
597
  if (file.type === mime) {
560
- // type уже правильный — FileReader даст правильный data URL.
561
598
  url = await new Promise((res, rej) => {
562
599
  const r = new FileReader();
563
600
  r.onload = () => res(r.result);
@@ -565,7 +602,6 @@ async function _imageRefToDataUrl(ref) {
565
602
  r.readAsDataURL(file);
566
603
  });
567
604
  } else {
568
- // type пустой/неправильный — пересобираем Blob с явным type.
569
605
  const buf = await file.arrayBuffer();
570
606
  const blob = new Blob([buf], { type: mime });
571
607
  url = await new Promise((res, rej) => {
@@ -1931,7 +1967,7 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
1931
1967
  const submitTimer = setInterval(() => {
1932
1968
  if (state.jobs.get(node.id) !== job) return;
1933
1969
  const sec = Math.round((Date.now() - submitStart) / 1000);
1934
- logJob(node.id, `submit still pending... ${sec}s (KIE может тормозить)`);
1970
+ logJob(node.id, `submit still pending... ${sec}s`);
1935
1971
  mutateNode(bKey, boardHandle, node.id, n => {
1936
1972
  n.generated = { ...(n.generated || {}), state: `submitting (${sec}s)` };
1937
1973
  }).catch(() => {});
@@ -2157,7 +2193,7 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
2157
2193
  return;
2158
2194
  }
2159
2195
  if (pd.status === 'error') {
2160
- logJob(nodeId, `KIE error: ${pd.error || 'unknown'}`);
2196
+ logJob(nodeId, `provider error: ${pd.error || 'unknown'}`);
2161
2197
  throw new Error(pd.error || 'generation failed');
2162
2198
  }
2163
2199
  if (pd.state) {
@@ -2174,7 +2210,7 @@ function updateNodeStateText(nodeId, stateKey) {
2174
2210
  const root = canvas.querySelector(`.node[data-id="${nodeId}"] .gen-pending .state-text`);
2175
2211
  if (!root) return false;
2176
2212
  const LABELS = {
2177
- 'uploading-refs': 'Загружаю референсы в KIE...',
2213
+ 'uploading-refs': 'Загружаю референсы…',
2178
2214
  'submitting': 'Отправляю задачу...',
2179
2215
  'queued': 'В очереди...',
2180
2216
  'waiting': 'В очереди...',
@@ -617,6 +617,9 @@
617
617
  ws.onclose = () => setTimeout(_connect, 5000);
618
618
  ws.onerror = () => {};
619
619
  }
620
- _connect();
620
+ // /ws живёт только в Electron-server'е (lib/wsHub.js → server.js). В web
621
+ // job'ы запускаются client-side (web-shim → polling chatium API), push-канал
622
+ // не нужен. Без guard'а — постоянные failed-WSS попытки на kingkont.ru/ws.
623
+ if (!window.__KINGKONT_WEB__) _connect();
621
624
  });
622
625
  })();
@@ -203,7 +203,7 @@ async function renderNodeBody(node, body) {
203
203
  wrap.className = 'gen-pending';
204
204
  const sp = document.createElement('div'); sp.className = 'spinner lg';
205
205
  const STATE_LABELS = {
206
- 'uploading-refs': 'Загружаю референсы в KIE...',
206
+ 'uploading-refs': 'Загружаю референсы…',
207
207
  'submitting': 'Отправляю задачу...',
208
208
  'queued': 'В очереди...',
209
209
  'waiting': 'В очереди...',
package/renderer/state.js CHANGED
@@ -159,23 +159,31 @@ window.bgJobEnd = bgJobEnd;
159
159
  window.bgJobsAll = bgJobsAll;
160
160
 
161
161
  // === SYSTEM NOTIFICATION (HTML5 Notification API) ===
162
- // Просим разрешение eagerly при старте приложения чтобы первое уведомление
163
- // уже работало (без race с первой генерацией).
162
+ // Запрос разрешения ТОЛЬКО из user-gesture'а (Chrome иначе кидает
163
+ // console.error: «Notification prompting can only be done from a user
164
+ // gesture»). Привязываемся к первому click/keydown — он считается жестом.
164
165
  let _notifPermAsked = false;
165
166
  if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
166
- // Откладываем чуть-чуть — иначе диалог появляется до того как юзер увидел UI.
167
- setTimeout(() => {
167
+ function _askNotifOnGesture() {
168
+ if (_notifPermAsked) return;
168
169
  _notifPermAsked = true;
169
- Notification.requestPermission().catch(() => {});
170
- }, 2000);
170
+ try { Notification.requestPermission().catch(() => {}); } catch {}
171
+ document.removeEventListener('click', _askNotifOnGesture);
172
+ document.removeEventListener('keydown', _askNotifOnGesture);
173
+ }
174
+ document.addEventListener('click', _askNotifOnGesture, { once: false });
175
+ document.addEventListener('keydown', _askNotifOnGesture, { once: false });
171
176
  }
172
177
  async function systemNotify(title, body, opts = {}) {
173
178
  if (typeof Notification === 'undefined') return null;
174
179
  let perm = Notification.permission;
175
- if (perm === 'default' && !_notifPermAsked) {
176
- _notifPermAsked = true;
177
- try { perm = await Notification.requestPermission(); } catch {}
178
- }
180
+ // Chrome требует чтобы requestPermission вызывался ТОЛЬКО из user-gesture'а
181
+ // (click/keydown handler). systemNotify часто зовут из фоновых событий
182
+ // (generation done, WS push) запрос упадёт с console.error «Notification
183
+ // prompting can only be done from a user gesture». Без granted перм-а
184
+ // OS-нотификации не работают, но в notifyPanel событие УЖЕ добавлено.
185
+ // Если default — silently skip; запросим permission на первом «реальном»
186
+ // user-клике (см. _wireNotifPermPrompt ниже).
179
187
  if (perm !== 'granted') {
180
188
  // Без OS-нотификации — событие УЖЕ добавлено в notifyPanel через
181
189
  // showToast-wrapper (single-source-of-truth). Раньше тут был fallback
@@ -171,7 +171,10 @@
171
171
  .sidebar-header .brand .sub.has-project {
172
172
  color: #fff; text-transform: none; letter-spacing: 0;
173
173
  font-size: 18px; font-weight: 600; line-height: 1.2;
174
- word-break: break-word;
174
+ /* Всегда одна строка — иначе после загрузки имя проекта переносится
175
+ и шапка прыгает вверх-вниз. ellipsis даёт `…` если не помещается. */
176
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
177
+ min-width: 0;
175
178
  }
176
179
  /* Имя выбранной сцены спрятано в шапке (юзер: «название выбранной сцены
177
180
  скрывай»). Сама подсветка сцены остаётся в sidebar-list. */
@@ -492,6 +495,11 @@
492
495
  .welcome-recent-grid {
493
496
  display: grid;
494
497
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
498
+ /* grid-auto-rows: max-content — иначе cards (у которых дети все
499
+ position:absolute → 0 intrinsic height) получают row-height ~18px,
500
+ и aspect-ratio:4/3 рисует контент за пределы трека. Соседние cards
501
+ визуально наезжают друг на друга — именно это юзер видел в Electron. */
502
+ grid-auto-rows: max-content;
495
503
  gap: 16px;
496
504
  overflow-y: auto; overflow-x: hidden;
497
505
  padding: 8px 52px 24px;
@@ -1054,6 +1062,82 @@
1054
1062
  .connections-layer .line { stroke: #6a8aaa; stroke-width: 2; fill: none; pointer-events: none; }
1055
1063
  .connections-layer .conn:hover .line { stroke: #f88; stroke-width: 3; }
1056
1064
  .connections-layer .temp-line { stroke: #6a8aaa; stroke-width: 2; stroke-dasharray: 6 4; fill: none; pointer-events: none; }
1065
+ /* === Drawing-нода (стрелка). ============================================
1066
+ `.drawing-node` — обычная .node, но без header/body/anchor (см.
1067
+ createNodeEl branch). Прозрачный фон, без border'а в нормальном
1068
+ состоянии — видна только SVG-линия. На select подсвечиваем bbox
1069
+ стандартным outline'ом для консистентности с другими нодами. */
1070
+ .node.drawing-node {
1071
+ background: transparent; border: none; box-shadow: none; padding: 0;
1072
+ overflow: visible; cursor: move;
1073
+ }
1074
+ .node.drawing-node .drawing-svg { display: block; width: 100%; height: 100%; }
1075
+ .node.drawing-node.selected,
1076
+ .node.drawing-node:hover {
1077
+ outline: 1px dashed rgba(106,138,170,0.6); outline-offset: 2px;
1078
+ }
1079
+ .node.drawing-node.selected {
1080
+ outline: 1.5px solid #4af; outline-offset: 2px;
1081
+ }
1082
+ /* Toolbar drawing-buttons: active — подсветка как «нажата». */
1083
+ .toolbar [data-draw-tool] { font-size: 16px; padding: 4px 8px; }
1084
+ .toolbar [data-draw-tool].active {
1085
+ background: #3a5273; color: #fff; border-color: #4a6a9a;
1086
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.15);
1087
+ }
1088
+ .toolbar-sep { display: inline-block; width: 1px; height: 20px; background: #333; margin: 0 6px; vertical-align: middle; }
1089
+
1090
+ /* === Edit-affordances visibility ========================================
1091
+ Юзер: «вид по умолчанию должен быть без элементов управления, они
1092
+ должны появляться потом». Поэтому:
1093
+ - .kk-edit-only по умолчанию СКРЫТО.
1094
+ - body.edit-mode реstoreит дефолтный display.
1095
+ edit-mode выставляется в cloudProjects.open() когда canModify=true.
1096
+ Для view-only mode edit-mode НЕ выставляется → все edit-кнопки остаются
1097
+ спрятанными, в toolbar показывается inline-баннер (.kk-view-only-banner).
1098
+ */
1099
+ .kk-edit-only { display: none !important; }
1100
+ body.edit-mode .kk-edit-only { display: revert !important; }
1101
+
1102
+ /* === View-only mode (template-share / canModify=false) =================
1103
+ Sidebar остаётся (юзер навигирует по сценам), но edit-секции уже скрыты
1104
+ через .kk-edit-only выше. В toolbar показываем inline-banner вместо
1105
+ отсутствующих edit-кнопок. */
1106
+ .kk-view-only-banner { display: none; }
1107
+ body.view-only-mode .kk-view-only-banner {
1108
+ display: flex; align-items: center; gap: 14px;
1109
+ flex: 1; min-width: 0;
1110
+ padding: 4px 14px; margin: -4px 0;
1111
+ background: linear-gradient(90deg,#3a5f8c,#4a8ad6);
1112
+ color: #fff; border-radius: 6px;
1113
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1114
+ font-size: 14px;
1115
+ }
1116
+ .kk-view-only-banner .kk-vo-label { opacity: 0.95; }
1117
+ .kk-view-only-banner #viewOnlyCloneBtn {
1118
+ padding: 6px 14px; background: #fff; color: #3a5f8c;
1119
+ border: none; border-radius: 5px; cursor: pointer;
1120
+ font-weight: 600; font-size: 13px;
1121
+ box-shadow: 0 1px 4px rgba(0,0,0,0.15);
1122
+ }
1123
+ .kk-view-only-banner #viewOnlyCloneBtn:hover { background: #f0f4fa; }
1124
+ /* В view-only ноды некликабельны — без drag/edit/resize/anchor.
1125
+ Картинки и видео по-прежнему кликабельны для fullscreen-просмотра. */
1126
+ body.view-only-mode .node {
1127
+ cursor: default !important; pointer-events: none;
1128
+ }
1129
+ body.view-only-mode .node img,
1130
+ body.view-only-mode .node video { pointer-events: auto; cursor: zoom-in; }
1131
+ body.view-only-mode .connections-layer .hit { pointer-events: none !important; }
1132
+ /* Дополнительно прячем in-node edit-аффордансы (× delete, resize-handle,
1133
+ anchor, draft-«Запустить» кнопки) — они генерятся JS'ом и не получают
1134
+ .kk-edit-only автоматом. */
1135
+ body.view-only-mode .node .node-header .delete,
1136
+ body.view-only-mode .node .resize-handle,
1137
+ body.view-only-mode .node .label-width-handle,
1138
+ body.view-only-mode .node .anchor,
1139
+ body.view-only-mode .node .gen-pending,
1140
+ body.view-only-mode .node-footer { display: none !important; }
1057
1141
 
1058
1142
  /* Меню «что добавить» */
1059
1143
  .add-menu {
package/server.js CHANGED
@@ -462,6 +462,33 @@ async function handleProjectDelete(res, id) {
462
462
  } catch (e) { sendError(res, e, 502); }
463
463
  }
464
464
 
465
+ // Прокси /api/proj/<action> → https://kingkont.ru/proj_<action>. Использует
466
+ // сохранённый Bearer-токен юзера (тот же что для всех chatium-вызовов).
467
+ // Сохраняем method, query-string и тело, передаём response как есть.
468
+ async function handleChatiumProjProxy(req, res, action, url) {
469
+ const s = getSettings();
470
+ if (!s.useChatium || !s.chatium?.token || !s.chatium?.base) {
471
+ return sendError(res, new Error('Войдите в KingKont — нет токена'), 401);
472
+ }
473
+ try {
474
+ const target = `${s.chatium.base.replace(/\/$/, '')}/app/spaces/server/api/proj_${action}${url.search || ''}`;
475
+ const init = {
476
+ method: req.method,
477
+ headers: {
478
+ 'Authorization': `Bearer ${s.chatium.token}`,
479
+ 'Content-Type': 'application/json',
480
+ },
481
+ };
482
+ if (req.method === 'POST' || req.method === 'PUT') {
483
+ init.body = JSON.stringify(await readJson(req));
484
+ }
485
+ const r = await fetch(target, init);
486
+ const text = await r.text();
487
+ res.writeHead(r.status, { 'Content-Type': r.headers.get('content-type') || 'application/json' });
488
+ res.end(text);
489
+ } catch (e) { sendError(res, e, 502); }
490
+ }
491
+
465
492
  // ---- Project texts (.md контент text-нод). --------------------------------
466
493
  // Зачем отдельный API: см. lib/providers.js → CHATIUM_PATHS.projectTexts.
467
494
 
@@ -715,6 +742,14 @@ async function _requestHandler(req, res) {
715
742
  if (req.method === 'DELETE') return handleProjectDelete(res, decodeURIComponent(m[1]));
716
743
  }
717
744
  }
745
+ // Share-management endpoints. Прозрачный прокси на chatium /proj_*
746
+ // (новые TS-файлы — обход модульного кеша исходного projects.ts).
747
+ // Действия: setPublic / share / unshare / shares / clone / sharedWithMe.
748
+ {
749
+ const m = url.pathname.match(/^\/api\/proj\/(setPublic|share|unshare|shares|clone|sharedWithMe)$/);
750
+ if (m) return handleChatiumProjProxy(req, res, m[1], url);
751
+ }
752
+
718
753
  // Project texts (.md контент). Отдельная таблица на сервере; клиент
719
754
  // ссылается на записи по id из manifest'а board.texts[relPath] = textId.
720
755
  if (req.method === 'POST' && url.pathname === '/api/project-texts') return handleProjectTextCreate(req, res);