@brandup/ui-richeditor 1.0.45 → 1.0.48
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 +2 -2
- package/source/editing.ts +33 -3
- package/source/emoji.ts +69 -0
- package/source/format-config.ts +12 -2
- package/source/format.ts +2 -0
- package/source/index.ts +1 -1
- package/source/richeditor.less +157 -28
- package/source/richeditor.ts +140 -29
- package/source/selection.ts +56 -3
- package/source/serialize.ts +125 -3
- package/source/toolbar.ts +228 -132
- 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([
|
|
@@ -167,6 +172,13 @@ type RichEditorEvents = {
|
|
|
167
172
|
export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
168
173
|
readonly editable: HTMLElement;
|
|
169
174
|
readonly format: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Объявленный набор инструментов: им разбирается и сохраняется значение. Не то же, что набор
|
|
177
|
+
* кнопок ({@link formatTools}) — показывать разметку редактор обязан и там, где её не
|
|
178
|
+
* переключить. Так же разведены и блоки ({@link blockTypes} против {@link blockTools}).
|
|
179
|
+
*/
|
|
180
|
+
readonly formatTypes: FormatTool[];
|
|
181
|
+
/** Инструменты в панели: в режиме только для чтения их нет — переключать разметку там нечем. */
|
|
170
182
|
readonly formatTools: FormatTool[];
|
|
171
183
|
readonly editorActions: EditorAction[];
|
|
172
184
|
readonly formatStorage: FormatStorage;
|
|
@@ -186,7 +198,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
186
198
|
// Каретка, снятая при отпускании фокуса: без фокуса браузер может убрать и выделение,
|
|
187
199
|
// а вставке из попапа нужно место — см. releaseFocus.
|
|
188
200
|
private __detachedCaret: [number, number] | null = null;
|
|
189
|
-
private __emojiHold: (() => void) | null = null; // правка придержана на время
|
|
201
|
+
private __emojiHold: (() => void) | null = null; // правка придержана на время попапа смайликов
|
|
202
|
+
private __emojiPicker: HTMLElement | null = null; // попап, открытый этим редактором — закрыть его в destroy
|
|
190
203
|
private __releasingFocus = false; // фокус снимаем сами, а не уходят из поля — см. releaseFocus
|
|
191
204
|
// Компонент снят. Удержание правки переживает снятие (окно хоста закрывается позже), и по его
|
|
192
205
|
// снятию трогать содержимое уже нельзя — редактора нет.
|
|
@@ -203,8 +216,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
203
216
|
const format = !!options.format;
|
|
204
217
|
const multiline = !!options.multiline;
|
|
205
218
|
const readonly = !!options.readonly;
|
|
206
|
-
//
|
|
207
|
-
|
|
219
|
+
// Объявленный набор — от readonly не зависит: значение обязано разбираться и показываться
|
|
220
|
+
// и там, где разметку не переключить, иначе редактор для чтения показывал бы вместо
|
|
221
|
+
// жирного сырые звёздочки. Кнопки — уже без него.
|
|
222
|
+
const types = format ? (options.tools ?? ALL_FORMAT_TOOLS.slice()) : [];
|
|
223
|
+
const tools = readonly ? [] : types;
|
|
208
224
|
// действия подключаются явно — иначе панель у существующих хостов молча обзавелась бы кнопками
|
|
209
225
|
const actions = format && !readonly ? (options.actions ?? []) : [];
|
|
210
226
|
|
|
@@ -218,6 +234,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
218
234
|
this.__window = editable.ownerDocument.defaultView ?? window;
|
|
219
235
|
this.__opts = options;
|
|
220
236
|
this.format = format;
|
|
237
|
+
this.formatTypes = types;
|
|
221
238
|
this.formatTools = tools;
|
|
222
239
|
this.editorActions = actions;
|
|
223
240
|
this.formatStorage = options.storage === "markdown" ? "markdown" : "html";
|
|
@@ -266,8 +283,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
266
283
|
private get __valueStorage(): FormatStorage {
|
|
267
284
|
return this.format ? this.formatStorage : "markdown";
|
|
268
285
|
}
|
|
286
|
+
// разбор и сохранение значения — по объявленному набору, а не по набору кнопок:
|
|
287
|
+
// в readonly кнопок нет, а разметка значения от этого не меняется
|
|
269
288
|
private get __valueTools(): FormatTool[] {
|
|
270
|
-
return this.
|
|
289
|
+
return this.formatTypes;
|
|
271
290
|
}
|
|
272
291
|
|
|
273
292
|
// --- публичный API ---
|
|
@@ -323,21 +342,37 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
323
342
|
}
|
|
324
343
|
|
|
325
344
|
/**
|
|
326
|
-
* Показать
|
|
327
|
-
* хоста (например справа от поля ввода). Вызывать из обработчика `click`, погасив всплытие:
|
|
328
|
-
* иначе попап закроется тем же кликом, которым открылся.
|
|
345
|
+
* Показать попап смайликов у кнопки.
|
|
329
346
|
*
|
|
330
|
-
*
|
|
331
|
-
* @
|
|
347
|
+
* Попап приносит владелец — у панели форматирования свой, у поля сообщения свой (собирает
|
|
348
|
+
* их {@link createEmojiPicker}). Редактор берёт на себя только своё: придержать правку,
|
|
349
|
+
* поставить каретку, отпустить фокус и убрать панель, если попап раскрывается не из неё.
|
|
350
|
+
*
|
|
351
|
+
* Вызывать из обработчика `click`, погасив всплытие: иначе попап закроется тем же кликом,
|
|
352
|
+
* которым открылся. Возвращает false, если попап этим нажатием закрылся.
|
|
332
353
|
*/
|
|
333
|
-
openEmojiPicker(
|
|
334
|
-
if (this.readonly) return;
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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;
|
|
338
373
|
|
|
339
|
-
|
|
340
|
-
if (!formatToolbar.
|
|
374
|
+
this.__emojiPicker = picker;
|
|
375
|
+
if (!inToolbar) formatToolbar.suspend(this);
|
|
341
376
|
|
|
342
377
|
// Правку придерживаем на всё время панели: фокус мы отпустим, а снятие фокуса — не конец
|
|
343
378
|
// ввода. Иначе нормализация обрезала бы пробел у каретки, и символ встал бы вплотную.
|
|
@@ -347,21 +382,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
347
382
|
// некуда. В конец её ставит focus(true), и только если её действительно не было: снятую
|
|
348
383
|
// при отпускании фокуса он вернёт на место, а иначе символ уезжал бы в конец сообщения
|
|
349
384
|
// с каждым открытием панели.
|
|
350
|
-
//
|
|
351
|
-
// Строго после открытия панели: фокус показывает тулбар, а придержать его панель успевает
|
|
352
|
-
// только когда открыта сама.
|
|
353
385
|
if (!this.selection) this.focus(true);
|
|
354
386
|
|
|
355
387
|
// Панель — слой над полем, а не вместо него: каретку видно, и видно, куда встанет символ.
|
|
356
388
|
// На сенсорном устройстве фокус вместо этого поднимает клавиатуру, которая саму панель
|
|
357
389
|
// и закрывает, — там его отпускаем, а каретку вернёт вставка (см. keepFocus).
|
|
358
390
|
if (!this.keepFocus) this.releaseFocus();
|
|
359
|
-
}
|
|
360
391
|
|
|
361
|
-
|
|
362
|
-
onEmojiClosed(): void {
|
|
363
|
-
this.__emojiHold?.();
|
|
364
|
-
this.__emojiHold = null;
|
|
392
|
+
return true;
|
|
365
393
|
}
|
|
366
394
|
|
|
367
395
|
getLength(): number {
|
|
@@ -526,12 +554,63 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
526
554
|
isToolEnabled(tool: FormatTool): boolean {
|
|
527
555
|
if (this.readonly || !this.formatTools.includes(tool)) return false;
|
|
528
556
|
if (tool === CODE) return true;
|
|
557
|
+
if (this.currentBlock === CODE || this.isToolActive(CODE)) return false;
|
|
529
558
|
|
|
530
|
-
|
|
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();
|
|
531
608
|
}
|
|
532
609
|
|
|
533
610
|
/** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
|
|
534
611
|
applyFormat(tool: FormatTool): void {
|
|
612
|
+
// у ссылки есть адрес, а переключением его не задать — она ставится через applyLink
|
|
613
|
+
if (tool === LINK) return;
|
|
535
614
|
if (!this.isToolEnabled(tool)) return;
|
|
536
615
|
|
|
537
616
|
let target = this.__formatTarget();
|
|
@@ -837,6 +916,13 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
837
916
|
this.__disposed = true;
|
|
838
917
|
this.flushChange(); // хост не должен остаться с устаревшей копией значения
|
|
839
918
|
this.__abort.abort();
|
|
919
|
+
|
|
920
|
+
// Попап смайликов мог остаться открытым, а показывали его мы — своим он бывает и у хоста
|
|
921
|
+
// (см. openEmojiPicker). Оставленный, он держал бы PopupManager на удалённом элементе:
|
|
922
|
+
// на body висел бы класс открытого попапа и слушатель закрытия, а на узком экране
|
|
923
|
+
// страница осталась бы непрокручиваемой.
|
|
924
|
+
if (this.__emojiPicker && PopupManager.isOpened(this.__emojiPicker)) PopupManager.close();
|
|
925
|
+
|
|
840
926
|
formatToolbar.detach(this);
|
|
841
927
|
|
|
842
928
|
// элемент передан хостом — не удаляем его, только снимаем оформление редактора
|
|
@@ -964,6 +1050,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
964
1050
|
this.editable.classList.toggle(UNSELECTABLE_CLASS, !own);
|
|
965
1051
|
}
|
|
966
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
|
+
|
|
967
1064
|
/**
|
|
968
1065
|
* Снимает запрет, когда выделение перестало задевать редактор.
|
|
969
1066
|
*
|
|
@@ -1001,6 +1098,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1001
1098
|
// По изменению выделения проверять нельзя: пока запрет действует, выделение до редактора
|
|
1002
1099
|
// не доходит — проверка увидела бы «не задевает» и сняла запрет сама. Только по концу протяжки.
|
|
1003
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
|
+
});
|
|
1004
1110
|
|
|
1005
1111
|
editable.addEventListener(
|
|
1006
1112
|
"mousedown",
|
|
@@ -1107,7 +1213,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1107
1213
|
if (tool) {
|
|
1108
1214
|
e.preventDefault();
|
|
1109
1215
|
e.stopPropagation();
|
|
1110
|
-
|
|
1216
|
+
|
|
1217
|
+
if (this.formatTools.includes(tool)) {
|
|
1218
|
+
// у ссылки сперва спрашивается адрес — тем же полем в панели, что и у кнопки
|
|
1219
|
+
if (tool === LINK) formatToolbar.openLinkFor(this);
|
|
1220
|
+
else this.applyFormat(tool);
|
|
1221
|
+
}
|
|
1111
1222
|
return;
|
|
1112
1223
|
}
|
|
1113
1224
|
}
|