@brandup/ui-richeditor 1.0.36 → 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,35 +4,47 @@ import { DOM, UIElementBound } from "@brandup/ui";
4
4
  import {
5
5
  ALL_FORMAT_TOOLS,
6
6
  HOTKEY_TOOLS,
7
+ activeFormats,
8
+ clearAllFormat,
9
+ clearFormat,
7
10
  defaultFormatMarkers,
8
11
  deserialize,
9
12
  ensureParagraphs,
13
+ hasAnyFormatting,
14
+ hasFormatting,
10
15
  insertFormattedText,
11
16
  isFormatActive,
17
+ documentSelection,
18
+ innerSelection,
19
+ mapCharOffset,
20
+ preserveCaret,
12
21
  normalizeParagraphs,
13
22
  normalizeWhitespace,
14
23
  restoreSelection,
15
24
  selectionCharBounds,
16
25
  serialize,
17
26
  toggleFormat,
27
+ type EditorAction,
18
28
  type FormatMarkers,
19
29
  type FormatStorage,
30
+ type ParagraphMode,
20
31
  type FormatTool,
21
32
  } from "./format";
22
33
  import {
34
+ buildParagraphs,
23
35
  caretToEnd,
24
- expandSelectionToWords,
36
+ expandRangeToWords,
25
37
  insertParagraph,
26
38
  insertPastedParagraphs,
27
39
  insertSoftBreak,
40
+ sanitizePastedHtml,
28
41
  selectAllContent,
29
- trimParagraphEdges,
30
42
  trimSelectionWhitespace,
31
43
  } from "./editing";
32
44
  import { EditorHistory } from "./history";
33
- import { formatToolbar } from "./toolbar";
45
+ import { formatToolbar, type ToolbarButton } from "./toolbar";
34
46
 
35
- export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost } from "./toolbar";
47
+ export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost, type ToolbarButton } from "./toolbar";
36
48
 
37
49
  export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
38
50
  export const CHANGE_EVENT = "richeditor-change";
@@ -52,6 +64,16 @@ const NATIVE_EDIT_TYPES = new Set([
52
64
  "deleteByCut",
53
65
  ]);
54
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
+
55
77
  // Буква физической клавиши (KeyA…KeyZ) — не зависит от раскладки. Для не-латинских раскладок
56
78
  // (например, кириллицы) e.key даёт другую букву, поэтому хоткеи сверяем и по e.code.
