@brandup/ui-richeditor 1.0.43 → 1.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,7 +49,8 @@ editor.onChange(({ value }) => console.log(value));
49
49
  | `placeholder` | `string \| null` | Текст-заглушка |
50
50
  | `multiline` | `boolean` | Многострочный режим |
51
51
  | `paragraph` | `"block" \| "break"` | Что делает Enter: новый абзац (по умолчанию) или мягкий перенос |
52
- | `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию только `paragraph`) |
52
+ | `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию все); пустой список оставляет только `paragraph` |
53
+ | `keepFocus` | `boolean` | Держать ли фокус в поле, пока открыта панель смайликов (по умолчанию да, а на сенсорном устройстве нет) |
53
54
  | `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются) |
54
55
  | `toolbarContainer` | `HTMLElement \| null` | Контейнер для панели; по умолчанию `document.body` (`position: fixed`). Если задан — панель монтируется в него и позиционируется над ним (`position: absolute`). Контейнер должен быть `position: relative` |
55
56
  | `value` | `string` | Начальное значение |
@@ -70,7 +71,8 @@ editor.onChange(({ value }) => console.log(value));
70
71
  | `setValue(value: string): void` | Установить значение (нормализует, генерирует `change`) |
71
72
  | `flushChange(): void` | Доставить отложенное `change` немедленно (см. ниже) |
72
73
  | `getLength(): number` | Длина текста (без учёта переводов строк) |
73
- | `focus(): void` | Установить фокус |
74
+ | `focus(atEnd?): void` | Установить фокус; каретку не двигает, а при `atEnd` ставит её в конец, если её ещё не было |
75
+ | `releaseFocus(): void` | Отпустить фокус, запомнив каретку — на время своего окна |
74
76
  | `applyFormat(tool): void` | Переключить формат на выделении (слово целиком) |
75
77
  | `applyBlock(type): void` | Переключить тип блоков под выделением; повторное применение возвращает обычный текст |
76
78
  | `applyCode(): void` | Код по выделению: моноширинный для части строки, блок — для целых строк |
@@ -86,12 +88,19 @@ editor.onChange(({ value }) => console.log(value));
86
88
  | `canUndo`, `canRedo` | Доступность отмены/повтора |
87
89
  | `applyAction(action): void` | Выполнить действие панели (`erase`/`undo`/`redo`) |
88
90
  | `isActionEnabled(action): boolean` | Доступно ли действие сейчас |
89
- | `insertText(text): void` | Вставить текст в каретку (или вместо выделения) с учётом режима набора |
91
+ | `insertText(text): void` | Вставить текст в каретку (или вместо выделения) с учётом режима набора; без фокуса вставляет по снятой каретке |
92
+ | `openEmojiPicker(initiator, container?): void` | Открыть панель смайликов у кнопки; повторный вызов у той же кнопки её закрывает |
90
93
  | `selection: Selection \| null` | Выделение, если оно внутри редактора (иначе `null`) — единая точка доступа для хоста |
91
94
  | `selectNode(node): void` | Выделить узел внутри редактора: следующая вставка заменит его целиком |
92
95
  | `onChange(handler)` | Подписка на событие `richeditor-change` |
93
96
  | `destroy(): void` | Разворачивает элемент обратно и освобождает ресурсы |
94
97
 
98
+ ### Фокус на время своего слоя
99
+
100
+ Окно хоста (модальное) забирает фокус всегда: правка идёт в нём, а мигающая под ним каретка только сбивает с толку. Панель смайликов — наоборот, слой над полем: каретка на виду, и видно, куда встанет символ. На сенсорном устройстве фокус вместо этого поднимает экранную клавиатуру, которая саму панель и закрывает, — там его отпускают и для неё (`keepFocus`).
101
+
102
+ Каретка при этом не теряется: она снимается текстовыми смещениями, `insertText()` возвращает её сам (вставка из панели идёт без фокуса), а `focus()` — вместе с фокусом. Правку на это время придерживает вызывающий: снятие фокуса не конец ввода, и нормализация обрезала бы пробел у каретки.
103
+
95
104
  ## Событие изменения
96
105
 
97
106
  `getValue()` считает значение по DOM и точен всегда. А вот **уведомление** `richeditor-change` при печати доставляется с задержкой: сериализация — самая дорогая операция редактора (обход всего содержимого), а печать даёт `input` на каждый символ.
@@ -135,13 +144,15 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
135
144
  | `undo` | Отменить | `undo()` | история пуста |
136
145
  | `redo` | Повторить | `redo()` | нечего повторять |
137
146
 
138
- Кнопки действий (`.action-button`) отделены от кнопок форматирования (`.format-button`) разделителем `.split` и получают атрибут `disabled`, когда действие недоступно. Панель показывается и в том случае, если инструментов форматирования нет, а действия заданы.
147
+ Кнопки действий (`.action-button`) стоят в одном ряду с инструментами (`.format-button`) и блоками (`.block-button`) — всё это правка оформления — и получают атрибут `disabled`, когда действие недоступно. Разделитель `.split` отбивает только кнопки хоста: они про другое. Панель показывается и в том случае, если инструментов форматирования нет, а действия заданы.
139
148
 
140
149
  ### Панель смайликов
141
150
 
142
151
  Кнопка `emoji` открывает под панелью попап `.ui-richeditor-emoji` со списком символов (`EMOJIS` — экспортируется пакетом). Выбранный символ вставляется через `insertText()`, то есть в текущую каретку и с учётом ожидающих форматов режима набора; попап после выбора закрывается.
143
152
 
144
- Открытием и закрытием управляет `PopupManager` из [`@brandup/ui-kit`](../brandup-ui-kit) — оттуда же приходят базовые стили `.ui-popup`. Ни кнопка, ни попап не забирают фокус у редактора (`mousedown` гасится), поэтому каретка и выделение сохраняются. Список кнопок собирается лениво, при первом открытии.
153
+ Открытием и закрытием управляет `PopupManager` из [`@brandup/ui-kit`](../brandup-ui-kit) — оттуда же приходят базовые стили `.ui-popup`. Ни кнопка, ни попап не забирают фокус сами (`mousedown` гасится), поэтому каретка и выделение сохраняются; на сенсорном устройстве поле отдаёт фокус намеренно (см. «Фокус на время своего слоя»), и вставка идёт по снятой каретке. Список кнопок собирается лениво, при первом открытии.
154
+
155
+ Панель — слой редактора, поэтому открывает её он сам (`openEmojiPicker()`): на время её работы он придерживает правку, чтобы нормализация не обрезала пробел у каретки. Хост может открыть её у своей кнопки, передав вторым аргументом контейнер, — так делает [`@brandup/ui-messageeditor`](../brandup-ui-messageeditor).
145
156
 
146
157
  ## Многострочный режим: абзацы и переносы
147
158
 
@@ -155,16 +166,18 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
155
166
 
156
167
  В этом режиме абзацных блоков в содержимом нет вовсе: значение загружается плоским текстом, где каждый `\n` становится `<br>` внутри единственного `<p>`. Иначе два переноса рисовались бы двумя абзацами, а у хоста без отступов между ними это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
157
168
 
158
- Хвостовой перенос абзаца отбрасывается (это `<br>`-заполнитель); пустая строка делается отдельным абзацем.
169
+ Хвостовой перенос отбрасывается ровно один — это `<br>`-заполнитель, без которого не видна последняя строка. Набранные пустые строки сохраняются и в поле, и в значении, где бы они ни стояли — в начале блока, в середине или в конце.
159
170
 
160
- При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются.
171
+ Блоки в этом режиме появляются побочно правкой блочного типа, — поэтому нормализация сводит соседние абзацы обратно в один: их граница уходила бы в значение пустой строкой, которой на экране нет.
172
+
173
+ При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются — кроме одного: пустой абзац сразу за блоком другого типа остаётся. Это единственное место, где каретка стоит вне цитаты или кода, и без него правка запиралась бы в блоке. В значение такой абзац не попадает — хвост значения обрезается.
161
174
 
162
175
  ## Блоки: цитата и код
163
176
 
164
- Кроме абзаца многострочный режим знает и другие типы блоков верхнего уровня. Они подключаются **явно** их понимает не каждый получатель:
177
+ Кроме абзаца многострочный режим знает и другие типы блоков верхнего уровня цитату и блок кода. Доступны они по умолчанию; поле, где они ни к чему, ограничивают пустым списком:
165
178
 
166
179
  ```typescript
