Repository navigation
Expand file tree
/
Copy pathpanel-compact.js
More file actions
234 lines (217 loc) · 13.2 KB
/
Copy pathpanel-compact.js
File metadata and controls
234 lines (217 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
'use strict';
/**
* panel-compact.js — панель сама решает, когда ей стать компактной.
*
* Жалоба 19.08.2026 с кадром: на широком окне подсказка внизу обрезана
* наполовину. Компактный режим был привязан к ВЫСОТЕ окна (`@media
* (max-height: 700px)`), а высота — не тот вопрос: помещается содержимое или
* нет, зависит ещё и от ширины (перенос подсказок), от языка и от того, какие
* строки сейчас показаны. Окно 761×737 в компактный порог не попадало, а
* содержимое в него не влезало.
*
* Правильный признак — ЗАМЕР: содержимое выше окна → сжимаемся. Медиазапрос
* этого знать не может в принципе.
*
* Решение принимается ЗАМЕРОМ В ТОМ СОСТОЯНИИ, в которое собираемся перейти:
* панель растянута на всё окно (герой забирает свободную высоту), поэтому в
* помещающемся состоянии `scrollHeight` РАВЕН высоте окна и «сколько нужно на
* самом деле» по нему не узнать. Разжатие проверяется так же, как полоса
* сверху в дисплее меряет уступку рамы: снять, пересчитать, померить.
*
* Логика решения — чистая функция на внедрённых замерах
* (tests/panel-compact.test.js), проводка — отдельно.
*/
/**
* Помещается ли содержимое.
*
* Запас в пиксель: содержимое РОВНО в окно — это «влезло», а не «почти».
*/
function fits(content, client) {
if (!Number.isFinite(content) || !Number.isFinite(client) || client <= 0) { return true; }
return content <= client + 1;
}
/**
* Решение принимается ЗАМЕРОМ В ТОМ СОСТОЯНИИ, в которое собираемся перейти.
*
* Почему не «сжались — влезло — разожмёмся»: панель растянута на всё окно
* (герой забирает свободную высоту), поэтому `scrollHeight` в помещающемся
* состоянии РАВЕН высоте окна и «сколько нужно на самом деле» по нему не
* узнать. Отсюда единственный честный способ проверить, пора ли разжиматься:
* снять класс, дать браузеру пересчитать раскладку и померить снова — тот же
* приём, что и «меряем при нулевой уступке» у полосы сверху в дисплее.
*
* @param {object} io { isCompact(), setCompact(v), measure() } — всё, что
* нужно знать функции о внешнем мире; в тестах подставляется поддельное.
* @returns {boolean} состояние после решения
*/
function decideCompact(io) {
if (!io.isCompact()) {
const cramped = !fits(...io.measure());
if (cramped) { io.setCompact(true); }
return cramped;
}
// Уже сжаты: снимаем сжатие и смотрим, помещается ли без него.
io.setCompact(false);
const roomy = fits(...io.measure());
if (!roomy) { io.setCompact(true); }
return !roomy;
}
/**
* Предохранитель от петли ступеней.
*
* Жалоба 27.10.2026, видео с Windows 11: в каждом кадре видны ДВЕ
* ступени сразу — крупные и мелкие цифры, два ряда настроек. Ступень менялась
* чаще кадра, пока пробел не запускал таймер (старт прячет пресеты, и
* содержимое уходит от границы). На macOS петля не воспроизводится ни на одном
* размере окна при масштабе 1 / 1.25 / 1.5: звено, которое её замыкает, живёт
* в Windows (дробный масштаб, метрики шрифта, полоса прокрутки).
*
* Гистерезис «разжимайся только с запасом» тут не построить: растянутая панель
* в помещающемся состоянии меряет `scrollHeight` РОВНО в окно, и запаса по
* замеру не видно. Поэтому ловится сама петля: больше `limit` смен ступени за
* `windowMs` пользователь не делает — это раскачка.
*
* @param {{now: () => number, limit?: number, windowMs?: number}} o
* @returns {{record: (level: number) => boolean}} true — петля поймана
*/
function createFlipBreaker({ now, limit = 4, windowMs = 1000 }) {
let stamps = [];
return {
record() {
const t = now();
stamps = stamps.filter((s) => t - s < windowMs);
stamps.push(t);
return stamps.length > limit;
}
};
}
// Насколько должно измениться окно, чтобы предохранитель отпустил: дробный
// масштаб Windows сам качает размер на пиксель, и это не новое окно.
const UNLOCK_DELTA_PX = 4;
/**
* Повесить автоматический компактный режим на панель.
*
* @param {object} deps
* @param {Document} deps.doc
* @param {string} [deps.selector] что меряем
* @param {string[]} [deps.classNames] классы ступеней сжатия на <body>
* @param {() => number} [deps.now] часы предохранителя (в тестах — поддельные)
* @param {() => {w: number, h: number}} [deps.getViewport] размер окна
* @returns {{update: () => boolean, naturalHeight: () => number, stop: () => void, locked: () => boolean}|null}
*/
function bindCompactMode({
doc, selector = '.control-panel', classNames = ['compact-panel', 'compact-panel-2'],
now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
getViewport = () => (typeof window !== 'undefined' ? { w: window.innerWidth, h: window.innerHeight } : { w: 0, h: 0 })
}) {
const panel = /** @type {HTMLElement|null} */ (doc && doc.querySelector ? doc.querySelector(selector) : null);
if (!panel) { return null; }
const body = doc.body;
/**
* Ступеней сжатия ДВЕ, и вторая нужна не для красоты.
*
* Первая убирает воздух и уменьшает цифры — этого хватает, пока настроек
* на экране обычное количество. Но «Считать ниже нуля» показывает ещё две
* строки, и на окне минимальной высоты содержимое остаётся выше окна даже
* сжатым (замер 19.08.2026: 700px при окне 660). Вторая ступень убирает то,
* без чего интерфейс остаётся понятным: подсказку под цифрами (её текст
* дублирует само действие — по цифрам и так кликают), ещё немного кегля и
* высоты транспорта.
*
* Дальше второй ступени не идём: панель прокручивается, и это честный
* ответ на окно, в которое не помещается даже сжатое.
*/
const setLevel = (n) => {
classNames.forEach((c, i) => body.classList.toggle(c, i < n));
// Чтение форсирует пересчёт раскладки: без него следующий замер вернул
// бы высоту ПРЕЖНЕГО состояния.
void panel.offsetHeight;
};
const currentLevel = () => classNames.filter((c) => body.classList.contains(c)).length;
const breaker = createFlipBreaker({ now });
let lockedAt = null; // размер окна, на котором петля поймана
// Каждую ступень проверяем ЗАМЕРОМ в ней самой, начиная с нулевой: так
// решение не зависит от того, с какой стороны мы в неё пришли.
const measureLevel = () => {
for (let n = 0; n <= classNames.length; n++) {
setLevel(n);
if (fits(panel.scrollHeight, panel.clientHeight)) { return n; }
}
return classNames.length;
};
const update = () => {
const prev = currentLevel();
if (lockedAt) {
const vp = getViewport();
const moved = Math.abs(vp.w - lockedAt.w) >= UNLOCK_DELTA_PX
|| Math.abs(vp.h - lockedAt.h) >= UNLOCK_DELTA_PX;
// Замороженная ступень не пробуется заново: сам перебор ступеней и
// есть то, что раскачивает панель.
if (!moved) { return prev > 0; }
lockedAt = null;
}
const next = measureLevel();
if (next !== prev && breaker.record(next)) {
// Петля: встаём на самую сжатую из двух. На ней содержимое
// помещается в обоих состояниях, а лишний воздух лучше обрезанного.
const safe = Math.max(next, prev);
setLevel(safe);
lockedAt = { ...getViewport() };
console.warn(`[panel-compact] петля ступеней ${prev}↔${next}, ступень ${safe} заморожена до смены размера окна`
+ ` (окно ${lockedAt.w}×${lockedAt.h}, содержимое ${panel.scrollHeight} при ${panel.clientHeight})`);
return safe > 0;
}
return next > 0;
};
let scheduled = false;
const schedule = () => {
if (scheduled) { return; }
scheduled = true;
const run = () => {
scheduled = false;
update();
};
if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(run); } else { run(); }
};
let observer = null;
if (typeof ResizeObserver === 'function') {
// Наблюдаем коробку панели И КАЖДУЮ ЕЁ СЕКЦИЮ.
//
// Первая версия смотрела на панель и её первого ребёнка — и пропускала
// самый частый случай: тумблер «Считать ниже нуля» показывает две
// строки настроек В СЕРЕДИНЕ панели. Высота самой панели при этом не
// меняется (её задаёт окно), первый ребёнок тоже не меняется, а
// содержимое вырастает на 46px и перестаёт помещаться. Замер
// 19.08.2026: после тумблера содержимое 700 при окне 660, а пересчёт
// не срабатывал вовсе — ручной вызов возвращал 660.
observer = new ResizeObserver(schedule);
observer.observe(panel);
for (const child of panel.children) { observer.observe(child); }
}
const onResize = schedule;
if (typeof window !== 'undefined') { window.addEventListener('resize', onResize); }
update();
/**
* Сколько панели нужно НЕСЖАТОЙ — для подгонки окна (autoResizeWindow).
* В окне ниже содержимого ступень ужимает панель ровно в окно, и замер
* сжатой возвращал бы высоту окна, а не содержимого. Ступень после замера
* возвращается прежней: решает её update() по resize, а не подгонка.
*/
const naturalHeight = () => {
const prev = currentLevel();
setLevel(0);
const h = panel.scrollHeight;
setLevel(prev);
return h;
};
return {
update,
naturalHeight,
locked: () => lockedAt !== null,
stop() {
if (observer) { observer.disconnect(); }
if (typeof window !== 'undefined') { window.removeEventListener('resize', onResize); }
}
};
}
export { fits, decideCompact, createFlipBreaker, bindCompactMode };