Repository navigation
Expand file tree
/
Copy pathpanel-display.js
More file actions
982 lines (912 loc) · 58.8 KB
/
Copy pathpanel-display.js
File metadata and controls
982 lines (912 loc) · 58.8 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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
'use strict';
/**
* panel-display.js — настройки полноэкранного окна в панели: ОДНА сборка их
* payload, проводка тумблеров блоков и приём «блок закрыли крестиком».
*
* Зачем модуль, а не строки в inline-скрипте:
*
* 1. Блоков дисплея стало пять, и у каждого свой тумблер (17.08.2026). Список
* тумблеров нужен в ТРЁХ местах панели — навесить обработчики, собрать
* payload, принять сообщение о закрытии крестиком. Три копии списка — это
* ровно тот дефект, ради которого написан settings-schema.js: блок
* добавляют в одну копию и забывают в другой, и настройка работает до
* перезапуска. Здесь список ОДИН, и он тот же, что в таблице настроек.
*
* 2. Потолок `electron-control.html` (tests/control-decomposition.test.js)
* сработал по назначению: первая версия правки писала всё это прямо в
* inline-скрипте и упёрлась в него. Заодно наружу уехала `pushDisplaySettings`
* — единственная сборка payload канала `display-settings-update`.
*
* Это ПРОТОТИПНЫЙ МИКСИН, как panel-colors.js и panel-state.js: методы
* обращаются к this.currentBgMode, this.syncClockStyle, this.saveExtSettings()
* и к полям контроллера с ссылками на контролы. Если строка Object.assign
* потеряется, при загрузке не упадёт ничего — упадёт при первом клике.
*
* Чистая часть (collectDisplayToggles на переданном документе) проверяется в
* Node на поддельном DOM.
*/
/**
* Тумблеры дисплея: пять блоков и две подписи.
*
* Сам перечень живёт в settings-schema.js — он нужен ещё и главному процессу
* (проверка имени блока, пришедшего из окна), а таблица настроек и без того
* владеет ключами. Здесь только разделение обязанностей: крестик на дисплее
* закрывает БЛОКИ, подписи выключаются лишь тумблером в панели.
*/
import * as SecurityUtils from './security.js';
import * as MoneyMeter from './money-meter.js';
import * as RendererShared from './renderer-shared.js';
import * as HeroModes from './hero-modes.js';
import * as Schema from './settings-schema.js';
const BLOCK_KEYS = Schema.DISPLAY_BLOCK_KEYS;
const LABEL_KEYS = Schema.DISPLAY_LABEL_KEYS;
const DISPLAY_TOGGLE_KEYS = BLOCK_KEYS.concat(LABEL_KEYS);
/**
* Реестр подписей: id элемента → ключ настройки и стандартное слово.
*
* Берётся из display-layouts.js, а не пишется здесь списком: то же знание
* нужно дисплею (подставить стандартную подпись) и тесту (сверить разметку),
* и три копии слова «Начало» разъехались бы молча.
*/
import * as Layouts from './display-layouts.js';
import { Toast } from './ui-feedback.js';
import { openModal, closeModal } from './modal-manager.js';
/** Объект из строки хранилища; битое или пустое значение — пустой объект. */
function safeParseObject(raw) {
if (typeof raw !== 'string' || raw === '') { return {}; }
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch { return {}; }
}
/**
* Значения полей с кастомными подписями блоков.
*
* @param {Document} doc
* @returns {object} ключ настройки → введённая строка (как есть)
*
* Обрезкой и подстановкой умолчания занимается ДИСПЛЕЙ (blockCaption): панель
* хранит ровно то, что человек напечатал, иначе его собственный текст
* менялся бы под курсором.
*/
function collectBlockLabels(doc) {
const out = {};
for (const el of Layouts.LABELLED_ELEMENTS) {
const node = doc.getElementById(el.labelKey);
if (node) { out[el.labelKey] = String(node.value || ''); }
}
return out;
}
/**
* Значения всех тумблеров дисплея одним объектом.
*
* @param {Document} doc
* @returns {object} ключ → boolean; отсутствующий контрол пропускается
*/
function collectDisplayToggles(doc) {
const out = {};
for (const key of DISPLAY_TOGGLE_KEYS) {
const el = doc.getElementById(key);
if (el) { out[key] = !!el.checked; }
}
return out;
}
/**
* Картинка фона — в payload только при СМЕНЕ (BUG-10).
*
* Весь фон — до ~13 М символов base64 — уходил в `display-settings-update` на
* каждое нажатие клавиши в названии мероприятия и каждый шаг ползунка, а
* главный процесс пересылал его дисплею и часам. Отсутствие ключа теперь
* значит «без изменений», пустая строка — «картинки нет». Главный процесс
* помнит последнюю и досылает её окну, открытому позже; дисплей держит копию.
*
* @param {object} settings — собираемый payload (дополняется на месте)
* @param {string} image — картинка, которую дисплей должен показывать сейчас
* @param {string|undefined} lastSent — последняя отправленная
* @returns {string} новая «последняя отправленная»
*/
function attachChangedBgImage(settings, image, lastSent) {
if (image !== lastSent) { settings.bgLocalImage = image; }
return image;
}
/**
* Список мониторов: «Авто» и по опции на экран, выбор — из сохранённого.
*
* Сохранённое значение (`selectedDisplay`) пришло из хранилища, то есть это
* чужие данные. Прежде оно встраивалось в CSS-селектор
* `option[value="${saved}"]`, и кавычка в нём роняла querySelector
* исключением — список мониторов не строился вовсе (BUG-18). Значение
* СРАВНИВАЕТСЯ с опциями, а в селектор не попадает никогда.
*
* @param {HTMLSelectElement} select
* @param {Array<{bounds: {x:number, y:number, width:number, height:number}}>} displays
* @param {string|null} saved
* @param {Document} doc
*/
function fillDisplaySelect(select, displays, saved, doc) {
select.replaceChildren();
const auto = doc.createElement('option');
auto.value = 'auto';
auto.textContent = 'Авто';
select.appendChild(auto);
(Array.isArray(displays) ? displays : []).forEach((display, index) => {
const option = doc.createElement('option');
option.value = index.toString();
const isPrimary = display.bounds.x === 0 && display.bounds.y === 0;
const label = isPrimary ? 'Основной' : `Монитор ${index + 1}`;
option.textContent = `${label} (${display.bounds.width}×${display.bounds.height})`;
select.appendChild(option);
});
const known = Array.from(select.options).some((o) => o.value === saved);
select.value = known ? saved : 'auto';
}
const PanelDisplayMixin = {
/** Список мониторов пришёл от главного процесса — перестроить выбор. */
updateDisplaysList(displays) {
fillDisplaySelect(this.displaySelectEl, displays, localStorage.getItem('selectedDisplay'), document);
},
/**
* Единственная сборка payload канала `display-settings-update`.
*
* Здесь же и сохранение: настройки дисплея пишутся в `displayExtSettings`
* тем же движением, каким уходят в окно. Разъехаться этим двум нельзя —
* иначе после перезапуска дисплей получит не то, что показывал.
*/
pushDisplaySettings() {
this.saveExtSettings();
const parse = SecurityUtils.safeJSONParse;
// Хранилище читается только в режиме «Файл»: строка до 13 М символов
// на каждое нажатие клавиши не нужна и внутри панели.
const localBgImage = this.currentBgMode === 'local'
? (localStorage.getItem('localBgImage') || '')
: '';
const localBgSettings = parse(localStorage.getItem('localBgSettings'), {});
const styleEl = this.displayTimerStyleEl || this.timerStyleEl;
const scaleEl = this.displayTimerScaleEl || this.timerScaleEl;
/** @type {DisplaySettingsUpdate} (types/ipc-payloads.d.ts) */
const base = {
bgMode: this.currentBgMode,
bgSolid: document.getElementById('bgSolidColor').value,
bgGrad1: document.getElementById('bgGrad1').value,
bgGrad2: document.getElementById('bgGrad2').value,
bgLocalFit: localBgSettings.fit || 'cover',
bgLocalOverlay: RendererShared.bgOverlayPercent(localBgSettings.overlay),
eventTime: this.eventTimeInputEl.value,
endTime: this.endTimeInputEl.value,
eventTitle: this.eventTitleInputEl ? this.eventTitleInputEl.value : '',
// Режим центрального времени и своя подпись КАЖДОГО режима.
// Подписи уходят все три, а не только текущего режима: дисплей
// хранит их для всех и переключение режима не должно требовать
// повторного ввода.
heroMode: this.heroMode || HeroModes.DEFAULT_MODE,
labelHeroCurrent: this.heroLabelInputs.current ? this.heroLabelInputs.current.value : '',
labelHeroToStart: this.heroLabelInputs['to-start'] ? this.heroLabelInputs['to-start'].value : '',
labelHeroToEnd: this.heroLabelInputs['to-end'] ? this.heroLabelInputs['to-end'].value : '',
timeBlocksScale: parseInt(this.timeBlocksScaleEl.value, 10),
// Стиль и масштаб идут ПОД СВОИМИ именами. Общие
// `timerStyle`/`timerScale` остаются рядом: на них опирается откат
// на предыдущую версию и существующие e2e-спеки, которые шлют голое
// общее имя. В localStorage под общим именем лежит стиль ВИДЖЕТА
// (settings-schema.js, alsoWrite), поэтому дисплей выбирает своё имя
// первым — см. pickOwnSetting в display-script.js.
displayTimerStyle: styleEl.value,
displayTimerScale: parseInt(scaleEl.value, 10),
timerStyle: styleEl.value,
timerScale: parseInt(scaleEl.value, 10),
displayDigitsFont: this.displayDigitsFontEl ? this.displayDigitsFontEl.value : 'inter',
// Скрытый режим «47-й этаж». Ставка идёт СТРОКОЙ — к числу её
// приводит money-meter.js, один раз и одинаково для обоих счётчиков.
overrunPrice: this.overrunPriceEl ? this.overrunPriceEl.value : '1000',
overrunPeriod: this.overrunPeriodEl ? this.overrunPeriodEl.value : '3',
floor47Unlocked: this.isFloor47Unlocked(),
reportOnlyOverruns: this.reportOnlyOverrunsEl ? this.reportOnlyOverrunsEl.checked : false
};
const settings = Object.assign(base, collectDisplayToggles(document), collectBlockLabels(document));
// Картинка — только при смене: см. attachChangedBgImage (BUG-10).
this._sentBgLocalImage = attachChangedBgImage(
settings, this.currentBgMode === 'local' ? localBgImage : '', this._sentBgLocalImage);
window.ipcRenderer.send('display-settings-update', settings);
},
/**
* Проводка тумблеров блоков, полей времени и названия — одним списком.
*
* Прежде здесь стоял общий тумблер «Показывать блоки», который ещё и прятал
* ряды настроек. Ряды теперь видны всегда: они описывают блоки независимо
* от того, показан ли каждый, а спрятанный ряд — это настройка, которой для
* пользователя не существует.
*/
bindDisplayBlockControls() {
for (const key of DISPLAY_TOGGLE_KEYS) {
const el = document.getElementById(key);
if (el) { el.addEventListener('change', () => this.pushDisplaySettings()); }
}
if (this.eventTimeInputEl) {
this.eventTimeInputEl.addEventListener('change', () => this.pushDisplaySettings());
}
if (this.endTimeInputEl) {
this.endTimeInputEl.addEventListener('change', () => this.pushDisplaySettings());
}
if (this.eventTitleInputEl) {
this.eventTitleInputEl.addEventListener('input', () => this.pushDisplaySettings());
}
// Режим центрального времени. Ряд кнопок ведёт себя как bgMode:
// владелец значения — this.heroMode, кнопки лишь его ВИД.
this.heroMode = HeroModes.DEFAULT_MODE;
this.heroLabelInputs = {};
for (const mode of HeroModes.HERO_MODES) {
if (!mode.labelKey) { continue; }
this.heroLabelInputs[mode.id] = document.getElementById(mode.labelKey);
}
document.querySelectorAll('.hero-mode-btn').forEach((btn) => {
btn.addEventListener('click', () => {
this.setHeroMode(btn.dataset.mode);
this.pushDisplaySettings();
});
});
for (const input of Object.values(this.heroLabelInputs)) {
if (input) { input.addEventListener('input', () => this.pushDisplaySettings()); }
}
// Замок читается ДО рядов подписей: ряды спрашивают его про секретные
// элементы. bindFloor47 в конце сам зовёт renderFloor47, а тот —
// bindBlockLabelRows, поэтому второго вызова здесь нет.
this.bindFloor47();
},
/**
* Смена режима героя.
*
* Поле заголовка ОДНО на четыре режима: показывается вход того режима,
* который выбран. Четыре видимых поля рядом означали бы, что три из них
* ни на что не влияют прямо сейчас, — а надпись обязана обещать правду.
*/
setHeroMode(id) {
const mode = HeroModes.modeById(id);
this.heroMode = mode.id;
document.querySelectorAll('.hero-mode-btn').forEach((btn) => {
const on = btn.dataset.mode === mode.id;
btn.classList.toggle('active', on);
btn.setAttribute('aria-checked', on ? 'true' : 'false');
});
for (const [modeId, input] of Object.entries(this.heroLabelInputs)) {
if (input) { input.hidden = modeId !== mode.id; }
}
const row = document.getElementById('heroLabelRow');
const note = document.getElementById('heroTimerLabelNote');
const hint = document.getElementById('heroLabelHint');
const isTimer = mode.id === 'timer';
if (row) { row.hidden = isTimer; }
if (note) { note.hidden = !isTimer; }
// Подпись поля ведёт к тому входу, который сейчас виден: иначе `for`
// указывает на скрытый элемент и клик по подписи не делает ничего.
if (!isTimer) {
const label = row && row.querySelector('.toggle-label');
if (label) { label.setAttribute('for', mode.labelKey); }
if (hint) { hint.textContent = `Пустое поле вернёт «${HeroModes.heroCaption(mode.id)}».`; }
}
},
/**
* Поля «своё название плашки» — по одному на блок времени.
*
* Разметку строит модуль, а не HTML: список блоков живёт в реестре
* (display-layouts.js), и ряды обязаны выводиться ИЗ него. Стандартное
* слово стоит и подписью ряда, и placeholder'ом поля — так видно, что
* покажет дисплей, если поле оставить пустым.
*
* Значение поля таблица настроек раскладывает сама (ключ = id контрола),
* поэтому ряды строятся ДО applyStoredSettings — иначе класть было бы
* некуда. Порядок держит bindDisplayBlockControls, вызываемая при сборке
* контроллера.
*/
isFloor47Unlocked() {
return !!(this.floor47UnlockedEl && this.floor47UnlockedEl.checked);
},
/**
* Скрытый режим «47-й этаж».
*
* Разблокировка — тройной клик по строке подсказок в подвале панели. Один
* жест мышью, без клавиатуры и без открытия справки; строка неинтерактивна,
* случайно трижды по ней не щёлкают. Это УДОБСТВО, а не защита: кто откроет
* профиль — увидит флаг. Настоящего замка здесь нет, и делать вид, что есть,
* не нужно: задача — чтобы контролы про деньги не мозолили глаза на обычных
* мероприятиях.
*
* `event.detail` считает браузер: у третьего клика подряд он равен 3. Свой
* счётчик с таймаутом был бы второй реализацией того же и разъехался бы с
* первой при первой же правке.
*/
/**
* Разметка скрытого режима: секция настроек и модалка подтверждения.
*
* Строит МОДУЛЬ, а не HTML, — по тому же правилу, что ряды подписей выше и
* ряд «Фон» в panel-colors.js. Здесь у него есть и вторая причина:
* `electron-control.html` живёт под храповиком (tests/control-decomposition),
* и число его строк может только убывать. Пятьдесят строк статики внутри
* god-файла подняли бы потолок, а потолок не поднимают.
*
* Строится ДО applyStoredSettings: таблица настроек раскладывает значения
* по getElementById, и класть их было бы некуда. Порядок держит
* bindDisplayBlockControls, вызываемая при сборке контроллера.
*/
/**
* Справка по скрытому режиму «47-й этаж».
*
* Строит ТОТ ЖЕ модуль, что строит саму настройку: два описания одного
* режима — это две правды, и расходятся они молча. По той же причине текст
* называет контролы теми же словами, какими они подписаны выше в этом
* файле.
*
* Секция видна ВСЕГДА, а не только при разблокированном режиме, — иначе
* ответ «как включить» прячется ровно от того, кто его ищет.
*
* Собирается в конструкторе контроллера, то есть ДО
* `querySelectorAll('.faq-question')` в панели: единственный обработчик
* аккордеона забирает эти вопросы вместе с остальными. Второй обработчик
* тут завёлся бы легко и сломал бы раскрытие всей справки.
*/
buildFloor47Help() {
const mount = document.getElementById('faq47Mount');
if (!mount || mount.childElementCount > 0) { return; }
const ITEMS = [
['Что это такое', [
'Режим для мероприятий с регламентом: приложение считает деньги за превышение времени доклада.',
'На дисплее показываются две суммы — сколько набежало у текущего докладчика и сколько накопилось за всё мероприятие.'
]],
['Как включить и выключить', [
'Включить: <strong>тройной клик</strong> по подвалу панели — строке с подсказками в самом низу окна.',
'Внизу вкладки <strong>Дисплей</strong> появится раздел со ставкой, тумблерами и командами мероприятия.',
'Выключить: тумблер <strong>Показывать этот раздел</strong> в конце того же раздела. Накопленное при этом не стирается.'
]],
['Ставка: сумма и период', [
'<strong>Сумма, ₽</strong> — сколько стоит один период перелимита. <strong>За каждые, сек</strong> — длина периода.',
'Ступень целая: при 1000 ₽ за 3 секунды первая тысяча набегает на третьей секунде минуса, вторая на шестой. Одна и две секунды стоят 0 ₽.',
'Ставку можно поправить посреди мероприятия — накопленное пересчитается по новой.'
]],
['Что видно на дисплее', [
'<strong>Перелимит</strong> — сколько набежало у текущего доклада. <strong>Итого</strong> — сумма за всё мероприятие.',
'Каждый включается своим тумблером в группе <strong>Показывать на дисплее</strong>.',
'Обе плашки таскаются мышью и масштабируются <strong>Shift + колесо</strong>, как остальные блоки дисплея.',
'Готовая раскладка <strong>47-й этаж</strong> ставит деньги по бокам от таймера. Она появляется в списке раскладок вместе с режимом.'
]],
['Как считаются доклады', [
'Доклад закрывается сам, как только таймер выходит из минуса: сброс, новое время, выбор пресета. Отдельной кнопки «доклад окончен» нет.',
'Секунды перелимита складываются <strong>до</strong> перевода в деньги. Поэтому два доклада по 2 секунды при ставке 1000 ₽ / 3 с стоят 1000 ₽, хотя в каждый отдельный момент на экране горел ноль.'
]],
['Завершить и начать заново', [
'Строка над кнопками всегда говорит, идёт мероприятие или заморожено и на какой сумме.',
'<strong>Завершить мероприятие</strong> — итог замирает на текущей сумме. Дальше перелимиты в него не идут, «Перелимит» показывает 0 ₽.',
'<strong>Новое мероприятие</strong> — накопленное стирается насовсем, вместе с текущим минусом, если таймер в нём.',
'Обе команды спрашивают подтверждение: вернуть стёртое нечем.'
]]
];
const section = document.createElement('div');
section.className = 'faq-section';
const title = document.createElement('div');
title.className = 'faq-section-title';
title.textContent = 'Перелимит доклада';
section.appendChild(title);
for (const [question, paragraphs] of ITEMS) {
const item = document.createElement('div');
item.className = 'faq-item';
// Кнопка, а не <div>: вопрос обязан получать фокус и открываться с
// клавиатуры. Разметка повторяет соседние вопросы дословно —
// обработчик аккордеона ищет их по классу.
const btn = document.createElement('button');
btn.className = 'faq-question';
btn.type = 'button';
btn.setAttribute('aria-expanded', 'false');
btn.textContent = question;
const arrow = document.createElement('span');
arrow.className = 'arrow';
arrow.setAttribute('aria-hidden', 'true');
arrow.textContent = '⌄';
btn.appendChild(arrow);
const answer = document.createElement('div');
answer.className = 'faq-answer';
for (const html of paragraphs) {
const p = document.createElement('p');
// Разметка тут своя, из литералов выше, а не пользовательская:
// жирным выделены названия контролов, и они обязаны совпадать с
// подписями в панели слово в слово.
p.innerHTML = html;
answer.appendChild(p);
}
item.appendChild(btn);
item.appendChild(answer);
section.appendChild(item);
}
mount.appendChild(section);
},
buildFloor47Markup() {
const mount = document.getElementById('floor47Mount');
if (!mount || document.getElementById('floor47Section')) { return; }
// Строка настройки: имя (и, если нужно, поясняющая подпись) слева,
// контрол справа. Подпись — не украшение: без неё «Перелимит» и
// «Итого» отличаются только словом, а означают разное — начисляемое
// прямо сейчас и накопленное за всё мероприятие.
const row = (labelText, forId, control, hintText) => {
const r = document.createElement('div');
r.className = 'toggle-row';
const text = document.createElement('div');
text.className = 'toggle-text';
const label = document.createElement('label');
label.className = 'toggle-label';
label.setAttribute('for', forId);
label.textContent = labelText;
text.appendChild(label);
if (hintText) {
const hint = document.createElement('div');
hint.className = 'toggle-hint';
hint.textContent = hintText;
text.appendChild(hint);
}
r.appendChild(text);
r.appendChild(control);
return r;
};
const subtitle = (textContent) => {
const el = document.createElement('div');
el.className = 'settings-subtitle';
el.textContent = textContent;
return el;
};
const numberInput = (id, min, step, aria) => {
const input = document.createElement('input');
input.type = 'number';
input.id = id;
input.className = 'srow-input';
input.min = String(min);
input.step = String(step);
input.inputMode = 'numeric';
input.setAttribute('aria-label', aria);
return input;
};
// Тумблер повторяет разметку соседей: label.toggle-switch + чекбокс +
// span.toggle-slider. Без span правило CSS не находит ползунок, и
// чекбокс остаётся системным.
const toggle = (id) => {
const wrap = document.createElement('label');
wrap.className = 'toggle-switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.id = id;
const slider = document.createElement('span');
slider.className = 'toggle-slider';
wrap.appendChild(input);
wrap.appendChild(slider);
return wrap;
};
const section = document.createElement('div');
section.className = 'settings-group';
section.id = 'floor47Section';
section.hidden = true;
// Три подгруппы вместо плоского списка. В плоском ставка, показ и
// команды выглядели одинаково важными и одинаково устроенными, хотя
// говорят о разном: ставка — про доклад, тумблеры — про экран, кнопки —
// про мероприятие целиком. Жалоба 28.08.2026 «непонятно, что какая
// делает» — про это.
section.appendChild(subtitle('Ставка за перелимит'));
section.appendChild(row('Сумма, ₽', 'overrunPrice',
numberInput('overrunPrice', 0, 100, 'Сумма штрафа за перелимит, рублей')));
section.appendChild(row('За каждые, сек', 'overrunPeriod',
numberInput('overrunPeriod', 1, 1, 'Период начисления штрафа, секунд')));
section.appendChild(subtitle('Показывать на дисплее'));
section.appendChild(row('Перелимит', 'showOverrunCost', toggle('showOverrunCost'),
'Сколько набежало у текущего доклада'));
section.appendChild(row('Итого', 'showTotalCost', toggle('showTotalCost'),
'Сумма за всё мероприятие'));
section.appendChild(subtitle('Мероприятие'));
// Строка-отчёт: идёт мероприятие или заморожено и на какой сумме.
// Собирается из ДЕЙСТВУЮЩИХ значений (накопитель, ставка, текущий
// минус), а не запоминается. Без неё нажатие «Завершить» не меняло в
// панели ничего, и сумму оператор читал с проекционного экрана.
const status = document.createElement('div');
status.className = 'event-status';
status.id = 'eventStatus';
// Живая величина: перечитывать её вслух должен screen reader, а не
// только видеть глазами.
status.setAttribute('role', 'status');
section.appendChild(status);
// Кнопки — в столбик и каждая со своей подписью. Пока они стояли рядом
// одинаковыми, разница между «заморозить итог» и «стереть его» жила
// только в тексте модалки, то есть открывалась уже ПОСЛЕ нажатия.
const actions = document.createElement('div');
actions.className = 'floor47-actions';
const ACTIONS = [
['eventFinishBtn', 'Завершить мероприятие', 'reset-btn',
'Итог замрёт на текущей сумме, перелимиты дальше не считаются.'],
['eventResetBtn', 'Новое мероприятие', 'reset-btn reset-btn-danger',
'Итог обнулится, накопленное будет стёрто насовсем.']
];
for (const [id, text, cls, hintText] of ACTIONS) {
const cell = document.createElement('div');
cell.className = 'floor47-action';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = cls;
btn.id = id;
btn.textContent = text;
const hint = document.createElement('div');
hint.className = 'toggle-hint';
hint.id = `${id}Hint`;
hint.textContent = hintText;
// Подпись объясняет саму кнопку — связываем их, иначе для
// screen reader кнопка остаётся такой же безымянной, какой она
// была для глаза.
btn.setAttribute('aria-describedby', hint.id);
cell.appendChild(btn);
cell.appendChild(hint);
actions.appendChild(cell);
}
section.appendChild(actions);
// Выгрузка стоит ОТДЕЛЬНО от двух действий выше и намеренно не похожа
// на них: те необратимы и спрашивают подтверждение, а эта ничего не
// меняет — она только читает то, что уже накоплено. Одинаковый вид
// заставлял бы бояться её так же, как соседних.
const exportCell = document.createElement('div');
exportCell.className = 'floor47-action';
const exportBtn = document.createElement('button');
exportBtn.type = 'button';
exportBtn.className = 'reset-btn';
exportBtn.id = 'eventExportBtn';
exportBtn.textContent = 'Выгрузить отчёт';
const exportHint = document.createElement('div');
exportHint.className = 'toggle-hint';
exportHint.id = 'eventExportBtnHint';
exportHint.textContent = 'Файл CSV: дата, перелимит каждого доклада и общий итог.';
exportBtn.setAttribute('aria-describedby', exportHint.id);
exportCell.appendChild(exportBtn);
exportCell.appendChild(exportHint);
section.appendChild(exportCell);
section.appendChild(row('Только с перелимитом', 'reportOnlyOverruns', toggle('reportOnlyOverruns'),
'Скрыть в отчёте доклады, уложившиеся в срок'));
section.appendChild(row('Показывать этот раздел', 'floor47Unlocked', toggle('floor47Unlocked')));
mount.appendChild(section);
// Модалки подтверждения — копия разметки #resetModal: подтверждения в
// этом проекте делаются модалкой с ловушкой фокуса (modal-manager.js),
// а window.confirm не используется нигде.
//
// Спрашивают ОБЕ кнопки мероприятия. «Завершить» тоже необратимо: итог
// замирает, и вернуть счёт можно только начав новое мероприятие, то
// есть стерев накопленное. Просьба 27.08.2026 «сделай с
// подтверждениями» — во множественном числе.
const modal = (id, title, text, confirmText) => {
const wrap = document.createElement('div');
wrap.className = 'reset-modal';
wrap.id = id;
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'true');
wrap.setAttribute('aria-labelledby', `${id}Title`);
const dialog = document.createElement('div');
dialog.className = 'reset-dialog';
const h3 = document.createElement('h3');
h3.id = `${id}Title`;
h3.textContent = title;
const p = document.createElement('p');
p.textContent = text;
const buttons = document.createElement('div');
buttons.className = 'reset-dialog-buttons';
for (const [btnId, cls, label] of [
[`${id}Cancel`, 'reset-btn-cancel', 'Отмена'],
[`${id}Confirm`, 'reset-btn-confirm', confirmText]
]) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = cls;
btn.id = btnId;
btn.textContent = label;
buttons.appendChild(btn);
}
dialog.appendChild(h3);
dialog.appendChild(p);
dialog.appendChild(buttons);
wrap.appendChild(dialog);
document.body.appendChild(wrap);
};
modal('eventReset', 'Обнулить итог мероприятия?',
'Накопленная сумма перелимита будет стёрта насовсем. Настройки и ставка останутся.',
'Обнулить');
modal('eventFinish', 'Завершить мероприятие?',
'Итог замрёт на текущей сумме: перелимиты дальше в него не пойдут. Возобновить счёт можно только начав новое мероприятие, а это стирает накопленное.',
'Завершить');
},
bindFloor47() {
this.buildFloor47Markup();
this.buildFloor47Help();
this.overrunPriceEl = document.getElementById('overrunPrice');
this.overrunPeriodEl = document.getElementById('overrunPeriod');
this.floor47UnlockedEl = document.getElementById('floor47Unlocked');
this.floor47SectionEl = document.getElementById('floor47Section');
this.eventStatusEl = document.getElementById('eventStatus');
this.eventFinishBtnEl = document.getElementById('eventFinishBtn');
this.eventExportBtnEl = document.getElementById('eventExportBtn');
this.reportOnlyOverrunsEl = document.getElementById('reportOnlyOverruns');
// Накопитель панель не ХРАНИТ — она держит последнее присланное
// состояние, чтобы было из чего собрать отчёт. Значения по умолчанию
// нужны до первой посылки: панель рисует строку сразу.
this.eventOverrunState = { overrunSeconds: 0, finished: false, excludedLiveSeconds: 0, talksCount: 0 };
const footer = document.getElementById('panelFooter');
if (footer) {
footer.addEventListener('click', (event) => {
if (event.detail < 3 || !this.floor47UnlockedEl) { return; }
this.floor47UnlockedEl.checked = true;
this.renderFloor47();
this.pushDisplaySettings();
});
}
// Фильтр отчёта — настройка, как ставка: главный процесс читает его из
// display-settings-update в момент выгрузки. Своего канала у него нет.
if (this.reportOnlyOverrunsEl) {
this.reportOnlyOverrunsEl.addEventListener('change', () => this.pushDisplaySettings());
}
for (const el of [this.overrunPriceEl, this.overrunPeriodEl]) {
if (el) {
el.addEventListener('input', () => {
this.pushDisplaySettings();
// Ставка входит в отчёт: поправленная посреди мероприятия,
// она пересчитывает уже накопленное. Ждать тика нельзя —
// строка отчитывается о действующем значении.
this.renderEventStatus();
});
}
}
// Три входа у одной строки, и каждый обязателен.
//
// Накопитель — единственный источник признака заморозки. Тик таймера
// нужен потому, что в минусе сумма растёт каждую секунду. Подписку
// ставит ТОТ, КТО СТРОИТ секцию: общий проход панели случается раньше,
// чем эта секция появляется в документе.
const ipc = window.ipcRenderer;
if (ipc) {
ipc.on('event-overrun-state', (_event, payload) => {
if (!payload || typeof payload !== 'object') { return; }
this.eventOverrunState = payload;
this.renderEventStatus();
});
ipc.on('timer-state', (_event, state) => {
if (!state || typeof state !== 'object') { return; }
this.eventRemainingSeconds = state.remainingSeconds;
this.renderEventStatus();
});
}
// Тумблеры денег подписывает ТОТ, КТО ИХ СТРОИТ.
//
// Общий проход по ключам таблицы в bindDisplayBlockControls случается
// РАНЬШЕ, чем эта секция появляется в документе, — там их ещё нет, и
// обработчик им не достаётся. Симптом был бесшумный: тумблер щёлкал,
// галочка вставала, а в окно не уходило ничего.
for (const el of Layouts.SECRET_ELEMENTS) {
const node = document.getElementById(el.toggle);
if (node) { node.addEventListener('change', () => this.pushDisplaySettings()); }
}
if (this.floor47UnlockedEl) {
this.floor47UnlockedEl.addEventListener('change', () => {
this.renderFloor47();
this.pushDisplaySettings();
});
}
// Обе команды мероприятия спрашивают подтверждение — одним путём, а не
// двумя похожими: вторая манера спрашивать это вторая манера выглядеть.
// Помощник получает ДЕЙСТВИЕ, а не имя канала. Имя канала литералом
// остаётся на виду в месте вызова: tests/ipc-liveness.test.js ищет
// `.send('литерал')` и без него объявил бы канал мёртвым. Проводка при
// этом всё равно одна — вторая манера спрашивать подтверждение была бы
// второй манерой выглядеть.
const confirmable = (btnId, modalId, onConfirm) => {
const btn = document.getElementById(btnId);
const box = document.getElementById(modalId);
const cancel = document.getElementById(`${modalId}Cancel`);
const confirm = document.getElementById(`${modalId}Confirm`);
if (btn && box) { btn.addEventListener('click', () => openModal(box, cancel)); }
if (cancel) { cancel.addEventListener('click', () => closeModal(box)); }
if (confirm) {
confirm.addEventListener('click', () => {
onConfirm();
closeModal(box);
});
}
};
// Тост после подтверждения — единственный ответ панели на нажатие,
// приходящий сразу. Строка отчёта обновится следующей посылкой из
// главного процесса, а «сработало ли» спрашивают в ту же секунду.
confirmable('eventFinishBtn', 'eventFinish', () => {
window.ipcRenderer.send('event-finish');
Toast.show('Мероприятие завершено — итог заморожен', 'success');
});
confirmable('eventResetBtn', 'eventReset', () => {
window.ipcRenderer.send('event-reset');
Toast.show('Новое мероприятие — итог обнулён', 'success');
});
// Выгрузка НЕ проходит через confirmable: подтверждения в этом проекте
// спрашивают необратимые действия, а лишний вопрос учит человека жать
// «Да» не читая — и тогда подтверждение перестаёт защищать там, где
// оно нужно.
if (this.eventExportBtnEl) {
this.eventExportBtnEl.addEventListener('click', () => {
window.ipcRenderer.send('event-export');
});
}
// Ответ обязателен в любом исходе: кнопка, после которой ничего не
// происходит и ничего не сказано, читается как сломанное окно. Отмена
// диалога — не событие: человек передумал, и тост об этом был бы шумом.
window.ipcRenderer.on('event-export-done', (_event, result) => {
const answer = result || {};
if (answer.canceled) { return; }
if (answer.ok) {
const rows = Number(answer.rows) || 0;
Toast.show(
rows > 0 ? `Отчёт сохранён · докладов: ${rows}` : 'Отчёт сохранён',
'success'
);
return;
}
Toast.show(`Отчёт не сохранён: ${answer.error || 'неизвестная причина'}`, 'error');
});
this.renderFloor47();
},
/**
* Отчёт о мероприятии: строка состояния и вид кнопки «Завершить».
*
* Сводку собирает money-meter — та же, которой считает дисплей. Второй
* формулы итога в проекте нет: разошедшиеся формулы и были дефектом,
* из-за которого «Завершить» ничего не меняло на экране.
*/
renderEventStatus() {
if (!this.eventStatusEl) { return; }
const summary = MoneyMeter.eventSummary({
overrunSeconds: this.eventOverrunState.overrunSeconds,
remainingSeconds: this.eventRemainingSeconds,
excludedLiveSeconds: this.eventOverrunState.excludedLiveSeconds,
finished: this.eventOverrunState.finished,
price: this.overrunPriceEl ? this.overrunPriceEl.value : 0,
period: this.overrunPeriodEl ? this.overrunPeriodEl.value : 0
});
this.eventStatusEl.textContent = summary.text;
// Состояние — КЛАСС, а не инлайн-цвет; и помечено оно не одним цветом:
// слово в строке говорит то же самое.
this.eventStatusEl.classList.toggle('is-finished', summary.finished);
if (this.eventFinishBtnEl) {
// Завершать завершённое нечего: кнопка, молча ничего не делающая,
// читается как сломанная.
this.eventFinishBtnEl.disabled = summary.finished;
}
if (this.eventExportBtnEl) {
// Выгружать нечего, пока нет ни перелимита, ни закрытых докладов:
// пустой отчёт — тот же молчаливый отказ, только в виде файла.
// Мероприятие из одних уложившихся докладов — законный отчёт с
// нулевым итогом, и кнопка обязана его отдавать.
this.eventExportBtnEl.disabled = summary.seconds <= 0
&& !(Number(this.eventOverrunState.talksCount) > 0);
}
},
/** Секция видна ровно тогда, когда режим разблокирован. */
renderFloor47() {
if (this.floor47SectionEl) {
this.floor47SectionEl.hidden = !this.isFloor47Unlocked();
}
this.renderEventStatus();
// Ряды подписей и кнопки раскладок строятся из реестра и знают про
// секретность, поэтому после смены замка их надо пересобрать.
this.bindBlockLabelRows();
this.bindDisplayLayouts();
},
bindBlockLabelRows() {
const mount = document.getElementById('blockLabelRows');
if (!mount) { return; }
// Пересборка (смена замка 47-го этажа) НЕ имеет права стирать подписи:
// следом за ней pushDisplaySettings собирает поля и пишет их в профиль,
// и новые пустые поля молча возвращали дисплею стандартные слова.
// Значение берётся из прежнего поля, а у ряда, которого не было
// (секретный — до разблокировки), — из сохранённого профиля.
const stored = safeParseObject(localStorage.getItem('displayExtSettings'));
const keep = {};
for (const el of Layouts.LABELLED_ELEMENTS) {
const prev = document.getElementById(el.labelKey);
keep[el.labelKey] = prev
? String(prev.value || '')
: (typeof stored[el.labelKey] === 'string' ? stored[el.labelKey] : '');
}
mount.textContent = '';
for (const el of Layouts.LABELLED_ELEMENTS) {
// Ряд секретного элемента строится только при разблокировке: иначе
// поле «своё название плашки Итого» увидел бы каждый.
if (el.secret && !this.isFloor47Unlocked()) { continue; }
const row = document.createElement('div');
row.className = 'toggle-row';
const label = document.createElement('label');
label.className = 'toggle-label';
label.setAttribute('for', el.labelKey);
label.textContent = el.caption;
const input = document.createElement('input');
input.type = 'text';
input.id = el.labelKey;
input.className = 'srow-input';
input.maxLength = Layouts.MAX_CAPTION;
input.placeholder = el.caption;
input.value = keep[el.labelKey];
input.setAttribute('aria-label', `Своё название плашки «${el.caption}»`);
input.addEventListener('input', () => this.pushDisplaySettings());
row.appendChild(label);
row.appendChild(input);
mount.appendChild(row);
}
},
/**
* Кнопки готовых раскладок. Разметку строит модуль — в HTML только точка
* монтирования, как у ряда «Фон» в panel-colors.js: перечень раскладок
* живёт в display-layouts.js, и вторая его копия в вёрстке разъехалась бы
* с первой при первом же добавлении раскладки.
*/
bindDisplayLayouts() {
const grid = document.getElementById('displayLayoutGrid');
if (!grid) { return; }
grid.textContent = '';
for (const layout of Layouts.LAYOUTS) {
// Секретная раскладка появляется вместе с разделом «47-го этажа».
// Кнопка с таким названием в общем списке рассказывала бы про режим
// каждому, кто открыл настройки, — а прятать сам раздел тогда
// незачем.
if (layout.secret && !this.isFloor47Unlocked()) { continue; }
const button = document.createElement('button');
button.type = 'button';
button.className = 'layout-btn';
button.dataset.layout = layout.id;
button.textContent = layout.name;
button.title = layout.hint;
// Кнопка НЕ переключатель: раскладка — действие, а не состояние.
// Отмеченная как выбранная, она врала бы уже после первого
// перетаскивания блока на дисплее.
button.setAttribute('aria-label', `Раскладка «${layout.name}»: ${layout.hint}`);
button.addEventListener('click', () => this.applyDisplayLayout(layout.id));
grid.appendChild(button);
}
},
/**
* Применить раскладку.
*
* Порядок из двух шагов несущий: сначала УХОДЯТ НАСТРОЙКИ с тумблерами,
* потом отдельным каналом — сама раскладка. Дисплей раскладывает по живым
* габаритам элементов, а у выключенного элемента прямоугольник нулевой:
* приди раскладка первой, включённый ею блок раскладывать было бы не по чему.
*
* Тумблерами и масштабом таймера владеет панель — поэтому здесь меняются её
* собственные контролы, а не посылается «поставь такой-то тумблер».
*/
applyDisplayLayout(layoutId) {
const layout = Layouts.layoutById(layoutId);
if (!layout) { return; }
for (const [key, on] of Object.entries(Layouts.layoutToggles(layout))) {
const el = document.getElementById(key);
if (el) { el.checked = on; }
}
const scaleEl = this.displayTimerScaleEl || this.timerScaleEl;
if (scaleEl) {
scaleEl.value = String(layout.timerScale);
const label = document.getElementById('displayTimerScaleValue');
if (label) { label.textContent = layout.timerScale + '%'; }
}
this.pushDisplaySettings();
window.ipcRenderer.send('display-layout', { layout: layout.id });
Toast.show(`Раскладка «${layout.name}»`, 'success');
},
/**
* Блок закрыли крестиком прямо на дисплее.
*
* Владелец настройки — панель: она снимает тумблер и рассылает настройки
* заново. Дисплей гасит блок у себя сразу (чтобы он исчез в тот же кадр), но
* СОСТОЯНИЕ хранится здесь и только здесь — иначе после переоткрытия окна
* блок вернулся бы.
*/
onDisplayBlockHidden(payload) {
const key = payload && payload.block;
// Крестик есть и у подписей, поэтому проверяется ВЕСЬ список тумблеров,
// а не только блоки.
if (!DISPLAY_TOGGLE_KEYS.includes(key)) { return; }
const el = document.getElementById(key);
if (!el || !el.checked) { return; }
el.checked = false;
this.pushDisplaySettings();
}
};
export {
PanelDisplayMixin, collectDisplayToggles, collectBlockLabels, attachChangedBgImage, fillDisplaySelect,
BLOCK_KEYS, LABEL_KEYS, DISPLAY_TOGGLE_KEYS
};