@brandup/ui-richeditor 1.0.38 → 1.0.40
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 +32 -6
- package/package.json +2 -2
- package/source/editing.ts +85 -21
- package/source/format.ts +5 -1
- package/source/history.ts +18 -9
- package/source/index.ts +3 -0
- package/source/paragraphs.ts +23 -3
- package/source/richeditor.less +4 -0
- package/source/richeditor.ts +213 -107
- package/source/selection.ts +201 -75
- package/source/serialize.ts +28 -6
- package/source/toolbar.ts +160 -14
package/source/selection.ts
CHANGED
|
@@ -14,25 +14,94 @@ const MATCH_TAG_NAMES = Array.from(new Set(ALL_FORMAT_TOOLS.flatMap((t) => FORMA
|
|
|
14
14
|
const FORMAT_SELECTOR = FORMAT_TAG_NAMES.join(",").toLowerCase();
|
|
15
15
|
const MATCH_SELECTOR = MATCH_TAG_NAMES.join(",").toLowerCase();
|
|
16
16
|
|
|
17
|
+
// Проверка тега идёт на каждого предка каждого текстового узла при каждом обходе — храним
|
|
18
|
+
// множествами, а не массивами: подсветка панели опрашивает их на каждое движение каретки.
|
|
19
|
+
const FORMAT_TAG_SET = new Set(FORMAT_TAG_NAMES);
|
|
20
|
+
const MATCH_TAG_SET = new Set(MATCH_TAG_NAMES);
|
|
21
|
+
const TOOL_TAG_SETS = ALL_FORMAT_TOOLS.reduce(
|
|
22
|
+
(map, tool) => {
|
|
23
|
+
map[tool] = new Set(FORMAT_TOOLS[tool].matchTags);
|
|
24
|
+
return map;
|
|
25
|
+
},
|
|
26
|
+
{} as Record<FormatTool, Set<string>>
|
|
27
|
+
);
|
|
28
|
+
|
|
17
29
|
/** Ближайший предок-элемент с одним из тегов (в пределах root, не включая root). */
|
|
18
|
-
function formatAncestor(node: Node, tags: string
|
|
30
|
+
function formatAncestor(node: Node, tags: ReadonlySet<string>, root: HTMLElement): HTMLElement | null {
|
|
19
31
|
let el = node.parentElement;
|
|
20
32
|
while (el && el !== root) {
|
|
21
|
-
if (tags.
|
|
33
|
+
if (tags.has(el.tagName)) return el;
|
|
22
34
|
el = el.parentElement;
|
|
23
35
|
}
|
|
24
36
|
return null;
|
|
25
37
|
}
|
|
26
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Поддерево, которого достаточно для обхода диапазона. Обход всего редактора на каждый
|
|
41
|
+
* запрос состояния панели стоит слишком дорого, а за пределами общего предка границ
|
|
42
|
+
* диапазона попасть в него нечему.
|
|
43
|
+
*/
|
|
44
|
+
function rangeScope(root: HTMLElement, range: Range): Node {
|
|
45
|
+
const scope = range.commonAncestorContainer;
|
|
46
|
+
if (!root.contains(scope)) return root;
|
|
47
|
+
|
|
48
|
+
// от текстового узла обходить нечего — берём его родителя (сам узел walker не вернёт)
|
|
49
|
+
return scope.nodeType === Node.TEXT_NODE ? (scope.parentNode ?? root) : scope;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Выделение документа, которому принадлежит узел, — для операций, которые выделение
|
|
54
|
+
* устанавливают, а не читают.
|
|
55
|
+
*
|
|
56
|
+
* Окно берём у самого узла, а не глобальное: редактор может жить в iframe, где глобальный
|
|
57
|
+
* `window` чужой и его выделение к нашему содержимому отношения не имеет; к тому же добраться
|
|
58
|
+
* до глобального окружения можно не всегда (например, при разрушении контрола).
|
|
59
|
+
*/
|
|
60
|
+
export function documentSelection(node: Node): Selection | null {
|
|
61
|
+
return node.ownerDocument?.defaultView?.getSelection() ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Выделение, если оно стоит внутри root, иначе null. Единственная проверка «правка относится
|
|
66
|
+
* к этому содержимому» — по ней работают и правки абзацев, и история, и хосты редактора.
|
|
67
|
+
*/
|
|
68
|
+
export function innerSelection(root: HTMLElement): Selection | null {
|
|
69
|
+
const selection = documentSelection(root);
|
|
70
|
+
if (!selection || selection.rangeCount === 0 || !root.contains(selection.anchorNode)) return null;
|
|
71
|
+
|
|
72
|
+
return selection;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Выполняет правку, сохраняя каретку: положение запоминается текстовым смещением до правки
|
|
77
|
+
* и возвращается после. Пропустить восстановление нельзя — правки пересоздают узлы, и прежнее
|
|
78
|
+
* выделение указывало бы на те, которых в дереве уже нет.
|
|
79
|
+
*
|
|
80
|
+
* Если правка вернула false, значит DOM она не трогала: выделение живо, и переставлять его
|
|
81
|
+
* незачем — лишний сброс способен прервать IME-набор.
|
|
82
|
+
*/
|
|
83
|
+
export function preserveCaret(root: HTMLElement, mutate: () => boolean | void): void {
|
|
84
|
+
const selection = innerSelection(root);
|
|
85
|
+
const bounds = selection ? selectionCharBounds(root, selection.getRangeAt(0)) : null;
|
|
86
|
+
|
|
87
|
+
const touched = mutate();
|
|
88
|
+
|
|
89
|
+
if (touched !== false && bounds && selection) restoreSelection(root, bounds[0], bounds[1], selection);
|
|
90
|
+
}
|
|
91
|
+
|
|
27
92
|
/** Абсолютные текстовые смещения границ выделения внутри root (для восстановления после правок DOM). */
|
|
28
93
|
export function selectionCharBounds(root: HTMLElement, range: Range): [number, number] {
|
|
29
94
|
const probe = document.createRange();
|
|
30
95
|
probe.selectNodeContents(root);
|
|
31
96
|
probe.setEnd(range.startContainer, range.startOffset);
|
|
32
97
|
const start = probe.toString().length;
|
|
98
|
+
if (range.collapsed) return [start, start];
|
|
99
|
+
|
|
100
|
+
// длину выделения меряем от его начала, а не от начала редактора: иначе весь текст
|
|
101
|
+
// до каретки собирается в строку дважды
|
|
102
|
+
probe.setStart(range.startContainer, range.startOffset);
|
|
33
103
|
probe.setEnd(range.endContainer, range.endOffset);
|
|
34
|
-
|
|
35
|
-
return [start, end];
|
|
104
|
+
return [start, start + probe.toString().length];
|
|
36
105
|
}
|
|
37
106
|
|
|
38
107
|
/**
|
|
@@ -55,26 +124,42 @@ export function mapCharOffset(before: string, after: string, offset: number): nu
|
|
|
55
124
|
return j;
|
|
56
125
|
}
|
|
57
126
|
|
|
58
|
-
|
|
59
|
-
|
|
127
|
+
type CharPosition = { node: Text; offset: number };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Находит текстовые узлы и локальные смещения для пары абсолютных смещений за один обход.
|
|
131
|
+
* Смещение за пределами текста прижимается к его концу.
|
|
132
|
+
*/
|
|
133
|
+
function locateChars(root: HTMLElement, lower: number, upper: number): [CharPosition, CharPosition] | null {
|
|
60
134
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
61
135
|
let count = 0;
|
|
62
136
|
let last: Text | null = null;
|
|
63
|
-
let
|
|
64
|
-
|
|
137
|
+
let low: CharPosition | null = null;
|
|
138
|
+
let high: CharPosition | null = null;
|
|
139
|
+
|
|
140
|
+
for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
|
|
65
141
|
last = n;
|
|
66
|
-
if (count + n.length >=
|
|
142
|
+
if (!low && count + n.length >= lower) low = { node: n, offset: lower - count };
|
|
143
|
+
if (count + n.length >= upper) {
|
|
144
|
+
high = { node: n, offset: upper - count };
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
67
147
|
count += n.length;
|
|
68
|
-
n = walker.nextNode() as Text | null;
|
|
69
148
|
}
|
|
70
|
-
|
|
149
|
+
|
|
150
|
+
if (!last) return null;
|
|
151
|
+
|
|
152
|
+
const tail: CharPosition = { node: last, offset: last.length };
|
|
153
|
+
return [low ?? tail, high ?? tail];
|
|
71
154
|
}
|
|
72
155
|
|
|
73
156
|
// Восстанавливает выделение по абсолютным текстовым смещениям (см. selectionCharBounds).
|
|
74
157
|
export function restoreSelection(root: HTMLElement, start: number, end: number, selection: Selection) {
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
if (!
|
|
158
|
+
const forward = start <= end;
|
|
159
|
+
const found = locateChars(root, forward ? start : end, forward ? end : start);
|
|
160
|
+
if (!found) return;
|
|
161
|
+
|
|
162
|
+
const [s, e] = forward ? found : [found[1], found[0]];
|
|
78
163
|
|
|
79
164
|
const range = document.createRange();
|
|
80
165
|
range.setStart(s.node, s.offset);
|
|
@@ -125,15 +210,29 @@ function nodeWithinRange(node: Node, range: Range): boolean {
|
|
|
125
210
|
);
|
|
126
211
|
}
|
|
127
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Непустые текстовые узлы, задетые диапазоном. Единственный обход содержимого в модуле:
|
|
215
|
+
* по нему работают и правки формата, и опрос состояния для панели.
|
|
216
|
+
*/
|
|
217
|
+
function* touchedTextNodes(root: HTMLElement, range: Range): Generator<Text> {
|
|
218
|
+
const walker = document.createTreeWalker(rangeScope(root, range), NodeFilter.SHOW_TEXT);
|
|
219
|
+
|
|
220
|
+
for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null)
|
|
221
|
+
if (n.length && range.intersectsNode(n)) yield n;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Узел под схлопнутой кареткой — от него и ищется формат. */
|
|
225
|
+
function caretProbe(range: Range): Node {
|
|
226
|
+
const node = range.startContainer;
|
|
227
|
+
return node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
|
|
228
|
+
}
|
|
229
|
+
|
|
128
230
|
/** Непустые текстовые узлы, целиком попавшие в диапазон (частично задетые правке не подлежат). */
|
|
129
231
|
function collectTextNodes(root: HTMLElement, range: Range): Text[] {
|
|
130
232
|
const nodes: Text[] = [];
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (n.length > 0 && nodeWithinRange(n, range)) nodes.push(n);
|
|
135
|
-
n = walker.nextNode() as Text | null;
|
|
136
|
-
}
|
|
233
|
+
// intersectsNode в обходе дешевле пары compareBoundaryPoints и отсекает почти всё лишнее
|
|
234
|
+
for (const n of touchedTextNodes(root, range)) if (nodeWithinRange(n, range)) nodes.push(n);
|
|
235
|
+
|
|
137
236
|
return nodes;
|
|
138
237
|
}
|
|
139
238
|
|
|
@@ -163,7 +262,7 @@ function unwrapAround(fmt: HTMLElement, node: Node) {
|
|
|
163
262
|
if (!fmt.firstChild) parent.removeChild(fmt);
|
|
164
263
|
}
|
|
165
264
|
|
|
166
|
-
function removeFormatFromNode(node: Text, tags: string
|
|
265
|
+
function removeFormatFromNode(node: Text, tags: ReadonlySet<string>, root: HTMLElement) {
|
|
167
266
|
let fmt = formatAncestor(node, tags, root);
|
|
168
267
|
while (fmt) {
|
|
169
268
|
unwrapAround(fmt, node);
|
|
@@ -178,38 +277,55 @@ function unwrapElement(el: HTMLElement) {
|
|
|
178
277
|
parent.removeChild(el);
|
|
179
278
|
}
|
|
180
279
|
|
|
181
|
-
/**
|
|
280
|
+
/**
|
|
281
|
+
* Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст.
|
|
282
|
+
*
|
|
283
|
+
* Правка одного тега может сделать «грязными» его соседей и потомков, поэтому обход идёт очередью:
|
|
284
|
+
* заново перебирается только затронутое, а не всё содержимое редактора. Вызывается на каждый символ
|
|
285
|
+
* в режиме набора, поэтому повторные выборки по всему дереву тут заметны.
|
|
286
|
+
*/
|
|
182
287
|
export function cleanupFormatting(root: HTMLElement) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
288
|
+
const queue: HTMLElement[] = Array.from(root.querySelectorAll<HTMLElement>(FORMAT_SELECTOR));
|
|
289
|
+
|
|
290
|
+
const enqueue = (node: Node | null | undefined) => {
|
|
291
|
+
if (node && node.nodeType === Node.ELEMENT_NODE && FORMAT_TAG_SET.has((node as HTMLElement).tagName))
|
|
292
|
+
queue.push(node as HTMLElement);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
while (queue.length) {
|
|
296
|
+
const el = queue.pop()!;
|
|
297
|
+
if (!el.isConnected || !root.contains(el)) continue;
|
|
298
|
+
|
|
299
|
+
// пустой тег: после удаления его соседи могут стать смежными одинаковыми,
|
|
300
|
+
// а родитель — опустеть
|
|
301
|
+
if (el.textContent === "") {
|
|
302
|
+
enqueue(el.nextSibling);
|
|
303
|
+
enqueue(el.parentElement);
|
|
304
|
+
el.remove();
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// вложен в такой же тег — разворачиваем, поднятые дети попадают в новое окружение
|
|
309
|
+
const parent = el.parentElement;
|
|
310
|
+
if (parent && parent !== root && parent.tagName === el.tagName) {
|
|
311
|
+
const children = Array.from(el.children);
|
|
312
|
+
enqueue(el.nextSibling);
|
|
313
|
+
unwrapElement(el);
|
|
314
|
+
children.forEach(enqueue);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// соседний такой же тег слева — склеиваем
|
|
319
|
+
const prev = el.previousSibling;
|
|
320
|
+
if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === el.tagName) {
|
|
321
|
+
const children = Array.from(el.children);
|
|
322
|
+
enqueue(el.nextSibling);
|
|
323
|
+
enqueue(el.parentElement);
|
|
324
|
+
while (el.firstChild) prev.appendChild(el.firstChild);
|
|
325
|
+
el.remove();
|
|
326
|
+
enqueue(prev);
|
|
327
|
+
children.forEach(enqueue);
|
|
328
|
+
continue;
|
|
213
329
|
}
|
|
214
330
|
}
|
|
215
331
|
|
|
@@ -257,7 +373,7 @@ export function toggleFormat(
|
|
|
257
373
|
restoreBounds?: [number, number]
|
|
258
374
|
) {
|
|
259
375
|
const def = FORMAT_TOOLS[tool];
|
|
260
|
-
const tags =
|
|
376
|
+
const tags = TOOL_TAG_SETS[tool];
|
|
261
377
|
|
|
262
378
|
editSelection(root, range, selection, restoreBounds, (nodes) => {
|
|
263
379
|
const allFormatted = nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
@@ -272,7 +388,7 @@ export function toggleFormat(
|
|
|
272
388
|
/** Снимает всё форматирование с выделения (все инструменты сразу, включая теги-синонимы). */
|
|
273
389
|
export function clearFormat(root: HTMLElement, range: Range, selection: Selection, restoreBounds?: [number, number]) {
|
|
274
390
|
editSelection(root, range, selection, restoreBounds, (nodes) => {
|
|
275
|
-
for (const n of nodes) removeFormatFromNode(n,
|
|
391
|
+
for (const n of nodes) removeFormatFromNode(n, MATCH_TAG_SET, root);
|
|
276
392
|
});
|
|
277
393
|
}
|
|
278
394
|
|
|
@@ -290,34 +406,44 @@ export function clearAllFormat(root: HTMLElement) {
|
|
|
290
406
|
* `some` — отформатирована хоть какая-то часть (доступность очистки).
|
|
291
407
|
* Обход прерывается на первом узле, решающем исход.
|
|
292
408
|
*/
|
|
293
|
-
|
|
409
|
+
/** Есть ли форматирование хоть на части выделения (или под кареткой) — доступность кнопки очистки. */
|
|
410
|
+
export function hasFormatting(root: HTMLElement, range: Range): boolean {
|
|
411
|
+
if (range.collapsed) return formatAncestor(caretProbe(range), MATCH_TAG_SET, root) !== null;
|
|
412
|
+
|
|
413
|
+
for (const node of touchedTextNodes(root, range)) if (formatAncestor(node, MATCH_TAG_SET, root)) return true;
|
|
414
|
+
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Инструменты, которыми отформатировано всё выделение, — за один обход вместо обхода
|
|
420
|
+
* на каждый инструмент. Панель опрашивает это состояние на каждое движение каретки.
|
|
421
|
+
*/
|
|
422
|
+
export function activeFormats(root: HTMLElement, range: Range, tools: FormatTool[]): Set<FormatTool> {
|
|
423
|
+
const active = new Set<FormatTool>();
|
|
424
|
+
if (!tools.length) return active;
|
|
425
|
+
|
|
294
426
|
if (range.collapsed) {
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
427
|
+
const probe = caretProbe(range);
|
|
428
|
+
for (const tool of tools) if (formatAncestor(probe, TOOL_TAG_SETS[tool], root)) active.add(tool);
|
|
429
|
+
|
|
430
|
+
return active;
|
|
298
431
|
}
|
|
299
432
|
|
|
300
|
-
|
|
433
|
+
// инструмент остаётся кандидатом, пока каждый задетый узел им отформатирован
|
|
434
|
+
const pending = new Set(tools);
|
|
301
435
|
let found = false;
|
|
302
436
|
|
|
303
|
-
for (
|
|
304
|
-
if (!n.length || !range.intersectsNode(n)) continue;
|
|
305
|
-
|
|
306
|
-
const formatted = formatAncestor(n, tags, root) !== null;
|
|
307
|
-
if (mode === "some") {
|
|
308
|
-
if (formatted) return true;
|
|
309
|
-
} else if (!formatted) return false;
|
|
310
|
-
|
|
437
|
+
for (const node of touchedTextNodes(root, range)) {
|
|
311
438
|
found = true;
|
|
439
|
+
for (const tool of pending) if (!formatAncestor(node, TOOL_TAG_SETS[tool], root)) pending.delete(tool);
|
|
440
|
+
if (!pending.size) break; // все выбыли — дальше смотреть нечего
|
|
312
441
|
}
|
|
313
442
|
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
}
|
|
443
|
+
// пустое выделение не считается «отформатированным целиком»
|
|
444
|
+
if (found) for (const tool of pending) active.add(tool);
|
|
317
445
|
|
|
318
|
-
|
|
319
|
-
export function hasFormatting(root: HTMLElement, range: Range): boolean {
|
|
320
|
-
return rangeFormatState(root, range, MATCH_TAG_NAMES, "some");
|
|
446
|
+
return active;
|
|
321
447
|
}
|
|
322
448
|
|
|
323
449
|
/** Есть ли форматирование хоть где-то в содержимом. */
|
|
@@ -351,7 +477,7 @@ export function insertFormattedText(root: HTMLElement, data: string, tools: Form
|
|
|
351
477
|
restoreSelection(root, offset, offset, selection);
|
|
352
478
|
}
|
|
353
479
|
|
|
354
|
-
/** Активен ли формат инструмента на текущем выделении (для подсветки кнопки). */
|
|
480
|
+
/** Активен ли формат инструмента на текущем выделении (для подсветки одиночной кнопки). */
|
|
355
481
|
export function isFormatActive(root: HTMLElement, range: Range, tool: FormatTool): boolean {
|
|
356
|
-
return
|
|
482
|
+
return activeFormats(root, range, [tool]).has(tool);
|
|
357
483
|
}
|
package/source/serialize.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Разбор и сериализация значения редактора (HTML | Markdown), модель абзацев и мягких переносов.
|
|
2
2
|
|
|
3
|
+
import { isBlock } from "./paragraphs";
|
|
3
4
|
import {
|
|
4
5
|
FORMAT_TOOLS,
|
|
5
6
|
defaultFormatMarkers,
|
|
@@ -112,11 +113,7 @@ function serializeParagraphs(
|
|
|
112
113
|
};
|
|
113
114
|
|
|
114
115
|
for (const node of Array.from(root.childNodes)) {
|
|
115
|
-
|
|
116
|
-
node.nodeType === Node.ELEMENT_NODE &&
|
|
117
|
-
((node as Element).tagName === "P" || (node as Element).tagName === "DIV");
|
|
118
|
-
|
|
119
|
-
if (isBlock) {
|
|
116
|
+
if (isBlock(node)) {
|
|
120
117
|
flush();
|
|
121
118
|
paragraphs.push(serializeInline((node as Element).childNodes, storage, tagMap, markers));
|
|
122
119
|
} else {
|
|
@@ -182,13 +179,38 @@ function orderedMarkers(tools: FormatTool[], markers: FormatMarkers): MarkerRule
|
|
|
182
179
|
});
|
|
183
180
|
}
|
|
184
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
|
+
|
|
185
205
|
// Markdown-разметка одного абзаца → инлайновый HTML (escape, маркеры, \n→<br>).
|
|
186
206
|
function markdownInline(text: string, order: MarkerRule[]): string {
|
|
187
207
|
let html = escapeHtml(text);
|
|
188
208
|
|
|
189
209
|
for (const [tool, marker, standalone] of order) {
|
|
190
210
|
const def = FORMAT_TOOLS[tool];
|
|
191
|
-
html = html.replace(markerPattern(marker, standalone),
|
|
211
|
+
html = html.replace(markerPattern(marker, standalone), (match, lead: string, inner: string) =>
|
|
212
|
+
balancedTags(inner) ? `${lead}<${def.tag}>${inner}</${def.tag}>` : match
|
|
213
|
+
);
|
|
192
214
|
}
|
|
193
215
|
|
|
194
216
|
// переносы — после маркеров: пока это \n, запрет на пересечение строки работает
|