@brandup/ui-richeditor 1.0.48 → 1.0.50

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
  | Опция | Тип | Описание |
@@ -54,6 +56,7 @@ editor.onChange(({ value }) => console.log(value));
54
56
  | `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию все); пустой список оставляет только `paragraph` |
55
57
  | `keepFocus` | `boolean` | Держать ли фокус в поле, пока открыта панель смайликов (по умолчанию да, а на сенсорном устройстве нет) |
56
58
  | `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются); разметка значения при этом разбирается и показывается, кнопок для неё просто нет |
59
+ | `disabled` | `boolean` | Выключенное поле: всё то же, что `readonly`, плюс снятый `contenteditable` — редактор не принимает ни фокус, ни выделение. Значение при этом не нормализуется — выключенное поле его не меняет |
57
60
  | `toolbarContainer` | `HTMLElement \| null` | Контейнер для панели; по умолчанию `document.body` (`position: fixed`). Если задан — панель монтируется в него и позиционируется над ним (`position: absolute`). Контейнер должен быть `position: relative` |
58
61
  | `value` | `string` | Начальное значение |
59
62
  | `filterChar` | `(char) => boolean` | Хук: `false` — отклонить вводимый символ |
@@ -88,7 +91,8 @@ editor.onChange(({ value }) => console.log(value));
88
91
  | `currentBlock: BlockType` | Тип блока под кареткой |
89
92
  | `blockTypes: BlockType[]` | Доступные типы блоков |
90
93
  | `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
91
- | `isToolEnabled(tool): boolean` | Доступен ли инструмент сейчас (внутри кода остальные выключены) |
94
+ | `isToolEnabled(tool, codeActive?): boolean` | Доступен ли инструмент сейчас (внутри кода остальные выключены). Второй аргумент — уже посчитанный признак «в коде»: панель считает его один раз на обновление и передаёт сюда, чтобы не обходить выделение на каждую кнопку |
95
+ | `readonly`, `disabled` | Состояния поля. `readonly` истинно и у выключенного поля: выключенное — это «только чтение плюс снятый contenteditable» |
92
96
  | `activeTools(): ReadonlySet<FormatTool>` | Активные форматы всех инструментов сразу (панель обновляется на каждое движение каретки, поштучный опрос обходил бы содержимое на каждую кнопку) |
93
97
  | `clearFormat(): void` | Снять всё форматирование с выделения (без выделения — со слова под кареткой) |
94
98
  | `clearAllFormat(): void` | Снять всё форматирование со всего содержимого |
@@ -100,6 +104,8 @@ editor.onChange(({ value }) => console.log(value));
100
104
  | `openEmojiPicker(picker, initiator): boolean` | Показать переданный попап смайликов у кнопки; false — этим нажатием он закрылся |
101
105
  | `selection: Selection \| null` | Выделение, если оно внутри редактора (иначе `null`) — единая точка доступа для хоста |
102
106
  | `selectNode(node): void` | Выделить узел внутри редактора: следующая вставка заменит его целиком |
107
+ | `caretWord: string` | Слово под кареткой; пусто при своём выделении, без каретки или когда каретка не в слове |
108
+ | `selectCaretWord(): boolean` | Выделить слово под кареткой — следующая вставка встанет на его место; `false` — выделять нечего |
103
109
  | `onChange(handler)` | Подписка на событие `richeditor-change` |
104
110
  | `destroy(): void` | Разворачивает элемент обратно и освобождает ресурсы |
105
111
 
@@ -128,6 +134,7 @@ editor.onChange(({ value }) => console.log(value));
128
134
 
129
135
  - Формат — переключатель (toggle): повторное применение снимает его.
130
136
  - Применяется к слову целиком: курсор внутри слова или выделение его части → формат охватывает всё слово; исходное выделение/каретка сохраняются.
137
+ - Слово — то, что стоит между пробелами, **без небуквенных знаков по краям**: каретка в слове перед точкой не отдаёт форматированию точку. Внутренние знаки — часть слова: `info@example.com`, `по-русски`, `don't` берутся целиком. Слово не кончается на границе тега (`Дарим <b>ск</b>идку` — одно слово «скидку»), но не пересекает перенос строки и готовую конструкцию (`contenteditable="false"`). Каретка вне слова (сразу за точкой) не расширяется никуда — включается режим набора. Явное выделение только растёт: выделенные знаки из него не выпадают.
131
138
  - **Режим набора**: на пустом месте (между пробелами / в пустом поле) кнопка/хоткей включают «ожидающий» формат — он применится к следующему введённому тексту. Сбрасывается при перемещении каретки, клике или потере фокуса.
