@brandup/ui-richeditor 1.0.40 → 1.0.42
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 -2
- package/package.json +2 -2
- package/source/editing.ts +170 -13
- package/source/emoji.ts +127 -51
- package/source/format-config.ts +117 -3
- package/source/format.ts +18 -1
- package/source/index.ts +6 -1
- package/source/paragraphs.ts +79 -13
- package/source/richeditor.less +103 -10
- package/source/richeditor.ts +340 -18
- package/source/selection.ts +195 -29
- package/source/serialize.ts +479 -296
- package/source/toolbar.ts +172 -24
- package/svg/codeblock.svg +1 -0
- package/svg/mono.svg +3 -0
- package/svg/quote.svg +1 -0
- package/svg/spoiler.svg +1 -0
package/source/richeditor.ts
CHANGED
|
@@ -3,8 +3,13 @@ import "./richeditor.less"; // стили редактора и панели ф
|
|
|
3
3
|
import { DOM, UIElementBound } from "@brandup/ui";
|
|
4
4
|
import {
|
|
5
5
|
ALL_FORMAT_TOOLS,
|
|
6
|
+
BLOCK_TYPES,
|
|
7
|
+
DEFAULT_BLOCK,
|
|
6
8
|
HOTKEY_TOOLS,
|
|
7
9
|
activeFormats,
|
|
10
|
+
blockAt,
|
|
11
|
+
blockTypeOf,
|
|
12
|
+
blocksInRange,
|
|
8
13
|
clearAllFormat,
|
|
9
14
|
clearFormat,
|
|
10
15
|
defaultFormatMarkers,
|
|
@@ -17,6 +22,9 @@ import {
|
|
|
17
22
|
documentSelection,
|
|
18
23
|
innerSelection,
|
|
19
24
|
mapCharOffset,
|
|
25
|
+
normalizeBlockTypes,
|
|
26
|
+
editorText,
|
|
27
|
+
charLength,
|
|
20
28
|
preserveCaret,
|
|
21
29
|
normalizeParagraphs,
|
|
22
30
|
normalizeWhitespace,
|
|
@@ -24,6 +32,7 @@ import {
|
|
|
24
32
|
selectionCharBounds,
|
|
25
33
|
serialize,
|
|
26
34
|
toggleFormat,
|
|
35
|
+
type BlockType,
|
|
27
36
|
type EditorAction,
|
|
28
37
|
type FormatMarkers,
|
|
29
38
|
type FormatStorage,
|
|
@@ -31,6 +40,8 @@ import {
|
|
|
31
40
|
type FormatTool,
|
|
32
41
|
} from "./format";
|
|
33
42
|
import {
|
|
43
|
+
applyBlocks,
|
|
44
|
+
atBlockStart,
|
|
34
45
|
buildParagraphs,
|
|
35
46
|
caretToEnd,
|
|
36
47
|
expandRangeToWords,
|
|
@@ -47,10 +58,15 @@ import { formatToolbar, type ToolbarButton } from "./toolbar";
|
|
|
47
58
|
export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost, type ToolbarButton } from "./toolbar";
|
|
48
59
|
|
|
49
60
|
export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
|
|
61
|
+
// Содержимое временно невыделяемо: по странице тянут выделение, начатое вне редактора (см. __holdSelectable).
|
|
62
|
+
export const UNSELECTABLE_CLASS = "unselectable";
|
|
50
63
|
export const CHANGE_EVENT = "richeditor-change";
|
|
51
64
|
|
|
52
65
|
const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Escape"];
|
|
53
66
|
|
|
67
|
+
// Код — единственное, что есть и инструментом, и типом блока: имя одно на оба (см. applyCode).
|
|
68
|
+
const CODE = "code";
|
|
69
|
+
|
|
54
70
|
// нативные правки (печать/удаление), состояние до которых запоминаем для собственного undo;
|
|
55
71
|
// вставка/перетаскивание и Enter обрабатываются отдельно, undo/redo — на keydown
|
|
56
72
|
const NATIVE_EDIT_TYPES = new Set([
|
|
@@ -102,6 +118,11 @@ export interface RichEditorOptions {
|
|
|
102
118
|
multiline?: boolean;
|
|
103
119
|
/** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
|
|
104
120
|
paragraph?: ParagraphMode;
|
|
121
|
+
/**
|
|
122
|
+
* Типы блоков в многострочном режиме: цитата, блок кода (по умолчанию только обычный текст).
|
|
123
|
+
* Обычный текст в наборе есть всегда — в него блок возвращают.
|
|
124
|
+
*/
|
|
125
|
+
blocks?: BlockType[];
|
|
105
126
|
/** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
|
|
106
127
|
readonly?: boolean;
|
|
107
128
|
/** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
|
|
@@ -140,6 +161,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
140
161
|
readonly formatMarkers: FormatMarkers;
|
|
141
162
|
readonly multiline: boolean;
|
|
142
163
|
readonly paragraph: ParagraphMode;
|
|
164
|
+
readonly blockTypes: BlockType[];
|
|
143
165
|
readonly toolbarContainer: HTMLElement | null;
|
|
144
166
|
readonly toolbarButtons: ToolbarButton[];
|
|
145
167
|
|
|
@@ -147,6 +169,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
147
169
|
private __abort = new AbortController();
|
|
148
170
|
private __pendingFormats = new Set<FormatTool>();
|
|
149
171
|
private __hasInputClick = false;
|
|
172
|
+
private __editHolds = 0; // правка продолжается в окне хоста — см. holdEditing
|
|
173
|
+
// Компонент снят. Удержание правки переживает снятие (окно хоста закрывается позже), и по его
|
|
174
|
+
// снятию трогать содержимое уже нельзя — редактора нет.
|
|
175
|
+
private __disposed = false;
|
|
150
176
|
private __changeTimer = 0; // отложенное change по печати — см. __emitChange/flushChange
|
|
151
177
|
// Окно редактируемого элемента, взятое при создании. Таймер отложенного change переживает
|
|
152
178
|
// снятие компонента и гасится в destroy, а тот случается когда угодно — к этому моменту
|
|
@@ -180,6 +206,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
180
206
|
this.formatMarkers = Object.assign(defaultFormatMarkers(), options.markers);
|
|
181
207
|
this.multiline = multiline;
|
|
182
208
|
this.paragraph = options.paragraph ?? "block";
|
|
209
|
+
// Блоки — часть многострочной модели: в однострочном режиме верхнего уровня нет вовсе,
|
|
210
|
+
// а в readonly их не переключить, но разбор и показ значения обязаны работать и там.
|
|
211
|
+
this.blockTypes = multiline ? normalizeBlockTypes(options.blocks) : [DEFAULT_BLOCK];
|
|
183
212
|
this.toolbarContainer = options.toolbarContainer ?? null;
|
|
184
213
|
// кнопки хоста живут и без форматирования, но не в readonly — там панели нет вовсе
|
|
185
214
|
this.toolbarButtons = readonly ? [] : (options.buttons ?? []);
|
|
@@ -190,7 +219,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
190
219
|
// при этом остаётся возможность фокуса, выделения и копирования
|
|
191
220
|
editable.contentEditable = "true";
|
|
192
221
|
|
|
193
|
-
if (options.placeholder != null) editable.
|
|
222
|
+
if (options.placeholder != null) editable.dataset.placeholder = options.placeholder;
|
|
194
223
|
if (multiline) editable.classList.add("multiline");
|
|
195
224
|
if (readonly) editable.classList.add("readonly");
|
|
196
225
|
|
|
@@ -226,7 +255,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
226
255
|
|
|
227
256
|
getValue(): string {
|
|
228
257
|
// multiline → модель абзацев (<p>/\n\n) и мягких переносов (<br>/\n)
|
|
229
|
-
return serialize(
|
|
258
|
+
return serialize(
|
|
259
|
+
this.editable,
|
|
260
|
+
this.__valueStorage,
|
|
261
|
+
this.__valueTools,
|
|
262
|
+
this.formatMarkers,
|
|
263
|
+
this.multiline,
|
|
264
|
+
this.blockTypes,
|
|
265
|
+
this.__blockParagraphs
|
|
266
|
+
);
|
|
230
267
|
}
|
|
231
268
|
|
|
232
269
|
setValue(value: string): void {
|
|
@@ -296,8 +333,65 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
296
333
|
return this.editable.textContent?.length ?? 0;
|
|
297
334
|
}
|
|
298
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Фокус в поле. Каретку, стоящую в содержимом, не двигает: правку продолжают там, где её
|
|
338
|
+
* прервали. Своей защиты обработчика фокуса для этого мало — браузер успевает поставить
|
|
339
|
+
* при фокусе собственную каретку (в начало содержимого), и она выглядит как «уже стоявшая».
|
|
340
|
+
*/
|
|
299
341
|
focus(): void {
|
|
342
|
+
const bounds = this.caretSnapshot();
|
|
343
|
+
|
|
344
|
+
if (bounds) this.restoreCaret(bounds);
|
|
345
|
+
else this.editable.focus();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Снимок каретки текстовыми смещениями — пережить работу окна хоста. Пока оно открыто, фокус
|
|
350
|
+
* уходит туда, а с ним и выделение документа: в окне могут быть свои поля ввода, и каретка
|
|
351
|
+
* встанет уже в них. Снимать нужно до открытия, возвращать — через {@link restoreCaret}.
|
|
352
|
+
*
|
|
353
|
+
* Смещения остаются верными, потому что на это время содержимое не меняется (см. {@link holdEditing}).
|
|
354
|
+
*/
|
|
355
|
+
caretSnapshot(): [number, number] | null {
|
|
356
|
+
const selection = this.selection;
|
|
357
|
+
|
|
358
|
+
return selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Возвращает в поле фокус и каретку по снимку {@link caretSnapshot}. */
|
|
362
|
+
restoreCaret(bounds: [number, number]): void {
|
|
300
363
|
this.editable.focus();
|
|
364
|
+
|
|
365
|
+
const selection = documentSelection(this.editable);
|
|
366
|
+
if (selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Сообщает, что правка продолжается снаружи: хост открыл своё окно, фокус ушёл туда, но ввод
|
|
371
|
+
* не закончен. Пока удержание живо, `blur` не считается концом ввода и содержимое не приводится
|
|
372
|
+
* к нормальному виду — иначе пробел на границе каретки был бы срезан как краевой, а сама каретка
|
|
373
|
+
* съехала бы на пересчёте смещений, и текст из окна встал бы не туда и вплотную к соседнему слову.
|
|
374
|
+
*
|
|
375
|
+
* Возвращает снятие удержания: повторные вызовы ничего не делают. Если к этому моменту фокус
|
|
376
|
+
* в поле не вернулся, содержимое доводится сразу — другого `blur` уже не будет.
|
|
377
|
+
*/
|
|
378
|
+
holdEditing(): () => void {
|
|
379
|
+
this.__editHolds++;
|
|
380
|
+
|
|
381
|
+
let released = false;
|
|
382
|
+
|
|
383
|
+
return () => {
|
|
384
|
+
if (released) return;
|
|
385
|
+
released = true;
|
|
386
|
+
|
|
387
|
+
this.__editHolds--;
|
|
388
|
+
if (this.__editHolds > 0 || this.__disposed) return;
|
|
389
|
+
|
|
390
|
+
if (this.editable.ownerDocument.activeElement !== this.editable) {
|
|
391
|
+
this.__normalize(true);
|
|
392
|
+
this.flushChange();
|
|
393
|
+
}
|
|
394
|
+
};
|
|
301
395
|
}
|
|
302
396
|
|
|
303
397
|
onChange(handler: (e: RichEditorChangeData) => void) {
|
|
@@ -352,11 +446,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
352
446
|
};
|
|
353
447
|
}
|
|
354
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Доступен ли инструмент в текущем месте. В коде разметки нет: и моноширинный, и блок кода
|
|
451
|
+
* оставляют написанное буквальным, поэтому остальные инструменты там выключены — иначе
|
|
452
|
+
* форматирование ставилось бы только чтобы пропасть при сохранении.
|
|
453
|
+
*/
|
|
454
|
+
isToolEnabled(tool: FormatTool): boolean {
|
|
455
|
+
if (this.readonly || !this.formatTools.includes(tool)) return false;
|
|
456
|
+
if (tool === CODE) return true;
|
|
457
|
+
|
|
458
|
+
return this.currentBlock !== CODE && !this.isToolActive(CODE);
|
|
459
|
+
}
|
|
460
|
+
|
|
355
461
|
/** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
|
|
356
462
|
applyFormat(tool: FormatTool): void {
|
|
357
|
-
if (!this.
|
|
463
|
+
if (!this.isToolEnabled(tool)) return;
|
|
358
464
|
|
|
359
|
-
|
|
465
|
+
let target = this.__formatTarget();
|
|
360
466
|
if (!target) return;
|
|
361
467
|
|
|
362
468
|
if (target.range.collapsed) {
|
|
@@ -371,12 +477,126 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
371
477
|
this.__pendingFormats.clear();
|
|
372
478
|
|
|
373
479
|
this.__history?.record("op");
|
|
480
|
+
|
|
481
|
+
// Код не уживается с другой разметкой: значение берёт из него голый текст. Снимаем всё,
|
|
482
|
+
// что было на этом куске, и только потом оборачиваем — иначе прежнее форматирование
|
|
483
|
+
// осталось бы снаружи кода и в поле выглядело бы рабочим.
|
|
484
|
+
if (
|
|
485
|
+
tool === CODE &&
|
|
486
|
+
!isFormatActive(this.editable, target.range, CODE) &&
|
|
487
|
+
hasFormatting(this.editable, target.range)
|
|
488
|
+
) {
|
|
489
|
+
clearFormat(this.editable, target.range, target.selection, target.original());
|
|
490
|
+
|
|
491
|
+
// правка перестроила узлы — прежний диапазон указывает в никуда
|
|
492
|
+
target = this.__formatTarget();
|
|
493
|
+
if (!target) return;
|
|
494
|
+
}
|
|
495
|
+
|
|
374
496
|
toggleFormat(this.editable, target.range, tool, target.selection, target.original());
|
|
375
497
|
|
|
376
498
|
this.__emitChange();
|
|
377
499
|
formatToolbar.refresh();
|
|
378
500
|
}
|
|
379
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Тип блока под кареткой. Без выделения (или вне блоков) — тип по умолчанию: именно им
|
|
504
|
+
* станет то, что сейчас наберут.
|
|
505
|
+
*/
|
|
506
|
+
get currentBlock(): BlockType {
|
|
507
|
+
const selection = this.selection;
|
|
508
|
+
if (!selection) return DEFAULT_BLOCK;
|
|
509
|
+
|
|
510
|
+
const block = blockAt(this.editable, selection.getRangeAt(0).startContainer);
|
|
511
|
+
|
|
512
|
+
return blockTypeOf(block) ?? DEFAULT_BLOCK;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Переключить тип блоков под выделением. Повторное применение того же типа возвращает
|
|
517
|
+
* блок к обычному тексту — иначе выйти из цитаты можно было бы только через другую кнопку.
|
|
518
|
+
*/
|
|
519
|
+
applyBlock(type: BlockType): void {
|
|
520
|
+
if (!this.multiline || this.readonly || !this.blockTypes.includes(type)) return;
|
|
521
|
+
|
|
522
|
+
const selection = this.selection;
|
|
523
|
+
if (!selection) return;
|
|
524
|
+
|
|
525
|
+
const range = selection.getRangeAt(0);
|
|
526
|
+
const target = this.currentBlock === type ? DEFAULT_BLOCK : type;
|
|
527
|
+
|
|
528
|
+
// нечего менять — не пишем в историю пустой шаг и не трогаем выделение.
|
|
529
|
+
// В пустом редакторе блока ещё нет, но завести его можно (кроме типа по умолчанию —
|
|
530
|
+
// им пустое поле и так является).
|
|
531
|
+
const blocks = blocksInRange(this.editable, range);
|
|
532
|
+
const nothing = this.editable.firstChild
|
|
533
|
+
? !blocks.length || blocks.every((block) => blockTypeOf(block) === target)
|
|
534
|
+
: target === DEFAULT_BLOCK;
|
|
535
|
+
|
|
536
|
+
if (nothing) return;
|
|
537
|
+
|
|
538
|
+
this.__history?.record("op");
|
|
539
|
+
|
|
540
|
+
// Смена тега переносит содержимое в новый элемент — живые границы выделения этого
|
|
541
|
+
// не переживают, поэтому держим каретку по текстовым смещениям.
|
|
542
|
+
const bounds = selectionCharBounds(this.editable, range);
|
|
543
|
+
const { created } = applyBlocks(this.editable, range, target);
|
|
544
|
+
|
|
545
|
+
// Разделение блока добавило границы, а они в смещениях считаются — прежние уже не те.
|
|
546
|
+
// Выделяем то, что стало блоком: заодно видно, к каким строкам правка и относилась.
|
|
547
|
+
// В пустом блоке выделять нечего — там только каретка.
|
|
548
|
+
if (created) {
|
|
549
|
+
const inside = this.editable.ownerDocument.createRange();
|
|
550
|
+
inside.selectNodeContents(created);
|
|
551
|
+
if (!(created.textContent ?? "")) inside.collapse(true);
|
|
552
|
+
|
|
553
|
+
selection.removeAllRanges();
|
|
554
|
+
selection.addRange(inside);
|
|
555
|
+
} else {
|
|
556
|
+
restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
this.__emitChange();
|
|
560
|
+
formatToolbar.refresh();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Код в любом виде: моноширинный на выделении или блок кода под кареткой. */
|
|
564
|
+
isCodeActive(): boolean {
|
|
565
|
+
return this.currentBlock === CODE || this.isToolActive(CODE);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Единая правка кода — то, что в мессенджерах делает одна кнопка. Вид выбирается по объёму
|
|
570
|
+
* правки: выделена одна строка или её часть — моноширинный, больше одной строки — блок.
|
|
571
|
+
*
|
|
572
|
+
* Блоком становится и правка без выделения: моноширинным там делать нечего, а иначе блок
|
|
573
|
+
* оказался бы недостижим — в пустом поле не выделить строки, которых ещё нет.
|
|
574
|
+
*
|
|
575
|
+
* Повторное применение снимает тот вид, который сейчас и включён.
|
|
576
|
+
*/
|
|
577
|
+
applyCode(): void {
|
|
578
|
+
const inline = this.formatTools.includes(CODE);
|
|
579
|
+
const block = this.multiline && this.blockTypes.includes(CODE);
|
|
580
|
+
if (!inline && !block) return;
|
|
581
|
+
|
|
582
|
+
if (block && this.currentBlock === CODE) return this.applyBlock(CODE); // выйти из блока
|
|
583
|
+
if (inline && this.isToolActive(CODE)) return this.applyFormat(CODE); // снять моноширинный
|
|
584
|
+
|
|
585
|
+
const range = this.selection?.getRangeAt(0);
|
|
586
|
+
const part = !!range && !range.collapsed && !this.__coversLines(range);
|
|
587
|
+
|
|
588
|
+
if (inline && (part || !block)) this.applyFormat(CODE);
|
|
589
|
+
else if (block) this.applyBlock(CODE);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Выделено больше одной строки: захвачено несколько блоков либо мягкий перенос внутри.
|
|
594
|
+
* Одна строка — даже целиком — остаётся куском текста: код на ней это моноширинный.
|
|
595
|
+
*/
|
|
596
|
+
private __coversLines(range: Range): boolean {
|
|
597
|
+
return blocksInRange(this.editable, range).length > 1 || !!range.cloneContents().querySelector("br");
|
|
598
|
+
}
|
|
599
|
+
|
|
380
600
|
/**
|
|
381
601
|
* Снимает всё форматирование с выделения; без выделения — со слова под кареткой
|
|
382
602
|
* (как и применение формата). Режим набора сбрасывается.
|
|
@@ -511,14 +731,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
511
731
|
}
|
|
512
732
|
|
|
513
733
|
override destroy(): void {
|
|
734
|
+
this.__disposed = true;
|
|
514
735
|
this.flushChange(); // хост не должен остаться с устаревшей копией значения
|
|
515
736
|
this.__abort.abort();
|
|
516
737
|
formatToolbar.detach(this);
|
|
517
738
|
|
|
518
739
|
// элемент передан хостом — не удаляем его, только снимаем оформление редактора
|
|
519
|
-
this.editable.classList.remove(ROOT_CLASS, "multiline", "readonly", "focused");
|
|
740
|
+
this.editable.classList.remove(ROOT_CLASS, UNSELECTABLE_CLASS, "multiline", "readonly", "focused");
|
|
520
741
|
this.editable.removeAttribute("contenteditable");
|
|
521
|
-
this.editable.
|
|
742
|
+
delete this.editable.dataset.placeholder;
|
|
522
743
|
|
|
523
744
|
super.destroy();
|
|
524
745
|
}
|
|
@@ -529,20 +750,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
529
750
|
DOM.empty(this.editable);
|
|
530
751
|
if (!value) return;
|
|
531
752
|
|
|
532
|
-
// multiline → <p>-абзацы; single-line → инлайновое
|
|
533
|
-
|
|
753
|
+
// multiline → <p>-абзацы; single-line → инлайновое содержимое. Блоки разбираются и в режиме
|
|
754
|
+
// мягких переносов: цитата и код узнаются по собственной разметке, а не по пустой строке.
|
|
755
|
+
const blocks = this.blockTypes.length > 1;
|
|
756
|
+
const paragraphs = this.__blockParagraphs || (this.multiline && blocks);
|
|
534
757
|
|
|
535
758
|
this.editable.innerHTML = deserialize(
|
|
536
759
|
value,
|
|
537
760
|
this.__valueStorage,
|
|
538
761
|
this.__valueTools,
|
|
539
762
|
this.formatMarkers,
|
|
540
|
-
paragraphs
|
|
763
|
+
paragraphs,
|
|
764
|
+
this.blockTypes,
|
|
765
|
+
this.__blockParagraphs
|
|
541
766
|
);
|
|
542
767
|
|
|
543
768
|
// в break инлайновое содержимое оборачивается в единственный абзац — модель абзацев
|
|
544
769
|
// нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
|
|
545
|
-
if (this.multiline && !
|
|
770
|
+
if (this.multiline && !this.__blockParagraphs) ensureParagraphs(this.editable);
|
|
546
771
|
}
|
|
547
772
|
|
|
548
773
|
/**
|
|
@@ -595,14 +820,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
595
820
|
const bounds = selection ? selectionCharBounds(this.editable, selection.getRangeAt(0)) : null;
|
|
596
821
|
|
|
597
822
|
const before = this.editable.innerHTML;
|
|
598
|
-
|
|
823
|
+
// текст в координатах каретки (с концами строк) — иначе смещения разъедутся на число строк
|
|
824
|
+
const textBefore = editorText(this.editable);
|
|
599
825
|
normalizeWhitespace(this.editable);
|
|
600
826
|
if (this.multiline) normalizeParagraphs(this.editable);
|
|
601
827
|
if (this.editable.innerHTML === before) return;
|
|
602
828
|
|
|
603
829
|
if (bounds && selection) {
|
|
604
830
|
// схлопнутые пробелы сдвигают смещения — переносим их на новый текст
|
|
605
|
-
const textAfter = this.editable
|
|
831
|
+
const textAfter = editorText(this.editable);
|
|
606
832
|
const start = mapCharOffset(textBefore, textAfter, bounds[0]);
|
|
607
833
|
const end = bounds[1] === bounds[0] ? start : mapCharOffset(textBefore, textAfter, bounds[1]);
|
|
608
834
|
restoreSelection(this.editable, start, end, selection);
|
|
@@ -613,6 +839,47 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
613
839
|
|
|
614
840
|
// --- обработчики ---
|
|
615
841
|
|
|
842
|
+
/**
|
|
843
|
+
* Изоляция выделения: протяжка, начатая вне редактора, не должна затягивать его содержимое —
|
|
844
|
+
* текст сообщения красился бы как часть страницы.
|
|
845
|
+
*
|
|
846
|
+
* Правим не выделение, а выделяемость: пока идёт чужая протяжка, содержимое редактора
|
|
847
|
+
* невыделяемо, и браузер обходит его сам. Обрезать выделение из кода нельзя — оно в документе
|
|
848
|
+
* одно, и когда протяжка проходит мимо редактора, оба её конца снаружи: середину из одного
|
|
849
|
+
* диапазона не вырезать.
|
|
850
|
+
*/
|
|
851
|
+
private __holdSelectable(target: EventTarget | null) {
|
|
852
|
+
// Нажали в самом редакторе — или он в работе: нажатие по панели и кнопкам хоста приходит
|
|
853
|
+
// мимо него, но выделение страницы тут ни при чём, а запрет заодно лишил бы поле
|
|
854
|
+
// редактируемости до следующего клика внутрь. Фокус на этот момент ещё не ушёл.
|
|
855
|
+
const own =
|
|
856
|
+
this.editable.contains(target as Node | null) ||
|
|
857
|
+
this.editable.ownerDocument.activeElement === this.editable;
|
|
858
|
+
|
|
859
|
+
this.editable.classList.toggle(UNSELECTABLE_CLASS, !own);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Снимает запрет, когда выделение перестало задевать редактор.
|
|
864
|
+
*
|
|
865
|
+
* Просто по отпусканию кнопки снимать нельзя: диапазон остаётся протянутым через редактор,
|
|
866
|
+
* и стоит вернуть содержимому выделяемость, как оно тут же оказывается выделенным — будто
|
|
867
|
+
* запрета и не было. Такой запрет держится до следующего нажатия: внутри редактора оно
|
|
868
|
+
* снимет запрет, снаружи — начнёт новую протяжку.
|
|
869
|
+
*/
|
|
870
|
+
private __releaseSelectable() {
|
|
871
|
+
if (!this.editable.classList.contains(UNSELECTABLE_CLASS)) return;
|
|
872
|
+
|
|
873
|
+
const selection = documentSelection(this.editable);
|
|
874
|
+
const across =
|
|
875
|
+
!!selection &&
|
|
876
|
+
selection.rangeCount > 0 &&
|
|
877
|
+
!selection.isCollapsed &&
|
|
878
|
+
selection.getRangeAt(0).intersectsNode(this.editable);
|
|
879
|
+
|
|
880
|
+
if (!across) this.editable.classList.remove(UNSELECTABLE_CLASS);
|
|
881
|
+
}
|
|
882
|
+
|
|
616
883
|
private __initEvents() {
|
|
617
884
|
const { signal } = this.__abort;
|
|
618
885
|
const editable = this.editable;
|
|
@@ -621,6 +888,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
621
888
|
// dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
|
|
622
889
|
this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
|
|
623
890
|
|
|
891
|
+
// Выделение страницы не должно затягивать содержимое редактора — см. __holdSelectable.
|
|
892
|
+
// Слушаем на документе и в фазе перехвата: протяжка начинается где угодно, а обработчик
|
|
893
|
+
// по пути может погасить всплытие.
|
|
894
|
+
const doc = editable.ownerDocument;
|
|
895
|
+
doc.addEventListener("mousedown", (e) => this.__holdSelectable(e.target), { signal, capture: true });
|
|
896
|
+
// По изменению выделения проверять нельзя: пока запрет действует, выделение до редактора
|
|
897
|
+
// не доходит — проверка увидела бы «не задевает» и сняла запрет сама. Только по концу протяжки.
|
|
898
|
+
doc.addEventListener("mouseup", () => this.__releaseSelectable(), { signal, capture: true });
|
|
899
|
+
|
|
624
900
|
editable.addEventListener(
|
|
625
901
|
"mousedown",
|
|
626
902
|
() => {
|
|
@@ -635,6 +911,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
635
911
|
() => {
|
|
636
912
|
this.element.classList.add("focused");
|
|
637
913
|
|
|
914
|
+
// Пришли править — держать запрет выделения не за чем, а с ним поле осталось бы
|
|
915
|
+
// нередактируемым. Мышью его снимает нажатие, но фокус берут и клавишей, и из кода.
|
|
916
|
+
this.element.classList.remove(UNSELECTABLE_CLASS);
|
|
917
|
+
|
|
638
918
|
// нужна ли панель этому редактору, решает она сама — иначе условие пришлось бы
|
|
639
919
|
// держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
|
|
640
920
|
formatToolbar.attach(this);
|
|
@@ -655,6 +935,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
655
935
|
this.element.classList.remove("focused");
|
|
656
936
|
formatToolbar.detach(this);
|
|
657
937
|
|
|
938
|
+
// правку продолжают в окне хоста — этот blur не конец ввода, содержимое трогать нельзя
|
|
939
|
+
if (this.__editHolds > 0) {
|
|
940
|
+
this.flushChange();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
658
944
|
// удаляем висящий BR, чтобы появился placeholder
|
|
659
945
|
if (editable.firstChild?.nodeName === "BR") DOM.empty(editable);
|
|
660
946
|
|
|
@@ -762,16 +1048,37 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
762
1048
|
|
|
763
1049
|
// В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
|
|
764
1050
|
// В режиме break наоборот: Enter переносит строку, как в мессенджерах, а абзац
|
|
765
|
-
// набирается двумя переносами.
|
|
1051
|
+
// набирается двумя переносами. Внутри блока правило берётся у его типа: в цитате
|
|
1052
|
+
// и в коде Enter переносит строку, иначе одну цитату было бы не набрать.
|
|
766
1053
|
const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
|
|
767
|
-
const
|
|
1054
|
+
const current = this.currentBlock;
|
|
1055
|
+
const breaks =
|
|
1056
|
+
current === DEFAULT_BLOCK ? this.paragraph === "break" : BLOCK_TYPES[current].enter === "break";
|
|
1057
|
+
const soft = breaks ? !withModifier : withModifier;
|
|
768
1058
|
|
|
769
1059
|
this.__history?.record("op");
|
|
1060
|
+
// Из блока выходят тем же нажатием, что делит его: продолжать цитату или код
|
|
1061
|
+
// новым таким же блоком незачем — для этого достаточно переноса строки.
|
|
770
1062
|
if (soft) insertSoftBreak(this.editable);
|
|
771
|
-
else insertParagraph(this.editable);
|
|
1063
|
+
else insertParagraph(this.editable, DEFAULT_BLOCK);
|
|
772
1064
|
|
|
773
1065
|
this.__clearPendingFormats();
|
|
774
1066
|
this.__emitChange();
|
|
1067
|
+
formatToolbar.refresh();
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Backspace в начале блока возвращает его к обычному тексту — так из цитаты или кода
|
|
1072
|
+
// выходят там же, где в них вошли, не трогая содержимого. Дальше по тексту Backspace
|
|
1073
|
+
// работает как обычно.
|
|
1074
|
+
if (e.key === "Backspace" && !this.readonly && this.multiline) {
|
|
1075
|
+
const block = this.currentBlock;
|
|
1076
|
+
const selection = block === DEFAULT_BLOCK ? null : this.selection;
|
|
1077
|
+
|
|
1078
|
+
if (selection && atBlockStart(this.editable, selection.getRangeAt(0))) {
|
|
1079
|
+
e.preventDefault();
|
|
1080
|
+
this.applyBlock(block); // тот же тип — переключение вернёт обычный текст
|
|
1081
|
+
}
|
|
775
1082
|
}
|
|
776
1083
|
}
|
|
777
1084
|
|
|
@@ -822,7 +1129,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
822
1129
|
// Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего —
|
|
823
1130
|
// тогда вызывающий откатывается на простую вставку.
|
|
824
1131
|
private __pasteHtml(html: string, selection: Selection): boolean {
|
|
825
|
-
return this.__insertPasted(
|
|
1132
|
+
return this.__insertPasted(
|
|
1133
|
+
sanitizePastedHtml(html, this.formatTools, this.formatMarkers, this.blockTypes),
|
|
1134
|
+
selection
|
|
1135
|
+
);
|
|
826
1136
|
}
|
|
827
1137
|
|
|
828
1138
|
/**
|
|
@@ -844,7 +1154,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
844
1154
|
let caret: number;
|
|
845
1155
|
|
|
846
1156
|
if (this.multiline) {
|
|
847
|
-
|
|
1157
|
+
// длина вставки в координатах каретки: текст, переносы внутри абзацев и границы между
|
|
1158
|
+
// ними (первый абзац вливается в текущий, поэтому границ на одну меньше)
|
|
1159
|
+
caret = start + paras.reduce((length, p) => length + charLength(p), paras.length - 1);
|
|
848
1160
|
|
|
849
1161
|
insertPastedParagraphs(this.editable, paras, range);
|
|
850
1162
|
ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
|
|
@@ -920,7 +1232,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
920
1232
|
const selection = this.selection;
|
|
921
1233
|
if (!selection) return;
|
|
922
1234
|
|
|
923
|
-
|
|
1235
|
+
// Замена выделенного не должна терять его оформление. Физически вставка часто оказывается
|
|
1236
|
+
// снаружи тега: границы выделения восстанавливаются по текстовым смещениям, а смещение на
|
|
1237
|
+
// стыке узлов достаётся соседнему — тому, что вне тега; сам же тег после удаления текста
|
|
1238
|
+
// остаётся пустым и убирается. Поэтому форматы, покрывавшие выделение целиком, переносим
|
|
1239
|
+
// на вставляемый текст явно. Каретке без выделения это не нужно: там вставка и так попадает
|
|
1240
|
+
// внутрь тега, а на его границе принадлежность неочевидна.
|
|
1241
|
+
const range = selection.getRangeAt(0);
|
|
1242
|
+
const inherited = range.collapsed ? [] : activeFormats(this.editable, range, this.formatTools);
|
|
1243
|
+
const formats = new Set([...this.__pendingFormats, ...inherited]);
|
|
1244
|
+
|
|
1245
|
+
insertFormattedText(this.editable, data, Array.from(formats), selection);
|
|
924
1246
|
|
|
925
1247
|
// В пустом поле абзацев ещё нет, и каретке некуда встать внутрь — вставка из кода
|
|
926
1248
|
// (смайлик, переменная) ложится прямо в редактор. Приводим к модели абзацев тем же
|