@brandup/ui-richeditor 1.0.43 → 1.0.45

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.
@@ -3,6 +3,7 @@
3
3
  import { blockTypeOf } from "./paragraphs";
4
4
  import {
5
5
  ALL_BLOCK_TYPES,
6
+ ALL_FORMAT_TOOLS,
6
7
  BLOCK_TYPES,
7
8
  DEFAULT_BLOCK,
8
9
  FORMAT_TOOLS,
@@ -28,17 +29,29 @@ function buildTagMap(tools: FormatTool[]): Record<string, FormatTool> {
28
29
  return map;
29
30
  }
30
31
 
32
+ /**
33
+ * Чем текст был размечен, если не настроенным маркером. Живёт только в разметке поля: значение
34
+ * собирается тегами, и атрибут в него не попадает ни при каком формате хранения.
35
+ */
36
+ const MARKER_ATTR = "data-md";
37
+
31
38
  function lineBreak(storage: FormatStorage): string {
32
39
  return storage === "html" ? "<br>" : "\n";
33
40
  }
34
41
 
35
- function wrap(storage: FormatStorage, tool: FormatTool, inner: string, markers: FormatMarkers): string {
42
+ function wrap(
43
+ storage: FormatStorage,
44
+ tool: FormatTool,
45
+ inner: string,
46
+ markers: FormatMarkers,
47
+ source?: HTMLElement
48
+ ): string {
36
49
  if (!inner) return inner;
37
50
 
38
51
  const def = FORMAT_TOOLS[tool];
39
52
  if (storage === "html") return `<${def.tag}>${inner}</${def.tag}>`;
40
53
 
41
- const marker = markers[tool];
54
+ const marker = ownMarker(tool, markers, source);
42
55
 
43
56
  // Маркер не сработает, если содержимое начинается или заканчивается пробелом, — ни у нас
44
57
  // при разборе, ни у мессенджера. Выносим краевые пробелы наружу: разметка сохраняется,
@@ -51,6 +64,17 @@ function wrap(storage: FormatStorage, tool: FormatTool, inner: string, markers:
51
64
  return `${leading}${marker}${core}${marker}${trailing}`;
52
65
  }
53
66
 
67
+ /**
68
+ * Маркер, которым текст был размечен: запомненный на элементе, если он известен инструменту,
69
+ * иначе настроенный. Чужой диалект (`*жирный*` из WhatsApp) обязан вернуться в значение таким
70
+ * же, каким пришёл; всё, что размечено в поле, пишется настроенным маркером.
71
+ */
72
+ function ownMarker(tool: FormatTool, markers: FormatMarkers, source?: HTMLElement): string {
73
+ const marked = source?.dataset.md;
74
+
75
+ return marked && MARKER_ALIASES[tool].includes(marked) ? marked : markers[tool];
76
+ }
77
+
54
78
  // Сериализует инлайновое содержимое (текст, форматирование, <br> как мягкий перенос).
55
79
  // Абзацы (<p>/<div>) на этом уровне не учитываются — их разбирает serializeParagraphs.
56
80
  function serializeInline(
@@ -82,9 +106,9 @@ function serializeInline(
82
106
 
83
107
  // В коде разметки нет: при разборе его содержимое не размечается, и вложенное
84
108
  // форматирование не вернулось бы — значение разошлось бы с тем, что было в поле.
85
- if (tool === "code") {
109
+ if (tool && FORMAT_TOOLS[tool].literal) {
86
110
  const text = el.textContent ?? "";
87
- result += wrap(storage, tool, storage === "html" ? escapeHtml(text) : text, markers);
111
+ result += wrap(storage, tool, storage === "html" ? escapeHtml(text) : text, markers, el);
88
112
  continue;
89
113
  }
90
114
 
@@ -97,16 +121,17 @@ function serializeInline(
97
121
  }
98
122
 
99
123
  // неизвестный или отключённый тег — отбрасываем обёртку, оставляем текст
100
- result += tool ? wrap(storage, tool, inner, markers) : inner;
124
+ result += tool ? wrap(storage, tool, inner, markers, el) : inner;
101
125
  }
102
126
 
103
127
  return result;
104
128
  }
105
129
 
106
- // Хвостовые переносы абзаца отбрасываем это <br>-заполнители, делающие последнюю строку видимой;
107
- // мягкий перенос осмыслен только между содержимым (для пустой строки используйте новый абзац).
130
+ // Хвостовой перенос заполнитель: без него последняя строка блока не видна и в неё не встать
131
+ // кареткой. Отбрасываем ровно один; остальные набраны руками и значат пустые строки отбросив
132
+ // их, редактор показывал бы строку, которой в значении нет. У всего значения хвост уберёт trim().
108
133
  function trimTrailingBreaks(inline: string, storage: FormatStorage): string {
109
- return storage === "html" ? inline.replace(/(?:<br>)+$/, "") : inline.replace(/\n+$/, "");
134
+ return storage === "html" ? inline.replace(/<br>$/, "") : inline.replace(/\n$/, "");
110
135
  }
111
136
 
112
137
  // Разбивает верхний уровень на блоки: у каждого свой тип, у не попавшего в блок содержимого —
@@ -157,6 +182,11 @@ function serializeParagraphs(
157
182
 
158
183
  const cleaned = blocks.map(([type, text]) => [type, trimTrailingBreaks(text, storage)] as const);
159
184
 
185
+ // Пустые блоки в хвосте в значение не идут: последний из них — место каретки, оставленное
186
+ // нормализацией (см. normalizeParagraphs), а не строка сообщения. В markdown его срезал бы
187
+ // trim всего значения — html обрезаем здесь, чтобы форматы хранения не расходились.
188
+ while (cleaned.length && !cleaned[cleaned.length - 1][1]) cleaned.pop();
189
+
160
190
  if (storage === "html")
161
191
  return cleaned
162
192
  .map(([type, text]) => {
@@ -220,8 +250,17 @@ export function serialize(
220
250
  return inline.replace(/^\n+/, "").replace(/\n+$/, "").trim();
221
251
  }
222
252
 
223
- /** Инструмент, его маркер и признак «содержимое не может начинаться/заканчиваться самим маркером». */
224
- type MarkerRule = [tool: FormatTool, marker: string, standalone: boolean];
253
+ /** Правило разбора одного маркера. */
254
+ interface MarkerRule {
255
+ tool: FormatTool;
256
+ marker: string;
257
+ /** Содержимое не может начинаться и заканчиваться символом самого маркера. */
258
+ standalone: boolean;
259
+ /** Символ маркера не считается границей: маркер не собирается из половинок длинного. */
260
+ guarded: boolean;
261
+ /** Маркер настроенный, а не из чужого диалекта. */
262
+ own: boolean;
263
+ }
225
264
 
226
265
  /**
227
266
  * Маркеры в порядке применения: длинный (`__`) раньше короткого-префикса (`_`), иначе
@@ -234,20 +273,47 @@ type MarkerRule = [tool: FormatTool, marker: string, standalone: boolean];
234
273
  * подчёркивания. Так же поступают паттерны конвертеров.
235
274
  */
236
275
  function orderedMarkers(tools: FormatTool[], markers: FormatMarkers): MarkerRule[] {
237
- const active = tools.filter((tool) => markers[tool]).sort((a, b) => markers[b].length - markers[a].length);
238
-
239
- return active.map((tool) => {
240
- const marker = markers[tool];
241
- const hasShorterPrefix = active.some((other) => {
242
- const value = markers[other];
243
- return value.length < marker.length && marker.startsWith(value);
244
- });
276
+ const active = tools.filter((tool) => markers[tool]);
277
+ const rules: Array<[FormatTool, string, boolean]> = active.map((tool) => [tool, markers[tool], true]);
278
+ const taken = new Set(rules.map(([, marker]) => marker));
279
+
280
+ // Чужие диалекты того же инструмента: запасные маркеры из реестра и его собственный, если
281
+ // настройка его заменила. Разбираются наравне, а ставится всегда настроенный — см. markdownInline.
282
+ for (const tool of active)
283
+ for (const alias of MARKER_ALIASES[tool])
284
+ if (!taken.has(alias)) {
285
+ taken.add(alias);
286
+ rules.push([tool, alias, false]);
287
+ }
245
288
 
246
- return [tool, marker, hasShorterPrefix];
247
- });
289
+ const all = Array.from(taken);
290
+
291
+ return rules
292
+ .sort((a, b) => b[1].length - a[1].length)
293
+ .map(([tool, marker, own]) => ({
294
+ tool,
295
+ marker,
296
+ own,
297
+ standalone: all.some((value) => value.length < marker.length && marker.startsWith(value)),
298
+ guarded: all.some((value) => value.length > marker.length && value.startsWith(marker)),
299
+ }));
248
300
  }
249
301
 
250
- const TAG = /<(\/?)([a-z]+)>/g;
302
+ /**
303
+ * Маркеры инструмента, объявленные в реестре: свой и запасные. Собираются один раз — их
304
+ * спрашивают и на каждый разбор, и на каждый размеченный элемент при сериализации.
305
+ */
306
+ const MARKER_ALIASES: Record<FormatTool, string[]> = ALL_FORMAT_TOOLS.reduce(
307
+ (map, tool) => {
308
+ const def = FORMAT_TOOLS[tool];
309
+ map[tool] = [def.md, ...(def.mdAliases ?? [])].filter(Boolean);
310
+
311
+ return map;
312
+ },
313
+ {} as Record<FormatTool, string[]>
314
+ );
315
+
316
+ const TAG = /<(\/?)([a-z-]+)(?:\s[^>]*)?>/g;
251
317
 
252
318
  /**
253
319
  * Закрыт ли в содержимом каждый тег, который в нём открыт.
@@ -285,28 +351,34 @@ function markdownInline(text: string, order: MarkerRule[]): string {
285
351
 
286
352
  // Код — первым: внутри него `*звёздочки*` остаются текстом, как у мессенджеров. Иначе
287
353
  // содержимое кода размечалось бы, и то, что человек написал буквально, уезжало бы жирным.
288
- const stash: string[] = [];
289
- const code = order.find(([tool]) => tool === "code");
290
- if (code) {
291
- html = html.replace(markerPattern(code[1], code[2]), (_match, lead: string, inner: string) => {
292
- stash.push(inner);
293
-
294
- return `${lead}${STASH_MARK}${stash.length - 1}${STASH_MARK}`;
295
- });
296
- }
297
-
298
- for (const [tool, marker, standalone] of order) {
299
- if (tool === "code") continue; // уже вынесен
300
-
301
- const def = FORMAT_TOOLS[tool];
302
- html = html.replace(markerPattern(marker, standalone), (match, lead: string, inner: string) =>
303
- balancedTags(inner) ? `${lead}<${def.tag}>${inner}</${def.tag}>` : match
354
+ const stash: Array<[inner: string, marked: string]> = []; // содержимое и чужой маркер, если был
355
+ for (const rule of order)
356
+ if (rule.tool === "code" && html.includes(rule.marker))
357
+ html = html.replace(pattern(rule), (_match, lead: string, inner: string) => {
358
+ stash.push([inner, rule.own ? "" : rule.marker]);
359
+
360
+ return `${lead}${STASH_MARK}${stash.length - 1}${STASH_MARK}`;
361
+ });
362
+
363
+ for (const rule of order) {
364
+ if (rule.tool === "code") continue; // уже вынесен
365
+ // Разбор идёт по абзацу на каждый маркер, а маркеров с диалектами уже семь. Поиск
366
+ // подстроки на порядок дешевле разбора регуляркой, и почти всегда он же и решает.
367
+ if (!html.includes(rule.marker)) continue;
368
+
369
+ const def = FORMAT_TOOLS[rule.tool];
370
+ html = html.replace(pattern(rule), (match, lead: string, inner: string) =>
371
+ balancedTags(inner) ? `${lead}${openTag(def.tag, rule.own ? "" : rule.marker)}${inner}</${def.tag}>` : match
304
372
  );
305
373
  }
306
374
 
307
375
  if (stash.length) {
308
376
  const def = FORMAT_TOOLS.code;
309
- html = html.replace(STASH_PATTERN, (_match, index: string) => `<${def.tag}>${stash[+index]}</${def.tag}>`);
377
+ html = html.replace(STASH_PATTERN, (_match, index: string) => {
378
+ const [inner, marked] = stash[+index];
379
+
380
+ return `${openTag(def.tag, marked)}${inner}</${def.tag}>`;
381
+ });
310
382
  }
311
383
 
312
384
  // переносы — после маркеров: пока это \n, запрет на пересечение строки работает
@@ -328,26 +400,41 @@ function markdownInline(text: string, order: MarkerRule[]): string {
328
400
  * Содержимое не начинается и с U+20E3: в keycap-последовательностях (`*⃣`, `#⃣`, `1⃣`) сам
329
401
  * маркер служит базовым символом, и без этого `*⃣раз*` разбиралось бы как разметка вместо эмодзи.
330
402
  *
331
- * При `standalone` содержимое дополнительно не начинается и не заканчивается символом самого
332
- * маркера см. {@link orderedMarkers}.
403
+ * При `standalone` содержимое не начинается и не заканчивается символом самого маркера, при
404
+ * `guarded` этот символ вдобавок перестаёт быть границей. Так разводятся маркеры, один из
405
+ * которых начинается с другого (`_` и `__`, `*` и `**`) — см. {@link orderedMarkers}.
333
406
  */
334
- function markerPattern(marker: string, standalone: boolean): RegExp {
335
- const key = standalone ? `${marker}` : marker;
407
+ function pattern(rule: MarkerRule): RegExp {
408
+ const { marker, standalone, guarded } = rule;
409
+ // в ключе различаем режимы: один и тот же маркер бывает разведён по-разному
410
+ const key = `${marker}|${standalone ? "s" : ""}${guarded ? "g" : ""}`;
336
411
 
337
- let pattern = markerPatterns.get(key);
338
- if (pattern) return pattern;
412
+ const cached = markerPatterns.get(key);
413
+ if (cached) return cached;
339
414
 
340
415
  const escaped = escapeRegExp(marker);
341
- const boundary = "[^\\p{L}\\p{N}]";
342
416
  // символ маркера в классе — экранируем то, что в нём значимо
343
- const own = standalone ? marker.slice(-1).replace(/[\\\]^-]/g, "\\$&") : "";
344
- const head = `[^\\s\\u20e3${own}]`;
345
- const tail = standalone ? `[^\\s${own}]` : "\\S";
417
+ const own = marker.slice(-1).replace(/[\\\]^-]/g, "\\$&");
418
+ const edge = standalone || guarded ? own : "";
419
+ // Границей символ маркера перестаёт быть только у короткого. Длинному она нужна: в
420
+ // `___текст___` его пара стоит рядом со своим же символом, и без границы её не найти.
421
+ const boundary = `[^\\p{L}\\p{N}${guarded ? own : ""}]`;
422
+ const head = `[^\\s\\u20e3${edge}]`;
423
+ const tail = edge ? `[^\\s${edge}]` : "\\S";
424
+
425
+ const built = new RegExp(
426
+ `(^|${boundary})${escaped}(${head}|${head}[^\\n]*?${tail})${escaped}(?=$|${boundary})`,
427
+ "gu"
428
+ );
429
+ markerPatterns.set(key, built);
346
430
 
347
- pattern = new RegExp(`(^|${boundary})${escaped}(${head}|${head}[^\\n]*?${tail})${escaped}(?=$|${boundary})`, "gu");
348
- markerPatterns.set(key, pattern);
431
+ return built;
432
+ }
349
433
 
350
- return pattern;
434
+ /** Открывающий тег: чужой маркер запоминается на элементе, чтобы значение вернулось таким же. */
435
+ function openTag(tag: string, marked: string): string {
436
+ // маркер задаёт хост — в значении атрибута экранируем и кавычку, иначе она разорвёт его
437
+ return marked ? `<${tag} ${MARKER_ATTR}="${escapeHtml(marked).replace(/"/g, "&quot;")}">` : `<${tag}>`;
351
438
  }
352
439
 
353
440
  // Набор маркеров за разбор не меняется, а разбор идёт по абзацам — компилируем каждую
@@ -380,7 +467,11 @@ export function deserialize(
380
467
  const def = BLOCK_TYPES[type];
381
468
  const inner = def.inline ? markdownInline(text, order) : escapeHtml(text).replace(/\n/g, "<br>");
382
469
 
383
- return `<${def.tag}>${inner || "<br>"}</${def.tag}>`;
470
+ // Пустая последняя строка видна только с заполнителем: без него браузер не рисует
471
+ // её и не пускает туда каретку — строка, которая в значении есть, пропала бы.
472
+ const filler = inner.endsWith("<br>") ? "<br>" : "";
473
+
474
+ return `<${def.tag}>${inner ? inner + filler : "<br>"}</${def.tag}>`;
384
475
  })
385
476
  .join("");
386
477
  }
package/source/toolbar.ts CHANGED
@@ -60,8 +60,8 @@ const MERGED_CODE_TITLE = "Код";
60
60
  // Временно скрытые кнопки. Сами возможности работают: значение разбирается, показывается
61
61
  // и сохраняется, правку можно вызвать из кода — в панель они просто не выводятся.
62
62
  // Убрать отсюда, когда будут доведены.
63
- const HIDDEN_TOOLS: FormatTool[] = ["spoiler", "code"];
64
- const HIDDEN_BLOCKS: BlockType[] = ["code"];
63
+ const HIDDEN_TOOLS: FormatTool[] = ["spoiler"];
64
+ const HIDDEN_BLOCKS: BlockType[] = [];
65
65
 
66
66
  export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
67
67
  export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
@@ -92,8 +92,12 @@ export interface ToolbarHost {
92
92
  readonly editorActions?: EditorAction[];
93
93
  /** Контейнер для тулбара; null/undefined — document.body (position: fixed над редактором). */
94
94
  readonly toolbarContainer?: HTMLElement | null;
95
- /** Типы блоков многострочного режима; пусто/undefined — кнопок блоков нет. */
96
- readonly blockTypes?: BlockType[];
95
+ /**
96
+ * Типы блоков, которые панель вправе предложить; пусто/undefined — кнопок блоков нет.
97
+ * Не то же, что набор для разбора значения: показывать цитату и код редактор обязан
98
+ * и там, где их не переключить (только для чтения).
99
+ */
100
+ readonly blockTools?: BlockType[];
97
101
  applyFormat(tool: FormatTool): void;
98
102
  isToolActive(tool: FormatTool): boolean;
99
103
  /** false — инструмент сейчас недоступен (например, внутри кода): кнопка гасится. */
@@ -112,6 +116,14 @@ export interface ToolbarHost {
112
116
  isActionEnabled?(action: EditorAction): boolean;
113
117
  /** Вставка текста в каретку — для панели смайликов. */
114
118
  insertText?(text: string): void;
119
+ /**
120
+ * Открыть панель смайликов. Своя кнопка панели зовёт редактор, а не {@link openEmoji}
121
+ * напрямую: он придерживает правку и отпускает фокус на её время — с кнопкой хоста это
122
+ * должно работать одинаково.
123
+ */
124
+ openEmojiPicker?(initiator: HTMLElement, container: HTMLElement): void;
125
+ /** Панель смайликов закрылась — редактор возвращает себе то, что придержал на её время. */
126
+ onEmojiClosed?(): void;
115
127
  /** Собственные кнопки хоста; пусто/undefined — только штатные. */
116
128
  readonly toolbarButtons?: ToolbarButton[];
117
129
  }
@@ -222,10 +234,15 @@ class FormatToolbar {
222
234
  const buttons = host.toolbarButtons ?? [];
223
235
  const tools = host.formatTools.filter((tool) => !HIDDEN_TOOLS.includes(tool));
224
236
  // Обычный текст кнопки не имеет — он не «включается», а остаётся, когда выключены остальные.
225
- const blocks = (host.blockTypes ?? []).filter(
237
+ const blocks = (host.blockTools ?? []).filter(
226
238
  (type) => type !== DEFAULT_BLOCK && !HIDDEN_BLOCKS.includes(type)
227
239
  );
228
- if (!tools.length && !blocks.length && !actions.length && !buttons.length) return;
240
+ // Nothing to show and the previous editor's panel must not stay on screen either: focus
241
+ // moved to this one, while its buttons would still edit the neighbour.
242
+ if (!tools.length && !blocks.length && !actions.length && !buttons.length) {
243
+ if (this.__active) this.__hide();
244
+ return;
245
+ }
229
246
 
230
247
  this.__bindSelection();
231
248
  this.__active = host;
@@ -258,6 +275,15 @@ class FormatToolbar {
258
275
  }
259
276
  }
260
277
 
278
+ /**
279
+ * Убрать панель с экрана, не отпуская редактор: он отдал фокус своему слою — окну или панели
280
+ * смайликов. От {@link detach} отличается тем, что панель смайликов при этом остаётся: её и
281
+ * открыли из этого редактора, а фокус сняли как раз ради неё.
282
+ */
283
+ suspend(host: ToolbarHost) {
284
+ if (this.__active === host) this.__hide();
285
+ }
286
+
261
287
  /** Скрыть тулбар, если он обслуживает этот редактор (на blur/destroy). */
262
288
  detach(host: ToolbarHost) {
263
289
  // Придержанный показ снимаем первым делом, до закрытия панели: иначе её onClose поднял бы
@@ -394,16 +420,6 @@ class FormatToolbar {
394
420
  this.__mergedCode = tools.includes(CODE_TOOL) && blocks.includes(CODE_BLOCK);
395
421
  const blockTypes = this.__mergedCode ? blocks.filter((type) => type !== CODE_BLOCK) : blocks;
396
422
 
397
- // Разделитель ставится только между непустыми группами — иначе панель начиналась бы
398
- // с линии или показывала две подряд.
399
- let filled = false;
400
- const separate = (group: unknown[]) => {
401
- if (filled && group.length) elem.appendChild(DOM.tag("div", { class: "split" }));
402
- filled ||= group.length > 0;
403
- };
404
-
405
- separate(tools);
406
-
407
423
  for (const tool of tools) {
408
424
  const merged = this.__mergedCode && tool === CODE_TOOL;
409
425
  const def = FORMAT_TOOLS[tool];
@@ -425,8 +441,6 @@ class FormatToolbar {
425
441
  this.__buttons.push([tool, btn]);
426
442
  }
427
443
 
428
- separate(blockTypes);
429
-
430
444
  for (const type of blockTypes) {
431
445
  const def = BLOCK_TYPES[type];
432
446
  const btn = DOM.tag(
@@ -440,8 +454,6 @@ class FormatToolbar {
440
454
  this.__blockButtons.push([type, btn]);
441
455
  }
442
456
 
443
- separate(actions);
444
-
445
457
  for (const action of actions) {
446
458
  const def = EDITOR_ACTIONS[action];
447
459
  const btn = DOM.tag(
@@ -456,7 +468,11 @@ class FormatToolbar {
456
468
  this.__actionButtons.push([action, btn]);
457
469
  }
458
470
 
459
- separate(buttons);
471
+ // Инструменты, блоки и действия — одна группа: всё это правка оформления. Разделитель
472
+ // нужен только перед кнопками хоста: они про другое — переменные, рандомизацию и прочее
473
+ // доменное. Пустых групп разделитель не касается, иначе панель начиналась бы с линии.
474
+ if (buttons.length && (tools.length || blockTypes.length || actions.length))
475
+ elem.appendChild(DOM.tag("div", { class: "split" }));
460
476
 
461
477
  // кнопки хоста — последними, чтобы штатные не переезжали при их появлении
462
478
  for (const button of buttons) {
@@ -485,7 +501,7 @@ class FormatToolbar {
485
501
  * закрытия на body прямо в open(), то есть во время этого же клика — до body событие ещё
486
502
  * не дошло, и слушатель закрыл бы панель сразу после открытия.
487
503
  */
488
- openEmoji(host: ToolbarHost, initiator: HTMLElement, container: HTMLElement) {
504
+ openEmoji(host: ToolbarHost, initiator: HTMLElement, container: HTMLElement): boolean {
489
505
  // повторный клик по той же кнопке закрывает панель (это делает toggle внутри PopupManager),
490
506
  // а вот у другой кнопки её нужно сперва закрыть — иначе toggle сочтёт открытие повторным
491
507
  if (this.__emojiInitiator !== initiator && this.__emojiPicker?.classList.contains("opened"))
@@ -504,7 +520,17 @@ class FormatToolbar {
504
520
  if (this.__active === host) this.__hide();
505
521
  }
506
522
 
507
- PopupManager.open(this.__ensureEmojiPicker(container), { initiator, onClose: () => this.__resume() });
523
+ PopupManager.open(this.__ensureEmojiPicker(container), {
524
+ initiator,
525
+ onClose: () => {
526
+ this.__resume();
527
+ host.onEmojiClosed?.();
528
+ },
529
+ });
530
+
531
+ // Повторное нажатие по той же кнопке панель закрывает (это toggle внутри PopupManager) —
532
+ // вызывающему нужно знать исход: открытая панель ещё и придерживает правку редактора.
533
+ return !!this.__emojiPicker?.classList.contains("opened");
508
534
  }
509
535
 
510
536
  /** Панель смайликов закрылась — показываем придержанный тулбар, если редактор ещё в фокусе. */
@@ -521,7 +547,12 @@ class FormatToolbar {
521
547
  private __toggleEmoji(initiator: HTMLButtonElement, e: MouseEvent) {
522
548
  e.stopPropagation();
523
549
 
524
- if (this.__active) this.openEmoji(this.__active, initiator, this.__ensure());
550
+ const host = this.__active;
551
+ if (!host) return;
552
+
553
+ // через редактор, а не напрямую: панель — его слой, и придерживает правку он
554
+ if (host.openEmojiPicker) host.openEmojiPicker(initiator, this.__ensure());
555
+ else this.openEmoji(host, initiator, this.__ensure());
525
556
  }
526
557
 
527
558
  private __ensureEmojiPicker(container: HTMLElement): HTMLElement {