@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 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
- Вся вставкаодин шаг истории (Ctrl+Z откатывает целиком).
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.36",
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.5"
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
- function isBlock(node: Node): boolean {
6
- return (
7
- node.nodeType === Node.ELEMENT_NODE &&
8
- ((node as Element).tagName === "P" || (node as Element).tagName === "DIV")
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 = window.getSelection();
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 = window.getSelection();
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
- window.getSelection()?.selectAllChildren(editable);
51
+ documentSelection(editable)?.selectAllChildren(editable);
54
52
  }
55
53
 
56
54
  /** Enter в multiline: разбить текущий абзац по каретке на два <p>. */
57
55
  export function insertParagraph(editable: HTMLElement) {
58
- const selection = window.getSelection();
59
- if (!selection || selection.rangeCount === 0 || !editable.contains(selection.anchorNode)) return;
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 = window.getSelection();
105
- if (!selection || selection.rangeCount === 0 || !editable.contains(selection.anchorNode)) return;
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
- export function trimParagraphEdges(p: HTMLElement) {
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
- texts[0].textContent = (texts[0].textContent ?? "").replace(/^ /, "");
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
- export function expandSelectionToWords(editable: HTMLElement, selection: Selection) {
184
- const range = selection.getRangeAt(0);
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
- selection.removeAllRanges();
206
- selection.addRange(expanded);
268
+ return expanded;
207
269
  }
208
270
 
209
271
  /** Убирает пробелы по краям выделения (например, после двойного клика по слову). */
210
272
  export function trimSelectionWhitespace(editable: HTMLElement) {
211
- const selection = window.getSelection();
212
- if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
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
 
@@ -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,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 { selectionCharBounds, restoreSelection, toggleFormat, insertFormattedText, isFormatActive } from "./selection";
19
- export { normalizeWhitespace, normalizeParagraphs, ensureParagraphs } from "./paragraphs";
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";