@brandup/ui-richeditor 1.0.36 → 1.0.39
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 +84 -12
- package/package.json +3 -2
- package/source/editing.ts +91 -28
- package/source/emoji.ts +53 -0
- package/source/format-config.ts +45 -18
- package/source/format.ts +22 -2
- package/source/history.ts +28 -9
- package/source/index.ts +9 -0
- package/source/paragraphs.ts +33 -6
- package/source/richeditor.less +56 -1
- package/source/richeditor.ts +396 -109
- package/source/selection.ts +297 -81
- package/source/serialize.ts +296 -201
- package/source/toolbar.ts +251 -17
- 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,24 +62,79 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
60
62
|
## API
|
|
61
63
|
|
|
62
64
|
| Член | Описание |
|
|
63
|
-
|
|
65
|
+
| --- | --- |
|
|
64
66
|
| `editable` | Редактируемый элемент |
|
|
65
|
-
| `format`, `formatTools`, `formatStorage`, `formatMarkers`, `multiline` | Параметры экземпляра |
|
|
66
|
-
| `getValue(): string` | Сериализованное значение (по `storage`) |
|
|
67
|
+
| `format`, `formatTools`, `editorActions`, `formatStorage`, `formatMarkers`, `multiline` | Параметры экземпляра |
|
|
68
|
+
| `getValue(): string` | Сериализованное значение (по `storage`) — считается по DOM, всегда актуально |
|
|
67
69
|
| `setValue(value: string): void` | Установить значение (нормализует, генерирует `change`) |
|
|
70
|
+
| `flushChange(): void` | Доставить отложенное `change` немедленно (см. ниже) |
|
|
68
71
|
| `getLength(): number` | Длина текста (без учёта переводов строк) |
|
|
69
72
|
| `focus(): void` | Установить фокус |
|
|
73
|
+
| `applyFormat(tool): void` | Переключить формат на выделении (слово целиком) |
|
|
74
|
+
| `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
|
|
75
|
+
| `activeTools(): ReadonlySet<FormatTool>` | Активные форматы всех инструментов сразу (панель обновляется на каждое движение каретки, поштучный опрос обходил бы содержимое на каждую кнопку) |
|
|
76
|
+
| `clearFormat(): void` | Снять всё форматирование с выделения (без выделения — со слова под кареткой) |
|
|
77
|
+
| `clearAllFormat(): void` | Снять всё форматирование со всего содержимого |
|
|
78
|
+
| `undo(): void`, `redo(): void` | Отмена и повтор |
|
|
79
|
+
| `canUndo`, `canRedo` | Доступность отмены/повтора |
|
|
80
|
+
| `applyAction(action): void` | Выполнить действие панели (`erase`/`undo`/`redo`) |
|
|
81
|
+
| `isActionEnabled(action): boolean` | Доступно ли действие сейчас |
|
|
82
|
+
| `insertText(text): void` | Вставить текст в каретку (или вместо выделения) с учётом режима набора |
|
|
83
|
+
| `selection: Selection \| null` | Выделение, если оно внутри редактора (иначе `null`) — единая точка доступа для хоста |
|
|
84
|
+
| `selectNode(node): void` | Выделить узел внутри редактора: следующая вставка заменит его целиком |
|
|
70
85
|
| `onChange(handler)` | Подписка на событие `richeditor-change` |
|
|
71
86
|
| `destroy(): void` | Разворачивает элемент обратно и освобождает ресурсы |
|
|
72
87
|
|
|
88
|
+
## Событие изменения
|
|
89
|
+
|
|
90
|
+
`getValue()` считает значение по DOM и точен всегда. А вот **уведомление** `richeditor-change` при печати доставляется с задержкой: сериализация — самая дорогая операция редактора (обход всего содержимого), а печать даёт `input` на каждый символ.
|
|
91
|
+
|
|
92
|
+
Это троттлинг, а не debounce: при непрерывном наборе событие приходит каждые ~150 мс, а не откладывается до паузы. Откладывается только печать — вставка, форматирование, отмена/повтор, `setValue` и Enter сообщаются сразу.
|
|
93
|
+
|
|
94
|
+
Отложенное доставляется немедленно:
|
|
95
|
+
|
|
96
|
+
- при потере фокуса и при `destroy()` — редактором самостоятельно;
|
|
97
|
+
- по вызову `flushChange()` — его должен звать хост перед тем, как значение прочитают снаружи.
|
|
98
|
+
|
|
99
|
+
Хосты пакета (`@brandup/ui-textbox`, `@brandup/ui-messageeditor`) держат в поле формы копию значения и сбрасывают отложенное перед каждым чтением значения снаружи: отправка формы, `validate()`, `getValue()`. На `submit` синхронизация идёт в фазе перехвата на документе — то есть раньше любого обработчика самой формы и независимо от того, включена ли её валидация (`novalidate`). Так что в отправляемых данных значение всегда актуально; свой хост обязан делать то же самое (в `@brandup/ui-input` для этого есть `__syncValue()`).
|
|
100
|
+
|
|
101
|
+
Не покрыт единственный случай: `new FormData(form)` **вне** отправки формы, в пределах окна троттлинга после ввода. Список полей там собирается до того, как о нём можно узнать, а точечно заменить свою запись `FormData` не позволяет. Читайте в таком коде `getValue()` хоста — он синхронизирует сам.
|
|
102
|
+
|
|
73
103
|
## Поведение форматирования
|
|
74
104
|
|
|
75
105
|
- Формат — переключатель (toggle): повторное применение снимает его.
|
|
76
106
|
- Применяется к слову целиком: курсор внутри слова или выделение его части → формат охватывает всё слово; исходное выделение/каретка сохраняются.
|
|
77
107
|
- **Режим набора**: на пустом месте (между пробелами / в пустом поле) кнопка/хоткей включают «ожидающий» формат — он применится к следующему введённому тексту. Сбрасывается при перемещении каретки, клике или потере фокуса.
|
|
78
108
|
- Хоткеи `Ctrl/Cmd+B/I/U`. Зачёркивание — только кнопкой.
|
|
79
|
-
- **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100
|
|
80
|
-
- При потере фокуса и после `setValue` пробелы нормализуются (схлопывание повторов + обрезка краёв строк).
|
|
109
|
+
- **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов, но не более ~512 КБ снимков суммарно (снимок — это всё содержимое редактора, поэтому на длинном тексте старые шаги вытесняются раньше).
|
|
110
|
+
- При потере фокуса и после `setValue` пробелы нормализуются (схлопывание повторов + обрезка краёв строк). Неразрывный пробел (`U+00A0`) считается обычным: браузер сам подставляет его в `contenteditable` вместо пробела, который иначе схлопнулся бы при отображении, и в значение он не попадает.
|
|
111
|
+
|
|
112
|
+
## Очистка форматирования и действия панели
|
|
113
|
+
|
|
114
|
+
`clearFormat()` снимает **все** форматы сразу — с выделения, а без выделения со слова под кареткой (та же логика, что и у применения формата). Режим набора при этом сбрасывается. Распознаются и теги-синонимы (`STRONG`/`EM`/`DEL`/`INS`), которые могли прийти из вставки или `setValue`. `clearAllFormat()` чистит всё содержимое и выделения не требует.
|
|
115
|
+
|
|
116
|
+
Обе операции попадают в историю (откатываются одним `Ctrl+Z`) и не создают пустой шаг отмены, если очищать нечего.
|
|
117
|
+
|
|
118
|
+
Кроме кнопок форматирования панель может показывать кнопки действий — они подключаются **явно** через `actions`:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"] });
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Действие | Кнопка | Что делает | Когда недоступна |
|
|
125
|
+
| --- | --- | --- | --- |
|
|
126
|
+
| `emoji` | Вставить смайлик | открывает панель вставки | в режиме `readonly` |
|
|
127
|
+
| `erase` | Очистить форматирование | `clearFormat()` | нет форматирования на выделении |
|
|
128
|
+
| `undo` | Отменить | `undo()` | история пуста |
|
|
129
|
+
| `redo` | Повторить | `redo()` | нечего повторять |
|
|
130
|
+
|
|
131
|
+
Кнопки действий (`.action-button`) отделены от кнопок форматирования (`.format-button`) разделителем `.split` и получают атрибут `disabled`, когда действие недоступно. Панель показывается и в том случае, если инструментов форматирования нет, а действия заданы.
|
|
132
|
+
|
|
133
|
+
### Панель смайликов
|
|
134
|
+
|
|
135
|
+
Кнопка `emoji` открывает под панелью попап `.ui-richeditor-emoji` со списком символов (`EMOJIS` — экспортируется пакетом). Выбранный символ вставляется через `insertText()`, то есть в текущую каретку и с учётом ожидающих форматов режима набора; попап после выбора закрывается.
|
|
136
|
+
|
|
137
|
+
Открытием и закрытием управляет `PopupManager` из [`@brandup/ui-kit`](../brandup-ui-kit) — оттуда же приходят базовые стили `.ui-popup`. Ни кнопка, ни попап не забирают фокус у редактора (`mousedown` гасится), поэтому каретка и выделение сохраняются. Список кнопок собирается лениво, при первом открытии.
|
|
81
138
|
|
|
82
139
|
## Многострочный режим: абзацы и переносы
|
|
83
140
|
|
|
@@ -87,32 +144,47 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
87
144
|
- **Shift+Enter** или **Ctrl/Cmd+Enter** → мягкий перенос (`<br>`) внутри абзаца;
|
|
88
145
|
- блуждающий текст и `<div>` нормализуются в `<p>` при вводе.
|
|
89
146
|
|
|
147
|
+
Опция `paragraph: "break"` меняет это местами — Enter даёт мягкий перенос, а абзац набирается модификатором либо просто двумя переносами. Так устроены мессенджеры, и это важно при `storage: "markdown"`: в режиме по умолчанию каждый Enter уходит в значение пустой строкой (`\n\n`), а в `break` — одним переносом (`\n`).
|
|
148
|
+
|
|
149
|
+
В этом режиме абзацных блоков в содержимом нет вовсе: значение загружается плоским текстом, где каждый `\n` становится `<br>` внутри единственного `<p>`. Иначе два переноса рисовались бы двумя абзацами, а у хоста без отступов между ними это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
|
|
150
|
+
|
|
90
151
|
Хвостовой перенос абзаца отбрасывается (это `<br>`-заполнитель); пустая строка делается отдельным абзацем.
|
|
91
152
|
|
|
92
153
|
При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются.
|
|
93
154
|
|
|
94
|
-
## Вставка
|
|
155
|
+
## Вставка текста
|
|
95
156
|
|
|
96
157
|
При включённом форматировании вставка (`paste`) сохраняет форматирование из буфера обмена (`text/html`):
|
|
97
158
|
|
|
98
159
|
- разметка санитизируется до включённых инструментов (синонимы `STRONG/EM/DEL/INS` → канонические `b/i/s/u`, всё прочее — `span`, стили, классы, `<style>`/`<script>` — отбрасывается, текст сохраняется);
|
|
99
160
|
- **multiline** сохраняет абзацы `<p>` и мягкие переносы `<br>`, разбивая текущий абзац по каретке; **single-line** — инлайн, абзацы/переносы становятся пробелами;
|
|
100
|
-
- если `text/html` нет — простая текстовая вставка (как раньше);
|
|
101
161
|
- хук `filterPaste` остаётся в силе: вернул `null` — вставка отклоняется; изменил текст (обрезка по длине, фильтр по типу) — форматирование не сохраняется, вставляется очищенный текст.
|
|
102
162
|
|
|
103
|
-
|
|
163
|
+
Если `text/html` нет (или форматирование выключено), вставляется простой текст — по той же модели абзацев:
|
|
164
|
+
|
|
165
|
+
- **multiline**, режим `block` — пустая строка разделяет абзацы `<p>`, одиночный перенос остаётся мягким `<br>`;
|
|
166
|
+
- **multiline**, режим `break` — абзацы не создаются, все переносы мягкие;
|
|
167
|
+
- **single-line** — строки склеиваются пробелами.
|
|
168
|
+
|
|
169
|
+
Каретка в обоих случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
|
|
104
170
|
|
|
105
171
|
## Формат хранения
|
|
106
172
|
|
|
107
173
|
| `storage` | Хранение | Абзац / мягкий перенос | Маркеры форматирования |
|
|
108
|
-
|
|
174
|
+
| --- | --- | --- | --- |
|
|
109
175
|
| `html` | Санитизированный HTML | `<p>…</p>` / `<br>` | `<b>`, `<i>`, `<s>`, `<u>` |
|
|
110
|
-
| `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`,
|
|
176
|
+
| `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`, `_курсив_`, `~зачёркнутый~`, `__подчёркнутый__` |
|
|
111
177
|
|
|
112
178
|
Без форматирования (plain) значение хранится как markdown без инструментов: абзацы `\n\n`, мягкий перенос `\n`.
|
|
113
179
|
|
|
114
180
|
Маркеры markdown настраиваются через `markers`. При разборе применяются по убыванию длины, поэтому длинный маркер срабатывает раньше короткого-префикса. Глубоко вложенные комбинации гарантированно сохраняются только в режиме `html`.
|
|
115
181
|
|
|
182
|
+
Вложенные пары разбираются (`_а **б** в_`), а вот **пересекающиеся** остаются текстом: в `**а _б** в_` внутренняя пара пересекает внешнюю, разметкой такое невыразимо, и короткий маркер отбрасывается — как и в мессенджерах.
|
|
183
|
+
|
|
184
|
+
Разметка распознаётся по правилам мессенджеров: маркер стоит на **границе слова**, содержимое не начинается и не заканчивается пробелом и не пересекает перенос строки. Поэтому `5**4 = 20`, `2 ** 2 ** 2` и `файл_имя_файла.txt` остаются обычным текстом — иначе редактор показывал бы форматирование там, где получатель увидит исходные символы. Форматирование применяется к словам целиком, так что собственный вывод редактора всегда разбирается обратно.
|
|
185
|
+
|
|
186
|
+
Краевые пробелы при сериализации выносятся за маркеры (`<b> слово </b>дальше` → `**слово** дальше`), иначе разметка не сработала бы ни у нас, ни у мессенджера. А вот формат, приклеенный к соседнему слову — такое приходит только со вставкой внешнего HTML (`супер<b>бонус</b>`), — маркерами невыразим: значение сохранится как `супер**бонус**` и разметкой уже не станет. Это осознанное решение: текст остаётся ровно тем, что набрал пользователь, а не молча теряет форматирование.
|
|
187
|
+
|
|
116
188
|
## CSS
|
|
117
189
|
|
|
118
190
|
Подключается `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.39",
|
|
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.39"
|
|
35
36
|
},
|
|
36
37
|
"files": [
|
|
37
38
|
"source",
|
package/source/editing.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
// Низкоуровневые операции редактирования на чистом Selection/Range: абзацы, мягкие переносы,
|
|
2
|
-
// каретка, расширение/обрезка
|
|
3
|
-
// вызывающий сам решает, когда записывать undo-шаг.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
);
|
|
10
|
-
}
|
|
2
|
+
// каретка, расширение/обрезка выделения, разбор вставляемого содержимого. Без состояния
|
|
3
|
+
// редактора и без истории — вызывающий сам решает, когда записывать undo-шаг.
|
|
4
|
+
|
|
5
|
+
import { deserialize } from "./serialize";
|
|
6
|
+
import { isBlock } from "./paragraphs";
|
|
7
|
+
import { documentSelection, innerSelection } from "./selection";
|
|
8
|
+
import type { FormatMarkers, FormatTool } from "./format-config";
|
|
11
9
|
|
|
12
10
|
function emptyParagraph(): HTMLParagraphElement {
|
|
13
11
|
const p = document.createElement("p");
|
|
@@ -25,7 +23,7 @@ function caretToStart(node: Node) {
|
|
|
25
23
|
const range = document.createRange();
|
|
26
24
|
range.setStart(node, 0);
|
|
27
25
|
range.collapse(true);
|
|
28
|
-
const selection =
|
|
26
|
+
const selection = documentSelection(node);
|
|
29
27
|
if (selection) {
|
|
30
28
|
selection.removeAllRanges();
|
|
31
29
|
selection.addRange(range);
|
|
@@ -40,7 +38,7 @@ export function caretToEnd(editable: HTMLElement, multiline: boolean) {
|
|
|
40
38
|
const last = multiline ? editable.lastElementChild : null;
|
|
41
39
|
range.selectNodeContents(last && isBlock(last) ? last : editable);
|
|
42
40
|
range.collapse(false);
|
|
43
|
-
const sel =
|
|
41
|
+
const sel = documentSelection(editable);
|
|
44
42
|
if (sel) {
|
|
45
43
|
sel.removeAllRanges();
|
|
46
44
|
sel.addRange(range);
|
|
@@ -50,13 +48,13 @@ export function caretToEnd(editable: HTMLElement, multiline: boolean) {
|
|
|
50
48
|
/** Фокус и выделение всего содержимого (например, readonly-режим). */
|
|
51
49
|
export function selectAllContent(editable: HTMLElement) {
|
|
52
50
|
editable.focus();
|
|
53
|
-
|
|
51
|
+
documentSelection(editable)?.selectAllChildren(editable);
|
|
54
52
|
}
|
|
55
53
|
|
|
56
54
|
/** Enter в multiline: разбить текущий абзац по каретке на два <p>. */
|
|
57
55
|
export function insertParagraph(editable: HTMLElement) {
|
|
58
|
-
const selection =
|
|
59
|
-
if (!selection
|
|
56
|
+
const selection = innerSelection(editable);
|
|
57
|
+
if (!selection) return;
|
|
60
58
|
|
|
61
59
|
const range = selection.getRangeAt(0);
|
|
62
60
|
range.deleteContents();
|
|
@@ -101,8 +99,8 @@ export function insertParagraph(editable: HTMLElement) {
|
|
|
101
99
|
|
|
102
100
|
/** Shift/Ctrl+Enter в multiline: вставить мягкий перенос <br>. */
|
|
103
101
|
export function insertSoftBreak(editable: HTMLElement) {
|
|
104
|
-
const selection =
|
|
105
|
-
if (!selection
|
|
102
|
+
const selection = innerSelection(editable);
|
|
103
|
+
if (!selection) return;
|
|
106
104
|
|
|
107
105
|
const range = selection.getRangeAt(0);
|
|
108
106
|
range.deleteContents();
|
|
@@ -168,21 +166,88 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
|
|
|
168
166
|
}
|
|
169
167
|
|
|
170
168
|
/** Обрезает пробелы по краям абзаца (после схлопывания) — у крайних текстовых узлов. */
|
|
171
|
-
|
|
169
|
+
function trimParagraphEdges(p: HTMLElement) {
|
|
172
170
|
const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
|
|
173
171
|
const texts: Text[] = [];
|
|
174
172
|
for (let t = walker.nextNode() as Text | null; t; t = walker.nextNode() as Text | null) texts.push(t);
|
|
175
173
|
if (!texts.length) return;
|
|
176
174
|
|
|
177
|
-
|
|
175
|
+
// неразрывный пробел режем наравне с обычным — из буфера обмена он приходит регулярно
|
|
176
|
+
texts[0].textContent = (texts[0].textContent ?? "").replace(/^[ \u00A0]/, "");
|
|
178
177
|
const last = texts[texts.length - 1];
|
|
179
|
-
last.textContent = (last.textContent ?? "").replace(/ $/, "");
|
|
178
|
+
last.textContent = (last.textContent ?? "").replace(/[ \u00A0]$/, "");
|
|
180
179
|
}
|
|
181
180
|
|
|
182
|
-
/**
|
|
183
|
-
|
|
184
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Строки вставляемого текста → абзацы `<p>` с мягкими переносами `<br>` внутри.
|
|
183
|
+
*
|
|
184
|
+
* При `blocks` абзацы разделяет пустая строка (режим `block` многострочного редактора);
|
|
185
|
+
* иначе весь текст — один абзац, а все переносы мягкие: так вставка ложится в ту же модель,
|
|
186
|
+
* которую даёт Enter, и значение после неё разбирается обратно.
|
|
187
|
+
*/
|
|
188
|
+
export function buildParagraphs(lines: string[], blocks: boolean): HTMLElement[] {
|
|
189
|
+
const groups: string[][] = [];
|
|
190
|
+
|
|
191
|
+
if (blocks) {
|
|
192
|
+
let group: string[] = [];
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
if (line !== "") group.push(line);
|
|
195
|
+
else if (group.length) {
|
|
196
|
+
groups.push(group);
|
|
197
|
+
group = [];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (group.length) groups.push(group);
|
|
201
|
+
} else if (lines.length) groups.push(lines);
|
|
202
|
+
|
|
203
|
+
return groups.map((group) => {
|
|
204
|
+
const p = document.createElement("p");
|
|
205
|
+
group.forEach((line, index) => {
|
|
206
|
+
if (index > 0) p.appendChild(document.createElement("br"));
|
|
207
|
+
p.appendChild(document.createTextNode(line));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return p;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
185
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Разбирает HTML из буфера обмена в абзацы `<p>`, оставляя только разрешённые инструменты.
|
|
216
|
+
* Пустой результат — вставлять нечего (вызывающий откатится на простой текст).
|
|
217
|
+
*/
|
|
218
|
+
export function sanitizePastedHtml(html: string, tools: FormatTool[], markers: FormatMarkers): HTMLElement[] {
|
|
219
|
+
// убираем мусорные элементы (Word/браузер: стили, скрипты, заголовок документа)
|
|
220
|
+
const source = document.createElement("template");
|
|
221
|
+
source.innerHTML = html;
|
|
222
|
+
source.content.querySelectorAll("script, style, head, meta, link, title, noscript").forEach((el) => el.remove());
|
|
223
|
+
|
|
224
|
+
// единый источник санитизации — deserialize (теги-синонимы → канонические, лишнее развёрнуто)
|
|
225
|
+
const holder = document.createElement("template");
|
|
226
|
+
holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true);
|
|
227
|
+
|
|
228
|
+
// внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
|
|
229
|
+
// иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
|
|
230
|
+
const walker = document.createTreeWalker(holder.content, NodeFilter.SHOW_TEXT);
|
|
231
|
+
for (let t = walker.nextNode(); t; t = walker.nextNode())
|
|
232
|
+
t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
|
|
233
|
+
|
|
234
|
+
const paras = Array.from(holder.content.children) as HTMLElement[];
|
|
235
|
+
for (const p of paras) trimParagraphEdges(p);
|
|
236
|
+
|
|
237
|
+
// отбрасываем пустые краевые абзацы (ведущие/хвостовые \n и <br>-обёртки из буфера),
|
|
238
|
+
// иначе перед и после вставленного текста появляются пустые строки
|
|
239
|
+
while (paras.length && (paras[0].textContent ?? "").trim() === "") paras.shift();
|
|
240
|
+
while (paras.length && (paras[paras.length - 1].textContent ?? "").trim() === "") paras.pop();
|
|
241
|
+
|
|
242
|
+
return paras;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Диапазон, расширенный до целых слов на границах (для применения формата к слову целиком).
|
|
247
|
+
* Возвращает новый Range и не трогает выделение — вызывающий сам решает, править ли по нему
|
|
248
|
+
* и когда двигать каретку.
|
|
249
|
+
*/
|
|
250
|
+
export function expandRangeToWords(editable: HTMLElement, range: Range): Range {
|
|
186
251
|
const { startContainer, endContainer } = range;
|
|
187
252
|
let startOffset = range.startOffset;
|
|
188
253
|
let endOffset = range.endOffset;
|
|
@@ -197,20 +262,18 @@ export function expandSelectionToWords(editable: HTMLElement, selection: Selecti
|
|
|
197
262
|
while (endOffset < text.length && !/\s/.test(text[endOffset])) endOffset++;
|
|
198
263
|
}
|
|
199
264
|
|
|
200
|
-
if (startOffset === range.startOffset && endOffset === range.endOffset) return;
|
|
201
|
-
|
|
202
265
|
const expanded = document.createRange();
|
|
203
266
|
expanded.setStart(startContainer, startOffset);
|
|
204
267
|
expanded.setEnd(endContainer, endOffset);
|
|
205
|
-
|
|
206
|
-
selection.addRange(expanded);
|
|
268
|
+
return expanded;
|
|
207
269
|
}
|
|
208
270
|
|
|
209
271
|
/** Убирает пробелы по краям выделения (например, после двойного клика по слову). */
|
|
210
272
|
export function trimSelectionWhitespace(editable: HTMLElement) {
|
|
211
|
-
const selection =
|
|
212
|
-
if (!selection || selection.
|
|
273
|
+
const selection = innerSelection(editable);
|
|
274
|
+
if (!selection || selection.isCollapsed) return;
|
|
213
275
|
|
|
276
|
+
// внутри редактора должно быть не только начало выделения, но и его конец
|
|
214
277
|
const range = selection.getRangeAt(0);
|
|
215
278
|
if (!editable.contains(range.startContainer) || !editable.contains(range.endContainer)) return;
|
|
216
279
|
|
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,35 @@
|
|
|
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 {
|
|
19
|
-
|
|
23
|
+
export {
|
|
24
|
+
documentSelection,
|
|
25
|
+
innerSelection,
|
|
26
|
+
preserveCaret,
|
|
27
|
+
selectionCharBounds,
|
|
28
|
+
restoreSelection,
|
|
29
|
+
mapCharOffset,
|
|
30
|
+
activeFormats,
|
|
31
|
+
toggleFormat,
|
|
32
|
+
clearFormat,
|
|
33
|
+
clearAllFormat,
|
|
34
|
+
hasFormatting,
|
|
35
|
+
hasAnyFormatting,
|
|
36
|
+
insertFormattedText,
|
|
37
|
+
isFormatActive,
|
|
38
|
+
} from "./selection";
|
|
39
|
+
export { isBlock, normalizeWhitespace, normalizeParagraphs, ensureParagraphs } from "./paragraphs";
|