@brandup/ui-richeditor 1.0.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -0
- package/package.json +41 -0
- package/source/editing.ts +238 -0
- package/source/format-config.ts +89 -0
- package/source/format.ts +19 -0
- package/source/history.ts +92 -0
- package/source/index.ts +12 -0
- package/source/paragraphs.ts +119 -0
- package/source/richeditor.less +127 -0
- package/source/richeditor.ts +627 -0
- package/source/selection.ts +267 -0
- package/source/serialize.ts +201 -0
- package/source/toolbar.ts +150 -0
- package/source/typings/less.d.ts +1 -0
- package/source/typings/svg.d.ts +4 -0
- package/svg/bold.svg +3 -0
- package/svg/italic.svg +3 -0
- package/svg/strike.svg +5 -0
- package/svg/underline.svg +4 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import "./richeditor.less"; // стили редактора и панели форматирования
|
|
2
|
+
|
|
3
|
+
import { DOM, UIElementBound } from "@brandup/ui";
|
|
4
|
+
import {
|
|
5
|
+
ALL_FORMAT_TOOLS,
|
|
6
|
+
HOTKEY_TOOLS,
|
|
7
|
+
defaultFormatMarkers,
|
|
8
|
+
deserialize,
|
|
9
|
+
ensureParagraphs,
|
|
10
|
+
insertFormattedText,
|
|
11
|
+
isFormatActive,
|
|
12
|
+
normalizeParagraphs,
|
|
13
|
+
normalizeWhitespace,
|
|
14
|
+
restoreSelection,
|
|
15
|
+
selectionCharBounds,
|
|
16
|
+
serialize,
|
|
17
|
+
toggleFormat,
|
|
18
|
+
type FormatMarkers,
|
|
19
|
+
type FormatStorage,
|
|
20
|
+
type FormatTool,
|
|
21
|
+
} from "./format";
|
|
22
|
+
import {
|
|
23
|
+
caretToEnd,
|
|
24
|
+
expandSelectionToWords,
|
|
25
|
+
insertParagraph,
|
|
26
|
+
insertPastedParagraphs,
|
|
27
|
+
insertSoftBreak,
|
|
28
|
+
selectAllContent,
|
|
29
|
+
trimParagraphEdges,
|
|
30
|
+
trimSelectionWhitespace,
|
|
31
|
+
} from "./editing";
|
|
32
|
+
import { EditorHistory } from "./history";
|
|
33
|
+
import { formatToolbar } from "./toolbar";
|
|
34
|
+
|
|
35
|
+
export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost } from "./toolbar";
|
|
36
|
+
|
|
37
|
+
export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
|
|
38
|
+
export const CHANGE_EVENT = "richeditor-change";
|
|
39
|
+
|
|
40
|
+
const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Escape"];
|
|
41
|
+
|
|
42
|
+
// нативные правки (печать/удаление), состояние до которых запоминаем для собственного undo;
|
|
43
|
+
// вставка/перетаскивание и Enter обрабатываются отдельно, undo/redo — на keydown
|
|
44
|
+
const NATIVE_EDIT_TYPES = new Set([
|
|
45
|
+
"insertText",
|
|
46
|
+
"insertReplacementText",
|
|
47
|
+
"insertCompositionText",
|
|
48
|
+
"deleteContentBackward",
|
|
49
|
+
"deleteContentForward",
|
|
50
|
+
"deleteWordBackward",
|
|
51
|
+
"deleteWordForward",
|
|
52
|
+
"deleteByCut",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// Буква физической клавиши (KeyA…KeyZ) — не зависит от раскладки. Для не-латинских раскладок
|
|
56
|
+
// (например, кириллицы) e.key даёт другую букву, поэтому хоткеи сверяем и по e.code.
|
|
57
|
+
function codeLetter(e: KeyboardEvent): string {
|
|
58
|
+
return /^Key[A-Z]$/.test(e.code) ? e.code.slice(3).toLowerCase() : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Совпадает ли нажатие с латинской буквой хоткея — по e.key (латиница/AZERTY) или e.code (кириллица и пр.).
|
|
62
|
+
function isHotkeyLetter(e: KeyboardEvent, letter: string): boolean {
|
|
63
|
+
return e.key.toLowerCase() === letter || codeLetter(e) === letter;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RichEditorOptions {
|
|
67
|
+
/** Включает форматирование и панель инструментов. */
|
|
68
|
+
format?: boolean;
|
|
69
|
+
/** Состав инструментов (по умолчанию все). */
|
|
70
|
+
tools?: FormatTool[];
|
|
71
|
+
/** Формат хранения значения при форматировании. */
|
|
72
|
+
storage?: FormatStorage;
|
|
73
|
+
/** Переопределения markdown-маркеров. */
|
|
74
|
+
markers?: Partial<FormatMarkers>;
|
|
75
|
+
/** Текст-заглушка (атрибут data-placeholder). */
|
|
76
|
+
placeholder?: string | null;
|
|
77
|
+
/** Многострочный режим. */
|
|
78
|
+
multiline?: boolean;
|
|
79
|
+
/** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
|
|
80
|
+
readonly?: boolean;
|
|
81
|
+
/** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
|
|
82
|
+
toolbarContainer?: HTMLElement | null;
|
|
83
|
+
/** Начальное значение. */
|
|
84
|
+
value?: string;
|
|
85
|
+
|
|
86
|
+
// --- хуки хоста (например, TextBox: фильтрация по типу, submit, индикация ошибки) ---
|
|
87
|
+
/** Возврат false — отклонить вводимый символ. */
|
|
88
|
+
filterChar?: (char: string) => boolean;
|
|
89
|
+
/** Возврат null — отклонить вставку; иначе очищенный текст. */
|
|
90
|
+
filterPaste?: (text: string) => string | null;
|
|
91
|
+
/** Ввод отклонён (символ/вставка). */
|
|
92
|
+
onReject?: () => void;
|
|
93
|
+
/** Enter в однострочном режиме. */
|
|
94
|
+
onEnter?: () => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface RichEditorChangeData {
|
|
98
|
+
editor: RichEditor;
|
|
99
|
+
value: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type RichEditorEvents = {
|
|
103
|
+
[CHANGE_EVENT]: (data: RichEditorChangeData) => void;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
107
|
+
readonly editable: HTMLElement;
|
|
108
|
+
readonly format: boolean;
|
|
109
|
+
readonly formatTools: FormatTool[];
|
|
110
|
+
readonly formatStorage: FormatStorage;
|
|
111
|
+
readonly formatMarkers: FormatMarkers;
|
|
112
|
+
readonly multiline: boolean;
|
|
113
|
+
readonly toolbarContainer: HTMLElement | null;
|
|
114
|
+
|
|
115
|
+
private __opts: RichEditorOptions;
|
|
116
|
+
private __abort = new AbortController();
|
|
117
|
+
private __pendingFormats = new Set<FormatTool>();
|
|
118
|
+
private __hasInputClick = false;
|
|
119
|
+
// собственная история undo/redo — только при форматировании (см. ./history)
|
|
120
|
+
private __history: EditorHistory | null = null;
|
|
121
|
+
|
|
122
|
+
constructor(editable: HTMLElement, options: RichEditorOptions = {}) {
|
|
123
|
+
const format = !!options.format;
|
|
124
|
+
const multiline = !!options.multiline;
|
|
125
|
+
const readonly = !!options.readonly;
|
|
126
|
+
// форматирование недоступно в режиме только для чтения
|
|
127
|
+
const tools = format && !readonly ? (options.tools ?? ALL_FORMAT_TOOLS.slice()) : [];
|
|
128
|
+
|
|
129
|
+
// тулбар общий и живёт в body (см. ./toolbar), поэтому обёртка не нужна —
|
|
130
|
+
// привязываем UIElement прямо к переданному элементу, он же и редактируемый
|
|
131
|
+
editable.classList.add(ROOT_CLASS);
|
|
132
|
+
|
|
133
|
+
super("BrandUp.RichEditor", editable);
|
|
134
|
+
|
|
135
|
+
this.editable = editable;
|
|
136
|
+
this.__opts = options;
|
|
137
|
+
this.format = format;
|
|
138
|
+
this.formatTools = tools;
|
|
139
|
+
this.formatStorage = options.storage === "markdown" ? "markdown" : "html";
|
|
140
|
+
this.formatMarkers = Object.assign(defaultFormatMarkers(), options.markers);
|
|
141
|
+
this.multiline = multiline;
|
|
142
|
+
this.toolbarContainer = options.toolbarContainer ?? null;
|
|
143
|
+
// история включается вместе с форматированием
|
|
144
|
+
this.__history = format ? new EditorHistory(editable) : null;
|
|
145
|
+
|
|
146
|
+
// редактируемость есть всегда; правки в readonly блокируются на beforeinput,
|
|
147
|
+
// при этом остаётся возможность фокуса, выделения и копирования
|
|
148
|
+
editable.contentEditable = "true";
|
|
149
|
+
|
|
150
|
+
if (options.placeholder != null) editable.setAttribute("data-placeholder", options.placeholder);
|
|
151
|
+
if (multiline) editable.classList.add("multiline");
|
|
152
|
+
if (readonly) editable.classList.add("readonly");
|
|
153
|
+
|
|
154
|
+
this.__initEvents();
|
|
155
|
+
|
|
156
|
+
this.__render(options.value ?? "");
|
|
157
|
+
this.__normalize(false); // нормализация без события (инициализация)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
get readonly(): boolean {
|
|
161
|
+
return !!this.__opts.readonly;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// формат хранения значения: format → выбранный; plain → markdown без инструментов (\n\n/\n)
|
|
165
|
+
private get __valueStorage(): FormatStorage {
|
|
166
|
+
return this.format ? this.formatStorage : "markdown";
|
|
167
|
+
}
|
|
168
|
+
private get __valueTools(): FormatTool[] {
|
|
169
|
+
return this.format ? this.formatTools : [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- публичный API ---
|
|
173
|
+
|
|
174
|
+
getValue(): string {
|
|
175
|
+
// multiline → модель абзацев (<p>/\n\n) и мягких переносов (<br>/\n)
|
|
176
|
+
return serialize(this.editable, this.__valueStorage, this.__valueTools, this.formatMarkers, this.multiline);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
setValue(value: string): void {
|
|
180
|
+
this.__render(value ?? "");
|
|
181
|
+
this.__normalize(false);
|
|
182
|
+
this.__emitChange();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
getLength(): number {
|
|
186
|
+
// textContent (а не innerText) корректно считает multiline и работает в jsdom
|
|
187
|
+
return this.editable.textContent?.length ?? 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
focus(): void {
|
|
191
|
+
this.editable.focus();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
onChange(handler: (e: RichEditorChangeData) => void) {
|
|
195
|
+
this.on(CHANGE_EVENT, handler);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
|
|
199
|
+
applyFormat(tool: FormatTool): void {
|
|
200
|
+
if (!this.format || this.readonly || !this.formatTools.includes(tool)) return;
|
|
201
|
+
|
|
202
|
+
const selection = window.getSelection();
|
|
203
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
204
|
+
if (!this.editable.contains(selection.anchorNode)) return;
|
|
205
|
+
|
|
206
|
+
// запоминаем исходное выделение, чтобы вернуть его после форматирования
|
|
207
|
+
const original = selectionCharBounds(this.editable, selection.getRangeAt(0));
|
|
208
|
+
|
|
209
|
+
// форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
|
|
210
|
+
expandSelectionToWords(this.editable, selection);
|
|
211
|
+
|
|
212
|
+
const range = selection.getRangeAt(0);
|
|
213
|
+
if (range.collapsed) {
|
|
214
|
+
// под кареткой нет слова — режим набора: формат для следующего ввода
|
|
215
|
+
if (this.__pendingFormats.has(tool)) this.__pendingFormats.delete(tool);
|
|
216
|
+
else this.__pendingFormats.add(tool);
|
|
217
|
+
|
|
218
|
+
formatToolbar.refresh();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
this.__pendingFormats.clear();
|
|
223
|
+
|
|
224
|
+
this.__history?.record("op");
|
|
225
|
+
toggleFormat(this.editable, range, tool, selection, original);
|
|
226
|
+
|
|
227
|
+
this.__emitChange();
|
|
228
|
+
formatToolbar.refresh();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Отменить последнее действие (история ведётся только при форматировании). */
|
|
232
|
+
private __undo(): void {
|
|
233
|
+
if (!this.__history?.undo()) return;
|
|
234
|
+
this.__afterHistory();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Повторить отменённое действие. */
|
|
238
|
+
private __redo(): void {
|
|
239
|
+
if (!this.__history?.redo()) return;
|
|
240
|
+
this.__afterHistory();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private __afterHistory(): void {
|
|
244
|
+
this.__pendingFormats.clear();
|
|
245
|
+
this.__emitChange();
|
|
246
|
+
formatToolbar.refresh();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Активен ли формат инструмента на текущем выделении (для подсветки кнопки тулбара). */
|
|
250
|
+
isToolActive(tool: FormatTool): boolean {
|
|
251
|
+
if (this.__pendingFormats.has(tool)) return true;
|
|
252
|
+
|
|
253
|
+
const selection = window.getSelection();
|
|
254
|
+
if (!selection || selection.rangeCount === 0 || !this.editable.contains(selection.anchorNode)) return false;
|
|
255
|
+
|
|
256
|
+
return isFormatActive(this.editable, selection.getRangeAt(0), tool);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
override destroy(): void {
|
|
260
|
+
this.__abort.abort();
|
|
261
|
+
formatToolbar.detach(this);
|
|
262
|
+
|
|
263
|
+
// элемент передан хостом — не удаляем его, только снимаем оформление редактора
|
|
264
|
+
this.editable.classList.remove(ROOT_CLASS, "multiline", "readonly", "focused");
|
|
265
|
+
this.editable.removeAttribute("contenteditable");
|
|
266
|
+
this.editable.removeAttribute("data-placeholder");
|
|
267
|
+
|
|
268
|
+
super.destroy();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// --- рендеринг и значение ---
|
|
272
|
+
|
|
273
|
+
private __render(value: string) {
|
|
274
|
+
DOM.empty(this.editable);
|
|
275
|
+
if (!value) return;
|
|
276
|
+
|
|
277
|
+
// multiline → <p>-абзацы; single-line → инлайновое содержимое
|
|
278
|
+
this.editable.innerHTML = deserialize(
|
|
279
|
+
value,
|
|
280
|
+
this.__valueStorage,
|
|
281
|
+
this.__valueTools,
|
|
282
|
+
this.formatMarkers,
|
|
283
|
+
this.multiline
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private __emitChange() {
|
|
288
|
+
this.trigger(CHANGE_EVENT, <RichEditorChangeData>{ editor: this, value: this.getValue() });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private __reject() {
|
|
292
|
+
this.__opts.onReject?.();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Нормализация: схлопывание пробелов + обрезка краёв строк; в multiline дополнительно
|
|
296
|
+
// удаление пустых абзацев. Событие change генерируется только при notify=true и реальном изменении
|
|
297
|
+
// (сравниваем innerHTML — дешевле двойной сериализации и ловит структурные правки абзацев).
|
|
298
|
+
private __normalize(notify: boolean) {
|
|
299
|
+
if (this.readonly) return;
|
|
300
|
+
|
|
301
|
+
const before = this.editable.innerHTML;
|
|
302
|
+
normalizeWhitespace(this.editable);
|
|
303
|
+
if (this.multiline) normalizeParagraphs(this.editable);
|
|
304
|
+
if (this.editable.innerHTML === before) return;
|
|
305
|
+
|
|
306
|
+
if (notify) this.__emitChange();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- обработчики ---
|
|
310
|
+
|
|
311
|
+
private __initEvents() {
|
|
312
|
+
const { signal } = this.__abort;
|
|
313
|
+
const editable = this.editable;
|
|
314
|
+
|
|
315
|
+
this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
|
|
316
|
+
this.element.addEventListener("dragenter", (e) => e.preventDefault(), { signal });
|
|
317
|
+
|
|
318
|
+
editable.addEventListener(
|
|
319
|
+
"mousedown",
|
|
320
|
+
() => {
|
|
321
|
+
this.__hasInputClick = true;
|
|
322
|
+
this.__clearPendingFormats(); // перемещение каретки кликом — выход из режима набора
|
|
323
|
+
},
|
|
324
|
+
{ signal }
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
editable.addEventListener(
|
|
328
|
+
"focus",
|
|
329
|
+
() => {
|
|
330
|
+
this.element.classList.add("focused");
|
|
331
|
+
|
|
332
|
+
// показываем общий тулбар над этим редактором
|
|
333
|
+
if (this.format && this.formatTools.length) formatToolbar.attach(this);
|
|
334
|
+
|
|
335
|
+
if (this.readonly) selectAllContent(this.editable);
|
|
336
|
+
else if (!this.__hasInputClick) caretToEnd(this.editable, this.multiline);
|
|
337
|
+
},
|
|
338
|
+
{ signal }
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
editable.addEventListener(
|
|
342
|
+
"blur",
|
|
343
|
+
() => {
|
|
344
|
+
this.__hasInputClick = false;
|
|
345
|
+
|
|
346
|
+
this.element.classList.remove("focused");
|
|
347
|
+
formatToolbar.detach(this);
|
|
348
|
+
|
|
349
|
+
// удаляем висящий BR, чтобы появился placeholder
|
|
350
|
+
if (editable.firstChild?.nodeName === "BR") DOM.empty(editable);
|
|
351
|
+
|
|
352
|
+
this.__clearPendingFormats();
|
|
353
|
+
this.__normalize(true); // редактирование завершено
|
|
354
|
+
},
|
|
355
|
+
{ signal }
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
editable.addEventListener("dblclick", () => trimSelectionWhitespace(this.editable), { signal });
|
|
359
|
+
|
|
360
|
+
this.element.addEventListener("paste", (e: ClipboardEvent) => this.__onPaste(e), { signal });
|
|
361
|
+
editable.addEventListener("keydown", (e: KeyboardEvent) => this.__onKeydown(e), { signal });
|
|
362
|
+
|
|
363
|
+
editable.addEventListener("beforeinput", (e: InputEvent) => this.__onBeforeInput(e), { signal });
|
|
364
|
+
|
|
365
|
+
editable.addEventListener(
|
|
366
|
+
"input",
|
|
367
|
+
() => {
|
|
368
|
+
if (this.multiline) {
|
|
369
|
+
// нормализация меняет структуру (обёртка в <p>, удаление <br>) и сбрасывает каретку —
|
|
370
|
+
// запоминаем её позицию по текстовому смещению и восстанавливаем после
|
|
371
|
+
const selection = window.getSelection();
|
|
372
|
+
const caret =
|
|
373
|
+
selection && selection.rangeCount > 0 && editable.contains(selection.anchorNode)
|
|
374
|
+
? selectionCharBounds(editable, selection.getRangeAt(0))
|
|
375
|
+
: null;
|
|
376
|
+
const before = editable.innerHTML;
|
|
377
|
+
|
|
378
|
+
ensureParagraphs(editable); // блуждающий текст/div → <p>
|
|
379
|
+
|
|
380
|
+
if (caret && selection && editable.innerHTML !== before)
|
|
381
|
+
restoreSelection(editable, caret[0], caret[1], selection);
|
|
382
|
+
|
|
383
|
+
// единственный пустой абзац → очищаем, чтобы показать placeholder
|
|
384
|
+
if (editable.children.length === 1) {
|
|
385
|
+
const only = editable.firstElementChild!;
|
|
386
|
+
if (only.tagName === "P" && (only.textContent ?? "") === "") DOM.empty(editable);
|
|
387
|
+
}
|
|
388
|
+
} else if (editable.firstChild?.nodeName === "BR") {
|
|
389
|
+
editable.innerHTML = "";
|
|
390
|
+
}
|
|
391
|
+
this.__emitChange();
|
|
392
|
+
},
|
|
393
|
+
{ signal }
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private __onKeydown(e: KeyboardEvent) {
|
|
398
|
+
// хоткеи форматирования (Ctrl/Cmd+B/I/U); зачёркивание — только кнопкой.
|
|
399
|
+
// Сверяем по e.key и e.code — иначе на не-латинской раскладке (кириллица) хоткеи не срабатывают.
|
|
400
|
+
if (this.format && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
|
|
401
|
+
const tool = HOTKEY_TOOLS[e.key.toLowerCase()] ?? HOTKEY_TOOLS[codeLetter(e)];
|
|
402
|
+
if (tool) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
e.stopPropagation();
|
|
405
|
+
if (this.formatTools.includes(tool)) this.applyFormat(tool);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// undo/redo собственной истории (только при форматировании): Ctrl/Cmd+Z — отмена,
|
|
411
|
+
// Ctrl+Y или Ctrl/Cmd+Shift+Z — повтор. Триггерим на keydown (надёжно во всех состояниях,
|
|
412
|
+
// в т.ч. когда нативный undo/redo-стек пуст); сам нативный undo гасим в __onBeforeInput.
|
|
413
|
+
if (this.__history && (e.ctrlKey || e.metaKey) && !e.altKey) {
|
|
414
|
+
const z = isHotkeyLetter(e, "z");
|
|
415
|
+
if (z && !e.shiftKey) {
|
|
416
|
+
e.preventDefault();
|
|
417
|
+
this.__undo();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (isHotkeyLetter(e, "y") || (z && e.shiftKey)) {
|
|
421
|
+
e.preventDefault();
|
|
422
|
+
this.__redo();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// перемещение каретки — выход из режима набора
|
|
428
|
+
if (NAV_KEYS.includes(e.key)) this.__clearPendingFormats();
|
|
429
|
+
|
|
430
|
+
const isChar = e.key.length === 1;
|
|
431
|
+
|
|
432
|
+
if (this.readonly && isChar && !e.ctrlKey) {
|
|
433
|
+
e.preventDefault();
|
|
434
|
+
e.stopPropagation();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (isChar && !e.ctrlKey && this.__opts.filterChar && !this.__opts.filterChar(e.key)) {
|
|
439
|
+
e.preventDefault();
|
|
440
|
+
e.stopPropagation();
|
|
441
|
+
this.__reject();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (e.key === "Enter" || e.key === "U+000A") {
|
|
446
|
+
e.preventDefault();
|
|
447
|
+
|
|
448
|
+
if (!this.multiline) {
|
|
449
|
+
this.__opts.onEnter?.(); // однострочный режим — submit и т.п.
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Shift/Ctrl/Cmd+Enter — мягкий перенос (<br>) внутри абзаца; Enter — новый абзац (<p>)
|
|
454
|
+
this.__history?.record("op");
|
|
455
|
+
if (e.shiftKey || e.ctrlKey || e.metaKey) insertSoftBreak(this.editable);
|
|
456
|
+
else insertParagraph(this.editable);
|
|
457
|
+
|
|
458
|
+
this.__clearPendingFormats();
|
|
459
|
+
this.__emitChange();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private __onPaste(e: ClipboardEvent) {
|
|
464
|
+
e.preventDefault();
|
|
465
|
+
e.stopPropagation();
|
|
466
|
+
|
|
467
|
+
if (this.readonly) return;
|
|
468
|
+
|
|
469
|
+
const data = e.clipboardData;
|
|
470
|
+
if (!data) return;
|
|
471
|
+
|
|
472
|
+
const plain = data.getData("text/plain");
|
|
473
|
+
// форматированную вставку берём из text/html только при включённом форматировании
|
|
474
|
+
const html = this.format && this.formatTools.length ? data.getData("text/html") : "";
|
|
475
|
+
|
|
476
|
+
// filterPaste решает по тексту: null — отклонить; если хук ИЗМЕНИЛ текст (обрезка по длине,
|
|
477
|
+
// фильтр по типу) — форматирование сохранить нельзя, вставляем очищенный простой текст
|
|
478
|
+
let plainOverride: string | null = null;
|
|
479
|
+
if (this.__opts.filterPaste) {
|
|
480
|
+
const filtered = this.__opts.filterPaste(plain);
|
|
481
|
+
if (filtered == null) {
|
|
482
|
+
this.__reject();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (filtered !== plain) plainOverride = filtered;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const selection = window.getSelection();
|
|
489
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
490
|
+
|
|
491
|
+
if (html && plainOverride == null && this.__pasteHtml(html, selection)) return;
|
|
492
|
+
this.__pastePlain(plainOverride ?? plain, selection);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Простая вставка текста: переносы строк — мягкие <br> (multiline) или пробелы (single-line).
|
|
496
|
+
private __pastePlain(text: string, selection: Selection) {
|
|
497
|
+
if (!text) return;
|
|
498
|
+
|
|
499
|
+
const lines = text.split(/\n/);
|
|
500
|
+
const output = lines.map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
|
|
501
|
+
|
|
502
|
+
const fragment = document.createDocumentFragment();
|
|
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();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
|
|
522
|
+
// (тогда вызывающий откатывается на простую вставку). Санитизация до включённых инструментов;
|
|
523
|
+
// multiline сохраняет абзацы <p> и мягкие переносы <br>, single-line — инлайн с пробелами.
|
|
524
|
+
private __pasteHtml(html: string, selection: Selection): boolean {
|
|
525
|
+
// убираем мусорные элементы (Word/браузер: стили, скрипты, заголовок документа)
|
|
526
|
+
const source = document.createElement("template");
|
|
527
|
+
source.innerHTML = html;
|
|
528
|
+
source.content
|
|
529
|
+
.querySelectorAll("script, style, head, meta, link, title, noscript")
|
|
530
|
+
.forEach((el) => el.remove());
|
|
531
|
+
|
|
532
|
+
// единый источник санитизации — deserialize (теги-синонимы → канонические, лишнее развёрнуто)
|
|
533
|
+
const clean = deserialize(source.innerHTML, "html", this.formatTools, this.formatMarkers, true);
|
|
534
|
+
const holder = document.createElement("template");
|
|
535
|
+
holder.innerHTML = clean;
|
|
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();
|
|
550
|
+
if (!paras.length) return false;
|
|
551
|
+
|
|
552
|
+
const range = selection.getRangeAt(0);
|
|
553
|
+
this.__history?.record("op");
|
|
554
|
+
range.deleteContents();
|
|
555
|
+
|
|
556
|
+
// каретку ставим по абсолютному текстовому смещению (длина вставки), не отслеживая узлы
|
|
557
|
+
const start = selectionCharBounds(this.editable, range)[0];
|
|
558
|
+
let caretOffset: number;
|
|
559
|
+
|
|
560
|
+
if (!this.multiline) {
|
|
561
|
+
// инлайн: абзацы и переносы → пробелы, форматирование сохраняем
|
|
562
|
+
const fragment = document.createDocumentFragment();
|
|
563
|
+
paras.forEach((p, index) => {
|
|
564
|
+
if (index > 0) fragment.appendChild(document.createTextNode(" "));
|
|
565
|
+
while (p.firstChild) fragment.appendChild(p.firstChild);
|
|
566
|
+
});
|
|
567
|
+
fragment.querySelectorAll("br").forEach((br) => br.replaceWith(document.createTextNode(" ")));
|
|
568
|
+
caretOffset = start + (fragment.textContent ?? "").length;
|
|
569
|
+
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
|
+
}
|
|
575
|
+
|
|
576
|
+
restoreSelection(this.editable, caretOffset, caretOffset, selection);
|
|
577
|
+
this.__emitChange();
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// --- форматирование ---
|
|
582
|
+
|
|
583
|
+
// Единый beforeinput: гашение нативной отмены, readonly-блокировка, режим набора и запись истории.
|
|
584
|
+
private __onBeforeInput(e: InputEvent) {
|
|
585
|
+
// Нативная отмена в contenteditable приходит сюда (historyUndo/Redo). Саму операцию выполняет
|
|
586
|
+
// keydown-обработчик (надёжно во всех состояниях), здесь лишь ГАСИМ нативный undo, чтобы он
|
|
587
|
+
// не конфликтовал с нашей историей (особенно после ручных правок форматирования).
|
|
588
|
+
// keydown всегда предшествует beforeinput, поэтому двойного срабатывания нет.
|
|
589
|
+
if (this.__history && (e.inputType === "historyUndo" || e.inputType === "historyRedo")) {
|
|
590
|
+
e.preventDefault();
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (this.readonly) {
|
|
595
|
+
// readonly — запрет любых правок (ввод, удаление, вставка, IME); выделение/копирование остаются
|
|
596
|
+
e.preventDefault();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// режим набора: оборачиваем вводимый текст в ожидающие форматы
|
|
601
|
+
if (this.__pendingFormats.size > 0 && e.inputType === "insertText" && e.data != null) {
|
|
602
|
+
e.preventDefault();
|
|
603
|
+
this.__history?.record("op");
|
|
604
|
+
this.__insertPendingText(e.data);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// запоминаем состояние до нативной печати/удаления для собственного undo
|
|
609
|
+
if (this.__history && NATIVE_EDIT_TYPES.has(e.inputType)) this.__history.record("type");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private __clearPendingFormats() {
|
|
613
|
+
if (!this.__pendingFormats.size) return;
|
|
614
|
+
this.__pendingFormats.clear();
|
|
615
|
+
formatToolbar.refresh();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private __insertPendingText(data: string) {
|
|
619
|
+
const selection = window.getSelection();
|
|
620
|
+
if (!selection || !this.editable.contains(selection.anchorNode)) return;
|
|
621
|
+
|
|
622
|
+
insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);
|
|
623
|
+
|
|
624
|
+
this.__emitChange();
|
|
625
|
+
formatToolbar.refresh();
|
|
626
|
+
}
|
|
627
|
+
}
|