@brandup/ui-richeditor 1.0.36 → 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 +84 -12
- package/package.json +3 -2
- package/source/editing.ts +91 -28
- package/source/emoji.ts +53 -0
- package/source/format-config.ts +45 -18
- package/source/format.ts +22 -2
- package/source/history.ts +28 -9
- package/source/index.ts +9 -0
- package/source/paragraphs.ts +33 -6
- package/source/richeditor.less +56 -1
- package/source/richeditor.ts +396 -109
- package/source/selection.ts +297 -81
- package/source/serialize.ts +296 -201
- package/source/toolbar.ts +251 -17
- package/svg/emoji.svg +3 -0
- package/svg/erase.svg +1 -0
- package/svg/redo.svg +1 -0
- package/svg/undo.svg +1 -0
package/source/richeditor.ts
CHANGED
|
@@ -4,35 +4,47 @@ import { DOM, UIElementBound } from "@brandup/ui";
|
|
|
4
4
|
import {
|
|
5
5
|
ALL_FORMAT_TOOLS,
|
|
6
6
|
HOTKEY_TOOLS,
|
|
7
|
+
activeFormats,
|
|
8
|
+
clearAllFormat,
|
|
9
|
+
clearFormat,
|
|
7
10
|
defaultFormatMarkers,
|
|
8
11
|
deserialize,
|
|
9
12
|
ensureParagraphs,
|
|
13
|
+
hasAnyFormatting,
|
|
14
|
+
hasFormatting,
|
|
10
15
|
insertFormattedText,
|
|
11
16
|
isFormatActive,
|
|
17
|
+
documentSelection,
|
|
18
|
+
innerSelection,
|
|
19
|
+
mapCharOffset,
|
|
20
|
+
preserveCaret,
|
|
12
21
|
normalizeParagraphs,
|
|
13
22
|
normalizeWhitespace,
|
|
14
23
|
restoreSelection,
|
|
15
24
|
selectionCharBounds,
|
|
16
25
|
serialize,
|
|
17
26
|
toggleFormat,
|
|
27
|
+
type EditorAction,
|
|
18
28
|
type FormatMarkers,
|
|
19
29
|
type FormatStorage,
|
|
30
|
+
type ParagraphMode,
|
|
20
31
|
type FormatTool,
|
|
21
32
|
} from "./format";
|
|
22
33
|
import {
|
|
34
|
+
buildParagraphs,
|
|
23
35
|
caretToEnd,
|
|
24
|
-
|
|
36
|
+
expandRangeToWords,
|
|
25
37
|
insertParagraph,
|
|
26
38
|
insertPastedParagraphs,
|
|
27
39
|
insertSoftBreak,
|
|
40
|
+
sanitizePastedHtml,
|
|
28
41
|
selectAllContent,
|
|
29
|
-
trimParagraphEdges,
|
|
30
42
|
trimSelectionWhitespace,
|
|
31
43
|
} from "./editing";
|
|
32
44
|
import { EditorHistory } from "./history";
|
|
33
|
-
import { formatToolbar } from "./toolbar";
|
|
45
|
+
import { formatToolbar, type ToolbarButton } from "./toolbar";
|
|
34
46
|
|
|
35
|
-
export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost } from "./toolbar";
|
|
47
|
+
export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost, type ToolbarButton } from "./toolbar";
|
|
36
48
|
|
|
37
49
|
export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
|
|
38
50
|
export const CHANGE_EVENT = "richeditor-change";
|
|
@@ -52,6 +64,16 @@ const NATIVE_EDIT_TYPES = new Set([
|
|
|
52
64
|
"deleteByCut",
|
|
53
65
|
]);
|
|
54
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
|
+
|
|
55
77
|
// Буква физической клавиши (KeyA…KeyZ) — не зависит от раскладки. Для не-латинских раскладок
|
|
56
78
|
// (например, кириллицы) e.key даёт другую букву, поэтому хоткеи сверяем и по e.code.
|
|
57
79
|
function codeLetter(e: KeyboardEvent): string {
|
|
@@ -68,6 +90,8 @@ export interface RichEditorOptions {
|
|
|
68
90
|
format?: boolean;
|
|
69
91
|
/** Состав инструментов (по умолчанию все). */
|
|
70
92
|
tools?: FormatTool[];
|
|
93
|
+
/** Кнопки действий в панели: очистка форматирования, отмена, повтор (по умолчанию нет). */
|
|
94
|
+
actions?: EditorAction[];
|
|
71
95
|
/** Формат хранения значения при форматировании. */
|
|
72
96
|
storage?: FormatStorage;
|
|
73
97
|
/** Переопределения markdown-маркеров. */
|
|
@@ -76,10 +100,14 @@ export interface RichEditorOptions {
|
|
|
76
100
|
placeholder?: string | null;
|
|
77
101
|
/** Многострочный режим. */
|
|
78
102
|
multiline?: boolean;
|
|
103
|
+
/** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
|
|
104
|
+
paragraph?: ParagraphMode;
|
|
79
105
|
/** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
|
|
80
106
|
readonly?: boolean;
|
|
81
107
|
/** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
|
|
82
108
|
toolbarContainer?: HTMLElement | null;
|
|
109
|
+
/** Собственные кнопки хоста в панели — для действий, которых редактор не знает. */
|
|
110
|
+
buttons?: ToolbarButton[];
|
|
83
111
|
/** Начальное значение. */
|
|
84
112
|
value?: string;
|
|
85
113
|
|
|
@@ -107,15 +135,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
107
135
|
readonly editable: HTMLElement;
|
|
108
136
|
readonly format: boolean;
|
|
109
137
|
readonly formatTools: FormatTool[];
|
|
138
|
+
readonly editorActions: EditorAction[];
|
|
110
139
|
readonly formatStorage: FormatStorage;
|
|
111
140
|
readonly formatMarkers: FormatMarkers;
|
|
112
141
|
readonly multiline: boolean;
|
|
142
|
+
readonly paragraph: ParagraphMode;
|
|
113
143
|
readonly toolbarContainer: HTMLElement | null;
|
|
144
|
+
readonly toolbarButtons: ToolbarButton[];
|
|
114
145
|
|
|
115
146
|
private __opts: RichEditorOptions;
|
|
116
147
|
private __abort = new AbortController();
|
|
117
148
|
private __pendingFormats = new Set<FormatTool>();
|
|
118
149
|
private __hasInputClick = false;
|
|
150
|
+
private __changeTimer = 0; // отложенное change по печати — см. __emitChange/flushChange
|
|
151
|
+
// Окно редактируемого элемента, взятое при создании. Таймер отложенного change переживает
|
|
152
|
+
// снятие компонента и гасится в destroy, а тот случается когда угодно — к этому моменту
|
|
153
|
+
// до глобального окружения может быть уже не добраться, да и элемент мог жить в iframe.
|
|
154
|
+
private readonly __window: Window;
|
|
119
155
|
// собственная история undo/redo — только при форматировании (см. ./history)
|
|
120
156
|
private __history: EditorHistory | null = null;
|
|
121
157
|
|
|
@@ -125,6 +161,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
125
161
|
const readonly = !!options.readonly;
|
|
126
162
|
// форматирование недоступно в режиме только для чтения
|
|
127
163
|
const tools = format && !readonly ? (options.tools ?? ALL_FORMAT_TOOLS.slice()) : [];
|
|
164
|
+
// действия подключаются явно — иначе панель у существующих хостов молча обзавелась бы кнопками
|
|
165
|
+
const actions = format && !readonly ? (options.actions ?? []) : [];
|
|
128
166
|
|
|
129
167
|
// тулбар общий и живёт в body (см. ./toolbar), поэтому обёртка не нужна —
|
|
130
168
|
// привязываем UIElement прямо к переданному элементу, он же и редактируемый
|
|
@@ -133,13 +171,18 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
133
171
|
super("BrandUp.RichEditor", editable);
|
|
134
172
|
|
|
135
173
|
this.editable = editable;
|
|
174
|
+
this.__window = editable.ownerDocument.defaultView ?? window;
|
|
136
175
|
this.__opts = options;
|
|
137
176
|
this.format = format;
|
|
138
177
|
this.formatTools = tools;
|
|
178
|
+
this.editorActions = actions;
|
|
139
179
|
this.formatStorage = options.storage === "markdown" ? "markdown" : "html";
|
|
140
180
|
this.formatMarkers = Object.assign(defaultFormatMarkers(), options.markers);
|
|
141
181
|
this.multiline = multiline;
|
|
182
|
+
this.paragraph = options.paragraph ?? "block";
|
|
142
183
|
this.toolbarContainer = options.toolbarContainer ?? null;
|
|
184
|
+
// кнопки хоста живут и без форматирования, но не в readonly — там панели нет вовсе
|
|
185
|
+
this.toolbarButtons = readonly ? [] : (options.buttons ?? []);
|
|
143
186
|
// история включается вместе с форматированием
|
|
144
187
|
this.__history = format ? new EditorHistory(editable) : null;
|
|
145
188
|
|
|
@@ -161,6 +204,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
161
204
|
return !!this.__opts.readonly;
|
|
162
205
|
}
|
|
163
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
|
+
|
|
164
217
|
// формат хранения значения: format → выбранный; plain → markdown без инструментов (\n\n/\n)
|
|
165
218
|
private get __valueStorage(): FormatStorage {
|
|
166
219
|
return this.format ? this.formatStorage : "markdown";
|
|
@@ -177,11 +230,53 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
177
230
|
}
|
|
178
231
|
|
|
179
232
|
setValue(value: string): void {
|
|
233
|
+
// каретка внутри относилась к прежнему содержимому — после замены ставим её в конец
|
|
234
|
+
const hadCaret = !!this.selection;
|
|
235
|
+
|
|
180
236
|
this.__render(value ?? "");
|
|
181
237
|
this.__normalize(false);
|
|
238
|
+
if (hadCaret) caretToEnd(this.editable, this.multiline);
|
|
239
|
+
|
|
182
240
|
this.__emitChange();
|
|
183
241
|
}
|
|
184
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Вставляет текст в каретку (или вместо выделения) с учётом ожидающих форматов режима набора.
|
|
245
|
+
* Каретка сохраняется и после потери фокуса, поэтому метод работает и при вызове из кода —
|
|
246
|
+
* например, кнопкой панели, которая не должна забирать фокус у редактора.
|
|
247
|
+
*/
|
|
248
|
+
insertText(text: string): void {
|
|
249
|
+
if (this.readonly || !text || !this.selection) return;
|
|
250
|
+
|
|
251
|
+
// вставка — такой же ввод, как с клавиатуры, поэтому проходит через filterChar хоста
|
|
252
|
+
// (ограничения по типу поля и длине). Обход символов идёт по кодпойнтам, чтобы эмодзи
|
|
253
|
+
// проверялся целиком; длина сверяется с текущим содержимым, так что многосимвольная
|
|
254
|
+
// вставка ограничивается по первому символу — для одного символа проверка точная.
|
|
255
|
+
const filterChar = this.__opts.filterChar;
|
|
256
|
+
if (filterChar && !Array.from(text).every((char) => filterChar(char))) {
|
|
257
|
+
this.__reject();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.__history?.record("op");
|
|
262
|
+
this.__insertText(text);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Показать панель вставки смайлика у своей кнопки — когда она живёт не в тулбаре, а в разметке
|
|
267
|
+
* хоста (например справа от поля ввода). Вызывать из обработчика `click`, погасив всплытие:
|
|
268
|
+
* иначе попап закроется тем же кликом, которым открылся.
|
|
269
|
+
*
|
|
270
|
+
* @param initiator Кнопка, у которой показывается панель.
|
|
271
|
+
* @param container Куда монтировать панель; по умолчанию — родитель кнопки.
|
|
272
|
+
*/
|
|
273
|
+
openEmojiPicker(initiator: HTMLElement, container?: HTMLElement): void {
|
|
274
|
+
if (this.readonly) return;
|
|
275
|
+
|
|
276
|
+
const target = container ?? initiator.parentElement;
|
|
277
|
+
if (target) formatToolbar.openEmoji(this, initiator, target);
|
|
278
|
+
}
|
|
279
|
+
|
|
185
280
|
getLength(): number {
|
|
186
281
|
// textContent (а не innerText) корректно считает multiline и работает в jsdom
|
|
187
282
|
return this.editable.textContent?.length ?? 0;
|
|
@@ -195,22 +290,62 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
195
290
|
this.on(CHANGE_EVENT, handler);
|
|
196
291
|
}
|
|
197
292
|
|
|
198
|
-
/**
|
|
199
|
-
|
|
200
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Выделение, если оно находится внутри этого редактора. Браузер сохраняет выделение и после
|
|
295
|
+
* blur, поэтому проверка работает и когда фокус ушёл на кнопку страницы.
|
|
296
|
+
*/
|
|
297
|
+
get selection(): Selection | null {
|
|
298
|
+
return innerSelection(this.editable);
|
|
299
|
+
}
|
|
201
300
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Цель правки форматирования: выделение, расширенное до целых слов, плюс исходные границы
|
|
320
|
+
* для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
|
|
321
|
+
* править, каретка пользователя не двигается. null — правка недоступна или выделение вне редактора.
|
|
322
|
+
*/
|
|
323
|
+
private __formatTarget(): { selection: Selection; range: Range; original: () => [number, number] } | null {
|
|
324
|
+
if (!this.format || this.readonly) return null;
|
|
325
|
+
|
|
326
|
+
const selection = this.selection;
|
|
327
|
+
if (!selection) return null;
|
|
328
|
+
|
|
329
|
+
const current = selection.getRangeAt(0);
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
selection,
|
|
333
|
+
// форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
|
|
334
|
+
range: expandRangeToWords(this.editable, current),
|
|
335
|
+
// границы считаем по требованию: они нужны только правкам, а цель вычисляется ещё и
|
|
336
|
+
// на каждое обновление панели, где сбор всего текста в строку — самая дорогая операция
|
|
337
|
+
original: () => selectionCharBounds(this.editable, current),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
205
340
|
|
|
206
|
-
|
|
207
|
-
|
|
341
|
+
/** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
|
|
342
|
+
applyFormat(tool: FormatTool): void {
|
|
343
|
+
if (!this.formatTools.includes(tool)) return;
|
|
208
344
|
|
|
209
|
-
|
|
210
|
-
|
|
345
|
+
const target = this.__formatTarget();
|
|
346
|
+
if (!target) return;
|
|
211
347
|
|
|
212
|
-
|
|
213
|
-
if (range.collapsed) {
|
|
348
|
+
if (target.range.collapsed) {
|
|
214
349
|
// под кареткой нет слова — режим набора: формат для следующего ввода
|
|
215
350
|
if (this.__pendingFormats.has(tool)) this.__pendingFormats.delete(tool);
|
|
216
351
|
else this.__pendingFormats.add(tool);
|
|
@@ -222,24 +357,114 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
222
357
|
this.__pendingFormats.clear();
|
|
223
358
|
|
|
224
359
|
this.__history?.record("op");
|
|
225
|
-
toggleFormat(this.editable, range, tool, selection, original);
|
|
360
|
+
toggleFormat(this.editable, target.range, tool, target.selection, target.original());
|
|
226
361
|
|
|
227
362
|
this.__emitChange();
|
|
228
363
|
formatToolbar.refresh();
|
|
229
364
|
}
|
|
230
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Снимает всё форматирование с выделения; без выделения — со слова под кареткой
|
|
368
|
+
* (как и применение формата). Режим набора сбрасывается.
|
|
369
|
+
*/
|
|
370
|
+
clearFormat(): void {
|
|
371
|
+
const target = this.__formatTarget();
|
|
372
|
+
if (!target) return;
|
|
373
|
+
|
|
374
|
+
this.__clearPendingFormats();
|
|
375
|
+
|
|
376
|
+
// нечего очищать — не пишем в историю пустой шаг и не трогаем выделение
|
|
377
|
+
if (!hasFormatting(this.editable, target.range)) return;
|
|
378
|
+
|
|
379
|
+
this.__history?.record("op");
|
|
380
|
+
clearFormat(this.editable, target.range, target.selection, target.original());
|
|
381
|
+
|
|
382
|
+
this.__emitChange();
|
|
383
|
+
formatToolbar.refresh();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Снимает всё форматирование со всего содержимого (выделение не требуется). */
|
|
387
|
+
clearAllFormat(): void {
|
|
388
|
+
if (!this.format || this.readonly) return;
|
|
389
|
+
|
|
390
|
+
this.__clearPendingFormats();
|
|
391
|
+
if (!hasAnyFormatting(this.editable)) return;
|
|
392
|
+
|
|
393
|
+
this.__history?.record("op");
|
|
394
|
+
// разворачивание тегов рвёт выделение — сохраняем его по текстовым смещениям
|
|
395
|
+
preserveCaret(this.editable, () => clearAllFormat(this.editable));
|
|
396
|
+
|
|
397
|
+
this.__emitChange();
|
|
398
|
+
formatToolbar.refresh();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Доступна ли отмена (история ведётся только при включённом форматировании). */
|
|
402
|
+
get canUndo(): boolean {
|
|
403
|
+
return !this.readonly && !!this.__history?.canUndo;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Доступен ли повтор отменённого. */
|
|
407
|
+
get canRedo(): boolean {
|
|
408
|
+
return !this.readonly && !!this.__history?.canRedo;
|
|
409
|
+
}
|
|
410
|
+
|
|
231
411
|
/** Отменить последнее действие (история ведётся только при форматировании). */
|
|
232
|
-
|
|
233
|
-
if (!this.__history?.undo()) return;
|
|
412
|
+
undo(): void {
|
|
413
|
+
if (this.readonly || !this.__history?.undo()) return;
|
|
234
414
|
this.__afterHistory();
|
|
235
415
|
}
|
|
236
416
|
|
|
237
417
|
/** Повторить отменённое действие. */
|
|
238
|
-
|
|
239
|
-
if (!this.__history?.redo()) return;
|
|
418
|
+
redo(): void {
|
|
419
|
+
if (this.readonly || !this.__history?.redo()) return;
|
|
240
420
|
this.__afterHistory();
|
|
241
421
|
}
|
|
242
422
|
|
|
423
|
+
/** Выполнить действие панели (вызывается кнопками тулбара). */
|
|
424
|
+
applyAction(action: EditorAction): void {
|
|
425
|
+
if (!this.editorActions.includes(action)) return;
|
|
426
|
+
|
|
427
|
+
switch (action) {
|
|
428
|
+
case "erase":
|
|
429
|
+
this.clearFormat();
|
|
430
|
+
break;
|
|
431
|
+
case "undo":
|
|
432
|
+
this.undo();
|
|
433
|
+
break;
|
|
434
|
+
case "redo":
|
|
435
|
+
this.redo();
|
|
436
|
+
break;
|
|
437
|
+
case "emoji":
|
|
438
|
+
// панель вставки открывает тулбар: ей нужна кнопка как якорь попапа.
|
|
439
|
+
// Вставка символа приходит сюда через insertText.
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Доступно ли действие сейчас (для disabled-состояния кнопки тулбара). */
|
|
445
|
+
isActionEnabled(action: EditorAction): boolean {
|
|
446
|
+
switch (action) {
|
|
447
|
+
case "undo":
|
|
448
|
+
return this.canUndo;
|
|
449
|
+
case "redo":
|
|
450
|
+
return this.canRedo;
|
|
451
|
+
case "erase":
|
|
452
|
+
return this.__hasFormatting();
|
|
453
|
+
case "emoji":
|
|
454
|
+
return !this.readonly;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Есть ли что очищать: ожидающий формат режима набора либо форматирование на цели очистки.
|
|
459
|
+
// Смотрим на тот же расширенный до слов диапазон, что возьмёт clearFormat, — иначе кнопка
|
|
460
|
+
// блокировалась бы в случаях, когда операция сработала бы (каретка на границе формата).
|
|
461
|
+
private __hasFormatting(): boolean {
|
|
462
|
+
if (this.__pendingFormats.size > 0) return true;
|
|
463
|
+
|
|
464
|
+
const target = this.__formatTarget();
|
|
465
|
+
return !!target && hasFormatting(this.editable, target.range);
|
|
466
|
+
}
|
|
467
|
+
|
|
243
468
|
private __afterHistory(): void {
|
|
244
469
|
this.__pendingFormats.clear();
|
|
245
470
|
this.__emitChange();
|
|
@@ -250,13 +475,29 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
250
475
|
isToolActive(tool: FormatTool): boolean {
|
|
251
476
|
if (this.__pendingFormats.has(tool)) return true;
|
|
252
477
|
|
|
253
|
-
const selection =
|
|
254
|
-
if (!selection
|
|
478
|
+
const selection = this.selection;
|
|
479
|
+
if (!selection) return false;
|
|
255
480
|
|
|
256
481
|
return isFormatActive(this.editable, selection.getRangeAt(0), tool);
|
|
257
482
|
}
|
|
258
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
|
+
|
|
259
499
|
override destroy(): void {
|
|
500
|
+
this.flushChange(); // хост не должен остаться с устаревшей копией значения
|
|
260
501
|
this.__abort.abort();
|
|
261
502
|
formatToolbar.detach(this);
|
|
262
503
|
|
|
@@ -275,19 +516,56 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
275
516
|
if (!value) return;
|
|
276
517
|
|
|
277
518
|
// multiline → <p>-абзацы; single-line → инлайновое содержимое
|
|
519
|
+
const paragraphs = this.__blockParagraphs;
|
|
520
|
+
|
|
278
521
|
this.editable.innerHTML = deserialize(
|
|
279
522
|
value,
|
|
280
523
|
this.__valueStorage,
|
|
281
524
|
this.__valueTools,
|
|
282
525
|
this.formatMarkers,
|
|
283
|
-
|
|
526
|
+
paragraphs
|
|
284
527
|
);
|
|
528
|
+
|
|
529
|
+
// в break инлайновое содержимое оборачивается в единственный абзац — модель абзацев
|
|
530
|
+
// нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
|
|
531
|
+
if (this.multiline && !paragraphs) ensureParagraphs(this.editable);
|
|
285
532
|
}
|
|
286
533
|
|
|
287
|
-
|
|
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();
|
|
288
550
|
this.trigger(CHANGE_EVENT, <RichEditorChangeData>{ editor: this, value: this.getValue() });
|
|
289
551
|
}
|
|
290
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
|
+
|
|
291
569
|
private __reject() {
|
|
292
570
|
this.__opts.onReject?.();
|
|
293
571
|
}
|
|
@@ -298,11 +576,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
298
576
|
private __normalize(notify: boolean) {
|
|
299
577
|
if (this.readonly) return;
|
|
300
578
|
|
|
579
|
+
// правка текстовых узлов рвёт живые Range — запоминаем выделение по текстовым смещениям
|
|
580
|
+
const selection = this.selection;
|
|
581
|
+
const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
582
|
+
|
|
301
583
|
const before = this.editable.innerHTML;
|
|
584
|
+
const textBefore = this.editable.textContent ?? "";
|
|
302
585
|
normalizeWhitespace(this.editable);
|
|
303
586
|
if (this.multiline) normalizeParagraphs(this.editable);
|
|
304
587
|
if (this.editable.innerHTML === before) return;
|
|
305
588
|
|
|
589
|
+
if (bounds && selection) {
|
|
590
|
+
// схлопнутые пробелы сдвигают смещения — переносим их на новый текст
|
|
591
|
+
const textAfter = this.editable.textContent ?? "";
|
|
592
|
+
const start = mapCharOffset(textBefore, textAfter, bounds[0]);
|
|
593
|
+
const end = bounds[1] === bounds[0] ? start : mapCharOffset(textBefore, textAfter, bounds[1]);
|
|
594
|
+
restoreSelection(this.editable, start, end, selection);
|
|
595
|
+
}
|
|
596
|
+
|
|
306
597
|
if (notify) this.__emitChange();
|
|
307
598
|
}
|
|
308
599
|
|
|
@@ -312,8 +603,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
312
603
|
const { signal } = this.__abort;
|
|
313
604
|
const editable = this.editable;
|
|
314
605
|
|
|
606
|
+
// перетаскивание в редактор проходит мимо истории и фильтров хоста — гасим саму вставку.
|
|
607
|
+
// dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
|
|
315
608
|
this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
|
|
316
|
-
this.element.addEventListener("dragenter", (e) => e.preventDefault(), { signal });
|
|
317
609
|
|
|
318
610
|
editable.addEventListener(
|
|
319
611
|
"mousedown",
|
|
@@ -329,11 +621,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
329
621
|
() => {
|
|
330
622
|
this.element.classList.add("focused");
|
|
331
623
|
|
|
332
|
-
//
|
|
333
|
-
|
|
624
|
+
// нужна ли панель этому редактору, решает она сама — иначе условие пришлось бы
|
|
625
|
+
// держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
|
|
626
|
+
formatToolbar.attach(this);
|
|
334
627
|
|
|
335
628
|
if (this.readonly) selectAllContent(this.editable);
|
|
336
|
-
|
|
629
|
+
// Каретку в конец ставим только когда её нет: клик ставит сам, а уже стоящую
|
|
630
|
+
// (фокус вернули из кода после вызова метода) двигать нельзя — уедет в конец текста.
|
|
631
|
+
else if (!this.__hasInputClick && !this.selection) caretToEnd(this.editable, this.multiline);
|
|
337
632
|
},
|
|
338
633
|
{ signal }
|
|
339
634
|
);
|
|
@@ -351,6 +646,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
351
646
|
|
|
352
647
|
this.__clearPendingFormats();
|
|
353
648
|
this.__normalize(true); // редактирование завершено
|
|
649
|
+
this.flushChange(); // ввод закончен — отложенное изменение доставляем сразу
|
|
354
650
|
},
|
|
355
651
|
{ signal }
|
|
356
652
|
);
|
|
@@ -366,19 +662,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
366
662
|
"input",
|
|
367
663
|
() => {
|
|
368
664
|
if (this.multiline) {
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
: null;
|
|
376
|
-
const before = editable.innerHTML;
|
|
377
|
-
|
|
378
|
-
ensureParagraphs(editable); // блуждающий текст/div → <p>
|
|
665
|
+
// приведение к абзацам меняет структуру (обёртка в <p>, удаление <br>) и сбрасывает
|
|
666
|
+
// каретку — сохраняем её; если структура не менялась, выделение живо и переставлять
|
|
667
|
+
// его не нужно (лишний сброс способен прервать IME-набор)
|
|
668
|
+
preserveCaret(editable, () => {
|
|
669
|
+
const before = editable.innerHTML;
|
|
670
|
+
ensureParagraphs(editable); // блуждающий текст/div → <p>
|
|
379
671
|
|
|
380
|
-
|
|
381
|
-
|
|
672
|
+
return editable.innerHTML !== before;
|
|
673
|
+
});
|
|
382
674
|
|
|
383
675
|
// единственный пустой абзац → очищаем, чтобы показать placeholder
|
|
384
676
|
if (editable.children.length === 1) {
|
|
@@ -388,7 +680,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
388
680
|
} else if (editable.firstChild?.nodeName === "BR") {
|
|
389
681
|
editable.innerHTML = "";
|
|
390
682
|
}
|
|
391
|
-
this.__emitChange();
|
|
683
|
+
this.__emitChange(true); // печать — единственный посимвольный источник, его и откладываем
|
|
392
684
|
},
|
|
393
685
|
{ signal }
|
|
394
686
|
);
|
|
@@ -414,12 +706,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
414
706
|
const z = isHotkeyLetter(e, "z");
|
|
415
707
|
if (z && !e.shiftKey) {
|
|
416
708
|
e.preventDefault();
|
|
417
|
-
this.
|
|
709
|
+
this.undo();
|
|
418
710
|
return;
|
|
419
711
|
}
|
|
420
712
|
if (isHotkeyLetter(e, "y") || (z && e.shiftKey)) {
|
|
421
713
|
e.preventDefault();
|
|
422
|
-
this.
|
|
714
|
+
this.redo();
|
|
423
715
|
return;
|
|
424
716
|
}
|
|
425
717
|
}
|
|
@@ -450,9 +742,18 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
450
742
|
return;
|
|
451
743
|
}
|
|
452
744
|
|
|
453
|
-
//
|
|
745
|
+
// Абзацы и переносы правятся вручную, мимо beforeinput, поэтому запрет на изменение
|
|
746
|
+
// текста проверяем здесь: иначе Enter добавлял бы строки и в режиме только для чтения.
|
|
747
|
+
if (this.readonly) return;
|
|
748
|
+
|
|
749
|
+
// В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
|
|
750
|
+
// В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
|
|
751
|
+
// набирается двумя переносами.
|
|
752
|
+
const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
|
|
753
|
+
const soft = this.paragraph === "break" ? !withModifier : withModifier;
|
|
754
|
+
|
|
454
755
|
this.__history?.record("op");
|
|
455
|
-
if (
|
|
756
|
+
if (soft) insertSoftBreak(this.editable);
|
|
456
757
|
else insertParagraph(this.editable);
|
|
457
758
|
|
|
458
759
|
this.__clearPendingFormats();
|
|
@@ -485,79 +786,55 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
485
786
|
if (filtered !== plain) plainOverride = filtered;
|
|
486
787
|
}
|
|
487
788
|
|
|
488
|
-
|
|
489
|
-
|
|
789
|
+
// вставлять только в своё содержимое: выделение вне редактора нам не адресовано
|
|
790
|
+
const selection = this.selection;
|
|
791
|
+
if (!selection) return;
|
|
490
792
|
|
|
491
793
|
if (html && plainOverride == null && this.__pasteHtml(html, selection)) return;
|
|
492
794
|
this.__pastePlain(plainOverride ?? plain, selection);
|
|
493
795
|
}
|
|
494
796
|
|
|
495
|
-
// Простая вставка текста:
|
|
797
|
+
// Простая вставка текста: в multiline — та же модель абзацев и мягких переносов, что и при
|
|
798
|
+
// вставке форматированного (в режиме block пустая строка разделяет абзацы, в break все переносы
|
|
799
|
+
// мягкие); в single-line — одна строка через пробелы.
|
|
496
800
|
private __pastePlain(text: string, selection: Selection) {
|
|
497
801
|
if (!text) return;
|
|
498
802
|
|
|
499
|
-
const lines = text.split(/\n/);
|
|
500
|
-
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()));
|
|
501
804
|
|
|
502
|
-
|
|
503
|
-
if (!this.multiline) {
|
|
504
|
-
fragment.appendChild(document.createTextNode(output.join(" ")));
|
|
505
|
-
} else {
|
|
506
|
-
output.forEach((line, index) => {
|
|
507
|
-
if (index > 0) fragment.appendChild(document.createElement("br"));
|
|
508
|
-
fragment.appendChild(document.createTextNode(line));
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
const range = selection.getRangeAt(0);
|
|
513
|
-
this.__history?.record("op");
|
|
514
|
-
range.deleteContents();
|
|
515
|
-
range.insertNode(fragment);
|
|
516
|
-
selection.setPosition(selection.focusNode, selection.focusOffset);
|
|
517
|
-
|
|
518
|
-
this.__emitChange();
|
|
805
|
+
this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs), selection);
|
|
519
806
|
}
|
|
520
807
|
|
|
521
|
-
// Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
|
|
522
|
-
//
|
|
523
|
-
// multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line — инлайн с пробелами.
|
|
808
|
+
// Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего —
|
|
809
|
+
// тогда вызывающий откатывается на простую вставку.
|
|
524
810
|
private __pasteHtml(html: string, selection: Selection): boolean {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
// внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
|
|
538
|
-
// иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
|
|
539
|
-
const textWalker = document.createTreeWalker(holder.content, NodeFilter.SHOW_TEXT);
|
|
540
|
-
for (let t = textWalker.nextNode(); t; t = textWalker.nextNode())
|
|
541
|
-
t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
|
|
542
|
-
|
|
543
|
-
const paras = Array.from(holder.content.children) as HTMLElement[];
|
|
544
|
-
for (const p of paras) trimParagraphEdges(p);
|
|
545
|
-
|
|
546
|
-
// отбрасываем пустые краевые абзацы (ведущие/хвостовые \n и <br>-обёртки из буфера),
|
|
547
|
-
// иначе перед и после вставленного текста появляются пустые строки
|
|
548
|
-
while (paras.length && (paras[0].textContent ?? "").trim() === "") paras.shift();
|
|
549
|
-
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 {
|
|
550
823
|
if (!paras.length) return false;
|
|
551
824
|
|
|
552
825
|
const range = selection.getRangeAt(0);
|
|
553
826
|
this.__history?.record("op");
|
|
554
827
|
range.deleteContents();
|
|
555
828
|
|
|
556
|
-
// каретку ставим по абсолютному текстовому смещению (длина вставки), не отслеживая узлы
|
|
557
829
|
const start = selectionCharBounds(this.editable, range)[0];
|
|
558
|
-
let
|
|
830
|
+
let caret: number;
|
|
559
831
|
|
|
560
|
-
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 {
|
|
561
838
|
// инлайн: абзацы и переносы → пробелы, форматирование сохраняем
|
|
562
839
|
const fragment = document.createDocumentFragment();
|
|
563
840
|
paras.forEach((p, index) => {
|
|
@@ -565,16 +842,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
565
842
|
while (p.firstChild) fragment.appendChild(p.firstChild);
|
|
566
843
|
});
|
|
567
844
|
fragment.querySelectorAll("br").forEach((br) => br.replaceWith(document.createTextNode(" ")));
|
|
568
|
-
|
|
845
|
+
|
|
846
|
+
caret = start + (fragment.textContent ?? "").length;
|
|
569
847
|
range.insertNode(fragment);
|
|
570
|
-
} else {
|
|
571
|
-
caretOffset = start + paras.map((p) => p.textContent ?? "").join("").length;
|
|
572
|
-
insertPastedParagraphs(this.editable, paras, range);
|
|
573
|
-
ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
|
|
574
848
|
}
|
|
575
849
|
|
|
576
|
-
restoreSelection(this.editable,
|
|
850
|
+
restoreSelection(this.editable, caret, caret, selection);
|
|
577
851
|
this.__emitChange();
|
|
852
|
+
|
|
578
853
|
return true;
|
|
579
854
|
}
|
|
580
855
|
|
|
@@ -597,11 +872,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
597
872
|
return;
|
|
598
873
|
}
|
|
599
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
|
+
|
|
600
887
|
// режим набора: оборачиваем вводимый текст в ожидающие форматы
|
|
601
888
|
if (this.__pendingFormats.size > 0 && e.inputType === "insertText" && e.data != null) {
|
|
602
889
|
e.preventDefault();
|
|
603
890
|
this.__history?.record("op");
|
|
604
|
-
this.
|
|
891
|
+
this.__insertText(e.data);
|
|
605
892
|
return;
|
|
606
893
|
}
|
|
607
894
|
|
|
@@ -615,9 +902,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
615
902
|
formatToolbar.refresh();
|
|
616
903
|
}
|
|
617
904
|
|
|
618
|
-
private
|
|
619
|
-
const selection =
|
|
620
|
-
if (!selection
|
|
905
|
+
private __insertText(data: string) {
|
|
906
|
+
const selection = this.selection;
|
|
907
|
+
if (!selection) return;
|
|
621
908
|
|
|
622
909
|
insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);
|
|
623
910
|
|