@brandup/ui-richeditor 1.0.43 → 1.0.44
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 +2 -2
- package/source/editing.ts +47 -6
- package/source/format-config.ts +21 -5
- package/source/format.ts +2 -0
- package/source/paragraphs.ts +53 -7
- package/source/richeditor.ts +127 -15
- package/source/selection.ts +42 -5
- package/source/serialize.ts +143 -52
- package/source/toolbar.ts +49 -23
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.44",
|
|
31
31
|
"main": "source/index.ts",
|
|
32
32
|
"types": "source/index.ts",
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@brandup/ui": "^2.0.7",
|
|
35
|
-
"@brandup/ui-kit": "^1.0.
|
|
35
|
+
"@brandup/ui-kit": "^1.0.44"
|
|
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);
|
|
@@ -200,7 +201,7 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
|
|
|
200
201
|
const next = createBlock(type);
|
|
201
202
|
if (editable.childNodes.length === 0) {
|
|
202
203
|
// пустой редактор: пустая строка-источник + новая строка с кареткой
|
|
203
|
-
editable.appendChild(
|
|
204
|
+
editable.appendChild(createBlock(DEFAULT_BLOCK));
|
|
204
205
|
editable.appendChild(next);
|
|
205
206
|
} else {
|
|
206
207
|
// каретка на уровне редактора между/после абзацев — вставляем новый абзац в эту позицию
|
|
@@ -217,6 +218,18 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
|
|
|
217
218
|
tail.setStart(range.endContainer, range.endOffset);
|
|
218
219
|
const fragment = tail.extractContents();
|
|
219
220
|
|
|
221
|
+
// Выходим из блока, а за ним уже стоит пустой абзац — переходим в него. Иначе одно нажатие
|
|
222
|
+
// давало бы две пустые строки: одна тут заводится, вторая уже была заведена под каретку.
|
|
223
|
+
const following = (para as HTMLElement).nextElementSibling;
|
|
224
|
+
const empty = !(fragment.textContent ?? "") && !fragment.querySelector("br");
|
|
225
|
+
|
|
226
|
+
if (empty && following && blockTypeOf(following) === type && !(following.textContent ?? "")) {
|
|
227
|
+
fillEmptyParagraph(para as HTMLElement);
|
|
228
|
+
caretToStart(following);
|
|
229
|
+
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
220
233
|
const next = document.createElement(BLOCK_TYPES[type].tag);
|
|
221
234
|
next.appendChild(fragment);
|
|
222
235
|
(para as ChildNode).after(next);
|
|
@@ -232,6 +245,27 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
|
|
|
232
245
|
caretToStart(next);
|
|
233
246
|
}
|
|
234
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Разрезает элемент по каретке: содержимое после неё уходит в такой же элемент следом,
|
|
250
|
+
* а диапазон встаёт между половинами — вставленное туда окажется снаружи обоих. Пустые
|
|
251
|
+
* половины не оставляем: печатать в них было бы нечего, а каретка попадала бы внутрь.
|
|
252
|
+
*/
|
|
253
|
+
function splitElement(el: HTMLElement, range: Range) {
|
|
254
|
+
const tail = document.createRange();
|
|
255
|
+
tail.selectNodeContents(el);
|
|
256
|
+
tail.setStart(range.startContainer, range.startOffset);
|
|
257
|
+
|
|
258
|
+
const rest = el.cloneNode(false) as HTMLElement;
|
|
259
|
+
rest.appendChild(tail.extractContents());
|
|
260
|
+
|
|
261
|
+
if (rest.textContent) el.after(rest);
|
|
262
|
+
|
|
263
|
+
range.setStartAfter(el);
|
|
264
|
+
range.collapse(true);
|
|
265
|
+
|
|
266
|
+
if (!el.textContent) el.remove();
|
|
267
|
+
}
|
|
268
|
+
|
|
235
269
|
/** Shift/Ctrl+Enter в multiline: вставить мягкий перенос <br>. */
|
|
236
270
|
export function insertSoftBreak(editable: HTMLElement) {
|
|
237
271
|
const selection = innerSelection(editable);
|
|
@@ -240,6 +274,13 @@ export function insertSoftBreak(editable: HTMLElement) {
|
|
|
240
274
|
const range = selection.getRangeAt(0);
|
|
241
275
|
range.deleteContents();
|
|
242
276
|
|
|
277
|
+
// Перенос не живёт в моноширинном: значение берёт оттуда голый текст, и новая строка
|
|
278
|
+
// пропала бы — поле показывало бы две, а получатель увидел одну. Разрезаем тег и ставим
|
|
279
|
+
// перенос между половинами: форматирование продолжается на новой строке, только если
|
|
280
|
+
// там что-то осталось.
|
|
281
|
+
const literal = literalAncestor(range.startContainer, editable);
|
|
282
|
+
if (literal) splitElement(literal, range);
|
|
283
|
+
|
|
243
284
|
const br = document.createElement("br");
|
|
244
285
|
range.insertNode(br);
|
|
245
286
|
|
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/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
|
}
|
package/source/richeditor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import "./richeditor.less"; // стили редактора и панели форматирования
|
|
2
2
|
|
|
3
3
|
import { DOM, UIElementBound } from "@brandup/ui";
|
|
4
|
+
import { IS_TOUCH_DEVICE } from "@brandup/ui-kit";
|
|
4
5
|
import {
|
|
5
6
|
ALL_FORMAT_TOOLS,
|
|
6
7
|
BLOCK_TYPES,
|
|
@@ -20,12 +21,14 @@ import {
|
|
|
20
21
|
insertFormattedText,
|
|
21
22
|
isFormatActive,
|
|
22
23
|
documentSelection,
|
|
24
|
+
emptyFormatAt,
|
|
23
25
|
innerSelection,
|
|
24
26
|
mapCharOffset,
|
|
25
27
|
normalizeBlockTypes,
|
|
26
28
|
editorText,
|
|
27
29
|
charLength,
|
|
28
30
|
preserveCaret,
|
|
31
|
+
mergeAdjacentBlocks,
|
|
29
32
|
normalizeParagraphs,
|
|
30
33
|
normalizeWhitespace,
|
|
31
34
|
restoreSelection,
|
|
@@ -123,6 +126,14 @@ export interface RichEditorOptions {
|
|
|
123
126
|
* Обычный текст в наборе есть всегда — в него блок возвращают.
|
|
124
127
|
*/
|
|
125
128
|
blocks?: BlockType[];
|
|
129
|
+
/**
|
|
130
|
+
* Держать ли фокус в поле, пока над ним открыта панель смайликов. По умолчанию держим —
|
|
131
|
+
* каретка на виду, и видно, куда встанет символ; на сенсорном устройстве нет: там фокус
|
|
132
|
+
* держит на экране клавиатуру, и она закрывает собой саму панель.
|
|
133
|
+
*
|
|
134
|
+
* Модального окна это не касается: правка идёт в нём, и фокус поле отдаёт всегда.
|
|
135
|
+
*/
|
|
136
|
+
keepFocus?: boolean;
|
|
126
137
|
/** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
|
|
127
138
|
readonly?: boolean;
|
|
128
139
|
/** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
|
|
@@ -162,6 +173,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
162
173
|
readonly multiline: boolean;
|
|
163
174
|
readonly paragraph: ParagraphMode;
|
|
164
175
|
readonly blockTypes: BlockType[];
|
|
176
|
+
readonly keepFocus: boolean;
|
|
165
177
|
readonly toolbarContainer: HTMLElement | null;
|
|
166
178
|
readonly toolbarButtons: ToolbarButton[];
|
|
167
179
|
|
|
@@ -170,6 +182,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
170
182
|
private __pendingFormats = new Set<FormatTool>();
|
|
171
183
|
private __hasInputClick = false;
|
|
172
184
|
private __editHolds = 0; // правка продолжается в окне хоста — см. holdEditing
|
|
185
|
+
// Каретка, снятая при отпускании фокуса: без фокуса браузер может убрать и выделение,
|
|
186
|
+
// а вставке из попапа нужно место — см. releaseFocus.
|
|
187
|
+
private __detachedCaret: [number, number] | null = null;
|
|
188
|
+
private __emojiHold: (() => void) | null = null; // правка придержана на время панели смайликов
|
|
189
|
+
private __releasingFocus = false; // фокус снимаем сами, а не уходят из поля — см. releaseFocus
|
|
173
190
|
// Компонент снят. Удержание правки переживает снятие (окно хоста закрывается позже), и по его
|
|
174
191
|
// снятию трогать содержимое уже нельзя — редактора нет.
|
|
175
192
|
private __disposed = false;
|
|
@@ -209,6 +226,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
209
226
|
// Блоки — часть многострочной модели: в однострочном режиме верхнего уровня нет вовсе,
|
|
210
227
|
// а в readonly их не переключить, но разбор и показ значения обязаны работать и там.
|
|
211
228
|
this.blockTypes = multiline ? normalizeBlockTypes(options.blocks) : [DEFAULT_BLOCK];
|
|
229
|
+
this.keepFocus = options.keepFocus ?? !IS_TOUCH_DEVICE;
|
|
212
230
|
this.toolbarContainer = options.toolbarContainer ?? null;
|
|
213
231
|
// кнопки хоста живут и без форматирования, но не в readonly — там панели нет вовсе
|
|
214
232
|
this.toolbarButtons = readonly ? [] : (options.buttons ?? []);
|
|
@@ -283,7 +301,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
283
301
|
* например, кнопкой панели, которая не должна забирать фокус у редактора.
|
|
284
302
|
*/
|
|
285
303
|
insertText(text: string): void {
|
|
286
|
-
if (this.readonly || !text
|
|
304
|
+
if (this.readonly || !text) return;
|
|
305
|
+
|
|
306
|
+
// фокус мог быть отпущен на время окна хоста — вместе с ним могло уйти и выделение
|
|
307
|
+
if (!this.selection) this.__reviveCaret();
|
|
308
|
+
if (!this.selection) return;
|
|
287
309
|
|
|
288
310
|
// вставка — такой же ввод, как с клавиатуры, поэтому проходит через filterChar хоста
|
|
289
311
|
// (ограничения по типу поля и длине). Обход символов идёт по кодпойнтам, чтобы эмодзи
|
|
@@ -313,19 +335,32 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
313
335
|
const target = container ?? initiator.parentElement;
|
|
314
336
|
if (!target) return;
|
|
315
337
|
|
|
316
|
-
|
|
338
|
+
// повторное нажатие по кнопке панель закрывает — держать и придерживать больше нечего
|
|
339
|
+
if (!formatToolbar.openEmoji(this, initiator, target)) return;
|
|
340
|
+
|
|
341
|
+
// Правку придерживаем на всё время панели: фокус мы отпустим, а снятие фокуса — не конец
|
|
342
|
+
// ввода. Иначе нормализация обрезала бы пробел у каретки, и символ встал бы вплотную.
|
|
343
|
+
this.__emojiHold ??= this.holdEditing();
|
|
317
344
|
|
|
318
345
|
// По кнопке могли нажать, ни разу не заходя в поле, — тогда каретки нет и вставлять символ
|
|
319
|
-
// некуда.
|
|
320
|
-
//
|
|
321
|
-
//
|
|
346
|
+
// некуда. В конец её ставит focus(true), и только если её действительно не было: снятую
|
|
347
|
+
// при отпускании фокуса он вернёт на место, а иначе символ уезжал бы в конец сообщения
|
|
348
|
+
// с каждым открытием панели.
|
|
322
349
|
//
|
|
323
350
|
// Строго после открытия панели: фокус показывает тулбар, а придержать его панель успевает
|
|
324
351
|
// только когда открыта сама.
|
|
325
|
-
if (!this.selection)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
352
|
+
if (!this.selection) this.focus(true);
|
|
353
|
+
|
|
354
|
+
// Панель — слой над полем, а не вместо него: каретку видно, и видно, куда встанет символ.
|
|
355
|
+
// На сенсорном устройстве фокус вместо этого поднимает клавиатуру, которая саму панель
|
|
356
|
+
// и закрывает, — там его отпускаем, а каретку вернёт вставка (см. keepFocus).
|
|
357
|
+
if (!this.keepFocus) this.releaseFocus();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Панель смайликов закрылась: снимаем удержание правки, взятое на время её работы. */
|
|
361
|
+
onEmojiClosed(): void {
|
|
362
|
+
this.__emojiHold?.();
|
|
363
|
+
this.__emojiHold = null;
|
|
329
364
|
}
|
|
330
365
|
|
|
331
366
|
getLength(): number {
|
|
@@ -337,12 +372,48 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
337
372
|
* Фокус в поле. Каретку, стоящую в содержимом, не двигает: правку продолжают там, где её
|
|
338
373
|
* прервали. Своей защиты обработчика фокуса для этого мало — браузер успевает поставить
|
|
339
374
|
* при фокусе собственную каретку (в начало содержимого), и она выглядит как «уже стоявшая».
|
|
375
|
+
*
|
|
376
|
+
* `atEnd` — куда ставить каретку, если её в поле ещё не было: в конец содержимого, а не
|
|
377
|
+
* в начало. Так фокусируют по клику мимо текста — это клик за ним, а не перед ним.
|
|
340
378
|
*/
|
|
341
|
-
focus(): void {
|
|
342
|
-
const bounds = this.caretSnapshot();
|
|
379
|
+
focus(atEnd = false): void {
|
|
380
|
+
const bounds = this.caretSnapshot() ?? this.__detachedCaret;
|
|
381
|
+
if (bounds) return this.restoreCaret(bounds);
|
|
343
382
|
|
|
344
|
-
|
|
345
|
-
|
|
383
|
+
this.editable.focus();
|
|
384
|
+
if (atEnd) caretToEnd(this.editable, this.multiline);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Отпускает фокус, запомнив каретку. Пока хост показывает своё окно, полю фокус не нужен:
|
|
389
|
+
* правка идёт в окне, а мигающая каретка под ним только сбивает с толку. На сенсорном
|
|
390
|
+
* устройстве фокус к тому же держит на экране клавиатуру, и она закрывает собой само окно.
|
|
391
|
+
*
|
|
392
|
+
* Каретка при этом не теряется: {@link insertText} вернёт её сам, а {@link focus} — вместе
|
|
393
|
+
* с фокусом. Правку на это время придерживает вызывающий (см. {@link holdEditing}): снятие
|
|
394
|
+
* фокуса не конец ввода, и содержимое трогать рано.
|
|
395
|
+
*/
|
|
396
|
+
releaseFocus(): void {
|
|
397
|
+
if (this.editable.ownerDocument.activeElement !== this.editable) return;
|
|
398
|
+
|
|
399
|
+
this.__detachedCaret = this.caretSnapshot();
|
|
400
|
+
this.__releasingFocus = true;
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
this.editable.blur();
|
|
404
|
+
} finally {
|
|
405
|
+
this.__releasingFocus = false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Ставит обратно каретку, снятую при отпускании фокуса. Фокус не возвращает: вставке из
|
|
410
|
+
// панели он не нужен, а на сенсорном устройстве вернул бы и клавиатуру.
|
|
411
|
+
private __reviveCaret() {
|
|
412
|
+
const bounds = this.__detachedCaret;
|
|
413
|
+
if (!bounds) return;
|
|
414
|
+
|
|
415
|
+
const selection = documentSelection(this.editable);
|
|
416
|
+
if (selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
346
417
|
}
|
|
347
418
|
|
|
348
419
|
/**
|
|
@@ -466,6 +537,20 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
466
537
|
if (!target) return;
|
|
467
538
|
|
|
468
539
|
if (target.range.collapsed) {
|
|
540
|
+
// Каретка в опустевшем теге — слово из него стёрли, а тег остался, и печать продолжится
|
|
541
|
+
// оформленной. Кнопка снимает именно его: режим набора тут ничего не изменил бы,
|
|
542
|
+
// а выключить формат стало бы нечем.
|
|
543
|
+
const empty = emptyFormatAt(this.editable, target.range, tool);
|
|
544
|
+
if (empty) {
|
|
545
|
+
this.__history?.record("op");
|
|
546
|
+
preserveCaret(this.editable, () => empty.replaceWith(...Array.from(empty.childNodes)));
|
|
547
|
+
this.__pendingFormats.delete(tool);
|
|
548
|
+
|
|
549
|
+
this.__emitChange();
|
|
550
|
+
formatToolbar.refresh();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
469
554
|
// под кареткой нет слова — режим набора: формат для следующего ввода
|
|
470
555
|
if (this.__pendingFormats.has(tool)) this.__pendingFormats.delete(tool);
|
|
471
556
|
else this.__pendingFormats.add(tool);
|
|
@@ -499,6 +584,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
499
584
|
formatToolbar.refresh();
|
|
500
585
|
}
|
|
501
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Типы блоков для панели. От {@link blockTypes} отличается только запретом правки: разбирать
|
|
589
|
+
* и показывать цитату и код редактор обязан и в режиме только для чтения, а переключать их
|
|
590
|
+
* там нечем — кнопок быть не должно.
|
|
591
|
+
*/
|
|
592
|
+
get blockTools(): BlockType[] {
|
|
593
|
+
return this.readonly ? [] : this.blockTypes;
|
|
594
|
+
}
|
|
595
|
+
|
|
502
596
|
/**
|
|
503
597
|
* Тип блока под кареткой. Без выделения (или вне блоков) — тип по умолчанию: именно им
|
|
504
598
|
* станет то, что сейчас наберут.
|
|
@@ -556,6 +650,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
556
650
|
restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
557
651
|
}
|
|
558
652
|
|
|
653
|
+
// Соседние цитаты в режиме мягких переносов — одна цитата: склеиваем сразу, а не на
|
|
654
|
+
// потерю фокуса, иначе поле до неё показывает два блока вместо одного.
|
|
655
|
+
//
|
|
656
|
+
// Строго после возврата каретки: склейка снимает границу блоков, а её смещения считают,
|
|
657
|
+
// и восстановленная по прежним смещениям каретка съехала бы на символ. Живое выделение
|
|
658
|
+
// переезжает вместе с узлами само.
|
|
659
|
+
if (!this.__blockParagraphs) mergeAdjacentBlocks(this.editable);
|
|
660
|
+
|
|
559
661
|
this.__emitChange();
|
|
560
662
|
formatToolbar.refresh();
|
|
561
663
|
}
|
|
@@ -823,7 +925,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
823
925
|
// текст в координатах каретки (с концами строк) — иначе смещения разъедутся на число строк
|
|
824
926
|
const textBefore = editorText(this.editable);
|
|
825
927
|
normalizeWhitespace(this.editable);
|
|
826
|
-
|
|
928
|
+
// В режиме мягких переносов соседние цитаты неразличимы: значение пишет их строки подряд,
|
|
929
|
+
// а разбор собирает в одну — склеиваем и в поле.
|
|
930
|
+
if (this.multiline) normalizeParagraphs(this.editable, !this.__blockParagraphs);
|
|
827
931
|
if (this.editable.innerHTML === before) return;
|
|
828
932
|
|
|
829
933
|
if (bounds && selection) {
|
|
@@ -911,6 +1015,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
911
1015
|
() => {
|
|
912
1016
|
this.element.classList.add("focused");
|
|
913
1017
|
|
|
1018
|
+
// вернулись в поле — каретку ставит браузер или тот, кто вернул фокус
|
|
1019
|
+
this.__detachedCaret = null;
|
|
1020
|
+
|
|
914
1021
|
// Пришли править — держать запрет выделения не за чем, а с ним поле осталось бы
|
|
915
1022
|
// нередактируемым. Мышью его снимает нажатие, но фокус берут и клавишей, и из кода.
|
|
916
1023
|
this.element.classList.remove(UNSELECTABLE_CLASS);
|
|
@@ -933,7 +1040,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
933
1040
|
this.__hasInputClick = false;
|
|
934
1041
|
|
|
935
1042
|
this.element.classList.remove("focused");
|
|
936
|
-
|
|
1043
|
+
|
|
1044
|
+
// Фокус отпущен намеренно — это не уход из поля, а работа хоста в своём слое над
|
|
1045
|
+
// ним: панель убираем с экрана, но открытую панель смайликов не закрываем, её же
|
|
1046
|
+
// ради этого и открыли.
|
|
1047
|
+
if (this.__releasingFocus) formatToolbar.suspend(this);
|
|
1048
|
+
else formatToolbar.detach(this);
|
|
937
1049
|
|
|
938
1050
|
// правку продолжают в окне хоста — этот blur не конец ввода, содержимое трогать нельзя
|
|
939
1051
|
if (this.__editHolds > 0) {
|
package/source/selection.ts
CHANGED
|
@@ -13,8 +13,13 @@ const MATCH_TAG_NAMES = Array.from(new Set(ALL_FORMAT_TOOLS.flatMap((t) => FORMA
|
|
|
13
13
|
// Селекторы считаем один раз: обе выборки идут на каждую правку формата и на каждое обновление панели.
|
|
14
14
|
const FORMAT_SELECTOR = FORMAT_TAG_NAMES.join(",").toLowerCase();
|
|
15
15
|
const MATCH_SELECTOR = MATCH_TAG_NAMES.join(",").toLowerCase();
|
|
16
|
-
//
|
|
17
|
-
|
|
16
|
+
// Инструменты, содержимое которых буквально (моноширинный): внутри них не бывает ни разметки
|
|
17
|
+
// (см. stripFormattingInCode), ни переносов строк — значение берёт оттуда голый текст.
|
|
18
|
+
const LITERAL_TAGS = ALL_FORMAT_TOOLS.filter((tool) => FORMAT_TOOLS[tool].literal).flatMap(
|
|
19
|
+
(tool) => FORMAT_TOOLS[tool].matchTags
|
|
20
|
+
);
|
|
21
|
+
const CODE_SELECTOR = LITERAL_TAGS.join(",").toLowerCase();
|
|
22
|
+
const LITERAL_TAG_SET = new Set(LITERAL_TAGS);
|
|
18
23
|
// Неделимые объекты хоста: конструкции сообщения объявляют себя нередактируемыми
|
|
19
24
|
const ATOMIC_SELECTOR = '[contenteditable="false"]';
|
|
20
25
|
|
|
@@ -161,6 +166,26 @@ export function charLength(root: HTMLElement): number {
|
|
|
161
166
|
return length;
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Пустой тег инструмента, внутри которого стоит каретка: слово из него стёрли, а тег остался,
|
|
171
|
+
* и печать продолжится оформленной. Кнопка панели обязана снимать именно его.
|
|
172
|
+
*/
|
|
173
|
+
export function emptyFormatAt(root: HTMLElement, range: Range, tool: FormatTool): HTMLElement | null {
|
|
174
|
+
if (!range.collapsed) return null;
|
|
175
|
+
|
|
176
|
+
const found = formatAt(caretProbe(range), TOOL_TAG_SETS[tool], root);
|
|
177
|
+
|
|
178
|
+
return found && !found.textContent ? found : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Ближайший предок с буквальным содержимым (моноширинный) — в нём не живёт перенос строки:
|
|
183
|
+
* значение берёт оттуда голый текст, и строка из значения пропала бы.
|
|
184
|
+
*/
|
|
185
|
+
export function literalAncestor(node: Node, root: HTMLElement): HTMLElement | null {
|
|
186
|
+
return formatAt(node, LITERAL_TAG_SET, root);
|
|
187
|
+
}
|
|
188
|
+
|
|
164
189
|
/** Абсолютные текстовые смещения границ выделения внутри root (для восстановления после правок DOM). */
|
|
165
190
|
export function selectionCharBounds(root: HTMLElement, range: Range): [number, number] {
|
|
166
191
|
const probe = document.createRange();
|
|
@@ -259,8 +284,8 @@ function locateChars(root: HTMLElement, lower: number, upper: number): [CharPosi
|
|
|
259
284
|
return false;
|
|
260
285
|
});
|
|
261
286
|
|
|
262
|
-
|
|
263
|
-
|
|
287
|
+
// Содержимого нет вовсе — каретке место только в самом корне. Возвращать «некуда» нельзя:
|
|
288
|
+
// в пустое поле как раз и вставляют, вернув каретку (панель смайликов работает без фокуса).
|
|
264
289
|
const tail: CharPosition = last ?? { node: root, offset: 0 };
|
|
265
290
|
|
|
266
291
|
return [low ?? tail, high ?? tail];
|
|
@@ -344,6 +369,18 @@ function* touchedTextNodes(root: HTMLElement, range: Range): Generator<Text> {
|
|
|
344
369
|
}
|
|
345
370
|
}
|
|
346
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Формат на самом узле или над ним. Каретка стоит и в самом теге — например в опустевшем `<code>`,
|
|
374
|
+
* из которого стёрли слово: браузер держит её внутри, и печать продолжится оформленной, поэтому
|
|
375
|
+
* состояние обязано этот тег видеть.
|
|
376
|
+
*/
|
|
377
|
+
function formatAt(node: Node, tags: ReadonlySet<string>, root: HTMLElement): HTMLElement | null {
|
|
378
|
+
const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
|
|
379
|
+
if (el && el !== root && tags.has(el.tagName)) return el;
|
|
380
|
+
|
|
381
|
+
return formatAncestor(node, tags, root);
|
|
382
|
+
}
|
|
383
|
+
|
|
347
384
|
/** Узел под схлопнутой кареткой — от него и ищется формат. */
|
|
348
385
|
function caretProbe(range: Range): Node {
|
|
349
386
|
const node = range.startContainer;
|
|
@@ -593,7 +630,7 @@ export function activeFormats(root: HTMLElement, range: Range, tools: FormatTool
|
|
|
593
630
|
|
|
594
631
|
if (range.collapsed) {
|
|
595
632
|
const probe = caretProbe(range);
|
|
596
|
-
for (const tool of tools) if (
|
|
633
|
+
for (const tool of tools) if (formatAt(probe, TOOL_TAG_SETS[tool], root)) active.add(tool);
|
|
597
634
|
|
|
598
635
|
return active;
|
|
599
636
|
}
|
package/source/serialize.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { blockTypeOf } from "./paragraphs";
|
|
4
4
|
import {
|
|
5
5
|
ALL_BLOCK_TYPES,
|
|
6
|
+
ALL_FORMAT_TOOLS,
|
|
6
7
|
BLOCK_TYPES,
|
|
7
8
|
DEFAULT_BLOCK,
|
|
8
9
|
FORMAT_TOOLS,
|
|
@@ -28,17 +29,29 @@ function buildTagMap(tools: FormatTool[]): Record<string, FormatTool> {
|
|
|
28
29
|
return map;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Чем текст был размечен, если не настроенным маркером. Живёт только в разметке поля: значение
|
|
34
|
+
* собирается тегами, и атрибут в него не попадает ни при каком формате хранения.
|
|
35
|
+
*/
|
|
36
|
+
const MARKER_ATTR = "data-md";
|
|
37
|
+
|
|
31
38
|
function lineBreak(storage: FormatStorage): string {
|
|
32
39
|
return storage === "html" ? "<br>" : "\n";
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
function wrap(
|
|
42
|
+
function wrap(
|
|
43
|
+
storage: FormatStorage,
|
|
44
|
+
tool: FormatTool,
|
|
45
|
+
inner: string,
|
|
46
|
+
markers: FormatMarkers,
|
|
47
|
+
source?: HTMLElement
|
|
48
|
+
): string {
|
|
36
49
|
if (!inner) return inner;
|
|
37
50
|
|
|
38
51
|
const def = FORMAT_TOOLS[tool];
|
|
39
52
|
if (storage === "html") return `<${def.tag}>${inner}</${def.tag}>`;
|
|
40
53
|
|
|
41
|
-
const marker = markers
|
|
54
|
+
const marker = ownMarker(tool, markers, source);
|
|
42
55
|
|
|
43
56
|
// Маркер не сработает, если содержимое начинается или заканчивается пробелом, — ни у нас
|
|
44
57
|
// при разборе, ни у мессенджера. Выносим краевые пробелы наружу: разметка сохраняется,
|
|
@@ -51,6 +64,17 @@ function wrap(storage: FormatStorage, tool: FormatTool, inner: string, markers:
|
|
|
51
64
|
return `${leading}${marker}${core}${marker}${trailing}`;
|
|
52
65
|
}
|
|
53
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Маркер, которым текст был размечен: запомненный на элементе, если он известен инструменту,
|
|
69
|
+
* иначе настроенный. Чужой диалект (`*жирный*` из WhatsApp) обязан вернуться в значение таким
|
|
70
|
+
* же, каким пришёл; всё, что размечено в поле, пишется настроенным маркером.
|
|
71
|
+
*/
|
|
72
|
+
function ownMarker(tool: FormatTool, markers: FormatMarkers, source?: HTMLElement): string {
|
|
73
|
+
const marked = source?.dataset.md;
|
|
74
|
+
|
|
75
|
+
return marked && MARKER_ALIASES[tool].includes(marked) ? marked : markers[tool];
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
// Сериализует инлайновое содержимое (текст, форматирование, <br> как мягкий перенос).
|
|
55
79
|
// Абзацы (<p>/<div>) на этом уровне не учитываются — их разбирает serializeParagraphs.
|
|
56
80
|
function serializeInline(
|
|
@@ -82,9 +106,9 @@ function serializeInline(
|
|
|
82
106
|
|
|
83
107
|
// В коде разметки нет: при разборе его содержимое не размечается, и вложенное
|
|
84
108
|
// форматирование не вернулось бы — значение разошлось бы с тем, что было в поле.
|
|
85
|
-
if (tool
|
|
109
|
+
if (tool && FORMAT_TOOLS[tool].literal) {
|
|
86
110
|
const text = el.textContent ?? "";
|
|
87
|
-
result += wrap(storage, tool, storage === "html" ? escapeHtml(text) : text, markers);
|
|
111
|
+
result += wrap(storage, tool, storage === "html" ? escapeHtml(text) : text, markers, el);
|
|
88
112
|
continue;
|
|
89
113
|
}
|
|
90
114
|
|
|
@@ -97,16 +121,17 @@ function serializeInline(
|
|
|
97
121
|
}
|
|
98
122
|
|
|
99
123
|
// неизвестный или отключённый тег — отбрасываем обёртку, оставляем текст
|
|
100
|
-
result += tool ? wrap(storage, tool, inner, markers) : inner;
|
|
124
|
+
result += tool ? wrap(storage, tool, inner, markers, el) : inner;
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
return result;
|
|
104
128
|
}
|
|
105
129
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
130
|
+
// Хвостовой перенос — заполнитель: без него последняя строка блока не видна и в неё не встать
|
|
131
|
+
// кареткой. Отбрасываем ровно один; остальные набраны руками и значат пустые строки — отбросив
|
|
132
|
+
// их, редактор показывал бы строку, которой в значении нет. У всего значения хвост уберёт trim().
|
|
108
133
|
function trimTrailingBreaks(inline: string, storage: FormatStorage): string {
|
|
109
|
-
return storage === "html" ? inline.replace(
|
|
134
|
+
return storage === "html" ? inline.replace(/<br>$/, "") : inline.replace(/\n$/, "");
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
// Разбивает верхний уровень на блоки: у каждого свой тип, у не попавшего в блок содержимого —
|
|
@@ -157,6 +182,11 @@ function serializeParagraphs(
|
|
|
157
182
|
|
|
158
183
|
const cleaned = blocks.map(([type, text]) => [type, trimTrailingBreaks(text, storage)] as const);
|
|
159
184
|
|
|
185
|
+
// Пустые блоки в хвосте в значение не идут: последний из них — место каретки, оставленное
|
|
186
|
+
// нормализацией (см. normalizeParagraphs), а не строка сообщения. В markdown его срезал бы
|
|
187
|
+
// trim всего значения — html обрезаем здесь, чтобы форматы хранения не расходились.
|
|
188
|
+
while (cleaned.length && !cleaned[cleaned.length - 1][1]) cleaned.pop();
|
|
189
|
+
|
|
160
190
|
if (storage === "html")
|
|
161
191
|
return cleaned
|
|
162
192
|
.map(([type, text]) => {
|
|
@@ -220,8 +250,17 @@ export function serialize(
|
|
|
220
250
|
return inline.replace(/^\n+/, "").replace(/\n+$/, "").trim();
|
|
221
251
|
}
|
|
222
252
|
|
|
223
|
-
/**
|
|
224
|
-
|
|
253
|
+
/** Правило разбора одного маркера. */
|
|
254
|
+
interface MarkerRule {
|
|
255
|
+
tool: FormatTool;
|
|
256
|
+
marker: string;
|
|
257
|
+
/** Содержимое не может начинаться и заканчиваться символом самого маркера. */
|
|
258
|
+
standalone: boolean;
|
|
259
|
+
/** Символ маркера не считается границей: маркер не собирается из половинок длинного. */
|
|
260
|
+
guarded: boolean;
|
|
261
|
+
/** Маркер настроенный, а не из чужого диалекта. */
|
|
262
|
+
own: boolean;
|
|
263
|
+
}
|
|
225
264
|
|
|
226
265
|
/**
|
|
227
266
|
* Маркеры в порядке применения: длинный (`__`) раньше короткого-префикса (`_`), иначе
|
|
@@ -234,20 +273,47 @@ type MarkerRule = [tool: FormatTool, marker: string, standalone: boolean];
|
|
|
234
273
|
* подчёркивания. Так же поступают паттерны конвертеров.
|
|
235
274
|
*/
|
|
236
275
|
function orderedMarkers(tools: FormatTool[], markers: FormatMarkers): MarkerRule[] {
|
|
237
|
-
const active = tools.filter((tool) => markers[tool])
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
276
|
+
const active = tools.filter((tool) => markers[tool]);
|
|
277
|
+
const rules: Array<[FormatTool, string, boolean]> = active.map((tool) => [tool, markers[tool], true]);
|
|
278
|
+
const taken = new Set(rules.map(([, marker]) => marker));
|
|
279
|
+
|
|
280
|
+
// Чужие диалекты того же инструмента: запасные маркеры из реестра и его собственный, если
|
|
281
|
+
// настройка его заменила. Разбираются наравне, а ставится всегда настроенный — см. markdownInline.
|
|
282
|
+
for (const tool of active)
|
|
283
|
+
for (const alias of MARKER_ALIASES[tool])
|
|
284
|
+
if (!taken.has(alias)) {
|
|
285
|
+
taken.add(alias);
|
|
286
|
+
rules.push([tool, alias, false]);
|
|
287
|
+
}
|
|
245
288
|
|
|
246
|
-
|
|
247
|
-
|
|
289
|
+
const all = Array.from(taken);
|
|
290
|
+
|
|
291
|
+
return rules
|
|
292
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
293
|
+
.map(([tool, marker, own]) => ({
|
|
294
|
+
tool,
|
|
295
|
+
marker,
|
|
296
|
+
own,
|
|
297
|
+
standalone: all.some((value) => value.length < marker.length && marker.startsWith(value)),
|
|
298
|
+
guarded: all.some((value) => value.length > marker.length && value.startsWith(marker)),
|
|
299
|
+
}));
|
|
248
300
|
}
|
|
249
301
|
|
|
250
|
-
|
|
302
|
+
/**
|
|
303
|
+
* Маркеры инструмента, объявленные в реестре: свой и запасные. Собираются один раз — их
|
|
304
|
+
* спрашивают и на каждый разбор, и на каждый размеченный элемент при сериализации.
|
|
305
|
+
*/
|
|
306
|
+
const MARKER_ALIASES: Record<FormatTool, string[]> = ALL_FORMAT_TOOLS.reduce(
|
|
307
|
+
(map, tool) => {
|
|
308
|
+
const def = FORMAT_TOOLS[tool];
|
|
309
|
+
map[tool] = [def.md, ...(def.mdAliases ?? [])].filter(Boolean);
|
|
310
|
+
|
|
311
|
+
return map;
|
|
312
|
+
},
|
|
313
|
+
{} as Record<FormatTool, string[]>
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const TAG = /<(\/?)([a-z-]+)(?:\s[^>]*)?>/g;
|
|
251
317
|
|
|
252
318
|
/**
|
|
253
319
|
* Закрыт ли в содержимом каждый тег, который в нём открыт.
|
|
@@ -285,28 +351,34 @@ function markdownInline(text: string, order: MarkerRule[]): string {
|
|
|
285
351
|
|
|
286
352
|
// Код — первым: внутри него `*звёздочки*` остаются текстом, как у мессенджеров. Иначе
|
|
287
353
|
// содержимое кода размечалось бы, и то, что человек написал буквально, уезжало бы жирным.
|
|
288
|
-
const stash: string
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
354
|
+
const stash: Array<[inner: string, marked: string]> = []; // содержимое и чужой маркер, если был
|
|
355
|
+
for (const rule of order)
|
|
356
|
+
if (rule.tool === "code" && html.includes(rule.marker))
|
|
357
|
+
html = html.replace(pattern(rule), (_match, lead: string, inner: string) => {
|
|
358
|
+
stash.push([inner, rule.own ? "" : rule.marker]);
|
|
359
|
+
|
|
360
|
+
return `${lead}${STASH_MARK}${stash.length - 1}${STASH_MARK}`;
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
for (const rule of order) {
|
|
364
|
+
if (rule.tool === "code") continue; // уже вынесен
|
|
365
|
+
// Разбор идёт по абзацу на каждый маркер, а маркеров с диалектами уже семь. Поиск
|
|
366
|
+
// подстроки на порядок дешевле разбора регуляркой, и почти всегда он же и решает.
|
|
367
|
+
if (!html.includes(rule.marker)) continue;
|
|
368
|
+
|
|
369
|
+
const def = FORMAT_TOOLS[rule.tool];
|
|
370
|
+
html = html.replace(pattern(rule), (match, lead: string, inner: string) =>
|
|
371
|
+
balancedTags(inner) ? `${lead}${openTag(def.tag, rule.own ? "" : rule.marker)}${inner}</${def.tag}>` : match
|
|
304
372
|
);
|
|
305
373
|
}
|
|
306
374
|
|
|
307
375
|
if (stash.length) {
|
|
308
376
|
const def = FORMAT_TOOLS.code;
|
|
309
|
-
html = html.replace(STASH_PATTERN, (_match, index: string) =>
|
|
377
|
+
html = html.replace(STASH_PATTERN, (_match, index: string) => {
|
|
378
|
+
const [inner, marked] = stash[+index];
|
|
379
|
+
|
|
380
|
+
return `${openTag(def.tag, marked)}${inner}</${def.tag}>`;
|
|
381
|
+
});
|
|
310
382
|
}
|
|
311
383
|
|
|
312
384
|
// переносы — после маркеров: пока это \n, запрет на пересечение строки работает
|
|
@@ -328,26 +400,41 @@ function markdownInline(text: string, order: MarkerRule[]): string {
|
|
|
328
400
|
* Содержимое не начинается и с U+20E3: в keycap-последовательностях (`*⃣`, `#⃣`, `1⃣`) сам
|
|
329
401
|
* маркер служит базовым символом, и без этого `*⃣раз*` разбиралось бы как разметка вместо эмодзи.
|
|
330
402
|
*
|
|
331
|
-
* При `standalone` содержимое
|
|
332
|
-
*
|
|
403
|
+
* При `standalone` содержимое не начинается и не заканчивается символом самого маркера, при
|
|
404
|
+
* `guarded` этот символ вдобавок перестаёт быть границей. Так разводятся маркеры, один из
|
|
405
|
+
* которых начинается с другого (`_` и `__`, `*` и `**`) — см. {@link orderedMarkers}.
|
|
333
406
|
*/
|
|
334
|
-
function
|
|
335
|
-
const
|
|
407
|
+
function pattern(rule: MarkerRule): RegExp {
|
|
408
|
+
const { marker, standalone, guarded } = rule;
|
|
409
|
+
// в ключе различаем режимы: один и тот же маркер бывает разведён по-разному
|
|
410
|
+
const key = `${marker}|${standalone ? "s" : ""}${guarded ? "g" : ""}`;
|
|
336
411
|
|
|
337
|
-
|
|
338
|
-
if (
|
|
412
|
+
const cached = markerPatterns.get(key);
|
|
413
|
+
if (cached) return cached;
|
|
339
414
|
|
|
340
415
|
const escaped = escapeRegExp(marker);
|
|
341
|
-
const boundary = "[^\\p{L}\\p{N}]";
|
|
342
416
|
// символ маркера в классе — экранируем то, что в нём значимо
|
|
343
|
-
const own =
|
|
344
|
-
const
|
|
345
|
-
|
|
417
|
+
const own = marker.slice(-1).replace(/[\\\]^-]/g, "\\$&");
|
|
418
|
+
const edge = standalone || guarded ? own : "";
|
|
419
|
+
// Границей символ маркера перестаёт быть только у короткого. Длинному она нужна: в
|
|
420
|
+
// `___текст___` его пара стоит рядом со своим же символом, и без границы её не найти.
|
|
421
|
+
const boundary = `[^\\p{L}\\p{N}${guarded ? own : ""}]`;
|
|
422
|
+
const head = `[^\\s\\u20e3${edge}]`;
|
|
423
|
+
const tail = edge ? `[^\\s${edge}]` : "\\S";
|
|
424
|
+
|
|
425
|
+
const built = new RegExp(
|
|
426
|
+
`(^|${boundary})${escaped}(${head}|${head}[^\\n]*?${tail})${escaped}(?=$|${boundary})`,
|
|
427
|
+
"gu"
|
|
428
|
+
);
|
|
429
|
+
markerPatterns.set(key, built);
|
|
346
430
|
|
|
347
|
-
|
|
348
|
-
|
|
431
|
+
return built;
|
|
432
|
+
}
|
|
349
433
|
|
|
350
|
-
|
|
434
|
+
/** Открывающий тег: чужой маркер запоминается на элементе, чтобы значение вернулось таким же. */
|
|
435
|
+
function openTag(tag: string, marked: string): string {
|
|
436
|
+
// маркер задаёт хост — в значении атрибута экранируем и кавычку, иначе она разорвёт его
|
|
437
|
+
return marked ? `<${tag} ${MARKER_ATTR}="${escapeHtml(marked).replace(/"/g, """)}">` : `<${tag}>`;
|
|
351
438
|
}
|
|
352
439
|
|
|
353
440
|
// Набор маркеров за разбор не меняется, а разбор идёт по абзацам — компилируем каждую
|
|
@@ -380,7 +467,11 @@ export function deserialize(
|
|
|
380
467
|
const def = BLOCK_TYPES[type];
|
|
381
468
|
const inner = def.inline ? markdownInline(text, order) : escapeHtml(text).replace(/\n/g, "<br>");
|
|
382
469
|
|
|
383
|
-
|
|
470
|
+
// Пустая последняя строка видна только с заполнителем: без него браузер не рисует
|
|
471
|
+
// её и не пускает туда каретку — строка, которая в значении есть, пропала бы.
|
|
472
|
+
const filler = inner.endsWith("<br>") ? "<br>" : "";
|
|
473
|
+
|
|
474
|
+
return `<${def.tag}>${inner ? inner + filler : "<br>"}</${def.tag}>`;
|
|
384
475
|
})
|
|
385
476
|
.join("");
|
|
386
477
|
}
|
package/source/toolbar.ts
CHANGED
|
@@ -60,8 +60,8 @@ const MERGED_CODE_TITLE = "Код";
|
|
|
60
60
|
// Временно скрытые кнопки. Сами возможности работают: значение разбирается, показывается
|
|
61
61
|
// и сохраняется, правку можно вызвать из кода — в панель они просто не выводятся.
|
|
62
62
|
// Убрать отсюда, когда будут доведены.
|
|
63
|
-
const HIDDEN_TOOLS: FormatTool[] = ["spoiler"
|
|
64
|
-
const HIDDEN_BLOCKS: BlockType[] = [
|
|
63
|
+
const HIDDEN_TOOLS: FormatTool[] = ["spoiler"];
|
|
64
|
+
const HIDDEN_BLOCKS: BlockType[] = [];
|
|
65
65
|
|
|
66
66
|
export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
|
|
67
67
|
export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
|
|
@@ -92,8 +92,12 @@ export interface ToolbarHost {
|
|
|
92
92
|
readonly editorActions?: EditorAction[];
|
|
93
93
|
/** Контейнер для тулбара; null/undefined — document.body (position: fixed над редактором). */
|
|
94
94
|
readonly toolbarContainer?: HTMLElement | null;
|
|
95
|
-
/**
|
|
96
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Типы блоков, которые панель вправе предложить; пусто/undefined — кнопок блоков нет.
|
|
97
|
+
* Не то же, что набор для разбора значения: показывать цитату и код редактор обязан
|
|
98
|
+
* и там, где их не переключить (только для чтения).
|
|
99
|
+
*/
|
|
100
|
+
readonly blockTools?: BlockType[];
|
|
97
101
|
applyFormat(tool: FormatTool): void;
|
|
98
102
|
isToolActive(tool: FormatTool): boolean;
|
|
99
103
|
/** false — инструмент сейчас недоступен (например, внутри кода): кнопка гасится. */
|
|
@@ -112,6 +116,14 @@ export interface ToolbarHost {
|
|
|
112
116
|
isActionEnabled?(action: EditorAction): boolean;
|
|
113
117
|
/** Вставка текста в каретку — для панели смайликов. */
|
|
114
118
|
insertText?(text: string): void;
|
|
119
|
+
/**
|
|
120
|
+
* Открыть панель смайликов. Своя кнопка панели зовёт редактор, а не {@link openEmoji}
|
|
121
|
+
* напрямую: он придерживает правку и отпускает фокус на её время — с кнопкой хоста это
|
|
122
|
+
* должно работать одинаково.
|
|
123
|
+
*/
|
|
124
|
+
openEmojiPicker?(initiator: HTMLElement, container: HTMLElement): void;
|
|
125
|
+
/** Панель смайликов закрылась — редактор возвращает себе то, что придержал на её время. */
|
|
126
|
+
onEmojiClosed?(): void;
|
|
115
127
|
/** Собственные кнопки хоста; пусто/undefined — только штатные. */
|
|
116
128
|
readonly toolbarButtons?: ToolbarButton[];
|
|
117
129
|
}
|
|
@@ -222,7 +234,7 @@ class FormatToolbar {
|
|
|
222
234
|
const buttons = host.toolbarButtons ?? [];
|
|
223
235
|
const tools = host.formatTools.filter((tool) => !HIDDEN_TOOLS.includes(tool));
|
|
224
236
|
// Обычный текст кнопки не имеет — он не «включается», а остаётся, когда выключены остальные.
|
|
225
|
-
const blocks = (host.
|
|
237
|
+
const blocks = (host.blockTools ?? []).filter(
|
|
226
238
|
(type) => type !== DEFAULT_BLOCK && !HIDDEN_BLOCKS.includes(type)
|
|
227
239
|
);
|
|
228
240
|
if (!tools.length && !blocks.length && !actions.length && !buttons.length) return;
|
|
@@ -258,6 +270,15 @@ class FormatToolbar {
|
|
|
258
270
|
}
|
|
259
271
|
}
|
|
260
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Убрать панель с экрана, не отпуская редактор: он отдал фокус своему слою — окну или панели
|
|
275
|
+
* смайликов. От {@link detach} отличается тем, что панель смайликов при этом остаётся: её и
|
|
276
|
+
* открыли из этого редактора, а фокус сняли как раз ради неё.
|
|
277
|
+
*/
|
|
278
|
+
suspend(host: ToolbarHost) {
|
|
279
|
+
if (this.__active === host) this.__hide();
|
|
280
|
+
}
|
|
281
|
+
|
|
261
282
|
/** Скрыть тулбар, если он обслуживает этот редактор (на blur/destroy). */
|
|
262
283
|
detach(host: ToolbarHost) {
|
|
263
284
|
// Придержанный показ снимаем первым делом, до закрытия панели: иначе её onClose поднял бы
|
|
@@ -394,16 +415,6 @@ class FormatToolbar {
|
|
|
394
415
|
this.__mergedCode = tools.includes(CODE_TOOL) && blocks.includes(CODE_BLOCK);
|
|
395
416
|
const blockTypes = this.__mergedCode ? blocks.filter((type) => type !== CODE_BLOCK) : blocks;
|
|
396
417
|
|
|
397
|
-
// Разделитель ставится только между непустыми группами — иначе панель начиналась бы
|
|
398
|
-
// с линии или показывала две подряд.
|
|
399
|
-
let filled = false;
|
|
400
|
-
const separate = (group: unknown[]) => {
|
|
401
|
-
if (filled && group.length) elem.appendChild(DOM.tag("div", { class: "split" }));
|
|
402
|
-
filled ||= group.length > 0;
|
|
403
|
-
};
|
|
404
|
-
|
|
405
|
-
separate(tools);
|
|
406
|
-
|
|
407
418
|
for (const tool of tools) {
|
|
408
419
|
const merged = this.__mergedCode && tool === CODE_TOOL;
|
|
409
420
|
const def = FORMAT_TOOLS[tool];
|
|
@@ -425,8 +436,6 @@ class FormatToolbar {
|
|
|
425
436
|
this.__buttons.push([tool, btn]);
|
|
426
437
|
}
|
|
427
438
|
|
|
428
|
-
separate(blockTypes);
|
|
429
|
-
|
|
430
439
|
for (const type of blockTypes) {
|
|
431
440
|
const def = BLOCK_TYPES[type];
|
|
432
441
|
const btn = DOM.tag(
|
|
@@ -440,8 +449,6 @@ class FormatToolbar {
|
|
|
440
449
|
this.__blockButtons.push([type, btn]);
|
|
441
450
|
}
|
|
442
451
|
|
|
443
|
-
separate(actions);
|
|
444
|
-
|
|
445
452
|
for (const action of actions) {
|
|
446
453
|
const def = EDITOR_ACTIONS[action];
|
|
447
454
|
const btn = DOM.tag(
|
|
@@ -456,7 +463,11 @@ class FormatToolbar {
|
|
|
456
463
|
this.__actionButtons.push([action, btn]);
|
|
457
464
|
}
|
|
458
465
|
|
|
459
|
-
|
|
466
|
+
// Инструменты, блоки и действия — одна группа: всё это правка оформления. Разделитель
|
|
467
|
+
// нужен только перед кнопками хоста: они про другое — переменные, рандомизацию и прочее
|
|
468
|
+
// доменное. Пустых групп разделитель не касается, иначе панель начиналась бы с линии.
|
|
469
|
+
if (buttons.length && (tools.length || blockTypes.length || actions.length))
|
|
470
|
+
elem.appendChild(DOM.tag("div", { class: "split" }));
|
|
460
471
|
|
|
461
472
|
// кнопки хоста — последними, чтобы штатные не переезжали при их появлении
|
|
462
473
|
for (const button of buttons) {
|
|
@@ -485,7 +496,7 @@ class FormatToolbar {
|
|
|
485
496
|
* закрытия на body прямо в open(), то есть во время этого же клика — до body событие ещё
|
|
486
497
|
* не дошло, и слушатель закрыл бы панель сразу после открытия.
|
|
487
498
|
*/
|
|
488
|
-
openEmoji(host: ToolbarHost, initiator: HTMLElement, container: HTMLElement) {
|
|
499
|
+
openEmoji(host: ToolbarHost, initiator: HTMLElement, container: HTMLElement): boolean {
|
|
489
500
|
// повторный клик по той же кнопке закрывает панель (это делает toggle внутри PopupManager),
|
|
490
501
|
// а вот у другой кнопки её нужно сперва закрыть — иначе toggle сочтёт открытие повторным
|
|
491
502
|
if (this.__emojiInitiator !== initiator && this.__emojiPicker?.classList.contains("opened"))
|
|
@@ -504,7 +515,17 @@ class FormatToolbar {
|
|
|
504
515
|
if (this.__active === host) this.__hide();
|
|
505
516
|
}
|
|
506
517
|
|
|
507
|
-
PopupManager.open(this.__ensureEmojiPicker(container), {
|
|
518
|
+
PopupManager.open(this.__ensureEmojiPicker(container), {
|
|
519
|
+
initiator,
|
|
520
|
+
onClose: () => {
|
|
521
|
+
this.__resume();
|
|
522
|
+
host.onEmojiClosed?.();
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// Повторное нажатие по той же кнопке панель закрывает (это toggle внутри PopupManager) —
|
|
527
|
+
// вызывающему нужно знать исход: открытая панель ещё и придерживает правку редактора.
|
|
528
|
+
return !!this.__emojiPicker?.classList.contains("opened");
|
|
508
529
|
}
|
|
509
530
|
|
|
510
531
|
/** Панель смайликов закрылась — показываем придержанный тулбар, если редактор ещё в фокусе. */
|
|
@@ -521,7 +542,12 @@ class FormatToolbar {
|
|
|
521
542
|
private __toggleEmoji(initiator: HTMLButtonElement, e: MouseEvent) {
|
|
522
543
|
e.stopPropagation();
|
|
523
544
|
|
|
524
|
-
|
|
545
|
+
const host = this.__active;
|
|
546
|
+
if (!host) return;
|
|
547
|
+
|
|
548
|
+
// через редактор, а не напрямую: панель — его слой, и придерживает правку он
|
|
549
|
+
if (host.openEmojiPicker) host.openEmojiPicker(initiator, this.__ensure());
|
|
550
|
+
else this.openEmoji(host, initiator, this.__ensure());
|
|
525
551
|
}
|
|
526
552
|
|
|
527
553
|
private __ensureEmojiPicker(container: HTMLElement): HTMLElement {
|