@brandup/ui-richeditor 1.0.40 → 1.0.42

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
@@ -49,6 +49,7 @@ editor.onChange(({ value }) => console.log(value));
49
49
  | `placeholder` | `string \| null` | Текст-заглушка |
50
50
  | `multiline` | `boolean` | Многострочный режим |
51
51
  | `paragraph` | `"block" \| "break"` | Что делает Enter: новый абзац (по умолчанию) или мягкий перенос |
52
+ | `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию только `paragraph`) |
52
53
  | `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются) |
53
54
  | `toolbarContainer` | `HTMLElement \| null` | Контейнер для панели; по умолчанию `document.body` (`position: fixed`). Если задан — панель монтируется в него и позиционируется над ним (`position: absolute`). Контейнер должен быть `position: relative` |
54
55
  | `value` | `string` | Начальное значение |
@@ -71,7 +72,13 @@ editor.onChange(({ value }) => console.log(value));
71
72
  | `getLength(): number` | Длина текста (без учёта переводов строк) |
72
73
  | `focus(): void` | Установить фокус |
73
74
  | `applyFormat(tool): void` | Переключить формат на выделении (слово целиком) |
75
+ | `applyBlock(type): void` | Переключить тип блоков под выделением; повторное применение возвращает обычный текст |
76
+ | `applyCode(): void` | Код по выделению: моноширинный для части строки, блок — для целых строк |
77
+ | `isCodeActive(): boolean` | Включён ли код в любом виде — подсветка объединённой кнопки |
78
+ | `currentBlock: BlockType` | Тип блока под кареткой |
79
+ | `blockTypes: BlockType[]` | Доступные типы блоков |
74
80
  | `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
81
+ | `isToolEnabled(tool): boolean` | Доступен ли инструмент сейчас (внутри кода остальные выключены) |
75
82
  | `activeTools(): ReadonlySet<FormatTool>` | Активные форматы всех инструментов сразу (панель обновляется на каждое движение каретки, поштучный опрос обходил бы содержимое на каждую кнопку) |
76
83
  | `clearFormat(): void` | Снять всё форматирование с выделения (без выделения — со слова под кареткой) |
77
84
  | `clearAllFormat(): void` | Снять всё форматирование со всего содержимого |
@@ -152,6 +159,49 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
152
159
 
153
160
  При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются.
154
161
 
162
+ ## Блоки: цитата и код
163
+
164
+ Кроме абзаца многострочный режим знает и другие типы блоков верхнего уровня. Они подключаются **явно** — их понимает не каждый получатель:
165
+
166
+ ```typescript
167
+ new RichEditor(elem, { format: true, multiline: true, blocks: ["quote", "code"] });
168
+ ```
169
+
170
+ | Тип | Тег | Разметка | Enter внутри |
171
+ | --- | --- | --- | --- |
172
+ | `paragraph` | `<p>` | — | по режиму `paragraph` |
173
+ | `quote` | `<blockquote>` | `>` с пробелом в начале каждой строки | заканчивает блок |
174
+ | `code` | `<pre>` | ограждение ```` ``` ```` | заканчивает блок |
175
+
176
+ Обычный текст — такой же тип, а не «тип не задан»: он есть в наборе всегда, им становится содержимое, не попавшее ни в какой блок, и в него же блок возвращают. Кнопки в панели (`.block-button`) получают только остальные типы.
177
+
178
+ Кнопки кода (моноширинного и блока) и спойлера временно скрыты (`HIDDEN_TOOLS`/`HIDDEN_BLOCKS` в `./toolbar`): сами возможности работают — значение разбирается, показывается и сохраняется, правку можно вызвать из кода (`applyFormat`, `applyBlock`, `applyCode`), — но в панель они пока не выводятся.
179
+
180
+ ### Одна кнопка на моноширинный и блок кода
181
+
182
+ Когда включены и инструмент `code`, и тип блока `code`, панель показывает **одну** кнопку — так это устроено в мессенджерах. Вид выбирается по выделению, как и в самой разметке:
183
+
184
+ - выделена одна строка или её часть → моноширинный (`` `код` ``);
185
+ - выделено больше одной строки → блок кода из этих строк. Остальные строки остаются как были: в мессенджерском режиме всё сообщение — это один блок, и иначе кодом становился бы весь текст;
186
+ - без выделения → блок кода из строки под кареткой. Моноширинным там делать нечего, а иначе блок был бы недостижим: в пустом поле не выделить строки, которых ещё нет;
187
+ - код уже включён → повторное нажатие снимает именно его вид, целиком по блоку.
188
+
189
+ Тем же занимается `applyCode()`, а `isCodeActive()` отвечает, включён ли код в любом виде — им подсвечена кнопка.
190
+
191
+ В коде разметки нет — ни в моноширинном, ни в блоке: значение берёт оттуда голый текст, и любое форматирование внутри до получателя не доедет. Поэтому при переходе в моноширинный прежнее форматирование с этого куска снимается, внутри кода остальные инструменты недоступны (`isToolEnabled()` — кнопки гаснут), а разметка, попавшая внутрь как-то ещё (вставка, чужое значение), вычищается при первой же нормализации. Если включено только что-то одно, кнопка остаётся обычной: инструмента (`.format-button`) или блока (`.block-button`).
192
+
193
+ Переключение:
194
+
195
+ - кнопка панели или `applyBlock(type)` — на все блоки, которых касается выделение; повторное применение того же типа возвращает обычный текст;
196
+ - **Enter** внутри цитаты или кода заканчивает блок и начинает обычный абзац, **Shift/Ctrl/Cmd+Enter** переносит строку внутри блока. Выходить из блока приходится чаще, чем продолжать его, а иначе уйти можно было бы только мышью;
197
+ - **Backspace** в начале блока возвращает его к обычному тексту, не трогая содержимого.
198
+
199
+ Особенности блока кода: инлайновое форматирование внутри не размечается (написанное остаётся буквальным) и снимается при переключении в этот тип, а пробелы внутри не схлопываются — отступы там часть текста.
200
+
201
+ Подряд идущие строки с маркером цитаты — одна цитата; пустая строка между ними разделяет цитаты. Незакрытое ограждение блоком не считается: его строки остаются текстом, иначе одна случайная кавычка съедала бы весь остаток сообщения. Блок отключённого типа, пришедший вставкой или из значения, сохраняется как обычный текст — разметки от него в значении не будет, но текст не теряется.
202
+
203
+ В режиме `paragraph: "break"` блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два.
204
+
155
205
  ## Вставка текста
