@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 +8 -9
- package/package.json +2 -2
- package/source/editing.ts +64 -8
- package/source/format.ts +2 -0
- package/source/paragraphs.ts +70 -10
- package/source/richeditor.less +11 -0
- package/source/richeditor.ts +118 -38
- package/source/serialize.ts +14 -14
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"` | Что
|
|
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"` меняет
|
|
185
|
+
Опция `paragraph: "break"` меняет смысл абзаца: там абзац — это строка, как в мессенджерах. Enter и модификатор делают одно и то же (новую строку), мягкому переносу в этом режиме взяться неоткуда, а пустая строка сообщения — это пустой абзац. Это важно при `storage: "markdown"`: в режиме по умолчанию граница абзацев уходит в значение пустой строкой (`\n\n`), а в `break` — одним переносом (`\n`).
|
|
185
186
|
|
|
186
|
-
|
|
187
|
+
Содержимое в обоих режимах — абзацные блоки: каждая строка (или абзац) лежит в своём `<p>`, а не разделяется `<br>` внутри общего. Отступов между абзацами в режиме `break` нет (класс `breaks` на редакторе): отступ читался бы пустой строкой, которой в значении не будет.
|
|
187
188
|
|
|
188
|
-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
504
|
+
* - `block` — абзацы разделяет пустая строка (режим `block` многострочного редактора);
|
|
505
|
+
* - `line` — каждая строка сама себе абзац (режим мягких переносов);
|
|
506
|
+
* - `single` — весь текст одним абзацем, все переносы мягкие (буквальное содержимое блока кода).
|
|
453
507
|
*/
|
|
454
|
-
export function buildParagraphs(lines: string[],
|
|
508
|
+
export function buildParagraphs(lines: string[], mode: "block" | "line" | "single"): HTMLElement[] {
|
|
455
509
|
const groups: string[][] = [];
|
|
456
510
|
|
|
457
|
-
if (
|
|
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,
|
package/source/paragraphs.ts
CHANGED
|
@@ -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,
|
|
158
|
-
if (
|
|
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.
|
|
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
|
-
|
|
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;
|
package/source/richeditor.less
CHANGED
|
@@ -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 {
|
package/source/richeditor.ts
CHANGED
|
@@ -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
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
382
|
+
* Разделяет ли абзацы пустая строка. Содержимое в обоих режимах — абзацные блоки, но значат
|
|
383
|
+
* они разное: в block абзац это абзац (`\n\n`), в break — строка (`\n`), а пустая строка
|
|
384
|
+
* сообщения хранится пустым абзацем. Поэтому в break у абзацев нет и отступов: между двумя
|
|
385
|
+
* строками их на экране быть не должно.
|
|
379
386
|
*/
|
|
380
|
-
private get
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
1163
|
+
this.multiline,
|
|
1103
1164
|
this.blockTypes,
|
|
1104
|
-
this.
|
|
1165
|
+
this.__separateParagraphs
|
|
1105
1166
|
);
|
|
1106
1167
|
|
|
1107
|
-
//
|
|
1108
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
1516
|
-
current === DEFAULT_BLOCK
|
|
1517
|
-
|
|
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.
|
|
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(
|
|
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;
|
package/source/serialize.ts
CHANGED
|
@@ -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 (
|
|
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
|
|