@brandup/ui-richeditor 1.0.38 → 1.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  // (ограничения по типу поля и длине). Обход символов идёт по кодпойнтам, чтобы эмодзи
@@ -238,7 +274,21 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
238
274
  if (this.readonly) return;
239
275
 
240
276
  const target = container ?? initiator.parentElement;
241
- if (target) formatToolbar.openEmoji(this, initiator, target);
277
+ if (!target) return;
278
+
279
+ formatToolbar.openEmoji(this, initiator, target);
280
+
281
+ // По кнопке могли нажать, ни разу не заходя в поле, — тогда каретки нет и вставлять символ
282
+ // некуда. Фокусировать вслепую нельзя: focus() сбросил бы уже стоящую каретку, и символ
283
+ // уехал бы не туда. Конец содержимого назначаем сами: фокус ставит каретку в начало,
284
+ // и дописанный к сообщению символ оказался бы перед текстом.
285
+ //
286
+ // Строго после открытия панели: фокус показывает тулбар, а придержать его панель успевает
287
+ // только когда открыта сама.
288
+ if (!this.selection) {
289
+ this.focus();
290
+ caretToEnd(this.editable, this.multiline);
291
+ }
242
292
  }
243
293
 
244
294
  getLength(): number {
@@ -258,11 +308,25 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
258
308
  * Выделение, если оно находится внутри этого редактора. Браузер сохраняет выделение и после
259
309
  * blur, поэтому проверка работает и когда фокус ушёл на кнопку страницы.
260
310
  */
261
- private __innerSelection(): Selection | null {
262
- const selection = window.getSelection();
263
- if (!selection || selection.rangeCount === 0 || !this.editable.contains(selection.anchorNode)) return null;
311
+ get selection(): Selection | null {
312
+ return innerSelection(this.editable);
313
+ }
264
314
 
265
- return selection;
315
+ /**
316
+ * Выделить узел внутри редактора — например, чтобы следующая вставка заменила его целиком.
317
+ * Не зависит от того, где стоит выделение сейчас: оно могло уйти, пока хост показывал
318
+ * своё окно, а сам узел никуда не делся.
319
+ */
320
+ selectNode(node: Node): void {
321
+ if (!this.editable.contains(node)) return;
322
+
323
+ const selection = documentSelection(this.editable);
324
+ if (!selection) return;
325
+
326
+ const range = this.editable.ownerDocument.createRange();
327
+ range.selectNode(node);
328
+ selection.removeAllRanges();
329
+ selection.addRange(range);
266
330
  }
267
331
 
268
332
  /**
@@ -270,10 +334,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
270
334
  * для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
271
335
  * править, каретка пользователя не двигается. null — правка недоступна или выделение вне редактора.
272
336
  */
273
- private __formatTarget(): { selection: Selection; range: Range; original: [number, number] } | null {
337
+ private __formatTarget(): { selection: Selection; range: Range; original: () => [number, number] } | null {
274
338
  if (!this.format || this.readonly) return null;
275
339
 
276
- const selection = this.__innerSelection();
340
+ const selection = this.selection;
277
341
  if (!selection) return null;
278
342
 
279
343
  const current = selection.getRangeAt(0);
@@ -282,7 +346,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
282
346
  selection,
283
347
  // форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
284
348
  range: expandRangeToWords(this.editable, current),
285
- original: selectionCharBounds(this.editable, current),
349
+ // границы считаем по требованию: они нужны только правкам, а цель вычисляется ещё и
350
+ // на каждое обновление панели, где сбор всего текста в строку — самая дорогая операция
351
+ original: () => selectionCharBounds(this.editable, current),
286
352
  };
287
353
  }
288
354
 
@@ -305,7 +371,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
305
371
  this.__pendingFormats.clear();
306
372
 
307
373
  this.__history?.record("op");
308
- toggleFormat(this.editable, target.range, tool, target.selection, target.original);
374
+ toggleFormat(this.editable, target.range, tool, target.selection, target.original());
309
375
 
310
376
  this.__emitChange();
311
377
  formatToolbar.refresh();
@@ -325,7 +391,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
325
391
  if (!hasFormatting(this.editable, target.range)) return;
326
392
 
327
393
  this.__history?.record("op");
328
- clearFormat(this.editable, target.range, target.selection, target.original);
394
+ clearFormat(this.editable, target.range, target.selection, target.original());
329
395
 
330
396
  this.__emitChange();
331
397
  formatToolbar.refresh();
@@ -338,14 +404,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
338
404
  this.__clearPendingFormats();
339
405
  if (!hasAnyFormatting(this.editable)) return;
340
406
 
341
- // разворачивание тегов рвёт выделение — запоминаем по текстовым смещениям
342
- const selection = this.__innerSelection();
343
- const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
344
-
345
407
  this.__history?.record("op");
346
- clearAllFormat(this.editable);
347
-
348
- if (bounds && selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
408
+ // разворачивание тегов рвёт выделение — сохраняем его по текстовым смещениям
409
+ preserveCaret(this.editable, () => clearAllFormat(this.editable));
349
410
 
350
411
  this.__emitChange();
351
412
  formatToolbar.refresh();
@@ -428,13 +489,29 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
428
489
  isToolActive(tool: FormatTool): boolean {
429
490
  if (this.__pendingFormats.has(tool)) return true;
430
491
 
431
- const selection = this.__innerSelection();
492
+ const selection = this.selection;
432
493
  if (!selection) return false;
433
494
 
434
495
  return isFormatActive(this.editable, selection.getRangeAt(0), tool);
435
496
  }
436
497
 
498
+ /**
499
+ * Активные форматы всех инструментов сразу — панель обновляется на каждое движение каретки,
500
+ * а поинструментный опрос обходил бы содержимое столько раз, сколько кнопок.
501
+ */
502
+ activeTools(): ReadonlySet<FormatTool> {
503
+ const selection = this.selection;
504
+ const active = selection
505
+ ? activeFormats(this.editable, selection.getRangeAt(0), this.formatTools)
506
+ : new Set<FormatTool>();
507
+
508
+ for (const tool of this.__pendingFormats) active.add(tool);
509
+
510
+ return active;
511
+ }
512
+
437
513
  override destroy(): void {
514
+ this.flushChange(); // хост не должен остаться с устаревшей копией значения
438
515
  this.__abort.abort();
439
516
  formatToolbar.detach(this);
440
517
 
@@ -452,11 +529,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
452
529
  DOM.empty(this.editable);
453
530
  if (!value) return;
454
531
 
455
- // multiline → <p>-абзацы; single-line → инлайновое содержимое.
456
- // В режиме break абзацных блоков нет: значение — плоский текст, где каждый \n это <br>.
457
- // Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без отступов между абзацами)
458
- // это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
459
- const paragraphs = this.multiline && this.paragraph === "block";
532
+ // multiline → <p>-абзацы; single-line → инлайновое содержимое
533
+ const paragraphs = this.__blockParagraphs;
460
534
 
461
535
  this.editable.innerHTML = deserialize(
462
536
  value,
@@ -471,10 +545,41 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
471
545
  if (this.multiline && !paragraphs) ensureParagraphs(this.editable);
472
546
  }
473
547
 
474
- private __emitChange() {
548
+ /**
549
+ * Событие изменения. При `defer` (печать) доставка откладывается — иначе каждый символ
550
+ * стоил бы полной сериализации содержимого. Все прочие правки (вставка, формат, отмена,
551
+ * setValue) сообщаются сразу: они разовые, а не посимвольные.
552
+ *
553
+ * `getValue()` считает значение по DOM и точен всегда; отложено только уведомление
554
+ * и, как следствие, копия значения у хоста — её сбрасывает {@link flushChange}.
555
+ */
556
+ private __emitChange(defer = false) {
557
+ if (defer) {
558
+ // троттлинг: первый ввод заводит таймер, последующие в этом окне его не сдвигают
559
+ this.__changeTimer ||= this.__window.setTimeout(() => this.__emitChange(), CHANGE_THROTTLE_MS);
560
+ return;
561
+ }
562
+
563
+ this.__cancelChange();
475
564
  this.trigger(CHANGE_EVENT, <RichEditorChangeData>{ editor: this, value: this.getValue() });
476
565
  }
477
566
 
567
+ private __cancelChange() {
568
+ if (!this.__changeTimer) return;
569
+
570
+ this.__window.clearTimeout(this.__changeTimer);
571
+ this.__changeTimer = 0;
572
+ }
573
+
574
+ /**
575
+ * Доставить отложенное изменение немедленно. Вызывать перед тем, как значение читают
576
+ * извне: отправка формы, валидация, чтение значения хостом. Если ничего не отложено —
577
+ * ничего и не делает, лишнего события не будет.
578
+ */
579
+ flushChange(): void {
580
+ if (this.__changeTimer) this.__emitChange();
581
+ }
582
+
478
583
  private __reject() {
479
584
  this.__opts.onReject?.();
480
585
  }
@@ -486,7 +591,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
486
591
  if (this.readonly) return;
487
592
 
488
593
  // правка текстовых узлов рвёт живые Range — запоминаем выделение по текстовым смещениям
489
- const selection = this.__innerSelection();
594
+ const selection = this.selection;
490
595
  const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
491
596
 
492
597
  const before = this.editable.innerHTML;
@@ -512,8 +617,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
512
617
  const { signal } = this.__abort;
513
618
  const editable = this.editable;
514
619
 
620
+ // перетаскивание в редактор проходит мимо истории и фильтров хоста — гасим саму вставку.
621
+ // dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
515
622
  this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
516
- this.element.addEventListener("dragenter", (e) => e.preventDefault(), { signal });
517
623
 
518
624
  editable.addEventListener(
519
625
  "mousedown",
@@ -529,13 +635,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
529
635
  () => {
530
636
  this.element.classList.add("focused");
531
637
 
532
- // показываем общий тулбар над этим редактором
533
- if (this.format && (this.formatTools.length || this.editorActions.length)) formatToolbar.attach(this);
638
+ // нужна ли панель этому редактору, решает она сама — иначе условие пришлось бы
639
+ // держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
640
+ formatToolbar.attach(this);
534
641
 
535
642
  if (this.readonly) selectAllContent(this.editable);
536
643
  // Каретку в конец ставим только когда её нет: клик ставит сам, а уже стоящую
537
644
  // (фокус вернули из кода после вызова метода) двигать нельзя — уедет в конец текста.
538
- else if (!this.__hasInputClick && !this.__innerSelection()) caretToEnd(this.editable, this.multiline);
645
+ else if (!this.__hasInputClick && !this.selection) caretToEnd(this.editable, this.multiline);
539
646
  },
540
647
  { signal }
541
648
  );
@@ -553,6 +660,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
553
660
 
554
661
  this.__clearPendingFormats();
555
662
  this.__normalize(true); // редактирование завершено
663
+ this.flushChange(); // ввод закончен — отложенное изменение доставляем сразу
556
664
  },
557
665
  { signal }
558
666
  );
@@ -568,19 +676,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
568
676
  "input",
569
677
  () => {
570
678
  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;
679
+ // приведение к абзацам меняет структуру (обёртка в <p>, удаление <br>) и сбрасывает
680
+ // каретку сохраняем её; если структура не менялась, выделение живо и переставлять
681
+ // его не нужно (лишний сброс способен прервать IME-набор)
682
+ preserveCaret(editable, () => {
683
+ const before = editable.innerHTML;
684
+ ensureParagraphs(editable); // блуждающий текст/div → <p>
579
685
 
580
- ensureParagraphs(editable); // блуждающий текст/div → <p>
581
-
582
- if (caret && selection && editable.innerHTML !== before)
583
- restoreSelection(editable, caret[0], caret[1], selection);
686
+ return editable.innerHTML !== before;
687
+ });
584
688
 
585
689
  // единственный пустой абзац → очищаем, чтобы показать placeholder
586
690
  if (editable.children.length === 1) {
@@ -590,7 +694,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
590
694
  } else if (editable.firstChild?.nodeName === "BR") {
591
695
  editable.innerHTML = "";
592
696
  }
593
- this.__emitChange();
697
+ this.__emitChange(true); // печать — единственный посимвольный источник, его и откладываем
594
698
  },
595
699
  { signal }
596
700
  );
@@ -652,6 +756,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
652
756
  return;
653
757
  }
654
758
 
759
+ // Абзацы и переносы правятся вручную, мимо beforeinput, поэтому запрет на изменение
760
+ // текста проверяем здесь: иначе Enter добавлял бы строки и в режиме только для чтения.
761
+ if (this.readonly) return;
762
+
655
763
  // В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
656
764
  // В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
657
765
  // набирается двумя переносами.
@@ -692,79 +800,55 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
692
800
  if (filtered !== plain) plainOverride = filtered;
693
801
  }
