@brandup/ui-richeditor 1.0.40 → 1.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,13 @@ import "./richeditor.less"; // стили редактора и панели ф
3
3
  import { DOM, UIElementBound } from "@brandup/ui";
4
4
  import {
5
5
  ALL_FORMAT_TOOLS,
6
+ BLOCK_TYPES,
7
+ DEFAULT_BLOCK,
6
8
  HOTKEY_TOOLS,
7
9
  activeFormats,
10
+ blockAt,
11
+ blockTypeOf,
12
+ blocksInRange,
8
13
  clearAllFormat,
9
14
  clearFormat,
10
15
  defaultFormatMarkers,
@@ -17,6 +22,9 @@ import {
17
22
  documentSelection,
18
23
  innerSelection,
19
24
  mapCharOffset,
25
+ normalizeBlockTypes,
26
+ editorText,
27
+ charLength,
20
28
  preserveCaret,
21
29
  normalizeParagraphs,
22
30
  normalizeWhitespace,
@@ -24,6 +32,7 @@ import {
24
32
  selectionCharBounds,
25
33
  serialize,
26
34
  toggleFormat,
35
+ type BlockType,
27
36
  type EditorAction,
28
37
  type FormatMarkers,
29
38
  type FormatStorage,
@@ -31,6 +40,8 @@ import {
31
40
  type FormatTool,
32
41
  } from "./format";
33
42
  import {
43
+ applyBlocks,
44
+ atBlockStart,
34
45
  buildParagraphs,
35
46
  caretToEnd,
36
47
  expandRangeToWords,
@@ -47,10 +58,15 @@ import { formatToolbar, type ToolbarButton } from "./toolbar";
47
58
  export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost, type ToolbarButton } from "./toolbar";
48
59
 
49
60
  export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
61
+ // Содержимое временно невыделяемо: по странице тянут выделение, начатое вне редактора (см. __holdSelectable).
62
+ export const UNSELECTABLE_CLASS = "unselectable";
50
63
  export const CHANGE_EVENT = "richeditor-change";
51
64
 
52
65
  const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Escape"];
53
66
 
67
+ // Код — единственное, что есть и инструментом, и типом блока: имя одно на оба (см. applyCode).
68
+ const CODE = "code";
69
+
54
70
  // нативные правки (печать/удаление), состояние до которых запоминаем для собственного undo;
55
71
  // вставка/перетаскивание и Enter обрабатываются отдельно, undo/redo — на keydown
56
72
  const NATIVE_EDIT_TYPES = new Set([
@@ -102,6 +118,11 @@ export interface RichEditorOptions {
102
118
  multiline?: boolean;
103
119
  /** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
104
120
  paragraph?: ParagraphMode;
121
+ /**
122
+ * Типы блоков в многострочном режиме: цитата, блок кода (по умолчанию только обычный текст).
123
+ * Обычный текст в наборе есть всегда — в него блок возвращают.
124
+ */
125
+ blocks?: BlockType[];
105
126
  /** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
106
127
  readonly?: boolean;
107
128
  /** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
@@ -140,6 +161,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
140
161
  readonly formatMarkers: FormatMarkers;
141
162
  readonly multiline: boolean;
142
163
  readonly paragraph: ParagraphMode;
164
+ readonly blockTypes: BlockType[];
143
165
  readonly toolbarContainer: HTMLElement | null;
144
166
  readonly toolbarButtons: ToolbarButton[];
145
167
 
@@ -147,6 +169,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
147
169
  private __abort = new AbortController();
148
170
  private __pendingFormats = new Set<FormatTool>();
149
171
  private __hasInputClick = false;
172
+ private __editHolds = 0; // правка продолжается в окне хоста — см. holdEditing
173
+ // Компонент снят. Удержание правки переживает снятие (окно хоста закрывается позже), и по его
174
+ // снятию трогать содержимое уже нельзя — редактора нет.
175
+ private __disposed = false;
150
176
  private __changeTimer = 0; // отложенное change по печати — см. __emitChange/flushChange
151
177
  // Окно редактируемого элемента, взятое при создании. Таймер отложенного change переживает
152
178
  // снятие компонента и гасится в destroy, а тот случается когда угодно — к этому моменту
@@ -180,6 +206,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
180
206
  this.formatMarkers = Object.assign(defaultFormatMarkers(), options.markers);
181
207
  this.multiline = multiline;
182
208
  this.paragraph = options.paragraph ?? "block";
209
+ // Блоки — часть многострочной модели: в однострочном режиме верхнего уровня нет вовсе,
210
+ // а в readonly их не переключить, но разбор и показ значения обязаны работать и там.
211
+ this.blockTypes = multiline ? normalizeBlockTypes(options.blocks) : [DEFAULT_BLOCK];
183
212
  this.toolbarContainer = options.toolbarContainer ?? null;
184
213
  // кнопки хоста живут и без форматирования, но не в readonly — там панели нет вовсе
185
214
  this.toolbarButtons = readonly ? [] : (options.buttons ?? []);
@@ -190,7 +219,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
190
219
  // при этом остаётся возможность фокуса, выделения и копирования
191
220
  editable.contentEditable = "true";
192
221
 
193
- if (options.placeholder != null) editable.setAttribute("data-placeholder", options.placeholder);
222
+ if (options.placeholder != null) editable.dataset.placeholder = options.placeholder;
194
223
  if (multiline) editable.classList.add("multiline");
195
224
  if (readonly) editable.classList.add("readonly");
196
225
 
@@ -226,7 +255,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
226
255
 
227
256
  getValue(): string {
228
257
  // multiline → модель абзацев (<p>/\n\n) и мягких переносов (<br>/\n)
229
- return serialize(this.editable, this.__valueStorage, this.__valueTools, this.formatMarkers, this.multiline);
258
+ return serialize(
259
+ this.editable,
260
+ this.__valueStorage,
261
+ this.__valueTools,
262
+ this.formatMarkers,
263
+ this.multiline,
264
+ this.blockTypes,
265
+ this.__blockParagraphs
266
+ );
230
267
  }
231
268
 
232
269
  setValue(value: string): void {
@@ -296,8 +333,65 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
296
333
  return this.editable.textContent?.length ?? 0;
297
334
  }
298
335
 
336
+ /**
337
+ * Фокус в поле. Каретку, стоящую в содержимом, не двигает: правку продолжают там, где её
338
+ * прервали. Своей защиты обработчика фокуса для этого мало — браузер успевает поставить
339
+ * при фокусе собственную каретку (в начало содержимого), и она выглядит как «уже стоявшая».
340
+ */
299
341
  focus(): void {
342
+ const bounds = this.caretSnapshot();
343
+
344
+ if (bounds) this.restoreCaret(bounds);
345
+ else this.editable.focus();
346
+ }
347
+
348
+ /**
349
+ * Снимок каретки текстовыми смещениями — пережить работу окна хоста. Пока оно открыто, фокус
350
+ * уходит туда, а с ним и выделение документа: в окне могут быть свои поля ввода, и каретка
351
+ * встанет уже в них. Снимать нужно до открытия, возвращать — через {@link restoreCaret}.
352
+ *
353
+ * Смещения остаются верными, потому что на это время содержимое не меняется (см. {@link holdEditing}).
354
+ */
355
+ caretSnapshot(): [number, number] | null {
356
+ const selection = this.selection;
357
+
358
+ return selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
359
+ }
360
+
361
+ /** Возвращает в поле фокус и каретку по снимку {@link caretSnapshot}. */
362
+ restoreCaret(bounds: [number, number]): void {
300
363
  this.editable.focus();
364
+
365
+ const selection = documentSelection(this.editable);
366
+ if (selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
367
+ }
368
+
369
+ /**
370
+ * Сообщает, что правка продолжается снаружи: хост открыл своё окно, фокус ушёл туда, но ввод
371
+ * не закончен. Пока удержание живо, `blur` не считается концом ввода и содержимое не приводится
372
+ * к нормальному виду — иначе пробел на границе каретки был бы срезан как краевой, а сама каретка
373
+ * съехала бы на пересчёте смещений, и текст из окна встал бы не туда и вплотную к соседнему слову.
374
+ *
375
+ * Возвращает снятие удержания: повторные вызовы ничего не делают. Если к этому моменту фокус
376
+ * в поле не вернулся, содержимое доводится сразу — другого `blur` уже не будет.
377
+ */
378
+ holdEditing(): () => void {
379
+ this.__editHolds++;
380
+
381
+ let released = false;
382
+
383
+ return () => {
384
+ if (released) return;
385
+ released = true;
386
+
387
+ this.__editHolds--;
388
+ if (this.__editHolds > 0 || this.__disposed) return;
389
+
390
+ if (this.editable.ownerDocument.activeElement !== this.editable) {
391
+ this.__normalize(true);
392
+ this.flushChange();
393
+ }
394
+ };
301
395
  }
302
396
 
303
397
  onChange(handler: (e: RichEditorChangeData) => void) {
@@ -352,11 +446,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
352
446
  };
353
447
  }
354
448
 
449
+ /**
450
+ * Доступен ли инструмент в текущем месте. В коде разметки нет: и моноширинный, и блок кода
451
+ * оставляют написанное буквальным, поэтому остальные инструменты там выключены — иначе
452
+ * форматирование ставилось бы только чтобы пропасть при сохранении.
453
+ */
454
+ isToolEnabled(tool: FormatTool): boolean {
455
+ if (this.readonly || !this.formatTools.includes(tool)) return false;
456
+ if (tool === CODE) return true;
457
+
458
+ return this.currentBlock !== CODE && !this.isToolActive(CODE);
459
+ }
460
+
355
461
  /** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
356
462
  applyFormat(tool: FormatTool): void {
357
- if (!this.formatTools.includes(tool)) return;
463
+ if (!this.isToolEnabled(tool)) return;
358
464
 
359
- const target = this.__formatTarget();
465
+ let target = this.__formatTarget();
360
466
  if (!target) return;
361
467
 
362
468
  if (target.range.collapsed) {
@@ -371,12 +477,126 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
371
477
  this.__pendingFormats.clear();
372
478
 
373
479
  this.__history?.record("op");
480
+
481
+ // Код не уживается с другой разметкой: значение берёт из него голый текст. Снимаем всё,
482
+ // что было на этом куске, и только потом оборачиваем — иначе прежнее форматирование
483
+ // осталось бы снаружи кода и в поле выглядело бы рабочим.
484
+ if (
485
+ tool === CODE &&
486
+ !isFormatActive(this.editable, target.range, CODE) &&
487
+ hasFormatting(this.editable, target.range)
488
+ ) {
489
+ clearFormat(this.editable, target.range, target.selection, target.original());
490
+
491
+ // правка перестроила узлы — прежний диапазон указывает в никуда
492
+ target = this.__formatTarget();
493
+ if (!target) return;
494
+ }
495
+
374
496
  toggleFormat(this.editable, target.range, tool, target.selection, target.original());
375
497
 
376
498
  this.__emitChange();
377
499
  formatToolbar.refresh();
378
500
  }
379
501
 
502
+ /**
503
+ * Тип блока под кареткой. Без выделения (или вне блоков) — тип по умолчанию: именно им
504
+ * станет то, что сейчас наберут.
505
+ */
506
+ get currentBlock(): BlockType {
507
+ const selection = this.selection;
508
+ if (!selection) return DEFAULT_BLOCK;
509
+
510
+ const block = blockAt(this.editable, selection.getRangeAt(0).startContainer);
511
+
512
+ return blockTypeOf(block) ?? DEFAULT_BLOCK;
513
+ }
514
+
515
+ /**
516
+ * Переключить тип блоков под выделением. Повторное применение того же типа возвращает
517
+ * блок к обычному тексту — иначе выйти из цитаты можно было бы только через другую кнопку.
518
+ */
519
+ applyBlock(type: BlockType): void {
520
+ if (!this.multiline || this.readonly || !this.blockTypes.includes(type)) return;
521
+
522
+ const selection = this.selection;
523
+ if (!selection) return;
524
+
525
+ const range = selection.getRangeAt(0);
526
+ const target = this.currentBlock === type ? DEFAULT_BLOCK : type;
527
+
528
+ // нечего менять — не пишем в историю пустой шаг и не трогаем выделение.
529
+ // В пустом редакторе блока ещё нет, но завести его можно (кроме типа по умолчанию —
530
+ // им пустое поле и так является).
531
+ const blocks = blocksInRange(this.editable, range);
532
+ const nothing = this.editable.firstChild
533
+ ? !blocks.length || blocks.every((block) => blockTypeOf(block) === target)
534
+ : target === DEFAULT_BLOCK;
535
+
536
+ if (nothing) return;
537
+
538
+ this.__history?.record("op");
539
+
540
+ // Смена тега переносит содержимое в новый элемент — живые границы выделения этого
541
+ // не переживают, поэтому держим каретку по текстовым смещениям.
542
+ const bounds = selectionCharBounds(this.editable, range);
543
+ const { created } = applyBlocks(this.editable, range, target);
544
+
545
+ // Разделение блока добавило границы, а они в смещениях считаются — прежние уже не те.
546
+ // Выделяем то, что стало блоком: заодно видно, к каким строкам правка и относилась.
547
+ // В пустом блоке выделять нечего — там только каретка.
548
+ if (created) {
549
+ const inside = this.editable.ownerDocument.createRange();
550
+ inside.selectNodeContents(created);
551
+ if (!(created.textContent ?? "")) inside.collapse(true);
552
+
553
+ selection.removeAllRanges();
554
+ selection.addRange(inside);
555
+ } else {
556
+ restoreSelection(this.editable, bounds[0], bounds[1], selection);
557
+ }
558
+
559
+ this.__emitChange();
560
+ formatToolbar.refresh();
561
+ }
562
+
563
+ /** Код в любом виде: моноширинный на выделении или блок кода под кареткой. */
564
+ isCodeActive(): boolean {
565
+ return this.currentBlock === CODE || this.isToolActive(CODE);
566
+ }
567
+
568
+ /**
569
+ * Единая правка кода — то, что в мессенджерах делает одна кнопка. Вид выбирается по объёму
570
+ * правки: выделена одна строка или её часть — моноширинный, больше одной строки — блок.
571
+ *
572
+ * Блоком становится и правка без выделения: моноширинным там делать нечего, а иначе блок
573
+ * оказался бы недостижим — в пустом поле не выделить строки, которых ещё нет.
574
+ *
575
+ * Повторное применение снимает тот вид, который сейчас и включён.
576
+ */
577
+ applyCode(): void {
578
+ const inline = this.formatTools.includes(CODE);
579
+ const block = this.multiline && this.blockTypes.includes(CODE);
580
+ if (!inline && !block) return;
581
+
582
+ if (block && this.currentBlock === CODE) return this.applyBlock(CODE); // выйти из блока
583
+ if (inline && this.isToolActive(CODE)) return this.applyFormat(CODE); // снять моноширинный
584
+
585
+ const range = this.selection?.getRangeAt(0);
586
+ const part = !!range && !range.collapsed && !this.__coversLines(range);
587
+
588
+ if (inline && (part || !block)) this.applyFormat(CODE);
589
+ else if (block) this.applyBlock(CODE);
590
+ }
591
+
592
+ /**
593
+ * Выделено больше одной строки: захвачено несколько блоков либо мягкий перенос внутри.
594
+ * Одна строка — даже целиком — остаётся куском текста: код на ней это моноширинный.
595
+ */
596
+ private __coversLines(range: Range): boolean {
597
+ return blocksInRange(this.editable, range).length > 1 || !!range.cloneContents().querySelector("br");
598
+ }
599
+
380
600
  /**
381
601
  * Снимает всё форматирование с выделения; без выделения — со слова под кареткой
382
602
  * (как и применение формата). Режим набора сбрасывается.
@@ -511,14 +731,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
511
731
  }
512
732
 
513
733
  override destroy(): void {
734
+ this.__disposed = true;
514
735
  this.flushChange(); // хост не должен остаться с устаревшей копией значения
515
736
  this.__abort.abort();
516
737
  formatToolbar.detach(this);
517
738
 
518
739
  // элемент передан хостом — не удаляем его, только снимаем оформление редактора
519
- this.editable.classList.remove(ROOT_CLASS, "multiline", "readonly", "focused");
740
+ this.editable.classList.remove(ROOT_CLASS, UNSELECTABLE_CLASS, "multiline", "readonly", "focused");
520
741
  this.editable.removeAttribute("contenteditable");
521
- this.editable.removeAttribute("data-placeholder");
742
+ delete this.editable.dataset.placeholder;
522
743
 
523
744
  super.destroy();
524
745
  }
@@ -529,20 +750,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
529
750
  DOM.empty(this.editable);
530
751
  if (!value) return;
531
752
 
532
- // multiline → <p>-абзацы; single-line → инлайновое содержимое
533
- const paragraphs = this.__blockParagraphs;
753
+ // multiline → <p>-абзацы; single-line → инлайновое содержимое. Блоки разбираются и в режиме
754
+ // мягких переносов: цитата и код узнаются по собственной разметке, а не по пустой строке.
755
+ const blocks = this.blockTypes.length > 1;
756
+ const paragraphs = this.__blockParagraphs || (this.multiline && blocks);
534
757
 
535
758
  this.editable.innerHTML = deserialize(
536
759
  value,
537
760
  this.__valueStorage,
538
761
  this.__valueTools,
539
762
  this.formatMarkers,
540
- paragraphs
763
+ paragraphs,
764
+ this.blockTypes,
765
+ this.__blockParagraphs
541
766
  );
542
767
 
543
768
  // в break инлайновое содержимое оборачивается в единственный абзац — модель абзацев
544
769
  // нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
545
- if (this.multiline && !paragraphs) ensureParagraphs(this.editable);
770
+ if (this.multiline && !this.__blockParagraphs) ensureParagraphs(this.editable);
546
771
  }
547
772
 
548
773
  /**
@@ -595,14 +820,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
595
820
  const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
596
821
 
597
822
  const before = this.editable.innerHTML;
598
- const textBefore = this.editable.textContent ?? "";
823
+ // текст в координатах каретки (с концами строк) — иначе смещения разъедутся на число строк
824
+ const textBefore = editorText(this.editable);
599
825
  normalizeWhitespace(this.editable);
600
826
  if (this.multiline) normalizeParagraphs(this.editable);
601
827
  if (this.editable.innerHTML === before) return;
602
828
 
603
829
  if (bounds && selection) {
604
830
  // схлопнутые пробелы сдвигают смещения — переносим их на новый текст
605
- const textAfter = this.editable.textContent ?? "";
831
+ const textAfter = editorText(this.editable);
606
832
  const start = mapCharOffset(textBefore, textAfter, bounds[0]);
607
833
  const end = bounds[1] === bounds[0] ? start : mapCharOffset(textBefore, textAfter, bounds[1]);
608
834
  restoreSelection(this.editable, start, end, selection);
@@ -613,6 +839,47 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
613
839
 
614
840
  // --- обработчики ---
615
841
 
842
+ /**
843
+ * Изоляция выделения: протяжка, начатая вне редактора, не должна затягивать его содержимое —
844
+ * текст сообщения красился бы как часть страницы.
845
+ *
846
+ * Правим не выделение, а выделяемость: пока идёт чужая протяжка, содержимое редактора
847
+ * невыделяемо, и браузер обходит его сам. Обрезать выделение из кода нельзя — оно в документе
848
+ * одно, и когда протяжка проходит мимо редактора, оба её конца снаружи: середину из одного
849
+ * диапазона не вырезать.
850
+ */
851
+ private __holdSelectable(target: EventTarget | null) {
852
+ // Нажали в самом редакторе — или он в работе: нажатие по панели и кнопкам хоста приходит
853
+ // мимо него, но выделение страницы тут ни при чём, а запрет заодно лишил бы поле
854
+ // редактируемости до следующего клика внутрь. Фокус на этот момент ещё не ушёл.
855
+ const own =
856
+ this.editable.contains(target as Node | null) ||
857
+ this.editable.ownerDocument.activeElement === this.editable;
858
+
859
+ this.editable.classList.toggle(UNSELECTABLE_CLASS, !own);
860
+ }
861
+
862
+ /**
863
+ * Снимает запрет, когда выделение перестало задевать редактор.
864
+ *
865
+ * Просто по отпусканию кнопки снимать нельзя: диапазон остаётся протянутым через редактор,
866
+ * и стоит вернуть содержимому выделяемость, как оно тут же оказывается выделенным — будто
867
+ * запрета и не было. Такой запрет держится до следующего нажатия: внутри редактора оно
868
+ * снимет запрет, снаружи — начнёт новую протяжку.
869
+ */
870
+ private __releaseSelectable() {
871
+ if (!this.editable.classList.contains(UNSELECTABLE_CLASS)) return;
872
+
873
+ const selection = documentSelection(this.editable);
874
+ const across =
875
+ !!selection &&
876
+ selection.rangeCount > 0 &&
877
+ !selection.isCollapsed &&
878
+ selection.getRangeAt(0).intersectsNode(this.editable);
879
+
880
+ if (!across) this.editable.classList.remove(UNSELECTABLE_CLASS);
881
+ }
882
+
616
883
  private __initEvents() {
617
884
  const { signal } = this.__abort;
618
885
  const editable = this.editable;
@@ -621,6 +888,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
621
888
  // dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
622
889
  this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
623
890
 
891
+ // Выделение страницы не должно затягивать содержимое редактора — см. __holdSelectable.
892
+ // Слушаем на документе и в фазе перехвата: протяжка начинается где угодно, а обработчик
893
+ // по пути может погасить всплытие.
894
+ const doc = editable.ownerDocument;
895
+ doc.addEventListener("mousedown", (e) => this.__holdSelectable(e.target), { signal, capture: true });
896
+ // По изменению выделения проверять нельзя: пока запрет действует, выделение до редактора
897
+ // не доходит — проверка увидела бы «не задевает» и сняла запрет сама. Только по концу протяжки.
898
+ doc.addEventListener("mouseup", () => this.__releaseSelectable(), { signal, capture: true });
899
+
624
900
  editable.addEventListener(
625
901
  "mousedown",
626
902
  () => {
@@ -635,6 +911,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
635
911
  () => {
636
912
  this.element.classList.add("focused");
637
913
 
914
+ // Пришли править — держать запрет выделения не за чем, а с ним поле осталось бы
915
+ // нередактируемым. Мышью его снимает нажатие, но фокус берут и клавишей, и из кода.
916
+ this.element.classList.remove(UNSELECTABLE_CLASS);
917
+
638
918
  // нужна ли панель этому редактору, решает она сама — иначе условие пришлось бы
639
919
  // держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
640
920
  formatToolbar.attach(this);
@@ -655,6 +935,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
655
935
  this.element.classList.remove("focused");
656
936
  formatToolbar.detach(this);
657
937
 
938
+ // правку продолжают в окне хоста — этот blur не конец ввода, содержимое трогать нельзя
939
+ if (this.__editHolds > 0) {
940
+ this.flushChange();
941
+ return;
942
+ }
943
+
658
944
  // удаляем висящий BR, чтобы появился placeholder
659
945
  if (editable.firstChild?.nodeName === "BR") DOM.empty(editable);
660
946
 
@@ -762,16 +1048,37 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
762
1048
 
763
1049
  // В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
764
1050
  // В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
765
- // набирается двумя переносами.
1051
+ // набирается двумя переносами. Внутри блока правило берётся у его типа: в цитате
1052
+ // и в коде Enter переносит строку, иначе одну цитату было бы не набрать.
766
1053
  const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
767
- const soft = this.paragraph === "break" ? !withModifier : withModifier;
1054
+ const current = this.currentBlock;
1055
+ const breaks =
1056
+ current === DEFAULT_BLOCK ? this.paragraph === "break" : BLOCK_TYPES[current].enter === "break";
1057
+ const soft = breaks ? !withModifier : withModifier;
768
1058
 
769
1059
  this.__history?.record("op");
1060
+ // Из блока выходят тем же нажатием, что делит его: продолжать цитату или код
1061
+ // новым таким же блоком незачем — для этого достаточно переноса строки.
770
1062
  if (soft) insertSoftBreak(this.editable);
771
- else insertParagraph(this.editable);
1063
+ else insertParagraph(this.editable, DEFAULT_BLOCK);
772
1064
 
773
1065
  this.__clearPendingFormats();
774
1066
  this.__emitChange();
1067
+ formatToolbar.refresh();
1068
+ return;
1069
+ }
1070
+
1071
+ // Backspace в начале блока возвращает его к обычному тексту — так из цитаты или кода
1072
+ // выходят там же, где в них вошли, не трогая содержимого. Дальше по тексту Backspace
1073
+ // работает как обычно.
1074
+ if (e.key === "Backspace" && !this.readonly && this.multiline) {
1075
+ const block = this.currentBlock;
1076
+ const selection = block === DEFAULT_BLOCK ? null : this.selection;
1077
+
1078
+ if (selection && atBlockStart(this.editable, selection.getRangeAt(0))) {
1079
+ e.preventDefault();
1080
+ this.applyBlock(block); // тот же тип — переключение вернёт обычный текст
1081
+ }
775
1082
  }
776
1083
  }
777
1084
 
@@ -822,7 +1129,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
822
1129
  // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего —
823
1130
  // тогда вызывающий откатывается на простую вставку.
824
1131
  private __pasteHtml(html: string, selection: Selection): boolean {
825
- return this.__insertPasted(sanitizePastedHtml(html, this.formatTools, this.formatMarkers), selection);
1132
+ return this.__insertPasted(
1133
+ sanitizePastedHtml(html, this.formatTools, this.formatMarkers, this.blockTypes),
1134
+ selection
1135
+ );
826
1136
  }
827
1137
 
828
1138
  /**
@@ -844,7 +1154,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
844
1154
  let caret: number;
845
1155
 
846
1156
  if (this.multiline) {
847
- caret = start + paras.reduce((length, p) => length + (p.textContent ?? "").length, 0);
1157
+ // длина вставки в координатах каретки: текст, переносы внутри абзацев и границы между
1158
+ // ними (первый абзац вливается в текущий, поэтому границ на одну меньше)
1159
+ caret = start + paras.reduce((length, p) => length + charLength(p), paras.length - 1);
848
1160
 
849
1161
  insertPastedParagraphs(this.editable, paras, range);
850
1162
  ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
@@ -920,7 +1232,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
920
1232
  const selection = this.selection;
921
1233
  if (!selection) return;
922
1234
 
923
- insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);
1235
+ // Замена выделенного не должна терять его оформление. Физически вставка часто оказывается
1236
+ // снаружи тега: границы выделения восстанавливаются по текстовым смещениям, а смещение на
1237
+ // стыке узлов достаётся соседнему — тому, что вне тега; сам же тег после удаления текста
1238
+ // остаётся пустым и убирается. Поэтому форматы, покрывавшие выделение целиком, переносим
1239
+ // на вставляемый текст явно. Каретке без выделения это не нужно: там вставка и так попадает
1240
+ // внутрь тега, а на его границе принадлежность неочевидна.
1241
+ const range = selection.getRangeAt(0);
1242
+ const inherited = range.collapsed ? [] : activeFormats(this.editable, range, this.formatTools);
1243
+ const formats = new Set([...this.__pendingFormats, ...inherited]);
1244
+
1245
+ insertFormattedText(this.editable, data, Array.from(formats), selection);
924
1246
 
925
1247
  // В пустом поле абзацев ещё нет, и каретке некуда встать внутрь — вставка из кода
926
1248
  // (смайлик, переменная) ложится прямо в редактор. Приводим к модели абзацев тем же