@brandup/ui-richeditor 1.0.48 → 1.0.50

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.
@@ -27,6 +27,7 @@ import {
27
27
  normalizeBlockTypes,
28
28
  editorText,
29
29
  charLength,
30
+ paragraphsNormalized,
30
31
  preserveCaret,
31
32
  mergeAdjacentBlocks,
32
33
  normalizeParagraphs,
@@ -50,14 +51,18 @@ import {
50
51
  buildParagraphs,
51
52
  caretToEnd,
52
53
  expandRangeToWords,
54
+ hasPastedMarkup,
55
+ isDocumentHtml,
53
56
  insertParagraph,
54
57
  insertPastedParagraphs,
55
58
  insertSoftBreak,
59
+ parsePastedMarkdown,
56
60
  sanitizePastedHtml,
57
61
  selectAllContent,
58
62
  trimSelectionWhitespace,
59
63
  } from "./editing";
60
64
  import { EditorHistory } from "./history";
65
+ import { refreshRecentEmojis } from "./emoji";
61
66
  import { formatToolbar, TOOLBAR_CLASS, type ToolbarButton } from "./toolbar";
62
67
 
63
68
  export { formatToolbar, TOOLBAR_CLASS, type ToolbarHost, type ToolbarButton } from "./toolbar";
@@ -72,6 +77,27 @@ const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "En
72
77
  // Код — единственное, что есть и инструментом, и типом блока: имя одно на оба (см. applyCode).
73
78
  const CODE = "code";
74
79
 
80
+ // Расширения текстовых файлов для броска в редактор: у .md и .txt тип в системе часто
81
+ // не зарегистрирован и приходит пустым — тогда решает имя.
82
+ const TEXT_FILE_NAME = /\.(md|markdown|txt|text)$/i;
83
+
84
+ /** Текстовый ли файл: по MIME (text/*), а без типа — по расширению. */
85
+ function isTextFile(file: File): boolean {
86
+ return file.type ? file.type.startsWith("text/") : TEXT_FILE_NAME.test(file.name);
87
+ }
88
+
89
+ /** Содержимое файла текстом: Blob.text() есть не во всех окружениях (jsdom) — тогда FileReader. */
90
+ function readFileText(file: File): Promise<string> {
91
+ if (typeof file.text === "function") return file.text();
92
+
93
+ return new Promise((resolve, reject) => {
94
+ const reader = new FileReader();
95
+ reader.onload = () => resolve(String(reader.result ?? ""));
96
+ reader.onerror = () => reject(reader.error);
97
+ reader.readAsText(file);
98
+ });
99
+ }
100
+
75
101
  // Ссылка — единственный инструмент с данными: адрес задаётся не переключением (см. applyLink).
76
102
  const LINK: FormatTool = "link";
77
103
 
@@ -109,6 +135,65 @@ function isHotkeyLetter(e: KeyboardEvent, letter: string): boolean {
109
135
  return e.key.toLowerCase() === letter || codeLetter(e) === letter;
110
136
  }
111
137
 
138
+ /** Коробка строки — какую высоту занимает строка текста. */
139
+ type LineBox = { top: number; bottom: number; height: number };
140
+
141
+ /**
142
+ * Коробки строк, занятых узлом. У переносимого текста их несколько, и общая коробка накрывает
143
+ * их все — для доводки прокрутки нужна конкретная строка, поэтому берём список, а не объединение.
144
+ * В среде без раскладки (jsdom) список пуст, там остаётся общая коробка.
145
+ */
146
+ function lineRects(node: Node): LineBox[] {
147
+ let source: { getClientRects?: () => DOMRectList; getBoundingClientRect?: () => DOMRect } | null = null;
148
+
149
+ if (node.nodeType === Node.TEXT_NODE) {
150
+ const range = node.ownerDocument?.createRange();
151
+ if (range) {
152
+ range.selectNodeContents(node);
153
+ source = range;
154
+ }
155
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
156
+ source = node as Element;
157
+ }
158
+
159
+ if (typeof source?.getBoundingClientRect !== "function") return [];
160
+
161
+ const rects = Array.from(source.getClientRects?.() ?? []);
162
+ return rects.length ? rects : [source.getBoundingClientRect()];
163
+ }
164
+
165
+ /**
166
+ * Строка, на которой стоит каретка, когда собственной коробки у неё нет — так бывает на пустой
167
+ * строке. Ищем ближайший измеримый ориентир на ТОЙ ЖЕ строке.
168
+ */
169
+ function caretLineBox(range: Range): LineBox | null {
170
+ const { startContainer: node, startOffset: offset } = range;
171
+
172
+ if (node.nodeType === Node.ELEMENT_NODE) {
173
+ // Узел ПОСЛЕ каретки стоит на её строке: <br> рисуется в конце строки, которую завершает,
174
+ // а текст за кареткой с этой строки начинается. Узел ДО каретки лежит строкой выше —
175
+ // по нему прокрутка недоезжала ровно на строку.
176
+ const next = node.childNodes[offset];
177
+ if (next) return lineRects(next)[0] ?? null;
178
+
179
+ // Каретка за последним узлом: своей строки у неё ещё нет, а предыдущий <br> завершает
180
+ // предыдущую — она ровно на строку ниже него.
181
+ const previous = node.childNodes[offset - 1];
182
+ if (previous) {
183
+ const rects = lineRects(previous);
184
+ const last = rects[rects.length - 1];
185
+ if (!last) return null;
186
+
187
+ const shift = previous.nodeName === "BR" ? last.height : 0;
188
+ return { top: last.top + shift, bottom: last.bottom + shift, height: last.height };
189
+ }
190
+ }
191
+
192
+ // Соседей нет вовсе: пустой блок и есть строка каретки.
193
+ const own = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
194
+ return own ? (lineRects(own)[0] ?? null) : null;
195
+ }
196
+
112
197
  export interface RichEditorOptions {
113
198
  /** Включает форматирование и панель инструментов. */
114
199
  format?: boolean;
@@ -142,6 +227,12 @@ export interface RichEditorOptions {
142
227
  keepFocus?: boolean;
143
228
  /** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
144
229
  readonly?: boolean;
230
+ /**
231
+ * Полное отключение: тот же запрет правок, что и readonly, но вдобавок с редактируемого
232
+ * элемента снимается contenteditable — он не берёт ни фокус, ни выделение (в отличие от
233
+ * readonly, где выделение и копирование остаются).
234
+ */
235
+ disabled?: boolean;
145
236
  /** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
146
237
  toolbarContainer?: HTMLElement | null;
147
238
  /** Собственные кнопки хоста в панели — для действий, которых редактор не знает. */
@@ -215,7 +306,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
215
306
  constructor(editable: HTMLElement, options: RichEditorOptions = {}) {
216
307
  const format = !!options.format;
217
308
  const multiline = !!options.multiline;
218
- const readonly = !!options.readonly;
309
+ // disabled — тот же запрет правок, что и readonly, поэтому вся readonly-механика
310
+ // (блокировка ввода, снятие кнопок, выделение-только-чтение) действует и в нём
311
+ const disabled = !!options.disabled;
312
+ const readonly = !!options.readonly || disabled;
219
313
  // Объявленный набор — от readonly не зависит: значение обязано разбираться и показываться
220
314
  // и там, где разметку не переключить, иначе редактор для чтения показывал бы вместо
221
315
  // жирного сырые звёздочки. Кнопки — уже без него.
@@ -252,8 +346,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
252
346
  this.__history = format ? new EditorHistory(editable) : null;
253
347
 
254
348
  // редактируемость есть всегда; правки в readonly блокируются на beforeinput,
255
- // при этом остаётся возможность фокуса, выделения и копирования
256
- editable.contentEditable = "true";
349
+ // при этом остаётся возможность фокуса, выделения и копирования.
350
+ // В disabled редактируемости нет вовсе: элемент не берёт ни фокус, ни выделение.
351
+ editable.contentEditable = disabled ? "false" : "true";
257
352
 
258
353
  if (options.placeholder != null) editable.dataset.placeholder = options.placeholder;
259
354
  if (multiline) editable.classList.add("multiline");
@@ -266,7 +361,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
266
361
  }
267
362
 
268
363
  get readonly(): boolean {
269
- return !!this.__opts.readonly;
364
+ // disabled включает весь запрет правок readonly: на этот getter опирается вся
365
+ // readonly-механика редактора, и в disabled она обязана работать так же
366
+ return !!this.__opts.readonly || this.disabled;
367
+ }
368
+
369
+ /** Редактор отключён: как readonly, но без фокуса и выделения (contenteditable снят). */
370
+ get disabled(): boolean {
371
+ return !!this.__opts.disabled;
270
372
  }
271
373
 
272
374
  /**
@@ -359,6 +461,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
359
461
  // прятать его носителя незачем и нечем.
360
462
  const inToolbar = !!picker.closest(`.${TOOLBAR_CLASS}`);
361
463
 
464
+ // Недавние — по хранилищу на момент показа: попап живёт между открытиями, а хранилище
465
+ // тем временем пополняют и другие попапы страницы.
466
+ refreshRecentEmojis(picker);
467
+
362
468
  PopupManager.open(picker, {
363
469
  initiator,
364
470
  onClose: () => {
@@ -523,6 +629,40 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
523
629
  selection.addRange(range);
524
630
  }
525
631
 
632
+ /**
633
+ * Слово, на котором стоит каретка. Пусто, когда выделение своё (пользователь выбрал сам,
634
+ * и подставлять ему нечего), когда каретки в редакторе нет вовсе или когда она стоит не
635
+ * в слове — за точкой, например: знаки препинания в слово не входят (см. expandRangeToWords).
636
+ */
637
+ get caretWord(): string {
638
+ const selection = this.selection;
639
+ if (!selection || selection.rangeCount === 0) return "";
640
+
641
+ const range = selection.getRangeAt(0);
642
+ if (!range.collapsed) return "";
643
+
644
+ return expandRangeToWords(this.editable, range).toString();
645
+ }
646
+
647
+ /**
648
+ * Выделить слово под кареткой — чтобы следующая вставка встала на его место, а не разорвала
649
+ * его пополам. Возвращает false, когда выделять нечего: тогда вставка идёт в точку каретки.
650
+ */
651
+ selectCaretWord(): boolean {
652
+ const selection = documentSelection(this.editable);
653
+ if (!selection || selection.rangeCount === 0) return false;
654
+
655
+ const range = selection.getRangeAt(0);
656
+ if (!range.collapsed) return false;
657
+
658
+ const word = expandRangeToWords(this.editable, range);
659
+ if (word.collapsed) return false;
660
+
661
+ selection.removeAllRanges();
662
+ selection.addRange(word);
663
+ return true;
664
+ }
665
+
526
666
  /**
527
667
  * Цель правки форматирования: выделение, расширенное до целых слов, плюс исходные границы
528
668
  * для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
@@ -551,10 +691,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
551
691
  * оставляют написанное буквальным, поэтому остальные инструменты там выключены — иначе
552
692
  * форматирование ставилось бы только чтобы пропасть при сохранении.
553
693
  */
554
- isToolEnabled(tool: FormatTool): boolean {
694
+ isToolEnabled(tool: FormatTool, codeActive?: boolean): boolean {
555
695
  if (this.readonly || !this.formatTools.includes(tool)) return false;
556
696
  if (tool === CODE) return true;
557
- if (this.currentBlock === CODE || this.isToolActive(CODE)) return false;
697
+ // Признак «в коде» обход выделения: панель считает его один раз на обновление
698
+ // и передаёт сюда, чтобы не обходить выделение заново на каждую кнопку.
699
+ if (codeActive ?? this.isCodeActive()) return false;
558
700
 
559
701
  // Ссылке нужен текст, который ею станет: оборачивать нечего — и делать нечего. Каретка
560
702
  // в слове за текст считается (формат применяется к слову целиком), в готовой ссылке —
@@ -599,6 +741,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
599
741
  const target = this.__formatTarget();
600
742
  if (!target) return;
601
743
 
744
+ // Выделение без единого символа правки не даёт (см. applyFormat) — кроме каретки
745
+ // в готовой ссылке, где правится её адрес.
746
+ if (!target.range.toString().length && !linkAt(this.editable, target.range)) return;
747
+
602
748
  this.__history?.record("op");
603
749
  applyLinkTo(this.editable, target.range, url.trim(), target.selection, target.original());
604
750
 
@@ -641,6 +787,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
641
787
 
642
788
  this.__pendingFormats.clear();
643
789
 
790
+ // Выделение без единого символа (граница элементов, голый перенос) правки не даёт —
791
+ // пустой шаг истории делал бы следующий Ctrl+Z «ничего не делающим».
792
+ if (!target.range.toString().length) return;
793
+
644
794
  this.__history?.record("op");
645
795
 
646
796
  // Код не уживается с другой разметкой: значение берёт из него голый текст. Снимаем всё,
@@ -1086,9 +1236,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1086
1236
  const { signal } = this.__abort;
1087
1237
  const editable = this.editable;
1088
1238
 
1089
- // перетаскивание в редактор проходит мимо истории и фильтров хоста гасим саму вставку.
1239
+ // Бросок в редактор: текстовые файлы принимаются содержимым (см. __onDrop), всё прочее
1240
+ // гасится — свободное перетаскивание прошло бы мимо истории и фильтров хоста.
1090
1241
  // dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
1091
- this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
1242
+ this.element.addEventListener("drop", (e) => this.__onDrop(e), { signal });
1092
1243
 
1093
1244
  // Выделение страницы не должно затягивать содержимое редактора — см. __holdSelectable.
1094
1245
  // Слушаем на документе и в фазе перехвата: протяжка начинается где угодно, а обработчик
@@ -1133,7 +1284,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1133
1284
  // держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
1134
1285
  formatToolbar.attach(this);
1135
1286
 
1136
- if (this.readonly) selectAllContent(this.editable);
1287
+ // В readonly фокус выделяет всё содержимое — его можно копировать;
1288
+ // в disabled выделения нет, как и самого содержимого для правки.
1289
+ if (this.readonly) {
1290
+ if (!this.disabled) selectAllContent(this.editable);
1291
+ }
1137
1292
  // Каретку в конец ставим только когда её нет: клик ставит сам, а уже стоящую
1138
1293
  // (фокус вернули из кода после вызова метода) двигать нельзя — уедет в конец текста.
1139
1294
  else if (!this.__hasInputClick && !this.selection) caretToEnd(this.editable, this.multiline);
@@ -1175,21 +1330,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1175
1330
  this.element.addEventListener("paste", (e: ClipboardEvent) => this.__onPaste(e), { signal });
1176
1331
  editable.addEventListener("keydown", (e: KeyboardEvent) => this.__onKeydown(e), { signal });
1177
1332
 
1333
+ // Каретку двигали клавишами: браузер к ней прокручивает сам, но до края коробки —
1334
+ // под отступы прокручиваемого контейнера. Поправляем после него, на keyup: на keydown
1335
+ // своей прокрутки он ещё не сделал, и поправка ушла бы впустую.
1336
+ editable.addEventListener(
1337
+ "keyup",
1338
+ (e: KeyboardEvent) => {
1339
+ if (NAV_KEYS.includes(e.key)) this.__scrollCaretIntoView();
1340
+ },
1341
+ { signal }
1342
+ );
1343
+
1178
1344
  editable.addEventListener("beforeinput", (e: InputEvent) => this.__onBeforeInput(e), { signal });
1179
1345
 
1180
1346
  editable.addEventListener(
1181
1347
  "input",
1182
1348
  () => {
1183
1349
  if (this.multiline) {
1184
- // приведение к абзацам меняет структуру (обёртка в <p>, удаление <br>) и сбрасывает
1185
- // каретку — сохраняем её; если структура не менялась, выделение живо и переставлять
1186
- // его не нужно (лишний сброс способен прервать IME-набор)
1187
- preserveCaret(editable, () => {
1188
- const before = editable.innerHTML;
1189
- ensureParagraphs(editable); // блуждающий текст/div → <p>
1190
-
1191
- return editable.innerHTML !== before;
1192
- });
1350
+ this.__ensureParagraphs(); // блуждающий текст/div <p>
1193
1351
 
1194
1352
  // единственный пустой абзац → очищаем, чтобы показать placeholder
1195
1353
  if (editable.children.length === 1) {
@@ -1199,12 +1357,85 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1199
1357
  } else if (editable.firstChild?.nodeName === "BR") {
1200
1358
  editable.innerHTML = "";
1201
1359
  }
1360
+ // При наборе браузер прокручивает к каретке сам, но доводит её лишь до края
1361
+ // коробки — под отступы контейнера. Доводим до текста.
1362
+ this.__scrollCaretIntoView();
1363
+
1202
1364
  this.__emitChange(true); // печать — единственный посимвольный источник, его и откладываем
1203
1365
  },
1204
1366
  { signal }
1205
1367
  );
1206
1368
  }
1207
1369
 
1370
+ /**
1371
+ * Доводит прокрутку до каретки. Нужна там, где браузер не справляется сам: переносы строк
1372
+ * редактор делает вручную (preventDefault), и к новой строке браузер не прокручивает; в
1373
+ * textbox прокручивается не редактируемый элемент, а его обёртка; а своей прокруткой браузер
1374
+ * доводит каретку лишь до края коробки — под отступы, которые едут вместе с текстом.
1375
+ */
1376
+ private __scrollCaretIntoView() {
1377
+ const selection = this.selection;
1378
+ if (!selection || selection.rangeCount === 0) return;
1379
+
1380
+ const range = selection.getRangeAt(0).cloneRange();
1381
+
1382
+ // в среде без раскладки (jsdom) у Range коробок нет вовсе
1383
+ if (typeof range.getBoundingClientRect !== "function") return;
1384
+
1385
+ // Выделение тянут клавишами (Shift+стрелки): вести нужно за его подвижным концом.
1386
+ // Коробка всего выделения накрывает все занятые строки, и прокрутка ушла бы к концу
1387
+ // неподвижному, уводя подвижный за пределы видимого.
1388
+ if (!range.collapsed) {
1389
+ const toStart = selection.focusNode === range.startContainer && selection.focusOffset === range.startOffset;
1390
+ range.collapse(toStart);
1391
+ }
1392
+
1393
+ let { top, bottom, height } = range.getBoundingClientRect();
1394
+ if (!height) {
1395
+ const line = caretLineBox(range);
1396
+ if (!line) return;
1397
+ ({ top, bottom, height } = line);
1398
+ }
1399
+ // раскладки нет (тестовая среда) — прокручивать не по чему
1400
+ if (!height && !top && !bottom) return;
1401
+
1402
+ // Ближайший прокручиваемый предок; прокручивается только он — внешним контейнерам
1403
+ // каретка не адресована, крутить страницу из редактора нельзя.
1404
+ const document = this.editable.ownerDocument;
1405
+ for (let el: HTMLElement | null = this.editable; el && el !== document.body; el = el.parentElement) {
1406
+ if (el.scrollHeight <= el.clientHeight) continue;
1407
+ const style = document.defaultView?.getComputedStyle(el);
1408
+ if (!style) continue;
1409
+ const overflow = style.overflowY;
1410
+ if (overflow !== "auto" && overflow !== "scroll") continue;
1411
+
1412
+ // Границей служит не коробка, а область, свободная от отступов: они едут вместе
1413
+ // с текстом (см. .editor в textbox, .ui-richeditor в messageeditor), и доведённая
1414
+ // до края коробки строка встала бы под них. Своего отступа у контейнера может
1415
+ // и не быть — тогда держим небольшой запас, чтобы строка не прилипала к краю.
1416
+ const gap = Math.min(height || 16, 16);
1417
+ const box = el.getBoundingClientRect();
1418
+ const viewTop = box.top + el.clientTop + (parseFloat(style.paddingTop) || gap);
1419
+ const viewBottom = box.top + el.clientTop + el.clientHeight - (parseFloat(style.paddingBottom) || gap);
1420
+
1421
+ if (bottom > viewBottom) el.scrollTop += bottom - viewBottom;
1422
+ else if (top < viewTop) el.scrollTop -= viewTop - top;
1423
+ return;
1424
+ }
1425
+ }
1426
+
1427
+ /**
1428
+ * Приведение к абзацам с сохранением каретки. Дешёвая проверка — до снимка каретки:
1429
+ * нормализация идёт на каждое нажатие, менять почти всегда нечего, а снимок собирает
1430
+ * весь текст в строку. Если структура не менялась, выделение живо и переставлять его
1431
+ * не нужно (лишний сброс способен прервать IME-набор).
1432
+ */
1433
+ private __ensureParagraphs() {
1434
+ if (paragraphsNormalized(this.editable)) return;
1435
+
1436
+ preserveCaret(this.editable, () => ensureParagraphs(this.editable));
1437
+ }
1438
+
1208
1439
  private __onKeydown(e: KeyboardEvent) {
1209
1440
  // хоткеи форматирования (Ctrl/Cmd+B/I/U); зачёркивание — только кнопкой.
1210
1441
  // Сверяем по e.key и e.code — иначе на не-латинской раскладке (кириллица) хоткеи не срабатывают.
@@ -1291,6 +1522,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1291
1522
  if (soft) insertSoftBreak(this.editable);
1292
1523
  else insertParagraph(this.editable, DEFAULT_BLOCK);
1293
1524
 
1525
+ // Перенос сделан вручную (preventDefault) — браузер к новой строке не прокрутит
1526
+ this.__scrollCaretIntoView();
1527
+
1294
1528
  this.__clearPendingFormats();
1295
1529
  this.__emitChange();
1296
1530
  formatToolbar.refresh();
@@ -1324,6 +1558,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1324
1558
  // форматированную вставку берём из text/html только при включённом форматировании
1325
1559
  const html = this.format && this.formatTools.length ? data.getData("text/html") : "";
1326
1560
 
1561
+ this.__insertExternal(plain, html);
1562
+ }
1563
+
1564
+ /**
1565
+ * Общий приём внешнего текста — вставки из буфера и брошенного файла: фильтр хоста, затем
1566
+ * форматированная ветка, затем простой текст. Форматирование приходит двумя путями: text/html
1567
+ * из буфера и, при markdown-хранении, маркеры прямо в тексте — раз значение хранится этой
1568
+ * разметкой, вставленный текст с ней разбирается так же, как разбиралось бы значение.
1569
+ */
1570
+ private __insertExternal(plain: string, html: string) {
1327
1571
  // filterPaste решает по тексту: null — отклонить; если хук ИЗМЕНИЛ текст (обрезка по длине,
1328
1572
  // фильтр по типу) — форматирование сохранить нельзя, вставляем очищенный простой текст
1329
1573
  let plainOverride: string | null = null;
@@ -1340,7 +1584,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1340
1584
  const selection = this.selection;
1341
1585
  if (!selection) return;
1342
1586
 
1343
- if (html && plainOverride == null && this.__pasteHtml(html, selection)) return;
1587
+ if (plainOverride == null) {
1588
+ const paras = html ? sanitizePastedHtml(html, this.formatTools, this.formatMarkers, this.blockTypes) : [];
1589
+ const markdown = this.format && this.formatStorage === "markdown";
1590
+
1591
+ // text/html точнее описывает скопированное, но лишь когда несёт собственную разметку.
1592
+ // Голый текст тоже приезжает html-ем — редакторы кода отдают исходник маркдауна
1593
+ // строками в <div>, — и такой html не знает ничего сверх text/plain, а маркеры в
1594
+ // тексте понимает только разбор разметкой хранения: при плоском html первым идёт он.
1595
+ // Документ (абзацы <p>, заголовки, списки) плоским не считается, даже когда в нём
1596
+ // нет ни одного тега форматирования: буквальные символы маркеров в тексте страницы
1597
+ // видит и её читатель, и разбор превращал бы их в разметку (см. isDocumentHtml).
1598
+ const flat = markdown && !hasPastedMarkup(paras) && !isDocumentHtml(html);
1599
+
1600
+ if (flat && this.__pasteMarkdown(plain, selection)) return;
1601
+ if (this.__insertPasted(paras, selection)) return;
1602
+ if (!flat && markdown && this.__pasteMarkdown(plain, selection)) return;
1603
+ }
1344
1604
  this.__pastePlain(plainOverride ?? plain, selection);
1345
1605
  }
1346
1606
 
@@ -1350,16 +1610,91 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1350
1610
  private __pastePlain(text: string, selection: Selection) {
1351
1611
  if (!text) return;
1352
1612
 
1353
- const lines = text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
1613
+ // Внутри блока без инлайновой разметки (код) пробелы значимы: отступы там часть
1614
+ // текста, и обрезка краёв строк ломала бы вставленный код. Пустая строка там — тоже
1615
+ // содержимое, а не разделитель абзацев: деление разваливало бы один блок на два.
1616
+ const literal = !BLOCK_TYPES[this.currentBlock].inline;
1617
+ const lines = literal
1618
+ ? text.split(/\n/)
1619
+ : text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
1354
1620
 
1355
- this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs), selection);
1621
+ this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs && !literal), selection);
1356
1622
  }
1357
1623
 
1358
- // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего —
1359
- // тогда вызывающий откатывается на простую вставку.
1360
- private __pasteHtml(html: string, selection: Selection): boolean {
1624
+ /**
1625
+ * Бросок файлов в редактор: текстовые (`text/*`, а без типа — `.md`/`.txt` по имени)
1626
+ * вставляются содержимым тем же путём, что вставка из буфера, — фильтр хоста, разбор
1627
+ * разметкой хранения объявленным набором, история. Любой другой бросок гасится: свободное
1628
+ * перетаскивание прошло бы мимо истории и фильтров хоста.
1629
+ */
1630
+ private __onDrop(e: DragEvent) {
1631
+ e.preventDefault();
1632
+
1633
+ if (this.readonly) return;
1634
+
1635
+ const files = Array.from(e.dataTransfer?.files ?? []).filter(isTextFile);
1636
+ if (!files.length) return;
1637
+
1638
+ // Каретка — в точку броска, если браузер умеет её назвать; иначе остаётся прежняя.
1639
+ // Фокус нужен в любом случае: бросают и в поле, которое его ещё не получало, — вставлять
1640
+ // тогда некуда, а без каретки focus(true) ставит её в конец.
1641
+ this.__caretFromPoint(e.clientX, e.clientY);
1642
+ this.focus(true);
1643
+
1644
+ // Файл читается асинхронно: правка на это время придержана, чтобы нормализация между
1645
+ // делом не перестроила содержимое под выставленной кареткой.
1646
+ const release = this.holdEditing();
1647
+ Promise.all(files.map(readFileText))
1648
+ .then((texts) => {
1649
+ // компонент могли снять, пока файл читался, — вставлять больше некуда
1650
+ if (this.__disposed) return;
1651
+
1652
+ // несколько файлов — подряд через пустую строку: это отдельные тексты
1653
+ this.__insertExternal(texts.join("\n\n"), "");
1654
+ })
1655
+ .catch(() => this.__reject())
1656
+ .finally(release);
1657
+ }
1658
+
1659
+ /** Ставит каретку в точку на экране, если браузер умеет её назвать, и точка — в редакторе. */
1660
+ private __caretFromPoint(x: number, y: number) {
1661
+ // стандартное API и его вебкитовский предшественник; в jsdom нет обоих
1662
+ const doc = this.editable.ownerDocument as Document & {
1663
+ caretPositionFromPoint?(x: number, y: number): { offsetNode: Node; offset: number } | null;
1664
+ caretRangeFromPoint?(x: number, y: number): Range | null;
1665
+ };
1666
+
1667
+ let node: Node | null = null;
1668
+ let offset = 0;
1669
+ if (doc.caretPositionFromPoint) {
1670
+ const position = doc.caretPositionFromPoint(x, y);
1671
+ if (position) ({ offsetNode: node, offset } = position);
1672
+ } else if (doc.caretRangeFromPoint) {
1673
+ const range = doc.caretRangeFromPoint(x, y);
1674
+ if (range) ({ startContainer: node, startOffset: offset } = range);
1675
+ }
1676
+
1677
+ if (!node || !this.editable.contains(node)) return;
1678
+
1679
+ const selection = documentSelection(this.editable);
1680
+ if (!selection) return;
1681
+
1682
+ const range = doc.createRange();
1683
+ range.setStart(node, offset);
1684
+ range.collapse(true);
1685
+ selection.removeAllRanges();
1686
+ selection.addRange(range);
1687
+ }
1688
+
1689
+ // Вставка простого текста разметкой хранения (markdown): разбор тем же deserialize и с теми же
1690
+ // наборами, что у значения, — маркеры снятых инструментов остаются текстом, как остались бы
1691
+ // в значении. Возвращает false, если вставлять нечего или разбирать нельзя.
1692
+ private __pasteMarkdown(text: string, selection: Selection): boolean {
1693
+ // внутри блока без инлайновой разметки (код) текст литерален — как и при простой вставке
1694
+ if (!BLOCK_TYPES[this.currentBlock].inline) return false;
1695
+
1361
1696
  return this.__insertPasted(
1362
- sanitizePastedHtml(html, this.formatTools, this.formatMarkers, this.blockTypes),
1697
+ parsePastedMarkdown(text, this.__valueTools, this.formatMarkers, this.blockTypes, this.__blockParagraphs),
1363
1698
  selection
1364
1699
  );
1365
1700
  }
@@ -1383,12 +1718,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1383
1718
  let caret: number;
1384
1719
 
1385
1720
  if (this.multiline) {
1386
- // длина вставки в координатах каретки: текст, переносы внутри абзацев и границы между
1387
- // ними (первый абзац вливается в текущий, поэтому границ на одну меньше)
1388
- caret = start + paras.reduce((length, p) => length + charLength(p), paras.length - 1);
1721
+ // Длина вставки меряется по факту разницей длины содержимого: ensureParagraphs
1722
+ // подчищает вставленное (краевые <br>-заполнители), и посчитанная заранее длина
1723
+ // уводила бы каретку за конец вставленного, в чужой текст.
1724
+ const before = charLength(this.editable);
1389
1725
 
1390
1726
  insertPastedParagraphs(this.editable, paras, range);
1391
1727
  ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
1728
+
1729
+ caret = start + (charLength(this.editable) - before);
1392
1730
  } else {
1393
1731
  // инлайн: абзацы и переносы → пробелы, форматирование сохраняем
1394
1732
  const doc = this.editable.ownerDocument;
@@ -1471,20 +1809,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1471
1809
  const range = selection.getRangeAt(0);
1472
1810
  const inherited = range.collapsed ? [] : activeFormats(this.editable, range, this.formatTools);
1473
1811
  const formats = new Set([...this.__pendingFormats, ...inherited]);
1812
+ // Адрес наследуемой ссылки читается до правки: после deleteContents исходный тег
1813
+ // пустеет и убирается, и адреса взять уже неоткуда.
1814
+ const href = formats.has("link") ? (linkAt(this.editable, range)?.getAttribute("href") ?? "") : "";
1474
1815
 
1475
- insertFormattedText(this.editable, data, Array.from(formats), selection);
1816
+ insertFormattedText(this.editable, data, Array.from(formats), selection, href);
1476
1817
 
1477
1818
  // В пустом поле абзацев ещё нет, и каретке некуда встать внутрь — вставка из кода
1478
1819
  // (смайлик, переменная) ложится прямо в редактор. Приводим к модели абзацев тем же
1479
1820
  // способом, что и обработчик input при печати: он на программную вставку не приходит,
1480
1821
  // а без <p> следующий Enter правит текст мимо абзаца.
1481
- if (this.multiline)
1482
- preserveCaret(this.editable, () => {
1483
- const before = this.editable.innerHTML;
1484
- ensureParagraphs(this.editable);
1822
+ if (this.multiline) this.__ensureParagraphs();
1485
1823
 
1486
- return this.editable.innerHTML !== before;
1487
- });
1824
+ // Вставка сдвинула каретку; пока фокус отпущен, её место хранит снимок — обновляем,
1825
+ // иначе следующая вставка (второй смайлик подряд) легла бы по старому смещению,
1826
+ // ПЕРЕД только что вставленным.
1827
+ if (this.__detachedCaret) this.__detachedCaret = this.caretSnapshot() ?? this.__detachedCaret;
1828
+
1829
+ this.__scrollCaretIntoView(); // вставка из кода браузерной прокрутки к каретке не даёт
1488
1830
 
1489
1831
  this.__emitChange();
1490
1832
  formatToolbar.refresh();