694
802
 
695
- const selection = window.getSelection();
696
- if (!selection || selection.rangeCount === 0) return;
803
+ // вставлять только в своё содержимое: выделение вне редактора нам не адресовано
804
+ const selection = this.selection;
805
+ if (!selection) return;
697
806
 
698
807
  if (html && plainOverride == null && this.__pasteHtml(html, selection)) return;
699
808
  this.__pastePlain(plainOverride ?? plain, selection);
700
809
  }
701
810
 
702
- // Простая вставка текста: переносы строкмягкие <br> (multiline) или пробелы (single-line).
811
+ // Простая вставка текста: в multilineта же модель абзацев и мягких переносов, что и при
812
+ // вставке форматированного (в режиме block пустая строка разделяет абзацы, в break все переносы
813
+ // мягкие); в single-line — одна строка через пробелы.
703
814
  private __pastePlain(text: string, selection: Selection) {
704
815
  if (!text) return;
705
816
 
706
- const lines = text.split(/\n/);
707
- const output = lines.map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
708
-
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);
817
+ const lines = text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
724
818
 
725
- this.__emitChange();
819
+ this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs), selection);
726
820
  }
727
821
 
728
- // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
729
- // (тогда вызывающий откатывается на простую вставку). Санитизация до включённых инструментов;
730
- // multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line — инлайн с пробелами.
822
+ // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
823
+ // тогда вызывающий откатывается на простую вставку.
731
824
  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();
