@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.
@@ -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[], root: HTMLElement): HTMLElement | null {
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.includes(el.tagName)) return el;
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
- const end = probe.toString().length;
27
- return [start, end];
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
- function locateChar(root: HTMLElement, target: number): { node: Text; offset: number } | null {
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 n = walker.nextNode() as Text | null;
36
- while (n) {
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 >= target) return { node: n, offset: Math.max(0, target - count) };
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
- return last ? { node: last, offset: last.length } : null;
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 s = locateChar(root, start);
48
- const e = locateChar(root, end);
49
- if (!s || !e) return;
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
- function collectTextNodes(root: HTMLElement, range: Range, strict: boolean): Text[] {
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
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
103
- let n = walker.nextNode() as Text | null;
104
- while (n) {
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[], root: HTMLElement) {
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 selector = FORMAT_TAG_NAMES.join(",").toLowerCase();
155
-
156
- let changed = true;
157
- while (changed) {
158
- changed = false;
159
-
160
- for (const el of Array.from(root.querySelectorAll<HTMLElement>(selector))) {
161
- if (!el.isConnected) continue;
162
-
163
- // пустой тег
164
- if (el.textContent === "") {
165
- el.remove();
166
- changed = true;
167
- continue;
168
- }
169
-
170
- // вложен в такой же тег
171
- const parent = el.parentElement;
172
- if (parent && parent !== root && parent.tagName === el.tagName) {
173
- unwrapElement(el);
174
- changed = true;
175
- continue;
176
- }
177
-
178
- // соседний такой же тег слева — склеиваем
179
- const prev = el.previousSibling;
180
- if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === el.tagName) {
181
- while (el.firstChild) prev.appendChild(el.firstChild);
182
- el.remove();
183
- changed = true;
184
- continue;
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
- export function toggleFormat(
342
+ function editSelection(
200
343
  root: HTMLElement,
201
344
  range: Range,
202
- tool: FormatTool,
203
345
  selection: Selection,
204
- restoreBounds?: [number, number]
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, true);
355
+ const nodes = collectTextNodes(root, range);
215
356
  if (!nodes.length) return;
216
357
 
217
- const allFormatted = nodes.every((n) => formatAncestor(n, tags, root) !== null);
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
- const tags = FORMAT_TOOLS[tool].matchTags;
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
  }