@brandup/ui-richeditor 1.0.50 → 1.0.52

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
@@ -52,7 +52,7 @@ editor.onChange(({ value }) => console.log(value));
52
52
  | `markers` | `Partial<FormatMarkers>` | Переопределение markdown-маркеров по инструментам |
53
53
  | `placeholder` | `string \| null` | Текст-заглушка |
54
54
  | `multiline` | `boolean` | Многострочный режим |
55
- | `paragraph` | `"block" \| "break"` | Что делает Enter: новый абзац (по умолчанию) или мягкий перенос |
55
+ | `paragraph` | `"block" \| "break"` | Что такое абзац: абзац, отделённый пустой строкой (по умолчанию), или строка, как в мессенджерах |
56
56
  | `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию все); пустой список оставляет только `paragraph` |
57
57
  | `keepFocus` | `boolean` | Держать ли фокус в поле, пока открыта панель смайликов (по умолчанию да, а на сенсорном устройстве нет) |
58
58
  | `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются); разметка значения при этом разбирается и показывается, кнопок для неё просто нет |
@@ -101,6 +101,7 @@ editor.onChange(({ value }) => console.log(value));
101
101
  | `applyAction(action): void` | Выполнить действие панели (`erase`/`undo`/`redo`) |
102
102
  | `isActionEnabled(action): boolean` | Доступно ли действие сейчас |
103
103
  | `insertText(text): void` | Вставить текст в каретку (или вместо выделения) с учётом режима набора; без фокуса вставляет по снятой каретке |
104
+ | `deleteNodes(nodes): void` | Удалить узлы содержимого одной правкой: с историей, кареткой на их месте и одним изменением значения |
104
105
  | `openEmojiPicker(picker, initiator): boolean` | Показать переданный попап смайликов у кнопки; false — этим нажатием он закрылся |
105
106
  | `selection: Selection \| null` | Выделение, если оно внутри редактора (иначе `null`) — единая точка доступа для хоста |
106
107
  | `selectNode(node): void` | Выделить узел внутри редактора: следующая вставка заменит его целиком |
@@ -181,15 +182,13 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
181
182
  - **Shift+Enter** или **Ctrl/Cmd+Enter** → мягкий перенос (`<br>`) внутри абзаца;
182
183
  - блуждающий текст и `<div>` нормализуются в `<p>` при вводе.
183
184
 
184
- Опция `paragraph: "break"` меняет это: Enter даёт мягкий перенос, а модификатор в этом режиме ничего не меняет пустая строка набирается двумя переносами, как в мессенджерах (отдельный абзац дал бы в значении ровно её же). Это важно при `storage: "markdown"`: в режиме по умолчанию каждый Enter уходит в значение пустой строкой (`\n\n`), а в `break` — одним переносом (`\n`).
185
+ Опция `paragraph: "break"` меняет смысл абзаца: там абзац это строка, как в мессенджерах. Enter и модификатор делают одно и то же (новую строку), мягкому переносу в этом режиме взяться неоткуда, а пустая строка сообщения это пустой абзац. Это важно при `storage: "markdown"`: в режиме по умолчанию граница абзацев уходит в значение пустой строкой (`\n\n`), а в `break` — одним переносом (`\n`).
185
186
 
186
- В этом режиме абзацных блоков в содержимом нет вовсе: значение загружается плоским текстом, где каждый `\n` становится `<br>` внутри единственного `<p>`. Иначе два переноса рисовались бы двумя абзацами, а у хоста без отступов между ними это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
187
+ Содержимое в обоих режимах абзацные блоки: каждая строка (или абзац) лежит в своём `<p>`, а не разделяется `<br>` внутри общего. Отступов между абзацами в режиме `break` нет (класс `breaks` на редакторе): отступ читался бы пустой строкой, которой в значении не будет.
187
188
 
188
- Хвостовой перенос отбрасывается ровно один это `<br>`-заполнитель, без которого не видна последняя строка. Набранные пустые строки сохраняются и в поле, и в значении, где бы они ни стоялив начале блока, в середине или в конце.
189
+ Мягкий перенос, пришедший извневставкой документа или чужим значением, приводится к той же модели: абзац делится по нему на строки-абзацы. Хвостовой перенос при этом строкой не считается это `<br>`-заполнитель, без которого не видна последняя (пустая) строка.
189
190
 
190
- Блоки в этом режиме появляются побочноправкой блочного типа, поэтому нормализация сводит соседние абзацы обратно в один: их граница уходила бы в значение пустой строкой, которой на экране нет.
191
-
192
- При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются — кроме одного: пустой абзац сразу за блоком другого типа остаётся. Это единственное место, где каретка стоит вне цитаты или кода, и без него правка запиралась бы в блоке. В значение такой абзац не попадает — хвост значения обрезается.
191
+ При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются кроме одного: пустой абзац сразу за блоком другого типа остаётся. Это единственное место, где каретка стоит вне цитаты или кода, и без него правка запиралась бы в блоке. В значение такой абзац не попадает — хвост значения обрезается. В режиме `break` пустой абзац осмыслен сам по себе (это пустая строка сообщения) и не удаляется вовсе; исчезает только единственный — иначе пустое поле не показало бы заглушку.
193
192
 
194
193
  ## Блоки: цитата и код
195
194
 