167
- new RichEditor(elem, { format: true, multiline: true, blocks: ["quote", "code"] });
180
+ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // только обычный текст
168
181
  ```
169
182
 
170
183
  | Тип | Тег | Разметка | Enter внутри |
@@ -175,7 +188,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: ["quote", "code"]
175
188
 
176
189
  Обычный текст — такой же тип, а не «тип не задан»: он есть в наборе всегда, им становится содержимое, не попавшее ни в какой блок, и в него же блок возвращают. Кнопки в панели (`.block-button`) получают только остальные типы.
177
190
 
178
- Кнопки кода (моноширинного и блока) и спойлера временно скрыты (`HIDDEN_TOOLS`/`HIDDEN_BLOCKS` в `./toolbar`): сами возможности работают — значение разбирается, показывается и сохраняется, правку можно вызвать из кода (`applyFormat`, `applyBlock`, `applyCode`), — но в панель они пока не выводятся.
191
+ Кнопка спойлера временно скрыта (`HIDDEN_TOOLS` в `./toolbar`): сам инструмент работает — значение разбирается, показывается и сохраняется, правку можно вызвать из кода (`applyFormat`), — но в панель он пока не выводится.
179
192
 
180
193
  ### Одна кнопка на моноширинный и блок кода
181
194
 
@@ -188,6 +201,8 @@ new RichEditor(elem, { format: true, multiline: true, blocks: ["quote", "code"]
188
201
 
189
202
  Тем же занимается `applyCode()`, а `isCodeActive()` отвечает, включён ли код в любом виде — им подсвечена кнопка.
190
203
 
204
+ Перенос строки в моноширинном тоже не живёт: значение берёт оттуда голый текст, и строка пропала бы — поле показывало бы две, а получатель увидел одну. Поэтому Enter разрезает моноширинный: форматирование продолжается на новой строке, только если там что-то осталось.
205
+
191
206
  В коде разметки нет — ни в моноширинном, ни в блоке: значение берёт оттуда голый текст, и любое форматирование внутри до получателя не доедет. Поэтому при переходе в моноширинный прежнее форматирование с этого куска снимается, внутри кода остальные инструменты недоступны (`isToolEnabled()` — кнопки гаснут), а разметка, попавшая внутрь как-то ещё (вставка, чужое значение), вычищается при первой же нормализации. Если включено только что-то одно, кнопка остаётся обычной: инструмента (`.format-button`) или блока (`.block-button`).
192
207
 
193
208
  Переключение:
@@ -198,7 +213,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: ["quote", "code"]
198
213
 
199
214
  Особенности блока кода: инлайновое форматирование внутри не размечается (написанное остаётся буквальным) и снимается при переключении в этот тип, а пробелы внутри не схлопываются — отступы там часть текста.
200
215
 
201
- Подряд идущие строки с маркером цитаты — одна цитата; пустая строка между ними разделяет цитаты. Незакрытое ограждение блоком не считается: его строки остаются текстом, иначе одна случайная кавычка съедала бы весь остаток сообщения. Блок отключённого типа, пришедший вставкой или из значения, сохраняется как обычный текст — разметки от него в значении не будет, но текст не теряется.
216
+ Подряд идущие строки с маркером цитаты — одна цитата; пустая строка между ними разделяет цитаты. В режиме мягких переносов пустой строки между блоками нет, поэтому соседние цитаты там склеиваются в одну сразу в поле — иначе оно показывало бы два блока, а в значении и у получателя был бы один. Незакрытое ограждение блоком не считается: его строки остаются текстом, иначе одна случайная кавычка съедала бы весь остаток сообщения. Блок отключённого типа, пришедший вставкой или из значения, сохраняется как обычный текст — разметки от него в значении не будет, но текст не теряется.
202
217
 
203
218
  В режиме `paragraph: "break"` блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два.
204
219
 
@@ -227,6 +242,10 @@ new RichEditor(elem, { format: true, multiline: true, blocks: ["quote", "code"]
227
242
 
228
243
  Без форматирования (plain) значение хранится как markdown без инструментов: абзацы `\n\n`, мягкий перенос `\n`.
229
244
 
245
+ Маркер того же инструмента из чужого диалекта разбирается наравне со своим: `*жирный*` — родная разметка WhatsApp, и так размечены сообщения, набранные до редактора. Показывать их звёздочками значит показывать не то, что увидит получатель.
246
+
247
+ Переписывать под свой маркер при этом нельзя — открыть и закрыть сообщение меняло бы текст, — поэтому редактор запоминает, чем текст был размечен, и возвращает в значение то же самое. Настроенный маркер ставится только на то, что отформатировали в поле. Настройка меняет их местами: заданный `markers.bold = "*"` делает чужим диалектом уже `**`.
248
+
230
249
  Маркеры markdown настраиваются через `markers`. При разборе применяются по убыванию длины, поэтому длинный маркер срабатывает раньше короткого-префикса. Глубоко вложенные комбинации гарантированно сохраняются только в режиме `html`.
231
250
 
232
251
  Вложенные пары разбираются (`_а **б** в_`), а вот **пересекающиеся** остаются текстом: в `**а _б** в_` внутренняя пара пересекает внешнюю, разметкой такое невыразимо, и короткий маркер отбрасывается — как и в мессенджерах.
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.43",
30
+ "version": "1.0.45",
31
31
  "main": "source/index.ts",
32
32
  "types": "source/index.ts",
33
33
  "dependencies": {
34
- "@brandup/ui": "^2.0.7",
35
- "@brandup/ui-kit": "^1.0.43"
34
+ "@brandup/ui": "^2.0.9",
35
+ "@brandup/ui-kit": "^1.0.45"
36
36
  },
37
37
  "files": [
38
38
  "source",
package/source/editing.ts CHANGED
@@ -4,13 +4,9 @@
4
4
 
5
5
  import { deserialize } from "./serialize";
6
6
  import { blockAt, blockTypeOf, blocksInRange, createBlock, isBlock } from "./paragraphs";
7
- import { documentSelection, innerSelection } from "./selection";
7
+ import { documentSelection, innerSelection, literalAncestor } from "./selection";
8
8
  import { BLOCK_TYPES, DEFAULT_BLOCK, type BlockType, type FormatMarkers, type FormatTool } from "./format-config";
9
9
 
10
- function emptyParagraph(): HTMLParagraphElement {
11
- return createBlock(DEFAULT_BLOCK) as HTMLParagraphElement;
12
- }
13
-
14
10
  // убирает пустые текст-узлы и ставит <br>-заполнитель в пустой абзац (для видимости и каретки)
15
11
  function fillEmptyParagraph(p: HTMLElement) {
16
12
  p.normalize(); // удаляет пустые Text-узлы, склеивает соседние
@@ -172,6 +168,11 @@ export function atBlockStart(editable: HTMLElement, range: Range): boolean {
172
168
  const block = blockAt(editable, range.startContainer);
173
169
  if (!block) return false;
174
170
 
171
+ // В пустом блоке каретка всегда в его начале: <br> там — заполнитель, он делает строку
172
+ // видимой, но строкой не является. Иначе из опустевшей цитаты было бы не выйти — каретка
173
+ // стоит за заполнителем, и проверка ниже приняла бы его за конец первой строки.
174
+ if (!(block.textContent ?? "").length) return true;
175
+
175
176
  const before = document.createRange();
176
177
  before.selectNodeContents(block);
177
178
  before.setEnd(range.startContainer, range.startOffset);
@@ -183,6 +184,35 @@ export function atBlockStart(editable: HTMLElement, range: Range): boolean {
183
184
  return before.toString().length === 0;
184
185
  }
185
186
 
187
+ /**
188
+ * Block the caret sits in, or null when it stands at the editor level — an empty editor, or text
189
+ * that has not been wrapped into a paragraph yet.
190
+ */
191
+ function blockOf(editable: HTMLElement, node: Node): HTMLElement | null {
192
+ let current: Node | null = node;
193
+ while (current && current !== editable && !isBlock(current)) current = current.parentNode;
194
+
195
+ return current && current !== editable ? (current as HTMLElement) : null;
196
+ }
197
+
198
+ /**
199
+ * Where to insert at the editor level: the node the new content goes before (null — at the end).
200
+ *
201
+ * A position addresses a child only when the container is the editor itself. A caret inside stray
202
+ * top-level text sits in a text node, and there the offset counts characters rather than children:
203
+ * taken as a child index it would send the insertion to an arbitrary place. From such a node we
204
+ * measure the node itself and insert after it, which is what a caret inside it asks for.
205
+ */
206
+ function topLevelRef(editable: HTMLElement, range: Range): ChildNode | null {
207
+ const container = range.startContainer;
208
+ if (container === editable) return editable.childNodes[range.startOffset] ?? null;
209
+
210
+ let node: Node = container;
211
+ while (node.parentNode && node.parentNode !== editable) node = node.parentNode;
212
+
213
+ return node.parentNode === editable ? (node as ChildNode).nextSibling : null;
214
+ }
215
+
186
216
  /** Enter в multiline: разбить текущий блок по каретке; хвост становится блоком типа `type`. */
187
217
  export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT_BLOCK) {
188
218
  const selection = innerSelection(editable);
@@ -192,20 +222,18 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
192
222
  range.deleteContents();
193
223
 
194
224
  // текущий блок (ближайший блочный предок внутри редактора)
195
- let para: Node | null = range.startContainer;
196
- while (para && para !== editable && !isBlock(para)) para = para.parentNode;
225
+ const para = blockOf(editable, range.startContainer);
197
226
 
198
227
  // каретка не внутри абзаца — создаём абзац сразу с видимым результатом (иначе Enter «срабатывает со 2-го раза»)
199
- if (!para || para === editable) {
228
+ if (!para) {
200
229
  const next = createBlock(type);
201
230
  if (editable.childNodes.length === 0) {
202
231
  // пустой редактор: пустая строка-источник + новая строка с кареткой
203
- editable.appendChild(emptyParagraph());
232
+ editable.appendChild(createBlock(DEFAULT_BLOCK));
204
233
  editable.appendChild(next);
205
234
  } else {
206
235
  // каретка на уровне редактора между/после абзацев — вставляем новый абзац в эту позицию
207
- const ref = editable.childNodes[range.startOffset] ?? null;
208
- editable.insertBefore(next, ref);
236
+ editable.insertBefore(next, topLevelRef(editable, range));
209
237
  }
210
238
  caretToStart(next);
211
239
  return;
@@ -217,21 +245,54 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
217
245
  tail.setStart(range.endContainer, range.endOffset);
218
246
  const fragment = tail.extractContents();
219
247
 
248
+ // Выходим из блока, а за ним уже стоит пустой абзац — переходим в него. Иначе одно нажатие
249
+ // давало бы две пустые строки: одна тут заводится, вторая уже была заведена под каретку.
250
+ const following = para.nextElementSibling;
251
+ const empty = !(fragment.textContent ?? "") && !fragment.querySelector("br");
252
+
253
+ if (empty && following && blockTypeOf(following) === type && !(following.textContent ?? "")) {
254
+ fillEmptyParagraph(para);
255
+ caretToStart(following);
256
+
257
+ return;
258
+ }
259
+
220
260
  const next = document.createElement(BLOCK_TYPES[type].tag);
221
261
  next.appendChild(fragment);
222
- (para as ChildNode).after(next);
262
+ para.after(next);
223
263
 
224
264
  // хвост уехал в блок другого типа — его правила распространяются и на содержимое
225
265
  if (!BLOCK_TYPES[type].inline) unwrapFormatting(next);
226
266
 
227
267
  // extractContents в конце абзаца оставляет пустой текст-узел → <p></p> без заполнителя
228
268
  // (невидим/нефокусируем, каретка не встаёт). Чистим и ставим <br> в опустевшие абзацы.
229
- fillEmptyParagraph(para as HTMLElement);
269
+ fillEmptyParagraph(para);
230
270
  fillEmptyParagraph(next);
231
271
 
232
272
  caretToStart(next);
233
273
  }
234
274
 
275
+ /**
276
+ * Разрезает элемент по каретке: содержимое после неё уходит в такой же элемент следом,
277
+ * а диапазон встаёт между половинами — вставленное туда окажется снаружи обоих. Пустые
278
+ * половины не оставляем: печатать в них было бы нечего, а каретка попадала бы внутрь.
279
+ */
280
+ function splitElement(el: HTMLElement, range: Range) {
281
+ const tail = document.createRange();
282
+ tail.selectNodeContents(el);
283
+ tail.setStart(range.startContainer, range.startOffset);
284
+
285
+ const rest = el.cloneNode(false) as HTMLElement;
286
+ rest.appendChild(tail.extractContents());
287
+
288
+ if (rest.textContent) el.after(rest);
289
+
290
+ range.setStartAfter(el);
291
+ range.collapse(true);
292
+
293
+ if (!el.textContent) el.remove();
294
+ }
295
+
235
296
  /** Shift/Ctrl+Enter в multiline: вставить мягкий перенос <br>. */
236
297
  export function insertSoftBreak(editable: HTMLElement) {
237
298
  const selection = innerSelection(editable);
@@ -240,6 +301,13 @@ export function insertSoftBreak(editable: HTMLElement) {
240
301
  const range = selection.getRangeAt(0);
241
302
  range.deleteContents();
242
303
 
304
+ // Перенос не живёт в моноширинном: значение берёт оттуда голый текст, и новая строка
305
+ // пропала бы — поле показывало бы две, а получатель увидел одну. Разрезаем тег и ставим
306
+ // перенос между половинами: форматирование продолжается на новой строке, только если
307
+ // там что-то осталось.
308
+ const literal = literalAncestor(range.startContainer, editable);
309
+ if (literal) splitElement(literal, range);
310
+
243
311
  const br = document.createElement("br");
244
312
  range.insertNode(br);
245
313
 
@@ -265,17 +333,15 @@ export function insertSoftBreak(editable: HTMLElement) {
265
333
 
266
334
  /** Вставляет санитизированные абзацы <p> в позицию каретки, разбивая текущий абзац. */
267
335
  export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement[], range: Range) {
268
- let para: Node | null = range.startContainer;
269
- while (para && para !== editable && !isBlock(para)) para = para.parentNode;
336
+ const block = blockOf(editable, range.startContainer);
270
337
 
271
338
  // каретка не внутри абзаца (пустой редактор / уровень редактора) — вставляем абзацы как есть
272
- if (!para || para === editable) {
273
- const ref = editable.childNodes[range.startOffset] ?? null;
339
+ if (!block) {
340
+ const ref = topLevelRef(editable, range);
274
341
  for (const p of paras) editable.insertBefore(p, ref);
275
342
  return;
276
343
  }
277
344
 
278
- const block = para as HTMLElement;
279
345
  // Вставка в цитату или код остаётся в них: разорвать блок посреди вставки — не то,
280
346
  // чего ждут, а тип целевого блока диктует и правила его содержимого.
281
347
  const type = blockTypeOf(block) ?? DEFAULT_BLOCK;
@@ -104,6 +104,17 @@ interface FormatToolDef {
104
104
  matchTags: string[];
105
105
  /** Маркер в Markdown. */
106
106
  md: string;
107
+ /**
108
+ * Маркеры того же инструмента из чужих диалектов: разбираются наравне с основным, но сами
109
+ * не ставятся. Текст, размеченный ими, встречается в старых сообщениях, и показывать его
110
+ * сырыми символами — значит показывать не то, что увидит получатель.
111
+ */
112
+ mdAliases?: string[];
113
+ /**
114
+ * Содержимое буквально: разметка внутри не разбирается и не сохраняется, значение берёт
115
+ * оттуда голый текст. Перенос строки в таком теге тоже не живёт — он его разрезает.
116
+ */
117
+ literal?: boolean;
107
118
  /** Клавиша для Ctrl/Cmd-хоткея (пусто — без хоткея). */
108
119
  hotkey: string;
109
120
  /** Подсказка на кнопке. */
@@ -115,6 +126,9 @@ export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
115
126
  tag: "b",
116
127
  matchTags: ["B", "STRONG"],
117
128
  md: "**",
129
+ // Одинарная звёздочка — жирный в WhatsApp; так размечены сообщения, набранные до
130
+ // редактора, и получатель увидит их жирными.
131
+ mdAliases: ["*"],
118
132
  hotkey: "b",
119
133
  title: "Жирный",
120
134
  },
@@ -152,6 +166,7 @@ export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
152
166
  tag: "code",
153
167
  matchTags: ["CODE"],
154
168
  md: "`",
