@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/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
- deserialize,
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
- } from "./format";
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 __inputElem: HTMLElement;
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", { class: "input" });
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
- if (this.type == "number") {
304
- const numberData = /[\d\s]+/.exec(pastedData);
305
- if (numberData && numberData.length) pastedData = numberData[0].replace(/\s/g, "");
306
- else {
307
- this.element.classList.add("incorrect");
308
- window.setTimeout(() => this.element.classList.remove("incorrect"), 300);
309
- return false;
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
- const selection = window.getSelection();
314
- if (!selection) return false; // TODO
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
- if (pastedData.length > leftSymbols) pastedData = pastedData.substring(0, leftSymbols);
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
- const lines = pastedData.split(/\n/);
327
- // тримим все строки, кроме начала первой строки, вдруг так нужно
328
- const output = lines.map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
329
-
330
- const fragment = document.createDocumentFragment();
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
- selection.getRangeAt(0).deleteContents();
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
- selection.setPosition(selection.focusNode, selection.focusOffset);
225
+ // RichEditor не знает про disabled — отключаем редактирование на стороне TextBox
226
+ // (визуал даёт класс .disabled от InputControl: затемнение, user-select: none)
227
+ if (disabled) inputElem.contentEditable = "false";
349
228
 
350
- this.__applyValue();
229
+ // синхронизируем скрытое поле с нормализованным содержимым редактора (без события)
230
+ this.__valueElem.value = this.__editor.getValue();
351
231
 
352
- return true;
353
- },
354
- { signal }
355
- );
232
+ this.__initLogic();
233
+ this.__refreshSymbolsCount();
356
234
 
357
- this.__inputElem.addEventListener(
358
- "keydown",
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
- const isChar = e.key.length === 1;
238
+ private __initLogic() {
239
+ const { signal } = this.__listenerAbort;
240
+ const editable = this.__inputElem;
374
241
 
375
- if ((this.readonly || this.disabled) && isChar && !e.ctrlKey) {
376
- e.preventDefault();
377
- e.stopPropagation();
378
- return false;
379
- }
242
+ // изменения редактора значение формы, счётчик, валидность, событие
243
+ this.__editor.onChange((data) => {
244
+ this.__valueElem.value = data.value;
380
245
 
381
- if (this.maxlength > 0 && isChar && !e.ctrlKey) {
382
- const currentTextLength = this.__getTextLength();
383
- if (currentTextLength >= this.maxlength) {
384
- e.preventDefault();
385
- e.stopPropagation();
246
+ this.__refreshSymbolsCount();
386
247
 
387
- this.__toIncorrect();
388
- return false;
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
- if (isChar && !e.ctrlKey) {
393
- let isIncorrect = false;
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
- if (!this.multyline && (e.key == "U+000A" || e.key == "Enter")) {
414
- // Если однострочный режим и нажат enter, то отправляем submit формы
415
- e.preventDefault();
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
- return true;
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
- this.__inputElem.addEventListener(
426
- "input",
427
- () => {
428
- if (this.multyline && this.__inputElem.children.length === 1) {
429
- const child = this.__inputElem.children.item(0);
430
- if (child && child.tagName === "BR") this.__inputElem.innerHTML = "";
431
- }
432
-
433
- this.__applyValue();
434
-
435
- let clearInvalidState = true;
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.__getTextLength();
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
- this.__valueElem.value = value?.trim() ?? "";
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
- if (this.maxlength > 0 && this.maxlength < value.length) isValid = false;
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.__valueElem.tabIndex = this.__inputElem.tabIndex;
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