@brandup/ui-textbox 1.0.30 → 1.0.36
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 +83 -1
- package/package.json +9 -5
- package/source/index.ts +13 -0
- package/source/textbox.less +19 -36
- package/source/textbox.ts +146 -255
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://dev.azure.com/brandup/BrandUp%20Core/_build/latest?definitionId=81&branchName=main)
|
|
4
4
|
|
|
5
|
-
Компонент текстового
|
|
5
|
+
Компонент текстового поля. Заменяет стандартные `<input>` и `<textarea>`, добавляя расширенное поведение: счётчик символов, кнопку копирования, фильтрацию ввода по типу, валидацию и опциональное форматирование текста (жирный, курсив, зачёркивание, подчёркивание).
|
|
6
|
+
|
|
7
|
+
Вся работа с редактируемой областью вынесена в [`@brandup/ui-richeditor`](../brandup-ui-richeditor): `TextBox` создаёт скрытое поле формы и компонует над ним `RichEditor`. Экземпляр редактора доступен через свойство `editor`.
|
|
6
8
|
|
|
7
9
|
## Установка
|
|
8
10
|
|
|
@@ -51,6 +53,72 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
51
53
|
| `data-copy-button` | Добавляет кнопку копирования значения в буфер обмена |
|
|
52
54
|
| `data-allow-empty-strings` | Разрешает значение из одних пробелов |
|
|
53
55
|
| `data-readonly` | Альтернативный способ задать режим только для чтения |
|
|
56
|
+
| `data-format` | Включает форматирование текста (только для `type="text"`) |
|
|
57
|
+
| `data-format-tools` | Состав инструментов форматирования через пробел (по умолчанию все): `bold italic strike underline` |
|
|
58
|
+
| `data-format-storage` | Формат хранения значения: `html` (по умолчанию) или `markdown` |
|
|
59
|
+
| `data-format-md-bold` | Markdown-маркер для жирного (по умолчанию `**`) |
|
|
60
|
+
| `data-format-md-italic` | Markdown-маркер для курсива (по умолчанию `*`) |
|
|
61
|
+
| `data-format-md-strike` | Markdown-маркер для зачёркивания (по умолчанию `~~`) |
|
|
62
|
+
| `data-format-md-underline` | Markdown-маркер для подчёркивания (по умолчанию `++`) |
|
|
63
|
+
|
|
64
|
+
## Форматирование текста
|
|
65
|
+
|
|
66
|
+
Атрибут `data-format` включает панель форматирования с инструментами: жирный, курсив, зачёркивание, подчёркивание. Панель всплывает над контролом, пока он в фокусе (класс состояния `focused`). Работает поверх существующего `contenteditable`-редактора, поэтому доступно только для текстового ввода (`type="text"` и `<textarea>`).
|
|
67
|
+
|
|
68
|
+
```html
|
|
69
|
+
<!-- все инструменты, хранение в HTML -->
|
|
70
|
+
<textarea data-content-script="textbox" data-format></textarea>
|
|
71
|
+
|
|
72
|
+
<!-- только жирный и курсив, хранение в Markdown -->
|
|
73
|
+
<input
|
|
74
|
+
type="text"
|
|
75
|
+
data-content-script="textbox"
|
|
76
|
+
data-format
|
|
77
|
+
data-format-tools="bold italic"
|
|
78
|
+
data-format-storage="markdown"
|
|
79
|
+
/>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Состав инструментов — `data-format-tools`
|
|
83
|
+
|
|
84
|
+
Список через пробел из значений `bold`, `italic`, `strike`, `underline`. Неизвестные значения игнорируются. Если атрибут не задан — включаются все инструменты.
|
|
85
|
+
|
|
86
|
+
### Применение формата
|
|
87
|
+
|
|
88
|
+
- Формат — **переключатель** (toggle): повторное применение к уже отформатированному тексту снимает его.
|
|
89
|
+
- Форматирование применяется **к слову целиком**: если курсор стоит внутри слова без выделения или выделена лишь его часть — формат охватывает всё слово. При выделении части нескольких слов каждая граница доводится до целого слова. Исходное выделение/каретка после применения сохраняются (слово не выделяется автоматически).
|
|
90
|
+
- **Режим набора**: если под кареткой нет слова (курсор между пробелами или поле пустое), кнопка/хоткей не форматируют текст, а включают «ожидающий» формат — он подсветится активным и применится к следующему введённому тексту. Режим сбрасывается при перемещении каретки, клике или потере фокуса.
|
|
91
|
+
- Кнопка инструмента подсвечивается (`active`), когда выделение целиком отформатировано этим инструментом или активен режим набора.
|
|
92
|
+
- Реализация работает на стандартном Selection/Range API — без устаревшего `document.execCommand`, поэтому разметка всегда семантическая (`<b>`, `<i>`, `<s>`, `<u>`) и предсказуема между браузерами.
|
|
93
|
+
|
|
94
|
+
### Хоткеи
|
|
95
|
+
|
|
96
|
+
`Ctrl/Cmd+B` — жирный, `Ctrl/Cmd+I` — курсив, `Ctrl/Cmd+U` — подчёркивание. Зачёркивание включается только кнопкой. Хоткеи отключённых инструментов перехватываются, чтобы не срабатывало форматирование браузера.
|
|
97
|
+
|
|
98
|
+
### Формат хранения — `data-format-storage`
|
|
99
|
+
|
|
100
|
+
Значение синхронизируется в скрытое поле в выбранном формате:
|
|
101
|
+
|
|
102
|
+
| Значение | Хранение | Поддерживаемые теги/маркеры |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `html` (по умолчанию) | Санитизированный HTML | `<b>`, `<i>`, `<s>`, `<u>`, переводы строк через `<br>` |
|
|
105
|
+
| `markdown` | Лёгкая разметка | `**жирный**`, `*курсив*`, `~~зачёркнутый~~`, `++подчёркнутый++` |
|
|
106
|
+
|
|
107
|
+
Маркеры для каждого инструмента настраиваются атрибутами `data-format-md-<tool>` (актуально только при `data-format-storage="markdown"`):
|
|
108
|
+
|
|
109
|
+
```html
|
|
110
|
+
<textarea
|
|
111
|
+
data-content-script="textbox"
|
|
112
|
+
data-format
|
|
113
|
+
data-format-storage="markdown"
|
|
114
|
+
data-format-md-italic="_"
|
|
115
|
+
data-format-md-bold="__"
|
|
116
|
+
></textarea>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
При разборе маркеры применяются по убыванию длины, поэтому более длинный маркер (`__`) корректно срабатывает раньше короткого-префикса (`_`). Маркеры разных инструментов должны различаться.
|
|
120
|
+
|
|
121
|
+
> Markdown-режим не имеет стандартного маркера для подчёркивания — по умолчанию используется `++текст++`. Глубоко вложенные комбинации форматов (например, жирный внутри курсива) гарантированно сохраняются только в режиме `html`.
|
|
54
122
|
|
|
55
123
|
## API
|
|
56
124
|
|
|
@@ -75,6 +143,10 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
75
143
|
| `copyButton` | `boolean` | Наличие кнопки копирования |
|
|
76
144
|
| `symbolCounter` | `boolean` | Наличие счётчика символов |
|
|
77
145
|
| `placeholder` | `string \| null` | Текст-заглушка |
|
|
146
|
+
| `format` | `boolean` | Включено ли форматирование |
|
|
147
|
+
| `formatStorage` | `FormatStorage` | Формат хранения: `"html"` \| `"markdown"` |
|
|
148
|
+
| `formatTools` | `FormatTool[]` | Включённые инструменты форматирования |
|
|
149
|
+
| `formatMarkers` | `FormatMarkers` | Markdown-маркеры по инструментам (с учётом переопределений) |
|
|
78
150
|
|
|
79
151
|
### Событие textbox-change
|
|
80
152
|
|
|
@@ -92,6 +164,15 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
92
164
|
textbox.onChange((data) => { ... });
|
|
93
165
|
```
|
|
94
166
|
|
|
167
|
+
## Нормализация пробелов
|
|
168
|
+
|
|
169
|
+
Когда редактирование логически завершено (поле теряет фокус), а также после инициализации и `setValue`, текст нормализуется:
|
|
170
|
+
|
|
171
|
+
- повторяющиеся пробелы/табы схлопываются в один;
|
|
172
|
+
- пробелы по краям каждой строки обрезаются.
|
|
173
|
+
|
|
174
|
+
Форматирование и переносы строк (`<br>`/блоки) при этом сохраняются. Во время набора текста пробелы не трогаются — нормализация выполняется только по завершении. Если нормализация изменила значение, при потере фокуса генерируется событие [`textbox-change`](#событие-textbox-change).
|
|
175
|
+
|
|
95
176
|
## CSS-классы состояний
|
|
96
177
|
|
|
97
178
|
| Класс | Условие |
|
|
@@ -102,3 +183,4 @@ textbox.onChange((data) => { ... });
|
|
|
102
183
|
| `required` | Атрибут `required` задан |
|
|
103
184
|
| `readonly` | Режим только для чтения |
|
|
104
185
|
| `disabled` | Элемент отключён |
|
|
186
|
+
| `active` | На кнопке панели форматирования — формат активен для текущего выделения |
|
package/package.json
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
+
"publishConfig": {
|
|
3
|
+
"access": "public"
|
|
4
|
+
},
|
|
2
5
|
"name": "@brandup/ui-textbox",
|
|
3
6
|
"description": "Textbox control type and styles.",
|
|
4
7
|
"keywords": [
|
|
@@ -21,14 +24,15 @@
|
|
|
21
24
|
"email": "it@brandup.online"
|
|
22
25
|
},
|
|
23
26
|
"license": "Apache-2.0",
|
|
24
|
-
"version": "1.0.
|
|
27
|
+
"version": "1.0.36",
|
|
25
28
|
"main": "source/index.ts",
|
|
26
29
|
"types": "source/index.ts",
|
|
27
30
|
"dependencies": {
|
|
28
|
-
"@brandup/ui": "^2.0.
|
|
29
|
-
"@brandup/ui-helpers": "^2.0.
|
|
30
|
-
"@brandup/ui-input": "^1.0.
|
|
31
|
-
"@brandup/ui-kit": "^1.0.
|
|
31
|
+
"@brandup/ui": "^2.0.5",
|
|
32
|
+
"@brandup/ui-helpers": "^2.0.5",
|
|
33
|
+
"@brandup/ui-input": "^1.0.36",
|
|
34
|
+
"@brandup/ui-kit": "^1.0.36",
|
|
35
|
+
"@brandup/ui-richeditor": "^1.0.36"
|
|
32
36
|
},
|
|
33
37
|
"files": [
|
|
34
38
|
"source",
|
package/source/index.ts
CHANGED
|
@@ -1 +1,14 @@
|
|
|
1
1
|
export { default } from "./textbox";
|
|
2
|
+
export * from "./textbox";
|
|
3
|
+
|
|
4
|
+
// реэкспорт типов/утилит форматирования из @brandup/ui-richeditor для обратной совместимости
|
|
5
|
+
export {
|
|
6
|
+
ALL_FORMAT_TOOLS,
|
|
7
|
+
FORMAT_TOOLS,
|
|
8
|
+
parseFormatTools,
|
|
9
|
+
defaultFormatMarkers,
|
|
10
|
+
normalizeWhitespace,
|
|
11
|
+
type FormatTool,
|
|
12
|
+
type FormatStorage,
|
|
13
|
+
type FormatMarkers,
|
|
14
|
+
} from "@brandup/ui-richeditor";
|
package/source/textbox.less
CHANGED
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
box-sizing: border-box;
|
|
47
47
|
cursor: text;
|
|
48
48
|
position: relative;
|
|
49
|
-
overflow-y: auto;
|
|
50
49
|
z-index: 2;
|
|
51
50
|
margin-right: 5px;
|
|
52
51
|
padding-right: calc(var(--input-padding-lr) - 5px);
|
|
53
52
|
scrollbar-width: 6px;
|
|
53
|
+
overflow-y: auto;
|
|
54
54
|
|
|
55
55
|
&::-webkit-scrollbar {
|
|
56
56
|
width: 6px;
|
|
@@ -71,35 +71,15 @@
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
position: relative;
|
|
74
|
+
// редактор (@brandup/ui-richeditor) — занимает доступное место; внутреннее оформление в richeditor.less
|
|
75
|
+
& .ui-richeditor {
|
|
77
76
|
flex: 1 1 auto;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
outline: none;
|
|
81
|
-
|
|
82
|
-
&:empty {
|
|
83
|
-
&:after {
|
|
84
|
-
content: attr(data-placeholder);
|
|
85
|
-
display: block;
|
|
86
|
-
position: absolute;
|
|
87
|
-
left: 0;
|
|
88
|
-
top: 0;
|
|
89
|
-
right: 0;
|
|
90
|
-
color: var(--placeholder-color);
|
|
91
|
-
font-weight: var(--placeholder-font-weight);
|
|
92
|
-
font-style: var(--placeholder-font-style);
|
|
93
|
-
box-sizing: border-box;
|
|
94
|
-
overflow: hidden;
|
|
95
|
-
white-space: nowrap;
|
|
96
|
-
text-overflow: ellipsis;
|
|
97
|
-
}
|
|
77
|
+
min-width: 0;
|
|
78
|
+
}
|
|
98
79
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
80
|
+
// скрываем счётчик, пока редактор пуст (показан placeholder)
|
|
81
|
+
& .editor:has(.ui-richeditor:empty) .symbols {
|
|
82
|
+
display: none;
|
|
103
83
|
}
|
|
104
84
|
|
|
105
85
|
& .symbols {
|
|
@@ -136,6 +116,16 @@
|
|
|
136
116
|
outline: none;
|
|
137
117
|
transition: all ease 100ms;
|
|
138
118
|
cursor: pointer;
|
|
119
|
+
opacity: 0.7;
|
|
120
|
+
|
|
121
|
+
&.success {
|
|
122
|
+
--svg-fill: var(--color-success);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
&:hover,
|
|
126
|
+
&.success {
|
|
127
|
+
opacity: 1;
|
|
128
|
+
}
|
|
139
129
|
}
|
|
140
130
|
|
|
141
131
|
&:empty {
|
|
@@ -153,15 +143,8 @@
|
|
|
153
143
|
gap: 2px;
|
|
154
144
|
}
|
|
155
145
|
|
|
156
|
-
& .
|
|
146
|
+
& .ui-richeditor {
|
|
157
147
|
flex: 1 10 auto;
|
|
158
|
-
|
|
159
|
-
&:empty {
|
|
160
|
-
&:after {
|
|
161
|
-
white-space: normal;
|
|
162
|
-
text-overflow: unset;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
148
|
}
|
|
166
149
|
}
|
|
167
150
|
|
package/source/textbox.ts
CHANGED
|
@@ -4,6 +4,14 @@ 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 RichEditor, {
|
|
8
|
+
defaultFormatMarkers,
|
|
9
|
+
parseFormatTools,
|
|
10
|
+
type FormatMarkers,
|
|
11
|
+
type FormatStorage,
|
|
12
|
+
type FormatTool,
|
|
13
|
+
type RichEditorOptions,
|
|
14
|
+
} from "@brandup/ui-richeditor";
|
|
7
15
|
import copyIcon from "../svg/copy.svg";
|
|
8
16
|
import doneIcon from "../svg/tick.svg";
|
|
9
17
|
|
|
@@ -20,7 +28,8 @@ type TextBoxEvents = {
|
|
|
20
28
|
};
|
|
21
29
|
|
|
22
30
|
export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAreaElement, TextBoxEvents> {
|
|
23
|
-
private
|
|
31
|
+
private __editor: RichEditor;
|
|
32
|
+
private __inputElem: HTMLElement; // редактируемый элемент (им владеет RichEditor)
|
|
24
33
|
private __symbolsCountElem: HTMLElement;
|
|
25
34
|
private __listenerAbort = new AbortController();
|
|
26
35
|
|
|
@@ -33,6 +42,10 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
33
42
|
readonly inputmode: string;
|
|
34
43
|
readonly symbolCounter: boolean;
|
|
35
44
|
readonly autoFocus: boolean;
|
|
45
|
+
readonly format: boolean;
|
|
46
|
+
readonly formatStorage: FormatStorage;
|
|
47
|
+
readonly formatTools: FormatTool[];
|
|
48
|
+
readonly formatMarkers: FormatMarkers;
|
|
36
49
|
|
|
37
50
|
constructor(valueElem: HTMLInputElement | HTMLTextAreaElement) {
|
|
38
51
|
// определяем тип ввода и нормализуем валидационные атрибуты до super()
|
|
@@ -73,8 +86,24 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
73
86
|
const multyline = valueElem instanceof HTMLTextAreaElement;
|
|
74
87
|
const copyButton = valueElem.hasAttribute("data-copy-button") || valueElem.hasAttribute("data-copybutton");
|
|
75
88
|
const disabled = valueElem.disabled;
|
|
89
|
+
const readonly = valueElem.hasAttribute("readonly") || valueElem.hasAttribute("data-readonly");
|
|
90
|
+
|
|
91
|
+
// форматирование доступно только для обычного текстового ввода
|
|
92
|
+
const format = type === "text" && valueElem.hasAttribute("data-format");
|
|
93
|
+
const formatStorage: FormatStorage =
|
|
94
|
+
valueElem.getAttribute("data-format-storage") === "markdown" ? "markdown" : "html";
|
|
95
|
+
const formatTools = format ? parseFormatTools(valueElem.getAttribute("data-format-tools")) : [];
|
|
96
|
+
|
|
97
|
+
// markdown-маркеры с дефолтами, переопределяются атрибутами data-format-md-<tool>
|
|
98
|
+
const formatMarkers = defaultFormatMarkers();
|
|
99
|
+
if (format) {
|
|
100
|
+
for (const tool of Object.keys(formatMarkers) as FormatTool[]) {
|
|
101
|
+
const marker = valueElem.getAttribute(`data-format-md-${tool}`)?.trim();
|
|
102
|
+
if (marker) formatMarkers[tool] = marker;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
76
105
|
|
|
77
|
-
const inputElem = DOM.tag("div"
|
|
106
|
+
const inputElem = DOM.tag("div");
|
|
78
107
|
const actionsElem = DOM.tag("div", { class: "actions" });
|
|
79
108
|
const symbolsCountElem = DOM.tag("div", { class: "symbols" });
|
|
80
109
|
|
|
@@ -86,15 +115,11 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
86
115
|
|
|
87
116
|
container.classList.remove(INPUT_CLASS);
|
|
88
117
|
|
|
89
|
-
inputElem.tabIndex = valueElem.tabIndex;
|
|
118
|
+
inputElem.tabIndex = disabled ? -1 : valueElem.tabIndex;
|
|
90
119
|
valueElem.tabIndex = -1;
|
|
91
120
|
|
|
92
121
|
if (multyline) container.classList.add("multyline");
|
|
93
122
|
if (symbolCounter) container.classList.add("counter");
|
|
94
|
-
|
|
95
|
-
if (disabled) inputElem.tabIndex = -1;
|
|
96
|
-
else inputElem.contentEditable = "true";
|
|
97
|
-
|
|
98
123
|
if (inputmode) inputElem.inputMode = inputmode;
|
|
99
124
|
|
|
100
125
|
if (copyButton) {
|
|
@@ -107,8 +132,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
107
132
|
actionsElem.insertAdjacentElement("beforeend", buttonElem);
|
|
108
133
|
}
|
|
109
134
|
|
|
110
|
-
inputElem.setAttribute("data-placeholder", placeholder ?? "");
|
|
111
|
-
|
|
112
135
|
// убираем висящую миниатюру, если есть, и вставляем container на место valueElem
|
|
113
136
|
if (valueElem.nextElementSibling) {
|
|
114
137
|
const nextElem = valueElem.nextElementSibling as HTMLElement;
|
|
@@ -128,218 +151,129 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
128
151
|
this.inputmode = inputmode;
|
|
129
152
|
this.multyline = multyline;
|
|
130
153
|
this.copyButton = copyButton;
|
|
154
|
+
this.format = format;
|
|
155
|
+
this.formatStorage = formatStorage;
|
|
156
|
+
this.formatTools = formatTools;
|
|
157
|
+
this.formatMarkers = formatMarkers;
|
|
131
158
|
|
|
132
159
|
this.__inputElem = inputElem;
|
|
133
160
|
this.__symbolsCountElem = symbolsCountElem;
|
|
134
161
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
this.__inputElem.addEventListener(
|
|
166
|
-
"focus",
|
|
167
|
-
() => {
|
|
168
|
-
if (this.disabled) return;
|
|
169
|
-
|
|
170
|
-
this.element.classList.add("focused");
|
|
171
|
-
|
|
172
|
-
if (this.readonly) this.__selectAll();
|
|
173
|
-
else if (!hasInputClick) this.__carretToEnd(); // пыремещаем курсов в конец, если клик не по строке
|
|
174
|
-
},
|
|
175
|
-
{ signal }
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
this.__inputElem.addEventListener(
|
|
179
|
-
"blur",
|
|
180
|
-
() => {
|
|
181
|
-
hasInputClick = false;
|
|
182
|
-
|
|
183
|
-
if (this.disabled) return;
|
|
184
|
-
|
|
185
|
-
this.element.classList.remove("focused");
|
|
186
|
-
|
|
187
|
-
// когда удаляем весь текст, то браузер оставляет один BR, что означает что текста нет
|
|
188
|
-
// удалить BR нужно, чтобы появился placeholder
|
|
189
|
-
if (this.__inputElem.firstChild?.nodeName === "BR") DOM.empty(this.__inputElem);
|
|
190
|
-
},
|
|
191
|
-
{ signal }
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
this.__inputElem.addEventListener(
|
|
195
|
-
"dblclick",
|
|
196
|
-
() => {
|
|
197
|
-
if (this.disabled) return;
|
|
198
|
-
|
|
199
|
-
if (this.copyButton && this.readonly) this.__selectAll();
|
|
200
|
-
},
|
|
201
|
-
{ signal }
|
|
202
|
-
);
|
|
203
|
-
|
|
204
|
-
this.element.addEventListener(
|
|
205
|
-
"paste",
|
|
206
|
-
(e: ClipboardEvent) => {
|
|
207
|
-
e.preventDefault();
|
|
208
|
-
e.stopPropagation();
|
|
209
|
-
|
|
210
|
-
if (this.readonly || this.disabled) return false;
|
|
211
|
-
|
|
212
|
-
let pastedData = e.clipboardData?.getData("text/plain");
|
|
213
|
-
if (!pastedData) return false;
|
|
214
|
-
|
|
215
|
-
if (this.type == "number") {
|
|
216
|
-
const numberData = /[\d\s]+/.exec(pastedData);
|
|
217
|
-
if (numberData && numberData.length) pastedData = numberData[0].replace(/\s/g, "");
|
|
218
|
-
else {
|
|
219
|
-
this.element.classList.add("incorrect");
|
|
220
|
-
window.setTimeout(() => this.element.classList.remove("incorrect"), 300);
|
|
221
|
-
return false;
|
|
222
|
-
}
|
|
162
|
+
// фильтрация ввода по типу, ограничение длины и обработка submit/ошибок — через хуки RichEditor
|
|
163
|
+
// (RichEditor про maxlength/типы не знает; всё это контролирует TextBox)
|
|
164
|
+
const options: RichEditorOptions = {
|
|
165
|
+
format,
|
|
166
|
+
tools: formatTools,
|
|
167
|
+
storage: formatStorage,
|
|
168
|
+
markers: formatMarkers,
|
|
169
|
+
placeholder,
|
|
170
|
+
multiline: multyline,
|
|
171
|
+
readonly,
|
|
172
|
+
// тулбар позиционируется относительно контейнера TextBox (а не document.body)
|
|
173
|
+
toolbarContainer: container,
|
|
174
|
+
value: valueElem.value,
|
|
175
|
+
onReject: () => this.__toIncorrect(),
|
|
176
|
+
onEnter: () => this.__submitForm(),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// допустим ли вводимый символ по типу
|
|
180
|
+
const typeAllowsChar = (char: string) => {
|
|
181
|
+
if (type === "number") return /\d/.test(char);
|
|
182
|
+
if (type === "email") return /[a-zA-Z\d.\-_@]/.test(char);
|
|
183
|
+
return true;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (type === "number" || type === "email" || maxlength > 0) {
|
|
187
|
+
options.filterChar = (char) => {
|
|
188
|
+
// достигнут лимит длины — отклоняем (выделение будет заменено, поэтому вычитаем его длину)
|
|
189
|
+
if (maxlength > 0) {
|
|
190
|
+
const selectionLength = window.getSelection()?.toString().length ?? 0;
|
|
191
|
+
if (this.__editor.getLength() - selectionLength >= maxlength) return false;
|
|
223
192
|
}
|
|
193
|
+
return typeAllowsChar(char);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
224
196
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (this.maxlength > 0) {
|
|
229
|
-
// обрезаем вставляемый текст по кол-ву оставшихся символов для ввода
|
|
230
|
-
|
|
231
|
-
const selectionLength = selection.toString().length;
|
|
232
|
-
const currentTextLength = this.__getTextLength();
|
|
233
|
-
const leftSymbols = this.maxlength - currentTextLength + selectionLength; // осталось символов для ввода
|
|
197
|
+
if (type === "number" || maxlength > 0) {
|
|
198
|
+
options.filterPaste = (text) => {
|
|
199
|
+
let pasted = text;
|
|
234
200
|
|
|
235
|
-
|
|
201
|
+
if (type === "number") {
|
|
202
|
+
const numberData = /[\d\s]+/.exec(pasted);
|
|
203
|
+
if (!numberData || !numberData.length) return null;
|
|
204
|
+
pasted = numberData[0].replace(/\s/g, "");
|
|
236
205
|
}
|
|
237
206
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (!this.multyline) {
|
|
244
|
-
fragment.appendChild(document.createTextNode(output.join(" ")));
|
|
245
|
-
} else {
|
|
246
|
-
output.forEach((line, index) => {
|
|
247
|
-
if (index > 0) fragment.appendChild(document.createElement("br"));
|
|
248
|
-
|
|
249
|
-
fragment.appendChild(document.createTextNode(line));
|
|
250
|
-
});
|
|
207
|
+
// обрезаем по количеству оставшихся символов (с учётом замены выделения)
|
|
208
|
+
if (maxlength > 0) {
|
|
209
|
+
const selectionLength = window.getSelection()?.toString().length ?? 0;
|
|
210
|
+
const left = maxlength - this.__editor.getLength() + selectionLength;
|
|
211
|
+
if (pasted.length > left) pasted = pasted.substring(0, Math.max(0, left));
|
|
251
212
|
}
|
|
252
213
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// Вставляем текст
|
|
257
|
-
selection.getRangeAt(0).insertNode(fragment);
|
|
258
|
-
|
|
259
|
-
// Перемещаем курсор в конец вставленной области
|
|
260
|
-
selection.setPosition(selection.focusNode, selection.focusOffset);
|
|
261
|
-
|
|
262
|
-
this.__applyValue();
|
|
214
|
+
return pasted;
|
|
215
|
+
};
|
|
216
|
+
}
|
|
263
217
|
|
|
264
|
-
|
|
265
|
-
},
|
|
266
|
-
{ signal }
|
|
267
|
-
);
|
|
218
|
+
this.__editor = new RichEditor(inputElem, options);
|
|
268
219
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const isChar = e.key.length === 1;
|
|
220
|
+
// RichEditor не знает про disabled — отключаем редактирование на стороне TextBox
|
|
221
|
+
// (визуал даёт класс .disabled от InputControl: затемнение, user-select: none)
|
|
222
|
+
if (disabled) inputElem.contentEditable = "false";
|
|
273
223
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
e.stopPropagation();
|
|
277
|
-
return false;
|
|
278
|
-
}
|
|
224
|
+
// синхронизируем скрытое поле с нормализованным содержимым редактора (без события)
|
|
225
|
+
this.__valueElem.value = this.__editor.getValue();
|
|
279
226
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (currentTextLength >= this.maxlength) {
|
|
283
|
-
e.preventDefault();
|
|
284
|
-
e.stopPropagation();
|
|
227
|
+
this.__initLogic();
|
|
228
|
+
this.__refreshSymbolsCount();
|
|
285
229
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
}
|
|
230
|
+
if (this.autoFocus && !IS_TOUCH_DEVICE && !disabled && !readonly) this.__editor.focus();
|
|
231
|
+
}
|
|
290
232
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
switch (this.type) {
|
|
295
|
-
case "number":
|
|
296
|
-
if (!/\d/.test(e.key)) isIncorrect = true;
|
|
297
|
-
break;
|
|
298
|
-
case "email":
|
|
299
|
-
if (!/[a-zA-Z\d.\-_@]/.test(e.key)) isIncorrect = true;
|
|
300
|
-
break;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (isIncorrect) {
|
|
304
|
-
e.preventDefault();
|
|
305
|
-
e.stopPropagation();
|
|
306
|
-
|
|
307
|
-
this.__toIncorrect();
|
|
308
|
-
return false;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
233
|
+
private __initLogic() {
|
|
234
|
+
const { signal } = this.__listenerAbort;
|
|
235
|
+
const editable = this.__inputElem;
|
|
311
236
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
this.__submitForm();
|
|
316
|
-
return false;
|
|
317
|
-
}
|
|
237
|
+
// изменения редактора → значение формы, счётчик, валидность, событие
|
|
238
|
+
this.__editor.onChange((data) => {
|
|
239
|
+
this.__valueElem.value = data.value;
|
|
318
240
|
|
|
319
|
-
|
|
320
|
-
},
|
|
321
|
-
{ signal }
|
|
322
|
-
);
|
|
323
|
-
|
|
324
|
-
this.__inputElem.addEventListener(
|
|
325
|
-
"input",
|
|
326
|
-
() => {
|
|
327
|
-
if (this.multyline && this.__inputElem.children.length === 1) {
|
|
328
|
-
const child = this.__inputElem.children.item(0);
|
|
329
|
-
if (child && child.tagName === "BR") this.__inputElem.innerHTML = "";
|
|
330
|
-
}
|
|
241
|
+
this.__refreshSymbolsCount();
|
|
331
242
|
|
|
332
|
-
|
|
243
|
+
let clearInvalidState = true;
|
|
244
|
+
if (this.element.classList.contains("invalid")) clearInvalidState = this.validate();
|
|
245
|
+
if (clearInvalidState) this.element.classList.remove("invalid");
|
|
333
246
|
|
|
334
|
-
|
|
247
|
+
this.__onChange();
|
|
248
|
+
});
|
|
335
249
|
|
|
336
|
-
|
|
250
|
+
// состояние фокуса контрола (рамка/заливка) — на корневом элементе
|
|
251
|
+
editable.addEventListener("focus", () => !this.disabled && this.element.classList.add("focused"), { signal });
|
|
252
|
+
editable.addEventListener("blur", () => !this.disabled && this.element.classList.remove("focused"), { signal });
|
|
337
253
|
|
|
338
|
-
|
|
254
|
+
// гасим нативный change скрытого поля
|
|
255
|
+
this.__valueElem.addEventListener(
|
|
256
|
+
"change",
|
|
257
|
+
(e: Event) => {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
e.stopImmediatePropagation();
|
|
339
260
|
},
|
|
340
261
|
{ signal }
|
|
341
262
|
);
|
|
342
263
|
|
|
264
|
+
// двойной клик по readonly-полю с кнопкой копирования — выделить всё для копирования
|
|
265
|
+
if (this.copyButton) {
|
|
266
|
+
editable.addEventListener(
|
|
267
|
+
"dblclick",
|
|
268
|
+
() => {
|
|
269
|
+
if (this.disabled || !this.readonly) return;
|
|
270
|
+
editable.focus();
|
|
271
|
+
window.getSelection()?.selectAllChildren(editable);
|
|
272
|
+
},
|
|
273
|
+
{ signal }
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
343
277
|
this.registerCommand("copy-text", async (context) => {
|
|
344
278
|
if (!window.navigator.clipboard || this.disabled) return;
|
|
345
279
|
|
|
@@ -357,37 +291,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
357
291
|
});
|
|
358
292
|
}
|
|
359
293
|
|
|
360
|
-
private __initText() {
|
|
361
|
-
DOM.empty(this.__inputElem);
|
|
362
|
-
|
|
363
|
-
const text = this.__valueElem.value;
|
|
364
|
-
if (text) {
|
|
365
|
-
const lines = text.split(/\n/);
|
|
366
|
-
lines.forEach((line, index) => {
|
|
367
|
-
line = line.trim();
|
|
368
|
-
|
|
369
|
-
if (index === 0) this.__inputElem.append(document.createTextNode(line));
|
|
370
|
-
else {
|
|
371
|
-
const lineElem = document.createElement("div");
|
|
372
|
-
lineElem.textContent = line;
|
|
373
|
-
this.__inputElem.append(lineElem);
|
|
374
|
-
}
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
this.__refreshSymbolsCount();
|
|
379
|
-
|
|
380
|
-
if (this.autoFocus && !IS_TOUCH_DEVICE && !this.disabled && !this.readonly) this.__inputElem.focus();
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
private __applyValue() {
|
|
384
|
-
const newValue = this.__inputElem.innerText.trim();
|
|
385
|
-
this.__valueElem.value = newValue;
|
|
386
|
-
|
|
387
|
-
this.__refreshSymbolsCount();
|
|
388
|
-
this.__onChange();
|
|
389
|
-
}
|
|
390
|
-
|
|
391
294
|
private __toIncorrect() {
|
|
392
295
|
this.element.classList.add("incorrect");
|
|
393
296
|
window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
|
|
@@ -396,7 +299,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
396
299
|
private __refreshSymbolsCount() {
|
|
397
300
|
if (!this.__symbolsCountElem) return;
|
|
398
301
|
|
|
399
|
-
const textLength = this.
|
|
302
|
+
const textLength = this.__editor.getLength();
|
|
400
303
|
let counterValue: string;
|
|
401
304
|
|
|
402
305
|
if (this.maxlength > 0) {
|
|
@@ -408,29 +311,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
408
311
|
this.__symbolsCountElem.textContent = counterValue;
|
|
409
312
|
}
|
|
410
313
|
|
|
411
|
-
private __selectAll() {
|
|
412
|
-
this.__inputElem.focus();
|
|
413
|
-
|
|
414
|
-
window.getSelection()?.selectAllChildren(this.__inputElem);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
private __carretToEnd() {
|
|
418
|
-
const range = document.createRange();
|
|
419
|
-
range.selectNodeContents(this.__inputElem);
|
|
420
|
-
range.collapse(false);
|
|
421
|
-
const sel = window.getSelection();
|
|
422
|
-
if (sel) {
|
|
423
|
-
sel.removeAllRanges();
|
|
424
|
-
sel.addRange(range);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
private __getTextLength() {
|
|
429
|
-
// textContent не вставляет \n между блочными детьми (в отличие от innerText), так что multiline-контент считается корректно;
|
|
430
|
-
// заодно работает в jsdom, где innerText не реализован.
|
|
431
|
-
return this.__inputElem.textContent?.length ?? 0;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
314
|
private __onChange() {
|
|
435
315
|
this.trigger(CHANGE_EVENT, <ChangeEventData>{
|
|
436
316
|
textbox: this,
|
|
@@ -438,6 +318,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
438
318
|
});
|
|
439
319
|
}
|
|
440
320
|
|
|
321
|
+
/** Многострочный режим (textarea). Псевдоним без опечатки в имени. */
|
|
322
|
+
get multiline(): boolean {
|
|
323
|
+
return this.multyline;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Доступ к встроенному редактору (форматирование, выделение и т.п.). */
|
|
327
|
+
get editor(): RichEditor {
|
|
328
|
+
return this.__editor;
|
|
329
|
+
}
|
|
330
|
+
|
|
441
331
|
onChange(handler: (e: ChangeEventData) => void) {
|
|
442
332
|
this.on(CHANGE_EVENT, handler);
|
|
443
333
|
}
|
|
@@ -451,10 +341,8 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
451
341
|
}
|
|
452
342
|
|
|
453
343
|
setValue(value: string): void {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
this.__initText();
|
|
457
|
-
this.__onChange();
|
|
344
|
+
// RichEditor нормализует и сгенерирует change — он синхронизирует скрытое поле и вызовет textbox-change
|
|
345
|
+
this.__editor.setValue(value?.trim() ?? "");
|
|
458
346
|
}
|
|
459
347
|
|
|
460
348
|
override validate(): boolean {
|
|
@@ -464,7 +352,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
464
352
|
|
|
465
353
|
if (this.required && !value) isValid = false;
|
|
466
354
|
|
|
467
|
-
|
|
355
|
+
// длина — по видимому тексту (getLength), а не по сериализованному value:
|
|
356
|
+
// при format/html в value есть теги, в multiline — разделители абзацев \n\n
|
|
357
|
+
if (this.maxlength > 0 && this.maxlength < this.__editor.getLength()) isValid = false;
|
|
468
358
|
}
|
|
469
359
|
|
|
470
360
|
if (!isValid) this.element.classList.add("invalid");
|
|
@@ -475,8 +365,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
475
365
|
|
|
476
366
|
override destroy(): void {
|
|
477
367
|
this.__listenerAbort.abort();
|
|
478
|
-
this.
|
|
368
|
+
this.__editor.destroy();
|
|
479
369
|
|
|
370
|
+
this.__valueElem.tabIndex = this.__inputElem.tabIndex;
|
|
480
371
|
this.element.insertAdjacentElement("afterend", this.__valueElem);
|
|
481
372
|
this.element.remove();
|
|
482
373
|
|