kingkont 0.7.80 → 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.80",
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,6 +52,104 @@ $('settingsRegen').addEventListener('click', () => {
52
52
  regenerateNode(node); // там редактируются ВСЕ поля (промпт, модель, голос)
53
53
  });
54
54
 
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: 'Моноширинный' },
70
+ ];
71
+ const LABEL_FONT_SIZES = [12, 14, 18, 24, 32, 48, 64, 96];
72
+ const LABEL_DEFAULT_STYLE = { fontSize: 32, italic: false, fontFamily: 'handwritten' };
73
+
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';
78
+ }
79
+
80
+ function renderLabelNodeBody(node, body) {
81
+ if (!node.textStyle) node.textStyle = { ...LABEL_DEFAULT_STYLE };
82
+
83
+ // Тулбар форматирования (на hover, чтобы не отвлекать от чистого текста).
84
+ const tb = document.createElement('div');
85
+ tb.className = 'label-toolbar';
86
+ tb.addEventListener('mousedown', e => e.stopPropagation());
87
+
88
+ const fontSel = document.createElement('select');
89
+ fontSel.title = 'Шрифт';
90
+ for (const f of LABEL_FONTS) {
91
+ const o = document.createElement('option');
92
+ o.value = f.id; o.textContent = f.label;
93
+ fontSel.appendChild(o);
94
+ }
95
+ fontSel.value = node.textStyle.fontFamily || 'default';
96
+ fontSel.addEventListener('change', () => {
97
+ node.textStyle.fontFamily = fontSel.value;
98
+ applyLabelStyle(ed, node.textStyle);
99
+ scheduleSave();
100
+ });
101
+ tb.appendChild(fontSel);
102
+
103
+ const sizeSel = document.createElement('select');
104
+ sizeSel.title = 'Размер шрифта';
105
+ for (const s of LABEL_FONT_SIZES) {
106
+ const o = document.createElement('option');
107
+ o.value = String(s); o.textContent = s + 'px';
108
+ sizeSel.appendChild(o);
109
+ }
110
+ sizeSel.value = String(node.textStyle.fontSize || 32);
111
+ sizeSel.addEventListener('change', () => {
112
+ node.textStyle.fontSize = parseInt(sizeSel.value, 10) || 32;
113
+ applyLabelStyle(ed, node.textStyle);
114
+ scheduleSave();
115
+ });
116
+ tb.appendChild(sizeSel);
117
+
118
+ const italicBtn = document.createElement('button');
119
+ italicBtn.className = 'tt-italic';
120
+ italicBtn.textContent = 'I';
121
+ italicBtn.title = 'Курсив';
122
+ if (node.textStyle.italic) italicBtn.classList.add('active');
123
+ italicBtn.addEventListener('click', e => {
124
+ e.stopPropagation();
125
+ node.textStyle.italic = !node.textStyle.italic;
126
+ italicBtn.classList.toggle('active', node.textStyle.italic);
127
+ applyLabelStyle(ed, node.textStyle);
128
+ scheduleSave();
129
+ });
130
+ tb.appendChild(italicBtn);
131
+
132
+ body.appendChild(tb);
133
+
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);
151
+ }
152
+
55
153
  async function renderNodeBody(node, body) {
56
154
  body.innerHTML = '';
57
155
  if (node.status === 'generating') {
@@ -192,6 +290,8 @@ async function renderNodeBody(node, body) {
192
290
  ta.addEventListener('input', () => { node.text = ta.value; scheduleSave(); });
193
291
  ta.addEventListener('mousedown', e => e.stopPropagation());
194
292
  body.appendChild(ta);
293
+ } else if (node.type === 'label') {
294
+ renderLabelNodeBody(node, body);
195
295
  } else if (['video','audio','image'].includes(node.type) && node.file) {
196
296
  const nodeEl = body.closest('.node');
197
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 = {
@@ -328,6 +328,91 @@
328
328
  width: 100%; height: 100%; resize: none; min-height: 80px;
329
329
  border: none; border-radius: 0; padding: 12px; flex: 1;
330
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;
396
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
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);
402
+ }
403
+ .node.label-node .label-text[data-font="handwritten"] {
404
+ font-family: 'Caveat', 'Marker Felt', 'Bradley Hand', 'Snell Roundhand', cursive;
405
+ }
406
+ .node.label-node .label-text[data-font="marker"] {
407
+ font-family: 'Permanent Marker', 'Chalkduster', 'Marker Felt', 'Comic Sans MS', fantasy;
408
+ letter-spacing: 0.02em;
409
+ }
410
+ .node.label-node .label-text[data-font="serif"] {
411
+ font-family: Georgia, 'Times New Roman', serif;
412
+ }
413
+ .node.label-node .label-text[data-font="mono"] {
414
+ font-family: ui-monospace, 'SF Mono', Menlo, 'Courier New', monospace;
415
+ }
331
416
  .node.video-node .node-body { padding: 0; overflow: hidden; border-radius: 0 0 8px 8px; }
332
417
  .node.video-node .node-body video { width: 100%; height: auto; max-height: none; background: transparent; border-radius: 0; display: block; }
333
418
  .audio-speed { display: flex; gap: 2px; margin-top: 6px; }