156
206
 
157
207
  При включённом форматировании вставка (`paste`) сохраняет форматирование из буфера обмена (`text/html`):
@@ -172,8 +222,8 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
172
222
 
173
223
  | `storage` | Хранение | Абзац / мягкий перенос | Маркеры форматирования |
174
224
  | --- | --- | --- | --- |
175
- | `html` | Санитизированный HTML | `<p>…</p>` / `<br>` | `<b>`, `<i>`, `<s>`, `<u>` |
176
- | `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`, `_курсив_`, `~зачёркнутый~`, `__подчёркнутый__` |
225
+ | `html` | Санитизированный HTML | `<p>…</p>` / `<br>` | `<b>`, `<i>`, `<s>`, `<u>`, `<spoiler>`, `<code>` |
226
+ | `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`, `_курсив_`, `~зачёркнутый~`, `__подчёркнутый__`, `\|\|спойлер\|\|`, `` `моноширинный` `` |
177
227
 
178
228
  Без форматирования (plain) значение хранится как markdown без инструментов: абзацы `\n\n`, мягкий перенос `\n`.
179
229
 
package/package.json CHANGED
@@ -27,12 +27,12 @@
27
27
  "email": "it@brandup.online"
28
28
  },
29
29
  "license": "Apache-2.0",
30
- "version": "1.0.40",
30
+ "version": "1.0.42",
31
31
  "main": "source/index.ts",
32
32
  "types": "source/index.ts",
33
33
  "dependencies": {
34
34
  "@brandup/ui": "^2.0.7",
35
- "@brandup/ui-kit": "^1.0.40"
35
+ "@brandup/ui-kit": "^1.0.42"
36
36
  },
