@brandup/ui-richeditor 1.0.49 → 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.
package/README.md CHANGED
@@ -39,6 +39,8 @@ editor.onChange(({ value }) => console.log(value));
39
39
 
40
40
  По умолчанию панель живёт в `document.body` (`position: fixed`) — это защищает её от обрезки `overflow: hidden` у родителей. Если задан `toolbarContainer`, панель монтируется в него и позиционируется относительно него (`position: absolute`, над контейнером) — например, `TextBox` передаёт свой контейнер `.ui-textbox`.
41
41
 
42
+ Панель не шире экрана (и не шире контейнера в режиме `toolbarContainer`), а правый край не уходит за границу: у поля справа она прижимается к краю экрана. Полный набор кнопок, не влезший по ширине — обычное дело на телефоне, — прокручивается внутри `.toolbar-body` по горизонтали: кнопки не переносятся и не прячутся, панель остаётся в одну строку. Полоса прокрутки — общая, от `.ui-scrollable` кита, только тоньше.
43
+
42
44
  ## Опции (`RichEditorOptions`)
43
45
 
44
46
  | Опция | Тип | Описание |
@@ -102,6 +104,8 @@ editor.onChange(({ value }) => console.log(value));
102
104
  | `openEmojiPicker(picker, initiator): boolean` | Показать переданный попап смайликов у кнопки; false — этим нажатием он закрылся |
103
105
  | `selection: Selection \| null` | Выделение, если оно внутри редактора (иначе `null`) — единая точка доступа для хоста |
104
106
  | `selectNode(node): void` | Выделить узел внутри редактора: следующая вставка заменит его целиком |
107
+ | `caretWord: string` | Слово под кареткой; пусто при своём выделении, без каретки или когда каретка не в слове |
108
+ | `selectCaretWord(): boolean` | Выделить слово под кареткой — следующая вставка встанет на его место; `false` — выделять нечего |
105
109
  | `onChange(handler)` | Подписка на событие `richeditor-change` |
106
110
  | `destroy(): void` | Разворачивает элемент обратно и освобождает ресурсы |
107
111
 
@@ -130,6 +134,7 @@ editor.onChange(({ value }) => console.log(value));
130
134
 
131
135
  - Формат — переключатель (toggle): повторное применение снимает его.
132
136
  - Применяется к слову целиком: курсор внутри слова или выделение его части → формат охватывает всё слово; исходное выделение/каретка сохраняются.
137
+ - Слово — то, что стоит между пробелами, **без небуквенных знаков по краям**: каретка в слове перед точкой не отдаёт форматированию точку. Внутренние знаки — часть слова: `info@example.com`, `по-русски`, `don't` берутся целиком. Слово не кончается на границе тега (`Дарим <b>ск</b>идку` — одно слово «скидку»), но не пересекает перенос строки и готовую конструкцию (`contenteditable="false"`). Каретка вне слова (сразу за точкой) не расширяется никуда — включается режим набора. Явное выделение только растёт: выделенные знаки из него не выпадают.
133
138
  - **Режим набора**: на пустом месте (между пробелами / в пустом поле) кнопка/хоткей включают «ожидающий» формат — он применится к следующему введённому тексту. Сбрасывается при перемещении каретки, клике или потере фокуса.
134
139
  - Хоткеи `Ctrl/Cmd+B/I/U` и `Ctrl/Cmd+K` (ссылка — показывает в панели поле адреса). Зачёркивание — только кнопкой.
135
140
  - **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов, но не более ~512 КБ снимков суммарно (снимок — это всё содержимое редактора, поэтому на длинном тексте старые шаги вытесняются раньше).
@@ -166,6 +171,8 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
166
171
 
167
172
  Попап — слой над полем, поэтому показывает его редактор (`openEmojiPicker()`): на время работы он придерживает правку, чтобы нормализация не обрезала пробел у каретки. Хост со своей кнопкой собирает попап сам и передаёт его сюда — так делает [`@brandup/ui-messageeditor`](../brandup-ui-messageeditor).
168
173
 
174
+ Первой группой в списке стоят **недавние** (`.emoji-recent`) — до двух рядов последних выбранных символов, свежий первым. Хранятся они в `localStorage` (ключ `RECENT_EMOJIS_KEY`), поэтому общие для всех попапов источника и переживают перезагрузку; пока ничего не выбрано — группы нет вовсе. Освежает её `openEmojiPicker()` при каждом показе: попап живёт между открытиями, а хранилище тем временем пополняют и другие попапы. Запоминается сам выбор, а не вставка — недавние про то, к чему тянутся. Недоступное хранилище (приватный режим) вставке не мешает — недавние просто не копятся. Пакет экспортирует `recentEmojis()`, `rememberEmoji()` и `refreshRecentEmojis(picker)` — хосту с собственным показом попапа освежать группу нужно самому.
175
+
169
176
  ## Многострочный режим: абзацы и переносы
170
177
 
171
178
  При `multiline: true` контент структурируется по абзацам:
@@ -200,7 +207,9 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
200
207
 
201
208
  Обычный текст — такой же тип, а не «тип не задан»: он есть в наборе всегда, им становится содержимое, не попавшее ни в какой блок, и в него же блок возвращают. Кнопки в панели (`.block-button`) получают только остальные типы.
202
209
 
