@emaxe/tuigram 1.3.0 → 1.5.0
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/.env.example +15 -0
- package/CHANGELOG.md +63 -1
- package/CHANGELOG.ru.md +67 -1
- package/README.md +96 -15
- package/README.ru.md +97 -15
- package/package.json +2 -1
- package/src/cli/videoSetup.js +187 -0
- package/src/config.js +6 -0
- package/src/index.js +8 -0
- package/src/state.js +31 -8
- package/src/telegram/dialogs.js +6 -2
- package/src/telegram/formatter.js +1 -1
- package/src/telegram/messages.js +182 -19
- package/src/ui/app.js +293 -73
- package/src/ui/components/chatList.js +159 -101
- package/src/ui/components/chatView.js +454 -109
- package/src/ui/components/header.js +2 -1
- package/src/ui/components/inputBox.js +14 -5
- package/src/ui/components/modals/actionModal.js +11 -14
- package/src/ui/components/modals/chatInfoModal.js +3 -13
- package/src/ui/components/modals/confirmModal.js +3 -13
- package/src/ui/components/modals/fileModal.js +6 -13
- package/src/ui/components/modals/filePickerModal.js +3 -13
- package/src/ui/components/modals/helpModal.js +14 -18
- package/src/ui/components/modals/imageViewerModal.js +172 -0
- package/src/ui/components/modals/videoPlayerModal.js +274 -0
- package/src/ui/components/statusBar.js +2 -1
- package/src/ui/modalMouse.js +43 -0
- package/src/ui/screen.js +168 -20
- package/src/ui/theme.js +1 -0
- package/src/utils/image.js +318 -14
- package/src/utils/mouse.js +62 -75
- package/src/utils/video.js +495 -0
package/src/utils/image.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import jpegJs from "jpeg-js";
|
|
8
8
|
import { PNG } from "pngjs";
|
|
9
|
+
import { stringCellWidth } from "./mouse.js";
|
|
9
10
|
|
|
10
11
|
/** Заголовок стандартного JPEG для распаковки Telegram PhotoStrippedSize. */
|
|
11
12
|
const JPEG_HEADER = Buffer.from([
|
|
@@ -216,6 +217,9 @@ export function resizeRgba(src, srcW, srcH, dstW, dstH) {
|
|
|
216
217
|
return dst;
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
/** Таблица предвычисленных двухсимвольных hex-значений 00-ff для быстрого рендеринга. */
|
|
221
|
+
const HEX_TABLE = Array.from({ length: 256 }, (_, i) => (i < 16 ? "0" : "") + i.toString(16));
|
|
222
|
+
|
|
219
223
|
/**
|
|
220
224
|
* Преобразует компоненты цвета R, G, B в hex-строку вида "#rrggbb".
|
|
221
225
|
* @param {number} r
|
|
@@ -224,10 +228,7 @@ export function resizeRgba(src, srcW, srcH, dstW, dstH) {
|
|
|
224
228
|
* @returns {string}
|
|
225
229
|
*/
|
|
226
230
|
export function rgbaToHex(r, g, b) {
|
|
227
|
-
|
|
228
|
-
const hexG = (g < 16 ? "0" : "") + g.toString(16);
|
|
229
|
-
const hexB = (b < 16 ? "0" : "") + b.toString(16);
|
|
230
|
-
return `#${hexR}${hexG}${hexB}`;
|
|
231
|
+
return `#${HEX_TABLE[r & 0xff]}${HEX_TABLE[g & 0xff]}${HEX_TABLE[b & 0xff]}`;
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
/**
|
|
@@ -258,8 +259,8 @@ export function rgbaToHalfBlockBlessed(data, width, height) {
|
|
|
258
259
|
const topOffset = topRowOffset + x * 4;
|
|
259
260
|
const botOffset = botRowOffset + x * 4;
|
|
260
261
|
|
|
261
|
-
const topHex =
|
|
262
|
-
const botHex =
|
|
262
|
+
const topHex = `#${HEX_TABLE[data[topOffset]]}${HEX_TABLE[data[topOffset + 1]]}${HEX_TABLE[data[topOffset + 2]]}`;
|
|
263
|
+
const botHex = `#${HEX_TABLE[data[botOffset]]}${HEX_TABLE[data[botOffset + 1]]}${HEX_TABLE[data[botOffset + 2]]}`;
|
|
263
264
|
|
|
264
265
|
if (topHex !== currentFg) {
|
|
265
266
|
line += `{${topHex}-fg}`;
|
|
@@ -294,8 +295,11 @@ export function rgbaToHalfBlockBlessed(data, width, height) {
|
|
|
294
295
|
* @returns {string}
|
|
295
296
|
*/
|
|
296
297
|
export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
|
|
297
|
-
|
|
298
|
-
|
|
298
|
+
// Размер входит в ключ: одно и то же фото рисуется и миниатюрой в ленте, и на весь
|
|
299
|
+
// экран в просмотрщике. Без размера полноэкранный рендер подменял бы превью в ленте.
|
|
300
|
+
const key = cacheKey ? `${cacheKey}@${maxWidth}x${maxHeight}` : null;
|
|
301
|
+
if (key && imagePreviewCache.has(key)) {
|
|
302
|
+
return imagePreviewCache.get(key);
|
|
299
303
|
}
|
|
300
304
|
|
|
301
305
|
try {
|
|
@@ -304,12 +308,12 @@ export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHei
|
|
|
304
308
|
const resized = resizeRgba(decoded.data, decoded.width, decoded.height, dstW, dstH);
|
|
305
309
|
const blessedText = rgbaToHalfBlockBlessed(resized, dstW, dstH);
|
|
306
310
|
|
|
307
|
-
if (
|
|
311
|
+
if (key) {
|
|
308
312
|
if (imagePreviewCache.size >= MAX_CACHE_SIZE) {
|
|
309
313
|
const firstKey = imagePreviewCache.keys().next().value;
|
|
310
314
|
imagePreviewCache.delete(firstKey);
|
|
311
315
|
}
|
|
312
|
-
imagePreviewCache.set(
|
|
316
|
+
imagePreviewCache.set(key, blessedText);
|
|
313
317
|
}
|
|
314
318
|
|
|
315
319
|
return blessedText;
|
|
@@ -330,10 +334,7 @@ export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHei
|
|
|
330
334
|
export function renderStrippedThumbnail(strippedBytes, { maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
|
|
331
335
|
if (!strippedBytes || strippedBytes.length < 3) return "";
|
|
332
336
|
|
|
333
|
-
|
|
334
|
-
return imagePreviewCache.get(cacheKey);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
+
// Кэш проверяет renderImageBuffer: только он знает итоговый ключ с размером
|
|
337
338
|
try {
|
|
338
339
|
const jpgBuf = strippedPhotoToJpg(strippedBytes);
|
|
339
340
|
return renderImageBuffer(jpgBuf, { mimeType: "image/jpeg", maxWidth, maxHeight, cacheKey });
|
|
@@ -341,3 +342,306 @@ export function renderStrippedThumbnail(strippedBytes, { maxWidth = 36, maxHeigh
|
|
|
341
342
|
return "";
|
|
342
343
|
}
|
|
343
344
|
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Извлекает исходные размеры медиа (ширину и высоту в пикселях) из объекта сообщения Telegram.
|
|
348
|
+
* @param {object} rawMessage исходное сообщение или объект медиа
|
|
349
|
+
* @returns {{ width: number, height: number }|null}
|
|
350
|
+
*/
|
|
351
|
+
export function getMediaDimensions(rawMessage) {
|
|
352
|
+
const media = rawMessage?.media || rawMessage;
|
|
353
|
+
if (!media) return null;
|
|
354
|
+
|
|
355
|
+
// 1. Фотография
|
|
356
|
+
if (media.className === "MessageMediaPhoto" || media.photo) {
|
|
357
|
+
const photo = media.photo || media;
|
|
358
|
+
let maxW = 0;
|
|
359
|
+
let maxH = 0;
|
|
360
|
+
for (const s of photo.sizes || []) {
|
|
361
|
+
if (s && typeof s.w === "number" && typeof s.h === "number" && s.w > 0 && s.h > 0) {
|
|
362
|
+
if (s.w * s.h > maxW * maxH) {
|
|
363
|
+
maxW = s.w;
|
|
364
|
+
maxH = s.h;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (maxW > 0 && maxH > 0) {
|
|
369
|
+
return { width: maxW, height: maxH };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 2. Документ (видео, изображение, gif)
|
|
374
|
+
if (media.className === "MessageMediaDocument" || media.document) {
|
|
375
|
+
const doc = media.document || media;
|
|
376
|
+
const attributes = doc.attributes || [];
|
|
377
|
+
for (const attr of attributes) {
|
|
378
|
+
if ((attr.className === "DocumentAttributeVideo" || attr.className === "DocumentAttributeImageSize") && attr.w && attr.h) {
|
|
379
|
+
return { width: attr.w, height: attr.h };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
let maxW = 0;
|
|
383
|
+
let maxH = 0;
|
|
384
|
+
for (const t of doc.thumbs || []) {
|
|
385
|
+
if (t && typeof t.w === "number" && typeof t.h === "number" && t.w > 0 && t.h > 0) {
|
|
386
|
+
if (t.w * t.h > maxW * maxH) {
|
|
387
|
+
maxW = t.w;
|
|
388
|
+
maxH = t.h;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (maxW > 0 && maxH > 0) {
|
|
393
|
+
return { width: maxW, height: maxH };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 3. Веб-страница (встроенное медиа статьи/ссылки)
|
|
398
|
+
if (media.className === "MessageMediaWebPage" || media.webpage) {
|
|
399
|
+
const page = media.webpage || media;
|
|
400
|
+
if (page.photo) {
|
|
401
|
+
const dims = getMediaDimensions(page.photo);
|
|
402
|
+
if (dims) return dims;
|
|
403
|
+
}
|
|
404
|
+
if (page.document) {
|
|
405
|
+
const dims = getMediaDimensions(page.document);
|
|
406
|
+
if (dims) return dims;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Проверяет, является ли вложение визуальным медиа (фото, видео, анимация),
|
|
415
|
+
* для которого отображается визуальное превью в ленте сообщений.
|
|
416
|
+
* @param {object} rawMessage
|
|
417
|
+
* @returns {boolean}
|
|
418
|
+
*/
|
|
419
|
+
export function isPreviewableMedia(rawMessage) {
|
|
420
|
+
const media = rawMessage?.media || rawMessage;
|
|
421
|
+
if (!media) return false;
|
|
422
|
+
|
|
423
|
+
if (media.className === "MessageMediaPhoto" || media.photo) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (media.className === "MessageMediaDocument" || media.document) {
|
|
428
|
+
const doc = media.document || media;
|
|
429
|
+
if (typeof doc.mimeType === "string") {
|
|
430
|
+
if (doc.mimeType.startsWith("video/") || doc.mimeType.startsWith("image/")) {
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const attributes = doc.attributes || [];
|
|
436
|
+
for (const attr of attributes) {
|
|
437
|
+
if (
|
|
438
|
+
attr.className === "DocumentAttributeVideo" ||
|
|
439
|
+
attr.className === "DocumentAttributeImageSize" ||
|
|
440
|
+
attr.className === "DocumentAttributeAnimated" ||
|
|
441
|
+
attr.className === "DocumentAttributeSticker"
|
|
442
|
+
) {
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (Array.isArray(doc.thumbs) && doc.thumbs.length > 0) {
|
|
448
|
+
const isAudio = attributes.some((a) => a.className === "DocumentAttributeAudio");
|
|
449
|
+
if (!isAudio) {
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (media.className === "MessageMediaWebPage" || media.webpage) {
|
|
456
|
+
const page = media.webpage || media;
|
|
457
|
+
return Boolean(page.photo || (page.document && isPreviewableMedia(page.document)));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Цветовая палитра по умолчанию для прелоадера медиа-вложений. */
|
|
464
|
+
const DEFAULT_PRELOADER_PALETTE = {
|
|
465
|
+
bg: "#1f2335",
|
|
466
|
+
border: "#3b4261",
|
|
467
|
+
fg: "#8288a6",
|
|
468
|
+
accent: "#7aa2f7",
|
|
469
|
+
dim: "#6874a0",
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Генерирует строку Blessed-разметки для прелоадера / плейсхолдера медиа-вложения
|
|
474
|
+
* точного целевого размера, чтобы избежать скачков интерфейса и сдвигов сообщений
|
|
475
|
+
* при асинхронной подгрузке превью.
|
|
476
|
+
*
|
|
477
|
+
* @param {object} rawMessage исходное сообщение или объект медиа
|
|
478
|
+
* @param {object} [options]
|
|
479
|
+
* @param {number} [options.maxWidth=36]
|
|
480
|
+
* @param {number} [options.maxHeight=14]
|
|
481
|
+
* @param {string} [options.customLabel] пользовательская подпись
|
|
482
|
+
* @param {object} [options.palette] цвета темы (border, bg, fg, accent)
|
|
483
|
+
* @returns {string}
|
|
484
|
+
*/
|
|
485
|
+
export function renderMediaPreloader(rawMessage, {
|
|
486
|
+
maxWidth = 36,
|
|
487
|
+
maxHeight = 14,
|
|
488
|
+
customLabel,
|
|
489
|
+
palette,
|
|
490
|
+
} = {}) {
|
|
491
|
+
const dims = getMediaDimensions(rawMessage);
|
|
492
|
+
const srcW = dims?.width || 320;
|
|
493
|
+
const srcH = dims?.height || 240;
|
|
494
|
+
const { dstW, rows } = calculateTargetDimensions(srcW, srcH, maxWidth, maxHeight);
|
|
495
|
+
|
|
496
|
+
const p = { ...DEFAULT_PRELOADER_PALETTE, ...(palette || {}) };
|
|
497
|
+
|
|
498
|
+
const media = rawMessage?.media || rawMessage;
|
|
499
|
+
const isDoc = media?.className === "MessageMediaDocument" || Boolean(media?.document);
|
|
500
|
+
const doc = media?.document || (isDoc ? media : null);
|
|
501
|
+
const attributes = doc?.attributes || [];
|
|
502
|
+
|
|
503
|
+
const isVideo = (typeof doc?.mimeType === "string" && doc.mimeType.startsWith("video/")) ||
|
|
504
|
+
attributes.some((a) => a?.className === "DocumentAttributeVideo");
|
|
505
|
+
const isGif = doc?.mimeType === "image/gif" ||
|
|
506
|
+
attributes.some((a) => a?.className === "DocumentAttributeAnimated");
|
|
507
|
+
const isSticker = attributes.some((a) => a?.className === "DocumentAttributeSticker");
|
|
508
|
+
|
|
509
|
+
// Определение длительности видео, если доступна
|
|
510
|
+
let duration = 0;
|
|
511
|
+
if (isVideo) {
|
|
512
|
+
const videoAttr = attributes.find((a) => a?.className === "DocumentAttributeVideo");
|
|
513
|
+
if (videoAttr?.duration) {
|
|
514
|
+
duration = videoAttr.duration;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Подготовка текстовых меток
|
|
519
|
+
let primaryLabel = customLabel || "";
|
|
520
|
+
if (!primaryLabel) {
|
|
521
|
+
if (isVideo) {
|
|
522
|
+
primaryLabel = dstW >= 24 ? "⏳ Загрузка видео..." : (dstW >= 16 ? "⏳ Видео..." : "⏳ Видео");
|
|
523
|
+
} else if (isGif) {
|
|
524
|
+
primaryLabel = dstW >= 22 ? "⏳ Загрузка GIF..." : "⏳ GIF";
|
|
525
|
+
} else if (isSticker) {
|
|
526
|
+
primaryLabel = dstW >= 24 ? "⏳ Загрузка стикера..." : "⏳ Стикер";
|
|
527
|
+
} else {
|
|
528
|
+
primaryLabel = dstW >= 22 ? "⏳ Загрузка фото..." : (dstW >= 16 ? "⏳ Фото..." : "⏳ Фото");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let secondaryLabel = "";
|
|
533
|
+
if (duration > 0 && dims?.width && dims?.height) {
|
|
534
|
+
const minSec = `${Math.floor(duration / 60)}:${String(Math.floor(duration % 60)).padStart(2, "0")}`;
|
|
535
|
+
secondaryLabel = dstW >= 22 ? `${minSec} · ${dims.width}×${dims.height}` : minSec;
|
|
536
|
+
} else if (duration > 0) {
|
|
537
|
+
secondaryLabel = `${Math.floor(duration / 60)}:${String(Math.floor(duration % 60)).padStart(2, "0")}`;
|
|
538
|
+
} else if (dims?.width && dims?.height) {
|
|
539
|
+
secondaryLabel = `${dims.width}×${dims.height}`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const innerWidth = Math.max(1, dstW - 2);
|
|
543
|
+
const innerHeight = Math.max(1, rows - 2);
|
|
544
|
+
|
|
545
|
+
const borderTag = `{${p.border}-fg}`;
|
|
546
|
+
const borderClose = `{/${p.border}-fg}`;
|
|
547
|
+
const bgTag = p.bg ? `{${p.bg}-bg}` : "";
|
|
548
|
+
const bgClose = p.bg ? `{/${p.bg}-bg}` : "";
|
|
549
|
+
const textTag = `{${p.fg}-fg}`;
|
|
550
|
+
const textClose = `{/${p.fg}-fg}`;
|
|
551
|
+
const dimTag = `{${p.dim}-fg}`;
|
|
552
|
+
const dimClose = `{/${p.dim}-fg}`;
|
|
553
|
+
|
|
554
|
+
// Если места слишком мало для рамки (высота 1-2 строки)
|
|
555
|
+
if (rows <= 1) {
|
|
556
|
+
const line = `[ ${primaryLabel} ]`;
|
|
557
|
+
const pad = Math.max(0, dstW - stringCellWidth(line));
|
|
558
|
+
return `${bgTag}${textTag}${line}${" ".repeat(pad)}${textClose}${bgClose}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (rows === 2) {
|
|
562
|
+
const topLine = `${borderTag}┌─ ${borderClose}${textTag}${primaryLabel}${textClose} ${borderTag}${"─".repeat(Math.max(0, innerWidth - stringCellWidth(primaryLabel) - 3))}┐${borderClose}`;
|
|
563
|
+
const botLine = `${borderTag}└${"─".repeat(innerWidth)}┘${borderClose}`;
|
|
564
|
+
return `${bgTag}${topLine}${bgClose}\n${bgTag}${botLine}${bgClose}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const lines = [];
|
|
568
|
+
|
|
569
|
+
// Верхняя граница
|
|
570
|
+
lines.push(`${bgTag}${borderTag}┌${"─".repeat(innerWidth)}┐${borderClose}${bgClose}`);
|
|
571
|
+
|
|
572
|
+
// Вычисление строки размещения подписей
|
|
573
|
+
const hasSecondary = Boolean(secondaryLabel) && innerHeight >= 3 && stringCellWidth(secondaryLabel) <= innerWidth;
|
|
574
|
+
const contentLinesCount = hasSecondary ? 2 : 1;
|
|
575
|
+
const startContentRow = Math.max(0, Math.floor((innerHeight - contentLinesCount) / 2));
|
|
576
|
+
|
|
577
|
+
for (let r = 0; r < innerHeight; r++) {
|
|
578
|
+
let content = "";
|
|
579
|
+
if (r === startContentRow) {
|
|
580
|
+
let label = primaryLabel;
|
|
581
|
+
if (stringCellWidth(label) > innerWidth) {
|
|
582
|
+
label = label.slice(0, Math.max(1, innerWidth - 1)) + "…";
|
|
583
|
+
}
|
|
584
|
+
const width = stringCellWidth(label);
|
|
585
|
+
const leftPad = Math.max(0, Math.floor((innerWidth - width) / 2));
|
|
586
|
+
const rightPad = Math.max(0, innerWidth - width - leftPad);
|
|
587
|
+
content = " ".repeat(leftPad) + textTag + label + textClose + " ".repeat(rightPad);
|
|
588
|
+
} else if (hasSecondary && r === startContentRow + 1) {
|
|
589
|
+
let label = secondaryLabel;
|
|
590
|
+
if (stringCellWidth(label) > innerWidth) {
|
|
591
|
+
label = label.slice(0, Math.max(1, innerWidth - 1));
|
|
592
|
+
}
|
|
593
|
+
const width = stringCellWidth(label);
|
|
594
|
+
const leftPad = Math.max(0, Math.floor((innerWidth - width) / 2));
|
|
595
|
+
const rightPad = Math.max(0, innerWidth - width - leftPad);
|
|
596
|
+
content = " ".repeat(leftPad) + dimTag + label + dimClose + " ".repeat(rightPad);
|
|
597
|
+
} else {
|
|
598
|
+
content = " ".repeat(innerWidth);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
lines.push(`${bgTag}${borderTag}│${borderClose}${content}${borderTag}│${borderClose}${bgClose}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Нижняя граница
|
|
605
|
+
lines.push(`${bgTag}${borderTag}└${"─".repeat(innerWidth)}┘${borderClose}${bgClose}`);
|
|
606
|
+
|
|
607
|
+
return lines.join("\n");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Извлекает готовое превью изображения из кэша по объекту сообщения, если оно уже рендерилось.
|
|
612
|
+
* @param {object} rawMessage
|
|
613
|
+
* @param {object} [options]
|
|
614
|
+
* @param {number} [options.maxWidth=36]
|
|
615
|
+
* @param {number} [options.maxHeight=14]
|
|
616
|
+
* @returns {string|null}
|
|
617
|
+
*/
|
|
618
|
+
export function getCachedImagePreview(rawMessage, { maxWidth = 36, maxHeight = 14 } = {}) {
|
|
619
|
+
const media = rawMessage?.media || rawMessage;
|
|
620
|
+
if (!media) return null;
|
|
621
|
+
|
|
622
|
+
const photo = media.photo || (media.className === "MessageMediaPhoto" ? media : null);
|
|
623
|
+
const doc = media.document || (media.className === "MessageMediaDocument" ? media : null);
|
|
624
|
+
|
|
625
|
+
const keysToCheck = [];
|
|
626
|
+
if (photo?.id) {
|
|
627
|
+
keysToCheck.push(`photo_${photo.id}@${maxWidth}x${maxHeight}`);
|
|
628
|
+
keysToCheck.push(`photo_full_${photo.id}@${maxWidth}x${maxHeight}`);
|
|
629
|
+
}
|
|
630
|
+
if (doc?.id) {
|
|
631
|
+
keysToCheck.push(`doc_${doc.id}@${maxWidth}x${maxHeight}`);
|
|
632
|
+
keysToCheck.push(`doc_full_${doc.id}@${maxWidth}x${maxHeight}`);
|
|
633
|
+
}
|
|
634
|
+
if (rawMessage?.id) {
|
|
635
|
+
keysToCheck.push(`msg_${rawMessage.id}@${maxWidth}x${maxHeight}`);
|
|
636
|
+
keysToCheck.push(`photo_full_${rawMessage.id}@${maxWidth}x${maxHeight}`);
|
|
637
|
+
keysToCheck.push(`doc_full_${rawMessage.id}@${maxWidth}x${maxHeight}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
for (const key of keysToCheck) {
|
|
641
|
+
if (imagePreviewCache.has(key)) {
|
|
642
|
+
return imagePreviewCache.get(key);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return null;
|
|
647
|
+
}
|
package/src/utils/mouse.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import unicode from "neo-blessed/lib/unicode.js";
|
|
6
6
|
|
|
7
7
|
/** Пиктограммы и эмодзи, занимающие две ячейки терминала. */
|
|
8
|
-
const EMOJI_REGEX = /\p{Extended_Pictographic}/u;
|
|
8
|
+
const EMOJI_REGEX = /\p{Extended_Pictographic}|[\u{1F000}-\u{1FAFF}]|[\u{2600}-\u{27BF}]/u;
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Вычисляет ширину строки в терминальных ячейках.
|
|
@@ -24,6 +24,17 @@ export function stringCellWidth(text) {
|
|
|
24
24
|
return width;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Отличает клик правой кнопкой от остальных.
|
|
29
|
+
* neo-blessed эмитит одно и то же событие "click" для любой кнопки мыши,
|
|
30
|
+
* поэтому компоненты, где правый клик не имеет смысла, обязаны его отсеивать.
|
|
31
|
+
* @param {{ button?: string }} [data] данные события мыши blessed
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isRightClick(data) {
|
|
35
|
+
return data?.button === "right";
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
/**
|
|
28
39
|
* Проверяет, попадают ли координаты (x, y) внутрь прямоугольной области.
|
|
29
40
|
* @param {number} x абсолютная или относительная X-координата
|
|
@@ -64,82 +75,43 @@ export function getTabByCoordinate(relativeX, tabKeys = DEFAULT_TAB_KEYS, tabNam
|
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* @param {
|
|
70
|
-
* @
|
|
78
|
+
* Находит сообщение в ленте по номеру отображаемой строки с учётом текущей прокрутки.
|
|
79
|
+
* Карту строк строит ChatView при отрисовке ленты — только он знает реальную вёрстку.
|
|
80
|
+
* @param {number} lineIndex индекс строки в буфере ленты (0-based)
|
|
81
|
+
* @param {Array<{ message: object, startLine: number, endLine: number }>} ranges
|
|
82
|
+
* @returns {object|null}
|
|
71
83
|
*/
|
|
72
|
-
export function
|
|
73
|
-
if (!
|
|
74
|
-
|
|
75
|
-
const ranges = [];
|
|
76
|
-
let currentLine = 0;
|
|
77
|
-
let lastDateString = "";
|
|
78
|
-
|
|
79
|
-
for (const msg of messages) {
|
|
80
|
-
// 1. Проверяем разделитель дат (если дата изменилась)
|
|
81
|
-
const dateDivider = msg.date ? new Date(msg.date).toLocaleDateString("ru-RU") : "";
|
|
82
|
-
if (dateDivider && dateDivider !== lastDateString) {
|
|
83
|
-
// Форматтер добавляет: \n ─────── дата ─────── \n\n (3 строки)
|
|
84
|
-
currentLine += 3;
|
|
85
|
-
lastDateString = dateDivider;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const startLine = currentLine;
|
|
89
|
-
|
|
90
|
-
// 2. Строка автора/времени
|
|
91
|
-
currentLine += 1;
|
|
92
|
-
|
|
93
|
-
// 3. Блок ответа (Reply), если есть
|
|
94
|
-
if (msg.replyToMsgId) {
|
|
95
|
-
currentLine += 1;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 4. Тело сообщения (текст + медиа-описание + превью)
|
|
99
|
-
let bodyLinesCount = 1;
|
|
100
|
-
const parts = [];
|
|
101
|
-
if (msg.mediaDescription) parts.push(msg.mediaDescription);
|
|
102
|
-
if (msg.imagePreview) parts.push(msg.imagePreview);
|
|
103
|
-
if (msg.text) parts.push(msg.text);
|
|
104
|
-
|
|
105
|
-
if (parts.length > 0) {
|
|
106
|
-
const fullBody = parts.join("\n");
|
|
107
|
-
bodyLinesCount = fullBody.split("\n").length;
|
|
108
|
-
}
|
|
109
|
-
currentLine += bodyLinesCount;
|
|
84
|
+
export function getMessageAtLine(lineIndex, ranges) {
|
|
85
|
+
if (!ranges || ranges.length === 0 || lineIndex < 0) return null;
|
|
110
86
|
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
87
|
+
for (const item of ranges) {
|
|
88
|
+
if (lineIndex >= item.startLine && lineIndex <= item.endLine) {
|
|
89
|
+
return item.message;
|
|
114
90
|
}
|
|
115
|
-
|
|
116
|
-
// 6. Замыкающий отступ между сообщениями (\n\n)
|
|
117
|
-
const endLine = currentLine;
|
|
118
|
-
currentLine += 2;
|
|
119
|
-
|
|
120
|
-
ranges.push({
|
|
121
|
-
message: msg,
|
|
122
|
-
startLine,
|
|
123
|
-
endLine,
|
|
124
|
-
});
|
|
125
91
|
}
|
|
126
92
|
|
|
127
|
-
return
|
|
93
|
+
return null;
|
|
128
94
|
}
|
|
129
95
|
|
|
130
96
|
/**
|
|
131
|
-
*
|
|
97
|
+
* Определяет, в какую часть сообщения попал клик: в превью изображения или в остальной текст.
|
|
132
98
|
* @param {number} lineIndex индекс строки в буфере ленты (0-based)
|
|
133
|
-
* @param {
|
|
134
|
-
* @
|
|
99
|
+
* @param {number} relativeX смещение по X от левого края ленты (в ячейках)
|
|
100
|
+
* @param {Array<{ message: object, startLine: number, endLine: number, image?: object|null }>} ranges
|
|
101
|
+
* @returns {{ message: object, part: "image"|"body" }|null}
|
|
135
102
|
*/
|
|
136
|
-
export function
|
|
103
|
+
export function getMessagePartAtPoint(lineIndex, relativeX, ranges) {
|
|
137
104
|
if (!ranges || ranges.length === 0 || lineIndex < 0) return null;
|
|
138
105
|
|
|
139
106
|
for (const item of ranges) {
|
|
140
|
-
if (lineIndex
|
|
141
|
-
|
|
142
|
-
|
|
107
|
+
if (lineIndex < item.startLine || lineIndex > item.endLine) continue;
|
|
108
|
+
|
|
109
|
+
const image = item.image;
|
|
110
|
+
const insideImage = Boolean(image)
|
|
111
|
+
&& lineIndex >= image.startLine && lineIndex <= image.endLine
|
|
112
|
+
&& relativeX >= image.left && relativeX < image.right;
|
|
113
|
+
|
|
114
|
+
return { message: item.message, part: insideImage ? "image" : "body" };
|
|
143
115
|
}
|
|
144
116
|
|
|
145
117
|
return null;
|
|
@@ -179,6 +151,9 @@ export function getStatusBarActionAt(relativeX, totalWidth = 120) {
|
|
|
179
151
|
return null;
|
|
180
152
|
}
|
|
181
153
|
|
|
154
|
+
/** Ширина логотипа « 🚀 TuiGram» в ячейках — кликабельная зона вызова справки. */
|
|
155
|
+
const HEADER_LOGO_WIDTH = stringCellWidth(" 🚀 TuiGram");
|
|
156
|
+
|
|
182
157
|
/**
|
|
183
158
|
* Определяет действие при клике на верхнюю шапку приложения.
|
|
184
159
|
* @param {number} relativeX смещение по X от левого края шапки
|
|
@@ -190,7 +165,8 @@ export function getStatusBarActionAt(relativeX, totalWidth = 120) {
|
|
|
190
165
|
export function getHeaderActionAt(relativeX, relativeY, { hasActiveChat = false } = {}) {
|
|
191
166
|
// Внутренняя строка 1 (верхняя линия контента): логотип TuiGram, имя пользователя, статус
|
|
192
167
|
if (relativeY === 1) {
|
|
193
|
-
|
|
168
|
+
// Контент шапки начинается после рамки, поэтому логотип занимает ячейки 1..HEADER_LOGO
|
|
169
|
+
if (relativeX >= 1 && relativeX <= HEADER_LOGO_WIDTH) {
|
|
194
170
|
return "help";
|
|
195
171
|
}
|
|
196
172
|
return "status";
|
|
@@ -206,6 +182,20 @@ export function getHeaderActionAt(relativeX, relativeY, { hasActiveChat = false
|
|
|
206
182
|
return null;
|
|
207
183
|
}
|
|
208
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Сегменты подсказок контекстной строки ввода в том порядке и с теми подписями,
|
|
187
|
+
* какими их рисует inputBox.renderContext. Ширины считаются из самих подписей —
|
|
188
|
+
* зашитые вручную координаты разъезжались при любой правке текста.
|
|
189
|
+
*/
|
|
190
|
+
const INPUT_HINT_SEGMENTS = [
|
|
191
|
+
{ id: null, label: " Введите сообщение... " },
|
|
192
|
+
{ id: null, label: "[Enter] Отправить " },
|
|
193
|
+
{ id: null, label: "[Ctrl+J] Новая строка " },
|
|
194
|
+
{ id: "reply", label: "[Ctrl+R] Ответ " },
|
|
195
|
+
{ id: "edit", label: "[Ctrl+E] Правка " },
|
|
196
|
+
{ id: "commands", label: "[/] Команды" },
|
|
197
|
+
];
|
|
198
|
+
|
|
209
199
|
/**
|
|
210
200
|
* Определяет действие при клике на контекстную строку поля ввода.
|
|
211
201
|
* @param {number} relativeX смещение по X от левого края контекстной строки
|
|
@@ -220,16 +210,13 @@ export function getInputContextActionAt(relativeX, mode) {
|
|
|
220
210
|
|
|
221
211
|
if (relativeX < 0) return null;
|
|
222
212
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
if (relativeX >= 91) {
|
|
232
|
-
return "commands";
|
|
213
|
+
let currentX = 0;
|
|
214
|
+
for (const seg of INPUT_HINT_SEGMENTS) {
|
|
215
|
+
const segWidth = stringCellWidth(seg.label);
|
|
216
|
+
if (relativeX >= currentX && relativeX < currentX + segWidth) {
|
|
217
|
+
return seg.id;
|
|
218
|
+
}
|
|
219
|
+
currentX += segWidth;
|
|
233
220
|
}
|
|
234
221
|
|
|
235
222
|
return null;
|