@brandup/ui-textbox 1.0.48 → 1.0.50
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 +5 -5
- package/package.json +4 -4
- package/source/textbox.less +51 -52
- package/source/textbox.ts +117 -103
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
56
56
|
| `data-format` | Включает форматирование текста (только для `type="text"`) |
|
|
57
57
|
| `data-format-tools` | Состав инструментов форматирования через пробел (по умолчанию все): `bold italic strike underline spoiler code` |
|
|
58
58
|
| `data-editor-actions` | Кнопки действий в панели через пробел (по умолчанию нет): `erase undo redo` |
|
|
59
|
-
| `data-blocks` | Типы блоков многострочного поля через
|
|
59
|
+
| `data-blocks` | Типы блоков многострочного поля через пробел: `quote code`. Подключаются только объявлением — без атрибута поле остаётся обычным текстом, и панель не показывается |
|
|
60
60
|
| `data-format-storage` | Формат хранения значения: `html` (по умолчанию) или `markdown` |
|
|
61
61
|
| `data-format-md-<tool>` | Markdown-маркер инструмента: `bold` — `**`, `italic` — `_`, `strike` — `~`, `underline` — `__`, `spoiler` — `\|\|`, `code` — `` ` `` |
|
|
62
62
|
|
|
@@ -64,8 +64,6 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
64
64
|
|
|
65
65
|
Атрибут `data-format` включает панель форматирования с инструментами: жирный, курсив, зачёркивание, подчёркивание, спойлер, моноширинный. Панель всплывает над контролом, пока он в фокусе (класс состояния `focused`). Работает поверх существующего `contenteditable`-редактора, поэтому доступно только для текстового ввода (`type="text"` и `<textarea>`).
|
|
66
66
|
|
|
67
|
-
Кнопка спойлера в панели временно скрыта (см. [`@brandup/ui-richeditor`](../brandup-ui-richeditor)): сам инструмент работает — значение разбирается, показывается и сохраняется.
|
|
68
|
-
|
|
69
67
|
```html
|
|
70
68
|
<!-- все инструменты, хранение в HTML -->
|
|
71
69
|
<textarea data-content-script="textbox" data-format></textarea>
|
|
@@ -92,12 +90,14 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
92
90
|
|
|
93
91
|
### Блоки — `data-blocks`
|
|
94
92
|
|
|
95
|
-
В многострочном поле (`<textarea>`) верхний уровень состоит из блоков. Обычный текст доступен всегда, цитата (`quote`) и блок кода (`code`)
|
|
93
|
+
В многострочном поле (`<textarea>`) верхний уровень состоит из блоков. Обычный текст доступен всегда, а цитата (`quote`) и блок кода (`code`) подключаются явно — списком через пробел:
|
|
96
94
|
|
|
97
95
|
```html
|
|
98
96
|
<textarea data-content-script="textbox" data-format data-blocks="quote"></textarea>
|
|
99
97
|
```
|
|
100
98
|
|
|
99
|
+
Без атрибута поле остаётся обычным текстом, и панель форматирования сама по себе не появляется — как и действия, блоки включают объявлением. Пустое значение атрибута равносильно его отсутствию.
|
|
100
|
+
|
|
101
101
|
**Enter** внутри блока заканчивает его, **Shift+Enter** переносит строку внутри, **Backspace** в начале блока возвращает обычный текст. Разбор, хранение и правку блоков описывает [`@brandup/ui-richeditor`](../brandup-ui-richeditor).
|
|
102
102
|
|
|
103
103
|
### Применение формата
|
|
@@ -150,7 +150,7 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
150
150
|
| `setValue(value: string): void` | Устанавливает значение программно |
|
|
151
151
|
| `hasValue(): boolean` | `true`, если значение не пустое |
|
|
152
152
|
| `validate(): boolean` | Валидирует значение, добавляет/снимает класс `invalid` |
|
|
153
|
-
| `focus(): void` |
|
|
153
|
+
| `focus(): void` | Ведёт фокус в редактор и прокручивает контрол в видимую область. В выключенном поле ничего не делает — как нативный `disabled` input; поле только для чтения фокусируется |
|
|
154
154
|
| `destroy(): void` | Восстанавливает исходный DOM-элемент и освобождает ресурсы |
|
|
155
155
|
|
|
156
156
|
### Свойства
|
package/package.json
CHANGED
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
"email": "it@brandup.online"
|
|
25
25
|
},
|
|
26
26
|
"license": "Apache-2.0",
|
|
27
|
-
"version": "1.0.
|
|
27
|
+
"version": "1.0.50",
|
|
28
28
|
"main": "source/index.ts",
|
|
29
29
|
"types": "source/index.ts",
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@brandup/ui": "^2.0.9",
|
|
32
32
|
"@brandup/ui-helpers": "^2.0.9",
|
|
33
|
-
"@brandup/ui-input": "^1.0.
|
|
34
|
-
"@brandup/ui-kit": "^1.0.
|
|
35
|
-
"@brandup/ui-richeditor": "^1.0.
|
|
33
|
+
"@brandup/ui-input": "^1.0.50",
|
|
34
|
+
"@brandup/ui-kit": "^1.0.50",
|
|
35
|
+
"@brandup/ui-richeditor": "^1.0.50"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"source",
|
package/source/textbox.less
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
|
+
@import (reference) "@brandup/ui-input/source/input.less";
|
|
2
|
+
|
|
3
|
+
// Значения по умолчанию у переменных кита — их же канонические значения (см. vars.less кита):
|
|
4
|
+
// без темы кита контрол выглядит так же, как с темой по умолчанию, а не разваливается.
|
|
5
|
+
|
|
1
6
|
:root {
|
|
2
7
|
--textbox-maxheight: 200px;
|
|
3
8
|
--textbox-multyline-height: 100px;
|
|
4
9
|
}
|
|
5
10
|
|
|
6
11
|
.ui-textbox {
|
|
7
|
-
min-height: var(--input-height);
|
|
12
|
+
min-height: var(--input-height, 46px);
|
|
8
13
|
max-height: var(--textbox-maxheight);
|
|
9
14
|
display: flex;
|
|
10
15
|
flex-flow: row nowrap;
|
|
11
16
|
align-items: stretch;
|
|
12
17
|
position: relative;
|
|
13
|
-
background-color: var(--input-fill);
|
|
14
|
-
border-radius: var(--input-border-radius); // чтобы заливка при состояниях не выходила за пределы импута
|
|
18
|
+
background-color: var(--input-fill, #fff);
|
|
19
|
+
border-radius: var(--input-border-radius, 0); // чтобы заливка при состояниях не выходила за пределы импута
|
|
15
20
|
box-sizing: border-box;
|
|
16
21
|
outline: none;
|
|
17
22
|
transition: background-color ease 60ms;
|
|
18
|
-
line-height: var(--input-line-height);
|
|
19
|
-
font-size: var(--input-font-size);
|
|
20
|
-
font-weight: var(--input-font-weight);
|
|
21
|
-
color: var(--input-color);
|
|
23
|
+
line-height: var(--input-line-height, 1.2rem);
|
|
24
|
+
font-size: var(--input-font-size, 14px);
|
|
25
|
+
font-weight: var(--input-font-weight, inherit);
|
|
26
|
+
color: var(--input-color, #222);
|
|
22
27
|
|
|
23
|
-
--svg-fill: var(--input-color);
|
|
28
|
+
--svg-fill: var(--input-color, #222);
|
|
24
29
|
--svg-size: 18px;
|
|
25
30
|
|
|
26
31
|
& .decorator {
|
|
@@ -30,8 +35,8 @@
|
|
|
30
35
|
top: 0;
|
|
31
36
|
width: 100%;
|
|
32
37
|
height: 100%;
|
|
33
|
-
border: var(--input-border-type) var(--input-border-width) var(--input-border-color);
|
|
34
|
-
border-radius: var(--input-border-radius);
|
|
38
|
+
border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
|
|
39
|
+
border-radius: var(--input-border-radius, 0);
|
|
35
40
|
z-index: 1;
|
|
36
41
|
box-sizing: border-box;
|
|
37
42
|
transition: border-color ease 120ms;
|
|
@@ -42,13 +47,11 @@
|
|
|
42
47
|
display: flex;
|
|
43
48
|
flex-flow: row nowrap;
|
|
44
49
|
gap: 15px;
|
|
45
|
-
padding: calc((var(--input-height) - var(--input-line-height)) / 2) var(--input-padding-lr);
|
|
50
|
+
padding: calc((var(--input-height, 46px) - var(--input-line-height, 1.2rem)) / 2) var(--input-padding-lr, 15px);
|
|
46
51
|
box-sizing: border-box;
|
|
47
52
|
cursor: text;
|
|
48
53
|
position: relative;
|
|
49
54
|
z-index: 2;
|
|
50
|
-
margin-right: 5px;
|
|
51
|
-
padding-right: calc(var(--input-padding-lr) - 5px);
|
|
52
55
|
overflow-y: auto; // полоса прокрутки — общая, от .ui-scrollable кита
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -81,15 +84,15 @@
|
|
|
81
84
|
display: flex;
|
|
82
85
|
flex-flow: row nowrap;
|
|
83
86
|
align-items: center;
|
|
84
|
-
height: var(--input-height);
|
|
87
|
+
height: var(--input-height, 46px);
|
|
85
88
|
|
|
86
89
|
& button {
|
|
87
90
|
display: flex;
|
|
88
91
|
flex-flow: row nowrap;
|
|
89
92
|
justify-content: center;
|
|
90
93
|
align-items: center;
|
|
91
|
-
width: var(--input-height);
|
|
92
|
-
height: var(--input-height);
|
|
94
|
+
width: var(--input-height, 46px);
|
|
95
|
+
height: var(--input-height, 46px);
|
|
93
96
|
background: transparent;
|
|
94
97
|
border: none;
|
|
95
98
|
padding: 0;
|
|
@@ -100,7 +103,7 @@
|
|
|
100
103
|
opacity: 0.7;
|
|
101
104
|
|
|
102
105
|
&.success {
|
|
103
|
-
--svg-fill: var(--color-success);
|
|
106
|
+
--svg-fill: var(--color-success, green);
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
&:hover,
|
|
@@ -143,30 +146,30 @@
|
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
&:hover {
|
|
146
|
-
--input-border-color: var(--hover--input-border-color);
|
|
147
|
-
--input-fill: var(--hover--input-fill);
|
|
148
|
-
--input-color: var(--hover--input-color);
|
|
149
|
-
--svg-fill: var(--hover--input-color);
|
|
149
|
+
--input-border-color: var(--hover--input-border-color, #666);
|
|
150
|
+
--input-fill: var(--hover--input-fill, #fff);
|
|
151
|
+
--input-color: var(--hover--input-color, inherit);
|
|
152
|
+
--svg-fill: var(--hover--input-color, inherit);
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
&.focused {
|
|
153
|
-
--input-border-color: var(--focus--input-border-color);
|
|
154
|
-
--input-fill: var(--focus--input-fill);
|
|
155
|
-
--input-color: var(--focus--input-color);
|
|
156
|
-
--svg-fill: var(--focus--input-color);
|
|
156
|
+
--input-border-color: var(--focus--input-border-color, #222);
|
|
157
|
+
--input-fill: var(--focus--input-fill, #fff);
|
|
158
|
+
--input-color: var(--focus--input-color, inherit);
|
|
159
|
+
--svg-fill: var(--focus--input-color, inherit);
|
|
157
160
|
}
|
|
158
161
|
|
|
159
162
|
&.readonly {
|
|
160
|
-
--input-fill: var(--readonly--input-fill);
|
|
161
|
-
--input-color: var(--readonly--input-color);
|
|
162
|
-
--svg-fill: var(--readonly--input-color);
|
|
163
|
+
--input-fill: var(--readonly--input-fill, #f7f7f7);
|
|
164
|
+
--input-color: var(--readonly--input-color, #222);
|
|
165
|
+
--svg-fill: var(--readonly--input-color, #222);
|
|
163
166
|
}
|
|
164
167
|
|
|
165
168
|
&.disabled {
|
|
166
|
-
--input-border-color: var(--disabled--input-border-color);
|
|
167
|
-
--input-fill: var(--disabled--input-fill);
|
|
168
|
-
--input-color: var(--disabled--input-color);
|
|
169
|
-
--svg-fill: var(--disabled--input-color);
|
|
169
|
+
--input-border-color: var(--disabled--input-border-color, #aaa);
|
|
170
|
+
--input-fill: var(--disabled--input-fill, #eee);
|
|
171
|
+
--input-color: var(--disabled--input-color, #a2a2a2);
|
|
172
|
+
--svg-fill: var(--disabled--input-color, #a2a2a2);
|
|
170
173
|
|
|
171
174
|
& .editor {
|
|
172
175
|
user-select: none;
|
|
@@ -178,42 +181,38 @@
|
|
|
178
181
|
cursor: default;
|
|
179
182
|
|
|
180
183
|
& svg {
|
|
181
|
-
fill: var(--disabled--input-color);
|
|
184
|
+
fill: var(--disabled--input-color, #a2a2a2);
|
|
182
185
|
}
|
|
183
186
|
}
|
|
184
187
|
}
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
&.invalid {
|
|
188
|
-
--input-border-color: var(--invalid--input-border-color);
|
|
189
|
-
--input-fill: var(--invalid--input-fill);
|
|
190
|
-
--input-color: var(--invalid--input-color);
|
|
191
|
-
--placeholder-color: var(--invalid--input-color);
|
|
192
|
-
--svg-fill: var(--invalid--input-color);
|
|
191
|
+
--input-border-color: var(--invalid--input-border-color, red);
|
|
192
|
+
--input-fill: var(--invalid--input-fill, rgb(255, 220, 220));
|
|
193
|
+
--input-color: var(--invalid--input-color, inherit);
|
|
194
|
+
--placeholder-color: var(--invalid--input-color, inherit);
|
|
195
|
+
--svg-fill: var(--invalid--input-color, inherit);
|
|
193
196
|
|
|
194
197
|
& .decorator {
|
|
195
|
-
transition-delay:
|
|
198
|
+
transition-delay: 0s;
|
|
196
199
|
}
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
&.incorrect {
|
|
200
|
-
--input-fill: var(--incorrect--input-fill);
|
|
201
|
-
--input-color: var(--incorrect--input-color);
|
|
202
|
-
--svg-fill: var(--incorrect--input-color);
|
|
203
|
+
--input-fill: var(--incorrect--input-fill, rgb(255, 220, 220));
|
|
204
|
+
--input-color: var(--incorrect--input-color, #222);
|
|
205
|
+
--svg-fill: var(--incorrect--input-color, #222);
|
|
203
206
|
|
|
204
207
|
& .decorator {
|
|
205
|
-
transition-delay:
|
|
208
|
+
transition-delay: 0s;
|
|
206
209
|
}
|
|
207
210
|
}
|
|
208
211
|
}
|
|
209
212
|
|
|
213
|
+
// поле уведено с экрана, но остаётся в форме — общий рецепт из @brandup/ui-input
|
|
210
214
|
.textbox-input {
|
|
211
|
-
|
|
212
|
-
position: absolute;
|
|
213
|
-
width: 1px;
|
|
214
|
-
height: 1px;
|
|
215
|
-
outline: none;
|
|
216
|
-
visibility: collapse;
|
|
215
|
+
.ui-input-hidden-value();
|
|
217
216
|
}
|
|
218
217
|
|
|
219
218
|
textarea.textbox-input ~ .textbox-miniature {
|
|
@@ -221,9 +220,9 @@ textarea.textbox-input ~ .textbox-miniature {
|
|
|
221
220
|
}
|
|
222
221
|
|
|
223
222
|
.textbox-miniature {
|
|
224
|
-
border: var(--input-border-type) var(--input-border-width) var(--input-border-color);
|
|
225
|
-
border-radius: var(--input-border-radius);
|
|
226
|
-
height: var(--input-height);
|
|
223
|
+
border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
|
|
224
|
+
border-radius: var(--input-border-radius, 0);
|
|
225
|
+
height: var(--input-height, 46px);
|
|
227
226
|
background: white;
|
|
228
227
|
box-sizing: border-box;
|
|
229
228
|
}
|
package/source/textbox.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import "./textbox.less"; // стили компонента
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { IS_TOUCH_DEVICE, SCROLLABLE_CLASS } from "@brandup/ui-kit";
|
|
3
|
+
import { EditorInputControl } from "@brandup/ui-input";
|
|
4
|
+
import { IS_TOUCH_DEVICE, POPUP_CLASS, SCROLLABLE_CLASS } from "@brandup/ui-kit";
|
|
5
5
|
import { DOM } from "@brandup/ui";
|
|
6
6
|
import { FuncHelper } from "@brandup/ui-helpers";
|
|
7
7
|
import RichEditor, {
|
|
8
|
+
TOOLBAR_CLASS,
|
|
8
9
|
defaultFormatMarkers,
|
|
9
10
|
parseBlockTypes,
|
|
10
11
|
parseEditorActions,
|
|
@@ -34,11 +35,10 @@ type TextBoxEvents = {
|
|
|
34
35
|
[CHANGE_EVENT]: (data: ChangeEventData) => void;
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
export default class TextBox extends
|
|
38
|
-
private __editor: RichEditor;
|
|
38
|
+
export default class TextBox extends EditorInputControl<RichEditor, ChangeEventData, TextBoxEvents> {
|
|
39
39
|
private __inputElem: HTMLElement; // редактируемый элемент (им владеет RichEditor)
|
|
40
40
|
private __symbolsCountElem: HTMLElement;
|
|
41
|
-
private
|
|
41
|
+
private __incorrectTimer?: number;
|
|
42
42
|
|
|
43
43
|
readonly type: TextBoxType;
|
|
44
44
|
readonly allowEmptyStrings: boolean;
|
|
@@ -98,7 +98,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
98
98
|
const multyline = valueElem instanceof HTMLTextAreaElement;
|
|
99
99
|
const copyButton = valueElem.hasAttribute("data-copy-button") || valueElem.hasAttribute("data-copybutton");
|
|
100
100
|
const disabled = valueElem.disabled;
|
|
101
|
-
const readonly =
|
|
101
|
+
const readonly = TextBox.isReadonly(valueElem);
|
|
102
102
|
|
|
103
103
|
// форматирование доступно только для обычного текстового ввода
|
|
104
104
|
const format = type === "text" && valueElem.hasAttribute("data-format");
|
|
@@ -106,8 +106,11 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
106
106
|
const formatTools = format ? parseFormatTools(valueElem.dataset.formatTools ?? null) : [];
|
|
107
107
|
// кнопки действий панели (очистка формата, отмена, повтор) — подключаются явно
|
|
108
108
|
const editorActions = format ? parseEditorActions(valueElem.dataset.editorActions ?? null) : [];
|
|
109
|
-
//
|
|
110
|
-
|
|
109
|
+
// Типы блоков (цитата, блок кода) — тоже явно и только в многострочном поле. Без атрибута
|
|
110
|
+
// список пуст, а не «не задан»: незаданный редактор понимает как «все», и в поле без единого
|
|
111
|
+
// объявления всплывала бы панель с кнопками цитаты и кода.
|
|
112
|
+
const blocks =
|
|
113
|
+
multyline && valueElem.dataset.blocks !== undefined ? parseBlockTypes(valueElem.dataset.blocks) : [];
|
|
111
114
|
|
|
112
115
|
// markdown-маркеры с дефолтами, переопределяются атрибутами data-format-md-<tool>
|
|
113
116
|
const formatMarkers = defaultFormatMarkers();
|
|
@@ -129,36 +132,39 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
129
132
|
actionsElem,
|
|
130
133
|
]);
|
|
131
134
|
|
|
132
|
-
TextBox.prepareValueElem(valueElem, container, INPUT_CLASS);
|
|
133
|
-
|
|
134
|
-
inputElem.tabIndex = disabled ? -1 : valueElem.tabIndex;
|
|
135
|
-
valueElem.tabIndex = -1;
|
|
136
|
-
|
|
137
135
|
if (multyline) container.classList.add("multyline");
|
|
138
136
|
if (symbolCounter) container.classList.add("counter");
|
|
139
137
|
if (inputmode) inputElem.inputMode = inputmode;
|
|
140
138
|
|
|
141
139
|
if (copyButton) {
|
|
142
|
-
// команда объявляется атрибутом data-command — по нему её ищет обработчик @brandup/ui
|
|
140
|
+
// команда объявляется атрибутом data-command — по нему её ищет обработчик @brandup/ui;
|
|
141
|
+
// type обязателен: кнопка без него внутри формы — submit, и клик отправлял бы форму
|
|
143
142
|
const buttonElem = DOM.tag(
|
|
144
143
|
"button",
|
|
145
|
-
{ command: "copy-text", title: "Скопировать в буфер обмена" },
|
|
144
|
+
{ type: "button", command: "copy-text", title: "Скопировать в буфер обмена" },
|
|
146
145
|
copyIcon
|
|
147
146
|
);
|
|
148
147
|
if (disabled) buttonElem.disabled = true;
|
|
149
148
|
actionsElem.insertAdjacentElement("beforeend", buttonElem);
|
|
150
149
|
}
|
|
151
150
|
|
|
152
|
-
// убираем висящую миниатюру, если есть,
|
|
151
|
+
// убираем висящую миниатюру, если есть, — до обёртки поля: после неё соседом поля станет контейнер
|
|
153
152
|
if (valueElem.nextElementSibling) {
|
|
154
153
|
const nextElem = valueElem.nextElementSibling as HTMLElement;
|
|
155
154
|
if (nextElem.classList.contains(MINIATURE_CLASS)) nextElem.remove();
|
|
156
155
|
}
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
|
|
157
|
+
// скрыть поле, подменить tabindex и обернуть контейнером — общая механика базового класса
|
|
158
|
+
TextBox.wrapValueElem(valueElem, container, INPUT_CLASS, inputElem, disabled);
|
|
159
159
|
|
|
160
160
|
// класс и подменённые атрибуты вернёт базовый класс при destroy
|
|
161
|
-
super(
|
|
161
|
+
super(
|
|
162
|
+
"BrandUp.TextBox",
|
|
163
|
+
container,
|
|
164
|
+
valueElem,
|
|
165
|
+
{ class: INPUT_CLASS, attrs: originalAttrs },
|
|
166
|
+
{ changeEvent: CHANGE_EVENT }
|
|
167
|
+
);
|
|
162
168
|
|
|
163
169
|
this.type = type;
|
|
164
170
|
this.maxlength = maxlength;
|
|
@@ -189,6 +195,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
189
195
|
multiline: multyline,
|
|
190
196
|
blocks,
|
|
191
197
|
readonly,
|
|
198
|
+
// disabled редактор знает сам: запрет правок как в readonly, плюс снятый contenteditable —
|
|
199
|
+
// без фокуса и выделения (визуал даёт класс .disabled от InputControl)
|
|
200
|
+
disabled,
|
|
192
201
|
// тулбар позиционируется относительно контейнера TextBox (а не document.body)
|
|
193
202
|
toolbarContainer: container,
|
|
194
203
|
value: valueElem.value,
|
|
@@ -207,16 +216,11 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
207
216
|
|
|
208
217
|
if (type === "number" || type === "email" || maxlength > 0) {
|
|
209
218
|
options.filterChar = (char) => {
|
|
210
|
-
// достигнут лимит длины — отклоняем (выделение будет заменено,
|
|
211
|
-
if (maxlength > 0)
|
|
212
|
-
const selectionLength = window.getSelection()?.toString().length ?? 0;
|
|
213
|
-
if (this.__editor.getLength() - selectionLength >= maxlength) return false;
|
|
214
|
-
}
|
|
219
|
+
// достигнут лимит длины — отклоняем (выделение будет заменено, его длину учитывает helper)
|
|
220
|
+
if (maxlength > 0 && this.__lengthLeft() <= 0) return false;
|
|
215
221
|
return typeAllowsChar(char);
|
|
216
222
|
};
|
|
217
|
-
}
|
|
218
223
|
|
|
219
|
-
if (type === "number" || maxlength > 0) {
|
|
220
224
|
options.filterPaste = (text) => {
|
|
221
225
|
let pasted = text;
|
|
222
226
|
|
|
@@ -224,12 +228,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
224
228
|
const numberData = /[\d\s]+/.exec(pasted);
|
|
225
229
|
if (!numberData || !numberData.length) return null;
|
|
226
230
|
pasted = numberData[0].replace(/\s/g, "");
|
|
231
|
+
} else if (type === "email") {
|
|
232
|
+
// вставка проходит тот же посимвольный фильтр, что и набор, — иначе запрещённые
|
|
233
|
+
// символы попадали бы в поле через буфер обмена
|
|
234
|
+
pasted = Array.from(pasted).filter(typeAllowsChar).join("");
|
|
235
|
+
if (!pasted) return null;
|
|
227
236
|
}
|
|
228
237
|
|
|
229
238
|
// обрезаем по количеству оставшихся символов (с учётом замены выделения)
|
|
230
239
|
if (maxlength > 0) {
|
|
231
|
-
const
|
|
232
|
-
const left = maxlength - this.__editor.getLength() + selectionLength;
|
|
240
|
+
const left = this.__lengthLeft();
|
|
233
241
|
// не влезает ни одного символа — это отказ, а не пустая вставка: иначе
|
|
234
242
|
// вставка молча не делала бы ничего, тогда как ввод символа на пределе мигает ошибкой
|
|
235
243
|
if (left <= 0) return null;
|
|
@@ -240,17 +248,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
240
248
|
};
|
|
241
249
|
}
|
|
242
250
|
|
|
243
|
-
this.
|
|
244
|
-
|
|
245
|
-
// RichEditor не знает про disabled — отключаем редактирование на стороне TextBox
|
|
246
|
-
// (визуал даёт класс .disabled от InputControl: затемнение, user-select: none)
|
|
247
|
-
if (disabled) inputElem.contentEditable = "false";
|
|
251
|
+
this.__attachEditor(new RichEditor(inputElem, options));
|
|
248
252
|
|
|
249
253
|
// синхронизируем скрытое поле с нормализованным содержимым редактора (без события)
|
|
250
254
|
this.__valueElem.value = this.__editor.getValue();
|
|
251
255
|
|
|
252
256
|
this.__initLogic();
|
|
253
257
|
this.__refreshSymbolsCount();
|
|
258
|
+
// значение из разметки может уже нарушать лимит длины — объявляем это полю сразу,
|
|
259
|
+
// не дожидаясь первой синхронизации
|
|
260
|
+
this.__refreshValidity();
|
|
254
261
|
|
|
255
262
|
if (this.autoFocus && !IS_TOUCH_DEVICE && !disabled && !readonly) this.__editor.focus();
|
|
256
263
|
}
|
|
@@ -274,24 +281,19 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
274
281
|
|
|
275
282
|
// Счётчик — на каждый ввод, а не по change: длина считается по textContent и стоит копейки,
|
|
276
283
|
// тогда как значение поля синхронизируется реже (см. RichEditor.flushChange).
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
e.preventDefault();
|
|
288
|
-
e.stopImmediatePropagation();
|
|
289
|
-
},
|
|
290
|
-
{ signal }
|
|
291
|
-
);
|
|
284
|
+
// Выключенный счётчик скрыт — пересчитывать его на каждое нажатие незачем.
|
|
285
|
+
if (this.symbolCounter) editable.addEventListener("input", () => this.__refreshSymbolsCount(), { signal });
|
|
286
|
+
|
|
287
|
+
// Клик мимо текста — тоже клик по полю: контрол выглядит одним полем ввода, а его
|
|
288
|
+
// внутренние отступы, место справа от текста и полоса действий в редактируемый
|
|
289
|
+
// элемент не входят.
|
|
290
|
+
this.element.addEventListener("mousedown", (e) => this.__focusFromBox(e), { signal });
|
|
291
|
+
|
|
292
|
+
// таймер вспышки "incorrect" не должен переживать компонент — гасим его вместе со слушателями
|
|
293
|
+
signal.addEventListener("abort", () => window.clearTimeout(this.__incorrectTimer));
|
|
292
294
|
|
|
293
|
-
// двойной клик по readonly-полю с кнопкой копирования — выделить всё для копирования
|
|
294
295
|
if (this.copyButton) {
|
|
296
|
+
// двойной клик по readonly-полю с кнопкой копирования — выделить всё для копирования
|
|
295
297
|
editable.addEventListener(
|
|
296
298
|
"dblclick",
|
|
297
299
|
() => {
|
|
@@ -301,38 +303,56 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
301
303
|
},
|
|
302
304
|
{ signal }
|
|
303
305
|
);
|
|
304
|
-
}
|
|
305
306
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
307
|
+
this.registerCommand("copy-text", async (context) => {
|
|
308
|
+
// повторный клик, пока показана галочка, запомнил бы её как исходную иконку —
|
|
309
|
+
// после возврата кнопка так и осталась бы с галочкой
|
|
310
|
+
if (!window.navigator.clipboard || this.disabled || context.target.classList.contains("success"))
|
|
311
|
+
return;
|
|
312
|
+
|
|
313
|
+
// копия значения в поле отстаёт на окно троттлинга — доводим её перед чтением
|
|
314
|
+
this.__syncValue();
|
|
315
|
+
await window.navigator.clipboard.writeText(this.__valueElem.value);
|
|
316
|
+
|
|
317
|
+
const prevHtml = context.target.innerHTML;
|
|
318
|
+
context.target.innerHTML = doneIcon;
|
|
319
|
+
context.target.classList.add("success");
|
|
320
|
+
|
|
321
|
+
// возврат иконки отменяем вместе с компонентом: иначе таймер переживает destroy
|
|
322
|
+
// и дописывает в уже отсоединённую кнопку
|
|
323
|
+
try {
|
|
324
|
+
await FuncHelper.delay(2000, signal);
|
|
325
|
+
} catch {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
310
328
|
|
|
311
|
-
|
|
329
|
+
context.target.innerHTML = prevHtml;
|
|
330
|
+
context.target.classList.remove("success");
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
312
334
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
335
|
+
/**
|
|
336
|
+
* Фокус по клику в контрол мимо текста: внутренние отступы, место справа от последней
|
|
337
|
+
* строки и полоса действий в редактируемый элемент не входят, а выглядит всё это одним
|
|
338
|
+
* полем ввода.
|
|
339
|
+
*
|
|
340
|
+
* Гасим нажатие: браузер иначе снял бы выделение и увёл фокус на корневой элемент, где
|
|
341
|
+
* каретке места нет. Каретку редактор вернёт на прежнее место, а если её ещё не было —
|
|
342
|
+
* в конец текста: клик мимо текста это клик за ним.
|
|
343
|
+
*/
|
|
344
|
+
private __focusFromBox(e: MouseEvent) {
|
|
345
|
+
// в выключенном поле редактируемого элемента нет вовсе — фокусировать нечего
|
|
346
|
+
if (this.disabled) return;
|
|
316
347
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
try {
|
|
320
|
-
await FuncHelper.delay(2000, signal);
|
|
321
|
-
} catch {
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
348
|
+
const target = e.target as HTMLElement | null;
|
|
349
|
+
if (!target || this.__inputElem.contains(target)) return; // в сам текст браузер попадёт и сам
|
|
324
350
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
});
|
|
328
|
-
}
|
|
351
|
+
// кнопка копирования, панель форматирования и её попапы живут внутри контрола и работают сами
|
|
352
|
+
if (target.closest(`button, a, input, textarea, select, .${TOOLBAR_CLASS}, .${POPUP_CLASS}`)) return;
|
|
329
353
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
// валидация, отправка формы, сбор FormData.
|
|
333
|
-
protected override __syncValue(): void {
|
|
334
|
-
this.__editor.flushChange();
|
|
335
|
-
this.__refreshValidity();
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
this.__editor.focus(true);
|
|
336
356
|
}
|
|
337
357
|
|
|
338
358
|
/**
|
|
@@ -344,20 +364,39 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
344
364
|
* разделители абзацев. Нативный maxLength здесь не помощник: он ограничивает набор, но не
|
|
345
365
|
* помечает значение, выставленное из кода.
|
|
346
366
|
*/
|
|
347
|
-
|
|
367
|
+
protected override __refreshValidity(): void {
|
|
348
368
|
if (this.maxlength <= 0) return;
|
|
349
369
|
|
|
350
370
|
const tooLong = this.maxlength < this.__editor.getLength();
|
|
351
371
|
this.__valueElem.setCustomValidity(tooLong ? `Не больше ${this.maxlength} символов.` : "");
|
|
352
372
|
}
|
|
353
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Сколько символов ещё поместится с учётом того, что выделение будет заменено вводом.
|
|
376
|
+
*
|
|
377
|
+
* Выделение берём у самого редактора: чужое выделение страницы (например, при вставке
|
|
378
|
+
* с отсоединённой кареткой) к содержимому отношения не имеет и ёмкость не освобождает.
|
|
379
|
+
* Длину выделения меряем так же, как getLength(), — по textContent, без переносов на границах
|
|
380
|
+
* блоков, которые toString() у Selection добавляет, — иначе многострочное выделение
|
|
381
|
+
* считалось бы длиннее содержимого.
|
|
382
|
+
*/
|
|
383
|
+
private __lengthLeft(): number {
|
|
384
|
+
const selection = this.__editor.selection;
|
|
385
|
+
const selectionLength = selection ? (selection.getRangeAt(0).cloneContents().textContent?.length ?? 0) : 0;
|
|
386
|
+
|
|
387
|
+
return this.maxlength - this.__editor.getLength() + selectionLength;
|
|
388
|
+
}
|
|
389
|
+
|
|
354
390
|
private __toIncorrect() {
|
|
355
391
|
this.element.classList.add("incorrect");
|
|
356
|
-
|
|
392
|
+
|
|
393
|
+
// каждый отказ перезапускает таймер: иначе таймер предыдущего отказа гасил бы вспышку раньше
|
|
394
|
+
window.clearTimeout(this.__incorrectTimer);
|
|
395
|
+
this.__incorrectTimer = window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
|
|
357
396
|
}
|
|
358
397
|
|
|
359
398
|
private __refreshSymbolsCount() {
|
|
360
|
-
if (!this.
|
|
399
|
+
if (!this.symbolCounter) return;
|
|
361
400
|
|
|
362
401
|
const textLength = this.__editor.getLength();
|
|
363
402
|
let counterValue: string;
|
|
@@ -388,24 +427,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
388
427
|
return this.__editor;
|
|
389
428
|
}
|
|
390
429
|
|
|
391
|
-
onChange(handler: (e: ChangeEventData) => void) {
|
|
392
|
-
this.on(CHANGE_EVENT, handler);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
hasValue(): boolean {
|
|
396
|
-
return !!this.getValue();
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
getValue(): string {
|
|
400
|
-
this.__syncValue(); // значение читают снаружи — отложенное изменение сюда обязано попасть
|
|
401
|
-
return this.__valueElem.value.trim();
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
setValue(value: string): void {
|
|
405
|
-
// RichEditor нормализует и сгенерирует change — он синхронизирует скрытое поле и вызовет textbox-change
|
|
406
|
-
this.__editor.setValue(value?.trim() ?? "");
|
|
407
|
-
}
|
|
408
|
-
|
|
409
430
|
override validate(): boolean {
|
|
410
431
|
let isValid = super.validate(); // super синхронизирует значение сам, через __syncValue
|
|
411
432
|
if (isValid) {
|
|
@@ -423,13 +444,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
423
444
|
|
|
424
445
|
return isValid;
|
|
425
446
|
}
|
|
426
|
-
|
|
427
|
-
override destroy(): void {
|
|
428
|
-
this.__listenerAbort.abort();
|
|
429
|
-
this.__editor.destroy();
|
|
430
|
-
|
|
431
|
-
super.destroy(); // снимет слушатели формы и вернёт поле-носитель в исходный вид
|
|
432
|
-
}
|
|
433
447
|
}
|
|
434
448
|
|
|
435
449
|
export interface ChangeEventData {
|