@emaxe/tuigram 1.1.0 → 1.3.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 +7 -0
- package/CHANGELOG.md +27 -1
- package/CHANGELOG.ru.md +27 -1
- package/README.md +43 -0
- package/README.ru.md +43 -0
- package/package.json +3 -1
- package/src/config.js +3 -0
- package/src/state.js +4 -4
- package/src/telegram/messages.js +58 -0
- package/src/ui/app.js +169 -5
- package/src/ui/components/chatList.js +62 -0
- package/src/ui/components/chatView.js +116 -14
- package/src/ui/components/header.js +24 -1
- package/src/ui/components/inputBox.js +40 -1
- package/src/ui/components/modals/actionModal.js +31 -0
- package/src/ui/components/modals/chatInfoModal.js +19 -0
- package/src/ui/components/modals/confirmModal.js +18 -0
- package/src/ui/components/modals/fileModal.js +33 -1
- package/src/ui/components/modals/filePickerModal.js +19 -0
- package/src/ui/components/modals/helpModal.js +24 -0
- package/src/ui/components/statusBar.js +53 -1
- package/src/ui/screen.js +52 -3
- package/src/utils/image.js +343 -0
- package/src/utils/mouse.js +236 -0
|
@@ -4,6 +4,8 @@ import { escapeBlessed } from "../../telegram/formatter.js";
|
|
|
4
4
|
import { fg, badge } from "../theme.js";
|
|
5
5
|
import unicode from "neo-blessed/lib/unicode.js";
|
|
6
6
|
|
|
7
|
+
import { getTabByCoordinate } from "../../utils/mouse.js";
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Создаёт компонент списка диалогов (левая панель).
|
|
9
11
|
* @param {blessed.Widgets.Screen} screen
|
|
@@ -20,6 +22,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
20
22
|
left: 0,
|
|
21
23
|
width: "35%",
|
|
22
24
|
bottom: 1,
|
|
25
|
+
mouse: true,
|
|
23
26
|
border: {
|
|
24
27
|
type: "line",
|
|
25
28
|
},
|
|
@@ -40,6 +43,8 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
40
43
|
right: 0,
|
|
41
44
|
height: 1,
|
|
42
45
|
tags: true,
|
|
46
|
+
mouse: true,
|
|
47
|
+
clickable: true,
|
|
43
48
|
style: {
|
|
44
49
|
bg: theme.tabs.bg,
|
|
45
50
|
fg: theme.tabs.fg,
|
|
@@ -64,6 +69,17 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
64
69
|
tabsBox.setContent(rendered);
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
tabsBox.on("click", (data) => {
|
|
73
|
+
const relX = data.x - (tabsBox.aleft || 0);
|
|
74
|
+
const tabKey = getTabByCoordinate(relX, TAB_KEYS, TAB_NAMES);
|
|
75
|
+
if (tabKey) {
|
|
76
|
+
currentTab = tabKey;
|
|
77
|
+
renderTabs();
|
|
78
|
+
onTabChange?.(currentTab);
|
|
79
|
+
screen.render();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
67
83
|
// 2. Строка поиска / фильтра
|
|
68
84
|
const searchBox = blessed.textbox({
|
|
69
85
|
parent: container,
|
|
@@ -72,6 +88,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
72
88
|
right: 0,
|
|
73
89
|
height: 1,
|
|
74
90
|
inputOnFocus: true,
|
|
91
|
+
mouse: true,
|
|
75
92
|
style: {
|
|
76
93
|
bg: theme.search.bg,
|
|
77
94
|
fg: theme.search.fg,
|
|
@@ -80,6 +97,14 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
80
97
|
const SEARCH_PLACEHOLDER = "[/] Поиск чатов...";
|
|
81
98
|
searchBox.setValue(SEARCH_PLACEHOLDER);
|
|
82
99
|
|
|
100
|
+
searchBox.on("click", () => {
|
|
101
|
+
if (searchBox.getValue() === SEARCH_PLACEHOLDER) {
|
|
102
|
+
searchBox.setValue("");
|
|
103
|
+
}
|
|
104
|
+
searchBox.focus();
|
|
105
|
+
screen.render();
|
|
106
|
+
});
|
|
107
|
+
|
|
83
108
|
// 3. Список диалогов
|
|
84
109
|
const list = blessed.list({
|
|
85
110
|
parent: container,
|
|
@@ -118,13 +143,31 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
118
143
|
// не влезающий хвост на вторую строку, которой в элементе высотой 1 просто
|
|
119
144
|
// нет: хвост исчезал, а начатый перед разрывом фон бейджа непрочитанных
|
|
120
145
|
// оставался залитым до края панели. С wrap: false лишнее просто обрезается.
|
|
146
|
+
// По клику мыши сразу выбираем и открываем диалог.
|
|
121
147
|
const baseCreateItem = list.createItem.bind(list);
|
|
122
148
|
list.createItem = (content) => {
|
|
123
149
|
const item = baseCreateItem(content);
|
|
124
150
|
item.wrap = false;
|
|
151
|
+
item.on("click", () => {
|
|
152
|
+
const index = list.getItemIndex(item);
|
|
153
|
+
if (index !== -1 && currentDialogs[index]) {
|
|
154
|
+
list.select(index);
|
|
155
|
+
onSelectDialog?.(currentDialogs[index]);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
125
158
|
return item;
|
|
126
159
|
};
|
|
127
160
|
|
|
161
|
+
container.on("wheelup", () => {
|
|
162
|
+
list.select(list.selected - 2);
|
|
163
|
+
screen.render();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
container.on("wheeldown", () => {
|
|
167
|
+
list.select(list.selected + 2);
|
|
168
|
+
screen.render();
|
|
169
|
+
});
|
|
170
|
+
|
|
128
171
|
let currentDialogs = [];
|
|
129
172
|
|
|
130
173
|
/**
|
|
@@ -275,6 +318,25 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
275
318
|
|
|
276
319
|
renderTabs();
|
|
277
320
|
|
|
321
|
+
/** Подсвечивает рамку, когда список диалогов или строка поиска в фокусе. */
|
|
322
|
+
function setFocusHighlight(active) {
|
|
323
|
+
container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
|
|
324
|
+
screen.render();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
list.on("focus", () => setFocusHighlight(true));
|
|
328
|
+
list.on("blur", (newTarget) => {
|
|
329
|
+
if (newTarget !== searchBox && screen.focused !== searchBox) {
|
|
330
|
+
setFocusHighlight(false);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
searchBox.on("focus", () => setFocusHighlight(true));
|
|
334
|
+
searchBox.on("blur", (newTarget) => {
|
|
335
|
+
if (newTarget !== list && screen.focused !== list) {
|
|
336
|
+
setFocusHighlight(false);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
278
340
|
return {
|
|
279
341
|
container,
|
|
280
342
|
list,
|
|
@@ -3,6 +3,8 @@ 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 { getMessageAtLine } from "../../utils/mouse.js";
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Создаёт компонент просмотра сообщений чата (правая центральная панель).
|
|
8
10
|
* @param {blessed.Widgets.Screen} screen
|
|
@@ -18,6 +20,7 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
18
20
|
left: "35%",
|
|
19
21
|
right: 0,
|
|
20
22
|
bottom: 6,
|
|
23
|
+
mouse: true,
|
|
21
24
|
border: {
|
|
22
25
|
type: "line",
|
|
23
26
|
},
|
|
@@ -38,7 +41,6 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
38
41
|
bottom: 0,
|
|
39
42
|
tags: true,
|
|
40
43
|
scrollable: true,
|
|
41
|
-
alwaysScroll: true,
|
|
42
44
|
mouse: true,
|
|
43
45
|
keys: true,
|
|
44
46
|
vi: true,
|
|
@@ -55,26 +57,43 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
55
57
|
},
|
|
56
58
|
});
|
|
57
59
|
|
|
60
|
+
/** Подсвечивает рамку, когда лента сообщений в фокусе. */
|
|
61
|
+
function setFocusHighlight(active) {
|
|
62
|
+
container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
|
|
63
|
+
screen.render();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
scrollBox.on("focus", () => setFocusHighlight(true));
|
|
67
|
+
scrollBox.on("blur", () => setFocusHighlight(false));
|
|
68
|
+
|
|
58
69
|
let currentMessages = [];
|
|
70
|
+
let currentRanges = [];
|
|
59
71
|
|
|
60
72
|
/**
|
|
61
|
-
* Форматирует список сообщений в единую ленту текста с разметкой Blessed
|
|
73
|
+
* Форматирует список сообщений в единую ленту текста с разметкой Blessed
|
|
74
|
+
* и вычисляет координаты строк каждого сообщения для кликов мыши.
|
|
62
75
|
* @param {Array<object>} messages
|
|
63
|
-
* @returns {string}
|
|
76
|
+
* @returns {{ text: string, ranges: Array<{ message: object, startLine: number, endLine: number }> }}
|
|
64
77
|
*/
|
|
65
|
-
function
|
|
78
|
+
function renderMessagesWithRanges(messages) {
|
|
66
79
|
if (!messages || messages.length === 0) {
|
|
67
|
-
return
|
|
80
|
+
return {
|
|
81
|
+
text: `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`,
|
|
82
|
+
ranges: [],
|
|
83
|
+
};
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
let output = "";
|
|
71
87
|
let lastDateString = "";
|
|
88
|
+
const ranges = [];
|
|
89
|
+
let lineCursor = 0;
|
|
72
90
|
|
|
73
91
|
for (const msg of messages) {
|
|
74
92
|
// Разделитель дат
|
|
75
93
|
const dateStr = formatDateDivider(msg.date);
|
|
76
94
|
if (dateStr && dateStr !== lastDateString) {
|
|
77
95
|
output += `\n ${fg(theme.chatView.dateDivider, `─────── ${escapeBlessed(dateStr)} ───────`)}\n\n`;
|
|
96
|
+
lineCursor += 3;
|
|
78
97
|
lastDateString = dateStr;
|
|
79
98
|
}
|
|
80
99
|
|
|
@@ -93,14 +112,27 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
93
112
|
|
|
94
113
|
// Блок ответа (Reply)
|
|
95
114
|
let replyBlock = "";
|
|
115
|
+
let replyLines = 0;
|
|
96
116
|
if (msg.replyToMsgId) {
|
|
97
117
|
replyBlock = ` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}\n`;
|
|
118
|
+
replyLines = 1;
|
|
98
119
|
}
|
|
99
120
|
|
|
100
121
|
// Текст сообщения и entities
|
|
101
122
|
let bodyText = formatMessageText(msg.text, msg.entities);
|
|
102
|
-
|
|
103
|
-
|
|
123
|
+
|
|
124
|
+
// Блок медиа-вложения и превью изображения
|
|
125
|
+
let mediaBlock = "";
|
|
126
|
+
if (msg.imagePreview) {
|
|
127
|
+
mediaBlock = msg.mediaDescription
|
|
128
|
+
? `${msg.mediaDescription}\n${msg.imagePreview}`
|
|
129
|
+
: msg.imagePreview;
|
|
130
|
+
} else if (msg.mediaDescription) {
|
|
131
|
+
mediaBlock = msg.mediaDescription;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (mediaBlock) {
|
|
135
|
+
bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
|
|
104
136
|
}
|
|
105
137
|
|
|
106
138
|
// Отступ строк текста сообщения
|
|
@@ -108,12 +140,15 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
108
140
|
.split("\n")
|
|
109
141
|
.map((line) => ` ${line}`)
|
|
110
142
|
.join("\n");
|
|
143
|
+
const bodyLinesCount = indentedBody.split("\n").length;
|
|
111
144
|
|
|
112
145
|
// Реакции
|
|
113
146
|
let reactionsLine = "";
|
|
147
|
+
let reactionLinesCount = 0;
|
|
114
148
|
if (msg.reactions && msg.reactions.length > 0) {
|
|
115
149
|
const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
|
|
116
150
|
reactionsLine = `\n ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`;
|
|
151
|
+
reactionLinesCount = 1;
|
|
117
152
|
}
|
|
118
153
|
|
|
119
154
|
// Метка редактирования
|
|
@@ -122,10 +157,17 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
122
157
|
editedTag = ` ${fg(theme.chatView.time, "(изменено)")}`;
|
|
123
158
|
}
|
|
124
159
|
|
|
160
|
+
const startLine = lineCursor;
|
|
161
|
+
const totalMsgLines = 1 + replyLines + bodyLinesCount + reactionLinesCount;
|
|
162
|
+
const endLine = startLine + totalMsgLines - 1;
|
|
163
|
+
|
|
164
|
+
ranges.push({ message: msg, startLine, endLine });
|
|
165
|
+
lineCursor += totalMsgLines + 2;
|
|
166
|
+
|
|
125
167
|
output += ` ${authorTag}${editedTag}\n${replyBlock}${indentedBody}${reactionsLine}\n\n`;
|
|
126
168
|
}
|
|
127
169
|
|
|
128
|
-
return output;
|
|
170
|
+
return { text: output, ranges };
|
|
129
171
|
}
|
|
130
172
|
|
|
131
173
|
/**
|
|
@@ -135,25 +177,84 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
135
177
|
*/
|
|
136
178
|
function setMessages(messages, autoScrollToBottom = true) {
|
|
137
179
|
currentMessages = messages;
|
|
138
|
-
scrollBox.
|
|
180
|
+
const prevScroll = scrollBox.getScroll();
|
|
181
|
+
const prevHeight = scrollBox.getScrollHeight();
|
|
182
|
+
|
|
183
|
+
const rendered = renderMessagesWithRanges(messages);
|
|
184
|
+
currentRanges = rendered.ranges;
|
|
185
|
+
scrollBox.setContent(rendered.text);
|
|
186
|
+
|
|
139
187
|
if (autoScrollToBottom) {
|
|
140
188
|
scrollBox.setScrollPerc(100);
|
|
189
|
+
} else {
|
|
190
|
+
// Сохраняем относительную позицию скролла: если добавились старые сообщения сверху,
|
|
191
|
+
// компенсируем сдвиг высоты ленты
|
|
192
|
+
const newHeight = scrollBox.getScrollHeight();
|
|
193
|
+
const addedLines = newHeight - prevHeight;
|
|
194
|
+
if (addedLines > 0 && prevScroll > 0) {
|
|
195
|
+
scrollBox.scrollTo(prevScroll + addedLines);
|
|
196
|
+
} else if (prevScroll > 0) {
|
|
197
|
+
scrollBox.scrollTo(prevScroll);
|
|
198
|
+
}
|
|
141
199
|
}
|
|
142
200
|
screen.render();
|
|
143
201
|
}
|
|
144
202
|
|
|
145
203
|
// Обработка прокрутки вверх для подгрузки истории
|
|
146
|
-
|
|
147
|
-
scrollBox.scroll(-
|
|
204
|
+
function handleScrollUp(step = 10) {
|
|
205
|
+
scrollBox.scroll(-step);
|
|
148
206
|
if (scrollBox.getScroll() <= 0) {
|
|
149
207
|
onLoadMoreHistory?.();
|
|
150
208
|
}
|
|
151
209
|
screen.render();
|
|
152
|
-
}
|
|
210
|
+
}
|
|
153
211
|
|
|
154
|
-
|
|
155
|
-
scrollBox.scroll(
|
|
212
|
+
function handleScrollDown(step = 10) {
|
|
213
|
+
scrollBox.scroll(step);
|
|
156
214
|
screen.render();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
|
|
218
|
+
scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
|
|
219
|
+
scrollBox.key(["up", "k"], () => handleScrollUp(2));
|
|
220
|
+
scrollBox.key(["down", "j"], () => handleScrollDown(2));
|
|
221
|
+
scrollBox.key(["home"], () => {
|
|
222
|
+
scrollBox.scrollTo(0);
|
|
223
|
+
onLoadMoreHistory?.();
|
|
224
|
+
screen.render();
|
|
225
|
+
});
|
|
226
|
+
scrollBox.key(["end"], () => {
|
|
227
|
+
scrollBox.setScrollPerc(100);
|
|
228
|
+
screen.render();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
scrollBox.on("wheelup", () => {
|
|
232
|
+
handleScrollUp(3);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
scrollBox.on("wheeldown", () => {
|
|
236
|
+
handleScrollDown(3);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
container.on("wheelup", () => {
|
|
240
|
+
handleScrollUp(3);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
container.on("wheeldown", () => {
|
|
244
|
+
handleScrollDown(3);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
scrollBox.on("click", (data) => {
|
|
248
|
+
const clickY = data.y;
|
|
249
|
+
const itop = scrollBox.itop || 0;
|
|
250
|
+
const lineIndex = clickY - (scrollBox.atop || 0) + scrollBox.getScroll() - itop;
|
|
251
|
+
const clickedMsg = getMessageAtLine(lineIndex, currentRanges);
|
|
252
|
+
if (clickedMsg) {
|
|
253
|
+
onActionMenu?.(clickedMsg);
|
|
254
|
+
} else {
|
|
255
|
+
scrollBox.focus();
|
|
256
|
+
screen.render();
|
|
257
|
+
}
|
|
157
258
|
});
|
|
158
259
|
|
|
159
260
|
// Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
|
|
@@ -169,6 +270,7 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
169
270
|
container,
|
|
170
271
|
scrollBox,
|
|
171
272
|
setMessages,
|
|
273
|
+
loadMore: () => onLoadMoreHistory?.(),
|
|
172
274
|
scrollToBottom: () => {
|
|
173
275
|
scrollBox.setScrollPerc(100);
|
|
174
276
|
screen.render();
|
|
@@ -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 } 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,19 @@ export function createHeader(screen, theme) {
|
|
|
29
38
|
},
|
|
30
39
|
});
|
|
31
40
|
|
|
41
|
+
headerBox.on("click", (data) => {
|
|
42
|
+
const relX = data.x - (headerBox.aleft || 0);
|
|
43
|
+
const relY = data.y - (headerBox.atop || 0);
|
|
44
|
+
const action = getHeaderActionAt(relX, relY, { hasActiveChat: Boolean(currentActiveChat) });
|
|
45
|
+
if (action === "help") {
|
|
46
|
+
onHelp?.();
|
|
47
|
+
} else if (action === "info") {
|
|
48
|
+
onChatInfo?.();
|
|
49
|
+
} else if (action === "status") {
|
|
50
|
+
onStatusClick?.();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
32
54
|
/**
|
|
33
55
|
* Обновляет содержимое шапки.
|
|
34
56
|
* @param {object} data
|
|
@@ -38,6 +60,7 @@ export function createHeader(screen, theme) {
|
|
|
38
60
|
* @param {string|null} [data.typingUser]
|
|
39
61
|
*/
|
|
40
62
|
headerBox.updateInfo = function ({ me, status = "connected", activeChat, typingUser }) {
|
|
63
|
+
currentActiveChat = activeChat;
|
|
41
64
|
let statusBadge = fg(theme.status.online, "● В сети");
|
|
42
65
|
if (status === "connecting") {
|
|
43
66
|
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 } 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, {
|
|
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,32 @@ export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSla
|
|
|
110
123
|
screen.render();
|
|
111
124
|
}
|
|
112
125
|
|
|
126
|
+
contextBar.on("click", (data) => {
|
|
127
|
+
const relX = data.x - (contextBar.aleft || 0);
|
|
128
|
+
const action = getInputContextActionAt(relX, currentMode);
|
|
129
|
+
if (action === "cancel") {
|
|
130
|
+
if (currentMode) {
|
|
131
|
+
currentMode = null;
|
|
132
|
+
currentTarget = null;
|
|
133
|
+
renderContext();
|
|
134
|
+
onCancelContext?.();
|
|
135
|
+
}
|
|
136
|
+
} else if (action === "reply") {
|
|
137
|
+
onReplyLast?.();
|
|
138
|
+
} else if (action === "edit") {
|
|
139
|
+
onEditLast?.();
|
|
140
|
+
} else if (action === "commands") {
|
|
141
|
+
textarea.setValue("/");
|
|
142
|
+
textarea.focus();
|
|
143
|
+
screen.render();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
textarea.on("click", () => {
|
|
148
|
+
textarea.focus();
|
|
149
|
+
screen.render();
|
|
150
|
+
});
|
|
151
|
+
|
|
113
152
|
textarea.key(["enter"], () => {
|
|
114
153
|
const value = textarea.getValue().trim();
|
|
115
154
|
if (!value) {
|
|
@@ -2,6 +2,8 @@ import blessed from "neo-blessed";
|
|
|
2
2
|
import { escapeBlessed } from "../../../telegram/formatter.js";
|
|
3
3
|
import { fg } from "../../theme.js";
|
|
4
4
|
|
|
5
|
+
import { isInsideBox } from "../../../utils/mouse.js";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Создаёт модальное окно контекстных действий над сообщением.
|
|
7
9
|
* @param {blessed.Widgets.Screen} screen
|
|
@@ -19,6 +21,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
19
21
|
height: "55%",
|
|
20
22
|
hidden: true,
|
|
21
23
|
tags: true,
|
|
24
|
+
mouse: true,
|
|
22
25
|
border: {
|
|
23
26
|
type: "line",
|
|
24
27
|
},
|
|
@@ -66,6 +69,20 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
66
69
|
},
|
|
67
70
|
});
|
|
68
71
|
|
|
72
|
+
const baseCreateItem = list.createItem.bind(list);
|
|
73
|
+
list.createItem = (content) => {
|
|
74
|
+
const item = baseCreateItem(content);
|
|
75
|
+
item.on("click", () => {
|
|
76
|
+
const index = list.getItemIndex(item);
|
|
77
|
+
const action = currentActions[index];
|
|
78
|
+
if (action && currentMsg) {
|
|
79
|
+
hide();
|
|
80
|
+
onAction?.(action.id, currentMsg);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return item;
|
|
84
|
+
};
|
|
85
|
+
|
|
69
86
|
let currentMsg = null;
|
|
70
87
|
let currentActions = [];
|
|
71
88
|
let previousFocus = null;
|
|
@@ -128,6 +145,20 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
128
145
|
// Фокус получает список — на нём и живут клавиши закрытия.
|
|
129
146
|
list.key(["escape", "q"], hide);
|
|
130
147
|
|
|
148
|
+
// Закрытие при клике мышью мимо модального окна
|
|
149
|
+
screen.on("click", (data) => {
|
|
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
|
+
});
|
|
161
|
+
|
|
131
162
|
return {
|
|
132
163
|
modal,
|
|
133
164
|
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 { isInsideBox } from "../../../utils/mouse.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,
|
|
@@ -114,9 +118,24 @@ export function createChatInfoModal(screen, theme) {
|
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
closeBtn.on("press", hide);
|
|
121
|
+
closeBtn.on("click", hide);
|
|
117
122
|
// Клавиши вешаем на кнопку: blessed отдаёт события только сфокусированному элементу.
|
|
118
123
|
closeBtn.key(["escape", "q"], hide);
|
|
119
124
|
|
|
125
|
+
// Закрытие при клике мышью мимо модального окна
|
|
126
|
+
screen.on("click", (data) => {
|
|
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
|
+
});
|
|
138
|
+
|
|
120
139
|
return {
|
|
121
140
|
modal,
|
|
122
141
|
show,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
2
|
import { fg } from "../../theme.js";
|
|
3
|
+
import { isInsideBox } from "../../../utils/mouse.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Создаёт модальное окно подтверждения действия (Да / Нет).
|
|
@@ -19,6 +20,7 @@ export function createConfirmModal(screen, theme) {
|
|
|
19
20
|
height: 8,
|
|
20
21
|
hidden: true,
|
|
21
22
|
tags: true,
|
|
23
|
+
mouse: true,
|
|
22
24
|
border: {
|
|
23
25
|
type: "line",
|
|
24
26
|
},
|
|
@@ -113,7 +115,9 @@ export function createConfirmModal(screen, theme) {
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
yesBtn.on("press", confirm);
|
|
118
|
+
yesBtn.on("click", confirm);
|
|
116
119
|
noBtn.on("press", hide);
|
|
120
|
+
noBtn.on("click", hide);
|
|
117
121
|
|
|
118
122
|
// Клавиши вешаем на обе кнопки — активна всегда одна из них.
|
|
119
123
|
for (const btn of [yesBtn, noBtn]) {
|
|
@@ -125,6 +129,20 @@ export function createConfirmModal(screen, theme) {
|
|
|
125
129
|
});
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
// Закрытие при клике мышью мимо модального окна
|
|
133
|
+
screen.on("click", (data) => {
|
|
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
|
+
});
|
|
145
|
+
|
|
128
146
|
return {
|
|
129
147
|
modal,
|
|
130
148
|
ask: (text, onConfirm) => {
|