@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 CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## Установка
10
10
 
11
- ```
11
+ ```bash
12
12
  npm i @brandup/ui-richeditor
13
13
  ```
14
14
 
@@ -40,13 +40,15 @@ editor.onChange(({ value }) => console.log(value));
40
40
  ## Опции (`RichEditorOptions`)
41
41
 
42
42
  | Опция | Тип | Описание |
43
- |---|---|---|
43
+ | --- | --- | --- |
44
44
  | `format` | `boolean` | Включает форматирование и панель инструментов |
45
45
  | `tools` | `FormatTool[]` | Состав инструментов (по умолчанию все) |
46
+ | `actions` | `EditorAction[]` | Кнопки действий в панели: `emoji`, `erase`, `undo`, `redo` (по умолчанию нет) |
46
47
  | `storage` | `"html" \| "markdown"` | Формат сериализации значения (по умолчанию `html`) |
47
48
  | `markers` | `Partial<FormatMarkers>` | Переопределение markdown-маркеров по инструментам |
48
49
  | `placeholder` | `string \| null` | Текст-заглушка |
49
50
  | `multiline` | `boolean` | Многострочный режим |
51
+ | `paragraph` | `"block" \| "break"` | Что делает Enter: новый абзац (по умолчанию) или мягкий перенос |
50
52
  | `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются) |
51
53
  | `toolbarContainer` | `HTMLElement \| null` | Контейнер для панели; по умолчанию `document.body` (`position: fixed`). Если задан — панель монтируется в него и позиционируется над ним (`position: absolute`). Контейнер должен быть `position: relative` |
52
54
  | `value` | `string` | Начальное значение |
@@ -60,13 +62,22 @@ editor.onChange(({ value }) => console.log(value));
60
62
  ## API
61
63
 
62
64
  | Член | Описание |
63
- |---|---|
65
+ | --- | --- |
64
66
  | `editable` | Редактируемый элемент |
65
- | `format`, `formatTools`, `formatStorage`, `formatMarkers`, `multiline` | Параметры экземпляра |
67
+ | `format`, `formatTools`, `editorActions`, `formatStorage`, `formatMarkers`, `multiline` | Параметры экземпляра |
66
68
  | `getValue(): string` | Сериализованное значение (по `storage`) |
67
69
  | `setValue(value: string): void` | Установить значение (нормализует, генерирует `change`) |
68
70
  | `getLength(): number` | Длина текста (без учёта переводов строк) |
69
71
  | `focus(): void` | Установить фокус |
