@brandup/ui-richeditor 1.0.36 → 1.0.38
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 +52 -6
- package/package.json +3 -2
- package/source/editing.ts +7 -8
- package/source/emoji.ts +53 -0
- package/source/format-config.ts +45 -18
- package/source/format.ts +17 -1
- package/source/history.ts +10 -0
- package/source/index.ts +6 -0
- package/source/paragraphs.ts +10 -3
- package/source/richeditor.less +52 -1
- package/source/richeditor.ts +239 -32
- package/source/selection.ts +120 -30
- package/source/serialize.ts +274 -201
- package/source/toolbar.ts +134 -12
- 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,24 +4,31 @@ import { DOM, UIElementBound } from "@brandup/ui";
|
|
|
4
4
|
import {
|
|
5
5
|
ALL_FORMAT_TOOLS,
|
|
6
6
|
HOTKEY_TOOLS,
|
|
7
|
+
clearAllFormat,
|
|
8
|
+
clearFormat,
|
|
7
9
|
defaultFormatMarkers,
|
|
8
10
|
deserialize,
|
|
9
11
|
ensureParagraphs,
|
|
12
|
+
hasAnyFormatting,
|
|
13
|
+
hasFormatting,
|
|
10
14
|
insertFormattedText,
|
|
11
15
|
isFormatActive,
|
|
16
|
+
mapCharOffset,
|
|
12
17
|
normalizeParagraphs,
|
|
13
18
|
normalizeWhitespace,
|
|
14
19
|
restoreSelection,
|
|
15
20
|
selectionCharBounds,
|
|
16
21
|
serialize,
|
|
17
22
|
toggleFormat,
|
|
23
|
+
type EditorAction,
|
|
18
24
|
type FormatMarkers,
|
|
19
25
|
type FormatStorage,
|
|
26
|
+
type ParagraphMode,
|
|
20
27
|
type FormatTool,
|
|
21
28
|
} from "./format";
|
|
22
29
|
import {
|
|
23
30
|
caretToEnd,
|
|
24
|
-
|
|
31
|
+
expandRangeToWords,
|
|
25
32
|
insertParagraph,
|
|
26
33
|
insertPastedParagraphs,
|
|
27
34
|
insertSoftBreak,
|
|
@@ -68,6 +75,8 @@ export interface RichEditorOptions {
|
|
|
68
75
|
format?: boolean;
|
|
69
76
|
/** Состав инструментов (по умолчанию все). */
|
|
70
77
|
tools?: FormatTool[];
|
|
78
|
+
/** Кнопки действий в панели: очистка форматирования, отмена, повтор (по умолчанию нет). */
|
|
79
|
+
actions?: EditorAction[];
|
|
71
80
|
/** Формат хранения значения при форматировании. */
|
|
72
81
|
storage?: FormatStorage;
|
|
73
82
|
/** Переопределения markdown-маркеров. */
|
|
@@ -76,6 +85,8 @@ export interface RichEditorOptions {
|
|
|
76
85
|
placeholder?: string | null;
|
|
77
86
|
/** Многострочный режим. */
|
|
78
87
|
multiline?: boolean;
|
|
88
|
+
/** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
|
|
89
|
+
paragraph?: ParagraphMode;
|
|
79
90
|
/** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
|
|
80
91
|
readonly?: boolean;
|
|
81
92
|
/** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
|
|
@@ -107,9 +118,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
107
118
|
readonly editable: HTMLElement;
|
|
108
119
|
readonly format: boolean;
|
|
109
120
|
readonly formatTools: FormatTool[];
|
|
121
|
+
readonly editorActions: EditorAction[];
|
|
110
122
|
readonly formatStorage: FormatStorage;
|
|
111
123
|
readonly formatMarkers: FormatMarkers;
|
|
112
124
|
readonly multiline: boolean;
|
|
125
|
+
readonly paragraph: ParagraphMode;
|
|
113
126
|
readonly toolbarContainer: HTMLElement | null;
|
|
114
127
|
|
|
115
128
|
private __opts: RichEditorOptions;
|
|
@@ -125,6 +138,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
125
138
|
const readonly = !!options.readonly;
|
|
126
139
|
// форматирование недоступно в режиме только для чтения
|
|
127
140
|
const tools = format && !readonly ? (options.tools ?? ALL_FORMAT_TOOLS.slice()) : [];
|
|
141
|
+
// действия подключаются явно — иначе панель у существующих хостов молча обзавелась бы кнопками
|
|
142
|
+
const actions = format && !readonly ? (options.actions ?? []) : [];
|
|
128
143
|
|
|
129
144
|
// тулбар общий и живёт в body (см. ./toolbar), поэтому обёртка не нужна —
|
|
130
145
|
// привязываем UIElement прямо к переданному элементу, он же и редактируемый
|
|
@@ -136,9 +151,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
136
151
|
this.__opts = options;
|
|
137
152
|
this.format = format;
|
|
138
153
|
this.formatTools = tools;
|
|
154
|
+
this.editorActions = actions;
|
|
139
155
|
this.formatStorage = options.storage === "markdown" ? "markdown" : "html";
|
|
140
156
|
this.formatMarkers = Object.assign(defaultFormatMarkers(), options.markers);
|
|
141
157
|
this.multiline = multiline;
|
|
158
|
+
this.paragraph = options.paragraph ?? "block";
|
|
142
159
|
this.toolbarContainer = options.toolbarContainer ?? null;
|
|
143
160
|
// история включается вместе с форматированием
|
|
144
161
|
this.__history = format ? new EditorHistory(editable) : null;
|
|
@@ -177,11 +194,53 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
177
194
|
}
|
|
178
195
|
|
|
179
196
|
setValue(value: string): void {
|
|
197
|
+
// каретка внутри относилась к прежнему содержимому — после замены ставим её в конец
|
|
198
|
+
const hadCaret = !!this.__innerSelection();
|
|
199
|
+
|
|
180
200
|
this.__render(value ?? "");
|
|
181
201
|
this.__normalize(false);
|
|
202
|
+
if (hadCaret) caretToEnd(this.editable, this.multiline);
|
|
203
|
+
|
|
182
204
|
this.__emitChange();
|
|
183
205
|
}
|
|
184
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Вставляет текст в каретку (или вместо выделения) с учётом ожидающих форматов режима набора.
|
|
209
|
+
* Каретка сохраняется и после потери фокуса, поэтому метод работает и при вызове из кода —
|
|
210
|
+
* например, кнопкой панели, которая не должна забирать фокус у редактора.
|
|
211
|
+
*/
|
|
212
|
+
insertText(text: string): void {
|
|
213
|
+
if (this.readonly || !text || !this.__innerSelection()) return;
|
|
214
|
+
|
|
215
|
+
// вставка — такой же ввод, как с клавиатуры, поэтому проходит через filterChar хоста
|
|
216
|
+
// (ограничения по типу поля и длине). Обход символов идёт по кодпойнтам, чтобы эмодзи
|
|
217
|
+
// проверялся целиком; длина сверяется с текущим содержимым, так что многосимвольная
|
|
218
|
+
// вставка ограничивается по первому символу — для одного символа проверка точная.
|
|
219
|
+
const filterChar = this.__opts.filterChar;
|
|
220
|
+
if (filterChar && !Array.from(text).every((char) => filterChar(char))) {
|
|
221
|
+
this.__reject();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this.__history?.record("op");
|
|
226
|
+
this.__insertText(text);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Показать панель вставки смайлика у своей кнопки — когда она живёт не в тулбаре, а в разметке
|
|
231
|
+
* хоста (например справа от поля ввода). Вызывать из обработчика `click`, погасив всплытие:
|
|
232
|
+
* иначе попап закроется тем же кликом, которым открылся.
|
|
233
|
+
*
|
|
234
|
+
* @param initiator Кнопка, у которой показывается панель.
|
|
235
|
+
* @param container Куда монтировать панель; по умолчанию — родитель кнопки.
|
|
236
|
+
*/
|
|
237
|
+
openEmojiPicker(initiator: HTMLElement, container?: HTMLElement): void {
|
|
238
|
+
if (this.readonly) return;
|
|
239
|
+
|
|
240
|
+
const target = container ?? initiator.parentElement;
|
|
241
|
+
if (target) formatToolbar.openEmoji(this, initiator, target);
|
|
242
|
+
}
|
|
243
|
+
|
|
185
244
|
getLength(): number {
|
|
186
245
|
// textContent (а не innerText) корректно считает multiline и работает в jsdom
|
|
187
246
|
return this.editable.textContent?.length ?? 0;
|
|
@@ -195,22 +254,46 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
195
254
|
this.on(CHANGE_EVENT, handler);
|
|
196
255
|
}
|
|
197
256
|
|
|
198
|
-
/**
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Выделение, если оно находится внутри этого редактора. Браузер сохраняет выделение и после
|
|
259
|
+
* blur, поэтому проверка работает и когда фокус ушёл на кнопку страницы.
|
|
260
|
+
*/
|
|
261
|
+
private __innerSelection(): Selection | null {
|
|
202
262
|
const selection = window.getSelection();
|
|
203
|
-
if (!selection || selection.rangeCount === 0) return;
|
|
204
|
-
if (!this.editable.contains(selection.anchorNode)) return;
|
|
263
|
+
if (!selection || selection.rangeCount === 0 || !this.editable.contains(selection.anchorNode)) return null;
|
|
205
264
|
|
|
206
|
-
|
|
207
|
-
|
|
265
|
+
return selection;
|
|
266
|
+
}
|
|
208
267
|
|
|
209
|
-
|
|
210
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Цель правки форматирования: выделение, расширенное до целых слов, плюс исходные границы
|
|
270
|
+
* для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
|
|
271
|
+
* править, каретка пользователя не двигается. null — правка недоступна или выделение вне редактора.
|
|
272
|
+
*/
|
|
273
|
+
private __formatTarget(): { selection: Selection; range: Range; original: [number, number] } | null {
|
|
274
|
+
if (!this.format || this.readonly) return null;
|
|
275
|
+
|
|
276
|
+
const selection = this.__innerSelection();
|
|
277
|
+
if (!selection) return null;
|
|
278
|
+
|
|
279
|
+
const current = selection.getRangeAt(0);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
selection,
|
|
283
|
+
// форматируем слова целиком: и при курсоре без выделения, и при выделении части слова
|
|
284
|
+
range: expandRangeToWords(this.editable, current),
|
|
285
|
+
original: selectionCharBounds(this.editable, current),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
211
288
|
|
|
212
|
-
|
|
213
|
-
|
|
289
|
+
/** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
|
|
290
|
+
applyFormat(tool: FormatTool): void {
|
|
291
|
+
if (!this.formatTools.includes(tool)) return;
|
|
292
|
+
|
|
293
|
+
const target = this.__formatTarget();
|
|
294
|
+
if (!target) return;
|
|
295
|
+
|
|
296
|
+
if (target.range.collapsed) {
|
|
214
297
|
// под кареткой нет слова — режим набора: формат для следующего ввода
|
|
215
298
|
if (this.__pendingFormats.has(tool)) this.__pendingFormats.delete(tool);
|
|
216
299
|
else this.__pendingFormats.add(tool);
|
|
@@ -222,24 +305,119 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
222
305
|
this.__pendingFormats.clear();
|
|
223
306
|
|
|
224
307
|
this.__history?.record("op");
|
|
225
|
-
toggleFormat(this.editable, range, tool, selection, original);
|
|
308
|
+
toggleFormat(this.editable, target.range, tool, target.selection, target.original);
|
|
309
|
+
|
|
310
|
+
this.__emitChange();
|
|
311
|
+
formatToolbar.refresh();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Снимает всё форматирование с выделения; без выделения — со слова под кареткой
|
|
316
|
+
* (как и применение формата). Режим набора сбрасывается.
|
|
317
|
+
*/
|
|
318
|
+
clearFormat(): void {
|
|
319
|
+
const target = this.__formatTarget();
|
|
320
|
+
if (!target) return;
|
|
321
|
+
|
|
322
|
+
this.__clearPendingFormats();
|
|
323
|
+
|
|
324
|
+
// нечего очищать — не пишем в историю пустой шаг и не трогаем выделение
|
|
325
|
+
if (!hasFormatting(this.editable, target.range)) return;
|
|
326
|
+
|
|
327
|
+
this.__history?.record("op");
|
|
328
|
+
clearFormat(this.editable, target.range, target.selection, target.original);
|
|
329
|
+
|
|
330
|
+
this.__emitChange();
|
|
331
|
+
formatToolbar.refresh();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Снимает всё форматирование со всего содержимого (выделение не требуется). */
|
|
335
|
+
clearAllFormat(): void {
|
|
336
|
+
if (!this.format || this.readonly) return;
|
|
337
|
+
|
|
338
|
+
this.__clearPendingFormats();
|
|
339
|
+
if (!hasAnyFormatting(this.editable)) return;
|
|
340
|
+
|
|
341
|
+
// разворачивание тегов рвёт выделение — запоминаем по текстовым смещениям
|
|
342
|
+
const selection = this.__innerSelection();
|
|
343
|
+
const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
344
|
+
|
|
345
|
+
this.__history?.record("op");
|
|
346
|
+
clearAllFormat(this.editable);
|
|
347
|
+
|
|
348
|
+
if (bounds && selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
226
349
|
|
|
227
350
|
this.__emitChange();
|
|
228
351
|
formatToolbar.refresh();
|
|
229
352
|
}
|
|
230
353
|
|
|
354
|
+
/** Доступна ли отмена (история ведётся только при включённом форматировании). */
|
|
355
|
+
get canUndo(): boolean {
|
|
356
|
+
return !this.readonly && !!this.__history?.canUndo;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Доступен ли повтор отменённого. */
|
|
360
|
+
get canRedo(): boolean {
|
|
361
|
+
return !this.readonly && !!this.__history?.canRedo;
|
|
362
|
+
}
|
|
363
|
+
|
|
231
364
|
/** Отменить последнее действие (история ведётся только при форматировании). */
|
|
232
|
-
|
|
233
|
-
if (!this.__history?.undo()) return;
|
|
365
|
+
undo(): void {
|
|
366
|
+
if (this.readonly || !this.__history?.undo()) return;
|
|
234
367
|
this.__afterHistory();
|
|
235
368
|
}
|
|
236
369
|
|
|
237
370
|
/** Повторить отменённое действие. */
|
|
238
|
-
|
|
239
|
-
if (!this.__history?.redo()) return;
|
|
371
|
+
redo(): void {
|
|
372
|
+
if (this.readonly || !this.__history?.redo()) return;
|
|
240
373
|
this.__afterHistory();
|
|
241
374
|
}
|
|
242
375
|
|
|
376
|
+
/** Выполнить действие панели (вызывается кнопками тулбара). */
|
|
377
|
+
applyAction(action: EditorAction): void {
|
|
378
|
+
if (!this.editorActions.includes(action)) return;
|
|
379
|
+
|
|
380
|
+
switch (action) {
|
|
381
|
+
case "erase":
|
|
382
|
+
this.clearFormat();
|
|
383
|
+
break;
|
|
384
|
+
case "undo":
|
|
385
|
+
this.undo();
|
|
386
|
+
break;
|
|
387
|
+
case "redo":
|
|
388
|
+
this.redo();
|
|
389
|
+
break;
|
|
390
|
+
case "emoji":
|
|
391
|
+
// панель вставки открывает тулбар: ей нужна кнопка как якорь попапа.
|
|
392
|
+
// Вставка символа приходит сюда через insertText.
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Доступно ли действие сейчас (для disabled-состояния кнопки тулбара). */
|
|
398
|
+
isActionEnabled(action: EditorAction): boolean {
|
|
399
|
+
switch (action) {
|
|
400
|
+
case "undo":
|
|
401
|
+
return this.canUndo;
|
|
402
|
+
case "redo":
|
|
403
|
+
return this.canRedo;
|
|
404
|
+
case "erase":
|
|
405
|
+
return this.__hasFormatting();
|
|
406
|
+
case "emoji":
|
|
407
|
+
return !this.readonly;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Есть ли что очищать: ожидающий формат режима набора либо форматирование на цели очистки.
|
|
412
|
+
// Смотрим на тот же расширенный до слов диапазон, что возьмёт clearFormat, — иначе кнопка
|
|
413
|
+
// блокировалась бы в случаях, когда операция сработала бы (каретка на границе формата).
|
|
414
|
+
private __hasFormatting(): boolean {
|
|
415
|
+
if (this.__pendingFormats.size > 0) return true;
|
|
416
|
+
|
|
417
|
+
const target = this.__formatTarget();
|
|
418
|
+
return !!target && hasFormatting(this.editable, target.range);
|
|
419
|
+
}
|
|
420
|
+
|
|
243
421
|
private __afterHistory(): void {
|
|
244
422
|
this.__pendingFormats.clear();
|
|
245
423
|
this.__emitChange();
|
|
@@ -250,8 +428,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
250
428
|
isToolActive(tool: FormatTool): boolean {
|
|
251
429
|
if (this.__pendingFormats.has(tool)) return true;
|
|
252
430
|
|
|
253
|
-
const selection =
|
|
254
|
-
if (!selection
|
|
431
|
+
const selection = this.__innerSelection();
|
|
432
|
+
if (!selection) return false;
|
|
255
433
|
|
|
256
434
|
return isFormatActive(this.editable, selection.getRangeAt(0), tool);
|
|
257
435
|
}
|
|
@@ -274,14 +452,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
274
452
|
DOM.empty(this.editable);
|
|
275
453
|
if (!value) return;
|
|
276
454
|
|
|
277
|
-
// multiline → <p>-абзацы; single-line → инлайновое
|
|
455
|
+
// multiline → <p>-абзацы; single-line → инлайновое содержимое.
|
|
456
|
+
// В режиме break абзацных блоков нет: значение — плоский текст, где каждый \n это <br>.
|
|
457
|
+
// Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без отступов между абзацами)
|
|
458
|
+
// это неотличимо от одного переноса — значение расходилось бы с видимым текстом.
|
|
459
|
+
const paragraphs = this.multiline && this.paragraph === "block";
|
|
460
|
+
|
|
278
461
|
this.editable.innerHTML = deserialize(
|
|
279
462
|
value,
|
|
280
463
|
this.__valueStorage,
|
|
281
464
|
this.__valueTools,
|
|
282
465
|
this.formatMarkers,
|
|
283
|
-
|
|
466
|
+
paragraphs
|
|
284
467
|
);
|
|
468
|
+
|
|
469
|
+
// в break инлайновое содержимое оборачивается в единственный абзац — модель абзацев
|
|
470
|
+
// нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
|
|
471
|
+
if (this.multiline && !paragraphs) ensureParagraphs(this.editable);
|
|
285
472
|
}
|
|
286
473
|
|
|
287
474
|
private __emitChange() {
|
|
@@ -298,11 +485,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
298
485
|
private __normalize(notify: boolean) {
|
|
299
486
|
if (this.readonly) return;
|
|
300
487
|
|
|
488
|
+
// правка текстовых узлов рвёт живые Range — запоминаем выделение по текстовым смещениям
|
|
489
|
+
const selection = this.__innerSelection();
|
|
490
|
+
const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
491
|
+
|
|
301
492
|
const before = this.editable.innerHTML;
|
|
493
|
+
const textBefore = this.editable.textContent ?? "";
|
|
302
494
|
normalizeWhitespace(this.editable);
|
|
303
495
|
if (this.multiline) normalizeParagraphs(this.editable);
|
|
304
496
|
if (this.editable.innerHTML === before) return;
|
|
305
497
|
|
|
498
|
+
if (bounds && selection) {
|
|
499
|
+
// схлопнутые пробелы сдвигают смещения — переносим их на новый текст
|
|
500
|
+
const textAfter = this.editable.textContent ?? "";
|
|
501
|
+
const start = mapCharOffset(textBefore, textAfter, bounds[0]);
|
|
502
|
+
const end = bounds[1] === bounds[0] ? start : mapCharOffset(textBefore, textAfter, bounds[1]);
|
|
503
|
+
restoreSelection(this.editable, start, end, selection);
|
|
504
|
+
}
|
|
505
|
+
|
|
306
506
|
if (notify) this.__emitChange();
|
|
307
507
|
}
|
|
308
508
|
|
|
@@ -330,10 +530,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
330
530
|
this.element.classList.add("focused");
|
|
331
531
|
|
|
332
532
|
// показываем общий тулбар над этим редактором
|
|
333
|
-
if (this.format && this.formatTools.length) formatToolbar.attach(this);
|
|
533
|
+
if (this.format && (this.formatTools.length || this.editorActions.length)) formatToolbar.attach(this);
|
|
334
534
|
|
|
335
535
|
if (this.readonly) selectAllContent(this.editable);
|
|
336
|
-
|
|
536
|
+
// Каретку в конец ставим только когда её нет: клик ставит сам, а уже стоящую
|
|
537
|
+
// (фокус вернули из кода после вызова метода) двигать нельзя — уедет в конец текста.
|
|
538
|
+
else if (!this.__hasInputClick && !this.__innerSelection()) caretToEnd(this.editable, this.multiline);
|
|
337
539
|
},
|
|
338
540
|
{ signal }
|
|
339
541
|
);
|
|
@@ -414,12 +616,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
414
616
|
const z = isHotkeyLetter(e, "z");
|
|
415
617
|
if (z && !e.shiftKey) {
|
|
416
618
|
e.preventDefault();
|
|
417
|
-
this.
|
|
619
|
+
this.undo();
|
|
418
620
|
return;
|
|
419
621
|
}
|
|
420
622
|
if (isHotkeyLetter(e, "y") || (z && e.shiftKey)) {
|
|
421
623
|
e.preventDefault();
|
|
422
|
-
this.
|
|
624
|
+
this.redo();
|
|
423
625
|
return;
|
|
424
626
|
}
|
|
425
627
|
}
|
|
@@ -450,9 +652,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
450
652
|
return;
|
|
451
653
|
}
|
|
452
654
|
|
|
453
|
-
//
|
|
655
|
+
// В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
|
|
656
|
+
// В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
|
|
657
|
+
// набирается двумя переносами.
|
|
658
|
+
const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
|
|
659
|
+
const soft = this.paragraph === "break" ? !withModifier : withModifier;
|
|
660
|
+
|
|
454
661
|
this.__history?.record("op");
|
|
455
|
-
if (
|
|
662
|
+
if (soft) insertSoftBreak(this.editable);
|
|
456
663
|
else insertParagraph(this.editable);
|
|
457
664
|
|
|
458
665
|
this.__clearPendingFormats();
|
|
@@ -601,7 +808,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
601
808
|
if (this.__pendingFormats.size > 0 && e.inputType === "insertText" && e.data != null) {
|
|
602
809
|
e.preventDefault();
|
|
603
810
|
this.__history?.record("op");
|
|
604
|
-
this.
|
|
811
|
+
this.__insertText(e.data);
|
|
605
812
|
return;
|
|
606
813
|
}
|
|
607
814
|
|
|
@@ -615,9 +822,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
615
822
|
formatToolbar.refresh();
|
|
616
823
|
}
|
|
617
824
|
|
|
618
|
-
private
|
|
619
|
-
const selection =
|
|
620
|
-
if (!selection
|
|
825
|
+
private __insertText(data: string) {
|
|
826
|
+
const selection = this.__innerSelection();
|
|
827
|
+
if (!selection) return;
|
|
621
828
|
|
|
622
829
|
insertFormattedText(this.editable, data, Array.from(this.__pendingFormats), selection);
|
|
623
830
|
|
package/source/selection.ts
CHANGED
|
@@ -6,6 +6,14 @@ import { ALL_FORMAT_TOOLS, FORMAT_TOOLS, type FormatTool } from "./format-config
|
|
|
6
6
|
/** Канонические теги форматирования (в верхнем регистре, как tagName). */
|
|
7
7
|
const FORMAT_TAG_NAMES = ALL_FORMAT_TOOLS.map((t) => FORMAT_TOOLS[t].tag.toUpperCase());
|
|
8
8
|
|
|
9
|
+
// Все распознаваемые теги форматирования (канонические и синонимы) — для снятия форматирования
|
|
10
|
+
// целиком: в содержимом редактора синонимов быть не должно, но вставка и setValue могут их принести.
|
|
11
|
+
const MATCH_TAG_NAMES = Array.from(new Set(ALL_FORMAT_TOOLS.flatMap((t) => FORMAT_TOOLS[t].matchTags)));
|
|
12
|
+
|
|
13
|
+
// Селекторы считаем один раз: обе выборки идут на каждую правку формата и на каждое обновление панели.
|
|
14
|
+
const FORMAT_SELECTOR = FORMAT_TAG_NAMES.join(",").toLowerCase();
|
|
15
|
+
const MATCH_SELECTOR = MATCH_TAG_NAMES.join(",").toLowerCase();
|
|
16
|
+
|
|
9
17
|
/** Ближайший предок-элемент с одним из тегов (в пределах root, не включая root). */
|
|
10
18
|
function formatAncestor(node: Node, tags: string[], root: HTMLElement): HTMLElement | null {
|
|
11
19
|
let el = node.parentElement;
|
|
@@ -27,6 +35,26 @@ export function selectionCharBounds(root: HTMLElement, range: Range): [number, n
|
|
|
27
35
|
return [start, end];
|
|
28
36
|
}
|
|
29
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Пересчитывает абсолютное текстовое смещение после нормализации пробелов.
|
|
40
|
+
*
|
|
41
|
+
* Нормализация только удаляет символы (схлопывает пробелы) и заменяет табы пробелами,
|
|
42
|
+
* поэтому старый и новый текст выравниваются одним проходом: несовпадение означает
|
|
43
|
+
* удалённый символ. Без пересчёта каретка отстаёт ровно на число схлопнутых перед ней
|
|
44
|
+
* пробелов и может уехать в соседнее слово.
|
|
45
|
+
*/
|
|
46
|
+
export function mapCharOffset(before: string, after: string, offset: number): number {
|
|
47
|
+
const same = (a: string, b: string) => a === b || (b === " " && (a === " " || a === "\t"));
|
|
48
|
+
|
|
49
|
+
let i = 0;
|
|
50
|
+
let j = 0;
|
|
51
|
+
while (i < offset && j < after.length) {
|
|
52
|
+
if (same(before[i], after[j])) j++;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
return j;
|
|
56
|
+
}
|
|
57
|
+
|
|
30
58
|
/** Находит текстовый узел и локальное смещение по абсолютному текстовому смещению. */
|
|
31
59
|
function locateChar(root: HTMLElement, target: number): { node: Text; offset: number } | null {
|
|
32
60
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
@@ -97,12 +125,13 @@ function nodeWithinRange(node: Node, range: Range): boolean {
|
|
|
97
125
|
);
|
|
98
126
|
}
|
|
99
127
|
|
|
100
|
-
|
|
128
|
+
/** Непустые текстовые узлы, целиком попавшие в диапазон (частично задетые правке не подлежат). */
|
|
129
|
+
function collectTextNodes(root: HTMLElement, range: Range): Text[] {
|
|
101
130
|
const nodes: Text[] = [];
|
|
102
131
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
103
132
|
let n = walker.nextNode() as Text | null;
|
|
104
133
|
while (n) {
|
|
105
|
-
if (n.length > 0 &&
|
|
134
|
+
if (n.length > 0 && nodeWithinRange(n, range)) nodes.push(n);
|
|
106
135
|
n = walker.nextNode() as Text | null;
|
|
107
136
|
}
|
|
108
137
|
return nodes;
|
|
@@ -151,13 +180,11 @@ function unwrapElement(el: HTMLElement) {
|
|
|
151
180
|
|
|
152
181
|
/** Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст. */
|
|
153
182
|
export function cleanupFormatting(root: HTMLElement) {
|
|
154
|
-
const selector = FORMAT_TAG_NAMES.join(",").toLowerCase();
|
|
155
|
-
|
|
156
183
|
let changed = true;
|
|
157
184
|
while (changed) {
|
|
158
185
|
changed = false;
|
|
159
186
|
|
|
160
|
-
for (const el of Array.from(root.querySelectorAll<HTMLElement>(
|
|
187
|
+
for (const el of Array.from(root.querySelectorAll<HTMLElement>(FORMAT_SELECTOR))) {
|
|
161
188
|
if (!el.isConnected) continue;
|
|
162
189
|
|
|
163
190
|
// пустой тег
|
|
@@ -190,41 +217,114 @@ export function cleanupFormatting(root: HTMLElement) {
|
|
|
190
217
|
}
|
|
191
218
|
|
|
192
219
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
220
|
+
* Каркас правки форматирования на выделении: доводит границы Range до целых текстовых узлов,
|
|
221
|
+
* отдаёт строго попавшие узлы в `mutate` и восстанавливает выделение.
|
|
195
222
|
*
|
|
196
223
|
* По умолчанию восстанавливает выделение, на котором работал. Через `restoreBounds`
|
|
197
224
|
* можно восстановить другое выделение (например, исходное до расширения до слова).
|
|
198
225
|
*/
|
|
199
|
-
|
|
226
|
+
function editSelection(
|
|
200
227
|
root: HTMLElement,
|
|
201
228
|
range: Range,
|
|
202
|
-
tool: FormatTool,
|
|
203
229
|
selection: Selection,
|
|
204
|
-
restoreBounds
|
|
230
|
+
restoreBounds: [number, number] | undefined,
|
|
231
|
+
mutate: (nodes: Text[]) => void
|
|
205
232
|
) {
|
|
206
233
|
if (range.collapsed) return;
|
|
207
234
|
|
|
208
|
-
const def = FORMAT_TOOLS[tool];
|
|
209
|
-
const tags = def.matchTags;
|
|
210
235
|
const [startChar, endChar] = restoreBounds ?? selectionCharBounds(root, range);
|
|
211
236
|
|
|
212
237
|
splitBoundaries(range);
|
|
213
238
|
|
|
214
|
-
const nodes = collectTextNodes(root, range
|
|
239
|
+
const nodes = collectTextNodes(root, range);
|
|
215
240
|
if (!nodes.length) return;
|
|
216
241
|
|
|
217
|
-
|
|
218
|
-
if (allFormatted) {
|
|
219
|
-
for (const n of nodes) removeFormatFromNode(n, tags, root);
|
|
220
|
-
} else {
|
|
221
|
-
for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
|
|
222
|
-
}
|
|
242
|
+
mutate(nodes);
|
|
223
243
|
|
|
224
244
|
cleanupFormatting(root);
|
|
225
245
|
restoreSelection(root, startChar, endChar, selection);
|
|
226
246
|
}
|
|
227
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Переключает форматирование инструмента на выделении.
|
|
250
|
+
* Если весь выделенный текст уже отформатирован — снимает формат, иначе применяет.
|
|
251
|
+
*/
|
|
252
|
+
export function toggleFormat(
|
|
253
|
+
root: HTMLElement,
|
|
254
|
+
range: Range,
|
|
255
|
+
tool: FormatTool,
|
|
256
|
+
selection: Selection,
|
|
257
|
+
restoreBounds?: [number, number]
|
|
258
|
+
) {
|
|
259
|
+
const def = FORMAT_TOOLS[tool];
|
|
260
|
+
const tags = def.matchTags;
|
|
261
|
+
|
|
262
|
+
editSelection(root, range, selection, restoreBounds, (nodes) => {
|
|
263
|
+
const allFormatted = nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
264
|
+
if (allFormatted) {
|
|
265
|
+
for (const n of nodes) removeFormatFromNode(n, tags, root);
|
|
266
|
+
} else {
|
|
267
|
+
for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Снимает всё форматирование с выделения (все инструменты сразу, включая теги-синонимы). */
|
|
273
|
+
export function clearFormat(root: HTMLElement, range: Range, selection: Selection, restoreBounds?: [number, number]) {
|
|
274
|
+
editSelection(root, range, selection, restoreBounds, (nodes) => {
|
|
275
|
+
for (const n of nodes) removeFormatFromNode(n, MATCH_TAG_NAMES, root);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Снимает всё форматирование со всего содержимого (выделение не участвует). */
|
|
280
|
+
export function clearAllFormat(root: HTMLElement) {
|
|
281
|
+
// вложенные элементы после разворачивания родителя остаются в дереве — снимок обходим целиком
|
|
282
|
+
for (const el of Array.from(root.querySelectorAll<HTMLElement>(MATCH_SELECTOR))) unwrapElement(el);
|
|
283
|
+
|
|
284
|
+
root.normalize();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Состояние форматирования на выделении указанными тегами.
|
|
289
|
+
* `every` — отформатирован весь текст (подсветка кнопки инструмента),
|
|
290
|
+
* `some` — отформатирована хоть какая-то часть (доступность очистки).
|
|
291
|
+
* Обход прерывается на первом узле, решающем исход.
|
|
292
|
+
*/
|
|
293
|
+
function rangeFormatState(root: HTMLElement, range: Range, tags: string[], mode: "every" | "some"): boolean {
|
|
294
|
+
if (range.collapsed) {
|
|
295
|
+
const node = range.startContainer;
|
|
296
|
+
const probe = node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
|
|
297
|
+
return formatAncestor(probe, tags, root) !== null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
301
|
+
let found = false;
|
|
302
|
+
|
|
303
|
+
for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
|
|
304
|
+
if (!n.length || !range.intersectsNode(n)) continue;
|
|
305
|
+
|
|
306
|
+
const formatted = formatAncestor(n, tags, root) !== null;
|
|
307
|
+
if (mode === "some") {
|
|
308
|
+
if (formatted) return true;
|
|
309
|
+
} else if (!formatted) return false;
|
|
310
|
+
|
|
311
|
+
found = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// every: пустое выделение — не «отформатировано целиком»; some: ни одного форматированного узла
|
|
315
|
+
return mode === "every" && found;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Есть ли форматирование на выделении (или под кареткой) — для доступности кнопки очистки. */
|
|
319
|
+
export function hasFormatting(root: HTMLElement, range: Range): boolean {
|
|
320
|
+
return rangeFormatState(root, range, MATCH_TAG_NAMES, "some");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Есть ли форматирование хоть где-то в содержимом. */
|
|
324
|
+
export function hasAnyFormatting(root: HTMLElement): boolean {
|
|
325
|
+
return root.querySelector(MATCH_SELECTOR) !== null;
|
|
326
|
+
}
|
|
327
|
+
|
|
228
328
|
/**
|
|
229
329
|
* Вставляет текст в позицию каретки, оборачивая его в указанные форматы (режим набора).
|
|
230
330
|
* Каретка ставится сразу после вставленного текста; соседние одинаковые теги склеиваются.
|
|
@@ -253,15 +353,5 @@ export function insertFormattedText(root: HTMLElement, data: string, tools: Form
|
|
|
253
353
|
|
|
254
354
|
/** Активен ли формат инструмента на текущем выделении (для подсветки кнопки). */
|
|
255
355
|
export function isFormatActive(root: HTMLElement, range: Range, tool: FormatTool): boolean {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (range.collapsed) {
|
|
259
|
-
const node = range.startContainer;
|
|
260
|
-
const probe = node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
|
|
261
|
-
return formatAncestor(probe, tags, root) !== null;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const nodes = collectTextNodes(root, range, false);
|
|
265
|
-
if (!nodes.length) return false;
|
|
266
|
-
return nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
356
|
+
return rangeFormatState(root, range, FORMAT_TOOLS[tool].matchTags, "every");
|
|
267
357
|
}
|