169
+ literal: true,
155
170
  hotkey: "",
156
171
  title: "Моноширинный",
157
172
  },
@@ -210,19 +225,20 @@ export function parseEditorActions(value: string | null): EditorAction[] {
210
225
  }
211
226
 
212
227
  /**
213
- * Разбирает значение атрибута data-blocks. Блоки подключаются явно: без атрибута доступен
214
- * только обычный текст. Он в наборе есть всегда иначе блок некуда было бы вернуть.
228
+ * Разбирает значение атрибута data-blocks. Без атрибута доступны все типы; пустое значение
229
+ * оставляет только обычный текст им ограничивают поле, где цитаты и код ни к чему.
215
230
  */
216
231
  export function parseBlockTypes(value: string | null): BlockType[] {
217
- return value === null ? [DEFAULT_BLOCK] : normalizeBlockTypes(parseList(value, ALL_BLOCK_TYPES));
232
+ return value === null ? ALL_BLOCK_TYPES.slice() : normalizeBlockTypes(parseList(value, ALL_BLOCK_TYPES));
218
233
  }
219
234
 
220
235
  /**
221
236
  * Приводит набор типов к порядку объявления. Обычный текст в наборе есть всегда: им становится
222
- * содержимое, не попавшее ни в какой блок, и в него же блок возвращают.
237
+ * содержимое, не попавшее ни в какой блок, и в него же блок возвращают. Набор не задан — берём
238
+ * все типы; ограничивают их явным списком.
223
239
  */
