@brandup/ui-richeditor 1.0.43 → 1.0.45
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 +30 -11
- package/package.json +3 -3
- package/source/editing.ts +84 -18
- package/source/format-config.ts +21 -5
- package/source/format.ts +2 -0
- package/source/history.ts +10 -5
- package/source/paragraphs.ts +53 -7
- package/source/richeditor.ts +141 -23
- package/source/selection.ts +42 -5
- package/source/serialize.ts +143 -52
- package/source/toolbar.ts +55 -24
package/source/richeditor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import "./richeditor.less"; // стили редактора и панели форматирования
|
|
2
2
|
|
|
3
3
|
import { DOM, UIElementBound } from "@brandup/ui";
|
|
4
|
+
import { IS_TOUCH_DEVICE } from "@brandup/ui-kit";
|
|
4
5
|
import {
|
|
5
6
|
ALL_FORMAT_TOOLS,
|
|
6
7
|
BLOCK_TYPES,
|
|
@@ -20,12 +21,14 @@ import {
|
|
|
20
21
|
insertFormattedText,
|
|
21
22
|
isFormatActive,
|
|
22
23
|
documentSelection,
|
|
24
|
+
emptyFormatAt,
|
|
23
25
|
innerSelection,
|
|
24
26
|
mapCharOffset,
|
|
25
27
|
normalizeBlockTypes,
|
|
26
28
|
editorText,
|
|
27
29
|
charLength,
|
|
28
30
|
preserveCaret,
|
|
31
|
+
mergeAdjacentBlocks,
|
|
29
32
|
normalizeParagraphs,
|
|
30
33
|
normalizeWhitespace,
|
|
31
34
|
restoreSelection,
|
|
@@ -119,10 +122,19 @@ export interface RichEditorOptions {
|
|
|
119
122
|
/** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
|
|
120
123
|
paragraph?: ParagraphMode;
|
|
121
124
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
125
|
+
* Block types of the multiline mode: quote, code block (all of them by default). A field that
|
|
126
|
+
* has no use for them is limited by an empty list. Plain text is always in the set — a block
|
|
127
|
+
* is turned back into it.
|
|
124
128
|
*/
|
|
125
129
|
blocks?: BlockType[];
|
|
130
|
+
/**
|
|
131
|
+
* Держать ли фокус в поле, пока над ним открыта панель смайликов. По умолчанию держим —
|
|
132
|
+
* каретка на виду, и видно, куда встанет символ; на сенсорном устройстве нет: там фокус
|
|
133
|
+
* держит на экране клавиатуру, и она закрывает собой саму панель.
|
|
134
|
+
*
|
|
135
|
+
* Модального окна это не касается: правка идёт в нём, и фокус поле отдаёт всегда.
|
|
136
|
+
*/
|
|
137
|
+
keepFocus?: boolean;
|
|
126
138
|
/** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
|
|
127
139
|
readonly?: boolean;
|
|
128
140
|
/** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
|
|
@@ -162,6 +174,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
162
174
|
readonly multiline: boolean;
|
|
163
175
|
readonly paragraph: ParagraphMode;
|
|
164
176
|
readonly blockTypes: BlockType[];
|
|
177
|
+
readonly keepFocus: boolean;
|
|
165
178
|
readonly toolbarContainer: HTMLElement | null;
|
|
166
179
|
readonly toolbarButtons: ToolbarButton[];
|
|
167
180
|
|
|
@@ -170,6 +183,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
170
183
|
private __pendingFormats = new Set<FormatTool>();
|
|
171
184
|
private __hasInputClick = false;
|
|
172
185
|
private __editHolds = 0; // правка продолжается в окне хоста — см. holdEditing
|
|
186
|
+
// Каретка, снятая при отпускании фокуса: без фокуса браузер может убрать и выделение,
|
|
187
|
+
// а вставке из попапа нужно место — см. releaseFocus.
|
|
188
|
+
private __detachedCaret: [number, number] | null = null;
|
|
189
|
+
private __emojiHold: (() => void) | null = null; // правка придержана на время панели смайликов
|
|
190
|
+
private __releasingFocus = false; // фокус снимаем сами, а не уходят из поля — см. releaseFocus
|
|
173
191
|
// Компонент снят. Удержание правки переживает снятие (окно хоста закрывается позже), и по его
|
|
174
192
|
// снятию трогать содержимое уже нельзя — редактора нет.
|
|
175
193
|
private __disposed = false;
|
|
@@ -209,6 +227,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
209
227
|
// Блоки — часть многострочной модели: в однострочном режиме верхнего уровня нет вовсе,
|
|
210
228
|
// а в readonly их не переключить, но разбор и показ значения обязаны работать и там.
|
|
211
229
|
this.blockTypes = multiline ? normalizeBlockTypes(options.blocks) : [DEFAULT_BLOCK];
|
|
230
|
+
this.keepFocus = options.keepFocus ?? !IS_TOUCH_DEVICE;
|
|
212
231
|
this.toolbarContainer = options.toolbarContainer ?? null;
|
|
213
232
|
// кнопки хоста живут и без форматирования, но не в readonly — там панели нет вовсе
|
|
214
233
|
this.toolbarButtons = readonly ? [] : (options.buttons ?? []);
|
|
@@ -283,7 +302,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
283
302
|
* например, кнопкой панели, которая не должна забирать фокус у редактора.
|
|
284
303
|
*/
|
|
285
304
|
insertText(text: string): void {
|
|
286
|
-
if (this.readonly || !text
|
|
305
|
+
if (this.readonly || !text) return;
|
|
306
|
+
|
|
307
|
+
// фокус мог быть отпущен на время окна хоста — вместе с ним могло уйти и выделение
|
|
308
|
+
if (!this.selection) this.__reviveCaret();
|
|
309
|
+
if (!this.selection) return;
|
|
287
310
|
|
|
288
311
|
// вставка — такой же ввод, как с клавиатуры, поэтому проходит через filterChar хоста
|
|
289
312
|
// (ограничения по типу поля и длине). Обход символов идёт по кодпойнтам, чтобы эмодзи
|
|
@@ -313,19 +336,32 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
313
336
|
const target = container ?? initiator.parentElement;
|
|
314
337
|
if (!target) return;
|
|
315
338
|
|
|
316
|
-
|
|
339
|
+
// повторное нажатие по кнопке панель закрывает — держать и придерживать больше нечего
|
|
340
|
+
if (!formatToolbar.openEmoji(this, initiator, target)) return;
|
|
341
|
+
|
|
342
|
+
// Правку придерживаем на всё время панели: фокус мы отпустим, а снятие фокуса — не конец
|
|
343
|
+
// ввода. Иначе нормализация обрезала бы пробел у каретки, и символ встал бы вплотную.
|
|
344
|
+
this.__emojiHold ??= this.holdEditing();
|
|
317
345
|
|
|
318
346
|
// По кнопке могли нажать, ни разу не заходя в поле, — тогда каретки нет и вставлять символ
|
|
319
|
-
// некуда.
|
|
320
|
-
//
|
|
321
|
-
//
|
|
347
|
+
// некуда. В конец её ставит focus(true), и только если её действительно не было: снятую
|
|
348
|
+
// при отпускании фокуса он вернёт на место, а иначе символ уезжал бы в конец сообщения
|
|
349
|
+
// с каждым открытием панели.
|
|
322
350
|
//
|
|
323
351
|
// Строго после открытия панели: фокус показывает тулбар, а придержать его панель успевает
|
|
324
352
|
// только когда открыта сама.
|
|
325
|
-
if (!this.selection)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
353
|
+
if (!this.selection) this.focus(true);
|
|
354
|
+
|
|
355
|
+
// Панель — слой над полем, а не вместо него: каретку видно, и видно, куда встанет символ.
|
|
356
|
+
// На сенсорном устройстве фокус вместо этого поднимает клавиатуру, которая саму панель
|
|
357
|
+
// и закрывает, — там его отпускаем, а каретку вернёт вставка (см. keepFocus).
|
|
358
|
+
if (!this.keepFocus) this.releaseFocus();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Панель смайликов закрылась: снимаем удержание правки, взятое на время её работы. */
|
|
362
|
+
onEmojiClosed(): void {
|
|
363
|
+
this.__emojiHold?.();
|
|
364
|
+
this.__emojiHold = null;
|
|
329
365
|
}
|
|
330
366
|
|
|
331
367
|
getLength(): number {
|
|
@@ -337,12 +373,48 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
337
373
|
* Фокус в поле. Каретку, стоящую в содержимом, не двигает: правку продолжают там, где её
|
|
338
374
|
* прервали. Своей защиты обработчика фокуса для этого мало — браузер успевает поставить
|
|
339
375
|
* при фокусе собственную каретку (в начало содержимого), и она выглядит как «уже стоявшая».
|
|
376
|
+
*
|
|
377
|
+
* `atEnd` — куда ставить каретку, если её в поле ещё не было: в конец содержимого, а не
|
|
378
|
+
* в начало. Так фокусируют по клику мимо текста — это клик за ним, а не перед ним.
|
|
340
379
|
*/
|
|
341
|
-
focus(): void {
|
|
342
|
-
const bounds = this.caretSnapshot();
|
|
380
|
+
focus(atEnd = false): void {
|
|
381
|
+
const bounds = this.caretSnapshot() ?? this.__detachedCaret;
|
|
382
|
+
if (bounds) return this.restoreCaret(bounds);
|
|
343
383
|
|
|
344
|
-
|
|
345
|
-
|
|
384
|
+
this.editable.focus();
|
|
385
|
+
if (atEnd) caretToEnd(this.editable, this.multiline);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Отпускает фокус, запомнив каретку. Пока хост показывает своё окно, полю фокус не нужен:
|
|
390
|
+
* правка идёт в окне, а мигающая каретка под ним только сбивает с толку. На сенсорном
|
|
391
|
+
* устройстве фокус к тому же держит на экране клавиатуру, и она закрывает собой само окно.
|
|
392
|
+
*
|
|
393
|
+
* Каретка при этом не теряется: {@link insertText} вернёт её сам, а {@link focus} — вместе
|
|
394
|
+
* с фокусом. Правку на это время придерживает вызывающий (см. {@link holdEditing}): снятие
|
|
395
|
+
* фокуса не конец ввода, и содержимое трогать рано.
|
|
396
|
+
*/
|
|
397
|
+
releaseFocus(): void {
|
|
398
|
+
if (this.editable.ownerDocument.activeElement !== this.editable) return;
|
|
399
|
+
|
|
400
|
+
this.__detachedCaret = this.caretSnapshot();
|
|
401
|
+
this.__releasingFocus = true;
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
this.editable.blur();
|
|
405
|
+
} finally {
|
|
406
|
+
this.__releasingFocus = false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Ставит обратно каретку, снятую при отпускании фокуса. Фокус не возвращает: вставке из
|
|
411
|
+
// панели он не нужен, а на сенсорном устройстве вернул бы и клавиатуру.
|
|
412
|
+
private __reviveCaret() {
|
|
413
|
+
const bounds = this.__detachedCaret;
|
|
414
|
+
if (!bounds) return;
|
|
415
|
+
|
|
416
|
+
const selection = documentSelection(this.editable);
|
|
417
|
+
if (selection) restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
346
418
|
}
|
|
347
419
|
|
|
348
420
|
/**
|
|
@@ -466,6 +538,20 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
466
538
|
if (!target) return;
|
|
467
539
|
|
|
468
540
|
if (target.range.collapsed) {
|
|
541
|
+
// Каретка в опустевшем теге — слово из него стёрли, а тег остался, и печать продолжится
|
|
542
|
+
// оформленной. Кнопка снимает именно его: режим набора тут ничего не изменил бы,
|
|
543
|
+
// а выключить формат стало бы нечем.
|
|
544
|
+
const empty = emptyFormatAt(this.editable, target.range, tool);
|
|
545
|
+
if (empty) {
|
|
546
|
+
this.__history?.record("op");
|
|
547
|
+
preserveCaret(this.editable, () => empty.replaceWith(...Array.from(empty.childNodes)));
|
|
548
|
+
this.__pendingFormats.delete(tool);
|
|
549
|
+
|
|
550
|
+
this.__emitChange();
|
|
551
|
+
formatToolbar.refresh();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
469
555
|
// под кареткой нет слова — режим набора: формат для следующего ввода
|
|
470
556
|
if (this.__pendingFormats.has(tool)) this.__pendingFormats.delete(tool);
|
|
471
557
|
else this.__pendingFormats.add(tool);
|
|
@@ -499,6 +585,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
499
585
|
formatToolbar.refresh();
|
|
500
586
|
}
|
|
501
587
|
|
|
588
|
+
/**
|
|
589
|
+
* Типы блоков для панели. От {@link blockTypes} отличается только запретом правки: разбирать
|
|
590
|
+
* и показывать цитату и код редактор обязан и в режиме только для чтения, а переключать их
|
|
591
|
+
* там нечем — кнопок быть не должно.
|
|
592
|
+
*/
|
|
593
|
+
get blockTools(): BlockType[] {
|
|
594
|
+
return this.readonly ? [] : this.blockTypes;
|
|
595
|
+
}
|
|
596
|
+
|
|
502
597
|
/**
|
|
503
598
|
* Тип блока под кареткой. Без выделения (или вне блоков) — тип по умолчанию: именно им
|
|
504
599
|
* станет то, что сейчас наберут.
|
|
@@ -556,6 +651,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
556
651
|
restoreSelection(this.editable, bounds[0], bounds[1], selection);
|
|
557
652
|
}
|
|
558
653
|
|
|
654
|
+
// Соседние цитаты в режиме мягких переносов — одна цитата: склеиваем сразу, а не на
|
|
655
|
+
// потерю фокуса, иначе поле до неё показывает два блока вместо одного.
|
|
656
|
+
//
|
|
657
|
+
// Строго после возврата каретки: склейка снимает границу блоков, а её смещения считают,
|
|
658
|
+
// и восстановленная по прежним смещениям каретка съехала бы на символ. Живое выделение
|
|
659
|
+
// переезжает вместе с узлами само.
|
|
660
|
+
if (!this.__blockParagraphs) mergeAdjacentBlocks(this.editable);
|
|
661
|
+
|
|
559
662
|
this.__emitChange();
|
|
560
663
|
formatToolbar.refresh();
|
|
561
664
|
}
|
|
@@ -823,7 +926,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
823
926
|
// текст в координатах каретки (с концами строк) — иначе смещения разъедутся на число строк
|
|
824
927
|
const textBefore = editorText(this.editable);
|
|
825
928
|
normalizeWhitespace(this.editable);
|
|
826
|
-
|
|
929
|
+
// В режиме мягких переносов соседние цитаты неразличимы: значение пишет их строки подряд,
|
|
930
|
+
// а разбор собирает в одну — склеиваем и в поле.
|
|
931
|
+
if (this.multiline) normalizeParagraphs(this.editable, !this.__blockParagraphs);
|
|
827
932
|
if (this.editable.innerHTML === before) return;
|
|
828
933
|
|
|
829
934
|
if (bounds && selection) {
|
|
@@ -911,6 +1016,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
911
1016
|
() => {
|
|
912
1017
|
this.element.classList.add("focused");
|
|
913
1018
|
|
|
1019
|
+
// вернулись в поле — каретку ставит браузер или тот, кто вернул фокус
|
|
1020
|
+
this.__detachedCaret = null;
|
|
1021
|
+
|
|
914
1022
|
// Пришли править — держать запрет выделения не за чем, а с ним поле осталось бы
|
|
915
1023
|
// нередактируемым. Мышью его снимает нажатие, но фокус берут и клавишей, и из кода.
|
|
916
1024
|
this.element.classList.remove(UNSELECTABLE_CLASS);
|
|
@@ -933,7 +1041,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
933
1041
|
this.__hasInputClick = false;
|
|
934
1042
|
|
|
935
1043
|
this.element.classList.remove("focused");
|
|
936
|
-
|
|
1044
|
+
|
|
1045
|
+
// Фокус отпущен намеренно — это не уход из поля, а работа хоста в своём слое над
|
|
1046
|
+
// ним: панель убираем с экрана, но открытую панель смайликов не закрываем, её же
|
|
1047
|
+
// ради этого и открыли.
|
|
1048
|
+
if (this.__releasingFocus) formatToolbar.suspend(this);
|
|
1049
|
+
else formatToolbar.detach(this);
|
|
937
1050
|
|
|
938
1051
|
// правку продолжают в окне хоста — этот blur не конец ввода, содержимое трогать нельзя
|
|
939
1052
|
if (this.__editHolds > 0) {
|
|
@@ -1019,15 +1132,19 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1019
1132
|
// перемещение каретки — выход из режима набора
|
|
1020
1133
|
if (NAV_KEYS.includes(e.key)) this.__clearPendingFormats();
|
|
1021
1134
|
|
|
1022
|
-
|
|
1135
|
+
// A character, not a shortcut: Cmd is the same modifier as Ctrl, just on another platform.
|
|
1136
|
+
// Without it Cmd+C on a Mac would look like typing the letter "c" — copying would be
|
|
1137
|
+
// swallowed by the readonly guard, and the host filter would reject copy, paste and
|
|
1138
|
+
// select-all alike.
|
|
1139
|
+
const isChar = e.key.length === 1 && !e.ctrlKey && !e.metaKey;
|
|
1023
1140
|
|
|
1024
|
-
if (this.readonly && isChar
|
|
1141
|
+
if (this.readonly && isChar) {
|
|
1025
1142
|
e.preventDefault();
|
|
1026
1143
|
e.stopPropagation();
|
|
1027
1144
|
return;
|
|
1028
1145
|
}
|
|
1029
1146
|
|
|
1030
|
-
if (isChar &&
|
|
1147
|
+
if (isChar && this.__opts.filterChar && !this.__opts.filterChar(e.key)) {
|
|
1031
1148
|
e.preventDefault();
|
|
1032
1149
|
e.stopPropagation();
|
|
1033
1150
|
this.__reject();
|
|
@@ -1163,12 +1280,13 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1163
1280
|
ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
|
|
1164
1281
|
} else {
|
|
1165
1282
|
// инлайн: абзацы и переносы → пробелы, форматирование сохраняем
|
|
1166
|
-
const
|
|
1283
|
+
const doc = this.editable.ownerDocument;
|
|
1284
|
+
const fragment = doc.createDocumentFragment();
|
|
1167
1285
|
paras.forEach((p, index) => {
|
|
1168
|
-
if (index > 0) fragment.appendChild(
|
|
1286
|
+
if (index > 0) fragment.appendChild(doc.createTextNode(" "));
|
|
1169
1287
|
while (p.firstChild) fragment.appendChild(p.firstChild);
|
|
1170
1288
|
});
|
|
1171
|
-
fragment.querySelectorAll("br").forEach((br) => br.replaceWith(
|
|
1289
|
+
fragment.querySelectorAll("br").forEach((br) => br.replaceWith(doc.createTextNode(" ")));
|
|
1172
1290
|
|
|
1173
1291
|
caret = start + (fragment.textContent ?? "").length;
|
|
1174
1292
|
range.insertNode(fragment);
|
package/source/selection.ts
CHANGED
|
@@ -13,8 +13,13 @@ const MATCH_TAG_NAMES = Array.from(new Set(ALL_FORMAT_TOOLS.flatMap((t) => FORMA
|
|
|
13
13
|
// Селекторы считаем один раз: обе выборки идут на каждую правку формата и на каждое обновление панели.
|
|
14
14
|
const FORMAT_SELECTOR = FORMAT_TAG_NAMES.join(",").toLowerCase();
|
|
15
15
|
const MATCH_SELECTOR = MATCH_TAG_NAMES.join(",").toLowerCase();
|
|
16
|
-
//
|
|
17
|
-
|
|
16
|
+
// Инструменты, содержимое которых буквально (моноширинный): внутри них не бывает ни разметки
|
|
17
|
+
// (см. stripFormattingInCode), ни переносов строк — значение берёт оттуда голый текст.
|
|
18
|
+
const LITERAL_TAGS = ALL_FORMAT_TOOLS.filter((tool) => FORMAT_TOOLS[tool].literal).flatMap(
|
|
19
|
+
(tool) => FORMAT_TOOLS[tool].matchTags
|
|
20
|
+
);
|
|
21
|
+
const CODE_SELECTOR = LITERAL_TAGS.join(",").toLowerCase();
|
|
22
|
+
const LITERAL_TAG_SET = new Set(LITERAL_TAGS);
|
|
18
23
|
// Неделимые объекты хоста: конструкции сообщения объявляют себя нередактируемыми
|
|
19
24
|
const ATOMIC_SELECTOR = '[contenteditable="false"]';
|
|
20
25
|
|
|
@@ -161,6 +166,26 @@ export function charLength(root: HTMLElement): number {
|
|
|
161
166
|
return length;
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Пустой тег инструмента, внутри которого стоит каретка: слово из него стёрли, а тег остался,
|
|
171
|
+
* и печать продолжится оформленной. Кнопка панели обязана снимать именно его.
|
|
172
|
+
*/
|
|
173
|
+
export function emptyFormatAt(root: HTMLElement, range: Range, tool: FormatTool): HTMLElement | null {
|
|
174
|
+
if (!range.collapsed) return null;
|
|
175
|
+
|
|
176
|
+
const found = formatAt(caretProbe(range), TOOL_TAG_SETS[tool], root);
|
|
177
|
+
|
|
178
|
+
return found && !found.textContent ? found : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Ближайший предок с буквальным содержимым (моноширинный) — в нём не живёт перенос строки:
|
|
183
|
+
* значение берёт оттуда голый текст, и строка из значения пропала бы.
|
|
184
|
+
*/
|
|
185
|
+
export function literalAncestor(node: Node, root: HTMLElement): HTMLElement | null {
|
|
186
|
+
return formatAt(node, LITERAL_TAG_SET, root);
|
|
187
|
+
}
|
|
188
|
+
|
|
164
189
|
/** Абсолютные текстовые смещения границ выделения внутри root (для восстановления после правок DOM). */
|
|
165
190
|
export function selectionCharBounds(root: HTMLElement, range: Range): [number, number] {
|
|
166
191
|
const probe = document.createRange();
|
|
@@ -259,8 +284,8 @@ function locateChars(root: HTMLElement, lower: number, upper: number): [CharPosi
|
|
|
259
284
|
return false;
|
|
260
285
|
});
|
|
261
286
|
|
|
262
|
-
|
|
263
|
-
|
|
287
|
+
// Содержимого нет вовсе — каретке место только в самом корне. Возвращать «некуда» нельзя:
|
|
288
|
+
// в пустое поле как раз и вставляют, вернув каретку (панель смайликов работает без фокуса).
|
|
264
289
|
const tail: CharPosition = last ?? { node: root, offset: 0 };
|
|
265
290
|
|
|
266
291
|
return [low ?? tail, high ?? tail];
|
|
@@ -344,6 +369,18 @@ function* touchedTextNodes(root: HTMLElement, range: Range): Generator<Text> {
|
|
|
344
369
|
}
|
|
345
370
|
}
|
|
346
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Формат на самом узле или над ним. Каретка стоит и в самом теге — например в опустевшем `<code>`,
|
|
374
|
+
* из которого стёрли слово: браузер держит её внутри, и печать продолжится оформленной, поэтому
|
|
375
|
+
* состояние обязано этот тег видеть.
|
|
376
|
+
*/
|
|
377
|
+
function formatAt(node: Node, tags: ReadonlySet<string>, root: HTMLElement): HTMLElement | null {
|
|
378
|
+
const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
|
|
379
|
+
if (el && el !== root && tags.has(el.tagName)) return el;
|
|
380
|
+
|
|
381
|
+
return formatAncestor(node, tags, root);
|
|
382
|
+
}
|
|
383
|
+
|
|
347
384
|
/** Узел под схлопнутой кареткой — от него и ищется формат. */
|
|
348
385
|
function caretProbe(range: Range): Node {
|
|
349
386
|
const node = range.startContainer;
|
|
@@ -593,7 +630,7 @@ export function activeFormats(root: HTMLElement, range: Range, tools: FormatTool
|
|
|
593
630
|
|
|
594
631
|
if (range.collapsed) {
|
|
595
632
|
const probe = caretProbe(range);
|
|
596
|
-
for (const tool of tools) if (
|
|
633
|
+
for (const tool of tools) if (formatAt(probe, TOOL_TAG_SETS[tool], root)) active.add(tool);
|
|
597
634
|
|
|
598
635
|
return active;
|
|
599
636
|
}
|