@tuidom/elements 0.1.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.
Files changed (89) hide show
  1. package/dist/body/bodyElement.d.ts +33 -0
  2. package/dist/body/bodyElement.js +87 -0
  3. package/dist/button/buttonElement.d.ts +18 -0
  4. package/dist/button/buttonElement.js +68 -0
  5. package/dist/completionlist/completionDetailsElement.d.ts +49 -0
  6. package/dist/completionlist/completionDetailsElement.js +165 -0
  7. package/dist/completionlist/completionItemKindIcon.d.ts +2 -0
  8. package/dist/completionlist/completionItemKindIcon.js +43 -0
  9. package/dist/completionlist/completionListElement.d.ts +102 -0
  10. package/dist/completionlist/completionListElement.js +309 -0
  11. package/dist/completionlist/completionWidgetElement.d.ts +41 -0
  12. package/dist/completionlist/completionWidgetElement.js +102 -0
  13. package/dist/contextview/contextMenuController.d.ts +23 -0
  14. package/dist/contextview/contextMenuController.js +75 -0
  15. package/dist/contextview/overlayHostElement.d.ts +23 -0
  16. package/dist/contextview/overlayHostElement.js +47 -0
  17. package/dist/editorgroup/editorTabItemElement.d.ts +41 -0
  18. package/dist/editorgroup/editorTabItemElement.js +186 -0
  19. package/dist/editorgroup/editorTabStripElement.d.ts +33 -0
  20. package/dist/editorgroup/editorTabStripElement.js +118 -0
  21. package/dist/editorpart/editorPartElement.d.ts +95 -0
  22. package/dist/editorpart/editorPartElement.js +285 -0
  23. package/dist/inputbox/inputElement.d.ts +34 -0
  24. package/dist/inputbox/inputElement.js +207 -0
  25. package/dist/inputbox/inputState.d.ts +69 -0
  26. package/dist/inputbox/inputState.js +326 -0
  27. package/dist/layout/boxContainerElement.d.ts +27 -0
  28. package/dist/layout/boxContainerElement.js +121 -0
  29. package/dist/layout/boxElement.d.ts +4 -0
  30. package/dist/layout/boxElement.js +30 -0
  31. package/dist/layout/fillerElement.d.ts +12 -0
  32. package/dist/layout/fillerElement.js +16 -0
  33. package/dist/layout/fitContentElement.d.ts +18 -0
  34. package/dist/layout/fitContentElement.js +46 -0
  35. package/dist/layout/hFlexElement.d.ts +26 -0
  36. package/dist/layout/hFlexElement.js +134 -0
  37. package/dist/layout/paddingContainerElement.d.ts +30 -0
  38. package/dist/layout/paddingContainerElement.js +88 -0
  39. package/dist/layout/sizedBoxElement.d.ts +30 -0
  40. package/dist/layout/sizedBoxElement.js +74 -0
  41. package/dist/layout/vFlexElement.d.ts +32 -0
  42. package/dist/layout/vFlexElement.js +136 -0
  43. package/dist/layout/vStackElement.d.ts +18 -0
  44. package/dist/layout/vStackElement.js +74 -0
  45. package/dist/list/listViewElement.d.ts +232 -0
  46. package/dist/list/listViewElement.js +944 -0
  47. package/dist/menu/menuBarElement.d.ts +47 -0
  48. package/dist/menu/menuBarElement.js +285 -0
  49. package/dist/menu/menuBarItemElement.d.ts +20 -0
  50. package/dist/menu/menuBarItemElement.js +68 -0
  51. package/dist/menu/popupMenuElement.d.ts +81 -0
  52. package/dist/menu/popupMenuElement.js +318 -0
  53. package/dist/menu/popupMenuItemElement.d.ts +24 -0
  54. package/dist/menu/popupMenuItemElement.js +89 -0
  55. package/dist/panel/panelContainerElement.d.ts +64 -0
  56. package/dist/panel/panelContainerElement.js +219 -0
  57. package/dist/quickpick/quickPickElement.d.ts +161 -0
  58. package/dist/quickpick/quickPickElement.js +561 -0
  59. package/dist/sash/sashElement.d.ts +30 -0
  60. package/dist/sash/sashElement.js +78 -0
  61. package/dist/scrollbar/iScrollable.d.ts +8 -0
  62. package/dist/scrollbar/iScrollable.js +1 -0
  63. package/dist/scrollbar/scrollBarRenderer.d.ts +36 -0
  64. package/dist/scrollbar/scrollBarRenderer.js +76 -0
  65. package/dist/scrollbar/scrollContainerElement.d.ts +39 -0
  66. package/dist/scrollbar/scrollContainerElement.js +91 -0
  67. package/dist/scrollbar/scrollViewport.d.ts +28 -0
  68. package/dist/scrollbar/scrollViewport.js +65 -0
  69. package/dist/scrollbar/scrollableElement.d.ts +22 -0
  70. package/dist/scrollbar/scrollableElement.js +42 -0
  71. package/dist/selectbox/selectBoxElement.d.ts +48 -0
  72. package/dist/selectbox/selectBoxElement.js +161 -0
  73. package/dist/terminal/encodeKeyForPty.d.ts +3 -0
  74. package/dist/terminal/encodeKeyForPty.js +56 -0
  75. package/dist/terminal/terminalViewElement.d.ts +17 -0
  76. package/dist/terminal/terminalViewElement.js +172 -0
  77. package/dist/text/textBlockElement.d.ts +13 -0
  78. package/dist/text/textBlockElement.js +35 -0
  79. package/dist/text/textLabelElement.d.ts +28 -0
  80. package/dist/text/textLabelElement.js +77 -0
  81. package/dist/titledpanel/titledPanelElement.d.ts +14 -0
  82. package/dist/titledpanel/titledPanelElement.js +40 -0
  83. package/dist/tree/iTreeDataProvider.d.ts +19 -0
  84. package/dist/tree/iTreeDataProvider.js +1 -0
  85. package/dist/tree/treeViewElement.d.ts +116 -0
  86. package/dist/tree/treeViewElement.js +748 -0
  87. package/dist/workbenchlayout/workbenchLayoutElement.d.ts +60 -0
  88. package/dist/workbenchlayout/workbenchLayoutElement.js +203 -0
  89. package/package.json +33 -0