57
79
  function codeLetter(e: KeyboardEvent): string {
@@ -68,6 +90,8 @@ export interface RichEditorOptions {
68
90
  format?: boolean;
69
91
  /** Состав инструментов (по умолчанию все). */
70
92
  tools?: FormatTool[];
93
+ /** Кнопки действий в панели: очистка форматирования, отмена, повтор (по умолчанию нет). */
94
+ actions?: EditorAction[];
71
95
  /** Формат хранения значения при форматировании. */
72
96
  storage?: FormatStorage;
73
97
  /** Переопределения markdown-маркеров. */
@@ -76,10 +100,14 @@ export interface RichEditorOptions {
76
100
  placeholder?: string | null;
77
101
  /** Многострочный режим. */
78
102
  multiline?: boolean;
103
+ /** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
104
+ paragraph?: ParagraphMode;
79
105
  /** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
80
106
  readonly?: boolean;
81
107
  /** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
82
108
  toolbarContainer?: HTMLElement | null;
109
+ /** Собственные кнопки хоста в панели — для действий, которых редактор не знает. */
110
+ buttons?: ToolbarButton[];
83
111
  /** Начальное значение. */
84
112
  value?: string;
85
113
 
@@ -107,15 +135,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
107
135
  readonly editable: HTMLElement;
108
136
  readonly format: boolean;
109
137
  readonly formatTools: FormatTool[];
138
+ readonly editorActions: EditorAction[];
110
139
  readonly formatStorage: FormatStorage;
111
140
  readonly formatMarkers: FormatMarkers;
112
141
  readonly multiline: boolean;
142
+ readonly paragraph: ParagraphMode;
113
143
  readonly toolbarContainer: HTMLElement | null;
144
+ readonly toolbarButtons: ToolbarButton[];
114
145
 
115
146
  private __opts: RichEditorOptions;
116
147
  private __abort = new AbortController();
117
148
  private __pendingFormats = new Set<FormatTool>();
118
149
  private __hasInputClick = false;
150
+ private __changeTimer = 0; // отложенное change по печати — см. __emitChange/flushChange
151
+ // Окно редактируемого элемента, взятое при создании. Таймер отложенного change переживает
152
+ // снятие компонента и гасится в destroy, а тот случается когда угодно — к этому моменту
153
+ // до глобального окружения может быть уже не добраться, да и элемент мог жить в iframe.
154
+ private readonly __window: Window;
119
155
  // собственная история undo/redo — только при форматировании (см. ./history)
120
156
  private __history: EditorHistory | null = null;
121
157
 
@@ -125,6 +161,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
125
161
  const readonly = !!options.readonly;
126
162
  // форматирование недоступно в режиме только для чтения
127
163
  const tools = format && !readonly ? (options.tools ?? ALL_FORMAT_TOOLS.slice()) : [];
164
+ // действия подключаются явно — иначе панель у существующих хостов молча обзавелась бы кнопками
165
+ const actions = format && !readonly ? (options.actions ?? []) : [];
128
166
 
129
167
  // тулбар общий и живёт в body (см. ./toolbar), поэтому обёртка не нужна —
130
168
  // привязываем UIElement прямо к переданному элементу, он же и редактируемый
@@ -133,13 +171,18 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
133
171
  super("BrandUp.RichEditor", editable);
134
172
 
135
173
  this.editable = editable;
174
+ this.__window = editable.ownerDocument.defaultView ?? window;
136
175
  this.__opts = options;
137
176
  this.format = format;
138
177
  this.formatTools = tools;
178
+ this.editorActions = actions;
139
179
  this.formatStorage = options.storage === "markdown" ? "markdown" : "html";
140
180
  this.formatMarkers = Object.assign(defaultFormatMarkers(), options.markers);
141
181
  this.multiline = multiline;
182
+ this.paragraph = options.paragraph ?? "block";
142
183
  this.toolbarContainer = options.toolbarContainer ?? null;
184
+ // кнопки хоста живут и без форматирования, но не в readonly — там панели нет вовсе
185
+ this.toolbarButtons = readonly ? [] : (options.buttons ?? []);
143
186
  // история включается вместе с форматированием
144
187
  this.__history = format ? new EditorHistory(editable) : null;
145
188
 
@@ -161,6 +204,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
161
204
  return !!this.__opts.readonly;
162
205
  }
163
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
+
164
217
  // формат хранения значения: format → выбранный; plain → markdown без инструментов (\n\n/\n)
165
218
  private get __valueStorage(): FormatStorage {
166
219
  return this.format ? this.formatStorage : "markdown";
@@ -177,11 +230,53 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
177
230
  }
178
231
 
179
232
  setValue(value: string): void {
233
+ // каретка внутри относилась к прежнему содержимому — после замены ставим её в конец
234
+ const hadCaret = !!this.selection;
235
+
180
236
  this.__render(value ?? "");
181
237
  this.__normalize(false);
238
+ if (hadCaret) caretToEnd(this.editable, this.multiline);
239
+
182
240
  this.__emitChange();
183
241
  }
184
242
 
243
+ /**
244
+ * Вставляет текст в каретку (или вместо выделения) с учётом ожидающих форматов режима набора.
245
+ * Каретка сохраняется и после потери фокуса, поэтому метод работает и при вызове из кода —
246
+ * например, кнопкой панели, которая не должна забирать фокус у редактора.
247
+ */
248
+ insertText(text: string): void {
249
+ if (this.readonly || !text || !this.selection) return;
250
+
251
+ // вставка — такой же ввод, как с клавиатуры, поэтому проходит через filterChar хоста
252
+ // (ограничения по типу поля и длине). Обход символов идёт по кодпойнтам, чтобы эмодзи
253
+ // проверялся целиком; длина сверяется с текущим содержимым, так что многосимвольная
254
+ // вставка ограничивается по первому символу — для одного символа проверка точная.
255
+ const filterChar = this.__opts.filterChar;
256
+ if (filterChar && !Array.from(text).every((char) => filterChar(char))) {
257
+ this.__reject();
258
+ return;
259
+ }
260
+
261
+ this.__history?.record("op");
262
+ this.__insertText(text);
263
+ }
264
+
265
+ /**
266
+ * Показать панель вставки смайлика у своей кнопки — когда она живёт не в тулбаре, а в разметке
267
+ * хоста (например справа от поля ввода). Вызывать из обработчика `click`, погасив всплытие:
268
+ * иначе попап закроется тем же кликом, которым открылся.
269
+ *
270
+ * @param initiator Кнопка, у которой показывается панель.
271
+ * @param container Куда монтировать панель; по умолчанию — родитель кнопки.
272
+ */
273
+ openEmojiPicker(initiator: HTMLElement, container?: HTMLElement): void {
274
+ if (this.readonly) return;
275
+
276
+ const target = container ?? initiator.parentElement;
277
+ if (target) formatToolbar.openEmoji(this, initiator, target);
278
+ }
279
+
185
280
  getLength(): number {
186
281
  // textContent (а не innerText) корректно считает multiline и работает в jsdom
187
282
  return this.editable.textContent?.length ?? 0;
@@ -195,22 +290,62 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
195
290
  this.on(CHANGE_EVENT, handler);
196
291
  }
197
292
 
198
- /** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
199
- applyFormat(tool: FormatTool): void {
200
- if (!this.format || this.readonly || !this.formatTools.includes(tool)) return;
293
+ /**
294
+ * Выделение, если оно находится внутри этого редактора. Браузер сохраняет выделение и после
295
+ * blur, поэтому проверка работает и когда фокус ушёл на кнопку страницы.
296
+ */
297
+ get selection(): Selection | null {
298
+ return innerSelection(this.editable);
299
+ }
201
300
 
202
- const selection = window.getSelection();
203
- if (!selection || selection.rangeCount === 0) return;
204
- if (!this.editable.contains(selection.anchorNode)) return;
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);
316
+ }
317
+
318
+ /**
319
+ * Цель правки форматирования: выделение, расширенное до целых слов, плюс исходные границы
320
+ * для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
321
+ * править, каретка пользователя не двигается. null — правка недоступна или выделение вне редактора.
322
+ */
323
+ private __formatTarget(): { selection: Selection; range: Range; original: () => [number, number] } | null {
324
+ if (!this.format || this.readonly) return null;
325
+
326
+ const selection = this.selection;
327
+ if (!selection) return null;
328
+
329
+ const current = selection.getRangeAt(0);
330
+
331
+ return {
332
+ selection,
333
+ // форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
334
+ range: expandRangeToWords(this.editable, current),
335
+ // границы считаем по требованию: они нужны только правкам, а цель вычисляется ещё и
336
+ // на каждое обновление панели, где сбор всего текста в строку — самая дорогая операция
337
+ original: () => selectionCharBounds(this.editable, current),
338
+ };
339
+ }
205
340
 
