@brandup/ui-richeditor 1.0.41 → 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.
@@ -2,17 +2,77 @@
2
2
  // верхнего уровня к абзацам <p> (модель многострочного режима).
3
3
 
4
4
  import { cleanupFormatting } from "./selection";
5
+ import { BLOCK_TYPES, DEFAULT_BLOCK, blockTypeOfTag, type BlockType } from "./format-config";
5
6
 
6
7
  /**
7
- * Что в содержимом редактора считается абзацем. Единственное определение модели абзацев:
8
- * по нему идут и разбор с сериализацией, и правки каретки, и нормализация. `<div>` признаём
9
- * наравне с `<p>` — его приносят вставка и чужой contenteditable, а нормализация приводит к `<p>`.
8
+ * Что в содержимом редактора считается блоком. Единственное определение модели: по нему идут
9
+ * и разбор с сериализацией, и правки каретки, и нормализация. `<div>` признаём наравне с `<p>` —
10
+ * его приносят вставка и чужой contenteditable, а нормализация приводит к `<p>`.
10
11
  */
11
12
  export function isBlock(node: Node): boolean {
12
- if (node.nodeType !== Node.ELEMENT_NODE) return false;
13
+ return blockTypeOf(node) !== null;
14
+ }
15
+
16
+ /** Тип блока по элементу; null — не блок верхнего уровня. */
17
+ export function blockTypeOf(node: Node | null | undefined): BlockType | null {
18
+ if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
19
+
20
+ return blockTypeOfTag((node as Element).tagName);
21
+ }
22
+
23
+ /** Блок, в котором лежит узел (обычно якорь выделения), в пределах редактора. */
24
+ export function blockAt(root: HTMLElement, node: Node | null | undefined): HTMLElement | null {
25
+ let current: Node | null = node ?? null;
26
+ while (current && current !== root) {
27
+ if (current.parentNode === root && isBlock(current)) return current as HTMLElement;
28
+ current = current.parentNode;
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Блоки, которых касается диапазон, по порядку. Границы берём по их блокам, а не перебором
36
+ * с проверкой пересечения: свёрнутая каретка на стыке пересекает сразу два соседних блока,
37
+ * и правка уходила бы в лишний.
38
+ */
39
+ export function blocksInRange(root: HTMLElement, range: Range): HTMLElement[] {
40
+ // Контейнером может быть и сам редактор — тогда позиция адресует ребёнка по номеру.
41
+ // Позиция — это место ПЕРЕД ребёнком, поэтому конец выделения относится к предыдущему
42
+ // блоку: иначе правка захватывала бы блок, из которого не выделено ни символа.
43
+ const edge = (container: Node, offset: number, end: boolean): HTMLElement | null => {
44
+ const own = blockAt(root, container);
45
+ if (own) return own;
46
+ if (container !== root) return null;
47
+
48
+ const index = end && offset > 0 ? offset - 1 : offset;
49
+ const child = root.childNodes[index] ?? root.lastChild;
50
+
51
+ return blockAt(root, child) ?? (isBlock(child as Node) ? (child as HTMLElement) : null);
52
+ };
53
+
54
+ const first = edge(range.startContainer, range.startOffset, false);
55
+ if (!first) return [];
56
+
57
+ const last = edge(range.endContainer, range.endOffset, !range.collapsed) ?? first;
58
+
59
+ const blocks: HTMLElement[] = [];
60
+ for (let el: HTMLElement | null = first; el; el = el.nextElementSibling as HTMLElement | null) {
61
+ if (isBlock(el)) blocks.push(el);
62
+ if (el === last) return blocks;
63
+ }
64
+
65
+ // Конца не встретили — он лежит раньше начала (диапазон собран вручную и вывернут).
66
+ // Берём один блок: пройтись до конца содержимого значило бы править всё подряд.
67
+ return [first];
68
+ }
69
+
70
+ /** Пустой блок нужного типа с заполнителем: без него строка не видна и в неё не встать кареткой. */
71
+ export function createBlock(type: BlockType): HTMLElement {
72
+ const block = document.createElement(BLOCK_TYPES[type].tag);
73
+ block.appendChild(document.createElement("br"));
13
74
 
14
- const tag = (node as Element).tagName;
15
- return tag === "P" || tag === "DIV";
75
+ return block;
16
76
  }
17
77
 
18
78
  // Пробел, таб и неразрывный пробел (U+00A0) — всё это «пробел» при наборе; см. normalizeWhitespace.
@@ -48,8 +108,10 @@ export function normalizeWhitespace(root: HTMLElement) {
48
108
  if (el.tagName === "BR") {
49
109
  items.push({ kind: "break" });
50
110
  } else if (isBlock(el)) {
111
+ // у блока без инлайновой разметки (код) пробелы значимы: отступы там — часть текста
112
+ const type = blockTypeOf(el);
51
113
  items.push({ kind: "break" });
52
- flatten(el);
114
+ if (!type || BLOCK_TYPES[type].inline) flatten(el);
53
115
  items.push({ kind: "break" });
54
116
  } else {
55
117
  flatten(el); // инлайновый тег — не разрывает строку
@@ -94,7 +156,9 @@ export function normalizeWhitespace(root: HTMLElement) {
94
156
  */
95
157
  export function normalizeParagraphs(root: HTMLElement) {
96
158
  for (const el of Array.from(root.children)) {
97
- if (el.tagName === "P" && (el.textContent ?? "").trim() === "") el.remove();
159
+ // Пустой блок другого типа не трогаем: его завели осознанно и в него сейчас будут писать,
160
+ // а пустая строка внутри кода вообще осмысленна сама по себе.
161
+ if (blockTypeOf(el) === DEFAULT_BLOCK && (el.textContent ?? "").trim() === "") el.remove();
98
162
  }
99
163
  }
100
164
 
@@ -107,9 +171,9 @@ export function ensureParagraphs(root: HTMLElement) {
107
171
 
108
172
  const flushRun = (before: Node | null) => {
109
173
  if (!run.length) return;
110
- const p = document.createElement("p");
111
- for (const node of run) p.appendChild(node);
112
- root.insertBefore(p, before);
174
+ const block = document.createElement(BLOCK_TYPES[DEFAULT_BLOCK].tag);
175
+ for (const node of run) block.appendChild(node);
176
+ root.insertBefore(block, before);
113
177
  run = [];
114
178
  };
115
179
 
@@ -118,8 +182,10 @@ export function ensureParagraphs(root: HTMLElement) {
118
182
 
119
183
  if (el && isBlock(el)) {
120
184
  flushRun(node);
185
+ // `<div>` — тот же абзац, только чужой: приводим к каноническому тегу.
186
+ // Блоки других типов оставляем как есть, их завели осознанно.
121
187
  if (el.tagName === "DIV") {
122
- const p = document.createElement("p");
188
+ const p = document.createElement(BLOCK_TYPES[DEFAULT_BLOCK].tag);
123
189
  while (el.firstChild) p.appendChild(el.firstChild);
124
190
  root.replaceChild(p, el);
125
191
  }
@@ -129,7 +195,7 @@ export function ensureParagraphs(root: HTMLElement) {
129
195
  }
130
196
  flushRun(null);
131
197
 
132
- for (const p of Array.from(root.querySelectorAll("p"))) {
198
+ for (const p of Array.from(root.children) as HTMLElement[]) {
133
199
  if (!p.firstChild) {
134
200
  p.appendChild(document.createElement("br")); // пустой абзац — заполнитель для видимости строки
135
201
  continue;
@@ -25,6 +25,36 @@
25
25
  }
26
26
  }
27
27
 
28
+ // Цитата: линия слева, как её рисуют мессенджеры. Вертикальные поля те же, что у абзаца, —
29
+ // строка не должна прыгать при смене типа блока.
30
+ & blockquote {
31
+ margin: 0;
32
+ padding: 5px 0 5px 10px;
33
+ border-left: 3px solid var(--richeditor-quote-line, rgba(0, 0, 0, 0.2));
34
+ }
35
+
36
+ // Блок кода: подложка на всю строку и моноширинный шрифт. Собственные поля тут нужны —
37
+ // иначе текст упирался бы в край подложки; краевые правила абзаца на него не распространяются.
38
+ & pre {
39
+ margin: 5px 0;
40
+ padding: 5px 8px;
41
+ border-radius: 3px;
42
+ background-color: var(--richeditor-code-fill, rgba(0, 0, 0, 0.06));
43
+ font-family: var(--richeditor-code-font, ui-monospace, "Cascadia Mono", Consolas, monospace);
44
+ font-size: 0.92em;
45
+ // содержимое переносится по границе редактора, как и обычный текст
46
+ white-space: pre-wrap;
47
+ word-wrap: anywhere;
48
+
49
+ &:first-child {
50
+ margin-top: 0;
51
+ }
52
+
53
+ &:last-child {
54
+ margin-bottom: 0;
55
+ }
56
+ }
57
+
28
58
  // видимые теги форматирования
29
59
  & b,
30
60
  & strong {
@@ -44,6 +74,29 @@
44
74
  text-decoration: underline;
45
75
  }
46
76
 
77
+ // Спойлер: у получателя текст закрыт до нажатия, а в поле его прячут не от автора —
78
+ // показываем плашкой, чтобы было видно и что написано, и что оно будет скрыто.
79
+ & spoiler,
80
+ & tg-spoiler {
81
+ border-radius: 3px;
82
+ padding: 0 2px;
83
+ background-color: var(--richeditor-spoiler-fill, rgba(0, 0, 0, 0.14));
84
+ }
85
+
86
+ // Строку не раздвигает. Наследовать межстрочный интервал для этого мало: у моноширинного
87
+ // шрифта своя база, и коробка той же высоты садится относительно неё со сдвигом — строка
88
+ // вырастает на пару пикселей. Нулевой интервал убирает вклад кода в высоту строки совсем,
89
+ // а высоту строки задаёт сам абзац. Подложка от этого не страдает: она рисуется по буквам,
90
+ // а не по интервалу. По вертикали отступов у кода нет.
91
+ & code {
92
+ border-radius: 3px;
93
+ padding: 0 3px;
94
+ background-color: var(--richeditor-code-fill, rgba(0, 0, 0, 0.06));
95
+ font-family: var(--richeditor-code-font, ui-monospace, "Cascadia Mono", Consolas, monospace);
96
+ font-size: 0.92em;
97
+ line-height: 0;
98
+ }
99
+
47
100
  &:empty:after {
48
101
  content: attr(data-placeholder);
49
102
  display: block;
@@ -122,6 +175,7 @@
122
175
  }
123
176
 
124
177
  & .format-button,
178
+ & .block-button,
125
179
  & .host-button,
126
180
  & .action-button {
127
181
  width: calc(var(--input-height, 46px) - 12px);
@@ -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,
@@ -53,6 +64,9 @@ export const CHANGE_EVENT = "richeditor-change";
53
64
 
54
65
  const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Escape"];
55
66
 
67
+ // Код — единственное, что есть и инструментом, и типом блока: имя одно на оба (см. applyCode).
68
+ const CODE = "code";
69
+
56
70
  // нативные правки (печать/удаление), состояние до которых запоминаем для собственного undo;
57
71
  // вставка/перетаскивание и Enter обрабатываются отдельно, undo/redo — на keydown
58
72
  const NATIVE_EDIT_TYPES = new Set([
@@ -104,6 +118,11 @@ export interface RichEditorOptions {
104
118
  multiline?: boolean;
105
119
  /** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
106
120
  paragraph?: ParagraphMode;
121
+ /**
122
+ * Типы блоков в многострочном режиме: цитата, блок кода (по умолчанию только обычный текст).
123
+ * Обычный текст в наборе есть всегда — в него блок возвращают.
124
+ */
125
+ blocks?: BlockType[];
107
126
  /** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
108
127
  readonly?: boolean;
109
128
  /** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
@@ -142,6 +161,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
142
161
  readonly formatMarkers: FormatMarkers;
143
162
  readonly multiline: boolean;
144
163
  readonly paragraph: ParagraphMode;
164
+ readonly blockTypes: BlockType[];
145
165
  readonly toolbarContainer: HTMLElement | null;
146
166
  readonly toolbarButtons: ToolbarButton[];
147
167
 
@@ -186,6 +206,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
186
206
  this.formatMarkers = Object.assign(defaultFormatMarkers(), options.markers);
187
207
  this.multiline = multiline;
188
208
  this.paragraph = options.paragraph ?? "block";
209
+ // Блоки — часть многострочной модели: в однострочном режиме верхнего уровня нет вовсе,
210
+ // а в readonly их не переключить, но разбор и показ значения обязаны работать и там.
211
+ this.blockTypes = multiline ? normalizeBlockTypes(options.blocks) : [DEFAULT_BLOCK];
189
212
  this.toolbarContainer = options.toolbarContainer ?? null;
190
213
  // кнопки хоста живут и без форматирования, но не в readonly — там панели нет вовсе
191
214
  this.toolbarButtons = readonly ? [] : (options.buttons ?? []);
@@ -232,7 +255,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
232
255
 
233
256
  getValue(): string {
234
257
  // multiline → модель абзацев (<p>/\n\n) и мягких переносов (<br>/\n)
235
- 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
+ );
236
267
  }
237
268
 
238
269
  setValue(value: string): void {
@@ -415,11 +446,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
415
446
  };
416
447
  }
417
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
+
418
461
  /** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
419
462
  applyFormat(tool: FormatTool): void {
420
- if (!this.formatTools.includes(tool)) return;
463
+ if (!this.isToolEnabled(tool)) return;
421
464
 
422
- const target = this.__formatTarget();
465
+ let target = this.__formatTarget();
423
466
  if (!target) return;
424
467
 
425
468
  if (target.range.collapsed) {
@@ -434,12 +477,126 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
434
477
  this.__pendingFormats.clear();
435
478
 
436
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
+
437
496
  toggleFormat(this.editable, target.range, tool, target.selection, target.original());
438
497
 
439
498
  this.__emitChange();
440
499
  formatToolbar.refresh();
441
500
  }
442
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
+
443
600
  /**
444
601
  * Снимает всё форматирование с выделения; без выделения — со слова под кареткой
445
602
  * (как и применение формата). Режим набора сбрасывается.
@@ -593,20 +750,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
593
750
  DOM.empty(this.editable);
594
751
  if (!value) return;
595
752
 
596
- // multiline → <p>-абзацы; single-line → инлайновое содержимое
597
- 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);
598
757
 
599
758
  this.editable.innerHTML = deserialize(
600
759
  value,
601
760
  this.__valueStorage,
602
761
  this.__valueTools,
603
762
  this.formatMarkers,
604
- paragraphs
763
+ paragraphs,
764
+ this.blockTypes,
765
+ this.__blockParagraphs
605
766
  );
606
767
 
607
768
  // в break инлайновое содержимое оборачивается в единственный абзац — модель абзацев
608
769
  // нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
609
- if (this.multiline && !paragraphs) ensureParagraphs(this.editable);
770
+ if (this.multiline && !this.__blockParagraphs) ensureParagraphs(this.editable);
610
771
  }
611
772
 
612
773
  /**
@@ -659,14 +820,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
659
820
  const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
660
821
 
661
822
  const before = this.editable.innerHTML;
662
- const textBefore = this.editable.textContent ?? "";
823
+ // текст в координатах каретки (с концами строк) — иначе смещения разъедутся на число строк
824
+ const textBefore = editorText(this.editable);
663
825
  normalizeWhitespace(this.editable);
664
826
  if (this.multiline) normalizeParagraphs(this.editable);
665
827
  if (this.editable.innerHTML === before) return;
666
828
 
667
829
  if (bounds && selection) {
668
830
  // схлопнутые пробелы сдвигают смещения — переносим их на новый текст
669
- const textAfter = this.editable.textContent ?? "";
831
+ const textAfter = editorText(this.editable);
670
832
  const start = mapCharOffset(textBefore, textAfter, bounds[0]);
671
833
  const end = bounds[1] === bounds[0] ? start : mapCharOffset(textBefore, textAfter, bounds[1]);
672
834
  restoreSelection(this.editable, start, end, selection);
@@ -886,16 +1048,37 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
886
1048
 
887
1049
  // В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
888
1050
  // В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
889
- // набирается двумя переносами.
1051
+ // набирается двумя переносами. Внутри блока правило берётся у его типа: в цитате
1052
+ // и в коде Enter переносит строку, иначе одну цитату было бы не набрать.
890
1053
  const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
891
- 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;
892
1058
 
893
1059
  this.__history?.record("op");
1060
+ // Из блока выходят тем же нажатием, что делит его: продолжать цитату или код
1061
+ // новым таким же блоком незачем — для этого достаточно переноса строки.
894
1062
  if (soft) insertSoftBreak(this.editable);
895
- else insertParagraph(this.editable);
1063
+ else insertParagraph(this.editable, DEFAULT_BLOCK);
896
1064
 
897
1065
  this.__clearPendingFormats();
898
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
+ }
899
1082
  }
900
1083
  }
901
1084
 
@@ -946,7 +1129,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
946
1129
  // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего —
947
1130
  // тогда вызывающий откатывается на простую вставку.
948
1131
  private __pasteHtml(html: string, selection: Selection): boolean {
949
- 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
+ );
950
1136
  }
951
1137
 
952
1138
  /**
@@ -968,7 +1154,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
968
1154
  let caret: number;
969
1155
 
970
1156
  if (this.multiline) {
971
- 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);
972
1160
 
973
1161
  insertPastedParagraphs(this.editable, paras, range);
974
1162
  ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>