@brandup/ui-richeditor 1.0.36 → 1.0.38

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.
@@ -4,24 +4,31 @@ import { DOM, UIElementBound } from "@brandup/ui";
4
4
  import {
5
5
  ALL_FORMAT_TOOLS,
6
6
  HOTKEY_TOOLS,
7
+ clearAllFormat,
8
+ clearFormat,
7
9
  defaultFormatMarkers,
8
10
  deserialize,
9
11
  ensureParagraphs,
12
+ hasAnyFormatting,
13
+ hasFormatting,
10
14
  insertFormattedText,
11
15
  isFormatActive,
16
+ mapCharOffset,
12
17
  normalizeParagraphs,
13
18
  normalizeWhitespace,
14
19
  restoreSelection,
15
20
  selectionCharBounds,
16
21
  serialize,
17
22
  toggleFormat,
23
+ type EditorAction,
18
24
  type FormatMarkers,
19
25
  type FormatStorage,
26
+ type ParagraphMode,
20
27
  type FormatTool,
21
28
  } from "./format";
22
29
  import {
23
30
  caretToEnd,
24
- expandSelectionToWords,
31
+ expandRangeToWords,
25
32
  insertParagraph,
26
33
  insertPastedParagraphs,
27
34
  insertSoftBreak,
@@ -68,6 +75,8 @@ export interface RichEditorOptions {
68
75
  format?: boolean;
69
76
  /** Состав инструментов (по умолчанию все). */
70
77
  tools?: FormatTool[];
78
+ /** Кнопки действий в панели: очистка форматирования, отмена, повтор (по умолчанию нет). */
79
+ actions?: EditorAction[];
71
80
  /** Формат хранения значения при форматировании. */
72
81
  storage?: FormatStorage;
73
82
  /** Переопределения markdown-маркеров. */
@@ -76,6 +85,8 @@ export interface RichEditorOptions {
76
85
  placeholder?: string | null;
77
86
  /** Многострочный режим. */
78
87
  multiline?: boolean;
88
+ /** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
89
+ paragraph?: ParagraphMode;
79
90
  /** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
80
91
  readonly?: boolean;
81
92
  /** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
@@ -107,9 +118,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
107
118
  readonly editable: HTMLElement;
108
119
  readonly format: boolean;
109
120
  readonly formatTools: FormatTool[];
121
+ readonly editorActions: EditorAction[];
110
122
  readonly formatStorage: FormatStorage;
111
123
  readonly formatMarkers: FormatMarkers;
112
124
  readonly multiline: boolean;
125
+ readonly paragraph: ParagraphMode;
113
126
  readonly toolbarContainer: HTMLElement | null;
114
127
 
115
128
  private __opts: RichEditorOptions;
@@ -125,6 +138,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
125
138
  const readonly = !!options.readonly;
126
139
  // форматирование недоступно в режиме только для чтения
127
140
  const tools = format && !readonly ? (options.tools ?? ALL_FORMAT_TOOLS.slice()) : [];
141
+ // действия подключаются явно — иначе панель у существующих хостов молча обзавелась бы кнопками
142
+ const actions = format && !readonly ? (options.actions ?? []) : [];
128
143
 
129
144
  // тулбар общий и живёт в body (см. ./toolbar), поэтому обёртка не нужна —
130
145
  // привязываем UIElement прямо к переданному элементу, он же и редактируемый
@@ -136,9 +151,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
136
151
  this.__opts = options;
137
152
  this.format = format;
138
153
  this.formatTools = tools;
154
+ this.editorActions = actions;
139
155
  this.formatStorage = options.storage === "markdown" ? "markdown" : "html";
140
156
  this.formatMarkers = Object.assign(defaultFormatMarkers(), options.markers);
141
157
  this.multiline = multiline;
158
+ this.paragraph = options.paragraph ?? "block";
142
159
  this.toolbarContainer = options.toolbarContainer ?? null;
143
160
  // история включается вместе с форматированием
144
161
  this.__history = format ? new EditorHistory(editable) : null;
@@ -177,11 +194,53 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
177
194
  }
178
195
 
179
196
  setValue(value: string): void {
197
+ // каретка внутри относилась к прежнему содержимому — после замены ставим её в конец
198
+ const hadCaret = !!this.__innerSelection();
199
+
180
200
  this.__render(value ?? "");
181
201
  this.__normalize(false);
202
+ if (hadCaret) caretToEnd(this.editable, this.multiline);
203
+
182
204
  this.__emitChange();
183
205
  }
184
206
 
207
+ /**
208
+ * Вставляет текст в каретку (или вместо выделения) с учётом ожидающих форматов режима набора.
209
+ * Каретка сохраняется и после потери фокуса, поэтому метод работает и при вызове из кода —
210
+ * например, кнопкой панели, которая не должна забирать фокус у редактора.
211
+ */
212
+ insertText(text: string): void {
213
+ if (this.readonly || !text || !this.__innerSelection()) return;
214
+
215
+ // вставка — такой же ввод, как с клавиатуры, поэтому проходит через filterChar хоста
216
+ // (ограничения по типу поля и длине). Обход символов идёт по кодпойнтам, чтобы эмодзи
217
+ // проверялся целиком; длина сверяется с текущим содержимым, так что многосимвольная
218
+ // вставка ограничивается по первому символу — для одного символа проверка точная.
219
+ const filterChar = this.__opts.filterChar;
220
+ if (filterChar && !Array.from(text).every((char) => filterChar(char))) {
221
+ this.__reject();
222
+ return;
223
+ }
224
+
225
+ this.__history?.record("op");
226
+ this.__insertText(text);
227
+ }
228
+
229
+ /**
230
+ * Показать панель вставки смайлика у своей кнопки — когда она живёт не в тулбаре, а в разметке
231
+ * хоста (например справа от поля ввода). Вызывать из обработчика `click`, погасив всплытие:
232
+ * иначе попап закроется тем же кликом, которым открылся.
233
+ *
234
+ * @param initiator Кнопка, у которой показывается панель.
235
+ * @param container Куда монтировать панель; по умолчанию — родитель кнопки.
236
+ */
237
+ openEmojiPicker(initiator: HTMLElement, container?: HTMLElement): void {
238
+ if (this.readonly) return;
239
+
240
+ const target = container ?? initiator.parentElement;
241
+ if (target) formatToolbar.openEmoji(this, initiator, target);
242
+ }
243
+
185
244
  getLength(): number {
186
245
  // textContent (а не innerText) корректно считает multiline и работает в jsdom
187
246
  return this.editable.textContent?.length ?? 0;
@@ -195,22 +254,46 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
195
254
  this.on(CHANGE_EVENT, handler);
196
255
  }
197
256
 
198
- /** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
199
- applyFormat(tool: FormatTool): void {
200
- if (!this.format || this.readonly || !this.formatTools.includes(tool)) return;
201
-
257
+ /**
258
+ * Выделение, если оно находится внутри этого редактора. Браузер сохраняет выделение и после
259
+ * blur, поэтому проверка работает и когда фокус ушёл на кнопку страницы.
260
+ */
261
+ private __innerSelection(): Selection | null {
202
262
  const selection = window.getSelection();
203
- if (!selection || selection.rangeCount === 0) return;
204
- if (!this.editable.contains(selection.anchorNode)) return;
263
+ if (!selection || selection.rangeCount === 0 || !this.editable.contains(selection.anchorNode)) return null;
205
264
 
206
- // запоминаем исходное выделение, чтобы вернуть его после форматирования
207
- const original = selectionCharBounds(this.editable, selection.getRangeAt(0));
265
+ return selection;
266
+ }
208
267
 
209
- // форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
210
- expandSelectionToWords(this.editable, selection);
268
+ /**
269
+ * Цель правки форматирования: выделение, расширенное до целых слов, плюс исходные границы
270
+ * для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
271
+ * править, каретка пользователя не двигается. null — правка недоступна или выделение вне редактора.
272
+ */
273
+ private __formatTarget(): { selection: Selection; range: Range; original: [number, number] } | null {
274
+ if (!this.format || this.readonly) return null;
275
+
276
+ const selection = this.__innerSelection();
277
+ if (!selection) return null;
278
+
279
+ const current = selection.getRangeAt(0);
280
+
281
+ return {
282
+ selection,
283
+ // форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
284
+ range: expandRangeToWords(this.editable, current),
285
+ original: selectionCharBounds(this.editable, current),
286
+ };
287
+ }
211
288
 
212
- const range = selection.getRangeAt(0);
213
- if (range.collapsed) {
289
+ /** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
290
+ applyFormat(tool: FormatTool): void {
291
+ if (!this.formatTools.includes(tool)) return;
292
+
293
+ const target = this.__formatTarget();
294
+ if (!target) return;
295
+
296
+ if (target.range.collapsed) {
214
297
  // под кареткой нет слова — режим набора: формат для следующего ввода
215
298
  if (this.__pendingFormats.has(tool)) this.__pendingFormats.delete(tool);
216
299
  else this.__pendingFormats.add(tool);
@@ -222,24 +305,119 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
222
305
  this.__pendingFormats.clear();
223
306
 
224
307
  this.__history?.record("op");
225
- toggleFormat(this.editable, range, tool, selection, original);
308
+ toggleFormat(this.editable, target.range, tool, target.selection, target.original);
309
+
310
+ this.__emitChange();
311
+ formatToolbar.refresh();
312
+ }
313
+
314
+ /**
315
+ * Снимает всё форматирование с выделения; без выделения — со слова под кареткой
316
+ * (как и применение формата). Режим набора сбрасывается.
317
+ */
318
+ clearFormat(): void {
319
+ const target = this.__formatTarget();
320
+ if (!target) return;
321
+
322
+ this.__clearPendingFormats();
323
+
324
+ // нечего очищать — не пишем в историю пустой шаг и не трогаем выделение
325
+ if (!hasFormatting(this.editable, target.range)) return;
326
+
327
+ this.__history?.record("op");
328
+ clearFormat(this.editable, target.range, target.selection, target.original);
329
+
330
+ this.__emitChange();
331
+ formatToolbar.refresh();
332
+ }
333
+
334
+ /** Снимает всё форматирование со всего содержимого (выделение не требуется). */
335
+ clearAllFormat(): void {
336
+ if (!this.format || this.readonly) return;
337
+
338
+ this.__clearPendingFormats();
339
+ if (!hasAnyFormatting(this.editable)) return;
340
+
341
+ // разворачивание тегов рвёт выделение — запоминаем по текстовым смещениям
342
+ const selection = this.__innerSelection();
343
+ const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
344
+
345
+ this.__history?.record("op");
346
+ clearAllFormat(this.editable);
347
+
348
+ if (bounds && selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
226
349
 
227
350
  this.__emitChange();
228
351
  formatToolbar.refresh();
229
352
  }
230
353
 
354
+ /** Доступна ли отмена (история ведётся только при включённом форматировании). */
355
+ get canUndo(): boolean {
356
+ return !this.readonly && !!this.__history?.canUndo;
357
+ }
358
+
359
+ /** Доступен ли повтор отменённого. */
360
+ get canRedo(): boolean {
361
+ return !this.readonly && !!this.__history?.canRedo;
362
+ }
363
+
231
364
  /** Отменить последнее действие (история ведётся только при форматировании). */
232
- private __undo(): void {
233
- if (!this.__history?.undo()) return;
365
+ undo(): void {
366
+ if (this.readonly || !this.__history?.undo()) return;
234
367
  this.__afterHistory();
235
368
  }
236
369
 
237
370
  /** Повторить отменённое действие. */
238
- private __redo(): void {
239
- if (!this.__history?.redo()) return;
371
+ redo(): void {
372
+ if (this.readonly || !this.__history?.redo()) return;
240
373
  this.__afterHistory();
241
374
  }
242
375
 
376
+ /** Выполнить действие панели (вызывается кнопками тулбара). */
377
+ applyAction(action: EditorAction): void {
378
+ if (!this.editorActions.includes(action)) return;
379
+
380
+ switch (action) {
381
+ case "erase":
382
+ this.clearFormat();
383
+ break;
384
+ case "undo":
385
+ this.undo();
386
+ break;
387
+ case "redo":
388
+ this.redo();
389
+ break;
390
+ case "emoji":
391
+ // панель вставки открывает тулбар: ей нужна кнопка как якорь попапа.
392
+ // Вставка символа приходит сюда через insertText.
393
+ break;
394
+ }
395
+ }
396
+
397
+ /** Доступно ли действие сейчас (для disabled-состояния кнопки тулбара). */
398
+ isActionEnabled(action: EditorAction): boolean {
399
+ switch (action) {
400
+ case "undo":
401
+ return this.canUndo;
402
+ case "redo":
403
+ return this.canRedo;
404
+ case "erase":
405
+ return this.__hasFormatting();
406
+ case "emoji":
407
+ return !this.readonly;
408
+ }
409
+ }
410
+
411
+ // Есть ли что очищать: ожидающий формат режима набора либо форматирование на цели очистки.
412
+ // Смотрим на тот же расширенный до слов диапазон, что возьмёт clearFormat, — иначе кнопка
413
+ // блокировалась бы в случаях, когда операция сработала бы (каретка на границе формата).
414
+ private __hasFormatting(): boolean {
415
+ if (this.__pendingFormats.size > 0) return true;
416
+
417
+ const target = this.__formatTarget();
418
+ return !!target && hasFormatting(this.editable, target.range);
419
+ }
420
+
243
421
  private __afterHistory(): void {
244
422
  this.__pendingFormats.clear();
245
423
  this.__emitChange();
@@ -250,8 +428,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
250
428
  isToolActive(tool: FormatTool): boolean {
251
429
  if (this.__pendingFormats.has(tool)) return true;
252
430
 
253
- const selection = window.getSelection();
254
- if (!selection || selection.rangeCount === 0 || !this.editable.contains(selection.anchorNode)) return false;
431
+ const selection = this.__innerSelection();
432
+ if (!selection) return false;
255
433
 
256
434
  return isFormatActive(this.editable, selection.getRangeAt(0), tool);
257
435
  }
@@ -274,14 +452,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
274
452
  DOM.empty(this.editable);
275
453
  if (!value) return;
276
454
 
277
- // multiline → <p>-абзацы; single-line → инлайновое содержимое
455
+ // multiline → <p>-абзацы; single-line → инлайновое содержимое.
456
+ // В режиме break абзацных блоков нет: значение — плоский текст, где каждый \n это <br>.
457
+ // Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без отступов между абзацами)
458
+ // это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
459
+ const paragraphs = this.multiline && this.paragraph === "block";
460
+
278
461
  this.editable.innerHTML = deserialize(
279
462
  value,
280
463
  this.__valueStorage,
281
464
  this.__valueTools,
282
465
  this.formatMarkers,
283
- this.multiline
466
+ paragraphs
284
467
  );
468
+
469
+ // в break инлайновое содержимое оборачивается в единственный абзац — модель абзацев
470
+ // нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
471
+ if (this.multiline && !paragraphs) ensureParagraphs(this.editable);
285
472
  }
286
473
 
287
474
  private __emitChange() {
@@ -298,11 +485,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
298
485
  private __normalize(notify: boolean) {
299
486
  if (this.readonly) return;
300
487
 
488
+ // правка текстовых узлов рвёт живые Range — запоминаем выделение по текстовым смещениям
489
+ const selection = this.__innerSelection();
490
+ const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
491
+
301
492
  const before = this.editable.innerHTML;
493
+ const textBefore = this.editable.textContent ?? "";
302
494
  normalizeWhitespace(this.editable);
303
495
  if (this.multiline) normalizeParagraphs(this.editable);
304
496
  if (this.editable.innerHTML === before) return;
305
497
 
498
+ if (bounds && selection) {
499
+ // схлопнутые пробелы сдвигают смещения — переносим их на новый текст
500
+ const textAfter = this.editable.textContent ?? "";
501
+ const start = mapCharOffset(textBefore, textAfter, bounds[0]);
502
+ const end = bounds[1] === bounds[0] ? start : mapCharOffset(textBefore, textAfter, bounds[1]);
503
+ restoreSelection(this.editable, start, end, selection);
504
+ }
505
+
306
506
  if (notify) this.__emitChange();
307
507
  }
308
508
 
@@ -330,10 +530,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
330
530
  this.element.classList.add("focused");
331
531
 
332
532
  // показываем общий тулбар над этим редактором
333
- if (this.format && this.formatTools.length) formatToolbar.attach(this);
533
+ if (this.format && (this.formatTools.length || this.editorActions.length)) formatToolbar.attach(this);
334
534
 
335
535
  if (this.readonly) selectAllContent(this.editable);
336
- else if (!this.__hasInputClick) caretToEnd(this.editable, this.multiline);
536
+ // Каретку в конец ставим только когда её нет: клик ставит сам, а уже стоящую
537
+ // (фокус вернули из кода после вызова метода) двигать нельзя — уедет в конец текста.
538
+ else if (!this.__hasInputClick && !this.__innerSelection()) caretToEnd(this.editable, this.multiline);
337
539
  },
338
540
  { signal }
339
541
  );
@@ -414,12 +616,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
414
616
  const z = isHotkeyLetter(e, "z");
415
617
  if (z && !e.shiftKey) {
416
618
  e.preventDefault();
417
- this.__undo();
619
+ this.undo();
418
620
  return;
419
621
  }
420
622
  if (isHotkeyLetter(e, "y") || (z && e.shiftKey)) {
421
623
  e.preventDefault();
422
- this.__redo();
624
+ this.redo();
423
625
  return;
424
626
  }
425
627
  }
@@ -450,9 +652,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
450
652
  return;
451
653
  }
452
654
 
453
- // Shift/Ctrl/Cmd+Enter — мягкий перенос (<br>) внутри абзаца; Enter новый абзац (<p>)
655
+ // В режиме block Enter — новый абзац (<p>), модификатормягкий перенос (<br>).
656
+ // В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
657
+ // набирается двумя переносами.
658
+ const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
659
+ const soft = this.paragraph === "break" ? !withModifier : withModifier;
660
+
454
661
  this.__history?.record("op");
455
- if (e.shiftKey || e.ctrlKey || e.metaKey) insertSoftBreak(this.editable);
662
+ if (soft) insertSoftBreak(this.editable);
456
663
  else insertParagraph(this.editable);
457
664
 
458
665
  this.__clearPendingFormats();
@@ -601,7 +808,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
601
808
  if (this.__pendingFormats.size > 0 && e.inputType === "insertText" && e.data != null) {
602
809
  e.preventDefault();
603
810
  this.__history?.record("op");
604
- this.__insertPendingText(e.data);
811
+ this.__insertText(e.data);
605
812
  return;
606
813
  }
607
814
 
@@ -615,9 +822,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
615
822
  formatToolbar.refresh();
616
823
  }
617
824
 
618
- private __insertPendingText(data: string) {
619
- const selection = window.getSelection();
620
- if (!selection || !this.editable.contains(selection.anchorNode)) return;
825
+ private __insertText(data: string) {
826
+ const selection = this.__innerSelection();
827
+ if (!selection) return;
621
828
 
622
829
  insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);
623
830
 
@@ -6,6 +6,14 @@ import { ALL_FORMAT_TOOLS, FORMAT_TOOLS, type FormatTool } from "./format-config
6
6
  /** Канонические теги форматирования (в верхнем регистре, как tagName). */
7
7
  const FORMAT_TAG_NAMES = ALL_FORMAT_TOOLS.map((t) => FORMAT_TOOLS[t].tag.toUpperCase());
8
8
 
9
+ // Все распознаваемые теги форматирования (канонические и синонимы) — для снятия форматирования
10
+ // целиком: в содержимом редактора синонимов быть не должно, но вставка и setValue могут их принести.
11
+ const MATCH_TAG_NAMES = Array.from(new Set(ALL_FORMAT_TOOLS.flatMap((t) => FORMAT_TOOLS[t].matchTags)));
12
+
13
+ // Селекторы считаем один раз: обе выборки идут на каждую правку формата и на каждое обновление панели.
14
+ const FORMAT_SELECTOR = FORMAT_TAG_NAMES.join(",").toLowerCase();
15
+ const MATCH_SELECTOR = MATCH_TAG_NAMES.join(",").toLowerCase();
16
+
9
17
  /** Ближайший предок-элемент с одним из тегов (в пределах root, не включая root). */
10
18
  function formatAncestor(node: Node, tags: string[], root: HTMLElement): HTMLElement | null {
11
19
  let el = node.parentElement;
@@ -27,6 +35,26 @@ export function selectionCharBounds(root: HTMLElement, range: Range): [number, n
27
35
  return [start, end];
28
36
  }
29
37
 
38
+ /**
39
+ * Пересчитывает абсолютное текстовое смещение после нормализации пробелов.
40
+ *
41
+ * Нормализация только удаляет символы (схлопывает пробелы) и заменяет табы пробелами,
42
+ * поэтому старый и новый текст выравниваются одним проходом: несовпадение означает
43
+ * удалённый символ. Без пересчёта каретка отстаёт ровно на число схлопнутых перед ней
44
+ * пробелов и может уехать в соседнее слово.
45
+ */
46
+ export function mapCharOffset(before: string, after: string, offset: number): number {
47
+ const same = (a: string, b: string) => a === b || (b === " " && (a === " " || a === "\t"));
48
+
49
+ let i = 0;
50
+ let j = 0;
51
+ while (i < offset && j < after.length) {
52
+ if (same(before[i], after[j])) j++;
53
+ i++;
54
+ }
55
+ return j;
56
+ }
57
+
30
58
  /** Находит текстовый узел и локальное смещение по абсолютному текстовому смещению. */
31
59
  function locateChar(root: HTMLElement, target: number): { node: Text; offset: number } | null {
32
60
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
@@ -97,12 +125,13 @@ function nodeWithinRange(node: Node, range: Range): boolean {
97
125
  );
98
126
  }
99
127
 
100
- function collectTextNodes(root: HTMLElement, range: Range, strict: boolean): Text[] {
128
+ /** Непустые текстовые узлы, целиком попавшие в диапазон (частично задетые правке не подлежат). */
129
+ function collectTextNodes(root: HTMLElement, range: Range): Text[] {
101
130
  const nodes: Text[] = [];
102
131
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
103
132
  let n = walker.nextNode() as Text | null;
104
133
  while (n) {
105
- if (n.length > 0 && (strict ? nodeWithinRange(n, range) : range.intersectsNode(n))) nodes.push(n);
134
+ if (n.length > 0 && nodeWithinRange(n, range)) nodes.push(n);
106
135
  n = walker.nextNode() as Text | null;
107
136
  }
108
137
  return nodes;
@@ -151,13 +180,11 @@ function unwrapElement(el: HTMLElement) {
151
180
 
152
181
  /** Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст. */
153
182
  export function cleanupFormatting(root: HTMLElement) {
154
- const selector = FORMAT_TAG_NAMES.join(",").toLowerCase();
155
-
156
183
  let changed = true;
157
184
  while (changed) {
158
185
  changed = false;
159
186
 
160
- for (const el of Array.from(root.querySelectorAll<HTMLElement>(selector))) {
187
+ for (const el of Array.from(root.querySelectorAll<HTMLElement>(FORMAT_SELECTOR))) {
161
188
  if (!el.isConnected) continue;
162
189
 
163
190
  // пустой тег
@@ -190,41 +217,114 @@ export function cleanupFormatting(root: HTMLElement) {
190
217
  }
191
218
 
192
219
  /**
193
- * Переключает форматирование инструмента на выделении.
194
- * Если весь выделенный текст уже отформатирован снимает формат, иначе применяет.
220
+ * Каркас правки форматирования на выделении: доводит границы Range до целых текстовых узлов,
221
+ * отдаёт строго попавшие узлы в `mutate` и восстанавливает выделение.
195
222
  *
196
223
  * По умолчанию восстанавливает выделение, на котором работал. Через `restoreBounds`
197
224
  * можно восстановить другое выделение (например, исходное до расширения до слова).
198
225
  */
199
- export function toggleFormat(
226
+ function editSelection(
200
227
  root: HTMLElement,
201
228
  range: Range,
202
- tool: FormatTool,
203
229
  selection: Selection,
204
- restoreBounds?: [number, number]
230
+ restoreBounds: [number, number] | undefined,
231
+ mutate: (nodes: Text[]) => void
205
232
  ) {
206
233
  if (range.collapsed) return;
207
234
 
208
- const def = FORMAT_TOOLS[tool];
209
- const tags = def.matchTags;
210
235
  const [startChar, endChar] = restoreBounds ?? selectionCharBounds(root, range);
211
236
 
212
237
  splitBoundaries(range);
213
238
 
214
- const nodes = collectTextNodes(root, range, true);
239
+ const nodes = collectTextNodes(root, range);
215
240
  if (!nodes.length) return;
216
241
 
217
- const allFormatted = nodes.every((n) => formatAncestor(n, tags, root) !== null);
218
- if (allFormatted) {
219
- for (const n of nodes) removeFormatFromNode(n, tags, root);
220
- } else {
221
- for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
222
- }
242
+ mutate(nodes);
223
243
 
224
244
  cleanupFormatting(root);
225
245
  restoreSelection(root, startChar, endChar, selection);
226
246
  }
227
247
 
248
+ /**
249
+ * Переключает форматирование инструмента на выделении.
250
+ * Если весь выделенный текст уже отформатирован — снимает формат, иначе применяет.
251
+ */
252
+ export function toggleFormat(
253
+ root: HTMLElement,
254
+ range: Range,
255
+ tool: FormatTool,
256
+ selection: Selection,
257
+ restoreBounds?: [number, number]
258
+ ) {
259
+ const def = FORMAT_TOOLS[tool];
260
+ const tags = def.matchTags;
261
+
262
+ editSelection(root, range, selection, restoreBounds, (nodes) => {
263
+ const allFormatted = nodes.every((n) => formatAncestor(n, tags, root) !== null);
264
+ if (allFormatted) {
265
+ for (const n of nodes) removeFormatFromNode(n, tags, root);
266
+ } else {
267
+ for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
268
+ }
269
+ });
270
+ }
271
+
272
+ /** Снимает всё форматирование с выделения (все инструменты сразу, включая теги-синонимы). */
273
+ export function clearFormat(root: HTMLElement, range: Range, selection: Selection, restoreBounds?: [number, number]) {
274
+ editSelection(root, range, selection, restoreBounds, (nodes) => {
275
+ for (const n of nodes) removeFormatFromNode(n, MATCH_TAG_NAMES, root);
276
+ });
277
+ }
278
+
279
+ /** Снимает всё форматирование со всего содержимого (выделение не участвует). */
280
+ export function clearAllFormat(root: HTMLElement) {
281
+ // вложенные элементы после разворачивания родителя остаются в дереве — снимок обходим целиком
282
+ for (const el of Array.from(root.querySelectorAll<HTMLElement>(MATCH_SELECTOR))) unwrapElement(el);
283
+
284
+ root.normalize();
285
+ }
286
+
287
+ /**
288
+ * Состояние форматирования на выделении указанными тегами.
289
+ * `every` — отформатирован весь текст (подсветка кнопки инструмента),
290
+ * `some` — отформатирована хоть какая-то часть (доступность очистки).
291
+ * Обход прерывается на первом узле, решающем исход.
292
+ */
293
+ function rangeFormatState(root: HTMLElement, range: Range, tags: string[], mode: "every" | "some"): boolean {
294
+ 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;
298
+ }
299
+
300
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
301
+ let found = false;
302
+
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
+
311
+ found = true;
312
+ }
313
+
314
+ // every: пустое выделение — не «отформатировано целиком»; some: ни одного форматированного узла
315
+ return mode === "every" && found;
316
+ }
317
+
318
+ /** Есть ли форматирование на выделении (или под кареткой) — для доступности кнопки очистки. */
319
+ export function hasFormatting(root: HTMLElement, range: Range): boolean {
320
+ return rangeFormatState(root, range, MATCH_TAG_NAMES, "some");
321
+ }
322
+
323
+ /** Есть ли форматирование хоть где-то в содержимом. */
324
+ export function hasAnyFormatting(root: HTMLElement): boolean {
325
+ return root.querySelector(MATCH_SELECTOR) !== null;
326
+ }
327
+
228
328
  /**
229
329
  * Вставляет текст в позицию каретки, оборачивая его в указанные форматы (режим набора).
230
330
  * Каретка ставится сразу после вставленного текста; соседние одинаковые теги склеиваются.
@@ -253,15 +353,5 @@ export function insertFormattedText(root: HTMLElement, data: string, tools: Form
253
353
 
254
354
  /** Активен ли формат инструмента на текущем выделении (для подсветки кнопки). */
255
355
  export function isFormatActive(root: HTMLElement, range: Range, tool: FormatTool): boolean {
256
- const tags = FORMAT_TOOLS[tool].matchTags;
257
-
258
- if (range.collapsed) {
259
- const node = range.startContainer;
260
- const probe = node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
261
- return formatAncestor(probe, tags, root) !== null;
262
- }
263
-
264
- const nodes = collectTextNodes(root, range, false);
265
- if (!nodes.length) return false;
266
- return nodes.every((n) => formatAncestor(n, tags, root) !== null);
356
+ return rangeFormatState(root, range, FORMAT_TOOLS[tool].matchTags, "every");
267
357
  }