@emaxe/tuigram 1.2.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.
@@ -3,21 +3,36 @@ import { formatMessageTime, formatDateDivider } from "../../utils/time.js";
3
3
  import { formatMessageText, escapeBlessed } from "../../telegram/formatter.js";
4
4
  import { fg } from "../theme.js";
5
5
 
6
+ import { getMessagePartAtPoint, isRightClick, stringCellWidth } from "../../utils/mouse.js";
7
+
8
+ /** Отступ тела сообщения от левого края ленты, в ячейках. */
9
+ const BODY_INDENT = 2;
10
+
6
11
  /**
7
12
  * Создаёт компонент просмотра сообщений чата (правая центральная панель).
8
13
  * @param {blessed.Widgets.Screen} screen
9
14
  * @param {object} theme
10
15
  * @param {object} callbacks
11
16
  * @param {() => void} [callbacks.onLoadMoreHistory]
12
- * @param {(msg: object) => void} [callbacks.onActionMenu]
17
+ * @param {(msg: object) => void} [callbacks.onActionMenu] правый клик / Enter / Ctrl+A
18
+ * @param {(msg: object) => void} [callbacks.onSelectMessage] сообщение выделено
19
+ * @param {(msg: object) => void} [callbacks.onOpenImage] клик по превью изображения
20
+ * @param {() => void} [callbacks.onFocusRequest] вызывается перед взятием фокуса мышью
13
21
  */
