@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
|
@@ -2,7 +2,7 @@ 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 } from "../../utils/mouse.js";
|
|
5
|
+
import { getHeaderActionAt, isRightClick } from "../../utils/mouse.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Верхняя шапка приложения с информацией о пользователе, активном чате и статусе соединения.
|
|
@@ -39,6 +39,7 @@ export function createHeader(screen, theme, { onHelp, onChatInfo, onStatusClick
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
headerBox.on("click", (data) => {
|
|
42
|
+
if (isRightClick(data)) return;
|
|
42
43
|
const relX = data.x - (headerBox.aleft || 0);
|
|
43
44
|
const relY = data.y - (headerBox.atop || 0);
|
|
44
45
|
const action = getHeaderActionAt(relX, relY, { hasActiveChat: Boolean(currentActiveChat) });
|
|
@@ -2,7 +2,7 @@ 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 } from "../../utils/mouse.js";
|
|
5
|
+
import { getInputContextActionAt, isRightClick } from "../../utils/mouse.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Создаёт компонент поля ввода сообщения (нижняя панель).
|
|
@@ -124,6 +124,7 @@ export function createInputBox(screen, theme, {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
contextBar.on("click", (data) => {
|
|
127
|
+
if (isRightClick(data)) return;
|
|
127
128
|
const relX = data.x - (contextBar.aleft || 0);
|
|
128
129
|
const action = getInputContextActionAt(relX, currentMode);
|
|
129
130
|
if (action === "cancel") {
|
|
@@ -145,8 +146,10 @@ export function createInputBox(screen, theme, {
|
|
|
145
146
|
});
|
|
146
147
|
|
|
147
148
|
textarea.on("click", () => {
|
|
148
|
-
textarea
|
|
149
|
-
|
|
149
|
+
if (screen.focused !== textarea) {
|
|
150
|
+
textarea.focus();
|
|
151
|
+
screen.render();
|
|
152
|
+
}
|
|
150
153
|
});
|
|
151
154
|
|
|
152
155
|
textarea.key(["enter"], () => {
|
|
@@ -229,7 +232,9 @@ export function createInputBox(screen, theme, {
|
|
|
229
232
|
textarea.setValue(target.text);
|
|
230
233
|
}
|
|
231
234
|
renderContext();
|
|
232
|
-
textarea
|
|
235
|
+
if (screen.focused !== textarea) {
|
|
236
|
+
textarea.focus();
|
|
237
|
+
}
|
|
233
238
|
},
|
|
234
239
|
/**
|
|
235
240
|
* Текущий режим ввода — нужен, чтобы отправить файл ответом.
|
|
@@ -245,7 +250,11 @@ export function createInputBox(screen, theme, {
|
|
|
245
250
|
textarea.setValue("");
|
|
246
251
|
screen.render();
|
|
247
252
|
},
|
|
248
|
-
focus: () =>
|
|
253
|
+
focus: () => {
|
|
254
|
+
if (screen.focused !== textarea) {
|
|
255
|
+
textarea.focus();
|
|
256
|
+
}
|
|
257
|
+
},
|
|
249
258
|
/**
|
|
250
259
|
* Завершает режим ввода, отдавая фокус предыдущей панели.
|
|
251
260
|
* Нужно вызывать перед открытием модального окна: иначе textarea по blur
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
2
|
import { escapeBlessed } from "../../../telegram/formatter.js";
|
|
3
3
|
import { fg } from "../../theme.js";
|
|
4
|
+
import { isMessageVideo } from "../../../utils/video.js";
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
+
import { isRightClick } from "../../../utils/mouse.js";
|
|
7
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Создаёт модальное окно контекстных действий над сообщением.
|
|
@@ -72,7 +74,8 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
72
74
|
const baseCreateItem = list.createItem.bind(list);
|
|
73
75
|
list.createItem = (content) => {
|
|
74
76
|
const item = baseCreateItem(content);
|
|
75
|
-
item.on("click", () => {
|
|
77
|
+
item.on("click", (data) => {
|
|
78
|
+
if (isRightClick(data)) return;
|
|
76
79
|
const index = list.getItemIndex(item);
|
|
77
80
|
const action = currentActions[index];
|
|
78
81
|
if (action && currentMsg) {
|
|
@@ -109,6 +112,10 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
109
112
|
{ id: "reply", label: "↩️ Ответить (Reply)" },
|
|
110
113
|
];
|
|
111
114
|
|
|
115
|
+
if (isMessageVideo(msg)) {
|
|
116
|
+
currentActions.push({ id: "play_video", label: "▶️ Воспроизвести видео" });
|
|
117
|
+
}
|
|
118
|
+
|
|
112
119
|
if (msg.out) {
|
|
113
120
|
currentActions.push({ id: "edit", label: "✏️ Редактировать текст" });
|
|
114
121
|
}
|
|
@@ -128,6 +135,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
128
135
|
|
|
129
136
|
list.setItems(currentActions.map((a) => a.label));
|
|
130
137
|
previousFocus = screen.focused;
|
|
138
|
+
armOutsideClose();
|
|
131
139
|
modal.show();
|
|
132
140
|
modal.setFront();
|
|
133
141
|
list.focus();
|
|
@@ -146,18 +154,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
146
154
|
list.key(["escape", "q"], hide);
|
|
147
155
|
|
|
148
156
|
// Закрытие при клике мышью мимо модального окна
|
|
149
|
-
screen
|
|
150
|
-
if (!modal.visible) return;
|
|
151
|
-
const inside = isInsideBox(data.x, data.y, {
|
|
152
|
-
left: modal.aleft,
|
|
153
|
-
top: modal.atop,
|
|
154
|
-
width: modal.width,
|
|
155
|
-
height: modal.height,
|
|
156
|
-
});
|
|
157
|
-
if (!inside) {
|
|
158
|
-
hide();
|
|
159
|
-
}
|
|
160
|
-
});
|
|
157
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
161
158
|
|
|
162
159
|
return {
|
|
163
160
|
modal,
|
|
@@ -3,7 +3,7 @@ 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 {
|
|
6
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Создаёт модальное окно информации о текущем чате.
|
|
@@ -111,6 +111,7 @@ export function createChatInfoModal(screen, theme) {
|
|
|
111
111
|
|
|
112
112
|
infoText.setContent(body);
|
|
113
113
|
previousFocus = screen.focused;
|
|
114
|
+
armOutsideClose();
|
|
114
115
|
modal.show();
|
|
115
116
|
modal.setFront();
|
|
116
117
|
closeBtn.focus();
|
|
@@ -123,18 +124,7 @@ export function createChatInfoModal(screen, theme) {
|
|
|
123
124
|
closeBtn.key(["escape", "q"], hide);
|
|
124
125
|
|
|
125
126
|
// Закрытие при клике мышью мимо модального окна
|
|
126
|
-
screen
|
|
127
|
-
if (!modal.visible) return;
|
|
128
|
-
const inside = isInsideBox(data.x, data.y, {
|
|
129
|
-
left: modal.aleft,
|
|
130
|
-
top: modal.atop,
|
|
131
|
-
width: modal.width,
|
|
132
|
-
height: modal.height,
|
|
133
|
-
});
|
|
134
|
-
if (!inside) {
|
|
135
|
-
hide();
|
|
136
|
-
}
|
|
137
|
-
});
|
|
127
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
138
128
|
|
|
139
129
|
return {
|
|
140
130
|
modal,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
2
|
import { fg } from "../../theme.js";
|
|
3
|
-
import {
|
|
3
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Создаёт модальное окно подтверждения действия (Да / Нет).
|
|
@@ -130,18 +130,7 @@ export function createConfirmModal(screen, theme) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
// Закрытие при клике мышью мимо модального окна
|
|
133
|
-
screen
|
|
134
|
-
if (!modal.visible) return;
|
|
135
|
-
const inside = isInsideBox(data.x, data.y, {
|
|
136
|
-
left: modal.aleft,
|
|
137
|
-
top: modal.atop,
|
|
138
|
-
width: modal.width,
|
|
139
|
-
height: modal.height,
|
|
140
|
-
});
|
|
141
|
-
if (!inside) {
|
|
142
|
-
hide();
|
|
143
|
-
}
|
|
144
|
-
});
|
|
133
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
145
134
|
|
|
146
135
|
return {
|
|
147
136
|
modal,
|
|
@@ -149,6 +138,7 @@ export function createConfirmModal(screen, theme) {
|
|
|
149
138
|
currentCallback = onConfirm;
|
|
150
139
|
previousFocus = screen.focused;
|
|
151
140
|
msgBox.setContent(`{bold}${text}{/bold}`);
|
|
141
|
+
armOutsideClose();
|
|
152
142
|
modal.show();
|
|
153
143
|
modal.setFront();
|
|
154
144
|
noBtn.focus();
|
|
@@ -4,7 +4,7 @@ import { inspectLocalFile } from "../../../utils/storage.js";
|
|
|
4
4
|
import { formatFileSize } from "../../../utils/time.js";
|
|
5
5
|
import { createFilePickerModal } from "./filePickerModal.js";
|
|
6
6
|
import { fg } from "../../theme.js";
|
|
7
|
-
import {
|
|
7
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
8
8
|
|
|
9
9
|
/** Разделитель нескольких путей в поле ввода. */
|
|
10
10
|
const PATH_SEPARATOR = "|";
|
|
@@ -253,6 +253,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
253
253
|
asDocumentCheck.uncheck();
|
|
254
254
|
statusLine.setContent("");
|
|
255
255
|
previousFocus = screen.focused;
|
|
256
|
+
armOutsideClose();
|
|
256
257
|
modal.show();
|
|
257
258
|
modal.setFront();
|
|
258
259
|
pathInput.focus();
|
|
@@ -360,18 +361,10 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
360
361
|
screen.render();
|
|
361
362
|
});
|
|
362
363
|
|
|
363
|
-
// Закрытие при клике мышью мимо модального
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
left: modal.aleft,
|
|
368
|
-
top: modal.atop,
|
|
369
|
-
width: modal.width,
|
|
370
|
-
height: modal.height,
|
|
371
|
-
});
|
|
372
|
-
if (!inside) {
|
|
373
|
-
hide();
|
|
374
|
-
}
|
|
364
|
+
// Закрытие при клике мышью мимо модального окна.
|
|
365
|
+
// Пока открыт файловый пикер, клики мимо окна отправки его не закрывают.
|
|
366
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, () => {
|
|
367
|
+
if (!picker.modal.visible) hide();
|
|
375
368
|
});
|
|
376
369
|
|
|
377
370
|
return {
|
|
@@ -4,7 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { formatFileSize } from "../../../utils/time.js";
|
|
6
6
|
import { fg } from "../../theme.js";
|
|
7
|
-
import {
|
|
7
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Модальное окно выбора локального файла (обёртка над blessed.filemanager).
|
|
@@ -194,18 +194,7 @@ export function createFilePickerModal(screen, theme) {
|
|
|
194
194
|
footer.on("click", hide);
|
|
195
195
|
|
|
196
196
|
// Закрытие при клике мышью мимо модального окна
|
|
197
|
-
screen
|
|
198
|
-
if (!modal.visible) return;
|
|
199
|
-
const inside = isInsideBox(data.x, data.y, {
|
|
200
|
-
left: modal.aleft,
|
|
201
|
-
top: modal.atop,
|
|
202
|
-
width: modal.width,
|
|
203
|
-
height: modal.height,
|
|
204
|
-
});
|
|
205
|
-
if (!inside) {
|
|
206
|
-
hide();
|
|
207
|
-
}
|
|
208
|
-
});
|
|
197
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
209
198
|
|
|
210
199
|
return {
|
|
211
200
|
modal,
|
|
@@ -219,6 +208,7 @@ export function createFilePickerModal(screen, theme) {
|
|
|
219
208
|
onPickCallback = onPick;
|
|
220
209
|
onCloseCallback = onClose;
|
|
221
210
|
const cwd = startDir && fs.existsSync(startDir) ? startDir : os.homedir();
|
|
211
|
+
armOutsideClose();
|
|
222
212
|
modal.show();
|
|
223
213
|
modal.setFront();
|
|
224
214
|
manager.refresh(cwd, () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
|
-
import {
|
|
2
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Создаёт модальное окно справки по всем горячим клавишам и возможностям.
|
|
@@ -44,16 +44,22 @@ export function createHelpModal(screen, theme) {
|
|
|
44
44
|
|
|
45
45
|
{bold}Навигация и фокус:{/bold}
|
|
46
46
|
${K_CYAN}[Tab]${K_END} / ${K_CYAN}[Shift+Tab]${K_END} Фокус по кругу: Список чатов → Лента сообщений → Ввод
|
|
47
|
-
${K_CYAN}[↑] / [↓]${K_END}
|
|
48
|
-
${K_CYAN}[Enter]${K_END} Открыть
|
|
47
|
+
${K_CYAN}[↑] / [↓]${K_END} Список чатов: перемещение · Лента: выделение сообщения
|
|
48
|
+
${K_CYAN}[Enter]${K_END} Открыть чат / Меню действий над выделенным сообщением
|
|
49
49
|
${K_CYAN}[PageUp] / [Ctrl+U]${K_END} Прокрутка сообщений вверх / Подгрузка старой истории
|
|
50
50
|
${K_CYAN}[PageDown] / [Ctrl+D]${K_END}Прокрутка сообщений вниз
|
|
51
|
+
${K_CYAN}[Home] / [End]${K_END} Начало ленты (подгрузка истории) / Последнее сообщение
|
|
51
52
|
|
|
52
53
|
{bold}Управление мышью:{/bold}
|
|
53
54
|
${K_CYAN}Клик по диалогу${K_END} Мгновенно открыть чат
|
|
54
55
|
${K_CYAN}Колесо мыши${K_END} Прокрутка списка чатов и сообщений (вверх — подгрузка истории)
|
|
55
|
-
${K_CYAN}
|
|
56
|
+
${K_CYAN}Левый клик по сообщению${K_END} Выделить сообщение (помечается полосой ▌ слева)
|
|
57
|
+
${K_CYAN}Правый клик по сообщению${K_END} Меню действий над сообщением
|
|
58
|
+
${K_CYAN}Клик по превью фото/видео${K_END}Открыть изображение или воспроизвести видео
|
|
59
|
+
${K_CYAN} [Space] / [r]${K_END} — в видеоплеере: пауза / перезапуск
|
|
56
60
|
${K_CYAN}Клик по вкладкам/кнопкам${K_END} Переключение фильтров и вызов действий
|
|
61
|
+
${K_GRAY}macOS Terminal.app перехватывает правый клик — там пользуйтесь [Enter] или [Ctrl+A]${K_END}
|
|
62
|
+
${K_CYAN}[F12]${K_END} Отдать мышь терминалу, чтобы выделить и скопировать текст
|
|
57
63
|
|
|
58
64
|
{bold}Вкладки фильтрации диалогов (нажмите цифру в списке чатов):{/bold}
|
|
59
65
|
${K_YELLOW}[1]${K_END} Все чаты ${K_YELLOW}[2]${K_END} Личные (ЛС) ${K_YELLOW}[3]${K_END} Группы
|
|
@@ -63,8 +69,8 @@ export function createHelpModal(screen, theme) {
|
|
|
63
69
|
{bold}Работа с сообщениями:{/bold}
|
|
64
70
|
${K_GREEN}[Enter]${K_END} Отправить набранный текст
|
|
65
71
|
${K_GREEN}[Ctrl+J]${K_END} Перенос строки без отправки
|
|
66
|
-
${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на последнее
|
|
67
|
-
${K_GREEN}[Ctrl+E]${K_END} Редактировать
|
|
72
|
+
${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на выделенное, иначе — на последнее
|
|
73
|
+
${K_GREEN}[Ctrl+E]${K_END} Редактировать выделенное своё, иначе — последнее своё
|
|
68
74
|
${K_GREEN}[Ctrl+A]${K_END} Контекстное меню действий (Реакции, Удаление, Скачивание)
|
|
69
75
|
${K_GREEN}[Ctrl+O]${K_END} Отправить файл / картинку / документ
|
|
70
76
|
${K_GREEN} [Ctrl+F]${K_END} — в окне отправки: обзор файлов
|
|
@@ -122,6 +128,7 @@ export function createHelpModal(screen, theme) {
|
|
|
122
128
|
|
|
123
129
|
function show() {
|
|
124
130
|
previousFocus = screen.focused;
|
|
131
|
+
armOutsideClose();
|
|
125
132
|
modal.show();
|
|
126
133
|
modal.setFront();
|
|
127
134
|
closeBtn.focus();
|
|
@@ -134,18 +141,7 @@ export function createHelpModal(screen, theme) {
|
|
|
134
141
|
closeBtn.key(["escape", "q", "f1"], hide);
|
|
135
142
|
|
|
136
143
|
// Закрытие при клике мышью мимо модального окна
|
|
137
|
-
screen
|
|
138
|
-
if (!modal.visible) return;
|
|
139
|
-
const inside = isInsideBox(data.x, data.y, {
|
|
140
|
-
left: modal.aleft,
|
|
141
|
-
top: modal.atop,
|
|
142
|
-
width: modal.width,
|
|
143
|
-
height: modal.height,
|
|
144
|
-
});
|
|
145
|
-
if (!inside) {
|
|
146
|
-
hide();
|
|
147
|
-
}
|
|
148
|
-
});
|
|
144
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
149
145
|
|
|
150
146
|
return {
|
|
151
147
|
modal,
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import blessed from "neo-blessed";
|
|
2
|
+
import { fg } from "../../theme.js";
|
|
3
|
+
import { renderImageBuffer } from "../../../utils/image.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Создаёт полноэкранный просмотрщик изображений.
|
|
7
|
+
*
|
|
8
|
+
* Сначала показывается встроенная в сообщение миниатюра (мгновенно, без сети),
|
|
9
|
+
* затем она заменяется полноразмерной версией, как только та скачается.
|
|
10
|
+
*
|
|
11
|
+
* @param {blessed.Widgets.Screen} screen
|
|
12
|
+
* @param {object} theme
|
|
13
|
+
* @param {object} callbacks
|
|
14
|
+
* @param {(msg: object) => Promise<{ buffer: Buffer, mimeType: string }>} [callbacks.onLoadFullImage]
|
|
15
|
+
* @param {(msg: object, size: { maxWidth: number, maxHeight: number }) => string} [callbacks.onRenderPlaceholder]
|
|
16
|
+
* @returns {{ modal: object, show: (msg: object) => void, hide: () => void, isVisible: () => boolean }}
|
|
17
|
+
*/
|
|
18
|
+
export function createImageViewerModal(screen, theme, { onLoadFullImage, onRenderPlaceholder } = {}) {
|
|
19
|
+
const modal = blessed.box({
|
|
20
|
+
parent: screen,
|
|
21
|
+
top: 0,
|
|
22
|
+
left: 0,
|
|
23
|
+
width: "100%",
|
|
24
|
+
height: "100%",
|
|
25
|
+
hidden: true,
|
|
26
|
+
mouse: true,
|
|
27
|
+
style: {
|
|
28
|
+
bg: theme.bg,
|
|
29
|
+
fg: theme.fg,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const canvas = blessed.box({
|
|
34
|
+
parent: modal,
|
|
35
|
+
top: 0,
|
|
36
|
+
left: 0,
|
|
37
|
+
right: 0,
|
|
38
|
+
bottom: 1,
|
|
39
|
+
tags: true,
|
|
40
|
+
align: "center",
|
|
41
|
+
valign: "middle",
|
|
42
|
+
style: {
|
|
43
|
+
bg: theme.bg,
|
|
44
|
+
fg: theme.fg,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const footer = blessed.box({
|
|
49
|
+
parent: modal,
|
|
50
|
+
bottom: 0,
|
|
51
|
+
left: 0,
|
|
52
|
+
right: 0,
|
|
53
|
+
height: 1,
|
|
54
|
+
tags: true,
|
|
55
|
+
style: {
|
|
56
|
+
bg: theme.status.bg,
|
|
57
|
+
fg: theme.status.fg,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let currentMsg = null;
|
|
62
|
+
let fullImage = null;
|
|
63
|
+
let statusText = "";
|
|
64
|
+
let previousFocus = null;
|
|
65
|
+
/** Счётчик открытий: результат опоздавшей загрузки не должен затирать новую картинку. */
|
|
66
|
+
let token = 0;
|
|
67
|
+
|
|
68
|
+
/** Размер холста в ячейках терминала. */
|
|
69
|
+
function viewportSize() {
|
|
70
|
+
return {
|
|
71
|
+
maxWidth: Math.max(8, (screen.width || 80) - 2),
|
|
72
|
+
maxHeight: Math.max(4, (screen.height || 24) - 2),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderFooter() {
|
|
77
|
+
const id = currentMsg ? `#${currentMsg.id}` : "";
|
|
78
|
+
const status = statusText ? `${fg(theme.warning, statusText)} ${fg(theme.dim, "│")} ` : "";
|
|
79
|
+
footer.setContent(
|
|
80
|
+
` ${fg(theme.accent, id)} ${fg(theme.dim, "│")} ${status}${fg(theme.muted, "[Esc] закрыть · клик — закрыть")}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Перерисовывает картинку под текущий размер терминала. */
|
|
85
|
+
function repaint() {
|
|
86
|
+
if (!currentMsg) return;
|
|
87
|
+
const size = viewportSize();
|
|
88
|
+
|
|
89
|
+
let content = "";
|
|
90
|
+
if (fullImage?.buffer) {
|
|
91
|
+
content = renderImageBuffer(fullImage.buffer, {
|
|
92
|
+
mimeType: fullImage.mimeType,
|
|
93
|
+
maxWidth: size.maxWidth,
|
|
94
|
+
maxHeight: size.maxHeight,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (!content) {
|
|
98
|
+
content = onRenderPlaceholder?.(currentMsg, size) || "";
|
|
99
|
+
}
|
|
100
|
+
if (!content) {
|
|
101
|
+
content = fg(theme.muted, "Изображение недоступно");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
canvas.setContent(content);
|
|
105
|
+
renderFooter();
|
|
106
|
+
screen.render();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function hide() {
|
|
110
|
+
// Инвалидируем незавершённую загрузку: её результат уже не нужен
|
|
111
|
+
token++;
|
|
112
|
+
modal.hide();
|
|
113
|
+
currentMsg = null;
|
|
114
|
+
fullImage = null;
|
|
115
|
+
statusText = "";
|
|
116
|
+
canvas.setContent("");
|
|
117
|
+
if (previousFocus) {
|
|
118
|
+
previousFocus.focus();
|
|
119
|
+
previousFocus = null;
|
|
120
|
+
}
|
|
121
|
+
screen.render();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Открывает изображение сообщения на весь экран.
|
|
126
|
+
* @param {object} msg нормализованное сообщение
|
|
127
|
+
*/
|
|
128
|
+
function show(msg) {
|
|
129
|
+
if (!msg) return;
|
|
130
|
+
|
|
131
|
+
const myToken = ++token;
|
|
132
|
+
currentMsg = msg;
|
|
133
|
+
fullImage = null;
|
|
134
|
+
statusText = "Загрузка полного изображения...";
|
|
135
|
+
previousFocus = screen.focused;
|
|
136
|
+
|
|
137
|
+
repaint();
|
|
138
|
+
modal.show();
|
|
139
|
+
modal.setFront();
|
|
140
|
+
modal.focus();
|
|
141
|
+
screen.render();
|
|
142
|
+
|
|
143
|
+
Promise.resolve()
|
|
144
|
+
.then(() => onLoadFullImage?.(msg))
|
|
145
|
+
.then((result) => {
|
|
146
|
+
if (myToken !== token || !result?.buffer) return;
|
|
147
|
+
fullImage = result;
|
|
148
|
+
statusText = "";
|
|
149
|
+
repaint();
|
|
150
|
+
})
|
|
151
|
+
.catch((err) => {
|
|
152
|
+
if (myToken !== token) return;
|
|
153
|
+
statusText = `Не удалось загрузить: ${err.message}`;
|
|
154
|
+
renderFooter();
|
|
155
|
+
screen.render();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
modal.key(["escape", "q", "enter", "return", "space"], hide);
|
|
160
|
+
modal.on("click", hide);
|
|
161
|
+
|
|
162
|
+
screen.on("resize", () => {
|
|
163
|
+
if (modal.visible) repaint();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
modal,
|
|
168
|
+
show,
|
|
169
|
+
hide,
|
|
170
|
+
isVisible: () => modal.visible,
|
|
171
|
+
};
|
|
172
|
+
}
|