@brandup/ui-richeditor 1.0.49 → 1.0.51

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
@@ -39,6 +39,8 @@ editor.onChange(({ value }) => console.log(value));
39
39
 
40
40
  По умолчанию панель живёт в `document.body` (`position: fixed`) — это защищает её от обрезки `overflow: hidden` у родителей. Если задан `toolbarContainer`, панель монтируется в него и позиционируется относительно него (`position: absolute`, над контейнером) — например, `TextBox` передаёт свой контейнер `.ui-textbox`.
41
41
 
42
+ Панель не шире экрана (и не шире контейнера в режиме `toolbarContainer`), а правый край не уходит за границу: у поля справа она прижимается к краю экрана. Полный набор кнопок, не влезший по ширине — обычное дело на телефоне, — прокручивается внутри `.toolbar-body` по горизонтали: кнопки не переносятся и не прячутся, панель остаётся в одну строку. Полоса прокрутки — общая, от `.ui-scrollable` кита, только тоньше.
43
+
42
44
  ## Опции (`RichEditorOptions`)
43
45
 
44
46
  | Опция | Тип | Описание |
@@ -50,7 +52,7 @@ editor.onChange(({ value }) => console.log(value));
50
52
  | `markers` | `Partial<FormatMarkers>` | Переопределение markdown-маркеров по инструментам |
51
53
  | `placeholder` | `string \| null` | Текст-заглушка |
52
54
  | `multiline` | `boolean` | Многострочный режим |
53
- | `paragraph` | `"block" \| "break"` | Что делает Enter: новый абзац (по умолчанию) или мягкий перенос |
55
+ | `paragraph` | `"block" \| "break"` | Что такое абзац: абзац, отделённый пустой строкой (по умолчанию), или строка, как в мессенджерах |
54
56
  | `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию все); пустой список оставляет только `paragraph` |
55
57
  | `keepFocus` | `boolean` | Держать ли фокус в поле, пока открыта панель смайликов (по умолчанию да, а на сенсорном устройстве нет) |
56
58
  | `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются); разметка значения при этом разбирается и показывается, кнопок для неё просто нет |
@@ -102,6 +104,8 @@ editor.onChange(({ value }) => console.log(value));
102
104
  | `openEmojiPicker(picker, initiator): boolean` | Показать переданный попап смайликов у кнопки; false — этим нажатием он закрылся |
103
105
  | `selection: Selection \| null` | Выделение, если оно внутри редактора (иначе `null`) — единая точка доступа для хоста |
104
106
  | `selectNode(node): void` | Выделить узел внутри редактора: следующая вставка заменит его целиком |
107
+ | `caretWord: string` | Слово под кареткой; пусто при своём выделении, без каретки или когда каретка не в слове |
108
+ | `selectCaretWord(): boolean` | Выделить слово под кареткой — следующая вставка встанет на его место; `false` — выделять нечего |
105
109
  | `onChange(handler)` | Подписка на событие `richeditor-change` |
106
110
  | `destroy(): void` | Разворачивает элемент обратно и освобождает ресурсы |
107
111
 
@@ -130,6 +134,7 @@ editor.onChange(({ value }) => console.log(value));
130
134
 
131
135
  - Формат — переключатель (toggle): повторное применение снимает его.
132
136
  - Применяется к слову целиком: курсор внутри слова или выделение его части → формат охватывает всё слово; исходное выделение/каретка сохраняются.
137
+ - Слово — то, что стоит между пробелами, **без небуквенных знаков по краям**: каретка в слове перед точкой не отдаёт форматированию точку. Внутренние знаки — часть слова: `info@example.com`, `по-русски`, `don't` берутся целиком. Слово не кончается на границе тега (`Дарим <b>ск</b>идку` — одно слово «скидку»), но не пересекает перенос строки и готовую конструкцию (`contenteditable="false"`). Каретка вне слова (сразу за точкой) не расширяется никуда — включается режим набора. Явное выделение только растёт: выделенные знаки из него не выпадают.
133
138
  - **Режим набора**: на пустом месте (между пробелами / в пустом поле) кнопка/хоткей включают «ожидающий» формат — он применится к следующему введённому тексту. Сбрасывается при перемещении каретки, клике или потере фокуса.
134
139
  - Хоткеи `Ctrl/Cmd+B/I/U` и `Ctrl/Cmd+K` (ссылка — показывает в панели поле адреса). Зачёркивание — только кнопкой.
135
140
  - **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов, но не более ~512 КБ снимков суммарно (снимок — это всё содержимое редактора, поэтому на длинном тексте старые шаги вытесняются раньше).
@@ -166,6 +171,8 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
166
171
 
167
172
  Попап — слой над полем, поэтому показывает его редактор (`openEmojiPicker()`): на время работы он придерживает правку, чтобы нормализация не обрезала пробел у каретки. Хост со своей кнопкой собирает попап сам и передаёт его сюда — так делает [`@brandup/ui-messageeditor`](../brandup-ui-messageeditor).
168
173
 
