@brandup/ui-textbox 1.0.30 → 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 CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  [![Build Status](https://dev.azure.com/brandup/BrandUp%20Core/_apis/build/status%2FBrandUp%2Fbrandup-ui-kit?branchName=main)](https://dev.azure.com/brandup/BrandUp%20Core/_build/latest?definitionId=81&branchName=main)
4
4
 
5
- Компонент текстового поля на базе `contenteditable`. Заменяет стандартные `<input>` и `<textarea>`, добавляя расширенное поведение: счётчик символов, кнопку копирования, фильтрацию ввода по типу и валидацию.
5
+ Компонент текстового поля. Заменяет стандартные `<input>` и `<textarea>`, добавляя расширенное поведение: счётчик символов, кнопку копирования, фильтрацию ввода по типу, валидацию и опциональное форматирование текста (жирный, курсив, зачёркивание, подчёркивание).
6
+
7
+ Вся работа с редактируемой областью вынесена в [`@brandup/ui-richeditor`](../brandup-ui-richeditor): `TextBox` создаёт скрытое поле формы и компонует над ним `RichEditor`. Экземпляр редактора доступен через свойство `editor`.
6
8
 
7
9
  ## Установка
8
10
 
@@ -51,6 +53,72 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
51
53
  | `data-copy-button` | Добавляет кнопку копирования значения в буфер обмена |
52
54
  | `data-allow-empty-strings` | Разрешает значение из одних пробелов |
53
55
  | `data-readonly` | Альтернативный способ задать режим только для чтения |
56
+ | `data-format` | Включает форматирование текста (только для `type="text"`) |
57
+ | `data-format-tools` | Состав инструментов форматирования через пробел (по умолчанию все): `bold italic strike underline` |
58
+ | `data-format-storage` | Формат хранения значения: `html` (по умолчанию) или `markdown` |
59
+ | `data-format-md-bold` | Markdown-маркер для жирного (по умолчанию `**`) |
60
+ | `data-format-md-italic` | Markdown-маркер для курсива (по умолчанию `*`) |
61
+ | `data-format-md-strike` | Markdown-маркер для зачёркивания (по умолчанию `~~`) |
62
+ | `data-format-md-underline` | Markdown-маркер для подчёркивания (по умолчанию `++`) |
63
+
64
+ ## Форматирование текста
65
+
66
+ Атрибут `data-format` включает панель форматирования с инструментами: жирный, курсив, зачёркивание, подчёркивание. Панель всплывает над контролом, пока он в фокусе (класс состояния `focused`). Работает поверх существующего `contenteditable`-редактора, поэтому доступно только для текстового ввода (`type="text"` и `<textarea>`).
67
+
68
+ ```html
69
+ <!-- все инструменты, хранение в HTML -->
70
+ <textarea data-content-script="textbox" data-format></textarea>
71
+
72
+ <!-- только жирный и курсив, хранение в Markdown -->
73
+ <input
74
+ type="text"
75
+ data-content-script="textbox"
76
+ data-format
77
+ data-format-tools="bold italic"
78
+ data-format-storage="markdown"
79
+ />
80
+ ```
81
+
82
+ ### Состав инструментов — `data-format-tools`
83
+
84
+ Список через пробел из значений `bold`, `italic`, `strike`, `underline`. Неизвестные значения игнорируются. Если атрибут не задан — включаются все инструменты.
85
+
86
+ ### Применение формата
87
+
88
+ - Формат — **переключатель** (toggle): повторное применение к уже отформатированному тексту снимает его.
89
+ - Форматирование применяется **к слову целиком**: если курсор стоит внутри слова без выделения или выделена лишь его часть — формат охватывает всё слово. При выделении части нескольких слов каждая граница доводится до целого слова. Исходное выделение/каретка после применения сохраняются (слово не выделяется автоматически).
90
+ - **Режим набора**: если под кареткой нет слова (курсор между пробелами или поле пустое), кнопка/хоткей не форматируют текст, а включают «ожидающий» формат — он подсветится активным и применится к следующему введённому тексту. Режим сбрасывается при перемещении каретки, клике или потере фокуса.
91
+ - Кнопка инструмента подсвечивается (`active`), когда выделение целиком отформатировано этим инструментом или активен режим набора.
92
+ - Реализация работает на стандартном Selection/Range API — без устаревшего `document.execCommand`, поэтому разметка всегда семантическая (`<b>`, `<i>`, `<s>`, `<u>`) и предсказуема между браузерами.
93
+
94
+ ### Хоткеи
95
+
96
+ `Ctrl/Cmd+B` — жирный, `Ctrl/Cmd+I` — курсив, `Ctrl/Cmd+U` — подчёркивание. Зачёркивание включается только кнопкой. Хоткеи отключённых инструментов перехватываются, чтобы не срабатывало форматирование браузера.
97
+
98
+ ### Формат хранения — `data-format-storage`
99
+
100
+ Значение синхронизируется в скрытое поле в выбранном формате:
101
+
102
+ | Значение | Хранение | Поддерживаемые теги/маркеры |
103
+ |---|---|---|
104
+ | `html` (по умолчанию) | Санитизированный HTML | `<b>`, `<i>`, `<s>`, `<u>`, переводы строк через `<br>` |
105
+ | `markdown` | Лёгкая разметка | `**жирный**`, `*курсив*`, `~~зачёркнутый~~`, `++подчёркнутый++` |
106
+
107
+ Маркеры для каждого инструмента настраиваются атрибутами `data-format-md-<tool>` (актуально только при `data-format-storage="markdown"`):
108
+
109
+ ```html
110
+ <textarea
111
+ data-content-script="textbox"
112
+ data-format
113
+ data-format-storage="markdown"
114
+ data-format-md-italic="_"
115
+ data-format-md-bold="__"
116
+ ></textarea>
117
+ ```
118
+
119
+ При разборе маркеры применяются по убыванию длины, поэтому более длинный маркер (`__`) корректно срабатывает раньше короткого-префикса (`_`). Маркеры разных инструментов должны различаться.
120
+
121
+ > Markdown-режим не имеет стандартного маркера для подчёркивания — по умолчанию используется `++текст++`. Глубоко вложенные комбинации форматов (например, жирный внутри курсива) гарантированно сохраняются только в режиме `html`.
54
122
 
55
123
  ## API
56
124
 
@@ -75,6 +143,10 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
75
143
  | `copyButton` | `boolean` | Наличие кнопки копирования |
76
144
  | `symbolCounter` | `boolean` | Наличие счётчика символов |
77
145
  | `placeholder` | `string \| null` | Текст-заглушка |
146
+ | `format` | `boolean` | Включено ли форматирование |
147
+ | `formatStorage` | `FormatStorage` | Формат хранения: `"html"` \| `"markdown"` |
148
+ | `formatTools` | `FormatTool[]` | Включённые инструменты форматирования |
149
+ | `formatMarkers` | `FormatMarkers` | Markdown-маркеры по инструментам (с учётом переопределений) |
78
150
 
79
151
  ### Событие textbox-change
80
152
 
@@ -92,6 +164,15 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
92
164
  textbox.onChange((data) => { ... });
93
165
  ```
94
166
 
167
+ ## Нормализация пробелов
168
+
169
+ Когда редактирование логически завершено (поле теряет фокус), а также после инициализации и `setValue`, текст нормализуется:
170
+
171
+ - повторяющиеся пробелы/табы схлопываются в один;
172
+ - пробелы по краям каждой строки обрезаются.
173
+
174
+ Форматирование и переносы строк (`<br>`/блоки) при этом сохраняются. Во время набора текста пробелы не трогаются — нормализация выполняется только по завершении. Если нормализация изменила значение, при потере фокуса генерируется событие [`textbox-change`](#событие-textbox-change).
175
+
95
176
  ## CSS-классы состояний
96
177
 
97
178
  | Класс | Условие |
@@ -102,3 +183,4 @@ textbox.onChange((data) => { ... });
102
183
  | `required` | Атрибут `required` задан |
103
184
  | `readonly` | Режим только для чтения |
104
185
  | `disabled` | Элемент отключён |
186
+ | `active` | На кнопке панели форматирования — формат активен для текущего выделения |
package/package.json CHANGED
@@ -1,4 +1,7 @@
1
1
  {
2
+ "publishConfig": {
3
+ "access": "public"
4
+ },
2
5
  "name": "@brandup/ui-textbox",
3
6
  "description": "Textbox control type and styles.",
4
7
  "keywords": [
@@ -21,14 +24,15 @@
21
24
  "email": "it@brandup.online"
22
25
  },
23
26
  "license": "Apache-2.0",
24
- "version": "1.0.30",
27
+ "version": "1.0.36",
25
28
  "main": "source/index.ts",
26
29
  "types": "source/index.ts",
27
30
  "dependencies": {
28
- "@brandup/ui": "^2.0.3",
29
- "@brandup/ui-helpers": "^2.0.3",
30
- "@brandup/ui-input": "^1.0.30",
31
- "@brandup/ui-kit": "^1.0.30"
31
+ "@brandup/ui": "^2.0.5",
32
+ "@brandup/ui-helpers": "^2.0.5",
33
+ "@brandup/ui-input": "^1.0.36",
34
+ "@brandup/ui-kit": "^1.0.36",
35
+ "@brandup/ui-richeditor": "^1.0.36"
32
36
  },
33
37
  "files": [
34
38
  "source",
package/source/index.ts CHANGED
@@ -1 +1,14 @@
1
1
  export { default } from "./textbox";
2
+ export * from "./textbox";
3
+
4
+ // реэкспорт типов/утилит форматирования из @brandup/ui-richeditor для обратной совместимости
5
+ export {
6
+ ALL_FORMAT_TOOLS,
7
+ FORMAT_TOOLS,
8
+ parseFormatTools,
9
+ defaultFormatMarkers,
10
+ normalizeWhitespace,
11
+ type FormatTool,
12
+ type FormatStorage,
13
+ type FormatMarkers,
14
+ } from "@brandup/ui-richeditor";
@@ -46,11 +46,11 @@
46
46
  box-sizing: border-box;
47
47
  cursor: text;
48
48
  position: relative;
49
- overflow-y: auto;
50
49
  z-index: 2;
51
50
  margin-right: 5px;
52
51
  padding-right: calc(var(--input-padding-lr) - 5px);
53
52
  scrollbar-width: 6px;
53
+ overflow-y: auto;
54
54
 
55
55
  &::-webkit-scrollbar {
56
56
  width: 6px;
@@ -71,35 +71,15 @@
71
71
  }
72
72
  }
73
73
 
74
- & .input {
75
- appearance: none;
76
- position: relative;
74
+ // редактор (@brandup/ui-richeditor) — занимает доступное место; внутреннее оформление в richeditor.less
75
+ & .ui-richeditor {
77
76
  flex: 1 1 auto;
78
- box-sizing: border-box;
79
- word-wrap: anywhere;
80
- outline: none;
81
-
82
- &:empty {
83
- &:after {
84
- content: attr(data-placeholder);
85
- display: block;
86
- position: absolute;
87
- left: 0;
88
- top: 0;
89
- right: 0;
90
- color: var(--placeholder-color);
91
- font-weight: var(--placeholder-font-weight);
92
- font-style: var(--placeholder-font-style);
93
- box-sizing: border-box;
94
- overflow: hidden;
95
- white-space: nowrap;
96
- text-overflow: ellipsis;
97
- }
77
+ min-width: 0;
78
+ }
98
79
 
99
- & ~ .symbols {
100
- display: none;
101
- }
102
- }
80
+ // скрываем счётчик, пока редактор пуст (показан placeholder)
81
+ & .editor:has(.ui-richeditor:empty) .symbols {
82
+ display: none;
103
83
  }
104
84
 
105
85
  & .symbols {
@@ -136,6 +116,16 @@
136
116
  outline: none;
137
117
  transition: all ease 100ms;
138
118
  cursor: pointer;
119
+ opacity: 0.7;
120
+
121
+ &.success {
122
+ --svg-fill: var(--color-success);
123
+ }
124
+
125
+ &:hover,
126
+ &.success {
127
+ opacity: 1;
128
+ }
139
129
  }
140
130
 
141
131
  &:empty {
@@ -153,15 +143,8 @@
153
143
  gap: 2px;
154
144
  }
155
145
 
156
- & .input {
146
+ & .ui-richeditor {
157
147
  flex: 1 10 auto;
158
-
159
- &:empty {
160
- &:after {
161
- white-space: normal;
162
- text-overflow: unset;
163
- }
164
- }
165
148
  }
166
149
  }
167
150
 
package/source/textbox.ts CHANGED
@@ -4,6 +4,14 @@ 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 RichEditor, {
8
+ defaultFormatMarkers,
9
+ parseFormatTools,
10
+ type FormatMarkers,
11
+ type FormatStorage,
12
+ type FormatTool,
13
+ type RichEditorOptions,
14
+ } from "@brandup/ui-richeditor";
7
15
  import copyIcon from "../svg/copy.svg";
8
16
  import doneIcon from "../svg/tick.svg";
9
17
 
@@ -20,7 +28,8 @@ type TextBoxEvents = {
20
28
  };
21
29
 
22
30
  export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAreaElement, TextBoxEvents> {
23
- private __inputElem: HTMLElement;
31
+ private __editor: RichEditor;
32
+ private __inputElem: HTMLElement; // редактируемый элемент (им владеет RichEditor)
24
33
  private __symbolsCountElem: HTMLElement;
25
34
  private __listenerAbort = new AbortController();
26
35
 
@@ -33,6 +42,10 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
33
42
  readonly inputmode: string;
34
43
  readonly symbolCounter: boolean;
35
44
  readonly autoFocus: boolean;
45
+ readonly format: boolean;
46
+ readonly formatStorage: FormatStorage;
47
+ readonly formatTools: FormatTool[];
48
+ readonly formatMarkers: FormatMarkers;
36
49
 
37
50
  constructor(valueElem: HTMLInputElement | HTMLTextAreaElement) {
38
51
  // определяем тип ввода и нормализуем валидационные атрибуты до super()
@@ -73,8 +86,24 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
73
86
  const multyline = valueElem instanceof HTMLTextAreaElement;
74
87
  const copyButton = valueElem.hasAttribute("data-copy-button") || valueElem.hasAttribute("data-copybutton");
75
88
  const disabled = valueElem.disabled;
89
+ const readonly = valueElem.hasAttribute("readonly") || valueElem.hasAttribute("data-readonly");
90
+
91
+ // форматирование доступно только для обычного текстового ввода
92
+ const format = type === "text" && valueElem.hasAttribute("data-format");
93
+ const formatStorage: FormatStorage =
94
+ valueElem.getAttribute("data-format-storage") === "markdown" ? "markdown" : "html";
95
+ const formatTools = format ? parseFormatTools(valueElem.getAttribute("data-format-tools")) : [];
96
+
97
+ // markdown-маркеры с дефолтами, переопределяются атрибутами data-format-md-<tool>
98
+ const formatMarkers = defaultFormatMarkers();
99
+ if (format) {
100
+ for (const tool of Object.keys(formatMarkers) as FormatTool[]) {
101
+ const marker = valueElem.getAttribute(`data-format-md-${tool}`)?.trim();
102
+ if (marker) formatMarkers[tool] = marker;
103
+ }
104
+ }
76
105
 
77
- const inputElem = DOM.tag("div", { class: "input" });
106
+ const inputElem = DOM.tag("div");
78
107
  const actionsElem = DOM.tag("div", { class: "actions" });
79
108
  const symbolsCountElem = DOM.tag("div", { class: "symbols" });
80
109
 
@@ -86,15 +115,11 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
86
115
 
87
116
  container.classList.remove(INPUT_CLASS);
88
117
 
89
- inputElem.tabIndex = valueElem.tabIndex;
118
+ inputElem.tabIndex = disabled ? -1 : valueElem.tabIndex;
90
119
  valueElem.tabIndex = -1;
91
120
 
92
121
  if (multyline) container.classList.add("multyline");
93
122
  if (symbolCounter) container.classList.add("counter");
94
-
95
- if (disabled) inputElem.tabIndex = -1;
96
- else inputElem.contentEditable = "true";
97
-
98
123
  if (inputmode) inputElem.inputMode = inputmode;
99
124
 
100
125
  if (copyButton) {
@@ -107,8 +132,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
107
132
  actionsElem.insertAdjacentElement("beforeend", buttonElem);
108
133
  }
109
134
 
110
- inputElem.setAttribute("data-placeholder", placeholder ?? "");
111
-
112
135
  // убираем висящую миниатюру, если есть, и вставляем container на место valueElem
113
136
  if (valueElem.nextElementSibling) {
114
137
  const nextElem = valueElem.nextElementSibling as HTMLElement;
@@ -128,218 +151,129 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
128
151
  this.inputmode = inputmode;
129
152
  this.multyline = multyline;
130
153
  this.copyButton = copyButton;
154
+ this.format = format;
155
+ this.formatStorage = formatStorage;
156
+ this.formatTools = formatTools;
157
+ this.formatMarkers = formatMarkers;
131
158
 
132
159
  this.__inputElem = inputElem;
133
160
  this.__symbolsCountElem = symbolsCountElem;
134
161
 
135
- this.__initLogic();
136
- this.__initText();
137
- }
138
-
139
- private __initLogic() {
140
- const { signal } = this.__listenerAbort; // один AbortController отписывает все listener'ы в destroy
141
-
142
- this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
143
- this.element.addEventListener("dragenter", (e) => e.preventDefault(), { signal });
144
-
145
- this.__valueElem.addEventListener(
146
- "change",
147
- (e: Event) => {
148
- e.preventDefault();
149
- e.stopImmediatePropagation();
150
- },
151
- { signal }
152
- );
153
-
154
- let hasInputClick = false;
155
- this.__inputElem.addEventListener(
156
- "mousedown",
157
- () => {
158
- if (this.disabled) return;
159
-
160
- hasInputClick = true;
161
- },
162
- { signal }
163
- );
164
-
165
- this.__inputElem.addEventListener(
166
- "focus",
167
- () => {
168
- if (this.disabled) return;
169
-
170
- this.element.classList.add("focused");
171
-
172
- if (this.readonly) this.__selectAll();
173
- else if (!hasInputClick) this.__carretToEnd(); // пыремещаем курсов в конец, если клик не по строке
174
- },
175
- { signal }
176
- );
177
-
178
- this.__inputElem.addEventListener(
179
- "blur",
180
- () => {
181
- hasInputClick = false;
182
-
183
- if (this.disabled) return;
184
-
185
- this.element.classList.remove("focused");
186
-
187
- // когда удаляем весь текст, то браузер оставляет один BR, что означает что текста нет
188
- // удалить BR нужно, чтобы появился placeholder
189
- if (this.__inputElem.firstChild?.nodeName === "BR") DOM.empty(this.__inputElem);
190
- },
191
- { signal }
192
- );
193
-
194
- this.__inputElem.addEventListener(
195
- "dblclick",
196
- () => {
197
- if (this.disabled) return;
198
-
199
- if (this.copyButton && this.readonly) this.__selectAll();
200
- },
201
- { signal }
202
- );
203
-
204
- this.element.addEventListener(
205
- "paste",
206
- (e: ClipboardEvent) => {
207
- e.preventDefault();
208
- e.stopPropagation();
209
-
210
- if (this.readonly || this.disabled) return false;
211
-
212
- let pastedData = e.clipboardData?.getData("text/plain");
213
- if (!pastedData) return false;
214
-
215
- if (this.type == "number") {
216
- const numberData = /[\d\s]+/.exec(pastedData);
217
- if (numberData && numberData.length) pastedData = numberData[0].replace(/\s/g, "");
218
- else {
219
- this.element.classList.add("incorrect");
220
- window.setTimeout(() => this.element.classList.remove("incorrect"), 300);
221
- return false;
222
- }
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;
223
192
  }
193
+ return typeAllowsChar(char);
194
+ };
195
+ }
224
196
 
225
- const selection = window.getSelection();
226
- if (!selection) return false; // TODO
227
-
228
- if (this.maxlength > 0) {
229
- // обрезаем вставляемый текст по кол-ву оставшихся символов для ввода
230
-
231
- const selectionLength = selection.toString().length;
232
- const currentTextLength = this.__getTextLength();
233
- const leftSymbols = this.maxlength - currentTextLength + selectionLength; // осталось символов для ввода
197
+ if (type === "number" || maxlength > 0) {
198
+ options.filterPaste = (text) => {
199
+ let pasted = text;
234
200
 
235
- 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, "");
236
205
  }
237
206
 
238
- const lines = pastedData.split(/\n/);
239
- // тримим все строки, кроме начала первой строки, вдруг так нужно
240
- const output = lines.map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
241
-
242
- const fragment = document.createDocumentFragment();
243
- if (!this.multyline) {
244
- fragment.appendChild(document.createTextNode(output.join(" ")));
245
- } else {
246
- output.forEach((line, index) => {
247
- if (index > 0) fragment.appendChild(document.createElement("br"));
248
-
249
- fragment.appendChild(document.createTextNode(line));
250
- });
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));
251
212
  }
252
213
 
253
- // Удаляем выделенную область
254
- selection.getRangeAt(0).deleteContents();
255
-
256
- // Вставляем текст
257
- selection.getRangeAt(0).insertNode(fragment);
258
-
259
- // Перемещаем курсор в конец вставленной области
260
- selection.setPosition(selection.focusNode, selection.focusOffset);
261
-
262
- this.__applyValue();
214
+ return pasted;
215
+ };
216
+ }
263
217
 
264
- return true;
265
- },
266
- { signal }
267
- );
218
+ this.__editor = new RichEditor(inputElem, options);
268
219
 
269
- this.__inputElem.addEventListener(
270
- "keydown",
271
- (e: KeyboardEvent) => {
272
- const isChar = e.key.length === 1;
220
+ // RichEditor не знает про disabled — отключаем редактирование на стороне TextBox
221
+ // (визуал даёт класс .disabled от InputControl: затемнение, user-select: none)
222
+ if (disabled) inputElem.contentEditable = "false";
273
223
 
274
- if ((this.readonly || this.disabled) && isChar && !e.ctrlKey) {
275
- e.preventDefault();
276
- e.stopPropagation();
277
- return false;
278
- }
224
+ // синхронизируем скрытое поле с нормализованным содержимым редактора (без события)
225
+ this.__valueElem.value = this.__editor.getValue();
279
226
 
280
- if (this.maxlength > 0 && isChar && !e.ctrlKey) {
281
- const currentTextLength = this.__getTextLength();
282
- if (currentTextLength >= this.maxlength) {
283
- e.preventDefault();
284
- e.stopPropagation();
227
+ this.__initLogic();
228
+ this.__refreshSymbolsCount();
285
229
 
286
- this.__toIncorrect();
287
- return false;
288
- }
289
- }
230
+ if (this.autoFocus && !IS_TOUCH_DEVICE && !disabled && !readonly) this.__editor.focus();
231
+ }
290
232
 
291
- if (isChar && !e.ctrlKey) {
292
- let isIncorrect = false;
293
-
294
- switch (this.type) {
295
- case "number":
296
- if (!/\d/.test(e.key)) isIncorrect = true;
297
- break;
298
- case "email":
299
- if (!/[a-zA-Z\d.\-_@]/.test(e.key)) isIncorrect = true;
300
- break;
301
- }
302
-
303
- if (isIncorrect) {
304
- e.preventDefault();
305
- e.stopPropagation();
306
-
307
- this.__toIncorrect();
308
- return false;
309
- }
310
- }
233
+ private __initLogic() {
234
+ const { signal } = this.__listenerAbort;
235
+ const editable = this.__inputElem;
311
236
 
312
- if (!this.multyline && (e.key == "U+000A" || e.key == "Enter")) {
313
- // Если однострочный режим и нажат enter, то отправляем submit формы
314
- e.preventDefault();
315
- this.__submitForm();
316
- return false;
317
- }
237
+ // изменения редактора значение формы, счётчик, валидность, событие
238
+ this.__editor.onChange((data) => {
239
+ this.__valueElem.value = data.value;
318
240
 
319
- return true;
320
- },
321
- { signal }
322
- );
323
-
324
- this.__inputElem.addEventListener(
325
- "input",
326
- () => {
327
- if (this.multyline && this.__inputElem.children.length === 1) {
328
- const child = this.__inputElem.children.item(0);
329
- if (child && child.tagName === "BR") this.__inputElem.innerHTML = "";
330
- }
241
+ this.__refreshSymbolsCount();
331
242
 
332
- this.__applyValue();
243
+ let clearInvalidState = true;
244
+ if (this.element.classList.contains("invalid")) clearInvalidState = this.validate();
245
+ if (clearInvalidState) this.element.classList.remove("invalid");
333
246
 
334
- let clearInvalidState = true;
247
+ this.__onChange();
248
+ });
335
249
 
336
- if (this.element.classList.contains("invalid")) clearInvalidState = this.validate(); // Если уже не валидно, то перепроверяем
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 });
337
253
 
338
- if (clearInvalidState) this.element.classList.remove("invalid");
254
+ // гасим нативный change скрытого поля
255
+ this.__valueElem.addEventListener(
256
+ "change",
257
+ (e: Event) => {
258
+ e.preventDefault();
259
+ e.stopImmediatePropagation();
339
260
  },
340
261
  { signal }
341
262
  );
342
263
 
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
+ }
276
+
343
277
  this.registerCommand("copy-text", async (context) => {
344
278
  if (!window.navigator.clipboard || this.disabled) return;
345
279
 
@@ -357,37 +291,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
357
291
  });
358
292
  }
359
293
 
360
- private __initText() {
361
- DOM.empty(this.__inputElem);
362
-
363
- const text = this.__valueElem.value;
364
- if (text) {
365
- const lines = text.split(/\n/);
366
- lines.forEach((line, index) => {
367
- line = line.trim();
368
-
369
- if (index === 0) this.__inputElem.append(document.createTextNode(line));
370
- else {
371
- const lineElem = document.createElement("div");
372
- lineElem.textContent = line;
373
- this.__inputElem.append(lineElem);
374
- }
375
- });
376
- }
377
-
378
- this.__refreshSymbolsCount();
379
-
380
- if (this.autoFocus && !IS_TOUCH_DEVICE && !this.disabled && !this.readonly) this.__inputElem.focus();
381
- }
382
-
383
- private __applyValue() {
384
- const newValue = this.__inputElem.innerText.trim();
385
- this.__valueElem.value = newValue;
386
-
387
- this.__refreshSymbolsCount();
388
- this.__onChange();
389
- }
390
-
391
294
  private __toIncorrect() {
392
295
  this.element.classList.add("incorrect");
393
296
  window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
@@ -396,7 +299,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
396
299
  private __refreshSymbolsCount() {
397
300
  if (!this.__symbolsCountElem) return;
398
301
 
399
- const textLength = this.__getTextLength();
302
+ const textLength = this.__editor.getLength();
400
303
  let counterValue: string;
401
304
 
402
305
  if (this.maxlength > 0) {
@@ -408,29 +311,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
408
311
  this.__symbolsCountElem.textContent = counterValue;
409
312
  }
410
313
 
411
- private __selectAll() {
412
- this.__inputElem.focus();
413
-
414
- window.getSelection()?.selectAllChildren(this.__inputElem);
415
- }
416
-
417
- private __carretToEnd() {
418
- const range = document.createRange();
419
- range.selectNodeContents(this.__inputElem);
420
- range.collapse(false);
421
- const sel = window.getSelection();
422
- if (sel) {
423
- sel.removeAllRanges();
424
- sel.addRange(range);
425
- }
426
- }
427
-
428
- private __getTextLength() {
429
- // textContent не вставляет \n между блочными детьми (в отличие от innerText), так что multiline-контент считается корректно;
430
- // заодно работает в jsdom, где innerText не реализован.
431
- return this.__inputElem.textContent?.length ?? 0;
432
- }
433
-
434
314
  private __onChange() {
435
315
  this.trigger(CHANGE_EVENT, <ChangeEventData>{
436
316
  textbox: this,
@@ -438,6 +318,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
438
318
  });
439
319
  }
440
320
 
321
+ /** Многострочный режим (textarea). Псевдоним без опечатки в имени. */
322
+ get multiline(): boolean {
323
+ return this.multyline;
324
+ }
325
+
326
+ /** Доступ к встроенному редактору (форматирование, выделение и т.п.). */
327
+ get editor(): RichEditor {
328
+ return this.__editor;
329
+ }
330
+
441
331
  onChange(handler: (e: ChangeEventData) => void) {
442
332
  this.on(CHANGE_EVENT, handler);
443
333
  }
@@ -451,10 +341,8 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
451
341
  }
452
342
 
453
343
  setValue(value: string): void {
454
- this.__valueElem.value = value?.trim() ?? "";
455
-
456
- this.__initText();
457
- this.__onChange();
344
+ // RichEditor нормализует и сгенерирует change — он синхронизирует скрытое поле и вызовет textbox-change
345
+ this.__editor.setValue(value?.trim() ?? "");
458
346
  }
459
347
 
460
348
  override validate(): boolean {
@@ -464,7 +352,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
464
352
 
465
353
  if (this.required && !value) isValid = false;
466
354
 
467
- 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;
468
358
  }
469
359
 
470
360
  if (!isValid) this.element.classList.add("invalid");
@@ -475,8 +365,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
475
365
 
476
366
  override destroy(): void {
477
367
  this.__listenerAbort.abort();
478
- this.__valueElem.tabIndex = this.__inputElem.tabIndex;
368
+ this.__editor.destroy();
479
369
 
370
+ this.__valueElem.tabIndex = this.__inputElem.tabIndex;
480
371
  this.element.insertAdjacentElement("afterend", this.__valueElem);
481
372
  this.element.remove();
482
373