@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 +52 -6
- package/package.json +3 -2
- package/source/editing.ts +7 -8
- package/source/emoji.ts +53 -0
- package/source/format-config.ts +45 -18
- package/source/format.ts +17 -1
- package/source/history.ts +10 -0
- package/source/index.ts +6 -0
- package/source/paragraphs.ts +10 -3
- package/source/richeditor.less +52 -1
- package/source/richeditor.ts +239 -32
- package/source/selection.ts +120 -30
- package/source/serialize.ts +274 -201
- package/source/toolbar.ts +134 -12
- package/svg/emoji.svg +3 -0
- package/svg/erase.svg +1 -0
- package/svg/redo.svg +1 -0
- package/svg/undo.svg +1 -0
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.
|
|
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.
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
206
|
-
selection.addRange(expanded);
|
|
205
|
+
return expanded;
|
|
207
206
|
}
|
|
208
207
|
|
|
209
208
|
/** Убирает пробелы по краям выделения (например, после двойного клика по слову). */
|
package/source/emoji.ts
ADDED
|
@@ -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
|
+
];
|
package/source/format-config.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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 {
|
|
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";
|
package/source/paragraphs.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
66
|
+
if (pendingSpaceNode) setData(pendingSpaceNode, pendingSpaceNode.data.replace(/ $/, "")); // хвост последней строки
|
|
60
67
|
|
|
61
68
|
cleanupFormatting(root); // убрать опустевшие теги, склеить узлы
|
|
62
69
|
}
|
package/source/richeditor.less
CHANGED
|
@@ -98,7 +98,16 @@
|
|
|
98
98
|
margin-bottom: 6px;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
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
|
}
|