@brandup/ui-richeditor 1.0.38 → 1.0.40
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 +32 -6
- package/package.json +2 -2
- package/source/editing.ts +85 -21
- package/source/format.ts +5 -1
- package/source/history.ts +18 -9
- package/source/index.ts +3 -0
- package/source/paragraphs.ts +23 -3
- package/source/richeditor.less +4 -0
- package/source/richeditor.ts +213 -107
- package/source/selection.ts +201 -75
- package/source/serialize.ts +28 -6
- package/source/toolbar.ts +160 -14
package/source/toolbar.ts
CHANGED
|
@@ -35,6 +35,24 @@ const ACTION_ICONS: Record<EditorAction, string> = {
|
|
|
35
35
|
export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
|
|
36
36
|
export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Кнопка хоста в общей панели — для действий, которых редактор не знает: рандомизация,
|
|
40
|
+
* вставка переменных и прочее доменное. Иконку и поведение задаёт хост, панель отвечает
|
|
41
|
+
* только за отрисовку, доступность и то, что фокус не уходит из редактора.
|
|
42
|
+
*/
|
|
43
|
+
export interface ToolbarButton {
|
|
44
|
+
/** Уникальное имя: идёт в data-атрибут и в ключ перестройки панели. */
|
|
45
|
+
name: string;
|
|
46
|
+
/** Подсказка на кнопке. */
|
|
47
|
+
title: string;
|
|
48
|
+
/** Разметка иконки (svg). */
|
|
49
|
+
icon: string;
|
|
50
|
+
/** Нажатие. */
|
|
51
|
+
run(): void;
|
|
52
|
+
/** false — кнопка недоступна; проверяется на каждом refresh, как у действий. */
|
|
53
|
+
isEnabled?(): boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
/** Редактор, которым управляет общий тулбар. */
|
|
39
57
|
export interface ToolbarHost {
|
|
40
58
|
readonly editable: HTMLElement;
|
|
@@ -45,11 +63,15 @@ export interface ToolbarHost {
|
|
|
45
63
|
readonly toolbarContainer?: HTMLElement | null;
|
|
46
64
|
applyFormat(tool: FormatTool): void;
|
|
47
65
|
isToolActive(tool: FormatTool): boolean;
|
|
66
|
+
/** Активные форматы всех инструментов сразу; нет реализации — панель опросит их поштучно. */
|
|
67
|
+
activeTools?(): ReadonlySet<FormatTool>;
|
|
48
68
|
applyAction?(action: EditorAction): void;
|
|
49
69
|
/** false — кнопка действия недоступна (нечего отменять/очищать). */
|
|
50
70
|
isActionEnabled?(action: EditorAction): boolean;
|
|
51
71
|
/** Вставка текста в каретку — для панели смайликов. */
|
|
52
72
|
insertText?(text: string): void;
|
|
73
|
+
/** Собственные кнопки хоста; пусто/undefined — только штатные. */
|
|
74
|
+
readonly toolbarButtons?: ToolbarButton[];
|
|
53
75
|
}
|
|
54
76
|
|
|
55
77
|
const MARGIN = 6;
|
|
@@ -61,25 +83,74 @@ class FormatToolbar {
|
|
|
61
83
|
private __emojiInitiator: HTMLElement | null = null; // кнопка, у которой открыта панель
|
|
62
84
|
private __buttons: Array<[FormatTool, HTMLButtonElement]> = [];
|
|
63
85
|
private __actionButtons: Array<[EditorAction, HTMLButtonElement]> = [];
|
|
86
|
+
private __hostButtons: Array<[ToolbarButton, HTMLButtonElement]> = [];
|
|
64
87
|
private __active: ToolbarHost | null = null;
|
|
88
|
+
private __suspended: ToolbarHost | null = null; // показ придержан на время панели смайликов
|
|
65
89
|
private __toolsKey = "";
|
|
66
90
|
private __inContainer = false;
|
|
67
|
-
private readonly __reposition = () => this.
|
|
91
|
+
private readonly __reposition = () => this.__schedule("position");
|
|
68
92
|
private __resizeObserver: ResizeObserver | null = null;
|
|
93
|
+
private __selectionBound = false;
|
|
94
|
+
private __frame = 0;
|
|
95
|
+
private __pendingRefresh = false;
|
|
96
|
+
private __pendingPosition = false;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Единый листенер на весь документ: подсветка активных инструментов по текущему выделению.
|
|
100
|
+
* Вешается при первом показе панели, а не при загрузке модуля, и живёт до конца страницы —
|
|
101
|
+
* refresh() сам проверяет наличие активного редактора.
|
|
102
|
+
*/
|
|
103
|
+
private __bindSelection() {
|
|
104
|
+
if (this.__selectionBound || typeof document === "undefined") return;
|
|
105
|
+
|
|
106
|
+
this.__selectionBound = true;
|
|
107
|
+
document.addEventListener("selectionchange", () => this.__schedule("refresh"));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Откладывает обновление до кадра отрисовки. selectionchange и scroll приходят пачками,
|
|
112
|
+
* а и подсветка (обход содержимого), и позиционирование (чтение геометрии) по событию
|
|
113
|
+
* заметно дороже, чем раз в кадр. Прямые вызовы refresh()/reposition() остаются синхронными.
|
|
114
|
+
*/
|
|
115
|
+
private __schedule(kind: "refresh" | "position") {
|
|
116
|
+
if (!this.__active) return;
|
|
117
|
+
|
|
118
|
+
if (kind === "refresh") this.__pendingRefresh = true;
|
|
119
|
+
else this.__pendingPosition = true;
|
|
120
|
+
|
|
121
|
+
if (typeof requestAnimationFrame !== "function") this.__flush();
|
|
122
|
+
else this.__frame ||= requestAnimationFrame(() => this.__flush());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private __flush() {
|
|
126
|
+
const refresh = this.__pendingRefresh;
|
|
127
|
+
const position = this.__pendingPosition;
|
|
128
|
+
this.__cancelScheduled();
|
|
129
|
+
|
|
130
|
+
if (refresh) this.refresh();
|
|
131
|
+
if (position) this.reposition();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private __cancelScheduled() {
|
|
135
|
+
if (this.__frame && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.__frame);
|
|
69
136
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (typeof document !== "undefined") document.addEventListener("selectionchange", () => this.refresh());
|
|
137
|
+
this.__frame = 0;
|
|
138
|
+
this.__pendingRefresh = false;
|
|
139
|
+
this.__pendingPosition = false;
|
|
74
140
|
}
|
|
75
141
|
|
|
76
142
|
/** Показать тулбар для редактора (на фокусе): перестроить кнопки, спозиционировать, показать. */
|
|
77
143
|
attach(host: ToolbarHost) {
|
|
144
|
+
// открыта панель смайликов у собственной кнопки хоста — показ отложен до её закрытия
|
|
145
|
+
if (this.__suspended === host) return;
|
|
146
|
+
|
|
78
147
|
const actions = host.editorActions ?? [];
|
|
79
|
-
|
|
148
|
+
const buttons = host.toolbarButtons ?? [];
|
|
149
|
+
if (!host.formatTools.length && !actions.length && !buttons.length) return;
|
|
80
150
|
|
|
151
|
+
this.__bindSelection();
|
|
81
152
|
this.__active = host;
|
|
82
|
-
this.__build(host.formatTools, actions);
|
|
153
|
+
this.__build(host.formatTools, actions, buttons);
|
|
83
154
|
this.refresh();
|
|
84
155
|
|
|
85
156
|
const elem = this.__ensure();
|
|
@@ -110,22 +181,59 @@ class FormatToolbar {
|
|
|
110
181
|
|
|
111
182
|
/** Скрыть тулбар, если он обслуживает этот редактор (на blur/destroy). */
|
|
112
183
|
detach(host: ToolbarHost) {
|
|
113
|
-
|
|
184
|
+
// Придержанный показ снимаем первым делом, до закрытия панели: иначе её onClose поднял бы
|
|
185
|
+
// тулбар над редактором, который как раз уходит (в том числе разрушается).
|
|
186
|
+
if (this.__suspended === host) this.__suspended = null;
|
|
187
|
+
|
|
188
|
+
// панель смайликов могла быть открыта не для активного редактора (у своей кнопки хоста) —
|
|
189
|
+
// ссылку на него всё равно отпускаем, иначе уничтоженный редактор держится синглтоном
|
|
190
|
+
const emojiHost = this.__emojiHost === host;
|
|
191
|
+
if (!emojiHost && this.__active !== host) return;
|
|
114
192
|
|
|
115
193
|
this.__closeEmoji();
|
|
194
|
+
|
|
195
|
+
if (emojiHost) {
|
|
196
|
+
this.__emojiHost = null;
|
|
197
|
+
this.__emojiInitiator = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (this.__active !== host) return;
|
|
201
|
+
|
|
202
|
+
this.__hide();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Убрать панель с экрана и отпустить активный редактор (позиционирование, подсветка). */
|
|
206
|
+
private __hide() {
|
|
116
207
|
this.__active = null;
|
|
208
|
+
this.__cancelScheduled();
|
|
117
209
|
if (this.__elem) this.__elem.classList.remove("visible");
|
|
118
210
|
this.__removeViewportListeners();
|
|
119
211
|
}
|
|
120
212
|
|
|
121
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Обновить подсветку активных инструментов и доступность действий по текущему состоянию.
|
|
215
|
+
*
|
|
216
|
+
* Пишем в DOM только при реальном изменении: обновление идёт на каждое движение каретки,
|
|
217
|
+
* а на документе живёт MutationObserver (им UIElement следит за удалением элементов) —
|
|
218
|
+
* повторная запись того же значения всё равно порождает запись мутации и его пробуждение.
|
|
219
|
+
*/
|
|
122
220
|
refresh() {
|
|
123
221
|
const host = this.__active;
|
|
124
222
|
if (!host) return;
|
|
125
223
|
|
|
126
|
-
|
|
224
|
+
const setDisabled = (btn: HTMLButtonElement, disabled: boolean) => {
|
|
225
|
+
if (btn.disabled !== disabled) btn.disabled = disabled;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const active = host.activeTools?.();
|
|
229
|
+
for (const [tool, btn] of this.__buttons) {
|
|
230
|
+
const isActive = active ? active.has(tool) : host.isToolActive(tool);
|
|
231
|
+
if (btn.classList.contains("active") !== isActive) btn.classList.toggle("active", isActive);
|
|
232
|
+
}
|
|
233
|
+
|
|
127
234
|
// хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
|
|
128
|
-
for (const [action, btn] of this.__actionButtons) btn
|
|
235
|
+
for (const [action, btn] of this.__actionButtons) setDisabled(btn, host.isActionEnabled?.(action) === false);
|
|
236
|
+
for (const [button, btn] of this.__hostButtons) setDisabled(btn, button.isEnabled?.() === false);
|
|
129
237
|
}
|
|
130
238
|
|
|
131
239
|
/** Пересчитать позицию над активным редактором (только для режима body/fixed). */
|
|
@@ -144,6 +252,7 @@ class FormatToolbar {
|
|
|
144
252
|
window.removeEventListener("scroll", this.__reposition);
|
|
145
253
|
window.removeEventListener("resize", this.__reposition);
|
|
146
254
|
this.__resizeObserver?.disconnect();
|
|
255
|
+
this.__cancelScheduled();
|
|
147
256
|
}
|
|
148
257
|
|
|
149
258
|
private __ensure(): HTMLElement {
|
|
@@ -160,8 +269,8 @@ class FormatToolbar {
|
|
|
160
269
|
return this.__elem;
|
|
161
270
|
}
|
|
162
271
|
|
|
163
|
-
private __build(tools: FormatTool[], actions: EditorAction[]) {
|
|
164
|
-
const key = `${tools.join(",")}|${actions.join(",")}`;
|
|
272
|
+
private __build(tools: FormatTool[], actions: EditorAction[], buttons: ToolbarButton[]) {
|
|
273
|
+
const key = `${tools.join(",")}|${actions.join(",")}|${buttons.map((b) => b.name).join(",")}`;
|
|
165
274
|
const elem = this.__ensure();
|
|
166
275
|
if (key === this.__toolsKey && elem.firstChild) return; // тот же состав — переиспользуем кнопки
|
|
167
276
|
|
|
@@ -170,6 +279,7 @@ class FormatToolbar {
|
|
|
170
279
|
DOM.empty(elem);
|
|
171
280
|
this.__buttons = [];
|
|
172
281
|
this.__actionButtons = [];
|
|
282
|
+
this.__hostButtons = [];
|
|
173
283
|
|
|
174
284
|
for (const tool of tools) {
|
|
175
285
|
const def = FORMAT_TOOLS[tool];
|
|
@@ -200,6 +310,21 @@ class FormatToolbar {
|
|
|
200
310
|
this.__actionButtons.push([action, btn]);
|
|
201
311
|
}
|
|
202
312
|
|
|
313
|
+
if ((tools.length || actions.length) && buttons.length) elem.appendChild(DOM.tag("div", { class: "split" }));
|
|
314
|
+
|
|
315
|
+
// кнопки хоста — последними, чтобы штатные не переезжали при их появлении
|
|
316
|
+
for (const button of buttons) {
|
|
317
|
+
const btn = DOM.tag(
|
|
318
|
+
"button",
|
|
319
|
+
{ type: "button", class: "host-button", "data-toolbar-button": button.name, title: button.title },
|
|
320
|
+
button.icon
|
|
321
|
+
);
|
|
322
|
+
btn.addEventListener("click", () => button.run());
|
|
323
|
+
|
|
324
|
+
elem.appendChild(btn);
|
|
325
|
+
this.__hostButtons.push([button, btn]);
|
|
326
|
+
}
|
|
327
|
+
|
|
203
328
|
// панель пережила перестройку кнопок — возвращаем её в тулбар, чтобы не собирать заново.
|
|
204
329
|
// Если её забрал хост под свою кнопку, она остаётся у него.
|
|
205
330
|
if (pickerInToolbar && this.__emojiPicker) elem.appendChild(this.__emojiPicker);
|
|
@@ -223,7 +348,28 @@ class FormatToolbar {
|
|
|
223
348
|
this.__emojiHost = host;
|
|
224
349
|
this.__emojiInitiator = initiator;
|
|
225
350
|
|
|
226
|
-
|
|
351
|
+
// Панель у собственной кнопки хоста — самостоятельный слой, и показывать её вместе с тулбаром
|
|
352
|
+
// нельзя: это два всплывающих окна над одним полем. Тулбар придерживаем на всё время работы
|
|
353
|
+
// панели, а показанный убираем с экрана; вернётся он сам при её закрытии, если поле осталось
|
|
354
|
+
// в фокусе. Панель самого тулбара (container === __elem) — его собственный выпадающий слой,
|
|
355
|
+
// прятать её носителя незачем и нечем.
|
|
356
|
+
if (container !== this.__elem) {
|
|
357
|
+
this.__suspended = host;
|
|
358
|
+
if (this.__active === host) this.__hide();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
PopupManager.open(this.__ensureEmojiPicker(container), { initiator, onClose: () => this.__resume() });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Панель смайликов закрылась — показываем придержанный тулбар, если редактор ещё в фокусе. */
|
|
365
|
+
private __resume() {
|
|
366
|
+
const host = this.__suspended;
|
|
367
|
+
if (!host) return;
|
|
368
|
+
|
|
369
|
+
this.__suspended = null;
|
|
370
|
+
|
|
371
|
+
// панель могло закрыть и движение мимо поля — тогда показывать нечего
|
|
372
|
+
if (host.editable.ownerDocument.activeElement === host.editable) this.attach(host);
|
|
227
373
|
}
|
|
228
374
|
|
|
229
375
|
private __toggleEmoji(initiator: HTMLButtonElement, e: MouseEvent) {
|