@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 CHANGED
@@ -27,12 +27,12 @@
27
27
  "email": "it@brandup.online"
28
28
  },
29
29
  "license": "Apache-2.0",
30
- "version": "1.0.40",
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.40"
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
 
@@ -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.removeAttribute("data-placeholder");
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
- 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);
924
1058
 
925
1059
  // В пустом поле абзацев ещё нет, и каретке некуда встать внутрь — вставка из кода
926
1060
  // (смайлик, переменная) ложится прямо в редактор. Приводим к модели абзацев тем же
@@ -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,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
- private __hostButtons: Array<[ToolbarButton, HTMLButtonElement]> = [];
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
- this.__closeEmoji();
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 [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);
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", "data-format-tool": tool, title: def.title },
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", "data-editor-action": action, title: def.title },
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", "data-toolbar-button": button.name, title: button.title },
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
- const fragment = document.createDocumentFragment();
392
- for (const emoji of EMOJIS)
393
- fragment.appendChild(DOM.tag("button", { type: "button", class: "emoji", tabindex: "-1" }, emoji));
394
- 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));
395
435
 
396
436
  // панель может висеть и вне тулбара, поэтому гасит фокус сама
397
437
  picker.addEventListener("mousedown", (e) => e.preventDefault());