@brandup/ui-richeditor 1.0.40 → 1.0.42

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,13 +6,25 @@
6
6
  // т.к. тулбар находится вне привязанных UIElement).
7
7
 
8
8
  import { DOM } from "@brandup/ui";
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";
9
+ import { POPUP_CLASS, PopupManager, SCROLLABLE_CLASS } from "@brandup/ui-kit";
10
+ import {
11
+ BLOCK_TYPES,
12
+ DEFAULT_BLOCK,
13
+ EDITOR_ACTIONS,
14
+ FORMAT_TOOLS,
15
+ type BlockType,
16
+ type EditorAction,
17
+ type FormatTool,
18
+ } from "./format";
19
+ import { EMOJI_GROUPS, type EmojiGroup } from "./emoji";
12
20
  import boldIcon from "../svg/bold.svg";
13
21
  import italicIcon from "../svg/italic.svg";
14
22
  import strikeIcon from "../svg/strike.svg";
15
23
  import underlineIcon from "../svg/underline.svg";
24
+ import spoilerIcon from "../svg/spoiler.svg";
25
+ import codeIcon from "../svg/mono.svg";
26
+ import quoteIcon from "../svg/quote.svg";
27
+ import codeblockIcon from "../svg/codeblock.svg";
16
28
  import emojiIcon from "../svg/emoji.svg";
17
29
  import eraseIcon from "../svg/erase.svg";
18
30
  import undoIcon from "../svg/undo.svg";
@@ -23,6 +35,14 @@ const FORMAT_ICONS: Record<FormatTool, string> = {
23
35
  italic: italicIcon,
24
36
  strike: strikeIcon,
25
37
  underline: underlineIcon,
38
+ spoiler: spoilerIcon,
39
+ code: codeIcon,
40
+ };
41
+
42
+ // Обычный текст кнопки не имеет: повторное нажатие активной кнопки возвращает блок к нему.
43
+ const BLOCK_ICONS: Partial<Record<BlockType, string>> = {
44
+ quote: quoteIcon,
45
+ code: codeblockIcon,
26
46
  };
27
47
 