@@ -263,7 +262,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
263
262
 
264
263
  Подряд идущие строки с маркером цитаты — одна цитата; пустая строка между ними разделяет цитаты. В режиме мягких переносов пустой строки между блоками нет, поэтому соседние цитаты там склеиваются в одну сразу в поле — иначе оно показывало бы два блока, а в значении и у получателя был бы один. Незакрытое ограждение блоком не считается: его строки остаются текстом, иначе одна случайная кавычка съедала бы весь остаток сообщения. Блок отключённого типа, пришедший вставкой или из значения, сохраняется как обычный текст — разметки от него в значении не будет, но текст не теряется.
265
264
 
266
- В режиме `paragraph: "break"` блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два.
265
+ В режиме `paragraph: "break"` блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два. Выделенные строки становятся при этом одним блоком, а не блоком на каждую: строка там — отдельный абзац, и кнопка на трёх строках дала бы три цитаты подряд.
267
266
 
268
267
  ## Вставка текста
269
268
 
@@ -280,7 +279,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
280
279
  Если `text/html` нет (или форматирование выключено), а разбирать маркеры не по чему, вставляется простой текст — по той же модели абзацев:
281
280
 
282
281
  - **multiline**, режим `block` — пустая строка разделяет абзацы `<p>`, одиночный перенос остаётся мягким `<br>`;
283
- - **multiline**, режим `break` — абзацы не создаются, все переносы мягкие;
282
+ - **multiline**, режим `break` — каждая строка становится абзацем `<p>`, пустая строка — пустым абзацем;
284
283
  - **single-line** — строки склеиваются пробелами.
285
284
 
286
285
  Каретка во всех случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
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.50",
30
+ "version": "1.0.52",
31
31
  "main": "source/index.ts",
32
32
  "types": "source/index.ts",
33
33
  "dependencies": {
34
34
  "@brandup/ui": "^2.0.9",
35
- "@brandup/ui-kit": "^1.0.50"
35
+ "@brandup/ui-kit": "^1.0.52"
36
36
  },