206
- // запоминаем исходное выделение, чтобы вернуть его после форматирования
207
- const original = selectionCharBounds(this.editable, selection.getRangeAt(0));
341
+ /** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
342
+ applyFormat(tool: FormatTool): void {
343
+ if (!this.formatTools.includes(tool)) return;
208
344
 
209
- // форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
210
- expandSelectionToWords(this.editable, selection);
345
+ const target = this.__formatTarget();
346
+ if (!target) return;
211
347
 
212
- const range = selection.getRangeAt(0);
213
- if (range.collapsed) {
348
+ if (target.range.collapsed) {
214
349
  // под кареткой нет слова — режим набора: формат для следующего ввода
215
350
  if (this.__pendingFormats.has(tool)) this.__pendingFormats.delete(tool);
216
351
  else this.__pendingFormats.add(tool);
@@ -222,24 +357,114 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
222
357
  this.__pendingFormats.clear();
223
358
 
224
359
  this.__history?.record("op");
225
- toggleFormat(this.editable, range, tool, selection, original);
360
+ toggleFormat(this.editable, target.range, tool, target.selection, target.original());
226
361
 
227
362
  this.__emitChange();
228
363
  formatToolbar.refresh();
229
364
  }
230
365
 
366
+ /**
367
+ * Снимает всё форматирование с выделения; без выделения — со слова под кареткой
368
+ * (как и применение формата). Режим набора сбрасывается.
369
+ */
370
+ clearFormat(): void {
371
+ const target = this.__formatTarget();
372
+ if (!target) return;
373
+
374
+ this.__clearPendingFormats();
375
+
376
+ // нечего очищать — не пишем в историю пустой шаг и не трогаем выделение
377
+ if (!hasFormatting(this.editable, target.range)) return;
378
+
379
+ this.__history?.record("op");
380
+ clearFormat(this.editable, target.range, target.selection, target.original());
381
+
382
+ this.__emitChange();
383
+ formatToolbar.refresh();
384
+ }
385
+
386
+ /** Снимает всё форматирование со всего содержимого (выделение не требуется). */
387
+ clearAllFormat(): void {
388
+ if (!this.format || this.readonly) return;
389
+
390
+ this.__clearPendingFormats();
391
+ if (!hasAnyFormatting(this.editable)) return;
392
+
393
+ this.__history?.record("op");
394
+ // разворачивание тегов рвёт выделение — сохраняем его по текстовым смещениям
395
+ preserveCaret(this.editable, () => clearAllFormat(this.editable));
396
+
397
+ this.__emitChange();
398
+ formatToolbar.refresh();
399
+ }
400
+
401
+ /** Доступна ли отмена (история ведётся только при включённом форматировании). */
402
+ get canUndo(): boolean {
403
+ return !this.readonly && !!this.__history?.canUndo;
404
+ }
405
+
406
+ /** Доступен ли повтор отменённого. */
407
+ get canRedo(): boolean {
408
+ return !this.readonly && !!this.__history?.canRedo;
409
+ }
410
+
231
411
  /** Отменить последнее действие (история ведётся только при форматировании). */
