@brandup/ui-richeditor 1.0.38 → 1.0.39
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 +186 -106
- package/source/selection.ts +201 -75
- package/source/serialize.ts +28 -6
- package/source/toolbar.ts +125 -13
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
|
// (ограничения по типу поля и длине). Обход символов идёт по кодпойнтам, чтобы эмодзи
|
|
@@ -258,11 +294,25 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
258
294
|
* Выделение, если оно находится внутри этого редактора. Браузер сохраняет выделение и после
|
|
259
295
|
* blur, поэтому проверка работает и когда фокус ушёл на кнопку страницы.
|
|
260
296
|
*/
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
297
|
+
get selection(): Selection | null {
|
|
298
|
+
return innerSelection(this.editable);
|
|
299
|
+
}
|
|
264
300
|
|
|
265
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Выделить узел внутри редактора — например, чтобы следующая вставка заменила его целиком.
|
|
303
|
+
* Не зависит от того, где стоит выделение сейчас: оно могло уйти, пока хост показывал
|
|
304
|
+
* своё окно, а сам узел никуда не делся.
|
|
305
|
+
*/
|
|
306
|
+
selectNode(node: Node): void {
|
|
307
|
+
if (!this.editable.contains(node)) return;
|
|
308
|
+
|
|
309
|
+
const selection = documentSelection(this.editable);
|
|
310
|
+
if (!selection) return;
|
|
311
|
+
|
|
312
|
+
const range = this.editable.ownerDocument.createRange();
|
|
313
|
+
range.selectNode(node);
|
|
314
|
+
selection.removeAllRanges();
|
|
315
|
+
selection.addRange(range);
|
|
266
316
|
}
|
|
267
317
|
|
|
268
318
|
/**
|
|
@@ -270,10 +320,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
270
320
|
* для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
|
|
271
321
|
* править, каретка пользователя не двигается. null — правка недоступна или выделение вне редактора.
|
|
272
322
|
*/
|
|
273
|
-
private __formatTarget(): { selection: Selection; range: Range; original: [number, number] } | null {
|
|
323
|
+
private __formatTarget(): { selection: Selection; range: Range; original: () => [number, number] } | null {
|
|
274
324
|
if (!this.format || this.readonly) return null;
|
|
275
325
|
|
|
276
|
-
const selection = this.
|
|
326
|
+
const selection = this.selection;
|
|
277
327
|
if (!selection) return null;
|
|
278
328
|
|
|
279
329
|
const current = selection.getRangeAt(0);
|
|
@@ -282,7 +332,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
282
332
|
selection,
|
|
283
333
|
// форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
|
|
284
334
|
range: expandRangeToWords(this.editable, current),
|
|
285
|
-
|
|
335
|
+
// границы считаем по требованию: они нужны только правкам, а цель вычисляется ещё и
|
|
336
|
+
// на каждое обновление панели, где сбор всего текста в строку — самая дорогая операция
|
|
337
|
+
original: () => selectionCharBounds(this.editable, current),
|
|
286
338
|
};
|
|
287
339
|
}
|
|
288
340
|
|
|
@@ -305,7 +357,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
305
357
|
this.__pendingFormats.clear();
|
|
306
358
|
|
|
307
359
|
this.__history?.record("op");
|
|
308
|
-
toggleFormat(this.editable, target.range, tool, target.selection, target.original);
|
|
360
|
+
toggleFormat(this.editable, target.range, tool, target.selection, target.original());
|
|
309
361
|
|
|
310
362
|
this.__emitChange();
|
|
311
363
|
formatToolbar.refresh();
|
|
@@ -325,7 +377,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
325
377
|
if (!hasFormatting(this.editable, target.range)) return;
|
|
326
378
|
|
|
327
379
|
this.__history?.record("op");
|
|
328
|
-
clearFormat(this.editable, target.range, target.selection, target.original);
|
|
380
|
+
clearFormat(this.editable, target.range, target.selection, target.original());
|
|
329
381
|
|
|
330
382
|
this.__emitChange();
|
|
331
383
|
formatToolbar.refresh();
|
|
@@ -338,14 +390,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
338
390
|
this.__clearPendingFormats();
|
|
339
391
|
if (!hasAnyFormatting(this.editable)) return;
|
|
340
392
|
|
|
341
|
-
// разворачивание тегов рвёт выделение — запоминаем по текстовым смещениям
|
|
342
|
-
const selection = this.__innerSelection();
|
|
343
|
-
const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
344
|
-
|
|
345
393
|
this.__history?.record("op");
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (bounds && selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
394
|
+
// разворачивание тегов рвёт выделение — сохраняем его по текстовым смещениям
|
|
395
|
+
preserveCaret(this.editable, () => clearAllFormat(this.editable));
|
|
349
396
|
|
|
350
397
|
this.__emitChange();
|
|
351
398
|
formatToolbar.refresh();
|
|
@@ -428,13 +475,29 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
428
475
|
isToolActive(tool: FormatTool): boolean {
|
|
429
476
|
if (this.__pendingFormats.has(tool)) return true;
|
|
430
477
|
|
|
431
|
-
const selection = this.
|
|
478
|
+
const selection = this.selection;
|
|
432
479
|
if (!selection) return false;
|
|
433
480
|
|
|
434
481
|
return isFormatActive(this.editable, selection.getRangeAt(0), tool);
|
|
435
482
|
}
|
|
436
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Активные форматы всех инструментов сразу — панель обновляется на каждое движение каретки,
|
|
486
|
+
* а поинструментный опрос обходил бы содержимое столько раз, сколько кнопок.
|
|
487
|
+
*/
|
|
488
|
+
activeTools(): ReadonlySet<FormatTool> {
|
|
489
|
+
const selection = this.selection;
|
|
490
|
+
const active = selection
|
|
491
|
+
? activeFormats(this.editable, selection.getRangeAt(0), this.formatTools)
|
|
492
|
+
: new Set<FormatTool>();
|
|
493
|
+
|
|
494
|
+
for (const tool of this.__pendingFormats) active.add(tool);
|
|
495
|
+
|
|
496
|
+
return active;
|
|
497
|
+
}
|
|
498
|
+
|
|
437
499
|
override destroy(): void {
|
|
500
|
+
this.flushChange(); // хост не должен остаться с устаревшей копией значения
|
|
438
501
|
this.__abort.abort();
|
|
439
502
|
formatToolbar.detach(this);
|
|
440
503
|
|
|
@@ -452,11 +515,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
452
515
|
DOM.empty(this.editable);
|
|
453
516
|
if (!value) return;
|
|
454
517
|
|
|
455
|
-
// multiline → <p>-абзацы; single-line → инлайновое
|
|
456
|
-
|
|
457
|
-
// Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без отступов между абзацами)
|
|
458
|
-
// это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
|
|
459
|
-
const paragraphs = this.multiline && this.paragraph === "block";
|
|
518
|
+
// multiline → <p>-абзацы; single-line → инлайновое содержимое
|
|
519
|
+
const paragraphs = this.__blockParagraphs;
|
|
460
520
|
|
|
461
521
|
this.editable.innerHTML = deserialize(
|
|
462
522
|
value,
|
|
@@ -471,10 +531,41 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
471
531
|
if (this.multiline && !paragraphs) ensureParagraphs(this.editable);
|
|
472
532
|
}
|
|
473
533
|
|
|
474
|
-
|
|
534
|
+
/**
|
|
535
|
+
* Событие изменения. При `defer` (печать) доставка откладывается — иначе каждый символ
|
|
536
|
+
* стоил бы полной сериализации содержимого. Все прочие правки (вставка, формат, отмена,
|
|
537
|
+
* setValue) сообщаются сразу: они разовые, а не посимвольные.
|
|
538
|
+
*
|
|
539
|
+
* `getValue()` считает значение по DOM и точен всегда; отложено только уведомление
|
|
540
|
+
* и, как следствие, копия значения у хоста — её сбрасывает {@link flushChange}.
|
|
541
|
+
*/
|
|
542
|
+
private __emitChange(defer = false) {
|
|
543
|
+
if (defer) {
|
|
544
|
+
// троттлинг: первый ввод заводит таймер, последующие в этом окне его не сдвигают
|
|
545
|
+
this.__changeTimer ||= this.__window.setTimeout(() => this.__emitChange(), CHANGE_THROTTLE_MS);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
this.__cancelChange();
|
|
475
550
|
this.trigger(CHANGE_EVENT, <RichEditorChangeData>{ editor: this, value: this.getValue() });
|
|
476
551
|
}
|
|
477
552
|
|
|
553
|
+
private __cancelChange() {
|
|
554
|
+
if (!this.__changeTimer) return;
|
|
555
|
+
|
|
556
|
+
this.__window.clearTimeout(this.__changeTimer);
|
|
557
|
+
this.__changeTimer = 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Доставить отложенное изменение немедленно. Вызывать перед тем, как значение читают
|
|
562
|
+
* извне: отправка формы, валидация, чтение значения хостом. Если ничего не отложено —
|
|
563
|
+
* ничего и не делает, лишнего события не будет.
|
|
564
|
+
*/
|
|
565
|
+
flushChange(): void {
|
|
566
|
+
if (this.__changeTimer) this.__emitChange();
|
|
567
|
+
}
|
|
568
|
+
|
|
478
569
|
private __reject() {
|
|
479
570
|
this.__opts.onReject?.();
|
|
480
571
|
}
|
|
@@ -486,7 +577,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
486
577
|
if (this.readonly) return;
|
|
487
578
|
|
|
488
579
|
// правка текстовых узлов рвёт живые Range — запоминаем выделение по текстовым смещениям
|
|
489
|
-
const selection = this.
|
|
580
|
+
const selection = this.selection;
|
|
490
581
|
const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
491
582
|
|
|
492
583
|
const before = this.editable.innerHTML;
|
|
@@ -512,8 +603,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
512
603
|
const { signal } = this.__abort;
|
|
513
604
|
const editable = this.editable;
|
|
514
605
|
|
|
606
|
+
// перетаскивание в редактор проходит мимо истории и фильтров хоста — гасим саму вставку.
|
|
607
|
+
// dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
|
|
515
608
|
this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
|
|
516
|
-
this.element.addEventListener("dragenter", (e) => e.preventDefault(), { signal });
|
|
517
609
|
|
|
518
610
|
editable.addEventListener(
|
|
519
611
|
"mousedown",
|
|
@@ -529,13 +621,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
529
621
|
() => {
|
|
530
622
|
this.element.classList.add("focused");
|
|
531
623
|
|
|
532
|
-
//
|
|
533
|
-
|
|
624
|
+
// нужна ли панель этому редактору, решает она сама — иначе условие пришлось бы
|
|
625
|
+
// держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
|
|
626
|
+
formatToolbar.attach(this);
|
|
534
627
|
|
|
535
628
|
if (this.readonly) selectAllContent(this.editable);
|
|
536
629
|
// Каретку в конец ставим только когда её нет: клик ставит сам, а уже стоящую
|
|
537
630
|
// (фокус вернули из кода после вызова метода) двигать нельзя — уедет в конец текста.
|
|
538
|
-
else if (!this.__hasInputClick && !this.
|
|
631
|
+
else if (!this.__hasInputClick && !this.selection) caretToEnd(this.editable, this.multiline);
|
|
539
632
|
},
|
|
540
633
|
{ signal }
|
|
541
634
|
);
|
|
@@ -553,6 +646,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
553
646
|
|
|
554
647
|
this.__clearPendingFormats();
|
|
555
648
|
this.__normalize(true); // редактирование завершено
|
|
649
|
+
this.flushChange(); // ввод закончен — отложенное изменение доставляем сразу
|
|
556
650
|
},
|
|
557
651
|
{ signal }
|
|
558
652
|
);
|
|
@@ -568,19 +662,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
568
662
|
"input",
|
|
569
663
|
() => {
|
|
570
664
|
if (this.multiline) {
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
: null;
|
|
578
|
-
const before = editable.innerHTML;
|
|
665
|
+
// приведение к абзацам меняет структуру (обёртка в <p>, удаление <br>) и сбрасывает
|
|
666
|
+
// каретку — сохраняем её; если структура не менялась, выделение живо и переставлять
|
|
667
|
+
// его не нужно (лишний сброс способен прервать IME-набор)
|
|
668
|
+
preserveCaret(editable, () => {
|
|
669
|
+
const before = editable.innerHTML;
|
|
670
|
+
ensureParagraphs(editable); // блуждающий текст/div → <p>
|
|
579
671
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (caret && selection && editable.innerHTML !== before)
|
|
583
|
-
restoreSelection(editable, caret[0], caret[1], selection);
|
|
672
|
+
return editable.innerHTML !== before;
|
|
673
|
+
});
|
|
584
674
|
|
|
585
675
|
// единственный пустой абзац → очищаем, чтобы показать placeholder
|
|
586
676
|
if (editable.children.length === 1) {
|
|
@@ -590,7 +680,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
590
680
|
} else if (editable.firstChild?.nodeName === "BR") {
|
|
591
681
|
editable.innerHTML = "";
|
|
592
682
|
}
|
|
593
|
-
this.__emitChange();
|
|
683
|
+
this.__emitChange(true); // печать — единственный посимвольный источник, его и откладываем
|
|
594
684
|
},
|
|
595
685
|
{ signal }
|
|
596
686
|
);
|
|
@@ -652,6 +742,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
652
742
|
return;
|
|
653
743
|
}
|
|
654
744
|
|
|
745
|
+
// Абзацы и переносы правятся вручную, мимо beforeinput, поэтому запрет на изменение
|
|
746
|
+
// текста проверяем здесь: иначе Enter добавлял бы строки и в режиме только для чтения.
|
|
747
|
+
if (this.readonly) return;
|
|
748
|
+
|
|
655
749
|
// В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
|
|
656
750
|
// В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
|
|
657
751
|
// набирается двумя переносами.
|
|
@@ -692,79 +786,55 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
692
786
|
if (filtered !== plain) plainOverride = filtered;
|
|
693
787
|
}
|
|
694
788
|
|
|
695
|
-
|
|
696
|
-
|
|
789
|
+
// вставлять только в своё содержимое: выделение вне редактора нам не адресовано
|
|
790
|
+
const selection = this.selection;
|
|
791
|
+
if (!selection) return;
|
|
697
792
|
|
|
698
793
|
if (html && plainOverride == null && this.__pasteHtml(html, selection)) return;
|
|
699
794
|
this.__pastePlain(plainOverride ?? plain, selection);
|
|
700
795
|
}
|
|
701
796
|
|
|
702
|
-
// Простая вставка текста:
|
|
797
|
+
// Простая вставка текста: в multiline — та же модель абзацев и мягких переносов, что и при
|
|
798
|
+
// вставке форматированного (в режиме block пустая строка разделяет абзацы, в break все переносы
|
|
799
|
+
// мягкие); в single-line — одна строка через пробелы.
|
|
703
800
|
private __pastePlain(text: string, selection: Selection) {
|
|
704
801
|
if (!text) return;
|
|
705
802
|
|
|
706
|
-
const lines = text.split(/\n/);
|
|
707
|
-
const output = lines.map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
|
|
803
|
+
const lines = text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
|
|
708
804
|
|
|
709
|
-
|
|
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);
|
|
724
|
-
|
|
725
|
-
this.__emitChange();
|
|
805
|
+
this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs), selection);
|
|
726
806
|
}
|
|
727
807
|
|
|
728
|
-
// Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
|
|
729
|
-
//
|
|
730
|
-
// multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line — инлайн с пробелами.
|
|
808
|
+
// Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего —
|
|
809
|
+
// тогда вызывающий откатывается на простую вставку.
|
|
731
810
|
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();
|
|
811
|
+
return this.__insertPasted(sanitizePastedHtml(html, this.formatTools, this.formatMarkers), selection);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Вставляет разобранные абзацы в каретку (или вместо выделения) и ставит каретку в конец
|
|
816
|
+
* вставленного. Возвращает false, если вставлять было нечего: в этом случае содержимое
|
|
817
|
+
* не трогается вовсе — иначе выделение оказалось бы удалено без замены и без уведомления.
|
|
818
|
+
*
|
|
819
|
+
* multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line сводит их к пробелам.
|
|
820
|
+
* Каретку адресуем текстовым смещением: узлы вставки при разбиении абзаца переезжают.
|
|
821
|
+
*/
|
|
822
|
+
private __insertPasted(paras: HTMLElement[], selection: Selection): boolean {
|
|
757
823
|
if (!paras.length) return false;
|
|
758
824
|
|
|
759
825
|
const range = selection.getRangeAt(0);
|
|
760
826
|
this.__history?.record("op");
|
|
761
827
|
range.deleteContents();
|
|
762
828
|
|
|
763
|
-
// каретку ставим по абсолютному текстовому смещению (длина вставки), не отслеживая узлы
|
|
764
829
|
const start = selectionCharBounds(this.editable, range)[0];
|
|
765
|
-
let
|
|
830
|
+
let caret: number;
|
|
766
831
|
|
|
767
|
-
if (
|
|
832
|
+
if (this.multiline) {
|
|
833
|
+
caret = start + paras.reduce((length, p) => length + (p.textContent ?? "").length, 0);
|
|
834
|
+
|
|
835
|
+
insertPastedParagraphs(this.editable, paras, range);
|
|
836
|
+
ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
|
|
837
|
+
} else {
|
|
768
838
|
// инлайн: абзацы и переносы → пробелы, форматирование сохраняем
|
|
769
839
|
const fragment = document.createDocumentFragment();
|
|
770
840
|
paras.forEach((p, index) => {
|
|
@@ -772,16 +842,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
772
842
|
while (p.firstChild) fragment.appendChild(p.firstChild);
|
|
773
843
|
});
|
|
774
844
|
fragment.querySelectorAll("br").forEach((br) => br.replaceWith(document.createTextNode(" ")));
|
|
775
|
-
|
|
845
|
+
|
|
846
|
+
caret = start + (fragment.textContent ?? "").length;
|
|
776
847
|
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
848
|
}
|
|
782
849
|
|
|
783
|
-
restoreSelection(this.editable,
|
|
850
|
+
restoreSelection(this.editable, caret, caret, selection);
|
|
784
851
|
this.__emitChange();
|
|
852
|
+
|
|
785
853
|
return true;
|
|
786
854
|
}
|
|
787
855
|
|
|
@@ -804,6 +872,18 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
804
872
|
return;
|
|
805
873
|
}
|
|
806
874
|
|
|
875
|
+
// Фильтр хоста на keydown видит только физические нажатия. IME, автозамена, автодополнение
|
|
876
|
+
// и голосовой ввод приходят сразу сюда, поэтому те же ограничения проверяем и на beforeinput —
|
|
877
|
+
// иначе через них в поле попадает что угодно.
|
|
878
|
+
const filterChar = this.__opts.filterChar;
|
|
879
|
+
if (filterChar && FILTERED_INPUT_TYPES.has(e.inputType) && e.data) {
|
|
880
|
+
if (!Array.from(e.data).every((char) => filterChar(char))) {
|
|
881
|
+
e.preventDefault();
|
|
882
|
+
this.__reject();
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
807
887
|
// режим набора: оборачиваем вводимый текст в ожидающие форматы
|
|
808
888
|
if (this.__pendingFormats.size > 0 && e.inputType === "insertText" && e.data != null) {
|
|
809
889
|
e.preventDefault();
|
|
@@ -823,7 +903,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
823
903
|
}
|
|
824
904
|
|
|
825
905
|
private __insertText(data: string) {
|
|
826
|
-
const selection = this.
|
|
906
|
+
const selection = this.selection;
|
|
827
907
|
if (!selection) return;
|
|
828
908
|
|
|
829
909
|
insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);
|