@brandup/ui-richeditor 1.0.45 → 1.0.48

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
@@ -35,6 +35,8 @@ editor.onChange(({ value }) => console.log(value));
35
35
 
36
36
  Панель — **общая для всех редакторов** (`div.ui-richeditor-toolbar`). При фокусе редактора она перестраивается под его инструменты, позиционируется над ним и показывается; при потере фокуса — скрывается. Кнопки диспатчат форматирование напрямую активному редактору.
37
37
 
38
+ Внутри обёртка разделена надвое: сама `.ui-richeditor-toolbar` отвечает только за положение, а вид и содержимое держит коробка `.toolbar-body` внутри неё. Её размер меняется вместе с содержимым — на правку адреса ссылки, например, — а точка привязки от этого съезжать не должна; выпадающие слои вроде панели смайликов висят на обёртке и коробкой не обрезаются. Все кнопки панели несут общий класс `.toolbar-button` (плюс свой — `.format-button`, `.block-button`, `.action-button`, `.host-button`), по нему они и оформляются.
39
+
38
40
  По умолчанию панель живёт в `document.body` (`position: fixed`) — это защищает её от обрезки `overflow: hidden` у родителей. Если задан `toolbarContainer`, панель монтируется в него и позиционируется относительно него (`position: absolute`, над контейнером) — например, `TextBox` передаёт свой контейнер `.ui-textbox`.
39
41
 
40
42
  ## Опции (`RichEditorOptions`)
@@ -42,7 +44,7 @@ editor.onChange(({ value }) => console.log(value));
42
44
  | Опция | Тип | Описание |
43
45
  | --- | --- | --- |
44
46
  | `format` | `boolean` | Включает форматирование и панель инструментов |
45
- | `tools` | `FormatTool[]` | Состав инструментов (по умолчанию все) |
47
+ | `tools` | `FormatTool[]` | Состав инструментов (по умолчанию все); им же разбирается и сохраняется значение — разметка, которой в наборе нет, остаётся в тексте как есть |
46
48
  | `actions` | `EditorAction[]` | Кнопки действий в панели: `emoji`, `erase`, `undo`, `redo` (по умолчанию нет) |
47
49
  | `storage` | `"html" \| "markdown"` | Формат сериализации значения (по умолчанию `html`) |
48
50
  | `markers` | `Partial<FormatMarkers>` | Переопределение markdown-маркеров по инструментам |
@@ -51,7 +53,7 @@ editor.onChange(({ value }) => console.log(value));
51
53
  | `paragraph` | `"block" \| "break"` | Что делает Enter: новый абзац (по умолчанию) или мягкий перенос |
52
54
  | `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию все); пустой список оставляет только `paragraph` |
53
55
  | `keepFocus` | `boolean` | Держать ли фокус в поле, пока открыта панель смайликов (по умолчанию да, а на сенсорном устройстве нет) |
54
- | `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются) |
56
+ | `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются); разметка значения при этом разбирается и показывается, кнопок для неё просто нет |
55
57
  | `toolbarContainer` | `HTMLElement \| null` | Контейнер для панели; по умолчанию `document.body` (`position: fixed`). Если задан — панель монтируется в него и позиционируется над ним (`position: absolute`). Контейнер должен быть `position: relative` |
56
58
  | `value` | `string` | Начальное значение |
57
59
  | `filterChar` | `(char) => boolean` | Хук: `false` — отклонить вводимый символ |
@@ -66,7 +68,9 @@ editor.onChange(({ value }) => console.log(value));
66
68
  | Член | Описание |
67
69
  | --- | --- |
68
70
  | `editable` | Редактируемый элемент |
69
- | `format`, `formatTools`, `editorActions`, `formatStorage`, `formatMarkers`, `multiline` | Параметры экземпляра |
71
+ | `format`, `editorActions`, `formatStorage`, `formatMarkers`, `multiline` | Параметры экземпляра |
72
+ | `formatTypes` | Объявленный набор инструментов — им разбирается и сохраняется значение |
73
+ | `formatTools` | Инструменты в панели: то же, но пусто в `readonly` — переключать разметку там нечем |
70
74
  | `getValue(): string` | Сериализованное значение (по `storage`) — считается по DOM, всегда актуально |
71
75
  | `setValue(value: string): void` | Установить значение (нормализует, генерирует `change`) |
72
76
  | `flushChange(): void` | Доставить отложенное `change` немедленно (см. ниже) |
@@ -77,6 +81,10 @@ editor.onChange(({ value }) => console.log(value));
77
81
  | `applyBlock(type): void` | Переключить тип блоков под выделением; повторное применение возвращает обычный текст |
78
82
  | `applyCode(): void` | Код по выделению: моноширинный для части строки, блок — для целых строк |
79
83
  | `isCodeActive(): boolean` | Включён ли код в любом виде — подсветка объединённой кнопки |
84
+ | `applyLink(url): void` | Поставить ссылку, поменять её адрес или снять её (пустым адресом) |
85
+ | `currentLink: string` | Адрес ссылки под кареткой; пусто — каретка не в ссылке |
86
+ | `caretSnapshot(): [number, number] \| null` | Каретка в символах — вернуть её потом можно и без фокуса |
87
+ | `restoreCaret(bounds): void` | Вернуть каретку по снимку |
80
88
  | `currentBlock: BlockType` | Тип блока под кареткой |
81
89
  | `blockTypes: BlockType[]` | Доступные типы блоков |
82
90
  | `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
