@brandup/ui-richeditor 1.0.45 → 1.0.48
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/README.md +65 -8
- package/package.json +2 -2
- package/source/editing.ts +33 -3
- package/source/emoji.ts +69 -0
- package/source/format-config.ts +12 -2
- package/source/format.ts +2 -0
- package/source/index.ts +1 -1
- package/source/richeditor.less +157 -28
- package/source/richeditor.ts +140 -29
- package/source/selection.ts +56 -3
- package/source/serialize.ts +125 -3
- package/source/toolbar.ts +228 -132
- package/svg/apply.svg +1 -0
- package/svg/link.svg +1 -0
- package/svg/unlink.svg +1 -0
package/source/toolbar.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// т.к. тулбар находится вне привязанных UIElement).
|
|
7
7
|
|
|
8
8
|
import { DOM } from "@brandup/ui";
|
|
9
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* должно работать одинаково.
|
|
144
|
+
* Показать переданный попап смайликов у кнопки. Попап панели принадлежит ей самой, а всё
|
|
145
|
+
* своё — удержание правки, каретку, фокус — редактор берёт на себя.
|
|
123
146
|
*/
|
|
124
|
-
openEmojiPicker?(
|
|
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
|
|
165
|
-
private
|
|
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)
|
|
@@ -281,6 +278,11 @@ class FormatToolbar {
|
|
|
281
278
|
* открыли из этого редактора, а фокус сняли как раз ради неё.
|
|
282
279
|
*/
|
|
283
280
|
suspend(host: ToolbarHost) {
|
|
281
|
+
// Фокус мог отпустить и сам тулбар — ради своего поля адреса. Убрав панель с экрана, мы
|
|
282
|
+
// убрали бы и его: кнопка нажата, а править негде. У панели смайликов слой чужой (она
|
|
283
|
+
// у кнопки хоста), поэтому там прятать и нужно.
|
|
284
|
+
if (this.__linkEditing) return;
|
|
285
|
+
|
|
284
286
|
if (this.__active === host) this.__hide();
|
|
285
287
|
}
|
|
286
288
|
|
|
@@ -290,23 +292,13 @@ class FormatToolbar {
|
|
|
290
292
|
// тулбар над редактором, который как раз уходит (в том числе разрушается).
|
|
291
293
|
if (this.__suspended === host) this.__suspended = null;
|
|
292
294
|
|
|
293
|
-
// панель смайликов могла быть открыта не для активного редактора (у своей кнопки хоста) —
|
|
294
|
-
// ссылку на него всё равно отпускаем, иначе уничтоженный редактор держится синглтоном
|
|
295
|
-
const emojiHost = this.__emojiHost === host;
|
|
296
|
-
if (!emojiHost && this.__active !== host) return;
|
|
297
|
-
|
|
298
|
-
// Закрываем только свою панель. Открытие у кнопки другого редактора само переводит туда
|
|
299
|
-
// фокус, и этот уход приходит уже после — панель к тому времени принадлежит соседу, и
|
|
300
|
-
// закрыть её значило бы гасить только что открытое: она требовала бы второго нажатия.
|
|
301
|
-
if (emojiHost) {
|
|
302
|
-
this.__closeEmoji();
|
|
303
|
-
|
|
304
|
-
this.__emojiHost = null;
|
|
305
|
-
this.__emojiInitiator = null;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
295
|
if (this.__active !== host) return;
|
|
309
296
|
|
|
297
|
+
// попап смайликов панели уходит вместе с ней — он её собственный слой
|
|
298
|
+
this.__closeEmoji();
|
|
299
|
+
|
|
300
|
+
// правка адреса живёт в самой панели и заканчивается вместе с ней
|
|
301
|
+
this.__closeLink();
|
|
310
302
|
this.__hide();
|
|
311
303
|
}
|
|
312
304
|
|
|
@@ -390,13 +382,25 @@ class FormatToolbar {
|
|
|
390
382
|
|
|
391
383
|
private __ensure(): HTMLElement {
|
|
392
384
|
if (!this.__elem) {
|
|
385
|
+
// Обёртка и коробка внутри: обёртку позиционируют (fixed над редактором либо absolute
|
|
386
|
+
// над контейнером), коробка держит вид и кнопки. Порознь потому, что размер коробки
|
|
387
|
+
// меняется вместе с содержимым — на правку адреса, например, — а точка привязки от
|
|
388
|
+
// этого съезжать не должна. Выпадающие слои (панель смайликов) висят на обёртке:
|
|
389
|
+
// её коробка их и не растит, и не обрезает.
|
|
393
390
|
this.__elem = DOM.tag("div", { class: TOOLBAR_CLASS });
|
|
391
|
+
this.__body = DOM.tag("div", { class: BODY_CLASS });
|
|
392
|
+
this.__elem.appendChild(this.__body);
|
|
394
393
|
|
|
395
394
|
// Панель нигде не должна забирать фокус, иначе редактор теряет выделение, а blur
|
|
396
395
|
// прячет сам тулбар. Слушатель висит на корне, а не на кнопках: до disabled-кнопки
|
|
397
396
|
// событие не доходит (браузер их не диспатчит), да и клик по фону панели между
|
|
398
397
|
// кнопками иначе тоже уводил бы фокус. Дочерние элементы покрываются всплытием.
|
|
399
|
-
|
|
398
|
+
//
|
|
399
|
+
// Исключение одно — поле адреса ссылки: набирать в нём без фокуса негде. Каретку
|
|
400
|
+
// редактора это не теряет: её снимают до перевода фокуса, см. openLink.
|
|
401
|
+
this.__elem.addEventListener("mousedown", (e) => {
|
|
402
|
+
if (e.target !== this.__linkInput) e.preventDefault();
|
|
403
|
+
});
|
|
400
404
|
}
|
|
401
405
|
|
|
402
406
|
return this.__elem;
|
|
@@ -404,11 +408,12 @@ class FormatToolbar {
|
|
|
404
408
|
|
|
405
409
|
private __build(tools: FormatTool[], blocks: BlockType[], actions: EditorAction[], buttons: ToolbarButton[]) {
|
|
406
410
|
const key = `${tools.join(",")}|${blocks.join(",")}|${actions.join(",")}|${buttons.map((b) => b.name).join(",")}`;
|
|
407
|
-
|
|
411
|
+
this.__ensure();
|
|
412
|
+
const elem = this.__body!;
|
|
408
413
|
if (key === this.__toolsKey && elem.firstChild) return; // тот же состав — переиспользуем кнопки
|
|
409
414
|
|
|
410
415
|
this.__toolsKey = key;
|
|
411
|
-
const
|
|
416
|
+
const linkInToolbar = !!this.__linkRow && this.__linkRow.parentElement === elem;
|
|
412
417
|
DOM.empty(elem);
|
|
413
418
|
this.__buttons = [];
|
|
414
419
|
this.__blockButtons = [];
|
|
@@ -427,14 +432,19 @@ class FormatToolbar {
|
|
|
427
432
|
"button",
|
|
428
433
|
{
|
|
429
434
|
type: "button",
|
|
430
|
-
class: "format-button",
|
|
435
|
+
class: [BUTTON_CLASS, "format-button"],
|
|
431
436
|
dataset: { formatTool: tool },
|
|
432
437
|
title: merged ? MERGED_CODE_TITLE : def.title,
|
|
433
438
|
},
|
|
434
439
|
FORMAT_ICONS[tool]
|
|
435
440
|
);
|
|
441
|
+
// у ссылки адрес спрашивается полем ввода в самой панели — переключать её кнопке нечем
|
|
436
442
|
btn.addEventListener("click", () =>
|
|
437
|
-
|
|
443
|
+
tool === LINK_TOOL
|
|
444
|
+
? this.openLink()
|
|
445
|
+
: merged
|
|
446
|
+
? this.__active?.applyCode?.()
|
|
447
|
+
: this.__active?.applyFormat(tool)
|
|
438
448
|
);
|
|
439
449
|
|
|
440
450
|
elem.appendChild(btn);
|
|
@@ -445,7 +455,12 @@ class FormatToolbar {
|
|
|
445
455
|
const def = BLOCK_TYPES[type];
|
|
446
456
|
const btn = DOM.tag(
|
|
447
457
|
"button",
|
|
448
|
-
{
|
|
458
|
+
{
|
|
459
|
+
type: "button",
|
|
460
|
+
class: [BUTTON_CLASS, "block-button"],
|
|
461
|
+
dataset: { blockType: type },
|
|
462
|
+
title: def.title,
|
|
463
|
+
},
|
|
449
464
|
BLOCK_ICONS[type] ?? ""
|
|
450
465
|
);
|
|
451
466
|
btn.addEventListener("click", () => this.__active?.applyBlock?.(type));
|
|
@@ -458,7 +473,12 @@ class FormatToolbar {
|
|
|
458
473
|
const def = EDITOR_ACTIONS[action];
|
|
459
474
|
const btn = DOM.tag(
|
|
460
475
|
"button",
|
|
461
|
-
{
|
|
476
|
+
{
|
|
477
|
+
type: "button",
|
|
478
|
+
class: [BUTTON_CLASS, "action-button"],
|
|
479
|
+
dataset: { editorAction: action },
|
|
480
|
+
title: def.title,
|
|
481
|
+
},
|
|
462
482
|
ACTION_ICONS[action]
|
|
463
483
|
);
|
|
464
484
|
if (action === "emoji") btn.addEventListener("click", (e) => this.__toggleEmoji(btn, e));
|
|
@@ -478,7 +498,12 @@ class FormatToolbar {
|
|
|
478
498
|
for (const button of buttons) {
|
|
479
499
|
const btn = DOM.tag(
|
|
480
500
|
"button",
|
|
481
|
-
{
|
|
501
|
+
{
|
|
502
|
+
type: "button",
|
|
503
|
+
class: [BUTTON_CLASS, "host-button"],
|
|
504
|
+
dataset: { toolbarButton: button.name },
|
|
505
|
+
title: button.title,
|
|
506
|
+
},
|
|
482
507
|
button.icon
|
|
483
508
|
);
|
|
484
509
|
btn.addEventListener("click", () => this.__hostButton(button.name)?.run());
|
|
@@ -487,54 +512,16 @@ class FormatToolbar {
|
|
|
487
512
|
this.__hostButtons.push([button.name, btn]);
|
|
488
513
|
}
|
|
489
514
|
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
if (
|
|
515
|
+
// строка адреса живёт только в панели, но пережить перестройку кнопок обязана так же.
|
|
516
|
+
// Панель смайликов висит на обёртке и перестройки кнопок не замечает вовсе.
|
|
517
|
+
if (linkInToolbar && this.__linkRow) elem.appendChild(this.__linkRow);
|
|
493
518
|
}
|
|
494
519
|
|
|
495
520
|
/**
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
* выбранный символ уходит в `host`, даже если тулбар сейчас обслуживает другой редактор.
|
|
499
|
-
*
|
|
500
|
-
* Вызывать из обработчика `click`, погасив всплытие: PopupManager вешает свой слушатель
|
|
501
|
-
* закрытия на body прямо в open(), то есть во время этого же клика — до body событие ещё
|
|
502
|
-
* не дошло, и слушатель закрыл бы панель сразу после открытия.
|
|
521
|
+
* Панель смайликов закрылась — показываем придержанный тулбар, если редактор ещё в фокусе.
|
|
522
|
+
* Зовёт редактор: попап у кнопки хоста принадлежит ему, а придерживает показ панель.
|
|
503
523
|
*/
|
|
504
|
-
|
|
505
|
-
// повторный клик по той же кнопке закрывает панель (это делает toggle внутри PopupManager),
|
|
506
|
-
// а вот у другой кнопки её нужно сперва закрыть — иначе toggle сочтёт открытие повторным
|
|
507
|
-
if (this.__emojiInitiator !== initiator && this.__emojiPicker?.classList.contains("opened"))
|
|
508
|
-
PopupManager.close();
|
|
509
|
-
|
|
510
|
-
this.__emojiHost = host;
|
|
511
|
-
this.__emojiInitiator = initiator;
|
|
512
|
-
|
|
513
|
-
// Панель у собственной кнопки хоста — самостоятельный слой, и показывать её вместе с тулбаром
|
|
514
|
-
// нельзя: это два всплывающих окна над одним полем. Тулбар придерживаем на всё время работы
|
|
515
|
-
// панели, а показанный убираем с экрана; вернётся он сам при её закрытии, если поле осталось
|
|
516
|
-
// в фокусе. Панель самого тулбара (container === __elem) — его собственный выпадающий слой,
|
|
517
|
-
// прятать её носителя незачем и нечем.
|
|
518
|
-
if (container !== this.__elem) {
|
|
519
|
-
this.__suspended = host;
|
|
520
|
-
if (this.__active === host) this.__hide();
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
PopupManager.open(this.__ensureEmojiPicker(container), {
|
|
524
|
-
initiator,
|
|
525
|
-
onClose: () => {
|
|
526
|
-
this.__resume();
|
|
527
|
-
host.onEmojiClosed?.();
|
|
528
|
-
},
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
// Повторное нажатие по той же кнопке панель закрывает (это toggle внутри PopupManager) —
|
|
532
|
-
// вызывающему нужно знать исход: открытая панель ещё и придерживает правку редактора.
|
|
533
|
-
return !!this.__emojiPicker?.classList.contains("opened");
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/** Панель смайликов закрылась — показываем придержанный тулбар, если редактор ещё в фокусе. */
|
|
537
|
-
private __resume() {
|
|
524
|
+
resume() {
|
|
538
525
|
const host = this.__suspended;
|
|
539
526
|
if (!host) return;
|
|
540
527
|
|
|
@@ -544,52 +531,161 @@ class FormatToolbar {
|
|
|
544
531
|
if (host.editable.ownerDocument.activeElement === host.editable) this.attach(host);
|
|
545
532
|
}
|
|
546
533
|
|
|
534
|
+
// Попап панели — её собственный: свой у неё, свой у хоста. Общий пришлось бы переносить
|
|
535
|
+
// между владельцами и помнить, чей он сейчас, а показать два разом всё равно нельзя.
|
|
547
536
|
private __toggleEmoji(initiator: HTMLButtonElement, e: MouseEvent) {
|
|
537
|
+
// PopupManager вешает слушатель закрытия на body прямо в open() — до body этот же клик
|
|
538
|
+
// ещё не дошёл, и без остановки он закрыл бы попап сразу после открытия
|
|
548
539
|
e.stopPropagation();
|
|
549
540
|
|
|
550
541
|
const host = this.__active;
|
|
551
542
|
if (!host) return;
|
|
552
543
|
|
|
553
|
-
// через редактор, а не напрямую:
|
|
554
|
-
|
|
555
|
-
else this.openEmoji(host, initiator, this.__ensure());
|
|
544
|
+
// через редактор, а не напрямую: правку на время попапа придерживает он
|
|
545
|
+
host.openEmojiPicker?.(this.__ensureEmojiPicker(), initiator);
|
|
556
546
|
}
|
|
557
547
|
|
|
558
|
-
private __ensureEmojiPicker(
|
|
559
|
-
const picker = this.__emojiPicker
|
|
560
|
-
|
|
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);
|
|
561
552
|
|
|
562
553
|
return picker;
|
|
563
554
|
}
|
|
564
555
|
|
|
565
|
-
|
|
566
|
-
|
|
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;
|
|
585
|
+
|
|
586
|
+
this.__ensureLinkRow();
|
|
587
|
+
const current = host.currentLink ?? "";
|
|
588
|
+
|
|
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?.();
|
|
567
601
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
602
|
+
this.__linkInput!.focus();
|
|
603
|
+
this.__linkInput!.select();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private __ensureLinkRow(): HTMLElement {
|
|
607
|
+
const row = this.__linkRow ?? this.__buildLinkRow();
|
|
608
|
+
this.__ensure();
|
|
609
|
+
if (row.parentElement !== this.__body) this.__body!.appendChild(row);
|
|
610
|
+
|
|
611
|
+
return row;
|
|
612
|
+
}
|
|
572
613
|
|
|
573
|
-
|
|
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(""));
|
|
574
640
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
+
]);
|
|
580
646
|
|
|
581
|
-
|
|
582
|
-
|
|
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();
|
|
583
672
|
});
|
|
584
673
|
|
|
585
|
-
this.
|
|
674
|
+
this.__linkRow = row;
|
|
675
|
+
this.__linkInput = input;
|
|
676
|
+
this.__linkRemove = remove;
|
|
586
677
|
|
|
587
|
-
return
|
|
678
|
+
return row;
|
|
588
679
|
}
|
|
589
680
|
|
|
590
|
-
/**
|
|
591
|
-
private
|
|
592
|
-
if (this.
|
|
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();
|
|
593
689
|
}
|
|
594
690
|
}
|
|
595
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>
|