kingkont 0.7.99 → 0.8.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 +9 -5
- package/renderer/media.js +23 -0
- package/renderer/settings.js +66 -2
- package/renderer/state.js +15 -1
- package/renderer/styles.css +25 -4
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -1885,15 +1885,19 @@ function showNodeContextMenu(node, clientX, clientY) {
|
|
|
1885
1885
|
add('📥 Вставить (Cmd+V)', () => pasteClipboardNodes(), { disabled: !canPaste });
|
|
1886
1886
|
const canReplace = !!(state.clipboard?.length);
|
|
1887
1887
|
add('⇄ Заменить из буфера', () => replaceNodeFromClipboard(node), { disabled: !canReplace });
|
|
1888
|
-
|
|
1889
|
-
|
|
1888
|
+
// «Сохранить как…» — для любого медиа-файла (image / audio / video).
|
|
1889
|
+
// Показывает нативный системный диалог через FS-Access-API; если API
|
|
1890
|
+
// недоступен (web-режим) — auto-сохранение в Downloads/.
|
|
1891
|
+
if ((node.type === 'image' || node.type === 'audio' || node.type === 'video') && node.file) {
|
|
1892
|
+
add('💾 Сохранить как…', async () => {
|
|
1890
1893
|
try {
|
|
1891
1894
|
const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
|
|
1892
1895
|
const file = await fh.getFile();
|
|
1893
|
-
|
|
1896
|
+
const suggested = node.file.split('/').pop();
|
|
1897
|
+
await saveAsToDisk(file, suggested);
|
|
1894
1898
|
} catch (err) {
|
|
1895
|
-
console.error('
|
|
1896
|
-
alert('Не удалось
|
|
1899
|
+
console.error('Save failed:', err);
|
|
1900
|
+
alert('Не удалось сохранить: ' + (err?.message || err));
|
|
1897
1901
|
}
|
|
1898
1902
|
});
|
|
1899
1903
|
}
|
package/renderer/media.js
CHANGED
|
@@ -152,6 +152,29 @@ async function downloadAudioAtSpeed(file, filename, speed, onProgress) {
|
|
|
152
152
|
await ff.deleteFile(outName).catch(() => {});
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// «Сохранить как…» — показывает нативный системный диалог выбора пути
|
|
156
|
+
// (через FS-Access-API), куда сохранить файл. Если API недоступен или
|
|
157
|
+
// юзер ничего не выбрал — fallback на triggerDownload (auto-сохранение
|
|
158
|
+
// в Downloads/). Возвращает true если файл сохранён, false если отменено.
|
|
159
|
+
async function saveAsToDisk(file, suggestedName) {
|
|
160
|
+
if (typeof window.showSaveFilePicker === 'function') {
|
|
161
|
+
try {
|
|
162
|
+
const handle = await window.showSaveFilePicker({ suggestedName });
|
|
163
|
+
const w = await handle.createWritable();
|
|
164
|
+
await w.write(file);
|
|
165
|
+
await w.close();
|
|
166
|
+
return true;
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// AbortError — юзер закрыл диалог. Не fallback'аемся, чтобы не
|
|
169
|
+
// дёргать auto-download против его воли.
|
|
170
|
+
if (err?.name === 'AbortError') return false;
|
|
171
|
+
// Любая другая ошибка — fall through на triggerDownload.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
triggerDownload(file, suggestedName);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
155
178
|
function triggerDownload(blob, filename) {
|
|
156
179
|
const url = URL.createObjectURL(blob);
|
|
157
180
|
const a = document.createElement('a');
|
package/renderer/settings.js
CHANGED
|
@@ -1285,7 +1285,7 @@ async function copySelectedNodes() {
|
|
|
1285
1285
|
const n = state.currentBoard.metadata.nodes.find(x => x.id === id);
|
|
1286
1286
|
if (!n) continue;
|
|
1287
1287
|
let blob = null, textContent = null;
|
|
1288
|
-
if (n.type === 'text') {
|
|
1288
|
+
if (n.type === 'text' || n.type === 'label') {
|
|
1289
1289
|
textContent = n.text || '';
|
|
1290
1290
|
} else if (n.file) {
|
|
1291
1291
|
try {
|
|
@@ -1296,13 +1296,16 @@ async function copySelectedNodes() {
|
|
|
1296
1296
|
state.clipboard.push({ node: { ...n }, blob, textContent });
|
|
1297
1297
|
}
|
|
1298
1298
|
console.log(`Скопировано в буфер: ${state.clipboard.length} нод`);
|
|
1299
|
+
// Дублируем в системный буфер обмена — чтобы Cmd+V в других приложениях
|
|
1300
|
+
// (браузер, Telegram, image-editor) тоже что-то получали.
|
|
1301
|
+
await writeNodesToSystemClipboard(state.clipboard);
|
|
1299
1302
|
}
|
|
1300
1303
|
|
|
1301
1304
|
// Скопировать одну ноду в буфер (для ПКМ → "Скопировать")
|
|
1302
1305
|
async function copyNodeToClipboard(node) {
|
|
1303
1306
|
if (!state.currentBoard) return;
|
|
1304
1307
|
let blob = null, textContent = null;
|
|
1305
|
-
if (node.type === 'text') {
|
|
1308
|
+
if (node.type === 'text' || node.type === 'label') {
|
|
1306
1309
|
textContent = node.text || '';
|
|
1307
1310
|
} else if (node.file) {
|
|
1308
1311
|
try {
|
|
@@ -1314,6 +1317,67 @@ async function copyNodeToClipboard(node) {
|
|
|
1314
1317
|
state.clipboardPasteCount = 0;
|
|
1315
1318
|
state.clipboardSourceBoardKey = state.currentBoard.key;
|
|
1316
1319
|
console.log(`В буфер: ${node.name || node.id}`);
|
|
1320
|
+
await writeNodesToSystemClipboard(state.clipboard);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Кладёт ноды в системный буфер обмена (где это имеет смысл):
|
|
1324
|
+
// • image — image/png (конвертим из jpg/webp через canvas)
|
|
1325
|
+
// • text/label — text/plain (если несколько — соединяем через \n\n)
|
|
1326
|
+
// • video/audio — пропускаем (Clipboard API binary-MIME'ов не держит)
|
|
1327
|
+
// Ошибки глотаем — основной путь это внутренний state.clipboard, system
|
|
1328
|
+
// clipboard это бонус. Если permission denied / focus lost / unsupported
|
|
1329
|
+
// MIME — внутренний работает как раньше.
|
|
1330
|
+
async function writeNodesToSystemClipboard(items) {
|
|
1331
|
+
if (!items?.length || !navigator.clipboard) return;
|
|
1332
|
+
try {
|
|
1333
|
+
// Один image-ноду — пишем как картинку.
|
|
1334
|
+
if (items.length === 1 && items[0].blob && items[0].node?.type === 'image') {
|
|
1335
|
+
const blob = items[0].blob;
|
|
1336
|
+
const pngBlob = blob.type === 'image/png' ? blob : await blobToPng(blob);
|
|
1337
|
+
if (pngBlob && typeof ClipboardItem === 'function') {
|
|
1338
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
// Текстовые ноды (text + label) — собираем тексты, пишем plain.
|
|
1343
|
+
const texts = items
|
|
1344
|
+
.filter(it => (it.node?.type === 'text' || it.node?.type === 'label') && typeof it.textContent === 'string')
|
|
1345
|
+
.map(it => it.textContent);
|
|
1346
|
+
if (texts.length) {
|
|
1347
|
+
await navigator.clipboard.writeText(texts.join('\n\n'));
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
// video/audio в одиночку — кладём имя файла как fallback (юзер хотя
|
|
1351
|
+
// бы pasted'ит куда-то осмысленный текст).
|
|
1352
|
+
const single = items[0]?.node;
|
|
1353
|
+
if (single?.file) {
|
|
1354
|
+
await navigator.clipboard.writeText(single.name || single.file.split('/').pop() || '');
|
|
1355
|
+
}
|
|
1356
|
+
} catch (e) {
|
|
1357
|
+
// Permission / async-context потерян — internal clipboard всё равно
|
|
1358
|
+
// отработал, юзер заметит при Cmd+V внутри редактора.
|
|
1359
|
+
console.warn('system clipboard write failed:', e?.message || e);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Конвертирует image-blob (jpg/webp/heic/etc) в PNG через canvas —
|
|
1364
|
+
// system clipboard принимает только image/png универсально.
|
|
1365
|
+
async function blobToPng(blob) {
|
|
1366
|
+
const url = URL.createObjectURL(blob);
|
|
1367
|
+
try {
|
|
1368
|
+
const img = await new Promise((res, rej) => {
|
|
1369
|
+
const i = new Image();
|
|
1370
|
+
i.onload = () => res(i);
|
|
1371
|
+
i.onerror = rej;
|
|
1372
|
+
i.src = url;
|
|
1373
|
+
});
|
|
1374
|
+
const canvas = document.createElement('canvas');
|
|
1375
|
+
canvas.width = img.naturalWidth;
|
|
1376
|
+
canvas.height = img.naturalHeight;
|
|
1377
|
+
canvas.getContext('2d').drawImage(img, 0, 0);
|
|
1378
|
+
return await new Promise(res => canvas.toBlob(res, 'image/png'));
|
|
1379
|
+
} catch { return null; }
|
|
1380
|
+
finally { URL.revokeObjectURL(url); }
|
|
1317
1381
|
}
|
|
1318
1382
|
|
|
1319
1383
|
// Заменить контент текущей ноды содержимым из буфера (текущее уходит в history)
|
package/renderer/state.js
CHANGED
|
@@ -439,6 +439,20 @@ function getFileType(file) {
|
|
|
439
439
|
} catch {}
|
|
440
440
|
})();
|
|
441
441
|
|
|
442
|
+
// One-time migration: дефолт video-разрешения раньше был 720p — сбрасываем
|
|
443
|
+
// тех, у кого залипло '720p' (вероятно, они никогда явно не выбирали),
|
|
444
|
+
// на минимальное '480p'. После явного выбора 720p/1080p — он сохранится
|
|
445
|
+
// как и раньше, миграция повторно не отрабатывает.
|
|
446
|
+
(function migrateVideoResolutionDefault() {
|
|
447
|
+
try {
|
|
448
|
+
if (localStorage.getItem('videoResolutionResetMin') === '1') return;
|
|
449
|
+
if (localStorage.getItem('videoResolution') === '720p') {
|
|
450
|
+
localStorage.setItem('videoResolution', '480p');
|
|
451
|
+
}
|
|
452
|
+
localStorage.setItem('videoResolutionResetMin', '1');
|
|
453
|
+
} catch {}
|
|
454
|
+
})();
|
|
455
|
+
|
|
442
456
|
const state = {
|
|
443
457
|
filmHandle: null,
|
|
444
458
|
currentBoard: null, // { kind, name, key, handle, metadata, urls }
|
|
@@ -449,7 +463,7 @@ const state = {
|
|
|
449
463
|
videoModel: localStorage.getItem('videoModel') || 'seedance-2', // 'seedance-2' | 'kling-o1' | 'kling-3.0' | ...
|
|
450
464
|
ttsModel: localStorage.getItem('ttsModel') || 'qwen/qwen3-tts', // qwen/elevenlabs/v3/minimax/speech-02-hd/gemini
|
|
451
465
|
videoDuration: +(localStorage.getItem('videoDuration') || 5),
|
|
452
|
-
videoResolution: localStorage.getItem('videoResolution') || '
|
|
466
|
+
videoResolution: localStorage.getItem('videoResolution') || '480p',
|
|
453
467
|
videoAspect: localStorage.getItem('videoAspect') || '9:16',
|
|
454
468
|
jobs: new Map(), // nodeId -> { boardKey, boardHandle, kind, taskId }
|
|
455
469
|
undoStack: [], // {type, ...payload}
|
package/renderer/styles.css
CHANGED
|
@@ -639,8 +639,14 @@
|
|
|
639
639
|
transparent 0%, transparent 55%,
|
|
640
640
|
#777 55%, #777 65%, transparent 65%,
|
|
641
641
|
transparent 75%, #777 75%, #777 85%, transparent 85%);
|
|
642
|
-
|
|
642
|
+
/* Прячем по умолчанию — handle видно только на hover ноды. Раньше
|
|
643
|
+
был opacity:0.45, и на image-нодах после v0.7.97 (square corners)
|
|
644
|
+
полоски стали хорошо видны против картинки и выглядели как
|
|
645
|
+
посторонний UI-элемент. */
|
|
646
|
+
opacity: 0; transition: opacity 0.15s;
|
|
647
|
+
border-bottom-right-radius: 8px;
|
|
643
648
|
}
|
|
649
|
+
.node:hover .resize-handle { opacity: 0.55; }
|
|
644
650
|
.resize-handle:hover { opacity: 1; }
|
|
645
651
|
|
|
646
652
|
.node-footer {
|
|
@@ -1282,18 +1288,33 @@
|
|
|
1282
1288
|
.seg-control .seg:first-child { border-radius: 4px 0 0 4px; }
|
|
1283
1289
|
.seg-control .seg:last-child { border-radius: 0 4px 4px 0; border-left: none; }
|
|
1284
1290
|
.seg-control .seg.active { background: #3a5a8a; border-color: #4a6a9a; color: #fff; }
|
|
1291
|
+
/* Hover ТОЛЬКО на наведённую кнопку — синеватый оттенок, чтобы видно
|
|
1292
|
+
было «вот это сейчас выберется», а не серая «обычный hover» (которая
|
|
1293
|
+
раньше шла от глобального button:hover и выглядела одинаково с
|
|
1294
|
+
disabled-состоянием). */
|
|
1295
|
+
.seg-control .seg:hover:not(.active):not(:disabled) {
|
|
1296
|
+
background: #2a3854; border-color: #3a5a8a; color: #e8e8e8;
|
|
1297
|
+
}
|
|
1298
|
+
.seg-control .seg.active:hover:not(:disabled) {
|
|
1299
|
+
background: #4a6aa0;
|
|
1300
|
+
}
|
|
1285
1301
|
.status { font-size: 12px; color: #aaa; display: flex; align-items: center; gap: 8px; }
|
|
1286
1302
|
.status.error { color: #e07a6a; }
|
|
1287
1303
|
.spinner { width: 14px; height: 14px; border: 2px solid #444; border-top-color: #6a8aaa; border-radius: 50%; animation: spin 0.9s linear infinite; }
|
|
1288
1304
|
.spinner.lg { width: 32px; height: 32px; border-width: 3px; }
|
|
1289
1305
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
1290
1306
|
|
|
1291
|
-
/* mention popup
|
|
1307
|
+
/* mention popup — overlay, чтобы появление @-списка не двигало modal-actions
|
|
1308
|
+
вниз (модалка не менялась бы по высоте). Позиционируется absolute
|
|
1309
|
+
относительно <label>-родителя (см. правило ниже). */
|
|
1310
|
+
.modal-card label:has(> .mention-popup) { position: relative; }
|
|
1292
1311
|
.mention-popup {
|
|
1312
|
+
position: absolute; left: 0; right: 0; top: 100%;
|
|
1293
1313
|
background: #1e1e1e; border: 1px solid #444; border-radius: 6px;
|
|
1294
|
-
box-shadow: 0
|
|
1314
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.7);
|
|
1295
1315
|
max-height: 240px; overflow-y: auto;
|
|
1296
|
-
margin-top:
|
|
1316
|
+
margin-top: 4px;
|
|
1317
|
+
z-index: 100;
|
|
1297
1318
|
}
|
|
1298
1319
|
.mention-popup.hidden { display: none; }
|
|
1299
1320
|
.mention-popup .mit {
|