174
+ Первой группой в списке стоят **недавние** (`.emoji-recent`) — до двух рядов последних выбранных символов, свежий первым. Хранятся они в `localStorage` (ключ `RECENT_EMOJIS_KEY`), поэтому общие для всех попапов источника и переживают перезагрузку; пока ничего не выбрано — группы нет вовсе. Освежает её `openEmojiPicker()` при каждом показе: попап живёт между открытиями, а хранилище тем временем пополняют и другие попапы. Запоминается сам выбор, а не вставка — недавние про то, к чему тянутся. Недоступное хранилище (приватный режим) вставке не мешает — недавние просто не копятся. Пакет экспортирует `recentEmojis()`, `rememberEmoji()` и `refreshRecentEmojis(picker)` — хосту с собственным показом попапа освежать группу нужно самому.
175
+
169
176
  ## Многострочный режим: абзацы и переносы
170
177
 
171
178
  При `multiline: true` контент структурируется по абзацам:
@@ -174,15 +181,13 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
174
181
  - **Shift+Enter** или **Ctrl/Cmd+Enter** → мягкий перенос (`<br>`) внутри абзаца;
175
182
  - блуждающий текст и `<div>` нормализуются в `<p>` при вводе.
176
183
 
177
- Опция `paragraph: "break"` меняет это: Enter даёт мягкий перенос, а модификатор в этом режиме ничего не меняет пустая строка набирается двумя переносами, как в мессенджерах (отдельный абзац дал бы в значении ровно её же). Это важно при `storage: "markdown"`: в режиме по умолчанию каждый Enter уходит в значение пустой строкой (`\n\n`), а в `break` — одним переносом (`\n`).
184
+ Опция `paragraph: "break"` меняет смысл абзаца: там абзац это строка, как в мессенджерах. Enter и модификатор делают одно и то же (новую строку), мягкому переносу в этом режиме взяться неоткуда, а пустая строка сообщения это пустой абзац. Это важно при `storage: "markdown"`: в режиме по умолчанию граница абзацев уходит в значение пустой строкой (`\n\n`), а в `break` — одним переносом (`\n`).
178
185
 
179
- В этом режиме абзацных блоков в содержимом нет вовсе: значение загружается плоским текстом, где каждый `\n` становится `<br>` внутри единственного `<p>`. Иначе два переноса рисовались бы двумя абзацами, а у хоста без отступов между ними это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
186
+ Содержимое в обоих режимах абзацные блоки: каждая строка (или абзац) лежит в своём `<p>`, а не разделяется `<br>` внутри общего. Отступов между абзацами в режиме `break` нет (класс `breaks` на редакторе): отступ читался бы пустой строкой, которой в значении не будет.
180
187
 
181
- Хвостовой перенос отбрасывается ровно один это `<br>`-заполнитель, без которого не видна последняя строка. Набранные пустые строки сохраняются и в поле, и в значении, где бы они ни стоялив начале блока, в середине или в конце.
188
+ Мягкий перенос, пришедший извневставкой документа или чужим значением, приводится к той же модели: абзац делится по нему на строки-абзацы. Хвостовой перенос при этом строкой не считается это `<br>`-заполнитель, без которого не видна последняя (пустая) строка.
182
189
 
183
- Блоки в этом режиме появляются побочноправкой блочного типа, поэтому нормализация сводит соседние абзацы обратно в один: их граница уходила бы в значение пустой строкой, которой на экране нет.
184
-
185
- При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются — кроме одного: пустой абзац сразу за блоком другого типа остаётся. Это единственное место, где каретка стоит вне цитаты или кода, и без него правка запиралась бы в блоке. В значение такой абзац не попадает — хвост значения обрезается.
190
+ При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются кроме одного: пустой абзац сразу за блоком другого типа остаётся. Это единственное место, где каретка стоит вне цитаты или кода, и без него правка запиралась бы в блоке. В значение такой абзац не попадает — хвост значения обрезается. В режиме `break` пустой абзац осмыслен сам по себе (это пустая строка сообщения) и не удаляется вовсе; исчезает только единственный — иначе пустое поле не показало бы заглушку.
186
191
 
187
192
  ## Блоки: цитата и код
188
193
 
@@ -200,7 +205,9 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
200
205
 
201
206
  Обычный текст — такой же тип, а не «тип не задан»: он есть в наборе всегда, им становится содержимое, не попавшее ни в какой блок, и в него же блок возвращают. Кнопки в панели (`.block-button`) получают только остальные типы.
202
207
 
