kingkont 0.7.81 → 0.7.82

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/index.html CHANGED
@@ -227,6 +227,7 @@
227
227
  <!-- ===== Меню «что добавить» (двойной клик на пустом месте холста) ===== -->
228
228
  <div class="add-menu hidden" id="addMenu">
229
229
  <button data-act="text">✏️ Написать</button>
230
+ <button data-act="label">🅰 Подпись</button>
230
231
  <button data-act="gen-text">📝 Сгенерировать текст</button>
231
232
  <button data-act="audio">🎙 Сгенерировать голос</button>
232
233
  <button data-act="image">🖼 Сгенерировать картинку</button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.7.81",
3
+ "version": "0.7.82",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -1634,6 +1634,7 @@ async function createNodeEl(node) {
1634
1634
  const el = document.createElement('div');
1635
1635
  el.className = 'node';
1636
1636
  if (node.type === 'text') el.classList.add('text-node');
1637
+ if (node.type === 'label') el.classList.add('label-node');
1637
1638
  el.dataset.id = node.id;
1638
1639
  el.style.left = node.x + 'px';
1639
1640
  el.style.top = node.y + 'px';
@@ -1693,7 +1694,7 @@ async function createNodeEl(node) {
1693
1694
  attachDrag(el, node);
1694
1695
 
1695
1696
  el.addEventListener('dblclick', e => {
1696
- if (e.target.closest('textarea, input, video, button, .delete, .anchor, .resize-handle')) return;
1697
+ if (e.target.closest('textarea, input, video, button, .delete, .anchor, .resize-handle, [contenteditable]')) return;
1697
1698
  if (node.type === 'audio' && node.file) {
1698
1699
  regenerateNode(node);
1699
1700
  return;
@@ -1705,7 +1706,7 @@ async function createNodeEl(node) {
1705
1706
  if (node.generated) showNodeSettings(node);
1706
1707
  });
1707
1708
  el.addEventListener('contextmenu', e => {
1708
- if (e.target.closest('textarea, input, .anchor, .resize-handle')) return;
1709
+ if (e.target.closest('textarea, input, .anchor, .resize-handle, [contenteditable]')) return;
1709
1710
  e.preventDefault();
1710
1711
  e.stopPropagation();
1711
1712
  showNodeContextMenu(node, e.clientX, e.clientY);
@@ -153,6 +153,11 @@ document.querySelectorAll('#addMenu button').forEach(btn => {
153
153
  state.addMenuPos = null;
154
154
  return;
155
155
  }
156
+ if (act === 'label') {
157
+ await addLabelAt(state.addMenuPos);
158
+ state.addMenuPos = null;
159
+ return;
160
+ }
156
161
  if (act === 'gen-text') {
157
162
  // открываем text-gen modal; если есть fromNode — подставляем @ref в промпт
158
163
  if (!await ensureApiKey('text')) return;
@@ -194,6 +199,42 @@ async function addTextAt(pos) {
194
199
  scheduleSave();
195
200
  }
196
201
 
202
+ // Label-нода: лёгкая надпись поверх холста, без файла. Текст хранится
203
+ // прямо в scene.json (см. saveBoardMetadata: для type='label' n.text НЕ
204
+ // удаляется при сериализации).
205
+ async function addLabelAt(pos) {
206
+ if (!state.currentBoard) return;
207
+ const rect = canvas.getBoundingClientRect();
208
+ const x = pos ? (pos.clientX - rect.left) / state.zoom : canvasWrap.scrollLeft / state.zoom + 80;
209
+ const y = pos ? (pos.clientY - rect.top) / state.zoom : canvasWrap.scrollTop / state.zoom + 80;
210
+ const node = {
211
+ id: crypto.randomUUID(),
212
+ type: 'label',
213
+ text: '',
214
+ textStyle: { fontSize: 32, italic: false, fontFamily: 'handwritten' },
215
+ x, y,
216
+ width: 260, height: 90,
217
+ };
218
+ state.currentBoard.metadata.nodes.push(node);
219
+ const el = await createNodeEl(node);
220
+ canvas.appendChild(el);
221
+ scheduleSave();
222
+ // Авто-фокус в редактируемый текст, чтобы юзер сразу начал печатать.
223
+ setTimeout(() => {
224
+ const ed = el.querySelector('.label-text');
225
+ if (ed) {
226
+ ed.focus();
227
+ // Курсор в конец (если контент пустой — это всё равно начало).
228
+ const sel = window.getSelection();
229
+ const r = document.createRange();
230
+ r.selectNodeContents(ed);
231
+ r.collapse(false);
232
+ sel?.removeAllRanges();
233
+ sel?.addRange(r);
234
+ }
235
+ }, 30);
236
+ }
237
+
197
238
  // =================== Generate (фоновая, не блокирующая) ===================
198
239
  // === Preferences modal (API-ключи) ===
199
240
  async function openSettings(initialFocus) {
@@ -52,76 +52,69 @@ $('settingsRegen').addEventListener('click', () => {
52
52
  regenerateNode(node); // там редактируются ВСЕ поля (промпт, модель, голос)
53
53
  });
54
54
 
55
- // =================== Text-node body (с форматированием) ===================
56
- // Шрифтыsystem-only, чтобы не тащить Google Fonts CDN (приложение
57
- // должно работать офлайн). На macOS все четыре варианта выглядят отлично;
58
- // на Windows/Linux fallback'и держат смысл (cursive/fantasy/serif/mono).
59
- const TEXT_NODE_FONTS = [
60
- { id: 'default', label: 'Обычный' },
61
- { id: 'handwritten', label: 'Карандашом' },
62
- { id: 'marker', label: 'Краской' },
63
- { id: 'serif', label: засечками' },
64
- { id: 'mono', label: 'Моноширинный' },
55
+ // =================== Label-node body (свободный текст-аннотация) ===================
56
+ // Label-нодаэто просто текст «поверх» холста: без фона, без рамки,
57
+ // без header/footer. Используется для подписей, заголовков, заметок
58
+ // «карандашом/краской» в отличие от text-ноды, которая хранит длинный
59
+ // .md-документ с обычным textarea.
60
+ //
61
+ // Шрифты — system-only, без Google Fonts CDN (приложение офлайновое).
62
+ // На macOS все варианты выглядят естественно; на Win/Linux fallback'и
63
+ // (cursive/fantasy/serif/monospace) сохраняют смысл.
64
+ const LABEL_FONTS = [
65
+ { id: 'default', label: 'Обычный' },
66
+ { id: 'handwritten', label: 'Карандашом' },
67
+ { id: 'marker', label: 'Краской' },
68
+ { id: 'serif', label: 'С засечками' },
69
+ { id: 'mono', label: 'Моноширинный' },
65
70
  ];
66
- const TEXT_NODE_FONT_SIZES = [12, 14, 18, 24, 32, 48, 64];
67
- const TEXT_NODE_DEFAULT_STYLE = { fontSize: 14, italic: false, fontFamily: 'default' };
68
-
69
- function getTextNodeStyle(node) {
70
- return { ...TEXT_NODE_DEFAULT_STYLE, ...(node.textStyle || {}) };
71
- }
71
+ const LABEL_FONT_SIZES = [12, 14, 18, 24, 32, 48, 64, 96];
72
+ const LABEL_DEFAULT_STYLE = { fontSize: 32, italic: false, fontFamily: 'handwritten' };
72
73
 
73
- function applyTextNodeStyle(ta, style) {
74
- ta.style.fontSize = (style.fontSize || 14) + 'px';
75
- ta.style.fontStyle = style.italic ? 'italic' : 'normal';
76
- ta.dataset.font = style.fontFamily || 'default';
74
+ function applyLabelStyle(ed, style) {
75
+ ed.style.fontSize = (style.fontSize || 32) + 'px';
76
+ ed.style.fontStyle = style.italic ? 'italic' : 'normal';
77
+ ed.dataset.font = style.fontFamily || 'default';
77
78
  }
78
79
 
79
- function renderTextNodeBody(node, body) {
80
- // Гарантируем что у ноды есть textStyle (без spread реальный объект).
81
- if (!node.textStyle) node.textStyle = { ...TEXT_NODE_DEFAULT_STYLE };
80
+ function renderLabelNodeBody(node, body) {
81
+ if (!node.textStyle) node.textStyle = { ...LABEL_DEFAULT_STYLE };
82
82
 
83
- // Тулбар форматирования: шрифт, размер, italic.
83
+ // Тулбар форматирования (на hover, чтобы не отвлекать от чистого текста).
84
84
  const tb = document.createElement('div');
85
- tb.className = 'text-toolbar';
86
- // Не блокируем drag-mousedown по тулбару — но клики по контролам
87
- // не должны таскать ноду. stopPropagation на mousedown решает это.
85
+ tb.className = 'label-toolbar';
88
86
  tb.addEventListener('mousedown', e => e.stopPropagation());
89
87
 
90
- // Селект шрифта.
91
88
  const fontSel = document.createElement('select');
92
89
  fontSel.title = 'Шрифт';
93
- for (const f of TEXT_NODE_FONTS) {
90
+ for (const f of LABEL_FONTS) {
94
91
  const o = document.createElement('option');
95
- o.value = f.id;
96
- o.textContent = f.label;
92
+ o.value = f.id; o.textContent = f.label;
97
93
  fontSel.appendChild(o);
98
94
  }
99
95
  fontSel.value = node.textStyle.fontFamily || 'default';
100
96
  fontSel.addEventListener('change', () => {
101
97
  node.textStyle.fontFamily = fontSel.value;
102
- applyTextNodeStyle(ta, node.textStyle);
98
+ applyLabelStyle(ed, node.textStyle);
103
99
  scheduleSave();
104
100
  });
105
101
  tb.appendChild(fontSel);
106
102
 
107
- // Селект размера.
108
103
  const sizeSel = document.createElement('select');
109
104
  sizeSel.title = 'Размер шрифта';
110
- for (const s of TEXT_NODE_FONT_SIZES) {
105
+ for (const s of LABEL_FONT_SIZES) {
111
106
  const o = document.createElement('option');
112
- o.value = String(s);
113
- o.textContent = s + 'px';
107
+ o.value = String(s); o.textContent = s + 'px';
114
108
  sizeSel.appendChild(o);
115
109
  }
116
- sizeSel.value = String(node.textStyle.fontSize || 14);
110
+ sizeSel.value = String(node.textStyle.fontSize || 32);
117
111
  sizeSel.addEventListener('change', () => {
118
- node.textStyle.fontSize = parseInt(sizeSel.value, 10) || 14;
119
- applyTextNodeStyle(ta, node.textStyle);
112
+ node.textStyle.fontSize = parseInt(sizeSel.value, 10) || 32;
113
+ applyLabelStyle(ed, node.textStyle);
120
114
  scheduleSave();
121
115
  });
122
116
  tb.appendChild(sizeSel);
123
117
 
124
- // Italic toggle.
125
118
  const italicBtn = document.createElement('button');
126
119
  italicBtn.className = 'tt-italic';
127
120
  italicBtn.textContent = 'I';
@@ -131,20 +124,30 @@ function renderTextNodeBody(node, body) {
131
124
  e.stopPropagation();
132
125
  node.textStyle.italic = !node.textStyle.italic;
133
126
  italicBtn.classList.toggle('active', node.textStyle.italic);
134
- applyTextNodeStyle(ta, node.textStyle);
127
+ applyLabelStyle(ed, node.textStyle);
135
128
  scheduleSave();
136
129
  });
137
130
  tb.appendChild(italicBtn);
138
131
 
139
132
  body.appendChild(tb);
140
133
 
141
- const ta = document.createElement('textarea');
142
- ta.value = node.text || '';
143
- ta.placeholder = 'Текст ноды...';
144
- applyTextNodeStyle(ta, node.textStyle);
145
- ta.addEventListener('input', () => { node.text = ta.value; scheduleSave(); });
146
- ta.addEventListener('mousedown', e => e.stopPropagation());
147
- body.appendChild(ta);
134
+ // Сам текст — contenteditable (без рамок, без скроллбаров, ровно как
135
+ // плавающая надпись на холсте). plaintext-only — режем форматирование
136
+ // из буфера обмена (текст копируется только как текст).
137
+ const ed = document.createElement('div');
138
+ ed.className = 'label-text';
139
+ ed.contentEditable = 'plaintext-only';
140
+ ed.spellcheck = false;
141
+ ed.dataset.placeholder = 'Текст…';
142
+ ed.textContent = node.text || '';
143
+ applyLabelStyle(ed, node.textStyle);
144
+ ed.addEventListener('input', () => { node.text = ed.textContent; scheduleSave(); });
145
+ ed.addEventListener('mousedown', e => e.stopPropagation());
146
+ // Enter — перенос строки. Esc — снять фокус.
147
+ ed.addEventListener('keydown', e => {
148
+ if (e.key === 'Escape') { e.preventDefault(); ed.blur(); }
149
+ });
150
+ body.appendChild(ed);
148
151
  }
149
152
 
150
153
  async function renderNodeBody(node, body) {
@@ -281,7 +284,14 @@ async function renderNodeBody(node, body) {
281
284
  }
282
285
 
283
286
  if (node.type === 'text') {
284
- renderTextNodeBody(node, body);
287
+ const ta = document.createElement('textarea');
288
+ ta.value = node.text || '';
289
+ ta.placeholder = 'Текст ноды...';
290
+ ta.addEventListener('input', () => { node.text = ta.value; scheduleSave(); });
291
+ ta.addEventListener('mousedown', e => e.stopPropagation());
292
+ body.appendChild(ta);
293
+ } else if (node.type === 'label') {
294
+ renderLabelNodeBody(node, body);
285
295
  } else if (['video','audio','image'].includes(node.type) && node.file) {
286
296
  const nodeEl = body.closest('.node');
287
297
  nodeEl?.classList.toggle('image-node', node.type === 'image');
package/renderer/state.js CHANGED
@@ -354,7 +354,9 @@ async function saveBoardMetadata(boardHandle, meta) {
354
354
  }
355
355
  const nodesForJson = meta.nodes.map(n => {
356
356
  const out = { ...n };
357
- delete out.text;
357
+ // Для text-нод текст в отдельном .md, в JSON его не пишем.
358
+ // Для label-нод — текст inline в JSON (короткие подписи, без файла).
359
+ if (n.type === 'text') delete out.text;
358
360
  return out;
359
361
  });
360
362
  const payload = {
@@ -323,56 +323,96 @@
323
323
  box-shadow: 0 0 0 2px #5aa8ff, 0 8px 24px rgba(90,168,255,0.4);
324
324
  }
325
325
  .node-body { flex: 1; min-height: 0; }
326
- .node.text-node .node-body { padding: 0; display: flex; flex-direction: column; }
326
+ .node.text-node .node-body { padding: 0; display: flex; }
327
327
  .node.text-node .node-body textarea {
328
- width: 100%; resize: none; min-height: 80px;
328
+ width: 100%; height: 100%; resize: none; min-height: 80px;
329
329
  border: none; border-radius: 0; padding: 12px; flex: 1;
330
- background: transparent; color: #eaeaea;
331
- font-size: 14px; font-style: normal;
330
+ }
331
+
332
+ /* === Label-node — плавающая текст-аннотация поверх холста ===
333
+ Без фона / рамки / тени — выглядит как «написанный» прямо на сцене текст.
334
+ Header / footer / resize / anchor скрыты по умолчанию, появляются на
335
+ hover чтобы можно было перетаскивать, удалять, ресайзить. */
336
+ .node.label-node {
337
+ background: transparent;
338
+ border: none; box-shadow: none;
339
+ padding: 0;
340
+ }
341
+ .node.label-node.selected {
342
+ outline: 1px dashed rgba(90,168,255,0.8);
343
+ outline-offset: 2px;
344
+ }
345
+ .node.label-node .node-header,
346
+ .node.label-node .node-footer,
347
+ .node.label-node .resize-handle,
348
+ .node.label-node .anchor,
349
+ .node.label-node .label-toolbar {
350
+ opacity: 0; transition: opacity 0.15s;
351
+ }
352
+ .node.label-node:hover .node-header,
353
+ .node.label-node:hover .node-footer,
354
+ .node.label-node:hover .resize-handle,
355
+ .node.label-node:hover .anchor,
356
+ .node.label-node:hover .label-toolbar,
357
+ .node.label-node:focus-within .label-toolbar { opacity: 1; }
358
+ .node.label-node .node-header {
359
+ background: rgba(20,20,20,0.85);
360
+ border-radius: 6px 6px 0 0;
361
+ }
362
+ .node.label-node .node-body {
363
+ padding: 0; background: transparent;
364
+ display: flex; flex-direction: column; min-height: 0;
365
+ }
366
+
367
+ /* Тулбар форматирования label — поверх текста, на hover. */
368
+ .node.label-node .label-toolbar {
369
+ display: flex; align-items: center; gap: 4px;
370
+ padding: 3px 6px; background: rgba(20,20,20,0.92);
371
+ border-radius: 4px; margin: 4px;
372
+ font-size: 11px; flex-shrink: 0;
373
+ align-self: flex-start;
374
+ pointer-events: auto;
375
+ }
376
+ .node.label-node .label-toolbar select,
377
+ .node.label-node .label-toolbar button {
378
+ background: #1e1e1e; border: 1px solid #383838; color: #ccc;
379
+ border-radius: 3px; padding: 2px 6px; font-size: 11px;
380
+ cursor: pointer; line-height: 1.2;
381
+ }
382
+ .node.label-node .label-toolbar select:hover,
383
+ .node.label-node .label-toolbar button:hover { background: #2c2c2c; color: #fff; }
384
+ .node.label-node .label-toolbar button.active {
385
+ background: #3a5a8a; color: #fff; border-color: #4a6a9a;
386
+ }
387
+ .node.label-node .label-toolbar .tt-italic { font-style: italic; min-width: 22px; }
388
+
389
+ /* Сам текст. flex:1 — занимает оставшееся тело, поэтому drag-area
390
+ совпадает с визуальной областью. */
391
+ .node.label-node .label-text {
392
+ flex: 1; padding: 8px 12px;
393
+ color: #eaeaea; line-height: 1.2;
394
+ word-break: break-word; white-space: pre-wrap;
395
+ outline: none; cursor: text;
332
396
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
333
- line-height: 1.4;
397
+ text-shadow: 0 1px 2px rgba(0,0,0,0.6);
398
+ }
399
+ .node.label-node .label-text:empty::before {
400
+ content: attr(data-placeholder);
401
+ color: rgba(200,200,200,0.35);
334
402
  }
335
- /* Декоративные шрифты для текстовых нод. */
336
- .node.text-node .node-body textarea[data-font="handwritten"] {
337
- /* «Карандашом» — рукописные шрифты, мягкие, наклонные. */
403
+ .node.label-node .label-text[data-font="handwritten"] {
338
404
  font-family: 'Caveat', 'Marker Felt', 'Bradley Hand', 'Snell Roundhand', cursive;
339
- line-height: 1.2;
340
405
  }
341
- .node.text-node .node-body textarea[data-font="marker"] {
342
- /* «Краской/маркером» — крупные мазки, plakat-стиль. */
406
+ .node.label-node .label-text[data-font="marker"] {
343
407
  font-family: 'Permanent Marker', 'Chalkduster', 'Marker Felt', 'Comic Sans MS', fantasy;
344
408
  letter-spacing: 0.02em;
345
409
  }
346
- .node.text-node .node-body textarea[data-font="serif"] {
410
+ .node.label-node .label-text[data-font="serif"] {
347
411
  font-family: Georgia, 'Times New Roman', serif;
348
412
  }
349
- .node.text-node .node-body textarea[data-font="mono"] {
413
+ .node.label-node .label-text[data-font="mono"] {
350
414
  font-family: ui-monospace, 'SF Mono', Menlo, 'Courier New', monospace;
351
415
  }
352
-
353
- /* Тулбар форматирования — компактный, появляется на hover, чтобы не
354
- отвлекать когда юзер просто читает. */
355
- .node.text-node .text-toolbar {
356
- display: flex; align-items: center; gap: 4px;
357
- padding: 4px 6px; background: #181818; border-bottom: 1px solid #2a2a2a;
358
- font-size: 11px; opacity: 0.35; transition: opacity 0.15s;
359
- flex-shrink: 0;
360
- }
361
- .node.text-node:hover .text-toolbar,
362
- .node.text-node .text-toolbar:focus-within { opacity: 1; }
363
- .node.text-node .text-toolbar select,
364
- .node.text-node .text-toolbar button {
365
- background: #1e1e1e; border: 1px solid #383838; color: #ccc;
366
- border-radius: 3px; padding: 2px 6px; font-size: 11px;
367
- cursor: pointer; line-height: 1.2;
368
- }
369
- .node.text-node .text-toolbar select:hover,
370
- .node.text-node .text-toolbar button:hover { background: #2c2c2c; color: #fff; }
371
- .node.text-node .text-toolbar button.active {
372
- background: #3a5a8a; color: #fff; border-color: #4a6a9a;
373
- }
374
- .node.text-node .text-toolbar .tt-spacer { flex: 1; }
375
- .node.text-node .text-toolbar .tt-italic { font-style: italic; min-width: 22px; }
376
416
  .node.video-node .node-body { padding: 0; overflow: hidden; border-radius: 0 0 8px 8px; }
377
417
  .node.video-node .node-body video { width: 100%; height: auto; max-height: none; background: transparent; border-radius: 0; display: block; }
378
418
  .audio-speed { display: flex; gap: 2px; margin-top: 6px; }