@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/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
- } from "./format";
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 __inputElem: HTMLElement;
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", { class: "input" });
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
- 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
- }
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
- 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; // осталось символов для ввода
197
+ if (type === "number" || maxlength > 0) {
198
+ options.filterPaste = (text) => {
199
+ let pasted = text;
322
200
 
323
- if (pastedData.length > leftSymbols) pastedData = pastedData.substring(0, leftSymbols);
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
- 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
- });
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
- selection.getRangeAt(0).deleteContents();
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
- selection.setPosition(selection.focusNode, selection.focusOffset);
220
+ // RichEditor не знает про disabled — отключаем редактирование на стороне TextBox
221
+ // (визуал даёт класс .disabled от InputControl: затемнение, user-select: none)
222
+ if (disabled) inputElem.contentEditable = "false";
349
223
 
350
- this.__applyValue();
224
+ // синхронизируем скрытое поле с нормализованным содержимым редактора (без события)
225
+ this.__valueElem.value = this.__editor.getValue();
351
226
 
352
- return true;
353
- },
354
- { signal }
355
- );
227
+ this.__initLogic();
228
+ this.__refreshSymbolsCount();
356
229
 
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
- }
230
+ if (this.autoFocus && !IS_TOUCH_DEVICE && !disabled && !readonly) this.__editor.focus();
231
+ }
372
232
 
373
- const isChar = e.key.length === 1;
233
+ private __initLogic() {
234
+ const { signal } = this.__listenerAbort;
235
+ const editable = this.__inputElem;
374
236
 
375
- if ((this.readonly || this.disabled) && isChar && !e.ctrlKey) {
376
- e.preventDefault();
377
- e.stopPropagation();
378
- return false;
379
- }
237
+ // изменения редактора значение формы, счётчик, валидность, событие
238
+ this.__editor.onChange((data) => {
239
+ this.__valueElem.value = data.value;
380
240
 
381
- if (this.maxlength > 0 && isChar && !e.ctrlKey) {
382
- const currentTextLength = this.__getTextLength();
383
- if (currentTextLength >= this.maxlength) {
384
- e.preventDefault();
385
- e.stopPropagation();
241
+ this.__refreshSymbolsCount();
386
242
 
387
- this.__toIncorrect();
388
- return false;
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
- 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
- }
247
+ this.__onChange();
248
+ });
412
249
 
413
- if (!this.multyline && (e.key == "U+000A" || e.key == "Enter")) {
414
- // Если однострочный режим и нажат enter, то отправляем submit формы
415
- e.preventDefault();
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
- return true;
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
- 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
- );
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.__getTextLength();
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
- this.__valueElem.value = value?.trim() ?? "";
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
- if (this.maxlength > 0 && this.maxlength < value.length) isValid = false;
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.__valueElem.tabIndex = this.__inputElem.tabIndex;
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