203
- Кнопка спойлера временно скрыта (`HIDDEN_TOOLS` в `./toolbar`): сам инструмент работает значение разбирается, показывается и сохраняется, правку можно вызвать из кода (`applyFormat`), но в панель он пока не выводится.
208
+ Цитата рисуется плашкой по ширине содержимого, прижатой к левому краю: с подложкой пустое место справа от короткой строки читалось бы её частью. Длинная цитата переносится по границе редактора. Цвета и отступы задаются переменными `--richeditor-quote-*` (см. «CSS»).
209
+
210
+ Открывающая ограда в чужом маркдауне часто приходит с меткой языка (```` ```text ````) — блок она открывает так же, а сама метка отбрасывается: значение кита её не хранит, обратно уезжает голая ограда. Закрывает блок только голая ограда — та же строка с меткой внутри блока остаётся его содержимым.
204
211
 
205
212
  ### Ссылка
206
213
 
@@ -219,7 +226,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
219
226
  | | |
220
227
  | --- | --- |
221
228
  | Выделение | становится ссылкой |
222
- | Каретка в слове | ссылкой становится слово целиком, как и у остальных инструментов |
229
+ | Каретка в слове | ссылкой становится слово целиком, как и у остальных инструментов; знаки внутри — часть слова, поэтому адрес или почта оборачиваются целиком |
223
230
  | Каретка в готовой ссылке | меняется её адрес — целиком, а не по куску выделения |
224
231
  | Каретка вне слова | кнопка недоступна: оборачивать нечего |
225
232
 
@@ -254,7 +261,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
254
261
 
255
262
  Подряд идущие строки с маркером цитаты — одна цитата; пустая строка между ними разделяет цитаты. В режиме мягких переносов пустой строки между блоками нет, поэтому соседние цитаты там склеиваются в одну сразу в поле — иначе оно показывало бы два блока, а в значении и у получателя был бы один. Незакрытое ограждение блоком не считается: его строки остаются текстом, иначе одна случайная кавычка съедала бы весь остаток сообщения. Блок отключённого типа, пришедший вставкой или из значения, сохраняется как обычный текст — разметки от него в значении не будет, но текст не теряется.
256
263
 
257
- В режиме `paragraph: "break"` блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два.
264
+ В режиме `paragraph: "break"` блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два. Выделенные строки становятся при этом одним блоком, а не блоком на каждую: строка там — отдельный абзац, и кнопка на трёх строках дала бы три цитаты подряд.
258
265
 
259
266
  ## Вставка текста
260
267
 
@@ -264,13 +271,21 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
264
271
  - **multiline** сохраняет абзацы `<p>` и мягкие переносы `<br>`, разбивая текущий абзац по каретке; **single-line** — инлайн, абзацы/переносы становятся пробелами;
265
272
  - хук `filterPaste` остаётся в силе: вернул `null` — вставка отклоняется; изменил текст (обрезка по длине, фильтр по типу) — форматирование не сохраняется, вставляется очищенный текст.
266
273
 
267
- Если `text/html` нет (или форматирование выключено), вставляется простой текстпо той же модели абзацев:
274
+ При `storage: "markdown"` разметку сохраняет и вставка простого текста: раз значение хранится этой разметкой, маркеры во вставляемом тексте значат то же, что в значении, и разбираются тем же `deserialize` с теми же наборами инструментов и блоков. Ограничение действует и здесь: **при любой вставке применяется только включённый формат** — маркер снятого инструмента остаётся текстом, как остался бы и в значении. Изменённый хуком `filterPaste` текст вставляется буквально, а внутри блока кода текст литерален всегда.
275
+
276
+ `text/html` точнее описывает скопированное, но лишь когда несёт собственную разметку. Голый текст тоже приезжает html-ем — редакторы кода отдают исходник маркдауна строками в `<div>` внутри общей обёртки, — и такой «плоский» html не знает ничего сверх `text/plain`, а маркеры в тексте понимает только разбор разметкой хранения: при плоском html первым идёт он. Границы строк-`<div>` при этом становятся переносами — склейка соседних строк встык потеряла бы и текст, и разметку.
277
+
278
+ Если `text/html` нет (или форматирование выключено), а разбирать маркеры не по чему, вставляется простой текст — по той же модели абзацев:
268
279
 
269
280
  - **multiline**, режим `block` — пустая строка разделяет абзацы `<p>`, одиночный перенос остаётся мягким `<br>`;
270
- - **multiline**, режим `break` — абзацы не создаются, все переносы мягкие;
281
+ - **multiline**, режим `break` — каждая строка становится абзацем `<p>`, пустая строка — пустым абзацем;
271
282
  - **single-line** — строки склеиваются пробелами.
272
283
 
273
- Каретка в обоих случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
284
+ Каретка во всех случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
285
+
286
+ ### Бросок файла
287
+
288
+ Текстовый файл, брошенный в редактор, вставляется содержимым — тем же путём, что вставка из буфера: хук `filterPaste`, разбор разметкой хранения включённым набором, один шаг истории. Текстовым считается файл с типом `text/*`, а без типа — `.md`/`.markdown`/`.txt`/`.text` по имени: у маркдауна тип в системе часто не зарегистрирован. Несколько файлов вставляются подряд через пустую строку. Каретка встаёт в точку броска, если браузер умеет её назвать (`caretPositionFromPoint`/`caretRangeFromPoint`). Любой другой бросок гасится: свободное перетаскивание прошло бы мимо истории и фильтров хоста.
274
289
 
275
290
  ## Формат хранения
276
291
 
@@ -302,6 +317,10 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
302
317
  | Переменная | По умолчанию | Что задаёт |
303
318
  | --- | --- | --- |
304
319
  | `--richeditor-quote-line` | `rgba(0,0,0,.2)` | Линия слева у цитаты |
320
+ | `--richeditor-quote-line-width` | `3px` | Толщина линии цитаты |
321
+ | `--richeditor-quote-fill` | `rgba(0,0,0,.04)` | Подложка цитаты |
322
+ | `--richeditor-quote-padding-tb` | `5px` | Вертикальный отступ цитаты — тот же, что у абзаца, чтобы строка не прыгала при смене типа блока |
323
+ | `--richeditor-quote-padding-lr` | `10px` | Горизонтальный отступ цитаты — насколько текст отходит от линии и от края подложки |
305
324
  | `--richeditor-code-fill` | `rgba(0,0,0,.06)` | Подложка кода — и моноширинного, и блока |
306
325
  | `--richeditor-code-font` | `ui-monospace, …` | Шрифт кода |
307
326
  | `--richeditor-spoiler-fill` | `rgba(0,0,0,.14)` | Плашка спойлера |
@@ -312,6 +331,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
312
331
  | `--richeditor-underline-room` | `2px` | Место под подчёркивание последней строки: рисуется оно ниже текста, но в раскладке места не занимает, и без запаса его срезает край прокручиваемой коробки |
313
332
  | `--richeditor-toolbar-padding` | `3px` | Поля панели |
314
333
  | `--richeditor-toolbar-button-size` | `34px` | Кнопка панели; по ней же высота поля адреса |
334
+ | `--richeditor-toolbar-edge-gap` | `4px` | Зазор панели от краёв экрана; то же значение зашито константой в позиционировании (`EDGE_GAP`), менять их нужно вместе |
315
335
  | `--richeditor-emoji-size` | `32px` | Ячейка в панели смайликов |
316
336
  | `--richeditor-emoji-rows` | `8` | Запасная высота нарисованной не сразу группы; своё значение панель ставит на каждую группу |
317
337
 
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.49",
30
+ "version": "1.0.51",
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.49"
35
+ "@brandup/ui-kit": "^1.0.51"
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, linkAt, literalAncestor } from "./selection";
7
+ import { cleanupFormatting, 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>-заполнитель в пустой абзац (для видимости и каретки)
@@ -58,8 +58,11 @@ export interface BlockChange {
58
58
  *
59
59
  * У типа без инлайновой разметки (код) форматирование снимается: внутри него написанное
60
60
  * остаётся буквальным, и сохранить его всё равно было бы негде.
61
+ *
62
+ * При `merge` выделенные блоки собираются в один блок нового типа: там, где абзац это строка,
63
+ * кнопка на трёх строках даёт один блок, а не три подряд.
61
64
  */
62
- export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType): BlockChange {
65
+ export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType, merge = false): BlockChange {
63
66
  const blocks = blocksInRange(editable, range);
64
67
 
65
68
  // Пустой редактор: блоков ещё нет, но тип задать можно — иначе в пустое поле его было бы
@@ -79,6 +82,26 @@ export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType
79
82
  if (created) return { changed: true, created };
80
83
  }