@@ -0,0 +1,944 @@
1
+ import { BoxConstraints, Offset, Point, Rect, Size } from "@tuidom/core/common/geometryPromitives";
2
+ import { TUIMouseEvent } from "@tuidom/core/dom/events/tuiMouseEvent";
3
+ import { TUIElement } from "@tuidom/core/dom/tuiElement";
4
+ import { ScrollableElement } from "../scrollbar/scrollableElement.js";
5
+ const DEFAULT_INDENT_SIZE = 2;
6
+ // Окно, в течение которого напечатанные символы складываются в одну последовательность
7
+ // быстрого поиска; после паузы буфер сбрасывается (как в VS Code / файловых менеджерах).
8
+ const TYPEAHEAD_TIMEOUT_MS = 800;
9
+ /**
10
+ * Состояние «строка под указателем ИЛИ под курсором сфокусированного списка» —
11
+ * то, что в вебе назвали бы «строка в фокусе внимания». Ставится на обёртку
12
+ * строки, поэтому потомкам доступно селектором `in:rowActive` и методом
13
+ * {@link TUIElement.hasStyleStateWithin} — так строка раскрывает свои
14
+ * инлайн-кнопки, ничего не зная про список.
15
+ *
16
+ * Почему не `hover`: диспатчер мыши ставит hover на ВСЮ цепочку target→root, а
17
+ * строки презентационные — target'ом всегда оказывается сам список. То есть
18
+ * `in:hover` у потомка строки был бы истиной для всех строк разом, стоит мыши
19
+ * оказаться где угодно над списком.
20
+ */
21
+ export const LIST_ROW_ACTIVE_STATE = "rowActive";
22
+ const ICON_EXPANDED = ""; // nf-fa-angle_down — chevron, как в TreeViewElement
23
+ const ICON_COLLAPSED = ""; // nf-fa-angle_right
24
+ /**
25
+ * Внутренняя обёртка строки: владеет when-вариантами состояний
26
+ * (hover < selected < selected+in:focus — порядок = приоритет) и сдвигом
27
+ * контента (гуттер иерархии). Состояния ставит список; in:focus видит
28
+ * focus-состояние самого списка (строки презентационные и фокуса не имеют).
29
+ * Пост-рендерный оверлей прежней реализации умер: смена состояния меняет
30
+ * resolvedStyle строки, дети без собственных цветов наследуют результат, а
31
+ * явные посимвольные подсветки (match highlight) выделение переживают.
32
+ */
33
+ class ListRowHostElement extends TUIElement {
34
+ contentX = 0;
35
+ child;
36
+ constructor(child) {
37
+ super();
38
+ this.child = child;
39
+ this.appendChild(child);
40
+ this.style = {
41
+ when: [
42
+ { states: ["hover"], bg: "list.hoverBackground" },
43
+ {
44
+ states: ["selected"],
45
+ fg: "list.inactiveSelectionForeground",
46
+ bg: "list.inactiveSelectionBackground",
47
+ },
48
+ {
49
+ states: ["selected", "in:focus"],
50
+ fg: "list.activeSelectionForeground",
51
+ bg: "list.activeSelectionBackground",
52
+ },
53
+ ],
54
+ };
55
+ }
56
+ performLayout(constraints) {
57
+ const size = super.performLayout(constraints);
58
+ this.layoutChild(this.child, this.contentX, 0, BoxConstraints.tight(new Size(Math.max(0, size.width - this.contentX), 1)));
59
+ return size;
60
+ }
61
+ }
62
+ /**
63
+ * Виртуализирующий список: настоящие TUIElement-дети высотой ровно 1 строка.
64
+ *
65
+ * Потребитель императивно собирает произвольные строки-элементы и добавляет их
66
+ * через {@link appendRow}; контейнер прячет внутри виртуализацию (layout и render
67
+ * получают только строки видимого окна), иерархию со сворачиванием (`parentId` +
68
+ * {@link setCollapsed}), скрытие ({@link setRowHidden}) и общие механики списка:
69
+ * курсор, множественный выбор, клавиатуру, hover, typeahead и сигнал контекстного
70
+ * меню. У каждой строки обязан быть непустой `element.id` — без него appendRow
71
+ * бросает ошибку.
72
+ *
73
+ * Контракт строк: строки — презентационные. Хит-тест всегда возвращает сам
74
+ * контейнер, поэтому строки не получают mouse-событий и фокуса; вся семантика
75
+ * клика/клавиатуры принадлежит списку. Единственное исключение — делегация
76
+ * левого клика без модификаторов: контейнер ре-диспатчит click/dblclick в
77
+ * поддерево видимой строки, и если чей-то listener вызвал `preventDefault()`,
78
+ * клик считается потреблённым — курсор, выделение и активация не трогаются.
79
+ * Так строка может нести инлайн-кнопки (обычные элементы с click-listener'ом),
80
+ * не зная ничего про список. Фокуса такая кнопка не берёт (строки вне Tab-обхода),
81
+ * поэтому «строка в фокусе внимания» выражена состоянием
82
+ * {@link LIST_ROW_ACTIVE_STATE} на обёртке строки — по нему кнопка решает,
83
+ * показываться ли ей. Выделение и hover рисуются пост-оверлеем
84
+ * поверх отрендеренной строки: перекрашиваются только ячейки, чьи цвета совпадают
85
+ * с унаследованными fg/bg контейнера, так что собственные цвета строки (подсветка
86
+ * совпадения, приглушённый номер) выделение переживают. Строка, явно выставившая
87
+ * цвет, равный унаследованному, неотличима от ненастроенной и тоже будет
88
+ * перекрашена — это осознанное ограничение механизма.
89
+ */
90
+ export class ListViewElement extends ScrollableElement {
91
+ indentSize;
92
+ typeaheadEnabled;
93
+ rowById = new Map();
94
+ /** Дети по родителю в порядке добавления; ключ null — верхний уровень. */
95
+ childIds = new Map();
96
+ collapsedIds = new Set();
97
+ /** Ленивая DFS-проекция видимых строк; null = требуется пересборка. */
98
+ visibleRows = null;
99
+ visibleIndexById = new Map();
100
+ hasCollapsibleRows = false;
101
+ /**
102
+ * Окно строк, реально разложенное последним performLayout. Делегация кликов
103
+ * (см. {@link delegateToRowChild}) доверяет позициям детей только внутри него:
104
+ * скролл или пересборка проекции между кадрами делают layout недействительным
105
+ * (-1), и незалейаученные строки с ленивыми позициями (0,0)/80×24 не ловят
106
+ * чужие клики.
107
+ */
108
+ lastLayoutScrollTop = -1;
109
+ lastLayoutViewportHeight = 0;
110
+ cursorIndex = 0;
111
+ anchorIndex = 0;
112
+ selectedIds = new Set();
113
+ hoveredIndex = null;
114
+ /** Курсор, запомненный по id на время грязной проекции (restoreSelection дерева). */
115
+ pendingCursorId = null;
116
+ pendingCursorIndex = 0;
117
+ typeaheadBuffer = "";
118
+ typeaheadTimer = null;
119
+ onSelect = null;
120
+ onActivate = null;
121
+ onCollapsedChanged = null;
122
+ onContextMenu = null;
123
+ constructor(options) {
124
+ super();
125
+ this.focusable = true;
126
+ this.indentSize = options?.indentSize ?? DEFAULT_INDENT_SIZE;
127
+ this.typeaheadEnabled = options?.typeahead ?? true;
128
+ // Смена фокуса входит в LIST_ROW_ACTIVE_STATE курсорной строки, а
129
+ // FocusManager метит поддерево только style-грязным — раскрытие же
130
+ // меняет ГЕОМЕТРИЮ строки, поэтому нужен пересчёт layout.
131
+ this.addEventListener("focus", () => {
132
+ this.markDirty();
133
+ });
134
+ this.addEventListener("blur", () => {
135
+ this.markDirty();
136
+ });
137
+ }
138
+ // ─── Rows ───
139
+ appendRow(element, options) {
140
+ const id = element.id;
141
+ if (id === undefined || id === "") {
142
+ throw new Error(`ListViewElement: every row element must have a non-empty id (row #${this.rowById.size} has none)`);
143
+ }
144
+ if (this.rowById.has(id)) {
145
+ throw new Error(`ListViewElement: duplicate row id "${id}"`);
146
+ }
147
+ const parentId = options?.parentId ?? null;
148
+ let depth = 0;
149
+ if (parentId !== null) {
150
+ const parent = this.rowById.get(parentId);
151
+ if (!parent) {
152
+ throw new Error(`ListViewElement: unknown parentId "${parentId}" for row "${id}"`);
153
+ }
154
+ depth = parent.depth + 1;
155
+ }
156
+ const host = new ListRowHostElement(element);
157
+ const row = { element, host, id, parentId, depth, label: options?.label, hidden: false };
158
+ this.rowById.set(id, row);
159
+ let siblings = this.childIds.get(parentId);
160
+ if (!siblings) {
161
+ siblings = [];
162
+ this.childIds.set(parentId, siblings);
163
+ }
164
+ siblings.push(id);
165
+ this.appendChild(host);
166
+ this.appendToProjection(row);
167
+ this.markDirty();
168
+ }
169
+ clear() {
170
+ this.setChildren([]);
171
+ this.rowById.clear();
172
+ this.childIds.clear();
173
+ this.collapsedIds.clear();
174
+ this.selectedIds.clear();
175
+ this.visibleRows = null;
176
+ this.visibleIndexById.clear();
177
+ this.lastLayoutScrollTop = -1;
178
+ this.cursorIndex = 0;
179
+ this.anchorIndex = 0;
180
+ this.hoveredIndex = null;
181
+ this.pendingCursorId = null;
182
+ this.pendingCursorIndex = 0;
183
+ this.scrollTop = 0;
184
+ this.scrollLeft = 0;
185
+ this.markDirty();
186
+ }
187
+ get rowCount() {
188
+ return this.rowById.size;
189
+ }
190
+ // ─── Visibility: collapse / hide ───
191
+ setCollapsed(id, collapsed) {
192
+ const row = this.requireRow(id);
193
+ if (!this.rowHasChildren(id))
194
+ return;
195
+ if (this.collapsedIds.has(id) === collapsed)
196
+ return;
197
+ if (collapsed) {
198
+ this.collapsedIds.add(id);
199
+ }
200
+ else {
201
+ this.collapsedIds.delete(id);
202
+ }
203
+ this.onCollapsedChanged?.(row.element, collapsed);
204
+ this.invalidateProjection();
205
+ this.markDirty();
206
+ }
207
+ toggleCollapsed(id) {
208
+ this.setCollapsed(id, !this.collapsedIds.has(id));
209
+ }
210
+ isCollapsed(id) {
211
+ return this.collapsedIds.has(id);
212
+ }
213
+ /** Снимок свёрнутых строк — восстановление состояния после полной пересборки. */
214
+ getCollapsedIds() {
215
+ return [...this.collapsedIds];
216
+ }
217
+ /**
218
+ * Есть ли в видимой проекции развёрнутая строка с детьми — семантика
219
+ * VS Code `viewHasSomeCollapsibleResult` (тумблер Collapse All/Expand All).
220
+ */
221
+ hasVisibleExpandedRow() {
222
+ return this.ensureProjection().some((row) => this.rowHasChildren(row.id) && !this.collapsedIds.has(row.id));
223
+ }
224
+ setRowHidden(id, hidden) {
225
+ const row = this.requireRow(id);
226
+ if (row.hidden === hidden)
227
+ return;
228
+ row.hidden = hidden;
229
+ this.invalidateProjection();
230
+ this.markDirty();
231
+ }
232
+ // ─── Cursor / selection ───
233
+ getCursorElement() {
234
+ const rows = this.ensureProjection();
235
+ if (this.cursorIndex >= 0 && this.cursorIndex < rows.length) {
236
+ return rows[this.cursorIndex].element;
237
+ }
238
+ return null;
239
+ }
240
+ /**
241
+ * Все выбранные строки. Если множественный выбор пуст, возвращает строку под
242
+ * курсором (в виде массива из одного элемента). Порядок — как в списке.
243
+ */
244
+ getSelectedElements() {
245
+ const rows = this.ensureProjection();
246
+ if (this.selectedIds.size === 0) {
247
+ const cursor = this.getCursorElement();
248
+ return cursor ? [cursor] : [];
249
+ }
250
+ const result = [];
251
+ for (const row of rows) {
252
+ if (this.selectedIds.has(row.id)) {
253
+ result.push(row.element);
254
+ }
255
+ }
256
+ return result;
257
+ }
258
+ /** Ставит курсор на строку, раскрывая свёрнутых предков, и проматывает к ней. */
259
+ setCursorTo(id) {
260
+ const row = this.requireRow(id);
261
+ for (let parentId = row.parentId; parentId !== null;) {
262
+ const parent = this.rowById.get(parentId);
263
+ /* v8 ignore start -- defensive: parent chains are validated at appendRow time */
264
+ if (!parent)
265
+ break;
266
+ /* v8 ignore stop */
267
+ if (this.collapsedIds.has(parent.id)) {
268
+ this.setCollapsed(parent.id, false);
269
+ }
270
+ parentId = parent.parentId;
271
+ }
272
+ this.ensureProjection();
273
+ const index = this.visibleIndexById.get(id);
274
+ if (index !== undefined) {
275
+ this.setSelectedIndex(index);
276
+ }
277
+ }
278
+ focusPageDown() {
279
+ const pageSize = Math.max(1, this.layoutSize.height - 1);
280
+ this.setSelectedIndex(this.cursorIndex + pageSize);
281
+ }
282
+ focusPageUp() {
283
+ const pageSize = Math.max(1, this.layoutSize.height - 1);
284
+ this.setSelectedIndex(this.cursorIndex - pageSize);
285
+ }
286
+ focusFirst() {
287
+ this.setSelectedIndex(0);
288
+ }
289
+ focusLast() {
290
+ this.setSelectedIndex(this.ensureProjection().length - 1);
291
+ }
292
+ // ─── ScrollableElement contract ───
293
+ get contentHeight() {
294
+ return this.ensureProjection().length;
295
+ }
296
+ get contentWidth() {
297
+ // Горизонтального скролла нет: строки живут в ширине вьюпорта и клипятся справа.
298
+ return this.layoutSize.width;
299
+ }
300
+ // ─── TUIElement overrides ───
301
+ /**
302
+ * Строки презентационные: в хит-тест не спускаемся — вся семантика мыши
303
+ * живёт в контейнере (и закуленные строки с непосчитанным layout не крадут
304
+ * хиты у чужих углов экрана).
305
+ */
306
+ hitTestChildren(_point) {
307
+ return null;
308
+ }
309
+ /** Строки не участвуют в Tab-обходе — фокусируется только сам список. */
310
+ getDepthFirstFocusableOrder() {
311
+ return this.focusable ? [this] : [];
312
+ }
313
+ /**
314
+ * Damage-обход не заходит в строки: их тысячи, офскрин-хосты держат
315
+ * протухшие layout-позиции (виртуализация), а любая смена состояния списка
316
+ * (скролл, курсор, данные) метит сам список — повреждается rect вьюпорта.
317
+ */
318
+ get paintsSubtreeAtomically() {
319
+ return true;
320
+ }
321
+ performLayout(constraints) {
322
+ const size = super.performLayout(constraints);
323
+ const rows = this.ensureProjection();
324
+ const start = this.scrollTop;
325
+ const end = Math.min(rows.length, start + size.height);
326
+ const listFocused = this.isFocused;
327
+ for (let i = start; i < end; i++) {
328
+ const row = rows[i];
329
+ const y = i - start;
330
+ row.host.contentX = this.rowContentX(row);
331
+ // Состояния строк синхронизируются здесь: layout идёт в кадре ДО
332
+ // резолва стилей. Вариант ДЛЯ layout — без markDirty, иначе кадр
333
+ // оставлял бы корень грязным и следующий ввод рендерил бы пустой кадр.
334
+ row.host.setStyleStateDuringLayout("selected", i === this.cursorIndex || this.selectedIds.has(row.id));
335
+ row.host.setStyleStateDuringLayout("hover", i === this.hoveredIndex);
336
+ row.host.setStyleStateDuringLayout(LIST_ROW_ACTIVE_STATE, i === this.hoveredIndex || (i === this.cursorIndex && listFocused));
337
+ this.layoutChild(row.host, 0, y, BoxConstraints.tight(new Size(size.width, 1)));
338
+ }
339
+ this.invalidateRowsLeavingWindow(rows, start, end);
340
+ this.lastLayoutScrollTop = start;
341
+ this.lastLayoutViewportHeight = size.height;
342
+ // Окно могло сместиться (скролл, пересборка проекции) — стилевой проход
343
+ // этого же кадра обязан зайти в список и дорезолвить въехавшие строки,
344
+ // остававшиеся style-dirty за кадром (смена темы/фокуса метит ВСЕ
345
+ // строки, а резолвится только окно). Чистые строки отсеет ранний выход.
346
+ this.markSubtreeStyleDirty();
347
+ return size;
348
+ }
349
+ /**
350
+ * Строки, которые были в окне прошлого layout и выпали из нынешнего, несут
351
+ * устаревшую геометрию (прежняя ширина/позиция). Помечаем их
352
+ * layout-грязными — тем же флагом, с которым живут ни разу не разложенные
353
+ * строки: он и вернёт им честный layout при въезде в окно, и выведет их
354
+ * из-под геометрических проверок `validateTree`.
355
+ *
356
+ * Окно умеет не только ехать, но и **сжиматься** — например, когда
357
+ * `ScrollBarDecorator` спрашивает `contentWidth` у ещё не разложенного
358
+ * списка: ленивый layout проходит по дефолтным 80×24, а следующий за ним
359
+ * настоящий кладёт лишь пару видимых строк. Без этой уборки хвост остался
360
+ * бы «чистым» с шириной 80 внутри 28-колоночного списка.
361
+ *
362
+ * Цена — O(окна), а не O(N): переcборка проекции сюда не попадает (там
363
+ * строки и так новые, а `lastLayoutScrollTop` сброшен в -1).
364
+ */
365
+ invalidateRowsLeavingWindow(rows, start, end) {
366
+ if (this.lastLayoutScrollTop < 0)
367
+ return;
368
+ const prevEnd = Math.min(rows.length, this.lastLayoutScrollTop + this.lastLayoutViewportHeight);
369
+ for (let i = this.lastLayoutScrollTop; i < prevEnd; i++) {
370
+ if (i < start || i >= end) {
371
+ // Именно флаг, а не markDirty(): тот пошёл бы вверх и оставил
372
+ // корень грязным после кадра (см. setStyleStateDuringLayout).
373
+ rows[i].host.isLayoutDirty = true;
374
+ }
375
+ }
376
+ }
377
+ /**
378
+ * Стилевой проход виртуализирован, как layout/render/hitTest: резолвится
379
+ * только видимое окно строк, O(вьюпорта) вместо O(N) на кадр. Офскрин-
380
+ * строки остаются style-dirty до въезда в окно (см. performLayout).
381
+ */
382
+ performChildrenStyleResolution(context) {
383
+ const rows = this.ensureProjection();
384
+ const end = Math.min(rows.length, this.scrollTop + this.layoutSize.height);
385
+ for (let i = this.scrollTop; i < end; i++) {
386
+ rows[i].host.performStyleResolution(context);
387
+ }
388
+ }
389
+ inspectState() {
390
+ const rows = this.ensureProjection();
391
+ return {
392
+ cursorId: this.rowIdAt(this.cursorIndex),
393
+ selectedIds: [...this.selectedIds].sort(),
394
+ collapsedIds: [...this.collapsedIds].sort(),
395
+ visibleCount: rows.length,
396
+ totalCount: this.rowById.size,
397
+ scrollTop: this.scrollTop,
398
+ };
399
+ }
400
+ performDefaultAction(event) {
401
+ if (event.type === "keypress") {
402
+ this.handleKeypress(event);
403
+ }
404
+ else if (event.type === "click") {
405
+ this.handleClick(event);
406
+ }
407
+ else if (event.type === "contextmenu") {
408
+ this.handleContextMenu(event);
409
+ }
410
+ else if (event.type === "dblclick") {
411
+ this.handleDblClick(event);
412
+ }
413
+ else if (event.type === "wheel") {
414
+ this.handleWheel(event);
415
+ }
416
+ else if (event.type === "mousemove") {
417
+ this.handleMouseMove(event);
418
+ }
419
+ else if (event.type === "mouseleave") {
420
+ this.handleMouseLeave();
421
+ }
422
+ else {
423
+ super.performDefaultAction(event);
424
+ }
425
+ }
426
+ /**
427
+ * Единый вход контекстного меню: от мыши — строка под кликом (мультивыбор по
428
+ * выбранной строке не сбрасывается), от клавиатуры — строка курсора с якорем
429
+ * на её глобальной позиции. Виджет только сигналит {@link onContextMenu} —
430
+ * сборка и показ меню на стороне владельца.
431
+ */
432
+ handleContextMenu(event) {
433
+ const rows = this.ensureProjection();
434
+ if (event.trigger === "mouse") {
435
+ const index = this.scrollTop + event.localY;
436
+ if (index < 0 || index >= rows.length)
437
+ return;
438
+ const row = rows[index];
439
+ // Не сбрасываем множественный выбор, если кликнули по уже выбранной строке.
440
+ if (!this.selectedIds.has(row.id)) {
441
+ this.setSelectedIndex(index);
442
+ }
443
+ else {
444
+ this.cursorIndex = index;
445
+ this.applyCursor(index);
446
+ }
447
+ this.onContextMenu?.(row.element, event.screenX, event.screenY);
448
+ return;
449
+ }
450
+ if (this.cursorIndex < 0 || this.cursorIndex >= rows.length)
451
+ return;
452
+ this.onContextMenu?.(rows[this.cursorIndex].element, this.globalPosition.x, this.globalPosition.y + (this.cursorIndex - this.scrollTop));
453
+ }
454
+ // ─── Render ───
455
+ renderViewport(context, viewport) {
456
+ const rows = this.ensureProjection();
457
+ const { scrollTop, viewportWidth, viewportHeight } = viewport;
458
+ const resolved = this.resolvedStyle;
459
+ // Границы клипа в локальных строках вьюпорта: строки вне клипа не льём
460
+ // и не рендерим — при частичном damage-кадре список платит только за
461
+ // пересечение со своим rect'ом.
462
+ const clipTopY = Math.max(0, context.clipRect.y - context.offset.dy);
463
+ const clipBottomY = Math.min(viewportHeight, context.clipRect.bottom - context.offset.dy);
464
+ for (let screenY = clipTopY; screenY < clipBottomY; screenY++) {
465
+ const rowIndex = scrollTop + screenY;
466
+ // 1. Заливка строки фоном списка (гуттер и область за строками).
467
+ for (let x = 0; x < viewportWidth; x++) {
468
+ context.setCell(x, screenY, { char: " ", fg: resolved.fg, bg: resolved.bg, width: 1 });
469
+ }
470
+ if (rowIndex >= rows.length)
471
+ continue;
472
+ const row = rows[rowIndex];
473
+ // 2. Строка-обёртка: её when-состояния (hover/selected) уже
474
+ // отрезолвлены каскадом — фон льёт база, дети наследуют результат.
475
+ const host = row.host;
476
+ const hostOffset = new Offset(host.localPosition.dx, host.localPosition.dy);
477
+ const hostClip = new Rect(host.globalPosition, host.layoutSize);
478
+ // Пустым hostClip быть не может: цикл уже ограничен строками клипа,
479
+ // а по горизонтали строка-хост занимает всю ширину вьюпорта.
480
+ host.render(context.withOffset(hostOffset).withClip(hostClip));
481
+ // 3. Шеврон сворачиваемой строки — поверх, на фоне строки.
482
+ if (this.hasCollapsibleRows && this.rowHasChildren(row.id)) {
483
+ const icon = this.collapsedIds.has(row.id) ? ICON_COLLAPSED : ICON_EXPANDED;
484
+ context.setCell(row.depth * this.indentSize, screenY, {
485
+ char: icon,
486
+ fg: this.styleVar("list.deemphasizedForeground"),
487
+ bg: host.resolvedStyle.bg,
488
+ width: 1,
489
+ });
490
+ }
491
+ }
492
+ }
493
+ // ─── Private: projection ───
494
+ ensureProjection() {
495
+ if (this.visibleRows !== null)
496
+ return this.visibleRows;
497
+ const out = [];
498
+ this.visibleIndexById.clear();
499
+ let hasCollapsible = false;
500
+ const walk = (parentId) => {
501
+ const ids = this.childIds.get(parentId);
502
+ if (!ids)
503
+ return;
504
+ for (const id of ids) {
505
+ /* v8 ignore next -- defensive: childIds and rowById are mutated in lock-step */
506
+ const row = this.rowById.get(id);
507
+ if (!row || row.hidden)
508
+ continue;
509
+ if (this.rowHasChildren(id))
510
+ hasCollapsible = true;
511
+ this.visibleIndexById.set(id, out.length);
512
+ out.push(row);
513
+ if (!this.collapsedIds.has(id))
514
+ walk(id);
515
+ }
516
+ };
517
+ walk(null);
518
+ this.visibleRows = out;
519
+ this.hasCollapsibleRows = hasCollapsible;
520
+ // Курсор возвращается на прежнюю строку по id; если она пропала (скрыта,
521
+ // свёрнута под родителя) — на ближайший оставшийся индекс, не прыгая наверх.
522
+ if (this.pendingCursorId !== null && this.visibleIndexById.has(this.pendingCursorId)) {
523
+ this.cursorIndex = this.visibleIndexById.get(this.pendingCursorId);
524
+ }
525
+ else if (out.length === 0) {
526
+ this.cursorIndex = 0;
527
+ }
528
+ else {
529
+ this.cursorIndex = Math.min(Math.max(this.pendingCursorIndex, 0), out.length - 1);
530
+ }
531
+ this.anchorIndex = this.cursorIndex;
532
+ this.pendingCursorId = null;
533
+ return out;
534
+ }
535
+ /**
536
+ * Инкрементальное дополнение живой проекции при appendRow: стрим результатов
537
+ * (SearchPerformance.md, случай 6) не платит полную пересборку O(N) на кадр.
538
+ * Быстрые случаи: строка под невидимым/свёрнутым родителем — проекция не
539
+ * меняется вовсе; строка в хвосте проекции (последний топ-левел или новый
540
+ * последний ребёнок раскрытой группы, чьё поддерево замыкает проекцию) —
541
+ * push в конец: индексы существующих строк стабильны, курсор, выделение и
542
+ * якорь Shift-диапазона не трогаются (полная пересборка якорь сбрасывает —
543
+ * инкрементальный append его сохраняет). Остальное (вставка в середину) —
544
+ * фоллбек на полную инвалидацию.
545
+ */
546
+ appendToProjection(row) {
547
+ const rows = this.visibleRows;
548
+ if (rows === null) {
549
+ // Проекция уже грязная — пересборка и так предстоит.
550
+ this.invalidateProjection();
551
+ return;
552
+ }
553
+ if (row.parentId !== null) {
554
+ if (!this.visibleIndexById.has(row.parentId)) {
555
+ // Родитель сам не виден (скрыт или под свёрнутым предком) — строка
556
+ // невидима, проекция не меняется. Порядок при последующем раскрытии
557
+ // обеспечит пересборка из setCollapsed/setRowHidden.
558
+ return;
559
+ }
560
+ // Видимый родитель теперь точно с детьми — шеврон обязан появиться
561
+ // (существенно для первого ребёнка: пересборки, которая выставила бы
562
+ // hasCollapsibleRows, при инкрементальном пути не будет).
563
+ this.hasCollapsibleRows = true;
564
+ if (this.collapsedIds.has(row.parentId))
565
+ return; // строка невидима
566
+ if (!this.isProjectionTail(row.parentId)) {
567
+ // Вставка в середину проекции — редкий для стрима случай.
568
+ this.invalidateProjection();
569
+ return;
570
+ }
571
+ }
572
+ this.visibleIndexById.set(row.id, rows.length);
573
+ rows.push(row);
574
+ // Строка попала в разложенное окно (контент короче вьюпорта), но сама ещё
575
+ // не разложена — делегация кликов не должна доверять её ленивой позиции.
576
+ // Append за окном делегацию не трогает: гард по концу окна отсеет индекс.
577
+ if (rows.length - 1 < this.lastLayoutScrollTop + this.lastLayoutViewportHeight) {
578
+ this.lastLayoutScrollTop = -1;
579
+ }
580
+ }
581
+ /**
582
+ * Замыкает ли поддерево строки текущую проекцию: последняя видимая строка —
583
+ * она сама или её потомок (walk по цепочке родителей, O(глубины)). Именно
584
+ * тогда новый последний ребёнок строки встаёт в самый хвост проекции.
585
+ */
586
+ isProjectionTail(id) {
587
+ // Вызывается при видимом родителе — проекция материализована и непуста.
588
+ let cursor = this.visibleRows[this.visibleRows.length - 1];
589
+ while (cursor) {
590
+ if (cursor.id === id)
591
+ return true;
592
+ cursor = cursor.parentId !== null ? this.rowById.get(cursor.parentId) : undefined;
593
+ }
594
+ return false;
595
+ }
596
+ invalidateProjection() {
597
+ this.lastLayoutScrollTop = -1;
598
+ if (this.visibleRows !== null) {
599
+ this.pendingCursorId = this.rowIdAt(this.cursorIndex);
600
+ this.pendingCursorIndex = this.cursorIndex;
601
+ this.visibleRows = null;
602
+ }
603
+ }
604
+ rowIdAt(index) {
605
+ if (this.visibleRows !== null && index >= 0 && index < this.visibleRows.length) {
606
+ return this.visibleRows[index].id;
607
+ }
608
+ return null;
609
+ }
610
+ requireRow(id) {
611
+ const row = this.rowById.get(id);
612
+ if (!row) {
613
+ throw new Error(`ListViewElement: unknown row id "${id}"`);
614
+ }
615
+ return row;
616
+ }
617
+ rowHasChildren(id) {
618
+ const ids = this.childIds.get(id);
619
+ return ids !== undefined && ids.length > 0;
620
+ }
621
+ rowContentX(row) {
622
+ // Гуттер под шевроны резервируется, только если в списке вообще есть что сворачивать.
623
+ return row.depth * this.indentSize + (this.hasCollapsibleRows ? 2 : 0);
624
+ }
625
+ // ─── Private: selection ───
626
+ setSelectedIndex(index) {
627
+ const rows = this.ensureProjection();
628
+ if (index < 0)
629
+ index = 0;
630
+ if (index >= rows.length)
631
+ index = rows.length - 1;
632
+ if (index < 0)
633
+ return;
634
+ this.cursorIndex = index;
635
+ this.anchorIndex = index;
636
+ this.selectedIds.clear();
637
+ this.applyCursor(index);
638
+ }
639
+ /** Двигает курсор и расширяет множественный выбор диапазоном от якоря (Shift+стрелки). */
640
+ extendSelectionTo(index) {
641
+ const rows = this.ensureProjection();
642
+ if (index < 0)
643
+ index = 0;
644
+ if (index >= rows.length)
645
+ index = rows.length - 1;
646
+ if (index < 0)
647
+ return;
648
+ this.cursorIndex = index;
649
+ this.selectRange(this.anchorIndex, index);
650
+ this.applyCursor(index);
651
+ }
652
+ selectRange(anchor, cursor) {
653
+ const rows = this.ensureProjection();
654
+ const lo = Math.min(anchor, cursor);
655
+ const hi = Math.max(anchor, cursor);
656
+ this.selectedIds.clear();
657
+ for (let i = lo; i <= hi; i++) {
658
+ this.selectedIds.add(rows[i].id);
659
+ }
660
+ }
661
+ /** Переключает выбор одной строки (Ctrl/Cmd+клик), не сбрасывая остальное. */
662
+ toggleSelectionAt(index) {
663
+ const rows = this.ensureProjection();
664
+ /* v8 ignore start -- defensive: the mouse handler bounds-checks the row before calling */
665
+ if (index < 0 || index >= rows.length)
666
+ return;
667
+ /* v8 ignore stop */
668
+ // Первый Ctrl+клик: включаем в набор текущий курсор, чтобы он не потерялся.
669
+ if (this.selectedIds.size === 0 && this.cursorIndex < rows.length) {
670
+ this.selectedIds.add(rows[this.cursorIndex].id);
671
+ }
672
+ const id = rows[index].id;
673
+ if (this.selectedIds.has(id)) {
674
+ this.selectedIds.delete(id);
675
+ }
676
+ else {
677
+ this.selectedIds.add(id);
678
+ }
679
+ this.cursorIndex = index;
680
+ this.anchorIndex = index;
681
+ this.applyCursor(index);
682
+ }
683
+ applyCursor(index) {
684
+ this.ensureVisible(index);
685
+ const rows = this.ensureProjection();
686
+ this.onSelect?.(rows[index].element);
687
+ this.markDirty();
688
+ }
689
+ ensureVisible(index) {
690
+ const viewportHeight = this.layoutSize.height;
691
+ if (index < this.scrollTop) {
692
+ this.scrollTo(this.scrollLeft, index);
693
+ }
694
+ else if (index >= this.scrollTop + viewportHeight) {
695
+ this.scrollTo(this.scrollLeft, index - viewportHeight + 1);
696
+ }
697
+ }
698
+ // ─── Private: input handlers ───
699
+ handleKeypress(event) {
700
+ switch (event.key) {
701
+ case "ArrowUp":
702
+ if (event.shiftKey) {
703
+ this.extendSelectionTo(this.cursorIndex - 1);
704
+ }
705
+ else {
706
+ this.setSelectedIndex(this.cursorIndex - 1);
707
+ }
708
+ break;
709
+ case "ArrowDown":
710
+ if (event.shiftKey) {
711
+ this.extendSelectionTo(this.cursorIndex + 1);
712
+ }
713
+ else {
714
+ this.setSelectedIndex(this.cursorIndex + 1);
715
+ }
716
+ break;
717
+ case "ArrowRight":
718
+ this.handleExpandOrMoveToChild();
719
+ break;
720
+ case "ArrowLeft":
721
+ this.handleCollapseOrMoveToParent();
722
+ break;
723
+ case "Enter":
724
+ this.activateCursorRow();
725
+ break;
726
+ case " ":
727
+ this.toggleCursorRow();
728
+ break;
729
+ // Page/Home/End дублируются здесь при живых глобальных командах list.*:
730
+ // в workbench команда съедает keydown, а диспатчер гасит парный keypress,
731
+ // так что сюда клавиша не доходит; standalone-использование контейнера
732
+ // вне workbench не должно терять навигацию.
733
+ case "PageDown":
734
+ this.focusPageDown();
735
+ break;
736
+ case "PageUp":
737
+ this.focusPageUp();
738
+ break;
739
+ case "Home":
740
+ this.focusFirst();
741
+ break;
742
+ case "End":
743
+ this.focusLast();
744
+ break;
745
+ default:
746
+ this.handleTypeahead(event);
747
+ return;
748
+ }
749
+ }
750
+ // ─── Private: type-ahead поиск ───
751
+ /**
752
+ * Быстрый поиск по набору: печатаешь символы — курсор перепрыгивает к первой строке,
753
+ * чей label начинается с набранной последовательности. Повтор одного и того же
754
+ * символа циклически перебирает совпадения; набор из разных символов уточняет
755
+ * префикс, начиная с текущей строки. Буфер копится, пока не выйдет тайм-аут.
756
+ */
757
+ handleTypeahead(event) {
758
+ if (!this.typeaheadEnabled)
759
+ return;
760
+ if (event.ctrlKey || event.altKey || event.metaKey)
761
+ return;
762
+ const ch = event.key;
763
+ // Только одиночные печатные символы: имена клавиш ("Enter", "F1"), управляющие
764
+ // символы и пробел (уже занят сворачиванием) в поиск не идут.
765
+ if (ch.length !== 1 || ch === " " || ch.charCodeAt(0) < 0x20)
766
+ return;
767
+ this.typeaheadBuffer += ch;
768
+ this.restartTypeaheadTimer();
769
+ this.jumpToTypeaheadMatch();
770
+ }
771
+ restartTypeaheadTimer() {
772
+ if (this.typeaheadTimer !== null) {
773
+ clearTimeout(this.typeaheadTimer);
774
+ }
775
+ this.typeaheadTimer = setTimeout(() => {
776
+ this.typeaheadTimer = null;
777
+ this.typeaheadBuffer = "";
778
+ }, TYPEAHEAD_TIMEOUT_MS);
779
+ // Незавершённый таймер поиска не должен удерживать процесс от выхода.
780
+ this.typeaheadTimer.unref();
781
+ }
782
+ jumpToTypeaheadMatch() {
783
+ const rows = this.ensureProjection();
784
+ const count = rows.length;
785
+ if (count === 0)
786
+ return;
787
+ const buffer = this.typeaheadBuffer.toLowerCase();
788
+ // Повтор одного символа ("aa", "aaa") трактуем как перебор совпадений по одной
789
+ // букве, а не как буквальный префикс — так работает быстрый поиск в проводниках.
790
+ const allSameChar = Array.from(buffer).every((c) => c === buffer[0]);
791
+ const prefix = allSameChar ? buffer[0] : buffer;
792
+ const cycling = buffer.length >= 2 && allSameChar;
793
+ const start = cycling ? this.cursorIndex + 1 : this.cursorIndex;
794
+ for (let i = 0; i < count; i++) {
795
+ const idx = (start + i) % count;
796
+ const label = rows[idx].label;
797
+ if (label !== undefined && label.toLowerCase().startsWith(prefix)) {
798
+ this.setSelectedIndex(idx);
799
+ return;
800
+ }
801
+ }
802
+ }
803
+ handleExpandOrMoveToChild() {
804
+ const rows = this.ensureProjection();
805
+ const row = rows[this.cursorIndex];
806
+ if (!row || !this.rowHasChildren(row.id))
807
+ return;
808
+ if (this.collapsedIds.has(row.id)) {
809
+ this.setCollapsed(row.id, false);
810
+ }
811
+ else if (this.cursorIndex + 1 < rows.length && rows[this.cursorIndex + 1].depth > row.depth) {
812
+ // Уже раскрыта — перейти на первого ребёнка.
813
+ this.setSelectedIndex(this.cursorIndex + 1);
814
+ }
815
+ }
816
+ handleCollapseOrMoveToParent() {
817
+ const rows = this.ensureProjection();
818
+ const row = rows[this.cursorIndex];
819
+ if (!row)
820
+ return;
821
+ if (this.rowHasChildren(row.id) && !this.collapsedIds.has(row.id)) {
822
+ this.setCollapsed(row.id, true);
823
+ }
824
+ else if (row.parentId !== null) {
825
+ const parentIndex = this.visibleIndexById.get(row.parentId);
826
+ /* v8 ignore start -- defensive: a visible child's parent is always visible too */
827
+ if (parentIndex !== undefined) {
828
+ this.setSelectedIndex(parentIndex);
829
+ }
830
+ /* v8 ignore stop */
831
+ }
832
+ }
833
+ activateCursorRow() {
834
+ const rows = this.ensureProjection();
835
+ const row = rows[this.cursorIndex];
836
+ if (!row)
837
+ return;
838
+ this.onActivate?.(row.element);
839
+ }
840
+ toggleCursorRow() {
841
+ const rows = this.ensureProjection();
842
+ const row = rows[this.cursorIndex];
843
+ if (!row || !this.rowHasChildren(row.id))
844
+ return;
845
+ this.toggleCollapsed(row.id);
846
+ }
847
+ /**
848
+ * Ре-диспатчит click/dblclick в поддерево видимой строки; true = ребёнок
849
+ * потребил событие (какой-то listener вызвал `preventDefault()`).
850
+ *
851
+ * Хит-тест идёт от элемента строки, так что результат по построению лежит в её
852
+ * поддереве. Доверять позициям детей можно только строкам, разложенным последним
853
+ * performLayout на своих местах: скролл или пересборка проекции между кадрами
854
+ * инвалидируют окно, и делегация отключается до следующего кадра — иначе
855
+ * незалейаученная строка с ленивыми (0,0)/80×24 поймала бы чужой клик.
856
+ */
857
+ delegateToRowChild(index, row, event) {
858
+ if (this.scrollTop !== this.lastLayoutScrollTop)
859
+ return false;
860
+ if (index >= this.lastLayoutScrollTop + this.lastLayoutViewportHeight)
861
+ return false;
862
+ const hit = row.element.elementFromPoint(new Point(event.screenX, event.screenY));
863
+ if (hit === null || hit === row.element)
864
+ return false;
865
+ const synthetic = new TUIMouseEvent(event.type, {
866
+ button: event.button,
867
+ screenX: event.screenX,
868
+ screenY: event.screenY,
869
+ localX: event.screenX - hit.globalPosition.x,
870
+ localY: event.screenY - hit.globalPosition.y,
871
+ shiftKey: event.shiftKey,
872
+ altKey: event.altKey,
873
+ ctrlKey: event.ctrlKey,
874
+ });
875
+ return !hit.dispatchEvent(synthetic);
876
+ }
877
+ handleClick(event) {
878
+ const rows = this.ensureProjection();
879
+ const index = this.scrollTop + event.localY;
880
+ if (index < 0 || index >= rows.length)
881
+ return;
882
+ const row = rows[index];
883
+ if (event.button !== "left")
884
+ return; // правый клик несёт событие contextmenu
885
+ if (event.ctrlKey) {
886
+ this.toggleSelectionAt(index);
887
+ return;
888
+ }
889
+ if (event.shiftKey) {
890
+ this.cursorIndex = index;
891
+ this.selectRange(this.anchorIndex, index);
892
+ this.applyCursor(index);
893
+ return;
894
+ }
895
+ if (this.delegateToRowChild(index, row, event))
896
+ return;
897
+ this.setSelectedIndex(index);
898
+ // Клик по колонке шеврона сворачивает/раскрывает.
899
+ if (this.hasCollapsibleRows && this.rowHasChildren(row.id)) {
900
+ const chevronX = row.depth * this.indentSize;
901
+ if (event.localX >= chevronX && event.localX <= chevronX + 1) {
902
+ this.toggleCollapsed(row.id);
903
+ }
904
+ }
905
+ }
906
+ handleDblClick(event) {
907
+ const rows = this.ensureProjection();
908
+ const index = this.scrollTop + event.localY;
909
+ if (index < 0 || index >= rows.length)
910
+ return;
911
+ const row = rows[index];
912
+ if (this.delegateToRowChild(index, row, event))
913
+ return;
914
+ if (this.rowHasChildren(row.id)) {
915
+ this.toggleCollapsed(row.id);
916
+ }
917
+ else {
918
+ this.onActivate?.(row.element);
919
+ }
920
+ }
921
+ handleMouseMove(event) {
922
+ const rows = this.ensureProjection();
923
+ const index = this.scrollTop + event.localY;
924
+ const newHovered = index >= 0 && index < rows.length ? index : null;
925
+ if (newHovered !== this.hoveredIndex) {
926
+ this.hoveredIndex = newHovered;
927
+ this.markDirty();
928
+ }
929
+ }
930
+ handleMouseLeave() {
931
+ if (this.hoveredIndex !== null) {
932
+ this.hoveredIndex = null;
933
+ this.markDirty();
934
+ }
935
+ }
936
+ handleWheel(event) {
937
+ if (event.wheelDirection === "up") {
938
+ this.scrollBy(0, -3);
939
+ }
940
+ else if (event.wheelDirection === "down") {
941
+ this.scrollBy(0, 3);
942
+ }
943
+ }
944
+ }