@@ -89,7 +97,7 @@ editor.onChange(({ value }) => console.log(value));
89
97
  | `applyAction(action): void` | Выполнить действие панели (`erase`/`undo`/`redo`) |
90
98
  | `isActionEnabled(action): boolean` | Доступно ли действие сейчас |
91
99
  | `insertText(text): void` | Вставить текст в каретку (или вместо выделения) с учётом режима набора; без фокуса вставляет по снятой каретке |
92
- | `openEmojiPicker(initiator, container?): void` | Открыть панель смайликов у кнопки; повторный вызов у той же кнопки её закрывает |
100
+ | `openEmojiPicker(picker, initiator): boolean` | Показать переданный попап смайликов у кнопки; false этим нажатием он закрылся |
93
101
  | `selection: Selection \| null` | Выделение, если оно внутри редактора (иначе `null`) — единая точка доступа для хоста |
94
102
  | `selectNode(node): void` | Выделить узел внутри редактора: следующая вставка заменит его целиком |
95
103
  | `onChange(handler)` | Подписка на событие `richeditor-change` |
@@ -121,7 +129,7 @@ editor.onChange(({ value }) => console.log(value));
121
129
  - Формат — переключатель (toggle): повторное применение снимает его.
122
130
  - Применяется к слову целиком: курсор внутри слова или выделение его части → формат охватывает всё слово; исходное выделение/каретка сохраняются.
123
131
  - **Режим набора**: на пустом месте (между пробелами / в пустом поле) кнопка/хоткей включают «ожидающий» формат — он применится к следующему введённому тексту. Сбрасывается при перемещении каретки, клике или потере фокуса.
124
- - Хоткеи `Ctrl/Cmd+B/I/U`. Зачёркивание — только кнопкой.
132
+ - Хоткеи `Ctrl/Cmd+B/I/U` и `Ctrl/Cmd+K` (ссылка — показывает в панели поле адреса). Зачёркивание — только кнопкой.
125
133
  - **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов, но не более ~512 КБ снимков суммарно (снимок — это всё содержимое редактора, поэтому на длинном тексте старые шаги вытесняются раньше).
126
134
  - При потере фокуса и после `setValue` пробелы нормализуются (схлопывание повторов + обрезка краёв строк). Неразрывный пробел (`U+00A0`) считается обычным: браузер сам подставляет его в `contenteditable` вместо пробела, который иначе схлопнулся бы при отображении, и в значение он не попадает.
127
135
 
@@ -150,9 +158,11 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
150
158
 
151
159
  Кнопка `emoji` открывает под панелью попап `.ui-richeditor-emoji` со списком символов (`EMOJIS` — экспортируется пакетом). Выбранный символ вставляется через `insertText()`, то есть в текущую каретку и с учётом ожидающих форматов режима набора; попап после выбора закрывается.
152
160
 
