@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/source/richeditor.less
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
// Стили RichEditor: редактируемая область, отображение тегов форматирования
|
|
2
2
|
// и общий тулбар форматирования (живёт в document.body, см. ./toolbar).
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
// Собственные переменные пакета и их значения по умолчанию. Держим их здесь, а не по месту:
|
|
5
|
+
// так виден весь набор целиком, и менять значение приходится в одном месте, а не во всех, где
|
|
6
|
+
// переменная встретилась (у кода их было по две на каждую).
|
|
7
|
+
//
|
|
8
|
+
// Переменные --input-*, --hover--input-*, --focus--input-*, --placeholder-* и --svg-* приходят
|
|
9
|
+
// из @brandup/ui-kit и здесь не объявляются: объявив их в :root, пакет перекрыл бы значения
|
|
10
|
+
// кита. Их запасные значения остаются по месту — они на случай использования пакета без кита.
|
|
11
|
+
:root {
|
|
12
|
+
// цитата: линия слева, как её рисуют мессенджеры
|
|
13
|
+
--richeditor-quote-line: rgba(0, 0, 0, 0.2);
|
|
14
|
+
|
|
15
|
+
// код — и моноширинный, и блок
|
|
16
|
+
--richeditor-code-fill: rgba(0, 0, 0, 0.06);
|
|
17
|
+
--richeditor-code-font: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
|
18
|
+
|
|
19
|
+
// спойлер: у получателя текст закрыт, в поле показан плашкой
|
|
20
|
+
--richeditor-spoiler-fill: rgba(0, 0, 0, 0.14);
|
|
21
|
+
|
|
22
|
+
// Правка адреса. В контейнере хоста панель на это время растягивается по нему, и ширину поля
|
|
23
|
+
// задавать не нужно — оно занимает всё, что осталось от кнопок. В document.body растягиваться
|
|
24
|
+
// не по чему, там ширину задаёт первая переменная; вторая ограничивает растянутую панель.
|
|
25
|
+
--richeditor-link-width: 320px;
|
|
26
|
+
--richeditor-link-max-width: 500px;
|
|
27
|
+
|
|
28
|
+
// ссылка
|
|
29
|
+
--richeditor-link-color: #2481cc;
|
|
30
|
+
// отступ подчёркивания от базовой линии; auto — на усмотрение шрифта
|
|
31
|
+
--richeditor-link-underline-offset: auto;
|
|
32
|
+
|
|
33
|
+
// Место под подчёркивание последней строки. Подчёркивание рисуется, но в раскладке места
|
|
34
|
+
// не занимает, а краевые отступы абзаца обнулены — на единственной строке абзац разом
|
|
35
|
+
// первый и последний, и снизу не остаётся ничего. Прокручиваемая коробка режет линию
|
|
36
|
+
// по своему краю, и отступом от базовой линии это не лечится: он только придвигает её
|
|
37
|
+
// к этому краю. Место берётся здесь.
|
|
38
|
+
--richeditor-underline-room: 2px;
|
|
39
|
+
|
|
40
|
+
// Панель. Кнопка — и её же размера кнопки поповера адреса; по ней же высота поля адреса.
|
|
41
|
+
// Значение своё, а не считанное от высоты поля ввода: панель живёт над полем, но размером
|
|
42
|
+
// с ним не связана, а вычитание отступов задавало бы размер дважды и могло не сойтись.
|
|
43
|
+
--richeditor-toolbar-padding: 3px;
|
|
44
|
+
--richeditor-toolbar-button-size: 34px;
|
|
45
|
+
|
|
46
|
+
// панель смайликов
|
|
47
|
+
--richeditor-emoji-size: 32px;
|
|
48
|
+
// строк в группе — панель ставит своё значение на каждую группу, здесь оно запасное
|
|
49
|
+
--richeditor-emoji-rows: 8;
|
|
50
|
+
}
|
|
4
51
|
|
|
5
52
|
// .ui-richeditor — это сам редактируемый элемент (обёртки нет, тулбар общий в body)
|
|
6
53
|
.ui-richeditor {
|
|
@@ -11,6 +58,8 @@
|
|
|
11
58
|
word-wrap: anywhere;
|
|
12
59
|
white-space: pre-wrap; // сохраняем пробелы (в т.ч. ведущие/повторяющиеся)
|
|
13
60
|
|
|
61
|
+
--link-weight: inherit;
|
|
62
|
+
|
|
14
63
|
// абзацы — без user-agent-полей, иначе первый <p> «съезжает» вниз (как новая строка)
|
|
15
64
|
& p {
|
|
16
65
|
margin: 0;
|
|
@@ -20,8 +69,9 @@
|
|
|
20
69
|
padding-top: 0;
|
|
21
70
|
}
|
|
22
71
|
|
|
72
|
+
// не ноль: последней строке нужно место под подчёркивание — см. --richeditor-underline-room
|
|
23
73
|
&:last-child {
|
|
24
|
-
padding-bottom:
|
|
74
|
+
padding-bottom: var(--richeditor-underline-room);
|
|
25
75
|
}
|
|
26
76
|
}
|
|
27
77
|
|
|
@@ -30,7 +80,7 @@
|
|
|
30
80
|
& blockquote {
|
|
31
81
|
margin: 0;
|
|
32
82
|
padding: 5px 0 5px 10px;
|
|
33
|
-
border-left: 3px solid var(--richeditor-quote-line
|
|
83
|
+
border-left: 3px solid var(--richeditor-quote-line);
|
|
34
84
|
}
|
|
35
85
|
|
|
36
86
|
// Блок кода: подложка на всю строку и моноширинный шрифт. Собственные поля тут нужны —
|
|
@@ -39,8 +89,8 @@
|
|
|
39
89
|
margin: 5px 0;
|
|
40
90
|
padding: 5px 8px;
|
|
41
91
|
border-radius: 3px;
|
|
42
|
-
background-color: var(--richeditor-code-fill
|
|
43
|
-
font-family: var(--richeditor-code-font
|
|
92
|
+
background-color: var(--richeditor-code-fill);
|
|
93
|
+
font-family: var(--richeditor-code-font);
|
|
44
94
|
font-size: 0.92em;
|
|
45
95
|
// содержимое переносится по границе редактора, как и обычный текст
|
|
46
96
|
white-space: pre-wrap;
|
|
@@ -74,13 +124,20 @@
|
|
|
74
124
|
text-decoration: underline;
|
|
75
125
|
}
|
|
76
126
|
|
|
127
|
+
& a {
|
|
128
|
+
color: var(--richeditor-link-color);
|
|
129
|
+
text-decoration: underline;
|
|
130
|
+
text-underline-offset: var(--richeditor-link-underline-offset);
|
|
131
|
+
cursor: text; // текст ссылки правят на месте, переходить по ней из редактора некуда
|
|
132
|
+
}
|
|
133
|
+
|
|
77
134
|
// Спойлер: у получателя текст закрыт до нажатия, а в поле его прячут не от автора —
|
|
78
135
|
// показываем плашкой, чтобы было видно и что написано, и что оно будет скрыто.
|
|
79
136
|
& spoiler,
|
|
80
137
|
& tg-spoiler {
|
|
81
138
|
border-radius: 3px;
|
|
82
139
|
padding: 0 2px;
|
|
83
|
-
background-color: var(--richeditor-spoiler-fill
|
|
140
|
+
background-color: var(--richeditor-spoiler-fill);
|
|
84
141
|
}
|
|
85
142
|
|
|
86
143
|
// Строку не раздвигает. Наследовать межстрочный интервал для этого мало: у моноширинного
|
|
@@ -91,8 +148,8 @@
|
|
|
91
148
|
& code {
|
|
92
149
|
border-radius: 3px;
|
|
93
150
|
padding: 0 3px;
|
|
94
|
-
background-color: var(--richeditor-code-fill
|
|
95
|
-
font-family: var(--richeditor-code-font
|
|
151
|
+
background-color: var(--richeditor-code-fill);
|
|
152
|
+
font-family: var(--richeditor-code-font);
|
|
96
153
|
font-size: 0.92em;
|
|
97
154
|
line-height: 0;
|
|
98
155
|
}
|
|
@@ -137,25 +194,18 @@
|
|
|
137
194
|
// По умолчанию в document.body (position: fixed, координаты задаёт ./toolbar);
|
|
138
195
|
// с классом .in-container — позиционируется над контейнером-родителем (position: absolute).
|
|
139
196
|
.ui-richeditor-toolbar {
|
|
197
|
+
// Обёртка: только положение. Вид и содержимое — на коробке внутри, поэтому её размер
|
|
198
|
+
// (он меняется вместе с содержимым) точку привязки не трогает, а выпадающие слои висят
|
|
199
|
+
// на обёртке и её коробкой не обрезаются.
|
|
140
200
|
display: none;
|
|
141
201
|
position: fixed;
|
|
142
202
|
z-index: 1000;
|
|
143
|
-
flex-flow: row nowrap;
|
|
144
|
-
align-items: center;
|
|
145
|
-
gap: 2px;
|
|
146
|
-
padding: 3px;
|
|
147
|
-
background-color: var(--input-fill, #fff);
|
|
148
|
-
border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
|
|
149
|
-
border-radius: var(--input-border-radius, 0);
|
|
150
|
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
151
|
-
// панель тоже монтируется в разметку хоста — курсор поля ввода ей не наследовать
|
|
152
|
-
cursor: default;
|
|
153
203
|
|
|
154
204
|
--svg-size: 18px;
|
|
155
205
|
--svg-fill: var(--input-color, #222);
|
|
156
206
|
|
|
157
207
|
&.visible {
|
|
158
|
-
display:
|
|
208
|
+
display: block;
|
|
159
209
|
}
|
|
160
210
|
|
|
161
211
|
// позиционирование относительно контейнера-родителя (например, .ui-textbox)
|
|
@@ -164,6 +214,24 @@
|
|
|
164
214
|
left: 0;
|
|
165
215
|
bottom: 100%;
|
|
166
216
|
margin-bottom: 6px;
|
|
217
|
+
|
|
218
|
+
&.link-editing {
|
|
219
|
+
right: 0;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
& .toolbar-body {
|
|
224
|
+
display: flex;
|
|
225
|
+
flex-flow: row nowrap;
|
|
226
|
+
align-items: center;
|
|
227
|
+
gap: 2px;
|
|
228
|
+
padding: var(--richeditor-toolbar-padding);
|
|
229
|
+
background-color: var(--input-fill, #fff);
|
|
230
|
+
border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
|
|
231
|
+
border-radius: var(--input-border-radius, 0);
|
|
232
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
233
|
+
// панель тоже монтируется в разметку хоста — курсор поля ввода ей не наследовать
|
|
234
|
+
cursor: default;
|
|
167
235
|
}
|
|
168
236
|
|
|
169
237
|
// разделитель между инструментами форматирования и действиями
|
|
@@ -174,12 +242,12 @@
|
|
|
174
242
|
background-color: var(--input-border-color, #aaa);
|
|
175
243
|
}
|
|
176
244
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
& .
|
|
180
|
-
|
|
181
|
-
width:
|
|
182
|
-
height:
|
|
245
|
+
// Оформление у всех кнопок панели общее, поэтому и класс общий: перечислять их по одной
|
|
246
|
+
// значило бы вспоминать про этот список на каждую новую.
|
|
247
|
+
& .toolbar-button {
|
|
248
|
+
flex: 0 0 auto;
|
|
249
|
+
width: var(--richeditor-toolbar-button-size);
|
|
250
|
+
height: var(--richeditor-toolbar-button-size);
|
|
183
251
|
border-radius: calc(var(--input-border-radius, 0) - 2px);
|
|
184
252
|
border: 0;
|
|
185
253
|
background: var(--input-toolbar-button-fill, transparent);
|
|
@@ -215,6 +283,67 @@
|
|
|
215
283
|
}
|
|
216
284
|
}
|
|
217
285
|
}
|
|
286
|
+
|
|
287
|
+
// Правка адреса ссылки: панель показывает поле ввода вместо кнопок. Не выпадающий слой —
|
|
288
|
+
// то же место, та же коробка: позиционировать и ужимать ничего не приходится.
|
|
289
|
+
& .link-row {
|
|
290
|
+
display: none;
|
|
291
|
+
flex: 1 1 auto;
|
|
292
|
+
min-width: 0; // иначе поле не даст строке сжаться уже своей ширины по содержимому
|
|
293
|
+
align-items: center;
|
|
294
|
+
gap: 2px;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
&.link-editing {
|
|
298
|
+
max-width: var(--richeditor-link-max-width);
|
|
299
|
+
|
|
300
|
+
// панели в document.body растягиваться не по чему — ширину задаём сами
|
|
301
|
+
&:not(.in-container) .toolbar-body {
|
|
302
|
+
width: var(--richeditor-link-width);
|
|
303
|
+
max-width: calc(100vw - 8px);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// кнопки на это время убраны, а не сдвинуты: панель узкая, рядом им не поместиться.
|
|
307
|
+
// Кнопки самой строки адреса лежат в ней и под это правило не попадают.
|
|
308
|
+
& > .toolbar-body > .toolbar-button,
|
|
309
|
+
& .split {
|
|
310
|
+
display: none;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
& .link-row {
|
|
314
|
+
display: flex;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
& .link-input {
|
|
319
|
+
// Тянется, а не «сто процентов»: процент считался бы от строки, а её ширина сама зависит
|
|
320
|
+
// от поля — круг, который браузер разрывает собственной шириной input. Ноль в min-width
|
|
321
|
+
// разрешает сжиматься: у поля ввода она по умолчанию не нулевая, и на узком экране
|
|
322
|
+
// строка вылезала бы за панель.
|
|
323
|
+
flex: 1 1 auto;
|
|
324
|
+
min-width: 0;
|
|
325
|
+
// В одну строку с кнопками — высота у них общая. Нижней границей, а не точной: поле живёт
|
|
326
|
+
// в чужой типографике, и от крупного шрифта ему нужно вырасти, а не обрезать строку.
|
|
327
|
+
min-height: var(--richeditor-toolbar-button-size);
|
|
328
|
+
box-sizing: border-box; // высота вместе с полями и рамкой, иначе поле выше кнопок
|
|
329
|
+
padding: 0 8px;
|
|
330
|
+
// оформление у поля общее с полями ввода кита — своих переменных ему не нужно
|
|
331
|
+
border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
|
|
332
|
+
border-radius: calc(var(--input-border-radius, 0) - 2px);
|
|
333
|
+
font: inherit;
|
|
334
|
+
color: inherit;
|
|
335
|
+
background: none;
|
|
336
|
+
outline: none;
|
|
337
|
+
|
|
338
|
+
&:focus {
|
|
339
|
+
border-color: var(--focus--input-border-color, #222);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// снимать нечего, пока ссылки под кареткой нет
|
|
344
|
+
& .link-remove[hidden] {
|
|
345
|
+
display: none;
|
|
346
|
+
}
|
|
218
347
|
}
|
|
219
348
|
|
|
220
349
|
// панель вставки смайликов — попап под кнопкой тулбара (открытием/закрытием управляет
|
|
@@ -247,7 +376,7 @@
|
|
|
247
376
|
flex-flow: row wrap;
|
|
248
377
|
content-visibility: auto;
|
|
249
378
|
// высота ненарисованной группы; auto — дальше браузер помнит фактическую
|
|
250
|
-
contain-intrinsic-size: auto calc(var(--emoji-rows
|
|
379
|
+
contain-intrinsic-size: auto calc(var(--richeditor-emoji-rows) * var(--richeditor-emoji-size));
|
|
251
380
|
}
|
|
252
381
|
|
|
253
382
|
& .emoji-group + .emoji-group {
|
|
@@ -258,8 +387,8 @@
|
|
|
258
387
|
}
|
|
259
388
|
|
|
260
389
|
& .emoji {
|
|
261
|
-
width: var(--richeditor-emoji-size
|
|
262
|
-
height: var(--richeditor-emoji-size
|
|
390
|
+
width: var(--richeditor-emoji-size);
|
|
391
|
+
height: var(--richeditor-emoji-size);
|
|
263
392
|
padding: 0;
|
|
264
393
|
border: 0;
|
|
265
394
|
background: transparent;
|
package/source/richeditor.ts
CHANGED
|
@@ -1,7 +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
|
+
import { IS_TOUCH_DEVICE, PopupManager } from "@brandup/ui-kit";
|
|
5
5
|
import {
|
|
6
6
|
ALL_FORMAT_TOOLS,
|
|
7
7
|
BLOCK_TYPES,
|
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
selectionCharBounds,
|
|
36
36
|
serialize,
|
|
37
37
|
toggleFormat,
|
|
38
|
+
applyLink as applyLinkTo,
|
|
39
|
+
linkAt,
|
|
38
40
|
type BlockType,
|
|
39
41
|
type EditorAction,
|
|
40
42
|
type FormatMarkers,
|
|
@@ -56,9 +58,9 @@ import {
|
|
|
56
58
|
trimSelectionWhitespace,
|
|
57
59
|
} from "./editing";
|
|
58
60
|
import { EditorHistory } from "./history";
|
|
59
|
-
import { formatToolbar, type ToolbarButton } from "./toolbar";
|
|
61
|
+
import { formatToolbar, TOOLBAR_CLASS, type ToolbarButton } from "./toolbar";
|
|
60
62
|
|
|
61
|
-
export {
|
|
63
|
+
export { formatToolbar, TOOLBAR_CLASS, type ToolbarHost, type ToolbarButton } from "./toolbar";
|
|
62
64
|
|
|
63
65
|
export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
|
|
64
66
|
// Содержимое временно невыделяемо: по странице тянут выделение, начатое вне редактора (см. __holdSelectable).
|
|
@@ -70,6 +72,9 @@ const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "En
|
|
|
70
72
|
// Код — единственное, что есть и инструментом, и типом блока: имя одно на оба (см. applyCode).
|
|
71
73
|
const CODE = "code";
|
|
72
74
|
|
|
75
|
+
// Ссылка — единственный инструмент с данными: адрес задаётся не переключением (см. applyLink).
|
|
76
|
+
const LINK: FormatTool = "link";
|
|
77
|
+
|
|
73
78
|
// нативные правки (печать/удаление), состояние до которых запоминаем для собственного undo;
|
|
74
79
|
// вставка/перетаскивание и Enter обрабатываются отдельно, undo/redo — на keydown
|
|
75
80
|
const NATIVE_EDIT_TYPES = new Set([
|
|
@@ -122,8 +127,9 @@ export interface RichEditorOptions {
|
|
|
122
127
|
/** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
|
|
123
128
|
paragraph?: ParagraphMode;
|
|
124
129
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
130
|
+
* Block types of the multiline mode: quote, code block (all of them by default). A field that
|
|
131
|
+
* has no use for them is limited by an empty list. Plain text is always in the set — a block
|
|
132
|
+
* is turned back into it.
|
|
127
133
|
*/
|
|
128
134
|
blocks?: BlockType[];
|
|
129
135
|
/**
|
|
@@ -166,6 +172,13 @@ type RichEditorEvents = {
|
|
|
166
172
|
export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
167
173
|
readonly editable: HTMLElement;
|
|
168
174
|
readonly format: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Объявленный набор инструментов: им разбирается и сохраняется значение. Не то же, что набор
|
|
177
|
+
* кнопок ({@link formatTools}) — показывать разметку редактор обязан и там, где её не
|
|
178
|
+
* переключить. Так же разведены и блоки ({@link blockTypes} против {@link blockTools}).
|
|
179
|
+
*/
|
|
180
|
+
readonly formatTypes: FormatTool[];
|
|
181
|
+
/** Инструменты в панели: в режиме только для чтения их нет — переключать разметку там нечем. */
|
|
169
182
|
readonly formatTools: FormatTool[];
|
|
170
183
|
readonly editorActions: EditorAction[];
|
|
171
184
|
readonly formatStorage: FormatStorage;
|
|
@@ -185,7 +198,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
185
198
|
// Каретка, снятая при отпускании фокуса: без фокуса браузер может убрать и выделение,
|
|
186
199
|
// а вставке из попапа нужно место — см. releaseFocus.
|
|
187
200
|
private __detachedCaret: [number, number] | null = null;
|
|
188
|
-
private __emojiHold: (() => void) | null = null; // правка придержана на время
|
|
201
|
+
private __emojiHold: (() => void) | null = null; // правка придержана на время попапа смайликов
|
|
202
|
+
private __emojiPicker: HTMLElement | null = null; // попап, открытый этим редактором — закрыть его в destroy
|
|
189
203
|
private __releasingFocus = false; // фокус снимаем сами, а не уходят из поля — см. releaseFocus
|
|
190
204
|
// Компонент снят. Удержание правки переживает снятие (окно хоста закрывается позже), и по его
|
|
191
205
|
// снятию трогать содержимое уже нельзя — редактора нет.
|
|
@@ -202,8 +216,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
202
216
|
const format = !!options.format;
|
|
203
217
|
const multiline = !!options.multiline;
|
|
204
218
|
const readonly = !!options.readonly;
|
|
205
|
-
//
|
|
206
|
-
|
|
219
|
+
// Объявленный набор — от readonly не зависит: значение обязано разбираться и показываться
|
|
220
|
+
// и там, где разметку не переключить, иначе редактор для чтения показывал бы вместо
|
|
221
|
+
// жирного сырые звёздочки. Кнопки — уже без него.
|
|
222
|
+
const types = format ? (options.tools ?? ALL_FORMAT_TOOLS.slice()) : [];
|
|
223
|
+
const tools = readonly ? [] : types;
|
|
207
224
|
// действия подключаются явно — иначе панель у существующих хостов молча обзавелась бы кнопками
|
|
208
225
|
const actions = format && !readonly ? (options.actions ?? []) : [];
|
|
209
226
|
|
|
@@ -217,6 +234,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
217
234
|
this.__window = editable.ownerDocument.defaultView ?? window;
|
|
218
235
|
this.__opts = options;
|
|
219
236
|
this.format = format;
|
|
237
|
+
this.formatTypes = types;
|
|
220
238
|
this.formatTools = tools;
|
|
221
239
|
this.editorActions = actions;
|
|
222
240
|
this.formatStorage = options.storage === "markdown" ? "markdown" : "html";
|
|
@@ -265,8 +283,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
265
283
|
private get __valueStorage(): FormatStorage {
|
|
266
284
|
return this.format ? this.formatStorage : "markdown";
|
|
267
285
|
}
|
|
286
|
+
// разбор и сохранение значения — по объявленному набору, а не по набору кнопок:
|
|
287
|
+
// в readonly кнопок нет, а разметка значения от этого не меняется
|
|
268
288
|
private get __valueTools(): FormatTool[] {
|
|
269
|
-
return this.
|
|
289
|
+
return this.formatTypes;
|
|
270
290
|
}
|
|
271
291
|
|
|
272
292
|
// --- публичный API ---
|
|
@@ -322,21 +342,37 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
322
342
|
}
|
|
323
343
|
|
|
324
344
|
/**
|
|
325
|
-
* Показать
|
|
326
|
-
* хоста (например справа от поля ввода). Вызывать из обработчика `click`, погасив всплытие:
|
|
327
|
-
* иначе попап закроется тем же кликом, которым открылся.
|
|
345
|
+
* Показать попап смайликов у кнопки.
|
|
328
346
|
*
|
|
329
|
-
*
|
|
330
|
-
* @
|
|
347
|
+
* Попап приносит владелец — у панели форматирования свой, у поля сообщения свой (собирает
|
|
348
|
+
* их {@link createEmojiPicker}). Редактор берёт на себя только своё: придержать правку,
|
|
349
|
+
* поставить каретку, отпустить фокус и убрать панель, если попап раскрывается не из неё.
|
|
350
|
+
*
|
|
351
|
+
* Вызывать из обработчика `click`, погасив всплытие: иначе попап закроется тем же кликом,
|
|
352
|
+
* которым открылся. Возвращает false, если попап этим нажатием закрылся.
|
|
331
353
|
*/
|
|
332
|
-
openEmojiPicker(
|
|
333
|
-
if (this.readonly) return;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
354
|
+
openEmojiPicker(picker: HTMLElement, initiator: HTMLElement): boolean {
|
|
355
|
+
if (this.readonly) return false;
|
|
356
|
+
|
|
357
|
+
// Попап у кнопки хоста — самостоятельный слой, и показывать его вместе с панелью нельзя:
|
|
358
|
+
// это два всплывающих окна над одним полем. Попап самой панели — её собственный слой,
|
|
359
|
+
// прятать его носителя незачем и нечем.
|
|
360
|
+
const inToolbar = !!picker.closest(`.${TOOLBAR_CLASS}`);
|
|
361
|
+
|
|
362
|
+
PopupManager.open(picker, {
|
|
363
|
+
initiator,
|
|
364
|
+
onClose: () => {
|
|
365
|
+
this.__emojiPicker = null;
|
|
366
|
+
this.__emojiHold?.();
|
|
367
|
+
this.__emojiHold = null;
|
|
368
|
+
if (!inToolbar) formatToolbar.resume();
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
// повторное нажатие по кнопке попап закрывает — держать и придерживать больше нечего
|
|
372
|
+
if (!PopupManager.isOpened(picker)) return false;
|
|
337
373
|
|
|
338
|
-
|
|
339
|
-
if (!formatToolbar.
|
|
374
|
+
this.__emojiPicker = picker;
|
|
375
|
+
if (!inToolbar) formatToolbar.suspend(this);
|
|
340
376
|
|
|
341
377
|
// Правку придерживаем на всё время панели: фокус мы отпустим, а снятие фокуса — не конец
|
|
342
378
|
// ввода. Иначе нормализация обрезала бы пробел у каретки, и символ встал бы вплотную.
|
|
@@ -346,21 +382,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
346
382
|
// некуда. В конец её ставит focus(true), и только если её действительно не было: снятую
|
|
347
383
|
// при отпускании фокуса он вернёт на место, а иначе символ уезжал бы в конец сообщения
|
|
348
384
|
// с каждым открытием панели.
|
|
349
|
-
//
|
|
350
|
-
// Строго после открытия панели: фокус показывает тулбар, а придержать его панель успевает
|
|
351
|
-
// только когда открыта сама.
|
|
352
385
|
if (!this.selection) this.focus(true);
|
|
353
386
|
|
|
354
387
|
// Панель — слой над полем, а не вместо него: каретку видно, и видно, куда встанет символ.
|
|
355
388
|
// На сенсорном устройстве фокус вместо этого поднимает клавиатуру, которая саму панель
|
|
356
389
|
// и закрывает, — там его отпускаем, а каретку вернёт вставка (см. keepFocus).
|
|
357
390
|
if (!this.keepFocus) this.releaseFocus();
|
|
358
|
-
}
|
|
359
391
|
|
|
360
|
-
|
|
361
|
-
onEmojiClosed(): void {
|
|
362
|
-
this.__emojiHold?.();
|
|
363
|
-
this.__emojiHold = null;
|
|
392
|
+
return true;
|
|
364
393
|
}
|
|
365
394
|
|
|
366
395
|
getLength(): number {
|
|
@@ -525,12 +554,63 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
525
554
|
isToolEnabled(tool: FormatTool): boolean {
|
|
526
555
|
if (this.readonly || !this.formatTools.includes(tool)) return false;
|
|
527
556
|
if (tool === CODE) return true;
|
|
557
|
+
if (this.currentBlock === CODE || this.isToolActive(CODE)) return false;
|
|
528
558
|
|
|
529
|
-
|
|
559
|
+
// Ссылке нужен текст, который ею станет: оборачивать нечего — и делать нечего. Каретка
|
|
560
|
+
// в слове за текст считается (формат применяется к слову целиком), в готовой ссылке —
|
|
561
|
+
// тоже: её адрес правят той же кнопкой.
|
|
562
|
+
return tool !== LINK || this.__hasLinkTarget();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Есть ли что делать ссылкой: текст под выделением или ссылка, в которой стоит каретка. */
|
|
566
|
+
private __hasLinkTarget(): boolean {
|
|
567
|
+
const target = this.__formatTarget();
|
|
568
|
+
if (!target) return false;
|
|
569
|
+
|
|
570
|
+
return !target.range.collapsed || !!linkAt(this.editable, target.range);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Адрес ссылки под кареткой; пусто — каретка не в ссылке.
|
|
575
|
+
*
|
|
576
|
+
* Состояние ссылки — не «включена», а «вот этот адрес»: панели нужен он сам, иначе править
|
|
577
|
+
* существующую ссылку было бы нечем.
|
|
578
|
+
*/
|
|
579
|
+
get currentLink(): string {
|
|
580
|
+
const selection = this.selection;
|
|
581
|
+
if (!selection) return "";
|
|
582
|
+
|
|
583
|
+
return linkAt(this.editable, selection.getRangeAt(0))?.getAttribute("href") ?? "";
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Ставит ссылку на выделение, меняет адрес у той, в которой стоит каретка, либо снимает её —
|
|
588
|
+
* пустым адресом. Оборачивать нечего — не делает ничего: ссылка это оформление текста,
|
|
589
|
+
* а не вставка (см. {@link isToolEnabled}, там же гаснет и кнопка).
|
|
590
|
+
*
|
|
591
|
+
* Не переключатель, в отличие от {@link applyFormat}: у ссылки есть данные, и повторное
|
|
592
|
+
* применение с другим адресом — правка, а не снятие.
|
|
593
|
+
*/
|
|
594
|
+
applyLink(url: string): void {
|
|
595
|
+
if (!this.isToolEnabled(LINK)) return;
|
|
596
|
+
|
|
597
|
+
// Правит по выделению в поле. Поле адреса в панели забирает фокус, поэтому каретку она
|
|
598
|
+
// снимает при открытии и возвращает перед вызовом — см. openLink в ./toolbar.
|
|
599
|
+
const target = this.__formatTarget();
|
|
600
|
+
if (!target) return;
|
|
601
|
+
|
|
602
|
+
this.__history?.record("op");
|
|
603
|
+
applyLinkTo(this.editable, target.range, url.trim(), target.selection, target.original());
|
|
604
|
+
|
|
605
|
+
this.__pendingFormats.clear();
|
|
606
|
+
this.__emitChange();
|
|
607
|
+
formatToolbar.refresh();
|
|
530
608
|
}
|
|
531
609
|
|
|
532
610
|
/** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
|
|
533
611
|
applyFormat(tool: FormatTool): void {
|
|
612
|
+
// у ссылки есть адрес, а переключением его не задать — она ставится через applyLink
|
|
613
|
+
if (tool === LINK) return;
|
|
534
614
|
if (!this.isToolEnabled(tool)) return;
|
|
535
615
|
|
|
536
616
|
let target = this.__formatTarget();
|
|
@@ -836,6 +916,13 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
836
916
|
this.__disposed = true;
|
|
837
917
|
this.flushChange(); // хост не должен остаться с устаревшей копией значения
|
|
838
918
|
this.__abort.abort();
|
|
919
|
+
|
|
920
|
+
// Попап смайликов мог остаться открытым, а показывали его мы — своим он бывает и у хоста
|
|
921
|
+
// (см. openEmojiPicker). Оставленный, он держал бы PopupManager на удалённом элементе:
|
|
922
|
+
// на body висел бы класс открытого попапа и слушатель закрытия, а на узком экране
|
|
923
|
+
// страница осталась бы непрокручиваемой.
|
|
924
|
+
if (this.__emojiPicker && PopupManager.isOpened(this.__emojiPicker)) PopupManager.close();
|
|
925
|
+
|
|
839
926
|
formatToolbar.detach(this);
|
|
840
927
|
|
|
841
928
|
// элемент передан хостом — не удаляем его, только снимаем оформление редактора
|
|
@@ -963,6 +1050,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
963
1050
|
this.editable.classList.toggle(UNSELECTABLE_CLASS, !own);
|
|
964
1051
|
}
|
|
965
1052
|
|
|
1053
|
+
/**
|
|
1054
|
+
* Снимает запрет, когда нажали в самом редакторе.
|
|
1055
|
+
*
|
|
1056
|
+
* Для касания, где браузер начинает выделять слово прямо на жесте: к этому моменту содержимое
|
|
1057
|
+
* обязано быть выделяемым, а {@link __holdSelectable} снимет запрет только по мышиному
|
|
1058
|
+
* нажатию — оно приходит уже после жеста, а у долгого нажатия не приходит вовсе.
|
|
1059
|
+
*/
|
|
1060
|
+
private __releaseSelectableAt(target: EventTarget | null) {
|
|
1061
|
+
if (this.editable.contains(target as Node | null)) this.editable.classList.remove(UNSELECTABLE_CLASS);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
966
1064
|
/**
|
|
967
1065
|
* Снимает запрет, когда выделение перестало задевать редактор.
|
|
968
1066
|
*
|
|
@@ -1000,6 +1098,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1000
1098
|
// По изменению выделения проверять нельзя: пока запрет действует, выделение до редактора
|
|
1001
1099
|
// не доходит — проверка увидела бы «не задевает» и сняла запрет сама. Только по концу протяжки.
|
|
1002
1100
|
doc.addEventListener("mouseup", () => this.__releaseSelectable(), { signal, capture: true });
|
|
1101
|
+
// Касание: запрет только снимаем, не ставим. Протяжки выделения через страницу на касании
|
|
1102
|
+
// нет — там его ведут за собственные ручки, — а вот слово по двойному нажатию браузер
|
|
1103
|
+
// выделяет сам, ещё на жесте. Мышиные события к нему приезжают уже после (а у долгого
|
|
1104
|
+
// нажатия их и вовсе нет), и снятого по ним запрета жест бы не дождался.
|
|
1105
|
+
doc.addEventListener("touchstart", (e) => this.__releaseSelectableAt(e.target), {
|
|
1106
|
+
signal,
|
|
1107
|
+
capture: true,
|
|
1108
|
+
passive: true,
|
|
1109
|
+
});
|
|
1003
1110
|
|
|
1004
1111
|
editable.addEventListener(
|
|
1005
1112
|
"mousedown",
|
|
@@ -1106,7 +1213,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1106
1213
|
if (tool) {
|
|
1107
1214
|
e.preventDefault();
|
|
1108
1215
|
e.stopPropagation();
|
|
1109
|
-
|
|
1216
|
+
|
|
1217
|
+
if (this.formatTools.includes(tool)) {
|
|
1218
|
+
// у ссылки сперва спрашивается адрес — тем же полем в панели, что и у кнопки
|
|
1219
|
+
if (tool === LINK) formatToolbar.openLinkFor(this);
|
|
1220
|
+
else this.applyFormat(tool);
|
|
1221
|
+
}
|
|
1110
1222
|
return;
|
|
1111
1223
|
}
|
|
1112
1224
|
}
|
|
@@ -1131,15 +1243,19 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1131
1243
|
// перемещение каретки — выход из режима набора
|
|
1132
1244
|
if (NAV_KEYS.includes(e.key)) this.__clearPendingFormats();
|
|
1133
1245
|
|
|
1134
|
-
|
|
1246
|
+
// A character, not a shortcut: Cmd is the same modifier as Ctrl, just on another platform.
|
|
1247
|
+
// Without it Cmd+C on a Mac would look like typing the letter "c" — copying would be
|
|
1248
|
+
// swallowed by the readonly guard, and the host filter would reject copy, paste and
|
|
1249
|
+
// select-all alike.
|
|
1250
|
+
const isChar = e.key.length === 1 && !e.ctrlKey && !e.metaKey;
|
|
1135
1251
|
|
|
1136
|
-
if (this.readonly && isChar
|
|
1252
|
+
if (this.readonly && isChar) {
|
|
1137
1253
|
e.preventDefault();
|
|
1138
1254
|
e.stopPropagation();
|
|
1139
1255
|
return;
|
|
1140
1256
|
}
|
|
1141
1257
|
|
|
1142
|
-
if (isChar &&
|
|
1258
|
+
if (isChar && this.__opts.filterChar && !this.__opts.filterChar(e.key)) {
|
|
1143
1259
|
e.preventDefault();
|
|
1144
1260
|
e.stopPropagation();
|
|
1145
1261
|
this.__reject();
|
|
@@ -1275,12 +1391,13 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1275
1391
|
ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
|
|
1276
1392
|
} else {
|
|
1277
1393
|
// инлайн: абзацы и переносы → пробелы, форматирование сохраняем
|
|
1278
|
-
const
|
|
1394
|
+
const doc = this.editable.ownerDocument;
|
|
1395
|
+
const fragment = doc.createDocumentFragment();
|
|
1279
1396
|
paras.forEach((p, index) => {
|
|
1280
|
-
if (index > 0) fragment.appendChild(
|
|
1397
|
+
if (index > 0) fragment.appendChild(doc.createTextNode(" "));
|
|
1281
1398
|
while (p.firstChild) fragment.appendChild(p.firstChild);
|
|
1282
1399
|
});
|
|
1283
|
-
fragment.querySelectorAll("br").forEach((br) => br.replaceWith(
|
|
1400
|
+
fragment.querySelectorAll("br").forEach((br) => br.replaceWith(doc.createTextNode(" ")));
|
|
1284
1401
|
|
|
1285
1402
|
caret = start + (fragment.textContent ?? "").length;
|
|
1286
1403
|
range.insertNode(fragment);
|