@brandup/ui-richeditor 1.0.49 → 1.0.51

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.
@@ -30,6 +30,7 @@ import {
30
30
  paragraphsNormalized,
31
31
  preserveCaret,
32
32
  mergeAdjacentBlocks,
33
+ splitSoftBreaks,
33
34
  normalizeParagraphs,
34
35
  normalizeWhitespace,
35
36
  restoreSelection,
@@ -50,15 +51,20 @@ import {
50
51
  atBlockStart,
51
52
  buildParagraphs,
52
53
  caretToEnd,
54
+ collapseEmptyEdges,
53
55
  expandRangeToWords,
56
+ hasPastedMarkup,
57
+ isDocumentHtml,
54
58
  insertParagraph,
55
59
  insertPastedParagraphs,
56
60
  insertSoftBreak,
61
+ parsePastedMarkdown,
57
62
  sanitizePastedHtml,
58
63
  selectAllContent,
59
64
  trimSelectionWhitespace,
60
65
  } from "./editing";
61
66
  import { EditorHistory } from "./history";
67
+ import { refreshRecentEmojis } from "./emoji";
62
68
  import { formatToolbar, TOOLBAR_CLASS, type ToolbarButton } from "./toolbar";
63
69
 
64
70
  export { formatToolbar, TOOLBAR_CLASS, type ToolbarHost, type ToolbarButton } from "./toolbar";
@@ -66,6 +72,8 @@ export { formatToolbar, TOOLBAR_CLASS, type ToolbarHost, type ToolbarButton } fr
66
72
  export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
67
73
  // Содержимое временно невыделяемо: по странице тянут выделение, начатое вне редактора (см. __holdSelectable).
68
74
  export const UNSELECTABLE_CLASS = "unselectable";
75
+ // Режим мягких переносов: абзац — это строка, и отступов между абзацами в нём нет.
76
+ export const BREAKS_CLASS = "breaks";
69
77
  export const CHANGE_EVENT = "richeditor-change";
70
78
 
71
79
  const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Escape"];
@@ -73,6 +81,27 @@ const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "En
73
81
  // Код — единственное, что есть и инструментом, и типом блока: имя одно на оба (см. applyCode).
74
82
  const CODE = "code";
75
83
 
84
+ // Расширения текстовых файлов для броска в редактор: у .md и .txt тип в системе часто
85
+ // не зарегистрирован и приходит пустым — тогда решает имя.
86
+ const TEXT_FILE_NAME = /\.(md|markdown|txt|text)$/i;
87
+
88
+ /** Текстовый ли файл: по MIME (text/*), а без типа — по расширению. */
89
+ function isTextFile(file: File): boolean {
90
+ return file.type ? file.type.startsWith("text/") : TEXT_FILE_NAME.test(file.name);
91
+ }
92
+
93
+ /** Содержимое файла текстом: Blob.text() есть не во всех окружениях (jsdom) — тогда FileReader. */
94
+ function readFileText(file: File): Promise<string> {
95
+ if (typeof file.text === "function") return file.text();
96
+
97
+ return new Promise((resolve, reject) => {
98
+ const reader = new FileReader();
99
+ reader.onload = () => resolve(String(reader.result ?? ""));
100
+ reader.onerror = () => reject(reader.error);
101
+ reader.readAsText(file);
102
+ });
103
+ }
104
+
76
105
  // Ссылка — единственный инструмент с данными: адрес задаётся не переключением (см. applyLink).
77
106
  const LINK: FormatTool = "link";
78
107
 
@@ -110,6 +139,65 @@ function isHotkeyLetter(e: KeyboardEvent, letter: string): boolean {
110
139
  return e.key.toLowerCase() === letter || codeLetter(e) === letter;
111
140
  }
112
141
 
142
+ /** Коробка строки — какую высоту занимает строка текста. */
143
+ type LineBox = { top: number; bottom: number; height: number };
144
+
145
+ /**
146
+ * Коробки строк, занятых узлом. У переносимого текста их несколько, и общая коробка накрывает
147
+ * их все — для доводки прокрутки нужна конкретная строка, поэтому берём список, а не объединение.
148
+ * В среде без раскладки (jsdom) список пуст, там остаётся общая коробка.
149
+ */
150
+ function lineRects(node: Node): LineBox[] {
151
+ let source: { getClientRects?: () => DOMRectList; getBoundingClientRect?: () => DOMRect } | null = null;
152
+
153
+ if (node.nodeType === Node.TEXT_NODE) {
154
+ const range = node.ownerDocument?.createRange();
155
+ if (range) {
156
+ range.selectNodeContents(node);
157
+ source = range;
158
+ }
159
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
160
+ source = node as Element;
161
+ }
162
+
163
+ if (typeof source?.getBoundingClientRect !== "function") return [];
164
+
165
+ const rects = Array.from(source.getClientRects?.() ?? []);
166
+ return rects.length ? rects : [source.getBoundingClientRect()];
167
+ }
168
+
169
+ /**
170
+ * Строка, на которой стоит каретка, когда собственной коробки у неё нет — так бывает на пустой
171
+ * строке. Ищем ближайший измеримый ориентир на ТОЙ ЖЕ строке.
172
+ */
173
+ function caretLineBox(range: Range): LineBox | null {
174
+ const { startContainer: node, startOffset: offset } = range;
175
+
176
+ if (node.nodeType === Node.ELEMENT_NODE) {
177
+ // Узел ПОСЛЕ каретки стоит на её строке: <br> рисуется в конце строки, которую завершает,
178
+ // а текст за кареткой с этой строки начинается. Узел ДО каретки лежит строкой выше —
179
+ // по нему прокрутка недоезжала ровно на строку.
180
+ const next = node.childNodes[offset];
181
+ if (next) return lineRects(next)[0] ?? null;
182
+
183
+ // Каретка за последним узлом: своей строки у неё ещё нет, а предыдущий <br> завершает
184
+ // предыдущую — она ровно на строку ниже него.
185
+ const previous = node.childNodes[offset - 1];
186
+ if (previous) {
187
+ const rects = lineRects(previous);
188
+ const last = rects[rects.length - 1];
189
+ if (!last) return null;
190
+
191
+ const shift = previous.nodeName === "BR" ? last.height : 0;
192
+ return { top: last.top + shift, bottom: last.bottom + shift, height: last.height };
193
+ }
194
+ }
195
+
196
+ // Соседей нет вовсе: пустой блок и есть строка каретки.
197
+ const own = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
198
+ return own ? (lineRects(own)[0] ?? null) : null;
199
+ }
200
+
113
201
  export interface RichEditorOptions {
114
202
  /** Включает форматирование и панель инструментов. */
115
203
  format?: boolean;
@@ -268,6 +356,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
268
356
 
269
357
  if (options.placeholder != null) editable.dataset.placeholder = options.placeholder;
270
358
  if (multiline) editable.classList.add("multiline");
359
+ // абзац-строка: отступы между абзацами показывали бы пустую строку, которой в значении нет
360
+ if (!this.__separateParagraphs && multiline) editable.classList.add(BREAKS_CLASS);
271
361
  if (readonly) editable.classList.add("readonly");
272
362
 
273
363
  this.__initEvents();
@@ -288,12 +378,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
288
378
  }
289
379
 
290
380
  /**
291
- * Работает ли редактор моделью абзацев. В режиме break абзацных блоков нет: значение плоский
292
- * текст, где каждый \n это <br>. Иначе `a\n\nb` рисовалось бы двумя <p>, а на экране (без
293
- * отступов между абзацами) это неотличимо от одного переноса значение расходилось бы
294
- * с видимым текстом.
381
+ * Разделяет ли абзацы пустая строка. Содержимое в обоих режимах абзацные блоки, но значат
382
+ * они разное: в block абзац это абзац (`\n\n`), в break строка (`\n`), а пустая строка
383
+ * сообщения хранится пустым абзацем. Поэтому в break у абзацев нет и отступов: между двумя
384
+ * строками их на экране быть не должно.
295
385
  */
296
- private get __blockParagraphs(): boolean {
386
+ private get __separateParagraphs(): boolean {
297
387
  return this.multiline && this.paragraph === "block";
298
388
  }
299
389
 
@@ -318,7 +408,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
318
408
  this.formatMarkers,
319
409
  this.multiline,
320
410
  this.blockTypes,
321
- this.__blockParagraphs
411
+ this.__separateParagraphs
322
412
  );
323
413
  }
