@brandup/ui-richeditor 1.0.36 → 1.0.39
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 +84 -12
- package/package.json +3 -2
- package/source/editing.ts +91 -28
- package/source/emoji.ts +53 -0
- package/source/format-config.ts +45 -18
- package/source/format.ts +22 -2
- package/source/history.ts +28 -9
- package/source/index.ts +9 -0
- package/source/paragraphs.ts +33 -6
- package/source/richeditor.less +56 -1
- package/source/richeditor.ts +396 -109
- package/source/selection.ts +297 -81
- package/source/serialize.ts +296 -201
- package/source/toolbar.ts +251 -17
- package/svg/emoji.svg +3 -0
- package/svg/erase.svg +1 -0
- package/svg/redo.svg +1 -0
- package/svg/undo.svg +1 -0
package/source/serialize.ts
CHANGED
|
@@ -1,201 +1,296 @@
|
|
|
1
|
-
// Разбор и сериализация значения редактора (HTML | Markdown), модель абзацев и мягких переносов.
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type
|
|
8
|
-
type
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
result +=
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
):
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
1
|
+
// Разбор и сериализация значения редактора (HTML | Markdown), модель абзацев и мягких переносов.
|
|
2
|
+
|
|
3
|
+
import { isBlock } from "./paragraphs";
|
|
4
|
+
import {
|
|
5
|
+
FORMAT_TOOLS,
|
|
6
|
+
defaultFormatMarkers,
|
|
7
|
+
type FormatMarkers,
|
|
8
|
+
type FormatStorage,
|
|
9
|
+
type FormatTool,
|
|
10
|
+
} from "./format-config";
|
|
11
|
+
|
|
12
|
+
function escapeHtml(text: string): string {
|
|
13
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function escapeRegExp(text: string): string {
|
|
17
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Тег → инструмент, только для включённых инструментов. */
|
|
21
|
+
function buildTagMap(tools: FormatTool[]): Record<string, FormatTool> {
|
|
22
|
+
const map: Record<string, FormatTool> = {};
|
|
23
|
+
for (const tool of tools) for (const tag of FORMAT_TOOLS[tool].matchTags) map[tag] = tool;
|
|
24
|
+
return map;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function lineBreak(storage: FormatStorage): string {
|
|
28
|
+
return storage === "html" ? "<br>" : "\n";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function wrap(storage: FormatStorage, tool: FormatTool, inner: string, markers: FormatMarkers): string {
|
|
32
|
+
if (!inner) return inner;
|
|
33
|
+
|
|
34
|
+
const def = FORMAT_TOOLS[tool];
|
|
35
|
+
if (storage === "html") return `<${def.tag}>${inner}</${def.tag}>`;
|
|
36
|
+
|
|
37
|
+
const marker = markers[tool];
|
|
38
|
+
|
|
39
|
+
// Маркер не сработает, если содержимое начинается или заканчивается пробелом, — ни у нас
|
|
40
|
+
// при разборе, ни у мессенджера. Выносим краевые пробелы наружу: разметка сохраняется,
|
|
41
|
+
// а иначе получатель увидел бы сами маркеры.
|
|
42
|
+
const leading = /^\s*/.exec(inner)![0];
|
|
43
|
+
const trailing = /\s*$/.exec(inner.slice(leading.length))![0];
|
|
44
|
+
const core = inner.slice(leading.length, inner.length - trailing.length);
|
|
45
|
+
if (!core) return inner; // одни пробелы — оборачивать нечего
|
|
46
|
+
|
|
47
|
+
return `${leading}${marker}${core}${marker}${trailing}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Сериализует инлайновое содержимое (текст, форматирование, <br> как мягкий перенос).
|
|
51
|
+
// Абзацы (<p>/<div>) на этом уровне не учитываются — их разбирает serializeParagraphs.
|
|
52
|
+
function serializeInline(
|
|
53
|
+
nodes: ArrayLike<ChildNode>,
|
|
54
|
+
storage: FormatStorage,
|
|
55
|
+
tagMap: Record<string, FormatTool>,
|
|
56
|
+
markers: FormatMarkers
|
|
57
|
+
): string {
|
|
58
|
+
let result = "";
|
|
59
|
+
|
|
60
|
+
for (const node of Array.from(nodes)) {
|
|
61
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
62
|
+
const text = node.textContent ?? "";
|
|
63
|
+
result += storage === "html" ? escapeHtml(text) : text;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
68
|
+
|
|
69
|
+
const el = node as HTMLElement;
|
|
70
|
+
const tag = el.tagName;
|
|
71
|
+
|
|
72
|
+
if (tag === "BR") {
|
|
73
|
+
result += lineBreak(storage); // мягкий перенос
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const inner = serializeInline(el.childNodes, storage, tagMap, markers);
|
|
78
|
+
|
|
79
|
+
// вложенный блочный элемент (нестандарт) — без обёртки, просто содержимое
|
|
80
|
+
if (tag === "DIV" || tag === "P") {
|
|
81
|
+
result += inner;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tool = tagMap[tag];
|
|
86
|
+
// неизвестный или отключённый тег — отбрасываем обёртку, оставляем текст
|
|
87
|
+
result += tool ? wrap(storage, tool, inner, markers) : inner;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Хвостовые переносы абзаца отбрасываем — это <br>-заполнители, делающие последнюю строку видимой;
|
|
94
|
+
// мягкий перенос осмыслен только между содержимым (для пустой строки используйте новый абзац).
|
|
95
|
+
function trimTrailingBreaks(inline: string, storage: FormatStorage): string {
|
|
96
|
+
return storage === "html" ? inline.replace(/(?:<br>)+$/, "") : inline.replace(/\n+$/, "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Разбивает верхний уровень на абзацы: <p>/<div> — отдельный абзац, остальное — неявный абзац.
|
|
100
|
+
// HTML: <p>содержимое</p>; Markdown/Plain: абзацы через \n\n, мягкие переносы внутри — \n.
|
|
101
|
+
function serializeParagraphs(
|
|
102
|
+
root: ParentNode,
|
|
103
|
+
storage: FormatStorage,
|
|
104
|
+
tagMap: Record<string, FormatTool>,
|
|
105
|
+
markers: FormatMarkers
|
|
106
|
+
): string {
|
|
107
|
+
const paragraphs: string[] = [];
|
|
108
|
+
let buffer: ChildNode[] = [];
|
|
109
|
+
|
|
110
|
+
const flush = () => {
|
|
111
|
+
if (buffer.length) paragraphs.push(serializeInline(buffer, storage, tagMap, markers));
|
|
112
|
+
buffer = [];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
for (const node of Array.from(root.childNodes)) {
|
|
116
|
+
if (isBlock(node)) {
|
|
117
|
+
flush();
|
|
118
|
+
paragraphs.push(serializeInline((node as Element).childNodes, storage, tagMap, markers));
|
|
119
|
+
} else {
|
|
120
|
+
buffer.push(node);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
flush();
|
|
124
|
+
|
|
125
|
+
const cleaned = paragraphs.map((p) => trimTrailingBreaks(p, storage));
|
|
126
|
+
|
|
127
|
+
if (storage === "html") return cleaned.map((p) => `<p>${p}</p>`).join("");
|
|
128
|
+
return cleaned.join("\n\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Сериализует содержимое редактора в строку для хранения. Сохраняются только включённые инструменты.
|
|
133
|
+
* При paragraphs=true применяется модель «абзацы (<p>/\n\n) + мягкие переносы (<br>/\n)».
|
|
134
|
+
*/
|
|
135
|
+
export function serialize(
|
|
136
|
+
root: HTMLElement,
|
|
137
|
+
storage: FormatStorage,
|
|
138
|
+
tools: FormatTool[],
|
|
139
|
+
markers: FormatMarkers = defaultFormatMarkers(),
|
|
140
|
+
paragraphs = false
|
|
141
|
+
): string {
|
|
142
|
+
const tagMap = buildTagMap(tools);
|
|
143
|
+
|
|
144
|
+
if (paragraphs) return serializeParagraphs(root, storage, tagMap, markers).trim();
|
|
145
|
+
|
|
146
|
+
const inline = serializeInline(root.childNodes, storage, tagMap, markers);
|
|
147
|
+
if (storage === "html")
|
|
148
|
+
return inline
|
|
149
|
+
.replace(/^(?:<br>)+/, "")
|
|
150
|
+
.replace(/(?:<br>)+$/, "")
|
|
151
|
+
.trim();
|
|
152
|
+
return inline.replace(/^\n+/, "").replace(/\n+$/, "").trim();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Инструмент, его маркер и признак «содержимое не может начинаться/заканчиваться самим маркером». */
|
|
156
|
+
type MarkerRule = [tool: FormatTool, marker: string, standalone: boolean];
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Маркеры в порядке применения: длинный (`__`) раньше короткого-префикса (`_`), иначе
|
|
160
|
+
* короткий съест половину длинного. Считается один раз на разбор — `markdownInline`
|
|
161
|
+
* вызывается на каждый абзац.
|
|
162
|
+
*
|
|
163
|
+
* Если короткий маркер является префиксом длинного и принадлежит другой инструкции
|
|
164
|
+
* (`_` курсив внутри `__` подчёркивания), длинный не должен захватывать лишний символ:
|
|
165
|
+
* иначе `___текст___` разберётся как подчёркивание с `_` по краям вместо курсива поверх
|
|
166
|
+
* подчёркивания. Так же поступают паттерны конвертеров.
|
|
167
|
+
*/
|
|
168
|
+
function orderedMarkers(tools: FormatTool[], markers: FormatMarkers): MarkerRule[] {
|
|
169
|
+
const active = tools.filter((tool) => markers[tool]).sort((a, b) => markers[b].length - markers[a].length);
|
|
170
|
+
|
|
171
|
+
return active.map((tool) => {
|
|
172
|
+
const marker = markers[tool];
|
|
173
|
+
const hasShorterPrefix = active.some((other) => {
|
|
174
|
+
const value = markers[other];
|
|
175
|
+
return value.length < marker.length && marker.startsWith(value);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return [tool, marker, hasShorterPrefix];
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const TAG = /<(\/?)([a-z]+)>/g;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Закрыт ли в содержимом каждый тег, который в нём открыт.
|
|
186
|
+
*
|
|
187
|
+
* Маркеры применяются по очереди к уже размеченному тексту, поэтому пара маркеров может
|
|
188
|
+
* пересечь чужой тег: `**a _b** c_` дал бы `<b>a <i>b</b> c</i>`, а такой HTML браузер
|
|
189
|
+
* перестроит по-своему, и форматирование «протечёт» за пределы разметки. Пересекающуюся
|
|
190
|
+
* пару оставляем текстом — так же поступают мессенджеры. Собственный текст пользователя
|
|
191
|
+
* сюда не попадает: `<` в нём уже заэкранирован, тег может быть только нашим.
|
|
192
|
+
*/
|
|
193
|
+
function balancedTags(inner: string): boolean {
|
|
194
|
+
if (!inner.includes("<")) return true;
|
|
195
|
+
|
|
196
|
+
const stack: string[] = [];
|
|
197
|
+
for (const [, closing, name] of inner.matchAll(TAG)) {
|
|
198
|
+
if (!closing) stack.push(name);
|
|
199
|
+
else if (stack.pop() !== name) return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return stack.length === 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Markdown-разметка одного абзаца → инлайновый HTML (escape, маркеры, \n→<br>).
|
|
206
|
+
function markdownInline(text: string, order: MarkerRule[]): string {
|
|
207
|
+
let html = escapeHtml(text);
|
|
208
|
+
|
|
209
|
+
for (const [tool, marker, standalone] of order) {
|
|
210
|
+
const def = FORMAT_TOOLS[tool];
|
|
211
|
+
html = html.replace(markerPattern(marker, standalone), (match, lead: string, inner: string) =>
|
|
212
|
+
balancedTags(inner) ? `${lead}<${def.tag}>${inner}</${def.tag}>` : match
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// переносы — после маркеров: пока это \n, запрет на пересечение строки работает
|
|
217
|
+
return html.replace(/\r?\n/g, "<br>");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Разметка распознаётся по правилам мессенджеров: маркер стоит на границе слова, содержимое
|
|
222
|
+
* не начинается и не заканчивается пробелом и не пересекает перенос строки. Иначе `5**4 = 20`
|
|
223
|
+
* или `2 ** 2 ** 2` превращались бы в текст с форматированием, которого получатель не увидит.
|
|
224
|
+
*
|
|
225
|
+
* Граница — всё, что не буква и не цифра (включая `_`). Именно `\p{L}\p{N}`, а не `\W`:
|
|
226
|
+
* в JavaScript `\w` — только ASCII, поэтому с `\W` каждая кириллическая буква считалась бы
|
|
227
|
+
* границей и `файл_имя_файла` разбирался бы как разметка.
|
|
228
|
+
*
|
|
229
|
+
* Левая граница захватывается группой и возвращается на место — lookbehind не используется,
|
|
230
|
+
* его нет в Safari до 16.4.
|
|
231
|
+
*
|
|
232
|
+
* Содержимое не начинается и с U+20E3: в keycap-последовательностях (`*⃣`, `#⃣`, `1⃣`) сам
|
|
233
|
+
* маркер служит базовым символом, и без этого `*⃣раз*` разбиралось бы как разметка вместо эмодзи.
|
|
234
|
+
*
|
|
235
|
+
* При `standalone` содержимое дополнительно не начинается и не заканчивается символом самого
|
|
236
|
+
* маркера — см. {@link orderedMarkers}.
|
|
237
|
+
*/
|
|
238
|
+
function markerPattern(marker: string, standalone: boolean): RegExp {
|
|
239
|
+
const key = standalone ? `${marker}` : marker;
|
|
240
|
+
|
|
241
|
+
let pattern = markerPatterns.get(key);
|
|
242
|
+
if (pattern) return pattern;
|
|
243
|
+
|
|
244
|
+
const escaped = escapeRegExp(marker);
|
|
245
|
+
const boundary = "[^\\p{L}\\p{N}]";
|
|
246
|
+
// символ маркера в классе — экранируем то, что в нём значимо
|
|
247
|
+
const own = standalone ? marker.slice(-1).replace(/[\\\]^-]/g, "\\$&") : "";
|
|
248
|
+
const head = `[^\\s\\u20e3${own}]`;
|
|
249
|
+
const tail = standalone ? `[^\\s${own}]` : "\\S";
|
|
250
|
+
|
|
251
|
+
pattern = new RegExp(`(^|${boundary})${escaped}(${head}|${head}[^\\n]*?${tail})${escaped}(?=$|${boundary})`, "gu");
|
|
252
|
+
markerPatterns.set(key, pattern);
|
|
253
|
+
|
|
254
|
+
return pattern;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Набор маркеров за разбор не меняется, а разбор идёт по абзацам — компилируем каждую
|
|
258
|
+
// регулярку один раз. Флаг `g` переиспользовать безопасно: `replace` сбрасывает lastIndex.
|
|
259
|
+
const markerPatterns = new Map<string, RegExp>();
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Готовит сохранённое значение к отображению в редакторе (возвращает HTML).
|
|
263
|
+
* При paragraphs=true строит <p>-абзацы; HTML-значения санитизируются до разрешённых тегов,
|
|
264
|
+
* Markdown/Plain — разбивается на абзацы по \n\n (мягкий перенос \n → <br>).
|
|
265
|
+
*/
|
|
266
|
+
export function deserialize(
|
|
267
|
+
value: string,
|
|
268
|
+
storage: FormatStorage,
|
|
269
|
+
tools: FormatTool[],
|
|
270
|
+
markers: FormatMarkers = defaultFormatMarkers(),
|
|
271
|
+
paragraphs = false
|
|
272
|
+
): string {
|
|
273
|
+
if (!value) return "";
|
|
274
|
+
|
|
275
|
+
if (storage === "markdown") {
|
|
276
|
+
const order = orderedMarkers(tools, markers);
|
|
277
|
+
|
|
278
|
+
if (!paragraphs) return markdownInline(value, order);
|
|
279
|
+
return value
|
|
280
|
+
.split(/\n{2,}/)
|
|
281
|
+
.map((p) => `<p>${markdownInline(p, order) || "<br>"}</p>`)
|
|
282
|
+
.join("");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// html: парсим и пересобираем, отбрасывая всё, кроме разрешённых тегов
|
|
286
|
+
const template = document.createElement("template");
|
|
287
|
+
template.innerHTML = value;
|
|
288
|
+
const tagMap = buildTagMap(tools);
|
|
289
|
+
|
|
290
|
+
if (!paragraphs) return serializeInline(template.content.childNodes, "html", tagMap, defaultFormatMarkers());
|
|
291
|
+
|
|
292
|
+
return serializeParagraphs(template.content, "html", tagMap, defaultFormatMarkers()).replace(
|
|
293
|
+
/<p><\/p>/g,
|
|
294
|
+
"<p><br></p>"
|
|
295
|
+
);
|
|
296
|
+
}
|