@brandup/ui-richeditor 1.0.38 → 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 +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 +186 -106
- package/source/selection.ts +201 -75
- package/source/serialize.ts +28 -6
- package/source/toolbar.ts +125 -13
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, запрет на пересечение строки работает
|
package/source/toolbar.ts
CHANGED
|
@@ -35,6 +35,24 @@ const ACTION_ICONS: Record<EditorAction, string> = {
|
|
|
35
35
|
export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
|
|
36
36
|
export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Кнопка хоста в общей панели — для действий, которых редактор не знает: рандомизация,
|
|
40
|
+
* вставка переменных и прочее доменное. Иконку и поведение задаёт хост, панель отвечает
|
|
41
|
+
* только за отрисовку, доступность и то, что фокус не уходит из редактора.
|
|
42
|
+
*/
|
|
43
|
+
export interface ToolbarButton {
|
|
44
|
+
/** Уникальное имя: идёт в data-атрибут и в ключ перестройки панели. */
|
|
45
|
+
name: string;
|
|
46
|
+
/** Подсказка на кнопке. */
|
|
47
|
+
title: string;
|
|
48
|
+
/** Разметка иконки (svg). */
|
|
49
|
+
icon: string;
|
|
50
|
+
/** Нажатие. */
|
|
51
|
+
run(): void;
|
|
52
|
+
/** false — кнопка недоступна; проверяется на каждом refresh, как у действий. */
|
|
53
|
+
isEnabled?(): boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
/** Редактор, которым управляет общий тулбар. */
|
|
39
57
|
export interface ToolbarHost {
|
|
40
58
|
readonly editable: HTMLElement;
|
|
@@ -45,11 +63,15 @@ export interface ToolbarHost {
|
|
|
45
63
|
readonly toolbarContainer?: HTMLElement | null;
|
|
46
64
|
applyFormat(tool: FormatTool): void;
|
|
47
65
|
isToolActive(tool: FormatTool): boolean;
|
|
66
|
+
/** Активные форматы всех инструментов сразу; нет реализации — панель опросит их поштучно. */
|
|
67
|
+
activeTools?(): ReadonlySet<FormatTool>;
|
|
48
68
|
applyAction?(action: EditorAction): void;
|
|
49
69
|
/** false — кнопка действия недоступна (нечего отменять/очищать). */
|
|
50
70
|
isActionEnabled?(action: EditorAction): boolean;
|
|
51
71
|
/** Вставка текста в каретку — для панели смайликов. */
|
|
52
72
|
insertText?(text: string): void;
|
|
73
|
+
/** Собственные кнопки хоста; пусто/undefined — только штатные. */
|
|
74
|
+
readonly toolbarButtons?: ToolbarButton[];
|
|
53
75
|
}
|
|
54
76
|
|
|
55
77
|
const MARGIN = 6;
|
|
@@ -61,25 +83,70 @@ class FormatToolbar {
|
|
|
61
83
|
private __emojiInitiator: HTMLElement | null = null; // кнопка, у которой открыта панель
|
|
62
84
|
private __buttons: Array<[FormatTool, HTMLButtonElement]> = [];
|
|
63
85
|
private __actionButtons: Array<[EditorAction, HTMLButtonElement]> = [];
|
|
86
|
+
private __hostButtons: Array<[ToolbarButton, HTMLButtonElement]> = [];
|
|
64
87
|
private __active: ToolbarHost | null = null;
|
|
65
88
|
private __toolsKey = "";
|
|
66
89
|
private __inContainer = false;
|
|
67
|
-
private readonly __reposition = () => this.
|
|
90
|
+
private readonly __reposition = () => this.__schedule("position");
|
|
68
91
|
private __resizeObserver: ResizeObserver | null = null;
|
|
92
|
+
private __selectionBound = false;
|
|
93
|
+
private __frame = 0;
|
|
94
|
+
private __pendingRefresh = false;
|
|
95
|
+
private __pendingPosition = false;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Единый листенер на весь документ: подсветка активных инструментов по текущему выделению.
|
|
99
|
+
* Вешается при первом показе панели, а не при загрузке модуля, и живёт до конца страницы —
|
|
100
|
+
* refresh() сам проверяет наличие активного редактора.
|
|
101
|
+
*/
|
|
102
|
+
private __bindSelection() {
|
|
103
|
+
if (this.__selectionBound || typeof document === "undefined") return;
|
|
69
104
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
105
|
+
this.__selectionBound = true;
|
|
106
|
+
document.addEventListener("selectionchange", () => this.__schedule("refresh"));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Откладывает обновление до кадра отрисовки. selectionchange и scroll приходят пачками,
|
|
111
|
+
* а и подсветка (обход содержимого), и позиционирование (чтение геометрии) по событию
|
|
112
|
+
* заметно дороже, чем раз в кадр. Прямые вызовы refresh()/reposition() остаются синхронными.
|
|
113
|
+
*/
|
|
114
|
+
private __schedule(kind: "refresh" | "position") {
|
|
115
|
+
if (!this.__active) return;
|
|
116
|
+
|
|
117
|
+
if (kind === "refresh") this.__pendingRefresh = true;
|
|
118
|
+
else this.__pendingPosition = true;
|
|
119
|
+
|
|
120
|
+
if (typeof requestAnimationFrame !== "function") this.__flush();
|
|
121
|
+
else this.__frame ||= requestAnimationFrame(() => this.__flush());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private __flush() {
|
|
125
|
+
const refresh = this.__pendingRefresh;
|
|
126
|
+
const position = this.__pendingPosition;
|
|
127
|
+
this.__cancelScheduled();
|
|
128
|
+
|
|
129
|
+
if (refresh) this.refresh();
|
|
130
|
+
if (position) this.reposition();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private __cancelScheduled() {
|
|
134
|
+
if (this.__frame && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.__frame);
|
|
135
|
+
|
|
136
|
+
this.__frame = 0;
|
|
137
|
+
this.__pendingRefresh = false;
|
|
138
|
+
this.__pendingPosition = false;
|
|
74
139
|
}
|
|
75
140
|
|
|
76
141
|
/** Показать тулбар для редактора (на фокусе): перестроить кнопки, спозиционировать, показать. */
|
|
77
142
|
attach(host: ToolbarHost) {
|
|
78
143
|
const actions = host.editorActions ?? [];
|
|
79
|
-
|
|
144
|
+
const buttons = host.toolbarButtons ?? [];
|
|
145
|
+
if (!host.formatTools.length && !actions.length && !buttons.length) return;
|
|
80
146
|
|
|
147
|
+
this.__bindSelection();
|
|
81
148
|
this.__active = host;
|
|
82
|
-
this.__build(host.formatTools, actions);
|
|
149
|
+
this.__build(host.formatTools, actions, buttons);
|
|
83
150
|
this.refresh();
|
|
84
151
|
|
|
85
152
|
const elem = this.__ensure();
|
|
@@ -110,22 +177,50 @@ class FormatToolbar {
|
|
|
110
177
|
|
|
111
178
|
/** Скрыть тулбар, если он обслуживает этот редактор (на blur/destroy). */
|
|
112
179
|
detach(host: ToolbarHost) {
|
|
113
|
-
|
|
180
|
+
// панель смайликов могла быть открыта не для активного редактора (у своей кнопки хоста) —
|
|
181
|
+
// ссылку на него всё равно отпускаем, иначе уничтоженный редактор держится синглтоном
|
|
182
|
+
const emojiHost = this.__emojiHost === host;
|
|
183
|
+
if (!emojiHost && this.__active !== host) return;
|
|
114
184
|
|
|
115
185
|
this.__closeEmoji();
|
|
186
|
+
|
|
187
|
+
if (emojiHost) {
|
|
188
|
+
this.__emojiHost = null;
|
|
189
|
+
this.__emojiInitiator = null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (this.__active !== host) return;
|
|
193
|
+
|
|
116
194
|
this.__active = null;
|
|
195
|
+
this.__cancelScheduled();
|
|
117
196
|
if (this.__elem) this.__elem.classList.remove("visible");
|
|
118
197
|
this.__removeViewportListeners();
|
|
119
198
|
}
|
|
120
199
|
|
|
121
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* Обновить подсветку активных инструментов и доступность действий по текущему состоянию.
|
|
202
|
+
*
|
|
203
|
+
* Пишем в DOM только при реальном изменении: обновление идёт на каждое движение каретки,
|
|
204
|
+
* а на документе живёт MutationObserver (им UIElement следит за удалением элементов) —
|
|
205
|
+
* повторная запись того же значения всё равно порождает запись мутации и его пробуждение.
|
|
206
|
+
*/
|
|
122
207
|
refresh() {
|
|
123
208
|
const host = this.__active;
|
|
124
209
|
if (!host) return;
|
|
125
210
|
|
|
126
|
-
|
|
211
|
+
const setDisabled = (btn: HTMLButtonElement, disabled: boolean) => {
|
|
212
|
+
if (btn.disabled !== disabled) btn.disabled = disabled;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const active = host.activeTools?.();
|
|
216
|
+
for (const [tool, btn] of this.__buttons) {
|
|
217
|
+
const isActive = active ? active.has(tool) : host.isToolActive(tool);
|
|
218
|
+
if (btn.classList.contains("active") !== isActive) btn.classList.toggle("active", isActive);
|
|
219
|
+
}
|
|
220
|
+
|
|
127
221
|
// хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
|
|
128
|
-
for (const [action, btn] of this.__actionButtons) btn
|
|
222
|
+
for (const [action, btn] of this.__actionButtons) setDisabled(btn, host.isActionEnabled?.(action) === false);
|
|
223
|
+
for (const [button, btn] of this.__hostButtons) setDisabled(btn, button.isEnabled?.() === false);
|
|
129
224
|
}
|
|
130
225
|
|
|
131
226
|
/** Пересчитать позицию над активным редактором (только для режима body/fixed). */
|
|
@@ -144,6 +239,7 @@ class FormatToolbar {
|
|
|
144
239
|
window.removeEventListener("scroll", this.__reposition);
|
|
145
240
|
window.removeEventListener("resize", this.__reposition);
|
|
146
241
|
this.__resizeObserver?.disconnect();
|
|
242
|
+
this.__cancelScheduled();
|
|
147
243
|
}
|
|
148
244
|
|
|
149
245
|
private __ensure(): HTMLElement {
|
|
@@ -160,8 +256,8 @@ class FormatToolbar {
|
|
|
160
256
|
return this.__elem;
|
|
161
257
|
}
|
|
162
258
|
|
|
163
|
-
private __build(tools: FormatTool[], actions: EditorAction[]) {
|
|
164
|
-
const key = `${tools.join(",")}|${actions.join(",")}`;
|
|
259
|
+
private __build(tools: FormatTool[], actions: EditorAction[], buttons: ToolbarButton[]) {
|
|
260
|
+
const key = `${tools.join(",")}|${actions.join(",")}|${buttons.map((b) => b.name).join(",")}`;
|
|
165
261
|
const elem = this.__ensure();
|
|
166
262
|
if (key === this.__toolsKey && elem.firstChild) return; // тот же состав — переиспользуем кнопки
|
|
167
263
|
|
|
@@ -170,6 +266,7 @@ class FormatToolbar {
|
|
|
170
266
|
DOM.empty(elem);
|
|
171
267
|
this.__buttons = [];
|
|
172
268
|
this.__actionButtons = [];
|
|
269
|
+
this.__hostButtons = [];
|
|
173
270
|
|
|
174
271
|
for (const tool of tools) {
|
|
175
272
|
const def = FORMAT_TOOLS[tool];
|
|
@@ -200,6 +297,21 @@ class FormatToolbar {
|
|
|
200
297
|
this.__actionButtons.push([action, btn]);
|
|
201
298
|
}
|
|
202
299
|
|
|
300
|
+
if ((tools.length || actions.length) && buttons.length) elem.appendChild(DOM.tag("div", { class: "split" }));
|
|
301
|
+
|
|
302
|
+
// кнопки хоста — последними, чтобы штатные не переезжали при их появлении
|
|
303
|
+
for (const button of buttons) {
|
|
304
|
+
const btn = DOM.tag(
|
|
305
|
+
"button",
|
|
306
|
+
{ type: "button", class: "host-button", "data-toolbar-button": button.name, title: button.title },
|
|
307
|
+
button.icon
|
|
308
|
+
);
|
|
309
|
+
btn.addEventListener("click", () => button.run());
|
|
310
|
+
|
|
311
|
+
elem.appendChild(btn);
|
|
312
|
+
this.__hostButtons.push([button, btn]);
|
|
313
|
+
}
|
|
314
|
+
|
|
203
315
|
// панель пережила перестройку кнопок — возвращаем её в тулбар, чтобы не собирать заново.
|
|
204
316
|
// Если её забрал хост под свою кнопку, она остаётся у него.
|
|
205
317
|
if (pickerInToolbar && this.__emojiPicker) elem.appendChild(this.__emojiPicker);
|