@brandup/ui-richeditor 1.0.48 → 1.0.50

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.
@@ -78,6 +78,10 @@ export function innerSelection(root: HTMLElement): Selection | null {
78
78
  const selection = documentSelection(root);
79
79
  if (!selection || selection.rangeCount === 0 || !root.contains(selection.anchorNode)) return null;
80
80
 
81
+ // Оба конца, а не только якорь: выделение можно вытянуть из редактора на страницу,
82
+ // и правка по такому диапазону трогала бы DOM за пределами содержимого.
83
+ if (!root.contains(selection.focusNode)) return null;
84
+
81
85
  return selection;
82
86
  }
83
87
 
@@ -233,7 +237,10 @@ function linesBefore(root: HTMLElement, container: Node, offset: number): number
233
237
  * пробелов и может уехать в соседнее слово.
234
238
  */
235
239
  export function mapCharOffset(before: string, after: string, offset: number): number {
236
- const same = (a: string, b: string) => a === b || (b === " " && (a === " " || a === "\t"));
240
+ // Пробелом после нормализации становится и таб, и неразрывный пробел (U+00A0) тот
241
+ // браузер сам подставляет в contenteditable; без него выравнивание срывалось бы на
242
+ // первом же nbsp, и каретка уезжала к нему.
243
+ const same = (a: string, b: string) => a === b || (b === " " && (a === " " || a === "\t" || a === " "));
237
244
 
238
245
  let i = 0;
239
246
  let j = 0;
@@ -433,13 +440,34 @@ function wrapNode(node: Node, tag: string): HTMLElement {
433
440
  return wrapper;
434
441
  }
435
442
 
436
- /** Выносит ветку, содержащую node, наружу из элемента fmt (расщепляя fmt на «до» и «после»). */
443
+ /** Выносит node наружу из элемента fmt (расщепляя fmt на «до» и «после»). */
437
444
  function unwrapAround(fmt: HTMLElement, node: Node) {
438
445
  const parent = fmt.parentNode;
439
446
  if (!parent) return;
440
447
 
448
+ // Промежуточные предки расщепляются вокруг узла: ветка целиком несла бы наружу и чужой
449
+ // текст — в <b><i>hello world</i></b> снятие жирного с «hello» уносило бы из <b> весь <i>,
450
+ // и « world» терял бы формат, который с него не снимали.
451
+ //
452
+ // Осознанная цена: расщеплённая ссылка становится двумя <a> с одним адресом — в значении
453
+ // два соседних куска вместо одного. Целая ссылка с чужим форматом снаружи стоила бы дороже:
454
+ // формат снимался бы с текста, которого не выделяли.
441
455
  let child: Node = node;
442
- while (child.parentNode && child.parentNode !== fmt) child = child.parentNode;
456
+ while (child.parentNode && child.parentNode !== fmt) {
457
+ const holder = child.parentNode as HTMLElement;
458
+
459
+ const left = holder.cloneNode(false) as HTMLElement;
460
+ while (holder.firstChild && holder.firstChild !== child) left.appendChild(holder.firstChild);
461
+ if (left.firstChild) holder.parentNode?.insertBefore(left, holder);
462
+
463
+ if (child.nextSibling) {
464
+ const right = holder.cloneNode(false) as HTMLElement;
465
+ while (child.nextSibling) right.appendChild(child.nextSibling);
466
+ holder.parentNode?.insertBefore(right, holder.nextSibling);
467
+ }
468
+
469
+ child = holder; // держит теперь только выносимую ветку
470
+ }
443
471
  if (child.parentNode !== fmt) return;
444
472
 
445
473
  const left = fmt.cloneNode(false) as HTMLElement;
@@ -610,7 +638,9 @@ const LINK_TAGS = TOOL_TAG_SETS.link;
610
638
  * По ней панель узнаёт текущий адрес: у ссылки состояние — не «включена», а «вот этот адрес».
611
639
  */
612
640
  export function linkAt(root: HTMLElement, range: Range): HTMLAnchorElement | null {
613
- return formatAncestor(caretProbe(range), LINK_TAGS, root) as HTMLAnchorElement | null;
641
+ // Пробой бывает и сам тег ссылки, а не текст внутри него: границы выделения встают на
642
+ // элементы при Ctrl+A и selectAllContent — искать надо включая сам узел (см. formatAt).
643
+ return formatAt(caretProbe(range), LINK_TAGS, root) as HTMLAnchorElement | null;
614
644
  }
615
645
 
616
646
  /**
@@ -658,12 +688,6 @@ export function clearAllFormat(root: HTMLElement) {
658
688
  root.normalize();
659
689
  }
660
690
 
661
- /**
662
- * Состояние форматирования на выделении указанными тегами.
663
- * `every` — отформатирован весь текст (подсветка кнопки инструмента),
664
- * `some` — отформатирована хоть какая-то часть (доступность очистки).
665
- * Обход прерывается на первом узле, решающем исход.
666
- */
667
691
  /** Есть ли форматирование хоть на части выделения (или под кареткой) — доступность кнопки очистки. */
668
692
  export function hasFormatting(root: HTMLElement, range: Range): boolean {
669
693
  if (range.collapsed) return formatAncestor(caretProbe(range), MATCH_TAG_SET, root) !== null;
@@ -713,7 +737,13 @@ export function hasAnyFormatting(root: HTMLElement): boolean {
713
737
  * Вставляет текст в позицию каретки, оборачивая его в указанные форматы (режим набора).
714
738
  * Каретка ставится сразу после вставленного текста; соседние одинаковые теги склеиваются.
715
739
  */
716
- export function insertFormattedText(root: HTMLElement, data: string, tools: FormatTool[], selection: Selection) {
740
+ export function insertFormattedText(
741
+ root: HTMLElement,
742
+ data: string,
743
+ tools: FormatTool[],
744
+ selection: Selection,
745
+ href = ""
746
+ ) {
717
747
  if (!data || selection.rangeCount === 0) return;
718
748
 
719
749
  const range = selection.getRangeAt(0);
@@ -724,6 +754,9 @@ export function insertFormattedText(root: HTMLElement, data: string, tools: Form
724
754
  let node: Node = document.createTextNode(data);
725
755
  for (const tool of tools) {
726
756
  const el = document.createElement(FORMAT_TOOLS[tool].tag);
757
+ // Ссылка — не просто тег: без адреса она не переживёт сериализацию. Адрес передаёт
758
+ // вызывающий — с выделения, чьё оформление наследуется.
759
+ if (tool === "link" && href) el.setAttribute("href", href);
727
760
  el.appendChild(node);
728
761
  node = el;
729
762
  }
@@ -72,7 +72,9 @@ function markdownUrl(href: string): string {
72
72
  function balancedParens(text: string): boolean {
73
73
  let depth = 0;
74
74
  for (const char of text) {
75
- if (char === "(") depth++;
75
+ // Глубже одного уровня разбор ссылки не читает (LINK_PATTERN) — такой адрес
76
+ // должен уйти в угловые скобки, иначе ссылка при чтении распалась бы в текст.
77
+ if (char === "(" && ++depth > 1) return false;
76
78
  else if (char === ")" && --depth < 0) return false;
77
79
  }
78
80
 
@@ -101,6 +103,15 @@ function wrap(
101
103
  const core = inner.slice(leading.length, inner.length - trailing.length);
102
104
  if (!core) return inner; // одни пробелы — оборачивать нечего
103
105
 
106
+ // Маркер не пересекает перенос строки — тоже ни у нас, ни у мессенджера. Перенос внутри
107
+ // содержимого (Shift+Enter в жирном): размечаем каждую строку отдельно, иначе `**а\nб**`
108
+ // вернулось бы из значения буквальным текстом с маркерами.
109
+ if (core.includes("\n"))
110
+ return `${leading}${core
111
+ .split("\n")
112
+ .map((line) => wrap(storage, tool, line, markers, source))
113
+ .join("\n")}${trailing}`;
114
+
104
115
  return `${leading}${marker}${core}${marker}${trailing}`;
105
116
  }
106
117
 
@@ -146,17 +157,32 @@ function serializeInline(
146
157
 
147
158
  // В коде разметки нет: при разборе его содержимое не размечается, и вложенное
148
159
  // форматирование не вернулось бы — значение разошлось бы с тем, что было в поле.
160
+ // Перенос внутри — тоже содержимое: textContent съел бы его, склеив строки. В html
161
+ // перенос пишется тегом: сырой \n виден только под pre-wrap самого редактора.
149
162
  if (tool && FORMAT_TOOLS[tool].literal) {
150
- const text = el.textContent ?? "";
151
- result += wrap(storage, tool, storage === "html" ? escapeHtml(text) : text, markers, el);
163
+ const text = Array.from(el.childNodes)
164
+ .map((child) => (child.nodeName === "BR" ? "\n" : (child.textContent ?? "")))
165
+ .join("");
166
+ result += wrap(
167
+ storage,
168
+ tool,
169
+ storage === "html" ? escapeHtml(text).replace(/\n/g, "<br>") : text,
170
+ markers,
171
+ el
172
+ );
152
173
  continue;
153
174
  }
154
175
 
155
176
  const inner = serializeInline(el.childNodes, storage, tagMap, markers);
156
177
 
157
- // вложенный блочный элемент (нестандарт) без обёртки, просто содержимое
178
+ // Вложенный блочный элемент — это строка: его граница даёт перенос, иначе соседние
179
+ // строки склеивались бы встык — VS Code и браузеры отдают буфер строками в <div>
180
+ // внутри общей обёртки. Хвостовой <br> внутри — заполнитель пустой строки (как у
181
+ // абзацев самого редактора), а не её содержимое.
158
182
  if (tag === "DIV" || tag === "P") {
159
- result += inner;
183
+ const br = lineBreak(storage);
184
+ if (result && !result.endsWith(br)) result += br;
185
+ result += trimTrailingBreaks(inner, storage) + br;
160
186
  continue;
161
187
  }
162
188
 
@@ -242,6 +268,10 @@ function serializeParagraphs(
242
268
  }
243
269
  flush();
244
270
 
271
+ // Пустой обычный блок ВНУТРИ содержимого — осознанная пустая строка (за цитатой или кодом
272
+ // её держит и normalizeParagraphs), поэтому здесь он не отбрасывается: этой же функцией
273
+ // значение и ЧИТАЕТСЯ (deserialize html идёт через неё), и фильтр молча терял бы пустые
274
+ // строки уже сохранённых значений при первой же загрузке.
245
275
  const cleaned = blocks.map(([type, text]) => [type, trimTrailingBreaks(text, storage)] as const);
246
276
 
247
277
  // Пустые блоки в хвосте в значение не идут: последний из них — место каретки, оставленное
@@ -448,11 +478,13 @@ function markdownInline(text: string, order: MarkerRule[], linked: boolean): str
448
478
 
449
479
  // Код — первым: внутри него `*звёздочки*` остаются текстом, как у мессенджеров. Иначе
450
480
  // содержимое кода размечалось бы, и то, что человек написал буквально, уезжало бы жирным.
451
- const stash: Array<[inner: string, marked: string]> = []; // содержимое и чужой маркер, если был
481
+ // содержимое, чужой маркер (если был) и исходный текст целиком для мест, где разметка
482
+ // не действует и спрятанное надо вернуть буквально (адрес ссылки)
483
+ const stash: Array<[inner: string, marked: string, literal: string]> = [];
452
484
  for (const rule of order)
453
485
  if (rule.tool === "code" && html.includes(rule.marker))
454
486
  html = html.replace(pattern(rule), (_match, lead: string, inner: string) => {
455
- stash.push([inner, rule.own ? "" : rule.marker]);
487
+ stash.push([inner, rule.own ? "" : rule.marker, `${rule.marker}${inner}${rule.marker}`]);
456
488
 
457
489
  return `${lead}${STASH_MARK}${stash.length - 1}${STASH_MARK}`;
458
490
  });
@@ -465,7 +497,10 @@ function markdownInline(text: string, order: MarkerRule[], linked: boolean): str
465
497
  if (linked && html.includes("("))
466
498
  html = html.replace(LINK_PATTERN, (match, inner: string, url: string) => {
467
499
  // адрес в угловых скобках — форма для адресов с пробелами; сами скобки в него не входят
468
- const raw = url.startsWith("&lt;") ? url.slice(4, -4) : url;
500
+ const bare = url.startsWith("&lt;") ? url.slice(4, -4) : url;
501
+ // Бэктики в адресе — буквальные символы: код успел спрятаться раньше (он разбирается
502
+ // первым), и без возврата метка уехала бы в href, а содержимое кода пропало бы.
503
+ const raw = bare.replace(STASH_PATTERN, (_m, index: string) => stash[+index][2]);
469
504
  const href = safeUrl(raw);
470
505
  const text = unescapeLinkText(inner);
471
506
  if (!text || !href) return match; // без текста или без адреса ссылки нет
@@ -603,9 +638,10 @@ export function deserialize(
603
638
  template.innerHTML = value;
604
639
  const tagMap = buildTagMap(tools);
605
640
 
606
- if (!paragraphs) return serializeInline(template.content.childNodes, "html", tagMap, defaultFormatMarkers());
641
+ // markers html-ветке не нужны (wrap выходит раньше), передаются лишь ради сигнатуры
642
+ if (!paragraphs) return serializeInline(template.content.childNodes, "html", tagMap, markers);
607
643
 
608
- return serializeParagraphs(template.content, "html", tagMap, defaultFormatMarkers(), types, separate).replace(
644
+ return serializeParagraphs(template.content, "html", tagMap, markers, types, separate).replace(
609
645
  /<([a-z]+)><\/\1>/g,
610
646
  "<$1><br></$1>"
611
647
  );
@@ -639,7 +675,15 @@ function markdownBlocks(value: string, types: BlockType[], separate: boolean): A
639
675
  for (let i = 0; i < lines.length; i++) {
640
676
  const line = lines[i];
641
677
 
642
- const fence = fenced.find((type) => line.trimEnd() === BLOCK_TYPES[type].fence);
678
+ // Открывающая ограда бывает с меткой языка (```text) блок она открывает так же, а сама
679
+ // метка отбрасывается: значение кита её не хранит. Остаток с символом ограды меткой не
680
+ // считается (```` — не ограда с меткой `). Закрывающая ограда — только голая: та же
681
+ // строка с меткой внутри блока — его содержимое, а не конец.
682
+ const fence = fenced.find((type) => {
683
+ const marker = BLOCK_TYPES[type].fence!;
684
+ const trimmed = line.trimEnd();
685
+ return trimmed.startsWith(marker) && !trimmed.slice(marker.length).includes(marker[0]);
686
+ });
643
687
  if (fence) {
644
688
  let close = -1;
645
689
  for (let at = i + 1; at < lines.length; at++)
package/source/toolbar.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // т.к. тулбар находится вне привязанных UIElement).
7
7
 
8
8
  import { DOM } from "@brandup/ui";
9
- import { PopupManager } from "@brandup/ui-kit";
9
+ import { PopupManager, SCROLLABLE_CLASS } from "@brandup/ui-kit";
10
10
  import {
11
11
  BLOCK_TYPES,
12
12
  DEFAULT_BLOCK,
@@ -68,10 +68,10 @@ export const LINK_ROW_CLASS = "link-row";
68
68
  // панель показывает поле адреса вместо кнопок
69
69
  export const LINK_EDITING_CLASS = "link-editing";
70
70
 
71
- // Временно скрытые кнопки. Сами возможности работают: значение разбирается, показывается
72
- // и сохраняется, правку можно вызвать из кода в панель они просто не выводятся.
73
- // Убрать отсюда, когда будут доведены.
74
- const HIDDEN_TOOLS: FormatTool[] = ["spoiler"];
71
+ // Временно скрытые кнопки для возможностей, которые работают (значение разбирается,
72
+ // показывается и сохраняется, правка зовётся из кода), но в панель ещё не выводятся.
73
+ // Сейчас скрытых нет; механика остаётся на следующую такую возможность.
74
+ const HIDDEN_TOOLS: FormatTool[] = [];
75
75
  const HIDDEN_BLOCKS: BlockType[] = [];
76
76
 
77
77
  export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
@@ -124,8 +124,12 @@ export interface ToolbarHost {
124
124
  readonly blockTools?: BlockType[];
125
125
  applyFormat(tool: FormatTool): void;
126
126
  isToolActive(tool: FormatTool): boolean;
127
- /** false — инструмент сейчас недоступен (например, внутри кода): кнопка гасится. */
128
- isToolEnabled?(tool: FormatTool): boolean;
127
+ /**
128
+ * false — инструмент сейчас недоступен (например, внутри кода): кнопка гасится.
129
+ * Признак «в коде» дорог (обход выделения) — панель считает его один раз на обновление
130
+ * и передаёт вторым аргументом, чтобы хост не обходил выделение на каждую кнопку.
131
+ */
132
+ isToolEnabled?(tool: FormatTool, codeActive?: boolean): boolean;
129
133
  /** Тип блока под кареткой — им подсвечивается активная кнопка блока. */
130
134
  readonly currentBlock?: BlockType;
131
135
  applyBlock?(type: BlockType): void;
@@ -150,6 +154,9 @@ export interface ToolbarHost {
150
154
  }
151
155
 
152
156
  const MARGIN = 6;
157
+ // Зазор от краёв экрана у панели в document.body. То же значение вычитается из её предельной
158
+ // ширины в richeditor.less (--richeditor-toolbar-edge-gap) — менять их нужно вместе.
159
+ const EDGE_GAP = 4;
153
160
 
154
161
  class FormatToolbar {
155
162
  private __elem: HTMLElement | null = null; // обёртка: её позиционируют
@@ -326,17 +333,27 @@ class FormatToolbar {
326
333
  };
327
334
 
328
335
  const active = host.activeTools?.();
336
+ // Признак «в коде» гасит остальные кнопки и подсвечивает объединённую; сам он — обход
337
+ // выделения, поэтому считается один раз на обновление, а не на каждую кнопку. Вывести
338
+ // его из activeTools можно только вместе с currentBlock (блок кода — не формат): хосту
339
+ // без блоков остаётся его собственный isCodeActive.
340
+ const inCode =
341
+ active && host.currentBlock !== undefined
342
+ ? host.currentBlock === CODE_TOOL || active.has(CODE_TOOL)
343
+ : host.isCodeActive?.();
344
+
329
345
  for (const [tool, btn] of this.__buttons) {
330
346
  // объединённая кнопка подсвечена и на блоке кода, а не только на моноширинном
331
347
  const isActive =
332
348
  this.__mergedCode && tool === CODE_TOOL
333
- ? !!host.isCodeActive?.()
349
+ ? !!inCode
334
350
  : active
335
351
  ? active.has(tool)
336
352
  : host.isToolActive(tool);
337
353
 
338
- if (btn.classList.contains("active") !== isActive) btn.classList.toggle("active", isActive);
339
- setDisabled(btn, host.isToolEnabled?.(tool) === false);
354
+ // toggle с force не трогает атрибут, когда состояние уже нужное, — мутации не будет
355
+ btn.classList.toggle("active", isActive);
356
+ setDisabled(btn, host.isToolEnabled?.(tool, inCode) === false);
340
357
  }
341
358
 
342
359
  // тип под кареткой спрашиваем, только если есть что подсвечивать: обновление идёт
@@ -344,10 +361,7 @@ class FormatToolbar {
344
361
  if (this.__blockButtons.length) {
345
362
  const block = host.currentBlock;
346
363
 
347
- for (const [type, btn] of this.__blockButtons) {
348
- const isActive = block === type;
349
- if (btn.classList.contains("active") !== isActive) btn.classList.toggle("active", isActive);
350
- }
364
+ for (const [type, btn] of this.__blockButtons) btn.classList.toggle("active", block === type);
351
365
  }
352
366
 
353
367
  // хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
@@ -367,9 +381,24 @@ class FormatToolbar {
367
381
 
368
382
  const rect = this.__active.editable.getBoundingClientRect();
369
383
  const elem = this.__elem;
384
+
385
+ // Ширина панели зависит от left: коробка ужимается по содержимому, и прижатый к правому
386
+ // краю left от прошлого показа ужал бы её до зазора — панель одна на все редакторы,
387
+ // а прошлый мог стоять у самого края. Меряем со снятой координатой.
388
+ elem.style.left = "";
389
+ const width = elem.offsetWidth;
390
+
370
391
  const top = rect.top - elem.offsetHeight - MARGIN;
371
- elem.style.left = `${Math.max(4, rect.left)}px`;
372
- elem.style.top = `${Math.max(4, top)}px`;
392
+
393
+ // Панель шире редактора (у узкого поля так бывает всегда), и по его левому краю она уехала
394
+ // бы за правый край экрана. Прижимаем к правому краю, но не левее отступа: панель шире
395
+ // самого экрана прижимается к левому и прокручивается — см. .toolbar-body.
396
+ // clientWidth корня, а не innerWidth: тот считает и полосу прокрутки страницы,
397
+ // и крайняя кнопка панели оказывалась бы под ней.
398
+ const viewport = elem.ownerDocument.documentElement.clientWidth;
399
+ const maxLeft = Math.max(EDGE_GAP, viewport - width - EDGE_GAP);
400
+ elem.style.left = `${Math.min(Math.max(EDGE_GAP, rect.left), maxLeft)}px`;
401
+ elem.style.top = `${Math.max(EDGE_GAP, top)}px`;
373
402
  }
374
403
 
375
404
  private __removeViewportListeners() {
@@ -388,7 +417,9 @@ class FormatToolbar {
388
417
  // этого съезжать не должна. Выпадающие слои (панель смайликов) висят на обёртке:
389
418
  // её коробка их и не растит, и не обрезает.
390
419
  this.__elem = DOM.tag("div", { class: TOOLBAR_CLASS });
391
- this.__body = DOM.tag("div", { class: BODY_CLASS });
420
+ // Коробка прокручивается по горизонтали: полный набор кнопок на узком экране в строку
421
+ // не влезает. Полоса — общая, от .ui-scrollable кита, только тоньше (см. richeditor.less).
422
+ this.__body = DOM.tag("div", { class: [BODY_CLASS, SCROLLABLE_CLASS] });
392
423
  this.__elem.appendChild(this.__body);
393
424
 
394
425
  // Панель нигде не должна забирать фокус, иначе редактор теряет выделение, а blur