203
- Кнопка спойлера временно скрыта (`HIDDEN_TOOLS` в `./toolbar`): сам инструмент работает значение разбирается, показывается и сохраняется, правку можно вызвать из кода (`applyFormat`), но в панель он пока не выводится.
210
+ Цитата рисуется плашкой по ширине содержимого, прижатой к левому краю: с подложкой пустое место справа от короткой строки читалось бы её частью. Длинная цитата переносится по границе редактора. Цвета и отступы задаются переменными `--richeditor-quote-*` (см. «CSS»).
211
+
212
+ Открывающая ограда в чужом маркдауне часто приходит с меткой языка (```` ```text ````) — блок она открывает так же, а сама метка отбрасывается: значение кита её не хранит, обратно уезжает голая ограда. Закрывает блок только голая ограда — та же строка с меткой внутри блока остаётся его содержимым.
204
213
 
205
214
  ### Ссылка
206
215
 
@@ -219,7 +228,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
219
228
  | | |
220
229
  | --- | --- |
221
230
  | Выделение | становится ссылкой |
222
- | Каретка в слове | ссылкой становится слово целиком, как и у остальных инструментов |
231
+ | Каретка в слове | ссылкой становится слово целиком, как и у остальных инструментов; знаки внутри — часть слова, поэтому адрес или почта оборачиваются целиком |
223
232
  | Каретка в готовой ссылке | меняется её адрес — целиком, а не по куску выделения |
224
233
  | Каретка вне слова | кнопка недоступна: оборачивать нечего |
225
234
 
@@ -264,13 +273,21 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
264
273
  - **multiline** сохраняет абзацы `<p>` и мягкие переносы `<br>`, разбивая текущий абзац по каретке; **single-line** — инлайн, абзацы/переносы становятся пробелами;
265
274
  - хук `filterPaste` остаётся в силе: вернул `null` — вставка отклоняется; изменил текст (обрезка по длине, фильтр по типу) — форматирование не сохраняется, вставляется очищенный текст.
266
275
 
267
- Если `text/html` нет (или форматирование выключено), вставляется простой текстпо той же модели абзацев:
276
+ При `storage: "markdown"` разметку сохраняет и вставка простого текста: раз значение хранится этой разметкой, маркеры во вставляемом тексте значат то же, что в значении, и разбираются тем же `deserialize` с теми же наборами инструментов и блоков. Ограничение действует и здесь: **при любой вставке применяется только включённый формат** — маркер снятого инструмента остаётся текстом, как остался бы и в значении. Изменённый хуком `filterPaste` текст вставляется буквально, а внутри блока кода текст литерален всегда.
277
+
278
+ `text/html` точнее описывает скопированное, но лишь когда несёт собственную разметку. Голый текст тоже приезжает html-ем — редакторы кода отдают исходник маркдауна строками в `<div>` внутри общей обёртки, — и такой «плоский» html не знает ничего сверх `text/plain`, а маркеры в тексте понимает только разбор разметкой хранения: при плоском html первым идёт он. Границы строк-`<div>` при этом становятся переносами — склейка соседних строк встык потеряла бы и текст, и разметку.
279
+
280
+ Если `text/html` нет (или форматирование выключено), а разбирать маркеры не по чему, вставляется простой текст — по той же модели абзацев:
268
281
 
269
282
  - **multiline**, режим `block` — пустая строка разделяет абзацы `<p>`, одиночный перенос остаётся мягким `<br>`;
270
283
  - **multiline**, режим `break` — абзацы не создаются, все переносы мягкие;
271
284
  - **single-line** — строки склеиваются пробелами.
272
285
 
273
- Каретка в обоих случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
286
+ Каретка во всех случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
287
+
288
+ ### Бросок файла
289
+
290
+ Текстовый файл, брошенный в редактор, вставляется содержимым — тем же путём, что вставка из буфера: хук `filterPaste`, разбор разметкой хранения включённым набором, один шаг истории. Текстовым считается файл с типом `text/*`, а без типа — `.md`/`.markdown`/`.txt`/`.text` по имени: у маркдауна тип в системе часто не зарегистрирован. Несколько файлов вставляются подряд через пустую строку. Каретка встаёт в точку броска, если браузер умеет её назвать (`caretPositionFromPoint`/`caretRangeFromPoint`). Любой другой бросок гасится: свободное перетаскивание прошло бы мимо истории и фильтров хоста.
274
291
 
275
292
  ## Формат хранения
276
293
 
@@ -302,6 +319,10 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
302
319
  | Переменная | По умолчанию | Что задаёт |
303
320
  | --- | --- | --- |
304
321
  | `--richeditor-quote-line` | `rgba(0,0,0,.2)` | Линия слева у цитаты |
322
+ | `--richeditor-quote-line-width` | `3px` | Толщина линии цитаты |
323
+ | `--richeditor-quote-fill` | `rgba(0,0,0,.04)` | Подложка цитаты |
324
+ | `--richeditor-quote-padding-tb` | `5px` | Вертикальный отступ цитаты — тот же, что у абзаца, чтобы строка не прыгала при смене типа блока |
325
+ | `--richeditor-quote-padding-lr` | `10px` | Горизонтальный отступ цитаты — насколько текст отходит от линии и от края подложки |
305
326
  | `--richeditor-code-fill` | `rgba(0,0,0,.06)` | Подложка кода — и моноширинного, и блока |
306
327
  | `--richeditor-code-font` | `ui-monospace, …` | Шрифт кода |
307
328
  | `--richeditor-spoiler-fill` | `rgba(0,0,0,.14)` | Плашка спойлера |
@@ -312,6 +333,7 @@ new RichEditor(elem, { format: true, multiline: true, blocks: [] }); // толь
312
333
  | `--richeditor-underline-room` | `2px` | Место под подчёркивание последней строки: рисуется оно ниже текста, но в раскладке места не занимает, и без запаса его срезает край прокручиваемой коробки |
313
334
  | `--richeditor-toolbar-padding` | `3px` | Поля панели |
314
335
  | `--richeditor-toolbar-button-size` | `34px` | Кнопка панели; по ней же высота поля адреса |
336
+ | `--richeditor-toolbar-edge-gap` | `4px` | Зазор панели от краёв экрана; то же значение зашито константой в позиционировании (`EDGE_GAP`), менять их нужно вместе |
315
337
  | `--richeditor-emoji-size` | `32px` | Ячейка в панели смайликов |
316
338
  | `--richeditor-emoji-rows` | `8` | Запасная высота нарисованной не сразу группы; своё значение панель ставит на каждую группу |
317
339
 
package/package.json CHANGED
@@ -27,12 +27,12 @@
27
27
  "email": "it@brandup.online"
28
28
  },
29
29
  "license": "Apache-2.0",
30
- "version": "1.0.49",
30
+ "version": "1.0.50",
31
31
  "main": "source/index.ts",
32
32
  "types": "source/index.ts",
33
33
  "dependencies": {
34
34
  "@brandup/ui": "^2.0.9",
35
- "@brandup/ui-kit": "^1.0.49"
35
+ "@brandup/ui-kit": "^1.0.50"
36
36
  },
37
37
  "files": [
38
38
  "source",
package/source/editing.ts CHANGED
@@ -519,29 +519,173 @@ export function sanitizePastedHtml(
519
519
  return paras;
520
520
  }
521
521
 
522
+ /**
523
+ * Несёт ли разобранная вставка собственную разметку: блок не-абзац либо инлайновый тег — кроме
524
+ * `<br>`, перенос есть и в простом тексте. Плоская вставка не знает ничего сверх `text/plain`:
525
+ * так выглядит скопированный из редактора кода исходник — голые строки в `<div>`.
526
+ */
527
+ export function hasPastedMarkup(paras: HTMLElement[]): boolean {
528
+ return paras.some(
529
+ (p) => p.tagName !== "P" || Array.from(p.querySelectorAll("*")).some((el) => el.tagName !== "BR")
530
+ );
531
+ }
532
+
533
+ /**
534
+ * Похож ли сырой html буфера на документ, а не на обёртку вокруг голых строк. Смотрим на сырой
535
+ * html, потому что санитизация разницу стирает: и строки-`<div>` из редактора кода, и абзацы
536
+ * веб-страницы выходят из неё одинаковыми `<p>`. А разница решает, чей разбор первый: в документе
537
+ * маркеры в тексте — буквальные символы, которые видит и читатель страницы, в голых строках —
538
+ * разметка хранения.
539
+ */
540
+ export function isDocumentHtml(html: string): boolean {
541
+ const holder = document.createElement("template");
542
+ holder.innerHTML = html;
543
+
544
+ return !!holder.content.querySelector("p, h1, h2, h3, h4, h5, h6, li, table");
545
+ }
546
+
547
+ /**
548
+ * Разбирает простой текст из буфера как markdown — для редакторов, хранящих значение этой
549
+ * разметкой: маркеры во вставляемом тексте значат то же, что в значении, и разбираются тем же
550
+ * `deserialize` с теми же наборами. Пустой результат — вставлять нечего (вызывающий откатится
551
+ * на простой текст).
552
+ *
553
+ * В отличие от {@link sanitizePastedHtml}, пробелы не схлопываются: это текст, а не вёрстка —
554
+ * переносы и отступы в нём и так значат сами себя.
555
+ */
556
+ export function parsePastedMarkdown(
557
+ text: string,
558
+ tools: FormatTool[],
559
+ markers: FormatMarkers,
560
+ types: BlockType[] = [DEFAULT_BLOCK],
561
+ separate = true
562
+ ): HTMLElement[] {
563
+ const holder = document.createElement("template");
564
+ holder.innerHTML = deserialize(text, "markdown", tools, markers, true, types, separate);
565
+
566
+ return Array.from(holder.content.children) as HTMLElement[];
567
+ }
568
+
569
+ // Слово — буквы и цифры любого алфавита плюс подчёркивание. \w здесь не годится: он знает
570
+ // только латиницу, и кириллическое слово по нему словом не будет.
571
+ const WORD_CHAR = /[\p{L}\p{N}_]/u;
572
+
573
+ const isWordChar = (ch: string | undefined) => !!ch && WORD_CHAR.test(ch);
574
+
575
+ /** Кусок текстового потока блока: текстовый узел и его начало в общей строке. */
576
+ type StreamPart = { node: Text; start: number };
577
+
578
+ /**
579
+ * Текстовый поток блока, в котором стоит узел: текст его текстовых узлов одной строкой плюс
580
+ * карта соответствия. Слово не кончается на границе узла — «сло<b>во</b>» это одно слово,
581
+ * и расширение обязано её пересекать. Пересекать нельзя другое:
582
+ * - переносы строк (<br>) — слова по разные стороны переноса разные, в поток идёт разделитель;
583
+ * - готовые конструкции (contenteditable="false" — переменные и спинтакс messageeditor):
584
+ * они атомарны и правятся своим окном, расширению внутри них делать нечего.
585
+ */
586
+ function blockStream(editable: HTMLElement, node: Node): { text: string; parts: StreamPart[] } {
587
+ // Блок — прямой потомок редактора (p, blockquote, pre); текст без блоков лежит в нём самом
588
+ let root: Node = node;
589
+ while (root.parentNode && root.parentNode !== editable) root = root.parentNode;
590
+ if (!root.parentNode) root = editable; // узел вне редактора — поток по самому редактору не собрать
591
+ const scope = root.nodeType === Node.ELEMENT_NODE ? (root as HTMLElement) : editable;
592
+
593
+ let text = "";
594
+ const parts: StreamPart[] = [];
595
+
596
+ const walk = (current: Node) => {
597
+ if (current.nodeType === Node.TEXT_NODE) {
598
+ parts.push({ node: current as Text, start: text.length });
599
+ text += current.textContent ?? "";
600
+ return;
601
+ }
602
+ if (current.nodeType !== Node.ELEMENT_NODE) return;
603
+ const elem = current as HTMLElement;
604
+ if (elem.tagName === "BR" || elem.getAttribute("contenteditable") === "false") {
605
+ text += "\n"; // разделитель: слово через перенос или конструкцию не перепрыгивает
606
+ return;
607
+ }
608
+ for (const child of Array.from(elem.childNodes)) walk(child);
609
+ };
610
+ walk(scope);
611
+
612
+ return { text, parts };
613
+ }
614
+
615
+ /** Позиция в потоке → узел и смещение. Позиция всегда из этого же потока, место найдётся. */
616
+ function streamPoint(parts: StreamPart[], position: number): [Text, number] {
617
+ let holder = parts[0];
618
+ for (const part of parts) {
619
+ if (part.start > position) break;
620
+ holder = part;
621
+ }
622
+ return [holder.node, Math.min(position - holder.start, holder.node.textContent?.length ?? 0)];
623
+ }
624
+
522
625
  /**
523
626
  * Диапазон, расширенный до целых слов на границах (для применения формата к слову целиком).
524
627
  * Возвращает новый Range и не трогает выделение — вызывающий сам решает, править ли по нему
525
628
  * и когда двигать каретку.
629
+ *
630
+ * Слово — то, что стоит между пробелами, без небуквенных знаков по краям: внутренние знаки
631
+ * остаются его частью («info@example.com», «по-русски»), а точка после слова — нет. Иначе
632
+ * каретка в слове перед точкой отдавала бы форматированию и точку, которую туда не просили.
633
+ * Явное выделение только растёт: выделенное вместе со знаками таким и останется. Каретка
634
+ * не в слове вовсе (сразу за точкой) не расширяется никуда — схлопнутый диапазон редактор
635
+ * понимает как «формат для того, что будут набирать».
526
636
  */
527
637
  export function expandRangeToWords(editable: HTMLElement, range: Range): Range {
528
638
  const { startContainer, endContainer } = range;
529
- let startOffset = range.startOffset;
530
- let endOffset = range.endOffset;
531
639
 
532
- if (startContainer.nodeType === Node.TEXT_NODE && editable.contains(startContainer)) {
533
- const text = startContainer.textContent ?? "";
534
- while (startOffset > 0 && !/\s/.test(text[startOffset - 1])) startOffset--;
640
+ const expanded = document.createRange();
641
+ expanded.setStart(startContainer, range.startOffset);
642
+ expanded.setEnd(endContainer, range.endOffset);
643
+
644
+ const expandIn = (container: Node, offset: number): { parts: StreamPart[]; from: number; to: number } | null => {
645
+ if (container.nodeType !== Node.TEXT_NODE || !editable.contains(container)) return null;
646
+
647
+ const { text, parts } = blockStream(editable, container);
648
+ const part = parts.find((candidate) => candidate.node === container);
649
+ if (!part) return null;
650
+ const position = part.start + offset;
651
+
652
+ // до пробелов…
653
+ let from = position;
654
+ let to = position;
655
+ while (from > 0 && !/\s/.test(text[from - 1])) from--;
656
+ while (to < text.length && !/\s/.test(text[to])) to++;
657
+
658
+ // …и без небуквенных знаков по краям
659
+ while (from < to && !isWordChar(text[from])) from++;
660
+ while (to > from && !isWordChar(text[to - 1])) to--;
661
+
662
+ return { parts, from, to };
663
+ };
664
+
665
+ const start = expandIn(startContainer, range.startOffset);
666
+ const end = range.collapsed ? start : expandIn(endContainer, range.endOffset);
667
+
668
+ if (range.collapsed) {
669
+ // каретка стоит вне слова (за точкой) — расширять нечего
670
+ if (!start || start.from >= start.to) return expanded;
671
+ const position = start.parts.find((part) => part.node === startContainer)!.start + range.startOffset;
672
+ if (position < start.from || position > start.to) return expanded;
673
+
674
+ expanded.setStart(...streamPoint(start.parts, start.from));
675
+ expanded.setEnd(...streamPoint(start.parts, start.to));
676
+ return expanded;
535
677
  }
536
678
 
537
- if (endContainer.nodeType === Node.TEXT_NODE && editable.contains(endContainer)) {
538
- const text = endContainer.textContent ?? "";
539
- while (endOffset < text.length && !/\s/.test(text[endOffset])) endOffset++;
679
+ // выделение только растёт: правая часть слова добирается, выделенные знаки не выпадают
680
+ if (start && start.from < start.to) {
681
+ const [node, offset] = streamPoint(start.parts, start.from);
682
+ if (expanded.comparePoint(node, offset) < 0) expanded.setStart(node, offset);
683
+ }
684
+ if (end && end.from < end.to) {
685
+ const [node, offset] = streamPoint(end.parts, end.to);
686
+ if (expanded.comparePoint(node, offset) > 0) expanded.setEnd(node, offset);
540
687
  }
541
688
 
542
- const expanded = document.createRange();
543
- expanded.setStart(startContainer, startOffset);
544
- expanded.setEnd(endContainer, endOffset);
545
689
  return expanded;
546
690
  }
547
691
 
package/source/emoji.ts CHANGED
@@ -164,6 +164,97 @@ function buildEmojiGroup(group: EmojiGroup): HTMLElement {
164
164
  return elem;
165
165
  }
166
166
 
167
+ // --- недавние ---
168
+
169
+ /** Ключ localStorage со списком недавних смайликов — один на все попапы источника. */
170
+ export const RECENT_EMOJIS_KEY = "brandup-richeditor-recent-emojis";
171
+
172
+ /** Группа недавних в попапе: стоит первой и пересобирается из хранилища при каждом открытии. */
173
+ export const RECENT_GROUP_CLASS = "emoji-recent";
174
+
175
+ /** Сколько недавних хранится и показывается: два ряда панели. */
176
+ export const RECENT_EMOJIS_LIMIT = EMOJI_COLUMNS * 2;
177
+
178
+ // в панели название не показывается, уходит в подпись для скринридера — как у остальных групп
179
+ const RECENT_TITLE = "Недавние";
180
+
181
+ const KNOWN_EMOJIS = new Set(EMOJIS);
182
+
183
+ /**
184
+ * Недавно вставленные смайлики, свежий первым.
185
+ *
186
+ * Хранилище общее и переживает версии пакета, поэтому список чистится до символов, которые
187
+ * панель действительно показывает: мусор и дубликаты отбрасываются. Недоступное или битое
188
+ * хранилище (приватный режим, правленое руками значение) — это пустой список, а не ошибка.
189
+ */
190
+ export function recentEmojis(): string[] {
191
+ let raw: string | null;
192
+ try {
193
+ raw = localStorage.getItem(RECENT_EMOJIS_KEY);
194
+ } catch {
195
+ return [];
196
+ }
197
+ if (!raw) return [];
198
+
199
+ let parsed: unknown;
200
+ try {
201
+ parsed = JSON.parse(raw);
202
+ } catch {
203
+ return [];
204
+ }
205
+ if (!Array.isArray(parsed)) return [];
206
+
207
+ // Set сохраняет порядок вставки, а повторное add место не меняет — первый и остаётся
208
+ const recent = new Set<string>();
209
+ for (const item of parsed) {
210
+ if (typeof item === "string" && KNOWN_EMOJIS.has(item)) recent.add(item);
211
+ if (recent.size === RECENT_EMOJIS_LIMIT) break;
212
+ }
213
+
214
+ return Array.from(recent);
215
+ }
216
+
217
+ /**
218
+ * Запоминает выбор для группы недавних: символ встаёт первым, дубликат схлопывается, хвост за
219
+ * лимитом отбрасывается. Недоступное хранилище вставке не мешает — недавние просто не копятся.
220
+ */
221
+ export function rememberEmoji(emoji: string): void {
222
+ if (!KNOWN_EMOJIS.has(emoji)) return;
223
+
224
+ const next = [emoji, ...recentEmojis().filter((other) => other !== emoji)].slice(0, RECENT_EMOJIS_LIMIT);
225
+ try {
226
+ localStorage.setItem(RECENT_EMOJIS_KEY, JSON.stringify(next));
227
+ } catch {
228
+ // приватный режим или переполненная квота — вставка работает, недавние не запоминаются
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Пересобирает группу недавних в собранном попапе по текущему хранилищу.
234
+ *
235
+ * Зовётся при каждом показе (см. `openEmojiPicker` в ./richeditor): сам попап живёт между
236
+ * открытиями, а хранилище тем временем пополняют и другие попапы страницы. Без недавних группы
237
+ * нет вовсе — пустая первая группа рисовала бы лишнюю отбивку над списком.
238
+ */
239
+ export function refreshRecentEmojis(picker: HTMLElement): void {
240
+ const list = picker.querySelector(".emoji-list");
241
+ if (!list) return;
242
+
243
+ const existing = list.querySelector(`.${RECENT_GROUP_CLASS}`);
244
+ const recent = recentEmojis();
245
+
246
+ if (!recent.length) {
247
+ existing?.remove();
248
+ return;
249
+ }
250
+
251
+ const group = buildEmojiGroup({ title: RECENT_TITLE, emojis: recent });
252
+ group.classList.add(RECENT_GROUP_CLASS);
253
+
254
+ if (existing) existing.replaceWith(group);
255
+ else list.prepend(group);
256
+ }
257
+
167
258
  /**
168
259
  * Собирает попап вставки смайлика.
169
260
  *
@@ -172,7 +263,8 @@ function buildEmojiGroup(group: EmojiGroup): HTMLElement {
172
263
  * два разом всё равно нельзя — {@link PopupManager} держит открытым один.
173
264
  *
174
265
  * Показом и закрытием занимается вызывающий (у редактора для этого есть `openEmojiPicker`):
175
- * здесь только разметка и выбор символа.
266
+ * здесь только разметка и выбор символа. Первой группой — недавние ({@link refreshRecentEmojis});
267
+ * показ обязан освежать её сам, здесь она собирается по состоянию хранилища на сейчас.
176
268
  */
177
269
  export function createEmojiPicker(onPick: (emoji: string) => void): HTMLElement {
178
270
  const picker = DOM.tag("div", { class: `${POPUP_CLASS} ${EMOJI_PICKER_CLASS}` });
@@ -183,6 +275,7 @@ export function createEmojiPicker(onPick: (emoji: string) => void): HTMLElement
183
275
  picker.appendChild(list);
184
276
 
185
277
  for (const group of EMOJI_GROUPS) list.appendChild(buildEmojiGroup(group));
278
+ refreshRecentEmojis(picker);
186
279
 
187
280
  // попап живёт и вне панели, поэтому фокус гасит сам
188
281
  picker.addEventListener("mousedown", (e) => e.preventDefault());
@@ -190,7 +283,11 @@ export function createEmojiPicker(onPick: (emoji: string) => void): HTMLElement
190
283
  const target = (e.target as HTMLElement).closest<HTMLElement>(".emoji");
191
284
  if (!target) return;
192
285
 
193
- onPick(target.textContent ?? "");
286
+ const emoji = target.textContent ?? "";
287
+ // Недавние — про то, к чему тянутся, поэтому запоминается сам выбор, а не вставка:
288
+ // удалась ли она (filterChar, снятый редактор), знает только владелец попапа.
289
+ rememberEmoji(emoji);
290
+ onPick(emoji);
194
291
  PopupManager.close();
195
292
  });
196
293
 
package/source/index.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  export { default } from "./richeditor";
2
2
  export * from "./richeditor";
3
- export { EMOJIS, EMOJI_GROUPS, EMOJI_PICKER_CLASS, createEmojiPicker, type EmojiGroup } from "./emoji";
3
+ export {
4
+ EMOJIS,
5
+ EMOJI_GROUPS,
6
+ EMOJI_PICKER_CLASS,
7
+ RECENT_EMOJIS_KEY,
8
+ RECENT_EMOJIS_LIMIT,
9
+ RECENT_GROUP_CLASS,
10
+ createEmojiPicker,
11
+ recentEmojis,
12
+ rememberEmoji,
13
+ refreshRecentEmojis,
14
+ type EmojiGroup,
15
+ } from "./emoji";
4
16
  export {
5
17
  ALL_BLOCK_TYPES,
6
18
  ALL_EDITOR_ACTIONS,
@@ -13,8 +13,14 @@
13
13
  @richeditor-border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
14
14
 
15
15
  :root {
16
- // цитата: линия слева, как её рисуют мессенджеры
17
- --richeditor-quote-line: rgba(0, 0, 0, 0.2);
16
+ // Цитата: линия слева и подложка, как её рисуют мессенджеры. Вертикальный отступ — тот же,
17
+ // что у абзаца: строка не должна прыгать при смене типа блока. Горизонтальный задаётся
18
+ // отдельно — от него зависит, насколько текст цитаты отходит от линии.
19
+ --richeditor-quote-line: #1872bc;
20
+ --richeditor-quote-line-width: 3px;
21
+ --richeditor-quote-fill: rgba(0, 0, 0, 0.04);
22
+ --richeditor-quote-padding-tb: 5px;
23
+ --richeditor-quote-padding-lr: 10px;
18
24
 
19
25
  // код — и моноширинный, и блок
20
26
  --richeditor-code-fill: rgba(0, 0, 0, 0.06);
@@ -46,6 +52,8 @@
46
52
  // с ним не связана, а вычитание отступов задавало бы размер дважды и могло не сойтись.
47
53
  --richeditor-toolbar-padding: 3px;
48
54
  --richeditor-toolbar-button-size: 34px;
55
+ // зазор панели от краёв экрана; то же значение — в EDGE_GAP (toolbar.ts)
56
+ --richeditor-toolbar-edge-gap: 4px;
49
57
 
50
58
  // панель смайликов
51
59
  --richeditor-emoji-size: 32px;
@@ -79,12 +87,20 @@
79
87
  }
80
88
  }
81
89
 
82
- // Цитата: линия слева, как её рисуют мессенджеры. Вертикальные поля те же, что у абзаца, —
83
- // строка не должна прыгать при смене типа блока.
90
+ // Цитата: линия слева и подложка, как её рисуют мессенджеры. Значения переменными
91
+ // (см. :root выше): подложке нужен и отступ справа, иначе текст упирался бы в её край.
84
92
  & blockquote {
85
93
  margin: 0;
86
- padding: 5px 0 5px 10px;
87
- border-left: 3px solid var(--richeditor-quote-line);
94
+ // Ширина по содержимому, а не во всю строку: с подложкой цитата — это плашка, и пустое
95
+ // место справа от короткой строки читалось бы её частью. Прижата влево, потому что
96
+ // поля по краям не заданы; длинная цитата переносится по границе редактора, а не вылезает
97
+ // за неё — на это max-width.
98
+ width: fit-content;
99
+ max-width: 100%;
100
+ padding: var(--richeditor-quote-padding-tb) var(--richeditor-quote-padding-lr);
101
+ border-left: var(--richeditor-quote-line-width) solid var(--richeditor-quote-line);
102
+ background-color: var(--richeditor-quote-fill);
103
+ border-radius: 3px;
88
104
  }
89
105
 
90
106
  // Блок кода: подложка на всю строку и моноширинный шрифт. Собственные поля тут нужны —
@@ -204,6 +220,9 @@
204
220
  display: none;
205
221
  position: fixed;
206
222
  z-index: 1000;
223
+ // Не шире экрана: полный набор кнопок на телефоне в строку не влезает, и без ограничения
224
+ // панель уезжала бы за правый край. Что не поместилось — прокручивается (см. .toolbar-body).
225
+ max-width: calc(100vw - var(--richeditor-toolbar-edge-gap) * 2);
207
226
 
208
227
  --svg-size: 18px;
209
228
  --svg-fill: var(--input-color, #222);
@@ -218,6 +237,7 @@
218
237
  left: 0;
219
238
  bottom: 100%;
220
239
  margin-bottom: 6px;
240
+ max-width: 100%; // предел задаёт контейнер: панель не шире поля, над которым стоит
221
241
 
222
242
  &.link-editing {
223
243
  right: 0;
@@ -229,6 +249,17 @@
229
249
  flex-flow: row nowrap;
230
250
  align-items: center;
231
251
  gap: 2px;
252
+ // Что не поместилось по ширине — прокручивается: кнопки не переносятся и не прячутся,
253
+ // панель остаётся в одну строку. Полоса появляется только когда набор не влез.
254
+ max-width: 100%;
255
+ overflow-x: auto;
256
+ // дойдя до конца, не пролистывать страницу под панелью и не вызывать жест «назад»
257
+ overscroll-behavior-x: contain;
258
+ // Полоса тоньше обычной: она внутри панели высотой в одну кнопку, и обычная
259
+ // заметно её раздувала бы.
260
+ --scrollbar-size: 3px;
261
+ --scrollbar-edge-inset: 2px;
262
+
232
263
  padding: var(--richeditor-toolbar-padding);
233
264
  background-color: var(--input-fill, #fff);
234
265
  border: @richeditor-border;
@@ -240,6 +271,9 @@
240
271
 
241
272
  // разделитель между инструментами форматирования и действиями
242
273
  & .split {
274
+ // Сжиматься нельзя: кнопки этого не умеют (flex: 0 0 auto), и всю нехватку ширины
275
+ // выбирал бы один разделитель — при полном наборе на узком экране он схлопывался в ноль.
276
+ flex: 0 0 auto;
243
277
  width: 1px;
244
278
  align-self: stretch;
245
279
  margin: 2px 3px;
@@ -51,14 +51,18 @@ import {
51
51
  buildParagraphs,
52
52
  caretToEnd,
53
53
  expandRangeToWords,
54
+ hasPastedMarkup,
55
+ isDocumentHtml,
54
56
  insertParagraph,
55
57
  insertPastedParagraphs,
56
58
  insertSoftBreak,
59
+ parsePastedMarkdown,
57
60
  sanitizePastedHtml,
58
61
  selectAllContent,
59
62
  trimSelectionWhitespace,
60
63
  } from "./editing";
61
64
  import { EditorHistory } from "./history";
65
+ import { refreshRecentEmojis } from "./emoji";
62
66
  import { formatToolbar, TOOLBAR_CLASS, type ToolbarButton } from "./toolbar";
63
67
 
64
68
  export { formatToolbar, TOOLBAR_CLASS, type ToolbarHost, type ToolbarButton } from "./toolbar";
@@ -73,6 +77,27 @@ const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "En
73
77
  // Код — единственное, что есть и инструментом, и типом блока: имя одно на оба (см. applyCode).
74
78
  const CODE = "code";
75
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
+
76
101
  // Ссылка — единственный инструмент с данными: адрес задаётся не переключением (см. applyLink).
77
102
  const LINK: FormatTool = "link";
78
103
 
@@ -110,6 +135,65 @@ function isHotkeyLetter(e: KeyboardEvent, letter: string): boolean {
110
135
  return e.key.toLowerCase() === letter || codeLetter(e) === letter;
111
136
  }
112
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
+
113
197
  export interface RichEditorOptions {
114
198
  /** Включает форматирование и панель инструментов. */
115
199
  format?: boolean;
@@ -377,6 +461,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
377
461
  // прятать его носителя незачем и нечем.
378
462
  const inToolbar = !!picker.closest(`.${TOOLBAR_CLASS}`);
379
463
 
464
+ // Недавние — по хранилищу на момент показа: попап живёт между открытиями, а хранилище
465
+ // тем временем пополняют и другие попапы страницы.
466
+ refreshRecentEmojis(picker);
467
+
380
468
  PopupManager.open(picker, {
381
469
  initiator,
382
470
  onClose: () => {
@@ -541,6 +629,40 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
541
629
  selection.addRange(range);
542
630
  }
543
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
+
544
666
  /**
545
667
  * Цель правки форматирования: выделение, расширенное до целых слов, плюс исходные границы
546
668
  * для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
@@ -1114,9 +1236,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1114
1236
  const { signal } = this.__abort;
1115
1237
  const editable = this.editable;
1116
1238
 
1117
- // перетаскивание в редактор проходит мимо истории и фильтров хоста гасим саму вставку.
1239
+ // Бросок в редактор: текстовые файлы принимаются содержимым (см. __onDrop), всё прочее
1240
+ // гасится — свободное перетаскивание прошло бы мимо истории и фильтров хоста.
1118
1241
  // dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
1119
- this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
1242
+ this.element.addEventListener("drop", (e) => this.__onDrop(e), { signal });
1120
1243
 
1121
1244
  // Выделение страницы не должно затягивать содержимое редактора — см. __holdSelectable.
1122
1245
  // Слушаем на документе и в фазе перехвата: протяжка начинается где угодно, а обработчик
@@ -1207,6 +1330,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1207
1330
  this.element.addEventListener("paste", (e: ClipboardEvent) => this.__onPaste(e), { signal });
1208
1331
  editable.addEventListener("keydown", (e: KeyboardEvent) => this.__onKeydown(e), { signal });
1209
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
+
1210
1344
  editable.addEventListener("beforeinput", (e: InputEvent) => this.__onBeforeInput(e), { signal });
1211
1345
 
1212
1346
  editable.addEventListener(
@@ -1223,6 +1357,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1223
1357
  } else if (editable.firstChild?.nodeName === "BR") {
1224
1358
  editable.innerHTML = "";
1225
1359
  }
1360
+ // При наборе браузер прокручивает к каретке сам, но доводит её лишь до края
1361
+ // коробки — под отступы контейнера. Доводим до текста.
1362
+ this.__scrollCaretIntoView();
1363
+
1226
1364
  this.__emitChange(true); // печать — единственный посимвольный источник, его и откладываем
1227
1365
  },
1228
1366
  { signal }
@@ -1230,46 +1368,58 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1230
1368
  }
1231
1369
 
1232
1370
  /**
1233
- * Доводит прокрутку до каретки. Переносы строк редактор делает сам (preventDefault), и
1234
- * браузер к новой строке не прокручивает; к тому же у хостов (textbox, messageeditor)
1235
- * прокручивается не редактируемый элемент, а его обёртка после достижения предельной
1236
- * высоты каретка у нижнего края уходила бы за пределы видимого.
1371
+ * Доводит прокрутку до каретки. Нужна там, где браузер не справляется сам: переносы строк
1372
+ * редактор делает вручную (preventDefault), и к новой строке браузер не прокручивает; в
1373
+ * textbox прокручивается не редактируемый элемент, а его обёртка; а своей прокруткой браузер
1374
+ * доводит каретку лишь до края коробки под отступы, которые едут вместе с текстом.
1237
1375
  */
1238
1376
  private __scrollCaretIntoView() {
1239
1377
  const selection = this.selection;
1240
1378
  if (!selection || selection.rangeCount === 0) return;
1241
1379
 
1242
- const range = selection.getRangeAt(0);
1380
+ const range = selection.getRangeAt(0).cloneRange();
1243
1381
 
1244
1382
  // в среде без раскладки (jsdom) у Range коробок нет вовсе
1245
1383
  if (typeof range.getBoundingClientRect !== "function") return;
1246
1384
 
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();
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);
1256
1398
  }
1257
1399
  // раскладки нет (тестовая среда) — прокручивать не по чему
1258
- if (!rect.height && !rect.top && !rect.bottom) return;
1400
+ if (!height && !top && !bottom) return;
1259
1401
 
1260
1402
  // Ближайший прокручиваемый предок; прокручивается только он — внешним контейнерам
1261
1403
  // каретка не адресована, крутить страницу из редактора нельзя.
1262
1404
  const document = this.editable.ownerDocument;
1263
1405
  for (let el: HTMLElement | null = this.editable; el && el !== document.body; el = el.parentElement) {
1264
1406
  if (el.scrollHeight <= el.clientHeight) continue;
1265
- const overflow = document.defaultView?.getComputedStyle(el).overflowY;
1407
+ const style = document.defaultView?.getComputedStyle(el);
1408
+ if (!style) continue;
1409
+ const overflow = style.overflowY;
1266
1410
  if (overflow !== "auto" && overflow !== "scroll") continue;
1267
1411
 
1412
+ // Границей служит не коробка, а область, свободная от отступов: они едут вместе
1413
+ // с текстом (см. .editor в textbox, .ui-richeditor в messageeditor), и доведённая
1414
+ // до края коробки строка встала бы под них. Своего отступа у контейнера может
1415
+ // и не быть — тогда держим небольшой запас, чтобы строка не прилипала к краю.
1416
+ const gap = Math.min(height || 16, 16);
1268
1417
  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;
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;
1273
1423
  return;
1274
1424
  }
1275
1425
  }
@@ -1408,6 +1558,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1408
1558
  // форматированную вставку берём из text/html только при включённом форматировании
1409
1559
  const html = this.format && this.formatTools.length ? data.getData("text/html") : "";
1410
1560
 
1561
+ this.__insertExternal(plain, html);
1562
+ }
1563
+
1564
+ /**
1565
+ * Общий приём внешнего текста — вставки из буфера и брошенного файла: фильтр хоста, затем
1566
+ * форматированная ветка, затем простой текст. Форматирование приходит двумя путями: text/html
1567
+ * из буфера и, при markdown-хранении, маркеры прямо в тексте — раз значение хранится этой
1568
+ * разметкой, вставленный текст с ней разбирается так же, как разбиралось бы значение.
1569
+ */
1570
+ private __insertExternal(plain: string, html: string) {
1411
1571
  // filterPaste решает по тексту: null — отклонить; если хук ИЗМЕНИЛ текст (обрезка по длине,
1412
1572
  // фильтр по типу) — форматирование сохранить нельзя, вставляем очищенный простой текст
1413
1573
  let plainOverride: string | null = null;
@@ -1424,7 +1584,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1424
1584
  const selection = this.selection;
1425
1585
  if (!selection) return;
1426
1586
 
1427
- 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
+ }
1428
1604
  this.__pastePlain(plainOverride ?? plain, selection);
1429
1605
  }
1430
1606
 
@@ -1445,11 +1621,80 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1445
1621
  this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs && !literal), selection);
1446
1622
  }
1447
1623
 
1448
- // Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего —
1449
- // тогда вызывающий откатывается на простую вставку.
1450
- 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
+
1451
1696
  return this.__insertPasted(
1452
- sanitizePastedHtml(html, this.formatTools, this.formatMarkers, this.blockTypes),
1697
+ parsePastedMarkdown(text, this.__valueTools, this.formatMarkers, this.blockTypes, this.__blockParagraphs),
1453
1698
  selection
1454
1699
  );
1455
1700
  }
@@ -175,9 +175,14 @@ function serializeInline(
175
175
 
176
176
  const inner = serializeInline(el.childNodes, storage, tagMap, markers);
177
177
 
178
- // вложенный блочный элемент (нестандарт) без обёртки, просто содержимое
178
+ // Вложенный блочный элемент — это строка: его граница даёт перенос, иначе соседние
179
+ // строки склеивались бы встык — VS Code и браузеры отдают буфер строками в <div>
180
+ // внутри общей обёртки. Хвостовой <br> внутри — заполнитель пустой строки (как у
181
+ // абзацев самого редактора), а не её содержимое.
179
182
  if (tag === "DIV" || tag === "P") {
180
- result += inner;
183
+ const br = lineBreak(storage);
184
+ if (result && !result.endsWith(br)) result += br;
185
+ result += trimTrailingBreaks(inner, storage) + br;
181
186
  continue;
182
187
  }
183
188
 
@@ -670,7 +675,15 @@ function markdownBlocks(value: string, types: BlockType[], separate: boolean): A
670
675
  for (let i = 0; i < lines.length; i++) {
671
676
  const line = lines[i];
672
677
 
673
- const fence = fenced.find((type) => line.trimEnd() === BLOCK_TYPES[type].fence);
678
+ // Открывающая ограда бывает с меткой языка (```text) блок она открывает так же, а сама
679
+ // метка отбрасывается: значение кита её не хранит. Остаток с символом ограды меткой не
680
+ // считается (```` — не ограда с меткой `). Закрывающая ограда — только голая: та же
681
+ // строка с меткой внутри блока — его содержимое, а не конец.
682
+ const fence = fenced.find((type) => {
683
+ const marker = BLOCK_TYPES[type].fence!;
684
+ const trimmed = line.trimEnd();
685
+ return trimmed.startsWith(marker) && !trimmed.slice(marker.length).includes(marker[0]);
686
+ });
674
687
  if (fence) {
675
688
  let close = -1;
676
689
  for (let at = i + 1; at < lines.length; at++)
package/source/toolbar.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // т.к. тулбар находится вне привязанных UIElement).
7
7
 
8
8
  import { DOM } from "@brandup/ui";
9
- import { PopupManager } from "@brandup/ui-kit";
9
+ import { PopupManager, SCROLLABLE_CLASS } from "@brandup/ui-kit";
10
10
  import {
11
11
  BLOCK_TYPES,
12
12
  DEFAULT_BLOCK,
@@ -68,10 +68,10 @@ export const LINK_ROW_CLASS = "link-row";
68
68
  // панель показывает поле адреса вместо кнопок
69
69
  export const LINK_EDITING_CLASS = "link-editing";
70
70
 
71
- // Временно скрытые кнопки. Сами возможности работают: значение разбирается, показывается
72
- // и сохраняется, правку можно вызвать из кода в панель они просто не выводятся.
73
- // Убрать отсюда, когда будут доведены.
74
- const HIDDEN_TOOLS: FormatTool[] = ["spoiler"];
71
+ // Временно скрытые кнопки для возможностей, которые работают (значение разбирается,
72
+ // показывается и сохраняется, правка зовётся из кода), но в панель ещё не выводятся.
73
+ // Сейчас скрытых нет; механика остаётся на следующую такую возможность.
74
+ const HIDDEN_TOOLS: FormatTool[] = [];
75
75
  const HIDDEN_BLOCKS: BlockType[] = [];
76
76
 
77
77
  export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
@@ -154,6 +154,9 @@ export interface ToolbarHost {
154
154
  }
155
155
 
156
156
  const MARGIN = 6;
157
+ // Зазор от краёв экрана у панели в document.body. То же значение вычитается из её предельной
158
+ // ширины в richeditor.less (--richeditor-toolbar-edge-gap) — менять их нужно вместе.
159
+ const EDGE_GAP = 4;
157
160
 
158
161
  class FormatToolbar {
159
162
  private __elem: HTMLElement | null = null; // обёртка: её позиционируют
@@ -378,9 +381,24 @@ class FormatToolbar {
378
381
 
379
382
  const rect = this.__active.editable.getBoundingClientRect();
380
383
  const elem = this.__elem;
384
+
385
+ // Ширина панели зависит от left: коробка ужимается по содержимому, и прижатый к правому
386
+ // краю left от прошлого показа ужал бы её до зазора — панель одна на все редакторы,
387
+ // а прошлый мог стоять у самого края. Меряем со снятой координатой.
388
+ elem.style.left = "";
389
+ const width = elem.offsetWidth;
390
+
381
391
  const top = rect.top - elem.offsetHeight - MARGIN;
382
- elem.style.left = `${Math.max(4, rect.left)}px`;
383
- elem.style.top = `${Math.max(4, top)}px`;
392
+
393
+ // Панель шире редактора (у узкого поля так бывает всегда), и по его левому краю она уехала
394
+ // бы за правый край экрана. Прижимаем к правому краю, но не левее отступа: панель шире
395
+ // самого экрана прижимается к левому и прокручивается — см. .toolbar-body.
396
+ // clientWidth корня, а не innerWidth: тот считает и полосу прокрутки страницы,
397
+ // и крайняя кнопка панели оказывалась бы под ней.
398
+ const viewport = elem.ownerDocument.documentElement.clientWidth;
399
+ const maxLeft = Math.max(EDGE_GAP, viewport - width - EDGE_GAP);
400
+ elem.style.left = `${Math.min(Math.max(EDGE_GAP, rect.left), maxLeft)}px`;
401
+ elem.style.top = `${Math.max(EDGE_GAP, top)}px`;
384
402
  }
385
403
 
386
404
  private __removeViewportListeners() {
@@ -399,7 +417,9 @@ class FormatToolbar {
399
417
  // этого съезжать не должна. Выпадающие слои (панель смайликов) висят на обёртке:
400
418
  // её коробка их и не растит, и не обрезает.
401
419
  this.__elem = DOM.tag("div", { class: TOOLBAR_CLASS });
402
- this.__body = DOM.tag("div", { class: BODY_CLASS });
420
+ // Коробка прокручивается по горизонтали: полный набор кнопок на узком экране в строку
421
+ // не влезает. Полоса — общая, от .ui-scrollable кита, только тоньше (см. richeditor.less).
422
+ this.__body = DOM.tag("div", { class: [BODY_CLASS, SCROLLABLE_CLASS] });
403
423
  this.__elem.appendChild(this.__body);
404
424
 
405
425
  // Панель нигде не должна забирать фокус, иначе редактор теряет выделение, а blur