@brandup/ui-richeditor 1.0.40 → 1.0.42

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;
249
+ }
250
+
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;
146
256
  }
147
- count += n.length;
148
- }
149
257
 
150
- if (!last) return null;
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
 
@@ -217,8 +330,18 @@ function nodeWithinRange(node: Node, range: Range): boolean {
217
330
  function* touchedTextNodes(root: HTMLElement, range: Range): Generator<Text> {
218
331
  const walker = document.createTreeWalker(rangeScope(root, range), NodeFilter.SHOW_TEXT);
219
332
 
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;
333
+ for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
334
+ if (!n.length || !range.intersectsNode(n)) continue;
335
+
336
+ // Узел, задетый одной лишь границей, не входит в диапазон ни одним символом: выделение
337
+ // кончается там, где он начинается, или начинается там, где он кончается. Учитывать его
338
+ // нельзя — соседний по тексту узел лежит уже вне тегов выделенного, и от него состояние
339
+ // всего выделения выглядело бы неотформатированным.
340
+ if (range.startContainer === n && range.startOffset === n.length) continue;
341
+ if (range.endContainer === n && range.endOffset === 0) continue;
342
+
343
+ yield n;
344
+ }
222
345
  }
223
346
 
224
347
  /** Узел под схлопнутой кареткой — от него и ищется формат. */
@@ -227,16 +350,45 @@ function caretProbe(range: Range): Node {
227
350
  return node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
228
351
  }
229
352
 
230
- /** Непустые текстовые узлы, целиком попавшие в диапазон (частично задетые правке не подлежат). */
231
- function collectTextNodes(root: HTMLElement, range: Range): Text[] {
232
- 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
+
233
377
  // intersectsNode в обходе дешевле пары compareBoundaryPoints и отсекает почти всё лишнее
234
- 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
+ }
235
387
 
236
388
  return nodes;
237
389
  }
238
390
 
239
- function wrapTextNode(node: Text, tag: string) {
391
+ function wrapNode(node: Node, tag: string) {
240
392
  const wrapper = document.createElement(tag);
241
393
  node.parentNode?.insertBefore(wrapper, node);
242
394
  wrapper.appendChild(node);
@@ -262,7 +414,7 @@ function unwrapAround(fmt: HTMLElement, node: Node) {
262
414
  if (!fmt.firstChild) parent.removeChild(fmt);
263
415
  }
264
416
 
265
- function removeFormatFromNode(node: Text, tags: ReadonlySet<string>, root: HTMLElement) {
417
+ function removeFormatFromNode(node: Node, tags: ReadonlySet<string>, root: HTMLElement) {
266
418
  let fmt = formatAncestor(node, tags, root);
267
419
  while (fmt) {
268
420
  unwrapAround(fmt, node);
@@ -277,6 +429,18 @@ function unwrapElement(el: HTMLElement) {
277
429
  parent.removeChild(el);
278
430
  }
279
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
+
280
444
  /**
281
445
  * Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст.
282
446
  *
@@ -285,6 +449,8 @@ function unwrapElement(el: HTMLElement) {
285
449
  * в режиме набора, поэтому повторные выборки по всему дереву тут заметны.
286
450
  */
287
451
  export function cleanupFormatting(root: HTMLElement) {
452
+ stripFormattingInCode(root);
453
+
288
454
  const queue: HTMLElement[] = Array.from(root.querySelectorAll<HTMLElement>(FORMAT_SELECTOR));
289
455
 
290
456
  const enqueue = (node: Node | null | undefined) => {
@@ -344,7 +510,7 @@ function editSelection(
344
510
  range: Range,
345
511
  selection: Selection,
346
512
  restoreBounds: [number, number] | undefined,
347
- mutate: (nodes: Text[]) => void
513
+ mutate: (nodes: Node[]) => void
348
514
  ) {
349
515
  if (range.collapsed) return;
350
516
 
@@ -352,7 +518,7 @@ function editSelection(
352
518
 
353
519
  splitBoundaries(range);
354
520
 
355
- const nodes = collectTextNodes(root, range);
521
+ const nodes = collectTargets(root, range);
356
522
  if (!nodes.length) return;
357
523
 
358
524
  mutate(nodes);
@@ -380,7 +546,7 @@ export function toggleFormat(
380
546
  if (allFormatted) {
381
547
  for (const n of nodes) removeFormatFromNode(n, tags, root);
382
548
  } else {
383
- for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
549
+ for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapNode(n, def.tag);
384
550
  }
385
551
  });
386
552
  }