@brandup/ui-richeditor 1.0.36 → 1.0.38
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 +52 -6
- package/package.json +3 -2
- package/source/editing.ts +7 -8
- package/source/emoji.ts +53 -0
- package/source/format-config.ts +45 -18
- package/source/format.ts +17 -1
- package/source/history.ts +10 -0
- package/source/index.ts +6 -0
- package/source/paragraphs.ts +10 -3
- package/source/richeditor.less +52 -1
- package/source/richeditor.ts +239 -32
- package/source/selection.ts +120 -30
- package/source/serialize.ts +274 -201
- package/source/toolbar.ts +134 -12
- package/svg/emoji.svg +3 -0
- package/svg/erase.svg +1 -0
- package/svg/redo.svg +1 -0
- package/svg/undo.svg +1 -0
package/source/toolbar.ts
CHANGED
|
@@ -6,11 +6,17 @@
|
|
|
6
6
|
// т.к. тулбар находится вне привязанных UIElement).
|
|
7
7
|
|
|
8
8
|
import { DOM } from "@brandup/ui";
|
|
9
|
-
import {
|
|
9
|
+
import { POPUP_CLASS, PopupManager } from "@brandup/ui-kit";
|
|
10
|
+
import { EDITOR_ACTIONS, FORMAT_TOOLS, type EditorAction, type FormatTool } from "./format";
|
|
11
|
+
import { EMOJIS } from "./emoji";
|
|
10
12
|
import boldIcon from "../svg/bold.svg";
|
|
11
13
|
import italicIcon from "../svg/italic.svg";
|
|
12
14
|
import strikeIcon from "../svg/strike.svg";
|
|
13
15
|
import underlineIcon from "../svg/underline.svg";
|
|
16
|
+
import emojiIcon from "../svg/emoji.svg";
|
|
17
|
+
import eraseIcon from "../svg/erase.svg";
|
|
18
|
+
import undoIcon from "../svg/undo.svg";
|
|
19
|
+
import redoIcon from "../svg/redo.svg";
|
|
14
20
|
|
|
15
21
|
const FORMAT_ICONS: Record<FormatTool, string> = {
|
|
16
22
|
bold: boldIcon,
|
|
@@ -19,23 +25,42 @@ const FORMAT_ICONS: Record<FormatTool, string> = {
|
|
|
19
25
|
underline: underlineIcon,
|
|
20
26
|
};
|
|
21
27
|
|
|
28
|
+
const ACTION_ICONS: Record<EditorAction, string> = {
|
|
29
|
+
emoji: emojiIcon,
|
|
30
|
+
erase: eraseIcon,
|
|
31
|
+
undo: undoIcon,
|
|
32
|
+
redo: redoIcon,
|
|
33
|
+
};
|
|
34
|
+
|
|
22
35
|
export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
|
|
36
|
+
export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
|
|
23
37
|
|
|
24
38
|
/** Редактор, которым управляет общий тулбар. */
|
|
25
39
|
export interface ToolbarHost {
|
|
26
40
|
readonly editable: HTMLElement;
|
|
27
41
|
readonly formatTools: FormatTool[];
|
|
42
|
+
/** Действия (очистка формата, отмена/повтор); пусто/undefined — кнопок действий нет. */
|
|
43
|
+
readonly editorActions?: EditorAction[];
|
|
28
44
|
/** Контейнер для тулбара; null/undefined — document.body (position: fixed над редактором). */
|
|
29
45
|
readonly toolbarContainer?: HTMLElement | null;
|
|
30
46
|
applyFormat(tool: FormatTool): void;
|
|
31
47
|
isToolActive(tool: FormatTool): boolean;
|
|
48
|
+
applyAction?(action: EditorAction): void;
|
|
49
|
+
/** false — кнопка действия недоступна (нечего отменять/очищать). */
|
|
50
|
+
isActionEnabled?(action: EditorAction): boolean;
|
|
51
|
+
/** Вставка текста в каретку — для панели смайликов. */
|
|
52
|
+
insertText?(text: string): void;
|
|
32
53
|
}
|
|
33
54
|
|
|
34
55
|
const MARGIN = 6;
|
|
35
56
|
|
|
36
57
|
class FormatToolbar {
|
|
37
58
|
private __elem: HTMLElement | null = null;
|
|
59
|
+
private __emojiPicker: HTMLElement | null = null;
|
|
60
|
+
private __emojiHost: ToolbarHost | null = null; // куда уйдёт выбранный символ
|
|
61
|
+
private __emojiInitiator: HTMLElement | null = null; // кнопка, у которой открыта панель
|
|
38
62
|
private __buttons: Array<[FormatTool, HTMLButtonElement]> = [];
|
|
63
|
+
private __actionButtons: Array<[EditorAction, HTMLButtonElement]> = [];
|
|
39
64
|
private __active: ToolbarHost | null = null;
|
|
40
65
|
private __toolsKey = "";
|
|
41
66
|
private __inContainer = false;
|
|
@@ -50,10 +75,11 @@ class FormatToolbar {
|
|
|
50
75
|
|
|
51
76
|
/** Показать тулбар для редактора (на фокусе): перестроить кнопки, спозиционировать, показать. */
|
|
52
77
|
attach(host: ToolbarHost) {
|
|
53
|
-
|
|
78
|
+
const actions = host.editorActions ?? [];
|
|
79
|
+
if (!host.formatTools.length && !actions.length) return;
|
|
54
80
|
|
|
55
81
|
this.__active = host;
|
|
56
|
-
this.__build(host.formatTools);
|
|
82
|
+
this.__build(host.formatTools, actions);
|
|
57
83
|
this.refresh();
|
|
58
84
|
|
|
59
85
|
const elem = this.__ensure();
|
|
@@ -86,15 +112,20 @@ class FormatToolbar {
|
|
|
86
112
|
detach(host: ToolbarHost) {
|
|
87
113
|
if (this.__active !== host) return;
|
|
88
114
|
|
|
115
|
+
this.__closeEmoji();
|
|
89
116
|
this.__active = null;
|
|
90
117
|
if (this.__elem) this.__elem.classList.remove("visible");
|
|
91
118
|
this.__removeViewportListeners();
|
|
92
119
|
}
|
|
93
120
|
|
|
94
|
-
/** Обновить подсветку активных инструментов по текущему
|
|
121
|
+
/** Обновить подсветку активных инструментов и доступность действий по текущему состоянию. */
|
|
95
122
|
refresh() {
|
|
96
|
-
|
|
97
|
-
|
|
123
|
+
const host = this.__active;
|
|
124
|
+
if (!host) return;
|
|
125
|
+
|
|
126
|
+
for (const [tool, btn] of this.__buttons) btn.classList.toggle("active", host.isToolActive(tool));
|
|
127
|
+
// хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
|
|
128
|
+
for (const [action, btn] of this.__actionButtons) btn.disabled = host.isActionEnabled?.(action) === false;
|
|
98
129
|
}
|
|
99
130
|
|
|
100
131
|
/** Пересчитать позицию над активным редактором (только для режима body/fixed). */
|
|
@@ -116,18 +147,29 @@ class FormatToolbar {
|
|
|
116
147
|
}
|
|
117
148
|
|
|
118
149
|
private __ensure(): HTMLElement {
|
|
119
|
-
if (!this.__elem)
|
|
150
|
+
if (!this.__elem) {
|
|
151
|
+
this.__elem = DOM.tag("div", { class: TOOLBAR_CLASS });
|
|
152
|
+
|
|
153
|
+
// Панель нигде не должна забирать фокус, иначе редактор теряет выделение, а blur
|
|
154
|
+
// прячет сам тулбар. Слушатель висит на корне, а не на кнопках: до disabled-кнопки
|
|
155
|
+
// событие не доходит (браузер их не диспатчит), да и клик по фону панели между
|
|
156
|
+
// кнопками иначе тоже уводил бы фокус. Дочерние элементы покрываются всплытием.
|
|
157
|
+
this.__elem.addEventListener("mousedown", (e) => e.preventDefault());
|
|
158
|
+
}
|
|
159
|
+
|
|
120
160
|
return this.__elem;
|
|
121
161
|
}
|
|
122
162
|
|
|
123
|
-
private __build(tools: FormatTool[]) {
|
|
124
|
-
const key = tools.join(",")
|
|
163
|
+
private __build(tools: FormatTool[], actions: EditorAction[]) {
|
|
164
|
+
const key = `${tools.join(",")}|${actions.join(",")}`;
|
|
125
165
|
const elem = this.__ensure();
|
|
126
|
-
if (key === this.__toolsKey &&
|
|
166
|
+
if (key === this.__toolsKey && elem.firstChild) return; // тот же состав — переиспользуем кнопки
|
|
127
167
|
|
|
128
168
|
this.__toolsKey = key;
|
|
169
|
+
const pickerInToolbar = !!this.__emojiPicker && this.__emojiPicker.parentElement === elem;
|
|
129
170
|
DOM.empty(elem);
|
|
130
171
|
this.__buttons = [];
|
|
172
|
+
this.__actionButtons = [];
|
|
131
173
|
|
|
132
174
|
for (const tool of tools) {
|
|
133
175
|
const def = FORMAT_TOOLS[tool];
|
|
@@ -136,13 +178,93 @@ class FormatToolbar {
|
|
|
136
178
|
{ type: "button", class: "format-button", "data-format-tool": tool, title: def.title },
|
|
137
179
|
FORMAT_ICONS[tool]
|
|
138
180
|
);
|
|
139
|
-
// не даём кнопке забрать фокус, иначе теряется выделение в редакторе
|
|
140
|
-
btn.addEventListener("mousedown", (e) => e.preventDefault());
|
|
141
181
|
btn.addEventListener("click", () => this.__active?.applyFormat(tool));
|
|
142
182
|
|
|
143
183
|
elem.appendChild(btn);
|
|
144
184
|
this.__buttons.push([tool, btn]);
|
|
145
185
|
}
|
|
186
|
+
|
|
187
|
+
if (tools.length && actions.length) elem.appendChild(DOM.tag("div", { class: "split" }));
|
|
188
|
+
|
|
189
|
+
for (const action of actions) {
|
|
190
|
+
const def = EDITOR_ACTIONS[action];
|
|
191
|
+
const btn = DOM.tag(
|
|
192
|
+
"button",
|
|
193
|
+
{ type: "button", class: "action-button", "data-editor-action": action, title: def.title },
|
|
194
|
+
ACTION_ICONS[action]
|
|
195
|
+
);
|
|
196
|
+
if (action === "emoji") btn.addEventListener("click", (e) => this.__toggleEmoji(btn, e));
|
|
197
|
+
else btn.addEventListener("click", () => this.__active?.applyAction?.(action));
|
|
198
|
+
|
|
199
|
+
elem.appendChild(btn);
|
|
200
|
+
this.__actionButtons.push([action, btn]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// панель пережила перестройку кнопок — возвращаем её в тулбар, чтобы не собирать заново.
|
|
204
|
+
// Если её забрал хост под свою кнопку, она остаётся у него.
|
|
205
|
+
if (pickerInToolbar && this.__emojiPicker) elem.appendChild(this.__emojiPicker);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Открыть панель смайликов у произвольной кнопки — например у собственной кнопки хоста рядом
|
|
210
|
+
* с полем ввода, а не в тулбаре. Панель одна на все редакторы и переезжает в `container`;
|
|
211
|
+
* выбранный символ уходит в `host`, даже если тулбар сейчас обслуживает другой редактор.
|
|
212
|
+
*
|
|
213
|
+
* Вызывать из обработчика `click`, погасив всплытие: PopupManager вешает свой слушатель
|
|
214
|
+
* закрытия на body прямо в open(), то есть во время этого же клика — до body событие ещё
|
|
215
|
+
* не дошло, и слушатель закрыл бы панель сразу после открытия.
|
|
216
|
+
*/
|
|
217
|
+
openEmoji(host: ToolbarHost, initiator: HTMLElement, container: HTMLElement) {
|
|
218
|
+
// повторный клик по той же кнопке закрывает панель (это делает toggle внутри PopupManager),
|
|
219
|
+
// а вот у другой кнопки её нужно сперва закрыть — иначе toggle сочтёт открытие повторным
|
|
220
|
+
if (this.__emojiInitiator !== initiator && this.__emojiPicker?.classList.contains("opened"))
|
|
221
|
+
PopupManager.close();
|
|
222
|
+
|
|
223
|
+
this.__emojiHost = host;
|
|
224
|
+
this.__emojiInitiator = initiator;
|
|
225
|
+
|
|
226
|
+
PopupManager.open(this.__ensureEmojiPicker(container), { initiator });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private __toggleEmoji(initiator: HTMLButtonElement, e: MouseEvent) {
|
|
230
|
+
e.stopPropagation();
|
|
231
|
+
|
|
232
|
+
if (this.__active) this.openEmoji(this.__active, initiator, this.__ensure());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private __ensureEmojiPicker(container: HTMLElement): HTMLElement {
|
|
236
|
+
const picker = this.__emojiPicker ?? this.__buildEmojiPicker();
|
|
237
|
+
if (picker.parentElement !== container) container.appendChild(picker);
|
|
238
|
+
|
|
239
|
+
return picker;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private __buildEmojiPicker(): HTMLElement {
|
|
243
|
+
const picker = DOM.tag("div", { class: `${POPUP_CLASS} ${EMOJI_PICKER_CLASS}` });
|
|
244
|
+
|
|
245
|
+
const fragment = document.createDocumentFragment();
|
|
246
|
+
for (const emoji of EMOJIS)
|
|
247
|
+
fragment.appendChild(DOM.tag("button", { type: "button", class: "emoji", tabindex: "-1" }, emoji));
|
|
248
|
+
picker.appendChild(fragment);
|
|
249
|
+
|
|
250
|
+
// панель может висеть и вне тулбара, поэтому гасит фокус сама
|
|
251
|
+
picker.addEventListener("mousedown", (e) => e.preventDefault());
|
|
252
|
+
picker.addEventListener("click", (e) => {
|
|
253
|
+
const target = (e.target as HTMLElement).closest<HTMLElement>(".emoji");
|
|
254
|
+
if (!target) return;
|
|
255
|
+
|
|
256
|
+
this.__emojiHost?.insertText?.(target.textContent ?? "");
|
|
257
|
+
PopupManager.close();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
this.__emojiPicker = picker;
|
|
261
|
+
|
|
262
|
+
return picker;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Закрыть панель смайликов, если открыта именно она (тулбар уходит — попап не должен остаться). */
|
|
266
|
+
private __closeEmoji() {
|
|
267
|
+
if (this.__emojiPicker?.classList.contains("opened")) PopupManager.close();
|
|
146
268
|
}
|
|
147
269
|
}
|
|
148
270
|
|
package/svg/emoji.svg
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg viewBox="0 0 20 20" >
|
|
2
|
+
<path d="M10 0C4.477 0 0 4.477 0 10C0 15.523 4.477 20 10 20C15.523 20 20 15.523 20 10C20 4.477 15.523 0 10 0ZM10 2C14.418 2 18 5.582 18 10C18 14.418 14.418 18 10 18C5.582 18 2 14.418 2 10C2 5.582 5.582 2 10 2ZM6.5 6C6.10218 6 5.72064 6.15804 5.43934 6.43934C5.15804 6.72064 5 7.10218 5 7.5C5 7.89782 5.15804 8.27936 5.43934 8.56066C5.72064 8.84196 6.10218 9 6.5 9C6.89782 9 7.27936 8.84196 7.56066 8.56066C7.84196 8.27936 8 7.89782 8 7.5C8 7.10218 7.84196 6.72064 7.56066 6.43934C7.27936 6.15804 6.89782 6 6.5 6ZM13.5 6C13.1022 6 12.7206 6.15804 12.4393 6.43934C12.158 6.72064 12 7.10218 12 7.5C12 7.89782 12.158 8.27936 12.4393 8.56066C12.7206 8.84196 13.1022 9 13.5 9C13.8978 9 14.2794 8.84196 14.5607 8.56066C14.842 8.27936 15 7.89782 15 7.5C15 7.10218 14.842 6.72064 14.5607 6.43934C14.2794 6.15804 13.8978 6 13.5 6ZM4.89062 12C5.69063 14.04 7.67 15.5 10 15.5C12.33 15.5 14.3094 14.04 15.1094 12H4.89062Z" />
|
|
3
|
+
</svg>
|
package/svg/erase.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 14.650391 2.0058594 C 14.145841 2.0058594 13.641227 2.1947106 13.261719 2.5742188 L 2.5761719 13.263672 C 1.8171557 14.022688 1.8171557 15.281999 2.5761719 16.041016 L 7.9609375 21.425781 C 8.7199537 22.184797 9.9792653 22.184797 10.738281 21.425781 L 21.423828 10.736328 C 22.182844 9.9773121 22.182844 8.7180005 21.423828 7.9589844 L 16.039062 2.5742188 C 15.659554 2.1947106 15.15494 2.0058594 14.650391 2.0058594 z M 9.3203125 9.3457031 L 14.654297 14.679688 L 9.3496094 19.986328 L 4.015625 14.650391 L 9.3203125 9.3457031 z"/></svg>
|
package/svg/redo.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 11.5 8 C 7.257 8 3.6110312 10.520531 1.9570312 14.144531 C 1.7090313 14.687531 1.9847813 15.328578 2.5507812 15.517578 C 3.0297813 15.677578 3.5527188 15.452188 3.7617188 14.992188 C 5.0977187 12.048188 8.057 10 11.5 10 C 13.678099 10 15.656912 10.829684 17.160156 12.177734 L 14.191406 15.146484 C 13.996406 15.341484 13.996406 15.658516 14.191406 15.853516 C 14.289406 15.950516 14.416922 16 14.544922 16 L 21.044922 16 A 1 1 0 0 0 22.044922 15 L 22.044922 8.5 C 22.044922 8.372 21.996437 8.2434844 21.898438 8.1464844 C 21.703437 7.9514844 21.386406 7.9514844 21.191406 8.1464844 L 18.568359 10.769531 C 16.705156 9.0570281 14.232024 8 11.5 8 z"/></svg>
|
package/svg/undo.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 2.5 8 C 2.372 8 2.2439844 8.0489844 2.1464844 8.1464844 C 2.0484844 8.2434844 2 8.372 2 8.5 L 2 15 A 1 1 0 0 0 3 16 L 9.5 16 C 9.628 16 9.7555156 15.950516 9.8535156 15.853516 C 10.048516 15.658516 10.048516 15.341484 9.8535156 15.146484 L 7.2207031 12.513672 C 8.6282801 11.265495 10.470063 10.5 12.5 10.5 C 15.649 10.5 18.367875 12.32275 19.671875 14.96875 C 19.947875 15.52875 20.588641 15.803469 21.181641 15.605469 C 21.890641 15.369469 22.250875 14.559672 21.921875 13.888672 C 20.215875 10.403672 16.643 8 12.5 8 C 9.7794954 8 7.3175698 9.0503139 5.4570312 10.75 L 2.8535156 8.1464844 C 2.7560156 8.0489844 2.628 8 2.5 8 z"/></svg>
|