@brandup/ui-richeditor 1.0.41 → 1.0.43
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 +53 -3
- package/package.json +2 -2
- package/source/editing.ts +170 -13
- package/source/format-config.ts +117 -3
- package/source/format.ts +18 -1
- package/source/index.ts +5 -0
- package/source/paragraphs.ts +79 -13
- package/source/richeditor.less +54 -0
- package/source/richeditor.ts +204 -15
- package/source/selection.ts +188 -30
- package/source/serialize.ts +479 -296
- package/source/toolbar.ts +119 -11
- package/svg/codeblock.svg +1 -0
- package/svg/mono.svg +3 -0
- package/svg/quote.svg +1 -0
- package/svg/spoiler.svg +1 -0
package/source/paragraphs.ts
CHANGED
|
@@ -2,17 +2,77 @@
|
|
|
2
2
|
// верхнего уровня к абзацам <p> (модель многострочного режима).
|
|
3
3
|
|
|
4
4
|
import { cleanupFormatting } from "./selection";
|
|
5
|
+
import { BLOCK_TYPES, DEFAULT_BLOCK, blockTypeOfTag, type BlockType } from "./format-config";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* Что в содержимом редактора считается
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Что в содержимом редактора считается блоком. Единственное определение модели: по нему идут
|
|
9
|
+
* и разбор с сериализацией, и правки каретки, и нормализация. `<div>` признаём наравне с `<p>` —
|
|
10
|
+
* его приносят вставка и чужой contenteditable, а нормализация приводит к `<p>`.
|
|
10
11
|
*/
|
|
11
12
|
export function isBlock(node: Node): boolean {
|
|
12
|
-
|
|
13
|
+
return blockTypeOf(node) !== null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Тип блока по элементу; null — не блок верхнего уровня. */
|
|
17
|
+
export function blockTypeOf(node: Node | null | undefined): BlockType | null {
|
|
18
|
+
if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
|
|
19
|
+
|
|
20
|
+
return blockTypeOfTag((node as Element).tagName);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Блок, в котором лежит узел (обычно якорь выделения), в пределах редактора. */
|
|
24
|
+
export function blockAt(root: HTMLElement, node: Node | null | undefined): HTMLElement | null {
|
|
25
|
+
let current: Node | null = node ?? null;
|
|
26
|
+
while (current && current !== root) {
|
|
27
|
+
if (current.parentNode === root && isBlock(current)) return current as HTMLElement;
|
|
28
|
+
current = current.parentNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Блоки, которых касается диапазон, по порядку. Границы берём по их блокам, а не перебором
|
|
36
|
+
* с проверкой пересечения: свёрнутая каретка на стыке пересекает сразу два соседних блока,
|
|
37
|
+
* и правка уходила бы в лишний.
|
|
38
|
+
*/
|
|
39
|
+
export function blocksInRange(root: HTMLElement, range: Range): HTMLElement[] {
|
|
40
|
+
// Контейнером может быть и сам редактор — тогда позиция адресует ребёнка по номеру.
|
|
41
|
+
// Позиция — это место ПЕРЕД ребёнком, поэтому конец выделения относится к предыдущему
|
|
42
|
+
// блоку: иначе правка захватывала бы блок, из которого не выделено ни символа.
|
|
43
|
+
const edge = (container: Node, offset: number, end: boolean): HTMLElement | null => {
|
|
44
|
+
const own = blockAt(root, container);
|
|
45
|
+
if (own) return own;
|
|
46
|
+
if (container !== root) return null;
|
|
47
|
+
|
|
48
|
+
const index = end && offset > 0 ? offset - 1 : offset;
|
|
49
|
+
const child = root.childNodes[index] ?? root.lastChild;
|
|
50
|
+
|
|
51
|
+
return blockAt(root, child) ?? (isBlock(child as Node) ? (child as HTMLElement) : null);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const first = edge(range.startContainer, range.startOffset, false);
|
|
55
|
+
if (!first) return [];
|
|
56
|
+
|
|
57
|
+
const last = edge(range.endContainer, range.endOffset, !range.collapsed) ?? first;
|
|
58
|
+
|
|
59
|
+
const blocks: HTMLElement[] = [];
|
|
60
|
+
for (let el: HTMLElement | null = first; el; el = el.nextElementSibling as HTMLElement | null) {
|
|
61
|
+
if (isBlock(el)) blocks.push(el);
|
|
62
|
+
if (el === last) return blocks;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Конца не встретили — он лежит раньше начала (диапазон собран вручную и вывернут).
|
|
66
|
+
// Берём один блок: пройтись до конца содержимого значило бы править всё подряд.
|
|
67
|
+
return [first];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Пустой блок нужного типа с заполнителем: без него строка не видна и в неё не встать кареткой. */
|
|
71
|
+
export function createBlock(type: BlockType): HTMLElement {
|
|
72
|
+
const block = document.createElement(BLOCK_TYPES[type].tag);
|
|
73
|
+
block.appendChild(document.createElement("br"));
|
|
13
74
|
|
|
14
|
-
|
|
15
|
-
return tag === "P" || tag === "DIV";
|
|
75
|
+
return block;
|
|
16
76
|
}
|
|
17
77
|
|
|
18
78
|
// Пробел, таб и неразрывный пробел (U+00A0) — всё это «пробел» при наборе; см. normalizeWhitespace.
|
|
@@ -48,8 +108,10 @@ export function normalizeWhitespace(root: HTMLElement) {
|
|
|
48
108
|
if (el.tagName === "BR") {
|
|
49
109
|
items.push({ kind: "break" });
|
|
50
110
|
} else if (isBlock(el)) {
|
|
111
|
+
// у блока без инлайновой разметки (код) пробелы значимы: отступы там — часть текста
|
|
112
|
+
const type = blockTypeOf(el);
|
|
51
113
|
items.push({ kind: "break" });
|
|
52
|
-
flatten(el);
|
|
114
|
+
if (!type || BLOCK_TYPES[type].inline) flatten(el);
|
|
53
115
|
items.push({ kind: "break" });
|
|
54
116
|
} else {
|
|
55
117
|
flatten(el); // инлайновый тег — не разрывает строку
|
|
@@ -94,7 +156,9 @@ export function normalizeWhitespace(root: HTMLElement) {
|
|
|
94
156
|
*/
|
|
95
157
|
export function normalizeParagraphs(root: HTMLElement) {
|
|
96
158
|
for (const el of Array.from(root.children)) {
|
|
97
|
-
|
|
159
|
+
// Пустой блок другого типа не трогаем: его завели осознанно и в него сейчас будут писать,
|
|
160
|
+
// а пустая строка внутри кода вообще осмысленна сама по себе.
|
|
161
|
+
if (blockTypeOf(el) === DEFAULT_BLOCK && (el.textContent ?? "").trim() === "") el.remove();
|
|
98
162
|
}
|
|
99
163
|
}
|
|
100
164
|
|
|
@@ -107,9 +171,9 @@ export function ensureParagraphs(root: HTMLElement) {
|
|
|
107
171
|
|
|
108
172
|
const flushRun = (before: Node | null) => {
|
|
109
173
|
if (!run.length) return;
|
|
110
|
-
const
|
|
111
|
-
for (const node of run)
|
|
112
|
-
root.insertBefore(
|
|
174
|
+
const block = document.createElement(BLOCK_TYPES[DEFAULT_BLOCK].tag);
|
|
175
|
+
for (const node of run) block.appendChild(node);
|
|
176
|
+
root.insertBefore(block, before);
|
|
113
177
|
run = [];
|
|
114
178
|
};
|
|
115
179
|
|
|
@@ -118,8 +182,10 @@ export function ensureParagraphs(root: HTMLElement) {
|
|
|
118
182
|
|
|
119
183
|
if (el && isBlock(el)) {
|
|
120
184
|
flushRun(node);
|
|
185
|
+
// `<div>` — тот же абзац, только чужой: приводим к каноническому тегу.
|
|
186
|
+
// Блоки других типов оставляем как есть, их завели осознанно.
|
|
121
187
|
if (el.tagName === "DIV") {
|
|
122
|
-
const p = document.createElement(
|
|
188
|
+
const p = document.createElement(BLOCK_TYPES[DEFAULT_BLOCK].tag);
|
|
123
189
|
while (el.firstChild) p.appendChild(el.firstChild);
|
|
124
190
|
root.replaceChild(p, el);
|
|
125
191
|
}
|
|
@@ -129,7 +195,7 @@ export function ensureParagraphs(root: HTMLElement) {
|
|
|
129
195
|
}
|
|
130
196
|
flushRun(null);
|
|
131
197
|
|
|
132
|
-
for (const p of Array.from(root.
|
|
198
|
+
for (const p of Array.from(root.children) as HTMLElement[]) {
|
|
133
199
|
if (!p.firstChild) {
|
|
134
200
|
p.appendChild(document.createElement("br")); // пустой абзац — заполнитель для видимости строки
|
|
135
201
|
continue;
|
package/source/richeditor.less
CHANGED
|
@@ -25,6 +25,36 @@
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Цитата: линия слева, как её рисуют мессенджеры. Вертикальные поля те же, что у абзаца, —
|
|
29
|
+
// строка не должна прыгать при смене типа блока.
|
|
30
|
+
& blockquote {
|
|
31
|
+
margin: 0;
|
|
32
|
+
padding: 5px 0 5px 10px;
|
|
33
|
+
border-left: 3px solid var(--richeditor-quote-line, rgba(0, 0, 0, 0.2));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Блок кода: подложка на всю строку и моноширинный шрифт. Собственные поля тут нужны —
|
|
37
|
+
// иначе текст упирался бы в край подложки; краевые правила абзаца на него не распространяются.
|
|
38
|
+
& pre {
|
|
39
|
+
margin: 5px 0;
|
|
40
|
+
padding: 5px 8px;
|
|
41
|
+
border-radius: 3px;
|
|
42
|
+
background-color: var(--richeditor-code-fill, rgba(0, 0, 0, 0.06));
|
|
43
|
+
font-family: var(--richeditor-code-font, ui-monospace, "Cascadia Mono", Consolas, monospace);
|
|
44
|
+
font-size: 0.92em;
|
|
45
|
+
// содержимое переносится по границе редактора, как и обычный текст
|
|
46
|
+
white-space: pre-wrap;
|
|
47
|
+
word-wrap: anywhere;
|
|
48
|
+
|
|
49
|
+
&:first-child {
|
|
50
|
+
margin-top: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
&:last-child {
|
|
54
|
+
margin-bottom: 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
// видимые теги форматирования
|
|
29
59
|
& b,
|
|
30
60
|
& strong {
|
|
@@ -44,6 +74,29 @@
|
|
|
44
74
|
text-decoration: underline;
|
|
45
75
|
}
|
|
46
76
|
|
|
77
|
+
// Спойлер: у получателя текст закрыт до нажатия, а в поле его прячут не от автора —
|
|
78
|
+
// показываем плашкой, чтобы было видно и что написано, и что оно будет скрыто.
|
|
79
|
+
& spoiler,
|
|
80
|
+
& tg-spoiler {
|
|
81
|
+
border-radius: 3px;
|
|
82
|
+
padding: 0 2px;
|
|
83
|
+
background-color: var(--richeditor-spoiler-fill, rgba(0, 0, 0, 0.14));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Строку не раздвигает. Наследовать межстрочный интервал для этого мало: у моноширинного
|
|
87
|
+
// шрифта своя база, и коробка той же высоты садится относительно неё со сдвигом — строка
|
|
88
|
+
// вырастает на пару пикселей. Нулевой интервал убирает вклад кода в высоту строки совсем,
|
|
89
|
+
// а высоту строки задаёт сам абзац. Подложка от этого не страдает: она рисуется по буквам,
|
|
90
|
+
// а не по интервалу. По вертикали отступов у кода нет.
|
|
91
|
+
& code {
|
|
92
|
+
border-radius: 3px;
|
|
93
|
+
padding: 0 3px;
|
|
94
|
+
background-color: var(--richeditor-code-fill, rgba(0, 0, 0, 0.06));
|
|
95
|
+
font-family: var(--richeditor-code-font, ui-monospace, "Cascadia Mono", Consolas, monospace);
|
|
96
|
+
font-size: 0.92em;
|
|
97
|
+
line-height: 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
47
100
|
&:empty:after {
|
|
48
101
|
content: attr(data-placeholder);
|
|
49
102
|
display: block;
|
|
@@ -122,6 +175,7 @@
|
|
|
122
175
|
}
|
|
123
176
|
|
|
124
177
|
& .format-button,
|
|
178
|
+
& .block-button,
|
|
125
179
|
& .host-button,
|
|
126
180
|
& .action-button {
|
|
127
181
|
width: calc(var(--input-height, 46px) - 12px);
|
package/source/richeditor.ts
CHANGED
|
@@ -3,8 +3,13 @@ import "./richeditor.less"; // стили редактора и панели ф
|
|
|
3
3
|
import { DOM, UIElementBound } from "@brandup/ui";
|
|
4
4
|
import {
|
|
5
5
|
ALL_FORMAT_TOOLS,
|
|
6
|
+
BLOCK_TYPES,
|
|
7
|
+
DEFAULT_BLOCK,
|
|
6
8
|
HOTKEY_TOOLS,
|
|
7
9
|
activeFormats,
|
|
10
|
+
blockAt,
|
|
11
|
+
blockTypeOf,
|
|
12
|
+
blocksInRange,
|
|
8
13
|
clearAllFormat,
|
|
9
14
|
clearFormat,
|
|
10
15
|
defaultFormatMarkers,
|
|
@@ -17,6 +22,9 @@ import {
|
|
|
17
22
|
documentSelection,
|
|
18
23
|
innerSelection,
|
|
19
24
|
mapCharOffset,
|
|
25
|
+
normalizeBlockTypes,
|
|
26
|
+
editorText,
|
|
27
|
+
charLength,
|
|
20
28
|
preserveCaret,
|
|
21
29
|
normalizeParagraphs,
|
|
22
30
|
normalizeWhitespace,
|
|
@@ -24,6 +32,7 @@ import {
|
|
|
24
32
|
selectionCharBounds,
|
|
25
33
|
serialize,
|
|
26
34
|
toggleFormat,
|
|
35
|
+
type BlockType,
|
|
27
36
|
type EditorAction,
|
|
28
37
|
type FormatMarkers,
|
|
29
38
|
type FormatStorage,
|
|
@@ -31,6 +40,8 @@ import {
|
|
|
31
40
|
type FormatTool,
|
|
32
41
|
} from "./format";
|
|
33
42
|
import {
|
|
43
|
+
applyBlocks,
|
|
44
|
+
atBlockStart,
|
|
34
45
|
buildParagraphs,
|
|
35
46
|
caretToEnd,
|
|
36
47
|
expandRangeToWords,
|
|
@@ -53,6 +64,9 @@ export const CHANGE_EVENT = "richeditor-change";
|
|
|
53
64
|
|
|
54
65
|
const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Escape"];
|
|
55
66
|
|
|
67
|
+
// Код — единственное, что есть и инструментом, и типом блока: имя одно на оба (см. applyCode).
|
|
68
|
+
const CODE = "code";
|
|
69
|
+
|
|
56
70
|
// нативные правки (печать/удаление), состояние до которых запоминаем для собственного undo;
|
|
57
71
|
// вставка/перетаскивание и Enter обрабатываются отдельно, undo/redo — на keydown
|
|
58
72
|
const NATIVE_EDIT_TYPES = new Set([
|
|
@@ -104,6 +118,11 @@ export interface RichEditorOptions {
|
|
|
104
118
|
multiline?: boolean;
|
|
105
119
|
/** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
|
|
106
120
|
paragraph?: ParagraphMode;
|
|
121
|
+
/**
|
|
122
|
+
* Типы блоков в многострочном режиме: цитата, блок кода (по умолчанию только обычный текст).
|
|
123
|
+
* Обычный текст в наборе есть всегда — в него блок возвращают.
|
|
124
|
+
*/
|
|
125
|
+
blocks?: BlockType[];
|
|
107
126
|
/** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
|
|
108
127
|
readonly?: boolean;
|
|
109
128
|
/** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
|
|
@@ -142,6 +161,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
142
161
|
readonly formatMarkers: FormatMarkers;
|
|
143
162
|
readonly multiline: boolean;
|
|
144
163
|
readonly paragraph: ParagraphMode;
|
|
164
|
+
readonly blockTypes: BlockType[];
|
|
145
165
|
readonly toolbarContainer: HTMLElement | null;
|
|
146
166
|
readonly toolbarButtons: ToolbarButton[];
|
|
147
167
|
|
|
@@ -186,6 +206,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
186
206
|
this.formatMarkers = Object.assign(defaultFormatMarkers(), options.markers);
|
|
187
207
|
this.multiline = multiline;
|
|
188
208
|
this.paragraph = options.paragraph ?? "block";
|
|
209
|
+
// Блоки — часть многострочной модели: в однострочном режиме верхнего уровня нет вовсе,
|
|
210
|
+
// а в readonly их не переключить, но разбор и показ значения обязаны работать и там.
|
|
211
|
+
this.blockTypes = multiline ? normalizeBlockTypes(options.blocks) : [DEFAULT_BLOCK];
|
|
189
212
|
this.toolbarContainer = options.toolbarContainer ?? null;
|
|
190
213
|
// кнопки хоста живут и без форматирования, но не в readonly — там панели нет вовсе
|
|
191
214
|
this.toolbarButtons = readonly ? [] : (options.buttons ?? []);
|
|
@@ -232,7 +255,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
232
255
|
|
|
233
256
|
getValue(): string {
|
|
234
257
|
// multiline → модель абзацев (<p>/\n\n) и мягких переносов (<br>/\n)
|
|
235
|
-
return serialize(
|
|
258
|
+
return serialize(
|
|
259
|
+
this.editable,
|
|
260
|
+
this.__valueStorage,
|
|
261
|
+
this.__valueTools,
|
|
262
|
+
this.formatMarkers,
|
|
263
|
+
this.multiline,
|
|
264
|
+
this.blockTypes,
|
|
265
|
+
this.__blockParagraphs
|
|
266
|
+
);
|
|
236
267
|
}
|
|
237
268
|
|
|
238
269
|
setValue(value: string): void {
|
|
@@ -415,11 +446,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
415
446
|
};
|
|
416
447
|
}
|
|
417
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Доступен ли инструмент в текущем месте. В коде разметки нет: и моноширинный, и блок кода
|
|
451
|
+
* оставляют написанное буквальным, поэтому остальные инструменты там выключены — иначе
|
|
452
|
+
* форматирование ставилось бы только чтобы пропасть при сохранении.
|
|
453
|
+
*/
|
|
454
|
+
isToolEnabled(tool: FormatTool): boolean {
|
|
455
|
+
if (this.readonly || !this.formatTools.includes(tool)) return false;
|
|
456
|
+
if (tool === CODE) return true;
|
|
457
|
+
|
|
458
|
+
return this.currentBlock !== CODE && !this.isToolActive(CODE);
|
|
459
|
+
}
|
|
460
|
+
|
|
418
461
|
/** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
|
|
419
462
|
applyFormat(tool: FormatTool): void {
|
|
420
|
-
if (!this.
|
|
463
|
+
if (!this.isToolEnabled(tool)) return;
|
|
421
464
|
|
|
422
|
-
|
|
465
|
+
let target = this.__formatTarget();
|
|
423
466
|
if (!target) return;
|
|
424
467
|
|
|
425
468
|
if (target.range.collapsed) {
|
|
@@ -434,12 +477,126 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
434
477
|
this.__pendingFormats.clear();
|
|
435
478
|
|
|
436
479
|
this.__history?.record("op");
|
|
480
|
+
|
|
481
|
+
// Код не уживается с другой разметкой: значение берёт из него голый текст. Снимаем всё,
|
|
482
|
+
// что было на этом куске, и только потом оборачиваем — иначе прежнее форматирование
|
|
483
|
+
// осталось бы снаружи кода и в поле выглядело бы рабочим.
|
|
484
|
+
if (
|
|
485
|
+
tool === CODE &&
|
|
486
|
+
!isFormatActive(this.editable, target.range, CODE) &&
|
|
487
|
+
hasFormatting(this.editable, target.range)
|
|
488
|
+
) {
|
|
489
|
+
clearFormat(this.editable, target.range, target.selection, target.original());
|
|
490
|
+
|
|
491
|
+
// правка перестроила узлы — прежний диапазон указывает в никуда
|
|
492
|
+
target = this.__formatTarget();
|
|
493
|
+
if (!target) return;
|
|
494
|
+
}
|
|
495
|
+
|
|
437
496
|
toggleFormat(this.editable, target.range, tool, target.selection, target.original());
|
|
438
497
|
|
|
439
498
|
this.__emitChange();
|
|
440
499
|
formatToolbar.refresh();
|
|
441
500
|
}
|
|
442
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Тип блока под кареткой. Без выделения (или вне блоков) — тип по умолчанию: именно им
|
|
504
|
+
* станет то, что сейчас наберут.
|
|
505
|
+
*/
|
|
506
|
+
get currentBlock(): BlockType {
|
|
507
|
+
const selection = this.selection;
|
|
508
|
+
if (!selection) return DEFAULT_BLOCK;
|
|
509
|
+
|
|
510
|
+
const block = blockAt(this.editable, selection.getRangeAt(0).startContainer);
|
|
511
|
+
|
|
512
|
+
return blockTypeOf(block) ?? DEFAULT_BLOCK;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Переключить тип блоков под выделением. Повторное применение того же типа возвращает
|
|
517
|
+
* блок к обычному тексту — иначе выйти из цитаты можно было бы только через другую кнопку.
|
|
518
|
+
*/
|
|
519
|
+
applyBlock(type: BlockType): void {
|
|
520
|
+
if (!this.multiline || this.readonly || !this.blockTypes.includes(type)) return;
|
|
521
|
+
|
|
522
|
+
const selection = this.selection;
|
|
523
|
+
if (!selection) return;
|
|
524
|
+
|
|
525
|
+
const range = selection.getRangeAt(0);
|
|
526
|
+
const target = this.currentBlock === type ? DEFAULT_BLOCK : type;
|
|
527
|
+
|
|
528
|
+
// нечего менять — не пишем в историю пустой шаг и не трогаем выделение.
|
|
529
|
+
// В пустом редакторе блока ещё нет, но завести его можно (кроме типа по умолчанию —
|
|
530
|
+
// им пустое поле и так является).
|
|
531
|
+
const blocks = blocksInRange(this.editable, range);
|
|
532
|
+
const nothing = this.editable.firstChild
|
|
533
|
+
? !blocks.length || blocks.every((block) => blockTypeOf(block) === target)
|
|
534
|
+
: target === DEFAULT_BLOCK;
|
|
535
|
+
|
|
536
|
+
if (nothing) return;
|
|
537
|
+
|
|
538
|
+
this.__history?.record("op");
|
|
539
|
+
|
|
540
|
+
// Смена тега переносит содержимое в новый элемент — живые границы выделения этого
|
|
541
|
+
// не переживают, поэтому держим каретку по текстовым смещениям.
|
|
542
|
+
const bounds = selectionCharBounds(this.editable, range);
|
|
543
|
+
const { created } = applyBlocks(this.editable, range, target);
|
|
544
|
+
|
|
545
|
+
// Разделение блока добавило границы, а они в смещениях считаются — прежние уже не те.
|
|
546
|
+
// Выделяем то, что стало блоком: заодно видно, к каким строкам правка и относилась.
|
|
547
|
+
// В пустом блоке выделять нечего — там только каретка.
|
|
548
|
+
if (created) {
|
|
549
|
+
const inside = this.editable.ownerDocument.createRange();
|
|
550
|
+
inside.selectNodeContents(created);
|
|
551
|
+
if (!(created.textContent ?? "")) inside.collapse(true);
|
|
552
|
+
|
|
553
|
+
selection.removeAllRanges();
|
|
554
|
+
selection.addRange(inside);
|
|
555
|
+
} else {
|
|
556
|
+
restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
this.__emitChange();
|
|
560
|
+
formatToolbar.refresh();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Код в любом виде: моноширинный на выделении или блок кода под кареткой. */
|
|
564
|
+
isCodeActive(): boolean {
|
|
565
|
+
return this.currentBlock === CODE || this.isToolActive(CODE);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Единая правка кода — то, что в мессенджерах делает одна кнопка. Вид выбирается по объёму
|
|
570
|
+
* правки: выделена одна строка или её часть — моноширинный, больше одной строки — блок.
|
|
571
|
+
*
|
|
572
|
+
* Блоком становится и правка без выделения: моноширинным там делать нечего, а иначе блок
|
|
573
|
+
* оказался бы недостижим — в пустом поле не выделить строки, которых ещё нет.
|
|
574
|
+
*
|
|
575
|
+
* Повторное применение снимает тот вид, который сейчас и включён.
|
|
576
|
+
*/
|
|
577
|
+
applyCode(): void {
|
|
578
|
+
const inline = this.formatTools.includes(CODE);
|
|
579
|
+
const block = this.multiline && this.blockTypes.includes(CODE);
|
|
580
|
+
if (!inline && !block) return;
|
|
581
|
+
|
|
582
|
+
if (block && this.currentBlock === CODE) return this.applyBlock(CODE); // выйти из блока
|
|
583
|
+
if (inline && this.isToolActive(CODE)) return this.applyFormat(CODE); // снять моноширинный
|
|
584
|
+
|
|
585
|
+
const range = this.selection?.getRangeAt(0);
|
|
586
|
+
const part = !!range && !range.collapsed && !this.__coversLines(range);
|
|
587
|
+
|
|
588
|
+
if (inline && (part || !block)) this.applyFormat(CODE);
|
|
589
|
+
else if (block) this.applyBlock(CODE);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Выделено больше одной строки: захвачено несколько блоков либо мягкий перенос внутри.
|
|
594
|
+
* Одна строка — даже целиком — остаётся куском текста: код на ней это моноширинный.
|
|
595
|
+
*/
|
|
596
|
+
private __coversLines(range: Range): boolean {
|
|
597
|
+
return blocksInRange(this.editable, range).length > 1 || !!range.cloneContents().querySelector("br");
|
|
598
|
+
}
|
|
599
|
+
|
|
443
600
|
/**
|
|
444
601
|
* Снимает всё форматирование с выделения; без выделения — со слова под кареткой
|
|
445
602
|
* (как и применение формата). Режим набора сбрасывается.
|
|
@@ -593,20 +750,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
593
750
|
DOM.empty(this.editable);
|
|
594
751
|
if (!value) return;
|
|
595
752
|
|
|
596
|
-
// multiline → <p>-абзацы; single-line → инлайновое
|
|
597
|
-
|
|
753
|
+
// multiline → <p>-абзацы; single-line → инлайновое содержимое. Блоки разбираются и в режиме
|
|
754
|
+
// мягких переносов: цитата и код узнаются по собственной разметке, а не по пустой строке.
|
|
755
|
+
const blocks = this.blockTypes.length > 1;
|
|
756
|
+
const paragraphs = this.__blockParagraphs || (this.multiline && blocks);
|
|
598
757
|
|
|
599
758
|
this.editable.innerHTML = deserialize(
|
|
600
759
|
value,
|
|
601
760
|
this.__valueStorage,
|
|
602
761
|
this.__valueTools,
|
|
603
762
|
this.formatMarkers,
|
|
604
|
-
paragraphs
|
|
763
|
+
paragraphs,
|
|
764
|
+
this.blockTypes,
|
|
765
|
+
this.__blockParagraphs
|
|
605
766
|
);
|
|
606
767
|
|
|
607
768
|
// в break инлайновое содержимое оборачивается в единственный абзац — модель абзацев
|
|
608
769
|
// нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
|
|
609
|
-
if (this.multiline && !
|
|
770
|
+
if (this.multiline && !this.__blockParagraphs) ensureParagraphs(this.editable);
|
|
610
771
|
}
|
|
611
772
|
|
|
612
773
|
/**
|
|
@@ -659,14 +820,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
659
820
|
const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
660
821
|
|
|
661
822
|
const before = this.editable.innerHTML;
|
|
662
|
-
|
|
823
|
+
// текст в координатах каретки (с концами строк) — иначе смещения разъедутся на число строк
|
|
824
|
+
const textBefore = editorText(this.editable);
|
|
663
825
|
normalizeWhitespace(this.editable);
|
|
664
826
|
if (this.multiline) normalizeParagraphs(this.editable);
|
|
665
827
|
if (this.editable.innerHTML === before) return;
|
|
666
828
|
|
|
667
829
|
if (bounds && selection) {
|
|
668
830
|
// схлопнутые пробелы сдвигают смещения — переносим их на новый текст
|
|
669
|
-
const textAfter = this.editable
|
|
831
|
+
const textAfter = editorText(this.editable);
|
|
670
832
|
const start = mapCharOffset(textBefore, textAfter, bounds[0]);
|
|
671
833
|
const end = bounds[1] === bounds[0] ? start : mapCharOffset(textBefore, textAfter, bounds[1]);
|
|
672
834
|
restoreSelection(this.editable, start, end, selection);
|
|
@@ -885,17 +1047,39 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
885
1047
|
if (this.readonly) return;
|
|
886
1048
|
|
|
887
1049
|
// В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
|
|
888
|
-
// В режиме break
|
|
889
|
-
//
|
|
1050
|
+
// В режиме break строку переносят оба нажатия: пустая строка набирается двумя
|
|
1051
|
+
// переносами, как в мессенджерах, и отдельный абзац дал бы в значении ровно её же.
|
|
1052
|
+
// Внутри блока правило берётся у его типа: из цитаты и кода Enter выходит,
|
|
1053
|
+
// а модификатор переносит строку внутри.
|
|
890
1054
|
const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
|
|
891
|
-
const
|
|
1055
|
+
const current = this.currentBlock;
|
|
1056
|
+
const breaks =
|
|
1057
|
+
current === DEFAULT_BLOCK ? this.paragraph === "break" : BLOCK_TYPES[current].enter === "break";
|
|
1058
|
+
const soft = breaks || withModifier;
|
|
892
1059
|
|
|
893
1060
|
this.__history?.record("op");
|
|
1061
|
+
// Из блока выходят тем же нажатием, что делит его: продолжать цитату или код
|
|
1062
|
+
// новым таким же блоком незачем — для этого достаточно переноса строки.
|
|
894
1063
|
if (soft) insertSoftBreak(this.editable);
|
|
895
|
-
else insertParagraph(this.editable);
|
|
1064
|
+
else insertParagraph(this.editable, DEFAULT_BLOCK);
|
|
896
1065
|
|
|
897
1066
|
this.__clearPendingFormats();
|
|
898
1067
|
this.__emitChange();
|
|
1068
|
+
formatToolbar.refresh();
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Backspace в начале блока возвращает его к обычному тексту — так из цитаты или кода
|
|
1073
|
+
// выходят там же, где в них вошли, не трогая содержимого. Дальше по тексту Backspace
|
|
1074
|
+
// работает как обычно.
|
|
1075
|
+
if (e.key === "Backspace" && !this.readonly && this.multiline) {
|
|
1076
|
+
const block = this.currentBlock;
|
|
1077
|
+
const selection = block === DEFAULT_BLOCK ? null : this.selection;
|
|
1078
|
+
|
|
1079
|
+
if (selection && atBlockStart(this.editable, selection.getRangeAt(0))) {
|
|
1080
|
+
e.preventDefault();
|
|
1081
|
+
this.applyBlock(block); // тот же тип — переключение вернёт обычный текст
|
|
1082
|
+
}
|
|
899
1083
|
}
|
|
900
1084
|
}
|
|
901
1085
|
|
|
@@ -946,7 +1130,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
946
1130
|
// Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего —
|
|
947
1131
|
// тогда вызывающий откатывается на простую вставку.
|
|
948
1132
|
private __pasteHtml(html: string, selection: Selection): boolean {
|
|
949
|
-
return this.__insertPasted(
|
|
1133
|
+
return this.__insertPasted(
|
|
1134
|
+
sanitizePastedHtml(html, this.formatTools, this.formatMarkers, this.blockTypes),
|
|
1135
|
+
selection
|
|
1136
|
+
);
|
|
950
1137
|
}
|
|
951
1138
|
|
|
952
1139
|
/**
|
|
@@ -968,7 +1155,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
968
1155
|
let caret: number;
|
|
969
1156
|
|
|
970
1157
|
if (this.multiline) {
|
|
971
|
-
|
|
1158
|
+
// длина вставки в координатах каретки: текст, переносы внутри абзацев и границы между
|
|
1159
|
+
// ними (первый абзац вливается в текущий, поэтому границ на одну меньше)
|
|
1160
|
+
caret = start + paras.reduce((length, p) => length + charLength(p), paras.length - 1);
|
|
972
1161
|
|
|
973
1162
|
insertPastedParagraphs(this.editable, paras, range);
|
|
974
1163
|
ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
|