825
+ return this.__insertPasted(sanitizePastedHtml(html, this.formatTools, this.formatMarkers), selection);
826
+ }
827
+
828
+ /**
829
+ * Вставляет разобранные абзацы в каретку (или вместо выделения) и ставит каретку в конец
830
+ * вставленного. Возвращает false, если вставлять было нечего: в этом случае содержимое
831
+ * не трогается вовсе — иначе выделение оказалось бы удалено без замены и без уведомления.
832
+ *
833
+ * multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line сводит их к пробелам.
834
+ * Каретку адресуем текстовым смещением: узлы вставки при разбиении абзаца переезжают.
835
+ */
836
+ private __insertPasted(paras: HTMLElement[], selection: Selection): boolean {
757
837
  if (!paras.length) return false;
758
838
 
759
839
  const range = selection.getRangeAt(0);
760
840
  this.__history?.record("op");
761
841
  range.deleteContents();
762
842
 
763
- // каретку ставим по абсолютному текстовому смещению (длина вставки), не отслеживая узлы
764
843
  const start = selectionCharBounds(this.editable, range)[0];
765
- let caretOffset: number;
844
+ let caret: number;
845
+
846
+ if (this.multiline) {
847
+ caret = start + paras.reduce((length, p) => length + (p.textContent ?? "").length, 0);
766
848
 
767
- if (!this.multiline) {
849
+ insertPastedParagraphs(this.editable, paras, range);
850
+ ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
851
+ } else {
768
852
  // инлайн: абзацы и переносы → пробелы, форматирование сохраняем
769
853
  const fragment = document.createDocumentFragment();
770
854
  paras.forEach((p, index) => {
@@ -772,16 +856,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
772
856
  while (p.firstChild) fragment.appendChild(p.firstChild);
773
857
  });
774
858
  fragment.querySelectorAll("br").forEach((br) => br.replaceWith(document.createTextNode(" ")));
775
- caretOffset = start + (fragment.textContent ?? "").length;
859
+
860
+ caret = start + (fragment.textContent ?? "").length;
776
861
  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
862
  }
782
863
 
783
- restoreSelection(this.editable, caretOffset, caretOffset, selection);
864
+ restoreSelection(this.editable, caret, caret, selection);
784
865
  this.__emitChange();
866
+
785
867
  return true;
786
868
  }
787
869
 
@@ -804,6 +886,18 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
804
886
  return;
805
887
  }