232
- private __undo(): void {
233
- if (!this.__history?.undo()) return;
412
+ undo(): void {
413
+ if (this.readonly || !this.__history?.undo()) return;
234
414
  this.__afterHistory();
235
415
  }
236
416
 
237
417
  /** Повторить отменённое действие. */
238
- private __redo(): void {
239
- if (!this.__history?.redo()) return;
418
+ redo(): void {
419
+ if (this.readonly || !this.__history?.redo()) return;
240
420
  this.__afterHistory();
241
421
  }
242
422
 
423
+ /** Выполнить действие панели (вызывается кнопками тулбара). */
424
+ applyAction(action: EditorAction): void {
425
+ if (!this.editorActions.includes(action)) return;
426
+
427
+ switch (action) {
428
+ case "erase":
429
+ this.clearFormat();
430
+ break;
431
+ case "undo":
432
+ this.undo();
433
+ break;
434
+ case "redo":
435
+ this.redo();
436
+ break;
437
+ case "emoji":
438
+ // панель вставки открывает тулбар: ей нужна кнопка как якорь попапа.
439
+ // Вставка символа приходит сюда через insertText.
440
+ break;
441
+ }
442
+ }
443
+
444
+ /** Доступно ли действие сейчас (для disabled-состояния кнопки тулбара). */
445
+ isActionEnabled(action: EditorAction): boolean {
446
+ switch (action) {
447
+ case "undo":
448
+ return this.canUndo;
449
+ case "redo":
450
+ return this.canRedo;
451
+ case "erase":
452
+ return this.__hasFormatting();
453
+ case "emoji":
454
+ return !this.readonly;
455
+ }
456
+ }
457
+
458
+ // Есть ли что очищать: ожидающий формат режима набора либо форматирование на цели очистки.
459
+ // Смотрим на тот же расширенный до слов диапазон, что возьмёт clearFormat, — иначе кнопка
460
+ // блокировалась бы в случаях, когда операция сработала бы (каретка на границе формата).
461
+ private __hasFormatting(): boolean {
462
+ if (this.__pendingFormats.size > 0) return true;
463
+
464
+ const target = this.__formatTarget();
465
+ return !!target && hasFormatting(this.editable, target.range);
466
+ }
467
+
243
468
  private __afterHistory(): void {
244
469
  this.__pendingFormats.clear();
245
470
  this.__emitChange();
@@ -250,13 +475,29 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
250
475
  isToolActive(tool: FormatTool): boolean {
251
476
  if (this.__pendingFormats.has(tool)) return true;
252
477
 
253
- const selection = window.getSelection();
254
- if (!selection || selection.rangeCount === 0 || !this.editable.contains(selection.anchorNode)) return false;
478
+ const selection = this.selection;
479
+ if (!selection) return false;
255
480
 
256
481
  return isFormatActive(this.editable, selection.getRangeAt(0), tool);
257
482
  }
258
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
+
259
499
  override destroy(): void {
500
+ this.flushChange(); // хост не должен остаться с устаревшей копией значения
260
501
  this.__abort.abort();
261
502
  formatToolbar.detach(this);
262
503
 
@@ -275,19 +516,56 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
275
516
  if (!value) return;
276
517
 
277
518
  // multiline → <p>-абзацы; single-line → инлайновое содержимое
519
+ const paragraphs = this.__blockParagraphs;
520
+
278
521
  this.editable.innerHTML = deserialize(
279
522
  value,
280
523
  this.__valueStorage,
281
524
  this.__valueTools,
282
525
  this.formatMarkers,
283
- this.multiline
526
+ paragraphs
284
527
  );
528
+
529
+ // в break инлайновое содержимое оборачивается в единственный абзац — модель абзацев
530
+ // нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
531
+ if (this.multiline && !paragraphs) ensureParagraphs(this.editable);
285
532
  }
286
533
 
287
- 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();
288
550
  this.trigger(CHANGE_EVENT, <RichEditorChangeData>{ editor: this, value: this.getValue() });
289
551
  }
290
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
+
291
569
  private __reject() {
292
570
  this.__opts.onReject?.();
293
571
  }
@@ -298,11 +576,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
298
576
  private __normalize(notify: boolean) {
299
577
  if (this.readonly) return;
300
578
 
579
+ // правка текстовых узлов рвёт живые Range — запоминаем выделение по текстовым смещениям
580
+ const selection = this.selection;
581
+ const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
582
+
301
583
  const before = this.editable.innerHTML;
584
+ const textBefore = this.editable.textContent ?? "";
302
585
  normalizeWhitespace(this.editable);
303
586
  if (this.multiline) normalizeParagraphs(this.editable);
304
587
  if (this.editable.innerHTML === before) return;
305
588
 
589
+ if (bounds && selection) {
590
+ // схлопнутые пробелы сдвигают смещения — переносим их на новый текст
591
+ const textAfter = this.editable.textContent ?? "";
592
+ const start = mapCharOffset(textBefore, textAfter, bounds[0]);
593
+ const end = bounds[1] === bounds[0] ? start : mapCharOffset(textBefore, textAfter, bounds[1]);
594
+ restoreSelection(this.editable, start, end, selection);
595
+ }
596
+
306
597
  if (notify) this.__emitChange();
307
598
  }
308
599
 
@@ -312,8 +603,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
312
603
  const { signal } = this.__abort;
313
604
  const editable = this.editable;
314
605
 
606
+ // перетаскивание в редактор проходит мимо истории и фильтров хоста — гасим саму вставку.
607
+ // dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
315
608
  this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
316
- this.element.addEventListener("dragenter", (e) => e.preventDefault(), { signal });
317
609
 
318
610
  editable.addEventListener(
319
611
  "mousedown",
@@ -329,11 +621,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
329
621
  () => {
330
622
  this.element.classList.add("focused");
331
623
 
332
- // показываем общий тулбар над этим редактором
333
- if (this.format && this.formatTools.length) formatToolbar.attach(this);
624
+ // нужна ли панель этому редактору, решает она сама — иначе условие пришлось бы
625
+ // держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
626
+ formatToolbar.attach(this);
334
627
 
335
628
  if (this.readonly) selectAllContent(this.editable);
336
- else if (!this.__hasInputClick) caretToEnd(this.editable, this.multiline);
629
+ // Каретку в конец ставим только когда её нет: клик ставит сам, а уже стоящую
630
+ // (фокус вернули из кода после вызова метода) двигать нельзя — уедет в конец текста.
631
+ else if (!this.__hasInputClick && !this.selection) caretToEnd(this.editable, this.multiline);
337
632
  },
338
633
  { signal }
339
634
  );
@@ -351,6 +646,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
351
646
 
352
647
  this.__clearPendingFormats();
353
648
  this.__normalize(true); // редактирование завершено
649
+ this.flushChange(); // ввод закончен — отложенное изменение доставляем сразу
354
650
  },
355
651
  { signal }
356
652
  );
