chuijs 4.0.0 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/README.md +62 -27
- package/framework/appLayout/default/chui_app_layout.js +72 -59
- package/framework/appLayout/default/global_style.css +35 -34
- package/framework/components/chui_data_grid/data_grid.js +7 -3
- package/framework/components/chui_notification/notification_update.js +3 -0
- package/framework/components/chui_notification/status_plate.css +7 -0
- package/framework/components/chui_spinner/spinner.js +38 -21
- package/framework/components/chui_spinner/styles.css +34 -28
- package/framework/components/chui_sticky_bar/sticky_bar.js +230 -0
- package/framework/components/chui_sticky_bar/styles.css +37 -0
- package/framework/modules/chui_bridge/preload.js +35 -2
- package/framework/modules/chui_functions.js +22 -6
- package/framework/modules/chui_page/styles.css +3 -26
- package/framework/modules/chui_services/chui_channels.js +84 -2
- package/framework/modules/chui_services/desktop.js +164 -1
- package/index.js +123 -62
- package/package.json +24 -6
|
@@ -5,6 +5,7 @@ const {Dialog} = require('../chui_modal/modal');
|
|
|
5
5
|
const {ContentBlock} = require('../chui_content_block/content_block');
|
|
6
6
|
const {Button} = require('../chui_button/button');
|
|
7
7
|
const {I18n} = require('../../modules/chui_i18n/chui_i18n');
|
|
8
|
+
const {Desktop} = require('../../modules/chui_services/desktop');
|
|
8
9
|
const {CheckBox} = require('../chui_inputs/chui_check_box/check_box');
|
|
9
10
|
const {Select} = require('../chui_inputs/chui_select_box/select_box');
|
|
10
11
|
|
|
@@ -1939,7 +1940,10 @@ class DataGrid {
|
|
|
1939
1940
|
return lines.join("\r\n");
|
|
1940
1941
|
}
|
|
1941
1942
|
|
|
1942
|
-
/**
|
|
1943
|
+
/**
|
|
1944
|
+
* Текст в буфер обмена: сначала буфер окна, иначе буфер приложения через `Desktop`
|
|
1945
|
+
* (он работает и в безопасном окне, где прямого доступа к Electron нет), иначе возврат
|
|
1946
|
+
*/
|
|
1943
1947
|
async exportToClipboard(options = {}) {
|
|
1944
1948
|
const text = this.exportCSV(options);
|
|
1945
1949
|
try {
|
|
@@ -1948,10 +1952,10 @@ class DataGrid {
|
|
|
1948
1952
|
return text;
|
|
1949
1953
|
}
|
|
1950
1954
|
} catch {
|
|
1951
|
-
// Буфера в окне нет: пробуем буфер
|
|
1955
|
+
// Буфера в окне нет: пробуем буфер приложения ниже
|
|
1952
1956
|
}
|
|
1953
1957
|
try {
|
|
1954
|
-
|
|
1958
|
+
await Desktop.clipboard.writeText(text);
|
|
1955
1959
|
} catch {
|
|
1956
1960
|
// Ни одного буфера нет — текст остаётся у вызывающего кода
|
|
1957
1961
|
}
|
|
@@ -44,6 +44,9 @@ class UpdateNotification {
|
|
|
44
44
|
// а текст не отъезжает вбок под вращающимся кольцом
|
|
45
45
|
this.#update_notification_icon = createStatusPlate(undefined, Icons.NOTIFICATION.SYSTEM_UPDATE);
|
|
46
46
|
if (options.spinner) {
|
|
47
|
+
// Под кольцом заливки нет: плашка держит только место значка, иначе серая подложка
|
|
48
|
+
// читалась вторым фоном вокруг тонкого индикатора
|
|
49
|
+
this.#update_notification_icon.classList.add("notification_icon_plain");
|
|
47
50
|
this.#update_notification_icon.innerHTML = "";
|
|
48
51
|
this.#update_notification_icon.appendChild(new Spinner(Spinner.SIZE.V_SMALL, "0").set());
|
|
49
52
|
}
|
|
@@ -35,3 +35,10 @@ notification_icon.notification_icon_error {
|
|
|
35
35
|
background: var(--badge_error_back);
|
|
36
36
|
color: var(--badge_error_text);
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
/* Плашка без заливки — только место значка. Так стоит вращающийся индикатор карточки
|
|
40
|
+
обновления: кольцо тонкое, и серая подложка читалась вторым фоном вокруг него.
|
|
41
|
+
Размер и выравнивание те же, поэтому текст заголовка не съезжает. */
|
|
42
|
+
notification_icon.notification_icon_plain {
|
|
43
|
+
background: transparent;
|
|
44
|
+
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
const {Animation} = require('../../modules/chui_animations/animations');
|
|
2
2
|
const {I18n} = require('../../modules/chui_i18n/chui_i18n');
|
|
3
3
|
|
|
4
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
5
|
+
/** Доля кольца, которую занимает дуга: четверть — самый читаемый «бегунок» */
|
|
6
|
+
const ARC_SHARE = 0.25;
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
|
-
* Индикатор
|
|
9
|
+
* Индикатор загрузки — одно тонкое кольцо: приглушённая дорожка и гладкая дуга со
|
|
10
|
+
* скруглёнными концами, равномерно вращающаяся. Раньше это были три вложенных кольца
|
|
11
|
+
* с коническими градиентами: на малых размерах они сливались и читались шумно.
|
|
6
12
|
*
|
|
7
13
|
* Доступность: блок — live-регион `role="status"` с именем из опции `label` (значения у индикатора
|
|
8
14
|
* нет, поэтому сообщать можно только о факте загрузки; пустая строка имя убирает).
|
|
@@ -11,8 +17,6 @@ const {I18n} = require('../../modules/chui_i18n/chui_i18n');
|
|
|
11
17
|
class Spinner {
|
|
12
18
|
#spinner_main = document.createElement('chui_spiner')
|
|
13
19
|
#spinner = document.createElement('spinner')
|
|
14
|
-
#spinner_2 = document.createElement('spinner')
|
|
15
|
-
#spinner_3 = document.createElement('spinner')
|
|
16
20
|
#label = I18n.t("feedback.loading");
|
|
17
21
|
#removed = false;
|
|
18
22
|
constructor(size = Spinner.SIZE.DEFAULT, margin = String(), label = I18n.t("feedback.loading")) {
|
|
@@ -22,25 +26,38 @@ class Spinner {
|
|
|
22
26
|
this.#spinner_main.setAttribute("aria-live", "polite");
|
|
23
27
|
this.setLabel(label);
|
|
24
28
|
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
this.#spinner.style.
|
|
28
|
-
this.#spinner.style.
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
29
|
+
const diameter = size.SIZE;
|
|
30
|
+
const border = size.BORDER;
|
|
31
|
+
this.#spinner.style.width = `${diameter}px`;
|
|
32
|
+
this.#spinner.style.height = `${diameter}px`;
|
|
33
|
+
// Толщину кольца читает CSS: `stroke-width` рисует и дорожку, и дугу
|
|
34
|
+
this.#spinner.style.setProperty("--spinner_border", `${border}px`);
|
|
35
|
+
// Геометрия идёт по радиусу до середины штриха, минус полпикселя запаса: сглаженная
|
|
36
|
+
// внешняя кромка кольца не должна срезаться рамкой `svg`
|
|
37
|
+
const radius = diameter / 2 - border / 2 - 0.5;
|
|
38
|
+
const circumference = 2 * Math.PI * radius;
|
|
39
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
40
|
+
svg.setAttribute("viewBox", `0 0 ${diameter} ${diameter}`);
|
|
41
|
+
svg.setAttribute("aria-hidden", "true");
|
|
42
|
+
svg.setAttribute("focusable", "false");
|
|
43
|
+
// Дорожка — целое приглушённое кольцо: дуга читается на любом фоне
|
|
44
|
+
svg.appendChild(this.#circle(radius, diameter, "spinner_track"));
|
|
45
|
+
// Дуга — четверть кольца; скруглённые концы и цвет задаёт CSS
|
|
46
|
+
const arc = this.#circle(radius, diameter, "spinner_arc");
|
|
47
|
+
arc.setAttribute("stroke-dasharray", `${circumference * ARC_SHARE} ${circumference}`);
|
|
48
|
+
svg.appendChild(arc);
|
|
49
|
+
this.#spinner.appendChild(svg);
|
|
42
50
|
this.#spinner_main.appendChild(this.#spinner);
|
|
43
51
|
}
|
|
52
|
+
/** Окружность по центру квадрата: общая разметка дорожки и дуги */
|
|
53
|
+
#circle(radius, diameter, className) {
|
|
54
|
+
const circle = document.createElementNS(SVG_NS, "circle");
|
|
55
|
+
circle.setAttribute("class", className);
|
|
56
|
+
circle.setAttribute("cx", `${diameter / 2}`);
|
|
57
|
+
circle.setAttribute("cy", `${diameter / 2}`);
|
|
58
|
+
circle.setAttribute("r", `${radius}`);
|
|
59
|
+
return circle;
|
|
60
|
+
}
|
|
44
61
|
/** Имя для скринридера; пустая строка убирает объявление */
|
|
45
62
|
getLabel() {
|
|
46
63
|
return this.#label;
|
|
@@ -2,10 +2,12 @@ chui_spiner {
|
|
|
2
2
|
width: max-content;
|
|
3
3
|
height: max-content;
|
|
4
4
|
display: flex;
|
|
5
|
+
/* Цвет дуги: по умолчанию акцент, меняется через `style.color` на самом индикаторе */
|
|
6
|
+
color: var(--blue_prime_background);
|
|
5
7
|
/* Вращается коробка, и её повёрнутый AABB больше стороны квадрата (90 → до 127 px):
|
|
6
8
|
без обрезки этот «инк» уходил в прокрутку предков и раздувал scrollWidth карточки.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
Кольцо остаётся внутри вписанной окружности, поэтому обрезка ничего не теряет;
|
|
10
|
+
1px запаса сохраняет сглаженную внешнюю кромку кольца. */
|
|
9
11
|
overflow: clip;
|
|
10
12
|
overflow-clip-margin: 1px;
|
|
11
13
|
}
|
|
@@ -13,38 +15,35 @@ chui_spiner {
|
|
|
13
15
|
spinner {
|
|
14
16
|
/* Толщину кольца задаёт компонент (в JS — по размеру индикатора) */
|
|
15
17
|
--spinner_border: 3px;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
align-items: center;
|
|
18
|
+
display: block;
|
|
19
|
+
animation: spinner_rotate 1s linear infinite;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
spinner svg {
|
|
23
|
+
display: block;
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
/*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
inset: 0;
|
|
35
|
-
border-radius: 50%;
|
|
36
|
-
background: conic-gradient(var(--blue_prime_background) 0deg 90deg, transparent 90deg 360deg);
|
|
37
|
-
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - var(--spinner_border)), #000 calc(100% - var(--spinner_border)));
|
|
38
|
-
mask: radial-gradient(farthest-side, transparent calc(100% - var(--spinner_border)), #000 calc(100% - var(--spinner_border)));
|
|
28
|
+
/* Дорожка — целое приглушённое кольцо под дугой: дуга читается и на светлом, и на тёмном
|
|
29
|
+
фоне, а в покое кольцо остаётся кругом, без «обрубка» */
|
|
30
|
+
spinner .spinner_track {
|
|
31
|
+
fill: none;
|
|
32
|
+
stroke: var(--text_color);
|
|
33
|
+
stroke-opacity: 0.12;
|
|
34
|
+
stroke-width: var(--spinner_border);
|
|
39
35
|
}
|
|
40
36
|
|
|
41
|
-
/*
|
|
42
|
-
(
|
|
43
|
-
spinner
|
|
44
|
-
|
|
37
|
+
/* Дуга — часть кольца со скруглёнными концами: длину полосы задаёт компонент
|
|
38
|
+
(`stroke-dasharray`), толщину — переменная, цвет — `currentColor` */
|
|
39
|
+
spinner .spinner_arc {
|
|
40
|
+
fill: none;
|
|
41
|
+
stroke: currentColor;
|
|
42
|
+
stroke-width: var(--spinner_border);
|
|
43
|
+
stroke-linecap: round;
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
@keyframes
|
|
46
|
+
@keyframes spinner_rotate {
|
|
48
47
|
from {
|
|
49
48
|
transform: rotate(0deg);
|
|
50
49
|
}
|
|
@@ -53,3 +52,10 @@ spinner spinner {
|
|
|
53
52
|
}
|
|
54
53
|
}
|
|
55
54
|
|
|
55
|
+
/* Кому вращение мешает — оно остаётся, но медленнее: индикатор не «замирает» */
|
|
56
|
+
@media (prefers-reduced-motion: reduce) {
|
|
57
|
+
spinner {
|
|
58
|
+
animation-duration: 2.5s;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
const {setStyles, optionOr, scrollParent} = require('../../modules/chui_functions');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Прилипающая полоса: остаётся у верха области прокрутки, пока её содержимое не кончилось.
|
|
5
|
+
*
|
|
6
|
+
* Полоса задаётся `add()`:
|
|
7
|
+
*
|
|
8
|
+
* ```javascript
|
|
9
|
+
* const bar = new StickyBar({
|
|
10
|
+
* onChange: (stuck) => Log.info('закреплена: ' + stuck)
|
|
11
|
+
* });
|
|
12
|
+
* bar.add(new Button({title: 'Раздел «А'}));
|
|
13
|
+
* content.appendChild(bar.set());
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Закрепление делает CSS (`position: sticky` у корня), поэтому полоса сохраняет место в потоке
|
|
17
|
+
* и не требует подпорки-заглушки. Компонент добавляет к этому состояние: пока полоса в своей
|
|
18
|
+
* позиции, у корня `data-stuck="false"`, после — `data-stuck="true"` и класс `sticky_bar_stuck`,
|
|
19
|
+
* о чём сообщает `onChange(stuck)`. Вид «в покое» и «закреплено» задаёт CSS — обычно материал и
|
|
20
|
+
* тень появляются только у закреплённой полосы, чтобы она не спорила с содержимым.
|
|
21
|
+
*
|
|
22
|
+
* **Отступ.** `offset` — на сколько ниже порога встаёт полоса. Число — пиксели, строка — любое
|
|
23
|
+
* CSS-значение (`'12px'`, `'1rem'`). Порог отсчитывается от **края содержимого** области прокрутки:
|
|
24
|
+
* её собственный `padding` браузер учитывает сам (в Chromium `sticky` стоит по content box),
|
|
25
|
+
* поэтому у страницы приложения (у `center` есть отступ под шапку) полосе хватает `offset: 0` —
|
|
26
|
+
* она встаёт ровно под шапкой. Отдельный отступ нужен, когда у контейнера прокрутки нет
|
|
27
|
+
* внутреннего отступа, а сверху что-то наложено.
|
|
28
|
+
*
|
|
29
|
+
* **Состояние считается замером, а не наблюдателем.** `IntersectionObserver` здесь не годится:
|
|
30
|
+
* липкий узел нельзя наблюдать относительно самого себя, а сторож, движущийся вместе с полосой,
|
|
31
|
+
* пересекает границу ровно в момент закрепления и даёт неустойчивый сигнал на дробных пикселях.
|
|
32
|
+
* Поэтому раз в кадр сравниваются верх полосы и верх области прокрутки; слушатель `scroll` —
|
|
33
|
+
* пассивный, работа сведена к одному замеру на кадр через `requestAnimationFrame`.
|
|
34
|
+
*
|
|
35
|
+
* Доступность: компонент ничего не объявляет и не перехватывает — содержимое остаётся обычным
|
|
36
|
+
* содержимым. `destroy()` снимает слушатели; повторный вызов безвреден.
|
|
37
|
+
*/
|
|
38
|
+
class StickyBar {
|
|
39
|
+
static #counter = 0;
|
|
40
|
+
|
|
41
|
+
#root = document.createElement("chui_sticky_bar");
|
|
42
|
+
#content = document.createElement("sticky_bar_content");
|
|
43
|
+
#id = undefined;
|
|
44
|
+
#offset = "0px";
|
|
45
|
+
#target = undefined;
|
|
46
|
+
#stuck = false;
|
|
47
|
+
#on_change = undefined;
|
|
48
|
+
#container = undefined;
|
|
49
|
+
#destroyed = false;
|
|
50
|
+
#frame = 0;
|
|
51
|
+
#pending = 0;
|
|
52
|
+
|
|
53
|
+
constructor(options = {
|
|
54
|
+
id: String(), offset: Number(), target: undefined, onChange: undefined
|
|
55
|
+
}) {
|
|
56
|
+
setStyles(__dirname + "/styles.css", "chUiJS_StickyBar");
|
|
57
|
+
StickyBar.#counter += 1;
|
|
58
|
+
this.#id = optionOr(options.id, `chui_sticky_bar_${StickyBar.#counter}`);
|
|
59
|
+
this.#root.id = this.#id;
|
|
60
|
+
// Начальное состояние видно в разметке: до порога полоса в своей позиции
|
|
61
|
+
this.#root.dataset.stuck = "false";
|
|
62
|
+
this.#root.appendChild(this.#content);
|
|
63
|
+
this.#target = options.target;
|
|
64
|
+
this.#on_change = options.onChange;
|
|
65
|
+
this.setOffset(options.offset === undefined ? 0 : options.offset);
|
|
66
|
+
this.#observe();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// GET
|
|
70
|
+
getId() {
|
|
71
|
+
return this.#id;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Отступ закрепления: как задан (число пикселей или CSS-значение) */
|
|
75
|
+
getOffset() {
|
|
76
|
+
return this.#offset;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Закреплена ли полоса сейчас */
|
|
80
|
+
isStuck() {
|
|
81
|
+
return this.#stuck;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Контейнер прокрутки: свой из `target`, иначе ближайший прокручиваемый предок */
|
|
85
|
+
getTarget() {
|
|
86
|
+
const target = this.#target;
|
|
87
|
+
if (target !== undefined && target !== null) {
|
|
88
|
+
if (typeof target === "string") return document.querySelector(target) ?? document.scrollingElement;
|
|
89
|
+
if (target instanceof Element) return target;
|
|
90
|
+
if (typeof target.set === "function") return target.set();
|
|
91
|
+
}
|
|
92
|
+
return scrollParent(this.#root);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Содержимое полосы (в него пишет `add()`) */
|
|
96
|
+
getContent() {
|
|
97
|
+
return this.#content;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// SET
|
|
101
|
+
setId(id = String()) {
|
|
102
|
+
this.#id = id;
|
|
103
|
+
this.#root.id = id;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Отступ закрепления: число пикселей или строка CSS (`"12px"`, `"var(--header_height)"`) */
|
|
108
|
+
setOffset(offset = Number()) {
|
|
109
|
+
this.#offset = typeof offset === "string" ? offset : `${Number(offset) || 0}px`;
|
|
110
|
+
this.#root.style.setProperty("--sticky_offset", this.#offset);
|
|
111
|
+
this.update();
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
setOnChange(listener = undefined) {
|
|
116
|
+
this.#on_change = listener;
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Содержимое полосы: компоненты фреймворка или готовые узлы (содержимое заменяется) */
|
|
121
|
+
add(...components) {
|
|
122
|
+
this.#content.innerHTML = "";
|
|
123
|
+
for (const component of components) {
|
|
124
|
+
if (component === undefined || component === null) continue;
|
|
125
|
+
this.#content.appendChild(component instanceof Element ? component : component.set());
|
|
126
|
+
}
|
|
127
|
+
this.update();
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
clear() {
|
|
132
|
+
this.#content.innerHTML = "";
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Пересобрать слушатели и замер — после смены содержимого, контейнера или раскладки */
|
|
137
|
+
update() {
|
|
138
|
+
this.#detach();
|
|
139
|
+
this.#observe();
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Снять слушатели; повторный вызов безвреден */
|
|
144
|
+
destroy() {
|
|
145
|
+
this.#destroyed = true;
|
|
146
|
+
this.#detach();
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// RENDER
|
|
151
|
+
set() {
|
|
152
|
+
return this.#root;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// === ВНУТРЕННЕЕ ===
|
|
156
|
+
/**
|
|
157
|
+
* Первое подключение — на следующем кадре: узел обычно вставляют в документ сразу после
|
|
158
|
+
* `set()`, и только тогда виден настоящий контейнер прокрутки. Повторные вызовы ничего не
|
|
159
|
+
* делают, пока подключение не снято.
|
|
160
|
+
*/
|
|
161
|
+
#observe() {
|
|
162
|
+
if (this.#destroyed === true) return;
|
|
163
|
+
if (this.#container !== undefined || this.#pending !== 0) return;
|
|
164
|
+
this.#pending = requestAnimationFrame(() => {
|
|
165
|
+
this.#pending = 0;
|
|
166
|
+
this.#attach();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#attach() {
|
|
171
|
+
if (this.#destroyed === true) return;
|
|
172
|
+
this.#container = this.getTarget();
|
|
173
|
+
if (this.#container instanceof Element) {
|
|
174
|
+
this.#container.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
175
|
+
}
|
|
176
|
+
window.addEventListener("resize", this.#onScroll);
|
|
177
|
+
this.#schedule();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#detach() {
|
|
181
|
+
if (this.#container instanceof Element) {
|
|
182
|
+
this.#container.removeEventListener("scroll", this.#onScroll);
|
|
183
|
+
}
|
|
184
|
+
window.removeEventListener("resize", this.#onScroll);
|
|
185
|
+
this.#container = undefined;
|
|
186
|
+
if (this.#pending !== 0) {
|
|
187
|
+
cancelAnimationFrame(this.#pending);
|
|
188
|
+
this.#pending = 0;
|
|
189
|
+
}
|
|
190
|
+
if (this.#frame !== 0) {
|
|
191
|
+
cancelAnimationFrame(this.#frame);
|
|
192
|
+
this.#frame = 0;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#onScroll = () => this.#schedule();
|
|
197
|
+
|
|
198
|
+
#schedule() {
|
|
199
|
+
if (this.#frame !== 0 || this.#destroyed === true) return;
|
|
200
|
+
this.#frame = requestAnimationFrame(() => {
|
|
201
|
+
this.#frame = 0;
|
|
202
|
+
this.#measure();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Закреплена ли полоса: её верх доехал до порога (край содержимого области прокрутки плюс
|
|
208
|
+
* отступ). Запас в 1px гасит дребезг на дробных пикселях (сглаживание при масштабе окна).
|
|
209
|
+
*/
|
|
210
|
+
#measure() {
|
|
211
|
+
if (this.#destroyed === true) return;
|
|
212
|
+
const container = this.#container;
|
|
213
|
+
if (!(container instanceof Element) || this.#root.isConnected === false) return;
|
|
214
|
+
// Порог — от края содержимого контейнера: собственный отступ прокрутки браузер
|
|
215
|
+
// учитывает сам, а рамку — нет, поэтому она прибавляется отдельно
|
|
216
|
+
const container_style = window.getComputedStyle(container);
|
|
217
|
+
const padding = Number.parseFloat(container_style.paddingTop) || 0;
|
|
218
|
+
const reference_top = container.getBoundingClientRect().top + container.clientTop + padding;
|
|
219
|
+
const inset = Number.parseFloat(window.getComputedStyle(this.#root).top);
|
|
220
|
+
const offset = Number.isFinite(inset) ? inset : 0;
|
|
221
|
+
const stuck = this.#root.getBoundingClientRect().top <= reference_top + offset + 1;
|
|
222
|
+
if (stuck === this.#stuck) return;
|
|
223
|
+
this.#stuck = stuck;
|
|
224
|
+
this.#root.dataset.stuck = String(stuck);
|
|
225
|
+
this.#root.classList.toggle("sticky_bar_stuck", stuck);
|
|
226
|
+
if (typeof this.#on_change === "function") this.#on_change(stuck);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
exports.StickyBar = StickyBar;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* Прилипающая полоса: корень сам липнет к верху области прокрутки, поэтому место в потоке
|
|
2
|
+
сохраняется и подпорка-заглушка не нужна. Отступ задаёт компонент переменной `--sticky_offset`
|
|
3
|
+
(число или CSS-значение); отсчитывается он от края содержимого области прокрутки. */
|
|
4
|
+
chui_sticky_bar {
|
|
5
|
+
--sticky_offset: 0px;
|
|
6
|
+
display: block;
|
|
7
|
+
position: sticky;
|
|
8
|
+
inset-block-start: var(--sticky_offset);
|
|
9
|
+
width: -webkit-fill-available;
|
|
10
|
+
/* Ниже шапки (999) и выше содержимого страницы: полоса — слой над текстом, но под оболочкой */
|
|
11
|
+
z-index: 6;
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
sticky_bar_content {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-wrap: wrap;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: 6px;
|
|
20
|
+
width: -webkit-fill-available;
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
/* Отступы есть всегда, а фон и рамка — только у закреплённой полосы: иначе при закреплении
|
|
23
|
+
содержимое прыгало бы. Прозрачная рамка держит ту же высоту, что и видимая */
|
|
24
|
+
padding: 8px 10px;
|
|
25
|
+
border: 1px solid transparent;
|
|
26
|
+
border-radius: var(--radius_md);
|
|
27
|
+
transition: background-color var(--transition_time) ease, box-shadow var(--transition_time) ease;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Закреплённая полоса читается отдельным слоем: материал как у шапки, волосяной край и тень.
|
|
31
|
+
В покое она — обычная строка содержимого, ни фона, ни тени */
|
|
32
|
+
chui_sticky_bar[data-stuck="true"] sticky_bar_content {
|
|
33
|
+
border-color: var(--border_color);
|
|
34
|
+
background: var(--header_background);
|
|
35
|
+
backdrop-filter: var(--global_blur);
|
|
36
|
+
box-shadow: var(--box_shadow_main);
|
|
37
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const {contextBridge, ipcRenderer} = require("electron");
|
|
2
|
-
const {SERVICES_CHANNELS, SERVICES_CALLS, BRIDGE_CHANNELS, BRIDGE_ARGS} = require("../chui_services/chui_channels");
|
|
2
|
+
const {SERVICES_CHANNELS, SERVICES_CALLS, SERVICES_SYNC_CALLS, BRIDGE_CHANNELS, BRIDGE_ARGS} = require("../chui_services/chui_channels");
|
|
3
3
|
// Мост переиспользует ту же обёртку сервисов, что и страница: разница только в транспорте
|
|
4
4
|
// (`Desktop` в preload'е сам вызовет ipcRenderer, потому что `window.chui` ему не виден)
|
|
5
5
|
const {Desktop} = require("../chui_services/desktop");
|
|
@@ -16,8 +16,10 @@ const {Desktop} = require("../chui_services/desktop");
|
|
|
16
16
|
* файл при `DOMContentLoaded`.
|
|
17
17
|
*
|
|
18
18
|
* Формы API:
|
|
19
|
-
* - `window.chui.desktop.{clipboard, recent, progress, shortcuts}` — то же, что `Desktop` из
|
|
19
|
+
* - `window.chui.desktop.{app, clipboard, recent, progress, shortcuts}` — то же, что `Desktop` из
|
|
20
20
|
* `require('chuijs')`, поэтому код страницы переносится между режимами почти без правок;
|
|
21
|
+
* `desktop.app.quit()`/`restart()` закрывают и перезапускают всё приложение, а не окно; ответ
|
|
22
|
+
* у них значением, без промиса (синхронный канал);
|
|
21
23
|
* - `window.chui.services` — плоская карта «метод → канал» (транспорт, который ищет `Desktop`);
|
|
22
24
|
* - `window.chui.window` — минимизировать, развернуть, закрыть, состояние окна;
|
|
23
25
|
* - `window.chui.theme` — текущая тема и подписка на её смену;
|
|
@@ -74,6 +76,12 @@ const services = {};
|
|
|
74
76
|
for (const method of Object.keys(SERVICES_CALLS)) {
|
|
75
77
|
services[method] = (...args) => call(method, ...args);
|
|
76
78
|
}
|
|
79
|
+
// Синхронные вызовы — отдельным циклом и через `sendSync`: на синхронный канал `invoke`
|
|
80
|
+
// не отвечает, промис никогда бы не завершился. Конверт `{value}`/`{error}` разбирает
|
|
81
|
+
// `Desktop.#callSync`, поэтому мост отдаёт ответ как есть — как и обычный канал
|
|
82
|
+
for (const [method, channel] of Object.entries(SERVICES_SYNC_CALLS)) {
|
|
83
|
+
services[method] = (...args) => ipcRenderer.sendSync(channel, ...args);
|
|
84
|
+
}
|
|
77
85
|
// Событие глобальной клавиши: подписка возвращает функцию отписки
|
|
78
86
|
services.onShortcut = (handler = () => {}) => {
|
|
79
87
|
const listener = (_event, accelerator) => handler(accelerator);
|
|
@@ -127,6 +135,31 @@ contextBridge.exposeInMainWorld("chui", {
|
|
|
127
135
|
shell: {
|
|
128
136
|
openExternal: (url = String()) => Desktop.shell.openExternal(url)
|
|
129
137
|
},
|
|
138
|
+
app: {
|
|
139
|
+
// Путь приходит значением, без промиса (синхронный канал), а `Desktop` в preload'е
|
|
140
|
+
// идёт в него сам: `window.chui` своей же странице здесь ещё не виден
|
|
141
|
+
path: (name = String()) => Desktop.app.path(name),
|
|
142
|
+
info: () => Desktop.app.info(),
|
|
143
|
+
// Сырые модули Electron странице не отдают. Методы есть для единообразия
|
|
144
|
+
// с `Desktop`, но со страницы бросают ошибку с подсказкой, что взять взамен
|
|
145
|
+
get: () => Desktop.app.get(),
|
|
146
|
+
getSession: () => Desktop.app.getSession(),
|
|
147
|
+
getWebContents: () => Desktop.app.getWebContents(),
|
|
148
|
+
// Закрытие и перезапуск — как у `Desktop` из пакета: те же шаги, что у пункта меню.
|
|
149
|
+
// Отвечают значением, а не промисом (синхронный канал), поэтому `await` не нужен
|
|
150
|
+
quit: () => Desktop.app.quit(),
|
|
151
|
+
restart: () => Desktop.app.restart()
|
|
152
|
+
},
|
|
153
|
+
dialog: {
|
|
154
|
+
open: (options = {}) => Desktop.dialog.open(options),
|
|
155
|
+
save: (options = {}) => Desktop.dialog.save(options),
|
|
156
|
+
message: (options = {}) => Desktop.dialog.message(options)
|
|
157
|
+
},
|
|
158
|
+
session: {
|
|
159
|
+
getCacheSize: () => Desktop.session.getCacheSize(),
|
|
160
|
+
clearCache: () => Desktop.session.clearCache(),
|
|
161
|
+
clearStorageData: (options = {}) => Desktop.session.clearStorageData(options)
|
|
162
|
+
},
|
|
130
163
|
print: {
|
|
131
164
|
page: (options = {}) => Desktop.print.page(options),
|
|
132
165
|
html: (html = String(), options = {}) => Desktop.print.html(html, options),
|
|
@@ -4,11 +4,14 @@ function sleep(ms) {
|
|
|
4
4
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
/** Кэш прочитанных таблиц стилей: один файл отдаётся разным `id` без повторного чтения с диска */
|
|
8
|
+
const styles_cache = new Map();
|
|
9
|
+
|
|
7
10
|
function setStyles(pathToCSS = String(), component = String()) {
|
|
8
11
|
if (document.getElementById(component) != null) return;
|
|
9
|
-
|
|
12
|
+
if (styles_cache.has(pathToCSS) === false) styles_cache.set(pathToCSS, fs.readFileSync(pathToCSS, 'utf8'));
|
|
10
13
|
let style = document.createElement('style');
|
|
11
|
-
style.innerHTML =
|
|
14
|
+
style.innerHTML = styles_cache.get(pathToCSS);
|
|
12
15
|
style.type = 'text/css';
|
|
13
16
|
style.setAttribute('id', component);
|
|
14
17
|
document.head.appendChild(style);
|
|
@@ -101,14 +104,27 @@ function shouldOpenDropdownUp(dropdown = new HTMLElement(), anchor = new HTMLEle
|
|
|
101
104
|
return dropdownPlacement(dropdown, anchor).up;
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Разбор markdown в HTML — `markdown-it`.
|
|
109
|
+
*
|
|
110
|
+
* Рендер идёт с `html: false`: сырой HTML из разметки не пропускается, а показывается текстом,
|
|
111
|
+
* поэтому `<script>` или обработчик события в `markdownText` не исполняются. Таблицы и
|
|
112
|
+
* ~~зачёркивание~~ включены по умолчанию, авто-ссылок нет — адрес становится ссылкой только
|
|
113
|
+
* явным `[текст](адрес)`. Разборщик создаётся при первом вызове: markdown нужен не каждой
|
|
114
|
+
* странице, а `require` тянет библиотеку целиком.
|
|
115
|
+
*/
|
|
116
|
+
let markdown_parser = undefined;
|
|
104
117
|
function markdownToHtml(text = String()) {
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
if (text === undefined || text === null || String(text) === "") return "";
|
|
119
|
+
if (markdown_parser === undefined) markdown_parser = new (require('markdown-it'))({html: false, linkify: false, breaks: false});
|
|
120
|
+
return markdown_parser.render(String(text)).trim();
|
|
107
121
|
}
|
|
108
122
|
|
|
123
|
+
/** Обратный разбор: HTML → markdown (`turndown`) */
|
|
109
124
|
function htmlToMarkdown(text = String()) {
|
|
110
|
-
|
|
111
|
-
|
|
125
|
+
if (text === undefined || text === null || String(text) === "") return "";
|
|
126
|
+
const TurndownService = require('turndown');
|
|
127
|
+
return new TurndownService().turndown(String(text));
|
|
112
128
|
}
|
|
113
129
|
|
|
114
130
|
function getDate() {
|
|
@@ -9,29 +9,6 @@ page {
|
|
|
9
9
|
align-items: baseline;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
page_title {
|
|
16
|
-
display: flex;
|
|
17
|
-
align-items: center;
|
|
18
|
-
gap: 10px;
|
|
19
|
-
/* Без собственных отступов: text заголовка стоит по тому же вертикали, что и блоки
|
|
20
|
-
ниже, — иначе он выезжает на 2px вправо и левый край страницы ломается */
|
|
21
|
-
margin: 0 0 calc(10px * var(--density)) 0;
|
|
22
|
-
padding: 0;
|
|
23
|
-
font-size: var(--font_lead_size);
|
|
24
|
-
font-weight: 600;
|
|
25
|
-
color: var(--text_color);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
page_title chui_icon {
|
|
29
|
-
color: var(--link_color);
|
|
30
|
-
/* Кегль значка — как у подписи: при 20px глиф перетягивал строку */
|
|
31
|
-
font-size: var(--font_lead_size);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/* Страница без заголовка не держит пустую строку */
|
|
35
|
-
page_title:empty {
|
|
36
|
-
display: none;
|
|
37
|
-
}
|
|
12
|
+
/* Вид заголовка страницы держит шапка (`page_name` в global_style.css): оболочка
|
|
13
|
+
в содержимое ничего не подмешивает (`Route#go`), поэтому страница начинается
|
|
14
|
+
со своих блоков, а видимый заголовок рисует приложение само */
|