@brandup/ui-richeditor 1.0.36 → 1.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -12
- package/package.json +3 -2
- package/source/editing.ts +91 -28
- package/source/emoji.ts +53 -0
- package/source/format-config.ts +45 -18
- package/source/format.ts +22 -2
- package/source/history.ts +28 -9
- package/source/index.ts +9 -0
- package/source/paragraphs.ts +33 -6
- package/source/richeditor.less +56 -1
- package/source/richeditor.ts +396 -109
- package/source/selection.ts +297 -81
- package/source/serialize.ts +296 -201
- package/source/toolbar.ts +251 -17
- package/svg/emoji.svg +3 -0
- package/svg/erase.svg +1 -0
- package/svg/redo.svg +1 -0
- package/svg/undo.svg +1 -0
package/source/selection.ts
CHANGED
|
@@ -6,47 +6,160 @@ import { ALL_FORMAT_TOOLS, FORMAT_TOOLS, type FormatTool } from "./format-config
|
|
|
6
6
|
/** Канонические теги форматирования (в верхнем регистре, как tagName). */
|
|
7
7
|
const FORMAT_TAG_NAMES = ALL_FORMAT_TOOLS.map((t) => FORMAT_TOOLS[t].tag.toUpperCase());
|
|
8
8
|
|
|
9
|
+
// Все распознаваемые теги форматирования (канонические и синонимы) — для снятия форматирования
|
|
10
|
+
// целиком: в содержимом редактора синонимов быть не должно, но вставка и setValue могут их принести.
|
|
11
|
+
const MATCH_TAG_NAMES = Array.from(new Set(ALL_FORMAT_TOOLS.flatMap((t) => FORMAT_TOOLS[t].matchTags)));
|
|
12
|
+
|
|
13
|
+
// Селекторы считаем один раз: обе выборки идут на каждую правку формата и на каждое обновление панели.
|
|
14
|
+
const FORMAT_SELECTOR = FORMAT_TAG_NAMES.join(",").toLowerCase();
|
|
15
|
+
const MATCH_SELECTOR = MATCH_TAG_NAMES.join(",").toLowerCase();
|
|
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
|
+
|
|
9
29
|
/** Ближайший предок-элемент с одним из тегов (в пределах root, не включая root). */
|
|
10
|
-
function formatAncestor(node: Node, tags: string
|
|
30
|
+
function formatAncestor(node: Node, tags: ReadonlySet<string>, root: HTMLElement): HTMLElement | null {
|
|
11
31
|
let el = node.parentElement;
|
|
12
32
|
while (el && el !== root) {
|
|
13
|
-
if (tags.
|
|
33
|
+
if (tags.has(el.tagName)) return el;
|
|
14
34
|
el = el.parentElement;
|
|
15
35
|
}
|
|
16
36
|
return null;
|
|
17
37
|
}
|
|
18
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
|
+
|
|
19
92
|
/** Абсолютные текстовые смещения границ выделения внутри root (для восстановления после правок DOM). */
|
|
20
93
|
export function selectionCharBounds(root: HTMLElement, range: Range): [number, number] {
|
|
21
94
|
const probe = document.createRange();
|
|
22
95
|
probe.selectNodeContents(root);
|
|
23
96
|
probe.setEnd(range.startContainer, range.startOffset);
|
|
24
97
|
const start = probe.toString().length;
|
|
98
|
+
if (range.collapsed) return [start, start];
|
|
99
|
+
|
|
100
|
+
// длину выделения меряем от его начала, а не от начала редактора: иначе весь текст
|
|
101
|
+
// до каретки собирается в строку дважды
|
|
102
|
+
probe.setStart(range.startContainer, range.startOffset);
|
|
25
103
|
probe.setEnd(range.endContainer, range.endOffset);
|
|
26
|
-
|
|
27
|
-
|
|
104
|
+
return [start, start + probe.toString().length];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Пересчитывает абсолютное текстовое смещение после нормализации пробелов.
|
|
109
|
+
*
|
|
110
|
+
* Нормализация только удаляет символы (схлопывает пробелы) и заменяет табы пробелами,
|
|
111
|
+
* поэтому старый и новый текст выравниваются одним проходом: несовпадение означает
|
|
112
|
+
* удалённый символ. Без пересчёта каретка отстаёт ровно на число схлопнутых перед ней
|
|
113
|
+
* пробелов и может уехать в соседнее слово.
|
|
114
|
+
*/
|
|
115
|
+
export function mapCharOffset(before: string, after: string, offset: number): number {
|
|
116
|
+
const same = (a: string, b: string) => a === b || (b === " " && (a === " " || a === "\t"));
|
|
117
|
+
|
|
118
|
+
let i = 0;
|
|
119
|
+
let j = 0;
|
|
120
|
+
while (i < offset && j < after.length) {
|
|
121
|
+
if (same(before[i], after[j])) j++;
|
|
122
|
+
i++;
|
|
123
|
+
}
|
|
124
|
+
return j;
|
|
28
125
|
}
|
|
29
126
|
|
|
30
|
-
|
|
31
|
-
|
|
127
|
+
type CharPosition = { node: Text; offset: number };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Находит текстовые узлы и локальные смещения для пары абсолютных смещений за один обход.
|
|
131
|
+
* Смещение за пределами текста прижимается к его концу.
|
|
132
|
+
*/
|
|
133
|
+
function locateChars(root: HTMLElement, lower: number, upper: number): [CharPosition, CharPosition] | null {
|
|
32
134
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
33
135
|
let count = 0;
|
|
34
136
|
let last: Text | null = null;
|
|
35
|
-
let
|
|
36
|
-
|
|
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) {
|
|
37
141
|
last = n;
|
|
38
|
-
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
|
+
}
|
|
39
147
|
count += n.length;
|
|
40
|
-
n = walker.nextNode() as Text | null;
|
|
41
148
|
}
|
|
42
|
-
|
|
149
|
+
|
|
150
|
+
if (!last) return null;
|
|
151
|
+
|
|
152
|
+
const tail: CharPosition = { node: last, offset: last.length };
|
|
153
|
+
return [low ?? tail, high ?? tail];
|
|
43
154
|
}
|
|
44
155
|
|
|
45
156
|
// Восстанавливает выделение по абсолютным текстовым смещениям (см. selectionCharBounds).
|
|
46
157
|
export function restoreSelection(root: HTMLElement, start: number, end: number, selection: Selection) {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
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]];
|
|
50
163
|
|
|
51
164
|
const range = document.createRange();
|
|
52
165
|
range.setStart(s.node, s.offset);
|
|
@@ -97,14 +210,29 @@ function nodeWithinRange(node: Node, range: Range): boolean {
|
|
|
97
210
|
);
|
|
98
211
|
}
|
|
99
212
|
|
|
100
|
-
|
|
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
|
+
|
|
230
|
+
/** Непустые текстовые узлы, целиком попавшие в диапазон (частично задетые правке не подлежат). */
|
|
231
|
+
function collectTextNodes(root: HTMLElement, range: Range): Text[] {
|
|
101
232
|
const nodes: Text[] = [];
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (n.length > 0 && (strict ? nodeWithinRange(n, range) : range.intersectsNode(n))) nodes.push(n);
|
|
106
|
-
n = walker.nextNode() as Text | null;
|
|
107
|
-
}
|
|
233
|
+
// intersectsNode в обходе дешевле пары compareBoundaryPoints и отсекает почти всё лишнее
|
|
234
|
+
for (const n of touchedTextNodes(root, range)) if (nodeWithinRange(n, range)) nodes.push(n);
|
|
235
|
+
|
|
108
236
|
return nodes;
|
|
109
237
|
}
|
|
110
238
|
|
|
@@ -134,7 +262,7 @@ function unwrapAround(fmt: HTMLElement, node: Node) {
|
|
|
134
262
|
if (!fmt.firstChild) parent.removeChild(fmt);
|
|
135
263
|
}
|
|
136
264
|
|
|
137
|
-
function removeFormatFromNode(node: Text, tags: string
|
|
265
|
+
function removeFormatFromNode(node: Text, tags: ReadonlySet<string>, root: HTMLElement) {
|
|
138
266
|
let fmt = formatAncestor(node, tags, root);
|
|
139
267
|
while (fmt) {
|
|
140
268
|
unwrapAround(fmt, node);
|
|
@@ -149,40 +277,55 @@ function unwrapElement(el: HTMLElement) {
|
|
|
149
277
|
parent.removeChild(el);
|
|
150
278
|
}
|
|
151
279
|
|
|
152
|
-
/**
|
|
280
|
+
/**
|
|
281
|
+
* Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст.
|
|
282
|
+
*
|
|
283
|
+
* Правка одного тега может сделать «грязными» его соседей и потомков, поэтому обход идёт очередью:
|
|
284
|
+
* заново перебирается только затронутое, а не всё содержимое редактора. Вызывается на каждый символ
|
|
285
|
+
* в режиме набора, поэтому повторные выборки по всему дереву тут заметны.
|
|
286
|
+
*/
|
|
153
287
|
export function cleanupFormatting(root: HTMLElement) {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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;
|
|
186
329
|
}
|
|
187
330
|
}
|
|
188
331
|
|
|
@@ -190,41 +333,124 @@ export function cleanupFormatting(root: HTMLElement) {
|
|
|
190
333
|
}
|
|
191
334
|
|
|
192
335
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
336
|
+
* Каркас правки форматирования на выделении: доводит границы Range до целых текстовых узлов,
|
|
337
|
+
* отдаёт строго попавшие узлы в `mutate` и восстанавливает выделение.
|
|
195
338
|
*
|
|
196
339
|
* По умолчанию восстанавливает выделение, на котором работал. Через `restoreBounds`
|
|
197
340
|
* можно восстановить другое выделение (например, исходное до расширения до слова).
|
|
198
341
|
*/
|
|
199
|
-
|
|
342
|
+
function editSelection(
|
|
200
343
|
root: HTMLElement,
|
|
201
344
|
range: Range,
|
|
202
|
-
tool: FormatTool,
|
|
203
345
|
selection: Selection,
|
|
204
|
-
restoreBounds
|
|
346
|
+
restoreBounds: [number, number] | undefined,
|
|
347
|
+
mutate: (nodes: Text[]) => void
|
|
205
348
|
) {
|
|
206
349
|
if (range.collapsed) return;
|
|
207
350
|
|
|
208
|
-
const def = FORMAT_TOOLS[tool];
|
|
209
|
-
const tags = def.matchTags;
|
|
210
351
|
const [startChar, endChar] = restoreBounds ?? selectionCharBounds(root, range);
|
|
211
352
|
|
|
212
353
|
splitBoundaries(range);
|
|
213
354
|
|
|
214
|
-
const nodes = collectTextNodes(root, range
|
|
355
|
+
const nodes = collectTextNodes(root, range);
|
|
215
356
|
if (!nodes.length) return;
|
|
216
357
|
|
|
217
|
-
|
|
218
|
-
if (allFormatted) {
|
|
219
|
-
for (const n of nodes) removeFormatFromNode(n, tags, root);
|
|
220
|
-
} else {
|
|
221
|
-
for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
|
|
222
|
-
}
|
|
358
|
+
mutate(nodes);
|
|
223
359
|
|
|
224
360
|
cleanupFormatting(root);
|
|
225
361
|
restoreSelection(root, startChar, endChar, selection);
|
|
226
362
|
}
|
|
227
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Переключает форматирование инструмента на выделении.
|
|
366
|
+
* Если весь выделенный текст уже отформатирован — снимает формат, иначе применяет.
|
|
367
|
+
*/
|
|
368
|
+
export function toggleFormat(
|
|
369
|
+
root: HTMLElement,
|
|
370
|
+
range: Range,
|
|
371
|
+
tool: FormatTool,
|
|
372
|
+
selection: Selection,
|
|
373
|
+
restoreBounds?: [number, number]
|
|
374
|
+
) {
|
|
375
|
+
const def = FORMAT_TOOLS[tool];
|
|
376
|
+
const tags = TOOL_TAG_SETS[tool];
|
|
377
|
+
|
|
378
|
+
editSelection(root, range, selection, restoreBounds, (nodes) => {
|
|
379
|
+
const allFormatted = nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
380
|
+
if (allFormatted) {
|
|
381
|
+
for (const n of nodes) removeFormatFromNode(n, tags, root);
|
|
382
|
+
} else {
|
|
383
|
+
for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Снимает всё форматирование с выделения (все инструменты сразу, включая теги-синонимы). */
|
|
389
|
+
export function clearFormat(root: HTMLElement, range: Range, selection: Selection, restoreBounds?: [number, number]) {
|
|
390
|
+
editSelection(root, range, selection, restoreBounds, (nodes) => {
|
|
391
|
+
for (const n of nodes) removeFormatFromNode(n, MATCH_TAG_SET, root);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Снимает всё форматирование со всего содержимого (выделение не участвует). */
|
|
396
|
+
export function clearAllFormat(root: HTMLElement) {
|
|
397
|
+
// вложенные элементы после разворачивания родителя остаются в дереве — снимок обходим целиком
|
|
398
|
+
for (const el of Array.from(root.querySelectorAll<HTMLElement>(MATCH_SELECTOR))) unwrapElement(el);
|
|
399
|
+
|
|
400
|
+
root.normalize();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Состояние форматирования на выделении указанными тегами.
|
|
405
|
+
* `every` — отформатирован весь текст (подсветка кнопки инструмента),
|
|
406
|
+
* `some` — отформатирована хоть какая-то часть (доступность очистки).
|
|
407
|
+
* Обход прерывается на первом узле, решающем исход.
|
|
408
|
+
*/
|
|
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
|
+
|
|
426
|
+
if (range.collapsed) {
|
|
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;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// инструмент остаётся кандидатом, пока каждый задетый узел им отформатирован
|
|
434
|
+
const pending = new Set(tools);
|
|
435
|
+
let found = false;
|
|
436
|
+
|
|
437
|
+
for (const node of touchedTextNodes(root, range)) {
|
|
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; // все выбыли — дальше смотреть нечего
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// пустое выделение не считается «отформатированным целиком»
|
|
444
|
+
if (found) for (const tool of pending) active.add(tool);
|
|
445
|
+
|
|
446
|
+
return active;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Есть ли форматирование хоть где-то в содержимом. */
|
|
450
|
+
export function hasAnyFormatting(root: HTMLElement): boolean {
|
|
451
|
+
return root.querySelector(MATCH_SELECTOR) !== null;
|
|
452
|
+
}
|
|
453
|
+
|
|
228
454
|
/**
|
|
229
455
|
* Вставляет текст в позицию каретки, оборачивая его в указанные форматы (режим набора).
|
|
230
456
|
* Каретка ставится сразу после вставленного текста; соседние одинаковые теги склеиваются.
|
|
@@ -251,17 +477,7 @@ export function insertFormattedText(root: HTMLElement, data: string, tools: Form
|
|
|
251
477
|
restoreSelection(root, offset, offset, selection);
|
|
252
478
|
}
|
|
253
479
|
|
|
254
|
-
/** Активен ли формат инструмента на текущем выделении (для подсветки кнопки). */
|
|
480
|
+
/** Активен ли формат инструмента на текущем выделении (для подсветки одиночной кнопки). */
|
|
255
481
|
export function isFormatActive(root: HTMLElement, range: Range, tool: FormatTool): boolean {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (range.collapsed) {
|
|
259
|
-
const node = range.startContainer;
|
|
260
|
-
const probe = node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
|
|
261
|
-
return formatAncestor(probe, tags, root) !== null;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const nodes = collectTextNodes(root, range, false);
|
|
265
|
-
if (!nodes.length) return false;
|
|
266
|
-
return nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
482
|
+
return activeFormats(root, range, [tool]).has(tool);
|
|
267
483
|
}
|