@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 +30 -11
- package/package.json +3 -3
- package/source/editing.ts +84 -18
- package/source/format-config.ts +21 -5
- package/source/format.ts +2 -0
- package/source/history.ts +10 -5
- package/source/paragraphs.ts +53 -7
- package/source/richeditor.ts +141 -23
- package/source/selection.ts +42 -5
- package/source/serialize.ts +143 -52
- package/source/toolbar.ts +55 -24
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`)
|
|
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`. Ни кнопка, ни попап не забирают фокус
|
|
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
|
-
Хвостовой перенос
|
|
169
|
+
Хвостовой перенос отбрасывается ровно один — это `<br>`-заполнитель, без которого не видна последняя строка. Набранные пустые строки сохраняются и в поле, и в значении, где бы они ни стояли — в начале блока, в середине или в конце.
|
|
159
170
|
|
|
160
|
-
|
|
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: [
|
|
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
|
-
|
|
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.
|
|
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.
|
|
35
|
-
"@brandup/ui-kit": "^1.0.
|
|
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
|
-
|
|
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
|
|
228
|
+
if (!para) {
|
|
200
229
|
const next = createBlock(type);
|
|
201
230
|
if (editable.childNodes.length === 0) {
|
|
202
231
|
// пустой редактор: пустая строка-источник + новая строка с кареткой
|
|
203
|
-
editable.appendChild(
|
|
232
|
+
editable.appendChild(createBlock(DEFAULT_BLOCK));
|
|
204
233
|
editable.appendChild(next);
|
|
205
234
|
} else {
|
|
206
235
|
// каретка на уровне редактора между/после абзацев — вставляем новый абзац в эту позицию
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
269
|
-
while (para && para !== editable && !isBlock(para)) para = para.parentNode;
|
|
336
|
+
const block = blockOf(editable, range.startContainer);
|
|
270
337
|
|
|
271
338
|
// каретка не внутри абзаца (пустой редактор / уровень редактора) — вставляем абзацы как есть
|
|
272
|
-
if (!
|
|
273
|
-
const ref = editable
|
|
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;
|
package/source/format-config.ts
CHANGED
|
@@ -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 ?
|
|
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
|
|
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
|
-
|
|
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;
|
package/source/paragraphs.ts
CHANGED
|
@@ -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)
|
|
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
|
-
// в
|
|
205
|
-
//
|
|
206
|
-
//
|
|
246
|
+
// Хвостовой перенос в одиночку — остаток заполнителя опустевшего абзаца: текст уже есть,
|
|
247
|
+
// а показывать за ним нечего. Два и больше — это набранные пустые строки плюс заполнитель,
|
|
248
|
+
// который их и делает видимыми (см. trimTrailingBreaks), и трогать их нельзя.
|
|
249
|
+
//
|
|
250
|
+
// Ведущие переносы не трогаем вовсе: заполнитель бывает только последним, а перенос
|
|
251
|
+
// в начале — это набранная пустая строка. Убрав его, редактор схлопывал бы её, стоило
|
|
252
|
+
// начать печатать в следующей.
|
|
207
253
|
if ((p.textContent ?? "").length > 0) {
|
|
208
|
-
|
|
209
|
-
|
|
254
|
+
const tail = p.lastChild;
|
|
255
|
+
if (tail?.nodeName === "BR" && tail.previousSibling?.nodeName !== "BR") p.removeChild(tail);
|
|
210
256
|
}
|
|
211
257
|
}
|
|
212
258
|
}
|