@@ -366,19 +662,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
366
662
  "input",
367
663
  () => {
368
664
  if (this.multiline) {
369
- // нормализация меняет структуру (обёртка в <p>, удаление <br>) и сбрасывает каретку —
370
- // запоминаем её позицию по текстовому смещению и восстанавливаем после
371
- const selection = window.getSelection();
372
- const caret =
373
- selection && selection.rangeCount > 0 && editable.contains(selection.anchorNode)
374
- ? selectionCharBounds(editable, selection.getRangeAt(0))
375
- : null;
376
- const before = editable.innerHTML;
377
-
378
- ensureParagraphs(editable); // блуждающий текст/div → <p>
665
+ // приведение к абзацам меняет структуру (обёртка в <p>, удаление <br>) и сбрасывает
666
+ // каретку сохраняем её; если структура не менялась, выделение живо и переставлять
667
+ // его не нужно (лишний сброс способен прервать IME-набор)
668
+ preserveCaret(editable, () => {
669
+ const before = editable.innerHTML;
670
+ ensureParagraphs(editable); // блуждающий текст/div → <p>
379
671
 
380
- if (caret && selection && editable.innerHTML !== before)
381
- restoreSelection(editable, caret[0], caret[1], selection);
672
+ return editable.innerHTML !== before;
673
+ });
382
674
 
383
675
  // единственный пустой абзац → очищаем, чтобы показать placeholder
384
676
  if (editable.children.length === 1) {
@@ -388,7 +680,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
388
680
  } else if (editable.firstChild?.nodeName === "BR") {
389
681
  editable.innerHTML = "";
390
682
  }
391
- this.__emitChange();
683
+ this.__emitChange(true); // печать — единственный посимвольный источник, его и откладываем
392
684
  },
393
685
  { signal }
394
686
  );
