@brandup/ui-richeditor 1.0.44 → 1.0.47
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 +65 -8
- package/package.json +3 -3
- package/source/editing.ts +72 -17
- package/source/emoji.ts +69 -0
- package/source/format-config.ts +12 -2
- package/source/format.ts +2 -0
- package/source/history.ts +10 -5
- package/source/index.ts +1 -1
- package/source/richeditor.less +157 -28
- package/source/richeditor.ts +154 -37
- package/source/selection.ts +56 -3
- package/source/serialize.ts +125 -3
- package/source/toolbar.ts +234 -133
- package/svg/apply.svg +1 -0
- package/svg/link.svg +1 -0
- package/svg/unlink.svg +1 -0
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
35
35
|
|
|
36
36
|
Панель — **общая для всех редакторов** (`div.ui-richeditor-toolbar`). При фокусе редактора она перестраивается под его инструменты, позиционируется над ним и показывается; при потере фокуса — скрывается. Кнопки диспатчат форматирование напрямую активному редактору.
|
|
37
37
|
|
|
38
|
+
Внутри обёртка разделена надвое: сама `.ui-richeditor-toolbar` отвечает только за положение, а вид и содержимое держит коробка `.toolbar-body` внутри неё. Её размер меняется вместе с содержимым — на правку адреса ссылки, например, — а точка привязки от этого съезжать не должна; выпадающие слои вроде панели смайликов висят на обёртке и коробкой не обрезаются. Все кнопки панели несут общий класс `.toolbar-button` (плюс свой — `.format-button`, `.block-button`, `.action-button`, `.host-button`), по нему они и оформляются.
|
|
39
|
+
|
|
38
40
|
По умолчанию панель живёт в `document.body` (`position: fixed`) — это защищает её от обрезки `overflow: hidden` у родителей. Если задан `toolbarContainer`, панель монтируется в него и позиционируется относительно него (`position: absolute`, над контейнером) — например, `TextBox` передаёт свой контейнер `.ui-textbox`.
|
|
39
41
|
|
|
40
42
|
## Опции (`RichEditorOptions`)
|
|
@@ -42,7 +44,7 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
42
44
|
| Опция | Тип | Описание |
|
|
43
45
|
| --- | --- | --- |
|
|
44
46
|
| `format` | `boolean` | Включает форматирование и панель инструментов |
|
|
45
|
-
| `tools` | `FormatTool[]` | Состав инструментов (по умолчанию все) |
|
|
47
|
+
| `tools` | `FormatTool[]` | Состав инструментов (по умолчанию все); им же разбирается и сохраняется значение — разметка, которой в наборе нет, остаётся в тексте как есть |
|
|
46
48
|
| `actions` | `EditorAction[]` | Кнопки действий в панели: `emoji`, `erase`, `undo`, `redo` (по умолчанию нет) |
|
|
47
49
|
| `storage` | `"html" \| "markdown"` | Формат сериализации значения (по умолчанию `html`) |
|
|
48
50
|
| `markers` | `Partial<FormatMarkers>` | Переопределение markdown-маркеров по инструментам |
|
|
@@ -51,7 +53,7 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
51
53
|
| `paragraph` | `"block" \| "break"` | Что делает Enter: новый абзац (по умолчанию) или мягкий перенос |
|
|
52
54
|
| `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию все); пустой список оставляет только `paragraph` |
|
|
53
55
|
| `keepFocus` | `boolean` | Держать ли фокус в поле, пока открыта панель смайликов (по умолчанию да, а на сенсорном устройстве нет) |
|
|
54
|
-
| `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются) |
|
|
56
|
+
| `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются); разметка значения при этом разбирается и показывается, кнопок для неё просто нет |
|
|
55
57
|
| `toolbarContainer` | `HTMLElement \| null` | Контейнер для панели; по умолчанию `document.body` (`position: fixed`). Если задан — панель монтируется в него и позиционируется над ним (`position: absolute`). Контейнер должен быть `position: relative` |
|
|
56
58
|
| `value` | `string` | Начальное значение |
|
|
57
59
|
| `filterChar` | `(char) => boolean` | Хук: `false` — отклонить вводимый символ |
|
|
@@ -66,7 +68,9 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
66
68
|
| Член | Описание |
|
|
67
69
|
| --- | --- |
|
|
68
70
|
| `editable` | Редактируемый элемент |
|
|
69
|
-
| `format`, `
|
|
71
|
+
| `format`, `editorActions`, `formatStorage`, `formatMarkers`, `multiline` | Параметры экземпляра |
|
|
72
|
+
| `formatTypes` | Объявленный набор инструментов — им разбирается и сохраняется значение |
|
|
73
|
+
| `formatTools` | Инструменты в панели: то же, но пусто в `readonly` — переключать разметку там нечем |
|
|
70
74
|
| `getValue(): string` | Сериализованное значение (по `storage`) — считается по DOM, всегда актуально |
|
|
71
75
|
| `setValue(value: string): void` | Установить значение (нормализует, генерирует `change`) |
|
|
72
76
|
| `flushChange(): void` | Доставить отложенное `change` немедленно (см. ниже) |
|
|
@@ -77,6 +81,10 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
77
81
|
| `applyBlock(type): void` | Переключить тип блоков под выделением; повторное применение возвращает обычный текст |
|
|
78
82
|
| `applyCode(): void` | Код по выделению: моноширинный для части строки, блок — для целых строк |
|
|
79
83
|
| `isCodeActive(): boolean` | Включён ли код в любом виде — подсветка объединённой кнопки |
|
|
84
|
+
| `applyLink(url): void` | Поставить ссылку, поменять её адрес или снять её (пустым адресом) |
|
|
85
|
+
| `currentLink: string` | Адрес ссылки под кареткой; пусто — каретка не в ссылке |
|
|
86
|
+
| `caretSnapshot(): [number, number] \| null` | Каретка в символах — вернуть её потом можно и без фокуса |
|
|
87
|
+
| `restoreCaret(bounds): void` | Вернуть каретку по снимку |
|
|
80
88
|
| `currentBlock: BlockType` | Тип блока под кареткой |
|
|
81
89
|
| `blockTypes: BlockType[]` | Доступные типы блоков |
|
|
82
90
|
| `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
|
|
@@ -89,7 +97,7 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
89
97
|
| `applyAction(action): void` | Выполнить действие панели (`erase`/`undo`/`redo`) |
|
|
90
98
|
| `isActionEnabled(action): boolean` | Доступно ли действие сейчас |
|
|
91
99
|
| `insertText(text): void` | Вставить текст в каретку (или вместо выделения) с учётом режима набора; без фокуса вставляет по снятой каретке |
|
|
92
|
-
| `openEmojiPicker(
|
|
100
|
+
| `openEmojiPicker(picker, initiator): boolean` | Показать переданный попап смайликов у кнопки; false — этим нажатием он закрылся |
|
|
93
101
|
| `selection: Selection \| null` | Выделение, если оно внутри редактора (иначе `null`) — единая точка доступа для хоста |
|
|
94
102
|
| `selectNode(node): void` | Выделить узел внутри редактора: следующая вставка заменит его целиком |
|
|
95
103
|
| `onChange(handler)` | Подписка на событие `richeditor-change` |
|
|
@@ -121,7 +129,7 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
121
129
|
- Формат — переключатель (toggle): повторное применение снимает его.
|
|
122
130
|
- Применяется к слову целиком: курсор внутри слова или выделение его части → формат охватывает всё слово; исходное выделение/каретка сохраняются.
|
|
123
131
|
- **Режим набора**: на пустом месте (между пробелами / в пустом поле) кнопка/хоткей включают «ожидающий» формат — он применится к следующему введённому тексту. Сбрасывается при перемещении каретки, клике или потере фокуса.
|
|
124
|
-
- Хоткеи `Ctrl/Cmd+B/I/U
|
|
132
|
+
- Хоткеи `Ctrl/Cmd+B/I/U` и `Ctrl/Cmd+K` (ссылка — показывает в панели поле адреса). Зачёркивание — только кнопкой.
|
|
125
133
|
- **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов, но не более ~512 КБ снимков суммарно (снимок — это всё содержимое редактора, поэтому на длинном тексте старые шаги вытесняются раньше).
|
|
126
134
|
- При потере фокуса и после `setValue` пробелы нормализуются (схлопывание повторов + обрезка краёв строк). Неразрывный пробел (`U+00A0`) считается обычным: браузер сам подставляет его в `contenteditable` вместо пробела, который иначе схлопнулся бы при отображении, и в значение он не попадает.
|
|
127
135
|
|
|
@@ -150,9 +158,11 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
|
|
|
150
158
|
|
|
151
159
|
Кнопка `emoji` открывает под панелью попап `.ui-richeditor-emoji` со списком символов (`EMOJIS` — экспортируется пакетом). Выбранный символ вставляется через `insertText()`, то есть в текущую каретку и с учётом ожидающих форматов режима набора; попап после выбора закрывается.
|
|
152
160
|
|
|
161
|
+
Попап принадлежит своему владельцу: у панели свой, у хоста со своей кнопкой (например у поля сообщения) — свой. Собирает их общая `createEmojiPicker(onPick)`, показывает — редактор: `openEmojiPicker(picker, initiator)` берёт на себя удержание правки на время попапа, каретку, если её ещё не было, и отпускание фокуса по `keepFocus`. Он же убирает панель форматирования, если попап раскрывается не из неё: два всплывающих слоя над одним полем вместе не показываются. Открытым в любом случае бывает один — за этим следит `PopupManager` кита.
|
|
162
|
+
|
|
153
163
|
Открытием и закрытием управляет `PopupManager` из [`@brandup/ui-kit`](../brandup-ui-kit) — оттуда же приходят базовые стили `.ui-popup`. Ни кнопка, ни попап не забирают фокус сами (`mousedown` гасится), поэтому каретка и выделение сохраняются; на сенсорном устройстве поле отдаёт фокус намеренно (см. «Фокус на время своего слоя»), и вставка идёт по снятой каретке. Список кнопок собирается лениво, при первом открытии.
|
|
154
164
|
|
|
155
|
-
|
|
165
|
+
Попап — слой над полем, поэтому показывает его редактор (`openEmojiPicker()`): на время работы он придерживает правку, чтобы нормализация не обрезала пробел у каретки. Хост со своей кнопкой собирает попап сам и передаёт его сюда — так делает [`@brandup/ui-messageeditor`](../brandup-ui-messageeditor).
|
|
156
166
|
|
|
157
167
|
## Многострочный режим: абзацы и переносы
|
|
158
168
|
|
|
@@ -190,6 +200,33 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
|
|
|
190
200
|
|
|
191
201
|
Кнопка спойлера временно скрыта (`HIDDEN_TOOLS` в `./toolbar`): сам инструмент работает — значение разбирается, показывается и сохраняется, правку можно вызвать из кода (`applyFormat`), — но в панель он пока не выводится.
|
|
192
202
|
|
|
203
|
+
### Ссылка
|
|
204
|
+
|
|
205
|
+
`link` — единственный инструмент, у которого есть данные: адрес идёт отдельной частью и в тексте не показывается. Поэтому он и устроен иначе остальных.
|
|
206
|
+
|
|
207
|
+
**В разметке.** Пара маркеров тут не годится, и в реестре у него `md: ""` — этим он выводится из всей маркерной механики. Разбор и сборку он делает своими ветками: `[текст](адрес)` в markdown, `<a href>` в html. Адрес прячется от маркеров до их разбора — иначе `example.com/a_b_c` уезжал бы курсивом. Текст ссылки от них не прячется: разметка внутри него разбирается наравне с остальной, `[**жирный**](адрес)` работает.
|
|
208
|
+
|
|
209
|
+
Адрес читается и в угловых скобках — `[текст](<адрес с пробелом>)`; пишется в них же, когда иначе не прочитается. Парные скобки внутри адреса разрешены и без угловых: ими кончается половина ссылок на википедию. Скобки в тексте экранируются обратной косой.
|
|
210
|
+
|
|
211
|
+
Ссылки без текста или без адреса не бывает — такая разметка остаётся текстом. Перенос строки внутри ссылки тоже: её текст в разметке один кусок, разорванный он не выражается и с разбора не вернётся. **Enter** внутри ссылки её заканчивает — за переносом остаётся обычный текст, а не вторая ссылка с тем же адресом. Схема адреса проверяется: `javascript:`, `data:` и прочее исполняемое ссылкой не становится ни при разборе значения, ни при вставке из буфера.
|
|
212
|
+
|
|
213
|
+
**В работе.** Кнопка не переключатель — по ней панель показывает поле адреса вместо кнопок (`Ctrl`/`Cmd`+`K` делает то же). Не выпадающий слой: то же место, та же коробка — позиционировать и ужимать ничего не приходится, а кнопки на это время всё равно не нужны. Повторное нажатие возвращает их. Каретка в готовой ссылке подставляет её адрес: тогда это правка, а не новая ссылка. Пустой адрес ссылку снимает, для этого же есть кнопка рядом с полем.
|
|
214
|
+
|
|
215
|
+
Ссылка — оформление текста, а не вставка: делать ссылкой нечего — кнопка погашена, и `applyLink` ничего не делает.
|
|
216
|
+
|
|
217
|
+
| | |
|
|
218
|
+
| --- | --- |
|
|
219
|
+
| Выделение | становится ссылкой |
|
|
220
|
+
| Каретка в слове | ссылкой становится слово целиком, как и у остальных инструментов |
|
|
221
|
+
| Каретка в готовой ссылке | меняется её адрес — целиком, а не по куску выделения |
|
|
222
|
+
| Каретка вне слова | кнопка недоступна: оборачивать нечего |
|
|
223
|
+
|
|
224
|
+
Адрес отдаёт `currentLink`, ставит и снимает `applyLink(url)`; `applyFormat("link")` ничего не делает — переключением адрес не задать.
|
|
225
|
+
|
|
226
|
+
Поле адреса, в отличие от всего остального в панели, забирает фокус: без него не набрать. Выделение в редакторе при этом теряется, поэтому панель снимает каретку до перевода фокуса и возвращает её перед правкой — тем же снимком в символах (`caretSnapshot`/`restoreCaret`), которым пользуются окна хоста. `Esc` возвращает каретку, ничего не изменив.
|
|
227
|
+
|
|
228
|
+
Отпущенный фокус обычно убирает панель с экрана (`suspend`), но не пока правят адрес: поле лежит **в самой панели**, и вместе с ней ушло бы и оно. У панели смайликов слой чужой — она раскрывается от кнопки хоста, — поэтому там панель прячется как раз правильно. А вот фокус, ушедший мимо панели, правку заканчивает: держаться ей больше не на чем.
|
|
229
|
+
|
|
193
230
|
### Одна кнопка на моноширинный и блок кода
|
|
194
231
|
|
|
195
232
|
Когда включены и инструмент `code`, и тип блока `code`, панель показывает **одну** кнопку — так это устроено в мессенджерах. Вид выбирается по выделению, как и в самой разметке:
|
|
@@ -221,7 +258,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
|
|
|
221
258
|
|
|
222
259
|
При включённом форматировании вставка (`paste`) сохраняет форматирование из буфера обмена (`text/html`):
|
|
223
260
|
|
|
224
|
-
- разметка санитизируется до включённых инструментов (синонимы `STRONG/EM/DEL/INS` → канонические `b/i/s/u`, всё прочее — `span`, стили, классы, `<style>`/`<script>` — отбрасывается, текст сохраняется)
|
|
261
|
+
- разметка санитизируется до включённых инструментов (синонимы `STRONG/EM/DEL/INS` → канонические `b/i/s/u`, всё прочее — `span`, стили, классы, `<style>`/`<script>` — отбрасывается, текст сохраняется). Из атрибутов остаётся только `href` у ссылки, и то по проверенной схеме;
|
|
225
262
|
- **multiline** сохраняет абзацы `<p>` и мягкие переносы `<br>`, разбивая текущий абзац по каретке; **single-line** — инлайн, абзацы/переносы становятся пробелами;
|
|
226
263
|
- хук `filterPaste` остаётся в силе: вернул `null` — вставка отклоняется; изменил текст (обрезка по длине, фильтр по типу) — форматирование не сохраняется, вставляется очищенный текст.
|
|
227
264
|
|
|
@@ -256,4 +293,24 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
|
|
|
256
293
|
|
|
257
294
|
## CSS
|
|
258
295
|
|
|
259
|
-
Подключается `richeditor.less`.
|
|
296
|
+
Подключается `richeditor.less`. Классы: `focused` на обёртке (поле в фокусе), `visible` на общей панели (показана), `active` на кнопке инструмента (формат активен на выделении).
|
|
297
|
+
|
|
298
|
+
Собственные переменные пакета объявлены в `:root` в начале `richeditor.less` — там же и их значения по умолчанию:
|
|
299
|
+
|
|
300
|
+
| Переменная | По умолчанию | Что задаёт |
|
|
301
|
+
| --- | --- | --- |
|
|
302
|
+
| `--richeditor-quote-line` | `rgba(0,0,0,.2)` | Линия слева у цитаты |
|
|
303
|
+
| `--richeditor-code-fill` | `rgba(0,0,0,.06)` | Подложка кода — и моноширинного, и блока |
|
|
304
|
+
| `--richeditor-code-font` | `ui-monospace, …` | Шрифт кода |
|
|
305
|
+
| `--richeditor-spoiler-fill` | `rgba(0,0,0,.14)` | Плашка спойлера |
|
|
306
|
+
| `--richeditor-link-width` | `320px` | Ширина панели при правке адреса, когда она стоит в `document.body` — растягиваться там не по чему |
|
|
307
|
+
| `--richeditor-link-max-width` | `500px` | Предел ширины растянутой панели при правке адреса |
|
|
308
|
+
| `--richeditor-link-color` | `#2481cc` | Цвет ссылки в тексте |
|
|
309
|
+
| `--richeditor-link-underline-offset` | `auto` | Отступ подчёркивания ссылки от базовой линии |
|
|
310
|
+
| `--richeditor-underline-room` | `2px` | Место под подчёркивание последней строки: рисуется оно ниже текста, но в раскладке места не занимает, и без запаса его срезает край прокручиваемой коробки |
|
|
311
|
+
| `--richeditor-toolbar-padding` | `3px` | Поля панели |
|
|
312
|
+
| `--richeditor-toolbar-button-size` | `34px` | Кнопка панели; по ней же высота поля адреса |
|
|
313
|
+
| `--richeditor-emoji-size` | `32px` | Ячейка в панели смайликов |
|
|
314
|
+
| `--richeditor-emoji-rows` | `8` | Запасная высота нарисованной не сразу группы; своё значение панель ставит на каждую группу |
|
|
315
|
+
|
|
316
|
+
Остальное оформление берётся у полей ввода `@brandup/ui-kit` — `--input-*`, `--hover--input-*`, `--focus--input-*`, `--placeholder-*`, `--svg-*`. В `:root` пакет их не объявляет: объявив, он перекрыл бы значения кита. Запасные значения у них стоят по месту — на случай использования пакета без кита.
|
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.47",
|
|
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.47"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"source",
|
package/source/editing.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { deserialize } from "./serialize";
|
|
6
6
|
import { blockAt, blockTypeOf, blocksInRange, createBlock, isBlock } from "./paragraphs";
|
|
7
|
-
import { documentSelection, innerSelection, literalAncestor } from "./selection";
|
|
7
|
+
import { documentSelection, innerSelection, linkAt, literalAncestor } from "./selection";
|
|
8
8
|
import { BLOCK_TYPES, DEFAULT_BLOCK, type BlockType, type FormatMarkers, type FormatTool } from "./format-config";
|
|
9
9
|
|
|
10
10
|
// убирает пустые текст-узлы и ставит <br>-заполнитель в пустой абзац (для видимости и каретки)
|
|
@@ -184,6 +184,35 @@ export function atBlockStart(editable: HTMLElement, range: Range): boolean {
|
|
|
184
184
|
return before.toString().length === 0;
|
|
185
185
|
}
|
|
186
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
|
+
|
|
187
216
|
/** Enter в multiline: разбить текущий блок по каретке; хвост становится блоком типа `type`. */
|
|
188
217
|
export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT_BLOCK) {
|
|
189
218
|
const selection = innerSelection(editable);
|
|
@@ -192,12 +221,15 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
|
|
|
192
221
|
const range = selection.getRangeAt(0);
|
|
193
222
|
range.deleteContents();
|
|
194
223
|
|
|
224
|
+
// Ссылку абзац разорвал бы пополам, а её текст в разметке — один кусок. Выводим хвост
|
|
225
|
+
// из неё до разбиения: в новом абзаце останется обычный текст (см. splitLinkOut).
|
|
226
|
+
splitLinkOut(editable, range);
|
|
227
|
+
|
|
195
228
|
// текущий блок (ближайший блочный предок внутри редактора)
|
|
196
|
-
|
|
197
|
-
while (para && para !== editable && !isBlock(para)) para = para.parentNode;
|
|
229
|
+
const para = blockOf(editable, range.startContainer);
|
|
198
230
|
|
|
199
231
|
// каретка не внутри абзаца — создаём абзац сразу с видимым результатом (иначе Enter «срабатывает со 2-го раза»)
|
|
200
|
-
if (!para
|
|
232
|
+
if (!para) {
|
|
201
233
|
const next = createBlock(type);
|
|
202
234
|
if (editable.childNodes.length === 0) {
|
|
203
235
|
// пустой редактор: пустая строка-источник + новая строка с кареткой
|
|
@@ -205,8 +237,7 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
|
|
|
205
237
|
editable.appendChild(next);
|
|
206
238
|
} else {
|
|
207
239
|
// каретка на уровне редактора между/после абзацев — вставляем новый абзац в эту позицию
|
|
208
|
-
|
|
209
|
-
editable.insertBefore(next, ref);
|
|
240
|
+
editable.insertBefore(next, topLevelRef(editable, range));
|
|
210
241
|
}
|
|
211
242
|
caretToStart(next);
|
|
212
243
|
return;
|
|
@@ -220,11 +251,11 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
|
|
|
220
251
|
|
|
221
252
|
// Выходим из блока, а за ним уже стоит пустой абзац — переходим в него. Иначе одно нажатие
|
|
222
253
|
// давало бы две пустые строки: одна тут заводится, вторая уже была заведена под каретку.
|
|
223
|
-
const following =
|
|
254
|
+
const following = para.nextElementSibling;
|
|
224
255
|
const empty = !(fragment.textContent ?? "") && !fragment.querySelector("br");
|
|
225
256
|
|
|
226
257
|
if (empty && following && blockTypeOf(following) === type && !(following.textContent ?? "")) {
|
|
227
|
-
fillEmptyParagraph(para
|
|
258
|
+
fillEmptyParagraph(para);
|
|
228
259
|
caretToStart(following);
|
|
229
260
|
|
|
230
261
|
return;
|
|
@@ -232,14 +263,14 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
|
|
|
232
263
|
|
|
233
264
|
const next = document.createElement(BLOCK_TYPES[type].tag);
|
|
234
265
|
next.appendChild(fragment);
|
|
235
|
-
|
|
266
|
+
para.after(next);
|
|
236
267
|
|
|
237
268
|
// хвост уехал в блок другого типа — его правила распространяются и на содержимое
|
|
238
269
|
if (!BLOCK_TYPES[type].inline) unwrapFormatting(next);
|
|
239
270
|
|
|
240
271
|
// extractContents в конце абзаца оставляет пустой текст-узел → <p></p> без заполнителя
|
|
241
272
|
// (невидим/нефокусируем, каретка не встаёт). Чистим и ставим <br> в опустевшие абзацы.
|
|
242
|
-
fillEmptyParagraph(para
|
|
273
|
+
fillEmptyParagraph(para);
|
|
243
274
|
fillEmptyParagraph(next);
|
|
244
275
|
|
|
245
276
|
caretToStart(next);
|
|
@@ -250,7 +281,25 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
|
|
|
250
281
|
* а диапазон встаёт между половинами — вставленное туда окажется снаружи обоих. Пустые
|
|
251
282
|
* половины не оставляем: печатать в них было бы нечего, а каретка попадала бы внутрь.
|
|
252
283
|
*/
|
|
253
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Выводит хвост ссылки из неё, разрезав по каретке: перед переносом строки.
|
|
286
|
+
*
|
|
287
|
+
* Ссылку разрывать нельзя — в разметке её текст один кусок, — а продолжать на новой строке
|
|
288
|
+
* нечем: адрес у ссылки один, и вторая ссылка с тем же адресом появилась бы сама собой,
|
|
289
|
+
* хотя её никто не заводил. Поэтому за переносом остаётся обычный текст.
|
|
290
|
+
*/
|
|
291
|
+
function splitLinkOut(editable: HTMLElement, range: Range) {
|
|
292
|
+
const link = linkAt(editable, range);
|
|
293
|
+
if (!link) return;
|
|
294
|
+
|
|
295
|
+
// Разрезаем и снимаем с хвоста обёртку: каретка стоит ровно перед ним, и разворачивание
|
|
296
|
+
// её не сдвигает — на месте одного узла оказываются его дети, с того же места.
|
|
297
|
+
const tail = splitElement(link, range);
|
|
298
|
+
if (tail) tail.replaceWith(...Array.from(tail.childNodes));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Разрезает элемент по каретке; возвращает хвост, если в нём что-то осталось. */
|
|
302
|
+
function splitElement(el: HTMLElement, range: Range): HTMLElement | null {
|
|
254
303
|
const tail = document.createRange();
|
|
255
304
|
tail.selectNodeContents(el);
|
|
256
305
|
tail.setStart(range.startContainer, range.startOffset);
|
|
@@ -258,12 +307,15 @@ function splitElement(el: HTMLElement, range: Range) {
|
|
|
258
307
|
const rest = el.cloneNode(false) as HTMLElement;
|
|
259
308
|
rest.appendChild(tail.extractContents());
|
|
260
309
|
|
|
261
|
-
|
|
310
|
+
const kept = !!rest.textContent;
|
|
311
|
+
if (kept) el.after(rest);
|
|
262
312
|
|
|
263
313
|
range.setStartAfter(el);
|
|
264
314
|
range.collapse(true);
|
|
265
315
|
|
|
266
316
|
if (!el.textContent) el.remove();
|
|
317
|
+
|
|
318
|
+
return kept ? rest : null;
|
|
267
319
|
}
|
|
268
320
|
|
|
269
321
|
/** Shift/Ctrl+Enter в multiline: вставить мягкий перенос <br>. */
|
|
@@ -281,6 +333,11 @@ export function insertSoftBreak(editable: HTMLElement) {
|
|
|
281
333
|
const literal = literalAncestor(range.startContainer, editable);
|
|
282
334
|
if (literal) splitElement(literal, range);
|
|
283
335
|
|
|
336
|
+
// Перенос не живёт и в ссылке: её текст — один сплошной кусок, разметкой разорванный
|
|
337
|
+
// не выражается. Разрезаем так же, но продолжения на новой строке не оставляем: адрес
|
|
338
|
+
// у неё один, а второй ссылки никто не заводил.
|
|
339
|
+
splitLinkOut(editable, range);
|
|
340
|
+
|
|
284
341
|
const br = document.createElement("br");
|
|
285
342
|
range.insertNode(br);
|
|
286
343
|
|
|
@@ -306,17 +363,15 @@ export function insertSoftBreak(editable: HTMLElement) {
|
|
|
306
363
|
|
|
307
364
|
/** Вставляет санитизированные абзацы <p> в позицию каретки, разбивая текущий абзац. */
|
|
308
365
|
export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement[], range: Range) {
|
|
309
|
-
|
|
310
|
-
while (para && para !== editable && !isBlock(para)) para = para.parentNode;
|
|
366
|
+
const block = blockOf(editable, range.startContainer);
|
|
311
367
|
|
|
312
368
|
// каретка не внутри абзаца (пустой редактор / уровень редактора) — вставляем абзацы как есть
|
|
313
|
-
if (!
|
|
314
|
-
const ref = editable
|
|
369
|
+
if (!block) {
|
|
370
|
+
const ref = topLevelRef(editable, range);
|
|
315
371
|
for (const p of paras) editable.insertBefore(p, ref);
|
|
316
372
|
return;
|
|
317
373
|
}
|
|
318
374
|
|
|
319
|
-
const block = para as HTMLElement;
|
|
320
375
|
// Вставка в цитату или код остаётся в них: разорвать блок посреди вставки — не то,
|
|
321
376
|
// чего ждут, а тип целевого блока диктует и правила его содержимого.
|
|
322
377
|
const type = blockTypeOf(block) ?? DEFAULT_BLOCK;
|
package/source/emoji.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { DOM } from "@brandup/ui";
|
|
2
|
+
import { POPUP_CLASS, PopupManager, SCROLLABLE_CLASS } from "@brandup/ui-kit";
|
|
3
|
+
|
|
1
4
|
// Набор смайликов для панели вставки. Все символы — одиночные кодпойнты (без ZWJ-последовательностей
|
|
2
5
|
// и модификаторов), поэтому переносятся в текст как единое целое. Внимание: в UTF-16 каждый занимает
|
|
3
6
|
// две единицы, и getLength() (а значит и maxlength у хоста) считает такой символ за два.
|
|
@@ -127,3 +130,69 @@ export const EMOJI_GROUPS: EmojiGroup[] = [
|
|
|
127
130
|
|
|
128
131
|
/** Все смайлики подряд, в порядке групп. */
|
|
129
132
|
export const EMOJIS: string[] = EMOJI_GROUPS.flatMap((group) => group.emojis);
|
|
133
|
+
|
|
134
|
+
// --- панель вставки ---
|
|
135
|
+
|
|
136
|
+
/** Попап вставки смайлика: у каждого владельца свой, собираются они здесь. */
|
|
137
|
+
export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
|
|
138
|
+
|
|
139
|
+
// Сколько кнопок помещается в ряд при ширине панели (см. .ui-richeditor-emoji в richeditor.less).
|
|
140
|
+
// Точность нужна только для оценки высоты нерисованной группы: ошибка сдвинет ползунок прокрутки,
|
|
141
|
+
// но не саму раскладку — группа переносит кнопки сама.
|
|
142
|
+
const EMOJI_COLUMNS = 8;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Группа смайликов: и смысловое деление в панели (отбивается линией), и кусок, к которому
|
|
146
|
+
* применяется пропуск отрисовки. Поэлементно это было бы семьсот отслеживаемых поддеревьев,
|
|
147
|
+
* и слежение за ними съедает выигрыш от пропуска.
|
|
148
|
+
*/
|
|
149
|
+
function buildEmojiGroup(group: EmojiGroup): HTMLElement {
|
|
150
|
+
const rows = Math.ceil(group.emojis.length / EMOJI_COLUMNS);
|
|
151
|
+
const elem = DOM.tag("div", {
|
|
152
|
+
class: "emoji-group",
|
|
153
|
+
role: "group",
|
|
154
|
+
"aria-label": group.title,
|
|
155
|
+
// высота, пока группа не нарисована: без неё список схлопнулся бы, а прокрутка скакала
|
|
156
|
+
style: `--richeditor-emoji-rows: ${rows}`,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const fragment = document.createDocumentFragment();
|
|
160
|
+
for (const emoji of group.emojis)
|
|
161
|
+
fragment.appendChild(DOM.tag("button", { type: "button", class: "emoji", tabindex: "-1" }, emoji));
|
|
162
|
+
elem.appendChild(fragment);
|
|
163
|
+
|
|
164
|
+
return elem;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Собирает попап вставки смайлика.
|
|
169
|
+
*
|
|
170
|
+
* Попап принадлежит тому, кто его собрал: у панели форматирования свой, у поля сообщения — свой.
|
|
171
|
+
* Общий на всех пришлось бы переносить между владельцами и помнить, чей он сейчас; а показать
|
|
172
|
+
* два разом всё равно нельзя — {@link PopupManager} держит открытым один.
|
|
173
|
+
*
|
|
174
|
+
* Показом и закрытием занимается вызывающий (у редактора для этого есть `openEmojiPicker`):
|
|
175
|
+
* здесь только разметка и выбор символа.
|
|
176
|
+
*/
|
|
177
|
+
export function createEmojiPicker(onPick: (emoji: string) => void): HTMLElement {
|
|
178
|
+
const picker = DOM.tag("div", { class: `${POPUP_CLASS} ${EMOJI_PICKER_CLASS}` });
|
|
179
|
+
|
|
180
|
+
// Прокручивается список, а не сам попап: полоса прокрутки рисуется по краю коробки
|
|
181
|
+
// и перекрывала бы скругление рамки — угол выглядел бы срезанным.
|
|
182
|
+
const list = DOM.tag("div", { class: ["emoji-list", SCROLLABLE_CLASS] });
|
|
183
|
+
picker.appendChild(list);
|
|
184
|
+
|
|
185
|
+
for (const group of EMOJI_GROUPS) list.appendChild(buildEmojiGroup(group));
|
|
186
|
+
|
|
187
|
+
// попап живёт и вне панели, поэтому фокус гасит сам
|
|
188
|
+
picker.addEventListener("mousedown", (e) => e.preventDefault());
|
|
189
|
+
picker.addEventListener("click", (e) => {
|
|
190
|
+
const target = (e.target as HTMLElement).closest<HTMLElement>(".emoji");
|
|
191
|
+
if (!target) return;
|
|
192
|
+
|
|
193
|
+
onPick(target.textContent ?? "");
|
|
194
|
+
PopupManager.close();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return picker;
|
|
198
|
+
}
|
package/source/format-config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Конфигурация форматирования: типы, набор инструментов, markdown-маркеры и Ctrl/Cmd-хоткеи.
|
|
2
2
|
|
|
3
|
-
export type FormatTool = "bold" | "italic" | "strike" | "underline" | "spoiler" | "code";
|
|
3
|
+
export type FormatTool = "bold" | "italic" | "strike" | "underline" | "spoiler" | "code" | "link";
|
|
4
4
|
export type FormatStorage = "html" | "markdown";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -93,7 +93,7 @@ export function blockTypeOfTag(tagName: string): BlockType | null {
|
|
|
93
93
|
return BLOCK_TAGS[tagName] ?? null;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline", "spoiler", "code"];
|
|
96
|
+
export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline", "spoiler", "code", "link"];
|
|
97
97
|
|
|
98
98
|
export const ALL_EDITOR_ACTIONS: EditorAction[] = ["emoji", "erase", "undo", "redo"];
|
|
99
99
|
|
|
@@ -170,6 +170,16 @@ export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
|
|
|
170
170
|
hotkey: "",
|
|
171
171
|
title: "Моноширинный",
|
|
172
172
|
},
|
|
173
|
+
link: {
|
|
174
|
+
tag: "a",
|
|
175
|
+
matchTags: ["A"],
|
|
176
|
+
// Не парный маркер: адрес отдельной частью и в тексте не показывается. Пустой маркер
|
|
177
|
+
// выводит инструмент из маркерной машинерии (см. orderedMarkers в ./serialize), разбор
|
|
178
|
+
// и сборку он делает своими ветками — как и переносы с абзацами.
|
|
179
|
+
md: "",
|
|
180
|
+
hotkey: "k",
|
|
181
|
+
title: "Ссылка",
|
|
182
|
+
},
|
|
173
183
|
};
|
|
174
184
|
|
|
175
185
|
interface EditorActionDef {
|
package/source/format.ts
CHANGED
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/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { default } from "./richeditor";
|
|
2
2
|
export * from "./richeditor";
|
|
3
|
-
export { EMOJIS, EMOJI_GROUPS, type EmojiGroup } from "./emoji";
|
|
3
|
+
export { EMOJIS, EMOJI_GROUPS, EMOJI_PICKER_CLASS, createEmojiPicker, type EmojiGroup } from "./emoji";
|
|
4
4
|
export {
|
|
5
5
|
ALL_BLOCK_TYPES,
|
|
6
6
|
ALL_EDITOR_ACTIONS,
|