@brandup/ui-richeditor 1.0.38 → 1.0.40
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 +32 -6
- package/package.json +2 -2
- package/source/editing.ts +85 -21
- package/source/format.ts +5 -1
- package/source/history.ts +18 -9
- package/source/index.ts +3 -0
- package/source/paragraphs.ts +23 -3
- package/source/richeditor.less +4 -0
- package/source/richeditor.ts +213 -107
- package/source/selection.ts +201 -75
- package/source/serialize.ts +28 -6
- package/source/toolbar.ts +160 -14
package/source/richeditor.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { DOM, UIElementBound } from "@brandup/ui";
|
|
|
4
4
|
import {
|
|
5
5
|
ALL_FORMAT_TOOLS,
|
|
6
6
|
HOTKEY_TOOLS,
|
|
7
|
+
activeFormats,
|
|
7
8
|
clearAllFormat,
|
|
8
9
|
clearFormat,
|
|
9
10
|
defaultFormatMarkers,
|
|
@@ -13,7 +14,10 @@ import {
|
|
|
13
14
|
hasFormatting,
|
|
14
15
|
insertFormattedText,
|
|
15
16
|
isFormatActive,
|
|
17
|
+
documentSelection,
|
|
18
|
+
innerSelection,
|
|
16
19
|
mapCharOffset,
|
|
20
|
+
preserveCaret,
|
|
17
21
|
normalizeParagraphs,
|
|
18
22
|
normalizeWhitespace,
|
|
19
23
|
restoreSelection,
|
|
@@ -27,19 +31,20 @@ import {
|
|
|
27
31
|
type FormatTool,
|
|
28
32
|
} from "./format";
|
|
29
33
|
import {
|
|
34
|
+
buildParagraphs,
|
|
30
35
|
caretToEnd,
|
|
31
36
|
expandRangeToWords,
|
|
32
37
|
insertParagraph,
|
|
33
38
|
insertPastedParagraphs,
|
|
34
39
|
insertSoftBreak,
|
|
40
|
+
sanitizePastedHtml,
|
|
35
41
|
selectAllContent,
|
|
36
|
-
trimParagraphEdges,
|
|
37
42
|
trimSelectionWhitespace,
|
|
38
43
|
} from "./editing";
|
|
39
44
|
import { EditorHistory } from "./history";
|
|
40
|
-
import { formatToolbar } from "./toolbar";
|
|
45
|
+
import { formatToolbar, type ToolbarButton } from "./toolbar";
|
|
41
46
|
|
|
42
|
-
export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost } from "./toolbar";
|
|
47
|
+
export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost, type ToolbarButton } from "./toolbar";
|
|
43
48
|
|
|
44
49
|
export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
|
|
45
50
|
export const CHANGE_EVENT = "richeditor-change";
|
|
@@ -59,6 +64,16 @@ const NATIVE_EDIT_TYPES = new Set([
|
|
|
59
64
|
"deleteByCut",
|
|
60
65
|
]);
|
|
61
66
|
|
|
67
|
+
// Ввод текста, который не проходит через keydown (IME, автозамена, автодополнение, диктовка), —
|
|
68
|
+
// к нему применяем фильтр символов хоста в beforeinput.
|
|
69
|
+
const FILTERED_INPUT_TYPES = new Set(["insertText", "insertReplacementText", "insertCompositionText"]);
|
|
70
|
+
|
|
71
|
+
// Максимальное отставание события change от печати. Сериализация значения — самая дорогая
|
|
72
|
+
// операция редактора (обход всего содержимого), а печать даёт input на каждый символ.
|
|
73
|
+
// Это троттлинг, а не debounce: при непрерывном наборе значение всё равно обновляется
|
|
74
|
+
// каждые CHANGE_THROTTLE_MS, а не откладывается до паузы.
|
|
75
|
+
const CHANGE_THROTTLE_MS = 150;
|
|
76
|
+
|
|
62
77
|
// Буква физической клавиши (KeyA…KeyZ) — не зависит от раскладки. Для не-латинских раскладок
|
|
63
78
|
// (например, кириллицы) e.key даёт другую букву, поэтому хоткеи сверяем и по e.code.
|
|
64
79
|
function codeLetter(e: KeyboardEvent): string {
|
|
@@ -91,6 +106,8 @@ export interface RichEditorOptions {
|
|
|
91
106
|
readonly?: boolean;
|
|
92
107
|
/** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
|
|
93
108
|
toolbarContainer?: HTMLElement | null;
|
|
109
|
+
/** Собственные кнопки хоста в панели — для действий, которых редактор не знает. */
|
|
110
|
+
buttons?: ToolbarButton[];
|
|
94
111
|
/** Начальное значение. */
|
|
95
112
|
value?: string;
|
|
96
113
|
|
|
@@ -124,11 +141,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
124
141
|
readonly multiline: boolean;
|
|
125
142
|
readonly paragraph: ParagraphMode;
|
|
126
143
|
readonly toolbarContainer: HTMLElement | null;
|
|
144
|
+
readonly toolbarButtons: ToolbarButton[];
|
|
127
145
|
|
|
128
146
|
private __opts: RichEditorOptions;
|
|
129
147
|
private __abort = new AbortController();
|
|
130
148
|
private __pendingFormats = new Set<FormatTool>();
|
|
131
149
|
private __hasInputClick = false;
|
|
150
|
+
private __changeTimer = 0; // отложенное change по печати — см. __emitChange/flushChange
|
|
151
|
+
// Окно редактируемого элемента, взятое при создании. Таймер отложенного change переживает
|
|
152
|
+
// снятие компонента и гасится в destroy, а тот случается когда угодно — к этому моменту
|
|
153
|
+
// до глобального окружения может быть уже не добраться, да и элемент мог жить в iframe.
|
|
154
|
+
private readonly __window: Window;
|
|
132
155
|
// собственная история undo/redo — только при форматировании (см. ./history)
|
|
133
156
|
private __history: EditorHistory | null = null;
|
|
134
157
|
|
|
@@ -148,6 +171,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
148
171
|
super("BrandUp.RichEditor", editable);
|
|
149
172
|
|
|
150
173
|
this.editable = editable;
|
|
174
|
+
this.__window = editable.ownerDocument.defaultView ?? window;
|
|
151
175
|
this.__opts = options;
|
|
152
176
|
this.format = format;
|
|
153
177
|
this.formatTools = tools;
|
|
@@ -157,6 +181,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
157
181
|
this.multiline = multiline;
|
|
158
182
|
this.paragraph = options.paragraph ?? "block";
|
|
159
183
|
this.toolbarContainer = options.toolbarContainer ?? null;
|
|
184
|
+
// кнопки хоста живут и без форматирования, но не в readonly — там панели нет вовсе
|
|
185
|
+
this.toolbarButtons = readonly ? [] : (options.buttons ?? []);
|
|
160
186
|
// история включается вместе с форматированием
|
|
161
187
|
this.__history = format ? new EditorHistory(editable) : null;
|
|
162
188
|
|
|
@@ -178,6 +204,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
178
204
|
return !!this.__opts.readonly;
|
|
179
205
|
}
|
|
180
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Работает ли редактор моделью абзацев. В режиме break абзацных блоков нет: значение — плоский
|
|
209
|
+
* текст, где каждый \n это <br>. Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без
|
|
210
|
+
* отступов между абзацами) это неотличимо от одного переноса — значение расходилось бы
|
|
211
|
+
* с видимым текстом.
|
|
212
|
+
*/
|
|
213
|
+
private get __blockParagraphs(): boolean {
|
|
214
|
+
return this.multiline && this.paragraph === "block";
|
|
215
|
+
}
|
|
216
|
+
|
|
181
217
|
// формат хранения значения: format → выбранный; plain → markdown без инструментов (\n\n/\n)
|
|
182
218
|
private get __valueStorage(): FormatStorage {
|
|
183
219
|
return this.format ? this.formatStorage : "markdown";
|
|
@@ -195,7 +231,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
195
231
|
|
|
196
232
|
setValue(value: string): void {
|
|
197
233
|
// каретка внутри относилась к прежнему содержимому — после замены ставим её в конец
|
|
198
|
-
const hadCaret = !!this.
|
|
234
|
+
const hadCaret = !!this.selection;
|
|
199
235
|
|
|
200
236
|
this.__render(value ?? "");
|
|
201
237
|
this.__normalize(false);
|
|
@@ -210,7 +246,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
210
246
|
* например, кнопкой панели, которая не должна забирать фокус у редактора.
|
|
211
247
|
*/
|
|
212
248
|
insertText(text: string): void {
|
|
213
|
-
if (this.readonly || !text || !this.
|
|
249
|
+
if (this.readonly || !text || !this.selection) return;
|
|
214
250
|
|
|
215
251
|
// вставка — такой же ввод, как с клавиатуры, поэтому проходит через filterChar хоста
|
|
216
252
|
// (ограничения по типу поля и длине). Обход символов идёт по кодпойнтам, чтобы эмодзи
|
|
@@ -238,7 +274,21 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
238
274
|
if (this.readonly) return;
|
|
239
275
|
|
|
240
276
|
const target = container ?? initiator.parentElement;
|
|
241
|
-
if (target)
|
|
277
|
+
if (!target) return;
|
|
278
|
+
|
|
279
|
+
formatToolbar.openEmoji(this, initiator, target);
|
|
280
|
+
|
|
281
|
+
// По кнопке могли нажать, ни разу не заходя в поле, — тогда каретки нет и вставлять символ
|
|
282
|
+
// некуда. Фокусировать вслепую нельзя: focus() сбросил бы уже стоящую каретку, и символ
|
|
283
|
+
// уехал бы не туда. Конец содержимого назначаем сами: фокус ставит каретку в начало,
|
|
284
|
+
// и дописанный к сообщению символ оказался бы перед текстом.
|
|
285
|
+
//
|
|
286
|
+
// Строго после открытия панели: фокус показывает тулбар, а придержать его панель успевает
|
|
287
|
+
// только когда открыта сама.
|
|
288
|
+
if (!this.selection) {
|
|
289
|
+
this.focus();
|
|
290
|
+
caretToEnd(this.editable, this.multiline);
|
|
291
|
+
}
|
|
242
292
|
}
|
|
243
293
|
|
|
244
294
|
getLength(): number {
|
|
@@ -258,11 +308,25 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
258
308
|
* Выделение, если оно находится внутри этого редактора. Браузер сохраняет выделение и после
|
|
259
309
|
* blur, поэтому проверка работает и когда фокус ушёл на кнопку страницы.
|
|
260
310
|
*/
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
311
|
+
get selection(): Selection | null {
|
|
312
|
+
return innerSelection(this.editable);
|
|
313
|
+
}
|
|
264
314
|
|
|
265
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Выделить узел внутри редактора — например, чтобы следующая вставка заменила его целиком.
|
|
317
|
+
* Не зависит от того, где стоит выделение сейчас: оно могло уйти, пока хост показывал
|
|
318
|
+
* своё окно, а сам узел никуда не делся.
|
|
319
|
+
*/
|
|
320
|
+
selectNode(node: Node): void {
|
|
321
|
+
if (!this.editable.contains(node)) return;
|
|
322
|
+
|
|
323
|
+
const selection = documentSelection(this.editable);
|
|
324
|
+
if (!selection) return;
|
|
325
|
+
|
|
326
|
+
const range = this.editable.ownerDocument.createRange();
|
|
327
|
+
range.selectNode(node);
|
|
328
|
+
selection.removeAllRanges();
|
|
329
|
+
selection.addRange(range);
|
|
266
330
|
}
|
|
267
331
|
|
|
268
332
|
/**
|
|
@@ -270,10 +334,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
270
334
|
* для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
|
|
271
335
|
* править, каретка пользователя не двигается. null — правка недоступна или выделение вне редактора.
|
|
272
336
|
*/
|
|
273
|
-
private __formatTarget(): { selection: Selection; range: Range; original: [number, number] } | null {
|
|
337
|
+
private __formatTarget(): { selection: Selection; range: Range; original: () => [number, number] } | null {
|
|
274
338
|
if (!this.format || this.readonly) return null;
|
|
275
339
|
|
|
276
|
-
const selection = this.
|
|
340
|
+
const selection = this.selection;
|
|
277
341
|
if (!selection) return null;
|
|
278
342
|
|
|
279
343
|
const current = selection.getRangeAt(0);
|
|
@@ -282,7 +346,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
282
346
|
selection,
|
|
283
347
|
// форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
|
|
284
348
|
range: expandRangeToWords(this.editable, current),
|
|
285
|
-
|
|
349
|
+
// границы считаем по требованию: они нужны только правкам, а цель вычисляется ещё и
|
|
350
|
+
// на каждое обновление панели, где сбор всего текста в строку — самая дорогая операция
|
|
351
|
+
original: () => selectionCharBounds(this.editable, current),
|
|
286
352
|
};
|
|
287
353
|
}
|
|
288
354
|
|
|
@@ -305,7 +371,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
305
371
|
this.__pendingFormats.clear();
|
|
306
372
|
|
|
307
373
|
this.__history?.record("op");
|
|
308
|
-
toggleFormat(this.editable, target.range, tool, target.selection, target.original);
|
|
374
|
+
toggleFormat(this.editable, target.range, tool, target.selection, target.original());
|
|
309
375
|
|
|
310
376
|
this.__emitChange();
|
|
311
377
|
formatToolbar.refresh();
|
|
@@ -325,7 +391,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
325
391
|
if (!hasFormatting(this.editable, target.range)) return;
|
|
326
392
|
|
|
327
393
|
this.__history?.record("op");
|
|
328
|
-
clearFormat(this.editable, target.range, target.selection, target.original);
|
|
394
|
+
clearFormat(this.editable, target.range, target.selection, target.original());
|
|
329
395
|
|
|
330
396
|
this.__emitChange();
|
|
331
397
|
formatToolbar.refresh();
|
|
@@ -338,14 +404,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
338
404
|
this.__clearPendingFormats();
|
|
339
405
|
if (!hasAnyFormatting(this.editable)) return;
|
|
340
406
|
|
|
341
|
-
// разворачивание тегов рвёт выделение — запоминаем по текстовым смещениям
|
|
342
|
-
const selection = this.__innerSelection();
|
|
343
|
-
const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
344
|
-
|
|
345
407
|
this.__history?.record("op");
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (bounds && selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
408
|
+
// разворачивание тегов рвёт выделение — сохраняем его по текстовым смещениям
|
|
409
|
+
preserveCaret(this.editable, () => clearAllFormat(this.editable));
|
|
349
410
|
|
|
350
411
|
this.__emitChange();
|
|
351
412
|
formatToolbar.refresh();
|
|
@@ -428,13 +489,29 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
428
489
|
isToolActive(tool: FormatTool): boolean {
|
|
429
490
|
if (this.__pendingFormats.has(tool)) return true;
|
|
430
491
|
|
|
431
|
-
const selection = this.
|
|
492
|
+
const selection = this.selection;
|
|
432
493
|
if (!selection) return false;
|
|
433
494
|
|
|
434
495
|
return isFormatActive(this.editable, selection.getRangeAt(0), tool);
|
|
435
496
|
}
|
|
436
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Активные форматы всех инструментов сразу — панель обновляется на каждое движение каретки,
|
|
500
|
+
* а поинструментный опрос обходил бы содержимое столько раз, сколько кнопок.
|
|
501
|
+
*/
|
|
502
|
+
activeTools(): ReadonlySet<FormatTool> {
|
|
503
|
+
const selection = this.selection;
|
|
504
|
+
const active = selection
|
|
505
|
+
? activeFormats(this.editable, selection.getRangeAt(0), this.formatTools)
|
|
506
|
+
: new Set<FormatTool>();
|
|
507
|
+
|
|
508
|
+
for (const tool of this.__pendingFormats) active.add(tool);
|
|
509
|
+
|
|
510
|
+
return active;
|
|
511
|
+
}
|
|
512
|
+
|
|
437
513
|
override destroy(): void {
|
|
514
|
+
this.flushChange(); // хост не должен остаться с устаревшей копией значения
|
|
438
515
|
this.__abort.abort();
|
|
439
516
|
formatToolbar.detach(this);
|
|
440
517
|
|
|
@@ -452,11 +529,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
452
529
|
DOM.empty(this.editable);
|
|
453
530
|
if (!value) return;
|
|
454
531
|
|
|
455
|
-
// multiline → <p>-абзацы; single-line → инлайновое
|
|
456
|
-
|
|
457
|
-
// Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без отступов между абзацами)
|
|
458
|
-
// это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
|
|
459
|
-
const paragraphs = this.multiline && this.paragraph === "block";
|
|
532
|
+
// multiline → <p>-абзацы; single-line → инлайновое содержимое
|
|
533
|
+
const paragraphs = this.__blockParagraphs;
|
|
460
534
|
|
|
461
535
|
this.editable.innerHTML = deserialize(
|
|
462
536
|
value,
|
|
@@ -471,10 +545,41 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
471
545
|
if (this.multiline && !paragraphs) ensureParagraphs(this.editable);
|
|
472
546
|
}
|
|
473
547
|
|
|
474
|
-
|
|
548
|
+
/**
|
|
549
|
+
* Событие изменения. При `defer` (печать) доставка откладывается — иначе каждый символ
|
|
550
|
+
* стоил бы полной сериализации содержимого. Все прочие правки (вставка, формат, отмена,
|
|
551
|
+
* setValue) сообщаются сразу: они разовые, а не посимвольные.
|
|
552
|
+
*
|
|
553
|
+
* `getValue()` считает значение по DOM и точен всегда; отложено только уведомление
|
|
554
|
+
* и, как следствие, копия значения у хоста — её сбрасывает {@link flushChange}.
|
|
555
|
+
*/
|
|
556
|
+
private __emitChange(defer = false) {
|
|
557
|
+
if (defer) {
|
|
558
|
+
// троттлинг: первый ввод заводит таймер, последующие в этом окне его не сдвигают
|
|
559
|
+
this.__changeTimer ||= this.__window.setTimeout(() => this.__emitChange(), CHANGE_THROTTLE_MS);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
this.__cancelChange();
|
|
475
564
|
this.trigger(CHANGE_EVENT, <RichEditorChangeData>{ editor: this, value: this.getValue() });
|
|
476
565
|
}
|
|
477
566
|
|
|
567
|
+
private __cancelChange() {
|
|
568
|
+
if (!this.__changeTimer) return;
|
|
569
|
+
|
|
570
|
+
this.__window.clearTimeout(this.__changeTimer);
|
|
571
|
+
this.__changeTimer = 0;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Доставить отложенное изменение немедленно. Вызывать перед тем, как значение читают
|
|
576
|
+
* извне: отправка формы, валидация, чтение значения хостом. Если ничего не отложено —
|
|
577
|
+
* ничего и не делает, лишнего события не будет.
|
|
578
|
+
*/
|
|
579
|
+
flushChange(): void {
|
|
580
|
+
if (this.__changeTimer) this.__emitChange();
|
|
581
|
+
}
|
|
582
|
+
|
|
478
583
|
private __reject() {
|
|
479
584
|
this.__opts.onReject?.();
|
|
480
585
|
}
|
|
@@ -486,7 +591,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
486
591
|
if (this.readonly) return;
|
|
487
592
|
|
|
488
593
|
// правка текстовых узлов рвёт живые Range — запоминаем выделение по текстовым смещениям
|
|
489
|
-
const selection = this.
|
|
594
|
+
const selection = this.selection;
|
|
490
595
|
const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
491
596
|
|
|
492
597
|
const before = this.editable.innerHTML;
|
|
@@ -512,8 +617,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
512
617
|
const { signal } = this.__abort;
|
|
513
618
|
const editable = this.editable;
|
|
514
619
|
|
|
620
|
+
// перетаскивание в редактор проходит мимо истории и фильтров хоста — гасим саму вставку.
|
|
621
|
+
// dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
|
|
515
622
|
this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
|
|
516
|
-
this.element.addEventListener("dragenter", (e) => e.preventDefault(), { signal });
|
|
517
623
|
|
|
518
624
|
editable.addEventListener(
|
|
519
625
|
"mousedown",
|
|
@@ -529,13 +635,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
529
635
|
() => {
|
|
530
636
|
this.element.classList.add("focused");
|
|
531
637
|
|
|
532
|
-
//
|
|
533
|
-
|
|
638
|
+
// нужна ли панель этому редактору, решает она сама — иначе условие пришлось бы
|
|
639
|
+
// держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
|
|
640
|
+
formatToolbar.attach(this);
|
|
534
641
|
|
|
535
642
|
if (this.readonly) selectAllContent(this.editable);
|
|
536
643
|
// Каретку в конец ставим только когда её нет: клик ставит сам, а уже стоящую
|
|
537
644
|
// (фокус вернули из кода после вызова метода) двигать нельзя — уедет в конец текста.
|
|
538
|
-
else if (!this.__hasInputClick && !this.
|
|
645
|
+
else if (!this.__hasInputClick && !this.selection) caretToEnd(this.editable, this.multiline);
|
|
539
646
|
},
|
|
540
647
|
{ signal }
|
|
541
648
|
);
|
|
@@ -553,6 +660,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
553
660
|
|
|
554
661
|
this.__clearPendingFormats();
|
|
555
662
|
this.__normalize(true); // редактирование завершено
|
|
663
|
+
this.flushChange(); // ввод закончен — отложенное изменение доставляем сразу
|
|
556
664
|
},
|
|
557
665
|
{ signal }
|
|
558
666
|
);
|
|
@@ -568,19 +676,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
568
676
|
"input",
|
|
569
677
|
() => {
|
|
570
678
|
if (this.multiline) {
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
: null;
|
|
578
|
-
const before = editable.innerHTML;
|
|
679
|
+
// приведение к абзацам меняет структуру (обёртка в <p>, удаление <br>) и сбрасывает
|
|
680
|
+
// каретку — сохраняем её; если структура не менялась, выделение живо и переставлять
|
|
681
|
+
// его не нужно (лишний сброс способен прервать IME-набор)
|
|
682
|
+
preserveCaret(editable, () => {
|
|
683
|
+
const before = editable.innerHTML;
|
|
684
|
+
ensureParagraphs(editable); // блуждающий текст/div → <p>
|
|
579
685
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (caret && selection && editable.innerHTML !== before)
|
|
583
|
-
restoreSelection(editable, caret[0], caret[1], selection);
|
|
686
|
+
return editable.innerHTML !== before;
|
|
687
|
+
});
|
|
584
688
|
|
|
585
689
|
// единственный пустой абзац → очищаем, чтобы показать placeholder
|
|
586
690
|
if (editable.children.length === 1) {
|
|
@@ -590,7 +694,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
590
694
|
} else if (editable.firstChild?.nodeName === "BR") {
|
|
591
695
|
editable.innerHTML = "";
|
|
592
696
|
}
|
|
593
|
-
this.__emitChange();
|
|
697
|
+
this.__emitChange(true); // печать — единственный посимвольный источник, его и откладываем
|
|
594
698
|
},
|
|
595
699
|
{ signal }
|
|
596
700
|
);
|
|
@@ -652,6 +756,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
652
756
|
return;
|
|
653
757
|
}
|
|
654
758
|
|
|
759
|
+
// Абзацы и переносы правятся вручную, мимо beforeinput, поэтому запрет на изменение
|
|
760
|
+
// текста проверяем здесь: иначе Enter добавлял бы строки и в режиме только для чтения.
|
|
761
|
+
if (this.readonly) return;
|
|
762
|
+
|
|
655
763
|
// В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
|
|
656
764
|
// В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
|
|
657
765
|
// набирается двумя переносами.
|
|
@@ -692,79 +800,55 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
692
800
|
if (filtered !== plain) plainOverride = filtered;
|
|
693
801
|
}
|
|
694
802
|
|
|
695
|
-
|
|
696
|
-
|
|
803
|
+
// вставлять только в своё содержимое: выделение вне редактора нам не адресовано
|
|
804
|
+
const selection = this.selection;
|
|
805
|
+
if (!selection) return;
|
|
697
806
|
|
|
698
807
|
if (html && plainOverride == null && this.__pasteHtml(html, selection)) return;
|
|
699
808
|
this.__pastePlain(plainOverride ?? plain, selection);
|
|
700
809
|
}
|
|
701
810
|
|
|
702
|
-
// Простая вставка текста:
|
|
811
|
+
// Простая вставка текста: в multiline — та же модель абзацев и мягких переносов, что и при
|
|
812
|
+
// вставке форматированного (в режиме block пустая строка разделяет абзацы, в break все переносы
|
|
813
|
+
// мягкие); в single-line — одна строка через пробелы.
|
|
703
814
|
private __pastePlain(text: string, selection: Selection) {
|
|
704
815
|
if (!text) return;
|
|
705
816
|
|
|
706
|
-
const lines = text.split(/\n/);
|
|
707
|
-
const output = lines.map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
|
|
708
|
-
|
|
709
|
-
const fragment = document.createDocumentFragment();
|
|
710
|
-
if (!this.multiline) {
|
|
711
|
-
fragment.appendChild(document.createTextNode(output.join(" ")));
|
|
712
|
-
} else {
|
|
713
|
-
output.forEach((line, index) => {
|
|
714
|
-
if (index > 0) fragment.appendChild(document.createElement("br"));
|
|
715
|
-
fragment.appendChild(document.createTextNode(line));
|
|
716
|
-
});
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
const range = selection.getRangeAt(0);
|
|
720
|
-
this.__history?.record("op");
|
|
721
|
-
range.deleteContents();
|
|
722
|
-
range.insertNode(fragment);
|
|
723
|
-
selection.setPosition(selection.focusNode, selection.focusOffset);
|
|
817
|
+
const lines = text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
|
|
724
818
|
|
|
725
|
-
this.
|
|
819
|
+
this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs), selection);
|
|
726
820
|
}
|
|
727
821
|
|
|
728
|
-
// Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
|
|
729
|
-
//
|
|
730
|
-
// multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line — инлайн с пробелами.
|
|
822
|
+
// Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего —
|
|
823
|
+
// тогда вызывающий откатывается на простую вставку.
|
|
731
824
|
private __pasteHtml(html: string, selection: Selection): boolean {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
// внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
|
|
745
|
-
// иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
|
|
746
|
-
const textWalker = document.createTreeWalker(holder.content, NodeFilter.SHOW_TEXT);
|
|
747
|
-
for (let t = textWalker.nextNode(); t; t = textWalker.nextNode())
|
|
748
|
-
t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
|
|
749
|
-
|
|
750
|
-
const paras = Array.from(holder.content.children) as HTMLElement[];
|
|
751
|
-
for (const p of paras) trimParagraphEdges(p);
|
|
752
|
-
|
|
753
|
-
// отбрасываем пустые краевые абзацы (ведущие/хвостовые \n и <br>-обёртки из буфера),
|
|
754
|
-
// иначе перед и после вставленного текста появляются пустые строки
|
|
755
|
-
while (paras.length && (paras[0].textContent ?? "").trim() === "") paras.shift();
|
|
756
|
-
while (paras.length && (paras[paras.length - 1].textContent ?? "").trim() === "") paras.pop();
|
|
825
|
+
return this.__insertPasted(sanitizePastedHtml(html, this.formatTools, this.formatMarkers), selection);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Вставляет разобранные абзацы в каретку (или вместо выделения) и ставит каретку в конец
|
|
830
|
+
* вставленного. Возвращает false, если вставлять было нечего: в этом случае содержимое
|
|
831
|
+
* не трогается вовсе — иначе выделение оказалось бы удалено без замены и без уведомления.
|
|
832
|
+
*
|
|
833
|
+
* multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line сводит их к пробелам.
|
|
834
|
+
* Каретку адресуем текстовым смещением: узлы вставки при разбиении абзаца переезжают.
|
|
835
|
+
*/
|
|
836
|
+
private __insertPasted(paras: HTMLElement[], selection: Selection): boolean {
|
|
757
837
|
if (!paras.length) return false;
|
|
758
838
|
|
|
759
839
|
const range = selection.getRangeAt(0);
|
|
760
840
|
this.__history?.record("op");
|
|
761
841
|
range.deleteContents();
|
|
762
842
|
|
|
763
|
-
// каретку ставим по абсолютному текстовому смещению (длина вставки), не отслеживая узлы
|
|
764
843
|
const start = selectionCharBounds(this.editable, range)[0];
|
|
765
|
-
let
|
|
844
|
+
let caret: number;
|
|
845
|
+
|
|
846
|
+
if (this.multiline) {
|
|
847
|
+
caret = start + paras.reduce((length, p) => length + (p.textContent ?? "").length, 0);
|
|
766
848
|
|
|
767
|
-
|
|
849
|
+
insertPastedParagraphs(this.editable, paras, range);
|
|
850
|
+
ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
|
|
851
|
+
} else {
|
|
768
852
|
// инлайн: абзацы и переносы → пробелы, форматирование сохраняем
|
|
769
853
|
const fragment = document.createDocumentFragment();
|
|
770
854
|
paras.forEach((p, index) => {
|
|
@@ -772,16 +856,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
772
856
|
while (p.firstChild) fragment.appendChild(p.firstChild);
|
|
773
857
|
});
|
|
774
858
|
fragment.querySelectorAll("br").forEach((br) => br.replaceWith(document.createTextNode(" ")));
|
|
775
|
-
|
|
859
|
+
|
|
860
|
+
caret = start + (fragment.textContent ?? "").length;
|
|
776
861
|
range.insertNode(fragment);
|
|
777
|
-
} else {
|
|
778
|
-
caretOffset = start + paras.map((p) => p.textContent ?? "").join("").length;
|
|
779
|
-
insertPastedParagraphs(this.editable, paras, range);
|
|
780
|
-
ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
|
|
781
862
|
}
|
|
782
863
|
|
|
783
|
-
restoreSelection(this.editable,
|
|
864
|
+
restoreSelection(this.editable, caret, caret, selection);
|
|
784
865
|
this.__emitChange();
|
|
866
|
+
|
|
785
867
|
return true;
|
|
786
868
|
}
|
|
787
869
|
|
|
@@ -804,6 +886,18 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
804
886
|
return;
|
|
805
887
|
}
|
|
806
888
|
|
|
889
|
+
// Фильтр хоста на keydown видит только физические нажатия. IME, автозамена, автодополнение
|
|
890
|
+
// и голосовой ввод приходят сразу сюда, поэтому те же ограничения проверяем и на beforeinput —
|
|
891
|
+
// иначе через них в поле попадает что угодно.
|
|
892
|
+
const filterChar = this.__opts.filterChar;
|
|
893
|
+
if (filterChar && FILTERED_INPUT_TYPES.has(e.inputType) && e.data) {
|
|
894
|
+
if (!Array.from(e.data).every((char) => filterChar(char))) {
|
|
895
|
+
e.preventDefault();
|
|
896
|
+
this.__reject();
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
807
901
|
// режим набора: оборачиваем вводимый текст в ожидающие форматы
|
|
808
902
|
if (this.__pendingFormats.size > 0 && e.inputType === "insertText" && e.data != null) {
|
|
809
903
|
e.preventDefault();
|
|
@@ -823,11 +917,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
823
917
|
}
|
|
824
918
|
|
|
825
919
|
private __insertText(data: string) {
|
|
826
|
-
const selection = this.
|
|
920
|
+
const selection = this.selection;
|
|
827
921
|
if (!selection) return;
|
|
828
922
|
|
|
829
923
|
insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);
|
|
830
924
|
|
|
925
|
+
// В пустом поле абзацев ещё нет, и каретке некуда встать внутрь — вставка из кода
|
|
926
|
+
// (смайлик, переменная) ложится прямо в редактор. Приводим к модели абзацев тем же
|
|
927
|
+
// способом, что и обработчик input при печати: он на программную вставку не приходит,
|
|
928
|
+
// а без <p> следующий Enter правит текст мимо абзаца.
|
|
929
|
+
if (this.multiline)
|
|
930
|
+
preserveCaret(this.editable, () => {
|
|
931
|
+
const before = this.editable.innerHTML;
|
|
932
|
+
ensureParagraphs(this.editable);
|
|
933
|
+
|
|
934
|
+
return this.editable.innerHTML !== before;
|
|
935
|
+
});
|
|
936
|
+
|
|
831
937
|
this.__emitChange();
|
|
832
938
|
formatToolbar.refresh();
|
|
833
939
|
}
|