@@ -414,12 +706,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
414
706
  const z = isHotkeyLetter(e, "z");
415
707
  if (z && !e.shiftKey) {
416
708
  e.preventDefault();
417
- this.__undo();
709
+ this.undo();
418
710
  return;
419
711
  }
420
712
  if (isHotkeyLetter(e, "y") || (z && e.shiftKey)) {
421
713
  e.preventDefault();
422
- this.__redo();
714
+ this.redo();
423
715
  return;
424
716
  }
425
717
  }
@@ -450,9 +742,18 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
450
742
  return;
451
743
  }
452
744
 
453
- // Shift/Ctrl/Cmd+Enter мягкий перенос (<br>) внутри абзаца; Enter новый абзац (<p>)
745
+ // Абзацы и переносы правятся вручную, мимо beforeinput, поэтому запрет на изменение
746
+ // текста проверяем здесь: иначе Enter добавлял бы строки и в режиме только для чтения.
747
+ if (this.readonly) return;
748
+
749
+ // В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
750
+ // В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
751
+ // набирается двумя переносами.
752
+ const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
753
+ const soft = this.paragraph === "break" ? !withModifier : withModifier;
754
+
454
755
  this.__history?.record("op");
455
- if (e.shiftKey || e.ctrlKey || e.metaKey) insertSoftBreak(this.editable);
756
+ if (soft) insertSoftBreak(this.editable);
456
757
  else insertParagraph(this.editable);
457
758
 
458
759
  this.__clearPendingFormats();
@@ -485,79 +786,55 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
485
786
  if (filtered !== plain) plainOverride = filtered;
486
787
  }
487
788
 
488
- const selection = window.getSelection();
489
- if (!selection || selection.rangeCount === 0) return;
789
+ // вставлять только в своё содержимое: выделение вне редактора нам не адресовано
790
+ const selection = this.selection;
791
+ if (!selection) return;
490
792
 
491
793
  if (html && plainOverride == null && this.__pasteHtml(html, selection)) return;
492
794
  this.__pastePlain(plainOverride ?? plain, selection);
493
795
  }
494
796
 
495
- // Простая вставка текста: переносы строкмягкие <br> (multiline) или пробелы (single-line).
797
+ // Простая вставка текста: в multilineта же модель абзацев и мягких переносов, что и при
798
+ // вставке форматированного (в режиме block пустая строка разделяет абзацы, в break все переносы
799
+ // мягкие); в single-line — одна строка через пробелы.
496
800
  private __pastePlain(text: string, selection: Selection) {
497
801
  if (!text) return;
498
802
 
499
- const lines = text.split(/\n/);
500
- 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()));
501
804
 
502
- const fragment = document.createDocumentFragment();
503
- if (!this.multiline) {
504
- fragment.appendChild(document.createTextNode(output.join(" ")));
505
- } else {
506
- output.forEach((line, index) => {
507
- if (index > 0) fragment.appendChild(document.createElement("br"));
508
- fragment.appendChild(document.createTextNode(line));
509
- });
510
- }
511
-
512
- const range = selection.getRangeAt(0);
513
- this.__history?.record("op");
514
- range.deleteContents();
515
- range.insertNode(fragment);
516
- selection.setPosition(selection.focusNode, selection.focusOffset);
517
-
518
- this.__emitChange();
805
+ this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs), selection);
519
806
  }