161
+ Попап принадлежит своему владельцу: у панели свой, у хоста со своей кнопкой (например у поля сообщения) — свой. Собирает их общая `createEmojiPicker(onPick)`, показывает — редактор: `openEmojiPicker(picker, initiator)` берёт на себя удержание правки на время попапа, каретку, если её ещё не было, и отпускание фокуса по `keepFocus`. Он же убирает панель форматирования, если попап раскрывается не из неё: два всплывающих слоя над одним полем вместе не показываются. Открытым в любом случае бывает один — за этим следит `PopupManager` кита.
162
+
153
163
  Открытием и закрытием управляет `PopupManager` из [`@brandup/ui-kit`](../brandup-ui-kit) — оттуда же приходят базовые стили `.ui-popup`. Ни кнопка, ни попап не забирают фокус сами (`mousedown` гасится), поэтому каретка и выделение сохраняются; на сенсорном устройстве поле отдаёт фокус намеренно (см. «Фокус на время своего слоя»), и вставка идёт по снятой каретке. Список кнопок собирается лениво, при первом открытии.
154
164
 
155
- Панель — слой редактора, поэтому открывает её он сам (`openEmojiPicker()`): на время её работы он придерживает правку, чтобы нормализация не обрезала пробел у каретки. Хост может открыть её у своей кнопки, передав вторым аргументом контейнер, — так делает [`@brandup/ui-messageeditor`](../brandup-ui-messageeditor).
165
+ Попап — слой над полем, поэтому показывает его редактор (`openEmojiPicker()`): на время работы он придерживает правку, чтобы нормализация не обрезала пробел у каретки. Хост со своей кнопкой собирает попап сам и передаёт его сюда — так делает [`@brandup/ui-messageeditor`](../brandup-ui-messageeditor).
156
166
 
157
167
  ## Многострочный режим: абзацы и переносы
158
168
 
@@ -190,6 +200,33 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
190
200
 
191
201
  Кнопка спойлера временно скрыта (`HIDDEN_TOOLS` в `./toolbar`): сам инструмент работает — значение разбирается, показывается и сохраняется, правку можно вызвать из кода (`applyFormat`), — но в панель он пока не выводится.
192
202
 
203
+ ### Ссылка
204
+
205
+ `link` — единственный инструмент, у которого есть данные: адрес идёт отдельной частью и в тексте не показывается. Поэтому он и устроен иначе остальных.
206
+
207
+ **В разметке.** Пара маркеров тут не годится, и в реестре у него `md: ""` — этим он выводится из всей маркерной механики. Разбор и сборку он делает своими ветками: `[текст](адрес)` в markdown, `<a href>` в html. Адрес прячется от маркеров до их разбора — иначе `example.com/a_b_c` уезжал бы курсивом. Текст ссылки от них не прячется: разметка внутри него разбирается наравне с остальной, `[**жирный**](адрес)` работает.
208
+
209
+ Адрес читается и в угловых скобках — `[текст](<адрес с пробелом>)`; пишется в них же, когда иначе не прочитается. Парные скобки внутри адреса разрешены и без угловых: ими кончается половина ссылок на википедию. Скобки в тексте экранируются обратной косой.
210
+
211
+ Ссылки без текста или без адреса не бывает — такая разметка остаётся текстом. Перенос строки внутри ссылки тоже: её текст в разметке один кусок, разорванный он не выражается и с разбора не вернётся. **Enter** внутри ссылки её заканчивает — за переносом остаётся обычный текст, а не вторая ссылка с тем же адресом. Схема адреса проверяется: `javascript:`, `data:` и прочее исполняемое ссылкой не становится ни при разборе значения, ни при вставке из буфера.
212
+
213
+ **В работе.** Кнопка не переключатель — по ней панель показывает поле адреса вместо кнопок (`Ctrl`/`Cmd`+`K` делает то же). Не выпадающий слой: то же место, та же коробка — позиционировать и ужимать ничего не приходится, а кнопки на это время всё равно не нужны. Повторное нажатие возвращает их. Каретка в готовой ссылке подставляет её адрес: тогда это правка, а не новая ссылка. Пустой адрес ссылку снимает, для этого же есть кнопка рядом с полем.
214
+
215
+ Ссылка — оформление текста, а не вставка: делать ссылкой нечего — кнопка погашена, и `applyLink` ничего не делает.
216
+
217
+ | | |
218
+ | --- | --- |
219
+ | Выделение | становится ссылкой |
220
+ | Каретка в слове | ссылкой становится слово целиком, как и у остальных инструментов |
221
+ | Каретка в готовой ссылке | меняется её адрес — целиком, а не по куску выделения |
222
+ | Каретка вне слова | кнопка недоступна: оборачивать нечего |
223
+
224
+ Адрес отдаёт `currentLink`, ставит и снимает `applyLink(url)`; `applyFormat("link")` ничего не делает — переключением адрес не задать.
225
+
226
+ Поле адреса, в отличие от всего остального в панели, забирает фокус: без него не набрать. Выделение в редакторе при этом теряется, поэтому панель снимает каретку до перевода фокуса и возвращает её перед правкой — тем же снимком в символах (`caretSnapshot`/`restoreCaret`), которым пользуются окна хоста. `Esc` возвращает каретку, ничего не изменив.
227
+
228
+ Отпущенный фокус обычно убирает панель с экрана (`suspend`), но не пока правят адрес: поле лежит **в самой панели**, и вместе с ней ушло бы и оно. У панели смайликов слой чужой — она раскрывается от кнопки хоста, — поэтому там панель прячется как раз правильно. А вот фокус, ушедший мимо панели, правку заканчивает: держаться ей больше не на чем.
229
+
193
230
  ### Одна кнопка на моноширинный и блок кода
