@brandup/ui-richeditor 1.0.44 → 1.0.47

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.
@@ -425,10 +425,12 @@ function collectTargets(root: HTMLElement, range: Range): Node[] {
425
425
  return nodes;
426
426
  }
427
427
 
428
- function wrapNode(node: Node, tag: string) {
428
+ function wrapNode(node: Node, tag: string): HTMLElement {
429
429
  const wrapper = document.createElement(tag);
430
430
  node.parentNode?.insertBefore(wrapper, node);
431
431
  wrapper.appendChild(node);
432
+
433
+ return wrapper;
432
434
  }
433
435
 
434
436
  /** Выносит ветку, содержащую node, наружу из элемента fmt (расщепляя fmt на «до» и «после»). */
@@ -478,6 +480,17 @@ function stripFormattingInCode(root: HTMLElement) {
478
480
  for (const el of Array.from(code.querySelectorAll<HTMLElement>(MATCH_SELECTOR))) unwrapElement(el);
479
481
  }
480
482
 
483
+ /**
484
+ * Один ли это формат у двух соседей. Тега для этого мало, когда формат несёт данные: у ссылки
485
+ * их несёт адрес, и склеив соседние ссылки с разными адресами, редактор потерял бы второй,
486
+ * ничего об этом не сказав.
487
+ */
488
+ function sameFormat(a: HTMLElement, b: HTMLElement): boolean {
489
+ if (a.tagName !== b.tagName) return false;
490
+
491
+ return a.tagName !== "A" || a.getAttribute("href") === b.getAttribute("href");
492
+ }
493
+
481
494
  /**
482
495
  * Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст.
483
496
  *
@@ -520,9 +533,9 @@ export function cleanupFormatting(root: HTMLElement) {
520
533
  continue;
521
534
  }
522
535
 
523
- // соседний такой же тег слева — склеиваем
536
+ // соседний такой же формат слева — склеиваем
524
537
  const prev = el.previousSibling;
525
- if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === el.tagName) {
538
+ if (prev && prev.nodeType === Node.ELEMENT_NODE && sameFormat(prev as HTMLElement, el)) {
526
539
  const children = Array.from(el.children);
527
540
  enqueue(el.nextSibling);
528
541
  enqueue(el.parentElement);
@@ -590,6 +603,46 @@ export function toggleFormat(
590
603
  });
591
604
  }
592
605
 
606
+ const LINK_TAGS = TOOL_TAG_SETS.link;
607
+
608
+ /**
609
+ * Ссылка, внутри которой стоит каретка или начало выделения; null — выделение не в ссылке.
610
+ * По ней панель узнаёт текущий адрес: у ссылки состояние — не «включена», а «вот этот адрес».
611
+ */
612
+ export function linkAt(root: HTMLElement, range: Range): HTMLAnchorElement | null {
613
+ return formatAncestor(caretProbe(range), LINK_TAGS, root) as HTMLAnchorElement | null;
614
+ }
615
+
616
+ /**
617
+ * Ставит ссылку на выделение или меняет адрес у той, в которой оно стоит; пустой адрес — снимает.
618
+ *
619
+ * Не переключатель, в отличие от {@link toggleFormat}: у ссылки есть данные, и повторное
620
+ * применение с другим адресом — это правка, а не снятие. Снятие выражается пустым адресом.
621
+ */
622
+ export function applyLink(
623
+ root: HTMLElement,
624
+ range: Range,
625
+ url: string,
626
+ selection: Selection,
627
+ restoreBounds?: [number, number]
628
+ ) {
629
+ editSelection(root, range, selection, restoreBounds, (nodes) => {
630
+ for (const node of nodes) {
631
+ const existing = formatAncestor(node, LINK_TAGS, root);
632
+
633
+ if (!url) {
634
+ if (existing) removeFormatFromNode(node, LINK_TAGS, root);
635
+ continue;
636
+ }
637
+
638
+ // Уже в ссылке — меняем адрес у неё целиком: разрезать её ради части выделения значит
639
+ // сделать из одной ссылки две, а просили поправить адрес.
640
+ if (existing) existing.setAttribute("href", url);
641
+ else wrapNode(node, "a").setAttribute("href", url);
642
+ }
643
+ });
644
+ }
645
+
593
646
  /** Снимает всё форматирование с выделения (все инструменты сразу, включая теги-синонимы). */
594
647
  export function clearFormat(root: HTMLElement, range: Range, selection: Selection, restoreBounds?: [number, number]) {
595
648
  editSelection(root, range, selection, restoreBounds, (nodes) => {
@@ -39,6 +39,46 @@ function lineBreak(storage: FormatStorage): string {
39
39
  return storage === "html" ? "<br>" : "\n";
40
40
  }
41
41
 
42
+ /**
43
+ * Текст ссылки в markdown: скобки внутри разорвали бы её на разборе, поэтому экранируются.
44
+ * Обратная косая — тоже: иначе она съела бы следующий символ при чтении.
45
+ */
46
+ function escapeLinkText(text: string): string {
47
+ return text.replace(/[\\[\]]/g, "\\$&");
48
+ }
49
+
50
+ /** Обратное к {@link escapeLinkText}: снимаем только то, что сами ставим. */
51
+ function unescapeLinkText(text: string): string {
52
+ return text.replace(/\\([\\[\]])/g, "$1");
53
+ }
54
+
55
+ /** Значение атрибута: кавычка внутри разорвала бы его. */
56
+ function attrValue(text: string): string {
57
+ return text.replace(/"/g, "&quot;");
58
+ }
59
+
60
+ /**
61
+ * Адрес ссылки в markdown. Пробелы и непарные скобки конец адреса не обозначают, поэтому такой
62
+ * адрес берётся в угловые скобки — форма markdown ровно для этого случая. Парные скобки читаются
63
+ * и без них: без этого половина ссылок на википедию писалась бы угловыми.
64
+ */
65
+ function markdownUrl(href: string): string {
66
+ if (!/[\s<>]/.test(href) && balancedParens(href)) return href;
67
+
68
+ // сами угловые внутри пришлось бы отличать от закрывающей — кодируем
69
+ return `<${href.replace(/[<>]/g, (char) => encodeURIComponent(char))}>`;
70
+ }
71
+
72
+ function balancedParens(text: string): boolean {
73
+ let depth = 0;
74
+ for (const char of text) {
75
+ if (char === "(") depth++;
76
+ else if (char === ")" && --depth < 0) return false;
77
+ }
78
+
79
+ return depth === 0;
80
+ }
81
+
42
82
  function wrap(
43
83
  storage: FormatStorage,
44
84
  tool: FormatTool,
@@ -120,6 +160,28 @@ function serializeInline(
120
160
  continue;
121
161
  }
122
162
 
163
+ // Ссылка собирается не маркером: адрес идёт отдельной частью и в тексте не виден.
164
+ // Без адреса или без текста ссылки нет — остаётся одно содержимое, как у отключённого тега.
165
+ if (tool === "link") {
166
+ // Адрес приходит из атрибута распакованным. При разборе значения его уже проверяли,
167
+ // но в DOM он мог попасть и мимо: вставкой, правкой из кода. Проверяем ещё раз здесь —
168
+ // через это место идут обе стороны, и сборка значения, и санитизация вставки.
169
+ const url = safeUrl(el.getAttribute("href") ?? "");
170
+ // Перенос внутри ссылки разметкой не выражается, и разбор её обратно не соберёт —
171
+ // оставляем текст, как и без адреса. Своя правка такого не создаёт (см. splitLinkOut
172
+ // в ./editing), а вот вставка чужого HTML — запросто.
173
+ if (!inner || !url || el.querySelector("br")) {
174
+ result += inner;
175
+ continue;
176
+ }
177
+
178
+ result +=
179
+ storage === "html"
180
+ ? `<a href="${attrValue(escapeHtml(url))}">${inner}</a>`
181
+ : `[${escapeLinkText(inner)}](${markdownUrl(url)})`;
182
+ continue;
183
+ }
184
+
123
185
  // неизвестный или отключённый тег — отбрасываем обёртку, оставляем текст
124
186
  result += tool ? wrap(storage, tool, inner, markers, el) : inner;
125
187
  }
@@ -345,8 +407,43 @@ function balancedTags(inner: string): boolean {
345
407
  const STASH_MARK = "&#0;";
346
408
  const STASH_PATTERN = /&#0;(\d+)&#0;/g;
347
409
 
410
+ // Адрес ссылки на то же время: в нём сплошь и рядом попадаются символы маркеров
411
+ // (`example.com/a_b_c`), а разметкой они там не являются. Метка своя, не общая с кодом:
412
+ // у кода прячется содержимое и возвращается тегом, здесь — значение атрибута готового тега.
413
+ const HREF_MARK = "&#1;";
414
+ const HREF_PATTERN = /&#1;(\d+)&#1;/g;
415
+
416
+ /**
417
+ * Ссылка markdown: `[текст](адрес)`. Текст — со скобками внутри, если они экранированы, но без
418
+ * переноса строки: разорванная ссылка разметкой не выражается, как и разорванная пара маркеров.
419
+ * Адрес — либо в угловых скобках (тогда в нём допустимы пробелы), либо до закрывающей скобки,
420
+ * причём парные скобки внутри разрешены: ими кончается половина ссылок на википедию.
421
+ *
422
+ * Разбирается по уже заэкранированному тексту, поэтому угловые скобки здесь — `&lt;`/`&gt;`.
423
+ */
424
+ const LINK_PATTERN = /\[((?:\\.|[^\\[\]\n])*)\]\((&lt;[^\n]*?&gt;|(?:[^\s()]|\([^()]*\))*)\)/g;
425
+
426
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
427
+ const SAFE_SCHEME = /^(?:https?|mailto|tel):/i;
428
+
429
+ /**
430
+ * Адрес, безопасный для `href`; пустая строка — такой ссылке не быть.
431
+ *
432
+ * `javascript:` в адресе — это исполнение кода, пришедшего вместе со значением: та же дыра, от
433
+ * которой бережёт вывод текста текстом. Пропускаем известные схемы и адреса без схемы вовсе —
434
+ * относительные и протокол-относительные.
435
+ */
436
+ function safeUrl(href: string): string {
437
+ const value = href.trim();
438
+ // Схему читаем так же, как её прочитает браузер: всё, из чего схема состоять не может,
439
+ // он из неё выбрасывает — и без этого `java<таб>script:` прошло бы мимо проверки.
440
+ const probe = value.replace(/[^a-z0-9+.:/-]/gi, "");
441
+
442
+ return !SCHEME.test(probe) || SAFE_SCHEME.test(probe) ? value : "";
443
+ }
444
+
348
445
  // Markdown-разметка одного абзаца → инлайновый HTML (escape, маркеры, \n→<br>).
349
- function markdownInline(text: string, order: MarkerRule[]): string {
446
+ function markdownInline(text: string, order: MarkerRule[], linked: boolean): string {
350
447
  let html = escapeHtml(text);
351
448
 
352
449
  // Код — первым: внутри него `*звёздочки*` остаются текстом, как у мессенджеров. Иначе
@@ -360,6 +457,24 @@ function markdownInline(text: string, order: MarkerRule[]): string {
360
457
  return `${lead}${STASH_MARK}${stash.length - 1}${STASH_MARK}`;
361
458
  });
362
459
 
460
+ // Ссылка — следом за кодом и до маркеров. Следом за кодом, чтобы `[раз](два)` внутри него
461
+ // осталось текстом; до маркеров — потому что в адресе их символы попадаются сплошь и рядом
462
+ // (`example.com/a_b_c`), а разметкой там не являются. Прячем меткой один адрес: текст
463
+ // остаётся в потоке, и разметка внутри него разбирается наравне с остальной.
464
+ const hrefs: string[] = [];
465
+ if (linked && html.includes("("))
466
+ html = html.replace(LINK_PATTERN, (match, inner: string, url: string) => {
467
+ // адрес в угловых скобках — форма для адресов с пробелами; сами скобки в него не входят
468
+ const raw = url.startsWith("&lt;") ? url.slice(4, -4) : url;
469
+ const href = safeUrl(raw);
470
+ const text = unescapeLinkText(inner);
471
+ if (!text || !href) return match; // без текста или без адреса ссылки нет
472
+
473
+ hrefs.push(href);
474
+
475
+ return `<a href="${HREF_MARK}${hrefs.length - 1}${HREF_MARK}">${text}</a>`;
476
+ });
477
+
363
478
  for (const rule of order) {
364
479
  if (rule.tool === "code") continue; // уже вынесен
365
480
  // Разбор идёт по абзацу на каждый маркер, а маркеров с диалектами уже семь. Поиск
@@ -381,6 +496,9 @@ function markdownInline(text: string, order: MarkerRule[]): string {
381
496
  });
382
497
  }
383
498
 
499
+ // адреса возвращаются последними: до этого места они прятались от маркеров
500
+ if (hrefs.length) html = html.replace(HREF_PATTERN, (_match, index: string) => attrValue(hrefs[+index]));
501
+
384
502
  // переносы — после маркеров: пока это \n, запрет на пересечение строки работает
385
503
  return html.replace(/\r?\n/g, "<br>");
386
504
  }
@@ -459,13 +577,17 @@ export function deserialize(
459
577
 
460
578
  if (storage === "markdown") {
461
579
  const order = orderedMarkers(tools, markers);
580
+ // ссылка живёт мимо маркеров, поэтому в order её нет — спрашиваем набор напрямую
581
+ const linked = tools.includes("link");
462
582
 
463
- if (!paragraphs) return markdownInline(value, order);
583
+ if (!paragraphs) return markdownInline(value, order, linked);
464
584
 
465
585
  return markdownBlocks(value, types, separate)
466
586
  .map(([type, text]) => {
467
587
  const def = BLOCK_TYPES[type];
468
- const inner = def.inline ? markdownInline(text, order) : escapeHtml(text).replace(/\n/g, "<br>");
588
+ const inner = def.inline
589
+ ? markdownInline(text, order, linked)
590
+ : escapeHtml(text).replace(/\n/g, "<br>");
469
591
 
470
592
  // Пустая последняя строка видна только с заполнителем: без него браузер не рисует
471
593
  // её и не пускает туда каретку — строка, которая в значении есть, пропала бы.