@brandup/ui-richeditor 1.0.38 → 1.0.39

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.
@@ -14,25 +14,94 @@ const MATCH_TAG_NAMES = Array.from(new Set(ALL_FORMAT_TOOLS.flatMap((t) => FORMA
14
14
  const FORMAT_SELECTOR = FORMAT_TAG_NAMES.join(",").toLowerCase();
15
15
  const MATCH_SELECTOR = MATCH_TAG_NAMES.join(",").toLowerCase();
16
16
 
17
+ // Проверка тега идёт на каждого предка каждого текстового узла при каждом обходе — храним
18
+ // множествами, а не массивами: подсветка панели опрашивает их на каждое движение каретки.
19
+ const FORMAT_TAG_SET = new Set(FORMAT_TAG_NAMES);
20
+ const MATCH_TAG_SET = new Set(MATCH_TAG_NAMES);
21
+ const TOOL_TAG_SETS = ALL_FORMAT_TOOLS.reduce(
22
+ (map, tool) => {
23
+ map[tool] = new Set(FORMAT_TOOLS[tool].matchTags);
24
+ return map;
25
+ },
26
+ {} as Record<FormatTool, Set<string>>
27
+ );
28
+
17
29
  /** Ближайший предок-элемент с одним из тегов (в пределах root, не включая root). */
18
- function formatAncestor(node: Node, tags: string[], root: HTMLElement): HTMLElement | null {
30
+ function formatAncestor(node: Node, tags: ReadonlySet<string>, root: HTMLElement): HTMLElement | null {
19
31
  let el = node.parentElement;
20
32
  while (el && el !== root) {
21
- if (tags.includes(el.tagName)) return el;
33
+ if (tags.has(el.tagName)) return el;
22
34
  el = el.parentElement;
23
35
  }
24
36
  return null;
25
37
  }
26
38
 
39
+ /**
40
+ * Поддерево, которого достаточно для обхода диапазона. Обход всего редактора на каждый
41
+ * запрос состояния панели стоит слишком дорого, а за пределами общего предка границ
42
+ * диапазона попасть в него нечему.
43
+ */
44
+ function rangeScope(root: HTMLElement, range: Range): Node {
45
+ const scope = range.commonAncestorContainer;
46
+ if (!root.contains(scope)) return root;
47
+
48
+ // от текстового узла обходить нечего — берём его родителя (сам узел walker не вернёт)
49
+ return scope.nodeType === Node.TEXT_NODE ? (scope.parentNode ?? root) : scope;
50
+ }
51
+
52
+ /**
53
+ * Выделение документа, которому принадлежит узел, — для операций, которые выделение
54
+ * устанавливают, а не читают.
55
+ *
56
+ * Окно берём у самого узла, а не глобальное: редактор может жить в iframe, где глобальный
57
+ * `window` чужой и его выделение к нашему содержимому отношения не имеет; к тому же добраться
58
+ * до глобального окружения можно не всегда (например, при разрушении контрола).
59
+ */
60
+ export function documentSelection(node: Node): Selection | null {
61
+ return node.ownerDocument?.defaultView?.getSelection() ?? null;
62
+ }
63
+
64
+ /**
65
+ * Выделение, если оно стоит внутри root, иначе null. Единственная проверка «правка относится
66
+ * к этому содержимому» — по ней работают и правки абзацев, и история, и хосты редактора.
67
+ */
68
+ export function innerSelection(root: HTMLElement): Selection | null {
69
+ const selection = documentSelection(root);
70
+ if (!selection || selection.rangeCount === 0 || !root.contains(selection.anchorNode)) return null;
71
+
72
+ return selection;
73
+ }
74
+
75
+ /**
76
+ * Выполняет правку, сохраняя каретку: положение запоминается текстовым смещением до правки
77
+ * и возвращается после. Пропустить восстановление нельзя — правки пересоздают узлы, и прежнее
78
+ * выделение указывало бы на те, которых в дереве уже нет.
79
+ *
80
+ * Если правка вернула false, значит DOM она не трогала: выделение живо, и переставлять его
81
+ * незачем — лишний сброс способен прервать IME-набор.
82
+ */
83
+ export function preserveCaret(root: HTMLElement, mutate: () => boolean | void): void {
84
+ const selection = innerSelection(root);
85
+ const bounds = selection ? selectionCharBounds(root, selection.getRangeAt(0)) : null;
86
+
87
+ const touched = mutate();
88
+
89
+ if (touched !== false && bounds && selection) restoreSelection(root, bounds[0], bounds[1], selection);
90
+ }
91
+
27
92
  /** Абсолютные текстовые смещения границ выделения внутри root (для восстановления после правок DOM). */
28
93
  export function selectionCharBounds(root: HTMLElement, range: Range): [number, number] {
29
94
  const probe = document.createRange();
30
95
  probe.selectNodeContents(root);
31
96
  probe.setEnd(range.startContainer, range.startOffset);
32
97
  const start = probe.toString().length;
98
+ if (range.collapsed) return [start, start];
99
+
100
+ // длину выделения меряем от его начала, а не от начала редактора: иначе весь текст
101
+ // до каретки собирается в строку дважды
102
+ probe.setStart(range.startContainer, range.startOffset);
33
103
  probe.setEnd(range.endContainer, range.endOffset);
34
- const end = probe.toString().length;
35
- return [start, end];
104
+ return [start, start + probe.toString().length];
36
105
  }
37
106
 
38
107
  /**
@@ -55,26 +124,42 @@ export function mapCharOffset(before: string, after: string, offset: number): nu
55
124
  return j;
56
125
  }
57
126
 
58
- /** Находит текстовый узел и локальное смещение по абсолютному текстовому смещению. */
59
- function locateChar(root: HTMLElement, target: number): { node: Text; offset: number } | null {
127
+ type CharPosition = { node: Text; offset: number };
128
+
129
+ /**
130
+ * Находит текстовые узлы и локальные смещения для пары абсолютных смещений за один обход.
131
+ * Смещение за пределами текста прижимается к его концу.
132
+ */
133
+ function locateChars(root: HTMLElement, lower: number, upper: number): [CharPosition, CharPosition] | null {
60
134
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
61
135
  let count = 0;
62
136
  let last: Text | null = null;
63
- let n = walker.nextNode() as Text | null;
64
- while (n) {
137
+ let low: CharPosition | null = null;
138
+ let high: CharPosition | null = null;
139
+
140
+ for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
65
141
  last = n;
66
- if (count + n.length >= target) return { node: n, offset: Math.max(0, target - count) };
142
+ if (!low && count + n.length >= lower) low = { node: n, offset: lower - count };
143
+ if (count + n.length >= upper) {
144
+ high = { node: n, offset: upper - count };
145
+ break;
146
+ }
67
147
  count += n.length;
68
- n = walker.nextNode() as Text | null;
69
148
  }
70
- return last ? { node: last, offset: last.length } : null;
149
+
150
+ if (!last) return null;
151
+
152
+ const tail: CharPosition = { node: last, offset: last.length };
153
+ return [low ?? tail, high ?? tail];
71
154
  }
72
155
 
73
156
  // Восстанавливает выделение по абсолютным текстовым смещениям (см. selectionCharBounds).
74
157
  export function restoreSelection(root: HTMLElement, start: number, end: number, selection: Selection) {
75
- const s = locateChar(root, start);
76
- const e = locateChar(root, end);
77
- if (!s || !e) return;
158
+ const forward = start <= end;
159
+ const found = locateChars(root, forward ? start : end, forward ? end : start);
160
+ if (!found) return;
161
+
162
+ const [s, e] = forward ? found : [found[1], found[0]];
78
163
 
79
164
  const range = document.createRange();
80
165
  range.setStart(s.node, s.offset);
@@ -125,15 +210,29 @@ function nodeWithinRange(node: Node, range: Range): boolean {
125
210
  );
126
211
  }
127
212
 
213
+ /**
214
+ * Непустые текстовые узлы, задетые диапазоном. Единственный обход содержимого в модуле:
215
+ * по нему работают и правки формата, и опрос состояния для панели.
216
+ */
217
+ function* touchedTextNodes(root: HTMLElement, range: Range): Generator<Text> {
218
+ const walker = document.createTreeWalker(rangeScope(root, range), NodeFilter.SHOW_TEXT);
219
+
220
+ for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null)
221
+ if (n.length && range.intersectsNode(n)) yield n;
222
+ }
223
+
224
+ /** Узел под схлопнутой кареткой — от него и ищется формат. */
225
+ function caretProbe(range: Range): Node {
226
+ const node = range.startContainer;
227
+ return node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
228
+ }
229
+
128
230
  /** Непустые текстовые узлы, целиком попавшие в диапазон (частично задетые правке не подлежат). */
129
231
  function collectTextNodes(root: HTMLElement, range: Range): Text[] {
130
232
  const nodes: Text[] = [];
131
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
132
- let n = walker.nextNode() as Text | null;
133
- while (n) {
134
- if (n.length > 0 && nodeWithinRange(n, range)) nodes.push(n);
135
- n = walker.nextNode() as Text | null;
136
- }
233
+ // intersectsNode в обходе дешевле пары compareBoundaryPoints и отсекает почти всё лишнее
234
+ for (const n of touchedTextNodes(root, range)) if (nodeWithinRange(n, range)) nodes.push(n);
235
+
137
236
  return nodes;
138
237
  }
139
238
 
@@ -163,7 +262,7 @@ function unwrapAround(fmt: HTMLElement, node: Node) {
163
262
  if (!fmt.firstChild) parent.removeChild(fmt);
164
263
  }
165
264
 
166
- function removeFormatFromNode(node: Text, tags: string[], root: HTMLElement) {
265
+ function removeFormatFromNode(node: Text, tags: ReadonlySet<string>, root: HTMLElement) {
167
266
  let fmt = formatAncestor(node, tags, root);
168
267
  while (fmt) {
169
268
  unwrapAround(fmt, node);
@@ -178,38 +277,55 @@ function unwrapElement(el: HTMLElement) {
178
277
  parent.removeChild(el);
179
278
  }
180
279
 
181
- /** Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст. */
280
+ /**
281
+ * Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст.
282
+ *
283
+ * Правка одного тега может сделать «грязными» его соседей и потомков, поэтому обход идёт очередью:
284
+ * заново перебирается только затронутое, а не всё содержимое редактора. Вызывается на каждый символ
285
+ * в режиме набора, поэтому повторные выборки по всему дереву тут заметны.
286
+ */
182
287
  export function cleanupFormatting(root: HTMLElement) {
183
- let changed = true;
184
- while (changed) {
185
- changed = false;
186
-
187
- for (const el of Array.from(root.querySelectorAll<HTMLElement>(FORMAT_SELECTOR))) {
188
- if (!el.isConnected) continue;
189
-
190
- // пустой тег
191
- if (el.textContent === "") {
192
- el.remove();
193
- changed = true;
194
- continue;
195
- }
196
-
197
- // вложен в такой же тег
198
- const parent = el.parentElement;
199
- if (parent && parent !== root && parent.tagName === el.tagName) {
200
- unwrapElement(el);
201
- changed = true;
202
- continue;
203
- }
204
-
205
- // соседний такой же тег слева склеиваем
206
- const prev = el.previousSibling;
207
- if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === el.tagName) {
208
- while (el.firstChild) prev.appendChild(el.firstChild);
209
- el.remove();
210
- changed = true;
211
- continue;
212
- }
288
+ const queue: HTMLElement[] = Array.from(root.querySelectorAll<HTMLElement>(FORMAT_SELECTOR));
289
+
290
+ const enqueue = (node: Node | null | undefined) => {
291
+ if (node && node.nodeType === Node.ELEMENT_NODE && FORMAT_TAG_SET.has((node as HTMLElement).tagName))
292
+ queue.push(node as HTMLElement);
293
+ };
294
+
295
+ while (queue.length) {
296
+ const el = queue.pop()!;
297
+ if (!el.isConnected || !root.contains(el)) continue;
298
+
299
+ // пустой тег: после удаления его соседи могут стать смежными одинаковыми,
300
+ // а родитель — опустеть
301
+ if (el.textContent === "") {
302
+ enqueue(el.nextSibling);
303
+ enqueue(el.parentElement);
304
+ el.remove();
305
+ continue;
306
+ }
307
+
308
+ // вложен в такой же тег — разворачиваем, поднятые дети попадают в новое окружение
309
+ const parent = el.parentElement;
310
+ if (parent && parent !== root && parent.tagName === el.tagName) {
311
+ const children = Array.from(el.children);
312
+ enqueue(el.nextSibling);
313
+ unwrapElement(el);
314
+ children.forEach(enqueue);
315
+ continue;
316
+ }
317
+
318
+ // соседний такой же тег слева — склеиваем
319
+ const prev = el.previousSibling;
320
+ if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === el.tagName) {
321
+ const children = Array.from(el.children);
322
+ enqueue(el.nextSibling);
323
+ enqueue(el.parentElement);
324
+ while (el.firstChild) prev.appendChild(el.firstChild);
325
+ el.remove();
326
+ enqueue(prev);
327
+ children.forEach(enqueue);
328
+ continue;
213
329
  }
214
330
  }
215
331
 
@@ -257,7 +373,7 @@ export function toggleFormat(
257
373
  restoreBounds?: [number, number]
258
374
  ) {
259
375
  const def = FORMAT_TOOLS[tool];
260
- const tags = def.matchTags;
376
+ const tags = TOOL_TAG_SETS[tool];
261
377
 
262
378
  editSelection(root, range, selection, restoreBounds, (nodes) => {
263
379
  const allFormatted = nodes.every((n) => formatAncestor(n, tags, root) !== null);
@@ -272,7 +388,7 @@ export function toggleFormat(
272
388
  /** Снимает всё форматирование с выделения (все инструменты сразу, включая теги-синонимы). */
273
389
  export function clearFormat(root: HTMLElement, range: Range, selection: Selection, restoreBounds?: [number, number]) {
274
390
  editSelection(root, range, selection, restoreBounds, (nodes) => {
275
- for (const n of nodes) removeFormatFromNode(n, MATCH_TAG_NAMES, root);
391
+ for (const n of nodes) removeFormatFromNode(n, MATCH_TAG_SET, root);
276
392
  });
277
393
  }
278
394
 
@@ -290,34 +406,44 @@ export function clearAllFormat(root: HTMLElement) {
290
406
  * `some` — отформатирована хоть какая-то часть (доступность очистки).
291
407
  * Обход прерывается на первом узле, решающем исход.
292
408
  */
293
- function rangeFormatState(root: HTMLElement, range: Range, tags: string[], mode: "every" | "some"): boolean {
409
+ /** Есть ли форматирование хоть на части выделения (или под кареткой) доступность кнопки очистки. */
410
+ export function hasFormatting(root: HTMLElement, range: Range): boolean {
411
+ if (range.collapsed) return formatAncestor(caretProbe(range), MATCH_TAG_SET, root) !== null;
412
+
413
+ for (const node of touchedTextNodes(root, range)) if (formatAncestor(node, MATCH_TAG_SET, root)) return true;
414
+
415
+ return false;
416
+ }
417
+
418
+ /**
419
+ * Инструменты, которыми отформатировано всё выделение, — за один обход вместо обхода
420
+ * на каждый инструмент. Панель опрашивает это состояние на каждое движение каретки.
421
+ */
422
+ export function activeFormats(root: HTMLElement, range: Range, tools: FormatTool[]): Set<FormatTool> {
423
+ const active = new Set<FormatTool>();
424
+ if (!tools.length) return active;
425
+
294
426
  if (range.collapsed) {
295
- const node = range.startContainer;
296
- const probe = node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
297
- return formatAncestor(probe, tags, root) !== null;
427
+ const probe = caretProbe(range);
428
+ for (const tool of tools) if (formatAncestor(probe, TOOL_TAG_SETS[tool], root)) active.add(tool);
429
+
430
+ return active;
298
431
  }
299
432
 
300
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
433
+ // инструмент остаётся кандидатом, пока каждый задетый узел им отформатирован
434
+ const pending = new Set(tools);
301
435
  let found = false;
302
436
 
303
- for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
304
- if (!n.length || !range.intersectsNode(n)) continue;
305
-
306
- const formatted = formatAncestor(n, tags, root) !== null;
307
- if (mode === "some") {
308
- if (formatted) return true;
309
- } else if (!formatted) return false;
310
-
437
+ for (const node of touchedTextNodes(root, range)) {
311
438
  found = true;
439
+ for (const tool of pending) if (!formatAncestor(node, TOOL_TAG_SETS[tool], root)) pending.delete(tool);
440
+ if (!pending.size) break; // все выбыли — дальше смотреть нечего
312
441
  }
313
442
 
314
- // every: пустое выделение не «отформатировано целиком»; some: ни одного форматированного узла
315
- return mode === "every" && found;
316
- }
443
+ // пустое выделение не считается «отформатированным целиком»
444
+ if (found) for (const tool of pending) active.add(tool);
317
445
 
318
- /** Есть ли форматирование на выделении (или под кареткой) — для доступности кнопки очистки. */
319
- export function hasFormatting(root: HTMLElement, range: Range): boolean {
320
- return rangeFormatState(root, range, MATCH_TAG_NAMES, "some");
446
+ return active;
321
447
  }
322
448
 
323
449
  /** Есть ли форматирование хоть где-то в содержимом. */
@@ -351,7 +477,7 @@ export function insertFormattedText(root: HTMLElement, data: string, tools: Form
351
477
  restoreSelection(root, offset, offset, selection);
352
478
  }
353
479
 
354
- /** Активен ли формат инструмента на текущем выделении (для подсветки кнопки). */
480
+ /** Активен ли формат инструмента на текущем выделении (для подсветки одиночной кнопки). */
355
481
  export function isFormatActive(root: HTMLElement, range: Range, tool: FormatTool): boolean {
356
- return rangeFormatState(root, range, FORMAT_TOOLS[tool].matchTags, "every");
482
+ return activeFormats(root, range, [tool]).has(tool);
357
483
  }
@@ -1,5 +1,6 @@
1
1
  // Разбор и сериализация значения редактора (HTML | Markdown), модель абзацев и мягких переносов.
2
2
 
3
+ import { isBlock } from "./paragraphs";
3
4
  import {
4
5
  FORMAT_TOOLS,
5
6
  defaultFormatMarkers,
@@ -112,11 +113,7 @@ function serializeParagraphs(
112
113
  };
113
114
 
114
115
  for (const node of Array.from(root.childNodes)) {
115
- const isBlock =
116
- node.nodeType === Node.ELEMENT_NODE &&
117
- ((node as Element).tagName === "P" || (node as Element).tagName === "DIV");
118
-
119
- if (isBlock) {
116
+ if (isBlock(node)) {
120
117
  flush();
121
118
  paragraphs.push(serializeInline((node as Element).childNodes, storage, tagMap, markers));
122
119
  } else {
@@ -182,13 +179,38 @@ function orderedMarkers(tools: FormatTool[], markers: FormatMarkers): MarkerRule
182
179
  });
183
180
  }
184
181
 
182
+ const TAG = /<(\/?)([a-z]+)>/g;
183
+
184
+ /**
185
+ * Закрыт ли в содержимом каждый тег, который в нём открыт.
186
+ *
187
+ * Маркеры применяются по очереди к уже размеченному тексту, поэтому пара маркеров может
188
+ * пересечь чужой тег: `**a _b** c_` дал бы `<b>a <i>b</b> c</i>`, а такой HTML браузер
189
+ * перестроит по-своему, и форматирование «протечёт» за пределы разметки. Пересекающуюся
190
+ * пару оставляем текстом — так же поступают мессенджеры. Собственный текст пользователя
191
+ * сюда не попадает: `<` в нём уже заэкранирован, тег может быть только нашим.
192
+ */
193
+ function balancedTags(inner: string): boolean {
194
+ if (!inner.includes("<")) return true;
195
+
196
+ const stack: string[] = [];
197
+ for (const [, closing, name] of inner.matchAll(TAG)) {
198
+ if (!closing) stack.push(name);
199
+ else if (stack.pop() !== name) return false;
200
+ }
201
+
202
+ return stack.length === 0;
203
+ }
204
+
185
205
  // Markdown-разметка одного абзаца → инлайновый HTML (escape, маркеры, \n→<br>).
186
206
  function markdownInline(text: string, order: MarkerRule[]): string {
187
207
  let html = escapeHtml(text);
188
208
 
189
209
  for (const [tool, marker, standalone] of order) {
190
210
  const def = FORMAT_TOOLS[tool];
191
- html = html.replace(markerPattern(marker, standalone), `$1<${def.tag}>$2</${def.tag}>`);
211
+ html = html.replace(markerPattern(marker, standalone), (match, lead: string, inner: string) =>
212
+ balancedTags(inner) ? `${lead}<${def.tag}>${inner}</${def.tag}>` : match
213
+ );
192
214
  }
193
215
 
194
216
  // переносы — после маркеров: пока это \n, запрет на пересечение строки работает
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,70 @@ 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;
65
88
  private __toolsKey = "";
66
89
  private __inContainer = false;
67
- private readonly __reposition = () => this.reposition();
90
+ private readonly __reposition = () => this.__schedule("position");
68
91
  private __resizeObserver: ResizeObserver | null = null;
92
+ private __selectionBound = false;
93
+ private __frame = 0;
94
+ private __pendingRefresh = false;
95
+ private __pendingPosition = false;
96
+
97
+ /**
98
+ * Единый листенер на весь документ: подсветка активных инструментов по текущему выделению.
99
+ * Вешается при первом показе панели, а не при загрузке модуля, и живёт до конца страницы —
100
+ * refresh() сам проверяет наличие активного редактора.
101
+ */
102
+ private __bindSelection() {
103
+ if (this.__selectionBound || typeof document === "undefined") return;
69
104
 
70
- constructor() {
71
- // единый листенер на весь app: подсветка активных инструментов по текущему выделению.
72
- // refresh() сам проверяет наличие активного редактора, поэтому отдельных per-editor листенеров не нужно.
73
- if (typeof document !== "undefined") document.addEventListener("selectionchange", () => this.refresh());
105
+ this.__selectionBound = true;
106
+ document.addEventListener("selectionchange", () => this.__schedule("refresh"));
107
+ }
108
+
109
+ /**
110
+ * Откладывает обновление до кадра отрисовки. selectionchange и scroll приходят пачками,
111
+ * а и подсветка (обход содержимого), и позиционирование (чтение геометрии) по событию
112
+ * заметно дороже, чем раз в кадр. Прямые вызовы refresh()/reposition() остаются синхронными.
113
+ */
114
+ private __schedule(kind: "refresh" | "position") {
115
+ if (!this.__active) return;
116
+
117
+ if (kind === "refresh") this.__pendingRefresh = true;
118
+ else this.__pendingPosition = true;
119
+
120
+ if (typeof requestAnimationFrame !== "function") this.__flush();
121
+ else this.__frame ||= requestAnimationFrame(() => this.__flush());
122
+ }
123
+
124
+ private __flush() {
125
+ const refresh = this.__pendingRefresh;
126
+ const position = this.__pendingPosition;
127
+ this.__cancelScheduled();
128
+
129
+ if (refresh) this.refresh();
130
+ if (position) this.reposition();
131
+ }
132
+
133
+ private __cancelScheduled() {
134
+ if (this.__frame && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.__frame);
135
+
136
+ this.__frame = 0;
137
+ this.__pendingRefresh = false;
138
+ this.__pendingPosition = false;
74
139
  }
75
140
 
76
141
  /** Показать тулбар для редактора (на фокусе): перестроить кнопки, спозиционировать, показать. */
77
142
  attach(host: ToolbarHost) {
78
143
  const actions = host.editorActions ?? [];
79
- if (!host.formatTools.length && !actions.length) return;
144
+ const buttons = host.toolbarButtons ?? [];
145
+ if (!host.formatTools.length && !actions.length && !buttons.length) return;
80
146
 
147
+ this.__bindSelection();
81
148
  this.__active = host;
82
- this.__build(host.formatTools, actions);
149
+ this.__build(host.formatTools, actions, buttons);
83
150
  this.refresh();
84
151
 
85
152
  const elem = this.__ensure();
@@ -110,22 +177,50 @@ class FormatToolbar {
110
177
 
111
178
  /** Скрыть тулбар, если он обслуживает этот редактор (на blur/destroy). */
112
179
  detach(host: ToolbarHost) {
113
- if (this.__active !== host) return;
180
+ // панель смайликов могла быть открыта не для активного редактора (у своей кнопки хоста)
181
+ // ссылку на него всё равно отпускаем, иначе уничтоженный редактор держится синглтоном
182
+ const emojiHost = this.__emojiHost === host;
183
+ if (!emojiHost && this.__active !== host) return;
114
184
 
115
185
  this.__closeEmoji();
186
+
187
+ if (emojiHost) {
188
+ this.__emojiHost = null;
189
+ this.__emojiInitiator = null;
190
+ }
191
+
192
+ if (this.__active !== host) return;
193
+
116
194
  this.__active = null;
195
+ this.__cancelScheduled();
117
196
  if (this.__elem) this.__elem.classList.remove("visible");
118
197
  this.__removeViewportListeners();
119
198
  }
120
199
 
121
- /** Обновить подсветку активных инструментов и доступность действий по текущему состоянию. */
200
+ /**
201
+ * Обновить подсветку активных инструментов и доступность действий по текущему состоянию.
202
+ *
203
+ * Пишем в DOM только при реальном изменении: обновление идёт на каждое движение каретки,
204
+ * а на документе живёт MutationObserver (им UIElement следит за удалением элементов) —
205
+ * повторная запись того же значения всё равно порождает запись мутации и его пробуждение.
206
+ */
122
207
  refresh() {
123
208
  const host = this.__active;
124
209
  if (!host) return;
125
210
 
126
- for (const [tool, btn] of this.__buttons) btn.classList.toggle("active", host.isToolActive(tool));
211
+ const setDisabled = (btn: HTMLButtonElement, disabled: boolean) => {
212
+ if (btn.disabled !== disabled) btn.disabled = disabled;
213
+ };
214
+
215
+ const active = host.activeTools?.();
216
+ for (const [tool, btn] of this.__buttons) {
217
+ const isActive = active ? active.has(tool) : host.isToolActive(tool);
218
+ if (btn.classList.contains("active") !== isActive) btn.classList.toggle("active", isActive);
219
+ }
220
+
127
221
  // хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
128
- for (const [action, btn] of this.__actionButtons) btn.disabled = host.isActionEnabled?.(action) === false;
222
+ for (const [action, btn] of this.__actionButtons) setDisabled(btn, host.isActionEnabled?.(action) === false);
223
+ for (const [button, btn] of this.__hostButtons) setDisabled(btn, button.isEnabled?.() === false);
129
224
  }
130
225
 
131
226
  /** Пересчитать позицию над активным редактором (только для режима body/fixed). */
@@ -144,6 +239,7 @@ class FormatToolbar {
144
239
  window.removeEventListener("scroll", this.__reposition);
145
240
  window.removeEventListener("resize", this.__reposition);
146
241
  this.__resizeObserver?.disconnect();
242
+ this.__cancelScheduled();
147
243
  }
148
244
 
149
245
  private __ensure(): HTMLElement {
@@ -160,8 +256,8 @@ class FormatToolbar {
160
256
  return this.__elem;
161
257
  }
162
258
 
163
- private __build(tools: FormatTool[], actions: EditorAction[]) {
164
- const key = `${tools.join(",")}|${actions.join(",")}`;
259
+ private __build(tools: FormatTool[], actions: EditorAction[], buttons: ToolbarButton[]) {
260
+ const key = `${tools.join(",")}|${actions.join(",")}|${buttons.map((b) => b.name).join(",")}`;
165
261
  const elem = this.__ensure();
166
262
  if (key === this.__toolsKey && elem.firstChild) return; // тот же состав — переиспользуем кнопки
167
263
 
@@ -170,6 +266,7 @@ class FormatToolbar {
170
266
  DOM.empty(elem);
171
267
  this.__buttons = [];
172
268
  this.__actionButtons = [];
269
+ this.__hostButtons = [];
173
270
 
174
271
  for (const tool of tools) {
175
272
  const def = FORMAT_TOOLS[tool];
@@ -200,6 +297,21 @@ class FormatToolbar {
200
297
  this.__actionButtons.push([action, btn]);
201
298
  }
202
299
 
300
+ if ((tools.length || actions.length) && buttons.length) elem.appendChild(DOM.tag("div", { class: "split" }));
301
+
302
+ // кнопки хоста — последними, чтобы штатные не переезжали при их появлении
303
+ for (const button of buttons) {
304
+ const btn = DOM.tag(
305
+ "button",
306
+ { type: "button", class: "host-button", "data-toolbar-button": button.name, title: button.title },
307
+ button.icon
308
+ );
309
+ btn.addEventListener("click", () => button.run());
310
+
311
+ elem.appendChild(btn);
312
+ this.__hostButtons.push([button, btn]);
313
+ }
314
+
203
315
  // панель пережила перестройку кнопок — возвращаем её в тулбар, чтобы не собирать заново.
204
316
  // Если её забрал хост под свою кнопку, она остаётся у него.
205
317
  if (pickerInToolbar && this.__emojiPicker) elem.appendChild(this.__emojiPicker);