14
- export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu } = {}) {
22
+ export function createChatView(screen, theme, {
23
+ onLoadMoreHistory,
24
+ onActionMenu,
25
+ onSelectMessage,
26
+ onOpenImage,
27
+ onFocusRequest,
28
+ } = {}) {
15
29
  const container = blessed.box({
16
30
  parent: screen,
17
31
  top: 4,
18
32
  left: "35%",
19
33
  right: 0,
20
34
  bottom: 6,
35
+ mouse: true,
21
36
  border: {
22
37
  type: "line",
23
38
  },
@@ -38,9 +53,13 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
38
53
  bottom: 0,
39
54
  tags: true,
40
55
  scrollable: true,
56
+ // alwaysScroll обязателен: без него blessed копит смещение в childOffset,
57
+ // не двигая содержимое (первые щелчки колеса «проглатываются»), а getScroll()
58
+ // перестаёт совпадать с реальным сдвигом ленты — клики попадали не в то сообщение.
59
+ alwaysScroll: true,
41
60
  mouse: true,
42
- keys: true,
43
- vi: true,
61
+ // keys/vi намеренно выключены: их встроенные обработчики скроллят ленту на
62
+ // стрелках, а стрелки здесь двигают выделение. Все клавиши навешаны явно ниже.
44
63
  scrollbar: {
45
64
  ch: "│",
46
65
  style: {
@@ -54,26 +73,49 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
54
73
  },
55
74
  });
56
75
 
76
+ // blessed сам вешает на scrollable-box с mouse:true прокрутку колесом на пол-экрана.
77
+ // Вместе с нашими обработчиками получалось height/2 + 3 строки за щелчок.
78
+ scrollBox.removeAllListeners("wheelup");
79
+ scrollBox.removeAllListeners("wheeldown");
80
+
81
+ /** Подсвечивает рамку, когда лента сообщений в фокусе. */
82
+ function setFocusHighlight(active) {
83
+ container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
84
+ screen.render();
85
+ }
86
+
87
+ scrollBox.on("focus", () => setFocusHighlight(true));
88
+ scrollBox.on("blur", () => setFocusHighlight(false));
89
+
57
90
  let currentMessages = [];
91
+ let currentRanges = [];
92
+ let selectedId = null;
58
93
 
59
94
  /**
60
- * Форматирует список сообщений в единую ленту текста с разметкой Blessed.
95
+ * Форматирует список сообщений в единую ленту текста с разметкой Blessed
96
+ * и вычисляет координаты строк каждого сообщения для кликов мыши.
61
97
  * @param {Array<object>} messages
62
- * @returns {string}
98
+ * @returns {{ text: string, ranges: Array<object> }}
63
99
  */
64
- function renderMessages(messages) {
100
+ function renderMessagesWithRanges(messages) {
65
101
  if (!messages || messages.length === 0) {
66
- return `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`;
102
+ return {
103
+ text: `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`,
104
+ ranges: [],
105
+ };
67
106
  }
68
107
 
69
108
  let output = "";
70
109
  let lastDateString = "";
110
+ const ranges = [];
111
+ let lineCursor = 0;
71
112
 
72
113
  for (const msg of messages) {
73
114
  // Разделитель дат
74
115
  const dateStr = formatDateDivider(msg.date);
75
116
  if (dateStr && dateStr !== lastDateString) {
76
117
  output += `\n ${fg(theme.chatView.dateDivider, `─────── ${escapeBlessed(dateStr)} ───────`)}\n\n`;
118
+ lineCursor += 3;
77
119
  lastDateString = dateStr;
78
120
  }
79
121
 
@@ -90,10 +132,14 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
90
132
  authorTag = `${fg(theme.chatView.incomingName, `{bold}${name}{/bold}`)} ${timeTag}`;
91
133
  }
92
134
 
135
+ // Метка редактирования
136
+ const editedTag = msg.editDate ? ` ${fg(theme.chatView.time, "(изменено)")}` : "";
137
+
138
+ const lines = [` ${authorTag}${editedTag}`];
139
+
93
140
  // Блок ответа (Reply)
94
- let replyBlock = "";
95
141
  if (msg.replyToMsgId) {
96
- replyBlock = ` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}\n`;
142
+ lines.push(` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}`);
97
143
  }
98
144
 
99
145
  // Текст сообщения и entities
@@ -113,29 +159,65 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
113
159
  bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
114
160
  }
115
161
 
116
- // Отступ строк текста сообщения
117
- const indentedBody = bodyText
118
- .split("\n")
119
- .map((line) => ` ${line}`)
120
- .join("\n");
162
+ // Строка, с которой начинается тело — нужна для координат превью
163
+ const bodyStartOffset = lines.length;
164
+ for (const line of bodyText.split("\n")) {
165
+ lines.push(` ${line}`);
166
+ }
121
167
 
122
168
  // Реакции
123
- let reactionsLine = "";
124
169
  if (msg.reactions && msg.reactions.length > 0) {
125
170
  const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
126
- reactionsLine = `\n ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`;
171
+ lines.push(` ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`);
127
172
  }
128
173
 
129
- // Метка редактирования
130
- let editedTag = "";
131
- if (msg.editDate) {
132
- editedTag = ` ${fg(theme.chatView.time, "(изменено)")}`;
174
+ const startLine = lineCursor;
175
+
176
+ // Прямоугольник превью изображения внутри сообщения
177
+ let image = null;
178
+ if (msg.imagePreview) {
179
+ const descLines = msg.mediaDescription ? msg.mediaDescription.split("\n").length : 0;
180
+ const previewLines = msg.imagePreview.split("\n");
181
+ const imageStart = startLine + bodyStartOffset + descLines;
182
+ const width = previewLines.reduce((max, line) => Math.max(max, stringCellWidth(line)), 0);
183
+ image = {
184
+ startLine: imageStart,
185
+ endLine: imageStart + previewLines.length - 1,
186
+ left: BODY_INDENT,
187
+ right: BODY_INDENT + width,
188
+ };
133
189
  }
134
190
 
135
- output += ` ${authorTag}${editedTag}\n${replyBlock}${indentedBody}${reactionsLine}\n\n`;
191
+ // Выделенное сообщение помечается полосой в первой колонке. Первая колонка
192
+ // каждой строки — пробел отступа, поэтому ширина строк не меняется и карта
193
+ // координат остаётся верной.
194
+ const rendered = msg.id === selectedId
195
+ ? lines.map((line) => `${fg(theme.accent, "▌")}${line.slice(1)}`)
196
+ : lines;
197
+
198
+ ranges.push({
199
+ message: msg,
200
+ startLine,
201
+ endLine: startLine + lines.length - 1,
202
+ image,
203
+ });
204
+
205
+ // lines.length строк + одна пустая строка-разделитель между сообщениями
206
+ lineCursor += lines.length + 1;
207
+ output += `${rendered.join("\n")}\n\n`;
136
208
  }
137
209
 
138
- return output;
210
+ return { text: output, ranges };
211
+ }
212
+
213
+ /** Перерисовывает ленту, сохраняя позицию прокрутки (например, после смены выделения). */
214
+ function redraw() {
215
+ const prevBase = scrollBox.childBase || 0;
216
+ const rendered = renderMessagesWithRanges(currentMessages);
217
+ currentRanges = rendered.ranges;
218
+ scrollBox.setContent(rendered.text);
219
+ scrollBox.scrollTo(prevBase);
220
+ screen.render();
139
221
  }
140
222
 
141
223
  /**
@@ -145,10 +227,17 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
145
227
  */
146
228
  function setMessages(messages, autoScrollToBottom = true) {
147
229
  currentMessages = messages;
148
- const prevScroll = scrollBox.getScroll();
230
+ const prevScroll = scrollBox.childBase || 0;
149
231
  const prevHeight = scrollBox.getScrollHeight();
150
232
 
151
- scrollBox.setContent(renderMessages(messages));
233
+ // Выделенное сообщение могло быть удалено или относиться к другому чату
234
+ if (selectedId !== null && !messages.some((m) => m.id === selectedId)) {
235
+ selectedId = null;
236
+ }
237
+
238
+ const rendered = renderMessagesWithRanges(messages);
239
+ currentRanges = rendered.ranges;
240
+ scrollBox.setContent(rendered.text);
152
241
 
153
242
  if (autoScrollToBottom) {
154
243
  scrollBox.setScrollPerc(100);
@@ -166,10 +255,108 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
166
255
  screen.render();
167
256
  }
168
257
 
258
+ /**
259
+ * Переводит номер отрисованной строки в номер строки исходного содержимого.
260
+ * blessed переносит длинные строки, поэтому напрямую индексы не совпадают.
261
+ * @param {number} renderedLine
262
+ * @returns {number}
263
+ */
264
+ function toContentLine(renderedLine) {
265
+ const rtof = scrollBox._clines?.rtof;
266
+ if (Array.isArray(rtof) && renderedLine >= 0 && renderedLine < rtof.length) {
267
+ return rtof[renderedLine];
268
+ }
269
+ return renderedLine;
270
+ }
271
+
272
+ /**
273
+ * Обратное преобразование: первая и последняя отрисованные строки для строки содержимого.
274
+ * @param {number} contentLine
275
+ * @param {"first"|"last"} edge
276
+ * @returns {number}
277
+ */
278
+ function toRenderedLine(contentLine, edge = "first") {
279
+ const ftor = scrollBox._clines?.ftor;
280
+ const mapped = Array.isArray(ftor) ? ftor[contentLine] : null;
281
+ if (!Array.isArray(mapped) || mapped.length === 0) return contentLine;
282
+ return edge === "first" ? mapped[0] : mapped[mapped.length - 1];
283
+ }
284
+
285
+ /** Подкручивает ленту так, чтобы выделенное сообщение было видно целиком. */
286
+ function scrollMessageIntoView(range) {
287
+ if (!range) return;
288
+ const visible = scrollBox.height - scrollBox.iheight;
289
+ const base = scrollBox.childBase || 0;
290
+ const top = toRenderedLine(range.startLine, "first");
291
+ const bottom = toRenderedLine(range.endLine, "last");
292
+
293
+ if (top < base) {
294
+ scrollBox.scrollTo(top);
295
+ } else if (bottom >= base + visible) {
296
+ scrollBox.scrollTo(Math.max(0, bottom - visible + 1));
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Выделяет сообщение по идентификатору.
302
+ * @param {number|null} id
303
+ * @param {object} [options]
304
+ * @param {boolean} [options.scrollIntoView=false]
305
+ * @param {boolean} [options.notify=false] вызвать onSelectMessage
306
+ */
307
+ function setSelected(id, { scrollIntoView = false, notify = false } = {}) {
308
+ if (selectedId === id && !scrollIntoView) return;
309
+ selectedId = id;
310
+ redraw();
311
+
312
+ const range = currentRanges.find((r) => r.message.id === id);
313
+ if (scrollIntoView && range) {
314
+ scrollMessageIntoView(range);
315
+ screen.render();
316
+ }
317
+ if (notify && range) {
318
+ onSelectMessage?.(range.message);
319
+ }
320
+ }
321
+
322
+ /** @returns {object|null} выделенное сообщение */
323
+ function getSelected() {
324
+ if (selectedId === null) return null;
325
+ return currentMessages.find((m) => m.id === selectedId) || null;
326
+ }
327
+
328
+ /** Сообщение, над которым выполняются действия: выделенное, иначе последнее. */
329
+ function getTargetMessage() {
330
+ return getSelected() || currentMessages[currentMessages.length - 1] || null;
331
+ }
332
+
333
+ /**
334
+ * Двигает выделение на step сообщений и подкручивает ленту к нему.
335
+ * @param {number} step
336
+ */
337
+ function selectByOffset(step) {
338
+ if (currentMessages.length === 0) return;
339
+
340
+ const currentIndex = currentMessages.findIndex((m) => m.id === selectedId);
341
+ let nextIndex;
342
+ if (currentIndex === -1) {
343
+ nextIndex = currentMessages.length - 1;
344
+ } else {
345
+ nextIndex = Math.min(currentMessages.length - 1, Math.max(0, currentIndex + step));
346
+ }
347
+
348
+ setSelected(currentMessages[nextIndex].id, { scrollIntoView: true, notify: true });
349
+
350
+ // Дошли до верха ленты — подтягиваем предыдущую страницу истории
351
+ if (nextIndex === 0 && step < 0) {
352
+ onLoadMoreHistory?.();
353
+ }
354
+ }
355
+
169
356
  // Обработка прокрутки вверх для подгрузки истории
170
357
  function handleScrollUp(step = 10) {
171
358
  scrollBox.scroll(-step);
172
- if (scrollBox.getScroll() <= 0) {
359
+ if ((scrollBox.childBase || 0) <= 0) {
173
360
  onLoadMoreHistory?.();
174
361
  }
175
362
  screen.render();
@@ -182,8 +369,8 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
182
369
 
183
370
  scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
184
371
  scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
185
- scrollBox.key(["up", "k"], () => handleScrollUp(2));
186
- scrollBox.key(["down", "j"], () => handleScrollDown(2));
372
+ scrollBox.key(["up", "k"], () => selectByOffset(-1));
373
+ scrollBox.key(["down", "j"], () => selectByOffset(1));
187
374
  scrollBox.key(["home"], () => {
188
375
  scrollBox.scrollTo(0);
189
376
  onLoadMoreHistory?.();
@@ -194,18 +381,48 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
194
381
  screen.render();
195
382
  });
196
383
 
197
- scrollBox.on("wheelup", () => {
198
- if (scrollBox.getScroll() <= 0) {
199
- onLoadMoreHistory?.();
384
+ scrollBox.on("wheelup", () => handleScrollUp(3));
385
+ scrollBox.on("wheeldown", () => handleScrollDown(3));
386
+ container.on("wheelup", () => handleScrollUp(3));
387
+ container.on("wheeldown", () => handleScrollDown(3));
388
+
389
+ scrollBox.on("click", (data) => {
390
+ const renderedLine = data.y - (scrollBox.atop || 0) - (scrollBox.itop || 0) + (scrollBox.childBase || 0);
391
+ const relX = data.x - (scrollBox.aleft || 0) - (scrollBox.ileft || 0);
392
+ const hit = getMessagePartAtPoint(toContentLine(renderedLine), relX, currentRanges);
393
+
394
+ if (!hit) {
395
+ // Клик по пустому месту ленты — просто передаём ей фокус
396
+ onFocusRequest?.();
397
+ scrollBox.focus();
398
+ screen.render();
399
+ return;
400
+ }
401
+
402
+ if (isRightClick(data)) {
403
+ setSelected(hit.message.id);
404
+ onActionMenu?.(hit.message);
405
+ return;
406
+ }
407
+
408
+ if (hit.part === "image") {
409
+ setSelected(hit.message.id);
410
+ onOpenImage?.(hit.message);
411
+ return;
200
412
  }
413
+
414
+ onFocusRequest?.();
415
+ scrollBox.focus();
416
+ setSelected(hit.message.id, { notify: true });
201
417
  });
202
418
 
203
419
  // Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
204
420
  // висит на Ctrl+A — иначе оно недостижимо.
205
- scrollBox.key(["C-a"], () => {
206
- if (currentMessages.length > 0) {
207
- const lastMsg = currentMessages[currentMessages.length - 1];
208
- onActionMenu?.(lastMsg);
421
+ scrollBox.key(["C-a", "enter", "return"], () => {
422
+ const msg = getTargetMessage();
423
+ if (msg) {
424
+ setSelected(msg.id);
425
+ onActionMenu?.(msg);
209
426
  }
210
427
  });
211
428
 
@@ -213,6 +430,10 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
213
430
  container,
214
431
  scrollBox,
215
432
  setMessages,
433
+ setSelected,
434
+ getSelected,
435
+ getTargetMessage,
436
+ selectByOffset,
216
437
  loadMore: () => onLoadMoreHistory?.(),
217
438
  scrollToBottom: () => {
218
439
  scrollBox.setScrollPerc(100);
@@ -2,13 +2,21 @@ import blessed from "neo-blessed";
2
2
  import { escapeBlessed } from "../../telegram/formatter.js";
3
3
  import { fg } from "../theme.js";
4
4
 
5
+ import { getHeaderActionAt, isRightClick } from "../../utils/mouse.js";
6
+
5
7
  /**
6
8
  * Верхняя шапка приложения с информацией о пользователе, активном чате и статусе соединения.
7
9
  * @param {blessed.Widgets.Screen} screen
8
10
  * @param {object} theme
11
+ * @param {object} [callbacks]
12
+ * @param {() => void} [callbacks.onHelp]
13
+ * @param {() => void} [callbacks.onChatInfo]
14
+ * @param {() => void} [callbacks.onStatusClick]
9
15
  * @returns {blessed.Widgets.BoxElement & { updateInfo: (data: object) => void }}
10
16
  */
11
- export function createHeader(screen, theme) {
17
+ export function createHeader(screen, theme, { onHelp, onChatInfo, onStatusClick } = {}) {
18
+ let currentActiveChat = null;
19
+
12
20
  const headerBox = blessed.box({
13
21
  parent: screen,
14
22
  top: 0,
@@ -17,6 +25,7 @@ export function createHeader(screen, theme) {
17
25
  // 4 = рамка (2) + две строки контента (профиль + активный чат)
18
26
  height: 4,
19
27
  tags: true,
28
+ mouse: true,
20
29
  border: {
21
30
  type: "line",
22
31
  },
@@ -29,6 +38,20 @@ export function createHeader(screen, theme) {
29
38
  },
30
39
  });
31
40
 
41
+ headerBox.on("click", (data) => {
42
+ if (isRightClick(data)) return;
43
+ const relX = data.x - (headerBox.aleft || 0);
44
+ const relY = data.y - (headerBox.atop || 0);
45
+ const action = getHeaderActionAt(relX, relY, { hasActiveChat: Boolean(currentActiveChat) });
46
+ if (action === "help") {
47
+ onHelp?.();
48
+ } else if (action === "info") {
49
+ onChatInfo?.();
50
+ } else if (action === "status") {
51
+ onStatusClick?.();
52
+ }
53
+ });
54
+
32
55
  /**
33
56
  * Обновляет содержимое шапки.
34
57
  * @param {object} data
@@ -38,6 +61,7 @@ export function createHeader(screen, theme) {
38
61
  * @param {string|null} [data.typingUser]
39
62
  */
40
63
  headerBox.updateInfo = function ({ me, status = "connected", activeChat, typingUser }) {
64
+ currentActiveChat = activeChat;
41
65
  let statusBadge = fg(theme.status.online, "● В сети");
42
66
  if (status === "connecting") {
43
67
  statusBadge = fg(theme.status.connecting, "◌ Подключение...");
@@ -2,6 +2,8 @@ import blessed from "neo-blessed";
2
2
  import { escapeBlessed } from "../../telegram/formatter.js";
3
3
  import { fg, badge } from "../theme.js";
4
4
 
5
+ import { getInputContextActionAt, isRightClick } from "../../utils/mouse.js";
6
+
5
7
  /**
6
8
  * Создаёт компонент поля ввода сообщения (нижняя панель).
7
9
  * @param {blessed.Widgets.Screen} screen
@@ -10,8 +12,16 @@ import { fg, badge } from "../theme.js";
10
12
  * @param {(text: string, context: { mode: string|null, target: object|null }) => void} callbacks.onSubmit
11
13
  * @param {() => void} [callbacks.onCancelContext]
12
14
  * @param {(command: string, args: string[]) => void} [callbacks.onSlashCommand]
15
+ * @param {() => void} [callbacks.onReplyLast]
16
+ * @param {() => void} [callbacks.onEditLast]
13
17
  */
14
- export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSlashCommand } = {}) {
18
+ export function createInputBox(screen, theme, {
19
+ onSubmit,
20
+ onCancelContext,
21
+ onSlashCommand,
22
+ onReplyLast,
23
+ onEditLast,
24
+ } = {}) {
15
25
  // Высота 5 = рамка (2) + контекстная плашка (1) + две строки ввода (2).
16
26
  // При autoPadding у blessed рамка съедает по строке сверху и снизу, поэтому
17
27
  // меньшая высота оставляет textarea нулевую высоту и вводимый текст не виден.
@@ -21,6 +31,7 @@ export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSla
21
31
  left: "35%",
22
32
  right: 0,
23
33
  height: 5,
34
+ mouse: true,
24
35
  border: {
25
36
  type: "line",
26
37
  },
@@ -41,6 +52,8 @@ export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSla
41
52
  right: 0,
42
53
  height: 1,
43
54
  tags: true,
55
+ mouse: true,
56
+ clickable: true,
44
57
  style: {
45
58
  bg: theme.input.contextBg,
46
59
  fg: theme.input.contextFg,
@@ -110,6 +123,33 @@ export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSla
110
123
  screen.render();
111
124
  }
112
125
 
126
+ contextBar.on("click", (data) => {
127
+ if (isRightClick(data)) return;
128
+ const relX = data.x - (contextBar.aleft || 0);
129
+ const action = getInputContextActionAt(relX, currentMode);
130
+ if (action === "cancel") {
131
+ if (currentMode) {
132
+ currentMode = null;
133
+ currentTarget = null;
134
+ renderContext();
135
+ onCancelContext?.();
136
+ }
137
+ } else if (action === "reply") {
138
+ onReplyLast?.();
139
+ } else if (action === "edit") {
140
+ onEditLast?.();
141
+ } else if (action === "commands") {
142
+ textarea.setValue("/");
143
+ textarea.focus();
144
+ screen.render();
145
+ }
146
+ });
147
+
148
+ textarea.on("click", () => {
149
+ textarea.focus();
150
+ screen.render();
151
+ });
152
+
113
153
  textarea.key(["enter"], () => {
114
154
  const value = textarea.getValue().trim();
115
155
  if (!value) {
@@ -2,6 +2,9 @@ import blessed from "neo-blessed";
2
2
  import { escapeBlessed } from "../../../telegram/formatter.js";
3
3
  import { fg } from "../../theme.js";
4
4
 
5
+ import { isRightClick } from "../../../utils/mouse.js";
6
+ import { bindOutsideClickClose } from "../../modalMouse.js";
7
+
5
8
  /**
6
9
  * Создаёт модальное окно контекстных действий над сообщением.
7
10
  * @param {blessed.Widgets.Screen} screen
@@ -19,6 +22,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
19
22
  height: "55%",
20
23
  hidden: true,
21
24
  tags: true,
25
+ mouse: true,
22
26
  border: {
23
27
  type: "line",
24
28
  },
@@ -66,6 +70,21 @@ export function createActionModal(screen, theme, { onAction } = {}) {
66
70
  },
67
71
  });
68
72
 
73
+ const baseCreateItem = list.createItem.bind(list);
74
+ list.createItem = (content) => {
75
+ const item = baseCreateItem(content);
76
+ item.on("click", (data) => {
77
+ if (isRightClick(data)) return;
78
+ const index = list.getItemIndex(item);
79
+ const action = currentActions[index];
80
+ if (action && currentMsg) {
81
+ hide();
82
+ onAction?.(action.id, currentMsg);
83
+ }
84
+ });
85
+ return item;
86
+ };
87
+
69
88
  let currentMsg = null;
70
89
  let currentActions = [];
71
90
  let previousFocus = null;
@@ -111,6 +130,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
111
130
 
112
131
  list.setItems(currentActions.map((a) => a.label));
113
132
  previousFocus = screen.focused;
133
+ armOutsideClose();
114
134
  modal.show();
115
135
  modal.setFront();
116
136
  list.focus();
@@ -128,6 +148,9 @@ export function createActionModal(screen, theme, { onAction } = {}) {
128
148
  // Фокус получает список — на нём и живут клавиши закрытия.
129
149
  list.key(["escape", "q"], hide);
130
150
 
151
+ // Закрытие при клике мышью мимо модального окна
152
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
153
+
131
154
  return {
132
155
  modal,
133
156
  show,
@@ -3,6 +3,8 @@ import { escapeBlessed } from "../../../telegram/formatter.js";
3
3
  import { idToString } from "../../../telegram/entities.js";
4
4
  import { fg } from "../../theme.js";
5
5
 
6
+ import { bindOutsideClickClose } from "../../modalMouse.js";
7
+
6
8
  /**
7
9
  * Создаёт модальное окно информации о текущем чате.
8
10
  * @param {blessed.Widgets.Screen} screen
@@ -18,6 +20,7 @@ export function createChatInfoModal(screen, theme) {
18
20
  height: "60%",
19
21
  hidden: true,
20
22
  tags: true,
23
+ mouse: true,
21
24
  border: {
22
25
  type: "line",
23
26
  },
@@ -38,6 +41,7 @@ export function createChatInfoModal(screen, theme) {
38
41
  right: 2,
39
42
  bottom: 3,
40
43
  tags: true,
44
+ mouse: true,
41
45
  scrollable: true,
42
46
  style: {
43
47
  bg: theme.modal.bg,
@@ -107,6 +111,7 @@ export function createChatInfoModal(screen, theme) {
107
111
 
108
112
  infoText.setContent(body);
109
113
  previousFocus = screen.focused;
114
+ armOutsideClose();
110
115
  modal.show();
111
116
  modal.setFront();
112
117
  closeBtn.focus();
@@ -114,9 +119,13 @@ export function createChatInfoModal(screen, theme) {
114
119
  }
115
120
 
116
121
  closeBtn.on("press", hide);
122
+ closeBtn.on("click", hide);
117
123
  // Клавиши вешаем на кнопку: blessed отдаёт события только сфокусированному элементу.
118
124
  closeBtn.key(["escape", "q"], hide);
119
125
 
126
+ // Закрытие при клике мышью мимо модального окна
127
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
128
+
120
129
  return {
121
130
  modal,
122
131
  show,