@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.
package/source/toolbar.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // т.к. тулбар находится вне привязанных UIElement).
7
7
 
8
8
  import { DOM } from "@brandup/ui";
9
- import { POPUP_CLASS, PopupManager, SCROLLABLE_CLASS } from "@brandup/ui-kit";
9
+ import { PopupManager } from "@brandup/ui-kit";
10
10
  import {
11
11
  BLOCK_TYPES,
12
12
  DEFAULT_BLOCK,
@@ -16,13 +16,16 @@ import {
16
16
  type EditorAction,
17
17
  type FormatTool,
18
18
  } from "./format";
19
- import { EMOJI_GROUPS, type EmojiGroup } from "./emoji";
19
+ import { createEmojiPicker } from "./emoji";
20
20
  import boldIcon from "../svg/bold.svg";
21
21
  import italicIcon from "../svg/italic.svg";
22
22
  import strikeIcon from "../svg/strike.svg";
23
23
  import underlineIcon from "../svg/underline.svg";
24
24
  import spoilerIcon from "../svg/spoiler.svg";
25
25
  import codeIcon from "../svg/mono.svg";
26
+ import linkIcon from "../svg/link.svg";
27
+ import unlinkIcon from "../svg/unlink.svg";
28
+ import applyIcon from "../svg/apply.svg";
26
29
  import quoteIcon from "../svg/quote.svg";
27
30
  import codeblockIcon from "../svg/codeblock.svg";
28
31
  import emojiIcon from "../svg/emoji.svg";
@@ -37,6 +40,7 @@ const FORMAT_ICONS: Record<FormatTool, string> = {
37
40
  underline: underlineIcon,
38
41
  spoiler: spoilerIcon,
39
42
  code: codeIcon,
43
+ link: linkIcon,
40
44
  };
41
45
 
42
46
  // Обычный текст кнопки не имеет: повторное нажатие активной кнопки возвращает блок к нему.
@@ -57,6 +61,13 @@ const CODE_TOOL: FormatTool = "code";
57
61
  const CODE_BLOCK: BlockType = "code";
58
62
  const MERGED_CODE_TITLE = "Код";
59
63
 
64
+ // Ссылка — единственный инструмент, у которого есть данные: кнопка не переключает её, а
65
+ // показывает в панели поле адреса вместо кнопок.
66
+ const LINK_TOOL: FormatTool = "link";
67
+ export const LINK_ROW_CLASS = "link-row";
68
+ // панель показывает поле адреса вместо кнопок
69
+ export const LINK_EDITING_CLASS = "link-editing";
70
+
60
71
  // Временно скрытые кнопки. Сами возможности работают: значение разбирается, показывается
61
72
  // и сохраняется, правку можно вызвать из кода — в панель они просто не выводятся.
62
73
  // Убрать отсюда, когда будут доведены.
@@ -64,7 +75,11 @@ const HIDDEN_TOOLS: FormatTool[] = ["spoiler"];
64
75
  const HIDDEN_BLOCKS: BlockType[] = [];
65
76
 
66
77
  export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
67
- export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
78
+ // Общий класс всех кнопок панели: им они и оформляются. Свой класс у каждой остаётся —
79
+ // по нему кнопку находят, а один на всех оформлять удобнее, чем перечислять их в стилях.
80
+ export const BUTTON_CLASS = "toolbar-button";
81
+ // Коробка с кнопками внутри обёртки: обёртку позиционируют, коробка держит вид и содержимое.
82
+ export const BODY_CLASS = "toolbar-body";
68
83
 
69
84
  /**
70
85
  * Кнопка хоста в общей панели — для действий, которых редактор не знает: рандомизация,
@@ -88,6 +103,15 @@ export interface ToolbarButton {
88
103
  export interface ToolbarHost {
89
104
  readonly editable: HTMLElement;
90
105
  readonly formatTools: FormatTool[];
106
+ /** Адрес ссылки под кареткой; пусто — каретка не в ссылке. */
107
+ readonly currentLink?: string;
108
+ /** Поставить ссылку, поменять её адрес или снять её (пустым адресом). */
109
+ applyLink?(url: string): void;
110
+ /** Отпустить фокус — на время правки адреса. */
111
+ releaseFocus?(): void;
112
+ /** Снимок каретки в символах: поле адреса забирает фокус, и выделение вернётся по нему. */
113
+ caretSnapshot?(): [number, number] | null;
114
+ restoreCaret?(bounds: [number, number]): void;
91
115
  /** Действия (очистка формата, отмена/повтор); пусто/undefined — кнопок действий нет. */
92
116
  readonly editorActions?: EditorAction[];
93
117
  /** Контейнер для тулбара; null/undefined — document.body (position: fixed над редактором). */
@@ -117,52 +141,25 @@ export interface ToolbarHost {
117
141
  /** Вставка текста в каретку — для панели смайликов. */
118
142
  insertText?(text: string): void;
119
143
  /**
120
- * Открыть панель смайликов. Своя кнопка панели зовёт редактор, а не {@link openEmoji}
121
- * напрямую: он придерживает правку и отпускает фокус на её время с кнопкой хоста это
122
- * должно работать одинаково.
144
+ * Показать переданный попап смайликов у кнопки. Попап панели принадлежит ей самой, а всё
145
+ * своё удержание правки, каретку, фокус — редактор берёт на себя.
123
146
  */
124
- openEmojiPicker?(initiator: HTMLElement, container: HTMLElement): void;
125
- /** Панель смайликов закрылась — редактор возвращает себе то, что придержал на её время. */
126
- onEmojiClosed?(): void;
147
+ openEmojiPicker?(picker: HTMLElement, initiator: HTMLElement): boolean;
127
148
  /** Собственные кнопки хоста; пусто/undefined — только штатные. */
128
149
  readonly toolbarButtons?: ToolbarButton[];
129
150
  }
130
151
 
131
152
  const MARGIN = 6;
132
153
 
133
- // Сколько кнопок помещается в ряд при ширине панели (см. .ui-richeditor-emoji в richeditor.less).
134
- // Точность нужна только для оценки высоты нерисованной группы: ошибка сдвинет ползунок прокрутки,
135
- // но не саму раскладку — группа переносит кнопки сама.
136
- const EMOJI_COLUMNS = 8;
137
-
138
- /**
139
- * Группа смайликов: и смысловое деление в панели (отбивается линией), и кусок, к которому
140
- * применяется пропуск отрисовки. Поэлементно это было бы семьсот отслеживаемых поддеревьев,
141
- * и слежение за ними съедает выигрыш от пропуска.
142
- */
143
- function buildEmojiGroup(group: EmojiGroup): HTMLElement {
144
- const rows = Math.ceil(group.emojis.length / EMOJI_COLUMNS);
145
- const elem = DOM.tag("div", {
146
- class: "emoji-group",
147
- role: "group",
148
- "aria-label": group.title,
149
- // высота, пока группа не нарисована: без неё список схлопнулся бы, а прокрутка скакала
150
- style: `--emoji-rows: ${rows}`,
151
- });
152
-
153
- const fragment = document.createDocumentFragment();
154
- for (const emoji of group.emojis)
155
- fragment.appendChild(DOM.tag("button", { type: "button", class: "emoji", tabindex: "-1" }, emoji));
156
- elem.appendChild(fragment);
157
-
158
- return elem;
159
- }
160
-
161
154
  class FormatToolbar {
162
- private __elem: HTMLElement | null = null;
155
+ private __elem: HTMLElement | null = null; // обёртка: её позиционируют
156
+ private __body: HTMLElement | null = null; // коробка с кнопками внутри обёртки
163
157
  private __emojiPicker: HTMLElement | null = null;
164
- private __emojiHost: ToolbarHost | null = null; // куда уйдёт выбранный символ
165
- private __emojiInitiator: HTMLElement | null = null; // кнопка, у которой открыта панель
158
+ private __linkRow: HTMLElement | null = null;
159
+ private __linkEditing = false;
160
+ private __linkInput: HTMLInputElement | null = null;
161
+ private __linkRemove: HTMLElement | null = null;
162
+ private __linkCaret: [number, number] | null = null; // каретка, снятая на время правки адреса
166
163
  private __buttons: Array<[FormatTool, HTMLButtonElement]> = [];
167
164
  private __blockButtons: Array<[BlockType, HTMLButtonElement]> = [];
168
165
  private __mergedCode = false; // кнопка кода делает и моноширинный, и блок (см. __build)
@@ -237,7 +234,12 @@ class FormatToolbar {
237
234
  const blocks = (host.blockTools ?? []).filter(
238
235
  (type) => type !== DEFAULT_BLOCK && !HIDDEN_BLOCKS.includes(type)
239
236
  );
240
- if (!tools.length && !blocks.length && !actions.length && !buttons.length) return;
237
+ // Nothing to show and the previous editor's panel must not stay on screen either: focus
238
+ // moved to this one, while its buttons would still edit the neighbour.
239
+ if (!tools.length && !blocks.length && !actions.length && !buttons.length) {
240
+ if (this.__active) this.__hide();
241
+ return;
242
+ }
241
243
 
242
244
  this.__bindSelection();
243
245
  this.__active = host;
@@ -276,6 +278,11 @@ class FormatToolbar {
276
278
  * открыли из этого редактора, а фокус сняли как раз ради неё.
277
279
  */
278
280
  suspend(host: ToolbarHost) {
281
+ // Фокус мог отпустить и сам тулбар — ради своего поля адреса. Убрав панель с экрана, мы
282
+ // убрали бы и его: кнопка нажата, а править негде. У панели смайликов слой чужой (она
283
+ // у кнопки хоста), поэтому там прятать и нужно.
284
+ if (this.__linkEditing) return;
285
+
279
286
  if (this.__active === host) this.__hide();
280
287
  }
281
288
 
@@ -285,23 +292,13 @@ class FormatToolbar {
285
292
  // тулбар над редактором, который как раз уходит (в том числе разрушается).
286
293
  if (this.__suspended === host) this.__suspended = null;
287
294
 
288
- // панель смайликов могла быть открыта не для активного редактора (у своей кнопки хоста) —
289
- // ссылку на него всё равно отпускаем, иначе уничтоженный редактор держится синглтоном
290
- const emojiHost = this.__emojiHost === host;
291
- if (!emojiHost && this.__active !== host) return;
292
-
293
- // Закрываем только свою панель. Открытие у кнопки другого редактора само переводит туда
294
- // фокус, и этот уход приходит уже после — панель к тому времени принадлежит соседу, и
295
- // закрыть её значило бы гасить только что открытое: она требовала бы второго нажатия.
296
- if (emojiHost) {
297
- this.__closeEmoji();
298
-
299
- this.__emojiHost = null;
300
- this.__emojiInitiator = null;
301
- }
302
-
303
295
  if (this.__active !== host) return;
304
296
 
297
+ // попап смайликов панели уходит вместе с ней — он её собственный слой
298
+ this.__closeEmoji();
299
+
300
+ // правка адреса живёт в самой панели и заканчивается вместе с ней
301
+ this.__closeLink();
305
302
  this.__hide();
306
303
  }
307
304
 
@@ -385,13 +382,25 @@ class FormatToolbar {
385
382
 
386
383
  private __ensure(): HTMLElement {
387
384
  if (!this.__elem) {
385
+ // Обёртка и коробка внутри: обёртку позиционируют (fixed над редактором либо absolute
386
+ // над контейнером), коробка держит вид и кнопки. Порознь потому, что размер коробки
387
+ // меняется вместе с содержимым — на правку адреса, например, — а точка привязки от
388
+ // этого съезжать не должна. Выпадающие слои (панель смайликов) висят на обёртке:
389
+ // её коробка их и не растит, и не обрезает.
388
390
  this.__elem = DOM.tag("div", { class: TOOLBAR_CLASS });
391
+ this.__body = DOM.tag("div", { class: BODY_CLASS });
392
+ this.__elem.appendChild(this.__body);
389
393
 
390
394
  // Панель нигде не должна забирать фокус, иначе редактор теряет выделение, а blur
391
395
  // прячет сам тулбар. Слушатель висит на корне, а не на кнопках: до disabled-кнопки
392
396
  // событие не доходит (браузер их не диспатчит), да и клик по фону панели между
393
397
  // кнопками иначе тоже уводил бы фокус. Дочерние элементы покрываются всплытием.
394
- this.__elem.addEventListener("mousedown", (e) => e.preventDefault());
398
+ //
399
+ // Исключение одно — поле адреса ссылки: набирать в нём без фокуса негде. Каретку
400
+ // редактора это не теряет: её снимают до перевода фокуса, см. openLink.
401
+ this.__elem.addEventListener("mousedown", (e) => {
402
+ if (e.target !== this.__linkInput) e.preventDefault();
403
+ });
395
404
  }
396
405
 
397
406
  return this.__elem;
@@ -399,11 +408,12 @@ class FormatToolbar {
399
408
 
400
409
  private __build(tools: FormatTool[], blocks: BlockType[], actions: EditorAction[], buttons: ToolbarButton[]) {
401
410
  const key = `${tools.join(",")}|${blocks.join(",")}|${actions.join(",")}|${buttons.map((b) => b.name).join(",")}`;
402
- const elem = this.__ensure();
411
+ this.__ensure();
412
+ const elem = this.__body!;
403
413
  if (key === this.__toolsKey && elem.firstChild) return; // тот же состав — переиспользуем кнопки
404
414
 
405
415
  this.__toolsKey = key;
406
- const pickerInToolbar = !!this.__emojiPicker && this.__emojiPicker.parentElement === elem;
416
+ const linkInToolbar = !!this.__linkRow && this.__linkRow.parentElement === elem;
407
417
  DOM.empty(elem);
408
418
  this.__buttons = [];
409
419
  this.__blockButtons = [];
@@ -422,14 +432,19 @@ class FormatToolbar {
422
432
  "button",
423
433
  {
424
434
  type: "button",
425
- class: "format-button",
435
+ class: [BUTTON_CLASS, "format-button"],
426
436
  dataset: { formatTool: tool },
427
437
  title: merged ? MERGED_CODE_TITLE : def.title,
428
438
  },
429
439
  FORMAT_ICONS[tool]
430
440
  );
441
+ // у ссылки адрес спрашивается полем ввода в самой панели — переключать её кнопке нечем
431
442
  btn.addEventListener("click", () =>
432
- merged ? this.__active?.applyCode?.() : this.__active?.applyFormat(tool)
443
+ tool === LINK_TOOL
444
+ ? this.openLink()
445
+ : merged
446
+ ? this.__active?.applyCode?.()
447
+ : this.__active?.applyFormat(tool)
433
448
  );
434
449
 
435
450
  elem.appendChild(btn);
@@ -440,7 +455,12 @@ class FormatToolbar {
440
455
  const def = BLOCK_TYPES[type];
441
456
  const btn = DOM.tag(
442
457
  "button",
443
- { type: "button", class: "block-button", dataset: { blockType: type }, title: def.title },
458
+ {
459
+ type: "button",
460
+ class: [BUTTON_CLASS, "block-button"],
461
+ dataset: { blockType: type },
462
+ title: def.title,
463
+ },
444
464
  BLOCK_ICONS[type] ?? ""
445
465
  );
446
466
  btn.addEventListener("click", () => this.__active?.applyBlock?.(type));
@@ -453,7 +473,12 @@ class FormatToolbar {
453
473
  const def = EDITOR_ACTIONS[action];
454
474
  const btn = DOM.tag(
455
475
  "button",
456
- { type: "button", class: "action-button", dataset: { editorAction: action }, title: def.title },
476
+ {
477
+ type: "button",
478
+ class: [BUTTON_CLASS, "action-button"],
479
+ dataset: { editorAction: action },
480
+ title: def.title,
481
+ },
457
482
  ACTION_ICONS[action]
458
483
  );
459
484
  if (action === "emoji") btn.addEventListener("click", (e) => this.__toggleEmoji(btn, e));
@@ -473,7 +498,12 @@ class FormatToolbar {
473
498
  for (const button of buttons) {
474
499
  const btn = DOM.tag(
475
500
  "button",
476
- { type: "button", class: "host-button", dataset: { toolbarButton: button.name }, title: button.title },
501
+ {
502
+ type: "button",
503
+ class: [BUTTON_CLASS, "host-button"],
504
+ dataset: { toolbarButton: button.name },
505
+ title: button.title,
506
+ },
477
507
  button.icon
478
508
  );
479
509
  btn.addEventListener("click", () => this.__hostButton(button.name)?.run());
@@ -482,54 +512,16 @@ class FormatToolbar {
482
512
  this.__hostButtons.push([button.name, btn]);
483
513
  }
484
514
 
485
- // панель пережила перестройку кнопок возвращаем её в тулбар, чтобы не собирать заново.
486
- // Если её забрал хост под свою кнопку, она остаётся у него.
487
- if (pickerInToolbar && this.__emojiPicker) elem.appendChild(this.__emojiPicker);
515
+ // строка адреса живёт только в панели, но пережить перестройку кнопок обязана так же.
516
+ // Панель смайликов висит на обёртке и перестройки кнопок не замечает вовсе.
517
+ if (linkInToolbar && this.__linkRow) elem.appendChild(this.__linkRow);
488
518
  }
489
519
 
490
520
  /**
491
- * Открыть панель смайликов у произвольной кнопки например у собственной кнопки хоста рядом
492
- * с полем ввода, а не в тулбаре. Панель одна на все редакторы и переезжает в `container`;
493
- * выбранный символ уходит в `host`, даже если тулбар сейчас обслуживает другой редактор.
494
- *
495
- * Вызывать из обработчика `click`, погасив всплытие: PopupManager вешает свой слушатель
496
- * закрытия на body прямо в open(), то есть во время этого же клика — до body событие ещё
497
- * не дошло, и слушатель закрыл бы панель сразу после открытия.
521
+ * Панель смайликов закрылась показываем придержанный тулбар, если редактор ещё в фокусе.
522
+ * Зовёт редактор: попап у кнопки хоста принадлежит ему, а придерживает показ панель.
498
523
  */
499
- openEmoji(host: ToolbarHost, initiator: HTMLElement, container: HTMLElement): boolean {
500
- // повторный клик по той же кнопке закрывает панель (это делает toggle внутри PopupManager),
501
- // а вот у другой кнопки её нужно сперва закрыть — иначе toggle сочтёт открытие повторным
502
- if (this.__emojiInitiator !== initiator && this.__emojiPicker?.classList.contains("opened"))
503
- PopupManager.close();
504
-
505
- this.__emojiHost = host;
506
- this.__emojiInitiator = initiator;
507
-
508
- // Панель у собственной кнопки хоста — самостоятельный слой, и показывать её вместе с тулбаром
509
- // нельзя: это два всплывающих окна над одним полем. Тулбар придерживаем на всё время работы
510
- // панели, а показанный убираем с экрана; вернётся он сам при её закрытии, если поле осталось
511
- // в фокусе. Панель самого тулбара (container === __elem) — его собственный выпадающий слой,
512
- // прятать её носителя незачем и нечем.
513
- if (container !== this.__elem) {
514
- this.__suspended = host;
515
- if (this.__active === host) this.__hide();
516
- }
517
-
518
- PopupManager.open(this.__ensureEmojiPicker(container), {
519
- initiator,
520
- onClose: () => {
521
- this.__resume();
522
- host.onEmojiClosed?.();
523
- },
524
- });
525
-
526
- // Повторное нажатие по той же кнопке панель закрывает (это toggle внутри PopupManager) —
527
- // вызывающему нужно знать исход: открытая панель ещё и придерживает правку редактора.
528
- return !!this.__emojiPicker?.classList.contains("opened");
529
- }
530
-
531
- /** Панель смайликов закрылась — показываем придержанный тулбар, если редактор ещё в фокусе. */
532
- private __resume() {
524
+ resume() {
533
525
  const host = this.__suspended;
534
526
  if (!host) return;
535
527
 
@@ -539,52 +531,161 @@ class FormatToolbar {
539
531
  if (host.editable.ownerDocument.activeElement === host.editable) this.attach(host);
540
532
  }
541
533
 
534
+ // Попап панели — её собственный: свой у неё, свой у хоста. Общий пришлось бы переносить
535
+ // между владельцами и помнить, чей он сейчас, а показать два разом всё равно нельзя.
542
536
  private __toggleEmoji(initiator: HTMLButtonElement, e: MouseEvent) {
537
+ // PopupManager вешает слушатель закрытия на body прямо в open() — до body этот же клик
538
+ // ещё не дошёл, и без остановки он закрыл бы попап сразу после открытия
543
539
  e.stopPropagation();
544
540
 
545
541
  const host = this.__active;
546
542
  if (!host) return;
547
543
 
548
- // через редактор, а не напрямую: панель его слой, и придерживает правку он
549
- if (host.openEmojiPicker) host.openEmojiPicker(initiator, this.__ensure());
550
- else this.openEmoji(host, initiator, this.__ensure());
544
+ // через редактор, а не напрямую: правку на время попапа придерживает он
545
+ host.openEmojiPicker?.(this.__ensureEmojiPicker(), initiator);
551
546
  }
552
547
 
553
- private __ensureEmojiPicker(container: HTMLElement): HTMLElement {
554
- const picker = this.__emojiPicker ?? this.__buildEmojiPicker();
555
- if (picker.parentElement !== container) container.appendChild(picker);
548
+ private __ensureEmojiPicker(): HTMLElement {
549
+ const picker = (this.__emojiPicker ??= createEmojiPicker((emoji) => this.__active?.insertText?.(emoji)));
550
+ const elem = this.__ensure();
551
+ if (picker.parentElement !== elem) elem.appendChild(picker);
556
552
 
557
553
  return picker;
558
554
  }
559
555
 
560
- private __buildEmojiPicker(): HTMLElement {
561
- const picker = DOM.tag("div", { class: `${POPUP_CLASS} ${EMOJI_PICKER_CLASS}` });
556
+ /** Закрыть панель смайликов, если открыта именно она (тулбар уходит — попап не должен остаться). */
557
+ private __closeEmoji() {
558
+ if (this.__emojiPicker && PopupManager.isOpened(this.__emojiPicker)) PopupManager.close();
559
+ }
560
+
561
+ /** Открыть правку адреса хоткеем — так же, как её открывает собственная кнопка. */
562
+ openLinkFor(host: ToolbarHost) {
563
+ if (this.__active === host) this.openLink();
564
+ }
565
+
566
+ /**
567
+ * Правка адреса: панель на это время показывает поле ввода вместо кнопок.
568
+ *
569
+ * Не выпадающий слой, а другое содержимое той же панели: место, коробка и положение у них
570
+ * общие, и панели не приходится ни ужиматься под соседа, ни позиционировать его. Кнопки при
571
+ * этом всё равно недоступны — пока адрес не введён, править нечего.
572
+ *
573
+ * Поле ввода, в отличие от всего остального в панели, забирает фокус: без него не наберёшь.
574
+ * А фокус в чужом поле — это и чужое выделение: то, что стояло в редакторе, `getSelection()`
575
+ * уже не вернёт. Поэтому каретку снимаем до перевода фокуса и возвращаем перед правкой.
576
+ */
577
+ openLink() {
578
+ // Повторное нажатие возвращает кнопки — проверяем раньше доступности: фокус на это время
579
+ // отпущен, каретки в поле нет, и кнопка ссылки сама себя считает недоступной.
580
+ if (this.__linkEditing) return this.__closeLink();
581
+
582
+ const host = this.__active;
583
+ // делать ссылкой нечего — спрашивать адрес не за чем (кнопка в этот момент и погашена)
584
+ if (!host?.applyLink || host.isToolEnabled?.(LINK_TOOL) === false) return;
562
585
 
563
- // Прокручивается список, а не сам попап: полоса прокрутки рисуется по краю коробки
564
- // и перекрывала бы скругление рамки — угол выглядел бы срезанным.
565
- const list = DOM.tag("div", { class: ["emoji-list", SCROLLABLE_CLASS] });
566
- picker.appendChild(list);
586
+ this.__ensureLinkRow();
587
+ const current = host.currentLink ?? "";
567
588
 
568
- for (const group of EMOJI_GROUPS) list.appendChild(buildEmojiGroup(group));
589
+ this.__linkInput!.value = current;
590
+ this.__linkRemove!.hidden = !current; // снимать нечего, пока ссылки под кареткой нет
591
+
592
+ this.__linkEditing = true;
593
+ this.__elem!.classList.add(LINK_EDITING_CLASS);
594
+ // ширина панели сменилась вместе с содержимым, а с ней и её высота — положение пересчитываем
595
+ this.reposition();
596
+
597
+ // releaseFocus снимает каретку только когда поле активно — на это полагаться нельзя,
598
+ // поэтому снимок делаем сами
599
+ this.__linkCaret = host.caretSnapshot?.() ?? null;
600
+ host.releaseFocus?.();
601
+
602
+ this.__linkInput!.focus();
603
+ this.__linkInput!.select();
604
+ }
569
605
 
570
- // панель может висеть и вне тулбара, поэтому гасит фокус сама
571
- picker.addEventListener("mousedown", (e) => e.preventDefault());
572
- picker.addEventListener("click", (e) => {
573
- const target = (e.target as HTMLElement).closest<HTMLElement>(".emoji");
574
- if (!target) return;
606
+ private __ensureLinkRow(): HTMLElement {
607
+ const row = this.__linkRow ?? this.__buildLinkRow();
608
+ this.__ensure();
609
+ if (row.parentElement !== this.__body) this.__body!.appendChild(row);
575
610
 
576
- this.__emojiHost?.insertText?.(target.textContent ?? "");
577
- PopupManager.close();
611
+ return row;
612
+ }
613
+
614
+ private __buildLinkRow(): HTMLElement {
615
+ const input = DOM.tag("input", {
616
+ type: "url",
617
+ class: "link-input",
618
+ placeholder: "https://",
619
+ spellcheck: "false",
620
+ }) as HTMLInputElement;
621
+
622
+ // Фокус ушёл в поле адреса, выделение — вместе с ним. Возвращаем каретку, снятую при
623
+ // открытии, и только потом правим: applyLink работает по выделению в редакторе.
624
+ const apply = (url: string) => {
625
+ const host = this.__active;
626
+ // каретку забираем до закрытия: оно её отпускает
627
+ const caret = this.__linkCaret;
628
+
629
+ this.__closeLink();
630
+ if (caret) host?.restoreCaret?.(caret);
631
+ host?.applyLink?.(url);
632
+ };
633
+
634
+ const remove = DOM.tag(
635
+ "button",
636
+ { type: "button", class: [BUTTON_CLASS, "link-remove"], title: "Убрать ссылку" },
637
+ unlinkIcon
638
+ );
639
+ remove.addEventListener("click", () => apply(""));
640
+
641
+ const row = DOM.tag("div", { class: LINK_ROW_CLASS }, [
642
+ input,
643
+ DOM.tag("button", { type: "button", class: [BUTTON_CLASS, "link-apply"], title: "Применить" }, applyIcon),
644
+ remove,
645
+ ]);
646
+
647
+ // фокус гасит корневой слушатель панели, строка лежит внутри неё — своего не нужно
648
+ row.addEventListener("click", (e) => {
649
+ if ((e.target as HTMLElement).closest(".link-apply")) apply(input.value);
650
+ });
651
+ input.addEventListener("keydown", (e) => {
652
+ if (e.key === "Enter") {
653
+ e.preventDefault();
654
+ apply(input.value);
655
+ } else if (e.key === "Escape") {
656
+ e.preventDefault();
657
+ // отказались от правки — возвращаем каретку туда, где её взяли
658
+ const host = this.__active;
659
+ const caret = this.__linkCaret;
660
+
661
+ this.__closeLink();
662
+ if (caret) host?.restoreCaret?.(caret);
663
+ }
664
+ });
665
+ // Ушли мимо панели — правку бросили: держаться ей больше не на чем, редактор фокус отпустил
666
+ // ради этого поля. Нажатия по своим кнопкам фокуса не уводят, и сюда не попадают.
667
+ input.addEventListener("blur", (e) => {
668
+ if (this.__elem?.contains(e.relatedTarget as Node | null)) return;
669
+
670
+ this.__closeLink();
671
+ this.__hide();
578
672
  });
579
673
 
580
- this.__emojiPicker = picker;
674
+ this.__linkRow = row;
675
+ this.__linkInput = input;
676
+ this.__linkRemove = remove;
581
677
 
582
- return picker;
678
+ return row;
583
679
  }
584
680
 
585
- /** Закрыть панель смайликов, если открыта именно она (тулбар уходит попап не должен остаться). */
586
- private __closeEmoji() {
587
- if (this.__emojiPicker?.classList.contains("opened")) PopupManager.close();
681
+ /** Вернуть панель к кнопкам (правку адреса закончили или бросили). */
682
+ private __closeLink() {
683
+ if (!this.__linkEditing) return;
684
+
685
+ this.__linkEditing = false;
686
+ this.__linkCaret = null;
687
+ this.__elem?.classList.remove(LINK_EDITING_CLASS);
688
+ this.reposition();
588
689
  }
589
690
  }
590
691
 
package/svg/apply.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M 19.980469 5.9902344 A 1.0001 1.0001 0 0 0 19.292969 6.2929688 L 9 16.585938 L 5.7070312 13.292969 A 1.0001 1.0001 0 1 0 4.2929688 14.707031 L 8.2929688 18.707031 A 1.0001 1.0001 0 0 0 9.7070312 18.707031 L 20.707031 7.7070312 A 1.0001 1.0001 0 0 0 19.980469 5.9902344 z"/></svg>
package/svg/link.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M 6 7 C 3.077 7 0.7394375 9.5082344 1.0234375 12.490234 C 1.2714375 15.090234 3.6028438 17 6.2148438 17 L 9 17 C 9.552 17 10 16.552 10 16 C 10 15.448 9.552 15 9 15 L 6.1699219 15 C 4.5459219 15 3.0898594 13.783156 3.0058594 12.160156 C 2.9148594 10.430156 4.29 9 6 9 L 9 9 C 9.552 9 10 8.552 10 8 C 10 7.448 9.552 7 9 7 L 6 7 z M 15 7 C 14.448 7 14 7.448 14 8 C 14 8.552 14.448 9 15 9 L 17.830078 9 C 19.454078 9 20.910141 10.216844 20.994141 11.839844 C 21.085141 13.569844 19.71 15 18 15 L 15 15 C 14.448 15 14 15.448 14 16 C 14 16.552 14.448 17 15 17 L 18 17 C 20.923 17 23.260563 14.491766 22.976562 11.509766 C 22.728562 8.9097656 20.397156 7 17.785156 7 L 15 7 z M 8 11 C 7.448 11 7 11.448 7 12 C 7 12.552 7.448 13 8 13 L 16 13 C 16.552 13 17 12.552 17 12 C 17 11.448 16.552 11 16 11 L 8 11 z"/></svg>
package/svg/unlink.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M 4.9902344 3.9902344 A 1.0001 1.0001 0 0 0 4.2929688 5.7070312 L 10.585938 12 L 4.2929688 18.292969 A 1.0001 1.0001 0 1 0 5.7070312 19.707031 L 12 13.414062 L 18.292969 19.707031 A 1.0001 1.0001 0 1 0 19.707031 18.292969 L 13.414062 12 L 19.707031 5.7070312 A 1.0001 1.0001 0 0 0 18.980469 3.9902344 A 1.0001 1.0001 0 0 0 18.292969 4.2929688 L 12 10.585938 L 5.7070312 4.2929688 A 1.0001 1.0001 0 0 0 4.9902344 3.9902344 z"/></svg>