@brandup/ui-textbox 1.0.34 → 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 +3 -1
- package/package.json +9 -5
- package/source/index.ts +3 -1
- package/source/textbox.less +9 -107
- package/source/textbox.ts +116 -541
- package/source/format.ts +0 -546
- package/svg/bold.svg +0 -3
- package/svg/italic.svg +0 -3
- package/svg/strike.svg +0 -5
- package/svg/underline.svg +0 -4
package/source/textbox.ts
CHANGED
|
@@ -4,35 +4,16 @@ 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,
|
|
7
|
+
import RichEditor, {
|
|
10
8
|
defaultFormatMarkers,
|
|
11
|
-
deserialize,
|
|
12
|
-
insertFormattedText,
|
|
13
|
-
isFormatActive,
|
|
14
|
-
normalizeWhitespace,
|
|
15
9
|
parseFormatTools,
|
|
16
|
-
selectionCharBounds,
|
|
17
|
-
serialize,
|
|
18
|
-
toggleFormat,
|
|
19
10
|
type FormatMarkers,
|
|
20
11
|
type FormatStorage,
|
|
21
12
|
type FormatTool,
|
|
22
|
-
|
|
13
|
+
type RichEditorOptions,
|
|
14
|
+
} from "@brandup/ui-richeditor";
|
|
23
15
|
import copyIcon from "../svg/copy.svg";
|
|
24
16
|
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
|
-
};
|
|
36
17
|
|
|
37
18
|
export const ROOT_CLASS = "ui-textbox";
|
|
38
19
|
export const INPUT_CLASS = "textbox-input";
|
|
@@ -47,11 +28,10 @@ type TextBoxEvents = {
|
|
|
47
28
|
};
|
|
48
29
|
|
|
49
30
|
export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAreaElement, TextBoxEvents> {
|
|
50
|
-
private
|
|
31
|
+
private __editor: RichEditor;
|
|
32
|
+
private __inputElem: HTMLElement; // редактируемый элемент (им владеет RichEditor)
|
|
51
33
|
private __symbolsCountElem: HTMLElement;
|
|
52
34
|
private __listenerAbort = new AbortController();
|
|
53
|
-
private __formatButtons: Array<[FormatTool, HTMLButtonElement]> = [];
|
|
54
|
-
private __pendingFormats = new Set<FormatTool>(); // режим набора: форматы для следующего ввода
|
|
55
35
|
|
|
56
36
|
readonly type: TextBoxType;
|
|
57
37
|
readonly allowEmptyStrings: boolean;
|
|
@@ -123,7 +103,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
123
103
|
}
|
|
124
104
|
}
|
|
125
105
|
|
|
126
|
-
const inputElem = DOM.tag("div"
|
|
106
|
+
const inputElem = DOM.tag("div");
|
|
127
107
|
const actionsElem = DOM.tag("div", { class: "actions" });
|
|
128
108
|
const symbolsCountElem = DOM.tag("div", { class: "symbols" });
|
|
129
109
|
|
|
@@ -135,41 +115,13 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
135
115
|
|
|
136
116
|
container.classList.remove(INPUT_CLASS);
|
|
137
117
|
|
|
138
|
-
inputElem.tabIndex = valueElem.tabIndex;
|
|
118
|
+
inputElem.tabIndex = disabled ? -1 : valueElem.tabIndex;
|
|
139
119
|
valueElem.tabIndex = -1;
|
|
140
120
|
|
|
141
121
|
if (multyline) container.classList.add("multyline");
|
|
142
122
|
if (symbolCounter) container.classList.add("counter");
|
|
143
|
-
|
|
144
|
-
if (disabled) inputElem.tabIndex = -1;
|
|
145
|
-
else inputElem.contentEditable = "true";
|
|
146
|
-
|
|
147
123
|
if (inputmode) inputElem.inputMode = inputmode;
|
|
148
124
|
|
|
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
|
-
|
|
173
125
|
if (copyButton) {
|
|
174
126
|
const buttonElem = DOM.tag(
|
|
175
127
|
"button",
|
|
@@ -180,8 +132,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
180
132
|
actionsElem.insertAdjacentElement("beforeend", buttonElem);
|
|
181
133
|
}
|
|
182
134
|
|
|
183
|
-
inputElem.setAttribute("data-placeholder", placeholder ?? "");
|
|
184
|
-
|
|
185
135
|
// убираем висящую миниатюру, если есть, и вставляем container на место valueElem
|
|
186
136
|
if (valueElem.nextElementSibling) {
|
|
187
137
|
const nextElem = valueElem.nextElementSibling as HTMLElement;
|
|
@@ -208,238 +158,121 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
208
158
|
|
|
209
159
|
this.__inputElem = inputElem;
|
|
210
160
|
this.__symbolsCountElem = symbolsCountElem;
|
|
211
|
-
this.__formatButtons = formatButtons;
|
|
212
|
-
|
|
213
|
-
this.__initLogic();
|
|
214
|
-
this.__initFormat();
|
|
215
|
-
this.__initText();
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
private __initLogic() {
|
|
219
|
-
const { signal } = this.__listenerAbort; // один AbortController отписывает все listener'ы в destroy
|
|
220
|
-
|
|
221
|
-
this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
|
|
222
|
-
this.element.addEventListener("dragenter", (e) => e.preventDefault(), { signal });
|
|
223
|
-
|
|
224
|
-
this.__valueElem.addEventListener(
|
|
225
|
-
"change",
|
|
226
|
-
(e: Event) => {
|
|
227
|
-
e.preventDefault();
|
|
228
|
-
e.stopImmediatePropagation();
|
|
229
|
-
},
|
|
230
|
-
{ signal }
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
-
let hasInputClick = false;
|
|
234
|
-
this.__inputElem.addEventListener(
|
|
235
|
-
"mousedown",
|
|
236
|
-
() => {
|
|
237
|
-
if (this.disabled) return;
|
|
238
|
-
|
|
239
|
-
hasInputClick = true;
|
|
240
|
-
},
|
|
241
|
-
{ signal }
|
|
242
|
-
);
|
|
243
|
-
|
|
244
|
-
this.__inputElem.addEventListener(
|
|
245
|
-
"focus",
|
|
246
|
-
() => {
|
|
247
|
-
if (this.disabled) return;
|
|
248
|
-
|
|
249
|
-
this.element.classList.add("focused");
|
|
250
|
-
|
|
251
|
-
if (this.readonly) this.__selectAll();
|
|
252
|
-
else if (!hasInputClick) this.__carretToEnd(); // пыремещаем курсов в конец, если клик не по строке
|
|
253
|
-
},
|
|
254
|
-
{ signal }
|
|
255
|
-
);
|
|
256
|
-
|
|
257
|
-
this.__inputElem.addEventListener(
|
|
258
|
-
"blur",
|
|
259
|
-
() => {
|
|
260
|
-
hasInputClick = false;
|
|
261
|
-
|
|
262
|
-
if (this.disabled) return;
|
|
263
|
-
|
|
264
|
-
this.element.classList.remove("focused");
|
|
265
|
-
|
|
266
|
-
// когда удаляем весь текст, то браузер оставляет один BR, что означает что текста нет
|
|
267
|
-
// удалить BR нужно, чтобы появился placeholder
|
|
268
|
-
if (this.__inputElem.firstChild?.nodeName === "BR") DOM.empty(this.__inputElem);
|
|
269
|
-
|
|
270
|
-
// редактирование завершено — нормализуем пробелы (с событием change, если что-то изменилось)
|
|
271
|
-
this.__normalizeWhitespace(true);
|
|
272
|
-
},
|
|
273
|
-
{ signal }
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
this.__inputElem.addEventListener(
|
|
277
|
-
"dblclick",
|
|
278
|
-
() => {
|
|
279
|
-
if (this.disabled) return;
|
|
280
|
-
|
|
281
|
-
if (this.copyButton && this.readonly) {
|
|
282
|
-
this.__selectAll();
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// браузер при выделении слова может захватить пробел у границы — убираем его
|
|
287
|
-
this.__trimSelectionWhitespace();
|
|
288
|
-
},
|
|
289
|
-
{ signal }
|
|
290
|
-
);
|
|
291
|
-
|
|
292
|
-
this.element.addEventListener(
|
|
293
|
-
"paste",
|
|
294
|
-
(e: ClipboardEvent) => {
|
|
295
|
-
e.preventDefault();
|
|
296
|
-
e.stopPropagation();
|
|
297
|
-
|
|
298
|
-
if (this.readonly || this.disabled) return false;
|
|
299
|
-
|
|
300
|
-
let pastedData = e.clipboardData?.getData("text/plain");
|
|
301
|
-
if (!pastedData) return false;
|
|
302
161
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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;
|
|
311
192
|
}
|
|
193
|
+
return typeAllowsChar(char);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
312
196
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (this.maxlength > 0) {
|
|
317
|
-
// обрезаем вставляемый текст по кол-ву оставшихся символов для ввода
|
|
318
|
-
|
|
319
|
-
const selectionLength = selection.toString().length;
|
|
320
|
-
const currentTextLength = this.__getTextLength();
|
|
321
|
-
const leftSymbols = this.maxlength - currentTextLength + selectionLength; // осталось символов для ввода
|
|
197
|
+
if (type === "number" || maxlength > 0) {
|
|
198
|
+
options.filterPaste = (text) => {
|
|
199
|
+
let pasted = text;
|
|
322
200
|
|
|
323
|
-
|
|
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, "");
|
|
324
205
|
}
|
|
325
206
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (!this.multyline) {
|
|
332
|
-
fragment.appendChild(document.createTextNode(output.join(" ")));
|
|
333
|
-
} else {
|
|
334
|
-
output.forEach((line, index) => {
|
|
335
|
-
if (index > 0) fragment.appendChild(document.createElement("br"));
|
|
336
|
-
|
|
337
|
-
fragment.appendChild(document.createTextNode(line));
|
|
338
|
-
});
|
|
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));
|
|
339
212
|
}
|
|
340
213
|
|
|
341
|
-
|
|
342
|
-
|
|
214
|
+
return pasted;
|
|
215
|
+
};
|
|
216
|
+
}
|
|
343
217
|
|
|
344
|
-
|
|
345
|
-
selection.getRangeAt(0).insertNode(fragment);
|
|
218
|
+
this.__editor = new RichEditor(inputElem, options);
|
|
346
219
|
|
|
347
|
-
|
|
348
|
-
|
|
220
|
+
// RichEditor не знает про disabled — отключаем редактирование на стороне TextBox
|
|
221
|
+
// (визуал даёт класс .disabled от InputControl: затемнение, user-select: none)
|
|
222
|
+
if (disabled) inputElem.contentEditable = "false";
|
|
349
223
|
|
|
350
|
-
|
|
224
|
+
// синхронизируем скрытое поле с нормализованным содержимым редактора (без события)
|
|
225
|
+
this.__valueElem.value = this.__editor.getValue();
|
|
351
226
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
{ signal }
|
|
355
|
-
);
|
|
227
|
+
this.__initLogic();
|
|
228
|
+
this.__refreshSymbolsCount();
|
|
356
229
|
|
|
357
|
-
this.
|
|
358
|
-
|
|
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
|
-
}
|
|
230
|
+
if (this.autoFocus && !IS_TOUCH_DEVICE && !disabled && !readonly) this.__editor.focus();
|
|
231
|
+
}
|
|
372
232
|
|
|
373
|
-
|
|
233
|
+
private __initLogic() {
|
|
234
|
+
const { signal } = this.__listenerAbort;
|
|
235
|
+
const editable = this.__inputElem;
|
|
374
236
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
return false;
|
|
379
|
-
}
|
|
237
|
+
// изменения редактора → значение формы, счётчик, валидность, событие
|
|
238
|
+
this.__editor.onChange((data) => {
|
|
239
|
+
this.__valueElem.value = data.value;
|
|
380
240
|
|
|
381
|
-
|
|
382
|
-
const currentTextLength = this.__getTextLength();
|
|
383
|
-
if (currentTextLength >= this.maxlength) {
|
|
384
|
-
e.preventDefault();
|
|
385
|
-
e.stopPropagation();
|
|
241
|
+
this.__refreshSymbolsCount();
|
|
386
242
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
243
|
+
let clearInvalidState = true;
|
|
244
|
+
if (this.element.classList.contains("invalid")) clearInvalidState = this.validate();
|
|
245
|
+
if (clearInvalidState) this.element.classList.remove("invalid");
|
|
391
246
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
switch (this.type) {
|
|
396
|
-
case "number":
|
|
397
|
-
if (!/\d/.test(e.key)) isIncorrect = true;
|
|
398
|
-
break;
|
|
399
|
-
case "email":
|
|
400
|
-
if (!/[a-zA-Z\d.\-_@]/.test(e.key)) isIncorrect = true;
|
|
401
|
-
break;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (isIncorrect) {
|
|
405
|
-
e.preventDefault();
|
|
406
|
-
e.stopPropagation();
|
|
407
|
-
|
|
408
|
-
this.__toIncorrect();
|
|
409
|
-
return false;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
247
|
+
this.__onChange();
|
|
248
|
+
});
|
|
412
249
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
this.__submitForm();
|
|
417
|
-
return false;
|
|
418
|
-
}
|
|
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 });
|
|
419
253
|
|
|
420
|
-
|
|
254
|
+
// гасим нативный change скрытого поля
|
|
255
|
+
this.__valueElem.addEventListener(
|
|
256
|
+
"change",
|
|
257
|
+
(e: Event) => {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
e.stopImmediatePropagation();
|
|
421
260
|
},
|
|
422
261
|
{ signal }
|
|
423
262
|
);
|
|
424
263
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (this.element.classList.contains("invalid")) clearInvalidState = this.validate(); // Если уже не валидно, то перепроверяем
|
|
438
|
-
|
|
439
|
-
if (clearInvalidState) this.element.classList.remove("invalid");
|
|
440
|
-
},
|
|
441
|
-
{ signal }
|
|
442
|
-
);
|
|
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
|
+
}
|
|
443
276
|
|
|
444
277
|
this.registerCommand("copy-text", async (context) => {
|
|
445
278
|
if (!window.navigator.clipboard || this.disabled) return;
|
|
@@ -458,216 +291,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
458
291
|
});
|
|
459
292
|
}
|
|
460
293
|
|
|
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
|
-
|
|
614
|
-
private __initText() {
|
|
615
|
-
DOM.empty(this.__inputElem);
|
|
616
|
-
|
|
617
|
-
const text = this.__valueElem.value;
|
|
618
|
-
if (text) {
|
|
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
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
this.__normalizeWhitespace(false); // нормализация после инициализации/setValue (без события)
|
|
642
|
-
|
|
643
|
-
this.__refreshSymbolsCount();
|
|
644
|
-
|
|
645
|
-
if (this.autoFocus && !IS_TOUCH_DEVICE && !this.disabled && !this.readonly) this.__inputElem.focus();
|
|
646
|
-
}
|
|
647
|
-
|
|
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();
|
|
652
|
-
this.__valueElem.value = newValue;
|
|
653
|
-
|
|
654
|
-
this.__refreshSymbolsCount();
|
|
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();
|
|
669
|
-
}
|
|
670
|
-
|
|
671
294
|
private __toIncorrect() {
|
|
672
295
|
this.element.classList.add("incorrect");
|
|
673
296
|
window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
|
|
@@ -676,7 +299,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
676
299
|
private __refreshSymbolsCount() {
|
|
677
300
|
if (!this.__symbolsCountElem) return;
|
|
678
301
|
|
|
679
|
-
const textLength = this.
|
|
302
|
+
const textLength = this.__editor.getLength();
|
|
680
303
|
let counterValue: string;
|
|
681
304
|
|
|
682
305
|
if (this.maxlength > 0) {
|
|
@@ -688,65 +311,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
688
311
|
this.__symbolsCountElem.textContent = counterValue;
|
|
689
312
|
}
|
|
690
313
|
|
|
691
|
-
private __selectAll() {
|
|
692
|
-
this.__inputElem.focus();
|
|
693
|
-
|
|
694
|
-
window.getSelection()?.selectAllChildren(this.__inputElem);
|
|
695
|
-
}
|
|
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
|
-
|
|
733
|
-
private __carretToEnd() {
|
|
734
|
-
const range = document.createRange();
|
|
735
|
-
range.selectNodeContents(this.__inputElem);
|
|
736
|
-
range.collapse(false);
|
|
737
|
-
const sel = window.getSelection();
|
|
738
|
-
if (sel) {
|
|
739
|
-
sel.removeAllRanges();
|
|
740
|
-
sel.addRange(range);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
private __getTextLength() {
|
|
745
|
-
// textContent не вставляет \n между блочными детьми (в отличие от innerText), так что multiline-контент считается корректно;
|
|
746
|
-
// заодно работает в jsdom, где innerText не реализован.
|
|
747
|
-
return this.__inputElem.textContent?.length ?? 0;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
314
|
private __onChange() {
|
|
751
315
|
this.trigger(CHANGE_EVENT, <ChangeEventData>{
|
|
752
316
|
textbox: this,
|
|
@@ -754,6 +318,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
754
318
|
});
|
|
755
319
|
}
|
|
756
320
|
|
|
321
|
+
/** Многострочный режим (textarea). Псевдоним без опечатки в имени. */
|
|
322
|
+
get multiline(): boolean {
|
|
323
|
+
return this.multyline;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Доступ к встроенному редактору (форматирование, выделение и т.п.). */
|
|
327
|
+
get editor(): RichEditor {
|
|
328
|
+
return this.__editor;
|
|
329
|
+
}
|
|
330
|
+
|
|
757
331
|
onChange(handler: (e: ChangeEventData) => void) {
|
|
758
332
|
this.on(CHANGE_EVENT, handler);
|
|
759
333
|
}
|
|
@@ -767,10 +341,8 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
767
341
|
}
|
|
768
342
|
|
|
769
343
|
setValue(value: string): void {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
this.__initText();
|
|
773
|
-
this.__onChange();
|
|
344
|
+
// RichEditor нормализует и сгенерирует change — он синхронизирует скрытое поле и вызовет textbox-change
|
|
345
|
+
this.__editor.setValue(value?.trim() ?? "");
|
|
774
346
|
}
|
|
775
347
|
|
|
776
348
|
override validate(): boolean {
|
|
@@ -780,7 +352,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
780
352
|
|
|
781
353
|
if (this.required && !value) isValid = false;
|
|
782
354
|
|
|
783
|
-
|
|
355
|
+
// длина — по видимому тексту (getLength), а не по сериализованному value:
|
|
356
|
+
// при format/html в value есть теги, в multiline — разделители абзацев \n\n
|
|
357
|
+
if (this.maxlength > 0 && this.maxlength < this.__editor.getLength()) isValid = false;
|
|
784
358
|
}
|
|
785
359
|
|
|
786
360
|
if (!isValid) this.element.classList.add("invalid");
|
|
@@ -791,8 +365,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
791
365
|
|
|
792
366
|
override destroy(): void {
|
|
793
367
|
this.__listenerAbort.abort();
|
|
794
|
-
this.
|
|
368
|
+
this.__editor.destroy();
|
|
795
369
|
|
|
370
|
+
this.__valueElem.tabIndex = this.__inputElem.tabIndex;
|
|
796
371
|
this.element.insertAdjacentElement("afterend", this.__valueElem);
|
|
797
372
|
this.element.remove();
|
|
798
373
|
|