@brandup/ui-textbox 1.0.28 → 1.0.34
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 +81 -1
- package/package.json +5 -5
- package/source/format.ts +546 -0
- package/source/index.ts +11 -0
- package/source/textbox.less +81 -0
- package/source/textbox.ts +331 -15
- package/svg/bold.svg +3 -0
- package/svg/italic.svg +3 -0
- package/svg/strike.svg +5 -0
- package/svg/underline.svg +4 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://dev.azure.com/brandup/BrandUp%20Core/_build/latest?definitionId=81&branchName=main)
|
|
4
4
|
|
|
5
|
-
Компонент текстового поля на базе `contenteditable`. Заменяет стандартные `<input>` и `<textarea>`, добавляя расширенное поведение: счётчик символов, кнопку копирования, фильтрацию ввода по
|
|
5
|
+
Компонент текстового поля на базе `contenteditable`. Заменяет стандартные `<input>` и `<textarea>`, добавляя расширенное поведение: счётчик символов, кнопку копирования, фильтрацию ввода по типу, валидацию и опциональное форматирование текста (жирный, курсив, зачёркивание, подчёркивание).
|
|
6
6
|
|
|
7
7
|
## Установка
|
|
8
8
|
|
|
@@ -51,6 +51,72 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
51
51
|
| `data-copy-button` | Добавляет кнопку копирования значения в буфер обмена |
|
|
52
52
|
| `data-allow-empty-strings` | Разрешает значение из одних пробелов |
|
|
53
53
|
| `data-readonly` | Альтернативный способ задать режим только для чтения |
|
|
54
|
+
| `data-format` | Включает форматирование текста (только для `type="text"`) |
|
|
55
|
+
| `data-format-tools` | Состав инструментов форматирования через пробел (по умолчанию все): `bold italic strike underline` |
|
|
56
|
+
| `data-format-storage` | Формат хранения значения: `html` (по умолчанию) или `markdown` |
|
|
57
|
+
| `data-format-md-bold` | Markdown-маркер для жирного (по умолчанию `**`) |
|
|
58
|
+
| `data-format-md-italic` | Markdown-маркер для курсива (по умолчанию `*`) |
|
|
59
|
+
| `data-format-md-strike` | Markdown-маркер для зачёркивания (по умолчанию `~~`) |
|
|
60
|
+
| `data-format-md-underline` | Markdown-маркер для подчёркивания (по умолчанию `++`) |
|
|
61
|
+
|
|
62
|
+
## Форматирование текста
|
|
63
|
+
|
|
64
|
+
Атрибут `data-format` включает панель форматирования с инструментами: жирный, курсив, зачёркивание, подчёркивание. Панель всплывает над контролом, пока он в фокусе (класс состояния `focused`). Работает поверх существующего `contenteditable`-редактора, поэтому доступно только для текстового ввода (`type="text"` и `<textarea>`).
|
|
65
|
+
|
|
66
|
+
```html
|
|
67
|
+
<!-- все инструменты, хранение в HTML -->
|
|
68
|
+
<textarea data-content-script="textbox" data-format></textarea>
|
|
69
|
+
|
|
70
|
+
<!-- только жирный и курсив, хранение в Markdown -->
|
|
71
|
+
<input
|
|
72
|
+
type="text"
|
|
73
|
+
data-content-script="textbox"
|
|
74
|
+
data-format
|
|
75
|
+
data-format-tools="bold italic"
|
|
76
|
+
data-format-storage="markdown"
|
|
77
|
+
/>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Состав инструментов — `data-format-tools`
|
|
81
|
+
|
|
82
|
+
Список через пробел из значений `bold`, `italic`, `strike`, `underline`. Неизвестные значения игнорируются. Если атрибут не задан — включаются все инструменты.
|
|
83
|
+
|
|
84
|
+
### Применение формата
|
|
85
|
+
|
|
86
|
+
- Формат — **переключатель** (toggle): повторное применение к уже отформатированному тексту снимает его.
|
|
87
|
+
- Форматирование применяется **к слову целиком**: если курсор стоит внутри слова без выделения или выделена лишь его часть — формат охватывает всё слово. При выделении части нескольких слов каждая граница доводится до целого слова. Исходное выделение/каретка после применения сохраняются (слово не выделяется автоматически).
|
|
88
|
+
- **Режим набора**: если под кареткой нет слова (курсор между пробелами или поле пустое), кнопка/хоткей не форматируют текст, а включают «ожидающий» формат — он подсветится активным и применится к следующему введённому тексту. Режим сбрасывается при перемещении каретки, клике или потере фокуса.
|
|
89
|
+
- Кнопка инструмента подсвечивается (`active`), когда выделение целиком отформатировано этим инструментом или активен режим набора.
|
|
90
|
+
- Реализация работает на стандартном Selection/Range API — без устаревшего `document.execCommand`, поэтому разметка всегда семантическая (`<b>`, `<i>`, `<s>`, `<u>`) и предсказуема между браузерами.
|
|
91
|
+
|
|
92
|
+
### Хоткеи
|
|
93
|
+
|
|
94
|
+
`Ctrl/Cmd+B` — жирный, `Ctrl/Cmd+I` — курсив, `Ctrl/Cmd+U` — подчёркивание. Зачёркивание включается только кнопкой. Хоткеи отключённых инструментов перехватываются, чтобы не срабатывало форматирование браузера.
|
|
95
|
+
|
|
96
|
+
### Формат хранения — `data-format-storage`
|
|
97
|
+
|
|
98
|
+
Значение синхронизируется в скрытое поле в выбранном формате:
|
|
99
|
+
|
|
100
|
+
| Значение | Хранение | Поддерживаемые теги/маркеры |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `html` (по умолчанию) | Санитизированный HTML | `<b>`, `<i>`, `<s>`, `<u>`, переводы строк через `<br>` |
|
|
103
|
+
| `markdown` | Лёгкая разметка | `**жирный**`, `*курсив*`, `~~зачёркнутый~~`, `++подчёркнутый++` |
|
|
104
|
+
|
|
105
|
+
Маркеры для каждого инструмента настраиваются атрибутами `data-format-md-<tool>` (актуально только при `data-format-storage="markdown"`):
|
|
106
|
+
|
|
107
|
+
```html
|
|
108
|
+
<textarea
|
|
109
|
+
data-content-script="textbox"
|
|
110
|
+
data-format
|
|
111
|
+
data-format-storage="markdown"
|
|
112
|
+
data-format-md-italic="_"
|
|
113
|
+
data-format-md-bold="__"
|
|
114
|
+
></textarea>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
При разборе маркеры применяются по убыванию длины, поэтому более длинный маркер (`__`) корректно срабатывает раньше короткого-префикса (`_`). Маркеры разных инструментов должны различаться.
|
|
118
|
+
|
|
119
|
+
> Markdown-режим не имеет стандартного маркера для подчёркивания — по умолчанию используется `++текст++`. Глубоко вложенные комбинации форматов (например, жирный внутри курсива) гарантированно сохраняются только в режиме `html`.
|
|
54
120
|
|
|
55
121
|
## API
|
|
56
122
|
|
|
@@ -75,6 +141,10 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
75
141
|
| `copyButton` | `boolean` | Наличие кнопки копирования |
|
|
76
142
|
| `symbolCounter` | `boolean` | Наличие счётчика символов |
|
|
77
143
|
| `placeholder` | `string \| null` | Текст-заглушка |
|
|
144
|
+
| `format` | `boolean` | Включено ли форматирование |
|
|
145
|
+
| `formatStorage` | `FormatStorage` | Формат хранения: `"html"` \| `"markdown"` |
|
|
146
|
+
| `formatTools` | `FormatTool[]` | Включённые инструменты форматирования |
|
|
147
|
+
| `formatMarkers` | `FormatMarkers` | Markdown-маркеры по инструментам (с учётом переопределений) |
|
|
78
148
|
|
|
79
149
|
### Событие textbox-change
|
|
80
150
|
|
|
@@ -92,6 +162,15 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
92
162
|
textbox.onChange((data) => { ... });
|
|
93
163
|
```
|
|
94
164
|
|
|
165
|
+
## Нормализация пробелов
|
|
166
|
+
|
|
167
|
+
Когда редактирование логически завершено (поле теряет фокус), а также после инициализации и `setValue`, текст нормализуется:
|
|
168
|
+
|
|
169
|
+
- повторяющиеся пробелы/табы схлопываются в один;
|
|
170
|
+
- пробелы по краям каждой строки обрезаются.
|
|
171
|
+
|
|
172
|
+
Форматирование и переносы строк (`<br>`/блоки) при этом сохраняются. Во время набора текста пробелы не трогаются — нормализация выполняется только по завершении. Если нормализация изменила значение, при потере фокуса генерируется событие [`textbox-change`](#событие-textbox-change).
|
|
173
|
+
|
|
95
174
|
## CSS-классы состояний
|
|
96
175
|
|
|
97
176
|
| Класс | Условие |
|
|
@@ -102,3 +181,4 @@ textbox.onChange((data) => { ... });
|
|
|
102
181
|
| `required` | Атрибут `required` задан |
|
|
103
182
|
| `readonly` | Режим только для чтения |
|
|
104
183
|
| `disabled` | Элемент отключён |
|
|
184
|
+
| `active` | На кнопке панели форматирования — формат активен для текущего выделения |
|
package/package.json
CHANGED
|
@@ -21,14 +21,14 @@
|
|
|
21
21
|
"email": "it@brandup.online"
|
|
22
22
|
},
|
|
23
23
|
"license": "Apache-2.0",
|
|
24
|
-
"version": "1.0.
|
|
24
|
+
"version": "1.0.34",
|
|
25
25
|
"main": "source/index.ts",
|
|
26
26
|
"types": "source/index.ts",
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@brandup/ui": "^2.0.
|
|
29
|
-
"@brandup/ui-helpers": "^2.0.
|
|
30
|
-
"@brandup/ui-input": "^1.0.
|
|
31
|
-
"@brandup/ui-kit": "^1.0.
|
|
28
|
+
"@brandup/ui": "^2.0.3",
|
|
29
|
+
"@brandup/ui-helpers": "^2.0.3",
|
|
30
|
+
"@brandup/ui-input": "^1.0.34",
|
|
31
|
+
"@brandup/ui-kit": "^1.0.34"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"source",
|
package/source/format.ts
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
// Опциональное форматирование текста (жирный, курсив, зачёркивание, подчёркивание)
|
|
2
|
+
// для TextBox. Включается атрибутом data-format, состав — data-format-tools,
|
|
3
|
+
// формат хранения значения — data-format-storage ("html" | "markdown").
|
|
4
|
+
|
|
5
|
+
export type FormatTool = "bold" | "italic" | "strike" | "underline";
|
|
6
|
+
export type FormatStorage = "html" | "markdown";
|
|
7
|
+
|
|
8
|
+
export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline"];
|
|
9
|
+
|
|
10
|
+
interface FormatToolDef {
|
|
11
|
+
/** Имя команды (атрибут command у кнопки и registerCommand). */
|
|
12
|
+
command: string;
|
|
13
|
+
/** Канонический тег при оборачивании и сериализации. */
|
|
14
|
+
tag: string;
|
|
15
|
+
/** Теги, распознаваемые при разборе входного HTML. */
|
|
16
|
+
matchTags: string[];
|
|
17
|
+
/** Маркер в Markdown. */
|
|
18
|
+
md: string;
|
|
19
|
+
/** Клавиша для Ctrl/Cmd-хоткея (пусто — без хоткея). */
|
|
20
|
+
hotkey: string;
|
|
21
|
+
/** Подсказка на кнопке. */
|
|
22
|
+
title: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
|
|
26
|
+
bold: {
|
|
27
|
+
command: "format-bold",
|
|
28
|
+
tag: "b",
|
|
29
|
+
matchTags: ["B", "STRONG"],
|
|
30
|
+
md: "**",
|
|
31
|
+
hotkey: "b",
|
|
32
|
+
title: "Жирный",
|
|
33
|
+
},
|
|
34
|
+
italic: {
|
|
35
|
+
command: "format-italic",
|
|
36
|
+
tag: "i",
|
|
37
|
+
matchTags: ["I", "EM"],
|
|
38
|
+
md: "*",
|
|
39
|
+
hotkey: "i",
|
|
40
|
+
title: "Курсив",
|
|
41
|
+
},
|
|
42
|
+
strike: {
|
|
43
|
+
command: "format-strike",
|
|
44
|
+
tag: "s",
|
|
45
|
+
matchTags: ["S", "STRIKE", "DEL"],
|
|
46
|
+
md: "~~",
|
|
47
|
+
hotkey: "",
|
|
48
|
+
title: "Зачёркнутый",
|
|
49
|
+
},
|
|
50
|
+
underline: {
|
|
51
|
+
command: "format-underline",
|
|
52
|
+
tag: "u",
|
|
53
|
+
matchTags: ["U", "INS"],
|
|
54
|
+
md: "++",
|
|
55
|
+
hotkey: "u",
|
|
56
|
+
title: "Подчёркнутый",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Канонические теги форматирования (в верхнем регистре, как tagName). */
|
|
61
|
+
const FORMAT_TAG_NAMES = ALL_FORMAT_TOOLS.map((t) => FORMAT_TOOLS[t].tag.toUpperCase());
|
|
62
|
+
|
|
63
|
+
/** Markdown-маркер для каждого инструмента форматирования. */
|
|
64
|
+
export type FormatMarkers = Record<FormatTool, string>;
|
|
65
|
+
|
|
66
|
+
/** Маркеры по умолчанию (из FORMAT_TOOLS): bold=**, italic=*, strike=~~, underline=++. */
|
|
67
|
+
export function defaultFormatMarkers(): FormatMarkers {
|
|
68
|
+
const markers = {} as FormatMarkers;
|
|
69
|
+
for (const tool of ALL_FORMAT_TOOLS) markers[tool] = FORMAT_TOOLS[tool].md;
|
|
70
|
+
return markers;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Карта Ctrl/Cmd-хоткеев: клавиша → инструмент. */
|
|
74
|
+
export const HOTKEY_TOOLS: Record<string, FormatTool> = (() => {
|
|
75
|
+
const map: Record<string, FormatTool> = {};
|
|
76
|
+
for (const tool of ALL_FORMAT_TOOLS) {
|
|
77
|
+
const hotkey = FORMAT_TOOLS[tool].hotkey;
|
|
78
|
+
if (hotkey) map[hotkey] = tool;
|
|
79
|
+
}
|
|
80
|
+
return map;
|
|
81
|
+
})();
|
|
82
|
+
|
|
83
|
+
/** Разбирает значение атрибута data-format-tools, оставляя только известные инструменты. */
|
|
84
|
+
export function parseFormatTools(value: string | null): FormatTool[] {
|
|
85
|
+
if (value === null) return ALL_FORMAT_TOOLS.slice();
|
|
86
|
+
|
|
87
|
+
const tools = value
|
|
88
|
+
.split(/\s+/)
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.filter((t): t is FormatTool => (ALL_FORMAT_TOOLS as string[]).includes(t));
|
|
91
|
+
|
|
92
|
+
// убираем дубли, сохраняя порядок объявления
|
|
93
|
+
return ALL_FORMAT_TOOLS.filter((t) => tools.includes(t));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function escapeHtml(text: string): string {
|
|
97
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function escapeRegExp(text: string): string {
|
|
101
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Тег → инструмент, только для включённых инструментов. */
|
|
105
|
+
function buildTagMap(tools: FormatTool[]): Record<string, FormatTool> {
|
|
106
|
+
const map: Record<string, FormatTool> = {};
|
|
107
|
+
for (const tool of tools) for (const tag of FORMAT_TOOLS[tool].matchTags) map[tag] = tool;
|
|
108
|
+
return map;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function lineBreak(storage: FormatStorage): string {
|
|
112
|
+
return storage === "html" ? "<br>" : "\n";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function wrap(storage: FormatStorage, tool: FormatTool, inner: string, markers: FormatMarkers): string {
|
|
116
|
+
if (!inner) return inner;
|
|
117
|
+
|
|
118
|
+
const def = FORMAT_TOOLS[tool];
|
|
119
|
+
if (storage === "html") return `<${def.tag}>${inner}</${def.tag}>`;
|
|
120
|
+
|
|
121
|
+
const marker = markers[tool];
|
|
122
|
+
return `${marker}${inner}${marker}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function serializeNodes(
|
|
126
|
+
nodes: ArrayLike<ChildNode>,
|
|
127
|
+
storage: FormatStorage,
|
|
128
|
+
tagMap: Record<string, FormatTool>,
|
|
129
|
+
markers: FormatMarkers
|
|
130
|
+
): string {
|
|
131
|
+
let result = "";
|
|
132
|
+
const list = Array.from(nodes);
|
|
133
|
+
|
|
134
|
+
for (const node of list) {
|
|
135
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
136
|
+
const text = node.textContent ?? "";
|
|
137
|
+
result += storage === "html" ? escapeHtml(text) : text;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
142
|
+
|
|
143
|
+
const el = node as HTMLElement;
|
|
144
|
+
const tag = el.tagName;
|
|
145
|
+
|
|
146
|
+
if (tag === "BR") {
|
|
147
|
+
result += lineBreak(storage);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const inner = serializeNodes(el.childNodes, storage, tagMap, markers);
|
|
152
|
+
|
|
153
|
+
if (tag === "DIV" || tag === "P") {
|
|
154
|
+
// блочный элемент = новая строка
|
|
155
|
+
const nl = lineBreak(storage);
|
|
156
|
+
if (result && !result.endsWith(nl)) result += nl;
|
|
157
|
+
result += inner;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const tool = tagMap[tag];
|
|
162
|
+
// неизвестный или отключённый тег — отбрасываем обёртку, оставляем текст
|
|
163
|
+
result += tool ? wrap(storage, tool, inner, markers) : inner;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Сериализует содержимое contenteditable-редактора в строку для хранения.
|
|
171
|
+
* Сохраняются только включённые инструменты форматирования.
|
|
172
|
+
*/
|
|
173
|
+
export function serialize(
|
|
174
|
+
root: HTMLElement,
|
|
175
|
+
storage: FormatStorage,
|
|
176
|
+
tools: FormatTool[],
|
|
177
|
+
markers: FormatMarkers = defaultFormatMarkers()
|
|
178
|
+
): string {
|
|
179
|
+
const value = serializeNodes(root.childNodes, storage, buildTagMap(tools), markers);
|
|
180
|
+
|
|
181
|
+
// нормализуем переводы строк по краям
|
|
182
|
+
if (storage === "html")
|
|
183
|
+
return value
|
|
184
|
+
.replace(/^(?:<br>)+/, "")
|
|
185
|
+
.replace(/(?:<br>)+$/, "")
|
|
186
|
+
.trim();
|
|
187
|
+
return value.replace(/^\n+/, "").replace(/\n+$/, "").trim();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function markdownToHtml(value: string, tools: FormatTool[], markers: FormatMarkers): string {
|
|
191
|
+
let html = escapeHtml(value).replace(/\r?\n/g, "<br>");
|
|
192
|
+
|
|
193
|
+
// Маркеры применяем по убыванию длины: длинный (**) раньше короткого-префикса (*),
|
|
194
|
+
// иначе одиночная звезда «съест» двойную.
|
|
195
|
+
const order = tools.slice().sort((a, b) => markers[b].length - markers[a].length);
|
|
196
|
+
for (const tool of order) {
|
|
197
|
+
const marker = markers[tool];
|
|
198
|
+
if (!marker) continue;
|
|
199
|
+
|
|
200
|
+
const def = FORMAT_TOOLS[tool];
|
|
201
|
+
const escaped = escapeRegExp(marker);
|
|
202
|
+
const re = new RegExp(`${escaped}([\\s\\S]+?)${escaped}`, "g");
|
|
203
|
+
html = html.replace(re, `<${def.tag}>$1</${def.tag}>`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return html;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Готовит сохранённое значение к отображению в редакторе (возвращает HTML).
|
|
211
|
+
* HTML-значения санитизируются до разрешённых тегов, Markdown — конвертируется в HTML.
|
|
212
|
+
*/
|
|
213
|
+
export function deserialize(
|
|
214
|
+
value: string,
|
|
215
|
+
storage: FormatStorage,
|
|
216
|
+
tools: FormatTool[],
|
|
217
|
+
markers: FormatMarkers = defaultFormatMarkers()
|
|
218
|
+
): string {
|
|
219
|
+
if (!value) return "";
|
|
220
|
+
|
|
221
|
+
if (storage === "markdown") return markdownToHtml(value, tools, markers);
|
|
222
|
+
|
|
223
|
+
// html: парсим и пересобираем, отбрасывая всё, кроме разрешённых тегов и переводов строк
|
|
224
|
+
const template = document.createElement("template");
|
|
225
|
+
template.innerHTML = value;
|
|
226
|
+
return serializeNodes(template.content.childNodes, "html", buildTagMap(tools), defaultFormatMarkers());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- Переключение форматирования на выделении (Selection/Range API, без execCommand) ---
|
|
230
|
+
|
|
231
|
+
/** Ближайший предок-элемент с одним из тегов (в пределах root, не включая root). */
|
|
232
|
+
function formatAncestor(node: Node, tags: string[], root: HTMLElement): HTMLElement | null {
|
|
233
|
+
let el = node.parentElement;
|
|
234
|
+
while (el && el !== root) {
|
|
235
|
+
if (tags.includes(el.tagName)) return el;
|
|
236
|
+
el = el.parentElement;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Абсолютные текстовые смещения границ выделения внутри root (для восстановления после правок DOM). */
|
|
242
|
+
export function selectionCharBounds(root: HTMLElement, range: Range): [number, number] {
|
|
243
|
+
const probe = document.createRange();
|
|
244
|
+
probe.selectNodeContents(root);
|
|
245
|
+
probe.setEnd(range.startContainer, range.startOffset);
|
|
246
|
+
const start = probe.toString().length;
|
|
247
|
+
probe.setEnd(range.endContainer, range.endOffset);
|
|
248
|
+
const end = probe.toString().length;
|
|
249
|
+
return [start, end];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Находит текстовый узел и локальное смещение по абсолютному текстовому смещению. */
|
|
253
|
+
function locateChar(root: HTMLElement, target: number): { node: Text; offset: number } | null {
|
|
254
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
255
|
+
let count = 0;
|
|
256
|
+
let last: Text | null = null;
|
|
257
|
+
let n = walker.nextNode() as Text | null;
|
|
258
|
+
while (n) {
|
|
259
|
+
last = n;
|
|
260
|
+
if (count + n.length >= target) return { node: n, offset: Math.max(0, target - count) };
|
|
261
|
+
count += n.length;
|
|
262
|
+
n = walker.nextNode() as Text | null;
|
|
263
|
+
}
|
|
264
|
+
return last ? { node: last, offset: last.length } : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function restoreSelection(root: HTMLElement, start: number, end: number, selection: Selection) {
|
|
268
|
+
const s = locateChar(root, start);
|
|
269
|
+
const e = locateChar(root, end);
|
|
270
|
+
if (!s || !e) return;
|
|
271
|
+
|
|
272
|
+
const range = document.createRange();
|
|
273
|
+
range.setStart(s.node, s.offset);
|
|
274
|
+
range.setEnd(e.node, e.offset);
|
|
275
|
+
selection.removeAllRanges();
|
|
276
|
+
selection.addRange(range);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Разбивает пограничные текстовые узлы так, чтобы Range покрывал их целиком. */
|
|
280
|
+
function splitBoundaries(range: Range) {
|
|
281
|
+
const sc = range.startContainer;
|
|
282
|
+
const ec = range.endContainer;
|
|
283
|
+
|
|
284
|
+
if (sc === ec && sc.nodeType === Node.TEXT_NODE) {
|
|
285
|
+
const t = sc as Text;
|
|
286
|
+
const s = range.startOffset;
|
|
287
|
+
const e = range.endOffset;
|
|
288
|
+
if (e < t.length) t.splitText(e);
|
|
289
|
+
let target = t;
|
|
290
|
+
if (s > 0) target = t.splitText(s);
|
|
291
|
+
range.setStart(target, 0);
|
|
292
|
+
range.setEnd(target, target.length);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (ec.nodeType === Node.TEXT_NODE) {
|
|
297
|
+
const t = ec as Text;
|
|
298
|
+
if (range.endOffset > 0 && range.endOffset < t.length) {
|
|
299
|
+
t.splitText(range.endOffset);
|
|
300
|
+
range.setEnd(t, t.length);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (sc.nodeType === Node.TEXT_NODE) {
|
|
304
|
+
const t = sc as Text;
|
|
305
|
+
if (range.startOffset > 0 && range.startOffset < t.length) {
|
|
306
|
+
const after = t.splitText(range.startOffset);
|
|
307
|
+
range.setStart(after, 0);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function nodeWithinRange(node: Node, range: Range): boolean {
|
|
313
|
+
const nr = document.createRange();
|
|
314
|
+
nr.selectNodeContents(node);
|
|
315
|
+
return (
|
|
316
|
+
range.compareBoundaryPoints(Range.START_TO_START, nr) <= 0 &&
|
|
317
|
+
range.compareBoundaryPoints(Range.END_TO_END, nr) >= 0
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function collectTextNodes(root: HTMLElement, range: Range, strict: boolean): Text[] {
|
|
322
|
+
const nodes: Text[] = [];
|
|
323
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
324
|
+
let n = walker.nextNode() as Text | null;
|
|
325
|
+
while (n) {
|
|
326
|
+
if (n.length > 0 && (strict ? nodeWithinRange(n, range) : range.intersectsNode(n))) nodes.push(n);
|
|
327
|
+
n = walker.nextNode() as Text | null;
|
|
328
|
+
}
|
|
329
|
+
return nodes;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function wrapTextNode(node: Text, tag: string) {
|
|
333
|
+
const wrapper = document.createElement(tag);
|
|
334
|
+
node.parentNode?.insertBefore(wrapper, node);
|
|
335
|
+
wrapper.appendChild(node);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Выносит ветку, содержащую node, наружу из элемента fmt (расщепляя fmt на «до» и «после»). */
|
|
339
|
+
function unwrapAround(fmt: HTMLElement, node: Node) {
|
|
340
|
+
const parent = fmt.parentNode;
|
|
341
|
+
if (!parent) return;
|
|
342
|
+
|
|
343
|
+
let child: Node = node;
|
|
344
|
+
while (child.parentNode && child.parentNode !== fmt) child = child.parentNode;
|
|
345
|
+
if (child.parentNode !== fmt) return;
|
|
346
|
+
|
|
347
|
+
const left = fmt.cloneNode(false) as HTMLElement;
|
|
348
|
+
while (fmt.firstChild && fmt.firstChild !== child) left.appendChild(fmt.firstChild);
|
|
349
|
+
|
|
350
|
+
fmt.removeChild(child);
|
|
351
|
+
parent.insertBefore(left, fmt);
|
|
352
|
+
parent.insertBefore(child, fmt);
|
|
353
|
+
|
|
354
|
+
if (!left.firstChild) parent.removeChild(left);
|
|
355
|
+
if (!fmt.firstChild) parent.removeChild(fmt);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function removeFormatFromNode(node: Text, tags: string[], root: HTMLElement) {
|
|
359
|
+
let fmt = formatAncestor(node, tags, root);
|
|
360
|
+
while (fmt) {
|
|
361
|
+
unwrapAround(fmt, node);
|
|
362
|
+
fmt = formatAncestor(node, tags, root);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function unwrapElement(el: HTMLElement) {
|
|
367
|
+
const parent = el.parentNode;
|
|
368
|
+
if (!parent) return;
|
|
369
|
+
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
370
|
+
parent.removeChild(el);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст. */
|
|
374
|
+
function cleanupFormatting(root: HTMLElement) {
|
|
375
|
+
const selector = FORMAT_TAG_NAMES.join(",").toLowerCase();
|
|
376
|
+
|
|
377
|
+
let changed = true;
|
|
378
|
+
while (changed) {
|
|
379
|
+
changed = false;
|
|
380
|
+
|
|
381
|
+
for (const el of Array.from(root.querySelectorAll<HTMLElement>(selector))) {
|
|
382
|
+
if (!el.isConnected) continue;
|
|
383
|
+
|
|
384
|
+
// пустой тег
|
|
385
|
+
if (el.textContent === "") {
|
|
386
|
+
el.remove();
|
|
387
|
+
changed = true;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// вложен в такой же тег
|
|
392
|
+
const parent = el.parentElement;
|
|
393
|
+
if (parent && parent !== root && parent.tagName === el.tagName) {
|
|
394
|
+
unwrapElement(el);
|
|
395
|
+
changed = true;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// соседний такой же тег слева — склеиваем
|
|
400
|
+
const prev = el.previousSibling;
|
|
401
|
+
if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === el.tagName) {
|
|
402
|
+
while (el.firstChild) prev.appendChild(el.firstChild);
|
|
403
|
+
el.remove();
|
|
404
|
+
changed = true;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
root.normalize();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Переключает форматирование инструмента на выделении.
|
|
415
|
+
* Если весь выделенный текст уже отформатирован — снимает формат, иначе применяет.
|
|
416
|
+
*
|
|
417
|
+
* По умолчанию восстанавливает выделение, на котором работал. Через `restoreBounds`
|
|
418
|
+
* можно восстановить другое выделение (например, исходное до расширения до слова).
|
|
419
|
+
*/
|
|
420
|
+
export function toggleFormat(
|
|
421
|
+
root: HTMLElement,
|
|
422
|
+
range: Range,
|
|
423
|
+
tool: FormatTool,
|
|
424
|
+
selection: Selection,
|
|
425
|
+
restoreBounds?: [number, number]
|
|
426
|
+
) {
|
|
427
|
+
if (range.collapsed) return;
|
|
428
|
+
|
|
429
|
+
const def = FORMAT_TOOLS[tool];
|
|
430
|
+
const tags = def.matchTags;
|
|
431
|
+
const [startChar, endChar] = restoreBounds ?? selectionCharBounds(root, range);
|
|
432
|
+
|
|
433
|
+
splitBoundaries(range);
|
|
434
|
+
|
|
435
|
+
const nodes = collectTextNodes(root, range, true);
|
|
436
|
+
if (!nodes.length) return;
|
|
437
|
+
|
|
438
|
+
const allFormatted = nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
439
|
+
if (allFormatted) {
|
|
440
|
+
for (const n of nodes) removeFormatFromNode(n, tags, root);
|
|
441
|
+
} else {
|
|
442
|
+
for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
cleanupFormatting(root);
|
|
446
|
+
restoreSelection(root, startChar, endChar, selection);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Вставляет текст в позицию каретки, оборачивая его в указанные форматы (режим набора).
|
|
451
|
+
* Каретка ставится сразу после вставленного текста; соседние одинаковые теги склеиваются.
|
|
452
|
+
*/
|
|
453
|
+
export function insertFormattedText(root: HTMLElement, data: string, tools: FormatTool[], selection: Selection) {
|
|
454
|
+
if (!data || selection.rangeCount === 0) return;
|
|
455
|
+
|
|
456
|
+
const range = selection.getRangeAt(0);
|
|
457
|
+
const caret = selectionCharBounds(root, range)[0];
|
|
458
|
+
|
|
459
|
+
range.deleteContents();
|
|
460
|
+
|
|
461
|
+
let node: Node = document.createTextNode(data);
|
|
462
|
+
for (const tool of tools) {
|
|
463
|
+
const el = document.createElement(FORMAT_TOOLS[tool].tag);
|
|
464
|
+
el.appendChild(node);
|
|
465
|
+
node = el;
|
|
466
|
+
}
|
|
467
|
+
range.insertNode(node);
|
|
468
|
+
|
|
469
|
+
cleanupFormatting(root);
|
|
470
|
+
|
|
471
|
+
const offset = caret + data.length;
|
|
472
|
+
restoreSelection(root, offset, offset, selection);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Активен ли формат инструмента на текущем выделении (для подсветки кнопки). */
|
|
476
|
+
export function isFormatActive(root: HTMLElement, range: Range, tool: FormatTool): boolean {
|
|
477
|
+
const tags = FORMAT_TOOLS[tool].matchTags;
|
|
478
|
+
|
|
479
|
+
if (range.collapsed) {
|
|
480
|
+
const node = range.startContainer;
|
|
481
|
+
const probe = node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
|
|
482
|
+
return formatAncestor(probe, tags, root) !== null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const nodes = collectTextNodes(root, range, false);
|
|
486
|
+
if (!nodes.length) return false;
|
|
487
|
+
return nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Нормализует пробелы в редакторе: схлопывает повторяющиеся пробелы/табы в один
|
|
492
|
+
* и обрезает пробелы по краям каждой строки. BR и блочные элементы (DIV/P) —
|
|
493
|
+
* границы строк; инлайновое форматирование (b/i/s/u) на строки не влияет.
|
|
494
|
+
*/
|
|
495
|
+
export function normalizeWhitespace(root: HTMLElement) {
|
|
496
|
+
type Item = { kind: "text"; node: Text } | { kind: "break" };
|
|
497
|
+
const items: Item[] = [];
|
|
498
|
+
|
|
499
|
+
const flatten = (node: Node) => {
|
|
500
|
+
for (const child of Array.from(node.childNodes)) {
|
|
501
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
502
|
+
items.push({ kind: "text", node: child as Text });
|
|
503
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
504
|
+
const el = child as HTMLElement;
|
|
505
|
+
if (el.tagName === "BR") {
|
|
506
|
+
items.push({ kind: "break" });
|
|
507
|
+
} else if (el.tagName === "DIV" || el.tagName === "P") {
|
|
508
|
+
items.push({ kind: "break" });
|
|
509
|
+
flatten(el);
|
|
510
|
+
items.push({ kind: "break" });
|
|
511
|
+
} else {
|
|
512
|
+
flatten(el); // инлайновый тег — не разрывает строку
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
flatten(root);
|
|
518
|
+
|
|
519
|
+
let atLineStart = true;
|
|
520
|
+
let pendingSpaceNode: Text | null = null; // узел, заканчивающийся пробелом (возможно хвостовым)
|
|
521
|
+
|
|
522
|
+
for (const item of items) {
|
|
523
|
+
if (item.kind === "break") {
|
|
524
|
+
if (pendingSpaceNode) {
|
|
525
|
+
pendingSpaceNode.data = pendingSpaceNode.data.replace(/ $/, "");
|
|
526
|
+
pendingSpaceNode = null;
|
|
527
|
+
}
|
|
528
|
+
atLineStart = true;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let text = item.node.data.replace(/[ \t]+/g, " ");
|
|
533
|
+
if (atLineStart) text = text.replace(/^ /, ""); // пробел в начале строки
|
|
534
|
+
if (pendingSpaceNode && text.startsWith(" ")) text = text.slice(1); // двойной пробел на границе узлов
|
|
535
|
+
|
|
536
|
+
item.node.data = text;
|
|
537
|
+
if (text.length === 0) continue;
|
|
538
|
+
|
|
539
|
+
atLineStart = false;
|
|
540
|
+
pendingSpaceNode = text.endsWith(" ") ? item.node : null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (pendingSpaceNode) pendingSpaceNode.data = pendingSpaceNode.data.replace(/ $/, ""); // хвост последней строки
|
|
544
|
+
|
|
545
|
+
cleanupFormatting(root); // убрать опустевшие теги, склеить узлы
|
|
546
|
+
}
|
package/source/index.ts
CHANGED
|
@@ -1 +1,12 @@
|
|
|
1
1
|
export { default } from "./textbox";
|
|
2
|
+
export * from "./textbox";
|
|
3
|
+
export {
|
|
4
|
+
ALL_FORMAT_TOOLS,
|
|
5
|
+
FORMAT_TOOLS,
|
|
6
|
+
parseFormatTools,
|
|
7
|
+
defaultFormatMarkers,
|
|
8
|
+
normalizeWhitespace,
|
|
9
|
+
type FormatTool,
|
|
10
|
+
type FormatStorage,
|
|
11
|
+
type FormatMarkers,
|
|
12
|
+
} from "./format";
|
package/source/textbox.less
CHANGED
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
flex: 1 1 auto;
|
|
78
78
|
box-sizing: border-box;
|
|
79
79
|
word-wrap: anywhere;
|
|
80
|
+
white-space: pre-wrap; // сохраняем пробелы (в т.ч. ведущие/повторяющиеся), чтобы они были видны
|
|
80
81
|
outline: none;
|
|
81
82
|
|
|
82
83
|
&:empty {
|
|
@@ -136,6 +137,16 @@
|
|
|
136
137
|
outline: none;
|
|
137
138
|
transition: all ease 100ms;
|
|
138
139
|
cursor: pointer;
|
|
140
|
+
opacity: 0.7;
|
|
141
|
+
|
|
142
|
+
&.success {
|
|
143
|
+
--svg-fill: var(--color-success);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
&:hover,
|
|
147
|
+
&.success {
|
|
148
|
+
opacity: 1;
|
|
149
|
+
}
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
&:empty {
|
|
@@ -143,6 +154,76 @@
|
|
|
143
154
|
}
|
|
144
155
|
}
|
|
145
156
|
|
|
157
|
+
// панель форматирования — плавающая панель над контролом, появляется в фокусе
|
|
158
|
+
& .format-toolbar {
|
|
159
|
+
display: none;
|
|
160
|
+
position: absolute;
|
|
161
|
+
left: 0;
|
|
162
|
+
bottom: 100%;
|
|
163
|
+
margin-bottom: 6px;
|
|
164
|
+
z-index: 10;
|
|
165
|
+
flex-flow: row nowrap;
|
|
166
|
+
align-items: center;
|
|
167
|
+
gap: 2px;
|
|
168
|
+
padding: 3px;
|
|
169
|
+
background-color: var(--input-fill);
|
|
170
|
+
border: var(--input-border-type) var(--input-border-width) var(--input-border-color);
|
|
171
|
+
border-radius: var(--input-border-radius);
|
|
172
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
173
|
+
|
|
174
|
+
& .format-button {
|
|
175
|
+
width: calc(var(--input-height) - 12px);
|
|
176
|
+
height: calc(var(--input-height) - 12px);
|
|
177
|
+
border-radius: calc(var(--input-border-radius) - 2px);
|
|
178
|
+
border: 0;
|
|
179
|
+
background: var(--input-toolbar-button-fill);
|
|
180
|
+
cursor: pointer;
|
|
181
|
+
display: flex;
|
|
182
|
+
align-items: center;
|
|
183
|
+
justify-content: center;
|
|
184
|
+
|
|
185
|
+
& svg {
|
|
186
|
+
pointer-events: none;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
&:hover {
|
|
190
|
+
background-color: var(--hover--input-toolbar-button-fill);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
&.active {
|
|
194
|
+
background-color: var(--hover--input-toolbar-button-fill);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
&.focused .format-toolbar {
|
|
200
|
+
display: flex;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// видимые теги форматирования внутри редактора
|
|
204
|
+
& .input {
|
|
205
|
+
& b,
|
|
206
|
+
& strong {
|
|
207
|
+
font-weight: 700;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
& i,
|
|
211
|
+
& em {
|
|
212
|
+
font-style: italic;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
& s,
|
|
216
|
+
& strike,
|
|
217
|
+
& del {
|
|
218
|
+
text-decoration: line-through;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
& u,
|
|
222
|
+
& ins {
|
|
223
|
+
text-decoration: underline;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
146
227
|
// мультистроковый режим
|
|
147
228
|
&.multyline {
|
|
148
229
|
// data-multyline == true
|
package/source/textbox.ts
CHANGED
|
@@ -4,8 +4,35 @@ import { InputControl } from "@brandup/ui-input";
|
|
|
4
4
|
import { IS_TOUCH_DEVICE } from "@brandup/ui-kit";
|
|
5
5
|
import { DOM } from "@brandup/ui";
|
|
6
6
|
import { FuncHelper } from "@brandup/ui-helpers";
|
|
7
|
+
import {
|
|
8
|
+
FORMAT_TOOLS,
|
|
9
|
+
HOTKEY_TOOLS,
|
|
10
|
+
defaultFormatMarkers,
|
|
11
|
+
deserialize,
|
|
12
|
+
insertFormattedText,
|
|
13
|
+
isFormatActive,
|
|
14
|
+
normalizeWhitespace,
|
|
15
|
+
parseFormatTools,
|
|
16
|
+
selectionCharBounds,
|
|
17
|
+
serialize,
|
|
18
|
+
toggleFormat,
|
|
19
|
+
type FormatMarkers,
|
|
20
|
+
type FormatStorage,
|
|
21
|
+
type FormatTool,
|
|
22
|
+
} from "./format";
|
|
7
23
|
import copyIcon from "../svg/copy.svg";
|
|
8
24
|
import doneIcon from "../svg/tick.svg";
|
|
25
|
+
import boldIcon from "../svg/bold.svg";
|
|
26
|
+
import italicIcon from "../svg/italic.svg";
|
|
27
|
+
import strikeIcon from "../svg/strike.svg";
|
|
28
|
+
import underlineIcon from "../svg/underline.svg";
|
|
29
|
+
|
|
30
|
+
const FORMAT_ICONS: Record<FormatTool, string> = {
|
|
31
|
+
bold: boldIcon,
|
|
32
|
+
italic: italicIcon,
|
|
33
|
+
strike: strikeIcon,
|
|
34
|
+
underline: underlineIcon,
|
|
35
|
+
};
|
|
9
36
|
|
|
10
37
|
export const ROOT_CLASS = "ui-textbox";
|
|
11
38
|
export const INPUT_CLASS = "textbox-input";
|
|
@@ -23,6 +50,8 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
23
50
|
private __inputElem: HTMLElement;
|
|
24
51
|
private __symbolsCountElem: HTMLElement;
|
|
25
52
|
private __listenerAbort = new AbortController();
|
|
53
|
+
private __formatButtons: Array<[FormatTool, HTMLButtonElement]> = [];
|
|
54
|
+
private __pendingFormats = new Set<FormatTool>(); // режим набора: форматы для следующего ввода
|
|
26
55
|
|
|
27
56
|
readonly type: TextBoxType;
|
|
28
57
|
readonly allowEmptyStrings: boolean;
|
|
@@ -33,6 +62,10 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
33
62
|
readonly inputmode: string;
|
|
34
63
|
readonly symbolCounter: boolean;
|
|
35
64
|
readonly autoFocus: boolean;
|
|
65
|
+
readonly format: boolean;
|
|
66
|
+
readonly formatStorage: FormatStorage;
|
|
67
|
+
readonly formatTools: FormatTool[];
|
|
68
|
+
readonly formatMarkers: FormatMarkers;
|
|
36
69
|
|
|
37
70
|
constructor(valueElem: HTMLInputElement | HTMLTextAreaElement) {
|
|
38
71
|
// определяем тип ввода и нормализуем валидационные атрибуты до super()
|
|
@@ -73,6 +106,22 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
73
106
|
const multyline = valueElem instanceof HTMLTextAreaElement;
|
|
74
107
|
const copyButton = valueElem.hasAttribute("data-copy-button") || valueElem.hasAttribute("data-copybutton");
|
|
75
108
|
const disabled = valueElem.disabled;
|
|
109
|
+
const readonly = valueElem.hasAttribute("readonly") || valueElem.hasAttribute("data-readonly");
|
|
110
|
+
|
|
111
|
+
// форматирование доступно только для обычного текстового ввода
|
|
112
|
+
const format = type === "text" && valueElem.hasAttribute("data-format");
|
|
113
|
+
const formatStorage: FormatStorage =
|
|
114
|
+
valueElem.getAttribute("data-format-storage") === "markdown" ? "markdown" : "html";
|
|
115
|
+
const formatTools = format ? parseFormatTools(valueElem.getAttribute("data-format-tools")) : [];
|
|
116
|
+
|
|
117
|
+
// markdown-маркеры с дефолтами, переопределяются атрибутами data-format-md-<tool>
|
|
118
|
+
const formatMarkers = defaultFormatMarkers();
|
|
119
|
+
if (format) {
|
|
120
|
+
for (const tool of Object.keys(formatMarkers) as FormatTool[]) {
|
|
121
|
+
const marker = valueElem.getAttribute(`data-format-md-${tool}`)?.trim();
|
|
122
|
+
if (marker) formatMarkers[tool] = marker;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
76
125
|
|
|
77
126
|
const inputElem = DOM.tag("div", { class: "input" });
|
|
78
127
|
const actionsElem = DOM.tag("div", { class: "actions" });
|
|
@@ -97,6 +146,30 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
97
146
|
|
|
98
147
|
if (inputmode) inputElem.inputMode = inputmode;
|
|
99
148
|
|
|
149
|
+
// панель форматирования — кнопки в той же области, что и кнопка копирования
|
|
150
|
+
const formatButtons: Array<[FormatTool, HTMLButtonElement]> = [];
|
|
151
|
+
if (format && formatTools.length && !disabled && !readonly) {
|
|
152
|
+
const toolbarElem = DOM.tag("div", { class: "format-toolbar" });
|
|
153
|
+
for (const tool of formatTools) {
|
|
154
|
+
const def = FORMAT_TOOLS[tool];
|
|
155
|
+
const buttonElem = DOM.tag(
|
|
156
|
+
"button",
|
|
157
|
+
{
|
|
158
|
+
type: "button",
|
|
159
|
+
class: "format-button",
|
|
160
|
+
command: def.command,
|
|
161
|
+
"data-format-tool": tool,
|
|
162
|
+
title: def.title,
|
|
163
|
+
},
|
|
164
|
+
FORMAT_ICONS[tool]
|
|
165
|
+
);
|
|
166
|
+
toolbarElem.insertAdjacentElement("beforeend", buttonElem);
|
|
167
|
+
formatButtons.push([tool, buttonElem]);
|
|
168
|
+
}
|
|
169
|
+
// плавающая панель над контролом, видимость управляется классом .focused
|
|
170
|
+
container.insertAdjacentElement("beforeend", toolbarElem);
|
|
171
|
+
}
|
|
172
|
+
|
|
100
173
|
if (copyButton) {
|
|
101
174
|
const buttonElem = DOM.tag(
|
|
102
175
|
"button",
|
|
@@ -128,11 +201,17 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
128
201
|
this.inputmode = inputmode;
|
|
129
202
|
this.multyline = multyline;
|
|
130
203
|
this.copyButton = copyButton;
|
|
204
|
+
this.format = format;
|
|
205
|
+
this.formatStorage = formatStorage;
|
|
206
|
+
this.formatTools = formatTools;
|
|
207
|
+
this.formatMarkers = formatMarkers;
|
|
131
208
|
|
|
132
209
|
this.__inputElem = inputElem;
|
|
133
210
|
this.__symbolsCountElem = symbolsCountElem;
|
|
211
|
+
this.__formatButtons = formatButtons;
|
|
134
212
|
|
|
135
213
|
this.__initLogic();
|
|
214
|
+
this.__initFormat();
|
|
136
215
|
this.__initText();
|
|
137
216
|
}
|
|
138
217
|
|
|
@@ -187,6 +266,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
187
266
|
// когда удаляем весь текст, то браузер оставляет один BR, что означает что текста нет
|
|
188
267
|
// удалить BR нужно, чтобы появился placeholder
|
|
189
268
|
if (this.__inputElem.firstChild?.nodeName === "BR") DOM.empty(this.__inputElem);
|
|
269
|
+
|
|
270
|
+
// редактирование завершено — нормализуем пробелы (с событием change, если что-то изменилось)
|
|
271
|
+
this.__normalizeWhitespace(true);
|
|
190
272
|
},
|
|
191
273
|
{ signal }
|
|
192
274
|
);
|
|
@@ -196,7 +278,13 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
196
278
|
() => {
|
|
197
279
|
if (this.disabled) return;
|
|
198
280
|
|
|
199
|
-
if (this.copyButton && this.readonly)
|
|
281
|
+
if (this.copyButton && this.readonly) {
|
|
282
|
+
this.__selectAll();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// браузер при выделении слова может захватить пробел у границы — убираем его
|
|
287
|
+
this.__trimSelectionWhitespace();
|
|
200
288
|
},
|
|
201
289
|
{ signal }
|
|
202
290
|
);
|
|
@@ -269,6 +357,19 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
269
357
|
this.__inputElem.addEventListener(
|
|
270
358
|
"keydown",
|
|
271
359
|
(e: KeyboardEvent) => {
|
|
360
|
+
// хоткеи форматирования (Ctrl/Cmd+B/I/U); зачёркивание — только кнопкой
|
|
361
|
+
if (this.format && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
|
|
362
|
+
const tool = HOTKEY_TOOLS[e.key.toLowerCase()];
|
|
363
|
+
if (tool) {
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
e.stopPropagation();
|
|
366
|
+
|
|
367
|
+
// перехватываем даже отключённый инструмент, чтобы не сработало форматирование браузера
|
|
368
|
+
if (this.formatTools.includes(tool)) this.__applyFormat(tool);
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
272
373
|
const isChar = e.key.length === 1;
|
|
273
374
|
|
|
274
375
|
if ((this.readonly || this.disabled) && isChar && !e.ctrlKey) {
|
|
@@ -357,35 +458,214 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
357
458
|
});
|
|
358
459
|
}
|
|
359
460
|
|
|
461
|
+
private __initFormat() {
|
|
462
|
+
if (!this.format || !this.__formatButtons.length) return;
|
|
463
|
+
|
|
464
|
+
const { signal } = this.__listenerAbort;
|
|
465
|
+
|
|
466
|
+
for (const [tool, buttonElem] of this.__formatButtons) {
|
|
467
|
+
// не даём кнопке забрать фокус, иначе теряется выделение в редакторе
|
|
468
|
+
buttonElem.addEventListener("mousedown", (e) => e.preventDefault(), { signal });
|
|
469
|
+
|
|
470
|
+
this.registerCommand(FORMAT_TOOLS[tool].command, () => this.__applyFormat(tool));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// подсветка активных инструментов по текущему выделению
|
|
474
|
+
document.addEventListener(
|
|
475
|
+
"selectionchange",
|
|
476
|
+
() => {
|
|
477
|
+
if (document.activeElement === this.element || this.element.contains(document.activeElement))
|
|
478
|
+
this.__refreshFormatState();
|
|
479
|
+
},
|
|
480
|
+
{ signal }
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
// режим набора: оборачиваем вводимый текст в ожидающие форматы
|
|
484
|
+
this.__inputElem.addEventListener(
|
|
485
|
+
"beforeinput",
|
|
486
|
+
(e: InputEvent) => {
|
|
487
|
+
if (this.disabled || this.readonly || this.__pendingFormats.size === 0) return;
|
|
488
|
+
if (e.inputType !== "insertText" || e.data == null) return;
|
|
489
|
+
|
|
490
|
+
e.preventDefault();
|
|
491
|
+
this.__insertPendingText(e.data);
|
|
492
|
+
},
|
|
493
|
+
{ signal }
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
// перемещение каретки/клик/потеря фокуса — выходим из режима набора
|
|
497
|
+
const navKeys = [
|
|
498
|
+
"ArrowLeft",
|
|
499
|
+
"ArrowRight",
|
|
500
|
+
"ArrowUp",
|
|
501
|
+
"ArrowDown",
|
|
502
|
+
"Home",
|
|
503
|
+
"End",
|
|
504
|
+
"PageUp",
|
|
505
|
+
"PageDown",
|
|
506
|
+
"Escape",
|
|
507
|
+
];
|
|
508
|
+
this.__inputElem.addEventListener(
|
|
509
|
+
"keydown",
|
|
510
|
+
(e: KeyboardEvent) => {
|
|
511
|
+
if (navKeys.includes(e.key)) this.__clearPendingFormats();
|
|
512
|
+
},
|
|
513
|
+
{ signal }
|
|
514
|
+
);
|
|
515
|
+
this.__inputElem.addEventListener("mousedown", () => this.__clearPendingFormats(), { signal });
|
|
516
|
+
this.__inputElem.addEventListener("blur", () => this.__clearPendingFormats(), { signal });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private __clearPendingFormats() {
|
|
520
|
+
if (!this.__pendingFormats.size) return;
|
|
521
|
+
|
|
522
|
+
this.__pendingFormats.clear();
|
|
523
|
+
this.__refreshFormatState();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private __insertPendingText(data: string) {
|
|
527
|
+
const selection = window.getSelection();
|
|
528
|
+
if (!selection || !this.__inputElem.contains(selection.anchorNode)) return;
|
|
529
|
+
|
|
530
|
+
insertFormattedText(this.__inputElem, data, Array.from(this.__pendingFormats), selection);
|
|
531
|
+
|
|
532
|
+
this.__applyValue();
|
|
533
|
+
this.__refreshFormatState();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private __applyFormat(tool: FormatTool) {
|
|
537
|
+
if (!this.format || this.readonly || this.disabled || !this.formatTools.includes(tool)) return;
|
|
538
|
+
|
|
539
|
+
const selection = window.getSelection();
|
|
540
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
541
|
+
|
|
542
|
+
// выделение должно быть внутри редактора (не вызываем focus() — он схлопнул бы выделение)
|
|
543
|
+
if (!this.__inputElem.contains(selection.anchorNode)) return;
|
|
544
|
+
|
|
545
|
+
// запоминаем исходное выделение/каретку, чтобы вернуть его после форматирования
|
|
546
|
+
const original = selectionCharBounds(this.__inputElem, selection.getRangeAt(0));
|
|
547
|
+
|
|
548
|
+
// форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
|
|
549
|
+
this.__expandSelectionToWords(selection);
|
|
550
|
+
|
|
551
|
+
const range = selection.getRangeAt(0);
|
|
552
|
+
if (range.collapsed) {
|
|
553
|
+
// под кареткой нет слова (пробелы/пустое поле) — режим набора: запоминаем формат для следующего ввода
|
|
554
|
+
if (this.__pendingFormats.has(tool)) this.__pendingFormats.delete(tool);
|
|
555
|
+
else this.__pendingFormats.add(tool);
|
|
556
|
+
|
|
557
|
+
this.__refreshFormatState();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
this.__pendingFormats.clear(); // есть что форматировать — режим набора не нужен
|
|
562
|
+
|
|
563
|
+
// формат применяется к слову, но восстанавливаем исходное выделение пользователя
|
|
564
|
+
toggleFormat(this.__inputElem, range, tool, selection, original);
|
|
565
|
+
|
|
566
|
+
this.__applyValue();
|
|
567
|
+
this.__refreshFormatState();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private __expandSelectionToWords(selection: Selection) {
|
|
571
|
+
const range = selection.getRangeAt(0);
|
|
572
|
+
|
|
573
|
+
const { startContainer, endContainer } = range;
|
|
574
|
+
let startOffset = range.startOffset;
|
|
575
|
+
let endOffset = range.endOffset;
|
|
576
|
+
|
|
577
|
+
// начало выделения — влево до начала слова
|
|
578
|
+
if (startContainer.nodeType === Node.TEXT_NODE && this.__inputElem.contains(startContainer)) {
|
|
579
|
+
const text = startContainer.textContent ?? "";
|
|
580
|
+
while (startOffset > 0 && !/\s/.test(text[startOffset - 1])) startOffset--;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// конец выделения — вправо до конца слова
|
|
584
|
+
if (endContainer.nodeType === Node.TEXT_NODE && this.__inputElem.contains(endContainer)) {
|
|
585
|
+
const text = endContainer.textContent ?? "";
|
|
586
|
+
while (endOffset < text.length && !/\s/.test(text[endOffset])) endOffset++;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (startOffset === range.startOffset && endOffset === range.endOffset) return; // границы не изменились
|
|
590
|
+
|
|
591
|
+
const expanded = document.createRange();
|
|
592
|
+
expanded.setStart(startContainer, startOffset);
|
|
593
|
+
expanded.setEnd(endContainer, endOffset);
|
|
594
|
+
selection.removeAllRanges();
|
|
595
|
+
selection.addRange(expanded);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private __refreshFormatState() {
|
|
599
|
+
if (!this.format) return;
|
|
600
|
+
|
|
601
|
+
const selection = window.getSelection();
|
|
602
|
+
const range =
|
|
603
|
+
selection && selection.rangeCount > 0 && this.__inputElem.contains(selection.anchorNode)
|
|
604
|
+
? selection.getRangeAt(0)
|
|
605
|
+
: null;
|
|
606
|
+
|
|
607
|
+
for (const [tool, buttonElem] of this.__formatButtons) {
|
|
608
|
+
const active =
|
|
609
|
+
this.__pendingFormats.has(tool) || (range ? isFormatActive(this.__inputElem, range, tool) : false);
|
|
610
|
+
buttonElem.classList.toggle("active", active);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
360
614
|
private __initText() {
|
|
361
615
|
DOM.empty(this.__inputElem);
|
|
362
616
|
|
|
363
617
|
const text = this.__valueElem.value;
|
|
364
618
|
if (text) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
619
|
+
if (this.format) {
|
|
620
|
+
this.__inputElem.innerHTML = deserialize(
|
|
621
|
+
text,
|
|
622
|
+
this.formatStorage,
|
|
623
|
+
this.formatTools,
|
|
624
|
+
this.formatMarkers
|
|
625
|
+
);
|
|
626
|
+
} else {
|
|
627
|
+
const lines = text.split(/\n/);
|
|
628
|
+
lines.forEach((line, index) => {
|
|
629
|
+
line = line.trim();
|
|
630
|
+
|
|
631
|
+
if (index === 0) this.__inputElem.append(document.createTextNode(line));
|
|
632
|
+
else {
|
|
633
|
+
const lineElem = document.createElement("div");
|
|
634
|
+
lineElem.textContent = line;
|
|
635
|
+
this.__inputElem.append(lineElem);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|
|
376
639
|
}
|
|
377
640
|
|
|
641
|
+
this.__normalizeWhitespace(false); // нормализация после инициализации/setValue (без события)
|
|
642
|
+
|
|
378
643
|
this.__refreshSymbolsCount();
|
|
379
644
|
|
|
380
645
|
if (this.autoFocus && !IS_TOUCH_DEVICE && !this.disabled && !this.readonly) this.__inputElem.focus();
|
|
381
646
|
}
|
|
382
647
|
|
|
383
|
-
private __applyValue() {
|
|
384
|
-
const newValue = this.
|
|
648
|
+
private __applyValue(silent = false) {
|
|
649
|
+
const newValue = this.format
|
|
650
|
+
? serialize(this.__inputElem, this.formatStorage, this.formatTools, this.formatMarkers)
|
|
651
|
+
: this.__inputElem.innerText.trim();
|
|
385
652
|
this.__valueElem.value = newValue;
|
|
386
653
|
|
|
387
654
|
this.__refreshSymbolsCount();
|
|
388
|
-
this.__onChange();
|
|
655
|
+
if (!silent) this.__onChange();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Нормализация пробелов редактора (схлопывание повторов + обрезка краёв строк).
|
|
659
|
+
// Значение пересинхронизируется только если содержимое изменилось; notify=true — c событием change.
|
|
660
|
+
private __normalizeWhitespace(notify: boolean) {
|
|
661
|
+
if (this.disabled || this.readonly) return;
|
|
662
|
+
|
|
663
|
+
const before = this.__inputElem.textContent ?? "";
|
|
664
|
+
normalizeWhitespace(this.__inputElem);
|
|
665
|
+
if ((this.__inputElem.textContent ?? "") === before) return;
|
|
666
|
+
|
|
667
|
+
this.__applyValue(true);
|
|
668
|
+
if (notify) this.__onChange();
|
|
389
669
|
}
|
|
390
670
|
|
|
391
671
|
private __toIncorrect() {
|
|
@@ -414,6 +694,42 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
414
694
|
window.getSelection()?.selectAllChildren(this.__inputElem);
|
|
415
695
|
}
|
|
416
696
|
|
|
697
|
+
private __trimSelectionWhitespace() {
|
|
698
|
+
const selection = window.getSelection();
|
|
699
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
|
|
700
|
+
|
|
701
|
+
const range = selection.getRangeAt(0);
|
|
702
|
+
|
|
703
|
+
// выделение должно оставаться внутри редактора
|
|
704
|
+
if (!this.__inputElem.contains(range.startContainer) || !this.__inputElem.contains(range.endContainer)) return;
|
|
705
|
+
|
|
706
|
+
const { startContainer, endContainer } = range;
|
|
707
|
+
let startOffset = range.startOffset;
|
|
708
|
+
let endOffset = range.endOffset;
|
|
709
|
+
|
|
710
|
+
// убираем пробелы в начале
|
|
711
|
+
if (startContainer.nodeType === Node.TEXT_NODE) {
|
|
712
|
+
const text = startContainer.textContent ?? "";
|
|
713
|
+
while (startOffset < text.length && /\s/.test(text[startOffset])) startOffset++;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// убираем пробелы в конце
|
|
717
|
+
if (endContainer.nodeType === Node.TEXT_NODE) {
|
|
718
|
+
const text = endContainer.textContent ?? "";
|
|
719
|
+
while (endOffset > 0 && /\s/.test(text[endOffset - 1])) endOffset--;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// в пределах одного узла выделение не должно схлопнуться или вывернуться
|
|
723
|
+
if (startContainer === endContainer && startOffset >= endOffset) return;
|
|
724
|
+
if (startOffset === range.startOffset && endOffset === range.endOffset) return;
|
|
725
|
+
|
|
726
|
+
const trimmed = document.createRange();
|
|
727
|
+
trimmed.setStart(startContainer, startOffset);
|
|
728
|
+
trimmed.setEnd(endContainer, endOffset);
|
|
729
|
+
selection.removeAllRanges();
|
|
730
|
+
selection.addRange(trimmed);
|
|
731
|
+
}
|
|
732
|
+
|
|
417
733
|
private __carretToEnd() {
|
|
418
734
|
const range = document.createRange();
|
|
419
735
|
range.selectNodeContents(this.__inputElem);
|
package/svg/bold.svg
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" >
|
|
2
|
+
<path d="M7 5C6.4477 5 6 5.4477 6 6V18C6 18.5523 6.4477 19 7 19H13.25C15.5972 19 17.5 17.0972 17.5 14.75C17.5 13.4203 16.8891 12.2333 15.9329 11.4546C16.451 10.7591 16.75 9.9056 16.75 8.9844C16.75 6.7853 14.9647 5 12.7656 5H7ZM12.7656 10.4688H8.5V7.5H12.7656C13.5854 7.5 14.25 8.1646 14.25 8.9844C14.25 9.8041 13.5854 10.4688 12.7656 10.4688ZM8.5 12.9688H13.25C14.2165 12.9688 15 13.7522 15 14.7188C15 15.6853 14.2165 16.4688 13.25 16.4688H8.5V12.9688Z" />
|
|
3
|
+
</svg>
|
package/svg/italic.svg
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" >
|
|
2
|
+
<path d="M10 5C9.4477 5 9 5.4477 9 6C9 6.5523 9.4477 7 10 7H12.3076L9.6924 17H8C7.4477 17 7 17.4477 7 18C7 18.5523 7.4477 19 8 19H14C14.5523 19 15 18.5523 15 18C15 17.4477 14.5523 17 14 17H11.6924L14.3076 7H16C16.5523 7 17 6.5523 17 6C17 5.4477 16.5523 5 16 5H10Z" />
|
|
3
|
+
</svg>
|
package/svg/strike.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" >
|
|
2
|
+
<path d="M4 11C3.4477 11 3 11.4477 3 12C3 12.5523 3.4477 13 4 13H20C20.5523 13 21 12.5523 21 12C21 11.4477 20.5523 11 20 11H4Z" />
|
|
3
|
+
<path d="M12 4C9.5147 4 7.5 5.7909 7.5 8C7.5 8.7286 7.7196 9.4036 8.1003 9.985C8.4029 10.4473 9.0231 10.5767 9.4854 10.2741C9.9477 9.9715 10.0771 9.3513 9.7745 8.889C9.6007 8.6235 9.5 8.3231 9.5 8C9.5 6.9601 10.5293 6 12 6C13.4707 6 14.5 6.9601 14.5 8C14.5 8.5523 14.9477 9 15.5 9C16.0523 9 16.5 8.5523 16.5 8C16.5 5.7909 14.4853 4 12 4Z" />
|
|
4
|
+
<path d="M14.7825 14C15.2241 14.5666 15.5 15.2462 15.5 16C15.5 17.0399 14.4707 18 13 18H11C9.5293 18 8.5 17.0399 8.5 16C8.5 15.4477 8.0523 15 7.5 15C6.9477 15 6.5 15.4477 6.5 16C6.5 18.2091 8.5147 20 11 20H13C15.4853 20 17.5 18.2091 17.5 16C17.5 15.2818 17.2851 14.6082 16.9106 14H14.7825Z" />
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" >
|
|
2
|
+
<path d="M7 4C6.4477 4 6 4.4477 6 5V11C6 14.3137 8.6863 17 12 17C15.3137 17 18 14.3137 18 11V5C18 4.4477 17.5523 4 17 4C16.4477 4 16 4.4477 16 5V11C16 13.2091 14.2091 15 12 15C9.7909 15 8 13.2091 8 11V5C8 4.4477 7.5523 4 7 4Z" />
|
|
3
|
+
<path d="M5 19C4.4477 19 4 19.4477 4 20C4 20.5523 4.4477 21 5 21H19C19.5523 21 20 20.5523 20 20C20 19.4477 19.5523 19 19 19H5Z" />
|
|
4
|
+
</svg>
|