132
139
  - Хоткеи `Ctrl/Cmd+B/I/U` и `Ctrl/Cmd+K` (ссылка — показывает в панели поле адреса). Зачёркивание — только кнопкой.
133
140
  - **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов, но не более ~512 КБ снимков суммарно (снимок — это всё содержимое редактора, поэтому на длинном тексте старые шаги вытесняются раньше).
@@ -164,6 +171,8 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
164
171
 
165
172
  Попап — слой над полем, поэтому показывает его редактор (`openEmojiPicker()`): на время работы он придерживает правку, чтобы нормализация не обрезала пробел у каретки. Хост со своей кнопкой собирает попап сам и передаёт его сюда — так делает [`@brandup/ui-messageeditor`](../brandup-ui-messageeditor).
166
173
 
174
+ Первой группой в списке стоят **недавние** (`.emoji-recent`) — до двух рядов последних выбранных символов, свежий первым. Хранятся они в `localStorage` (ключ `RECENT_EMOJIS_KEY`), поэтому общие для всех попапов источника и переживают перезагрузку; пока ничего не выбрано — группы нет вовсе. Освежает её `openEmojiPicker()` при каждом показе: попап живёт между открытиями, а хранилище тем временем пополняют и другие попапы. Запоминается сам выбор, а не вставка — недавние про то, к чему тянутся. Недоступное хранилище (приватный режим) вставке не мешает — недавние просто не копятся. Пакет экспортирует `recentEmojis()`, `rememberEmoji()` и `refreshRecentEmojis(picker)` — хосту с собственным показом попапа освежать группу нужно самому.
175
+
167
176
  ## Многострочный режим: абзацы и переносы
168
177
 
169
178
  При `multiline: true` контент структурируется по абзацам:
@@ -198,7 +207,9 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
198
207
 
199
208
  Обычный текст — такой же тип, а не «тип не задан»: он есть в наборе всегда, им становится содержимое, не попавшее ни в какой блок, и в него же блок возвращают. Кнопки в панели (`.block-button`) получают только остальные типы.
200
209
 