37
37
  "files": [
38
38
  "source",
package/source/editing.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { deserialize } from "./serialize";
6
6
  import { blockAt, blockTypeOf, blocksInRange, createBlock, isBlock } from "./paragraphs";
7
- import { documentSelection, innerSelection, linkAt, literalAncestor } from "./selection";
7
+ import { cleanupFormatting, documentSelection, innerSelection, linkAt, literalAncestor } from "./selection";
8
8
  import { BLOCK_TYPES, DEFAULT_BLOCK, type BlockType, type FormatMarkers, type FormatTool } from "./format-config";
9
9
 
10
10
  // убирает пустые текст-узлы и ставит <br>-заполнитель в пустой абзац (для видимости и каретки)
@@ -58,8 +58,11 @@ export interface BlockChange {
58
58
  *
59
59
  * У типа без инлайновой разметки (код) форматирование снимается: внутри него написанное
60
60
  * остаётся буквальным, и сохранить его всё равно было бы негде.
61
+ *
62
+ * При `merge` выделенные блоки собираются в один блок нового типа: там, где абзац это строка,
63
+ * кнопка на трёх строках даёт один блок, а не три подряд.
61
64
  */
62
- export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType): BlockChange {
65
+ export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType, merge = false): BlockChange {
63
66
  const blocks = blocksInRange(editable, range);
64
67
 
65
68
  // Пустой редактор: блоков ещё нет, но тип задать можно — иначе в пустое поле его было бы
@@ -79,6 +82,26 @@ export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType
79
82
  if (created) return { changed: true, created };
80
83
  }
81
84
 
85
+ // Собираем ВСЕ задетые блоки, а не только чужого типа: блок нужного типа посреди выделения
86
+ // иначе остался бы на месте, а собранный блок встал бы перед ним — строки менялись местами.
87
+ if (merge && type !== DEFAULT_BLOCK && blocks.length > 1) {
88
+ const replacement = retagBlock(blocks[0], type);
89
+
90
+ for (const block of blocks.slice(1)) {
91
+ // строки склеиваем переносом — тем же, что разделяет их внутри блока (mergeAdjacentBlocks)
92
+ if (replacement.lastChild?.nodeName !== "BR") replacement.appendChild(document.createElement("br"));
93
+
94
+ while (block.firstChild) replacement.appendChild(block.firstChild);
95
+ block.remove();
96
+ }
97
+
98
+ if (!BLOCK_TYPES[type].inline) unwrapFormatting(replacement);
99
+ if (replacement !== blocks[0]) blocks[0].replaceWith(replacement);
100
+ fillEmptyParagraph(replacement);
101
+
102
+ return { changed: true, created: null };
103
+ }
104
+
82
105
  let changed = false;
83
106
 
84
107
  for (const block of blocks) {
@@ -291,6 +314,11 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
291
314
  // хвост уехал в блок другого типа — его правила распространяются и на содержимое
292
315
  if (!BLOCK_TYPES[type].inline) unwrapFormatting(next);
293
316
 
317
+ // Разрез по краю оформленного куска оставляет от него пустой тег в одной из половин:
318
+ // продолжать им нечего, а набор в него попадал бы оформленным (см. insertSoftBreak).
319
+ cleanupFormatting(para);
320
+ cleanupFormatting(next);
321
+
294
322
  // extractContents в конце абзаца оставляет пустой текст-узел → <p></p> без заполнителя
295
323
  // (невидим/нефокусируем, каретка не встаёт). Чистим и ставим <br> в опустевшие абзацы.
296
324
  fillEmptyParagraph(para);
@@ -379,6 +407,31 @@ export function insertSoftBreak(editable: HTMLElement) {
379
407
  selection.addRange(after);
380
408
  }
381
409
 
410
+ /**
411
+ * Схлопывает пустые оболочки, оставшиеся от удалённого выделения. Выделение, начатое и
412
+ * законченное в разных абзацах, забирает их содержимое, но сами абзацы задевает лишь частично —
413
+ * и они остаются пустыми по краям каретки. Правка продолжается в одном из них: иначе вокруг
414
+ * набранного или вставленного появлялись бы пустые строки, которых не было.
415
+ */
416
+ export function collapseEmptyEdges(editable: HTMLElement, range: Range) {
417
+ if (range.startContainer !== editable) return;
418
+
419
+ // от удалённого текста остаются пустые текстовые узлы — пустоту смотрим по содержимому
420
+ const empty = (node: ChildNode | null) =>
421
+ !!node && blockTypeOf(node) === DEFAULT_BLOCK && !node.textContent && !(node as HTMLElement).querySelector("br");
422
+
423
+ const before = editable.childNodes[range.startOffset - 1] ?? null;
424
+ const after = editable.childNodes[range.startOffset] ?? null;
425
+
426
+ const kept = empty(before) ? before : empty(after) ? after : null;
427
+ if (!kept) return;
428
+
429
+ if (kept === before && empty(after)) after.remove();
430
+
431
+ range.setStart(kept, 0);
432
+ range.collapse(true);
433
+ }
434
+
382
435
  /** Вставляет санитизированные абзацы <p> в позицию каретки, разбивая текущий абзац. */
383
436
  export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement[], range: Range) {
384
437
  const block = blockOf(editable, range.startContainer);
@@ -445,16 +498,17 @@ function trimParagraphEdges(p: HTMLElement) {
445
498
  }
446
499
 
447
500
  /**
448
- * Строки вставляемого текста → абзацы `<p>` с мягкими переносами `<br>` внутри.
501
+ * Строки вставляемого текста → абзацы `<p>` с мягкими переносами `<br>` внутри. Вставка ложится
502
+ * в ту же модель, которую даёт Enter, и значение после неё разбирается обратно:
449
503
  *
450
- * При `blocks` абзацы разделяет пустая строка (режим `block` многострочного редактора);
451
- * иначе весь текст один абзац, а все переносы мягкие: так вставка ложится в ту же модель,
452
- * которую даёт Enter, и значение после неё разбирается обратно.
504
+ * - `block` абзацы разделяет пустая строка (режим `block` многострочного редактора);
505
+ * - `line`каждая строка сама себе абзац (режим мягких переносов);
506
+ * - `single` весь текст одним абзацем, все переносы мягкие (буквальное содержимое блока кода).
453
507
  */
454
- export function buildParagraphs(lines: string[], blocks: boolean): HTMLElement[] {
508
+ export function buildParagraphs(lines: string[], mode: "block" | "line" | "single"): HTMLElement[] {
455
509
  const groups: string[][] = [];
456
510
 
457
- if (blocks) {
511
+ if (mode === "block") {
458
512
  let group: string[] = [];
459
513
  for (const line of lines) {
460
514
  if (line !== "") group.push(line);
@@ -464,6 +518,8 @@ export function buildParagraphs(lines: string[], blocks: boolean): HTMLElement[]
464
518
  }
465
519
  }
466
520
  if (group.length) groups.push(group);
521
+ } else if (mode === "line") {
522
+ for (const line of lines) groups.push([line]);
467
523
  } else if (lines.length) groups.push(lines);
468
524
 
469
525
  return groups.map((group) => {
package/source/format.ts CHANGED
@@ -42,6 +42,7 @@ export {
42
42
  linkAt,
43
43
  clearFormat,
44
44
  clearAllFormat,
45
+ cleanupFormatting,
45
46
  hasFormatting,
46
47
  hasAnyFormatting,
47
48
  insertFormattedText,
@@ -55,6 +56,7 @@ export {
55
56
  createBlock,
56
57
  normalizeWhitespace,
57
58
  mergeAdjacentBlocks,
59
+ splitSoftBreaks,
58
60
  normalizeParagraphs,
59
61
  ensureParagraphs,
60
62
  paragraphsNormalized,
@@ -153,9 +153,25 @@ export function normalizeWhitespace(root: HTMLElement) {
153
153
  /**
154
154
  * Нормализует абзацы многострочного режима: удаляет пустые абзацы (без текстового содержимого).
155
155
  * Если содержимого нет вовсе — редактор остаётся пустым (показывается placeholder).
156
+ *
157
+ * В режиме мягких переносов (`softBreaks`) абзац — это строка: пустой абзац там осмыслен сам по
158
+ * себе (пустая строка сообщения) и не удаляется, а мягкий перенос внутри абзаца приводится
159
+ * к той же модели — делит его на строки-абзацы.
156
160
  */
157
- export function normalizeParagraphs(root: HTMLElement, merge = false) {
158
- if (merge) mergeAdjacentBlocks(root);
161
+ export function normalizeParagraphs(root: HTMLElement, softBreaks = false) {
162
+ if (softBreaks) {
163
+ mergeAdjacentBlocks(root);
164
+ splitSoftBreaks(root);
165
+
166
+ // Пустая строка здесь осмысленна сама по себе, поэтому пустые абзацы не удаляются —
167
+ // кроме случая, когда всё поле из них и состоит: в значение они не идут (хвост
168
+ // обрезается), а пока они в поле, оно не пустое и не покажет заглушку.
169
+ const lines = Array.from(root.children);
170
+ const blank = (el: Element) => blockTypeOf(el) === DEFAULT_BLOCK && !(el.textContent ?? "").trim();
171
+ if (lines.length && lines.every(blank)) for (const el of lines) el.remove();
172
+
173
+ return;
174
+ }
159
175
 
160
176
  for (const el of Array.from(root.children)) {
161
177
  // Пустой блок другого типа не трогаем: его завели осознанно и в него сейчас будут писать,
@@ -178,18 +194,18 @@ export function normalizeParagraphs(root: HTMLElement, merge = false) {
178
194
  }
179
195
 
180
196
  /**
181
- * Склеивает соседние блоки одного типа — в режиме мягких переносов, где граница между блоками
182
- * в значение не попадает. Подряд идущие строки с маркером цитаты разбор собирает в одну цитату,
183
- * а два обычных абзаца там и вовсе неразличимы: пустой строки между ними на экране нет, а в
184
- * значении она была бы. Два блока в поле показывали бы то, чего в сообщении не будет.
197
+ * Склеивает соседние блоки одного типа — в режиме мягких переносов, где граница между ними
198
+ * в значение не попадает: подряд идущие строки с маркером цитаты разбор собирает в одну цитату,
199
+ * и два блока в поле показывали бы то, чего в сообщении не будет.
185
200
  *
186
- * Блоки с ограждением (код) не трогаем: у них есть свои границы, и два подряд разбираются
201
+ * Обычные абзацы не трогаем: там граница блоков это перевод строки, и она в значение как раз
202
+ * попадает. Блоки с ограждением (код) — тоже: у них есть свои границы, и два подряд разбираются
187
203
  * ровно как два.
188
204
  */
189
205
  export function mergeAdjacentBlocks(root: HTMLElement) {
190
206
  for (const el of Array.from(root.children) as HTMLElement[]) {
191
207
  const type = blockTypeOf(el);
192
- if (!type || BLOCK_TYPES[type].fence) continue;
208
+ if (!type || type === DEFAULT_BLOCK || BLOCK_TYPES[type].fence) continue;
193
209
 
194
210
  const previous = el.previousElementSibling;
195
211
  if (!previous || blockTypeOf(previous) !== type) continue;
@@ -204,6 +220,46 @@ export function mergeAdjacentBlocks(root: HTMLElement) {
204
220
  }
205
221
  }
206
222
 
223
+ /**
224
+ * Делит обычные абзацы по мягким переносам — в режиме, где абзац это строка. Собственный перенос
225
+ * приходит извне (вставка документа, чужое значение), и без деления одна строка модели была бы
226
+ * то `<br>`, то границей абзацев.
227
+ *
228
+ * Хвостовой перенос строкой не считается: это заполнитель, которым браузер показывает последнюю
229
+ * пустую строку (см. ensureParagraphs), — от него делить нечего.
230
+ */
231
+ export function splitSoftBreaks(root: HTMLElement) {
232
+ for (const el of Array.from(root.children) as HTMLElement[]) {
233
+ if (blockTypeOf(el) !== DEFAULT_BLOCK) continue;
234
+
235
+ const parts: ChildNode[][] = [[]];
236
+ for (const node of Array.from(el.childNodes)) {
237
+ if (node.nodeName === "BR") parts.push([]);
238
+ else parts[parts.length - 1].push(node);
239
+ }
240
+ if (parts.length === 1) continue;
241
+
242
+ // Перенос, спрятанный внутри инлайнового тега (мягкий перенос его не разрезает), делит
243
+ // строки наравне с верхним, но по границам тега — с ним и хвостовой перенос абзаца уже
244
+ // не обязательно заполнитель. Такой абзац оставляем как есть: делить его по половине
245
+ // переносов значило бы терять строки.
246
+ if (el.querySelectorAll("br").length !== parts.length - 1) continue;
247
+
248
+ const empty = (nodes: ChildNode[]) => !nodes.some((node) => node.textContent);
249
+ if (empty(parts[parts.length - 1])) parts.pop();
250
+
251
+ el.replaceWith(
252
+ ...parts.map((nodes) => {
253
+ const p = document.createElement(BLOCK_TYPES[DEFAULT_BLOCK].tag);
254
+ for (const node of nodes) p.appendChild(node);
255
+ if (empty(nodes)) p.appendChild(document.createElement("br")); // заполнитель пустой строки
256
+
257
+ return p;
258
+ })
259
+ );
260
+ }
261
+ }
262
+
207
263
  /**
208
264
  * Есть ли что нормализовать {@link ensureParagraphs}: блуждающий текст/инлайн, чужой `<div>`,
209
265
  * пустой абзац без заполнителя или лишний хвостовой перенос.
@@ -217,7 +273,7 @@ export function paragraphsNormalized(root: HTMLElement): boolean {
217
273
  for (const node of root.childNodes) {
218
274
  const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
219
275
  if (!el || !isBlock(el) || el.tagName === "DIV") return false;
220
- if (!el.firstChild) return false;
276
+ if (!(el.textContent ?? "") && !el.querySelector("br")) return false;
221
277
 
222
278
  const tail = el.lastChild!;
223
279
  if (tail.nodeName === "BR" && tail.previousSibling?.nodeName !== "BR" && (el.textContent ?? "").length > 0)
@@ -266,7 +322,11 @@ export function ensureParagraphs(root: HTMLElement): boolean {
266
322
  flushRun(null);
267
323
 
268
324
  for (const p of Array.from(root.children) as HTMLElement[]) {
269
- if (!p.firstChild) {
325
+ // Пустой абзац опознаём по содержимому, а не по наличию узлов: из буфера обмена приходят
326
+ // абзацы из одних пробелов, и после обрезки в них остаются пустые текстовые узлы. Без
327
+ // заполнителя такой абзац не занимает строки, и в режиме мягких переносов склейка теряет
328
+ // его вместе с пустой строкой между абзацами (см. mergeAdjacentBlocks).
329
+ if (!(p.textContent ?? "") && !p.querySelector("br")) {
270
330
  p.appendChild(document.createElement("br")); // пустой абзац — заполнитель для видимости строки
271
331
  changed = true;
272
332
  continue;
@@ -87,6 +87,17 @@
87
87
  }
88
88
  }
89
89
 
90
+ // Режим мягких переносов: абзац здесь — строка, а не абзац. Отступ между ними читался бы
91
+ // пустой строкой, которой в сообщении нет.
92
+ &.breaks p {
93
+ padding-top: 0;
94
+ padding-bottom: 0;
95
+
96
+ &:last-child {
97
+ padding-bottom: var(--richeditor-underline-room);
98
+ }
99
+ }
100
+
90
101
  // Цитата: линия слева и подложка, как её рисуют мессенджеры. Значения — переменными
91
102
  // (см. :root выше): подложке нужен и отступ справа, иначе текст упирался бы в её край.
92
103
  & blockquote {
@@ -11,6 +11,7 @@ import {
11
11
  blockAt,
12
12
  blockTypeOf,
13
13
  blocksInRange,
14
+ cleanupFormatting,
14
15
  clearAllFormat,
15
16
  clearFormat,
16
17
  defaultFormatMarkers,
@@ -30,6 +31,7 @@ import {
30
31
  paragraphsNormalized,
31
32
  preserveCaret,
32
33
  mergeAdjacentBlocks,
34
+ splitSoftBreaks,
33
35
  normalizeParagraphs,
34
36
  normalizeWhitespace,
35
37
  restoreSelection,
@@ -50,6 +52,7 @@ import {
50
52
  atBlockStart,
51
53
  buildParagraphs,
52
54
  caretToEnd,
55
+ collapseEmptyEdges,
53
56
  expandRangeToWords,
54
57
  hasPastedMarkup,
55
58
  isDocumentHtml,
@@ -70,6 +73,8 @@ export { formatToolbar, TOOLBAR_CLASS, type ToolbarHost, type ToolbarButton } fr
70
73
  export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
71
74
  // Содержимое временно невыделяемо: по странице тянут выделение, начатое вне редактора (см. __holdSelectable).
72
75
  export const UNSELECTABLE_CLASS = "unselectable";
76
+ // Режим мягких переносов: абзац — это строка, и отступов между абзацами в нём нет.
77
+ export const BREAKS_CLASS = "breaks";
73
78
  export const CHANGE_EVENT = "richeditor-change";
74
79
 
75
80
  const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Escape"];
@@ -352,6 +357,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
352
357
 
353
358
  if (options.placeholder != null) editable.dataset.placeholder = options.placeholder;
354
359
  if (multiline) editable.classList.add("multiline");
360
+ // абзац-строка: отступы между абзацами показывали бы пустую строку, которой в значении нет
361
+ if (!this.__separateParagraphs && multiline) editable.classList.add(BREAKS_CLASS);
355
362
  if (readonly) editable.classList.add("readonly");
356
363
 
357
364
  this.__initEvents();
@@ -372,12 +379,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
372
379
  }
373
380
 
374
381
  /**
375
- * Работает ли редактор моделью абзацев. В режиме break абзацных блоков нет: значение плоский
376
- * текст, где каждый \n это <br>. Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без
377
- * отступов между абзацами) это неотличимо от одного переноса значение расходилось бы
378
- * с видимым текстом.
382
+ * Разделяет ли абзацы пустая строка. Содержимое в обоих режимах абзацные блоки, но значат
383
+ * они разное: в block абзац это абзац (`\n\n`), в break строка (`\n`), а пустая строка
384
+ * сообщения хранится пустым абзацем. Поэтому в break у абзацев нет и отступов: между двумя
385
+ * строками их на экране быть не должно.
379
386
  */
380
- private get __blockParagraphs(): boolean {
387
+ private get __separateParagraphs(): boolean {
381
388
  return this.multiline && this.paragraph === "block";
382
389
  }
383
390
 
@@ -402,7 +409,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
402
409
  this.formatMarkers,
403
410
  this.multiline,
404
411
  this.blockTypes,
405
- this.__blockParagraphs
412
+ this.__separateParagraphs
406
413
  );
407
414
  }
408
415
 
@@ -443,6 +450,52 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
443
450
  this.__insertText(text);
444
451
  }
445
452
 
453
+ /**
454
+ * Удаляет узлы содержимого одной правкой: с записью в историю, кареткой на их месте и одним
455
+ * изменением значения. Каретка встаёт туда, где стоял первый узел списка, поэтому передавать
456
+ * их нужно в порядке текста.
457
+ *
458
+ * Для неделимых объектов хоста (`contenteditable="false"`): в тексте они атомарны, и стирать
459
+ * их приходится целиком, а нативное удаление рядом с ними браузеры делают по-разному — где-то
460
+ * объект сперва выделяется, где-то исчезает разом. Что именно удалить, решает хост — он же
461
+ * свои объекты и собирал; редактор проводит это как свою правку.
462
+ */
463
+ deleteNodes(nodes: Node[]): void {
464
+ if (this.readonly) return;
465
+
466
+ // только своё: чужие узлы правке не подлежат, сам редактируемый элемент — тем более
467
+ const targets = nodes.filter((node) => node !== this.editable && this.editable.contains(node));
468
+ if (!targets.length) return;
469
+
470
+ this.__history?.record("op");
471
+
472
+ // Место каретки запоминаем текстовым смещением до правки: узлы исчезнут, а соседние тексты
473
+ // склеятся — живой Range после этого указывал бы в никуда.
474
+ const range = this.editable.ownerDocument.createRange();
475
+ range.setStartBefore(targets[0]);
476
+ range.collapse(true);
477
+ const caret = selectionCharBounds(this.editable, range)[0];
478
+ // выделение снимаем до правки: удаляемое могло его и держать
479
+ const selection = this.selection;
480
+
481
+ targets.forEach((node) => node.parentNode?.removeChild(node));
482
+
483
+ // Опустевшее оформление убираем, а соседние тексты склеиваем: несклеенные, они мешают
484
+ // и разбору значения, и смещениям каретки. Без форматирования хватает склейки.
485
+ if (this.format) cleanupFormatting(this.editable);
486
+ else this.editable.normalize();
487
+
488
+ if (this.multiline) this.__ensureParagraphs(); // опустевший абзац получает заполнитель
489
+ this.__clearEmptyContent(); // стёрли последнее — возвращаем заглушку
490
+
491
+ if (selection) {
492
+ restoreSelection(this.editable, caret, caret, selection);
493
+ this.__scrollCaretIntoView(); // правка из кода браузерной прокрутки к каретке не даёт
494
+ }
495
+
496
+ this.__emitChange();
497
+ }
498
+
446
499
  /**
447
500
  * Показать попап смайликов у кнопки.
448
501
  *
@@ -864,7 +917,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
864
917
  // Смена тега переносит содержимое в новый элемент — живые границы выделения этого
865
918
  // не переживают, поэтому держим каретку по текстовым смещениям.
866
919
  const bounds = selectionCharBounds(this.editable, range);
867
- const { created } = applyBlocks(this.editable, range, target);
920
+ const { created } = applyBlocks(this.editable, range, target, !this.__separateParagraphs);
921
+
922
+ // Возврат блока в обычный текст оставляет его строки мягкими переносами — приводим их
923
+ // к абзацам. Строго ДО возврата каретки: разбиение блока живой Range не переживает,
924
+ // а смещения от него не меняются — граница абзацев и мягкий перенос считаются одинаково.
925
+ if (!this.__separateParagraphs) splitSoftBreaks(this.editable);
868
926
 
869
927
  // Разделение блока добавило границы, а они в смещениях считаются — прежние уже не те.
870
928
  // Выделяем то, что стало блоком: заодно видно, к каким строкам правка и относилась.
@@ -886,7 +944,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
886
944
  // Строго после возврата каретки: склейка снимает границу блоков, а её смещения считают,
887
945
  // и восстановленная по прежним смещениям каретка съехала бы на символ. Живое выделение
888
946
  // переезжает вместе с узлами само.
889
- if (!this.__blockParagraphs) mergeAdjacentBlocks(this.editable);
947
+ if (!this.__separateParagraphs) mergeAdjacentBlocks(this.editable);
890
948
 
891
949
  this.__emitChange();
892
950
  formatToolbar.refresh();
@@ -1076,7 +1134,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1076
1134
  formatToolbar.detach(this);
1077
1135
 
1078
1136
  // элемент передан хостом — не удаляем его, только снимаем оформление редактора
1079
- this.editable.classList.remove(ROOT_CLASS, UNSELECTABLE_CLASS, "multiline", "readonly", "focused");
1137
+ this.editable.classList.remove(
1138
+ ROOT_CLASS,
1139
+ UNSELECTABLE_CLASS,
1140
+ BREAKS_CLASS,
1141
+ "multiline",
1142
+ "readonly",
1143
+ "focused"
1144
+ );
1080
1145
  this.editable.removeAttribute("contenteditable");
1081
1146
  delete this.editable.dataset.placeholder;
1082
1147
 
@@ -1089,24 +1154,19 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1089
1154
  DOM.empty(this.editable);
1090
1155
  if (!value) return;
1091
1156
 
1092
- // multiline → <p>-абзацы; single-line → инлайновое содержимое. Блоки разбираются и в режиме
1093
- // мягких переносов: цитата и код узнаются по собственной разметке, а не по пустой строке.
1094
- const blocks = this.blockTypes.length > 1;
1095
- const paragraphs = this.__blockParagraphs || (this.multiline && blocks);
1096
-
1157
+ // multiline → <p>-абзацы; single-line → инлайновое содержимое
1097
1158
  this.editable.innerHTML = deserialize(
1098
1159
  value,
1099
1160
  this.__valueStorage,
1100
1161
  this.__valueTools,
1101
1162
  this.formatMarkers,
1102
- paragraphs,
1163
+ this.multiline,
1103
1164
  this.blockTypes,
1104
- this.__blockParagraphs
1165
+ this.__separateParagraphs
1105
1166
  );
1106
1167
 
1107
- // в break инлайновое содержимое оборачивается в единственный абзац модель абзацев
1108
- // нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
1109
- if (this.multiline && !this.__blockParagraphs) ensureParagraphs(this.editable);
1168
+ // пустые абзацы значения получают заполнитель, чужая вёрсткаканонический тег
1169
+ if (this.multiline) ensureParagraphs(this.editable);
1110
1170
  }
1111
1171
 
1112
1172
  /**
@@ -1164,7 +1224,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1164
1224
  normalizeWhitespace(this.editable);
1165
1225
  // В режиме мягких переносов соседние цитаты неразличимы: значение пишет их строки подряд,
1166
1226
  // а разбор собирает в одну — склеиваем и в поле.
1167
- if (this.multiline) normalizeParagraphs(this.editable, !this.__blockParagraphs);
1227
+ if (this.multiline) normalizeParagraphs(this.editable, !this.__separateParagraphs);
1168
1228
  if (this.editable.innerHTML === before) return;
1169
1229
 
1170
1230
  if (bounds && selection) {
@@ -1346,17 +1406,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1346
1406
  editable.addEventListener(
1347
1407
  "input",
1348
1408
  () => {
1349
- if (this.multiline) {
1350
- this.__ensureParagraphs(); // блуждающий текст/div → <p>
1351
-
1352
- // единственный пустой абзац → очищаем, чтобы показать placeholder
1353
- if (editable.children.length === 1) {
1354
- const only = editable.firstElementChild!;
1355
- if (only.tagName === "P" && (only.textContent ?? "") === "") DOM.empty(editable);
1356
- }
1357
- } else if (editable.firstChild?.nodeName === "BR") {
1358
- editable.innerHTML = "";
1359
- }
1409
+ if (this.multiline) this.__ensureParagraphs(); // блуждающий текст/div → <p>
1410
+ this.__clearEmptyContent();
1411
+
1360
1412
  // При наборе браузер прокручивает к каретке сам, но доводит её лишь до края
1361
1413
  // коробки — под отступы контейнера. Доводим до текста.
1362
1414
  this.__scrollCaretIntoView();
@@ -1436,6 +1488,22 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1436
1488
  preserveCaret(this.editable, () => ensureParagraphs(this.editable));
1437
1489
  }
1438
1490
 
1491
+ /**
1492
+ * Очищает содержимое, от которого остался один пустой каркас: абзац с заполнителем или
1493
+ * висящий `<br>`. Без этого не показывается заглушка — стёртое поле выглядит непустым.
1494
+ */
1495
+ private __clearEmptyContent() {
1496
+ if (!this.multiline) {
1497
+ if (this.editable.firstChild?.nodeName === "BR") this.editable.innerHTML = "";
1498
+ return;
1499
+ }
1500
+
1501
+ if (this.editable.children.length !== 1) return;
1502
+
1503
+ const only = this.editable.firstElementChild!;
1504
+ if (only.tagName === "P" && (only.textContent ?? "") === "") DOM.empty(this.editable);
1505
+ }
1506
+
1439
1507
  private __onKeydown(e: KeyboardEvent) {
1440
1508
  // хоткеи форматирования (Ctrl/Cmd+B/I/U); зачёркивание — только кнопкой.
1441
1509
  // Сверяем по e.key и e.code — иначе на не-латинской раскладке (кириллица) хоткеи не срабатывают.
@@ -1506,15 +1574,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1506
1574
  if (this.readonly) return;
1507
1575
 
1508
1576
  // В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
1509
- // В режиме break строку переносят оба нажатия: пустая строка набирается двумя
1510
- // переносами, как в мессенджерах, и отдельный абзац дал бы в значении ровно её же.
1577
+ // В режиме break абзац и есть строка, поэтому её создают оба нажатия: мягкому переносу
1578
+ // там неоткуда взяться в значении он дал бы ровно то же самое.
1511
1579
  // Внутри блока правило берётся у его типа: из цитаты и кода Enter выходит,
1512
1580
  // а модификатор переносит строку внутри.
1513
1581
  const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
1514
1582
  const current = this.currentBlock;
1515
- const breaks =
1516
- current === DEFAULT_BLOCK ? this.paragraph === "break" : BLOCK_TYPES[current].enter === "break";
1517
- const soft = breaks || withModifier;
1583
+ const soft =
1584
+ current === DEFAULT_BLOCK
1585
+ ? this.__separateParagraphs && withModifier
1586
+ : BLOCK_TYPES[current].enter === "break" || withModifier;
1518
1587
 
1519
1588
  this.__history?.record("op");
1520
1589
  // Из блока выходят тем же нажатием, что делит его: продолжать цитату или код
@@ -1618,7 +1687,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1618
1687
  ? text.split(/\n/)
1619
1688
  : text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
1620
1689
 
1621
- this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs && !literal), selection);
1690
+ const mode = literal || !this.multiline ? "single" : this.__separateParagraphs ? "block" : "line";
1691
+
1692
+ this.__insertPasted(buildParagraphs(lines, mode), selection);
1622
1693
  }
1623
1694
 
1624
1695
  /**
@@ -1694,7 +1765,13 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1694
1765
  if (!BLOCK_TYPES[this.currentBlock].inline) return false;
1695
1766
 
1696
1767
  return this.__insertPasted(
1697
- parsePastedMarkdown(text, this.__valueTools, this.formatMarkers, this.blockTypes, this.__blockParagraphs),
1768
+ parsePastedMarkdown(
1769
+ text,
1770
+ this.__valueTools,
1771
+ this.formatMarkers,
1772
+ this.blockTypes,
1773
+ this.__separateParagraphs
1774
+ ),
1698
1775
  selection
1699
1776
  );
1700
1777
  }
@@ -1712,7 +1789,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1712
1789
 
1713
1790
  const range = selection.getRangeAt(0);
1714
1791
  this.__history?.record("op");
1792
+
1793
+ const spanned = !range.collapsed;
1715
1794
  range.deleteContents();
1795
+ if (spanned && this.multiline) collapseEmptyEdges(this.editable, range);
1716
1796
 
1717
1797
  const start = selectionCharBounds(this.editable, range)[0];
1718
1798
  let caret: number;
@@ -287,18 +287,11 @@ function serializeParagraphs(
287
287
  })
288
288
  .join("");
289
289
 
290
- // Пустая строка между блоками нужна там, где она их и разделяет. Блок с собственной
291
- // разметкой (цитата, код) узнаётся и без неё, а в режиме мягких переносов пустая строка
292
- // это пустая строка сообщения: поставив её от себя, редактор менял бы текст.
290
+ // Пустая строка между блоками нужна там, где она их и разделяет. В режиме мягких переносов
291
+ // блок это строка, а не абзац: граница блоков там и есть перевод строки, а пустая строка
292
+ // сообщения хранится пустым блоком. Поставив её от себя, редактор менял бы текст.
293
293
  return cleaned
294
- .map(([type, text], index) => {
295
- if (!index) return markdownBlock(type, text);
296
-
297
- const previous = cleaned[index - 1][0];
298
- const blank = separate || (previous === DEFAULT_BLOCK && type === DEFAULT_BLOCK);
299
-
300
- return `${blank ? "\n\n" : "\n"}${markdownBlock(type, text)}`;
301
- })
294
+ .map(([type, text], index) => (index ? `${separate ? "\n\n" : "\n"}` : "") + markdownBlock(type, text))
302
295
  .join("");
303
296
  }
304
297
 
@@ -319,6 +312,9 @@ function markdownBlock(type: BlockType, text: string): string {
319
312
  /**
320
313
  * Сериализует содержимое редактора в строку для хранения. Сохраняются только включённые инструменты.
321
314
  * При paragraphs=true применяется модель «абзацы (<p>/\n\n) + мягкие переносы (<br>/\n)».
315
+ *
316
+ * При separate=false блоки разделяет один перевод строки: там абзац — это строка, а пустая строка
317
+ * сообщения хранится пустым блоком.
322
318
  */
323
319
  export function serialize(
324
320
  root: HTMLElement,
@@ -656,8 +652,9 @@ export function deserialize(
656
652
  * строкой. Незакрытое ограждение разметкой не считается — его строки остаются текстом, как
657
653
  * у мессенджеров: иначе одна случайная кавычка съедала бы весь остаток сообщения.
658
654
  *
659
- * При `separate = false` пустая строка блоки не делит: в режиме мягких переносов она сама по себе
660
- * строка сообщения, и разбиение съедало бы её.
655
+ * При `separate = false` пустая строка блоки не делит: в режиме мягких переносов блок это строка,
656
+ * а не абзац, поэтому каждая строка выходит своим блоком, и пустая среди них тоже. Разметку это
657
+ * не рвёт: маркер и так не пересекает перенос строки — ни в значении, ни в поле.
661
658
  */
662
659
  function markdownBlocks(value: string, types: BlockType[], separate: boolean): Array<[BlockType, string]> {
663
660
  const fenced = types.filter((type) => BLOCK_TYPES[type].fence);
@@ -668,7 +665,10 @@ function markdownBlocks(value: string, types: BlockType[], separate: boolean): A
668
665
  let buffer: string[] = [];
669
666
 
670
667
  const flush = () => {
671
- if (buffer.length) blocks.push([DEFAULT_BLOCK, buffer.join("\n")]);
668
+ if (separate) {
669
+ if (buffer.length) blocks.push([DEFAULT_BLOCK, buffer.join("\n")]);
670
+ } else for (const line of buffer) blocks.push([DEFAULT_BLOCK, line]);
671
+
672
672
  buffer = [];
673
673
  };
674
674