@brandup/ui-richeditor 1.0.41 → 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
@@ -7,12 +7,24 @@
7
7
 
8
8
  import { DOM } from "@brandup/ui";
9
9
  import { POPUP_CLASS, PopupManager, SCROLLABLE_CLASS } from "@brandup/ui-kit";
10
- import { EDITOR_ACTIONS, FORMAT_TOOLS, type EditorAction, type FormatTool } from "./format";
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";
11
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;
@@ -110,6 +152,8 @@ class FormatToolbar {
110
152
  private __emojiHost: ToolbarHost | null = null; // куда уйдёт выбранный символ
111
153
  private __emojiInitiator: HTMLElement | null = null; // кнопка, у которой открыта панель
112
154
  private __buttons: Array<[FormatTool, HTMLButtonElement]> = [];
155
+ private __blockButtons: Array<[BlockType, HTMLButtonElement]> = [];
156
+ private __mergedCode = false; // кнопка кода делает и моноширинный, и блок (см. __build)
113
157
  private __actionButtons: Array<[EditorAction, HTMLButtonElement]> = [];
114
158
  // имя, а не сама кнопка хоста: панель одна на все редакторы и переиспользует разметку между
115
159
  // ними, а поведение принадлежит текущему — держать здесь ссылку значит звать чужой обработчик
@@ -176,11 +220,16 @@ class FormatToolbar {
176
220
 
177
221
  const actions = host.editorActions ?? [];
178
222
  const buttons = host.toolbarButtons ?? [];
179
- 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;
180
229
 
181
230
  this.__bindSelection();
182
231
  this.__active = host;
183
- this.__build(host.formatTools, actions, buttons);
232
+ this.__build(tools, blocks, actions, buttons);
184
233
  this.refresh();
185
234
 
186
235
  const elem = this.__ensure();
@@ -260,13 +309,33 @@ class FormatToolbar {
260
309
 
261
310
  const active = host.activeTools?.();
262
311
  for (const [tool, btn] of this.__buttons) {
263
- 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
+
264
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
+ }
265
333
  }
266
334
 
267
335
  // хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
268
336
  for (const [action, btn] of this.__actionButtons) setDisabled(btn, host.isActionEnabled?.(action) === false);
269
- for (const [name, btn] of this.__hostButtons) setDisabled(btn, this.__hostButton(name)?.isEnabled?.() === false);
337
+ for (const [name, btn] of this.__hostButtons)
338
+ setDisabled(btn, this.__hostButton(name)?.isEnabled?.() === false);
270
339
  }
271
340
 
272
341
  /** Кнопка хоста по имени — у активного редактора, а не у того, кто собрал разметку панели. */
@@ -307,8 +376,8 @@ class FormatToolbar {
307
376
  return this.__elem;
308
377
  }
309
378
 
310
- private __build(tools: FormatTool[], actions: EditorAction[], buttons: ToolbarButton[]) {
311
- 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(",")}`;
312
381
  const elem = this.__ensure();
313
382
  if (key === this.__toolsKey && elem.firstChild) return; // тот же состав — переиспользуем кнопки
314
383
 
@@ -316,23 +385,62 @@ class FormatToolbar {
316
385
  const pickerInToolbar = !!this.__emojiPicker && this.__emojiPicker.parentElement === elem;
317
386
  DOM.empty(elem);
318
387
  this.__buttons = [];
388
+ this.__blockButtons = [];
319
389
  this.__actionButtons = [];
320
390
  this.__hostButtons = [];
321
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
+
322
407
  for (const tool of tools) {
408
+ const merged = this.__mergedCode && tool === CODE_TOOL;
323
409
  const def = FORMAT_TOOLS[tool];
324
410
  const btn = DOM.tag(
325
411
  "button",
326
- { type: "button", class: "format-button", dataset: { formatTool: 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
+ },
327
418
  FORMAT_ICONS[tool]
328
419
  );
329
- btn.addEventListener("click", () => this.__active?.applyFormat(tool));
420
+ btn.addEventListener("click", () =>
421
+ merged ? this.__active?.applyCode?.() : this.__active?.applyFormat(tool)
422
+ );
330
423
 
331
424
  elem.appendChild(btn);
332
425
  this.__buttons.push([tool, btn]);
333
426
  }
334
427
 
335
- 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);
336
444
 
337
445
  for (const action of actions) {
338
446
  const def = EDITOR_ACTIONS[action];
@@ -348,7 +456,7 @@ class FormatToolbar {
348
456
  this.__actionButtons.push([action, btn]);
349
457
  }
350
458
 
351
- if ((tools.length || actions.length) && buttons.length) elem.appendChild(DOM.tag("div", { class: "split" }));
459
+ separate(buttons);
352
460
 
353
461
  // кнопки хоста — последними, чтобы штатные не переезжали при их появлении
354
462
  for (const button of buttons) {
@@ -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>