@brandup/ui-richeditor 1.0.50 → 1.0.51

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` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются); разметка значения при этом разбирается и показывается, кнопок для неё просто нет |
@@ -181,15 +181,13 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
181
181
  - **Shift+Enter** или **Ctrl/Cmd+Enter** → мягкий перенос (`<br>`) внутри абзаца;
182
182
  - блуждающий текст и `<div>` нормализуются в `<p>` при вводе.
183
183
 
184
- Опция `paragraph: "break"` меняет это: Enter даёт мягкий перенос, а модификатор в этом режиме ничего не меняет пустая строка набирается двумя переносами, как в мессенджерах (отдельный абзац дал бы в значении ровно её же). Это важно при `storage: "markdown"`: в режиме по умолчанию каждый Enter уходит в значение пустой строкой (`\n\n`), а в `break` — одним переносом (`\n`).
184
+ Опция `paragraph: "break"` меняет смысл абзаца: там абзац это строка, как в мессенджерах. Enter и модификатор делают одно и то же (новую строку), мягкому переносу в этом режиме взяться неоткуда, а пустая строка сообщения это пустой абзац. Это важно при `storage: "markdown"`: в режиме по умолчанию граница абзацев уходит в значение пустой строкой (`\n\n`), а в `break` — одним переносом (`\n`).
185
185
 
186
- В этом режиме абзацных блоков в содержимом нет вовсе: значение загружается плоским текстом, где каждый `\n` становится `<br>` внутри единственного `<p>`. Иначе два переноса рисовались бы двумя абзацами, а у хоста без отступов между ними это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
186
+ Содержимое в обоих режимах абзацные блоки: каждая строка (или абзац) лежит в своём `<p>`, а не разделяется `<br>` внутри общего. Отступов между абзацами в режиме `break` нет (класс `breaks` на редакторе): отступ читался бы пустой строкой, которой в значении не будет.
187
187
 
188
- Хвостовой перенос отбрасывается ровно один это `<br>`-заполнитель, без которого не видна последняя строка. Набранные пустые строки сохраняются и в поле, и в значении, где бы они ни стоялив начале блока, в середине или в конце.
188
+ Мягкий перенос, пришедший извневставкой документа или чужим значением, приводится к той же модели: абзац делится по нему на строки-абзацы. Хвостовой перенос при этом строкой не считается это `<br>`-заполнитель, без которого не видна последняя (пустая) строка.
189
189
 
190
- Блоки в этом режиме появляются побочноправкой блочного типа, поэтому нормализация сводит соседние абзацы обратно в один: их граница уходила бы в значение пустой строкой, которой на экране нет.
191
-
192
- При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются — кроме одного: пустой абзац сразу за блоком другого типа остаётся. Это единственное место, где каретка стоит вне цитаты или кода, и без него правка запиралась бы в блоке. В значение такой абзац не попадает — хвост значения обрезается.
190
+ При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются кроме одного: пустой абзац сразу за блоком другого типа остаётся. Это единственное место, где каретка стоит вне цитаты или кода, и без него правка запиралась бы в блоке. В значение такой абзац не попадает — хвост значения обрезается. В режиме `break` пустой абзац осмыслен сам по себе (это пустая строка сообщения) и не удаляется вовсе; исчезает только единственный — иначе пустое поле не показало бы заглушку.
193
191
 
194
192
  ## Блоки: цитата и код
195
193
 
@@ -263,7 +261,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
263
261
 
264
262
  Подряд идущие строки с маркером цитаты — одна цитата; пустая строка между ними разделяет цитаты. В режиме мягких переносов пустой строки между блоками нет, поэтому соседние цитаты там склеиваются в одну сразу в поле — иначе оно показывало бы два блока, а в значении и у получателя был бы один. Незакрытое ограждение блоком не считается: его строки остаются текстом, иначе одна случайная кавычка съедала бы весь остаток сообщения. Блок отключённого типа, пришедший вставкой или из значения, сохраняется как обычный текст — разметки от него в значении не будет, но текст не теряется.
265
263
 
266
- В режиме `paragraph: "break"` блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два.
264
+ В режиме `paragraph: "break"` блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два. Выделенные строки становятся при этом одним блоком, а не блоком на каждую: строка там — отдельный абзац, и кнопка на трёх строках дала бы три цитаты подряд.
267
265
 
268
266
  ## Вставка текста
269
267
 
@@ -280,7 +278,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
280
278
  Если `text/html` нет (или форматирование выключено), а разбирать маркеры не по чему, вставляется простой текст — по той же модели абзацев:
281
279
 
282
280
  - **multiline**, режим `block` — пустая строка разделяет абзацы `<p>`, одиночный перенос остаётся мягким `<br>`;
283
- - **multiline**, режим `break` — абзацы не создаются, все переносы мягкие;
281
+ - **multiline**, режим `break` — каждая строка становится абзацем `<p>`, пустая строка — пустым абзацем;
284
282
  - **single-line** — строки склеиваются пробелами.
285
283
 
286
284
  Каретка во всех случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (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.51",
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.51"
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
@@ -55,6 +55,7 @@ export {
55
55
  createBlock,
56
56
  normalizeWhitespace,
57
57
  mergeAdjacentBlocks,
58
+ splitSoftBreaks,
58
59
  normalizeParagraphs,
59
60
  ensureParagraphs,
60
61
  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 {
@@ -30,6 +30,7 @@ import {
30
30
  paragraphsNormalized,
31
31
  preserveCaret,
32
32
  mergeAdjacentBlocks,
33
+ splitSoftBreaks,
33
34
  normalizeParagraphs,
34
35
  normalizeWhitespace,
35
36
  restoreSelection,
@@ -50,6 +51,7 @@ import {
50
51
  atBlockStart,
51
52
  buildParagraphs,
52
53
  caretToEnd,
54
+ collapseEmptyEdges,
53
55
  expandRangeToWords,
54
56
  hasPastedMarkup,
55
57
  isDocumentHtml,
@@ -70,6 +72,8 @@ export { formatToolbar, TOOLBAR_CLASS, type ToolbarHost, type ToolbarButton } fr
70
72
  export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
71
73
  // Содержимое временно невыделяемо: по странице тянут выделение, начатое вне редактора (см. __holdSelectable).
72
74
  export const UNSELECTABLE_CLASS = "unselectable";
75
+ // Режим мягких переносов: абзац — это строка, и отступов между абзацами в нём нет.
76
+ export const BREAKS_CLASS = "breaks";
73
77
  export const CHANGE_EVENT = "richeditor-change";
74
78
 
75
79
  const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Escape"];
@@ -352,6 +356,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
352
356
 
353
357
  if (options.placeholder != null) editable.dataset.placeholder = options.placeholder;
354
358
  if (multiline) editable.classList.add("multiline");
359
+ // абзац-строка: отступы между абзацами показывали бы пустую строку, которой в значении нет
360
+ if (!this.__separateParagraphs && multiline) editable.classList.add(BREAKS_CLASS);
355
361
  if (readonly) editable.classList.add("readonly");
356
362
 
357
363
  this.__initEvents();
@@ -372,12 +378,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
372
378
  }
373
379
 
374
380
  /**
375
- * Работает ли редактор моделью абзацев. В режиме break абзацных блоков нет: значение плоский
376
- * текст, где каждый \n это <br>. Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без
377
- * отступов между абзацами) это неотличимо от одного переноса значение расходилось бы
378
- * с видимым текстом.
381
+ * Разделяет ли абзацы пустая строка. Содержимое в обоих режимах абзацные блоки, но значат
382
+ * они разное: в block абзац это абзац (`\n\n`), в break строка (`\n`), а пустая строка
383
+ * сообщения хранится пустым абзацем. Поэтому в break у абзацев нет и отступов: между двумя
384
+ * строками их на экране быть не должно.
379
385
  */
380
- private get __blockParagraphs(): boolean {
386
+ private get __separateParagraphs(): boolean {
381
387
  return this.multiline && this.paragraph === "block";
382
388
  }
383
389
 
@@ -402,7 +408,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
402
408
  this.formatMarkers,
403
409
  this.multiline,
404
410
  this.blockTypes,
405
- this.__blockParagraphs
411
+ this.__separateParagraphs
406
412
  );
407
413
  }
408
414
 
@@ -864,7 +870,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
864
870
  // Смена тега переносит содержимое в новый элемент — живые границы выделения этого
865
871
  // не переживают, поэтому держим каретку по текстовым смещениям.
866
872
  const bounds = selectionCharBounds(this.editable, range);
867
- const { created } = applyBlocks(this.editable, range, target);
873
+ const { created } = applyBlocks(this.editable, range, target, !this.__separateParagraphs);
874
+
875
+ // Возврат блока в обычный текст оставляет его строки мягкими переносами — приводим их
876
+ // к абзацам. Строго ДО возврата каретки: разбиение блока живой Range не переживает,
877
+ // а смещения от него не меняются — граница абзацев и мягкий перенос считаются одинаково.
878
+ if (!this.__separateParagraphs) splitSoftBreaks(this.editable);
868
879
 
869
880
  // Разделение блока добавило границы, а они в смещениях считаются — прежние уже не те.
870
881
  // Выделяем то, что стало блоком: заодно видно, к каким строкам правка и относилась.
@@ -886,7 +897,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
886
897
  // Строго после возврата каретки: склейка снимает границу блоков, а её смещения считают,
887
898
  // и восстановленная по прежним смещениям каретка съехала бы на символ. Живое выделение
888
899
  // переезжает вместе с узлами само.
889
- if (!this.__blockParagraphs) mergeAdjacentBlocks(this.editable);
900
+ if (!this.__separateParagraphs) mergeAdjacentBlocks(this.editable);
890
901
 
891
902
  this.__emitChange();
892
903
  formatToolbar.refresh();
@@ -1076,7 +1087,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1076
1087
  formatToolbar.detach(this);
1077
1088
 
1078
1089
  // элемент передан хостом — не удаляем его, только снимаем оформление редактора
1079
- this.editable.classList.remove(ROOT_CLASS, UNSELECTABLE_CLASS, "multiline", "readonly", "focused");
1090
+ this.editable.classList.remove(
1091
+ ROOT_CLASS,
1092
+ UNSELECTABLE_CLASS,
1093
+ BREAKS_CLASS,
1094
+ "multiline",
1095
+ "readonly",
1096
+ "focused"
1097
+ );
1080
1098
  this.editable.removeAttribute("contenteditable");
1081
1099
  delete this.editable.dataset.placeholder;
1082
1100
 
@@ -1089,24 +1107,19 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1089
1107
  DOM.empty(this.editable);
1090
1108
  if (!value) return;
1091
1109
 
1092
- // multiline → <p>-абзацы; single-line → инлайновое содержимое. Блоки разбираются и в режиме
1093
- // мягких переносов: цитата и код узнаются по собственной разметке, а не по пустой строке.
1094
- const blocks = this.blockTypes.length > 1;
1095
- const paragraphs = this.__blockParagraphs || (this.multiline && blocks);
1096
-
1110
+ // multiline → <p>-абзацы; single-line → инлайновое содержимое
1097
1111
  this.editable.innerHTML = deserialize(
1098
1112
  value,
1099
1113
  this.__valueStorage,
1100
1114
  this.__valueTools,
1101
1115
  this.formatMarkers,
1102
- paragraphs,
1116
+ this.multiline,
1103
1117
  this.blockTypes,
1104
- this.__blockParagraphs
1118
+ this.__separateParagraphs
1105
1119
  );
1106
1120
 
1107
- // в break инлайновое содержимое оборачивается в единственный абзац модель абзацев
1108
- // нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
1109
- if (this.multiline && !this.__blockParagraphs) ensureParagraphs(this.editable);
1121
+ // пустые абзацы значения получают заполнитель, чужая вёрсткаканонический тег
1122
+ if (this.multiline) ensureParagraphs(this.editable);
1110
1123
  }
1111
1124
 
1112
1125
  /**
@@ -1164,7 +1177,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1164
1177
  normalizeWhitespace(this.editable);
1165
1178
  // В режиме мягких переносов соседние цитаты неразличимы: значение пишет их строки подряд,
1166
1179
  // а разбор собирает в одну — склеиваем и в поле.
1167
- if (this.multiline) normalizeParagraphs(this.editable, !this.__blockParagraphs);
1180
+ if (this.multiline) normalizeParagraphs(this.editable, !this.__separateParagraphs);
1168
1181
  if (this.editable.innerHTML === before) return;
1169
1182
 
1170
1183
  if (bounds && selection) {
@@ -1506,15 +1519,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1506
1519
  if (this.readonly) return;
1507
1520
 
1508
1521
  // В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
1509
- // В режиме break строку переносят оба нажатия: пустая строка набирается двумя
1510
- // переносами, как в мессенджерах, и отдельный абзац дал бы в значении ровно её же.
1522
+ // В режиме break абзац и есть строка, поэтому её создают оба нажатия: мягкому переносу
1523
+ // там неоткуда взяться в значении он дал бы ровно то же самое.
1511
1524
  // Внутри блока правило берётся у его типа: из цитаты и кода Enter выходит,
1512
1525
  // а модификатор переносит строку внутри.
1513
1526
  const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
1514
1527
  const current = this.currentBlock;
1515
- const breaks =
1516
- current === DEFAULT_BLOCK ? this.paragraph === "break" : BLOCK_TYPES[current].enter === "break";
1517
- const soft = breaks || withModifier;
1528
+ const soft =
1529
+ current === DEFAULT_BLOCK
1530
+ ? this.__separateParagraphs && withModifier
1531
+ : BLOCK_TYPES[current].enter === "break" || withModifier;
1518
1532
 
1519
1533
  this.__history?.record("op");
1520
1534
  // Из блока выходят тем же нажатием, что делит его: продолжать цитату или код
@@ -1618,7 +1632,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1618
1632
  ? text.split(/\n/)
1619
1633
  : text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
1620
1634
 
1621
- this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs && !literal), selection);
1635
+ const mode = literal || !this.multiline ? "single" : this.__separateParagraphs ? "block" : "line";
1636
+
1637
+ this.__insertPasted(buildParagraphs(lines, mode), selection);
1622
1638
  }
1623
1639
 
1624
1640
  /**
@@ -1694,7 +1710,13 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1694
1710
  if (!BLOCK_TYPES[this.currentBlock].inline) return false;
1695
1711
 
1696
1712
  return this.__insertPasted(
1697
- parsePastedMarkdown(text, this.__valueTools, this.formatMarkers, this.blockTypes, this.__blockParagraphs),
1713
+ parsePastedMarkdown(
1714
+ text,
1715
+ this.__valueTools,
1716
+ this.formatMarkers,
1717
+ this.blockTypes,
1718
+ this.__separateParagraphs
1719
+ ),
1698
1720
  selection
1699
1721
  );
1700
1722
  }
@@ -1712,7 +1734,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1712
1734
 
1713
1735
  const range = selection.getRangeAt(0);
1714
1736
  this.__history?.record("op");
1737
+
1738
+ const spanned = !range.collapsed;
1715
1739
  range.deleteContents();
1740
+ if (spanned && this.multiline) collapseEmptyEdges(this.editable, range);
1716
1741
 
1717
1742
  const start = selectionCharBounds(this.editable, range)[0];
1718
1743
  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