@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/source/toolbar.ts CHANGED
@@ -6,11 +6,17 @@
6
6
  // т.к. тулбар находится вне привязанных UIElement).
7
7
 
8
8
  import { DOM } from "@brandup/ui";
9
- import { FORMAT_TOOLS, type FormatTool } from "./format";
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
- if (!host.formatTools.length) return;
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
- if (!this.__active) return;
97
- for (const [tool, btn] of this.__buttons) btn.classList.toggle("active", this.__active.isToolActive(tool));
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) this.__elem = DOM.tag("div", { class: TOOLBAR_CLASS });
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 && this.__buttons.length) return; // тот же состав — переиспользуем кнопки
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>