72
+ | `applyFormat(tool): void` | Переключить формат на выделении (слово целиком) |
73
+ | `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
74
+ | `clearFormat(): void` | Снять всё форматирование с выделения (без выделения — со слова под кареткой) |
75
+ | `clearAllFormat(): void` | Снять всё форматирование со всего содержимого |
76
+ | `undo(): void`, `redo(): void` | Отмена и повтор |
77
+ | `canUndo`, `canRedo` | Доступность отмены/повтора |
78
+ | `applyAction(action): void` | Выполнить действие панели (`erase`/`undo`/`redo`) |
79
+ | `isActionEnabled(action): boolean` | Доступно ли действие сейчас |
80
+ | `insertText(text): void` | Вставить текст в каретку (или вместо выделения) с учётом режима набора |
70
81
  | `onChange(handler)` | Подписка на событие `richeditor-change` |
71
82
  | `destroy(): void` | Разворачивает элемент обратно и освобождает ресурсы |
72
83
 
@@ -79,6 +90,33 @@ editor.onChange(({ value }) => console.log(value));
79
90
  - **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов.
80
91
  - При потере фокуса и после `setValue` пробелы нормализуются (схлопывание повторов + обрезка краёв строк).
81
92
 
93
+ ## Очистка форматирования и действия панели
94
+
95
+ `clearFormat()` снимает **все** форматы сразу — с выделения, а без выделения со слова под кареткой (та же логика, что и у применения формата). Режим набора при этом сбрасывается. Распознаются и теги-синонимы (`STRONG`/`EM`/`DEL`/`INS`), которые могли прийти из вставки или `setValue`. `clearAllFormat()` чистит всё содержимое и выделения не требует.
96
+
97
+ Обе операции попадают в историю (откатываются одним `Ctrl+Z`) и не создают пустой шаг отмены, если очищать нечего.
98
+
99
+ Кроме кнопок форматирования панель может показывать кнопки действий — они подключаются **явно** через `actions`:
100
+
101
+ ```typescript
102
+ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"] });
103
+ ```
104
+
105
+ | Действие | Кнопка | Что делает | Когда недоступна |
106
+ | --- | --- | --- | --- |
107
+ | `emoji` | Вставить смайлик | открывает панель вставки | в режиме `readonly` |
108
+ | `erase` | Очистить форматирование | `clearFormat()` | нет форматирования на выделении |
109
+ | `undo` | Отменить | `undo()` | история пуста |
110
+ | `redo` | Повторить | `redo()` | нечего повторять |
111
+
112
+ Кнопки действий (`.action-button`) отделены от кнопок форматирования (`.format-button`) разделителем `.split` и получают атрибут `disabled`, когда действие недоступно. Панель показывается и в том случае, если инструментов форматирования нет, а действия заданы.
113
+
114
+ ### Панель смайликов
115
+
116
+ Кнопка `emoji` открывает под панелью попап `.ui-richeditor-emoji` со списком символов (`EMOJIS` — экспортируется пакетом). Выбранный символ вставляется через `insertText()`, то есть в текущую каретку и с учётом ожидающих форматов режима набора; попап после выбора закрывается.
117
+
118
+ Открытием и закрытием управляет `PopupManager` из [`@brandup/ui-kit`](../brandup-ui-kit) — оттуда же приходят базовые стили `.ui-popup`. Ни кнопка, ни попап не забирают фокус у редактора (`mousedown` гасится), поэтому каретка и выделение сохраняются. Список кнопок собирается лениво, при первом открытии.
119
+
82
120
  ## Многострочный режим: абзацы и переносы
83
121
 
84
122
  При `multiline: true` контент структурируется по абзацам:
@@ -87,6 +125,10 @@ editor.onChange(({ value }) => console.log(value));
87
125
  - **Shift+Enter** или **Ctrl/Cmd+Enter** → мягкий перенос (`<br>`) внутри абзаца;
88
126
  - блуждающий текст и `<div>` нормализуются в `<p>` при вводе.
89
127
 
128
+ Опция `paragraph: "break"` меняет это местами — Enter даёт мягкий перенос, а абзац набирается модификатором либо просто двумя переносами. Так устроены мессенджеры, и это важно при `storage: "markdown"`: в режиме по умолчанию каждый Enter уходит в значение пустой строкой (`\n\n`), а в `break` — одним переносом (`\n`).
129
+
130
+ В этом режиме абзацных блоков в содержимом нет вовсе: значение загружается плоским текстом, где каждый `\n` становится `<br>` внутри единственного `<p>`. Иначе два переноса рисовались бы двумя абзацами, а у хоста без отступов между ними это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
131
+
90
132
  Хвостовой перенос абзаца отбрасывается (это `<br>`-заполнитель); пустая строка делается отдельным абзацем.
91
133
 
92
134
  При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются.
@@ -105,14 +147,18 @@ editor.onChange(({ value }) => console.log(value));
105
147
  ## Формат хранения
106
148
 
107
149
  | `storage` | Хранение | Абзац / мягкий перенос | Маркеры форматирования |
108
- |---|---|---|---|
150
+ | --- | --- | --- | --- |
109
151
  | `html` | Санитизированный HTML | `<p>…</p>` / `<br>` | `<b>`, `<i>`, `<s>`, `<u>` |
110
- | `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`, `*курсив*`, `~~зачёркнутый~~`, `++подчёркнутый++` |
152
+ | `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`, `_курсив_`, `~зачёркнутый~`, `__подчёркнутый__` |
111
153
 
112
154
  Без форматирования (plain) значение хранится как markdown без инструментов: абзацы `\n\n`, мягкий перенос `\n`.
113
155
 
114
156
  Маркеры markdown настраиваются через `markers`. При разборе применяются по убыванию длины, поэтому длинный маркер срабатывает раньше короткого-префикса. Глубоко вложенные комбинации гарантированно сохраняются только в режиме `html`.
115
157
 
158
+ Разметка распознаётся по правилам мессенджеров: маркер стоит на **границе слова**, содержимое не начинается и не заканчивается пробелом и не пересекает перенос строки. Поэтому `5**4 = 20`, `2 ** 2 ** 2` и `файл_имя_файла.txt` остаются обычным текстом — иначе редактор показывал бы форматирование там, где получатель увидит исходные символы. Форматирование применяется к словам целиком, так что собственный вывод редактора всегда разбирается обратно.
159
+
160
+ Краевые пробелы при сериализации выносятся за маркеры (`<b> слово </b>дальше` → `**слово** дальше`), иначе разметка не сработала бы ни у нас, ни у мессенджера. А вот формат, приклеенный к соседнему слову — такое приходит только со вставкой внешнего HTML (`супер<b>бонус</b>`), — маркерами невыразим: значение сохранится как `супер**бонус**` и разметкой уже не станет. Это осознанное решение: текст остаётся ровно тем, что набрал пользователь, а не молча теряет форматирование.
161
+
116
162
  ## CSS
117
163
 
118
164
  Подключается `richeditor.less`. Цветовые переменные `--input-*` берутся из `@brandup/ui-kit` (с fallback-значениями для standalone). Классы: `focused` на обёртке (поле в фокусе), `visible` на общей панели (показана), `active` на кнопке инструмента (формат активен на выделении).
package/package.json CHANGED
@@ -27,11 +27,12 @@
27
27
  "email": "it@brandup.online"
28
28
  },
29
29
  "license": "Apache-2.0",
30
- "version": "1.0.36",
30
+ "version": "1.0.38",
31
31
  "main": "source/index.ts",
32
32
  "types": "source/index.ts",
33
33
  "dependencies": {
34
- "@brandup/ui": "^2.0.5"
34
+ "@brandup/ui": "^2.0.7",
35
+ "@brandup/ui-kit": "^1.0.38"
35
36
  },
36
37
  "files": [
37
38
  "source",
package/source/editing.ts CHANGED
@@ -179,10 +179,12 @@ export function trimParagraphEdges(p: HTMLElement) {
179
179
  last.textContent = (last.textContent ?? "").replace(/ $/, "");
180
180
  }
181
181
 
182
- /** Расширяет выделение до целых слов на границах (для применения формата к слову целиком). */
183
- export function expandSelectionToWords(editable: HTMLElement, selection: Selection) {
184
- const range = selection.getRangeAt(0);
185
-
182
+ /**
183
+ * Диапазон, расширенный до целых слов на границах (для применения формата к слову целиком).
184
+ * Возвращает новый Range и не трогает выделение — вызывающий сам решает, править ли по нему
185
+ * и когда двигать каретку.
186
+ */
187
+ export function expandRangeToWords(editable: HTMLElement, range: Range): Range {
186
188
  const { startContainer, endContainer } = range;
187
189
  let startOffset = range.startOffset;
188
190
  let endOffset = range.endOffset;
@@ -197,13 +199,10 @@ export function expandSelectionToWords(editable: HTMLElement, selection: Selecti
197
199
  while (endOffset < text.length && !/\s/.test(text[endOffset])) endOffset++;
198
200
  }
199
201
 
200
- if (startOffset === range.startOffset && endOffset === range.endOffset) return;
201
-
202
202
  const expanded = document.createRange();
203
203
  expanded.setStart(startContainer, startOffset);
204
204
  expanded.setEnd(endContainer, endOffset);
205
- selection.removeAllRanges();
206
- selection.addRange(expanded);
205
+ return expanded;
207
206
  }
208
207
 
209
208
  /** Убирает пробелы по краям выделения (например, после двойного клика по слову). */
@@ -0,0 +1,53 @@
1
+ // Набор смайликов для кнопки вставки в тулбаре. Все символы — одиночные кодпойнты (без
2
+ // ZWJ-последовательностей и модификаторов), поэтому переносятся в текст как единое целое.
3
+ // Внимание: в UTF-16 каждый занимает две единицы, и getLength() (а значит и maxlength у
4
+ // хоста) считает такой символ за два.
5
+
6
+ /** Смайлики, доступные в панели вставки (порядок сохраняется в UI). */
7
+ export const EMOJIS: string[] = [
8
+ "😀", "😁", "😂", "😃", "😄", "😅", "😆", "😉", "😊", "😋", "😌", "😍", "😏", "😒", "😓", "😔",
9
+ "😖", "😘", "😚", "😜", "😝", "😞", "😠", "😡", "😢", "😣", "😤", "😥", "😨", "😩", "😪", "😫",
10
+ "😭", "😰", "😱", "😲", "😳", "😵", "😷", "😸", "😹", "😺", "😻", "😼", "😽", "😾", "😿", "🙀",
11
+ "🙅", "🙆", "🙇", "🙈", "🙉", "🙊", "🙋", "🙌", "🙍", "🙎", "🙏", "🚀", "🚃", "🚄", "🚅", "🚇",
12
+ "🚉", "🚌", "🚏", "🚑", "🚒", "🚓", "🚕", "🚗", "🚙", "🚚", "🚢", "🚤", "🚥", "🚧", "🚨", "🚩",
13
+ "🚪", "🚫", "🚬", "🚭", "🚲", "🚶", "🚹", "🚺", "🚻", "🚼", "🚽", "🚾", "🛀", "🅰", "🅱", "🅾",
14
+ "🅿", "🆎", "🆑", "🆒", "🆓", "🆔", "🆕", "🆖", "🆗", "🆘", "🆙", "🆚", "🈁", "🈂", "🈚", "🈯",
15
+ "🈲", "🈳", "🈴", "🈵", "🈶", "🈷", "🈸", "🈹", "🈺", "🉐", "🉑", "🀄", "🃏", "🌀", "🌁", "🌂",
16
+ "🌃", "🌄", "🌅", "🌆", "🌇", "🌈", "🌉", "🌊", "🌋", "🌌", "🌏", "🌑", "🌓", "🌔", "🌕", "🌙",
17
+ "🌛", "🌟", "🌠", "🌰", "🌱", "🌴", "🌵", "🌷", "🌸", "🌹", "🌺", "🌻", "🌼", "🌽", "🌾", "🌿",
18
+ "🍀", "🍁", "🍂", "🍃", "🍄", "🍅", "🍆", "🍇", "🍈", "🍉", "🍊", "🍌", "🍍", "🍎", "🍏", "🍑",
19
+ "🍒", "🍓", "🍔", "🍕", "🍖", "🍗", "🍘", "🍙", "🍚", "🍛", "🍜", "🍝", "🍞", "🍟", "🍠", "🍡",
20
+ "🍢", "🍣", "🍤", "🍥", "🍦", "🍧", "🍨", "🍩", "🍪", "🍫", "🍬", "🍭", "🍮", "🍯", "🍰", "🍱",
21
+ "🍲", "🍳", "🍴", "🍵", "🍶", "🍷", "🍸", "🍹", "🍺", "🍻", "🎀", "🎁", "🎂", "🎃", "🎄", "🎅",
22
+ "🎆", "🎇", "🎈", "🎉", "🎊", "🎋", "🎌", "🎍", "🎎", "🎏", "🎐", "🎑", "🎒", "🎓", "🎠", "🎡",
23
+ "🎢", "🎣", "🎤", "🎥", "🎦", "🎧", "🎨", "🎩", "🎪", "🎫", "🎬", "🎭", "🎮", "🎯", "🎰", "🎱",
24
+ "🎲", "🎳", "🎴", "🎵", "🎶", "🎷", "🎸", "🎹", "🎺", "🎻", "🎼", "🎽", "🎾", "🎿", "🏀", "🏁",
25
+ "🏂", "🏃", "🏄", "🏆", "🏈", "🏊", "🏠", "🏡", "🏢", "🏣", "🏥", "🏦", "🏧", "🏨", "🏩", "🏪",
26
+ "🏫", "🏬", "🏭", "🏮", "🏯", "🏰", "🐌", "🐍", "🐎", "🐑", "🐒", "🐔", "🐗", "🐘", "🐙", "🐚",
27
+ "🐛", "🐜", "🐝", "🐞", "🐟", "🐠", "🐡", "🐢", "🐣", "🐤", "🐥", "🐦", "🐧", "🐨", "🐩", "🐫",
28
+ "🐬", "🐭", "🐮", "🐯", "🐰", "🐱", "🐲", "🐳", "🐴", "🐵", "🐶", "🐷", "🐸", "🐹", "🐺", "🐻",
29
+ "🐼", "🐽", "🐾", "👀", "👂", "👃", "👄", "👅", "👆", "👇", "👈", "👉", "👊", "👋", "👌", "👍",
30
+ "👎", "👏", "👐", "👑", "👒", "👓", "👔", "👕", "👖", "👗", "👘", "👙", "👚", "👛", "👜", "👝",
31
+ "👞", "👟", "👠", "👡", "👢", "👣", "👤", "👦", "👧", "👨", "👩", "👪", "👫", "👮", "👯", "👰",
32
+ "👱", "👲", "👳", "👴", "👵", "👶", "👷", "👸", "👹", "👺", "👻", "👼", "👽", "👾", "👿", "💀",
33
+ "💁", "💂", "💃", "💄", "💅", "💆", "💇", "💈", "💉", "💊", "💋", "💌", "💍", "💎", "💏", "💐",
34
+ "💑", "💒", "💓", "💔", "💕", "💖", "💗", "💘", "💙", "💚", "💛", "💜", "💝", "💞", "💟", "💠",
35
+ "💡", "💢", "💣", "💤", "💥", "💦", "💧", "💨", "💩", "💪", "💫", "💬", "💮", "💯", "💰", "💱",
36
+ "💲", "💳", "💴", "💵", "💸", "💹", "💺", "💻", "💼", "💽", "💾", "💿", "📀", "📁", "📂", "📃",
37
+ "📄", "📅", "📆", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "📏", "📐", "📑", "📒", "📓",
38
+ "📔", "📕", "📖", "📗", "📘", "📙", "📚", "📛", "📜", "📝", "📞", "📟", "📠", "📡", "📢", "📣",
39
+ "📤", "📥", "📦", "📧", "📨", "📩", "📪", "📫", "📮", "📰", "📱", "📲", "📳", "📴", "📶", "📷",
40
+ "📹", "📺", "📻", "📼", "🔃", "🔊", "🔋", "🔌", "🔍", "🔎", "🔏", "🔐", "🔑", "🔒", "🔓", "🔔",
41
+ "🔖", "🔗", "🔘", "🔙", "🔚", "🔛", "🔜", "🔝", "🔞", "🔟", "🔠", "🔡", "🔢", "🔣", "🔤", "🔥",
42
+ "🔦", "🔧", "🔨", "🔩", "🔪", "🔫", "🔮", "🔯", "🔰", "🔱", "🔲", "🔳", "🔴", "🔵", "🔶", "🔷",
43
+ "🔸", "🔹", "🔺", "🔻", "🔼", "🔽", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙",
44
+ "🕚", "🕛", "🗻", "🗼", "🗽", "🗾", "🗿", "😇", "😈", "😎", "😐", "😑", "😕", "😗", "😙", "😛",
45
+ "😟", "😦", "😧", "😬", "😮", "😯", "😴", "😶", "🚁", "🚂", "🚆", "🚈", "🚊", "🚍", "🚎", "🚐",
46
+ "🚔", "🚖", "🚘", "🚛", "🚜", "🚝", "🚞", "🚟", "🚠", "🚡", "🚣", "🚦", "🚮", "🚯", "🚰", "🚱",
47
+ "🚳", "🚴", "🚵", "🚷", "🚸", "🚿", "🛁", "🛂", "🛃", "🛄", "🛅", "🌍", "🌎", "🌐", "🌒", "🌖",
48
+ "🌗", "🌘", "🌚", "🌜", "🌝", "🌞", "🌲", "🌳", "🍋", "🍐", "🍼", "🏇", "🏉", "🏤", "🐀", "🐁",
49
+ "🐂", "🐃", "🐄", "🐅", "🐆", "🐇", "🐈", "🐉", "🐊", "🐋", "🐏", "🐐", "🐓", "🐕", "🐖", "🐪",
50
+ "👥", "👬", "👭", "💭", "💶", "💷", "📬", "📭", "📯", "📵", "🔀", "🔁", "🔂", "🔄", "🔅", "🔆",
51
+ "🔇", "🔉", "🔕", "🔬", "🔭", "🕜", "🕝", "🕞", "🕟", "🕠", "🕡", "🕢", "🕣", "🕤", "🕥", "🕦",
52
+ "🕧",
53
+ ];
@@ -3,11 +3,23 @@
3
3
  export type FormatTool = "bold" | "italic" | "strike" | "underline";
4
4
  export type FormatStorage = "html" | "markdown";
5
5
 
6
+ /**
7
+ * Что делает Enter в многострочном режиме.
8
+ *
9
+ * `block` — новый абзац (`<p>`, в markdown `\n\n`); модификатор даёт мягкий перенос.
10
+ * `break` — мягкий перенос (`<br>`, в markdown `\n`), как в мессенджерах: абзац там набирается
11
+ * двумя переносами. Без этого каждый Enter уезжал бы в хранилище пустой строкой.
12
+ */
13
+ export type ParagraphMode = "block" | "break";
14
+
15
+ /** Действие редактора (не формат): вставка смайлика, очистка форматирования, отмена и повтор. */
16
+ export type EditorAction = "emoji" | "erase" | "undo" | "redo";
17
+
6
18
  export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline"];
7
19
 
20
+ export const ALL_EDITOR_ACTIONS: EditorAction[] = ["emoji", "erase", "undo", "redo"];
21
+
8
22
  interface FormatToolDef {
9
- /** Имя команды (атрибут command у кнопки и registerCommand). */
10
- command: string;
11
23
  /** Канонический тег при оборачивании и сериализации. */
12
24
  tag: string;
13
25
  /** Теги, распознаваемые при разборе входного HTML. */
@@ -22,7 +34,6 @@ interface FormatToolDef {
22
34
 
23
35
  export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
24
36
  bold: {
25
- command: "format-bold",
26
37
  tag: "b",
27
38
  matchTags: ["B", "STRONG"],
28
39
  md: "**",
@@ -30,31 +41,40 @@ export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
30
41
  title: "Жирный",
31
42
  },
32
43
  italic: {
33
- command: "format-italic",
34
44
  tag: "i",
35
45
  matchTags: ["I", "EM"],
36
- md: "*",
46
+ md: "_",
37
47
  hotkey: "i",
38
48
  title: "Курсив",
39
49
  },
40
50
  strike: {
41
- command: "format-strike",
42
51
  tag: "s",
43
52
  matchTags: ["S", "STRIKE", "DEL"],
44
- md: "~~",
53
+ md: "~",
45
54
  hotkey: "",
46
55
  title: "Зачёркнутый",
47
56
  },
48
57
  underline: {
49
- command: "format-underline",
50
58
  tag: "u",
51
59
  matchTags: ["U", "INS"],
52
- md: "++",
60
+ md: "__",
53
61
  hotkey: "u",
54
62
  title: "Подчёркнутый",
55
63
  },
56
64
  };
57
65
 
66
+ interface EditorActionDef {
67
+ /** Подсказка на кнопке. */
68
+ title: string;
69
+ }
70
+
71
+ export const EDITOR_ACTIONS: Record<EditorAction, EditorActionDef> = {
72
+ emoji: { title: "Вставить смайлик" },
73
+ erase: { title: "Очистить форматирование" },
74
+ undo: { title: "Отменить (Ctrl+Z)" },
75
+ redo: { title: "Повторить (Ctrl+Y)" },
76
+ };
77
+
58
78
  /** Markdown-маркер для каждого инструмента форматирования. */
59
79
  export type FormatMarkers = Record<FormatTool, string>;
60
80
 
@@ -75,15 +95,22 @@ export const HOTKEY_TOOLS: Record<string, FormatTool> = (() => {
75
95
  return map;
76
96
  })();
77
97
 
78
- /** Разбирает значение атрибута data-format-tools, оставляя только известные инструменты. */
79
- export function parseFormatTools(value: string | null): FormatTool[] {
80
- if (value === null) return ALL_FORMAT_TOOLS.slice();
98
+ // Разбор атрибута-списка через пробел: оставляет только известные значения,
99
+ // убирает дубли и восстанавливает порядок объявления.
100
+ function parseList<T extends string>(value: string, known: T[]): T[] {
101
+ const parsed = value.split(/\s+/).filter(Boolean);
102
+ return known.filter((item) => parsed.includes(item));
103
+ }
81
104
 
82
- const tools = value
83
- .split(/\s+/)
84
- .filter(Boolean)
85
- .filter((t): t is FormatTool => (ALL_FORMAT_TOOLS as string[]).includes(t));
105
+ /** Разбирает значение атрибута data-format-tools; отсутствие атрибута — все инструменты. */
106
+ export function parseFormatTools(value: string | null): FormatTool[] {
107
+ return value === null ? ALL_FORMAT_TOOLS.slice() : parseList(value, ALL_FORMAT_TOOLS);
108
+ }
86
109
 
87
- // убираем дубли, сохраняя порядок объявления
88
- return ALL_FORMAT_TOOLS.filter((t) => tools.includes(t));
110
+ /**
111
+ * Разбирает значение атрибута data-editor-actions. В отличие от инструментов,
112
+ * действия подключаются явно: отсутствие атрибута — пустой набор.
113
+ */
114
+ export function parseEditorActions(value: string | null): EditorAction[] {
115
+ return value === null ? [] : parseList(value, ALL_EDITOR_ACTIONS);
89
116
  }
package/source/format.ts CHANGED
@@ -5,15 +5,31 @@
5
5
  // paragraphs — нормализация пробелов и приведение к абзацам <p>
6
6
 
7
7
  export {
8
+ ALL_EDITOR_ACTIONS,
8
9
  ALL_FORMAT_TOOLS,
10
+ EDITOR_ACTIONS,
9
11
  FORMAT_TOOLS,
10
12
  HOTKEY_TOOLS,
11
13
  defaultFormatMarkers,
14
+ parseEditorActions,
12
15
  parseFormatTools,
16
+ type EditorAction,
13
17
  type FormatMarkers,
14
18
  type FormatStorage,
19
+ type ParagraphMode,
15
20
  type FormatTool,
16
21
  } from "./format-config";
17
22
  export { serialize, deserialize } from "./serialize";
18
- export { selectionCharBounds, restoreSelection, toggleFormat, insertFormattedText, isFormatActive } from "./selection";
23
+ export {
24
+ selectionCharBounds,
25
+ restoreSelection,
26
+ mapCharOffset,
27
+ toggleFormat,
28
+ clearFormat,
29
+ clearAllFormat,
30
+ hasFormatting,
31
+ hasAnyFormatting,
32
+ insertFormattedText,
33
+ isFormatActive,
34
+ } from "./selection";
19
35
  export { normalizeWhitespace, normalizeParagraphs, ensureParagraphs } from "./paragraphs";
package/source/history.ts CHANGED
@@ -30,6 +30,16 @@ export class EditorHistory {
30
30
  this.__root = root;
31
31
  }
32
32
 
33
+ /** Есть ли что отменять (для disabled-состояния кнопки). */
34
+ get canUndo(): boolean {
35
+ return this.__undo.length > 0;
36
+ }
37
+
38
+ /** Есть ли что повторять. */
39
+ get canRedo(): boolean {
40
+ return this.__redo.length > 0;
41
+ }
42
+
33
43
  private __snapshot(): Snapshot {
34
44
  const sel = window.getSelection();
35
45
  let start = 0;
package/source/index.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  export { default } from "./richeditor";
2
2
  export * from "./richeditor";
3
+ export { EMOJIS } from "./emoji";
3
4
  export {
5
+ ALL_EDITOR_ACTIONS,
4
6
  ALL_FORMAT_TOOLS,
7
+ EDITOR_ACTIONS,
5
8
  FORMAT_TOOLS,
9
+ parseEditorActions,
6
10
  parseFormatTools,
7
11
  defaultFormatMarkers,
8
12
  normalizeWhitespace,
13
+ type EditorAction,
9
14
  type FormatTool,
10
15
  type FormatStorage,
16
+ type ParagraphMode,
11
17
  type FormatMarkers,
12
18
  } from "./format";
@@ -12,6 +12,13 @@ export function normalizeWhitespace(root: HTMLElement) {
12
12
  type Item = { kind: "text"; node: Text } | { kind: "break" };
13
13
  const items: Item[] = [];
14
14
 
15
+ // Присваивание Text.data — это «replace data» по всему узлу, а оно схлопывает границы
16
+ // живых Range внутри узла в его начало: каретка уезжает в начало строки. Нормализация
17
+ // чаще всего ничего не меняет (вызывается на blur), поэтому пишем только при отличии.
18
+ const setData = (node: Text, text: string) => {
19
+ if (node.data !== text) node.data = text;
20
+ };
21
+
15
22
  const flatten = (node: Node) => {
16
23
  for (const child of Array.from(node.childNodes)) {
17
24
  if (child.nodeType === Node.TEXT_NODE) {
@@ -38,7 +45,7 @@ export function normalizeWhitespace(root: HTMLElement) {
38
45
  for (const item of items) {
39
46
  if (item.kind === "break") {
40
47
  if (pendingSpaceNode) {
41
- pendingSpaceNode.data = pendingSpaceNode.data.replace(/ $/, "");
48
+ setData(pendingSpaceNode, pendingSpaceNode.data.replace(/ $/, ""));
42
49
  pendingSpaceNode = null;
43
50
  }
44
51
  atLineStart = true;
@@ -49,14 +56,14 @@ export function normalizeWhitespace(root: HTMLElement) {
49
56
  if (atLineStart) text = text.replace(/^ /, ""); // пробел в начале строки
50
57
  if (pendingSpaceNode && text.startsWith(" ")) text = text.slice(1); // двойной пробел на границе узлов
51
58
 
52
- item.node.data = text;
59
+ setData(item.node, text);
53
60
  if (text.length === 0) continue;
54
61
 
55
62
  atLineStart = false;
56
63
  pendingSpaceNode = text.endsWith(" ") ? item.node : null;
57
64
  }
58
65
 
59
- if (pendingSpaceNode) pendingSpaceNode.data = pendingSpaceNode.data.replace(/ $/, ""); // хвост последней строки
66
+ if (pendingSpaceNode) setData(pendingSpaceNode, pendingSpaceNode.data.replace(/ $/, "")); // хвост последней строки
60
67
 
61
68
  cleanupFormatting(root); // убрать опустевшие теги, склеить узлы
62
69
  }
@@ -98,7 +98,16 @@
98
98
  margin-bottom: 6px;
99
99
  }
100
100
 
101
- & .format-button {
101
+ // разделитель между инструментами форматирования и действиями
102
+ & .split {
103
+ width: 1px;
104
+ align-self: stretch;
105
+ margin: 2px 3px;
106
+ background-color: var(--input-border-color, #aaa);
107
+ }
108
+
109
+ & .format-button,
110
+ & .action-button {
102
111
  width: calc(var(--input-height, 46px) - 12px);
103
112
  height: calc(var(--input-height, 46px) - 12px);
104
113
  border-radius: calc(var(--input-border-radius, 0) - 2px);
@@ -123,5 +132,47 @@
123
132
  &.active {
124
133
  background-color: var(--hover--input-toolbar-button-fill, rgba(0, 0, 0, 0.12));
125
134
  }
135
+
136
+ &[disabled] {
137
+ cursor: default;
138
+ opacity: 0.4;
139
+ // хит-тест проваливается на сам тулбар: браузер не диспатчит события на disabled-кнопку,
140
+ // и без этого mousedown не гасится — редактор теряет фокус, а панель прячется по blur
141
+ pointer-events: none;
142
+
143
+ &:hover {
144
+ background: var(--input-toolbar-button-fill, transparent);
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ // панель вставки смайликов — попап под кнопкой тулбара (открытием/закрытием управляет
151
+ // PopupManager из @brandup/ui-kit; базовые .ui-popup-стили приходят оттуда же).
152
+ .ui-richeditor-emoji {
153
+ top: 100%;
154
+ left: 0;
155
+ margin-top: 4px;
156
+ width: 296px;
157
+ max-height: 220px;
158
+ overflow-y: auto;
159
+ padding: 4px;
160
+ display: flex;
161
+ flex-flow: row wrap;
162
+
163
+ & .emoji {
164
+ width: 32px;
165
+ height: 32px;
166
+ padding: 0;
167
+ border: 0;
168
+ background: transparent;
169
+ cursor: pointer;
170
+ font-size: 20px;
171
+ line-height: 1;
172
+ border-radius: calc(var(--input-border-radius, 0) - 2px);
173
+
174
+ &:hover {
175
+ background-color: var(--hover--input-toolbar-button-fill, rgba(0, 0, 0, 0.06));
176
+ }
126
177
  }
127
178
  }