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