@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.
@@ -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 || !this.selection) return;
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
- formatToolbar.openEmoji(this, initiator, target);
339
+ // повторное нажатие по кнопке панель закрывает — держать и придерживать больше нечего
340
+ if (!formatToolbar.openEmoji(this, initiator, target)) return;
341
+
342
+ // Правку придерживаем на всё время панели: фокус мы отпустим, а снятие фокуса — не конец
343
+ // ввода. Иначе нормализация обрезала бы пробел у каретки, и символ встал бы вплотную.
344
+ this.__emojiHold ??= this.holdEditing();
317
345
 
318
346
  // По кнопке могли нажать, ни разу не заходя в поле, — тогда каретки нет и вставлять символ
319
- // некуда. Фокусировать вслепую нельзя: focus() сбросил бы уже стоящую каретку, и символ
320
- // уехал бы не туда. Конец содержимого назначаем сами: фокус ставит каретку в начало,
321
- // и дописанный к сообщению символ оказался бы перед текстом.
347
+ // некуда. В конец её ставит focus(true), и только если её действительно не было: снятую
348
+ // при отпускании фокуса он вернёт на место, а иначе символ уезжал бы в конец сообщения
349
+ // с каждым открытием панели.
322
350
  //
323
351
  // Строго после открытия панели: фокус показывает тулбар, а придержать его панель успевает
324
352
  // только когда открыта сама.
325
- if (!this.selection) {
326
- this.focus();
327
- caretToEnd(this.editable, this.multiline);
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
- if (bounds) this.restoreCaret(bounds);
345
- else this.editable.focus();
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
- if (this.multiline) normalizeParagraphs(this.editable);
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
- formatToolbar.detach(this);
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
- const isChar = e.key.length === 1;
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 && !e.ctrlKey) {
1141
+ if (this.readonly && isChar) {
1025
1142
  e.preventDefault();
1026
1143
  e.stopPropagation();
1027
1144
  return;
1028
1145
  }
1029
1146
 
1030
- if (isChar && !e.ctrlKey && this.__opts.filterChar && !this.__opts.filterChar(e.key)) {
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 fragment = document.createDocumentFragment();
1283
+ const doc = this.editable.ownerDocument;
1284
+ const fragment = doc.createDocumentFragment();
1167
1285
  paras.forEach((p, index) => {
1168
- if (index > 0) fragment.appendChild(document.createTextNode(" "));
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(document.createTextNode(" ")));
1289
+ fragment.querySelectorAll("br").forEach((br) => br.replaceWith(doc.createTextNode(" ")));
1172
1290
 
1173
1291
  caret = start + (fragment.textContent ?? "").length;
1174
1292
  range.insertNode(fragment);
@@ -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
- // Моноширинный: внутри него разметки не бывает (см. stripFormattingInCode)
17
- const CODE_SELECTOR = FORMAT_TOOLS.code.matchTags.join(",").toLowerCase();
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
- if (!last && !low && !high) return null;
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 (formatAncestor(probe, TOOL_TAG_SETS[tool], root)) active.add(tool);
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
  }