28
48
  const ACTION_ICONS: Record<EditorAction, string> = {
@@ -32,6 +52,17 @@ const ACTION_ICONS: Record<EditorAction, string> = {
32
52
  redo: redoIcon,
33
53
  };
34
54
 
55
+ // Код есть и инструментом (моноширинный), и типом блока — панель сводит их в одну кнопку.
56
+ const CODE_TOOL: FormatTool = "code";
57
+ const CODE_BLOCK: BlockType = "code";
58
+ const MERGED_CODE_TITLE = "Код";
59
+
60
+ // Временно скрытые кнопки. Сами возможности работают: значение разбирается, показывается
61
+ // и сохраняется, правку можно вызвать из кода — в панель они просто не выводятся.
62
+ // Убрать отсюда, когда будут доведены.
63
+ const HIDDEN_TOOLS: FormatTool[] = ["spoiler", "code"];
64
+ const HIDDEN_BLOCKS: BlockType[] = ["code"];
65
+
35
66
  export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
36
67
  export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
37
68
 
@@ -61,8 +92,19 @@ export interface ToolbarHost {
61
92
  readonly editorActions?: EditorAction[];
62
93
  /** Контейнер для тулбара; null/undefined — document.body (position: fixed над редактором). */
63
94
  readonly toolbarContainer?: HTMLElement | null;
95
+ /** Типы блоков многострочного режима; пусто/undefined — кнопок блоков нет. */
96
+ readonly blockTypes?: BlockType[];
64
97
  applyFormat(tool: FormatTool): void;
65
98
  isToolActive(tool: FormatTool): boolean;
99
+ /** false — инструмент сейчас недоступен (например, внутри кода): кнопка гасится. */
100
+ isToolEnabled?(tool: FormatTool): boolean;
101
+ /** Тип блока под кареткой — им подсвечивается активная кнопка блока. */
102
+ readonly currentBlock?: BlockType;
103
+ applyBlock?(type: BlockType): void;
104
+ /** Правка кода любого вида — для объединённой кнопки (моноширинный + блок кода). */
105
+ applyCode?(): void;
106
+ /** Активен ли код в любом виде — подсветка объединённой кнопки. */
107
+ isCodeActive?(): boolean;
66
108
  /** Активные форматы всех инструментов сразу; нет реализации — панель опросит их поштучно. */
67
109
  activeTools?(): ReadonlySet<FormatTool>;
68
110
  applyAction?(action: EditorAction): void;
@@ -76,14 +118,46 @@ export interface ToolbarHost {
76
118
 
77
119
  const MARGIN = 6;
78
120
 
121
+ // Сколько кнопок помещается в ряд при ширине панели (см. .ui-richeditor-emoji в richeditor.less).
122
+ // Точность нужна только для оценки высоты нерисованной группы: ошибка сдвинет ползунок прокрутки,
123
+ // но не саму раскладку — группа переносит кнопки сама.
124
+ const EMOJI_COLUMNS = 8;
125
+
126
+ /**
127
+ * Группа смайликов: и смысловое деление в панели (отбивается линией), и кусок, к которому
128
+ * применяется пропуск отрисовки. Поэлементно это было бы семьсот отслеживаемых поддеревьев,
129
+ * и слежение за ними съедает выигрыш от пропуска.
130
+ */
131
+ function buildEmojiGroup(group: EmojiGroup): HTMLElement {
132
+ const rows = Math.ceil(group.emojis.length / EMOJI_COLUMNS);
133
+ const elem = DOM.tag("div", {
134
+ class: "emoji-group",
135
+ role: "group",
136
+ "aria-label": group.title,
137
+ // высота, пока группа не нарисована: без неё список схлопнулся бы, а прокрутка скакала
138
+ style: `--emoji-rows: ${rows}`,
139
+ });
140
+
141
+ const fragment = document.createDocumentFragment();
142
+ for (const emoji of group.emojis)
143
+ fragment.appendChild(DOM.tag("button", { type: "button", class: "emoji", tabindex: "-1" }, emoji));
144
+ elem.appendChild(fragment);
145
+
146
+ return elem;
147
+ }
148
+
79
149
  class FormatToolbar {
80
150
  private __elem: HTMLElement | null = null;
81
151
  private __emojiPicker: HTMLElement | null = null;
82
152
  private __emojiHost: ToolbarHost | null = null; // куда уйдёт выбранный символ
83
153
  private __emojiInitiator: HTMLElement | null = null; // кнопка, у которой открыта панель
84
154
  private __buttons: Array<[FormatTool, HTMLButtonElement]> = [];
155
+ private __blockButtons: Array<[BlockType, HTMLButtonElement]> = [];
156
+ private __mergedCode = false; // кнопка кода делает и моноширинный, и блок (см. __build)
85
157
  private __actionButtons: Array<[EditorAction, HTMLButtonElement]> = [];
86
- private __hostButtons: Array<[ToolbarButton, HTMLButtonElement]> = [];
158
+ // имя, а не сама кнопка хоста: панель одна на все редакторы и переиспользует разметку между
159
+ // ними, а поведение принадлежит текущему — держать здесь ссылку значит звать чужой обработчик
160
+ private __hostButtons: Array<[string, HTMLButtonElement]> = [];
87
161
  private __active: ToolbarHost | null = null;
88
162
  private __suspended: ToolbarHost | null = null; // показ придержан на время панели смайликов
89
163
  private __toolsKey = "";
@@ -146,11 +220,16 @@ class FormatToolbar {
146
220
 
147
221
  const actions = host.editorActions ?? [];
148
222
  const buttons = host.toolbarButtons ?? [];
149
- if (!host.formatTools.length && !actions.length && !buttons.length) return;
223
+ const tools = host.formatTools.filter((tool) => !HIDDEN_TOOLS.includes(tool));
224
+ // Обычный текст кнопки не имеет — он не «включается», а остаётся, когда выключены остальные.
225
+ const blocks = (host.blockTypes ?? []).filter(
226
+ (type) => type !== DEFAULT_BLOCK && !HIDDEN_BLOCKS.includes(type)
227
+ );
228
+ if (!tools.length && !blocks.length && !actions.length && !buttons.length) return;
150
229
 
151
230
  this.__bindSelection();
152
231
  this.__active = host;
153
- this.__build(host.formatTools, actions, buttons);
232
+ this.__build(tools, blocks, actions, buttons);
154
233
  this.refresh();
155
234
 
156
235
  const elem = this.__ensure();
@@ -190,9 +269,12 @@ class FormatToolbar {
190
269
  const emojiHost = this.__emojiHost === host;
191
270
  if (!emojiHost && this.__active !== host) return;
192
271
 
193
- this.__closeEmoji();
194
-
272
+ // Закрываем только свою панель. Открытие у кнопки другого редактора само переводит туда
273
+ // фокус, и этот уход приходит уже после — панель к тому времени принадлежит соседу, и
274
+ // закрыть её значило бы гасить только что открытое: она требовала бы второго нажатия.
195
275
  if (emojiHost) {
276
+ this.__closeEmoji();
277
+
196
278
  this.__emojiHost = null;
197
279
  this.__emojiInitiator = null;
198
280
  }
@@ -227,13 +309,38 @@ class FormatToolbar {
227
309
 
228
310
  const active = host.activeTools?.();
229
311
  for (const [tool, btn] of this.__buttons) {
230
- const isActive = active ? active.has(tool) : host.isToolActive(tool);
312
+ // объединённая кнопка подсвечена и на блоке кода, а не только на моноширинном
313
+ const isActive =
314
+ this.__mergedCode && tool === CODE_TOOL
315
+ ? !!host.isCodeActive?.()
316
+ : active
317
+ ? active.has(tool)
318
+ : host.isToolActive(tool);
319
+
231
320
  if (btn.classList.contains("active") !== isActive) btn.classList.toggle("active", isActive);
321
+ setDisabled(btn, host.isToolEnabled?.(tool) === false);
322
+ }
323
+
324
+ // тип под кареткой спрашиваем, только если есть что подсвечивать: обновление идёт
325
+ // на каждое её движение, а поиск блока — обход предков
326
+ if (this.__blockButtons.length) {
327
+ const block = host.currentBlock;
328
+
329
+ for (const [type, btn] of this.__blockButtons) {
330
+ const isActive = block === type;
331
+ if (btn.classList.contains("active") !== isActive) btn.classList.toggle("active", isActive);
332
+ }
232
333
  }
233
334
 
234
335
  // хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
235
336
  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);
337
+ for (const [name, btn] of this.__hostButtons)
338
+ setDisabled(btn, this.__hostButton(name)?.isEnabled?.() === false);
339
+ }
340
+
341
+ /** Кнопка хоста по имени — у активного редактора, а не у того, кто собрал разметку панели. */
342
+ private __hostButton(name: string): ToolbarButton | undefined {
343
+ return this.__active?.toolbarButtons?.find((button) => button.name === name);
237
344
  }
238
345
 
239
346
  /** Пересчитать позицию над активным редактором (только для режима body/fixed). */
@@ -269,8 +376,8 @@ class FormatToolbar {
269
376
  return this.__elem;
270
377
  }
271
378
 
272
- private __build(tools: FormatTool[], actions: EditorAction[], buttons: ToolbarButton[]) {
273
- const key = `${tools.join(",")}|${actions.join(",")}|${buttons.map((b) => b.name).join(",")}`;
379
+ private __build(tools: FormatTool[], blocks: BlockType[], actions: EditorAction[], buttons: ToolbarButton[]) {
380
+ const key = `${tools.join(",")}|${blocks.join(",")}|${actions.join(",")}|${buttons.map((b) => b.name).join(",")}`;
274
381
  const elem = this.__ensure();
275
382
  if (key === this.__toolsKey && elem.firstChild) return; // тот же состав — переиспользуем кнопки
276
383
 
@@ -278,29 +385,68 @@ class FormatToolbar {
278
385
  const pickerInToolbar = !!this.__emojiPicker && this.__emojiPicker.parentElement === elem;
279
386
  DOM.empty(elem);
280
387
  this.__buttons = [];
388
+ this.__blockButtons = [];
281
389
  this.__actionButtons = [];
282
390
  this.__hostButtons = [];
283
391
 
392
+ // Код — одна кнопка на оба вида, как в мессенджерах: и моноширинный, и блок кода. Какой
393
+ // из них применить, решает редактор по выделению, поэтому отдельная кнопка блока не нужна.
394
+ this.__mergedCode = tools.includes(CODE_TOOL) && blocks.includes(CODE_BLOCK);
395
+ const blockTypes = this.__mergedCode ? blocks.filter((type) => type !== CODE_BLOCK) : blocks;
396
+
397
+ // Разделитель ставится только между непустыми группами — иначе панель начиналась бы
398
+ // с линии или показывала две подряд.
399
+ let filled = false;
400
+ const separate = (group: unknown[]) => {
401
+ if (filled && group.length) elem.appendChild(DOM.tag("div", { class: "split" }));
402
+ filled ||= group.length > 0;
403
+ };
404
+
405
+ separate(tools);
406
+
284
407
  for (const tool of tools) {
408
+ const merged = this.__mergedCode && tool === CODE_TOOL;
285
409
  const def = FORMAT_TOOLS[tool];
286
410
  const btn = DOM.tag(
287
411
  "button",
288
- { type: "button", class: "format-button", "data-format-tool": tool, title: def.title },
412
+ {
413
+ type: "button",
414
+ class: "format-button",
415
+ dataset: { formatTool: tool },
416
+ title: merged ? MERGED_CODE_TITLE : def.title,
417
+ },
289
418
  FORMAT_ICONS[tool]
290
419
  );
291
- btn.addEventListener("click", () => this.__active?.applyFormat(tool));
420
+ btn.addEventListener("click", () =>
421
+ merged ? this.__active?.applyCode?.() : this.__active?.applyFormat(tool)
422
+ );
292
423
 
293
424
  elem.appendChild(btn);
294
425
  this.__buttons.push([tool, btn]);
295
426
  }
296
427
 
297
- if (tools.length && actions.length) elem.appendChild(DOM.tag("div", { class: "split" }));
428
+ separate(blockTypes);
429
+
430
+ for (const type of blockTypes) {
431
+ const def = BLOCK_TYPES[type];
432
+ const btn = DOM.tag(
433
+ "button",
434
+ { type: "button", class: "block-button", dataset: { blockType: type }, title: def.title },
435
+ BLOCK_ICONS[type] ?? ""
436
+ );
437
+ btn.addEventListener("click", () => this.__active?.applyBlock?.(type));
438
+
439
+ elem.appendChild(btn);
440
+ this.__blockButtons.push([type, btn]);
441
+ }
442
+
443
+ separate(actions);
298
444
 
299
445
  for (const action of actions) {
300
446
  const def = EDITOR_ACTIONS[action];
301
447
  const btn = DOM.tag(
302
448
  "button",
303
- { type: "button", class: "action-button", "data-editor-action": action, title: def.title },
449
+ { type: "button", class: "action-button", dataset: { editorAction: action }, title: def.title },
304
450
  ACTION_ICONS[action]
305
451
  );
306
452
  if (action === "emoji") btn.addEventListener("click", (e) => this.__toggleEmoji(btn, e));
@@ -310,19 +456,19 @@ class FormatToolbar {
310
456
  this.__actionButtons.push([action, btn]);
311
457
  }
312
458
 
313
- if ((tools.length || actions.length) && buttons.length) elem.appendChild(DOM.tag("div", { class: "split" }));
459
+ separate(buttons);
314
460
 
315
461
  // кнопки хоста — последними, чтобы штатные не переезжали при их появлении
316
462
  for (const button of buttons) {
317
463
  const btn = DOM.tag(
318
464
  "button",
319
- { type: "button", class: "host-button", "data-toolbar-button": button.name, title: button.title },
465
+ { type: "button", class: "host-button", dataset: { toolbarButton: button.name }, title: button.title },
320
466
  button.icon
321
467
  );
322
- btn.addEventListener("click", () => button.run());
468
+ btn.addEventListener("click", () => this.__hostButton(button.name)?.run());
323
469
 
324
470
  elem.appendChild(btn);
325
- this.__hostButtons.push([button, btn]);
471
+ this.__hostButtons.push([button.name, btn]);
326
472
  }
327
473
 
328
474
  // панель пережила перестройку кнопок — возвращаем её в тулбар, чтобы не собирать заново.
@@ -388,10 +534,12 @@ class FormatToolbar {
388
534
  private __buildEmojiPicker(): HTMLElement {
389
535
  const picker = DOM.tag("div", { class: `${POPUP_CLASS} ${EMOJI_PICKER_CLASS}` });
390
536
 
391
- const fragment = document.createDocumentFragment();
392
- for (const emoji of EMOJIS)
393
- fragment.appendChild(DOM.tag("button", { type: "button", class: "emoji", tabindex: "-1" }, emoji));
394
- picker.appendChild(fragment);
537
+ // Прокручивается список, а не сам попап: полоса прокрутки рисуется по краю коробки
538
+ // и перекрывала бы скругление рамки — угол выглядел бы срезанным.
539
+ const list = DOM.tag("div", { class: ["emoji-list", SCROLLABLE_CLASS] });
540
+ picker.appendChild(list);
541
+
542
+ for (const group of EMOJI_GROUPS) list.appendChild(buildEmojiGroup(group));
395
543
 
396
544
  // панель может висеть и вне тулбара, поэтому гасит фокус сама
397
545
  picker.addEventListener("mousedown", (e) => e.preventDefault());
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M 5 3 C 3.895 3 3 3.895 3 5 L 3 7 L 3 19 C 3 20.093063 3.9069372 21 5 21 L 19 21 C 20.093063 21 21 20.093063 21 19 L 21 6 L 21 5 C 21 3.895 20.105 3 19 3 L 5 3 z M 5 7 L 19 7 L 19 19 L 5 19 L 5 7 z M 10 10.085938 C 9.819 10.085938 9.638 10.154969 9.5 10.292969 L 7.5 12.292969 C 7.109 12.682969 7.109 13.316031 7.5 13.707031 L 9.5 15.707031 C 9.776 15.983031 10.224 15.983031 10.5 15.707031 C 10.776 15.431031 10.776 14.983031 10.5 14.707031 L 8.7929688 13 L 10.5 11.292969 C 10.776 11.016969 10.776 10.568969 10.5 10.292969 C 10.362 10.154969 10.181 10.085938 10 10.085938 z M 14 10.085938 C 13.819 10.085938 13.638 10.154969 13.5 10.292969 C 13.224 10.568969 13.224 11.016969 13.5 11.292969 L 15.207031 13 L 13.5 14.707031 C 13.224 14.983031 13.224 15.431031 13.5 15.707031 C 13.776 15.983031 14.224 15.983031 14.5 15.707031 L 16.5 13.707031 C 16.891 13.316031 16.891 12.682969 16.5 12.292969 L 14.5 10.292969 C 14.362 10.154969 14.181 10.085938 14 10.085938 z"/></svg>
package/svg/mono.svg ADDED
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 0 24 24" >
2
+ <path d="M5.5 19V5h2.9l3.6 7.4L15.6 5h2.9v14H16V9.9l-2.9 6h-2.2l-2.9-6V19H5.5Z" />
3
+ </svg>
package/svg/quote.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="M20,3H4.011C2.911,3,2.01,3.9,2.01,5L2.001,16.999c0,1.105,0.895,2.001,2,2.001H5.5C5.776,19,6,19.224,6,19.5v2.086 c0,0.891,1.077,1.337,1.707,0.707l3-3C10.895,19.105,11.149,19,11.414,19H20c1.1,0,2-0.9,2-2V5C22,3.9,21.1,3,20,3z M11,11.697 c0,0.197-0.058,0.39-0.168,0.555l-1.596,2.378c-0.25,0.374-0.752,0.483-1.133,0.244c-0.398-0.249-0.512-0.777-0.251-1.167L9,12H8 c-0.552,0-1-0.448-1-1V9c0-0.552,0.448-1,1-1h2c0.552,0,1,0.448,1,1V11.697z M17,11.697c0,0.197-0.058,0.39-0.168,0.555 l-1.596,2.378c-0.25,0.374-0.752,0.483-1.133,0.244c-0.398-0.249-0.512-0.777-0.251-1.167L15,12h-1c-0.552,0-1-0.448-1-1V9 c0-0.552,0.448-1,1-1h2c0.552,0,1,0.448,1,1V11.697z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M2 6h20v12H2V6zm4 5h3v2H6v-2zm4.5 0h3v2h-3v-2zm4.5 0h3v2h-3v-2z"/></svg>