@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.
@@ -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
- const hexR = (r < 16 ? "0" : "") + r.toString(16);
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 = rgbaToHex(data[topOffset], data[topOffset + 1], data[topOffset + 2]);
262
- const botHex = rgbaToHex(data[botOffset], data[botOffset + 1], data[botOffset + 2]);
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
- if (cacheKey && imagePreviewCache.has(cacheKey)) {
298
- return imagePreviewCache.get(cacheKey);
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 (cacheKey) {
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(cacheKey, blessedText);
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
- if (cacheKey && imagePreviewCache.has(cacheKey)) {
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
+ }
@@ -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
- * Нужна для точного определения сообщения, по которому кликнули мышью в ChatView.
69
- * @param {Array<object>} messages список сообщений чата
70
- * @returns {Array<{ message: object, startLine: number, endLine: number }>}
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 buildMessageLineRanges(messages) {
73
- if (!messages || messages.length === 0) return [];
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
- // 5. Реакции, если есть
112
- if (msg.reactions && msg.reactions.length > 0) {
113
- currentLine += 1;
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 ranges;
93
+ return null;
128
94
  }
129
95
 
130
96
  /**
131
- * Находит сообщение в ленте по номеру отображаемой строки с учётом текущей прокрутки.
97
+ * Определяет, в какую часть сообщения попал клик: в превью изображения или в остальной текст.
132
98
  * @param {number} lineIndex индекс строки в буфере ленты (0-based)
133
- * @param {Array<{ message: object, startLine: number, endLine: number }>} ranges
134
- * @returns {object|null}
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 getMessageAtLine(lineIndex, ranges) {
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 >= item.startLine && lineIndex <= item.endLine) {
141
- return item.message;
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
- if (relativeX >= 1 && relativeX <= 14) {
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
- // Введите сообщение... [Enter] Отправить [Ctrl+J] Новая строка [Ctrl+R] Ответ [Ctrl+E] Правка [/] Команды
225
- if (relativeX >= 55 && relativeX < 73) {
226
- return "reply";
227
- }
228
- if (relativeX >= 73 && relativeX < 91) {
229
- return "edit";
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;