@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.
@@ -4,6 +4,7 @@ import { DOM, UIElementBound } from "@brandup/ui";
4
4
  import {
5
5
  ALL_FORMAT_TOOLS,
6
6
  HOTKEY_TOOLS,
7
+ activeFormats,
7
8
  clearAllFormat,
8
9
  clearFormat,
9
10
  defaultFormatMarkers,
@@ -13,7 +14,10 @@ import {
13
14
  hasFormatting,
14
15
  insertFormattedText,
15
16
  isFormatActive,
17
+ documentSelection,
18
+ innerSelection,
16
19
  mapCharOffset,
20
+ preserveCaret,
17
21
  normalizeParagraphs,
18
22
  normalizeWhitespace,
19
23
  restoreSelection,
@@ -27,19 +31,20 @@ import {
27
31
  type FormatTool,
28
32
  } from "./format";
29
33
  import {
34
+ buildParagraphs,
30
35
  caretToEnd,
31
36
  expandRangeToWords,
32
37
  insertParagraph,
33
38
  insertPastedParagraphs,
34
39
  insertSoftBreak,
40
+ sanitizePastedHtml,
35
41
  selectAllContent,
36
- trimParagraphEdges,
37
42
  trimSelectionWhitespace,
38
43
  } from "./editing";
39
44
  import { EditorHistory } from "./history";
40
- import { formatToolbar } from "./toolbar";
45
+ import { formatToolbar, type ToolbarButton } from "./toolbar";
41
46
 
42
- export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost } from "./toolbar";
47
+ export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost, type ToolbarButton } from "./toolbar";
43
48
 
44
49
  export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
45
50
  export const CHANGE_EVENT = "richeditor-change";
@@ -59,6 +64,16 @@ const NATIVE_EDIT_TYPES = new Set([
59
64
  "deleteByCut",
60
65
  ]);
61
66
 
67
+ // Ввод текста, который не проходит через keydown (IME, автозамена, автодополнение, диктовка), —
68
+ // к нему применяем фильтр символов хоста в beforeinput.
69
+ const FILTERED_INPUT_TYPES = new Set(["insertText", "insertReplacementText", "insertCompositionText"]);
70
+
71
+ // Максимальное отставание события change от печати. Сериализация значения — самая дорогая
72
+ // операция редактора (обход всего содержимого), а печать даёт input на каждый символ.
73
+ // Это троттлинг, а не debounce: при непрерывном наборе значение всё равно обновляется
74
+ // каждые CHANGE_THROTTLE_MS, а не откладывается до паузы.
75
+ const CHANGE_THROTTLE_MS = 150;
76
+
62
77
  // Буква физической клавиши (KeyA…KeyZ) — не зависит от раскладки. Для не-латинских раскладок
63
78
  // (например, кириллицы) e.key даёт другую букву, поэтому хоткеи сверяем и по e.code.
