@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.
- package/README.md +52 -2
- package/package.json +2 -2
- package/source/editing.ts +170 -13
- package/source/emoji.ts +127 -51
- package/source/format-config.ts +117 -3
- package/source/format.ts +18 -1
- package/source/index.ts +6 -1
- package/source/paragraphs.ts +79 -13
- package/source/richeditor.less +103 -10
- package/source/richeditor.ts +340 -18
- package/source/selection.ts +195 -29
- package/source/serialize.ts +479 -296
- package/source/toolbar.ts +172 -24
- package/svg/codeblock.svg +1 -0
- package/svg/mono.svg +3 -0
- package/svg/quote.svg +1 -0
- package/svg/spoiler.svg +1 -0
package/source/format-config.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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,
|
package/source/paragraphs.ts
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Что в содержимом редактора считается блоком. Единственное определение модели: по нему идут
|
|
9
|
+
* и разбор с сериализацией, и правки каретки, и нормализация. `<div>` признаём наравне с `<p>` —
|
|
10
|
+
* его приносят вставка и чужой contenteditable, а нормализация приводит к `<p>`.
|
|
10
11
|
*/
|
|
11
12
|
export function isBlock(node: Node): boolean {
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
for (const node of run)
|
|
112
|
-
root.insertBefore(
|
|
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(
|
|
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.
|
|
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;
|
package/source/richeditor.less
CHANGED
|
@@ -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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
227
|
+
padding: 6px;
|
|
228
|
+
// прокрутка живёт на списке внутри — см. .emoji-list
|
|
229
|
+
overflow: hidden;
|
|
230
|
+
// Курсор задаём свой: панель монтируется в разметку хоста и наследует его курсор — рядом
|
|
231
|
+
// с полем ввода это текстовый, и он же показывался бы над полосой прокрутки и фоном панели.
|
|
232
|
+
cursor: default;
|
|
163
233
|
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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;
|