@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 +7 -9
- package/package.json +2 -2
- package/source/editing.ts +64 -8
- package/source/format.ts +1 -0
- package/source/paragraphs.ts +70 -10
- package/source/richeditor.less +11 -0
- package/source/richeditor.ts +52 -27
- 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` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются); разметка значения при этом разбирается и показывается, кнопок для неё просто нет |
|
|
@@ -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"` меняет
|
|
184
|
+
Опция `paragraph: "break"` меняет смысл абзаца: там абзац — это строка, как в мессенджерах. Enter и модификатор делают одно и то же (новую строку), мягкому переносу в этом режиме взяться неоткуда, а пустая строка сообщения — это пустой абзац. Это важно при `storage: "markdown"`: в режиме по умолчанию граница абзацев уходит в значение пустой строкой (`\n\n`), а в `break` — одним переносом (`\n`).
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
Содержимое в обоих режимах — абзацные блоки: каждая строка (или абзац) лежит в своём `<p>`, а не разделяется `<br>` внутри общего. Отступов между абзацами в режиме `break` нет (класс `breaks` на редакторе): отступ читался бы пустой строкой, которой в значении не будет.
|
|
187
187
|
|
|
188
|
-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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
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
|
@@ -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
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
381
|
+
* Разделяет ли абзацы пустая строка. Содержимое в обоих режимах — абзацные блоки, но значат
|
|
382
|
+
* они разное: в block абзац это абзац (`\n\n`), в break — строка (`\n`), а пустая строка
|
|
383
|
+
* сообщения хранится пустым абзацем. Поэтому в break у абзацев нет и отступов: между двумя
|
|
384
|
+
* строками их на экране быть не должно.
|
|
379
385
|
*/
|
|
380
|
-
private get
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
1116
|
+
this.multiline,
|
|
1103
1117
|
this.blockTypes,
|
|
1104
|
-
this.
|
|
1118
|
+
this.__separateParagraphs
|
|
1105
1119
|
);
|
|
1106
1120
|
|
|
1107
|
-
//
|
|
1108
|
-
|
|
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.
|
|
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
|
|
1516
|
-
current === DEFAULT_BLOCK
|
|
1517
|
-
|
|
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.
|
|
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(
|
|
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;
|
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
|
|