37
37
  "files": [
38
38
  "source",
package/source/editing.ts CHANGED
@@ -3,14 +3,12 @@
3
3
  // редактора и без истории — вызывающий сам решает, когда записывать undo-шаг.
4
4
 
5
5
  import { deserialize } from "./serialize";
6
- import { isBlock } from "./paragraphs";
6
+ import { blockAt, blockTypeOf, blocksInRange, createBlock, isBlock } from "./paragraphs";
7
7
  import { documentSelection, innerSelection } from "./selection";
8
- import type { FormatMarkers, FormatTool } from "./format-config";
8
+ import { BLOCK_TYPES, DEFAULT_BLOCK, type BlockType, type FormatMarkers, type FormatTool } from "./format-config";
9
9
 
10
10
  function emptyParagraph(): HTMLParagraphElement {
11
- const p = document.createElement("p");
12
- p.appendChild(document.createElement("br"));
13
- return p;
11
+ return createBlock(DEFAULT_BLOCK) as HTMLParagraphElement;
14
12
  }
15
13
 
16
14
  // убирает пустые текст-узлы и ставит <br>-заполнитель в пустой абзац (для видимости и каретки)
@@ -51,21 +49,155 @@ export function selectAllContent(editable: HTMLElement) {
51
49
  documentSelection(editable)?.selectAllChildren(editable);
52
50
  }
53
51
 
54
- /** Enter в multiline: разбить текущий абзац по каретке на два <p>. */
55
- export function insertParagraph(editable: HTMLElement) {
52
+ /** Что сделала правка блоков: менялось ли содержимое и не появился ли блок из части строк. */
53
+ export interface BlockChange {
54
+ changed: boolean;
55
+ /** Блок, собранный из выделенных строк (разделение); null — блоки меняли целиком. */
56
+ created: HTMLElement | null;
57
+ }
58
+
59
+ /**
60
+ * Меняет тип блоков, которых касается выделение. `changed: false` — менять было нечего:
61
+ * тогда ни содержимое, ни история не трогаются.
62
+ *
63
+ * У типа без инлайновой разметки (код) форматирование снимается: внутри него написанное
64
+ * остаётся буквальным, и сохранить его всё равно было бы негде.
65
+ */
66
+ export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType): BlockChange {
67
+ const blocks = blocksInRange(editable, range);
68
+
69
+ // Пустой редактор: блоков ещё нет, но тип задать можно — иначе в пустое поле его было бы
70
+ // не поставить вовсе, а набор блока обычно с этого и начинают.
71
+ if (!blocks.length && !editable.firstChild && type !== DEFAULT_BLOCK) {
72
+ const block = createBlock(type);
73
+ editable.appendChild(block);
74
+ caretToStart(block);
75
+
76
+ return { changed: true, created: block };
77
+ }
78
+
79
+ // Выделена часть строк блока — правим их, а не весь блок: иначе в мессенджерском режиме,
80
+ // где всё сообщение это один блок, кодом становился бы весь текст.
81
+ if (blocks.length === 1 && type !== DEFAULT_BLOCK && blockTypeOf(blocks[0]) !== type) {
82
+ const created = splitLines(blocks[0], range, type);
83
+ if (created) return { changed: true, created };
84
+ }
85
+
86
+ let changed = false;
87
+
88
+ for (const block of blocks) {
89
+ if (blockTypeOf(block) === type) continue;
90
+
91
+ const replacement = retagBlock(block, type);
92
+ if (!BLOCK_TYPES[type].inline) unwrapFormatting(replacement);
93
+
94
+ block.replaceWith(replacement);
95
+ fillEmptyParagraph(replacement);
96
+ changed = true;
97
+ }
98
+
99
+ return { changed, created: null };
100
+ }
101
+
102
+ /**
103
+ * Собирает блок нужного типа из строк, которых коснулось выделение; остальные строки остаются
104
+ * блоками прежнего типа до и после. Строки берутся целиком: код из половины строки — это уже
105
+ * моноширинный, а не блок.
106
+ *
107
+ * null — делить нечего: выделение и так захватило все строки блока.
108
+ */
109
+ function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElement | null {
110
+ const breaks = Array.from(block.querySelectorAll("br"));
111
+
112
+ // последний перенос перед выделением и первый после него — по ним и режем
113
+ const head = breaks.filter((br) => range.comparePoint(br, 0) < 0).pop();
114
+ const tail = breaks.find((br) => range.comparePoint(br, 0) > 0);
115
+ if (!head && !tail) return null;
116
+
117
+ const original = blockTypeOf(block) ?? DEFAULT_BLOCK;
118
+
119
+ // Хвост выносим первым: он дальше по дереву, и вынос головы сдвинул бы его границы.
120
+ // Сам перенос-разделитель уходит вместе с ним — строки разъезжаются по блокам.
121
+ const cut = (from: "before" | "after", br: HTMLElement): HTMLElement => {
122
+ const part = document.createRange();
123
+
124
+ if (from === "after") {
125
+ part.setStartAfter(br);
126
+ part.setEnd(block, block.childNodes.length);
127
+ } else {
128
+ part.setStart(block, 0);
129
+ part.setEndBefore(br);
130
+ }
131
+
132
+ const piece = document.createElement(BLOCK_TYPES[original].tag);
133
+ piece.appendChild(part.extractContents());
134
+ br.remove();
135
+ fillEmptyParagraph(piece);
136
+
137
+ return piece;
138
+ };
139
+
140
+ const after = tail ? cut("after", tail) : null;
141
+ const before = head ? cut("before", head) : null;
142
+
143
+ const created = retagBlock(block, type);
144
+ if (!BLOCK_TYPES[type].inline) unwrapFormatting(created);
145
+
146
+ block.replaceWith(created);
147
+ fillEmptyParagraph(created);
148
+
149
+ if (before) created.before(before);
150
+ if (after) created.after(after);
151
+
152
+ return created;
153
+ }
154
+
155
+ // Разворачивает инлайновые теги, оставляя текст и мягкие переносы. Список снимается заранее:
156
+ // разворот внешнего тега поднимает вложенные к блоку, и обойти их нужно тоже.
157
+ function unwrapFormatting(block: HTMLElement) {
158
+ for (const el of Array.from(block.querySelectorAll<HTMLElement>("*"))) {
159
+ if (el.tagName === "BR") continue;
160
+ el.replaceWith(...Array.from(el.childNodes));
161
+ }
162
+ block.normalize();
163
+ }
164
+
165
+ /**
166
+ * Стоит ли каретка в начале своего блока — по тексту до неё, а не по узлу: началом считается
167
+ * и позиция перед вложенным форматированием, и позиция в его первом текстовом узле.
168
+ */
169
+ export function atBlockStart(editable: HTMLElement, range: Range): boolean {
170
+ if (!range.collapsed) return false;
171
+
172
+ const block = blockAt(editable, range.startContainer);
173
+ if (!block) return false;
174
+
175
+ const before = document.createRange();
176
+ before.selectNodeContents(block);
177
+ before.setEnd(range.startContainer, range.startOffset);
178
+
179
+ // Начало блока — это и начало его первой строки. Перед кареткой стоит перенос — значит
180
+ // строка не первая, и удалять нужно сам перенос, а не тип блока.
181
+ if (before.cloneContents().querySelector("br")) return false;
182
+
183
+ return before.toString().length === 0;
184
+ }
185
+
186
+ /** Enter в multiline: разбить текущий блок по каретке; хвост становится блоком типа `type`. */
187
+ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT_BLOCK) {
56
188
  const selection = innerSelection(editable);
57
189
  if (!selection) return;
58
190
 
59
191
  const range = selection.getRangeAt(0);
60
192
  range.deleteContents();
61
193
 
62
- // текущий абзац (ближайший <p>/<div> внутри редактора)
194
+ // текущий блок (ближайший блочный предок внутри редактора)
63
195
  let para: Node | null = range.startContainer;
64
196
  while (para && para !== editable && !isBlock(para)) para = para.parentNode;
65
197
 
66
198
  // каретка не внутри абзаца — создаём абзац сразу с видимым результатом (иначе Enter «срабатывает со 2-го раза»)
67
199
  if (!para || para === editable) {
68
- const next = emptyParagraph();
200
+ const next = createBlock(type);
69
201
  if (editable.childNodes.length === 0) {
70
202
  // пустой редактор: пустая строка-источник + новая строка с кареткой
71
203
  editable.appendChild(emptyParagraph());
@@ -85,10 +217,13 @@ export function insertParagraph(editable: HTMLElement) {
85
217
  tail.setStart(range.endContainer, range.endOffset);
86
218
  const fragment = tail.extractContents();
87
219
 
88
- const next = document.createElement("p");
220
+ const next = document.createElement(BLOCK_TYPES[type].tag);
89
221
  next.appendChild(fragment);
90
222
  (para as ChildNode).after(next);
91
223
 
224
+ // хвост уехал в блок другого типа — его правила распространяются и на содержимое
225
+ if (!BLOCK_TYPES[type].inline) unwrapFormatting(next);
226
+
92
227
  // extractContents в конце абзаца оставляет пустой текст-узел → <p></p> без заполнителя
93
228
  // (невидим/нефокусируем, каретка не встаёт). Чистим и ставим <br> в опустевшие абзацы.
94
229
  fillEmptyParagraph(para as HTMLElement);
@@ -141,6 +276,9 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
141
276
  }
142
277
 
143
278
  const block = para as HTMLElement;
279
+ // Вставка в цитату или код остаётся в них: разорвать блок посреди вставки — не то,
280
+ // чего ждут, а тип целевого блока диктует и правила его содержимого.
281
+ const type = blockTypeOf(block) ?? DEFAULT_BLOCK;
144
282
 
145
283
  // хвост текущего абзаца после каретки — выносим, чтобы вернуть в конец вставки
146
284
  const tailRange = document.createRange();
@@ -148,6 +286,8 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
148
286
  tailRange.setStart(range.startContainer, range.startOffset);
149
287
  const tail = tailRange.extractContents();
150
288
 
289
+ if (!BLOCK_TYPES[type].inline) for (const p of paras) unwrapFormatting(p);
290
+
151
291
  // первый вставляемый абзац вливается в текущий (после содержимого до каретки)
152
292
  while (paras[0].firstChild) block.appendChild(paras[0].firstChild);
153
293
 
@@ -156,15 +296,27 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
156
296
  return;
157
297
  }
158
298
 
159
- // остальные абзацы — отдельными <p> после текущего; хвост — в конец последнего
299
+ // остальные абзацы — отдельными блоками того же типа после текущего; хвост — в конец последнего
160
300
  let anchor: ChildNode = block;
161
301
  for (let i = 1; i < paras.length; i++) {
302
+ paras[i] = retagBlock(paras[i], type);
162
303
  anchor.after(paras[i]);
163
304
  anchor = paras[i];
164
305
  }
165
306
  paras[paras.length - 1].appendChild(tail);
166
307
  }
167
308
 
309
+ // Тот же блок, но другим тегом. Содержимое переносится как есть — правила типа к нему
310
+ // применяет вызывающий, он же знает, откуда это содержимое взялось.
311
+ function retagBlock(block: HTMLElement, type: BlockType): HTMLElement {
312
+ if (blockTypeOf(block) === type) return block;
313
+
314
+ const replacement = document.createElement(BLOCK_TYPES[type].tag);
315
+ while (block.firstChild) replacement.appendChild(block.firstChild);
316
+
317
+ return replacement;
318
+ }
319
+
168
320
  /** Обрезает пробелы по краям абзаца (после схлопывания) — у крайних текстовых узлов. */
169
321
  function trimParagraphEdges(p: HTMLElement) {
170
322
  const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
@@ -215,7 +367,12 @@ export function buildParagraphs(lines: string[], blocks: boolean): HTMLElement[]
215
367
  * Разбирает HTML из буфера обмена в абзацы `<p>`, оставляя только разрешённые инструменты.
216
368
  * Пустой результат — вставлять нечего (вызывающий откатится на простой текст).
217
369
  */
218
- export function sanitizePastedHtml(html: string, tools: FormatTool[], markers: FormatMarkers): HTMLElement[] {
370
+ export function sanitizePastedHtml(
371
+ html: string,
372
+ tools: FormatTool[],
373
+ markers: FormatMarkers,
374
+ types: BlockType[] = [DEFAULT_BLOCK]
375
+ ): HTMLElement[] {
219
376
  // убираем мусорные элементы (Word/браузер: стили, скрипты, заголовок документа)
220
377
  const source = document.createElement("template");
221
378
  source.innerHTML = html;
@@ -223,7 +380,7 @@ export function sanitizePastedHtml(html: string, tools: FormatTool[], markers: F
223
380
 
224
381
  // единый источник санитизации — deserialize (теги-синонимы → канонические, лишнее развёрнуто)
225
382
  const holder = document.createElement("template");
226
- holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true);
383
+ holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true, types);
227
384
 
228
385
  // внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
229
386
  // иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
package/source/emoji.ts CHANGED
@@ -1,53 +1,129 @@
1
- // Набор смайликов для кнопки вставки в тулбаре. Все символы — одиночные кодпойнты (без
2
- // ZWJ-последовательностей и модификаторов), поэтому переносятся в текст как единое целое.
3
- // Внимание: в UTF-16 каждый занимает две единицы, и getLength() (а значит и maxlength у
4
- // хоста) считает такой символ за два.
1
+ // Набор смайликов для панели вставки. Все символы — одиночные кодпойнты (без ZWJ-последовательностей
2
+ // и модификаторов), поэтому переносятся в текст как единое целое. Внимание: в UTF-16 каждый занимает
3
+ // две единицы, и getLength() (а значит и maxlength у хоста) считает такой символ за два.
4
+ //
5
+ // Разложены по группам: панель рисует их в том же порядке и отделяет линией, а заодно группа —
6
+ // это кусок, к которому применяется пропуск отрисовки (см. .emoji-group в richeditor.less).
5
7
 
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
- "🕧",
8
+ /** Группа смайликов в панели вставки. */
9
+ export interface EmojiGroup {
10
+ /** Название группы; в панели не показывается, уходит в подпись для скринридера. */
11
+ title: string;
12
+ emojis: string[];
13
+ }
14
+
15
+ /** Смайлики по группам (порядок сохраняется в UI). */
16
+ export const EMOJI_GROUPS: EmojiGroup[] = [
17
+ {
18
+ title: "Смайлы и жесты",
19
+ // prettier-ignore
20
+ emojis: [
21
+ "😀", "😁", "😂", "😃", "😄", "😅", "😆", "😉", "😊", "😋", "😌", "😍", "😏", "😒", "😓", "😔",
22
+ "😖", "😘", "😚", "😜", "😝", "😞", "😠", "😡", "😢", "😣", "😤", "😥", "😨", "😩", "😪", "😫",
23
+ "😭", "😰", "😱", "😲", "😳", "😵", "😷", "😸", "😹", "😺", "😻", "😼", "😽", "😾", "😿", "🙀",
24
+ "🙅", "🙆", "🙇", "🙈", "🙉", "🙊", "🙋", "🙌", "🙍", "🙎", "🙏", "😇", "😈", "😎", "😐", "😑",
25
+ "😕", "😗", "😙", "😛", "😟", "😦", "😧", "😬", "😮", "😯", "😴", "😶",
26
+ ],
27
+ },
28
+ {
29
+ title: "Люди",
30
+ // prettier-ignore
31
+ emojis: [
32
+ "👀", "👂", "👃", "👄", "👅", "👆", "👇", "👈", "👉", "👊", "👋", "👌", "👍", "👎", "👏", "👐",
33
+ "👑", "👒", "👓", "👔", "👕", "👖", "👗", "👘", "👙", "👚", "👛", "👜", "👝", "👞", "👟", "👠",
34
+ "👡", "👢", "👣", "👤", "👦", "👧", "👨", "👩", "👪", "👫", "👮", "👯", "👰", "👱", "👲", "👳",
35
+ "👴", "👵", "👶", "👷", "👸", "👹", "👺", "👻", "👼", "👽", "👾", "👿", "💀", "💁", "💂", "💃",
36
+ "💄", "💅", "💆", "💇", "💏", "💑", "👥", "👬", "👭",
37
+ ],
38
+ },
39
+ {
40
+ title: "Животные",
41
+ // prettier-ignore
42
+ emojis: [
43
+ "🐌", "🐍", "🐎", "🐑", "🐒", "🐔", "🐗", "🐘", "🐙", "🐚", "🐛", "🐜", "🐝", "🐞", "🐟", "🐠",
44
+ "🐡", "🐢", "🐣", "🐤", "🐥", "🐦", "🐧", "🐨", "🐩", "🐫", "🐬", "🐭", "🐮", "🐯", "🐰", "🐱",
45
+ "🐲", "🐳", "🐴", "🐵", "🐶", "🐷", "🐸", "🐹", "🐺", "🐻", "🐼", "🐽", "🐾", "🐀", "🐁", "🐂",
46
+ "🐃", "🐄", "🐅", "🐆", "🐇", "🐈", "🐉", "🐊", "🐋", "🐏", "🐐", "🐓", "🐕", "🐖", "🐪",
47
+ ],
48
+ },
49
+ {
50
+ title: "Природа",
51
+ // prettier-ignore
52
+ emojis: [
53
+ "🌀", "🌁", "🌂", "🌃", "🌄", "🌅", "🌆", "🌇", "🌈", "🌉", "🌊", "🌋", "🌌", "🌏", "🌑", "🌓",
54
+ "🌔", "🌕", "🌙", "🌛", "🌟", "🌠", "🌰", "🌱", "🌴", "🌵", "🌷", "🌸", "🌹", "🌺", "🌻", "🌼",
55
+ "🌽", "🌾", "🌿", "🍀", "🍁", "🍂", "🍃", "🌍", "🌎", "🌐", "🌒", "🌖", "🌗", "🌘", "🌚", "🌜",
56
+ "🌝", "🌞", "🌲", "🌳",
57
+ ],
58
+ },
59
+ {
60
+ title: "Еда",
61
+ // prettier-ignore
62
+ emojis: [
63
+ "🍄", "🍅", "🍆", "🍇", "🍈", "🍉", "🍊", "🍌", "🍍", "🍎", "🍏", "🍑", "🍒", "🍓", "🍔", "🍕",
64
+ "🍖", "🍗", "🍘", "🍙", "🍚", "🍛", "🍜", "🍝", "🍞", "🍟", "🍠", "🍡", "🍢", "🍣", "🍤", "🍥",
65
+ "🍦", "🍧", "🍨", "🍩", "🍪", "🍫", "🍬", "🍭", "🍮", "🍯", "🍰", "🍱", "🍲", "🍳", "🍴", "🍵",
66
+ "🍶", "🍷", "🍸", "🍹", "🍺", "🍻", "🍋", "🍐", "🍼",
67
+ ],
68
+ },
69
+ {
70
+ title: "Праздники и спорт",
71
+ // prettier-ignore
72
+ emojis: [
73
+ "🎀", "🎁", "🎂", "🎃", "🎄", "🎅", "🎆", "🎇", "🎈", "🎉", "🎊", "🎋", "🎌", "🎍", "🎎", "🎏",
74
+ "🎐", "🎑", "🎒", "🎓", "🎠", "🎡", "🎢", "🎣", "🎤", "🎥", "🎦", "🎧", "🎨", "🎩", "🎪", "🎫",
75
+ "🎬", "🎭", "🎮", "🎯", "🎰", "🎱", "🎲", "🎳", "🎴", "🎵", "🎶", "🎷", "🎸", "🎹", "🎺", "🎻",
76
+ "🎼", "🎽", "🎾", "🎿", "🏀", "🏁", "🏂", "🏃", "🏄", "🏆", "🏈", "🏊", "🏇", "🏉",
77
+ ],
78
+ },
79
+ {
80
+ title: "Места",
81
+ // prettier-ignore
82
+ emojis: [
83
+ "🏠", "🏡", "🏢", "🏣", "🏥", "🏦", "🏧", "🏨", "🏩", "🏪", "🏫", "🏬", "🏭", "🏮", "🏯", "🏰",
84
+ "🗻", "🗼", "🗽", "🗾", "🗿", "🏤",
85
+ ],
86
+ },
87
+ {
88
+ title: "Транспорт",
89
+ // prettier-ignore
90
+ emojis: [
91
+ "🚀", "🚃", "🚄", "🚅", "🚇", "🚉", "🚌", "🚏", "🚑", "🚒", "🚓", "🚕", "🚗", "🚙", "🚚", "🚢",
92
+ "🚤", "🚥", "🚧", "🚨", "🚩", "🚪", "🚫", "🚬", "🚭", "🚲", "🚶", "🚹", "🚺", "🚻", "🚼", "🚽",
93
+ "🚾", "🛀", "🚁", "🚂", "🚆", "🚈", "🚊", "🚍", "🚎", "🚐", "🚔", "🚖", "🚘", "🚛", "🚜", "🚝",
94
+ "🚞", "🚟", "🚠", "🚡", "🚣", "🚦", "🚮", "🚯", "🚰", "🚱", "🚳", "🚴", "🚵", "🚷", "🚸", "🚿",
95
+ "🛁", "🛂", "🛃", "🛄", "🛅",
96
+ ],
97
+ },
98
+ {
99
+ title: "Предметы",
100
+ // prettier-ignore
101
+ emojis: [
102
+ "💈", "💉", "💊", "💋", "💌", "💍", "💎", "💐", "💠", "💡", "💰", "💱", "💲", "💳", "💴", "💵",
103
+ "💸", "💹", "💺", "💻", "💼", "💽", "💾", "💿", "📀", "📁", "📂", "📃", "📄", "📅", "📆", "📇",
104
+ "📈", "📉", "📊", "📋", "📌", "📍", "📎", "📏", "📐", "📑", "📒", "📓", "📔", "📕", "📖", "📗",
105
+ "📘", "📙", "📚", "📛", "📜", "📝", "📞", "📟", "📠", "📡", "📢", "📣", "📤", "📥", "📦", "📧",
106
+ "📨", "📩", "📪", "📫", "📮", "📰", "📱", "📲", "📳", "📴", "📶", "📷", "📹", "📺", "📻", "📼",
107
+ "💶", "💷", "📬", "📭", "📯", "📵",
108
+ ],
109
+ },
110
+ {
111
+ title: "Символы",
112
+ // prettier-ignore
113
+ emojis: [
114
+ "🅰", "🅱", "🅾", "🅿", "🆎", "🆑", "🆒", "🆓", "🆔", "🆕", "🆖", "🆗", "🆘", "🆙", "🆚", "🈁",
115
+ "🈂", "🈚", "🈯", "🈲", "🈳", "🈴", "🈵", "🈶", "🈷", "🈸", "🈹", "🈺", "🉐", "🉑", "🀄", "🃏",
116
+ "💒", "💓", "💔", "💕", "💖", "💗", "💘", "💙", "💚", "💛", "💜", "💝", "💞", "💟", "💢", "💣",
117
+ "💤", "💥", "💦", "💧", "💨", "💩", "💪", "💫", "💬", "💮", "💯", "🔃", "🔊", "🔋", "🔌", "🔍",
118
+ "🔎", "🔏", "🔐", "🔑", "🔒", "🔓", "🔔", "🔖", "🔗", "🔘", "🔙", "🔚", "🔛", "🔜", "🔝", "🔞",
119
+ "🔟", "🔠", "🔡", "🔢", "🔣", "🔤", "🔥", "🔦", "🔧", "🔨", "🔩", "🔪", "🔫", "🔮", "🔯", "🔰",
120
+ "🔱", "🔲", "🔳", "🔴", "🔵", "🔶", "🔷", "🔸", "🔹", "🔺", "🔻", "🔼", "🔽", "🕐", "🕑", "🕒",
121
+ "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛", "💭", "🔀", "🔁", "🔂", "🔄", "🔅", "🔆",
122
+ "🔇", "🔉", "🔕", "🔬", "🔭", "🕜", "🕝", "🕞", "🕟", "🕠", "🕡", "🕢", "🕣", "🕤", "🕥", "🕦",
123
+ "🕧",
124
+ ],
125
+ },
53
126
  ];
127
+
128
+ /** Все смайлики подряд, в порядке групп. */
129
+ export const EMOJIS: string[] = EMOJI_GROUPS.flatMap((group) => group.emojis);