@brandup/ui-richeditor 1.0.40 → 1.0.42

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.
@@ -1,6 +1,6 @@
1
1
  // Конфигурация форматирования: типы, набор инструментов, markdown-маркеры и Ctrl/Cmd-хоткеи.
2
2
 
3
- export type FormatTool = "bold" | "italic" | "strike" | "underline";
3
+ export type FormatTool = "bold" | "italic" | "strike" | "underline" | "spoiler" | "code";
4
4
  export type FormatStorage = "html" | "markdown";
5
5
 
6
6
  /**
@@ -15,7 +15,85 @@ export type ParagraphMode = "block" | "break";
15
15
  /** Действие редактора (не формат): вставка смайлика, очистка форматирования, отмена и повтор. */
16
16
  export type EditorAction = "emoji" | "erase" | "undo" | "redo";
17
17
 
18
- export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline"];
18
+ /**
19
+ * Тип блока верхнего уровня. Обычный абзац — такой же тип, а не «тип не задан»: тогда
20
+ * и разбор, и печать, и переключение в панели работают одинаково для всех блоков.
21
+ */
22
+ export type BlockType = "paragraph" | "quote" | "code";
23
+
24
+ export const ALL_BLOCK_TYPES: BlockType[] = ["paragraph", "quote", "code"];
25
+
26
+ /** Куда попадает содержимое, не попавшее ни в какой блок: вставка, чужой `<div>`, блуждающий текст. */
27
+ export const DEFAULT_BLOCK: BlockType = "paragraph";
28
+
29
+ interface BlockDef {
30
+ /** Канонический тег при построении и сериализации. */
31
+ tag: string;
32
+ /** Теги, распознаваемые при разборе входного HTML. */
33
+ matchTags: string[];
34
+ /** Подсказка на кнопке панели. */
35
+ title: string;
36
+ /**
37
+ * Маркер в начале каждой строки блока (цитата). Подряд идущие строки с ним — один блок:
38
+ * так эту разметку понимают и мессенджеры.
39
+ */
40
+ linePrefix?: string;
41
+ /** Ограждение блока целиком (код): строка до и строка после содержимого. */
42
+ fence?: string;
43
+ /**
44
+ * Размечается ли содержимое инлайновыми инструментами. У кода нет: написанное в нём
45
+ * буквально таким и остаётся — как и внутри моноширинного.
46
+ */
47
+ inline: boolean;
48
+ /**
49
+ * Что делает Enter внутри: `paragraph` — заканчивает блок (хвост уходит в обычный текст),
50
+ * `break` — переносит строку. Модификатор делает обратное.
51
+ */
52
+ enter: "paragraph" | "break";
53
+ }
54
+
55
+ export const BLOCK_TYPES: Record<BlockType, BlockDef> = {
56
+ paragraph: {
57
+ tag: "p",
58
+ matchTags: ["P", "DIV"],
59
+ title: "Обычный текст",
60
+ inline: true,
61
+ enter: "paragraph",
62
+ },
63
+ quote: {
64
+ tag: "blockquote",
65
+ matchTags: ["BLOCKQUOTE"],
66
+ title: "Цитата",
67
+ linePrefix: "> ",
68
+ inline: true,
69
+ // Enter заканчивает цитату, строка внутри — Shift+Enter: выход нужен чаще, чем
70
+ // продолжение, а уйти из блока иначе можно только мышью.
71
+ enter: "paragraph",
72
+ },
73
+ code: {
74
+ tag: "pre",
75
+ matchTags: ["PRE"],
76
+ title: "Блок кода",
77
+ fence: "```",
78
+ inline: false,
79
+ enter: "paragraph",
80
+ },
81
+ };
82
+
83
+ // Тег → тип блока. Собирается один раз: спрашивается на каждый узел верхнего уровня и на каждую
84
+ // единицу обхода координат каретки.
85
+ const BLOCK_TAGS: Record<string, BlockType> = (() => {
86
+ const map: Record<string, BlockType> = {};
87
+ for (const type of ALL_BLOCK_TYPES) for (const tag of BLOCK_TYPES[type].matchTags) map[tag] = type;
88
+ return map;
89
+ })();
90
+
91
+ /** Тип блока по имени тега (как `tagName`, в верхнем регистре); null — тег не блочный. */
92
+ export function blockTypeOfTag(tagName: string): BlockType | null {
93
+ return BLOCK_TAGS[tagName] ?? null;
94
+ }
95
+
96
+ export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline", "spoiler", "code"];
19
97
 