64
79
  function codeLetter(e: KeyboardEvent): string {
@@ -91,6 +106,8 @@ export interface RichEditorOptions {
91
106
  readonly?: boolean;
92
107
  /** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
93
108
  toolbarContainer?: HTMLElement | null;
109
+ /** Собственные кнопки хоста в панели — для действий, которых редактор не знает. */
110
+ buttons?: ToolbarButton[];
94
111
  /** Начальное значение. */
95
112
  value?: string;
96
113
 
@@ -124,11 +141,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
124
141
  readonly multiline: boolean;
125
142
  readonly paragraph: ParagraphMode;
126
143
  readonly toolbarContainer: HTMLElement | null;
144
+ readonly toolbarButtons: ToolbarButton[];
127
145
 
128
146
  private __opts: RichEditorOptions;
129
147
  private __abort = new AbortController();
130
148
  private __pendingFormats = new Set<FormatTool>();
131
149
  private __hasInputClick = false;
150
+ private __changeTimer = 0; // отложенное change по печати — см. __emitChange/flushChange
151
+ // Окно редактируемого элемента, взятое при создании. Таймер отложенного change переживает
152
+ // снятие компонента и гасится в destroy, а тот случается когда угодно — к этому моменту
153
+ // до глобального окружения может быть уже не добраться, да и элемент мог жить в iframe.
154
+ private readonly __window: Window;
132
155
  // собственная история undo/redo — только при форматировании (см. ./history)
133
156
  private __history: EditorHistory | null = null;
134
157
 
@@ -148,6 +171,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
148
171
  super("BrandUp.RichEditor", editable);
149
172
 
150
173
  this.editable = editable;
174
+ this.__window = editable.ownerDocument.defaultView ?? window;
151
175
  this.__opts = options;
152
176
  this.format = format;
153
177
  this.formatTools = tools;
@@ -157,6 +181,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
157
181
  this.multiline = multiline;
158
182
  this.paragraph = options.paragraph ?? "block";
159
183
  this.toolbarContainer = options.toolbarContainer ?? null;
184
+ // кнопки хоста живут и без форматирования, но не в readonly — там панели нет вовсе
185
+ this.toolbarButtons = readonly ? [] : (options.buttons ?? []);
160
186
  // история включается вместе с форматированием
161
187
  this.__history = format ? new EditorHistory(editable) : null;
162
188
 
@@ -178,6 +204,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
178
204
  return !!this.__opts.readonly;
179
205
  }
180
206
 
207
+ /**
208
+ * Работает ли редактор моделью абзацев. В режиме break абзацных блоков нет: значение — плоский
209
+ * текст, где каждый \n это <br>. Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без
210
+ * отступов между абзацами) это неотличимо от одного переноса — значение расходилось бы
211
+ * с видимым текстом.
212
+ */
213
+ private get __blockParagraphs(): boolean {
214
+ return this.multiline && this.paragraph === "block";
215
+ }
216
+
181
217
  // формат хранения значения: format → выбранный; plain → markdown без инструментов (\n\n/\n)
182
218
  private get __valueStorage(): FormatStorage {
183
219
  return this.format ? this.formatStorage : "markdown";
@@ -195,7 +231,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
195
231
 
196
232
  setValue(value: string): void {
197
233
  // каретка внутри относилась к прежнему содержимому — после замены ставим её в конец
198
- const hadCaret = !!this.__innerSelection();
234
+ const hadCaret = !!this.selection;
199
235
 
200
236
  this.__render(value ?? "");
201
237
  this.__normalize(false);
@@ -210,7 +246,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
210
246
  * например, кнопкой панели, которая не должна забирать фокус у редактора.
211
247
  */
212
248
  insertText(text: string): void {
213
- if (this.readonly || !text || !this.__innerSelection()) return;
249
+ if (this.readonly || !text || !this.selection) return;
214
250
 
215
251
  // вставка — такой же ввод, как с клавиатуры, поэтому проходит через filterChar хоста
216
252
  // (ограничения по типу поля и длине). Обход символов идёт по кодпойнтам, чтобы эмодзи
@@ -258,11 +294,25 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
258
294
  * Выделение, если оно находится внутри этого редактора. Браузер сохраняет выделение и после
259
295
  * blur, поэтому проверка работает и когда фокус ушёл на кнопку страницы.
260
296
  */
261
- private __innerSelection(): Selection | null {
262
- const selection = window.getSelection();
263
- if (!selection || selection.rangeCount === 0 || !this.editable.contains(selection.anchorNode)) return null;
297
+ get selection(): Selection | null {
298
+ return innerSelection(this.editable);
299
+ }
264
300
 
265
- return selection;
301
+ /**
302
+ * Выделить узел внутри редактора — например, чтобы следующая вставка заменила его целиком.
303
+ * Не зависит от того, где стоит выделение сейчас: оно могло уйти, пока хост показывал
304
+ * своё окно, а сам узел никуда не делся.
305
+ */
306
+ selectNode(node: Node): void {
307
+ if (!this.editable.contains(node)) return;
308
+
309
+ const selection = documentSelection(this.editable);
310
+ if (!selection) return;
311
+
312
+ const range = this.editable.ownerDocument.createRange();
313
+ range.selectNode(node);
314
+ selection.removeAllRanges();
315
+ selection.addRange(range);
266
316
  }
267
317
 
268
318
  /**
@@ -270,10 +320,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
270
320
  * для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
271
321
  * править, каретка пользователя не двигается. null — правка недоступна или выделение вне редактора.
272
322
  */
273
- private __formatTarget(): { selection: Selection; range: Range; original: [number, number] } | null {
323
+ private __formatTarget(): { selection: Selection; range: Range; original: () => [number, number] } | null {
274
324
  if (!this.format || this.readonly) return null;
275
325
 
276
- const selection = this.__innerSelection();
326
+ const selection = this.selection;
277
327
  if (!selection) return null;
278
328
 
279
329
  const current = selection.getRangeAt(0);
@@ -282,7 +332,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
282
332
  selection,
283
333
  // форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
284
334
  range: expandRangeToWords(this.editable, current),
285
- original: selectionCharBounds(this.editable, current),
335
+ // границы считаем по требованию: они нужны только правкам, а цель вычисляется ещё и
336
+ // на каждое обновление панели, где сбор всего текста в строку — самая дорогая операция
337
+ original: () => selectionCharBounds(this.editable, current),
286
338
  };
287
339
  }
288
340
 
@@ -305,7 +357,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
305
357
  this.__pendingFormats.clear();
306
358
 
307
359
  this.__history?.record("op");
308
- toggleFormat(this.editable, target.range, tool, target.selection, target.original);
360
+ toggleFormat(this.editable, target.range, tool, target.selection, target.original());
309
361
 
310
362
  this.__emitChange();
311
363
  formatToolbar.refresh();
@@ -325,7 +377,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
325
377
  if (!hasFormatting(this.editable, target.range)) return;
326
378
 
327
379
  this.__history?.record("op");
328
- clearFormat(this.editable, target.range, target.selection, target.original);
380
+ clearFormat(this.editable, target.range, target.selection, target.original());
329
381
 
330
382
  this.__emitChange();
331
383
  formatToolbar.refresh();
@@ -338,14 +390,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
338
390
  this.__clearPendingFormats();
339
391
  if (!hasAnyFormatting(this.editable)) return;
340
392
 
341
- // разворачивание тегов рвёт выделение — запоминаем по текстовым смещениям
342
- const selection = this.__innerSelection();
343
- const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
344
-
345
393
  this.__history?.record("op");
346
- clearAllFormat(this.editable);
347
-
348
- if (bounds && selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
394
+ // разворачивание тегов рвёт выделение — сохраняем его по текстовым смещениям
395
+ preserveCaret(this.editable, () => clearAllFormat(this.editable));
349
396
 
350
397
  this.__emitChange();
351
398
  formatToolbar.refresh();
@@ -428,13 +475,29 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
428
475
  isToolActive(tool: FormatTool): boolean {
429
476
  if (this.__pendingFormats.has(tool)) return true;
430
477
 
431
- const selection = this.__innerSelection();
478
+ const selection = this.selection;
432
479
  if (!selection) return false;
433
480
 
434
481
  return isFormatActive(this.editable, selection.getRangeAt(0), tool);
435
482
  }
436
483
 
484
+ /**
485
+ * Активные форматы всех инструментов сразу — панель обновляется на каждое движение каретки,
486
+ * а поинструментный опрос обходил бы содержимое столько раз, сколько кнопок.
487
+ */
488
+ activeTools(): ReadonlySet<FormatTool> {
489
+ const selection = this.selection;
490
+ const active = selection
491
+ ? activeFormats(this.editable, selection.getRangeAt(0), this.formatTools)
492
+ : new Set<FormatTool>();
493
+
494
+ for (const tool of this.__pendingFormats) active.add(tool);
495
+
496
+ return active;
497
+ }
498
+
437
499
  override destroy(): void {
500
+ this.flushChange(); // хост не должен остаться с устаревшей копией значения
438
501
  this.__abort.abort();
439
502
  formatToolbar.detach(this);
440
503
 
@@ -452,11 +515,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
452
515
  DOM.empty(this.editable);
453
516
  if (!value) return;
454
517
 
455
- // multiline → <p>-абзацы; single-line → инлайновое содержимое.
456
- // В режиме break абзацных блоков нет: значение — плоский текст, где каждый \n это <br>.
457
- // Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без отступов между абзацами)
458
- // это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
459
- const paragraphs = this.multiline && this.paragraph === "block";
518
+ // multiline → <p>-абзацы; single-line → инлайновое содержимое
519
+ const paragraphs = this.__blockParagraphs;
460
520
 
461
521
  this.editable.innerHTML = deserialize(
462
522
  value,
@@ -471,10 +531,41 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
471
531
  if (this.multiline && !paragraphs) ensureParagraphs(this.editable);
472
532
  }
473
533
 
474
- private __emitChange() {
534
+ /**
535
+ * Событие изменения. При `defer` (печать) доставка откладывается — иначе каждый символ
536
+ * стоил бы полной сериализации содержимого. Все прочие правки (вставка, формат, отмена,
537
+ * setValue) сообщаются сразу: они разовые, а не посимвольные.
538
+ *
539
+ * `getValue()` считает значение по DOM и точен всегда; отложено только уведомление
540
+ * и, как следствие, копия значения у хоста — её сбрасывает {@link flushChange}.
541
+ */
542
+ private __emitChange(defer = false) {
543
+ if (defer) {
544
+ // троттлинг: первый ввод заводит таймер, последующие в этом окне его не сдвигают
545
+ this.__changeTimer ||= this.__window.setTimeout(() => this.__emitChange(), CHANGE_THROTTLE_MS);
546
+ return;
547
+ }
548
+
549
+ this.__cancelChange();
475
550
  this.trigger(CHANGE_EVENT, <RichEditorChangeData>{ editor: this, value: this.getValue() });
476
551
  }
477
552
 
553
+ private __cancelChange() {
554
+ if (!this.__changeTimer) return;
555
+
556
+ this.__window.clearTimeout(this.__changeTimer);
557
+ this.__changeTimer = 0;
558
+ }
559
+
560
+ /**
561
+ * Доставить отложенное изменение немедленно. Вызывать перед тем, как значение читают
562
+ * извне: отправка формы, валидация, чтение значения хостом. Если ничего не отложено —
563
+ * ничего и не делает, лишнего события не будет.
564
+ */
565
+ flushChange(): void {
566
+ if (this.__changeTimer) this.__emitChange();
567
+ }
568
+
478
569
  private __reject() {
479
570
  this.__opts.onReject?.();
480
571
  }
@@ -486,7 +577,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
486
577
  if (this.readonly) return;
487
578
 
488
579
  // правка текстовых узлов рвёт живые Range — запоминаем выделение по текстовым смещениям
489
- const selection = this.__innerSelection();
580
+ const selection = this.selection;
490
581
  const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
491
582
 
492
583
  const before = this.editable.innerHTML;
@@ -512,8 +603,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
512
603
  const { signal } = this.__abort;
513
604
  const editable = this.editable;
514
605
 
606
+ // перетаскивание в редактор проходит мимо истории и фильтров хоста — гасим саму вставку.
607
+ // dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
515
608
  this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
516
- this.element.addEventListener("dragenter", (e) => e.preventDefault(), { signal });
517
609
 
518
610
  editable.addEventListener(
519
611
  "mousedown",
@@ -529,13 +621,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
529
621
  () => {
530
622
  this.element.classList.add("focused");
531
623
 
532
- // показываем общий тулбар над этим редактором
533
- if (this.format && (this.formatTools.length || this.editorActions.length)) formatToolbar.attach(this);
624
+ // нужна ли панель этому редактору, решает она сама — иначе условие пришлось бы
625
+ // держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
626
+ formatToolbar.attach(this);
534
627
 
535
628
  if (this.readonly) selectAllContent(this.editable);
536
629
  // Каретку в конец ставим только когда её нет: клик ставит сам, а уже стоящую
537
630
  // (фокус вернули из кода после вызова метода) двигать нельзя — уедет в конец текста.
538
- else if (!this.__hasInputClick && !this.__innerSelection()) caretToEnd(this.editable, this.multiline);
631
+ else if (!this.__hasInputClick && !this.selection) caretToEnd(this.editable, this.multiline);
539
632
  },
540
633
  { signal }
541
634
  );
@@ -553,6 +646,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
553
646
 
554
647
  this.__clearPendingFormats();
555
648
  this.__normalize(true); // редактирование завершено
649
+ this.flushChange(); // ввод закончен — отложенное изменение доставляем сразу
556
650
  },
557
651
  { signal }
558
652
  );
@@ -568,19 +662,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
568
662
  "input",
569
663
  () => {
570
664
  if (this.multiline) {
571
- // нормализация меняет структуру (обёртка в <p>, удаление <br>) и сбрасывает каретку —
572
- // запоминаем её позицию по текстовому смещению и восстанавливаем после
573
- const selection = window.getSelection();
574
- const caret =
575
- selection && selection.rangeCount > 0 && editable.contains(selection.anchorNode)
576
- ? selectionCharBounds(editable, selection.getRangeAt(0))
577
- : null;
578
- const before = editable.innerHTML;
665
+ // приведение к абзацам меняет структуру (обёртка в <p>, удаление <br>) и сбрасывает
666
+ // каретку сохраняем её; если структура не менялась, выделение живо и переставлять
667
+ // его не нужно (лишний сброс способен прервать IME-набор)
668
+ preserveCaret(editable, () => {
669
+ const before = editable.innerHTML;
670
+ ensureParagraphs(editable); // блуждающий текст/div → <p>
579
671
 
580
- ensureParagraphs(editable); // блуждающий текст/div → <p>
581
-
582
- if (caret && selection && editable.innerHTML !== before)
583
- restoreSelection(editable, caret[0], caret[1], selection);
672
+ return editable.innerHTML !== before;
673
+ });
584
674
 
585
675
  // единственный пустой абзац → очищаем, чтобы показать placeholder
586
676
  if (editable.children.length === 1) {
@@ -590,7 +680,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
590
680
  } else if (editable.firstChild?.nodeName === "BR") {
591
681
  editable.innerHTML = "";
592
682
  }
593
- this.__emitChange();
683
+ this.__emitChange(true); // печать — единственный посимвольный источник, его и откладываем
594
684
  },
595
685
  { signal }
596
686
  );
@@ -652,6 +742,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
652
742
  return;
653
743
  }
654
744
 
745
+ // Абзацы и переносы правятся вручную, мимо beforeinput, поэтому запрет на изменение
746
+ // текста проверяем здесь: иначе Enter добавлял бы строки и в режиме только для чтения.
747
+ if (this.readonly) return;
748
+
655
749
  // В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
656
750
  // В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
657
751
  // набирается двумя переносами.
@@ -692,79 +786,55 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
692
786
  if (filtered !== plain) plainOverride = filtered;
693
787
  }
694
788
 
695
- const selection = window.getSelection();
696
- if (!selection || selection.rangeCount === 0) return;
789
+ // вставлять только в своё содержимое: выделение вне редактора нам не адресовано
790
+ const selection = this.selection;
791
+ if (!selection) return;
697
792
 
698
793
  if (html && plainOverride == null && this.__pasteHtml(html, selection)) return;
699
794
  this.__pastePlain(plainOverride ?? plain, selection);
700
795
  }
701
796
 
702
- // Простая вставка текста: переносы строкмягкие <br> (multiline) или пробелы (single-line).
797
+ // Простая вставка текста: в multilineта же модель абзацев и мягких переносов, что и при
798
+ // вставке форматированного (в режиме block пустая строка разделяет абзацы, в break все переносы
799
+ // мягкие); в single-line — одна строка через пробелы.
703
800
  private __pastePlain(text: string, selection: Selection) {
704
801
  if (!text) return;
705
802
 
706
- const lines = text.split(/\n/);
707
- const output = lines.map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
803
+ const lines = text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
708
804
 
709
- const fragment = document.createDocumentFragment();
710
- if (!this.multiline) {
711
- fragment.appendChild(document.createTextNode(output.join(" ")));
712
- } else {
713
- output.forEach((line, index) => {
714
- if (index > 0) fragment.appendChild(document.createElement("br"));
715
- fragment.appendChild(document.createTextNode(line));
716
- });
717
- }
718
-
719
- const range = selection.getRangeAt(0);
720
- this.__history?.record("op");
721
- range.deleteContents();
722
- range.insertNode(fragment);
723
- selection.setPosition(selection.focusNode, selection.focusOffset);
724
-
725
- this.__emitChange();
805
+ this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs), selection);
726
806
  }
727
807
 
728
- // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
729
- // (тогда вызывающий откатывается на простую вставку). Санитизация до включённых инструментов;
730
- // multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line — инлайн с пробелами.
808
+ // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
809
+ // тогда вызывающий откатывается на простую вставку.
731
810
  private __pasteHtml(html: string, selection: Selection): boolean {
732
- // убираем мусорные элементы (Word/браузер: стили, скрипты, заголовок документа)
733
- const source = document.createElement("template");
734
- source.innerHTML = html;
735
- source.content
736
- .querySelectorAll("script, style, head, meta, link, title, noscript")
737
- .forEach((el) => el.remove());
738
-
739
- // единый источник санитизации — deserialize (теги-синонимы → канонические, лишнее развёрнуто)
740
- const clean = deserialize(source.innerHTML, "html", this.formatTools, this.formatMarkers, true);
741
- const holder = document.createElement("template");
742
- holder.innerHTML = clean;
743
-
744
- // внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
745
- // иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
746
- const textWalker = document.createTreeWalker(holder.content, NodeFilter.SHOW_TEXT);
747
- for (let t = textWalker.nextNode(); t; t = textWalker.nextNode())
748
- t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
749
-
750
- const paras = Array.from(holder.content.children) as HTMLElement[];
751
- for (const p of paras) trimParagraphEdges(p);
752
-
753
- // отбрасываем пустые краевые абзацы (ведущие/хвостовые \n и <br>-обёртки из буфера),
754
- // иначе перед и после вставленного текста появляются пустые строки
755
- while (paras.length && (paras[0].textContent ?? "").trim() === "") paras.shift();
756
- while (paras.length && (paras[paras.length - 1].textContent ?? "").trim() === "") paras.pop();
811
+ return this.__insertPasted(sanitizePastedHtml(html, this.formatTools, this.formatMarkers), selection);
812
+ }
813
+
814
+ /**
815
+ * Вставляет разобранные абзацы в каретку (или вместо выделения) и ставит каретку в конец
816
+ * вставленного. Возвращает false, если вставлять было нечего: в этом случае содержимое
817
+ * не трогается вовсе — иначе выделение оказалось бы удалено без замены и без уведомления.
818
+ *
819
+ * multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line сводит их к пробелам.
820
+ * Каретку адресуем текстовым смещением: узлы вставки при разбиении абзаца переезжают.
821
+ */
822
+ private __insertPasted(paras: HTMLElement[], selection: Selection): boolean {
757
823
  if (!paras.length) return false;
758
824
 
759
825
  const range = selection.getRangeAt(0);
760
826
  this.__history?.record("op");
761
827
  range.deleteContents();
762
828
 
763
- // каретку ставим по абсолютному текстовому смещению (длина вставки), не отслеживая узлы
764
829
  const start = selectionCharBounds(this.editable, range)[0];
765
- let caretOffset: number;
830
+ let caret: number;
766
831
 
767
- if (!this.multiline) {
832
+ if (this.multiline) {
833
+ caret = start + paras.reduce((length, p) => length + (p.textContent ?? "").length, 0);
834
+
835
+ insertPastedParagraphs(this.editable, paras, range);
836
+ ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
837
+ } else {
768
838
  // инлайн: абзацы и переносы → пробелы, форматирование сохраняем
769
839
  const fragment = document.createDocumentFragment();
770
840
  paras.forEach((p, index) => {
@@ -772,16 +842,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
772
842
  while (p.firstChild) fragment.appendChild(p.firstChild);
773
843
  });
774
844
  fragment.querySelectorAll("br").forEach((br) => br.replaceWith(document.createTextNode(" ")));
775
- caretOffset = start + (fragment.textContent ?? "").length;
845
+
846
+ caret = start + (fragment.textContent ?? "").length;
776
847
  range.insertNode(fragment);
777
- } else {
778
- caretOffset = start + paras.map((p) => p.textContent ?? "").join("").length;
779
- insertPastedParagraphs(this.editable, paras, range);
780
- ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
781
848
  }
782
849
 
783
- restoreSelection(this.editable, caretOffset, caretOffset, selection);
850
+ restoreSelection(this.editable, caret, caret, selection);
784
851
  this.__emitChange();
852
+
785
853
  return true;
786
854
  }
787
855
 
@@ -804,6 +872,18 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
804
872
  return;
805
873
  }
806
874
 
875
+ // Фильтр хоста на keydown видит только физические нажатия. IME, автозамена, автодополнение
876
+ // и голосовой ввод приходят сразу сюда, поэтому те же ограничения проверяем и на beforeinput —
877
+ // иначе через них в поле попадает что угодно.
878
+ const filterChar = this.__opts.filterChar;
879
+ if (filterChar && FILTERED_INPUT_TYPES.has(e.inputType) && e.data) {
880
+ if (!Array.from(e.data).every((char) => filterChar(char))) {
881
+ e.preventDefault();
882
+ this.__reject();
883
+ return;
884
+ }
885
+ }
886
+
807
887
  // режим набора: оборачиваем вводимый текст в ожидающие форматы
808
888
  if (this.__pendingFormats.size > 0 && e.inputType === "insertText" && e.data != null) {
809
889
  e.preventDefault();
@@ -823,7 +903,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
823
903
  }
824
904
 
825
905
  private __insertText(data: string) {
826
- const selection = this.__innerSelection();
906
+ const selection = this.selection;
827
907
  if (!selection) return;
828
908
 
829
909
  insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);