@brandup/ui-richeditor 1.0.39 → 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 +165 -5
- package/source/selection.ts +12 -2
- package/source/toolbar.ts +90 -16
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
|
|
|
@@ -274,7 +280,21 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
274
280
|
if (this.readonly) return;
|
|
275
281
|
|
|
276
282
|
const target = container ?? initiator.parentElement;
|
|
277
|
-
if (target)
|
|
283
|
+
if (!target) return;
|
|
284
|
+
|
|
285
|
+
formatToolbar.openEmoji(this, initiator, target);
|
|
286
|
+
|
|
287
|
+
// По кнопке могли нажать, ни разу не заходя в поле, — тогда каретки нет и вставлять символ
|
|
288
|
+
// некуда. Фокусировать вслепую нельзя: focus() сбросил бы уже стоящую каретку, и символ
|
|
289
|
+
// уехал бы не туда. Конец содержимого назначаем сами: фокус ставит каретку в начало,
|
|
290
|
+
// и дописанный к сообщению символ оказался бы перед текстом.
|
|
291
|
+
//
|
|
292
|
+
// Строго после открытия панели: фокус показывает тулбар, а придержать его панель успевает
|
|
293
|
+
// только когда открыта сама.
|
|
294
|
+
if (!this.selection) {
|
|
295
|
+
this.focus();
|
|
296
|
+
caretToEnd(this.editable, this.multiline);
|
|
297
|
+
}
|
|
278
298
|
}
|
|
279
299
|
|
|
280
300
|
getLength(): number {
|
|
@@ -282,8 +302,65 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
282
302
|
return this.editable.textContent?.length ?? 0;
|
|
283
303
|
}
|
|
284
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Фокус в поле. Каретку, стоящую в содержимом, не двигает: правку продолжают там, где её
|
|
307
|
+
* прервали. Своей защиты обработчика фокуса для этого мало — браузер успевает поставить
|
|
308
|
+
* при фокусе собственную каретку (в начало содержимого), и она выглядит как «уже стоявшая».
|
|
309
|
+
*/
|
|
285
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 {
|
|
286
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
|
+
};
|
|
287
364
|
}
|
|
288
365
|
|
|
289
366
|
onChange(handler: (e: RichEditorChangeData) => void) {
|
|
@@ -497,14 +574,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
497
574
|
}
|
|
498
575
|
|
|
499
576
|
override destroy(): void {
|
|
577
|
+
this.__disposed = true;
|
|
500
578
|
this.flushChange(); // хост не должен остаться с устаревшей копией значения
|
|
501
579
|
this.__abort.abort();
|
|
502
580
|
formatToolbar.detach(this);
|
|
503
581
|
|
|
504
582
|
// элемент передан хостом — не удаляем его, только снимаем оформление редактора
|
|
505
|
-
this.editable.classList.remove(ROOT_CLASS, "multiline", "readonly", "focused");
|
|
583
|
+
this.editable.classList.remove(ROOT_CLASS, UNSELECTABLE_CLASS, "multiline", "readonly", "focused");
|
|
506
584
|
this.editable.removeAttribute("contenteditable");
|
|
507
|
-
this.editable.
|
|
585
|
+
delete this.editable.dataset.placeholder;
|
|
508
586
|
|
|
509
587
|
super.destroy();
|
|
510
588
|
}
|
|
@@ -599,6 +677,47 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
599
677
|
|
|
600
678
|
// --- обработчики ---
|
|
601
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
|
+
|
|
602
721
|
private __initEvents() {
|
|
603
722
|
const { signal } = this.__abort;
|
|
604
723
|
const editable = this.editable;
|
|
@@ -607,6 +726,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
607
726
|
// dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
|
|
608
727
|
this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
|
|
609
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
|
+
|
|
610
738
|
editable.addEventListener(
|
|
611
739
|
"mousedown",
|
|
612
740
|
() => {
|
|
@@ -621,6 +749,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
621
749
|
() => {
|
|
622
750
|
this.element.classList.add("focused");
|
|
623
751
|
|
|
752
|
+
// Пришли править — держать запрет выделения не за чем, а с ним поле осталось бы
|
|
753
|
+
// нередактируемым. Мышью его снимает нажатие, но фокус берут и клавишей, и из кода.
|
|
754
|
+
this.element.classList.remove(UNSELECTABLE_CLASS);
|
|
755
|
+
|
|
624
756
|
// нужна ли панель этому редактору, решает она сама — иначе условие пришлось бы
|
|
625
757
|
// держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
|
|
626
758
|
formatToolbar.attach(this);
|
|
@@ -641,6 +773,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
641
773
|
this.element.classList.remove("focused");
|
|
642
774
|
formatToolbar.detach(this);
|
|
643
775
|
|
|
776
|
+
// правку продолжают в окне хоста — этот blur не конец ввода, содержимое трогать нельзя
|
|
777
|
+
if (this.__editHolds > 0) {
|
|
778
|
+
this.flushChange();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
644
782
|
// удаляем висящий BR, чтобы появился placeholder
|
|
645
783
|
if (editable.firstChild?.nodeName === "BR") DOM.empty(editable);
|
|
646
784
|
|
|
@@ -906,7 +1044,29 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
906
1044
|
const selection = this.selection;
|
|
907
1045
|
if (!selection) return;
|
|
908
1046
|
|
|
909
|
-
|
|
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);
|
|
1058
|
+
|
|
1059
|
+
// В пустом поле абзацев ещё нет, и каретке некуда встать внутрь — вставка из кода
|
|
1060
|
+
// (смайлик, переменная) ложится прямо в редактор. Приводим к модели абзацев тем же
|
|
1061
|
+
// способом, что и обработчик input при печати: он на программную вставку не приходит,
|
|
1062
|
+
// а без <p> следующий Enter правит текст мимо абзаца.
|
|
1063
|
+
if (this.multiline)
|
|
1064
|
+
preserveCaret(this.editable, () => {
|
|
1065
|
+
const before = this.editable.innerHTML;
|
|
1066
|
+
ensureParagraphs(this.editable);
|
|
1067
|
+
|
|
1068
|
+
return this.editable.innerHTML !== before;
|
|
1069
|
+
});
|
|
910
1070
|
|
|
911
1071
|
this.__emitChange();
|
|
912
1072
|
formatToolbar.refresh();
|
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,8 +111,11 @@ 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;
|
|
118
|
+
private __suspended: ToolbarHost | null = null; // показ придержан на время панели смайликов
|
|
88
119
|
private __toolsKey = "";
|
|
89
120
|
private __inContainer = false;
|
|
90
121
|
private readonly __reposition = () => this.__schedule("position");
|
|
@@ -140,6 +171,9 @@ class FormatToolbar {
|
|
|
140
171
|
|
|
141
172
|
/** Показать тулбар для редактора (на фокусе): перестроить кнопки, спозиционировать, показать. */
|
|
142
173
|
attach(host: ToolbarHost) {
|
|
174
|
+
// открыта панель смайликов у собственной кнопки хоста — показ отложен до её закрытия
|
|
175
|
+
if (this.__suspended === host) return;
|
|
176
|
+
|
|
143
177
|
const actions = host.editorActions ?? [];
|
|
144
178
|
const buttons = host.toolbarButtons ?? [];
|
|
145
179
|
if (!host.formatTools.length && !actions.length && !buttons.length) return;
|
|
@@ -177,20 +211,32 @@ class FormatToolbar {
|
|
|
177
211
|
|
|
178
212
|
/** Скрыть тулбар, если он обслуживает этот редактор (на blur/destroy). */
|
|
179
213
|
detach(host: ToolbarHost) {
|
|
214
|
+
// Придержанный показ снимаем первым делом, до закрытия панели: иначе её onClose поднял бы
|
|
215
|
+
// тулбар над редактором, который как раз уходит (в том числе разрушается).
|
|
216
|
+
if (this.__suspended === host) this.__suspended = null;
|
|
217
|
+
|
|
180
218
|
// панель смайликов могла быть открыта не для активного редактора (у своей кнопки хоста) —
|
|
181
219
|
// ссылку на него всё равно отпускаем, иначе уничтоженный редактор держится синглтоном
|
|
182
220
|
const emojiHost = this.__emojiHost === host;
|
|
183
221
|
if (!emojiHost && this.__active !== host) return;
|
|
184
222
|
|
|
185
|
-
|
|
186
|
-
|
|
223
|
+
// Закрываем только свою панель. Открытие у кнопки другого редактора само переводит туда
|
|
224
|
+
// фокус, и этот уход приходит уже после — панель к тому времени принадлежит соседу, и
|
|
225
|
+
// закрыть её значило бы гасить только что открытое: она требовала бы второго нажатия.
|
|
187
226
|
if (emojiHost) {
|
|
227
|
+
this.__closeEmoji();
|
|
228
|
+
|
|
188
229
|
this.__emojiHost = null;
|
|
189
230
|
this.__emojiInitiator = null;
|
|
190
231
|
}
|
|
191
232
|
|
|
192
233
|
if (this.__active !== host) return;
|
|
193
234
|
|
|
235
|
+
this.__hide();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Убрать панель с экрана и отпустить активный редактор (позиционирование, подсветка). */
|
|
239
|
+
private __hide() {
|
|
194
240
|
this.__active = null;
|
|
195
241
|
this.__cancelScheduled();
|
|
196
242
|
if (this.__elem) this.__elem.classList.remove("visible");
|
|
@@ -220,7 +266,12 @@ class FormatToolbar {
|
|
|
220
266
|
|
|
221
267
|
// хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
|
|
222
268
|
for (const [action, btn] of this.__actionButtons) setDisabled(btn, host.isActionEnabled?.(action) === false);
|
|
223
|
-
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);
|
|
224
275
|
}
|
|
225
276
|
|
|
226
277
|
/** Пересчитать позицию над активным редактором (только для режима body/fixed). */
|
|
@@ -272,7 +323,7 @@ class FormatToolbar {
|
|
|
272
323
|
const def = FORMAT_TOOLS[tool];
|
|
273
324
|
const btn = DOM.tag(
|
|
274
325
|
"button",
|
|
275
|
-
{ type: "button", class: "format-button",
|
|
326
|
+
{ type: "button", class: "format-button", dataset: { formatTool: tool }, title: def.title },
|
|
276
327
|
FORMAT_ICONS[tool]
|
|
277
328
|
);
|
|
278
329
|
btn.addEventListener("click", () => this.__active?.applyFormat(tool));
|
|
@@ -287,7 +338,7 @@ class FormatToolbar {
|
|
|
287
338
|
const def = EDITOR_ACTIONS[action];
|
|
288
339
|
const btn = DOM.tag(
|
|
289
340
|
"button",
|
|
290
|
-
{ type: "button", class: "action-button",
|
|
341
|
+
{ type: "button", class: "action-button", dataset: { editorAction: action }, title: def.title },
|
|
291
342
|
ACTION_ICONS[action]
|
|
292
343
|
);
|
|
293
344
|
if (action === "emoji") btn.addEventListener("click", (e) => this.__toggleEmoji(btn, e));
|
|
@@ -303,13 +354,13 @@ class FormatToolbar {
|
|
|
303
354
|
for (const button of buttons) {
|
|
304
355
|
const btn = DOM.tag(
|
|
305
356
|
"button",
|
|
306
|
-
{ type: "button", class: "host-button",
|
|
357
|
+
{ type: "button", class: "host-button", dataset: { toolbarButton: button.name }, title: button.title },
|
|
307
358
|
button.icon
|
|
308
359
|
);
|
|
309
|
-
btn.addEventListener("click", () => button.run());
|
|
360
|
+
btn.addEventListener("click", () => this.__hostButton(button.name)?.run());
|
|
310
361
|
|
|
311
362
|
elem.appendChild(btn);
|
|
312
|
-
this.__hostButtons.push([button, btn]);
|
|
363
|
+
this.__hostButtons.push([button.name, btn]);
|
|
313
364
|
}
|
|
314
365
|
|
|
315
366
|
// панель пережила перестройку кнопок — возвращаем её в тулбар, чтобы не собирать заново.
|
|
@@ -335,7 +386,28 @@ class FormatToolbar {
|
|
|
335
386
|
this.__emojiHost = host;
|
|
336
387
|
this.__emojiInitiator = initiator;
|
|
337
388
|
|
|
338
|
-
|
|
389
|
+
// Панель у собственной кнопки хоста — самостоятельный слой, и показывать её вместе с тулбаром
|
|
390
|
+
// нельзя: это два всплывающих окна над одним полем. Тулбар придерживаем на всё время работы
|
|
391
|
+
// панели, а показанный убираем с экрана; вернётся он сам при её закрытии, если поле осталось
|
|
392
|
+
// в фокусе. Панель самого тулбара (container === __elem) — его собственный выпадающий слой,
|
|
393
|
+
// прятать её носителя незачем и нечем.
|
|
394
|
+
if (container !== this.__elem) {
|
|
395
|
+
this.__suspended = host;
|
|
396
|
+
if (this.__active === host) this.__hide();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
PopupManager.open(this.__ensureEmojiPicker(container), { initiator, onClose: () => this.__resume() });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Панель смайликов закрылась — показываем придержанный тулбар, если редактор ещё в фокусе. */
|
|
403
|
+
private __resume() {
|
|
404
|
+
const host = this.__suspended;
|
|
405
|
+
if (!host) return;
|
|
406
|
+
|
|
407
|
+
this.__suspended = null;
|
|
408
|
+
|
|
409
|
+
// панель могло закрыть и движение мимо поля — тогда показывать нечего
|
|
410
|
+
if (host.editable.ownerDocument.activeElement === host.editable) this.attach(host);
|
|
339
411
|
}
|
|
340
412
|
|
|
341
413
|
private __toggleEmoji(initiator: HTMLButtonElement, e: MouseEvent) {
|
|
@@ -354,10 +426,12 @@ class FormatToolbar {
|
|
|
354
426
|
private __buildEmojiPicker(): HTMLElement {
|
|
355
427
|
const picker = DOM.tag("div", { class: `${POPUP_CLASS} ${EMOJI_PICKER_CLASS}` });
|
|
356
428
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
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));
|
|
361
435
|
|
|
362
436
|
// панель может висеть и вне тулбара, поэтому гасит фокус сама
|
|
363
437
|
picker.addEventListener("mousedown", (e) => e.preventDefault());
|