@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 +26 -4
- package/package.json +2 -2
- package/source/editing.ts +155 -11
- package/source/emoji.ts +99 -2
- package/source/index.ts +13 -1
- package/source/richeditor.less +40 -6
- package/source/richeditor.ts +272 -27
- package/source/serialize.ts +16 -3
- package/source/toolbar.ts +28 -8
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
|
-
|
|
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
|
-
|
|
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
|
-
Каретка
|
|
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.
|
|
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.
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
package/source/richeditor.less
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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;
|
package/source/richeditor.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
* Доводит прокрутку до каретки.
|
|
1234
|
-
*
|
|
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
|
-
//
|
|
1249
|
-
|
|
1250
|
-
if (!
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
1271
|
-
|
|
1272
|
-
|
|
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 (
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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
|
-
|
|
1697
|
+
parsePastedMarkdown(text, this.__valueTools, this.formatMarkers, this.blockTypes, this.__blockParagraphs),
|
|
1453
1698
|
selection
|
|
1454
1699
|
);
|
|
1455
1700
|
}
|
package/source/serialize.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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[] = [
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|