324
414
 
@@ -377,6 +467,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
377
467
  // прятать его носителя незачем и нечем.
378
468
  const inToolbar = !!picker.closest(`.${TOOLBAR_CLASS}`);
379
469
 
470
+ // Недавние — по хранилищу на момент показа: попап живёт между открытиями, а хранилище
471
+ // тем временем пополняют и другие попапы страницы.
472
+ refreshRecentEmojis(picker);
473
+
380
474
  PopupManager.open(picker, {
381
475
  initiator,
382
476
  onClose: () => {
@@ -541,6 +635,40 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
541
635
  selection.addRange(range);
542
636
  }
543
637
 
638
+ /**
639
+ * Слово, на котором стоит каретка. Пусто, когда выделение своё (пользователь выбрал сам,
640
+ * и подставлять ему нечего), когда каретки в редакторе нет вовсе или когда она стоит не
641
+ * в слове — за точкой, например: знаки препинания в слово не входят (см. expandRangeToWords).
642
+ */
643
+ get caretWord(): string {
644
+ const selection = this.selection;
645
+ if (!selection || selection.rangeCount === 0) return "";
646
+
647
+ const range = selection.getRangeAt(0);
648
+ if (!range.collapsed) return "";
649
+
650
+ return expandRangeToWords(this.editable, range).toString();
651
+ }
652
+
653
+ /**
654
+ * Выделить слово под кареткой — чтобы следующая вставка встала на его место, а не разорвала
655
+ * его пополам. Возвращает false, когда выделять нечего: тогда вставка идёт в точку каретки.
656
+ */
657
+ selectCaretWord(): boolean {
658
+ const selection = documentSelection(this.editable);
659
+ if (!selection || selection.rangeCount === 0) return false;
660
+
661
+ const range = selection.getRangeAt(0);
662
+ if (!range.collapsed) return false;
663
+
664
+ const word = expandRangeToWords(this.editable, range);
665
+ if (word.collapsed) return false;
666
+
667
+ selection.removeAllRanges();
668
+ selection.addRange(word);
669
+ return true;
670
+ }
671
+
544
672
  /**
545
673
  * Цель правки форматирования: выделение, расширенное до целых слов, плюс исходные границы
546
674
  * для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
@@ -742,7 +870,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
742
870
  // Смена тега переносит содержимое в новый элемент — живые границы выделения этого
743
871
  // не переживают, поэтому держим каретку по текстовым смещениям.
744
872
  const bounds = selectionCharBounds(this.editable, range);
745
- const { created } = applyBlocks(this.editable, range, target);
873
+ const { created } = applyBlocks(this.editable, range, target, !this.__separateParagraphs);
874
+
875
+ // Возврат блока в обычный текст оставляет его строки мягкими переносами — приводим их
876
+ // к абзацам. Строго ДО возврата каретки: разбиение блока живой Range не переживает,
877
+ // а смещения от него не меняются — граница абзацев и мягкий перенос считаются одинаково.
878
+ if (!this.__separateParagraphs) splitSoftBreaks(this.editable);
746
879
 
747
880
  // Разделение блока добавило границы, а они в смещениях считаются — прежние уже не те.
748
881
  // Выделяем то, что стало блоком: заодно видно, к каким строкам правка и относилась.
@@ -764,7 +897,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
764
897
  // Строго после возврата каретки: склейка снимает границу блоков, а её смещения считают,
765
898
  // и восстановленная по прежним смещениям каретка съехала бы на символ. Живое выделение
766
899
  // переезжает вместе с узлами само.
767
- if (!this.__blockParagraphs) mergeAdjacentBlocks(this.editable);
900
+ if (!this.__separateParagraphs) mergeAdjacentBlocks(this.editable);
768
901
 
769
902
  this.__emitChange();
770
903
  formatToolbar.refresh();
@@ -954,7 +1087,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
954
1087
  formatToolbar.detach(this);
955
1088
 
956
1089
  // элемент передан хостом — не удаляем его, только снимаем оформление редактора
957
- this.editable.classList.remove(ROOT_CLASS, UNSELECTABLE_CLASS, "multiline", "readonly", "focused");
1090
+ this.editable.classList.remove(
1091
+ ROOT_CLASS,
1092
+ UNSELECTABLE_CLASS,
1093
+ BREAKS_CLASS,
1094
+ "multiline",
1095
+ "readonly",
1096
+ "focused"
1097
+ );
958
1098
  this.editable.removeAttribute("contenteditable");
959
1099
  delete this.editable.dataset.placeholder;
960
1100
 
@@ -967,24 +1107,19 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
967
1107
  DOM.empty(this.editable);
968
1108
  if (!value) return;
969
1109
 
970
- // multiline → <p>-абзацы; single-line → инлайновое содержимое. Блоки разбираются и в режиме
971
- // мягких переносов: цитата и код узнаются по собственной разметке, а не по пустой строке.
972
- const blocks = this.blockTypes.length > 1;
973
- const paragraphs = this.__blockParagraphs || (this.multiline && blocks);
974
-
1110
+ // multiline → <p>-абзацы; single-line → инлайновое содержимое
975
1111
  this.editable.innerHTML = deserialize(
976
1112
  value,
977
1113
  this.__valueStorage,
978
1114
  this.__valueTools,
979
1115
  this.formatMarkers,
980
- paragraphs,
1116
+ this.multiline,
981
1117
  this.blockTypes,
982
- this.__blockParagraphs
1118
+ this.__separateParagraphs
983
1119
  );
984
1120
 
985
- // в break инлайновое содержимое оборачивается в единственный абзац модель абзацев
986
- // нужна редактированию (каретка, вставка), а разделителем строк остаётся <br>
987
- if (this.multiline && !this.__blockParagraphs) ensureParagraphs(this.editable);
1121
+ // пустые абзацы значения получают заполнитель, чужая вёрсткаканонический тег
1122
+ if (this.multiline) ensureParagraphs(this.editable);
988
1123
  }
989
1124
 
990
1125
  /**
@@ -1042,7 +1177,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1042
1177
  normalizeWhitespace(this.editable);
1043
1178
  // В режиме мягких переносов соседние цитаты неразличимы: значение пишет их строки подряд,
1044
1179
  // а разбор собирает в одну — склеиваем и в поле.
1045
- if (this.multiline) normalizeParagraphs(this.editable, !this.__blockParagraphs);
1180
+ if (this.multiline) normalizeParagraphs(this.editable, !this.__separateParagraphs);
1046
1181
  if (this.editable.innerHTML === before) return;
1047
1182
 
1048
1183
  if (bounds && selection) {
@@ -1114,9 +1249,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1114
1249
  const { signal } = this.__abort;
1115
1250
  const editable = this.editable;
1116
1251
 
1117
- // перетаскивание в редактор проходит мимо истории и фильтров хоста гасим саму вставку.
1252
+ // Бросок в редактор: текстовые файлы принимаются содержимым (см. __onDrop), всё прочее
1253
+ // гасится — свободное перетаскивание прошло бы мимо истории и фильтров хоста.
1118
1254
  // dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
1119
- this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
1255
+ this.element.addEventListener("drop", (e) => this.__onDrop(e), { signal });
1120
1256
 
1121
1257
  // Выделение страницы не должно затягивать содержимое редактора — см. __holdSelectable.
1122
1258
  // Слушаем на документе и в фазе перехвата: протяжка начинается где угодно, а обработчик
@@ -1207,6 +1343,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1207
1343
  this.element.addEventListener("paste", (e: ClipboardEvent) => this.__onPaste(e), { signal });
1208
1344
  editable.addEventListener("keydown", (e: KeyboardEvent) => this.__onKeydown(e), { signal });
1209
1345
 
1346
+ // Каретку двигали клавишами: браузер к ней прокручивает сам, но до края коробки —
1347
+ // под отступы прокручиваемого контейнера. Поправляем после него, на keyup: на keydown
1348
+ // своей прокрутки он ещё не сделал, и поправка ушла бы впустую.
1349
+ editable.addEventListener(
1350
+ "keyup",
1351
+ (e: KeyboardEvent) => {
1352
+ if (NAV_KEYS.includes(e.key)) this.__scrollCaretIntoView();
1353
+ },
1354
+ { signal }
1355
+ );
1356
+
1210
1357
  editable.addEventListener("beforeinput", (e: InputEvent) => this.__onBeforeInput(e), { signal });
1211
1358
 
1212
1359
  editable.addEventListener(
@@ -1223,6 +1370,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1223
1370
  } else if (editable.firstChild?.nodeName === "BR") {
1224
1371
  editable.innerHTML = "";
1225
1372
  }
1373
+ // При наборе браузер прокручивает к каретке сам, но доводит её лишь до края
1374
+ // коробки — под отступы контейнера. Доводим до текста.
1375
+ this.__scrollCaretIntoView();
1376
+
1226
1377
  this.__emitChange(true); // печать — единственный посимвольный источник, его и откладываем
1227
1378
  },
1228
1379
  { signal }
@@ -1230,46 +1381,58 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1230
1381
  }
1231
1382
 
1232
1383
  /**
1233
- * Доводит прокрутку до каретки. Переносы строк редактор делает сам (preventDefault), и
1234
- * браузер к новой строке не прокручивает; к тому же у хостов (textbox, messageeditor)
1235
- * прокручивается не редактируемый элемент, а его обёртка после достижения предельной
1236
- * высоты каретка у нижнего края уходила бы за пределы видимого.
1384
+ * Доводит прокрутку до каретки. Нужна там, где браузер не справляется сам: переносы строк
1385
+ * редактор делает вручную (preventDefault), и к новой строке браузер не прокручивает; в
1386
+ * textbox прокручивается не редактируемый элемент, а его обёртка; а своей прокруткой браузер
1387
+ * доводит каретку лишь до края коробки под отступы, которые едут вместе с текстом.
1237
1388
  */
1238
1389
  private __scrollCaretIntoView() {
1239
1390
  const selection = this.selection;
1240
1391
  if (!selection || selection.rangeCount === 0) return;
1241
1392
 
1242
- const range = selection.getRangeAt(0);
1393
+ const range = selection.getRangeAt(0).cloneRange();
1243
1394
 
1244
1395
  // в среде без раскладки (jsdom) у Range коробок нет вовсе
1245
1396
  if (typeof range.getBoundingClientRect !== "function") return;
1246
1397
 
1247
- // У схлопнутой каретки на пустой строке нет собственной коробки — берём ближайший
1248
- // измеримый ориентир: узел перед кареткой (обычно свежий <br>), иначе родителя.
1249
- let rect = range.getBoundingClientRect();
1250
- if (!rect.height) {
1251
- const { startContainer: node, startOffset: offset } = range;
1252
- const anchor = node.nodeType === Node.ELEMENT_NODE ? (node.childNodes[offset - 1] ?? node) : node;
1253
- const el = anchor.nodeType === Node.ELEMENT_NODE ? (anchor as Element) : anchor.parentElement;
1254
- if (!el) return;
1255
- rect = el.getBoundingClientRect();
1398
+ // Выделение тянут клавишами (Shift+стрелки): вести нужно за его подвижным концом.
1399
+ // Коробка всего выделения накрывает все занятые строки, и прокрутка ушла бы к концу
1400
+ // неподвижному, уводя подвижный за пределы видимого.
1401
+ if (!range.collapsed) {
1402
+ const toStart = selection.focusNode === range.startContainer && selection.focusOffset === range.startOffset;
1403
+ range.collapse(toStart);
1404
+ }
1405
+
1406
+ let { top, bottom, height } = range.getBoundingClientRect();
1407
+ if (!height) {
1408
+ const line = caretLineBox(range);
1409
+ if (!line) return;
1410
+ ({ top, bottom, height } = line);
1256
1411
  }
1257
1412
  // раскладки нет (тестовая среда) — прокручивать не по чему
1258
- if (!rect.height && !rect.top && !rect.bottom) return;
1413
+ if (!height && !top && !bottom) return;
1259
1414
 
1260
1415
  // Ближайший прокручиваемый предок; прокручивается только он — внешним контейнерам
1261
1416
  // каретка не адресована, крутить страницу из редактора нельзя.
1262
1417
  const document = this.editable.ownerDocument;
1263
1418
  for (let el: HTMLElement | null = this.editable; el && el !== document.body; el = el.parentElement) {
1264
1419
  if (el.scrollHeight <= el.clientHeight) continue;
1265
- const overflow = document.defaultView?.getComputedStyle(el).overflowY;
1420
+ const style = document.defaultView?.getComputedStyle(el);
1421
+ if (!style) continue;
1422
+ const overflow = style.overflowY;
1266
1423
  if (overflow !== "auto" && overflow !== "scroll") continue;
1267
1424
 
1425
+ // Границей служит не коробка, а область, свободная от отступов: они едут вместе
1426
+ // с текстом (см. .editor в textbox, .ui-richeditor в messageeditor), и доведённая
1427
+ // до края коробки строка встала бы под них. Своего отступа у контейнера может
1428
+ // и не быть — тогда держим небольшой запас, чтобы строка не прилипала к краю.
1429
+ const gap = Math.min(height || 16, 16);
1268
1430
  const box = el.getBoundingClientRect();
1269
- // небольшой запас, чтобы строка не прилипала к самому краю
1270
- const pad = Math.min(rect.height || 16, 16);
1271
- if (rect.bottom > box.bottom - pad) el.scrollTop += rect.bottom - (box.bottom - pad);
1272
- else if (rect.top < box.top + pad) el.scrollTop -= box.top + pad - rect.top;
1431
+ const viewTop = box.top + el.clientTop + (parseFloat(style.paddingTop) || gap);
1432
+ const viewBottom = box.top + el.clientTop + el.clientHeight - (parseFloat(style.paddingBottom) || gap);
1433
+
1434
+ if (bottom > viewBottom) el.scrollTop += bottom - viewBottom;
1435
+ else if (top < viewTop) el.scrollTop -= viewTop - top;
1273
1436
  return;
1274
1437
  }
1275
1438
  }
@@ -1356,15 +1519,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1356
1519
  if (this.readonly) return;
1357
1520
 
1358
1521
  // В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
1359
- // В режиме break строку переносят оба нажатия: пустая строка набирается двумя
1360
- // переносами, как в мессенджерах, и отдельный абзац дал бы в значении ровно её же.
1522
+ // В режиме break абзац и есть строка, поэтому её создают оба нажатия: мягкому переносу
1523
+ // там неоткуда взяться в значении он дал бы ровно то же самое.
1361
1524
  // Внутри блока правило берётся у его типа: из цитаты и кода Enter выходит,
1362
1525
  // а модификатор переносит строку внутри.
1363
1526
  const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
1364
1527
  const current = this.currentBlock;
1365
- const breaks =
1366
- current === DEFAULT_BLOCK ? this.paragraph === "break" : BLOCK_TYPES[current].enter === "break";
1367
- const soft = breaks || withModifier;
1528
+ const soft =
1529
+ current === DEFAULT_BLOCK
1530
+ ? this.__separateParagraphs && withModifier
1531
+ : BLOCK_TYPES[current].enter === "break" || withModifier;
1368
1532
 
1369
1533
  this.__history?.record("op");
1370
1534
  // Из блока выходят тем же нажатием, что делит его: продолжать цитату или код
@@ -1408,6 +1572,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1408
1572
  // форматированную вставку берём из text/html только при включённом форматировании
1409
1573
  const html = this.format && this.formatTools.length ? data.getData("text/html") : "";
1410
1574
 
1575
+ this.__insertExternal(plain, html);
1576
+ }
1577
+
1578
+ /**
1579
+ * Общий приём внешнего текста — вставки из буфера и брошенного файла: фильтр хоста, затем
1580
+ * форматированная ветка, затем простой текст. Форматирование приходит двумя путями: text/html
1581
+ * из буфера и, при markdown-хранении, маркеры прямо в тексте — раз значение хранится этой
1582
+ * разметкой, вставленный текст с ней разбирается так же, как разбиралось бы значение.
1583
+ */
1584
+ private __insertExternal(plain: string, html: string) {
1411
1585
  // filterPaste решает по тексту: null — отклонить; если хук ИЗМЕНИЛ текст (обрезка по длине,
1412
1586
  // фильтр по типу) — форматирование сохранить нельзя, вставляем очищенный простой текст
1413
1587
  let plainOverride: string | null = null;
@@ -1424,7 +1598,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1424
1598
  const selection = this.selection;
1425
1599
  if (!selection) return;
1426
1600
 
1427
- if (html && plainOverride == null && this.__pasteHtml(html, selection)) return;
1601
+ if (plainOverride == null) {
1602
+ const paras = html ? sanitizePastedHtml(html, this.formatTools, this.formatMarkers, this.blockTypes) : [];
1603
+ const markdown = this.format && this.formatStorage === "markdown";
1604
+
1605
+ // text/html точнее описывает скопированное, но лишь когда несёт собственную разметку.
1606
+ // Голый текст тоже приезжает html-ем — редакторы кода отдают исходник маркдауна
1607
+ // строками в <div>, — и такой html не знает ничего сверх text/plain, а маркеры в
1608
+ // тексте понимает только разбор разметкой хранения: при плоском html первым идёт он.
1609
+ // Документ (абзацы <p>, заголовки, списки) плоским не считается, даже когда в нём
1610
+ // нет ни одного тега форматирования: буквальные символы маркеров в тексте страницы
1611
+ // видит и её читатель, и разбор превращал бы их в разметку (см. isDocumentHtml).
1612
+ const flat = markdown && !hasPastedMarkup(paras) && !isDocumentHtml(html);
1613
+
1614
+ if (flat && this.__pasteMarkdown(plain, selection)) return;
1615
+ if (this.__insertPasted(paras, selection)) return;
1616
+ if (!flat && markdown && this.__pasteMarkdown(plain, selection)) return;
1617
+ }
1428
1618
  this.__pastePlain(plainOverride ?? plain, selection);
1429
1619
  }
1430
1620
 
@@ -1442,14 +1632,91 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1442
1632
  ? text.split(/\n/)
1443
1633
  : text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
1444
1634
 
1445
- this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs && !literal), selection);
1635
+ const mode = literal || !this.multiline ? "single" : this.__separateParagraphs ? "block" : "line";
1636
+
1637
+ this.__insertPasted(buildParagraphs(lines, mode), selection);
1638
+ }
1639
+
1640
+ /**
1641
+ * Бросок файлов в редактор: текстовые (`text/*`, а без типа — `.md`/`.txt` по имени)
1642
+ * вставляются содержимым тем же путём, что вставка из буфера, — фильтр хоста, разбор
1643
+ * разметкой хранения объявленным набором, история. Любой другой бросок гасится: свободное
1644
+ * перетаскивание прошло бы мимо истории и фильтров хоста.
1645
+ */
1646
+ private __onDrop(e: DragEvent) {
1647
+ e.preventDefault();
1648
+
1649
+ if (this.readonly) return;
1650
+
1651
+ const files = Array.from(e.dataTransfer?.files ?? []).filter(isTextFile);
1652
+ if (!files.length) return;
1653
+
1654
+ // Каретка — в точку броска, если браузер умеет её назвать; иначе остаётся прежняя.
1655
+ // Фокус нужен в любом случае: бросают и в поле, которое его ещё не получало, — вставлять
1656
+ // тогда некуда, а без каретки focus(true) ставит её в конец.
1657
+ this.__caretFromPoint(e.clientX, e.clientY);
1658
+ this.focus(true);
1659
+
1660
+ // Файл читается асинхронно: правка на это время придержана, чтобы нормализация между
1661
+ // делом не перестроила содержимое под выставленной кареткой.
1662
+ const release = this.holdEditing();
1663
+ Promise.all(files.map(readFileText))
1664
+ .then((texts) => {
1665
+ // компонент могли снять, пока файл читался, — вставлять больше некуда
1666
+ if (this.__disposed) return;
1667
+
1668
+ // несколько файлов — подряд через пустую строку: это отдельные тексты
1669
+ this.__insertExternal(texts.join("\n\n"), "");
1670
+ })
1671
+ .catch(() => this.__reject())
1672
+ .finally(release);
1673
+ }
1674
+
1675
+ /** Ставит каретку в точку на экране, если браузер умеет её назвать, и точка — в редакторе. */
1676
+ private __caretFromPoint(x: number, y: number) {
1677
+ // стандартное API и его вебкитовский предшественник; в jsdom нет обоих
1678
+ const doc = this.editable.ownerDocument as Document & {
1679
+ caretPositionFromPoint?(x: number, y: number): { offsetNode: Node; offset: number } | null;
1680
+ caretRangeFromPoint?(x: number, y: number): Range | null;
1681
+ };
1682
+
1683
+ let node: Node | null = null;
1684
+ let offset = 0;
1685
+ if (doc.caretPositionFromPoint) {
1686
+ const position = doc.caretPositionFromPoint(x, y);
1687
+ if (position) ({ offsetNode: node, offset } = position);
1688
+ } else if (doc.caretRangeFromPoint) {
1689
+ const range = doc.caretRangeFromPoint(x, y);
1690
+ if (range) ({ startContainer: node, startOffset: offset } = range);
1691
+ }
1692
+
1693
+ if (!node || !this.editable.contains(node)) return;
1694
+
1695
+ const selection = documentSelection(this.editable);
1696
+ if (!selection) return;
1697
+
1698
+ const range = doc.createRange();
1699
+ range.setStart(node, offset);
1700
+ range.collapse(true);
1701
+ selection.removeAllRanges();
1702
+ selection.addRange(range);
1446
1703
  }
