@brandup/ui-richeditor 1.0.41 → 1.0.43
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 +53 -3
- package/package.json +2 -2
- package/source/editing.ts +170 -13
- package/source/format-config.ts +117 -3
- package/source/format.ts +18 -1
- package/source/index.ts +5 -0
- package/source/paragraphs.ts +79 -13
- package/source/richeditor.less +54 -0
- package/source/richeditor.ts +204 -15
- package/source/selection.ts +188 -30
- package/source/serialize.ts +479 -296
- package/source/toolbar.ts +119 -11
- package/svg/codeblock.svg +1 -0
- package/svg/mono.svg +3 -0
- package/svg/quote.svg +1 -0
- package/svg/spoiler.svg +1 -0
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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
|
-
{
|
|
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", () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
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>
|
package/svg/spoiler.svg
ADDED
|
@@ -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>
|