@brandup/ui-richeditor 1.0.41 → 1.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  // Переключение форматирования на выделении и вставка форматированного текста
2
2
  // на чистом Selection/Range API (без execCommand), плюс сохранение/восстановление выделения.
3
3
 
4
- import { ALL_FORMAT_TOOLS, FORMAT_TOOLS, type FormatTool } from "./format-config";
4
+ import { ALL_FORMAT_TOOLS, FORMAT_TOOLS, blockTypeOfTag, type FormatTool } from "./format-config";
5
5
 
6
6
  /** Канонические теги форматирования (в верхнем регистре, как tagName). */
7
7
  const FORMAT_TAG_NAMES = ALL_FORMAT_TOOLS.map((t) => FORMAT_TOOLS[t].tag.toUpperCase());
@@ -13,6 +13,10 @@ const MATCH_TAG_NAMES = Array.from(new Set(ALL_FORMAT_TOOLS.flatMap((t) => FORMA
13
13
  // Селекторы считаем один раз: обе выборки идут на каждую правку формата и на каждое обновление панели.
14
14
  const FORMAT_SELECTOR = FORMAT_TAG_NAMES.join(",").toLowerCase();
15
15
  const MATCH_SELECTOR = MATCH_TAG_NAMES.join(",").toLowerCase();
16
+ // Моноширинный: внутри него разметки не бывает (см. stripFormattingInCode)
17
+ const CODE_SELECTOR = FORMAT_TOOLS.code.matchTags.join(",").toLowerCase();
18
+ // Неделимые объекты хоста: конструкции сообщения объявляют себя нередактируемыми
19
+ const ATOMIC_SELECTOR = '[contenteditable="false"]';
16
20
 
17
21
  // Проверка тега идёт на каждого предка каждого текстового узла при каждом обходе — храним
18
22
  // множествами, а не массивами: подсветка панели опрашивает их на каждое движение каретки.
@@ -89,19 +93,110 @@ export function preserveCaret(root: HTMLElement, mutate: () => boolean | void):
89
93
  if (touched !== false && bounds && selection) restoreSelection(root, bounds[0], bounds[1], selection);
90
94
  }
91
95
 
96
+ /**
97
+ * Единица текстовых координат: символы текстового узла либо конец строки — мягкий перенос
98
+ * и граница блока. Перенос обязан занимать позицию: иначе конец строки и начало следующей
99
+ * неразличимы, и восстановленная по смещению каретка возвращается на строку выше (это видно
100
+ * сразу после Enter, когда содержимое перестраивают — например подсветкой конструкций).
101
+ */
102
+ type CharUnit = { kind: "text"; node: Text } | { kind: "line"; node: Node; offset: number };
103
+
104
+ /**
105
+ * Обходит содержимое в текстовых координатах по порядку. `visit` возвращает true — обход
106
+ * прекращается (границу нашли, дальше считать нечего).
107
+ *
108
+ * Позиция единицы «конец строки» — сразу ЗА переносом (или в начале следующего блока): именно
109
+ * её занимает каретка, оказавшись на новой строке.
110
+ */
111
+ function walkChars(root: HTMLElement, visit: (unit: CharUnit) => boolean | void) {
112
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
113
+ let blocks = 0;
114
+
115
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
116
+ if (node.nodeType === Node.TEXT_NODE) {
117
+ if (visit({ kind: "text", node: node as Text })) return;
118
+ continue;
119
+ }
120
+
121
+ const el = node as HTMLElement;
122
+
123
+ if (el.tagName === "BR") {
124
+ const parent = el.parentNode;
125
+ if (!parent) continue;
126
+
127
+ const index = Array.prototype.indexOf.call(parent.childNodes, el) + 1;
128
+ if (visit({ kind: "line", node: parent, offset: index })) return;
129
+ continue;
130
+ }
131
+
132
+ // граница блоков — только между ними: перед первым строка ещё не кончалась
133
+ if (el.parentNode === root && blockTypeOfTag(el.tagName)) {
134
+ blocks++;
135
+ if (blocks > 1 && visit({ kind: "line", node: el, offset: 0 })) return;
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Содержимое в тех же координатах, в которых считается каретка: концы строк — переводом строки.
142
+ * Обычного `textContent` для этого мало — в нём переносов нет, и смещения по нему разъезжаются
143
+ * со смещениями каретки ровно на число строк.
144
+ */
145
+ export function editorText(root: HTMLElement): string {
146
+ let text = "";
147
+ walkChars(root, (unit) => {
148
+ text += unit.kind === "text" ? unit.node.data : "\n";
149
+ });
150
+
151
+ return text;
152
+ }
153
+
154
+ /** Длина содержимого в координатах каретки (текст + концы строк внутри). */
155
+ export function charLength(root: HTMLElement): number {
156
+ let length = 0;
157
+ walkChars(root, (unit) => {
158
+ length += unit.kind === "text" ? unit.node.length : 1;
159
+ });
160
+
161
+ return length;
162
+ }
163
+
92
164
  /** Абсолютные текстовые смещения границ выделения внутри root (для восстановления после правок DOM). */
93
165
  export function selectionCharBounds(root: HTMLElement, range: Range): [number, number] {
94
166
  const probe = document.createRange();
95
167
  probe.selectNodeContents(root);
96
168
  probe.setEnd(range.startContainer, range.startOffset);
97
- const start = probe.toString().length;
169
+ const text = probe.toString().length;
170
+ const start = text + linesBefore(root, range.startContainer, range.startOffset);
98
171
  if (range.collapsed) return [start, start];
99
172
 
100
173
  // длину выделения меряем от его начала, а не от начала редактора: иначе весь текст
101
174
  // до каретки собирается в строку дважды
102
175
  probe.setStart(range.startContainer, range.startOffset);
103
176
  probe.setEnd(range.endContainer, range.endOffset);
104
- return [start, start + probe.toString().length];
177
+
178
+ return [start, text + probe.toString().length + linesBefore(root, range.endContainer, range.endOffset)];
179
+ }
180
+
181
+ // Сколько концов строк уже пройдено к моменту точки: единица считается, если её позиция
182
+ // не позже самой точки. Строк на порядок меньше, чем символов, поэтому считаем их отдельно —
183
+ // текст быстрее собрать одной строкой, чем обходить по узлам.
184
+ function linesBefore(root: HTMLElement, container: Node, offset: number): number {
185
+ const point = document.createRange();
186
+ point.setStart(container, offset);
187
+ point.collapse(true);
188
+
189
+ let count = 0;
190
+ walkChars(root, (unit) => {
191
+ if (unit.kind === "text") return false;
192
+ // единицы идут по порядку: встретили позицию за точкой — дальше только такие же
193
+ if (point.comparePoint(unit.node, unit.offset) > 0) return true;
194
+
195
+ count++;
196
+ return false;
197
+ });
198
+
199
+ return count;
105
200
  }
106
201
 
107
202
  /**
@@ -124,32 +219,50 @@ export function mapCharOffset(before: string, after: string, offset: number): nu
124
219
  return j;
125
220
  }
126
221
 
127
- type CharPosition = { node: Text; offset: number };
222
+ type CharPosition = { node: Node; offset: number };
128
223
 
129
224
  /**
130
- * Находит текстовые узлы и локальные смещения для пары абсолютных смещений за один обход.
131
- * Смещение за пределами текста прижимается к его концу.
225
+ * Находит узлы и локальные смещения для пары абсолютных смещений за один обход.
226
+ * Смещение за пределами содержимого прижимается к его концу.
132
227
  */
133
228
  function locateChars(root: HTMLElement, lower: number, upper: number): [CharPosition, CharPosition] | null {
134
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
135
229
  let count = 0;
136
- let last: Text | null = null;
230
+ let last: CharPosition | null = null;
137
231
  let low: CharPosition | null = null;
138
232
  let high: CharPosition | null = null;
139
233
 
140
- for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
141
- last = n;
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;
234
+ walkChars(root, (unit) => {
235
+ if (unit.kind === "text") {
236
+ const length = unit.node.length;
237
+
238
+ // конец текстового узла это конец строки, а не её начало: смещение, равное ему,
239
+ // принадлежит тексту, а следующая позиция (+1) — уже переносу
240
+ if (!low && count + length >= lower) low = { node: unit.node, offset: lower - count };
241
+ if (count + length >= upper) {
242
+ high = { node: unit.node, offset: upper - count };
243
+ return true;
244
+ }
245
+
246
+ count += length;
247
+ last = { node: unit.node, offset: length };
248
+ return false;
146
249
  }
147
- count += n.length;
148
- }
149
250
 
150
- if (!last) return null;
251
+ count++;
252
+ if (!low && count === lower) low = { node: unit.node, offset: unit.offset };
253
+ if (count === upper) {
254
+ high = { node: unit.node, offset: unit.offset };
255
+ return true;
256
+ }
257
+
258
+ last = { node: unit.node, offset: unit.offset };
259
+ return false;
260
+ });
261
+
262
+ if (!last && !low && !high) return null;
263
+
264
+ const tail: CharPosition = last ?? { node: root, offset: 0 };
151
265
 
152
- const tail: CharPosition = { node: last, offset: last.length };
153
266
  return [low ?? tail, high ?? tail];
154
267
  }
155
268
 
@@ -237,16 +350,45 @@ function caretProbe(range: Range): Node {
237
350
  return node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
238
351
  }
239
352
 
240
- /** Непустые текстовые узлы, целиком попавшие в диапазон (частично задетые правке не подлежат). */
241
- function collectTextNodes(root: HTMLElement, range: Range): Text[] {
242
- const nodes: Text[] = [];
353
+ /**
354
+ * Неделимый объект в тексте — элемент, объявленный нередактируемым (конструкции хоста:
355
+ * переменная, рандомизация). Разметка обязана оборачивать его целиком: внутрь него ни каретка,
356
+ * ни правка не заходят, а хост пересобирает его содержимое по-своему.
357
+ */
358
+ function atomicAt(node: Node, root: HTMLElement): HTMLElement | null {
359
+ const elem = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement;
360
+ const atomic = elem?.closest<HTMLElement>(ATOMIC_SELECTOR) ?? null;
361
+
362
+ // сам редактор бывает нередактируемым (disabled у хоста) — он объектом не является
363
+ return atomic && atomic !== root && root.contains(atomic) ? atomic : null;
364
+ }
365
+
366
+ /**
367
+ * Узлы правки: непустые текстовые узлы, целиком попавшие в диапазон (частично задетые правке
368
+ * не подлежат), где текст внутри неделимого объекта заменён самим объектом.
369
+ *
370
+ * Иначе разметка ложилась бы внутрь конструкции, и вместо `**раз {ИМЯ} два**` в значение
371
+ * уходило бы `**раз** **{ИМЯ}** **два**` — три отдельных куска вместо одного.
372
+ */
373
+ function collectTargets(root: HTMLElement, range: Range): Node[] {
374
+ const nodes: Node[] = [];
375
+ const seen = new Set<Node>();
376
+
243
377
  // intersectsNode в обходе дешевле пары compareBoundaryPoints и отсекает почти всё лишнее
244
- for (const n of touchedTextNodes(root, range)) if (nodeWithinRange(n, range)) nodes.push(n);
378
+ for (const n of touchedTextNodes(root, range)) {
379
+ if (!nodeWithinRange(n, range)) continue;
380
+
381
+ const target = atomicAt(n, root) ?? n;
382
+ if (seen.has(target)) continue;
383
+
384
+ seen.add(target);
385
+ nodes.push(target);
386
+ }
245
387
 
246
388
  return nodes;
247
389
  }
248
390
 
249
- function wrapTextNode(node: Text, tag: string) {
391
+ function wrapNode(node: Node, tag: string) {
250
392
  const wrapper = document.createElement(tag);
251
393
  node.parentNode?.insertBefore(wrapper, node);
252
394
  wrapper.appendChild(node);
@@ -272,7 +414,7 @@ function unwrapAround(fmt: HTMLElement, node: Node) {
272
414
  if (!fmt.firstChild) parent.removeChild(fmt);
273
415
  }
274
416
 
275
- function removeFormatFromNode(node: Text, tags: ReadonlySet<string>, root: HTMLElement) {
417
+ function removeFormatFromNode(node: Node, tags: ReadonlySet<string>, root: HTMLElement) {
276
418
  let fmt = formatAncestor(node, tags, root);
277
419
  while (fmt) {
278
420
  unwrapAround(fmt, node);
@@ -287,6 +429,18 @@ function unwrapElement(el: HTMLElement) {
287
429
  parent.removeChild(el);
288
430
  }
289
431
 
432
+ /**
433
+ * Внутри моноширинного разметки нет — ни своей, ни чужой.
434
+ *
435
+ * Написанное в коде остаётся буквальным: значение берёт из него голый текст, и любое
436
+ * форматирование внутри до получателя не доедет. Оставлять его в поле — показывать то,
437
+ * чего в сообщении не будет, поэтому чистим сразу, а не при сохранении.
438
+ */
439
+ function stripFormattingInCode(root: HTMLElement) {
440
+ for (const code of Array.from(root.querySelectorAll<HTMLElement>(CODE_SELECTOR)))
441
+ for (const el of Array.from(code.querySelectorAll<HTMLElement>(MATCH_SELECTOR))) unwrapElement(el);
442
+ }
443
+
290
444
  /**
291
445
  * Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст.
292
446
  *
@@ -295,6 +449,8 @@ function unwrapElement(el: HTMLElement) {
295
449
  * в режиме набора, поэтому повторные выборки по всему дереву тут заметны.
296
450
  */
297
451
  export function cleanupFormatting(root: HTMLElement) {
452
+ stripFormattingInCode(root);
453
+
298
454
  const queue: HTMLElement[] = Array.from(root.querySelectorAll<HTMLElement>(FORMAT_SELECTOR));
299
455
 
300
456
  const enqueue = (node: Node | null | undefined) => {
@@ -306,12 +462,14 @@ export function cleanupFormatting(root: HTMLElement) {
306
462
  const el = queue.pop()!;
307
463
  if (!el.isConnected || !root.contains(el)) continue;
308
464
 
309
- // пустой тег: после удаления его соседи могут стать смежными одинаковыми,
310
- // а родитель опустеть
465
+ // Тег без текста: оформлять в нём нечего. Разворачиваем, а не удаляем — внутри может
466
+ // лежать перенос строки, и вместе с тегом он унёс бы разделение строк (снятие формата
467
+ // с нескольких строк схлопывало их в одну). Пустой совсем — исчезнет и так.
468
+ // После правки соседи могут стать смежными одинаковыми, а родитель — опустеть.
311
469
  if (el.textContent === "") {
312
470
  enqueue(el.nextSibling);
313
471
  enqueue(el.parentElement);
314
- el.remove();
472
+ unwrapElement(el);
315
473
  continue;
316
474
  }
317
475
 
@@ -354,7 +512,7 @@ function editSelection(
354
512
  range: Range,
355
513
  selection: Selection,
356
514
  restoreBounds: [number, number] | undefined,
357
- mutate: (nodes: Text[]) => void
515
+ mutate: (nodes: Node[]) => void
358
516
  ) {
359
517
  if (range.collapsed) return;
360
518
 
@@ -362,7 +520,7 @@ function editSelection(
362
520
 
363
521
  splitBoundaries(range);
364
522
 
365
- const nodes = collectTextNodes(root, range);
523
+ const nodes = collectTargets(root, range);
366
524
  if (!nodes.length) return;
367
525
 
368
526
  mutate(nodes);
@@ -390,7 +548,7 @@ export function toggleFormat(
390
548
  if (allFormatted) {
391
549
  for (const n of nodes) removeFormatFromNode(n, tags, root);
392
550
  } else {
393
- for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
551
+ for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapNode(n, def.tag);
394
552
  }
395
553
  });
396
554
  }