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.
@@ -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
- /** Текст в буфер обмена: браузерный буфер, иначе буфер Electron, иначе просто возврат */
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
- // Буфера в окне нет: пробуем буфер Electron ниже
1955
+ // Буфера в окне нет: пробуем буфер приложения ниже
1952
1956
  }
1953
1957
  try {
1954
- require("electron").clipboard.writeText(text);
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
- let {Animation} = require('../../modules/chui_animations/animations');
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
- let size_1 = size.SIZE;
26
- this.#spinner.style.width = `${size_1}px`;
27
- this.#spinner.style.height = `${size_1}px`;
28
- this.#spinner.style.setProperty("--spinner_border", `${size.BORDER}px`);
29
- //
30
- let size_2 = size_1 - size.BORDER * 2;
31
- this.#spinner_2.style.width = `${size_2}px`;
32
- this.#spinner_2.style.height = `${size_2}px`;
33
- this.#spinner_2.style.setProperty("--spinner_border", `${size.BORDER}px`);
34
- //
35
- let size_3 = size_2 - size.BORDER * 2;
36
- this.#spinner_3.style.width = `${size_3}px`;
37
- this.#spinner_3.style.height = `${size_3}px`;
38
- this.#spinner_3.style.setProperty("--spinner_border", `${size.BORDER}px`);
39
- //ADDS
40
- this.#spinner_2.appendChild(this.#spinner_3)
41
- this.#spinner.appendChild(this.#spinner_2)
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
- ничего не теряет; 1px запаса сохраняет сглаженные касательные точки окружности. */
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
- position: relative;
17
- /* Кольцо — окружность: без border-box верхняя граница прибавлялась к высоте
18
- (например 60x64) и скругление 50% рисовало овал */
19
- box-sizing: border-box;
20
- border-radius: 50%;
21
- animation: spinner 2s linear infinite;
22
- display: flex;
23
- justify-content: center;
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
- сегмента (`border-top`) концы дуги сходятся клином, и на кольце 3–5px они читались
29
- рваными. Маска стоит на псевдоэлементе: на самом `spinner` она срезала бы
30
- вложенные кольца индикатора. */
31
- spinner::before {
32
- content: "";
33
- position: absolute;
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
- (1×/2×/3×), и дуги расходились по кругу — три рваных штриха вместо индикатора */
43
- spinner spinner {
44
- animation: none;
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 spinner {
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
- let data = fs.readFileSync(pathToCSS, 'utf8');
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 = String(data);
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
- let showdown = require('showdown');
106
- return new showdown.Converter().makeHtml(text);
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
- let showdown = require('showdown');
111
- return new showdown.Converter().makeMarkdown(text);
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
- (`Route#go`), текст и значок берёт из `Page#getTitle` / `Page#getIcon` */
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
+ со своих блоков, а видимый заголовок рисует приложение само */