806
888
 
889
+ // Фильтр хоста на keydown видит только физические нажатия. IME, автозамена, автодополнение
890
+ // и голосовой ввод приходят сразу сюда, поэтому те же ограничения проверяем и на beforeinput —
891
+ // иначе через них в поле попадает что угодно.
892
+ const filterChar = this.__opts.filterChar;
893
+ if (filterChar && FILTERED_INPUT_TYPES.has(e.inputType) && e.data) {
894
+ if (!Array.from(e.data).every((char) => filterChar(char))) {
895
+ e.preventDefault();
896
+ this.__reject();
897
+ return;
898
+ }
899
+ }
900
+
807
901
  // режим набора: оборачиваем вводимый текст в ожидающие форматы
808
902
  if (this.__pendingFormats.size > 0 && e.inputType === "insertText" && e.data != null) {
809
903
  e.preventDefault();
@@ -823,11 +917,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
823
917
  }
824
918
 
825
919
  private __insertText(data: string) {
826
- const selection = this.__innerSelection();
920
+ const selection = this.selection;
827
921
  if (!selection) return;
828
922
 
829
923
  insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);
830
924
 
925
+ // В пустом поле абзацев ещё нет, и каретке некуда встать внутрь — вставка из кода
926
+ // (смайлик, переменная) ложится прямо в редактор. Приводим к модели абзацев тем же
927
+ // способом, что и обработчик input при печати: он на программную вставку не приходит,
928
+ // а без <p> следующий Enter правит текст мимо абзаца.
929
+ if (this.multiline)
930
+ preserveCaret(this.editable, () => {
931
+ const before = this.editable.innerHTML;
932
+ ensureParagraphs(this.editable);
933
+
934
+ return this.editable.innerHTML !== before;
935
+ });
936
+
831
937
  this.__emitChange();
832
938
  formatToolbar.refresh();
833
939
  }