@brandup/ui-textbox 1.0.47 → 1.0.49
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 +1 -1
- package/package.json +4 -4
- package/source/textbox.less +52 -51
- package/source/textbox.ts +112 -101
package/README.md
CHANGED
|
@@ -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.49",
|
|
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.49",
|
|
34
|
+
"@brandup/ui-kit": "^1.0.49",
|
|
35
|
+
"@brandup/ui-richeditor": "^1.0.49"
|
|
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,13 @@
|
|
|
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
55
|
margin-right: 5px;
|
|
51
|
-
padding-right: calc(var(--input-padding-lr) - 5px);
|
|
56
|
+
padding-right: calc(var(--input-padding-lr, 15px) - 5px);
|
|
52
57
|
overflow-y: auto; // полоса прокрутки — общая, от .ui-scrollable кита
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -81,15 +86,15 @@
|
|
|
81
86
|
display: flex;
|
|
82
87
|
flex-flow: row nowrap;
|
|
83
88
|
align-items: center;
|
|
84
|
-
height: var(--input-height);
|
|
89
|
+
height: var(--input-height, 46px);
|
|
85
90
|
|
|
86
91
|
& button {
|
|
87
92
|
display: flex;
|
|
88
93
|
flex-flow: row nowrap;
|
|
89
94
|
justify-content: center;
|
|
90
95
|
align-items: center;
|
|
91
|
-
width: var(--input-height);
|
|
92
|
-
height: var(--input-height);
|
|
96
|
+
width: var(--input-height, 46px);
|
|
97
|
+
height: var(--input-height, 46px);
|
|
93
98
|
background: transparent;
|
|
94
99
|
border: none;
|
|
95
100
|
padding: 0;
|
|
@@ -100,7 +105,7 @@
|
|
|
100
105
|
opacity: 0.7;
|
|
101
106
|
|
|
102
107
|
&.success {
|
|
103
|
-
--svg-fill: var(--color-success);
|
|
108
|
+
--svg-fill: var(--color-success, green);
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
&:hover,
|
|
@@ -143,30 +148,30 @@
|
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
&: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);
|
|
151
|
+
--input-border-color: var(--hover--input-border-color, #666);
|
|
152
|
+
--input-fill: var(--hover--input-fill, #fff);
|
|
153
|
+
--input-color: var(--hover--input-color, inherit);
|
|
154
|
+
--svg-fill: var(--hover--input-color, inherit);
|
|
150
155
|
}
|
|
151
156
|
|
|
152
157
|
&.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);
|
|
158
|
+
--input-border-color: var(--focus--input-border-color, #222);
|
|
159
|
+
--input-fill: var(--focus--input-fill, #fff);
|
|
160
|
+
--input-color: var(--focus--input-color, inherit);
|
|
161
|
+
--svg-fill: var(--focus--input-color, inherit);
|
|
157
162
|
}
|
|
158
163
|
|
|
159
164
|
&.readonly {
|
|
160
|
-
--input-fill: var(--readonly--input-fill);
|
|
161
|
-
--input-color: var(--readonly--input-color);
|
|
162
|
-
--svg-fill: var(--readonly--input-color);
|
|
165
|
+
--input-fill: var(--readonly--input-fill, #f7f7f7);
|
|
166
|
+
--input-color: var(--readonly--input-color, #222);
|
|
167
|
+
--svg-fill: var(--readonly--input-color, #222);
|
|
163
168
|
}
|
|
164
169
|
|
|
165
170
|
&.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);
|
|
171
|
+
--input-border-color: var(--disabled--input-border-color, #aaa);
|
|
172
|
+
--input-fill: var(--disabled--input-fill, #eee);
|
|
173
|
+
--input-color: var(--disabled--input-color, #a2a2a2);
|
|
174
|
+
--svg-fill: var(--disabled--input-color, #a2a2a2);
|
|
170
175
|
|
|
171
176
|
& .editor {
|
|
172
177
|
user-select: none;
|
|
@@ -178,42 +183,38 @@
|
|
|
178
183
|
cursor: default;
|
|
179
184
|
|
|
180
185
|
& svg {
|
|
181
|
-
fill: var(--disabled--input-color);
|
|
186
|
+
fill: var(--disabled--input-color, #a2a2a2);
|
|
182
187
|
}
|
|
183
188
|
}
|
|
184
189
|
}
|
|
185
190
|
}
|
|
186
191
|
|
|
187
192
|
&.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);
|
|
193
|
+
--input-border-color: var(--invalid--input-border-color, red);
|
|
194
|
+
--input-fill: var(--invalid--input-fill, rgb(255, 220, 220));
|
|
195
|
+
--input-color: var(--invalid--input-color, inherit);
|
|
196
|
+
--placeholder-color: var(--invalid--input-color, inherit);
|
|
197
|
+
--svg-fill: var(--invalid--input-color, inherit);
|
|
193
198
|
|
|
194
199
|
& .decorator {
|
|
195
|
-
transition-delay:
|
|
200
|
+
transition-delay: 0s;
|
|
196
201
|
}
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
&.incorrect {
|
|
200
|
-
--input-fill: var(--incorrect--input-fill);
|
|
201
|
-
--input-color: var(--incorrect--input-color);
|
|
202
|
-
--svg-fill: var(--incorrect--input-color);
|
|
205
|
+
--input-fill: var(--incorrect--input-fill, rgb(255, 220, 220));
|
|
206
|
+
--input-color: var(--incorrect--input-color, #222);
|
|
207
|
+
--svg-fill: var(--incorrect--input-color, #222);
|
|
203
208
|
|
|
204
209
|
& .decorator {
|
|
205
|
-
transition-delay:
|
|
210
|
+
transition-delay: 0s;
|
|
206
211
|
}
|
|
207
212
|
}
|
|
208
213
|
}
|
|
209
214
|
|
|
215
|
+
// поле уведено с экрана, но остаётся в форме — общий рецепт из @brandup/ui-input
|
|
210
216
|
.textbox-input {
|
|
211
|
-
|
|
212
|
-
position: absolute;
|
|
213
|
-
width: 1px;
|
|
214
|
-
height: 1px;
|
|
215
|
-
outline: none;
|
|
216
|
-
visibility: collapse;
|
|
217
|
+
.ui-input-hidden-value();
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
textarea.textbox-input ~ .textbox-miniature {
|
|
@@ -221,9 +222,9 @@ textarea.textbox-input ~ .textbox-miniature {
|
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
.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);
|
|
225
|
+
border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
|
|
226
|
+
border-radius: var(--input-border-radius, 0);
|
|
227
|
+
height: var(--input-height, 46px);
|
|
227
228
|
background: white;
|
|
228
229
|
box-sizing: border-box;
|
|
229
230
|
}
|
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");
|
|
@@ -129,36 +129,39 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
129
129
|
actionsElem,
|
|
130
130
|
]);
|
|
131
131
|
|
|
132
|
-
TextBox.prepareValueElem(valueElem, container, INPUT_CLASS);
|
|
133
|
-
|
|
134
|
-
inputElem.tabIndex = disabled ? -1 : valueElem.tabIndex;
|
|
135
|
-
valueElem.tabIndex = -1;
|
|
136
|
-
|
|
137
132
|
if (multyline) container.classList.add("multyline");
|
|
138
133
|
if (symbolCounter) container.classList.add("counter");
|
|
139
134
|
if (inputmode) inputElem.inputMode = inputmode;
|
|
140
135
|
|
|
141
136
|
if (copyButton) {
|
|
142
|
-
// команда объявляется атрибутом data-command — по нему её ищет обработчик @brandup/ui
|
|
137
|
+
// команда объявляется атрибутом data-command — по нему её ищет обработчик @brandup/ui;
|
|
138
|
+
// type обязателен: кнопка без него внутри формы — submit, и клик отправлял бы форму
|
|
143
139
|
const buttonElem = DOM.tag(
|
|
144
140
|
"button",
|
|
145
|
-
{ command: "copy-text", title: "Скопировать в буфер обмена" },
|
|
141
|
+
{ type: "button", command: "copy-text", title: "Скопировать в буфер обмена" },
|
|
146
142
|
copyIcon
|
|
147
143
|
);
|
|
148
144
|
if (disabled) buttonElem.disabled = true;
|
|
149
145
|
actionsElem.insertAdjacentElement("beforeend", buttonElem);
|
|
150
146
|
}
|
|
151
147
|
|
|
152
|
-
// убираем висящую миниатюру, если есть,
|
|
148
|
+
// убираем висящую миниатюру, если есть, — до обёртки поля: после неё соседом поля станет контейнер
|
|
153
149
|
if (valueElem.nextElementSibling) {
|
|
154
150
|
const nextElem = valueElem.nextElementSibling as HTMLElement;
|
|
155
151
|
if (nextElem.classList.contains(MINIATURE_CLASS)) nextElem.remove();
|
|
156
152
|
}
|
|
157
|
-
|
|
158
|
-
|
|
153
|
+
|
|
154
|
+
// скрыть поле, подменить tabindex и обернуть контейнером — общая механика базового класса
|
|
155
|
+
TextBox.wrapValueElem(valueElem, container, INPUT_CLASS, inputElem, disabled);
|
|
159
156
|
|
|
160
157
|
// класс и подменённые атрибуты вернёт базовый класс при destroy
|
|
161
|
-
super(
|
|
158
|
+
super(
|
|
159
|
+
"BrandUp.TextBox",
|
|
160
|
+
container,
|
|
161
|
+
valueElem,
|
|
162
|
+
{ class: INPUT_CLASS, attrs: originalAttrs },
|
|
163
|
+
{ changeEvent: CHANGE_EVENT }
|
|
164
|
+
);
|
|
162
165
|
|
|
163
166
|
this.type = type;
|
|
164
167
|
this.maxlength = maxlength;
|
|
@@ -189,6 +192,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
189
192
|
multiline: multyline,
|
|
190
193
|
blocks,
|
|
191
194
|
readonly,
|
|
195
|
+
// disabled редактор знает сам: запрет правок как в readonly, плюс снятый contenteditable —
|
|
196
|
+
// без фокуса и выделения (визуал даёт класс .disabled от InputControl)
|
|
197
|
+
disabled,
|
|
192
198
|
// тулбар позиционируется относительно контейнера TextBox (а не document.body)
|
|
193
199
|
toolbarContainer: container,
|
|
194
200
|
value: valueElem.value,
|
|
@@ -207,16 +213,11 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
207
213
|
|
|
208
214
|
if (type === "number" || type === "email" || maxlength > 0) {
|
|
209
215
|
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
|
-
}
|
|
216
|
+
// достигнут лимит длины — отклоняем (выделение будет заменено, его длину учитывает helper)
|
|
217
|
+
if (maxlength > 0 && this.__lengthLeft() <= 0) return false;
|
|
215
218
|
return typeAllowsChar(char);
|
|
216
219
|
};
|
|
217
|
-
}
|
|
218
220
|
|
|
219
|
-
if (type === "number" || maxlength > 0) {
|
|
220
221
|
options.filterPaste = (text) => {
|
|
221
222
|
let pasted = text;
|
|
222
223
|
|
|
@@ -224,12 +225,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
224
225
|
const numberData = /[\d\s]+/.exec(pasted);
|
|
225
226
|
if (!numberData || !numberData.length) return null;
|
|
226
227
|
pasted = numberData[0].replace(/\s/g, "");
|
|
228
|
+
} else if (type === "email") {
|
|
229
|
+
// вставка проходит тот же посимвольный фильтр, что и набор, — иначе запрещённые
|
|
230
|
+
// символы попадали бы в поле через буфер обмена
|
|
231
|
+
pasted = Array.from(pasted).filter(typeAllowsChar).join("");
|
|
232
|
+
if (!pasted) return null;
|
|
227
233
|
}
|
|
228
234
|
|
|
229
235
|
// обрезаем по количеству оставшихся символов (с учётом замены выделения)
|
|
230
236
|
if (maxlength > 0) {
|
|
231
|
-
const
|
|
232
|
-
const left = maxlength - this.__editor.getLength() + selectionLength;
|
|
237
|
+
const left = this.__lengthLeft();
|
|
233
238
|
// не влезает ни одного символа — это отказ, а не пустая вставка: иначе
|
|
234
239
|
// вставка молча не делала бы ничего, тогда как ввод символа на пределе мигает ошибкой
|
|
235
240
|
if (left <= 0) return null;
|
|
@@ -240,17 +245,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
240
245
|
};
|
|
241
246
|
}
|
|
242
247
|
|
|
243
|
-
this.
|
|
244
|
-
|
|
245
|
-
// RichEditor не знает про disabled — отключаем редактирование на стороне TextBox
|
|
246
|
-
// (визуал даёт класс .disabled от InputControl: затемнение, user-select: none)
|
|
247
|
-
if (disabled) inputElem.contentEditable = "false";
|
|
248
|
+
this.__attachEditor(new RichEditor(inputElem, options));
|
|
248
249
|
|
|
249
250
|
// синхронизируем скрытое поле с нормализованным содержимым редактора (без события)
|
|
250
251
|
this.__valueElem.value = this.__editor.getValue();
|
|
251
252
|
|
|
252
253
|
this.__initLogic();
|
|
253
254
|
this.__refreshSymbolsCount();
|
|
255
|
+
// значение из разметки может уже нарушать лимит длины — объявляем это полю сразу,
|
|
256
|
+
// не дожидаясь первой синхронизации
|
|
257
|
+
this.__refreshValidity();
|
|
254
258
|
|
|
255
259
|
if (this.autoFocus && !IS_TOUCH_DEVICE && !disabled && !readonly) this.__editor.focus();
|
|
256
260
|
}
|
|
@@ -274,24 +278,19 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
274
278
|
|
|
275
279
|
// Счётчик — на каждый ввод, а не по change: длина считается по textContent и стоит копейки,
|
|
276
280
|
// тогда как значение поля синхронизируется реже (см. RichEditor.flushChange).
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
e.preventDefault();
|
|
288
|
-
e.stopImmediatePropagation();
|
|
289
|
-
},
|
|
290
|
-
{ signal }
|
|
291
|
-
);
|
|
281
|
+
// Выключенный счётчик скрыт — пересчитывать его на каждое нажатие незачем.
|
|
282
|
+
if (this.symbolCounter) editable.addEventListener("input", () => this.__refreshSymbolsCount(), { signal });
|
|
283
|
+
|
|
284
|
+
// Клик мимо текста — тоже клик по полю: контрол выглядит одним полем ввода, а его
|
|
285
|
+
// внутренние отступы, место справа от текста и полоса действий в редактируемый
|
|
286
|
+
// элемент не входят.
|
|
287
|
+
this.element.addEventListener("mousedown", (e) => this.__focusFromBox(e), { signal });
|
|
288
|
+
|
|
289
|
+
// таймер вспышки "incorrect" не должен переживать компонент — гасим его вместе со слушателями
|
|
290
|
+
signal.addEventListener("abort", () => window.clearTimeout(this.__incorrectTimer));
|
|
292
291
|
|
|
293
|
-
// двойной клик по readonly-полю с кнопкой копирования — выделить всё для копирования
|
|
294
292
|
if (this.copyButton) {
|
|
293
|
+
// двойной клик по readonly-полю с кнопкой копирования — выделить всё для копирования
|
|
295
294
|
editable.addEventListener(
|
|
296
295
|
"dblclick",
|
|
297
296
|
() => {
|
|
@@ -301,38 +300,56 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
301
300
|
},
|
|
302
301
|
{ signal }
|
|
303
302
|
);
|
|
304
|
-
}
|
|
305
303
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
304
|
+
this.registerCommand("copy-text", async (context) => {
|
|
305
|
+
// повторный клик, пока показана галочка, запомнил бы её как исходную иконку —
|
|
306
|
+
// после возврата кнопка так и осталась бы с галочкой
|
|
307
|
+
if (!window.navigator.clipboard || this.disabled || context.target.classList.contains("success"))
|
|
308
|
+
return;
|
|
309
|
+
|
|
310
|
+
// копия значения в поле отстаёт на окно троттлинга — доводим её перед чтением
|
|
311
|
+
this.__syncValue();
|
|
312
|
+
await window.navigator.clipboard.writeText(this.__valueElem.value);
|
|
313
|
+
|
|
314
|
+
const prevHtml = context.target.innerHTML;
|
|
315
|
+
context.target.innerHTML = doneIcon;
|
|
316
|
+
context.target.classList.add("success");
|
|
317
|
+
|
|
318
|
+
// возврат иконки отменяем вместе с компонентом: иначе таймер переживает destroy
|
|
319
|
+
// и дописывает в уже отсоединённую кнопку
|
|
320
|
+
try {
|
|
321
|
+
await FuncHelper.delay(2000, signal);
|
|
322
|
+
} catch {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
310
325
|
|
|
311
|
-
|
|
326
|
+
context.target.innerHTML = prevHtml;
|
|
327
|
+
context.target.classList.remove("success");
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
312
331
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Фокус по клику в контрол мимо текста: внутренние отступы, место справа от последней
|
|
334
|
+
* строки и полоса действий в редактируемый элемент не входят, а выглядит всё это одним
|
|
335
|
+
* полем ввода.
|
|
336
|
+
*
|
|
337
|
+
* Гасим нажатие: браузер иначе снял бы выделение и увёл фокус на корневой элемент, где
|
|
338
|
+
* каретке места нет. Каретку редактор вернёт на прежнее место, а если её ещё не было —
|
|
339
|
+
* в конец текста: клик мимо текста это клик за ним.
|
|
340
|
+
*/
|
|
341
|
+
private __focusFromBox(e: MouseEvent) {
|
|
342
|
+
// в выключенном поле редактируемого элемента нет вовсе — фокусировать нечего
|
|
343
|
+
if (this.disabled) return;
|
|
316
344
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
try {
|
|
320
|
-
await FuncHelper.delay(2000, signal);
|
|
321
|
-
} catch {
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
345
|
+
const target = e.target as HTMLElement | null;
|
|
346
|
+
if (!target || this.__inputElem.contains(target)) return; // в сам текст браузер попадёт и сам
|
|
324
347
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
});
|
|
328
|
-
}
|
|
348
|
+
// кнопка копирования, панель форматирования и её попапы живут внутри контрола и работают сами
|
|
349
|
+
if (target.closest(`button, a, input, textarea, select, .${TOOLBAR_CLASS}, .${POPUP_CLASS}`)) return;
|
|
329
350
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
// валидация, отправка формы, сбор FormData.
|
|
333
|
-
protected override __syncValue(): void {
|
|
334
|
-
this.__editor.flushChange();
|
|
335
|
-
this.__refreshValidity();
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
this.__editor.focus(true);
|
|
336
353
|
}
|
|
337
354
|
|
|
338
355
|
/**
|
|
@@ -344,20 +361,39 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
344
361
|
* разделители абзацев. Нативный maxLength здесь не помощник: он ограничивает набор, но не
|
|
345
362
|
* помечает значение, выставленное из кода.
|
|
346
363
|
*/
|
|
347
|
-
|
|
364
|
+
protected override __refreshValidity(): void {
|
|
348
365
|
if (this.maxlength <= 0) return;
|
|
349
366
|
|
|
350
367
|
const tooLong = this.maxlength < this.__editor.getLength();
|
|
351
368
|
this.__valueElem.setCustomValidity(tooLong ? `Не больше ${this.maxlength} символов.` : "");
|
|
352
369
|
}
|
|
353
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Сколько символов ещё поместится с учётом того, что выделение будет заменено вводом.
|
|
373
|
+
*
|
|
374
|
+
* Выделение берём у самого редактора: чужое выделение страницы (например, при вставке
|
|
375
|
+
* с отсоединённой кареткой) к содержимому отношения не имеет и ёмкость не освобождает.
|
|
376
|
+
* Длину выделения меряем так же, как getLength(), — по textContent, без переносов на границах
|
|
377
|
+
* блоков, которые toString() у Selection добавляет, — иначе многострочное выделение
|
|
378
|
+
* считалось бы длиннее содержимого.
|
|
379
|
+
*/
|
|
380
|
+
private __lengthLeft(): number {
|
|
381
|
+
const selection = this.__editor.selection;
|
|
382
|
+
const selectionLength = selection ? (selection.getRangeAt(0).cloneContents().textContent?.length ?? 0) : 0;
|
|
383
|
+
|
|
384
|
+
return this.maxlength - this.__editor.getLength() + selectionLength;
|
|
385
|
+
}
|
|
386
|
+
|
|
354
387
|
private __toIncorrect() {
|
|
355
388
|
this.element.classList.add("incorrect");
|
|
356
|
-
|
|
389
|
+
|
|
390
|
+
// каждый отказ перезапускает таймер: иначе таймер предыдущего отказа гасил бы вспышку раньше
|
|
391
|
+
window.clearTimeout(this.__incorrectTimer);
|
|
392
|
+
this.__incorrectTimer = window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
|
|
357
393
|
}
|
|
358
394
|
|
|
359
395
|
private __refreshSymbolsCount() {
|
|
360
|
-
if (!this.
|
|
396
|
+
if (!this.symbolCounter) return;
|
|
361
397
|
|
|
362
398
|
const textLength = this.__editor.getLength();
|
|
363
399
|
let counterValue: string;
|
|
@@ -388,24 +424,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
388
424
|
return this.__editor;
|
|
389
425
|
}
|
|
390
426
|
|
|
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
427
|
override validate(): boolean {
|
|
410
428
|
let isValid = super.validate(); // super синхронизирует значение сам, через __syncValue
|
|
411
429
|
if (isValid) {
|
|
@@ -423,13 +441,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
423
441
|
|
|
424
442
|
return isValid;
|
|
425
443
|
}
|
|
426
|
-
|
|
427
|
-
override destroy(): void {
|
|
428
|
-
this.__listenerAbort.abort();
|
|
429
|
-
this.__editor.destroy();
|
|
430
|
-
|
|
431
|
-
super.destroy(); // снимет слушатели формы и вернёт поле-носитель в исходный вид
|
|
432
|
-
}
|
|
433
444
|
}
|
|
434
445
|
|
|
435
446
|
export interface ChangeEventData {
|