chuijs 4.0.0 → 4.0.1

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 ADDED
@@ -0,0 +1,65 @@
1
+ # Changelog
2
+
3
+ Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
4
+ версии — [Semantic Versioning](https://semver.org/lang/ru/).
5
+
6
+ ## [4.0.1] - 2026-09-28
7
+
8
+ ### Добавлено
9
+
10
+ - **`StickyBar`** — прилипающая полоса: компонент остаётся у верха области прокрутки, пока его
11
+ содержимое не кончилось. В покое не рисует фона и рамки, у закреплённой появляются материал,
12
+ волосяной край и тень; состояние отдаётся в `data-stuck` и `onChange(stuck)`.
13
+ - Раздел `StickyBar` в витрине: карточка в каталоге группы «Компоновка и навигация» с показом
14
+ закрепления.
15
+ - Страница **«История изменений»** на сайте документации: собирается из `CHANGELOG.md`, чтобы
16
+ журнал не расходился с копией в доке. Дорога к ней — из навигации и с бейджа версии на главной.
17
+ - Тест `test/chui_app_paths.test.js`: белый список путей приложения не разойдётся с `App.*Path()`.
18
+
19
+ ### Изменено
20
+
21
+ - **Рендер markdown переведён с `showdown` на `markdown-it`** (HTML), обратный разбор — на
22
+ `turndown`. `showdown` не обновлялся с 2022 года, исправленной версии у него нет, а аудит
23
+ показывал три неустранённые уязвимости (ReDoS и две XSS) именно в пути «markdown → innerHTML».
24
+ Новый разбор идёт с `html: false`: сырой HTML в `markdownText` теперь **показывается текстом,
25
+ а не исполняется** — это и закрывает XSS. Таблицы и `~~зачёркивание~~` работают как раньше,
26
+ авто-ссылок нет (адрес становится ссылкой только явным `[текст](адрес)`). Сайт документации
27
+ собирается тем же `markdown-it`; `npm audit` — 0 уязвимостей.
28
+ - **`@electron/remote` убран полностью** — Electron не рекомендует отдавать странице модули
29
+ main-процесса. Кнопки окна ходят в main по каналам `WINDOW_ACTION` / `WINDOW_STATE` (обработчики
30
+ регистрируются и в обычном режиме, `enableServices`), а пути, диалоги и сессия доступны как
31
+ `Desktop.app.path()/info()`, `Desktop.dialog.*`, `Desktop.session.*` или свой канал из
32
+ `security.channels`; в безопасном окне то же отдаёт мост `window.chui`. `chooseFile` со страницы
33
+ идёт по IPC в своё окно, в main работает напрямую. **Ломающее:** `App.get()`, `App.getSession()`,
34
+ `App.getWebContents()` и `App.*Path()` теперь только для main-процесса — со страницы они бросают
35
+ ошибку с подсказкой. Убраны устаревшие `enableRemoteModule` из `webPreferences`.
36
+ - **`Spinner`** переделан: вместо трёх вложенных колец — одно тонкое кольцо с приглушённой
37
+ дорожкой и гладкой дугой со скруглёнными концами. Цвет дуги задаётся `style.color`,
38
+ при `prefers-reduced-motion` вращение замедляется. Публичный API сохранён.
39
+ - **Кнопки окна** (`AppLayout`): значки свернуть/развернуть/восстановить/закрыть нарисованы
40
+ линиями (SVG) вместо лигатур шрифта иконок — не зависят от Material Symbols.
41
+ - **Шапка**: низ скруглён (логические радиусы), волосяная черта снизу сохранена.
42
+ - **`UpdateNotification`**: под спиннером больше нет заливки плашки (`notification_icon_plain`) —
43
+ подложка читалась вторым фоном вокруг тонкого индикатора.
44
+ - Витрина: липкая навигация по разделам переведена на `StickyBar`.
45
+ - `DataGrid.exportToClipboard()`: резервный буфер обмена берётся через `Desktop`, а не напрямую
46
+ из `electron` — работает и в безопасном окне.
47
+ - `chui_functions.setStyles()` кэширует прочитанные таблицы стилей: один файл отдаётся разным
48
+ `id` без повторного чтения с диска.
49
+ - Документация: сайт пересобран, добавлены страницы `StickyBar` и «История изменений», в README —
50
+ раздел про разметку `markdownText`, её безопасный режим и группы моста `window.chui.desktop`.
51
+ Примеры в разделах о безопасности больше не упоминают `enableRemoteModule` — опции, которой в
52
+ `webPreferences` больше нет.
53
+
54
+ ### Пакет
55
+
56
+ - Версия 4.0.1.
57
+ - Удалена неиспользуемая зависимость `dataurl`: в коде не было ни одного `require`, ни реэкспорта.
58
+ - Добавлены `repository`, `bugs`, `engines` и карта экспортов (`exports`) с сохранением глубоких
59
+ импортов вида `require('chuijs/framework/modules/chui_fonts')`.
60
+ - Явно указан `"type": "commonjs"`.
61
+
62
+ ## [4.0.0]
63
+
64
+ - Версия 4.0.0: реструктуризация модулей, локализация и RTL, десктоп-сервисы и безопасный режим
65
+ окон, статический сайт документации (`npm run docs:build`).
package/README.md CHANGED
@@ -181,7 +181,9 @@ main.getWindows(); // [{name, window}, …]
181
181
  повторный `start()` подпишет их заново по одному разу.
182
182
 
183
183
  #### `App`
184
- Обёртка над `app` Electron.
184
+ Обёртка над `app` Electron. **Только для main-процесса:** странице модули main не отдают
185
+ (Electron не рекомендует `remote`), и её `App.get*` бросает ошибку с подсказкой. Со страницы
186
+ берут `Desktop.app`, `Desktop.session`, `Desktop.dialog` или свой канал из `security.channels`.
185
187
 
186
188
  ```javascript
187
189
  const { App } = require('chuijs');
@@ -191,7 +193,8 @@ App.getWebContents(); // webContents
191
193
  ```
192
194
 
193
195
  #### `App.*Path()`
194
- Статические системные пути (все возвращают строку).
196
+ Статические системные пути (все возвращают строку). **Только для main-процесса;** со страницы —
197
+ `await Desktop.app.path('userData')` (там путь приходит по IPC и потому асинхронно).
195
198
 
196
199
  ```javascript
197
200
  const { App } = require('chuijs');
@@ -299,6 +302,13 @@ offTheme();
299
302
  await Desktop.print.page(); // системный диалог печати текущего окна
300
303
  await Desktop.print.html('<h1>Отчёт</h1>'); // печать HTML из скрытого окна
301
304
  await Desktop.print.pdf(html, {path: '/tmp/отчёт.pdf'}); // PDF без диалога (или с ним, без path)
305
+ await Desktop.app.path('userData'); // каталог настроек приложения
306
+ await Desktop.app.info(); // {name, version, locale, isPackaged}
307
+ const {canceled, filePaths} = await Desktop.dialog.open({properties: ['openFile']});
308
+ await Desktop.dialog.save({defaultPath: '/tmp/отчёт.pdf'});
309
+ await Desktop.dialog.message({type: 'question', message: 'Продолжить?'});
310
+ await Desktop.session.clearCache(); // кеш своей сессии
311
+ await Desktop.session.clearStorageData({storages: ['cookies']});
302
312
  ```
303
313
 
304
314
  Глобальная клавиша регистрируется в main один раз на комбинацию: сколько бы окон ни подписалось,
@@ -389,9 +399,9 @@ const preview = await main.createWindow({
389
399
 
390
400
  Что меняется в таком окне:
391
401
 
392
- - `nodeIntegration: false`, `contextIsolation: true`, `enableRemoteModule: false`,
393
- `nodeIntegrationInSubFrames: false`, `webviewTag: false` — `require`, `process` и `module`
394
- в странице недоступны, `@electron/remote` тоже;
402
+ - `nodeIntegration: false`, `contextIsolation: true`, `nodeIntegrationInSubFrames: false`,
403
+ `webviewTag: false` — `require`, `process` и `module` в странице недоступны, модули
404
+ main-процесса (в том числе `@electron/remote`) тоже;
395
405
  - preload'ом становится мост фреймворка (`framework/modules/chui_bridge/preload.js`),
396
406
  а `render` — **скрипт документа**: мост подключает его как обычный `<script>` после
397
407
  `DOMContentLoaded`. Поэтому код вроде `require('chuijs')` в таком окне не работает —
@@ -409,6 +419,11 @@ window.chui.desktop.clipboard.readText(); // то же, что Desktop из re
409
419
  window.chui.desktop.recent.list();
410
420
  window.chui.desktop.progress.set(0.4);
411
421
  const off = window.chui.desktop.shortcuts.on('Mod+Shift+P', () => {});
422
+ await window.chui.desktop.app.path('userData'); // путь приложения — по IPC, потому асинхронно
423
+ await window.chui.desktop.app.info(); // {name, version, locale, isPackaged}
424
+ const res = await window.chui.desktop.dialog.open({properties: ['openFile']});
425
+ await window.chui.desktop.dialog.message({type: 'question', message: 'Продолжить?'});
426
+ await window.chui.desktop.session.clearCache();
412
427
  window.chui.theme.get(); // 'light' | 'dark'
413
428
  window.chui.theme.onChange((theme) => {});
414
429
  window.chui.window.minimize(); window.chui.window.toggleMaximize(); window.chui.window.close();
@@ -435,7 +450,9 @@ const { BrowserWindow, ipcMain, ipcRenderer, shell, path, fs, os, request,
435
450
  transliterate, systemPreferences, chooseFile, store, downloadSession } = require('chuijs');
436
451
  ```
437
452
 
438
- `chooseFile` — это `dialog` из `@electron/remote/main`, `store` — готовый экземпляр `new Store()`.
453
+ `chooseFile` — диалоги `showOpenDialog` / `showSaveDialog` / `showMessageBox`: со страницы они идут
454
+ по IPC в окно, которое их вызвало (`Desktop.dialog`), а в main — напрямую `dialog` Electron.
455
+ `store` — готовый экземпляр `new Store()`.
439
456
 
440
457
  #### Автоматическое обновление приложения
441
458
  Только AppImage и NSIS.
@@ -604,6 +621,12 @@ Desktop.locale.onChange((locale) => I18n.setLocale(locale));
604
621
  DOM-элемент, поэтому внутри `Page.add(...)`, `Card.add(...)`, `ContentBlock.add(...)` передаётся
605
622
  сам объект, а в `Form.components` / `SlideShow.SLIDE` / `FieldSet.components` — тоже объекты.
606
623
 
624
+ **Разметка `markdownText`.** Компоненты с этой опцией (`Label`, `Tooltip`, `Badge`, `Alert`,
625
+ `Notification`, `ToastQueue`, `Chat`, `Tour`, `MarkdownEditor`) рендерят её `markdown-it` в безопасном
626
+ режиме: сырой HTML из разметки не исполняется, а показывается текстом — так закрыт XSS через
627
+ `markdownText`. Таблицы и `~~зачёркивание~~` поддерживаются, авто-ссылок нет: адрес становится
628
+ ссылкой только явным `[текст](адрес)`. Обратный разбор (`htmlToMarkdown`) делает `turndown`.
629
+
607
630
  Описание каждого компонента — отдельная страница статического сайта документации в [`docs/`](docs/):
608
631
  [`docs/index.html`](docs/index.html) — полный индекс по группам и алфавиту, а у каждой страницы сверху
609
632
  ссылка на группу и общий список. Сайт собирается генератором `node docs/build.js`
@@ -13,6 +13,7 @@ const {DownloadNotification} = require("../../components/chui_notification/notif
13
13
  const {Log} = require("../../../framework/modules/chui_logger/chui_logger");
14
14
  const {MenuKeyboard, makeKeyboardButton} = require('../../modules/chui_a11y/chui_a11y');
15
15
  const {LAYOUT_ROLE, LayoutRefs, registerLayout, requireLayoutNode} = require('../../modules/chui_layout/chui_layout');
16
+ const {BRIDGE_CHANNELS} = require('../../modules/chui_services/chui_channels');
16
17
  const {OverlayScrollbars} = require('../../modules/chui_scrollbar/chui_scrollbar');
17
18
  const {MenuBar} = require('../../components/chui_menu_bar/menu_bar');
18
19
  const {Avatar} = require('../../components/chui_avatar/avatar');
@@ -157,6 +158,22 @@ function makeHeaderButtonBox(options = { title: undefined, icon: undefined, reve
157
158
  return button;
158
159
  }
159
160
 
161
+ /**
162
+ * Значки кнопок окна нарисованы линиями, а не взяты из шрифта иконок: системные кнопки
163
+ * должны выглядеть одинаково, даже если Material Symbols недоступен (веб-сборка, безопасное
164
+ * окно, где шрифт фреймворка не подключён). «Свернуть»/«развернуть» — зеркальные шевроны,
165
+ * «восстановить» — контурный ромб, «закрыть» — крест. Все четыре значка вписаны в один
166
+ * квадрат 20×20 и центрированы геометрически (чернила симметричны относительно 10,10),
167
+ * поэтому в кнопке они стоят ровно и читаются одной тройкой. Толщина 1.3 и скругления
168
+ * подобраны под вид набора; цвет наследуется от кнопки через `currentColor`.
169
+ */
170
+ const WINDOW_ICONS = {
171
+ minimize: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4.5 7.5 L10 12.5 L15.5 7.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
172
+ maximize: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4.5 12.5 L10 7.5 L15.5 12.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
173
+ restore: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 5 L15 10 L10 15 L5 10 Z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
174
+ close: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M5 5 L15 15 M15 5 L5 15" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>',
175
+ };
176
+
160
177
  class WindowControls {
161
178
  // Управление кнопками
162
179
  #box = document.createElement("wc_box")
@@ -166,70 +183,73 @@ class WindowControls {
166
183
  // Подписка на нересайзящееся окно: set() вызывается и из конструктора Header, и из
167
184
  // addWC(), поэтому в set() слушатель ipcRenderer накапливался.
168
185
  #resizable_false_event = () => this.#maximize.remove();
169
- // Прокси окна берём один раз: getCurrentWindow() — синхронный IPC, а состояние
170
- // нужно и на resize. null — окно недоступно (безопасные окна живут без @electron/remote).
171
- #current_window = false;
186
+ // Номер запроса состояния гасит ответы, пришедшие не в том порядке
187
+ #sync_request = 0;
172
188
  constructor() {
173
189
  // Скрипт окна может быть preload'ом и выполняться до документа: здесь документ уже настоящий,
174
190
  // поэтому `dir`/`lang` (и зеркалирование логических свойств CSS) встают гарантированно
175
191
  I18n.apply();
176
- this.#setButton(this.#close, I18n.t("appLayout.window.close"), Icons.NAVIGATION.CLOSE);
177
- // Линию «свернуть» берём знак `horizontal_rule`, а не `minimize`: у того линия стоит
178
- // у нижнего края глифа (замер: центр чернил на 5.25px ниже центра кнопки), и кнопка
179
- // читалась смещённой; `horizontal_rule` — ровная линия по центру
180
- this.#setButton(this.#minimize, I18n.t("appLayout.window.minimize"), Icons.EDITOR.HORIZONTAL_RULE);
181
- // Глиф «развернуть» — квадрат (crop_square): в наборе есть и `maximize`, но там это
182
- // линия у верхнего края — визуально она неотличима от второй кнопки «свернуть»
183
- this.#setButton(this.#maximize, I18n.t("appLayout.window.maximize"), Icons.IMAGE.CROP_SQUARE);
184
- this.#close.addEventListener("click", () => {
185
- const window_ = this.#getWindow();
186
- if (window_ !== undefined) window_.close();
187
- })
188
- this.#minimize.addEventListener("click", () => {
189
- const window_ = this.#getWindow();
190
- if (window_ !== undefined) window_.minimize();
191
- })
192
- this.#maximize.addEventListener("click", () => {
193
- const window_ = this.#getWindow();
194
- if (window_ === undefined) return;
195
- window_.isMaximized() ? window_.unmaximize() : window_.maximize();
196
- })
192
+ this.#setButton(this.#close, I18n.t("appLayout.window.close"), "close");
193
+ // «Свернуть» — шеврон вниз: плоская широкая галочка читается лучше горизонтальной
194
+ // черты и не сливается с рамкой кнопки
195
+ this.#setButton(this.#minimize, I18n.t("appLayout.window.minimize"), "minimize");
196
+ // «Развернуть» — шеврон вверх, зеркало «свернуть»: пара читается как одна ось
197
+ this.#setButton(this.#maximize, I18n.t("appLayout.window.maximize"), "maximize");
198
+ this.#close.addEventListener("click", () => this.#windowAction("close"))
199
+ this.#minimize.addEventListener("click", () => this.#windowAction("minimize"))
200
+ // Разворот и восстановление — одно действие: решение принимает окно, а не кнопка
201
+ this.#maximize.addEventListener("click", () => this.#windowAction("toggle-maximize"))
197
202
  // Разворот приходит не только от нашей кнопки (двойной клик по полосе, меню окна),
198
203
  // поэтому состояние читаем по факту изменения размера окна
199
204
  window.addEventListener("resize", this.#sync_maximized_event)
200
205
  this.#syncMaximized()
201
206
  ipcRenderer.on("chui_resizable_false", this.#resizable_false_event)
202
207
  }
203
- /** Кнопка окна — иконка шапки с именем для скринридера, фокусом и активацией с клавиатуры.
204
- * Кегль глифа задаёт CSS (см. global_style.css): у кнопок окна он меньше, чем у соседних
205
- * иконок шапки, поэтому опция размера здесь не передаётся */
208
+ /** Кнопка окна — значок с именем для скринридера, фокусом и активацией с клавиатуры.
209
+ * Значок рисуется линиями (`WINDOW_ICONS`), размер задаёт CSS (см. global_style.css) */
206
210
  #setButton(button, label, icon) {
207
- button.innerHTML = new Icon(icon).getHTML();
211
+ button.innerHTML = WINDOW_ICONS[icon];
208
212
  button.setAttribute("title", label);
209
213
  button.setAttribute("aria-label", label);
210
214
  makeKeyboardButton(button);
211
215
  }
212
- #getWindow() {
213
- if (this.#current_window === false) {
214
- try {
215
- this.#current_window = require("@electron/remote").getCurrentWindow();
216
- } catch (_error) {
217
- this.#current_window = null;
218
- }
216
+ /**
217
+ * Действия кнопок окна идут по IPC в main (`WINDOW_ACTION`/`WINDOW_STATE`). Прежде здесь
218
+ * стоял `@electron/remote`, но он устарел и тянул модули main-процесса прямо в страницу.
219
+ * Обработчики берут окно отправителя, поэтому канал не может задеть чужое окно; ошибка
220
+ * глушится — без `Main` (обработчики не зарегистрированы) нажатие не должно ломать страницу.
221
+ */
222
+ #windowAction(action = String()) {
223
+ try {
224
+ return Promise.resolve(ipcRenderer.invoke(BRIDGE_CHANNELS.WINDOW_ACTION, action)).catch(() => false);
225
+ } catch (_error) {
226
+ return Promise.resolve(false);
219
227
  }
220
- return this.#current_window === null ? undefined : this.#current_window;
221
228
  }
222
- // Развёрнутое окно — другой глиф и другая подпись: кнопка возвращает окно в оконный режим.
223
- // Знак «восстановить» — те же два квадрата (`filter_none`); размер ему задаёт CSS
229
+
230
+ /** Состояние окна: `{isMaximized, isMinimized, …}`; без окна — пустой объект */
231
+ async #windowState() {
232
+ try {
233
+ return (await ipcRenderer.invoke(BRIDGE_CHANNELS.WINDOW_STATE)) ?? {};
234
+ } catch (_error) {
235
+ return {};
236
+ }
237
+ }
238
+ // Развёрнутое окно — другой значок и другая подпись: кнопка возвращает окно в оконный режим.
239
+ // «Восстановить» — контурный ромб; размер значкам задаёт CSS.
240
+ // Состояние приходит асинхронно, поэтому ответ устаревшего запроса отбрасывается по номеру:
241
+ // иначе быстрая пара «развернуть/восстановить» могла бы применить ответ в обратном порядке
224
242
  #syncMaximized() {
225
- const window_ = this.#getWindow();
226
- if (window_ === undefined) return;
227
- const maximized = window_.isMaximized();
228
- const label = maximized ? I18n.t("appLayout.window.restore") : I18n.t("appLayout.window.maximize");
229
- this.#maximize.classList.toggle("wc_maximized", maximized);
230
- this.#maximize.innerHTML = new Icon(maximized ? Icons.IMAGE.FILTER_NONE : Icons.IMAGE.CROP_SQUARE).getHTML();
231
- this.#maximize.setAttribute("title", label);
232
- this.#maximize.setAttribute("aria-label", label);
243
+ const request = ++this.#sync_request;
244
+ this.#windowState().then((state) => {
245
+ if (request !== this.#sync_request) return;
246
+ const maximized = state.isMaximized === true;
247
+ const label = maximized ? I18n.t("appLayout.window.restore") : I18n.t("appLayout.window.maximize");
248
+ this.#maximize.classList.toggle("wc_maximized", maximized);
249
+ this.#maximize.innerHTML = WINDOW_ICONS[maximized ? "restore" : "maximize"];
250
+ this.#maximize.setAttribute("title", label);
251
+ this.#maximize.setAttribute("aria-label", label);
252
+ });
233
253
  }
234
254
  #sync_maximized_event = () => this.#syncMaximized();
235
255
  set(pos_bool = Boolean()) {
@@ -454,15 +454,17 @@ header {
454
454
  display: flex;
455
455
  z-index: 999;
456
456
  flex-direction: column;
457
- /* Шапка — плоская полоса во всю ширину с волосяной чертой снизу: содержимое уходит
458
- под неё (материал полупрозрачный, с блюром). Углы прямые: скруглённые углы у полосы
459
- во всю ширину окна не читались — вверху их срезал бы композитор, а внизу они
460
- оставляли зазор. */
457
+ /* Шапка — плоская полоса во всю ширину: содержимое уходит под неё (материал
458
+ полупрозрачный, с блюром), низ скруглён и подчёркнут волосяной чертой. Радиус задан
459
+ логическими свойствами, поэтому в RTL скругление зеркалится; верхние углы прямые —
460
+ их срезал бы композитор. */
461
461
  align-items: start;
462
462
  background-color: var(--header_background);
463
463
  backdrop-filter: var(--global_blur);
464
464
  justify-content: space-between;
465
465
  border-bottom: 1px solid var(--border_color);
466
+ border-end-start-radius: var(--radius_md);
467
+ border-end-end-radius: var(--radius_md);
466
468
  }
467
469
 
468
470
  /* Прокрутка содержимого: материал плотнее и появляется тень — полоса читается
@@ -1253,25 +1255,21 @@ wc_close, wc_maximize, wc_minimize {
1253
1255
  transition: background-color var(--transition_time) ease, color var(--transition_time) ease;
1254
1256
  }
1255
1257
 
1256
- /* Кегль глифов меньше соседних иконок шапки (--header_icon_size = 13pt): три системные
1257
- кнопки не должны перетягивать на себя внимание. Замер чернил при 14px: квадрат
1258
- 9.6×9.6, линия 9.6×1.3, крест 7.9×7.9 — у креста знак мельче, так он нарисован
1259
- в наборе. chui_icon задаёт собственный color, поэтому цвет кнопки до глифа не доходит —
1260
- правила состояний ниже тоже про иконку. */
1261
- wc_close chui_icon,
1262
- wc_maximize chui_icon,
1263
- wc_minimize chui_icon {
1264
- font-size: 14px;
1258
+ /* Значки кнопок окна нарисованы линиями (SVG), а не взяты из шрифта иконок: три системные
1259
+ кнопки должны выглядеть одинаково и тогда, когда Material Symbols недоступен (веб-сборка,
1260
+ безопасное окно без шрифта фреймворка). Все четыре значка вписаны в один квадрат 20×20 и
1261
+ центрированы геометрически, поэтому при общем размере 16 пикселей стоят ровно и читаются
1262
+ одной тройкой. Цвет приходит от кнопки через `currentColor`, поэтому состояния ниже красят
1263
+ сам `svg`. */
1264
+ wc_close svg,
1265
+ wc_maximize svg,
1266
+ wc_minimize svg {
1267
+ display: block;
1268
+ width: 16px;
1269
+ height: 16px;
1265
1270
  color: var(--text_color_disabled);
1266
1271
  }
1267
1272
 
1268
- /* «Восстановить» — два квадрата вместо одного: знак плотнее и при общем кегле выходил
1269
- крупнее одиночного квадрата (чернила 15×15 против 11×11). Меньший кегль выравнивает
1270
- боксы: 9.5×9.5 у «восстановить» против 9.6×9.6 у «развернуть» */
1271
- wc_maximize.wc_maximized chui_icon {
1272
- font-size: 10px;
1273
- }
1274
-
1275
1273
  wc_maximize:hover, wc_minimize:hover {
1276
1274
  background: var(--element_background_hover);
1277
1275
  }
@@ -1280,13 +1278,13 @@ wc_maximize:active, wc_minimize:active {
1280
1278
  background: var(--element_background_prime); color: var(--text_color_hover);
1281
1279
  }
1282
1280
 
1283
- wc_maximize:hover chui_icon,
1284
- wc_maximize:active chui_icon,
1285
- wc_minimize:hover chui_icon,
1286
- wc_minimize:active chui_icon,
1287
- wc_close:focus-visible chui_icon,
1288
- wc_maximize:focus-visible chui_icon,
1289
- wc_minimize:focus-visible chui_icon {
1281
+ wc_maximize:hover svg,
1282
+ wc_maximize:active svg,
1283
+ wc_minimize:hover svg,
1284
+ wc_minimize:active svg,
1285
+ wc_close:focus-visible svg,
1286
+ wc_maximize:focus-visible svg,
1287
+ wc_minimize:focus-visible svg {
1290
1288
  color: var(--text_color);
1291
1289
  }
1292
1290
 
@@ -1297,8 +1295,8 @@ wc_close:active {
1297
1295
  background: var(--red_prime_background);
1298
1296
  }
1299
1297
 
1300
- wc_close:hover chui_icon,
1301
- wc_close:active chui_icon {
1298
+ wc_close:hover svg,
1299
+ wc_close:active svg {
1302
1300
  color: var(--text_color);
1303
1301
  }
1304
1302
 
@@ -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;