20
98
  export const ALL_EDITOR_ACTIONS: EditorAction[] = ["emoji", "erase", "undo", "redo"];
21
99
 
@@ -61,6 +139,22 @@ export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
61
139
  hotkey: "u",
62
140
  title: "Подчёркнутый",
63
141
  },
142
+ spoiler: {
143
+ // Своего элемента для скрытого текста в HTML нет. Берём собственный тег, а разбирать
144
+ // умеем и телеграмный: значения приходят и из его разметки.
145
+ tag: "spoiler",
146
+ matchTags: ["SPOILER", "TG-SPOILER"],
147
+ md: "||",
148
+ hotkey: "",
149
+ title: "Спойлер",
150
+ },
151
+ code: {
152
+ tag: "code",
153
+ matchTags: ["CODE"],
154
+ md: "`",
155
+ hotkey: "",
156
+ title: "Моноширинный",
157
+ },
64
158
  };
65
159
 
66
160
  interface EditorActionDef {
@@ -78,7 +172,7 @@ export const EDITOR_ACTIONS: Record<EditorAction, EditorActionDef> = {
78
172
  /** Markdown-маркер для каждого инструмента форматирования. */
79
173
  export type FormatMarkers = Record<FormatTool, string>;
80
174
 
81
- /** Маркеры по умолчанию (из FORMAT_TOOLS): bold=**, italic=*, strike=~~, underline=++. */
175
+ /** Маркеры по умолчанию (из FORMAT_TOOLS): bold=**, italic=_, strike=~, underline=__, spoiler=||, code=`. */
82
176
  export function defaultFormatMarkers(): FormatMarkers {
83
177
  const markers = {} as FormatMarkers;
84
178
  for (const tool of ALL_FORMAT_TOOLS) markers[tool] = FORMAT_TOOLS[tool].md;
@@ -114,3 +208,23 @@ export function parseFormatTools(value: string | null): FormatTool[] {
114
208
  export function parseEditorActions(value: string | null): EditorAction[] {
115
209
  return value === null ? [] : parseList(value, ALL_EDITOR_ACTIONS);
116
210
  }
211
+
212
+ /**
213
+ * Разбирает значение атрибута data-blocks. Блоки подключаются явно: без атрибута доступен
214
+ * только обычный текст. Он в наборе есть всегда — иначе блок некуда было бы вернуть.
215
+ */
216
+ export function parseBlockTypes(value: string | null): BlockType[] {
217
+ return value === null ? [DEFAULT_BLOCK] : normalizeBlockTypes(parseList(value, ALL_BLOCK_TYPES));
218
+ }
219
+
220
+ /**
221
+ * Приводит набор типов к порядку объявления. Обычный текст в наборе есть всегда: им становится
222
+ * содержимое, не попавшее ни в какой блок, и в него же блок возвращают.
223
+ */
224
+ export function normalizeBlockTypes(types: BlockType[] | undefined): BlockType[] {
225
+ if (!types) return [DEFAULT_BLOCK];
226
+
227
+ const list = ALL_BLOCK_TYPES.filter((type) => types.includes(type));
228
+
229
+ return list.includes(DEFAULT_BLOCK) ? list : [DEFAULT_BLOCK, ...list];
230
+ }
package/source/format.ts CHANGED
@@ -5,14 +5,20 @@
5
5
  // paragraphs — нормализация пробелов и приведение к абзацам <p>
6
6
 
7
7
  export {
8
+ ALL_BLOCK_TYPES,
8
9
  ALL_EDITOR_ACTIONS,
9
10
  ALL_FORMAT_TOOLS,
11
+ BLOCK_TYPES,
12
+ DEFAULT_BLOCK,
10
13
  EDITOR_ACTIONS,
11
14
  FORMAT_TOOLS,
12
15
  HOTKEY_TOOLS,
13
16
  defaultFormatMarkers,
17
+ normalizeBlockTypes,
18
+ parseBlockTypes,
14
19
  parseEditorActions,
15
20
  parseFormatTools,
21
+ type BlockType,
16
22
  type EditorAction,
17
23
  type FormatMarkers,
18
24
  type FormatStorage,
@@ -25,6 +31,8 @@ export {
25
31
  innerSelection,
26
32
  preserveCaret,
27
33
  selectionCharBounds,
34
+ editorText,
35
+ charLength,
28
36
  restoreSelection,
29
37
  mapCharOffset,
30
38
  activeFormats,
@@ -36,4 +44,13 @@ export {
36
44
  insertFormattedText,
37
45
  isFormatActive,
38
46
  } from "./selection";
39
- export { isBlock, normalizeWhitespace, normalizeParagraphs, ensureParagraphs } from "./paragraphs";
47
+ export {
48
+ isBlock,
49
+ blockAt,
50
+ blockTypeOf,
51
+ blocksInRange,
52
+ createBlock,
53
+ normalizeWhitespace,
54
+ normalizeParagraphs,
55
+ ensureParagraphs,
56
+ } from "./paragraphs";
package/source/index.ts CHANGED
@@ -1,11 +1,15 @@
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
+ ALL_BLOCK_TYPES,
5
6
  ALL_EDITOR_ACTIONS,
6
7
  ALL_FORMAT_TOOLS,
8
+ BLOCK_TYPES,
9
+ DEFAULT_BLOCK,
7
10
  EDITOR_ACTIONS,
8
11
  FORMAT_TOOLS,
12
+ parseBlockTypes,
9
13
  parseEditorActions,
10
14
  parseFormatTools,
11
15
  defaultFormatMarkers,
@@ -13,6 +17,7 @@ export {
13
17
  selectionCharBounds,
14
18
  restoreSelection,
15
19
  preserveCaret,
20
+ type BlockType,
16
21
  type EditorAction,
17
22
  type FormatTool,
18
23
  type FormatStorage,
@@ -2,17 +2,77 @@
2
2
  // верхнего уровня к абзацам <p> (модель многострочного режима).
3
3
 
4
4
  import { cleanupFormatting } from "./selection";
5
+ import { BLOCK_TYPES, DEFAULT_BLOCK, blockTypeOfTag, type BlockType } from "./format-config";
5
6
 
6
7
  /**
7
- * Что в содержимом редактора считается абзацем. Единственное определение модели абзацев:
8
- * по нему идут и разбор с сериализацией, и правки каретки, и нормализация. `<div>` признаём
9
- * наравне с `<p>` — его приносят вставка и чужой contenteditable, а нормализация приводит к `<p>`.
8
+ * Что в содержимом редактора считается блоком. Единственное определение модели: по нему идут
9
+ * и разбор с сериализацией, и правки каретки, и нормализация. `<div>` признаём наравне с `<p>` —
10
+ * его приносят вставка и чужой contenteditable, а нормализация приводит к `<p>`.
10
11
  */
11
12
  export function isBlock(node: Node): boolean {
12
- if (node.nodeType !== Node.ELEMENT_NODE) return false;
13
+ return blockTypeOf(node) !== null;
14
+ }
15
+
16
+ /** Тип блока по элементу; null — не блок верхнего уровня. */
17
+ export function blockTypeOf(node: Node | null | undefined): BlockType | null {
18
+ if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
19
+
20
+ return blockTypeOfTag((node as Element).tagName);
21
+ }
22
+
23
+ /** Блок, в котором лежит узел (обычно якорь выделения), в пределах редактора. */
24
+ export function blockAt(root: HTMLElement, node: Node | null | undefined): HTMLElement | null {
25
+ let current: Node | null = node ?? null;
26
+ while (current && current !== root) {
27
+ if (current.parentNode === root && isBlock(current)) return current as HTMLElement;
28
+ current = current.parentNode;
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Блоки, которых касается диапазон, по порядку. Границы берём по их блокам, а не перебором
36
+ * с проверкой пересечения: свёрнутая каретка на стыке пересекает сразу два соседних блока,
37
+ * и правка уходила бы в лишний.
38
+ */
39
+ export function blocksInRange(root: HTMLElement, range: Range): HTMLElement[] {
40
+ // Контейнером может быть и сам редактор — тогда позиция адресует ребёнка по номеру.
41
+ // Позиция — это место ПЕРЕД ребёнком, поэтому конец выделения относится к предыдущему
42
+ // блоку: иначе правка захватывала бы блок, из которого не выделено ни символа.
43
+ const edge = (container: Node, offset: number, end: boolean): HTMLElement | null => {
44
+ const own = blockAt(root, container);
45
+ if (own) return own;
46
+ if (container !== root) return null;
47
+
48
+ const index = end && offset > 0 ? offset - 1 : offset;
49
+ const child = root.childNodes[index] ?? root.lastChild;
50
+
51
+ return blockAt(root, child) ?? (isBlock(child as Node) ? (child as HTMLElement) : null);
52
+ };
53
+
54
+ const first = edge(range.startContainer, range.startOffset, false);
55
+ if (!first) return [];
56
+
57
+ const last = edge(range.endContainer, range.endOffset, !range.collapsed) ?? first;
58
+
59
+ const blocks: HTMLElement[] = [];
60
+ for (let el: HTMLElement | null = first; el; el = el.nextElementSibling as HTMLElement | null) {
61
+ if (isBlock(el)) blocks.push(el);
62
+ if (el === last) return blocks;
63
+ }
64
+
65
+ // Конца не встретили — он лежит раньше начала (диапазон собран вручную и вывернут).
66
+ // Берём один блок: пройтись до конца содержимого значило бы править всё подряд.
67
+ return [first];
68
+ }
69
+
70
+ /** Пустой блок нужного типа с заполнителем: без него строка не видна и в неё не встать кареткой. */
71
+ export function createBlock(type: BlockType): HTMLElement {
72
+ const block = document.createElement(BLOCK_TYPES[type].tag);
73
+ block.appendChild(document.createElement("br"));
13
74
 
14
- const tag = (node as Element).tagName;
15
- return tag === "P" || tag === "DIV";
75
+ return block;
16
76
  }
17
77
 
18
78
  // Пробел, таб и неразрывный пробел (U+00A0) — всё это «пробел» при наборе; см. normalizeWhitespace.
@@ -48,8 +108,10 @@ export function normalizeWhitespace(root: HTMLElement) {
48
108
  if (el.tagName === "BR") {
49
109
  items.push({ kind: "break" });
50
110
  } else if (isBlock(el)) {
111
+ // у блока без инлайновой разметки (код) пробелы значимы: отступы там — часть текста
112
+ const type = blockTypeOf(el);
51
113
  items.push({ kind: "break" });
52
- flatten(el);
114
+ if (!type || BLOCK_TYPES[type].inline) flatten(el);
53
115
  items.push({ kind: "break" });
54
116
  } else {
55
117
  flatten(el); // инлайновый тег — не разрывает строку
@@ -94,7 +156,9 @@ export function normalizeWhitespace(root: HTMLElement) {
94
156
  */
95
157
  export function normalizeParagraphs(root: HTMLElement) {
96
158
  for (const el of Array.from(root.children)) {
97
- if (el.tagName === "P" && (el.textContent ?? "").trim() === "") el.remove();
159
+ // Пустой блок другого типа не трогаем: его завели осознанно и в него сейчас будут писать,
160
+ // а пустая строка внутри кода вообще осмысленна сама по себе.
161
+ if (blockTypeOf(el) === DEFAULT_BLOCK && (el.textContent ?? "").trim() === "") el.remove();
98
162
  }
99
163
  }
100
164
 
@@ -107,9 +171,9 @@ export function ensureParagraphs(root: HTMLElement) {
107
171
 
108
172
  const flushRun = (before: Node | null) => {
109
173
  if (!run.length) return;
110
- const p = document.createElement("p");
111
- for (const node of run) p.appendChild(node);
112
- root.insertBefore(p, before);
174
+ const block = document.createElement(BLOCK_TYPES[DEFAULT_BLOCK].tag);
175
+ for (const node of run) block.appendChild(node);
176
+ root.insertBefore(block, before);
113
177
  run = [];
114
178
  };
115
179
 
@@ -118,8 +182,10 @@ export function ensureParagraphs(root: HTMLElement) {
118
182
 
119
183
  if (el && isBlock(el)) {
120
184
  flushRun(node);
185
+ // `<div>` — тот же абзац, только чужой: приводим к каноническому тегу.
186
+ // Блоки других типов оставляем как есть, их завели осознанно.
121
187
  if (el.tagName === "DIV") {
122
- const p = document.createElement("p");
188
+ const p = document.createElement(BLOCK_TYPES[DEFAULT_BLOCK].tag);
123
189
  while (el.firstChild) p.appendChild(el.firstChild);
124
190
  root.replaceChild(p, el);
125
191
  }
@@ -129,7 +195,7 @@ export function ensureParagraphs(root: HTMLElement) {
129
195
  }
130
196
  flushRun(null);
131
197
 
132
- for (const p of Array.from(root.querySelectorAll("p"))) {
198
+ for (const p of Array.from(root.children) as HTMLElement[]) {
133
199
  if (!p.firstChild) {
134
200
  p.appendChild(document.createElement("br")); // пустой абзац — заполнитель для видимости строки
135
201
  continue;
@@ -25,6 +25,36 @@
25
25
  }
26
26
  }
27
27
 
28
+ // Цитата: линия слева, как её рисуют мессенджеры. Вертикальные поля те же, что у абзаца, —
29
+ // строка не должна прыгать при смене типа блока.
30
+ & blockquote {
31
+ margin: 0;
32
+ padding: 5px 0 5px 10px;
33
+ border-left: 3px solid var(--richeditor-quote-line, rgba(0, 0, 0, 0.2));
34
+ }
35
+
36
+ // Блок кода: подложка на всю строку и моноширинный шрифт. Собственные поля тут нужны —
37
+ // иначе текст упирался бы в край подложки; краевые правила абзаца на него не распространяются.
38
+ & pre {
39
+ margin: 5px 0;
40
+ padding: 5px 8px;
41
+ border-radius: 3px;
42
+ background-color: var(--richeditor-code-fill, rgba(0, 0, 0, 0.06));
43
+ font-family: var(--richeditor-code-font, ui-monospace, "Cascadia Mono", Consolas, monospace);
44
+ font-size: 0.92em;
45
+ // содержимое переносится по границе редактора, как и обычный текст
46
+ white-space: pre-wrap;
47
+ word-wrap: anywhere;
48
+
49
+ &:first-child {
50
+ margin-top: 0;
51
+ }
52
+
53
+ &:last-child {
54
+ margin-bottom: 0;
55
+ }
56
+ }
57
+
28
58
  // видимые теги форматирования
29
59
  & b,
30
60
  & strong {
@@ -44,6 +74,29 @@
44
74
  text-decoration: underline;
45
75
  }
46
76
 
77
+ // Спойлер: у получателя текст закрыт до нажатия, а в поле его прячут не от автора —
78
+ // показываем плашкой, чтобы было видно и что написано, и что оно будет скрыто.
79
+ & spoiler,
80
+ & tg-spoiler {
81
+ border-radius: 3px;
82
+ padding: 0 2px;
83
+ background-color: var(--richeditor-spoiler-fill, rgba(0, 0, 0, 0.14));
84
+ }
85
+
86
+ // Строку не раздвигает. Наследовать межстрочный интервал для этого мало: у моноширинного
87
+ // шрифта своя база, и коробка той же высоты садится относительно неё со сдвигом — строка
88
+ // вырастает на пару пикселей. Нулевой интервал убирает вклад кода в высоту строки совсем,
89
+ // а высоту строки задаёт сам абзац. Подложка от этого не страдает: она рисуется по буквам,
90
+ // а не по интервалу. По вертикали отступов у кода нет.
91
+ & code {
92
+ border-radius: 3px;
93
+ padding: 0 3px;
94
+ background-color: var(--richeditor-code-fill, rgba(0, 0, 0, 0.06));
95
+ font-family: var(--richeditor-code-font, ui-monospace, "Cascadia Mono", Consolas, monospace);
96
+ font-size: 0.92em;
97
+ line-height: 0;
98
+ }
99
+
47
100
  &:empty:after {
48
101
  content: attr(data-placeholder);
49
102
  display: block;
@@ -65,6 +118,19 @@
65
118
  white-space: normal;
66
119
  text-overflow: unset;
67
120
  }
121
+
122
+ // По странице тянут выделение, начатое вне редактора: пока это так, его содержимое
123
+ // невыделяемо — иначе текст сообщения попал бы в выделение страницы как её часть.
124
+ // Класс держится только на время протяжки (см. __holdSelectable).
125
+ //
126
+ // Одного user-select мало: редактируемому содержимому браузер принудительно возвращает
127
+ // выделяемость, поэтому на это же время снимаем и саму редактируемость. Атрибут
128
+ // contenteditable не трогаем — он держит состояние редактора, а правится лишь оформление.
129
+ &.unselectable {
130
+ -webkit-user-modify: read-only;
131
+ -webkit-user-select: none;
132
+ user-select: none;
133
+ }
68
134
  }
69
135
 
70
136
  // общий тулбар форматирования — единый для всех редакторов.
@@ -82,6 +148,8 @@
82
148
  border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
83
149
  border-radius: var(--input-border-radius, 0);
84
150
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
151
+ // панель тоже монтируется в разметку хоста — курсор поля ввода ей не наследовать
152
+ cursor: default;
85
153
 
86
154
  --svg-size: 18px;
87
155
  --svg-fill: var(--input-color, #222);
@@ -107,6 +175,7 @@
107
175
  }
108
176
 
109
177
  & .format-button,
178
+ & .block-button,
110
179
  & .host-button,
111
180
  & .action-button {
112
181
  width: calc(var(--input-height, 46px) - 12px);
@@ -155,18 +224,42 @@
155
224
  left: 0;
156
225
  margin-top: 4px;
157
226
  width: 296px;
158
- max-height: 220px;
159
- overflow-y: auto;
160
- padding: 4px;
161
- display: flex;
162
- flex-flow: row wrap;
227
+ padding: 6px;
228
+ // прокрутка живёт на списке внутри — см. .emoji-list
229
+ overflow: hidden;
230
+ // Курсор задаём свой: панель монтируется в разметку хоста и наследует его курсор — рядом
231
+ // с полем ввода это текстовый, и он же показывался бы над полосой прокрутки и фоном панели.
232
+ cursor: default;
163
233
 
164
- & .emoji {
165
- // в панели больше семисот кнопок: за пределами прокрутки браузер их не раскладывает
234
+ // Прокручивается список, а не сам попап: полоса рисуется по краю коробки и перекрывала бы
235
+ // скругление рамки угол выглядел бы срезанным. Заодно полоса отступает от краёв на
236
+ // внутренний отступ попапа — сверху, снизу и справа, — а не упирается в рамку.
237
+ & .emoji-list {
238
+ max-height: 220px;
239
+ overflow-y: auto; // полоса прокрутки — общая, от .ui-scrollable кита
240
+ }
241
+
242
+ // Смысловая группа смайликов, отбитая линией. Она же — кусок, который браузер вправе не
243
+ // рисовать, пока до него не долистали: поэлементно это было бы семьсот отслеживаемых
244
+ // поддеревьев, и слежение за ними съедает весь выигрыш от пропуска.
245
+ & .emoji-group {
246
+ display: flex;
247
+ flex-flow: row wrap;
166
248
  content-visibility: auto;
167
- contain-intrinsic-size: 32px 32px;
168
- width: 32px;
169
- height: 32px;
249
+ // высота ненарисованной группы; auto — дальше браузер помнит фактическую
250
+ contain-intrinsic-size: auto calc(var(--emoji-rows, 8) * var(--richeditor-emoji-size, 32px));
251
+ }
252
+
253
+ & .emoji-group + .emoji-group {
254
+ margin-top: 6px;
255
+ padding-top: 6px;
256
+ border-top: var(--input-border-type, solid) var(--input-border-width, 1px)
257
+ var(--input-border-color, #aaa);
258
+ }
259
+
260
+ & .emoji {
261
+ width: var(--richeditor-emoji-size, 32px);
262
+ height: var(--richeditor-emoji-size, 32px);
170
263
  padding: 0;
171
264
  border: 0;
172
265
  background: transparent;