@brandup/ui-richeditor 1.0.40 → 1.0.41
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/package.json +2 -2
- package/source/emoji.ts +127 -51
- package/source/index.ts +1 -1
- package/source/richeditor.less +49 -10
- package/source/richeditor.ts +138 -4
- package/source/selection.ts +12 -2
- package/source/toolbar.ts +55 -15
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.41",
|
|
31
31
|
"main": "source/index.ts",
|
|
32
32
|
"types": "source/index.ts",
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@brandup/ui": "^2.0.7",
|
|
35
|
-
"@brandup/ui-kit": "^1.0.
|
|
35
|
+
"@brandup/ui-kit": "^1.0.41"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"source",
|
package/source/emoji.ts
CHANGED
|
@@ -1,53 +1,129 @@
|
|
|
1
|
-
// Набор смайликов для
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Набор смайликов для панели вставки. Все символы — одиночные кодпойнты (без ZWJ-последовательностей
|
|
2
|
+
// и модификаторов), поэтому переносятся в текст как единое целое. Внимание: в UTF-16 каждый занимает
|
|
3
|
+
// две единицы, и getLength() (а значит и maxlength у хоста) считает такой символ за два.
|
|
4
|
+
//
|
|
5
|
+
// Разложены по группам: панель рисует их в том же порядке и отделяет линией, а заодно группа —
|
|
6
|
+
// это кусок, к которому применяется пропуск отрисовки (см. .emoji-group в richeditor.less).
|
|
5
7
|
|
|
6
|
-
/**
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
8
|
+
/** Группа смайликов в панели вставки. */
|
|
9
|
+
export interface EmojiGroup {
|
|
10
|
+
/** Название группы; в панели не показывается, уходит в подпись для скринридера. */
|
|
11
|
+
title: string;
|
|
12
|
+
emojis: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Смайлики по группам (порядок сохраняется в UI). */
|
|
16
|
+
export const EMOJI_GROUPS: EmojiGroup[] = [
|
|
17
|
+
{
|
|
18
|
+
title: "Смайлы и жесты",
|
|
19
|
+
// prettier-ignore
|
|
20
|
+
emojis: [
|
|
21
|
+
"😀", "😁", "😂", "😃", "😄", "😅", "😆", "😉", "😊", "😋", "😌", "😍", "😏", "😒", "😓", "😔",
|
|
22
|
+
"😖", "😘", "😚", "😜", "😝", "😞", "😠", "😡", "😢", "😣", "😤", "😥", "😨", "😩", "😪", "😫",
|
|
23
|
+
"😭", "😰", "😱", "😲", "😳", "😵", "😷", "😸", "😹", "😺", "😻", "😼", "😽", "😾", "😿", "🙀",
|
|
24
|
+
"🙅", "🙆", "🙇", "🙈", "🙉", "🙊", "🙋", "🙌", "🙍", "🙎", "🙏", "😇", "😈", "😎", "😐", "😑",
|
|
25
|
+
"😕", "😗", "😙", "😛", "😟", "😦", "😧", "😬", "😮", "😯", "😴", "😶",
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
title: "Люди",
|
|
30
|
+
// prettier-ignore
|
|
31
|
+
emojis: [
|
|
32
|
+
"👀", "👂", "👃", "👄", "👅", "👆", "👇", "👈", "👉", "👊", "👋", "👌", "👍", "👎", "👏", "👐",
|
|
33
|
+
"👑", "👒", "👓", "👔", "👕", "👖", "👗", "👘", "👙", "👚", "👛", "👜", "👝", "👞", "👟", "👠",
|
|
34
|
+
"👡", "👢", "👣", "👤", "👦", "👧", "👨", "👩", "👪", "👫", "👮", "👯", "👰", "👱", "👲", "👳",
|
|
35
|
+
"👴", "👵", "👶", "👷", "👸", "👹", "👺", "👻", "👼", "👽", "👾", "👿", "💀", "💁", "💂", "💃",
|
|
36
|
+
"💄", "💅", "💆", "💇", "💏", "💑", "👥", "👬", "👭",
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
title: "Животные",
|
|
41
|
+
// prettier-ignore
|
|
42
|
+
emojis: [
|
|
43
|
+
"🐌", "🐍", "🐎", "🐑", "🐒", "🐔", "🐗", "🐘", "🐙", "🐚", "🐛", "🐜", "🐝", "🐞", "🐟", "🐠",
|
|
44
|
+
"🐡", "🐢", "🐣", "🐤", "🐥", "🐦", "🐧", "🐨", "🐩", "🐫", "🐬", "🐭", "🐮", "🐯", "🐰", "🐱",
|
|
45
|
+
"🐲", "🐳", "🐴", "🐵", "🐶", "🐷", "🐸", "🐹", "🐺", "🐻", "🐼", "🐽", "🐾", "🐀", "🐁", "🐂",
|
|
46
|
+
"🐃", "🐄", "🐅", "🐆", "🐇", "🐈", "🐉", "🐊", "🐋", "🐏", "🐐", "🐓", "🐕", "🐖", "🐪",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
title: "Природа",
|
|
51
|
+
// prettier-ignore
|
|
52
|
+
emojis: [
|
|
53
|
+
"🌀", "🌁", "🌂", "🌃", "🌄", "🌅", "🌆", "🌇", "🌈", "🌉", "🌊", "🌋", "🌌", "🌏", "🌑", "🌓",
|
|
54
|
+
"🌔", "🌕", "🌙", "🌛", "🌟", "🌠", "🌰", "🌱", "🌴", "🌵", "🌷", "🌸", "🌹", "🌺", "🌻", "🌼",
|
|
55
|
+
"🌽", "🌾", "🌿", "🍀", "🍁", "🍂", "🍃", "🌍", "🌎", "🌐", "🌒", "🌖", "🌗", "🌘", "🌚", "🌜",
|
|
56
|
+
"🌝", "🌞", "🌲", "🌳",
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
title: "Еда",
|
|
61
|
+
// prettier-ignore
|
|
62
|
+
emojis: [
|
|
63
|
+
"🍄", "🍅", "🍆", "🍇", "🍈", "🍉", "🍊", "🍌", "🍍", "🍎", "🍏", "🍑", "🍒", "🍓", "🍔", "🍕",
|
|
64
|
+
"🍖", "🍗", "🍘", "🍙", "🍚", "🍛", "🍜", "🍝", "🍞", "🍟", "🍠", "🍡", "🍢", "🍣", "🍤", "🍥",
|
|
65
|
+
"🍦", "🍧", "🍨", "🍩", "🍪", "🍫", "🍬", "🍭", "🍮", "🍯", "🍰", "🍱", "🍲", "🍳", "🍴", "🍵",
|
|
66
|
+
"🍶", "🍷", "🍸", "🍹", "🍺", "🍻", "🍋", "🍐", "🍼",
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
title: "Праздники и спорт",
|
|
71
|
+
// prettier-ignore
|
|
72
|
+
emojis: [
|
|
73
|
+
"🎀", "🎁", "🎂", "🎃", "🎄", "🎅", "🎆", "🎇", "🎈", "🎉", "🎊", "🎋", "🎌", "🎍", "🎎", "🎏",
|
|
74
|
+
"🎐", "🎑", "🎒", "🎓", "🎠", "🎡", "🎢", "🎣", "🎤", "🎥", "🎦", "🎧", "🎨", "🎩", "🎪", "🎫",
|
|
75
|
+
"🎬", "🎭", "🎮", "🎯", "🎰", "🎱", "🎲", "🎳", "🎴", "🎵", "🎶", "🎷", "🎸", "🎹", "🎺", "🎻",
|
|
76
|
+
"🎼", "🎽", "🎾", "🎿", "🏀", "🏁", "🏂", "🏃", "🏄", "🏆", "🏈", "🏊", "🏇", "🏉",
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
title: "Места",
|
|
81
|
+
// prettier-ignore
|
|
82
|
+
emojis: [
|
|
83
|
+
"🏠", "🏡", "🏢", "🏣", "🏥", "🏦", "🏧", "🏨", "🏩", "🏪", "🏫", "🏬", "🏭", "🏮", "🏯", "🏰",
|
|
84
|
+
"🗻", "🗼", "🗽", "🗾", "🗿", "🏤",
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
title: "Транспорт",
|
|
89
|
+
// prettier-ignore
|
|
90
|
+
emojis: [
|
|
91
|
+
"🚀", "🚃", "🚄", "🚅", "🚇", "🚉", "🚌", "🚏", "🚑", "🚒", "🚓", "🚕", "🚗", "🚙", "🚚", "🚢",
|
|
92
|
+
"🚤", "🚥", "🚧", "🚨", "🚩", "🚪", "🚫", "🚬", "🚭", "🚲", "🚶", "🚹", "🚺", "🚻", "🚼", "🚽",
|
|
93
|
+
"🚾", "🛀", "🚁", "🚂", "🚆", "🚈", "🚊", "🚍", "🚎", "🚐", "🚔", "🚖", "🚘", "🚛", "🚜", "🚝",
|
|
94
|
+
"🚞", "🚟", "🚠", "🚡", "🚣", "🚦", "🚮", "🚯", "🚰", "🚱", "🚳", "🚴", "🚵", "🚷", "🚸", "🚿",
|
|
95
|
+
"🛁", "🛂", "🛃", "🛄", "🛅",
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
title: "Предметы",
|
|
100
|
+
// prettier-ignore
|
|
101
|
+
emojis: [
|
|
102
|
+
"💈", "💉", "💊", "💋", "💌", "💍", "💎", "💐", "💠", "💡", "💰", "💱", "💲", "💳", "💴", "💵",
|
|
103
|
+
"💸", "💹", "💺", "💻", "💼", "💽", "💾", "💿", "📀", "📁", "📂", "📃", "📄", "📅", "📆", "📇",
|
|
104
|
+
"📈", "📉", "📊", "📋", "📌", "📍", "📎", "📏", "📐", "📑", "📒", "📓", "📔", "📕", "📖", "📗",
|
|
105
|
+
"📘", "📙", "📚", "📛", "📜", "📝", "📞", "📟", "📠", "📡", "📢", "📣", "📤", "📥", "📦", "📧",
|
|
106
|
+
"📨", "📩", "📪", "📫", "📮", "📰", "📱", "📲", "📳", "📴", "📶", "📷", "📹", "📺", "📻", "📼",
|
|
107
|
+
"💶", "💷", "📬", "📭", "📯", "📵",
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
title: "Символы",
|
|
112
|
+
// prettier-ignore
|
|
113
|
+
emojis: [
|
|
114
|
+
"🅰", "🅱", "🅾", "🅿", "🆎", "🆑", "🆒", "🆓", "🆔", "🆕", "🆖", "🆗", "🆘", "🆙", "🆚", "🈁",
|
|
115
|
+
"🈂", "🈚", "🈯", "🈲", "🈳", "🈴", "🈵", "🈶", "🈷", "🈸", "🈹", "🈺", "🉐", "🉑", "🀄", "🃏",
|
|
116
|
+
"💒", "💓", "💔", "💕", "💖", "💗", "💘", "💙", "💚", "💛", "💜", "💝", "💞", "💟", "💢", "💣",
|
|
117
|
+
"💤", "💥", "💦", "💧", "💨", "💩", "💪", "💫", "💬", "💮", "💯", "🔃", "🔊", "🔋", "🔌", "🔍",
|
|
118
|
+
"🔎", "🔏", "🔐", "🔑", "🔒", "🔓", "🔔", "🔖", "🔗", "🔘", "🔙", "🔚", "🔛", "🔜", "🔝", "🔞",
|
|
119
|
+
"🔟", "🔠", "🔡", "🔢", "🔣", "🔤", "🔥", "🔦", "🔧", "🔨", "🔩", "🔪", "🔫", "🔮", "🔯", "🔰",
|
|
120
|
+
"🔱", "🔲", "🔳", "🔴", "🔵", "🔶", "🔷", "🔸", "🔹", "🔺", "🔻", "🔼", "🔽", "🕐", "🕑", "🕒",
|
|
121
|
+
"🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛", "💭", "🔀", "🔁", "🔂", "🔄", "🔅", "🔆",
|
|
122
|
+
"🔇", "🔉", "🔕", "🔬", "🔭", "🕜", "🕝", "🕞", "🕟", "🕠", "🕡", "🕢", "🕣", "🕤", "🕥", "🕦",
|
|
123
|
+
"🕧",
|
|
124
|
+
],
|
|
125
|
+
},
|
|
53
126
|
];
|
|
127
|
+
|
|
128
|
+
/** Все смайлики подряд, в порядке групп. */
|
|
129
|
+
export const EMOJIS: string[] = EMOJI_GROUPS.flatMap((group) => group.emojis);
|
package/source/index.ts
CHANGED
package/source/richeditor.less
CHANGED
|
@@ -65,6 +65,19 @@
|
|
|
65
65
|
white-space: normal;
|
|
66
66
|
text-overflow: unset;
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
// По странице тянут выделение, начатое вне редактора: пока это так, его содержимое
|
|
70
|
+
// невыделяемо — иначе текст сообщения попал бы в выделение страницы как её часть.
|
|
71
|
+
// Класс держится только на время протяжки (см. __holdSelectable).
|
|
72
|
+
//
|
|
73
|
+
// Одного user-select мало: редактируемому содержимому браузер принудительно возвращает
|
|
74
|
+
// выделяемость, поэтому на это же время снимаем и саму редактируемость. Атрибут
|
|
75
|
+
// contenteditable не трогаем — он держит состояние редактора, а правится лишь оформление.
|
|
76
|
+
&.unselectable {
|
|
77
|
+
-webkit-user-modify: read-only;
|
|
78
|
+
-webkit-user-select: none;
|
|
79
|
+
user-select: none;
|
|
80
|
+
}
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
// общий тулбар форматирования — единый для всех редакторов.
|
|
@@ -82,6 +95,8 @@
|
|
|
82
95
|
border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
|
|
83
96
|
border-radius: var(--input-border-radius, 0);
|
|
84
97
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
98
|
+
// панель тоже монтируется в разметку хоста — курсор поля ввода ей не наследовать
|
|
99
|
+
cursor: default;
|
|
85
100
|
|
|
86
101
|
--svg-size: 18px;
|
|
87
102
|
--svg-fill: var(--input-color, #222);
|
|
@@ -155,18 +170,42 @@
|
|
|
155
170
|
left: 0;
|
|
156
171
|
margin-top: 4px;
|
|
157
172
|
width: 296px;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
173
|
+
padding: 6px;
|
|
174
|
+
// прокрутка живёт на списке внутри — см. .emoji-list
|
|
175
|
+
overflow: hidden;
|
|
176
|
+
// Курсор задаём свой: панель монтируется в разметку хоста и наследует его курсор — рядом
|
|
177
|
+
// с полем ввода это текстовый, и он же показывался бы над полосой прокрутки и фоном панели.
|
|
178
|
+
cursor: default;
|
|
179
|
+
|
|
180
|
+
// Прокручивается список, а не сам попап: полоса рисуется по краю коробки и перекрывала бы
|
|
181
|
+
// скругление рамки — угол выглядел бы срезанным. Заодно полоса отступает от краёв на
|
|
182
|
+
// внутренний отступ попапа — сверху, снизу и справа, — а не упирается в рамку.
|
|
183
|
+
& .emoji-list {
|
|
184
|
+
max-height: 220px;
|
|
185
|
+
overflow-y: auto; // полоса прокрутки — общая, от .ui-scrollable кита
|
|
186
|
+
}
|
|
163
187
|
|
|
164
|
-
|
|
165
|
-
|
|
188
|
+
// Смысловая группа смайликов, отбитая линией. Она же — кусок, который браузер вправе не
|
|
189
|
+
// рисовать, пока до него не долистали: поэлементно это было бы семьсот отслеживаемых
|
|
190
|
+
// поддеревьев, и слежение за ними съедает весь выигрыш от пропуска.
|
|
191
|
+
& .emoji-group {
|
|
192
|
+
display: flex;
|
|
193
|
+
flex-flow: row wrap;
|
|
166
194
|
content-visibility: auto;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
195
|
+
// высота ненарисованной группы; auto — дальше браузер помнит фактическую
|
|
196
|
+
contain-intrinsic-size: auto calc(var(--emoji-rows, 8) * var(--richeditor-emoji-size, 32px));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
& .emoji-group + .emoji-group {
|
|
200
|
+
margin-top: 6px;
|
|
201
|
+
padding-top: 6px;
|
|
202
|
+
border-top: var(--input-border-type, solid) var(--input-border-width, 1px)
|
|
203
|
+
var(--input-border-color, #aaa);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
& .emoji {
|
|
207
|
+
width: var(--richeditor-emoji-size, 32px);
|
|
208
|
+
height: var(--richeditor-emoji-size, 32px);
|
|
170
209
|
padding: 0;
|
|
171
210
|
border: 0;
|
|
172
211
|
background: transparent;
|
package/source/richeditor.ts
CHANGED
|
@@ -47,6 +47,8 @@ import { formatToolbar, type ToolbarButton } from "./toolbar";
|
|
|
47
47
|
export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost, type ToolbarButton } from "./toolbar";
|
|
48
48
|
|
|
49
49
|
export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
|
|
50
|
+
// Содержимое временно невыделяемо: по странице тянут выделение, начатое вне редактора (см. __holdSelectable).
|
|
51
|
+
export const UNSELECTABLE_CLASS = "unselectable";
|
|
50
52
|
export const CHANGE_EVENT = "richeditor-change";
|
|
51
53
|
|
|
52
54
|
const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Escape"];
|
|
@@ -147,6 +149,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
147
149
|
private __abort = new AbortController();
|
|
148
150
|
private __pendingFormats = new Set<FormatTool>();
|
|
149
151
|
private __hasInputClick = false;
|
|
152
|
+
private __editHolds = 0; // правка продолжается в окне хоста — см. holdEditing
|
|
153
|
+
// Компонент снят. Удержание правки переживает снятие (окно хоста закрывается позже), и по его
|
|
154
|
+
// снятию трогать содержимое уже нельзя — редактора нет.
|
|
155
|
+
private __disposed = false;
|
|
150
156
|
private __changeTimer = 0; // отложенное change по печати — см. __emitChange/flushChange
|
|
151
157
|
// Окно редактируемого элемента, взятое при создании. Таймер отложенного change переживает
|
|
152
158
|
// снятие компонента и гасится в destroy, а тот случается когда угодно — к этому моменту
|
|
@@ -190,7 +196,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
190
196
|
// при этом остаётся возможность фокуса, выделения и копирования
|
|
191
197
|
editable.contentEditable = "true";
|
|
192
198
|
|
|
193
|
-
if (options.placeholder != null) editable.
|
|
199
|
+
if (options.placeholder != null) editable.dataset.placeholder = options.placeholder;
|
|
194
200
|
if (multiline) editable.classList.add("multiline");
|
|
195
201
|
if (readonly) editable.classList.add("readonly");
|
|
196
202
|
|
|
@@ -296,8 +302,65 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
296
302
|
return this.editable.textContent?.length ?? 0;
|
|
297
303
|
}
|
|
298
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Фокус в поле. Каретку, стоящую в содержимом, не двигает: правку продолжают там, где её
|
|
307
|
+
* прервали. Своей защиты обработчика фокуса для этого мало — браузер успевает поставить
|
|
308
|
+
* при фокусе собственную каретку (в начало содержимого), и она выглядит как «уже стоявшая».
|
|
309
|
+
*/
|
|
299
310
|
focus(): void {
|
|
311
|
+
const bounds = this.caretSnapshot();
|
|
312
|
+
|
|
313
|
+
if (bounds) this.restoreCaret(bounds);
|
|
314
|
+
else this.editable.focus();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Снимок каретки текстовыми смещениями — пережить работу окна хоста. Пока оно открыто, фокус
|
|
319
|
+
* уходит туда, а с ним и выделение документа: в окне могут быть свои поля ввода, и каретка
|
|
320
|
+
* встанет уже в них. Снимать нужно до открытия, возвращать — через {@link restoreCaret}.
|
|
321
|
+
*
|
|
322
|
+
* Смещения остаются верными, потому что на это время содержимое не меняется (см. {@link holdEditing}).
|
|
323
|
+
*/
|
|
324
|
+
caretSnapshot(): [number, number] | null {
|
|
325
|
+
const selection = this.selection;
|
|
326
|
+
|
|
327
|
+
return selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Возвращает в поле фокус и каретку по снимку {@link caretSnapshot}. */
|
|
331
|
+
restoreCaret(bounds: [number, number]): void {
|
|
300
332
|
this.editable.focus();
|
|
333
|
+
|
|
334
|
+
const selection = documentSelection(this.editable);
|
|
335
|
+
if (selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Сообщает, что правка продолжается снаружи: хост открыл своё окно, фокус ушёл туда, но ввод
|
|
340
|
+
* не закончен. Пока удержание живо, `blur` не считается концом ввода и содержимое не приводится
|
|
341
|
+
* к нормальному виду — иначе пробел на границе каретки был бы срезан как краевой, а сама каретка
|
|
342
|
+
* съехала бы на пересчёте смещений, и текст из окна встал бы не туда и вплотную к соседнему слову.
|
|
343
|
+
*
|
|
344
|
+
* Возвращает снятие удержания: повторные вызовы ничего не делают. Если к этому моменту фокус
|
|
345
|
+
* в поле не вернулся, содержимое доводится сразу — другого `blur` уже не будет.
|
|
346
|
+
*/
|
|
347
|
+
holdEditing(): () => void {
|
|
348
|
+
this.__editHolds++;
|
|
349
|
+
|
|
350
|
+
let released = false;
|
|
351
|
+
|
|
352
|
+
return () => {
|
|
353
|
+
if (released) return;
|
|
354
|
+
released = true;
|
|
355
|
+
|
|
356
|
+
this.__editHolds--;
|
|
357
|
+
if (this.__editHolds > 0 || this.__disposed) return;
|
|
358
|
+
|
|
359
|
+
if (this.editable.ownerDocument.activeElement !== this.editable) {
|
|
360
|
+
this.__normalize(true);
|
|
361
|
+
this.flushChange();
|
|
362
|
+
}
|
|
363
|
+
};
|
|
301
364
|
}
|
|
302
365
|
|
|
303
366
|
onChange(handler: (e: RichEditorChangeData) => void) {
|
|
@@ -511,14 +574,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
511
574
|
}
|
|
512
575
|
|
|
513
576
|
override destroy(): void {
|
|
577
|
+
this.__disposed = true;
|
|
514
578
|
this.flushChange(); // хост не должен остаться с устаревшей копией значения
|
|
515
579
|
this.__abort.abort();
|
|
516
580
|
formatToolbar.detach(this);
|
|
517
581
|
|
|
518
582
|
// элемент передан хостом — не удаляем его, только снимаем оформление редактора
|
|
519
|
-
this.editable.classList.remove(ROOT_CLASS, "multiline", "readonly", "focused");
|
|
583
|
+
this.editable.classList.remove(ROOT_CLASS, UNSELECTABLE_CLASS, "multiline", "readonly", "focused");
|
|
520
584
|
this.editable.removeAttribute("contenteditable");
|
|
521
|
-
this.editable.
|
|
585
|
+
delete this.editable.dataset.placeholder;
|
|
522
586
|
|
|
523
587
|
super.destroy();
|
|
524
588
|
}
|
|
@@ -613,6 +677,47 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
613
677
|
|
|
614
678
|
// --- обработчики ---
|
|
615
679
|
|
|
680
|
+
/**
|
|
681
|
+
* Изоляция выделения: протяжка, начатая вне редактора, не должна затягивать его содержимое —
|
|
682
|
+
* текст сообщения красился бы как часть страницы.
|
|
683
|
+
*
|
|
684
|
+
* Правим не выделение, а выделяемость: пока идёт чужая протяжка, содержимое редактора
|
|
685
|
+
* невыделяемо, и браузер обходит его сам. Обрезать выделение из кода нельзя — оно в документе
|
|
686
|
+
* одно, и когда протяжка проходит мимо редактора, оба её конца снаружи: середину из одного
|
|
687
|
+
* диапазона не вырезать.
|
|
688
|
+
*/
|
|
689
|
+
private __holdSelectable(target: EventTarget | null) {
|
|
690
|
+
// Нажали в самом редакторе — или он в работе: нажатие по панели и кнопкам хоста приходит
|
|
691
|
+
// мимо него, но выделение страницы тут ни при чём, а запрет заодно лишил бы поле
|
|
692
|
+
// редактируемости до следующего клика внутрь. Фокус на этот момент ещё не ушёл.
|
|
693
|
+
const own =
|
|
694
|
+
this.editable.contains(target as Node | null) ||
|
|
695
|
+
this.editable.ownerDocument.activeElement === this.editable;
|
|
696
|
+
|
|
697
|
+
this.editable.classList.toggle(UNSELECTABLE_CLASS, !own);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Снимает запрет, когда выделение перестало задевать редактор.
|
|
702
|
+
*
|
|
703
|
+
* Просто по отпусканию кнопки снимать нельзя: диапазон остаётся протянутым через редактор,
|
|
704
|
+
* и стоит вернуть содержимому выделяемость, как оно тут же оказывается выделенным — будто
|
|
705
|
+
* запрета и не было. Такой запрет держится до следующего нажатия: внутри редактора оно
|
|
706
|
+
* снимет запрет, снаружи — начнёт новую протяжку.
|
|
707
|
+
*/
|
|
708
|
+
private __releaseSelectable() {
|
|
709
|
+
if (!this.editable.classList.contains(UNSELECTABLE_CLASS)) return;
|
|
710
|
+
|
|
711
|
+
const selection = documentSelection(this.editable);
|
|
712
|
+
const across =
|
|
713
|
+
!!selection &&
|
|
714
|
+
selection.rangeCount > 0 &&
|
|
715
|
+
!selection.isCollapsed &&
|
|
716
|
+
selection.getRangeAt(0).intersectsNode(this.editable);
|
|
717
|
+
|
|
718
|
+
if (!across) this.editable.classList.remove(UNSELECTABLE_CLASS);
|
|
719
|
+
}
|
|
720
|
+
|
|
616
721
|
private __initEvents() {
|
|
617
722
|
const { signal } = this.__abort;
|
|
618
723
|
const editable = this.editable;
|
|
@@ -621,6 +726,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
621
726
|
// dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
|
|
622
727
|
this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
|
|
623
728
|
|
|
729
|
+
// Выделение страницы не должно затягивать содержимое редактора — см. __holdSelectable.
|
|
730
|
+
// Слушаем на документе и в фазе перехвата: протяжка начинается где угодно, а обработчик
|
|
731
|
+
// по пути может погасить всплытие.
|
|
732
|
+
const doc = editable.ownerDocument;
|
|
733
|
+
doc.addEventListener("mousedown", (e) => this.__holdSelectable(e.target), { signal, capture: true });
|
|
734
|
+
// По изменению выделения проверять нельзя: пока запрет действует, выделение до редактора
|
|
735
|
+
// не доходит — проверка увидела бы «не задевает» и сняла запрет сама. Только по концу протяжки.
|
|
736
|
+
doc.addEventListener("mouseup", () => this.__releaseSelectable(), { signal, capture: true });
|
|
737
|
+
|
|
624
738
|
editable.addEventListener(
|
|
625
739
|
"mousedown",
|
|
626
740
|
() => {
|
|
@@ -635,6 +749,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
635
749
|
() => {
|
|
636
750
|
this.element.classList.add("focused");
|
|
637
751
|
|
|
752
|
+
// Пришли править — держать запрет выделения не за чем, а с ним поле осталось бы
|
|
753
|
+
// нередактируемым. Мышью его снимает нажатие, но фокус берут и клавишей, и из кода.
|
|
754
|
+
this.element.classList.remove(UNSELECTABLE_CLASS);
|
|
755
|
+
|
|
638
756
|
// нужна ли панель этому редактору, решает она сама — иначе условие пришлось бы
|
|
639
757
|
// держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
|
|
640
758
|
formatToolbar.attach(this);
|
|
@@ -655,6 +773,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
655
773
|
this.element.classList.remove("focused");
|
|
656
774
|
formatToolbar.detach(this);
|
|
657
775
|
|
|
776
|
+
// правку продолжают в окне хоста — этот blur не конец ввода, содержимое трогать нельзя
|
|
777
|
+
if (this.__editHolds > 0) {
|
|
778
|
+
this.flushChange();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
658
782
|
// удаляем висящий BR, чтобы появился placeholder
|
|
659
783
|
if (editable.firstChild?.nodeName === "BR") DOM.empty(editable);
|
|
660
784
|
|
|
@@ -920,7 +1044,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
920
1044
|
const selection = this.selection;
|
|
921
1045
|
if (!selection) return;
|
|
922
1046
|
|
|
923
|
-
|
|
1047
|
+
// Замена выделенного не должна терять его оформление. Физически вставка часто оказывается
|
|
1048
|
+
// снаружи тега: границы выделения восстанавливаются по текстовым смещениям, а смещение на
|
|
1049
|
+
// стыке узлов достаётся соседнему — тому, что вне тега; сам же тег после удаления текста
|
|
1050
|
+
// остаётся пустым и убирается. Поэтому форматы, покрывавшие выделение целиком, переносим
|
|
1051
|
+
// на вставляемый текст явно. Каретке без выделения это не нужно: там вставка и так попадает
|
|
1052
|
+
// внутрь тега, а на его границе принадлежность неочевидна.
|
|
1053
|
+
const range = selection.getRangeAt(0);
|
|
1054
|
+
const inherited = range.collapsed ? [] : activeFormats(this.editable, range, this.formatTools);
|
|
1055
|
+
const formats = new Set([...this.__pendingFormats, ...inherited]);
|
|
1056
|
+
|
|
1057
|
+
insertFormattedText(this.editable, data, Array.from(formats), selection);
|
|
924
1058
|
|
|
925
1059
|
// В пустом поле абзацев ещё нет, и каретке некуда встать внутрь — вставка из кода
|
|
926
1060
|
// (смайлик, переменная) ложится прямо в редактор. Приводим к модели абзацев тем же
|
package/source/selection.ts
CHANGED
|
@@ -217,8 +217,18 @@ function nodeWithinRange(node: Node, range: Range): boolean {
|
|
|
217
217
|
function* touchedTextNodes(root: HTMLElement, range: Range): Generator<Text> {
|
|
218
218
|
const walker = document.createTreeWalker(rangeScope(root, range), NodeFilter.SHOW_TEXT);
|
|
219
219
|
|
|
220
|
-
for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null)
|
|
221
|
-
if (n.length
|
|
220
|
+
for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
|
|
221
|
+
if (!n.length || !range.intersectsNode(n)) continue;
|
|
222
|
+
|
|
223
|
+
// Узел, задетый одной лишь границей, не входит в диапазон ни одним символом: выделение
|
|
224
|
+
// кончается там, где он начинается, или начинается там, где он кончается. Учитывать его
|
|
225
|
+
// нельзя — соседний по тексту узел лежит уже вне тегов выделенного, и от него состояние
|
|
226
|
+
// всего выделения выглядело бы неотформатированным.
|
|
227
|
+
if (range.startContainer === n && range.startOffset === n.length) continue;
|
|
228
|
+
if (range.endContainer === n && range.endOffset === 0) continue;
|
|
229
|
+
|
|
230
|
+
yield n;
|
|
231
|
+
}
|
|
222
232
|
}
|
|
223
233
|
|
|
224
234
|
/** Узел под схлопнутой кареткой — от него и ищется формат. */
|
package/source/toolbar.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// т.к. тулбар находится вне привязанных UIElement).
|
|
7
7
|
|
|
8
8
|
import { DOM } from "@brandup/ui";
|
|
9
|
-
import { POPUP_CLASS, PopupManager } from "@brandup/ui-kit";
|
|
9
|
+
import { POPUP_CLASS, PopupManager, SCROLLABLE_CLASS } from "@brandup/ui-kit";
|
|
10
10
|
import { EDITOR_ACTIONS, FORMAT_TOOLS, type EditorAction, type FormatTool } from "./format";
|
|
11
|
-
import {
|
|
11
|
+
import { EMOJI_GROUPS, type EmojiGroup } from "./emoji";
|
|
12
12
|
import boldIcon from "../svg/bold.svg";
|
|
13
13
|
import italicIcon from "../svg/italic.svg";
|
|
14
14
|
import strikeIcon from "../svg/strike.svg";
|
|
@@ -76,6 +76,34 @@ export interface ToolbarHost {
|
|
|
76
76
|
|
|
77
77
|
const MARGIN = 6;
|
|
78
78
|
|
|
79
|
+
// Сколько кнопок помещается в ряд при ширине панели (см. .ui-richeditor-emoji в richeditor.less).
|
|
80
|
+
// Точность нужна только для оценки высоты нерисованной группы: ошибка сдвинет ползунок прокрутки,
|
|
81
|
+
// но не саму раскладку — группа переносит кнопки сама.
|
|
82
|
+
const EMOJI_COLUMNS = 8;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Группа смайликов: и смысловое деление в панели (отбивается линией), и кусок, к которому
|
|
86
|
+
* применяется пропуск отрисовки. Поэлементно это было бы семьсот отслеживаемых поддеревьев,
|
|
87
|
+
* и слежение за ними съедает выигрыш от пропуска.
|
|
88
|
+
*/
|
|
89
|
+
function buildEmojiGroup(group: EmojiGroup): HTMLElement {
|
|
90
|
+
const rows = Math.ceil(group.emojis.length / EMOJI_COLUMNS);
|
|
91
|
+
const elem = DOM.tag("div", {
|
|
92
|
+
class: "emoji-group",
|
|
93
|
+
role: "group",
|
|
94
|
+
"aria-label": group.title,
|
|
95
|
+
// высота, пока группа не нарисована: без неё список схлопнулся бы, а прокрутка скакала
|
|
96
|
+
style: `--emoji-rows: ${rows}`,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const fragment = document.createDocumentFragment();
|
|
100
|
+
for (const emoji of group.emojis)
|
|
101
|
+
fragment.appendChild(DOM.tag("button", { type: "button", class: "emoji", tabindex: "-1" }, emoji));
|
|
102
|
+
elem.appendChild(fragment);
|
|
103
|
+
|
|
104
|
+
return elem;
|
|
105
|
+
}
|
|
106
|
+
|
|
79
107
|
class FormatToolbar {
|
|
80
108
|
private __elem: HTMLElement | null = null;
|
|
81
109
|
private __emojiPicker: HTMLElement | null = null;
|
|
@@ -83,7 +111,9 @@ class FormatToolbar {
|
|
|
83
111
|
private __emojiInitiator: HTMLElement | null = null; // кнопка, у которой открыта панель
|
|
84
112
|
private __buttons: Array<[FormatTool, HTMLButtonElement]> = [];
|
|
85
113
|
private __actionButtons: Array<[EditorAction, HTMLButtonElement]> = [];
|
|
86
|
-
|
|
114
|
+
// имя, а не сама кнопка хоста: панель одна на все редакторы и переиспользует разметку между
|
|
115
|
+
// ними, а поведение принадлежит текущему — держать здесь ссылку значит звать чужой обработчик
|
|
116
|
+
private __hostButtons: Array<[string, HTMLButtonElement]> = [];
|
|
87
117
|
private __active: ToolbarHost | null = null;
|
|
88
118
|
private __suspended: ToolbarHost | null = null; // показ придержан на время панели смайликов
|
|
89
119
|
private __toolsKey = "";
|
|
@@ -190,9 +220,12 @@ class FormatToolbar {
|
|
|
190
220
|
const emojiHost = this.__emojiHost === host;
|
|
191
221
|
if (!emojiHost && this.__active !== host) return;
|
|
192
222
|
|
|
193
|
-
|
|
194
|
-
|
|
223
|
+
// Закрываем только свою панель. Открытие у кнопки другого редактора само переводит туда
|
|
224
|
+
// фокус, и этот уход приходит уже после — панель к тому времени принадлежит соседу, и
|
|
225
|
+
// закрыть её значило бы гасить только что открытое: она требовала бы второго нажатия.
|
|
195
226
|
if (emojiHost) {
|
|
227
|
+
this.__closeEmoji();
|
|
228
|
+
|
|
196
229
|
this.__emojiHost = null;
|
|
197
230
|
this.__emojiInitiator = null;
|
|
198
231
|
}
|
|
@@ -233,7 +266,12 @@ class FormatToolbar {
|
|
|
233
266
|
|
|
234
267
|
// хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
|
|
235
268
|
for (const [action, btn] of this.__actionButtons) setDisabled(btn, host.isActionEnabled?.(action) === false);
|
|
236
|
-
for (const [
|
|
269
|
+
for (const [name, btn] of this.__hostButtons) setDisabled(btn, this.__hostButton(name)?.isEnabled?.() === false);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Кнопка хоста по имени — у активного редактора, а не у того, кто собрал разметку панели. */
|
|
273
|
+
private __hostButton(name: string): ToolbarButton | undefined {
|
|
274
|
+
return this.__active?.toolbarButtons?.find((button) => button.name === name);
|
|
237
275
|
}
|
|
238
276
|
|
|
239
277
|
/** Пересчитать позицию над активным редактором (только для режима body/fixed). */
|
|
@@ -285,7 +323,7 @@ class FormatToolbar {
|
|
|
285
323
|
const def = FORMAT_TOOLS[tool];
|
|
286
324
|
const btn = DOM.tag(
|
|
287
325
|
"button",
|
|
288
|
-
{ type: "button", class: "format-button",
|
|
326
|
+
{ type: "button", class: "format-button", dataset: { formatTool: tool }, title: def.title },
|
|
289
327
|
FORMAT_ICONS[tool]
|
|
290
328
|
);
|
|
291
329
|
btn.addEventListener("click", () => this.__active?.applyFormat(tool));
|
|
@@ -300,7 +338,7 @@ class FormatToolbar {
|
|
|
300
338
|
const def = EDITOR_ACTIONS[action];
|
|
301
339
|
const btn = DOM.tag(
|
|
302
340
|
"button",
|
|
303
|
-
{ type: "button", class: "action-button",
|
|
341
|
+
{ type: "button", class: "action-button", dataset: { editorAction: action }, title: def.title },
|
|
304
342
|
ACTION_ICONS[action]
|
|
305
343
|
);
|
|
306
344
|
if (action === "emoji") btn.addEventListener("click", (e) => this.__toggleEmoji(btn, e));
|
|
@@ -316,13 +354,13 @@ class FormatToolbar {
|
|
|
316
354
|
for (const button of buttons) {
|
|
317
355
|
const btn = DOM.tag(
|
|
318
356
|
"button",
|
|
319
|
-
{ type: "button", class: "host-button",
|
|
357
|
+
{ type: "button", class: "host-button", dataset: { toolbarButton: button.name }, title: button.title },
|
|
320
358
|
button.icon
|
|
321
359
|
);
|
|
322
|
-
btn.addEventListener("click", () => button.run());
|
|
360
|
+
btn.addEventListener("click", () => this.__hostButton(button.name)?.run());
|
|
323
361
|
|
|
324
362
|
elem.appendChild(btn);
|
|
325
|
-
this.__hostButtons.push([button, btn]);
|
|
363
|
+
this.__hostButtons.push([button.name, btn]);
|
|
326
364
|
}
|
|
327
365
|
|
|
328
366
|
// панель пережила перестройку кнопок — возвращаем её в тулбар, чтобы не собирать заново.
|
|
@@ -388,10 +426,12 @@ class FormatToolbar {
|
|
|
388
426
|
private __buildEmojiPicker(): HTMLElement {
|
|
389
427
|
const picker = DOM.tag("div", { class: `${POPUP_CLASS} ${EMOJI_PICKER_CLASS}` });
|
|
390
428
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
picker.appendChild(
|
|
429
|
+
// Прокручивается список, а не сам попап: полоса прокрутки рисуется по краю коробки
|
|
430
|
+
// и перекрывала бы скругление рамки — угол выглядел бы срезанным.
|
|
431
|
+
const list = DOM.tag("div", { class: ["emoji-list", SCROLLABLE_CLASS] });
|
|
432
|
+
picker.appendChild(list);
|
|
433
|
+
|
|
434
|
+
for (const group of EMOJI_GROUPS) list.appendChild(buildEmojiGroup(group));
|
|
395
435
|
|
|
396
436
|
// панель может висеть и вне тулбара, поэтому гасит фокус сама
|
|
397
437
|
picker.addEventListener("mousedown", (e) => e.preventDefault());
|