194
231
 
195
232
  Когда включены и инструмент `code`, и тип блока `code`, панель показывает **одну** кнопку — так это устроено в мессенджерах. Вид выбирается по выделению, как и в самой разметке:
@@ -221,7 +258,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
221
258
 
222
259
  При включённом форматировании вставка (`paste`) сохраняет форматирование из буфера обмена (`text/html`):
223
260
 
224
- - разметка санитизируется до включённых инструментов (синонимы `STRONG/EM/DEL/INS` → канонические `b/i/s/u`, всё прочее — `span`, стили, классы, `<style>`/`<script>` — отбрасывается, текст сохраняется);
261
+ - разметка санитизируется до включённых инструментов (синонимы `STRONG/EM/DEL/INS` → канонические `b/i/s/u`, всё прочее — `span`, стили, классы, `<style>`/`<script>` — отбрасывается, текст сохраняется). Из атрибутов остаётся только `href` у ссылки, и то по проверенной схеме;
225
262
  - **multiline** сохраняет абзацы `<p>` и мягкие переносы `<br>`, разбивая текущий абзац по каретке; **single-line** — инлайн, абзацы/переносы становятся пробелами;
226
263
  - хук `filterPaste` остаётся в силе: вернул `null` — вставка отклоняется; изменил текст (обрезка по длине, фильтр по типу) — форматирование не сохраняется, вставляется очищенный текст.
227
264
 
@@ -256,4 +293,24 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
256
293
 
257
294
  ## CSS
258
295
 