520
807
 
521
- // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
522
- // (тогда вызывающий откатывается на простую вставку). Санитизация до включённых инструментов;
523
- // multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line — инлайн с пробелами.
808
+ // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
809
+ // тогда вызывающий откатывается на простую вставку.
524
810
  private __pasteHtml(html: string, selection: Selection): boolean {
525
- // убираем мусорные элементы (Word/браузер: стили, скрипты, заголовок документа)
526
- const source = document.createElement("template");
527
- source.innerHTML = html;
528
- source.content
529
- .querySelectorAll("script, style, head, meta, link, title, noscript")
530
- .forEach((el) => el.remove());
531
-
532
- // единый источник санитизации — deserialize (теги-синонимы → канонические, лишнее развёрнуто)
533
- const clean = deserialize(source.innerHTML, "html", this.formatTools, this.formatMarkers, true);
534
- const holder = document.createElement("template");
535
- holder.innerHTML = clean;
536
-
537
- // внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
538
- // иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
539
- const textWalker = document.createTreeWalker(holder.content, NodeFilter.SHOW_TEXT);
540
- for (let t = textWalker.nextNode(); t; t = textWalker.nextNode())
541
- t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
542
-
543
- const paras = Array.from(holder.content.children) as HTMLElement[];
544
- for (const p of paras) trimParagraphEdges(p);
545
-
546
- // отбрасываем пустые краевые абзацы (ведущие/хвостовые \n и <br>-обёртки из буфера),
547
- // иначе перед и после вставленного текста появляются пустые строки
548
- while (paras.length && (paras[0].textContent ?? "").trim() === "") paras.shift();
549
- 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 {
550
823
  if (!paras.length) return false;
551
824
 
552
825
  const range = selection.getRangeAt(0);
553
826
  this.__history?.record("op");
554
827
  range.deleteContents();
555
828
 
556
- // каретку ставим по абсолютному текстовому смещению (длина вставки), не отслеживая узлы
557
829
  const start = selectionCharBounds(this.editable, range)[0];
558
- let caretOffset: number;
830
+ let caret: number;
559
831
 
560
- 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 {
561
838
  // инлайн: абзацы и переносы → пробелы, форматирование сохраняем
562
839
  const fragment = document.createDocumentFragment();
563
840
  paras.forEach((p, index) => {
@@ -565,16 +842,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
565
842
  while (p.firstChild) fragment.appendChild(p.firstChild);
566
843
  });
567
844
  fragment.querySelectorAll("br").forEach((br) => br.replaceWith(document.createTextNode(" ")));
568
- caretOffset = start + (fragment.textContent ?? "").length;
845
+
846
+ caret = start + (fragment.textContent ?? "").length;
569
847
  range.insertNode(fragment);
570
- } else {
571
- caretOffset = start + paras.map((p) => p.textContent ?? "").join("").length;
572
- insertPastedParagraphs(this.editable, paras, range);
573
- ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
574
848
  }
575
849
 
576
- restoreSelection(this.editable, caretOffset, caretOffset, selection);
850
+ restoreSelection(this.editable, caret, caret, selection);
577
851
  this.__emitChange();
852
+
578
853
  return true;
579
854
  }
580
855
 
@@ -597,11 +872,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
597
872
  return;
598
873
  }
599
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
+
600
887
  // режим набора: оборачиваем вводимый текст в ожидающие форматы
601
888
  if (this.__pendingFormats.size > 0 && e.inputType === "insertText" && e.data != null) {
602
889
  e.preventDefault();
603
890
  this.__history?.record("op");
604
- this.__insertPendingText(e.data);
891
+ this.__insertText(e.data);
605
892
  return;
606
893
  }
607
894
 
@@ -615,9 +902,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
615
902
  formatToolbar.refresh();
616
903
  }
617
904
 
618
- private __insertPendingText(data: string) {
619
- const selection = window.getSelection();
620
- if (!selection || !this.editable.contains(selection.anchorNode)) return;
905
+ private __insertText(data: string) {
906
+ const selection = this.selection;
907
+ if (!selection) return;
621
908
 
622
909
  insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);
623
910