81
84
 
85
+ // Собираем ВСЕ задетые блоки, а не только чужого типа: блок нужного типа посреди выделения
86
+ // иначе остался бы на месте, а собранный блок встал бы перед ним — строки менялись местами.
87
+ if (merge && type !== DEFAULT_BLOCK && blocks.length > 1) {
88
+ const replacement = retagBlock(blocks[0], type);
89
+
90
+ for (const block of blocks.slice(1)) {
91
+ // строки склеиваем переносом — тем же, что разделяет их внутри блока (mergeAdjacentBlocks)
92
+ if (replacement.lastChild?.nodeName !== "BR") replacement.appendChild(document.createElement("br"));
93
+
94
+ while (block.firstChild) replacement.appendChild(block.firstChild);
95
+ block.remove();
96
+ }
97
+
98
+ if (!BLOCK_TYPES[type].inline) unwrapFormatting(replacement);
99
+ if (replacement !== blocks[0]) blocks[0].replaceWith(replacement);
100
+ fillEmptyParagraph(replacement);
101
+
102
+ return { changed: true, created: null };
103
+ }
104
+
82
105
  let changed = false;
83
106
 
84
107
  for (const block of blocks) {
@@ -291,6 +314,11 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
291
314
  // хвост уехал в блок другого типа — его правила распространяются и на содержимое
292
315
  if (!BLOCK_TYPES[type].inline) unwrapFormatting(next);
293
316
 
317
+ // Разрез по краю оформленного куска оставляет от него пустой тег в одной из половин:
318
+ // продолжать им нечего, а набор в него попадал бы оформленным (см. insertSoftBreak).
319
+ cleanupFormatting(para);
320
+ cleanupFormatting(next);
321
+
294
322
  // extractContents в конце абзаца оставляет пустой текст-узел → <p></p> без заполнителя
295
323
  // (невидим/нефокусируем, каретка не встаёт). Чистим и ставим <br> в опустевшие абзацы.
296
324
  fillEmptyParagraph(para);
@@ -379,6 +407,31 @@ export function insertSoftBreak(editable: HTMLElement) {
379
407
  selection.addRange(after);
380
408
  }
381
409
 
410
+ /**
411
+ * Схлопывает пустые оболочки, оставшиеся от удалённого выделения. Выделение, начатое и
412
+ * законченное в разных абзацах, забирает их содержимое, но сами абзацы задевает лишь частично —
413
+ * и они остаются пустыми по краям каретки. Правка продолжается в одном из них: иначе вокруг
414
+ * набранного или вставленного появлялись бы пустые строки, которых не было.
415
+ */
416
+ export function collapseEmptyEdges(editable: HTMLElement, range: Range) {
417
+ if (range.startContainer !== editable) return;
418
+
419
+ // от удалённого текста остаются пустые текстовые узлы — пустоту смотрим по содержимому
420
+ const empty = (node: ChildNode | null) =>
421
+ !!node && blockTypeOf(node) === DEFAULT_BLOCK && !node.textContent && !(node as HTMLElement).querySelector("br");
422
+
423
+ const before = editable.childNodes[range.startOffset - 1] ?? null;
424
+ const after = editable.childNodes[range.startOffset] ?? null;
425
+
426
+ const kept = empty(before) ? before : empty(after) ? after : null;
427
+ if (!kept) return;
428
+
429
+ if (kept === before && empty(after)) after.remove();
430
+
431
+ range.setStart(kept, 0);
432
+ range.collapse(true);
433
+ }
434
+
382
435
  /** Вставляет санитизированные абзацы <p> в позицию каретки, разбивая текущий абзац. */
383
436
  export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement[], range: Range) {
384
437
  const block = blockOf(editable, range.startContainer);
@@ -445,16 +498,17 @@ function trimParagraphEdges(p: HTMLElement) {
445
498
  }
446
499
 
447
500
  /**
448
- * Строки вставляемого текста → абзацы `<p>` с мягкими переносами `<br>` внутри.
501
+ * Строки вставляемого текста → абзацы `<p>` с мягкими переносами `<br>` внутри. Вставка ложится
502
+ * в ту же модель, которую даёт Enter, и значение после неё разбирается обратно:
449
503
  *
450
- * При `blocks` абзацы разделяет пустая строка (режим `block` многострочного редактора);
451
- * иначе весь текст один абзац, а все переносы мягкие: так вставка ложится в ту же модель,
452
- * которую даёт Enter, и значение после неё разбирается обратно.
504
+ * - `block` абзацы разделяет пустая строка (режим `block` многострочного редактора);
505
+ * - `line`каждая строка сама себе абзац (режим мягких переносов);
506
+ * - `single` весь текст одним абзацем, все переносы мягкие (буквальное содержимое блока кода).
453
507
  */
454
- export function buildParagraphs(lines: string[], blocks: boolean): HTMLElement[] {
508
+ export function buildParagraphs(lines: string[], mode: "block" | "line" | "single"): HTMLElement[] {
455
509
  const groups: string[][] = [];
456
510
 
457
- if (blocks) {
511
+ if (mode === "block") {
458
512
  let group: string[] = [];
459
513
  for (const line of lines) {
460
514
  if (line !== "") group.push(line);
@@ -464,6 +518,8 @@ export function buildParagraphs(lines: string[], blocks: boolean): HTMLElement[]
464
518
  }
465
519
  }
466
520
  if (group.length) groups.push(group);
521
+ } else if (mode === "line") {
522
+ for (const line of lines) groups.push([line]);
467
523
  } else if (lines.length) groups.push(lines);
468
524
 
469
525
  return groups.map((group) => {
@@ -519,29 +575,173 @@ export function sanitizePastedHtml(
519
575
  return paras;
520
576
  }
521
577
 
578
+ /**
579
+ * Несёт ли разобранная вставка собственную разметку: блок не-абзац либо инлайновый тег — кроме
580
+ * `<br>`, перенос есть и в простом тексте. Плоская вставка не знает ничего сверх `text/plain`:
581
+ * так выглядит скопированный из редактора кода исходник — голые строки в `<div>`.
582
+ */
583
+ export function hasPastedMarkup(paras: HTMLElement[]): boolean {
584
+ return paras.some(
585
+ (p) => p.tagName !== "P" || Array.from(p.querySelectorAll("*")).some((el) => el.tagName !== "BR")
586
+ );
587
+ }
588
+
589
+ /**
590
+ * Похож ли сырой html буфера на документ, а не на обёртку вокруг голых строк. Смотрим на сырой
591
+ * html, потому что санитизация разницу стирает: и строки-`<div>` из редактора кода, и абзацы
592
+ * веб-страницы выходят из неё одинаковыми `<p>`. А разница решает, чей разбор первый: в документе
593
+ * маркеры в тексте — буквальные символы, которые видит и читатель страницы, в голых строках —
594
+ * разметка хранения.
595
+ */
596
+ export function isDocumentHtml(html: string): boolean {
597
+ const holder = document.createElement("template");
598
+ holder.innerHTML = html;
599
+
600
+ return !!holder.content.querySelector("p, h1, h2, h3, h4, h5, h6, li, table");
601
+ }
602
+
603
+ /**
604
+ * Разбирает простой текст из буфера как markdown — для редакторов, хранящих значение этой
605
+ * разметкой: маркеры во вставляемом тексте значат то же, что в значении, и разбираются тем же
606
+ * `deserialize` с теми же наборами. Пустой результат — вставлять нечего (вызывающий откатится
607
+ * на простой текст).
608
+ *
609
+ * В отличие от {@link sanitizePastedHtml}, пробелы не схлопываются: это текст, а не вёрстка —
610
+ * переносы и отступы в нём и так значат сами себя.
611
+ */
612
+ export function parsePastedMarkdown(
613
+ text: string,
614
+ tools: FormatTool[],
615
+ markers: FormatMarkers,
616
+ types: BlockType[] = [DEFAULT_BLOCK],
617
+ separate = true
618
+ ): HTMLElement[] {
619
+ const holder = document.createElement("template");
620
+ holder.innerHTML = deserialize(text, "markdown", tools, markers, true, types, separate);
621
+
622
+ return Array.from(holder.content.children) as HTMLElement[];
623
+ }
624
+
625
+ // Слово — буквы и цифры любого алфавита плюс подчёркивание. \w здесь не годится: он знает
626
+ // только латиницу, и кириллическое слово по нему словом не будет.
627
+ const WORD_CHAR = /[\p{L}\p{N}_]/u;
628
+
629
+ const isWordChar = (ch: string | undefined) => !!ch && WORD_CHAR.test(ch);
630
+
631
+ /** Кусок текстового потока блока: текстовый узел и его начало в общей строке. */
632
+ type StreamPart = { node: Text; start: number };
633
+
634
+ /**
635
+ * Текстовый поток блока, в котором стоит узел: текст его текстовых узлов одной строкой плюс
636
+ * карта соответствия. Слово не кончается на границе узла — «сло<b>во</b>» это одно слово,
637
+ * и расширение обязано её пересекать. Пересекать нельзя другое:
638
+ * - переносы строк (<br>) — слова по разные стороны переноса разные, в поток идёт разделитель;
639
+ * - готовые конструкции (contenteditable="false" — переменные и спинтакс messageeditor):
640
+ * они атомарны и правятся своим окном, расширению внутри них делать нечего.
641
+ */
642
+ function blockStream(editable: HTMLElement, node: Node): { text: string; parts: StreamPart[] } {
643
+ // Блок — прямой потомок редактора (p, blockquote, pre); текст без блоков лежит в нём самом
644
+ let root: Node = node;
645
+ while (root.parentNode && root.parentNode !== editable) root = root.parentNode;
646
+ if (!root.parentNode) root = editable; // узел вне редактора — поток по самому редактору не собрать
647
+ const scope = root.nodeType === Node.ELEMENT_NODE ? (root as HTMLElement) : editable;
648
+
649
+ let text = "";
650
+ const parts: StreamPart[] = [];
651
+
652
+ const walk = (current: Node) => {
653
+ if (current.nodeType === Node.TEXT_NODE) {
654
+ parts.push({ node: current as Text, start: text.length });
655
+ text += current.textContent ?? "";
656
+ return;
657
+ }
658
+ if (current.nodeType !== Node.ELEMENT_NODE) return;
659
+ const elem = current as HTMLElement;
660
+ if (elem.tagName === "BR" || elem.getAttribute("contenteditable") === "false") {
661
+ text += "\n"; // разделитель: слово через перенос или конструкцию не перепрыгивает
662
+ return;
663
+ }
664
+ for (const child of Array.from(elem.childNodes)) walk(child);
665
+ };
666
+ walk(scope);
667
+
668
+ return { text, parts };
669
+ }
670
+
671
+ /** Позиция в потоке → узел и смещение. Позиция всегда из этого же потока, место найдётся. */
672
+ function streamPoint(parts: StreamPart[], position: number): [Text, number] {
673
+ let holder = parts[0];
674
+ for (const part of parts) {
675
+ if (part.start > position) break;
676
+ holder = part;
677
+ }
678
+ return [holder.node, Math.min(position - holder.start, holder.node.textContent?.length ?? 0)];
679
+ }
680
+
522
681
  /**
523
682
  * Диапазон, расширенный до целых слов на границах (для применения формата к слову целиком).
524
683
  * Возвращает новый Range и не трогает выделение — вызывающий сам решает, править ли по нему
525
684
  * и когда двигать каретку.
685
+ *
686
+ * Слово — то, что стоит между пробелами, без небуквенных знаков по краям: внутренние знаки
687
+ * остаются его частью («info@example.com», «по-русски»), а точка после слова — нет. Иначе
688
+ * каретка в слове перед точкой отдавала бы форматированию и точку, которую туда не просили.
689
+ * Явное выделение только растёт: выделенное вместе со знаками таким и останется. Каретка
690
+ * не в слове вовсе (сразу за точкой) не расширяется никуда — схлопнутый диапазон редактор
691
+ * понимает как «формат для того, что будут набирать».
526
692
  */
527
693
  export function expandRangeToWords(editable: HTMLElement, range: Range): Range {
528
694
  const { startContainer, endContainer } = range;
529
- let startOffset = range.startOffset;
530
- let endOffset = range.endOffset;
531
695
 
532
- if (startContainer.nodeType === Node.TEXT_NODE && editable.contains(startContainer)) {
533
- const text = startContainer.textContent ?? "";
534
- while (startOffset > 0 && !/\s/.test(text[startOffset - 1])) startOffset--;
696
+ const expanded = document.createRange();
697
+ expanded.setStart(startContainer, range.startOffset);
698
+ expanded.setEnd(endContainer, range.endOffset);
699
+
700
+ const expandIn = (container: Node, offset: number): { parts: StreamPart[]; from: number; to: number } | null => {
701
+ if (container.nodeType !== Node.TEXT_NODE || !editable.contains(container)) return null;
702
+
703
+ const { text, parts } = blockStream(editable, container);
704
+ const part = parts.find((candidate) => candidate.node === container);
705
+ if (!part) return null;
706
+ const position = part.start + offset;
707
+
708
+ // до пробелов…
709
+ let from = position;
710
+ let to = position;
711
+ while (from > 0 && !/\s/.test(text[from - 1])) from--;
712
+ while (to < text.length && !/\s/.test(text[to])) to++;
713
+
714
+ // …и без небуквенных знаков по краям
715
+ while (from < to && !isWordChar(text[from])) from++;
716
+ while (to > from && !isWordChar(text[to - 1])) to--;
717
+
718
+ return { parts, from, to };
719
+ };
720
+
721
+ const start = expandIn(startContainer, range.startOffset);
722
+ const end = range.collapsed ? start : expandIn(endContainer, range.endOffset);
723
+
724
+ if (range.collapsed) {
725
+ // каретка стоит вне слова (за точкой) — расширять нечего
726
+ if (!start || start.from >= start.to) return expanded;
727
+ const position = start.parts.find((part) => part.node === startContainer)!.start + range.startOffset;
728
+ if (position < start.from || position > start.to) return expanded;
729
+
730
+ expanded.setStart(...streamPoint(start.parts, start.from));
731
+ expanded.setEnd(...streamPoint(start.parts, start.to));
732
+ return expanded;
535
733
  }
536
734
 
537
- if (endContainer.nodeType === Node.TEXT_NODE && editable.contains(endContainer)) {
538
- const text = endContainer.textContent ?? "";
539
- while (endOffset < text.length && !/\s/.test(text[endOffset])) endOffset++;
735
+ // выделение только растёт: правая часть слова добирается, выделенные знаки не выпадают
736
+ if (start && start.from < start.to) {
737
+ const [node, offset] = streamPoint(start.parts, start.from);
738
+ if (expanded.comparePoint(node, offset) < 0) expanded.setStart(node, offset);
739
+ }
740
+ if (end && end.from < end.to) {
741
+ const [node, offset] = streamPoint(end.parts, end.to);
742
+ if (expanded.comparePoint(node, offset) > 0) expanded.setEnd(node, offset);
540
743
  }
541
744
 
542
- const expanded = document.createRange();
543
- expanded.setStart(startContainer, startOffset);
544
- expanded.setEnd(endContainer, endOffset);
545
745
  return expanded;
546
746
  }
547
747
 
package/source/emoji.ts CHANGED
@@ -164,6 +164,97 @@ function buildEmojiGroup(group: EmojiGroup): HTMLElement {
164
164
  return elem;
165
165
  }
166
166
 
167
+ // --- недавние ---
168
+
169
+ /** Ключ localStorage со списком недавних смайликов — один на все попапы источника. */
170
+ export const RECENT_EMOJIS_KEY = "brandup-richeditor-recent-emojis";
171
+
172
+ /** Группа недавних в попапе: стоит первой и пересобирается из хранилища при каждом открытии. */
173
+ export const RECENT_GROUP_CLASS = "emoji-recent";
174
+
175
+ /** Сколько недавних хранится и показывается: два ряда панели. */
176
+ export const RECENT_EMOJIS_LIMIT = EMOJI_COLUMNS * 2;
177
+
178
+ // в панели название не показывается, уходит в подпись для скринридера — как у остальных групп
179
+ const RECENT_TITLE = "Недавние";
180
+
181
+ const KNOWN_EMOJIS = new Set(EMOJIS);
182
+
183
+ /**
184
+ * Недавно вставленные смайлики, свежий первым.
185
+ *
186
+ * Хранилище общее и переживает версии пакета, поэтому список чистится до символов, которые
187
+ * панель действительно показывает: мусор и дубликаты отбрасываются. Недоступное или битое
188
+ * хранилище (приватный режим, правленое руками значение) — это пустой список, а не ошибка.
189
+ */
190
+ export function recentEmojis(): string[] {
191
+ let raw: string | null;
192
+ try {
193
+ raw = localStorage.getItem(RECENT_EMOJIS_KEY);
194
+ } catch {
195
+ return [];
196
+ }
197
+ if (!raw) return [];
198
+
199
+ let parsed: unknown;
200
+ try {
201
+ parsed = JSON.parse(raw);
202
+ } catch {
203
+ return [];
204
+ }
205
+ if (!Array.isArray(parsed)) return [];
206
+
207
+ // Set сохраняет порядок вставки, а повторное add место не меняет — первый и остаётся
208
+ const recent = new Set<string>();
209
+ for (const item of parsed) {
210
+ if (typeof item === "string" && KNOWN_EMOJIS.has(item)) recent.add(item);
211
+ if (recent.size === RECENT_EMOJIS_LIMIT) break;
212
+ }
213
+
214
+ return Array.from(recent);
215
+ }
216
+
217
+ /**
218
+ * Запоминает выбор для группы недавних: символ встаёт первым, дубликат схлопывается, хвост за
219
+ * лимитом отбрасывается. Недоступное хранилище вставке не мешает — недавние просто не копятся.
220
+ */
221
+ export function rememberEmoji(emoji: string): void {
222
+ if (!KNOWN_EMOJIS.has(emoji)) return;
223
+
224
+ const next = [emoji, ...recentEmojis().filter((other) => other !== emoji)].slice(0, RECENT_EMOJIS_LIMIT);
225
+ try {
226
+ localStorage.setItem(RECENT_EMOJIS_KEY, JSON.stringify(next));
227
+ } catch {
228
+ // приватный режим или переполненная квота — вставка работает, недавние не запоминаются
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Пересобирает группу недавних в собранном попапе по текущему хранилищу.
234
+ *
235
+ * Зовётся при каждом показе (см. `openEmojiPicker` в ./richeditor): сам попап живёт между
236
+ * открытиями, а хранилище тем временем пополняют и другие попапы страницы. Без недавних группы
237
+ * нет вовсе — пустая первая группа рисовала бы лишнюю отбивку над списком.
238
+ */
239
+ export function refreshRecentEmojis(picker: HTMLElement): void {
240
+ const list = picker.querySelector(".emoji-list");
241
+ if (!list) return;
242
+
243
+ const existing = list.querySelector(`.${RECENT_GROUP_CLASS}`);
244
+ const recent = recentEmojis();
245
+
246
+ if (!recent.length) {
247
+ existing?.remove();
248
+ return;
249
+ }
250
+
251
+ const group = buildEmojiGroup({ title: RECENT_TITLE, emojis: recent });
252
+ group.classList.add(RECENT_GROUP_CLASS);
253
+
254
+ if (existing) existing.replaceWith(group);
255
+ else list.prepend(group);
256
+ }
257
+
167
258
  /**
168
259
  * Собирает попап вставки смайлика.
169
260
  *
@@ -172,7 +263,8 @@ function buildEmojiGroup(group: EmojiGroup): HTMLElement {
172
263
  * два разом всё равно нельзя — {@link PopupManager} держит открытым один.
173
264
  *
174
265
  * Показом и закрытием занимается вызывающий (у редактора для этого есть `openEmojiPicker`):
175
- * здесь только разметка и выбор символа.
266
+ * здесь только разметка и выбор символа. Первой группой — недавние ({@link refreshRecentEmojis});
267
+ * показ обязан освежать её сам, здесь она собирается по состоянию хранилища на сейчас.
176
268
  */
177
269
  export function createEmojiPicker(onPick: (emoji: string) => void): HTMLElement {
178
270
  const picker = DOM.tag("div", { class: `${POPUP_CLASS} ${EMOJI_PICKER_CLASS}` });
@@ -183,6 +275,7 @@ export function createEmojiPicker(onPick: (emoji: string) => void): HTMLElement
183
275
  picker.appendChild(list);
184
276
 
185
277
  for (const group of EMOJI_GROUPS) list.appendChild(buildEmojiGroup(group));
278
+ refreshRecentEmojis(picker);
186
279
 
187
280
  // попап живёт и вне панели, поэтому фокус гасит сам
188
281
  picker.addEventListener("mousedown", (e) => e.preventDefault());
@@ -190,7 +283,11 @@ export function createEmojiPicker(onPick: (emoji: string) => void): HTMLElement
190
283
  const target = (e.target as HTMLElement).closest<HTMLElement>(".emoji");
191
284
  if (!target) return;
192
285
 
193
- onPick(target.textContent ?? "");
286
+ const emoji = target.textContent ?? "";
287
+ // Недавние — про то, к чему тянутся, поэтому запоминается сам выбор, а не вставка:
288
+ // удалась ли она (filterChar, снятый редактор), знает только владелец попапа.
289
+ rememberEmoji(emoji);
290
+ onPick(emoji);
194
291
  PopupManager.close();
195
292
  });
196
293
 
package/source/format.ts CHANGED
@@ -55,6 +55,7 @@ export {
55
55
  createBlock,
56
56
  normalizeWhitespace,
57
57
  mergeAdjacentBlocks,
58
+ splitSoftBreaks,
58
59
  normalizeParagraphs,
59
60
  ensureParagraphs,
60
61
  paragraphsNormalized,