1447
1704
 
1448
- // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего
1449
- // тогда вызывающий откатывается на простую вставку.
1450
- private __pasteHtml(html: string, selection: Selection): boolean {
1705
+ // Вставка простого текста разметкой хранения (markdown): разбор тем же deserialize и с теми же
1706
+ // наборами, что у значения, маркеры снятых инструментов остаются текстом, как остались бы
1707
+ // в значении. Возвращает false, если вставлять нечего или разбирать нельзя.
1708
+ private __pasteMarkdown(text: string, selection: Selection): boolean {
1709
+ // внутри блока без инлайновой разметки (код) текст литерален — как и при простой вставке
1710
+ if (!BLOCK_TYPES[this.currentBlock].inline) return false;
1711
+
1451
1712
  return this.__insertPasted(
1452
- sanitizePastedHtml(html, this.formatTools, this.formatMarkers, this.blockTypes),
1713
+ parsePastedMarkdown(
1714
+ text,
1715
+ this.__valueTools,
1716
+ this.formatMarkers,
1717
+ this.blockTypes,
1718
+ this.__separateParagraphs
1719
+ ),
1453
1720
  selection
1454
1721
  );
1455
1722
  }
@@ -1467,7 +1734,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1467
1734
 
1468
1735
  const range = selection.getRangeAt(0);
1469
1736
  this.__history?.record("op");
1737
+
1738
+ const spanned = !range.collapsed;
1470
1739
  range.deleteContents();
1740
+ if (spanned && this.multiline) collapseEmptyEdges(this.editable, range);
1471
1741
 
1472
1742
  const start = selectionCharBounds(this.editable, range)[0];
1473
1743
  let caret: number;