@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 CHANGED
@@ -27,12 +27,12 @@
27
27
  "email": "it@brandup.online"
28
28
  },
29
29
  "license": "Apache-2.0",
30
- "version": "1.0.39",
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.39"
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
- // ZWJ-последовательностей и модификаторов), поэтому переносятся в текст как единое целое.
3
- // Внимание: в UTF-16 каждый занимает две единицы, и getLength() (а значит и maxlength у
4
- // хоста) считает такой символ за два.
1
+ // Набор смайликов для панели вставки. Все символы — одиночные кодпойнты (без ZWJ-последовательностей
2
+ // и модификаторов), поэтому переносятся в текст как единое целое. Внимание: в UTF-16 каждый занимает
3
+ // две единицы, и getLength() (а значит и maxlength у хоста) считает такой символ за два.
4
+ //
5
+ // Разложены по группам: панель рисует их в том же порядке и отделяет линией, а заодно группа —
6
+ // это кусок, к которому применяется пропуск отрисовки (см. .emoji-group в richeditor.less).
5
7
 
6
- /** Смайлики, доступные в панели вставки (порядок сохраняется в UI). */
7
- export const EMOJIS: string[] = [
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
@@ -1,6 +1,6 @@
1
1
  export { default } from "./richeditor";
2
2
  export * from "./richeditor";
3
- export { EMOJIS } from "./emoji";
3
+ export { EMOJIS, EMOJI_GROUPS, type EmojiGroup } from "./emoji";
4
4
  export {
5
5
  ALL_EDITOR_ACTIONS,
6
6
  ALL_FORMAT_TOOLS,
@@ -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
- max-height: 220px;
159
- overflow-y: auto;
160
- padding: 4px;
161
- display: flex;
162
- flex-flow: row wrap;
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
- & .emoji {
165
- // в панели больше семисот кнопок: за пределами прокрутки браузер их не раскладывает
188
+ // Смысловая группа смайликов, отбитая линией. Она же — кусок, который браузер вправе не
189
+ // рисовать, пока до него не долистали: поэлементно это было бы семьсот отслеживаемых
190
+ // поддеревьев, и слежение за ними съедает весь выигрыш от пропуска.
191
+ & .emoji-group {
192
+ display: flex;
193
+ flex-flow: row wrap;
166
194
  content-visibility: auto;
167
- contain-intrinsic-size: 32px 32px;
168
- width: 32px;
169
- height: 32px;
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;
@@ -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.setAttribute("data-placeholder", options.placeholder);
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) formatToolbar.openEmoji(this, initiator, 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.removeAttribute("data-placeholder");
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
- insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);
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();
@@ -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 && range.intersectsNode(n)) yield n;
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 { EMOJIS } from "./emoji";
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
- private __hostButtons: Array<[ToolbarButton, HTMLButtonElement]> = [];
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
- this.__closeEmoji();
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 [button, btn] of this.__hostButtons) setDisabled(btn, button.isEnabled?.() === false);
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", "data-format-tool": tool, title: def.title },
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", "data-editor-action": action, title: def.title },
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", "data-toolbar-button": button.name, title: button.title },
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
- PopupManager.open(this.__ensureEmojiPicker(container), { initiator });
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
- const fragment = document.createDocumentFragment();
358
- for (const emoji of EMOJIS)
359
- fragment.appendChild(DOM.tag("button", { type: "button", class: "emoji", tabindex: "-1" }, emoji));
360
- picker.appendChild(fragment);
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());