224
240
  export function normalizeBlockTypes(types: BlockType[] | undefined): BlockType[] {
225
- if (!types) return [DEFAULT_BLOCK];
241
+ if (!types) return ALL_BLOCK_TYPES.slice();
226
242
 
227
243
  const list = ALL_BLOCK_TYPES.filter((type) => types.includes(type));
228
244
 
package/source/format.ts CHANGED
@@ -36,6 +36,7 @@ export {
36
36
  restoreSelection,
37
37
  mapCharOffset,
38
38
  activeFormats,
39
+ emptyFormatAt,
39
40
  toggleFormat,
40
41
  clearFormat,
41
42
  clearAllFormat,
@@ -51,6 +52,7 @@ export {
51
52
  blocksInRange,
52
53
  createBlock,
53
54
  normalizeWhitespace,
55
+ mergeAdjacentBlocks,
54
56
  normalizeParagraphs,
55
57
  ensureParagraphs,
56
58
  } from "./paragraphs";
package/source/history.ts CHANGED
@@ -68,12 +68,19 @@ export class EditorHistory {
68
68
  const top = this.__undo[this.__undo.length - 1];
69
69
  if (top && top.html === snap.html) return; // состояние не изменилось — не дублируем
70
70
 
71
+ this.__push(snap);
72
+ this.__redo = [];
73
+ }
74
+
75
+ // Puts a snapshot on the undo stack, dropping the oldest ones beyond the limits. Every push
76
+ // goes through here: redo adds a snapshot just like an edit does, and without trimming the
77
+ // history would outgrow the limits over a run of undo/redo.
78
+ private __push(snap: Snapshot): void {
71
79
  this.__undo.push(snap);
72
80
  this.__chars += snap.html.length;
81
+
73
82
  while (this.__undo.length > MAX_DEPTH || (this.__chars > MAX_CHARS && this.__undo.length > 1))
74
83
  this.__chars -= this.__undo.shift()!.html.length;
75
-
76
- this.__redo = [];
77
84
  }
78
85
 
79
86
  /** Откатить на шаг назад. Возвращает false, если откатывать нечего. */
@@ -93,9 +100,7 @@ export class EditorHistory {
93
100
  const next = this.__redo.pop();
94
101
  if (!next) return false;
95
102
 
96
- const current = this.__snapshot();
97
- this.__undo.push(current);
98
- this.__chars += current.html.length;
103
+ this.__push(this.__snapshot());
99
104
  this.__restore(next);
100
105
  this.__lastKind = null;
101
106
  return true;
@@ -154,11 +154,53 @@ export function normalizeWhitespace(root: HTMLElement) {
154
154
  * Нормализует абзацы многострочного режима: удаляет пустые абзацы (без текстового содержимого).
155
155
  * Если содержимого нет вовсе — редактор остаётся пустым (показывается placeholder).
156
156
  */
157
- export function normalizeParagraphs(root: HTMLElement) {
157
+ export function normalizeParagraphs(root: HTMLElement, merge = false) {
158
+ if (merge) mergeAdjacentBlocks(root);
159
+
158
160
  for (const el of Array.from(root.children)) {
159
161
  // Пустой блок другого типа не трогаем: его завели осознанно и в него сейчас будут писать,
160
162
  // а пустая строка внутри кода вообще осмысленна сама по себе.
161
- if (blockTypeOf(el) === DEFAULT_BLOCK && (el.textContent ?? "").trim() === "") el.remove();
163
+ if (blockTypeOf(el) !== DEFAULT_BLOCK || (el.textContent ?? "").trim() !== "") continue;
164
+
165
+ // Последний пустой абзац — это место, где оставили каретку: перенеслись на новую строку
166
+ // и ушли из поля. Убрав его, редактор схлопывал бы только что набранную строку. За блоком
167
+ // другого типа он к тому же единственное место вне цитаты или кода — без него правка
168
+ // запиралась бы внутри, хотя вышли оттуда как раз затем, чтобы писать дальше.
169
+ //
170
+ // В значение такой абзац не попадает (хвост обрезается), а единственный в поле — попадает
171
+ // под удаление: пустое поле должно оставаться пустым, иначе не покажется заглушка.
172
+ const previous = el.previousElementSibling;
173
+ const kept = previous && (!el.nextElementSibling || blockTypeOf(previous) !== DEFAULT_BLOCK);
174
+ if (kept) continue;
175
+
176
+ el.remove();
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Склеивает соседние блоки одного типа — в режиме мягких переносов, где граница между блоками
182
+ * в значение не попадает. Подряд идущие строки с маркером цитаты разбор собирает в одну цитату,
183
+ * а два обычных абзаца там и вовсе неразличимы: пустой строки между ними на экране нет, а в
184
+ * значении она была бы. Два блока в поле показывали бы то, чего в сообщении не будет.
185
+ *
186
+ * Блоки с ограждением (код) не трогаем: у них есть свои границы, и два подряд разбираются
187
+ * ровно как два.
188
+ */
189
+ export function mergeAdjacentBlocks(root: HTMLElement) {
190
+ for (const el of Array.from(root.children) as HTMLElement[]) {
191
+ const type = blockTypeOf(el);
192
+ if (!type || BLOCK_TYPES[type].fence) continue;
193
+
194
+ const previous = el.previousElementSibling;
195
+ if (!previous || blockTypeOf(previous) !== type) continue;
196
+
197
+ // Строки склеиваем переносом: между блоками была граница, а внутри блока её роль играет он.
198
+ // Если перенос там уже есть (заполнитель последней строки), он границей и станет — иначе
199
+ // между строками появилась бы пустая, которой на экране не было.
200
+ if (previous.lastChild?.nodeName !== "BR") previous.appendChild(document.createElement("br"));
201
+
202
+ while (el.firstChild) previous.appendChild(el.firstChild);
203
+ el.remove();
162
204
  }
163
205
  }
164
206
 
@@ -201,12 +243,16 @@ export function ensureParagraphs(root: HTMLElement) {
201
243
  continue;
202
244
  }
203
245
 
204
- // в непустом абзаце убираем краевые <br>-заполнители: иначе введённый текст
205
- // оказывается рядом с лишним переносом (символ «съезжает» на новую строку).
206
- // Внутренние <br> (мягкие переносы) сохраняются.
246
+ // Хвостовой перенос в одиночку остаток заполнителя опустевшего абзаца: текст уже есть,
247
+ // а показывать за ним нечего. Два и больше это набранные пустые строки плюс заполнитель,
248
+ // который их и делает видимыми (см. trimTrailingBreaks), и трогать их нельзя.
249
+ //
250
+ // Ведущие переносы не трогаем вовсе: заполнитель бывает только последним, а перенос
251
+ // в начале — это набранная пустая строка. Убрав его, редактор схлопывал бы её, стоило
252
+ // начать печатать в следующей.
207
253
  if ((p.textContent ?? "").length > 0) {
208
- while (p.firstChild && p.firstChild.nodeName === "BR") p.removeChild(p.firstChild);
209
- while (p.lastChild && p.lastChild.nodeName === "BR") p.removeChild(p.lastChild);
254
+ const tail = p.lastChild;
255
+ if (tail?.nodeName === "BR" && tail.previousSibling?.nodeName !== "BR") p.removeChild(tail);
210
256
  }
211
257
  }
212
258
  }