259
- Подключается `richeditor.less`. Цветовые переменные `--input-*` берутся из `@brandup/ui-kit` (с fallback-значениями для standalone). Классы: `focused` на обёртке (поле в фокусе), `visible` на общей панели (показана), `active` на кнопке инструмента (формат активен на выделении).
296
+ Подключается `richeditor.less`. Классы: `focused` на обёртке (поле в фокусе), `visible` на общей панели (показана), `active` на кнопке инструмента (формат активен на выделении).
297
+
298
+ Собственные переменные пакета объявлены в `:root` в начале `richeditor.less` — там же и их значения по умолчанию:
299
+
300
+ | Переменная | По умолчанию | Что задаёт |
301
+ | --- | --- | --- |
302
+ | `--richeditor-quote-line` | `rgba(0,0,0,.2)` | Линия слева у цитаты |
303
+ | `--richeditor-code-fill` | `rgba(0,0,0,.06)` | Подложка кода — и моноширинного, и блока |
304
+ | `--richeditor-code-font` | `ui-monospace, …` | Шрифт кода |
305
+ | `--richeditor-spoiler-fill` | `rgba(0,0,0,.14)` | Плашка спойлера |
306
+ | `--richeditor-link-width` | `320px` | Ширина панели при правке адреса, когда она стоит в `document.body` — растягиваться там не по чему |
307
+ | `--richeditor-link-max-width` | `500px` | Предел ширины растянутой панели при правке адреса |
308
+ | `--richeditor-link-color` | `#2481cc` | Цвет ссылки в тексте |
309
+ | `--richeditor-link-underline-offset` | `auto` | Отступ подчёркивания ссылки от базовой линии |
310
+ | `--richeditor-underline-room` | `2px` | Место под подчёркивание последней строки: рисуется оно ниже текста, но в раскладке места не занимает, и без запаса его срезает край прокручиваемой коробки |
311
+ | `--richeditor-toolbar-padding` | `3px` | Поля панели |
312
+ | `--richeditor-toolbar-button-size` | `34px` | Кнопка панели; по ней же высота поля адреса |
313
+ | `--richeditor-emoji-size` | `32px` | Ячейка в панели смайликов |
314
+ | `--richeditor-emoji-rows` | `8` | Запасная высота нарисованной не сразу группы; своё значение панель ставит на каждую группу |
315
+
316
+ Остальное оформление берётся у полей ввода `@brandup/ui-kit` — `--input-*`, `--hover--input-*`, `--focus--input-*`, `--placeholder-*`, `--svg-*`. В `:root` пакет их не объявляет: объявив, он перекрыл бы значения кита. Запасные значения у них стоят по месту — на случай использования пакета без кита.
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.45",
30
+ "version": "1.0.48",
31
31
  "main": "source/index.ts",
32
32
  "types": "source/index.ts",
33
33
  "dependencies": {
34
34
  "@brandup/ui": "^2.0.9",
35
- "@brandup/ui-kit": "^1.0.45"
35
+ "@brandup/ui-kit": "^1.0.48"
36
36
  },
