@brandup/ui-textbox 1.0.34 → 1.0.38
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 +17 -8
- package/package.json +9 -5
- package/source/index.ts +7 -1
- package/source/textbox.less +9 -107
- package/source/textbox.ts +122 -542
- 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,17 @@ 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
|
-
|
|
12
|
-
insertFormattedText,
|
|
13
|
-
isFormatActive,
|
|
14
|
-
normalizeWhitespace,
|
|
9
|
+
parseEditorActions,
|
|
15
10
|
parseFormatTools,
|
|
16
|
-
selectionCharBounds,
|
|
17
|
-
serialize,
|
|
18
|
-
toggleFormat,
|
|
19
11
|
type FormatMarkers,
|
|
20
12
|
type FormatStorage,
|
|
21
13
|
type FormatTool,
|
|
22
|
-
|
|
14
|
+
type RichEditorOptions,
|
|
15
|
+
} from "@brandup/ui-richeditor";
|
|
23
16
|
import copyIcon from "../svg/copy.svg";
|
|
24
17
|
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
18
|
|
|
37
19
|
export const ROOT_CLASS = "ui-textbox";
|
|
38
20
|
export const INPUT_CLASS = "textbox-input";
|
|
@@ -47,11 +29,10 @@ type TextBoxEvents = {
|
|
|
47
29
|
};
|
|
48
30
|
|
|
49
31
|
export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAreaElement, TextBoxEvents> {
|
|
50
|
-
private
|
|
32
|
+
private __editor: RichEditor;
|
|
33
|
+
private __inputElem: HTMLElement; // редактируемый элемент (им владеет RichEditor)
|
|
51
34
|
private __symbolsCountElem: HTMLElement;
|
|
52
35
|
private __listenerAbort = new AbortController();
|
|
53
|
-
private __formatButtons: Array<[FormatTool, HTMLButtonElement]> = [];
|
|
54
|
-
private __pendingFormats = new Set<FormatTool>(); // режим набора: форматы для следующего ввода
|
|
55
36
|
|
|
56
37
|
readonly type: TextBoxType;
|
|
57
38
|
readonly allowEmptyStrings: boolean;
|
|
@@ -113,6 +94,8 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
113
94
|
const formatStorage: FormatStorage =
|
|
114
95
|
valueElem.getAttribute("data-format-storage") === "markdown" ? "markdown" : "html";
|
|
115
96
|
const formatTools = format ? parseFormatTools(valueElem.getAttribute("data-format-tools")) : [];
|
|
97
|
+
// кнопки действий панели (очистка формата, отмена, повтор) — подключаются явно
|
|
98
|
+
const editorActions = format ? parseEditorActions(valueElem.getAttribute("data-editor-actions")) : [];
|
|
116
99
|
|
|
117
100
|
// markdown-маркеры с дефолтами, переопределяются атрибутами data-format-md-<tool>
|
|
118
101
|
const formatMarkers = defaultFormatMarkers();
|
|
@@ -123,7 +106,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
123
106
|
}
|
|
124
107
|
}
|
|
125
108
|
|
|
126
|
-
const inputElem = DOM.tag("div"
|
|
109
|
+
const inputElem = DOM.tag("div");
|
|
127
110
|
const actionsElem = DOM.tag("div", { class: "actions" });
|
|
128
111
|
const symbolsCountElem = DOM.tag("div", { class: "symbols" });
|
|
129
112
|
|
|
@@ -135,53 +118,24 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
135
118
|
|
|
136
119
|
container.classList.remove(INPUT_CLASS);
|
|
137
120
|
|
|
138
|
-
inputElem.tabIndex = valueElem.tabIndex;
|
|
121
|
+
inputElem.tabIndex = disabled ? -1 : valueElem.tabIndex;
|
|
139
122
|
valueElem.tabIndex = -1;
|
|
140
123
|
|
|
141
124
|
if (multyline) container.classList.add("multyline");
|
|
142
125
|
if (symbolCounter) container.classList.add("counter");
|
|
143
|
-
|
|
144
|
-
if (disabled) inputElem.tabIndex = -1;
|
|
145
|
-
else inputElem.contentEditable = "true";
|
|
146
|
-
|
|
147
126
|
if (inputmode) inputElem.inputMode = inputmode;
|
|
148
127
|
|
|
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
128
|
if (copyButton) {
|
|
129
|
+
// команда объявляется атрибутом data-command — по нему её ищет обработчик @brandup/ui
|
|
174
130
|
const buttonElem = DOM.tag(
|
|
175
131
|
"button",
|
|
176
|
-
{ command: "copy-text", title: "Скопировать в буфер обмена" },
|
|
132
|
+
{ "data-command": "copy-text", title: "Скопировать в буфер обмена" },
|
|
177
133
|
copyIcon
|
|
178
134
|
);
|
|
179
135
|
if (disabled) buttonElem.disabled = true;
|
|
180
136
|
actionsElem.insertAdjacentElement("beforeend", buttonElem);
|
|
181
137
|
}
|
|
182
138
|
|
|
183
|
-
inputElem.setAttribute("data-placeholder", placeholder ?? "");
|
|
184
|
-
|
|
185
139
|
// убираем висящую миниатюру, если есть, и вставляем container на место valueElem
|
|
186
140
|
if (valueElem.nextElementSibling) {
|
|
187
141
|
const nextElem = valueElem.nextElementSibling as HTMLElement;
|
|
@@ -208,238 +162,122 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
208
162
|
|
|
209
163
|
this.__inputElem = inputElem;
|
|
210
164
|
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
165
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
166
|
+
// фильтрация ввода по типу, ограничение длины и обработка submit/ошибок — через хуки RichEditor
|
|
167
|
+
// (RichEditor про maxlength/типы не знает; всё это контролирует TextBox)
|
|
168
|
+
const options: RichEditorOptions = {
|
|
169
|
+
format,
|
|
170
|
+
tools: formatTools,
|
|
171
|
+
actions: editorActions,
|
|
172
|
+
storage: formatStorage,
|
|
173
|
+
markers: formatMarkers,
|
|
174
|
+
placeholder,
|
|
175
|
+
multiline: multyline,
|
|
176
|
+
readonly,
|
|
177
|
+
// тулбар позиционируется относительно контейнера TextBox (а не document.body)
|
|
178
|
+
toolbarContainer: container,
|
|
179
|
+
value: valueElem.value,
|
|
180
|
+
onReject: () => this.__toIncorrect(),
|
|
181
|
+
onEnter: () => this.__submitForm(),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// допустим ли вводимый символ по типу
|
|
185
|
+
const typeAllowsChar = (char: string) => {
|
|
186
|
+
if (type === "number") return /\d/.test(char);
|
|
187
|
+
if (type === "email") return /[a-zA-Z\d.\-_@]/.test(char);
|
|
188
|
+
return true;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (type === "number" || type === "email" || maxlength > 0) {
|
|
192
|
+
options.filterChar = (char) => {
|
|
193
|
+
// достигнут лимит длины — отклоняем (выделение будет заменено, поэтому вычитаем его длину)
|
|
194
|
+
if (maxlength > 0) {
|
|
195
|
+
const selectionLength = window.getSelection()?.toString().length ?? 0;
|
|
196
|
+
if (this.__editor.getLength() - selectionLength >= maxlength) return false;
|
|
311
197
|
}
|
|
198
|
+
return typeAllowsChar(char);
|
|
199
|
+
};
|
|
200
|
+
}
|
|
312
201
|
|
|
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; // осталось символов для ввода
|
|
202
|
+
if (type === "number" || maxlength > 0) {
|
|
203
|
+
options.filterPaste = (text) => {
|
|
204
|
+
let pasted = text;
|
|
322
205
|
|
|
323
|
-
|
|
206
|
+
if (type === "number") {
|
|
207
|
+
const numberData = /[\d\s]+/.exec(pasted);
|
|
208
|
+
if (!numberData || !numberData.length) return null;
|
|
209
|
+
pasted = numberData[0].replace(/\s/g, "");
|
|
324
210
|
}
|
|
325
211
|
|
|
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
|
-
});
|
|
212
|
+
// обрезаем по количеству оставшихся символов (с учётом замены выделения)
|
|
213
|
+
if (maxlength > 0) {
|
|
214
|
+
const selectionLength = window.getSelection()?.toString().length ?? 0;
|
|
215
|
+
const left = maxlength - this.__editor.getLength() + selectionLength;
|
|
216
|
+
if (pasted.length > left) pasted = pasted.substring(0, Math.max(0, left));
|
|
339
217
|
}
|
|
340
218
|
|
|
341
|
-
|
|
342
|
-
|
|
219
|
+
return pasted;
|
|
220
|
+
};
|
|
221
|
+
}
|
|
343
222
|
|
|
344
|
-
|
|
345
|
-
selection.getRangeAt(0).insertNode(fragment);
|
|
223
|
+
this.__editor = new RichEditor(inputElem, options);
|
|
346
224
|
|
|
347
|
-
|
|
348
|
-
|
|
225
|
+
// RichEditor не знает про disabled — отключаем редактирование на стороне TextBox
|
|
226
|
+
// (визуал даёт класс .disabled от InputControl: затемнение, user-select: none)
|
|
227
|
+
if (disabled) inputElem.contentEditable = "false";
|
|
349
228
|
|
|
350
|
-
|
|
229
|
+
// синхронизируем скрытое поле с нормализованным содержимым редактора (без события)
|
|
230
|
+
this.__valueElem.value = this.__editor.getValue();
|
|
351
231
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
{ signal }
|
|
355
|
-
);
|
|
232
|
+
this.__initLogic();
|
|
233
|
+
this.__refreshSymbolsCount();
|
|
356
234
|
|
|
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
|
-
}
|
|
235
|
+
if (this.autoFocus && !IS_TOUCH_DEVICE && !disabled && !readonly) this.__editor.focus();
|
|
236
|
+
}
|
|
372
237
|
|
|
373
|
-
|
|
238
|
+
private __initLogic() {
|
|
239
|
+
const { signal } = this.__listenerAbort;
|
|
240
|
+
const editable = this.__inputElem;
|
|
374
241
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
return false;
|
|
379
|
-
}
|
|
242
|
+
// изменения редактора → значение формы, счётчик, валидность, событие
|
|
243
|
+
this.__editor.onChange((data) => {
|
|
244
|
+
this.__valueElem.value = data.value;
|
|
380
245
|
|
|
381
|
-
|
|
382
|
-
const currentTextLength = this.__getTextLength();
|
|
383
|
-
if (currentTextLength >= this.maxlength) {
|
|
384
|
-
e.preventDefault();
|
|
385
|
-
e.stopPropagation();
|
|
246
|
+
this.__refreshSymbolsCount();
|
|
386
247
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
248
|
+
let clearInvalidState = true;
|
|
249
|
+
if (this.element.classList.contains("invalid")) clearInvalidState = this.validate();
|
|
250
|
+
if (clearInvalidState) this.element.classList.remove("invalid");
|
|
391
251
|
|
|
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
|
-
}
|
|
252
|
+
this.__onChange();
|
|
253
|
+
});
|
|
412
254
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
this.__submitForm();
|
|
417
|
-
return false;
|
|
418
|
-
}
|
|
255
|
+
// состояние фокуса контрола (рамка/заливка) — на корневом элементе
|
|
256
|
+
editable.addEventListener("focus", () => !this.disabled && this.element.classList.add("focused"), { signal });
|
|
257
|
+
editable.addEventListener("blur", () => !this.disabled && this.element.classList.remove("focused"), { signal });
|
|
419
258
|
|
|
420
|
-
|
|
259
|
+
// гасим нативный change скрытого поля
|
|
260
|
+
this.__valueElem.addEventListener(
|
|
261
|
+
"change",
|
|
262
|
+
(e: Event) => {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
e.stopImmediatePropagation();
|
|
421
265
|
},
|
|
422
266
|
{ signal }
|
|
423
267
|
);
|
|
424
268
|
|
|
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
|
-
);
|
|
269
|
+
// двойной клик по readonly-полю с кнопкой копирования — выделить всё для копирования
|
|
270
|
+
if (this.copyButton) {
|
|
271
|
+
editable.addEventListener(
|
|
272
|
+
"dblclick",
|
|
273
|
+
() => {
|
|
274
|
+
if (this.disabled || !this.readonly) return;
|
|
275
|
+
editable.focus();
|
|
276
|
+
window.getSelection()?.selectAllChildren(editable);
|
|
277
|
+
},
|
|
278
|
+
{ signal }
|
|
279
|
+
);
|
|
280
|
+
}
|
|
443
281
|
|
|
444
282
|
this.registerCommand("copy-text", async (context) => {
|
|
445
283
|
if (!window.navigator.clipboard || this.disabled) return;
|
|
@@ -458,216 +296,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
458
296
|
});
|
|
459
297
|
}
|
|
460
298
|
|
|
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
299
|
private __toIncorrect() {
|
|
672
300
|
this.element.classList.add("incorrect");
|
|
673
301
|
window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
|
|
@@ -676,7 +304,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
676
304
|
private __refreshSymbolsCount() {
|
|
677
305
|
if (!this.__symbolsCountElem) return;
|
|
678
306
|
|
|
679
|
-
const textLength = this.
|
|
307
|
+
const textLength = this.__editor.getLength();
|
|
680
308
|
let counterValue: string;
|
|
681
309
|
|
|
682
310
|
if (this.maxlength > 0) {
|
|
@@ -688,65 +316,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
688
316
|
this.__symbolsCountElem.textContent = counterValue;
|
|
689
317
|
}
|
|
690
318
|
|
|
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
319
|
private __onChange() {
|
|
751
320
|
this.trigger(CHANGE_EVENT, <ChangeEventData>{
|
|
752
321
|
textbox: this,
|
|
@@ -754,6 +323,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
754
323
|
});
|
|
755
324
|
}
|
|
756
325
|
|
|
326
|
+
/** Многострочный режим (textarea). Псевдоним без опечатки в имени. */
|
|
327
|
+
get multiline(): boolean {
|
|
328
|
+
return this.multyline;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Доступ к встроенному редактору (форматирование, выделение и т.п.). */
|
|
332
|
+
get editor(): RichEditor {
|
|
333
|
+
return this.__editor;
|
|
334
|
+
}
|
|
335
|
+
|
|
757
336
|
onChange(handler: (e: ChangeEventData) => void) {
|
|
758
337
|
this.on(CHANGE_EVENT, handler);
|
|
759
338
|
}
|
|
@@ -767,10 +346,8 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
767
346
|
}
|
|
768
347
|
|
|
769
348
|
setValue(value: string): void {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
this.__initText();
|
|
773
|
-
this.__onChange();
|
|
349
|
+
// RichEditor нормализует и сгенерирует change — он синхронизирует скрытое поле и вызовет textbox-change
|
|
350
|
+
this.__editor.setValue(value?.trim() ?? "");
|
|
774
351
|
}
|
|
775
352
|
|
|
776
353
|
override validate(): boolean {
|
|
@@ -780,7 +357,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
780
357
|
|
|
781
358
|
if (this.required && !value) isValid = false;
|
|
782
359
|
|
|
783
|
-
|
|
360
|
+
// длина — по видимому тексту (getLength), а не по сериализованному value:
|
|
361
|
+
// при format/html в value есть теги, в multiline — разделители абзацев \n\n
|
|
362
|
+
if (this.maxlength > 0 && this.maxlength < this.__editor.getLength()) isValid = false;
|
|
784
363
|
}
|
|
785
364
|
|
|
786
365
|
if (!isValid) this.element.classList.add("invalid");
|
|
@@ -791,8 +370,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
791
370
|
|
|
792
371
|
override destroy(): void {
|
|
793
372
|
this.__listenerAbort.abort();
|
|
794
|
-
this.
|
|
373
|
+
this.__editor.destroy();
|
|
795
374
|
|
|
375
|
+
this.__valueElem.tabIndex = this.__inputElem.tabIndex;
|
|
796
376
|
this.element.insertAdjacentElement("afterend", this.__valueElem);
|
|
797
377
|
this.element.remove();
|
|
798
378
|
|