@brandup/ui-richeditor 1.0.48 → 1.0.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -5
- package/package.json +2 -2
- package/source/editing.ts +192 -24
- package/source/emoji.ts +99 -2
- package/source/format.ts +1 -0
- package/source/history.ts +12 -1
- package/source/index.ts +13 -1
- package/source/paragraphs.ts +36 -2
- package/source/richeditor.less +47 -10
- package/source/richeditor.ts +377 -35
- package/source/selection.ts +44 -11
- package/source/serialize.ts +55 -11
- package/source/toolbar.ts +48 -17
package/source/selection.ts
CHANGED
|
@@ -78,6 +78,10 @@ export function innerSelection(root: HTMLElement): Selection | null {
|
|
|
78
78
|
const selection = documentSelection(root);
|
|
79
79
|
if (!selection || selection.rangeCount === 0 || !root.contains(selection.anchorNode)) return null;
|
|
80
80
|
|
|
81
|
+
// Оба конца, а не только якорь: выделение можно вытянуть из редактора на страницу,
|
|
82
|
+
// и правка по такому диапазону трогала бы DOM за пределами содержимого.
|
|
83
|
+
if (!root.contains(selection.focusNode)) return null;
|
|
84
|
+
|
|
81
85
|
return selection;
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -233,7 +237,10 @@ function linesBefore(root: HTMLElement, container: Node, offset: number): number
|
|
|
233
237
|
* пробелов и может уехать в соседнее слово.
|
|
234
238
|
*/
|
|
235
239
|
export function mapCharOffset(before: string, after: string, offset: number): number {
|
|
236
|
-
|
|
240
|
+
// Пробелом после нормализации становится и таб, и неразрывный пробел (U+00A0) — тот
|
|
241
|
+
// браузер сам подставляет в contenteditable; без него выравнивание срывалось бы на
|
|
242
|
+
// первом же nbsp, и каретка уезжала к нему.
|
|
243
|
+
const same = (a: string, b: string) => a === b || (b === " " && (a === " " || a === "\t" || a === " "));
|
|
237
244
|
|
|
238
245
|
let i = 0;
|
|
239
246
|
let j = 0;
|
|
@@ -433,13 +440,34 @@ function wrapNode(node: Node, tag: string): HTMLElement {
|
|
|
433
440
|
return wrapper;
|
|
434
441
|
}
|
|
435
442
|
|
|
436
|
-
/** Выносит
|
|
443
|
+
/** Выносит node наружу из элемента fmt (расщепляя fmt на «до» и «после»). */
|
|
437
444
|
function unwrapAround(fmt: HTMLElement, node: Node) {
|
|
438
445
|
const parent = fmt.parentNode;
|
|
439
446
|
if (!parent) return;
|
|
440
447
|
|
|
448
|
+
// Промежуточные предки расщепляются вокруг узла: ветка целиком несла бы наружу и чужой
|
|
449
|
+
// текст — в <b><i>hello world</i></b> снятие жирного с «hello» уносило бы из <b> весь <i>,
|
|
450
|
+
// и « world» терял бы формат, который с него не снимали.
|
|
451
|
+
//
|
|
452
|
+
// Осознанная цена: расщеплённая ссылка становится двумя <a> с одним адресом — в значении
|
|
453
|
+
// два соседних куска вместо одного. Целая ссылка с чужим форматом снаружи стоила бы дороже:
|
|
454
|
+
// формат снимался бы с текста, которого не выделяли.
|
|
441
455
|
let child: Node = node;
|
|
442
|
-
while (child.parentNode && child.parentNode !== fmt)
|
|
456
|
+
while (child.parentNode && child.parentNode !== fmt) {
|
|
457
|
+
const holder = child.parentNode as HTMLElement;
|
|
458
|
+
|
|
459
|
+
const left = holder.cloneNode(false) as HTMLElement;
|
|
460
|
+
while (holder.firstChild && holder.firstChild !== child) left.appendChild(holder.firstChild);
|
|
461
|
+
if (left.firstChild) holder.parentNode?.insertBefore(left, holder);
|
|
462
|
+
|
|
463
|
+
if (child.nextSibling) {
|
|
464
|
+
const right = holder.cloneNode(false) as HTMLElement;
|
|
465
|
+
while (child.nextSibling) right.appendChild(child.nextSibling);
|
|
466
|
+
holder.parentNode?.insertBefore(right, holder.nextSibling);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
child = holder; // держит теперь только выносимую ветку
|
|
470
|
+
}
|
|
443
471
|
if (child.parentNode !== fmt) return;
|
|
444
472
|
|
|
445
473
|
const left = fmt.cloneNode(false) as HTMLElement;
|
|
@@ -610,7 +638,9 @@ const LINK_TAGS = TOOL_TAG_SETS.link;
|
|
|
610
638
|
* По ней панель узнаёт текущий адрес: у ссылки состояние — не «включена», а «вот этот адрес».
|
|
611
639
|
*/
|
|
612
640
|
export function linkAt(root: HTMLElement, range: Range): HTMLAnchorElement | null {
|
|
613
|
-
|
|
641
|
+
// Пробой бывает и сам тег ссылки, а не текст внутри него: границы выделения встают на
|
|
642
|
+
// элементы при Ctrl+A и selectAllContent — искать надо включая сам узел (см. formatAt).
|
|
643
|
+
return formatAt(caretProbe(range), LINK_TAGS, root) as HTMLAnchorElement | null;
|
|
614
644
|
}
|
|
615
645
|
|
|
616
646
|
/**
|
|
@@ -658,12 +688,6 @@ export function clearAllFormat(root: HTMLElement) {
|
|
|
658
688
|
root.normalize();
|
|
659
689
|
}
|
|
660
690
|
|
|
661
|
-
/**
|
|
662
|
-
* Состояние форматирования на выделении указанными тегами.
|
|
663
|
-
* `every` — отформатирован весь текст (подсветка кнопки инструмента),
|
|
664
|
-
* `some` — отформатирована хоть какая-то часть (доступность очистки).
|
|
665
|
-
* Обход прерывается на первом узле, решающем исход.
|
|
666
|
-
*/
|
|
667
691
|
/** Есть ли форматирование хоть на части выделения (или под кареткой) — доступность кнопки очистки. */
|
|
668
692
|
export function hasFormatting(root: HTMLElement, range: Range): boolean {
|
|
669
693
|
if (range.collapsed) return formatAncestor(caretProbe(range), MATCH_TAG_SET, root) !== null;
|
|
@@ -713,7 +737,13 @@ export function hasAnyFormatting(root: HTMLElement): boolean {
|
|
|
713
737
|
* Вставляет текст в позицию каретки, оборачивая его в указанные форматы (режим набора).
|
|
714
738
|
* Каретка ставится сразу после вставленного текста; соседние одинаковые теги склеиваются.
|
|
715
739
|
*/
|
|
716
|
-
export function insertFormattedText(
|
|
740
|
+
export function insertFormattedText(
|
|
741
|
+
root: HTMLElement,
|
|
742
|
+
data: string,
|
|
743
|
+
tools: FormatTool[],
|
|
744
|
+
selection: Selection,
|
|
745
|
+
href = ""
|
|
746
|
+
) {
|
|
717
747
|
if (!data || selection.rangeCount === 0) return;
|
|
718
748
|
|
|
719
749
|
const range = selection.getRangeAt(0);
|
|
@@ -724,6 +754,9 @@ export function insertFormattedText(root: HTMLElement, data: string, tools: Form
|
|
|
724
754
|
let node: Node = document.createTextNode(data);
|
|
725
755
|
for (const tool of tools) {
|
|
726
756
|
const el = document.createElement(FORMAT_TOOLS[tool].tag);
|
|
757
|
+
// Ссылка — не просто тег: без адреса она не переживёт сериализацию. Адрес передаёт
|
|
758
|
+
// вызывающий — с выделения, чьё оформление наследуется.
|
|
759
|
+
if (tool === "link" && href) el.setAttribute("href", href);
|
|
727
760
|
el.appendChild(node);
|
|
728
761
|
node = el;
|
|
729
762
|
}
|
package/source/serialize.ts
CHANGED
|
@@ -72,7 +72,9 @@ function markdownUrl(href: string): string {
|
|
|
72
72
|
function balancedParens(text: string): boolean {
|
|
73
73
|
let depth = 0;
|
|
74
74
|
for (const char of text) {
|
|
75
|
-
|
|
75
|
+
// Глубже одного уровня разбор ссылки не читает (LINK_PATTERN) — такой адрес
|
|
76
|
+
// должен уйти в угловые скобки, иначе ссылка при чтении распалась бы в текст.
|
|
77
|
+
if (char === "(" && ++depth > 1) return false;
|
|
76
78
|
else if (char === ")" && --depth < 0) return false;
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -101,6 +103,15 @@ function wrap(
|
|
|
101
103
|
const core = inner.slice(leading.length, inner.length - trailing.length);
|
|
102
104
|
if (!core) return inner; // одни пробелы — оборачивать нечего
|
|
103
105
|
|
|
106
|
+
// Маркер не пересекает перенос строки — тоже ни у нас, ни у мессенджера. Перенос внутри
|
|
107
|
+
// содержимого (Shift+Enter в жирном): размечаем каждую строку отдельно, иначе `**а\nб**`
|
|
108
|
+
// вернулось бы из значения буквальным текстом с маркерами.
|
|
109
|
+
if (core.includes("\n"))
|
|
110
|
+
return `${leading}${core
|
|
111
|
+
.split("\n")
|
|
112
|
+
.map((line) => wrap(storage, tool, line, markers, source))
|
|
113
|
+
.join("\n")}${trailing}`;
|
|
114
|
+
|
|
104
115
|
return `${leading}${marker}${core}${marker}${trailing}`;
|
|
105
116
|
}
|
|
106
117
|
|
|
@@ -146,17 +157,32 @@ function serializeInline(
|
|
|
146
157
|
|
|
147
158
|
// В коде разметки нет: при разборе его содержимое не размечается, и вложенное
|
|
148
159
|
// форматирование не вернулось бы — значение разошлось бы с тем, что было в поле.
|
|
160
|
+
// Перенос внутри — тоже содержимое: textContent съел бы его, склеив строки. В html
|
|
161
|
+
// перенос пишется тегом: сырой \n виден только под pre-wrap самого редактора.
|
|
149
162
|
if (tool && FORMAT_TOOLS[tool].literal) {
|
|
150
|
-
const text = el.
|
|
151
|
-
|
|
163
|
+
const text = Array.from(el.childNodes)
|
|
164
|
+
.map((child) => (child.nodeName === "BR" ? "\n" : (child.textContent ?? "")))
|
|
165
|
+
.join("");
|
|
166
|
+
result += wrap(
|
|
167
|
+
storage,
|
|
168
|
+
tool,
|
|
169
|
+
storage === "html" ? escapeHtml(text).replace(/\n/g, "<br>") : text,
|
|
170
|
+
markers,
|
|
171
|
+
el
|
|
172
|
+
);
|
|
152
173
|
continue;
|
|
153
174
|
}
|
|
154
175
|
|
|
155
176
|
const inner = serializeInline(el.childNodes, storage, tagMap, markers);
|
|
156
177
|
|
|
157
|
-
//
|
|
178
|
+
// Вложенный блочный элемент — это строка: его граница даёт перенос, иначе соседние
|
|
179
|
+
// строки склеивались бы встык — VS Code и браузеры отдают буфер строками в <div>
|
|
180
|
+
// внутри общей обёртки. Хвостовой <br> внутри — заполнитель пустой строки (как у
|
|
181
|
+
// абзацев самого редактора), а не её содержимое.
|
|
158
182
|
if (tag === "DIV" || tag === "P") {
|
|
159
|
-
|
|
183
|
+
const br = lineBreak(storage);
|
|
184
|
+
if (result && !result.endsWith(br)) result += br;
|
|
185
|
+
result += trimTrailingBreaks(inner, storage) + br;
|
|
160
186
|
continue;
|
|
161
187
|
}
|
|
162
188
|
|
|
@@ -242,6 +268,10 @@ function serializeParagraphs(
|
|
|
242
268
|
}
|
|
243
269
|
flush();
|
|
244
270
|
|
|
271
|
+
// Пустой обычный блок ВНУТРИ содержимого — осознанная пустая строка (за цитатой или кодом
|
|
272
|
+
// её держит и normalizeParagraphs), поэтому здесь он не отбрасывается: этой же функцией
|
|
273
|
+
// значение и ЧИТАЕТСЯ (deserialize html идёт через неё), и фильтр молча терял бы пустые
|
|
274
|
+
// строки уже сохранённых значений при первой же загрузке.
|
|
245
275
|
const cleaned = blocks.map(([type, text]) => [type, trimTrailingBreaks(text, storage)] as const);
|
|
246
276
|
|
|
247
277
|
// Пустые блоки в хвосте в значение не идут: последний из них — место каретки, оставленное
|
|
@@ -448,11 +478,13 @@ function markdownInline(text: string, order: MarkerRule[], linked: boolean): str
|
|
|
448
478
|
|
|
449
479
|
// Код — первым: внутри него `*звёздочки*` остаются текстом, как у мессенджеров. Иначе
|
|
450
480
|
// содержимое кода размечалось бы, и то, что человек написал буквально, уезжало бы жирным.
|
|
451
|
-
|
|
481
|
+
// содержимое, чужой маркер (если был) и исходный текст целиком — для мест, где разметка
|
|
482
|
+
// не действует и спрятанное надо вернуть буквально (адрес ссылки)
|
|
483
|
+
const stash: Array<[inner: string, marked: string, literal: string]> = [];
|
|
452
484
|
for (const rule of order)
|
|
453
485
|
if (rule.tool === "code" && html.includes(rule.marker))
|
|
454
486
|
html = html.replace(pattern(rule), (_match, lead: string, inner: string) => {
|
|
455
|
-
stash.push([inner, rule.own ? "" : rule.marker]);
|
|
487
|
+
stash.push([inner, rule.own ? "" : rule.marker, `${rule.marker}${inner}${rule.marker}`]);
|
|
456
488
|
|
|
457
489
|
return `${lead}${STASH_MARK}${stash.length - 1}${STASH_MARK}`;
|
|
458
490
|
});
|
|
@@ -465,7 +497,10 @@ function markdownInline(text: string, order: MarkerRule[], linked: boolean): str
|
|
|
465
497
|
if (linked && html.includes("("))
|
|
466
498
|
html = html.replace(LINK_PATTERN, (match, inner: string, url: string) => {
|
|
467
499
|
// адрес в угловых скобках — форма для адресов с пробелами; сами скобки в него не входят
|
|
468
|
-
const
|
|
500
|
+
const bare = url.startsWith("<") ? url.slice(4, -4) : url;
|
|
501
|
+
// Бэктики в адресе — буквальные символы: код успел спрятаться раньше (он разбирается
|
|
502
|
+
// первым), и без возврата метка уехала бы в href, а содержимое кода пропало бы.
|
|
503
|
+
const raw = bare.replace(STASH_PATTERN, (_m, index: string) => stash[+index][2]);
|
|
469
504
|
const href = safeUrl(raw);
|
|
470
505
|
const text = unescapeLinkText(inner);
|
|
471
506
|
if (!text || !href) return match; // без текста или без адреса ссылки нет
|
|
@@ -603,9 +638,10 @@ export function deserialize(
|
|
|
603
638
|
template.innerHTML = value;
|
|
604
639
|
const tagMap = buildTagMap(tools);
|
|
605
640
|
|
|
606
|
-
|
|
641
|
+
// markers html-ветке не нужны (wrap выходит раньше), передаются лишь ради сигнатуры
|
|
642
|
+
if (!paragraphs) return serializeInline(template.content.childNodes, "html", tagMap, markers);
|
|
607
643
|
|
|
608
|
-
return serializeParagraphs(template.content, "html", tagMap,
|
|
644
|
+
return serializeParagraphs(template.content, "html", tagMap, markers, types, separate).replace(
|
|
609
645
|
/<([a-z]+)><\/\1>/g,
|
|
610
646
|
"<$1><br></$1>"
|
|
611
647
|
);
|
|
@@ -639,7 +675,15 @@ function markdownBlocks(value: string, types: BlockType[], separate: boolean): A
|
|
|
639
675
|
for (let i = 0; i < lines.length; i++) {
|
|
640
676
|
const line = lines[i];
|
|
641
677
|
|
|
642
|
-
|
|
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
|
+
});
|
|
643
687
|
if (fence) {
|
|
644
688
|
let close = -1;
|
|
645
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";
|
|
@@ -124,8 +124,12 @@ export interface ToolbarHost {
|
|
|
124
124
|
readonly blockTools?: BlockType[];
|
|
125
125
|
applyFormat(tool: FormatTool): void;
|
|
126
126
|
isToolActive(tool: FormatTool): boolean;
|
|
127
|
-
/**
|
|
128
|
-
|
|
127
|
+
/**
|
|
128
|
+
* false — инструмент сейчас недоступен (например, внутри кода): кнопка гасится.
|
|
129
|
+
* Признак «в коде» дорог (обход выделения) — панель считает его один раз на обновление
|
|
130
|
+
* и передаёт вторым аргументом, чтобы хост не обходил выделение на каждую кнопку.
|
|
131
|
+
*/
|
|
132
|
+
isToolEnabled?(tool: FormatTool, codeActive?: boolean): boolean;
|
|
129
133
|
/** Тип блока под кареткой — им подсвечивается активная кнопка блока. */
|
|
130
134
|
readonly currentBlock?: BlockType;
|
|
131
135
|
applyBlock?(type: BlockType): void;
|
|
@@ -150,6 +154,9 @@ export interface ToolbarHost {
|
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
const MARGIN = 6;
|
|
157
|
+
// Зазор от краёв экрана у панели в document.body. То же значение вычитается из её предельной
|
|
158
|
+
// ширины в richeditor.less (--richeditor-toolbar-edge-gap) — менять их нужно вместе.
|
|
159
|
+
const EDGE_GAP = 4;
|
|
153
160
|
|
|
154
161
|
class FormatToolbar {
|
|
155
162
|
private __elem: HTMLElement | null = null; // обёртка: её позиционируют
|
|
@@ -326,17 +333,27 @@ class FormatToolbar {
|
|
|
326
333
|
};
|
|
327
334
|
|
|
328
335
|
const active = host.activeTools?.();
|
|
336
|
+
// Признак «в коде» гасит остальные кнопки и подсвечивает объединённую; сам он — обход
|
|
337
|
+
// выделения, поэтому считается один раз на обновление, а не на каждую кнопку. Вывести
|
|
338
|
+
// его из activeTools можно только вместе с currentBlock (блок кода — не формат): хосту
|
|
339
|
+
// без блоков остаётся его собственный isCodeActive.
|
|
340
|
+
const inCode =
|
|
341
|
+
active && host.currentBlock !== undefined
|
|
342
|
+
? host.currentBlock === CODE_TOOL || active.has(CODE_TOOL)
|
|
343
|
+
: host.isCodeActive?.();
|
|
344
|
+
|
|
329
345
|
for (const [tool, btn] of this.__buttons) {
|
|
330
346
|
// объединённая кнопка подсвечена и на блоке кода, а не только на моноширинном
|
|
331
347
|
const isActive =
|
|
332
348
|
this.__mergedCode && tool === CODE_TOOL
|
|
333
|
-
? !!
|
|
349
|
+
? !!inCode
|
|
334
350
|
: active
|
|
335
351
|
? active.has(tool)
|
|
336
352
|
: host.isToolActive(tool);
|
|
337
353
|
|
|
338
|
-
|
|
339
|
-
|
|
354
|
+
// toggle с force не трогает атрибут, когда состояние уже нужное, — мутации не будет
|
|
355
|
+
btn.classList.toggle("active", isActive);
|
|
356
|
+
setDisabled(btn, host.isToolEnabled?.(tool, inCode) === false);
|
|
340
357
|
}
|
|
341
358
|
|
|
342
359
|
// тип под кареткой спрашиваем, только если есть что подсвечивать: обновление идёт
|
|
@@ -344,10 +361,7 @@ class FormatToolbar {
|
|
|
344
361
|
if (this.__blockButtons.length) {
|
|
345
362
|
const block = host.currentBlock;
|
|
346
363
|
|
|
347
|
-
for (const [type, btn] of this.__blockButtons)
|
|
348
|
-
const isActive = block === type;
|
|
349
|
-
if (btn.classList.contains("active") !== isActive) btn.classList.toggle("active", isActive);
|
|
350
|
-
}
|
|
364
|
+
for (const [type, btn] of this.__blockButtons) btn.classList.toggle("active", block === type);
|
|
351
365
|
}
|
|
352
366
|
|
|
353
367
|
// хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
|
|
@@ -367,9 +381,24 @@ class FormatToolbar {
|
|
|
367
381
|
|
|
368
382
|
const rect = this.__active.editable.getBoundingClientRect();
|
|
369
383
|
const elem = this.__elem;
|
|
384
|
+
|
|
385
|
+
// Ширина панели зависит от left: коробка ужимается по содержимому, и прижатый к правому
|
|
386
|
+
// краю left от прошлого показа ужал бы её до зазора — панель одна на все редакторы,
|
|
387
|
+
// а прошлый мог стоять у самого края. Меряем со снятой координатой.
|
|
388
|
+
elem.style.left = "";
|
|
389
|
+
const width = elem.offsetWidth;
|
|
390
|
+
|
|
370
391
|
const top = rect.top - elem.offsetHeight - MARGIN;
|
|
371
|
-
|
|
372
|
-
|
|
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`;
|
|
373
402
|
}
|
|
374
403
|
|
|
375
404
|
private __removeViewportListeners() {
|
|
@@ -388,7 +417,9 @@ class FormatToolbar {
|
|
|
388
417
|
// этого съезжать не должна. Выпадающие слои (панель смайликов) висят на обёртке:
|
|
389
418
|
// её коробка их и не растит, и не обрезает.
|
|
390
419
|
this.__elem = DOM.tag("div", { class: TOOLBAR_CLASS });
|
|
391
|
-
|
|
420
|
+
// Коробка прокручивается по горизонтали: полный набор кнопок на узком экране в строку
|
|
421
|
+
// не влезает. Полоса — общая, от .ui-scrollable кита, только тоньше (см. richeditor.less).
|
|
422
|
+
this.__body = DOM.tag("div", { class: [BODY_CLASS, SCROLLABLE_CLASS] });
|
|
392
423
|
this.__elem.appendChild(this.__body);
|
|
393
424
|
|
|
394
425
|
// Панель нигде не должна забирать фокус, иначе редактор теряет выделение, а blur
|