@emaxe/tuigram 1.3.0 → 1.4.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.
@@ -294,8 +294,11 @@ export function rgbaToHalfBlockBlessed(data, width, height) {
294
294
  * @returns {string}
295
295
  */
296
296
  export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
297
- if (cacheKey && imagePreviewCache.has(cacheKey)) {
298
- return imagePreviewCache.get(cacheKey);
297
+ // Размер входит в ключ: одно и то же фото рисуется и миниатюрой в ленте, и на весь
298
+ // экран в просмотрщике. Без размера полноэкранный рендер подменял бы превью в ленте.
299
+ const key = cacheKey ? `${cacheKey}@${maxWidth}x${maxHeight}` : null;
300
+ if (key && imagePreviewCache.has(key)) {
301
+ return imagePreviewCache.get(key);
299
302
  }
300
303
 
301
304
  try {
@@ -304,12 +307,12 @@ export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHei
304
307
  const resized = resizeRgba(decoded.data, decoded.width, decoded.height, dstW, dstH);
305
308
  const blessedText = rgbaToHalfBlockBlessed(resized, dstW, dstH);
306
309
 
307
- if (cacheKey) {
310
+ if (key) {
308
311
  if (imagePreviewCache.size >= MAX_CACHE_SIZE) {
309
312
  const firstKey = imagePreviewCache.keys().next().value;
310
313
  imagePreviewCache.delete(firstKey);
311
314
  }
312
- imagePreviewCache.set(cacheKey, blessedText);
315
+ imagePreviewCache.set(key, blessedText);
313
316
  }
314
317
 
315
318
  return blessedText;
@@ -330,10 +333,7 @@ export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHei
330
333
  export function renderStrippedThumbnail(strippedBytes, { maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
331
334
  if (!strippedBytes || strippedBytes.length < 3) return "";
332
335
 
333
- if (cacheKey && imagePreviewCache.has(cacheKey)) {
334
- return imagePreviewCache.get(cacheKey);
335
- }
336
-
336
+ // Кэш проверяет renderImageBuffer: только он знает итоговый ключ с размером
337
337
  try {
338
338
  const jpgBuf = strippedPhotoToJpg(strippedBytes);
339
339
  return renderImageBuffer(jpgBuf, { mimeType: "image/jpeg", maxWidth, maxHeight, cacheKey });
@@ -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;