201
- Кнопка спойлера временно скрыта (`HIDDEN_TOOLS` в `./toolbar`): сам инструмент работает значение разбирается, показывается и сохраняется, правку можно вызвать из кода (`applyFormat`), но в панель он пока не выводится.
210
+ Цитата рисуется плашкой по ширине содержимого, прижатой к левому краю: с подложкой пустое место справа от короткой строки читалось бы её частью. Длинная цитата переносится по границе редактора. Цвета и отступы задаются переменными `--richeditor-quote-*` (см. «CSS»).
211
+
212
+ Открывающая ограда в чужом маркдауне часто приходит с меткой языка (```` ```text ````) — блок она открывает так же, а сама метка отбрасывается: значение кита её не хранит, обратно уезжает голая ограда. Закрывает блок только голая ограда — та же строка с меткой внутри блока остаётся его содержимым.
202
213
 
203
214
  ### Ссылка
204
215
 
@@ -217,7 +228,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
217
228
  | | |
218
229
  | --- | --- |
219
230
  | Выделение | становится ссылкой |
220
- | Каретка в слове | ссылкой становится слово целиком, как и у остальных инструментов |
231
+ | Каретка в слове | ссылкой становится слово целиком, как и у остальных инструментов; знаки внутри — часть слова, поэтому адрес или почта оборачиваются целиком |
221
232
  | Каретка в готовой ссылке | меняется её адрес — целиком, а не по куску выделения |
222
233
  | Каретка вне слова | кнопка недоступна: оборачивать нечего |
223
234
 
@@ -262,13 +273,21 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
262
273
  - **multiline** сохраняет абзацы `<p>` и мягкие переносы `<br>`, разбивая текущий абзац по каретке; **single-line** — инлайн, абзацы/переносы становятся пробелами;
263
274
  - хук `filterPaste` остаётся в силе: вернул `null` — вставка отклоняется; изменил текст (обрезка по длине, фильтр по типу) — форматирование не сохраняется, вставляется очищенный текст.
264
275
 
265
- Если `text/html` нет (или форматирование выключено), вставляется простой текстпо той же модели абзацев:
276
+ При `storage: "markdown"` разметку сохраняет и вставка простого текста: раз значение хранится этой разметкой, маркеры во вставляемом тексте значат то же, что в значении, и разбираются тем же `deserialize` с теми же наборами инструментов и блоков. Ограничение действует и здесь: **при любой вставке применяется только включённый формат** — маркер снятого инструмента остаётся текстом, как остался бы и в значении. Изменённый хуком `filterPaste` текст вставляется буквально, а внутри блока кода текст литерален всегда.
277
+
278
+ `text/html` точнее описывает скопированное, но лишь когда несёт собственную разметку. Голый текст тоже приезжает html-ем — редакторы кода отдают исходник маркдауна строками в `<div>` внутри общей обёртки, — и такой «плоский» html не знает ничего сверх `text/plain`, а маркеры в тексте понимает только разбор разметкой хранения: при плоском html первым идёт он. Границы строк-`<div>` при этом становятся переносами — склейка соседних строк встык потеряла бы и текст, и разметку.
279
+
280
+ Если `text/html` нет (или форматирование выключено), а разбирать маркеры не по чему, вставляется простой текст — по той же модели абзацев:
266
281
 
267
282
  - **multiline**, режим `block` — пустая строка разделяет абзацы `<p>`, одиночный перенос остаётся мягким `<br>`;
268
283
  - **multiline**, режим `break` — абзацы не создаются, все переносы мягкие;
269
284
  - **single-line** — строки склеиваются пробелами.
270
285
 
271
- Каретка в обоих случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
286
+ Каретка во всех случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
287
+
288
+ ### Бросок файла
289
+
290
+ Текстовый файл, брошенный в редактор, вставляется содержимым — тем же путём, что вставка из буфера: хук `filterPaste`, разбор разметкой хранения включённым набором, один шаг истории. Текстовым считается файл с типом `text/*`, а без типа — `.md`/`.markdown`/`.txt`/`.text` по имени: у маркдауна тип в системе часто не зарегистрирован. Несколько файлов вставляются подряд через пустую строку. Каретка встаёт в точку броска, если браузер умеет её назвать (`caretPositionFromPoint`/`caretRangeFromPoint`). Любой другой бросок гасится: свободное перетаскивание прошло бы мимо истории и фильтров хоста.
272
291
 
273
292
  ## Формат хранения
274
293
 
@@ -300,6 +319,10 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
300
319
  | Переменная | По умолчанию | Что задаёт |
301
320
  | --- | --- | --- |
302
321
  | `--richeditor-quote-line` | `rgba(0,0,0,.2)` | Линия слева у цитаты |
322
+ | `--richeditor-quote-line-width` | `3px` | Толщина линии цитаты |
323
+ | `--richeditor-quote-fill` | `rgba(0,0,0,.04)` | Подложка цитаты |
324
+ | `--richeditor-quote-padding-tb` | `5px` | Вертикальный отступ цитаты — тот же, что у абзаца, чтобы строка не прыгала при смене типа блока |
325
+ | `--richeditor-quote-padding-lr` | `10px` | Горизонтальный отступ цитаты — насколько текст отходит от линии и от края подложки |
303
326
  | `--richeditor-code-fill` | `rgba(0,0,0,.06)` | Подложка кода — и моноширинного, и блока |
304
327
  | `--richeditor-code-font` | `ui-monospace, …` | Шрифт кода |
305
328
  | `--richeditor-spoiler-fill` | `rgba(0,0,0,.14)` | Плашка спойлера |
@@ -310,6 +333,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
310
333
  | `--richeditor-underline-room` | `2px` | Место под подчёркивание последней строки: рисуется оно ниже текста, но в раскладке места не занимает, и без запаса его срезает край прокручиваемой коробки |
311
334
  | `--richeditor-toolbar-padding` | `3px` | Поля панели |
312
335
  | `--richeditor-toolbar-button-size` | `34px` | Кнопка панели; по ней же высота поля адреса |
336
+ | `--richeditor-toolbar-edge-gap` | `4px` | Зазор панели от краёв экрана; то же значение зашито константой в позиционировании (`EDGE_GAP`), менять их нужно вместе |
313
337
  | `--richeditor-emoji-size` | `32px` | Ячейка в панели смайликов |
314
338
  | `--richeditor-emoji-rows` | `8` | Запасная высота нарисованной не сразу группы; своё значение панель ставит на каждую группу |
315
339
 
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.48",
30
+ "version": "1.0.50",
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.48"
35
+ "@brandup/ui-kit": "^1.0.50"
36
36
  },
37
37
  "files": [
38
38
  "source",
package/source/editing.ts CHANGED
@@ -103,7 +103,13 @@ export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType
103
103
  * null — делить нечего: выделение и так захватило все строки блока.
104
104
  */
105
105
  function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElement | null {
106
- const breaks = Array.from(block.querySelectorAll("br"));
106
+ // Хвостовой перенос — заполнитель последней строки, а не разделитель: резать по нему
107
+ // нельзя, иначе каретка за ним считалась бы отдельной строкой и в значении появлялась бы
108
+ // пустая строка, которой не набирали. Ищется по дереву, как в pad(): мягкий перенос
109
+ // (а с ним и заполнитель) живёт и внутри инлайнового тега.
110
+ let trailing: Node | null = block.lastChild;
111
+ while (trailing?.lastChild) trailing = trailing.lastChild;
112
+ const breaks = Array.from(block.querySelectorAll("br")).filter((br) => br !== trailing);
107
113
 
108
114
  // последний перенос перед выделением и первый после него — по ним и режем
109
115
  const head = breaks.filter((br) => range.comparePoint(br, 0) < 0).pop();
@@ -112,6 +118,16 @@ function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElem
112
118
 
113
119
  const original = blockTypeOf(block) ?? DEFAULT_BLOCK;
114
120
 
121
+ // Кусок, потерявший хвостовой разделитель, кончается переносом — значит, его последняя
122
+ // строка пустая. Одинокий хвостовой перенос — это заполнитель (см. ensureParagraphs),
123
+ // и без второго пустая строка пропала бы и с экрана, и из значения. Перенос ищется по
124
+ // дереву, а не по верхнему уровню: мягкий перенос живёт и внутри инлайнового тега.
125
+ const pad = (piece: HTMLElement) => {
126
+ let last: Node | null = piece.lastChild;
127
+ while (last?.lastChild) last = last.lastChild;
128
+ if (last?.nodeName === "BR") piece.appendChild(document.createElement("br"));
129
+ };
130
+
115
131
  // Хвост выносим первым: он дальше по дереву, и вынос головы сдвинул бы его границы.
116
132
  // Сам перенос-разделитель уходит вместе с ним — строки разъезжаются по блокам.
117
133
  const cut = (from: "before" | "after", br: HTMLElement): HTMLElement => {
@@ -128,6 +144,8 @@ function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElem
128
144
  const piece = document.createElement(BLOCK_TYPES[original].tag);
129
145
  piece.appendChild(part.extractContents());
130
146
  br.remove();
147
+
148
+ if (from === "before") pad(piece);
131
149
  fillEmptyParagraph(piece);
132
150
 
133
151
  return piece;
@@ -139,6 +157,11 @@ function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElem
139
157
  const created = retagBlock(block, type);
140
158
  if (!BLOCK_TYPES[type].inline) unwrapFormatting(created);
141
159
 
160
+ // Вынос хвоста забрал разделитель и у самого блока: пустая строка в конце выделения
161
+ // осталась бы без заполнителя. Кусок «после» в заполнителе не нуждается — его хвост
162
+ // и есть прежний хвост блока, заполнитель там свой.
163
+ if (tail) pad(created);
164
+
142
165
  block.replaceWith(created);
143
166
  fillEmptyParagraph(created);
144
167
 
@@ -276,11 +299,6 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
276
299
  caretToStart(next);
277
300
  }
278
301
 
279
- /**
280
- * Разрезает элемент по каретке: содержимое после неё уходит в такой же элемент следом,
281
- * а диапазон встаёт между половинами — вставленное туда окажется снаружи обоих. Пустые
282
- * половины не оставляем: печатать в них было бы нечего, а каретка попадала бы внутрь.
283
- */
284
302
  /**
285
303
  * Выводит хвост ссылки из неё, разрезав по каретке: перед переносом строки.
286
304
  *
@@ -478,14 +496,20 @@ export function sanitizePastedHtml(
478
496
  const holder = document.createElement("template");
479
497
  holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true, types);
480
498
 
481
- // внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
482
- // иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
483
- const walker = document.createTreeWalker(holder.content, NodeFilter.SHOW_TEXT);
484
- for (let t = walker.nextNode(); t; t = walker.nextNode())
485
- t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
486
-
499
+ // Внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем, иначе
500
+ // литеральные \n (pre-wrap) и отступы дают лишние переносы. Кроме блока без инлайновой
501
+ // разметки (код): там переносы и отступы — содержимое, а не вёрстка.
487
502
  const paras = Array.from(holder.content.children) as HTMLElement[];
488
- for (const p of paras) trimParagraphEdges(p);
503
+ for (const p of paras) {
504
+ const type = blockTypeOf(p);
505
+ if (type && !BLOCK_TYPES[type].inline) continue;
506
+
507
+ const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
508
+ for (let t = walker.nextNode(); t; t = walker.nextNode())
509
+ t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
510
+
511
+ trimParagraphEdges(p);
512
+ }
489
513
 
490
514
  // отбрасываем пустые краевые абзацы (ведущие/хвостовые \n и <br>-обёртки из буфера),
491
515
  // иначе перед и после вставленного текста появляются пустые строки
@@ -495,29 +519,173 @@ export function sanitizePastedHtml(
495
519
  return paras;
496
520
  }
497
521
 
522
+ /**
523
+ * Несёт ли разобранная вставка собственную разметку: блок не-абзац либо инлайновый тег — кроме
524
+ * `<br>`, перенос есть и в простом тексте. Плоская вставка не знает ничего сверх `text/plain`:
525
+ * так выглядит скопированный из редактора кода исходник — голые строки в `<div>`.
526
+ */
527
+ export function hasPastedMarkup(paras: HTMLElement[]): boolean {
528
+ return paras.some(
529
+ (p) => p.tagName !== "P" || Array.from(p.querySelectorAll("*")).some((el) => el.tagName !== "BR")
530
+ );
531
+ }
532
+
533
+ /**
534
+ * Похож ли сырой html буфера на документ, а не на обёртку вокруг голых строк. Смотрим на сырой
535
+ * html, потому что санитизация разницу стирает: и строки-`<div>` из редактора кода, и абзацы
536
+ * веб-страницы выходят из неё одинаковыми `<p>`. А разница решает, чей разбор первый: в документе
537
+ * маркеры в тексте — буквальные символы, которые видит и читатель страницы, в голых строках —
538
+ * разметка хранения.
539
+ */
540
+ export function isDocumentHtml(html: string): boolean {
541
+ const holder = document.createElement("template");
542
+ holder.innerHTML = html;
543
+
544
+ return !!holder.content.querySelector("p, h1, h2, h3, h4, h5, h6, li, table");
545
+ }
546
+
547
+ /**
548
+ * Разбирает простой текст из буфера как markdown — для редакторов, хранящих значение этой
549
+ * разметкой: маркеры во вставляемом тексте значат то же, что в значении, и разбираются тем же
550
+ * `deserialize` с теми же наборами. Пустой результат — вставлять нечего (вызывающий откатится
551
+ * на простой текст).
552
+ *
553
+ * В отличие от {@link sanitizePastedHtml}, пробелы не схлопываются: это текст, а не вёрстка —
554
+ * переносы и отступы в нём и так значат сами себя.
555
+ */
556
+ export function parsePastedMarkdown(
557
+ text: string,
558
+ tools: FormatTool[],
559
+ markers: FormatMarkers,
560
+ types: BlockType[] = [DEFAULT_BLOCK],
561
+ separate = true
562
+ ): HTMLElement[] {
563
+ const holder = document.createElement("template");
564
+ holder.innerHTML = deserialize(text, "markdown", tools, markers, true, types, separate);
565
+
566
+ return Array.from(holder.content.children) as HTMLElement[];
567
+ }
568
+
569
+ // Слово — буквы и цифры любого алфавита плюс подчёркивание. \w здесь не годится: он знает
570
+ // только латиницу, и кириллическое слово по нему словом не будет.
571
+ const WORD_CHAR = /[\p{L}\p{N}_]/u;
572
+
573
+ const isWordChar = (ch: string | undefined) => !!ch && WORD_CHAR.test(ch);
574
+
575
+ /** Кусок текстового потока блока: текстовый узел и его начало в общей строке. */
576
+ type StreamPart = { node: Text; start: number };
577
+
578
+ /**
579
+ * Текстовый поток блока, в котором стоит узел: текст его текстовых узлов одной строкой плюс
580
+ * карта соответствия. Слово не кончается на границе узла — «сло<b>во</b>» это одно слово,
581
+ * и расширение обязано её пересекать. Пересекать нельзя другое:
582
+ * - переносы строк (<br>) — слова по разные стороны переноса разные, в поток идёт разделитель;
583
+ * - готовые конструкции (contenteditable="false" — переменные и спинтакс messageeditor):
584
+ * они атомарны и правятся своим окном, расширению внутри них делать нечего.
585
+ */
586
+ function blockStream(editable: HTMLElement, node: Node): { text: string; parts: StreamPart[] } {
587
+ // Блок — прямой потомок редактора (p, blockquote, pre); текст без блоков лежит в нём самом
588
+ let root: Node = node;
589
+ while (root.parentNode && root.parentNode !== editable) root = root.parentNode;
590
+ if (!root.parentNode) root = editable; // узел вне редактора — поток по самому редактору не собрать
591
+ const scope = root.nodeType === Node.ELEMENT_NODE ? (root as HTMLElement) : editable;
592
+
593
+ let text = "";
594
+ const parts: StreamPart[] = [];
595
+
596
+ const walk = (current: Node) => {
597
+ if (current.nodeType === Node.TEXT_NODE) {
598
+ parts.push({ node: current as Text, start: text.length });
599
+ text += current.textContent ?? "";
600
+ return;
601
+ }
602
+ if (current.nodeType !== Node.ELEMENT_NODE) return;
603
+ const elem = current as HTMLElement;
604
+ if (elem.tagName === "BR" || elem.getAttribute("contenteditable") === "false") {
605
+ text += "\n"; // разделитель: слово через перенос или конструкцию не перепрыгивает
606
+ return;
607
+ }
608
+ for (const child of Array.from(elem.childNodes)) walk(child);
609
+ };
610
+ walk(scope);
611
+
612
+ return { text, parts };
613
+ }
614
+
615
+ /** Позиция в потоке → узел и смещение. Позиция всегда из этого же потока, место найдётся. */
616
+ function streamPoint(parts: StreamPart[], position: number): [Text, number] {
617
+ let holder = parts[0];
618
+ for (const part of parts) {
619
+ if (part.start > position) break;
620
+ holder = part;
621
+ }
622
+ return [holder.node, Math.min(position - holder.start, holder.node.textContent?.length ?? 0)];
623
+ }
624
+
498
625
  /**
499
626
  * Диапазон, расширенный до целых слов на границах (для применения формата к слову целиком).
500
627
  * Возвращает новый Range и не трогает выделение — вызывающий сам решает, править ли по нему
501
628
  * и когда двигать каретку.
629
+ *
630
+ * Слово — то, что стоит между пробелами, без небуквенных знаков по краям: внутренние знаки
631
+ * остаются его частью («info@example.com», «по-русски»), а точка после слова — нет. Иначе
632
+ * каретка в слове перед точкой отдавала бы форматированию и точку, которую туда не просили.
633
+ * Явное выделение только растёт: выделенное вместе со знаками таким и останется. Каретка
634
+ * не в слове вовсе (сразу за точкой) не расширяется никуда — схлопнутый диапазон редактор
635
+ * понимает как «формат для того, что будут набирать».
502
636
  */
503
637
  export function expandRangeToWords(editable: HTMLElement, range: Range): Range {
504
638
  const { startContainer, endContainer } = range;
505
- let startOffset = range.startOffset;
506
- let endOffset = range.endOffset;
507
639
 
508
- if (startContainer.nodeType === Node.TEXT_NODE && editable.contains(startContainer)) {
509
- const text = startContainer.textContent ?? "";
510
- while (startOffset > 0 && !/\s/.test(text[startOffset - 1])) startOffset--;
640
+ const expanded = document.createRange();
641
+ expanded.setStart(startContainer, range.startOffset);
642
+ expanded.setEnd(endContainer, range.endOffset);
643
+
644
+ const expandIn = (container: Node, offset: number): { parts: StreamPart[]; from: number; to: number } | null => {
645
+ if (container.nodeType !== Node.TEXT_NODE || !editable.contains(container)) return null;
646
+
647
+ const { text, parts } = blockStream(editable, container);
648
+ const part = parts.find((candidate) => candidate.node === container);
649
+ if (!part) return null;
650
+ const position = part.start + offset;
651
+
652
+ // до пробелов…
653
+ let from = position;
654
+ let to = position;
655
+ while (from > 0 && !/\s/.test(text[from - 1])) from--;
656
+ while (to < text.length && !/\s/.test(text[to])) to++;
657
+
658
+ // …и без небуквенных знаков по краям
659
+ while (from < to && !isWordChar(text[from])) from++;
660
+ while (to > from && !isWordChar(text[to - 1])) to--;
661
+
662
+ return { parts, from, to };
663
+ };
664
+
665
+ const start = expandIn(startContainer, range.startOffset);
666
+ const end = range.collapsed ? start : expandIn(endContainer, range.endOffset);
667
+
668
+ if (range.collapsed) {
669
+ // каретка стоит вне слова (за точкой) — расширять нечего
670
+ if (!start || start.from >= start.to) return expanded;
671
+ const position = start.parts.find((part) => part.node === startContainer)!.start + range.startOffset;
672
+ if (position < start.from || position > start.to) return expanded;
673
+
674
+ expanded.setStart(...streamPoint(start.parts, start.from));
675
+ expanded.setEnd(...streamPoint(start.parts, start.to));
676
+ return expanded;
511
677
  }
512
678
 
513
- if (endContainer.nodeType === Node.TEXT_NODE && editable.contains(endContainer)) {
514
- const text = endContainer.textContent ?? "";
515
- while (endOffset < text.length && !/\s/.test(text[endOffset])) endOffset++;
679
+ // выделение только растёт: правая часть слова добирается, выделенные знаки не выпадают
680
+ if (start && start.from < start.to) {
681
+ const [node, offset] = streamPoint(start.parts, start.from);
682
+ if (expanded.comparePoint(node, offset) < 0) expanded.setStart(node, offset);
683
+ }
684
+ if (end && end.from < end.to) {
685
+ const [node, offset] = streamPoint(end.parts, end.to);
686
+ if (expanded.comparePoint(node, offset) > 0) expanded.setEnd(node, offset);
516
687
  }
517
688
 
518
- const expanded = document.createRange();
519
- expanded.setStart(startContainer, startOffset);
520
- expanded.setEnd(endContainer, endOffset);
521
689
  return expanded;
522
690
  }
523
691
 
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
@@ -57,4 +57,5 @@ export {
57
57
  mergeAdjacentBlocks,
58
58
  normalizeParagraphs,
59
59
  ensureParagraphs,
60
+ paragraphsNormalized,
60
61
  } from "./paragraphs";
package/source/history.ts CHANGED
@@ -29,6 +29,7 @@ export class EditorHistory {
29
29
  private __lastKind: HistoryKind | null = null;
30
30
  private __lastTime = 0;
31
31
  private __chars = 0; // суммарный объём снимков отмены — см. MAX_CHARS
32
+ private __redoChars = 0; // объём снимков повтора: длинная серия undo копит их так же
32
33
 
33
34
  constructor(root: HTMLElement) {
34
35
  this.__root = root;
@@ -70,6 +71,7 @@ export class EditorHistory {
70
71
 
71
72
  this.__push(snap);
72
73
  this.__redo = [];
74
+ this.__redoChars = 0;
73
75
  }
74
76
 
75
77
  // Puts a snapshot on the undo stack, dropping the oldest ones beyond the limits. Every push
@@ -89,7 +91,15 @@ export class EditorHistory {
89
91
  if (!prev) return false;
90
92
 
91
93
  this.__chars -= prev.html.length;
92
- this.__redo.push(this.__snapshot());
94
+
95
+ // Бюджет у повтора тот же, что и у отмены: серия undo перекладывает снимки сюда,
96
+ // и без учёта их объёма ограничение MAX_CHARS теряло бы смысл.
97
+ const snap = this.__snapshot();
98
+ this.__redo.push(snap);
99
+ this.__redoChars += snap.html.length;
100
+ while (this.__redo.length > MAX_DEPTH || (this.__redoChars > MAX_CHARS && this.__redo.length > 1))
101
+ this.__redoChars -= this.__redo.shift()!.html.length;
102
+
93
103
  this.__restore(prev);
94
104
  this.__lastKind = null; // следующая печать начнёт новый шаг
95
105
  return true;
@@ -100,6 +110,7 @@ export class EditorHistory {
100
110
  const next = this.__redo.pop();
101
111
  if (!next) return false;
102
112
 
113
+ this.__redoChars -= next.html.length;
103
114
  this.__push(this.__snapshot());
104
115
  this.__restore(next);
105
116
  this.__lastKind = null;
package/source/index.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  export { default } from "./richeditor";
2
2
  export * from "./richeditor";
3
- export { EMOJIS, EMOJI_GROUPS, EMOJI_PICKER_CLASS, createEmojiPicker, type EmojiGroup } from "./emoji";
3
+ export {
4
+ EMOJIS,
5
+ EMOJI_GROUPS,
6
+ EMOJI_PICKER_CLASS,
7
+ RECENT_EMOJIS_KEY,
8
+ RECENT_EMOJIS_LIMIT,
9
+ RECENT_GROUP_CLASS,
10
+ createEmojiPicker,
11
+ recentEmojis,
12
+ rememberEmoji,
13
+ refreshRecentEmojis,
14
+ type EmojiGroup,
15
+ } from "./emoji";
4
16
  export {
5
17
  ALL_BLOCK_TYPES,
6
18
  ALL_EDITOR_ACTIONS,