@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/README.md +65 -8
- package/package.json +3 -3
- package/source/editing.ts +72 -17
- package/source/emoji.ts +69 -0
- package/source/format-config.ts +12 -2
- package/source/format.ts +2 -0
- package/source/history.ts +10 -5
- package/source/index.ts +1 -1
- package/source/richeditor.less +157 -28
- package/source/richeditor.ts +154 -37
- package/source/selection.ts +56 -3
- package/source/serialize.ts +125 -3
- package/source/toolbar.ts +234 -133
- 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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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 (
|
|
515
|
+
// строка адреса живёт только в панели, но пережить перестройку кнопок обязана так же.
|
|
516
|
+
// Панель смайликов висит на обёртке и перестройки кнопок не замечает вовсе.
|
|
517
|
+
if (linkInToolbar && this.__linkRow) elem.appendChild(this.__linkRow);
|
|
488
518
|
}
|
|
489
519
|
|
|
490
520
|
/**
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* выбранный символ уходит в `host`, даже если тулбар сейчас обслуживает другой редактор.
|
|
494
|
-
*
|
|
495
|
-
* Вызывать из обработчика `click`, погасив всплытие: PopupManager вешает свой слушатель
|
|
496
|
-
* закрытия на body прямо в open(), то есть во время этого же клика — до body событие ещё
|
|
497
|
-
* не дошло, и слушатель закрыл бы панель сразу после открытия.
|
|
521
|
+
* Панель смайликов закрылась — показываем придержанный тулбар, если редактор ещё в фокусе.
|
|
522
|
+
* Зовёт редактор: попап у кнопки хоста принадлежит ему, а придерживает показ панель.
|
|
498
523
|
*/
|
|
499
|
-
|
|
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
|
-
|
|
550
|
-
else this.openEmoji(host, initiator, this.__ensure());
|
|
544
|
+
// через редактор, а не напрямую: правку на время попапа придерживает он
|
|
545
|
+
host.openEmojiPicker?.(this.__ensureEmojiPicker(), initiator);
|
|
551
546
|
}
|
|
552
547
|
|
|
553
|
-
private __ensureEmojiPicker(
|
|
554
|
-
const picker = this.__emojiPicker
|
|
555
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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.
|
|
674
|
+
this.__linkRow = row;
|
|
675
|
+
this.__linkInput = input;
|
|
676
|
+
this.__linkRemove = remove;
|
|
581
677
|
|
|
582
|
-
return
|
|
678
|
+
return row;
|
|
583
679
|
}
|
|
584
680
|
|
|
585
|
-
/**
|
|
586
|
-
private
|
|
587
|
-
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();
|
|
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>
|