@brandup/ui-richeditor 1.0.38 → 1.0.40

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/source/toolbar.ts CHANGED
@@ -35,6 +35,24 @@ const ACTION_ICONS: Record<EditorAction, string> = {
35
35
  export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
36
36
  export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
37
37
 
38
+ /**
39
+ * Кнопка хоста в общей панели — для действий, которых редактор не знает: рандомизация,
40
+ * вставка переменных и прочее доменное. Иконку и поведение задаёт хост, панель отвечает
41
+ * только за отрисовку, доступность и то, что фокус не уходит из редактора.
42
+ */
43
+ export interface ToolbarButton {
44
+ /** Уникальное имя: идёт в data-атрибут и в ключ перестройки панели. */
45
+ name: string;
46
+ /** Подсказка на кнопке. */
47
+ title: string;
48
+ /** Разметка иконки (svg). */
49
+ icon: string;
50
+ /** Нажатие. */
51
+ run(): void;
52
+ /** false — кнопка недоступна; проверяется на каждом refresh, как у действий. */
53
+ isEnabled?(): boolean;
54
+ }
55
+
38
56
  /** Редактор, которым управляет общий тулбар. */
39
57
  export interface ToolbarHost {
40
58
  readonly editable: HTMLElement;
@@ -45,11 +63,15 @@ export interface ToolbarHost {
45
63
  readonly toolbarContainer?: HTMLElement | null;
46
64
  applyFormat(tool: FormatTool): void;
47
65
  isToolActive(tool: FormatTool): boolean;
66
+ /** Активные форматы всех инструментов сразу; нет реализации — панель опросит их поштучно. */
67
+ activeTools?(): ReadonlySet<FormatTool>;
48
68
  applyAction?(action: EditorAction): void;
49
69
  /** false — кнопка действия недоступна (нечего отменять/очищать). */
50
70
  isActionEnabled?(action: EditorAction): boolean;
51
71
  /** Вставка текста в каретку — для панели смайликов. */
52
72
  insertText?(text: string): void;
73
+ /** Собственные кнопки хоста; пусто/undefined — только штатные. */
74
+ readonly toolbarButtons?: ToolbarButton[];
53
75
  }
54
76
 
55
77
  const MARGIN = 6;
@@ -61,25 +83,74 @@ class FormatToolbar {
61
83
  private __emojiInitiator: HTMLElement | null = null; // кнопка, у которой открыта панель
62
84
  private __buttons: Array<[FormatTool, HTMLButtonElement]> = [];
63
85
  private __actionButtons: Array<[EditorAction, HTMLButtonElement]> = [];
86
+ private __hostButtons: Array<[ToolbarButton, HTMLButtonElement]> = [];
64
87
  private __active: ToolbarHost | null = null;
88
+ private __suspended: ToolbarHost | null = null; // показ придержан на время панели смайликов
65
89
  private __toolsKey = "";
66
90
  private __inContainer = false;
67
- private readonly __reposition = () => this.reposition();
91
+ private readonly __reposition = () => this.__schedule("position");
68
92
  private __resizeObserver: ResizeObserver | null = null;
93
+ private __selectionBound = false;
94
+ private __frame = 0;
95
+ private __pendingRefresh = false;
96
+ private __pendingPosition = false;
97
+
98
+ /**
99
+ * Единый листенер на весь документ: подсветка активных инструментов по текущему выделению.
100
+ * Вешается при первом показе панели, а не при загрузке модуля, и живёт до конца страницы —
101
+ * refresh() сам проверяет наличие активного редактора.
102
+ */
103
+ private __bindSelection() {
104
+ if (this.__selectionBound || typeof document === "undefined") return;
105
+
106
+ this.__selectionBound = true;
107
+ document.addEventListener("selectionchange", () => this.__schedule("refresh"));
108
+ }
109
+
110
+ /**
111
+ * Откладывает обновление до кадра отрисовки. selectionchange и scroll приходят пачками,
112
+ * а и подсветка (обход содержимого), и позиционирование (чтение геометрии) по событию
113
+ * заметно дороже, чем раз в кадр. Прямые вызовы refresh()/reposition() остаются синхронными.
114
+ */
115
+ private __schedule(kind: "refresh" | "position") {
116
+ if (!this.__active) return;
117
+
118
+ if (kind === "refresh") this.__pendingRefresh = true;
119
+ else this.__pendingPosition = true;
120
+
121
+ if (typeof requestAnimationFrame !== "function") this.__flush();
122
+ else this.__frame ||= requestAnimationFrame(() => this.__flush());
123
+ }
124
+
125
+ private __flush() {
126
+ const refresh = this.__pendingRefresh;
127
+ const position = this.__pendingPosition;
128
+ this.__cancelScheduled();
129
+
130
+ if (refresh) this.refresh();
131
+ if (position) this.reposition();
132
+ }
133
+
134
+ private __cancelScheduled() {
135
+ if (this.__frame && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.__frame);
69
136
 
70
- constructor() {
71
- // единый листенер на весь app: подсветка активных инструментов по текущему выделению.
72
- // refresh() сам проверяет наличие активного редактора, поэтому отдельных per-editor листенеров не нужно.
73
- if (typeof document !== "undefined") document.addEventListener("selectionchange", () => this.refresh());
137
+ this.__frame = 0;
138
+ this.__pendingRefresh = false;
139
+ this.__pendingPosition = false;
74
140
  }
75
141
 
76
142
  /** Показать тулбар для редактора (на фокусе): перестроить кнопки, спозиционировать, показать. */
77
143
  attach(host: ToolbarHost) {
144
+ // открыта панель смайликов у собственной кнопки хоста — показ отложен до её закрытия
145
+ if (this.__suspended === host) return;
146
+
78
147
  const actions = host.editorActions ?? [];
79
- if (!host.formatTools.length && !actions.length) return;
148
+ const buttons = host.toolbarButtons ?? [];
149
+ if (!host.formatTools.length && !actions.length && !buttons.length) return;
80
150
 
151
+ this.__bindSelection();
81
152
  this.__active = host;
82
- this.__build(host.formatTools, actions);
153
+ this.__build(host.formatTools, actions, buttons);
83
154
  this.refresh();
84
155
 
85
156
  const elem = this.__ensure();
@@ -110,22 +181,59 @@ class FormatToolbar {
110
181
 
111
182
  /** Скрыть тулбар, если он обслуживает этот редактор (на blur/destroy). */
112
183
  detach(host: ToolbarHost) {
113
- if (this.__active !== host) return;
184
+ // Придержанный показ снимаем первым делом, до закрытия панели: иначе её onClose поднял бы
185
+ // тулбар над редактором, который как раз уходит (в том числе разрушается).
186
+ if (this.__suspended === host) this.__suspended = null;
187
+
188
+ // панель смайликов могла быть открыта не для активного редактора (у своей кнопки хоста) —
189
+ // ссылку на него всё равно отпускаем, иначе уничтоженный редактор держится синглтоном
190
+ const emojiHost = this.__emojiHost === host;
191
+ if (!emojiHost && this.__active !== host) return;
114
192
 
115
193
  this.__closeEmoji();
194
+
195
+ if (emojiHost) {
196
+ this.__emojiHost = null;
197
+ this.__emojiInitiator = null;
198
+ }
199
+
200
+ if (this.__active !== host) return;
201
+
202
+ this.__hide();
203
+ }
204
+
205
+ /** Убрать панель с экрана и отпустить активный редактор (позиционирование, подсветка). */
206
+ private __hide() {
116
207
  this.__active = null;
208
+ this.__cancelScheduled();
117
209
  if (this.__elem) this.__elem.classList.remove("visible");
118
210
  this.__removeViewportListeners();
119
211
  }
120
212
 
121
- /** Обновить подсветку активных инструментов и доступность действий по текущему состоянию. */
213
+ /**
214
+ * Обновить подсветку активных инструментов и доступность действий по текущему состоянию.
215
+ *
216
+ * Пишем в DOM только при реальном изменении: обновление идёт на каждое движение каретки,
217
+ * а на документе живёт MutationObserver (им UIElement следит за удалением элементов) —
218
+ * повторная запись того же значения всё равно порождает запись мутации и его пробуждение.
219
+ */
122
220
  refresh() {
123
221
  const host = this.__active;
124
222
  if (!host) return;
125
223
 
126
- for (const [tool, btn] of this.__buttons) btn.classList.toggle("active", host.isToolActive(tool));
224
+ const setDisabled = (btn: HTMLButtonElement, disabled: boolean) => {
225
+ if (btn.disabled !== disabled) btn.disabled = disabled;
226
+ };
227
+
228
+ const active = host.activeTools?.();
229
+ for (const [tool, btn] of this.__buttons) {
230
+ const isActive = active ? active.has(tool) : host.isToolActive(tool);
231
+ if (btn.classList.contains("active") !== isActive) btn.classList.toggle("active", isActive);
232
+ }
233
+
127
234
  // хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
128
- for (const [action, btn] of this.__actionButtons) btn.disabled = host.isActionEnabled?.(action) === false;
235
+ for (const [action, btn] of this.__actionButtons) setDisabled(btn, host.isActionEnabled?.(action) === false);
236
+ for (const [button, btn] of this.__hostButtons) setDisabled(btn, button.isEnabled?.() === false);
129
237
  }
130
238
 
131
239
  /** Пересчитать позицию над активным редактором (только для режима body/fixed). */
@@ -144,6 +252,7 @@ class FormatToolbar {
144
252
  window.removeEventListener("scroll", this.__reposition);
145
253
  window.removeEventListener("resize", this.__reposition);
146
254
  this.__resizeObserver?.disconnect();
255
+ this.__cancelScheduled();
147
256
  }
148
257
 
149
258
  private __ensure(): HTMLElement {
@@ -160,8 +269,8 @@ class FormatToolbar {
160
269
  return this.__elem;
161
270
  }
162
271
 
163
- private __build(tools: FormatTool[], actions: EditorAction[]) {
164
- const key = `${tools.join(",")}|${actions.join(",")}`;
272
+ private __build(tools: FormatTool[], actions: EditorAction[], buttons: ToolbarButton[]) {
273
+ const key = `${tools.join(",")}|${actions.join(",")}|${buttons.map((b) => b.name).join(",")}`;
165
274
  const elem = this.__ensure();
166
275
  if (key === this.__toolsKey && elem.firstChild) return; // тот же состав — переиспользуем кнопки
167
276
 
@@ -170,6 +279,7 @@ class FormatToolbar {
170
279
  DOM.empty(elem);
171
280
  this.__buttons = [];
172
281
  this.__actionButtons = [];
282
+ this.__hostButtons = [];
173
283
 
174
284
  for (const tool of tools) {
175
285
  const def = FORMAT_TOOLS[tool];
@@ -200,6 +310,21 @@ class FormatToolbar {
200
310
  this.__actionButtons.push([action, btn]);
201
311
  }
202
312
 
313
+ if ((tools.length || actions.length) && buttons.length) elem.appendChild(DOM.tag("div", { class: "split" }));
314
+
315
+ // кнопки хоста — последними, чтобы штатные не переезжали при их появлении
316
+ for (const button of buttons) {
317
+ const btn = DOM.tag(
318
+ "button",
319
+ { type: "button", class: "host-button", "data-toolbar-button": button.name, title: button.title },
320
+ button.icon
321
+ );
322
+ btn.addEventListener("click", () => button.run());
323
+
324
+ elem.appendChild(btn);
325
+ this.__hostButtons.push([button, btn]);
326
+ }
327
+
203
328
  // панель пережила перестройку кнопок — возвращаем её в тулбар, чтобы не собирать заново.
204
329
  // Если её забрал хост под свою кнопку, она остаётся у него.
205
330
  if (pickerInToolbar && this.__emojiPicker) elem.appendChild(this.__emojiPicker);
@@ -223,7 +348,28 @@ class FormatToolbar {
223
348
  this.__emojiHost = host;
224
349
  this.__emojiInitiator = initiator;
225
350
 
226
- PopupManager.open(this.__ensureEmojiPicker(container), { initiator });
351
+ // Панель у собственной кнопки хоста — самостоятельный слой, и показывать её вместе с тулбаром
352
+ // нельзя: это два всплывающих окна над одним полем. Тулбар придерживаем на всё время работы
353
+ // панели, а показанный убираем с экрана; вернётся он сам при её закрытии, если поле осталось
354
+ // в фокусе. Панель самого тулбара (container === __elem) — его собственный выпадающий слой,
355
+ // прятать её носителя незачем и нечем.
356
+ if (container !== this.__elem) {
357
+ this.__suspended = host;
358
+ if (this.__active === host) this.__hide();
359
+ }
360
+
361
+ PopupManager.open(this.__ensureEmojiPicker(container), { initiator, onClose: () => this.__resume() });
362
+ }
363
+
364
+ /** Панель смайликов закрылась — показываем придержанный тулбар, если редактор ещё в фокусе. */
365
+ private __resume() {
366
+ const host = this.__suspended;
367
+ if (!host) return;
368
+
369
+ this.__suspended = null;
370
+
371
+ // панель могло закрыть и движение мимо поля — тогда показывать нечего
372
+ if (host.editable.ownerDocument.activeElement === host.editable) this.attach(host);
227
373
  }
228
374
 
229
375
  private __toggleEmoji(initiator: HTMLButtonElement, e: MouseEvent) {