37
37
  "files": [
38
38
  "source",
package/source/editing.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { deserialize } from "./serialize";
6
6
  import { blockAt, blockTypeOf, blocksInRange, createBlock, isBlock } from "./paragraphs";
7
- import { documentSelection, innerSelection, literalAncestor } from "./selection";
7
+ import { documentSelection, innerSelection, linkAt, literalAncestor } from "./selection";
8
8
  import { BLOCK_TYPES, DEFAULT_BLOCK, type BlockType, type FormatMarkers, type FormatTool } from "./format-config";
9
9
 
10
10
  // убирает пустые текст-узлы и ставит <br>-заполнитель в пустой абзац (для видимости и каретки)
@@ -221,6 +221,10 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
221
221
  const range = selection.getRangeAt(0);
222
222
  range.deleteContents();
223
223
 
224
+ // Ссылку абзац разорвал бы пополам, а её текст в разметке — один кусок. Выводим хвост
225
+ // из неё до разбиения: в новом абзаце останется обычный текст (см. splitLinkOut).
226
+ splitLinkOut(editable, range);
227
+
224
228
  // текущий блок (ближайший блочный предок внутри редактора)
225
229
  const para = blockOf(editable, range.startContainer);
226
230
 
@@ -277,7 +281,25 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
277
281
  * а диапазон встаёт между половинами — вставленное туда окажется снаружи обоих. Пустые
278
282
  * половины не оставляем: печатать в них было бы нечего, а каретка попадала бы внутрь.
279
283
  */
280
- function splitElement(el: HTMLElement, range: Range) {
284
+ /**
285
+ * Выводит хвост ссылки из неё, разрезав по каретке: перед переносом строки.
286
+ *
287
+ * Ссылку разрывать нельзя — в разметке её текст один кусок, — а продолжать на новой строке
288
+ * нечем: адрес у ссылки один, и вторая ссылка с тем же адресом появилась бы сама собой,
289
+ * хотя её никто не заводил. Поэтому за переносом остаётся обычный текст.
290
+ */
291
+ function splitLinkOut(editable: HTMLElement, range: Range) {
292
+ const link = linkAt(editable, range);
293
+ if (!link) return;
294
+
295
+ // Разрезаем и снимаем с хвоста обёртку: каретка стоит ровно перед ним, и разворачивание
296
+ // её не сдвигает — на месте одного узла оказываются его дети, с того же места.
297
+ const tail = splitElement(link, range);
298
+ if (tail) tail.replaceWith(...Array.from(tail.childNodes));
299
+ }
300
+
301
+ /** Разрезает элемент по каретке; возвращает хвост, если в нём что-то осталось. */
302
+ function splitElement(el: HTMLElement, range: Range): HTMLElement | null {
281
303
  const tail = document.createRange();
282
304
  tail.selectNodeContents(el);
283
305
  tail.setStart(range.startContainer, range.startOffset);
@@ -285,12 +307,15 @@ function splitElement(el: HTMLElement, range: Range) {
285
307
  const rest = el.cloneNode(false) as HTMLElement;
286
308
  rest.appendChild(tail.extractContents());
287
309
 
288
- if (rest.textContent) el.after(rest);
310
+ const kept = !!rest.textContent;
311
+ if (kept) el.after(rest);
289
312
 
290
313
  range.setStartAfter(el);
291
314
  range.collapse(true);
292
315
 
293
316
  if (!el.textContent) el.remove();
317
+
318
+ return kept ? rest : null;
294
319
  }
295
320
 
296
321
  /** Shift/Ctrl+Enter в multiline: вставить мягкий перенос <br>. */
@@ -308,6 +333,11 @@ export function insertSoftBreak(editable: HTMLElement) {
308
333
  const literal = literalAncestor(range.startContainer, editable);
309
334
  if (literal) splitElement(literal, range);
310
335
 
336
+ // Перенос не живёт и в ссылке: её текст — один сплошной кусок, разметкой разорванный
337
+ // не выражается. Разрезаем так же, но продолжения на новой строке не оставляем: адрес
338
+ // у неё один, а второй ссылки никто не заводил.
339
+ splitLinkOut(editable, range);
340
+
311
341
  const br = document.createElement("br");
312
342
  range.insertNode(br);
313
343
 
package/source/emoji.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { DOM } from "@brandup/ui";
2
+ import { POPUP_CLASS, PopupManager, SCROLLABLE_CLASS } from "@brandup/ui-kit";
3
+
1
4
  // Набор смайликов для панели вставки. Все символы — одиночные кодпойнты (без ZWJ-последовательностей
2
5
  // и модификаторов), поэтому переносятся в текст как единое целое. Внимание: в UTF-16 каждый занимает
3
6
  // две единицы, и getLength() (а значит и maxlength у хоста) считает такой символ за два.
@@ -127,3 +130,69 @@ export const EMOJI_GROUPS: EmojiGroup[] = [
127
130
 
128
131
  /** Все смайлики подряд, в порядке групп. */
129
132
  export const EMOJIS: string[] = EMOJI_GROUPS.flatMap((group) => group.emojis);
133
+
134
+ // --- панель вставки ---
135
+
136
+ /** Попап вставки смайлика: у каждого владельца свой, собираются они здесь. */
137
+ export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
138
+
139
+ // Сколько кнопок помещается в ряд при ширине панели (см. .ui-richeditor-emoji в richeditor.less).
140
+ // Точность нужна только для оценки высоты нерисованной группы: ошибка сдвинет ползунок прокрутки,
141
+ // но не саму раскладку — группа переносит кнопки сама.
142
+ const EMOJI_COLUMNS = 8;
143
+
144
+ /**
145
+ * Группа смайликов: и смысловое деление в панели (отбивается линией), и кусок, к которому
146
+ * применяется пропуск отрисовки. Поэлементно это было бы семьсот отслеживаемых поддеревьев,
147
+ * и слежение за ними съедает выигрыш от пропуска.
148
+ */
149
+ function buildEmojiGroup(group: EmojiGroup): HTMLElement {
150
+ const rows = Math.ceil(group.emojis.length / EMOJI_COLUMNS);
151
+ const elem = DOM.tag("div", {
152
+ class: "emoji-group",
153
+ role: "group",
154
+ "aria-label": group.title,
155
+ // высота, пока группа не нарисована: без неё список схлопнулся бы, а прокрутка скакала
156
+ style: `--richeditor-emoji-rows: ${rows}`,
157
+ });
158
+
159
+ const fragment = document.createDocumentFragment();
160
+ for (const emoji of group.emojis)
161
+ fragment.appendChild(DOM.tag("button", { type: "button", class: "emoji", tabindex: "-1" }, emoji));
162
+ elem.appendChild(fragment);
163
+
164
+ return elem;
165
+ }
166
+
167
+ /**
168
+ * Собирает попап вставки смайлика.
169
+ *
170
+ * Попап принадлежит тому, кто его собрал: у панели форматирования свой, у поля сообщения — свой.
171
+ * Общий на всех пришлось бы переносить между владельцами и помнить, чей он сейчас; а показать
172
+ * два разом всё равно нельзя — {@link PopupManager} держит открытым один.
173
+ *
174
+ * Показом и закрытием занимается вызывающий (у редактора для этого есть `openEmojiPicker`):
175
+ * здесь только разметка и выбор символа.
176
+ */
177
+ export function createEmojiPicker(onPick: (emoji: string) => void): HTMLElement {
178
+ const picker = DOM.tag("div", { class: `${POPUP_CLASS} ${EMOJI_PICKER_CLASS}` });
179
+
180
+ // Прокручивается список, а не сам попап: полоса прокрутки рисуется по краю коробки
181
+ // и перекрывала бы скругление рамки — угол выглядел бы срезанным.
182
+ const list = DOM.tag("div", { class: ["emoji-list", SCROLLABLE_CLASS] });
183
+ picker.appendChild(list);
184
+
185
+ for (const group of EMOJI_GROUPS) list.appendChild(buildEmojiGroup(group));
186
+
187
+ // попап живёт и вне панели, поэтому фокус гасит сам
188
+ picker.addEventListener("mousedown", (e) => e.preventDefault());
189
+ picker.addEventListener("click", (e) => {
190
+ const target = (e.target as HTMLElement).closest<HTMLElement>(".emoji");
191
+ if (!target) return;
192
+
193
+ onPick(target.textContent ?? "");
194
+ PopupManager.close();
195
+ });
196
+
197
+ return picker;
198
+ }
@@ -1,6 +1,6 @@
1
1
  // Конфигурация форматирования: типы, набор инструментов, markdown-маркеры и Ctrl/Cmd-хоткеи.
2
2
 
3
- export type FormatTool = "bold" | "italic" | "strike" | "underline" | "spoiler" | "code";
3
+ export type FormatTool = "bold" | "italic" | "strike" | "underline" | "spoiler" | "code" | "link";
4
4
  export type FormatStorage = "html" | "markdown";
5
5
 
6
6
  /**
@@ -93,7 +93,7 @@ export function blockTypeOfTag(tagName: string): BlockType | null {
93
93
  return BLOCK_TAGS[tagName] ?? null;
94
94
  }
95
95
 
96
- export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline", "spoiler", "code"];
96
+ export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline", "spoiler", "code", "link"];
97
97
 
98
98
  export const ALL_EDITOR_ACTIONS: EditorAction[] = ["emoji", "erase", "undo", "redo"];
99
99
 
@@ -170,6 +170,16 @@ export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
170
170
  hotkey: "",
171
171
  title: "Моноширинный",
172
172
  },
173
+ link: {
174
+ tag: "a",
175
+ matchTags: ["A"],
176
+ // Не парный маркер: адрес отдельной частью и в тексте не показывается. Пустой маркер
177
+ // выводит инструмент из маркерной машинерии (см. orderedMarkers в ./serialize), разбор
178
+ // и сборку он делает своими ветками — как и переносы с абзацами.
179
+ md: "",
180
+ hotkey: "k",
181
+ title: "Ссылка",
182
+ },
173
183
  };
174
184
 
175
185
  interface EditorActionDef {
package/source/format.ts CHANGED
@@ -38,6 +38,8 @@ export {
38
38
  activeFormats,
39
39
  emptyFormatAt,
40
40
  toggleFormat,
41
+ applyLink,
42
+ linkAt,
41
43
  clearFormat,
42
44
  clearAllFormat,
43
45
  hasFormatting,
package/source/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { default } from "./richeditor";
2
2
  export * from "./richeditor";
3
- export { EMOJIS, EMOJI_GROUPS, type EmojiGroup } from "./emoji";
3
+ export { EMOJIS, EMOJI_GROUPS, EMOJI_PICKER_CLASS, createEmojiPicker, type EmojiGroup } from "./emoji";
4
4
  export {
5
5
  ALL_BLOCK_TYPES,
6
6
  ALL_EDITOR_ACTIONS,