@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.
@@ -1,201 +1,296 @@
1
- // Разбор и сериализация значения редактора (HTML | Markdown), модель абзацев и мягких переносов.
2
-
3
- import {
4
- FORMAT_TOOLS,
5
- defaultFormatMarkers,
6
- type FormatMarkers,
7
- type FormatStorage,
8
- type FormatTool,
9
- } from "./format-config";
10
-
11
- function escapeHtml(text: string): string {
12
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
- }
14
-
15
- function escapeRegExp(text: string): string {
16
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
- }
18
-
19
- /** Тег → инструмент, только для включённых инструментов. */
20
- function buildTagMap(tools: FormatTool[]): Record<string, FormatTool> {
21
- const map: Record<string, FormatTool> = {};
22
- for (const tool of tools) for (const tag of FORMAT_TOOLS[tool].matchTags) map[tag] = tool;
23
- return map;
24
- }
25
-
26
- function lineBreak(storage: FormatStorage): string {
27
- return storage === "html" ? "<br>" : "\n";
28
- }
29
-
30
- function wrap(storage: FormatStorage, tool: FormatTool, inner: string, markers: FormatMarkers): string {
31
- if (!inner) return inner;
32
-
33
- const def = FORMAT_TOOLS[tool];
34
- if (storage === "html") return `<${def.tag}>${inner}</${def.tag}>`;
35
-
36
- const marker = markers[tool];
37
- return `${marker}${inner}${marker}`;
38
- }
39
-
40
- // Сериализует инлайновое содержимое (текст, форматирование, <br> как мягкий перенос).
41
- // Абзацы (<p>/<div>) на этом уровне не учитываются — их разбирает serializeParagraphs.
42
- function serializeInline(
43
- nodes: ArrayLike<ChildNode>,
44
- storage: FormatStorage,
45
- tagMap: Record<string, FormatTool>,
46
- markers: FormatMarkers
47
- ): string {
48
- let result = "";
49
-
50
- for (const node of Array.from(nodes)) {
51
- if (node.nodeType === Node.TEXT_NODE) {
52
- const text = node.textContent ?? "";
53
- result += storage === "html" ? escapeHtml(text) : text;
54
- continue;
55
- }
56
-
57
- if (node.nodeType !== Node.ELEMENT_NODE) continue;
58
-
59
- const el = node as HTMLElement;
60
- const tag = el.tagName;
61
-
62
- if (tag === "BR") {
63
- result += lineBreak(storage); // мягкий перенос
64
- continue;
65
- }
66
-
67
- const inner = serializeInline(el.childNodes, storage, tagMap, markers);
68
-
69
- // вложенный блочный элемент (нестандарт) — без обёртки, просто содержимое
70
- if (tag === "DIV" || tag === "P") {
71
- result += inner;
72
- continue;
73
- }
74
-
75
- const tool = tagMap[tag];
76
- // неизвестный или отключённый тег — отбрасываем обёртку, оставляем текст
77
- result += tool ? wrap(storage, tool, inner, markers) : inner;
78
- }
79
-
80
- return result;
81
- }
82
-
83
- // Хвостовые переносы абзаца отбрасываем — это <br>-заполнители, делающие последнюю строку видимой;
84
- // мягкий перенос осмыслен только между содержимым (для пустой строки используйте новый абзац).
85
- function trimTrailingBreaks(inline: string, storage: FormatStorage): string {
86
- return storage === "html" ? inline.replace(/(?:<br>)+$/, "") : inline.replace(/\n+$/, "");
87
- }
88
-
89
- // Разбивает верхний уровень на абзацы: <p>/<div> — отдельный абзац, остальное — неявный абзац.
90
- // HTML: <p>содержимое</p>; Markdown/Plain: абзацы через \n\n, мягкие переносы внутри — \n.
91
- function serializeParagraphs(
92
- root: ParentNode,
93
- storage: FormatStorage,
94
- tagMap: Record<string, FormatTool>,
95
- markers: FormatMarkers
96
- ): string {
97
- const paragraphs: string[] = [];
98
- let buffer: ChildNode[] = [];
99
-
100
- const flush = () => {
101
- if (buffer.length) paragraphs.push(serializeInline(buffer, storage, tagMap, markers));
102
- buffer = [];
103
- };
104
-
105
- for (const node of Array.from(root.childNodes)) {
106
- const isBlock =
107
- node.nodeType === Node.ELEMENT_NODE &&
108
- ((node as Element).tagName === "P" || (node as Element).tagName === "DIV");
109
-
110
- if (isBlock) {
111
- flush();
112
- paragraphs.push(serializeInline((node as Element).childNodes, storage, tagMap, markers));
113
- } else {
114
- buffer.push(node);
115
- }
116
- }
117
- flush();
118
-
119
- const cleaned = paragraphs.map((p) => trimTrailingBreaks(p, storage));
120
-
121
- if (storage === "html") return cleaned.map((p) => `<p>${p}</p>`).join("");
122
- return cleaned.join("\n\n");
123
- }
124
-
125
- /**
126
- * Сериализует содержимое редактора в строку для хранения. Сохраняются только включённые инструменты.
127
- * При paragraphs=true применяется модель «абзацы (<p>/\n\n) + мягкие переносы (<br>/\n)».
128
- */
129
- export function serialize(
130
- root: HTMLElement,
131
- storage: FormatStorage,
132
- tools: FormatTool[],
133
- markers: FormatMarkers = defaultFormatMarkers(),
134
- paragraphs = false
135
- ): string {
136
- const tagMap = buildTagMap(tools);
137
-
138
- if (paragraphs) return serializeParagraphs(root, storage, tagMap, markers).trim();
139
-
140
- const inline = serializeInline(root.childNodes, storage, tagMap, markers);
141
- if (storage === "html")
142
- return inline
143
- .replace(/^(?:<br>)+/, "")
144
- .replace(/(?:<br>)+$/, "")
145
- .trim();
146
- return inline.replace(/^\n+/, "").replace(/\n+$/, "").trim();
147
- }
148
-
149
- // Markdown-разметка одного абзаца → инлайновый HTML (escape, \n→<br>, маркеры).
150
- function markdownInline(text: string, tools: FormatTool[], markers: FormatMarkers): string {
151
- let html = escapeHtml(text).replace(/\r?\n/g, "<br>");
152
-
153
- // Маркеры применяем по убыванию длины: длинный (**) раньше короткого-префикса (*).
154
- const order = tools.slice().sort((a, b) => markers[b].length - markers[a].length);
155
- for (const tool of order) {
156
- const marker = markers[tool];
157
- if (!marker) continue;
158
-
159
- const def = FORMAT_TOOLS[tool];
160
- const escaped = escapeRegExp(marker);
161
- const re = new RegExp(`${escaped}([\\s\\S]+?)${escaped}`, "g");
162
- html = html.replace(re, `<${def.tag}>$1</${def.tag}>`);
163
- }
164
-
165
- return html;
166
- }
167
-
168
- /**
169
- * Готовит сохранённое значение к отображению в редакторе (возвращает HTML).
170
- * При paragraphs=true строит <p>-абзацы; HTML-значения санитизируются до разрешённых тегов,
171
- * Markdown/Plain — разбивается на абзацы по \n\n (мягкий перенос \n → <br>).
172
- */
173
- export function deserialize(
174
- value: string,
175
- storage: FormatStorage,
176
- tools: FormatTool[],
177
- markers: FormatMarkers = defaultFormatMarkers(),
178
- paragraphs = false
179
- ): string {
180
- if (!value) return "";
181
-
182
- if (storage === "markdown") {
183
- if (!paragraphs) return markdownInline(value, tools, markers);
184
- return value
185
- .split(/\n{2,}/)
186
- .map((p) => `<p>${markdownInline(p, tools, markers) || "<br>"}</p>`)
187
- .join("");
188
- }
189
-
190
- // html: парсим и пересобираем, отбрасывая всё, кроме разрешённых тегов
191
- const template = document.createElement("template");
192
- template.innerHTML = value;
193
- const tagMap = buildTagMap(tools);
194
-
195
- if (!paragraphs) return serializeInline(template.content.childNodes, "html", tagMap, defaultFormatMarkers());
196
-
197
- return serializeParagraphs(template.content, "html", tagMap, defaultFormatMarkers()).replace(
198
- /<p><\/p>/g,
199
- "<p><br></p>"
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+ }