@brandup/ui-richeditor 1.0.38 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,12 +65,14 @@ editor.onChange(({ value }) => console.log(value));
65
65
  | --- | --- |
66
66
  | `editable` | Редактируемый элемент |
67
67
  | `format`, `formatTools`, `editorActions`, `formatStorage`, `formatMarkers`, `multiline` | Параметры экземпляра |
68
- | `getValue(): string` | Сериализованное значение (по `storage`) |
68
+ | `getValue(): string` | Сериализованное значение (по `storage`) — считается по DOM, всегда актуально |
69
69
  | `setValue(value: string): void` | Установить значение (нормализует, генерирует `change`) |
70
+ | `flushChange(): void` | Доставить отложенное `change` немедленно (см. ниже) |
70
71
  | `getLength(): number` | Длина текста (без учёта переводов строк) |
71
72
  | `focus(): void` | Установить фокус |
72
73
  | `applyFormat(tool): void` | Переключить формат на выделении (слово целиком) |
73
74
  | `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
75
+ | `activeTools(): ReadonlySet<FormatTool>` | Активные форматы всех инструментов сразу (панель обновляется на каждое движение каретки, поштучный опрос обходил бы содержимое на каждую кнопку) |
74
76
  | `clearFormat(): void` | Снять всё форматирование с выделения (без выделения — со слова под кареткой) |
75
77
  | `clearAllFormat(): void` | Снять всё форматирование со всего содержимого |
76
78
  | `undo(): void`, `redo(): void` | Отмена и повтор |
@@ -78,17 +80,34 @@ editor.onChange(({ value }) => console.log(value));
78
80
  | `applyAction(action): void` | Выполнить действие панели (`erase`/`undo`/`redo`) |
79
81
  | `isActionEnabled(action): boolean` | Доступно ли действие сейчас |
80
82
  | `insertText(text): void` | Вставить текст в каретку (или вместо выделения) с учётом режима набора |
83
+ | `selection: Selection \| null` | Выделение, если оно внутри редактора (иначе `null`) — единая точка доступа для хоста |
84
+ | `selectNode(node): void` | Выделить узел внутри редактора: следующая вставка заменит его целиком |
81
85
  | `onChange(handler)` | Подписка на событие `richeditor-change` |
82
86
  | `destroy(): void` | Разворачивает элемент обратно и освобождает ресурсы |
83
87
 
88
+ ## Событие изменения
89
+
90
+ `getValue()` считает значение по DOM и точен всегда. А вот **уведомление** `richeditor-change` при печати доставляется с задержкой: сериализация — самая дорогая операция редактора (обход всего содержимого), а печать даёт `input` на каждый символ.
91
+
92
+ Это троттлинг, а не debounce: при непрерывном наборе событие приходит каждые ~150 мс, а не откладывается до паузы. Откладывается только печать — вставка, форматирование, отмена/повтор, `setValue` и Enter сообщаются сразу.
93
+
94
+ Отложенное доставляется немедленно:
95
+
96
+ - при потере фокуса и при `destroy()` — редактором самостоятельно;
97
+ - по вызову `flushChange()` — его должен звать хост перед тем, как значение прочитают снаружи.
98
+
99
+ Хосты пакета (`@brandup/ui-textbox`, `@brandup/ui-messageeditor`) держат в поле формы копию значения и сбрасывают отложенное перед каждым чтением значения снаружи: отправка формы, `validate()`, `getValue()`. На `submit` синхронизация идёт в фазе перехвата на документе — то есть раньше любого обработчика самой формы и независимо от того, включена ли её валидация (`novalidate`). Так что в отправляемых данных значение всегда актуально; свой хост обязан делать то же самое (в `@brandup/ui-input` для этого есть `__syncValue()`).
100
+
101
+ Не покрыт единственный случай: `new FormData(form)` **вне** отправки формы, в пределах окна троттлинга после ввода. Список полей там собирается до того, как о нём можно узнать, а точечно заменить свою запись `FormData` не позволяет. Читайте в таком коде `getValue()` хоста — он синхронизирует сам.
102
+
84
103
  ## Поведение форматирования
85
104
 
86
105
  - Формат — переключатель (toggle): повторное применение снимает его.
87
106
  - Применяется к слову целиком: курсор внутри слова или выделение его части → формат охватывает всё слово; исходное выделение/каретка сохраняются.
88
107
  - **Режим набора**: на пустом месте (между пробелами / в пустом поле) кнопка/хоткей включают «ожидающий» формат — он применится к следующему введённому тексту. Сбрасывается при перемещении каретки, клике или потере фокуса.
89
108
  - Хоткеи `Ctrl/Cmd+B/I/U`. Зачёркивание — только кнопкой.
90
- - **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов.
91
- - При потере фокуса и после `setValue` пробелы нормализуются (схлопывание повторов + обрезка краёв строк).
109
+ - **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов, но не более ~512 КБ снимков суммарно (снимок — это всё содержимое редактора, поэтому на длинном тексте старые шаги вытесняются раньше).
110
+ - При потере фокуса и после `setValue` пробелы нормализуются (схлопывание повторов + обрезка краёв строк). Неразрывный пробел (`U+00A0`) считается обычным: браузер сам подставляет его в `contenteditable` вместо пробела, который иначе схлопнулся бы при отображении, и в значение он не попадает.
92
111
 
93
112
  ## Очистка форматирования и действия панели
94
113
 
@@ -133,16 +152,21 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
133
152
 
134
153
  При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются.
135
154
 
136
- ## Вставка форматированного текста
155
+ ## Вставка текста
137
156
 
138
157
  При включённом форматировании вставка (`paste`) сохраняет форматирование из буфера обмена (`text/html`):
139
158
 
140
159
  - разметка санитизируется до включённых инструментов (синонимы `STRONG/EM/DEL/INS` → канонические `b/i/s/u`, всё прочее — `span`, стили, классы, `<style>`/`<script>` — отбрасывается, текст сохраняется);
141
160
  - **multiline** сохраняет абзацы `<p>` и мягкие переносы `<br>`, разбивая текущий абзац по каретке; **single-line** — инлайн, абзацы/переносы становятся пробелами;
142
- - если `text/html` нет — простая текстовая вставка (как раньше);
143
161
  - хук `filterPaste` остаётся в силе: вернул `null` — вставка отклоняется; изменил текст (обрезка по длине, фильтр по типу) — форматирование не сохраняется, вставляется очищенный текст.
144
162
 
145
- Вся вставкаодин шаг истории (Ctrl+Z откатывает целиком).
163
+ Если `text/html` нет (или форматирование выключено), вставляется простой текст по той же модели абзацев:
164
+
165
+ - **multiline**, режим `block` — пустая строка разделяет абзацы `<p>`, одиночный перенос остаётся мягким `<br>`;
166
+ - **multiline**, режим `break` — абзацы не создаются, все переносы мягкие;
167
+ - **single-line** — строки склеиваются пробелами.
168
+
169
+ Каретка в обоих случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
146
170
 
147
171
  ## Формат хранения
148
172
 
@@ -155,6 +179,8 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
155
179
 
156
180
  Маркеры markdown настраиваются через `markers`. При разборе применяются по убыванию длины, поэтому длинный маркер срабатывает раньше короткого-префикса. Глубоко вложенные комбинации гарантированно сохраняются только в режиме `html`.
157
181
 
182
+ Вложенные пары разбираются (`_а **б** в_`), а вот **пересекающиеся** остаются текстом: в `**а _б** в_` внутренняя пара пересекает внешнюю, разметкой такое невыразимо, и короткий маркер отбрасывается — как и в мессенджерах.
183
+
158
184
  Разметка распознаётся по правилам мессенджеров: маркер стоит на **границе слова**, содержимое не начинается и не заканчивается пробелом и не пересекает перенос строки. Поэтому `5**4 = 20`, `2 ** 2 ** 2` и `файл_имя_файла.txt` остаются обычным текстом — иначе редактор показывал бы форматирование там, где получатель увидит исходные символы. Форматирование применяется к словам целиком, так что собственный вывод редактора всегда разбирается обратно.
159
185
 
160
186
  Краевые пробелы при сериализации выносятся за маркеры (`<b> слово </b>дальше` → `**слово** дальше`), иначе разметка не сработала бы ни у нас, ни у мессенджера. А вот формат, приклеенный к соседнему слову — такое приходит только со вставкой внешнего HTML (`супер<b>бонус</b>`), — маркерами невыразим: значение сохранится как `супер**бонус**` и разметкой уже не станет. Это осознанное решение: текст остаётся ровно тем, что набрал пользователь, а не молча теряет форматирование.
package/package.json CHANGED
@@ -27,12 +27,12 @@
27
27
  "email": "it@brandup.online"
28
28
  },
29
29
  "license": "Apache-2.0",
30
- "version": "1.0.38",
30
+ "version": "1.0.39",
31
31
  "main": "source/index.ts",
32
32
  "types": "source/index.ts",
33
33
  "dependencies": {
34
34
  "@brandup/ui": "^2.0.7",
35
- "@brandup/ui-kit": "^1.0.38"
35
+ "@brandup/ui-kit": "^1.0.39"
36
36
  },
37
37
  "files": [
38
38
  "source",
package/source/editing.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  // Низкоуровневые операции редактирования на чистом Selection/Range: абзацы, мягкие переносы,
2
- // каретка, расширение/обрезка выделения. Без состояния редактора и без истории —
3
- // вызывающий сам решает, когда записывать undo-шаг.
4
-
5
- function isBlock(node: Node): boolean {
6
- return (
7
- node.nodeType === Node.ELEMENT_NODE &&
8
- ((node as Element).tagName === "P" || (node as Element).tagName === "DIV")
9
- );
10
- }
2
+ // каретка, расширение/обрезка выделения, разбор вставляемого содержимого. Без состояния
3
+ // редактора и без истории — вызывающий сам решает, когда записывать undo-шаг.
4
+
5
+ import { deserialize } from "./serialize";
6
+ import { isBlock } from "./paragraphs";
7
+ import { documentSelection, innerSelection } from "./selection";
8
+ import type { FormatMarkers, FormatTool } from "./format-config";
11
9
 
12
10
  function emptyParagraph(): HTMLParagraphElement {
13
11
  const p = document.createElement("p");
@@ -25,7 +23,7 @@ function caretToStart(node: Node) {
25
23
  const range = document.createRange();
26
24
  range.setStart(node, 0);
27
25
  range.collapse(true);
28
- const selection = window.getSelection();
26
+ const selection = documentSelection(node);
29
27
  if (selection) {
30
28
  selection.removeAllRanges();
31
29
  selection.addRange(range);
@@ -40,7 +38,7 @@ export function caretToEnd(editable: HTMLElement, multiline: boolean) {
40
38
  const last = multiline ? editable.lastElementChild : null;
41
39
  range.selectNodeContents(last && isBlock(last) ? last : editable);
42
40
  range.collapse(false);
43
- const sel = window.getSelection();
41
+ const sel = documentSelection(editable);
44
42
  if (sel) {
45
43
  sel.removeAllRanges();
46
44
  sel.addRange(range);
@@ -50,13 +48,13 @@ export function caretToEnd(editable: HTMLElement, multiline: boolean) {
50
48
  /** Фокус и выделение всего содержимого (например, readonly-режим). */
51
49
  export function selectAllContent(editable: HTMLElement) {
52
50
  editable.focus();
53
- window.getSelection()?.selectAllChildren(editable);
51
+ documentSelection(editable)?.selectAllChildren(editable);
54
52
  }
55
53
 
56
54
  /** Enter в multiline: разбить текущий абзац по каретке на два <p>. */
57
55
  export function insertParagraph(editable: HTMLElement) {
58
- const selection = window.getSelection();
59
- if (!selection || selection.rangeCount === 0 || !editable.contains(selection.anchorNode)) return;
56
+ const selection = innerSelection(editable);
57
+ if (!selection) return;
60
58
 
61
59
  const range = selection.getRangeAt(0);
62
60
  range.deleteContents();
@@ -101,8 +99,8 @@ export function insertParagraph(editable: HTMLElement) {
101
99
 
102
100
  /** Shift/Ctrl+Enter в multiline: вставить мягкий перенос <br>. */
103
101
  export function insertSoftBreak(editable: HTMLElement) {
104
- const selection = window.getSelection();
105
- if (!selection || selection.rangeCount === 0 || !editable.contains(selection.anchorNode)) return;
102
+ const selection = innerSelection(editable);
103
+ if (!selection) return;
106
104
 
107
105
  const range = selection.getRangeAt(0);
108
106
  range.deleteContents();
@@ -168,15 +166,80 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
168
166
  }
169
167
 
170
168
  /** Обрезает пробелы по краям абзаца (после схлопывания) — у крайних текстовых узлов. */
171
- export function trimParagraphEdges(p: HTMLElement) {
169
+ function trimParagraphEdges(p: HTMLElement) {
172
170
  const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
173
171
  const texts: Text[] = [];
174
172
  for (let t = walker.nextNode() as Text | null; t; t = walker.nextNode() as Text | null) texts.push(t);
175
173
  if (!texts.length) return;
176
174
 
177
- texts[0].textContent = (texts[0].textContent ?? "").replace(/^ /, "");
175
+ // неразрывный пробел режем наравне с обычным — из буфера обмена он приходит регулярно
176
+ texts[0].textContent = (texts[0].textContent ?? "").replace(/^[ \u00A0]/, "");
178
177
  const last = texts[texts.length - 1];
179
- last.textContent = (last.textContent ?? "").replace(/ $/, "");
178
+ last.textContent = (last.textContent ?? "").replace(/[ \u00A0]$/, "");
179
+ }
180
+
181
+ /**
182
+ * Строки вставляемого текста → абзацы `<p>` с мягкими переносами `<br>` внутри.
183
+ *
184
+ * При `blocks` абзацы разделяет пустая строка (режим `block` многострочного редактора);
185
+ * иначе весь текст — один абзац, а все переносы мягкие: так вставка ложится в ту же модель,
186
+ * которую даёт Enter, и значение после неё разбирается обратно.
187
+ */
188
+ export function buildParagraphs(lines: string[], blocks: boolean): HTMLElement[] {
189
+ const groups: string[][] = [];
190
+
191
+ if (blocks) {
192
+ let group: string[] = [];
193
+ for (const line of lines) {
194
+ if (line !== "") group.push(line);
195
+ else if (group.length) {
196
+ groups.push(group);
197
+ group = [];
198
+ }
199
+ }
200
+ if (group.length) groups.push(group);
201
+ } else if (lines.length) groups.push(lines);
202
+
203
+ return groups.map((group) => {
204
+ const p = document.createElement("p");
205
+ group.forEach((line, index) => {
206
+ if (index > 0) p.appendChild(document.createElement("br"));
207
+ p.appendChild(document.createTextNode(line));
208
+ });
209
+
210
+ return p;
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Разбирает HTML из буфера обмена в абзацы `<p>`, оставляя только разрешённые инструменты.
216
+ * Пустой результат — вставлять нечего (вызывающий откатится на простой текст).
217
+ */
218
+ export function sanitizePastedHtml(html: string, tools: FormatTool[], markers: FormatMarkers): HTMLElement[] {
219
+ // убираем мусорные элементы (Word/браузер: стили, скрипты, заголовок документа)
220
+ const source = document.createElement("template");
221
+ source.innerHTML = html;
222
+ source.content.querySelectorAll("script, style, head, meta, link, title, noscript").forEach((el) => el.remove());
223
+
224
+ // единый источник санитизации — deserialize (теги-синонимы → канонические, лишнее развёрнуто)
225
+ const holder = document.createElement("template");
226
+ holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true);
227
+
228
+ // внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
229
+ // иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
230
+ const walker = document.createTreeWalker(holder.content, NodeFilter.SHOW_TEXT);
231
+ for (let t = walker.nextNode(); t; t = walker.nextNode())
232
+ t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
233
+
234
+ const paras = Array.from(holder.content.children) as HTMLElement[];
235
+ for (const p of paras) trimParagraphEdges(p);
236
+
237
+ // отбрасываем пустые краевые абзацы (ведущие/хвостовые \n и <br>-обёртки из буфера),
238
+ // иначе перед и после вставленного текста появляются пустые строки
239
+ while (paras.length && (paras[0].textContent ?? "").trim() === "") paras.shift();
240
+ while (paras.length && (paras[paras.length - 1].textContent ?? "").trim() === "") paras.pop();
241
+
242
+ return paras;
180
243
  }
181
244
 
182
245
  /**
@@ -207,9 +270,10 @@ export function expandRangeToWords(editable: HTMLElement, range: Range): Range {
207
270
 
208
271
  /** Убирает пробелы по краям выделения (например, после двойного клика по слову). */
209
272
  export function trimSelectionWhitespace(editable: HTMLElement) {
210
- const selection = window.getSelection();
211
- if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
273
+ const selection = innerSelection(editable);
274
+ if (!selection || selection.isCollapsed) return;
212
275
 
276
+ // внутри редактора должно быть не только начало выделения, но и его конец
213
277
  const range = selection.getRangeAt(0);
214
278
  if (!editable.contains(range.startContainer) || !editable.contains(range.endContainer)) return;
215
279
 
package/source/format.ts CHANGED
@@ -21,9 +21,13 @@ export {
21
21
  } from "./format-config";
22
22
  export { serialize, deserialize } from "./serialize";
23
23
  export {
24
+ documentSelection,
25
+ innerSelection,
26
+ preserveCaret,
24
27
  selectionCharBounds,
25
28
  restoreSelection,
26
29
  mapCharOffset,
30
+ activeFormats,
27
31
  toggleFormat,
28
32
  clearFormat,
29
33
  clearAllFormat,
@@ -32,4 +36,4 @@ export {
32
36
  insertFormattedText,
33
37
  isFormatActive,
34
38
  } from "./selection";
35
- export { normalizeWhitespace, normalizeParagraphs, ensureParagraphs } from "./paragraphs";
39
+ export { isBlock, normalizeWhitespace, normalizeParagraphs, ensureParagraphs } from "./paragraphs";
package/source/history.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // хуже того, ломается при смешивании с ручными мутациями. Поэтому ведём свою историю снимков
6
6
  // (innerHTML + позиция выделения). Используется только при включённом форматировании.
7
7
 
8
- import { restoreSelection, selectionCharBounds } from "./format";
8
+ import { documentSelection, innerSelection, restoreSelection, selectionCharBounds } from "./format";
9
9
 
10
10
  interface Snapshot {
11
11
  html: string;
@@ -18,6 +18,9 @@ export type HistoryKind = "type" | "op";
18
18
 
19
19
  const COALESCE_MS = 300; // печать в пределах паузы — один шаг отмены
20
20
  const MAX_DEPTH = 100; // ограничение глубины истории (память)
21
+ // Снимок — это весь innerHTML редактора, поэтому глубины мало: сто шагов на длинном тексте
22
+ // это мегабайты на одно поле. Ограничиваем ещё и суммарный объём, отбрасывая самые старые шаги.
23
+ const MAX_CHARS = 512 * 1024;
21
24
 
22
25
  export class EditorHistory {
23
26
  private readonly __root: HTMLElement;
@@ -25,6 +28,7 @@ export class EditorHistory {
25
28
  private __redo: Snapshot[] = [];
26
29
  private __lastKind: HistoryKind | null = null;
27
30
  private __lastTime = 0;
31
+ private __chars = 0; // суммарный объём снимков отмены — см. MAX_CHARS
28
32
 
29
33
  constructor(root: HTMLElement) {
30
34
  this.__root = root;
@@ -41,11 +45,8 @@ export class EditorHistory {
41
45
  }
42
46
 
43
47
  private __snapshot(): Snapshot {
44
- const sel = window.getSelection();
45
- let start = 0;
46
- let end = 0;
47
- if (sel && sel.rangeCount > 0 && this.__root.contains(sel.anchorNode))
48
- [start, end] = selectionCharBounds(this.__root, sel.getRangeAt(0));
48
+ const sel = innerSelection(this.__root);
49
+ const [start, end] = sel ? selectionCharBounds(this.__root, sel.getRangeAt(0)) : [0, 0];
49
50
 
50
51
  return { html: this.__root.innerHTML, start, end };
51
52
  }
@@ -68,7 +69,10 @@ export class EditorHistory {
68
69
  if (top && top.html === snap.html) return; // состояние не изменилось — не дублируем
69
70
 
70
71
  this.__undo.push(snap);
71
- if (this.__undo.length > MAX_DEPTH) this.__undo.shift();
72
+ this.__chars += snap.html.length;
73
+ while (this.__undo.length > MAX_DEPTH || (this.__chars > MAX_CHARS && this.__undo.length > 1))
74
+ this.__chars -= this.__undo.shift()!.html.length;
75
+
72
76
  this.__redo = [];
73
77
  }
74
78
 
@@ -77,6 +81,7 @@ export class EditorHistory {
77
81
  const prev = this.__undo.pop();
78
82
  if (!prev) return false;
79
83
 
84
+ this.__chars -= prev.html.length;
80
85
  this.__redo.push(this.__snapshot());
81
86
  this.__restore(prev);
82
87
  this.__lastKind = null; // следующая печать начнёт новый шаг
@@ -88,7 +93,9 @@ export class EditorHistory {
88
93
  const next = this.__redo.pop();
89
94
  if (!next) return false;
90
95
 
91
- this.__undo.push(this.__snapshot());
96
+ const current = this.__snapshot();
97
+ this.__undo.push(current);
98
+ this.__chars += current.html.length;
92
99
  this.__restore(next);
93
100
  this.__lastKind = null;
94
101
  return true;
@@ -96,7 +103,9 @@ export class EditorHistory {
96
103
 
97
104
  private __restore(snap: Snapshot): void {
98
105
  this.__root.innerHTML = snap.html;
99
- const sel = window.getSelection();
106
+
107
+ // содержимое заменено целиком — выделение восстанавливаем, где бы оно ни стояло
108
+ const sel = documentSelection(this.__root);
100
109
  if (sel) restoreSelection(this.__root, snap.start, snap.end, sel);
101
110
  }
102
111
  }
package/source/index.ts CHANGED
@@ -10,6 +10,9 @@ export {
10
10
  parseFormatTools,
11
11
  defaultFormatMarkers,
12
12
  normalizeWhitespace,
13
+ selectionCharBounds,
14
+ restoreSelection,
15
+ preserveCaret,
13
16
  type EditorAction,
14
17
  type FormatTool,
15
18
  type FormatStorage,
@@ -3,10 +3,30 @@
3
3
 
4
4
  import { cleanupFormatting } from "./selection";
5
5
 
6
+ /**
7
+ * Что в содержимом редактора считается абзацем. Единственное определение модели абзацев:
8
+ * по нему идут и разбор с сериализацией, и правки каретки, и нормализация. `<div>` признаём
9
+ * наравне с `<p>` — его приносят вставка и чужой contenteditable, а нормализация приводит к `<p>`.
10
+ */
11
+ export function isBlock(node: Node): boolean {
12
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
13
+
14
+ const tag = (node as Element).tagName;
15
+ return tag === "P" || tag === "DIV";
16
+ }
17
+
18
+ // Пробел, таб и неразрывный пробел (U+00A0) — всё это «пробел» при наборе; см. normalizeWhitespace.
19
+ // После схлопывания в тексте остаются только обычные пробелы, поэтому дальше по коду хватает " ".
20
+ const SPACE_RUN = /[ \t\u00A0]+/g;
21
+
6
22
  /**
7
23
  * Нормализует пробелы в редакторе: схлопывает повторяющиеся пробелы/табы в один
8
24
  * и обрезает пробелы по краям каждой строки. BR и блочные элементы (DIV/P) —
9
25
  * границы строк; инлайновое форматирование (b/i/s/u) на строки не влияет.
26
+ *
27
+ * Неразрывный пробел (U+00A0) считается обычным: браузер сам подставляет его в contenteditable
28
+ * вместо пробела, который иначе схлопнулся бы при отображении. Без этого набранные подряд
29
+ * пробелы не схлопывались бы вовсе, а U+00A0 уезжал бы в сохраняемое значение.
10
30
  */
11
31
  export function normalizeWhitespace(root: HTMLElement) {
12
32
  type Item = { kind: "text"; node: Text } | { kind: "break" };
@@ -27,7 +47,7 @@ export function normalizeWhitespace(root: HTMLElement) {
27
47
  const el = child as HTMLElement;
28
48
  if (el.tagName === "BR") {
29
49
  items.push({ kind: "break" });
30
- } else if (el.tagName === "DIV" || el.tagName === "P") {
50
+ } else if (isBlock(el)) {
31
51
  items.push({ kind: "break" });
32
52
  flatten(el);
33
53
  items.push({ kind: "break" });
@@ -52,7 +72,7 @@ export function normalizeWhitespace(root: HTMLElement) {
52
72
  continue;
53
73
  }
54
74
 
55
- let text = item.node.data.replace(/[ \t]+/g, " ");
75
+ let text = item.node.data.replace(SPACE_RUN, " ");
56
76
  if (atLineStart) text = text.replace(/^ /, ""); // пробел в начале строки
57
77
  if (pendingSpaceNode && text.startsWith(" ")) text = text.slice(1); // двойной пробел на границе узлов
58
78
 
@@ -96,7 +116,7 @@ export function ensureParagraphs(root: HTMLElement) {
96
116
  for (const node of Array.from(root.childNodes)) {
97
117
  const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
98
118
 
99
- if (el && (el.tagName === "P" || el.tagName === "DIV")) {
119
+ if (el && isBlock(el)) {
100
120
  flushRun(node);
101
121
  if (el.tagName === "DIV") {
102
122
  const p = document.createElement("p");
@@ -107,6 +107,7 @@
107
107
  }
108
108
 
109
109
  & .format-button,
110
+ & .host-button,
110
111
  & .action-button {
111
112
  width: calc(var(--input-height, 46px) - 12px);
112
113
  height: calc(var(--input-height, 46px) - 12px);
@@ -161,6 +162,9 @@
161
162
  flex-flow: row wrap;
162
163
 
163
164
  & .emoji {
165
+ // в панели больше семисот кнопок: за пределами прокрутки браузер их не раскладывает
166
+ content-visibility: auto;
167
+ contain-intrinsic-size: 32px 32px;
164
168
  width: 32px;
165
169
  height: 32px;
166
170
  padding: 0;