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 +1 -0
- package/package.json +1 -1
- package/renderer/board.js +3 -2
- package/renderer/generate.js +41 -0
- package/renderer/settings.js +100 -0
- package/renderer/state.js +3 -1
- package/renderer/styles.css +85 -0
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
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);
|
package/renderer/generate.js
CHANGED
|
@@ -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) {
|
package/renderer/settings.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
package/renderer/styles.css
CHANGED
|
@@ -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; }
|