@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/serialize.ts
CHANGED
|
@@ -1,296 +1,479 @@
|
|
|
1
|
-
// Разбор и сериализация значения редактора (HTML | Markdown), модель абзацев и мягких переносов.
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function
|
|
17
|
-
return text.replace(
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
const
|
|
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
|
-
return
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
1
|
+
// Разбор и сериализация значения редактора (HTML | Markdown), модель абзацев и мягких переносов.
|
|
2
|
+
|
|
3
|
+
import { blockTypeOf } from "./paragraphs";
|
|
4
|
+
import {
|
|
5
|
+
ALL_BLOCK_TYPES,
|
|
6
|
+
BLOCK_TYPES,
|
|
7
|
+
DEFAULT_BLOCK,
|
|
8
|
+
FORMAT_TOOLS,
|
|
9
|
+
defaultFormatMarkers,
|
|
10
|
+
type BlockType,
|
|
11
|
+
type FormatMarkers,
|
|
12
|
+
type FormatStorage,
|
|
13
|
+
type FormatTool,
|
|
14
|
+
} from "./format-config";
|
|
15
|
+
|
|
16
|
+
function escapeHtml(text: string): string {
|
|
17
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function escapeRegExp(text: string): string {
|
|
21
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Тег → инструмент, только для включённых инструментов. */
|
|
25
|
+
function buildTagMap(tools: FormatTool[]): Record<string, FormatTool> {
|
|
26
|
+
const map: Record<string, FormatTool> = {};
|
|
27
|
+
for (const tool of tools) for (const tag of FORMAT_TOOLS[tool].matchTags) map[tag] = tool;
|
|
28
|
+
return map;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function lineBreak(storage: FormatStorage): string {
|
|
32
|
+
return storage === "html" ? "<br>" : "\n";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function wrap(storage: FormatStorage, tool: FormatTool, inner: string, markers: FormatMarkers): string {
|
|
36
|
+
if (!inner) return inner;
|
|
37
|
+
|
|
38
|
+
const def = FORMAT_TOOLS[tool];
|
|
39
|
+
if (storage === "html") return `<${def.tag}>${inner}</${def.tag}>`;
|
|
40
|
+
|
|
41
|
+
const marker = markers[tool];
|
|
42
|
+
|
|
43
|
+
// Маркер не сработает, если содержимое начинается или заканчивается пробелом, — ни у нас
|
|
44
|
+
// при разборе, ни у мессенджера. Выносим краевые пробелы наружу: разметка сохраняется,
|
|
45
|
+
// а иначе получатель увидел бы сами маркеры.
|
|
46
|
+
const leading = /^\s*/.exec(inner)![0];
|
|
47
|
+
const trailing = /\s*$/.exec(inner.slice(leading.length))![0];
|
|
48
|
+
const core = inner.slice(leading.length, inner.length - trailing.length);
|
|
49
|
+
if (!core) return inner; // одни пробелы — оборачивать нечего
|
|
50
|
+
|
|
51
|
+
return `${leading}${marker}${core}${marker}${trailing}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Сериализует инлайновое содержимое (текст, форматирование, <br> как мягкий перенос).
|
|
55
|
+
// Абзацы (<p>/<div>) на этом уровне не учитываются — их разбирает serializeParagraphs.
|
|
56
|
+
function serializeInline(
|
|
57
|
+
nodes: ArrayLike<ChildNode>,
|
|
58
|
+
storage: FormatStorage,
|
|
59
|
+
tagMap: Record<string, FormatTool>,
|
|
60
|
+
markers: FormatMarkers
|
|
61
|
+
): string {
|
|
62
|
+
let result = "";
|
|
63
|
+
|
|
64
|
+
for (const node of Array.from(nodes)) {
|
|
65
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
66
|
+
const text = node.textContent ?? "";
|
|
67
|
+
result += storage === "html" ? escapeHtml(text) : text;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
72
|
+
|
|
73
|
+
const el = node as HTMLElement;
|
|
74
|
+
const tag = el.tagName;
|
|
75
|
+
|
|
76
|
+
if (tag === "BR") {
|
|
77
|
+
result += lineBreak(storage); // мягкий перенос
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const tool = tagMap[tag];
|
|
82
|
+
|
|
83
|
+
// В коде разметки нет: при разборе его содержимое не размечается, и вложенное
|
|
84
|
+
// форматирование не вернулось бы — значение разошлось бы с тем, что было в поле.
|
|
85
|
+
if (tool === "code") {
|
|
86
|
+
const text = el.textContent ?? "";
|
|
87
|
+
result += wrap(storage, tool, storage === "html" ? escapeHtml(text) : text, markers);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const inner = serializeInline(el.childNodes, storage, tagMap, markers);
|
|
92
|
+
|
|
93
|
+
// вложенный блочный элемент (нестандарт) — без обёртки, просто содержимое
|
|
94
|
+
if (tag === "DIV" || tag === "P") {
|
|
95
|
+
result += inner;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// неизвестный или отключённый тег — отбрасываем обёртку, оставляем текст
|
|
100
|
+
result += tool ? wrap(storage, tool, inner, markers) : inner;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Хвостовые переносы абзаца отбрасываем — это <br>-заполнители, делающие последнюю строку видимой;
|
|
107
|
+
// мягкий перенос осмыслен только между содержимым (для пустой строки используйте новый абзац).
|
|
108
|
+
function trimTrailingBreaks(inline: string, storage: FormatStorage): string {
|
|
109
|
+
return storage === "html" ? inline.replace(/(?:<br>)+$/, "") : inline.replace(/\n+$/, "");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Разбивает верхний уровень на блоки: у каждого свой тип, у не попавшего в блок содержимого —
|
|
113
|
+
// тип по умолчанию. HTML: тег типа; Markdown: блоки через \n\n, мягкие переносы внутри — \n.
|
|
114
|
+
function serializeParagraphs(
|
|
115
|
+
root: ParentNode,
|
|
116
|
+
storage: FormatStorage,
|
|
117
|
+
tagMap: Record<string, FormatTool>,
|
|
118
|
+
markers: FormatMarkers,
|
|
119
|
+
types: BlockType[],
|
|
120
|
+
separate: boolean
|
|
121
|
+
): string {
|
|
122
|
+
const blocks: Array<[BlockType, string]> = [];
|
|
123
|
+
let buffer: ChildNode[] = [];
|
|
124
|
+
|
|
125
|
+
// Содержимое блока: у типа без инлайновой разметки (код) — буквальный текст, у остальных
|
|
126
|
+
// обычная сериализация. Дальше типы отличаются только оформлением готовых строк.
|
|
127
|
+
const content = (type: BlockType, nodes: ArrayLike<ChildNode>): string => {
|
|
128
|
+
if (BLOCK_TYPES[type].inline) return serializeInline(nodes, storage, tagMap, markers);
|
|
129
|
+
|
|
130
|
+
const text = Array.from(nodes)
|
|
131
|
+
.map((node) => (node.nodeName === "BR" ? "\n" : (node.textContent ?? "")))
|
|
132
|
+
.join("");
|
|
133
|
+
|
|
134
|
+
return storage === "html" ? escapeHtml(text) : text;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const flush = () => {
|
|
138
|
+
if (buffer.length) blocks.push([DEFAULT_BLOCK, content(DEFAULT_BLOCK, buffer)]);
|
|
139
|
+
buffer = [];
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
for (const node of Array.from(root.childNodes)) {
|
|
143
|
+
const found = blockTypeOf(node);
|
|
144
|
+
|
|
145
|
+
if (found) {
|
|
146
|
+
// Отключённый тип пришёл со стороны (вставка, чужое значение) — сохраняем как обычный
|
|
147
|
+
// блок: его разметки в значении всё равно не будет, а текст терять нельзя.
|
|
148
|
+
const type = types.includes(found) ? found : DEFAULT_BLOCK;
|
|
149
|
+
|
|
150
|
+
flush();
|
|
151
|
+
blocks.push([type, content(type, (node as Element).childNodes)]);
|
|
152
|
+
} else {
|
|
153
|
+
buffer.push(node);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
flush();
|
|
157
|
+
|
|
158
|
+
const cleaned = blocks.map(([type, text]) => [type, trimTrailingBreaks(text, storage)] as const);
|
|
159
|
+
|
|
160
|
+
if (storage === "html")
|
|
161
|
+
return cleaned
|
|
162
|
+
.map(([type, text]) => {
|
|
163
|
+
const tag = BLOCK_TYPES[type].tag;
|
|
164
|
+
return `<${tag}>${text}</${tag}>`;
|
|
165
|
+
})
|
|
166
|
+
.join("");
|
|
167
|
+
|
|
168
|
+
// Пустая строка между блоками нужна там, где она их и разделяет. Блок с собственной
|
|
169
|
+
// разметкой (цитата, код) узнаётся и без неё, а в режиме мягких переносов пустая строка —
|
|
170
|
+
// это пустая строка сообщения: поставив её от себя, редактор менял бы текст.
|
|
171
|
+
return cleaned
|
|
172
|
+
.map(([type, text], index) => {
|
|
173
|
+
if (!index) return markdownBlock(type, text);
|
|
174
|
+
|
|
175
|
+
const previous = cleaned[index - 1][0];
|
|
176
|
+
const blank = separate || (previous === DEFAULT_BLOCK && type === DEFAULT_BLOCK);
|
|
177
|
+
|
|
178
|
+
return `${blank ? "\n\n" : "\n"}${markdownBlock(type, text)}`;
|
|
179
|
+
})
|
|
180
|
+
.join("");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Оформляет готовое содержимое блока по правилам типа: ограждение, построчный маркер или ничего. */
|
|
184
|
+
function markdownBlock(type: BlockType, text: string): string {
|
|
185
|
+
const def = BLOCK_TYPES[type];
|
|
186
|
+
|
|
187
|
+
if (def.fence) return `${def.fence}\n${text}\n${def.fence}`;
|
|
188
|
+
if (def.linePrefix)
|
|
189
|
+
return text
|
|
190
|
+
.split("\n")
|
|
191
|
+
.map((line) => `${def.linePrefix}${line}`.trimEnd())
|
|
192
|
+
.join("\n");
|
|
193
|
+
|
|
194
|
+
return text;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Сериализует содержимое редактора в строку для хранения. Сохраняются только включённые инструменты.
|
|
199
|
+
* При paragraphs=true применяется модель «абзацы (<p>/\n\n) + мягкие переносы (<br>/\n)».
|
|
200
|
+
*/
|
|
201
|
+
export function serialize(
|
|
202
|
+
root: HTMLElement,
|
|
203
|
+
storage: FormatStorage,
|
|
204
|
+
tools: FormatTool[],
|
|
205
|
+
markers: FormatMarkers = defaultFormatMarkers(),
|
|
206
|
+
paragraphs = false,
|
|
207
|
+
types: BlockType[] = ALL_BLOCK_TYPES,
|
|
208
|
+
separate = true
|
|
209
|
+
): string {
|
|
210
|
+
const tagMap = buildTagMap(tools);
|
|
211
|
+
|
|
212
|
+
if (paragraphs) return serializeParagraphs(root, storage, tagMap, markers, types, separate).trim();
|
|
213
|
+
|
|
214
|
+
const inline = serializeInline(root.childNodes, storage, tagMap, markers);
|
|
215
|
+
if (storage === "html")
|
|
216
|
+
return inline
|
|
217
|
+
.replace(/^(?:<br>)+/, "")
|
|
218
|
+
.replace(/(?:<br>)+$/, "")
|
|
219
|
+
.trim();
|
|
220
|
+
return inline.replace(/^\n+/, "").replace(/\n+$/, "").trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Инструмент, его маркер и признак «содержимое не может начинаться/заканчиваться самим маркером». */
|
|
224
|
+
type MarkerRule = [tool: FormatTool, marker: string, standalone: boolean];
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Маркеры в порядке применения: длинный (`__`) раньше короткого-префикса (`_`), иначе
|
|
228
|
+
* короткий съест половину длинного. Считается один раз на разбор — `markdownInline`
|
|
229
|
+
* вызывается на каждый абзац.
|
|
230
|
+
*
|
|
231
|
+
* Если короткий маркер является префиксом длинного и принадлежит другой инструкции
|
|
232
|
+
* (`_` курсив внутри `__` подчёркивания), длинный не должен захватывать лишний символ:
|
|
233
|
+
* иначе `___текст___` разберётся как подчёркивание с `_` по краям вместо курсива поверх
|
|
234
|
+
* подчёркивания. Так же поступают паттерны конвертеров.
|
|
235
|
+
*/
|
|
236
|
+
function orderedMarkers(tools: FormatTool[], markers: FormatMarkers): MarkerRule[] {
|
|
237
|
+
const active = tools.filter((tool) => markers[tool]).sort((a, b) => markers[b].length - markers[a].length);
|
|
238
|
+
|
|
239
|
+
return active.map((tool) => {
|
|
240
|
+
const marker = markers[tool];
|
|
241
|
+
const hasShorterPrefix = active.some((other) => {
|
|
242
|
+
const value = markers[other];
|
|
243
|
+
return value.length < marker.length && marker.startsWith(value);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return [tool, marker, hasShorterPrefix];
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const TAG = /<(\/?)([a-z]+)>/g;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Закрыт ли в содержимом каждый тег, который в нём открыт.
|
|
254
|
+
*
|
|
255
|
+
* Маркеры применяются по очереди к уже размеченному тексту, поэтому пара маркеров может
|
|
256
|
+
* пересечь чужой тег: `**a _b** c_` дал бы `<b>a <i>b</b> c</i>`, а такой HTML браузер
|
|
257
|
+
* перестроит по-своему, и форматирование «протечёт» за пределы разметки. Пересекающуюся
|
|
258
|
+
* пару оставляем текстом — так же поступают мессенджеры. Собственный текст пользователя
|
|
259
|
+
* сюда не попадает: `<` в нём уже заэкранирован, тег может быть только нашим.
|
|
260
|
+
*/
|
|
261
|
+
function balancedTags(inner: string): boolean {
|
|
262
|
+
if (!inner.includes("<")) return true;
|
|
263
|
+
|
|
264
|
+
const stack: string[] = [];
|
|
265
|
+
for (const [, closing, name] of inner.matchAll(TAG)) {
|
|
266
|
+
if (!closing) stack.push(name);
|
|
267
|
+
else if (stack.pop() !== name) return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return stack.length === 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Метка места кода на время разбора: содержимое кода разметкой не считается, поэтому выносится
|
|
274
|
+
// до остальных маркеров и возвращается в конце.
|
|
275
|
+
//
|
|
276
|
+
// В тексте пользователя такой метки не бывает: он уже заэкранирован, и его `&` стал `&`.
|
|
277
|
+
// Границам соседних маркеров она не мешает — по краям не буква и не цифра, а угловых скобок,
|
|
278
|
+
// на которые смотрит balancedTags, в ней нет.
|
|
279
|
+
const STASH_MARK = "�";
|
|
280
|
+
const STASH_PATTERN = /�(\d+)�/g;
|
|
281
|
+
|
|
282
|
+
// Markdown-разметка одного абзаца → инлайновый HTML (escape, маркеры, \n→<br>).
|
|
283
|
+
function markdownInline(text: string, order: MarkerRule[]): string {
|
|
284
|
+
let html = escapeHtml(text);
|
|
285
|
+
|
|
286
|
+
// Код — первым: внутри него `*звёздочки*` остаются текстом, как у мессенджеров. Иначе
|
|
287
|
+
// содержимое кода размечалось бы, и то, что человек написал буквально, уезжало бы жирным.
|
|
288
|
+
const stash: string[] = [];
|
|
289
|
+
const code = order.find(([tool]) => tool === "code");
|
|
290
|
+
if (code) {
|
|
291
|
+
html = html.replace(markerPattern(code[1], code[2]), (_match, lead: string, inner: string) => {
|
|
292
|
+
stash.push(inner);
|
|
293
|
+
|
|
294
|
+
return `${lead}${STASH_MARK}${stash.length - 1}${STASH_MARK}`;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const [tool, marker, standalone] of order) {
|
|
299
|
+
if (tool === "code") continue; // уже вынесен
|
|
300
|
+
|
|
301
|
+
const def = FORMAT_TOOLS[tool];
|
|
302
|
+
html = html.replace(markerPattern(marker, standalone), (match, lead: string, inner: string) =>
|
|
303
|
+
balancedTags(inner) ? `${lead}<${def.tag}>${inner}</${def.tag}>` : match
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (stash.length) {
|
|
308
|
+
const def = FORMAT_TOOLS.code;
|
|
309
|
+
html = html.replace(STASH_PATTERN, (_match, index: string) => `<${def.tag}>${stash[+index]}</${def.tag}>`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// переносы — после маркеров: пока это \n, запрет на пересечение строки работает
|
|
313
|
+
return html.replace(/\r?\n/g, "<br>");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Разметка распознаётся по правилам мессенджеров: маркер стоит на границе слова, содержимое
|
|
318
|
+
* не начинается и не заканчивается пробелом и не пересекает перенос строки. Иначе `5**4 = 20`
|
|
319
|
+
* или `2 ** 2 ** 2` превращались бы в текст с форматированием, которого получатель не увидит.
|
|
320
|
+
*
|
|
321
|
+
* Граница — всё, что не буква и не цифра (включая `_`). Именно `\p{L}\p{N}`, а не `\W`:
|
|
322
|
+
* в JavaScript `\w` — только ASCII, поэтому с `\W` каждая кириллическая буква считалась бы
|
|
323
|
+
* границей и `файл_имя_файла` разбирался бы как разметка.
|
|
324
|
+
*
|
|
325
|
+
* Левая граница захватывается группой и возвращается на место — lookbehind не используется,
|
|
326
|
+
* его нет в Safari до 16.4.
|
|
327
|
+
*
|
|
328
|
+
* Содержимое не начинается и с U+20E3: в keycap-последовательностях (`*⃣`, `#⃣`, `1⃣`) сам
|
|
329
|
+
* маркер служит базовым символом, и без этого `*⃣раз*` разбиралось бы как разметка вместо эмодзи.
|
|
330
|
+
*
|
|
331
|
+
* При `standalone` содержимое дополнительно не начинается и не заканчивается символом самого
|
|
332
|
+
* маркера — см. {@link orderedMarkers}.
|
|
333
|
+
*/
|
|
334
|
+
function markerPattern(marker: string, standalone: boolean): RegExp {
|
|
335
|
+
const key = standalone ? `${marker}` : marker;
|
|
336
|
+
|
|
337
|
+
let pattern = markerPatterns.get(key);
|
|
338
|
+
if (pattern) return pattern;
|
|
339
|
+
|
|
340
|
+
const escaped = escapeRegExp(marker);
|
|
341
|
+
const boundary = "[^\\p{L}\\p{N}]";
|
|
342
|
+
// символ маркера в классе — экранируем то, что в нём значимо
|
|
343
|
+
const own = standalone ? marker.slice(-1).replace(/[\\\]^-]/g, "\\$&") : "";
|
|
344
|
+
const head = `[^\\s\\u20e3${own}]`;
|
|
345
|
+
const tail = standalone ? `[^\\s${own}]` : "\\S";
|
|
346
|
+
|
|
347
|
+
pattern = new RegExp(`(^|${boundary})${escaped}(${head}|${head}[^\\n]*?${tail})${escaped}(?=$|${boundary})`, "gu");
|
|
348
|
+
markerPatterns.set(key, pattern);
|
|
349
|
+
|
|
350
|
+
return pattern;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Набор маркеров за разбор не меняется, а разбор идёт по абзацам — компилируем каждую
|
|
354
|
+
// регулярку один раз. Флаг `g` переиспользовать безопасно: `replace` сбрасывает lastIndex.
|
|
355
|
+
const markerPatterns = new Map<string, RegExp>();
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Готовит сохранённое значение к отображению в редакторе (возвращает HTML).
|
|
359
|
+
* При paragraphs=true строит <p>-абзацы; HTML-значения санитизируются до разрешённых тегов,
|
|
360
|
+
* Markdown/Plain — разбивается на абзацы по \n\n (мягкий перенос \n → <br>).
|
|
361
|
+
*/
|
|
362
|
+
export function deserialize(
|
|
363
|
+
value: string,
|
|
364
|
+
storage: FormatStorage,
|
|
365
|
+
tools: FormatTool[],
|
|
366
|
+
markers: FormatMarkers = defaultFormatMarkers(),
|
|
367
|
+
paragraphs = false,
|
|
368
|
+
types: BlockType[] = ALL_BLOCK_TYPES,
|
|
369
|
+
separate = true
|
|
370
|
+
): string {
|
|
371
|
+
if (!value) return "";
|
|
372
|
+
|
|
373
|
+
if (storage === "markdown") {
|
|
374
|
+
const order = orderedMarkers(tools, markers);
|
|
375
|
+
|
|
376
|
+
if (!paragraphs) return markdownInline(value, order);
|
|
377
|
+
|
|
378
|
+
return markdownBlocks(value, types, separate)
|
|
379
|
+
.map(([type, text]) => {
|
|
380
|
+
const def = BLOCK_TYPES[type];
|
|
381
|
+
const inner = def.inline ? markdownInline(text, order) : escapeHtml(text).replace(/\n/g, "<br>");
|
|
382
|
+
|
|
383
|
+
return `<${def.tag}>${inner || "<br>"}</${def.tag}>`;
|
|
384
|
+
})
|
|
385
|
+
.join("");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// html: парсим и пересобираем, отбрасывая всё, кроме разрешённых тегов
|
|
389
|
+
const template = document.createElement("template");
|
|
390
|
+
template.innerHTML = value;
|
|
391
|
+
const tagMap = buildTagMap(tools);
|
|
392
|
+
|
|
393
|
+
if (!paragraphs) return serializeInline(template.content.childNodes, "html", tagMap, defaultFormatMarkers());
|
|
394
|
+
|
|
395
|
+
return serializeParagraphs(template.content, "html", tagMap, defaultFormatMarkers(), types, separate).replace(
|
|
396
|
+
/<([a-z]+)><\/\1>/g,
|
|
397
|
+
"<$1><br></$1>"
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Первый проход разбора: значение → последовательность блоков с их типом.
|
|
403
|
+
*
|
|
404
|
+
* Порядок проверок — от самой сильной разметки к самой слабой: огражденный блок забирает строки
|
|
405
|
+
* целиком (внутри него разметки нет, в том числе чужих маркеров начала строки), затем идут подряд
|
|
406
|
+
* идущие строки с построчным маркером, и лишь остаток разбивается на блоки по умолчанию пустой
|
|
407
|
+
* строкой. Незакрытое ограждение разметкой не считается — его строки остаются текстом, как
|
|
408
|
+
* у мессенджеров: иначе одна случайная кавычка съедала бы весь остаток сообщения.
|
|
409
|
+
*
|
|
410
|
+
* При `separate = false` пустая строка блоки не делит: в режиме мягких переносов она сама по себе
|
|
411
|
+
* строка сообщения, и разбиение съедало бы её.
|
|
412
|
+
*/
|
|
413
|
+
function markdownBlocks(value: string, types: BlockType[], separate: boolean): Array<[BlockType, string]> {
|
|
414
|
+
const fenced = types.filter((type) => BLOCK_TYPES[type].fence);
|
|
415
|
+
const prefixed = types.filter((type) => BLOCK_TYPES[type].linePrefix);
|
|
416
|
+
|
|
417
|
+
const lines = value.split(/\r?\n/);
|
|
418
|
+
const blocks: Array<[BlockType, string]> = [];
|
|
419
|
+
let buffer: string[] = [];
|
|
420
|
+
|
|
421
|
+
const flush = () => {
|
|
422
|
+
if (buffer.length) blocks.push([DEFAULT_BLOCK, buffer.join("\n")]);
|
|
423
|
+
buffer = [];
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
for (let i = 0; i < lines.length; i++) {
|
|
427
|
+
const line = lines[i];
|
|
428
|
+
|
|
429
|
+
const fence = fenced.find((type) => line.trimEnd() === BLOCK_TYPES[type].fence);
|
|
430
|
+
if (fence) {
|
|
431
|
+
let close = -1;
|
|
432
|
+
for (let at = i + 1; at < lines.length; at++)
|
|
433
|
+
if (lines[at].trimEnd() === BLOCK_TYPES[fence].fence) {
|
|
434
|
+
close = at;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (close > i) {
|
|
439
|
+
flush();
|
|
440
|
+
blocks.push([fence, lines.slice(i + 1, close).join("\n")]);
|
|
441
|
+
i = close;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Маркер строки: пустая строка цитаты приходит без замыкающего пробела, поэтому
|
|
447
|
+
// узнаём и маркер без него.
|
|
448
|
+
const prefix = prefixed.find((type) => {
|
|
449
|
+
const marker = BLOCK_TYPES[type].linePrefix!;
|
|
450
|
+
return line.startsWith(marker) || line.trimEnd() === marker.trimEnd();
|
|
451
|
+
});
|
|
452
|
+
if (prefix) {
|
|
453
|
+
const marker = BLOCK_TYPES[prefix].linePrefix!;
|
|
454
|
+
const collected: string[] = [];
|
|
455
|
+
|
|
456
|
+
while (i < lines.length) {
|
|
457
|
+
const next = lines[i];
|
|
458
|
+
if (next.startsWith(marker)) collected.push(next.slice(marker.length));
|
|
459
|
+
else if (next.trimEnd() === marker.trimEnd()) collected.push("");
|
|
460
|
+
else break;
|
|
461
|
+
|
|
462
|
+
i++;
|
|
463
|
+
}
|
|
464
|
+
i--;
|
|
465
|
+
|
|
466
|
+
flush();
|
|
467
|
+
blocks.push([prefix, collected.join("\n")]);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Пустая строка разделяет блоки по умолчанию и в значение не попадает: пустой блок
|
|
472
|
+
// сериализуется в пустую строку, поэтому сохранять его нечем и незачем.
|
|
473
|
+
if (separate && !line.trim()) flush();
|
|
474
|
+
else buffer.push(line);
|
|
475
|
+
}
|
|
476
|
+
flush();
|
|
477
|
+
|
|
478
|
+
return blocks;
|
|
479
|
+
}
|