@brandup/ui-richeditor 1.0.49 → 1.0.51
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 +33 -13
- package/package.json +2 -2
- package/source/editing.ts +219 -19
- package/source/emoji.ts +99 -2
- package/source/format.ts +1 -0
- package/source/index.ts +13 -1
- package/source/paragraphs.ts +70 -10
- package/source/richeditor.less +51 -6
- package/source/richeditor.ts +323 -53
- package/source/serialize.ts +30 -17
- package/source/toolbar.ts +28 -8
package/source/richeditor.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
paragraphsNormalized,
|
|
31
31
|
preserveCaret,
|
|
32
32
|
mergeAdjacentBlocks,
|
|
33
|
+
splitSoftBreaks,
|
|
33
34
|
normalizeParagraphs,
|
|
34
35
|
normalizeWhitespace,
|
|
35
36
|
restoreSelection,
|
|
@@ -50,15 +51,20 @@ import {
|
|
|
50
51
|
atBlockStart,
|
|
51
52
|
buildParagraphs,
|
|
52
53
|
caretToEnd,
|
|
54
|
+
collapseEmptyEdges,
|
|
53
55
|
expandRangeToWords,
|
|
56
|
+
hasPastedMarkup,
|
|
57
|
+
isDocumentHtml,
|
|
54
58
|
insertParagraph,
|
|
55
59
|
insertPastedParagraphs,
|
|
56
60
|
insertSoftBreak,
|
|
61
|
+
parsePastedMarkdown,
|
|
57
62
|
sanitizePastedHtml,
|
|
58
63
|
selectAllContent,
|
|
59
64
|
trimSelectionWhitespace,
|
|
60
65
|
} from "./editing";
|
|
61
66
|
import { EditorHistory } from "./history";
|
|
67
|
+
import { refreshRecentEmojis } from "./emoji";
|
|
62
68
|
import { formatToolbar, TOOLBAR_CLASS, type ToolbarButton } from "./toolbar";
|
|
63
69
|
|
|
64
70
|
export { formatToolbar, TOOLBAR_CLASS, type ToolbarHost, type ToolbarButton } from "./toolbar";
|
|
@@ -66,6 +72,8 @@ export { formatToolbar, TOOLBAR_CLASS, type ToolbarHost, type ToolbarButton } fr
|
|
|
66
72
|
export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
|
|
67
73
|
// Содержимое временно невыделяемо: по странице тянут выделение, начатое вне редактора (см. __holdSelectable).
|
|
68
74
|
export const UNSELECTABLE_CLASS = "unselectable";
|
|
75
|
+
// Режим мягких переносов: абзац — это строка, и отступов между абзацами в нём нет.
|
|
76
|
+
export const BREAKS_CLASS = "breaks";
|
|
69
77
|
export const CHANGE_EVENT = "richeditor-change";
|
|
70
78
|
|
|
71
79
|
const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Escape"];
|
|
@@ -73,6 +81,27 @@ const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "En
|
|
|
73
81
|
// Код — единственное, что есть и инструментом, и типом блока: имя одно на оба (см. applyCode).
|
|
74
82
|
const CODE = "code";
|
|
75
83
|
|
|
84
|
+
// Расширения текстовых файлов для броска в редактор: у .md и .txt тип в системе часто
|
|
85
|
+
// не зарегистрирован и приходит пустым — тогда решает имя.
|
|
86
|
+
const TEXT_FILE_NAME = /\.(md|markdown|txt|text)$/i;
|
|
87
|
+
|
|
88
|
+
/** Текстовый ли файл: по MIME (text/*), а без типа — по расширению. */
|
|
89
|
+
function isTextFile(file: File): boolean {
|
|
90
|
+
return file.type ? file.type.startsWith("text/") : TEXT_FILE_NAME.test(file.name);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Содержимое файла текстом: Blob.text() есть не во всех окружениях (jsdom) — тогда FileReader. */
|
|
94
|
+
function readFileText(file: File): Promise<string> {
|
|
95
|
+
if (typeof file.text === "function") return file.text();
|
|
96
|
+
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const reader = new FileReader();
|
|
99
|
+
reader.onload = () => resolve(String(reader.result ?? ""));
|
|
100
|
+
reader.onerror = () => reject(reader.error);
|
|
101
|
+
reader.readAsText(file);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
76
105
|
// Ссылка — единственный инструмент с данными: адрес задаётся не переключением (см. applyLink).
|
|
77
106
|
const LINK: FormatTool = "link";
|
|
78
107
|
|
|
@@ -110,6 +139,65 @@ function isHotkeyLetter(e: KeyboardEvent, letter: string): boolean {
|
|
|
110
139
|
return e.key.toLowerCase() === letter || codeLetter(e) === letter;
|
|
111
140
|
}
|
|
112
141
|
|
|
142
|
+
/** Коробка строки — какую высоту занимает строка текста. */
|
|
143
|
+
type LineBox = { top: number; bottom: number; height: number };
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Коробки строк, занятых узлом. У переносимого текста их несколько, и общая коробка накрывает
|
|
147
|
+
* их все — для доводки прокрутки нужна конкретная строка, поэтому берём список, а не объединение.
|
|
148
|
+
* В среде без раскладки (jsdom) список пуст, там остаётся общая коробка.
|
|
149
|
+
*/
|
|
150
|
+
function lineRects(node: Node): LineBox[] {
|
|
151
|
+
let source: { getClientRects?: () => DOMRectList; getBoundingClientRect?: () => DOMRect } | null = null;
|
|
152
|
+
|
|
153
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
154
|
+
const range = node.ownerDocument?.createRange();
|
|
155
|
+
if (range) {
|
|
156
|
+
range.selectNodeContents(node);
|
|
157
|
+
source = range;
|
|
158
|
+
}
|
|
159
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
160
|
+
source = node as Element;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (typeof source?.getBoundingClientRect !== "function") return [];
|
|
164
|
+
|
|
165
|
+
const rects = Array.from(source.getClientRects?.() ?? []);
|
|
166
|
+
return rects.length ? rects : [source.getBoundingClientRect()];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Строка, на которой стоит каретка, когда собственной коробки у неё нет — так бывает на пустой
|
|
171
|
+
* строке. Ищем ближайший измеримый ориентир на ТОЙ ЖЕ строке.
|
|
172
|
+
*/
|
|
173
|
+
function caretLineBox(range: Range): LineBox | null {
|
|
174
|
+
const { startContainer: node, startOffset: offset } = range;
|
|
175
|
+
|
|
176
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
177
|
+
// Узел ПОСЛЕ каретки стоит на её строке: <br> рисуется в конце строки, которую завершает,
|
|
178
|
+
// а текст за кареткой с этой строки начинается. Узел ДО каретки лежит строкой выше —
|
|
179
|
+
// по нему прокрутка недоезжала ровно на строку.
|
|
180
|
+
const next = node.childNodes[offset];
|
|
181
|
+
if (next) return lineRects(next)[0] ?? null;
|
|
182
|
+
|
|
183
|
+
// Каретка за последним узлом: своей строки у неё ещё нет, а предыдущий <br> завершает
|
|
184
|
+
// предыдущую — она ровно на строку ниже него.
|
|
185
|
+
const previous = node.childNodes[offset - 1];
|
|
186
|
+
if (previous) {
|
|
187
|
+
const rects = lineRects(previous);
|
|
188
|
+
const last = rects[rects.length - 1];
|
|
189
|
+
if (!last) return null;
|
|
190
|
+
|
|
191
|
+
const shift = previous.nodeName === "BR" ? last.height : 0;
|
|
192
|
+
return { top: last.top + shift, bottom: last.bottom + shift, height: last.height };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Соседей нет вовсе: пустой блок и есть строка каретки.
|
|
197
|
+
const own = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
198
|
+
return own ? (lineRects(own)[0] ?? null) : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
113
201
|
export interface RichEditorOptions {
|
|
114
202
|
/** Включает форматирование и панель инструментов. */
|
|
115
203
|
format?: boolean;
|
|
@@ -268,6 +356,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
268
356
|
|
|
269
357
|
if (options.placeholder != null) editable.dataset.placeholder = options.placeholder;
|
|
270
358
|
if (multiline) editable.classList.add("multiline");
|
|
359
|
+
// абзац-строка: отступы между абзацами показывали бы пустую строку, которой в значении нет
|
|
360
|
+
if (!this.__separateParagraphs && multiline) editable.classList.add(BREAKS_CLASS);
|
|
271
361
|
if (readonly) editable.classList.add("readonly");
|
|
272
362
|
|
|
273
363
|
this.__initEvents();
|
|
@@ -288,12 +378,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
288
378
|
}
|
|
289
379
|
|
|
290
380
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
381
|
+
* Разделяет ли абзацы пустая строка. Содержимое в обоих режимах — абзацные блоки, но значат
|
|
382
|
+
* они разное: в block абзац это абзац (`\n\n`), в break — строка (`\n`), а пустая строка
|
|
383
|
+
* сообщения хранится пустым абзацем. Поэтому в break у абзацев нет и отступов: между двумя
|
|
384
|
+
* строками их на экране быть не должно.
|
|
295
385
|
*/
|
|
296
|
-
private get
|
|
386
|
+
private get __separateParagraphs(): boolean {
|
|
297
387
|
return this.multiline && this.paragraph === "block";
|
|
298
388
|
}
|
|
299
389
|
|
|
@@ -318,7 +408,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
318
408
|
this.formatMarkers,
|
|
319
409
|
this.multiline,
|
|
320
410
|
this.blockTypes,
|
|
321
|
-
this.
|
|
411
|
+
this.__separateParagraphs
|
|
322
412
|
);
|
|
323
413
|
}
|
|
324
414
|
|
|
@@ -377,6 +467,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
377
467
|
// прятать его носителя незачем и нечем.
|
|
378
468
|
const inToolbar = !!picker.closest(`.${TOOLBAR_CLASS}`);
|
|
379
469
|
|
|
470
|
+
// Недавние — по хранилищу на момент показа: попап живёт между открытиями, а хранилище
|
|
471
|
+
// тем временем пополняют и другие попапы страницы.
|
|
472
|
+
refreshRecentEmojis(picker);
|
|
473
|
+
|
|
380
474
|
PopupManager.open(picker, {
|
|
381
475
|
initiator,
|
|
382
476
|
onClose: () => {
|
|
@@ -541,6 +635,40 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
541
635
|
selection.addRange(range);
|
|
542
636
|
}
|
|
543
637
|
|
|
638
|
+
/**
|
|
639
|
+
* Слово, на котором стоит каретка. Пусто, когда выделение своё (пользователь выбрал сам,
|
|
640
|
+
* и подставлять ему нечего), когда каретки в редакторе нет вовсе или когда она стоит не
|
|
641
|
+
* в слове — за точкой, например: знаки препинания в слово не входят (см. expandRangeToWords).
|
|
642
|
+
*/
|
|
643
|
+
get caretWord(): string {
|
|
644
|
+
const selection = this.selection;
|
|
645
|
+
if (!selection || selection.rangeCount === 0) return "";
|
|
646
|
+
|
|
647
|
+
const range = selection.getRangeAt(0);
|
|
648
|
+
if (!range.collapsed) return "";
|
|
649
|
+
|
|
650
|
+
return expandRangeToWords(this.editable, range).toString();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Выделить слово под кареткой — чтобы следующая вставка встала на его место, а не разорвала
|
|
655
|
+
* его пополам. Возвращает false, когда выделять нечего: тогда вставка идёт в точку каретки.
|
|
656
|
+
*/
|
|
657
|
+
selectCaretWord(): boolean {
|
|
658
|
+
const selection = documentSelection(this.editable);
|
|
659
|
+
if (!selection || selection.rangeCount === 0) return false;
|
|
660
|
+
|
|
661
|
+
const range = selection.getRangeAt(0);
|
|
662
|
+
if (!range.collapsed) return false;
|
|
663
|
+
|
|
664
|
+
const word = expandRangeToWords(this.editable, range);
|
|
665
|
+
if (word.collapsed) return false;
|
|
666
|
+
|
|
667
|
+
selection.removeAllRanges();
|
|
668
|
+
selection.addRange(word);
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
|
|
544
672
|
/**
|
|
545
673
|
* Цель правки форматирования: выделение, расширенное до целых слов, плюс исходные границы
|
|
546
674
|
* для восстановления. Диапазон отдельный от выделения — пока операция не решила, что будет
|
|
@@ -742,7 +870,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
742
870
|
// Смена тега переносит содержимое в новый элемент — живые границы выделения этого
|
|
743
871
|
// не переживают, поэтому держим каретку по текстовым смещениям.
|
|
744
872
|
const bounds = selectionCharBounds(this.editable, range);
|
|
745
|
-
const { created } = applyBlocks(this.editable, range, target);
|
|
873
|
+
const { created } = applyBlocks(this.editable, range, target, !this.__separateParagraphs);
|
|
874
|
+
|
|
875
|
+
// Возврат блока в обычный текст оставляет его строки мягкими переносами — приводим их
|
|
876
|
+
// к абзацам. Строго ДО возврата каретки: разбиение блока живой Range не переживает,
|
|
877
|
+
// а смещения от него не меняются — граница абзацев и мягкий перенос считаются одинаково.
|
|
878
|
+
if (!this.__separateParagraphs) splitSoftBreaks(this.editable);
|
|
746
879
|
|
|
747
880
|
// Разделение блока добавило границы, а они в смещениях считаются — прежние уже не те.
|
|
748
881
|
// Выделяем то, что стало блоком: заодно видно, к каким строкам правка и относилась.
|
|
@@ -764,7 +897,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
764
897
|
// Строго после возврата каретки: склейка снимает границу блоков, а её смещения считают,
|
|
765
898
|
// и восстановленная по прежним смещениям каретка съехала бы на символ. Живое выделение
|
|
766
899
|
// переезжает вместе с узлами само.
|
|
767
|
-
if (!this.
|
|
900
|
+
if (!this.__separateParagraphs) mergeAdjacentBlocks(this.editable);
|
|
768
901
|
|
|
769
902
|
this.__emitChange();
|
|
770
903
|
formatToolbar.refresh();
|
|
@@ -954,7 +1087,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
954
1087
|
formatToolbar.detach(this);
|
|
955
1088
|
|
|
956
1089
|
// элемент передан хостом — не удаляем его, только снимаем оформление редактора
|
|
957
|
-
this.editable.classList.remove(
|
|
1090
|
+
this.editable.classList.remove(
|
|
1091
|
+
ROOT_CLASS,
|
|
1092
|
+
UNSELECTABLE_CLASS,
|
|
1093
|
+
BREAKS_CLASS,
|
|
1094
|
+
"multiline",
|
|
1095
|
+
"readonly",
|
|
1096
|
+
"focused"
|
|
1097
|
+
);
|
|
958
1098
|
this.editable.removeAttribute("contenteditable");
|
|
959
1099
|
delete this.editable.dataset.placeholder;
|
|
960
1100
|
|
|
@@ -967,24 +1107,19 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
967
1107
|
DOM.empty(this.editable);
|
|
968
1108
|
if (!value) return;
|
|
969
1109
|
|
|
970
|
-
// multiline → <p>-абзацы; single-line → инлайновое
|
|
971
|
-
// мягких переносов: цитата и код узнаются по собственной разметке, а не по пустой строке.
|
|
972
|
-
const blocks = this.blockTypes.length > 1;
|
|
973
|
-
const paragraphs = this.__blockParagraphs || (this.multiline && blocks);
|
|
974
|
-
|
|
1110
|
+
// multiline → <p>-абзацы; single-line → инлайновое содержимое
|
|
975
1111
|
this.editable.innerHTML = deserialize(
|
|
976
1112
|
value,
|
|
977
1113
|
this.__valueStorage,
|
|
978
1114
|
this.__valueTools,
|
|
979
1115
|
this.formatMarkers,
|
|
980
|
-
|
|
1116
|
+
this.multiline,
|
|
981
1117
|
this.blockTypes,
|
|
982
|
-
this.
|
|
1118
|
+
this.__separateParagraphs
|
|
983
1119
|
);
|
|
984
1120
|
|
|
985
|
-
//
|
|
986
|
-
|
|
987
|
-
if (this.multiline && !this.__blockParagraphs) ensureParagraphs(this.editable);
|
|
1121
|
+
// пустые абзацы значения получают заполнитель, чужая вёрстка — канонический тег
|
|
1122
|
+
if (this.multiline) ensureParagraphs(this.editable);
|
|
988
1123
|
}
|
|
989
1124
|
|
|
990
1125
|
/**
|
|
@@ -1042,7 +1177,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1042
1177
|
normalizeWhitespace(this.editable);
|
|
1043
1178
|
// В режиме мягких переносов соседние цитаты неразличимы: значение пишет их строки подряд,
|
|
1044
1179
|
// а разбор собирает в одну — склеиваем и в поле.
|
|
1045
|
-
if (this.multiline) normalizeParagraphs(this.editable, !this.
|
|
1180
|
+
if (this.multiline) normalizeParagraphs(this.editable, !this.__separateParagraphs);
|
|
1046
1181
|
if (this.editable.innerHTML === before) return;
|
|
1047
1182
|
|
|
1048
1183
|
if (bounds && selection) {
|
|
@@ -1114,9 +1249,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1114
1249
|
const { signal } = this.__abort;
|
|
1115
1250
|
const editable = this.editable;
|
|
1116
1251
|
|
|
1117
|
-
//
|
|
1252
|
+
// Бросок в редактор: текстовые файлы принимаются содержимым (см. __onDrop), всё прочее
|
|
1253
|
+
// гасится — свободное перетаскивание прошло бы мимо истории и фильтров хоста.
|
|
1118
1254
|
// dragenter/dragover отменять нельзя: в модели DnD отмена как раз и означает «сюда можно бросить»
|
|
1119
|
-
this.element.addEventListener("drop", (e) =>
|
|
1255
|
+
this.element.addEventListener("drop", (e) => this.__onDrop(e), { signal });
|
|
1120
1256
|
|
|
1121
1257
|
// Выделение страницы не должно затягивать содержимое редактора — см. __holdSelectable.
|
|
1122
1258
|
// Слушаем на документе и в фазе перехвата: протяжка начинается где угодно, а обработчик
|
|
@@ -1207,6 +1343,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1207
1343
|
this.element.addEventListener("paste", (e: ClipboardEvent) => this.__onPaste(e), { signal });
|
|
1208
1344
|
editable.addEventListener("keydown", (e: KeyboardEvent) => this.__onKeydown(e), { signal });
|
|
1209
1345
|
|
|
1346
|
+
// Каретку двигали клавишами: браузер к ней прокручивает сам, но до края коробки —
|
|
1347
|
+
// под отступы прокручиваемого контейнера. Поправляем после него, на keyup: на keydown
|
|
1348
|
+
// своей прокрутки он ещё не сделал, и поправка ушла бы впустую.
|
|
1349
|
+
editable.addEventListener(
|
|
1350
|
+
"keyup",
|
|
1351
|
+
(e: KeyboardEvent) => {
|
|
1352
|
+
if (NAV_KEYS.includes(e.key)) this.__scrollCaretIntoView();
|
|
1353
|
+
},
|
|
1354
|
+
{ signal }
|
|
1355
|
+
);
|
|
1356
|
+
|
|
1210
1357
|
editable.addEventListener("beforeinput", (e: InputEvent) => this.__onBeforeInput(e), { signal });
|
|
1211
1358
|
|
|
1212
1359
|
editable.addEventListener(
|
|
@@ -1223,6 +1370,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1223
1370
|
} else if (editable.firstChild?.nodeName === "BR") {
|
|
1224
1371
|
editable.innerHTML = "";
|
|
1225
1372
|
}
|
|
1373
|
+
// При наборе браузер прокручивает к каретке сам, но доводит её лишь до края
|
|
1374
|
+
// коробки — под отступы контейнера. Доводим до текста.
|
|
1375
|
+
this.__scrollCaretIntoView();
|
|
1376
|
+
|
|
1226
1377
|
this.__emitChange(true); // печать — единственный посимвольный источник, его и откладываем
|
|
1227
1378
|
},
|
|
1228
1379
|
{ signal }
|
|
@@ -1230,46 +1381,58 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1230
1381
|
}
|
|
1231
1382
|
|
|
1232
1383
|
/**
|
|
1233
|
-
* Доводит прокрутку до каретки.
|
|
1234
|
-
*
|
|
1235
|
-
* прокручивается не редактируемый элемент, а его
|
|
1236
|
-
*
|
|
1384
|
+
* Доводит прокрутку до каретки. Нужна там, где браузер не справляется сам: переносы строк
|
|
1385
|
+
* редактор делает вручную (preventDefault), и к новой строке браузер не прокручивает; в
|
|
1386
|
+
* textbox прокручивается не редактируемый элемент, а его обёртка; а своей прокруткой браузер
|
|
1387
|
+
* доводит каретку лишь до края коробки — под отступы, которые едут вместе с текстом.
|
|
1237
1388
|
*/
|
|
1238
1389
|
private __scrollCaretIntoView() {
|
|
1239
1390
|
const selection = this.selection;
|
|
1240
1391
|
if (!selection || selection.rangeCount === 0) return;
|
|
1241
1392
|
|
|
1242
|
-
const range = selection.getRangeAt(0);
|
|
1393
|
+
const range = selection.getRangeAt(0).cloneRange();
|
|
1243
1394
|
|
|
1244
1395
|
// в среде без раскладки (jsdom) у Range коробок нет вовсе
|
|
1245
1396
|
if (typeof range.getBoundingClientRect !== "function") return;
|
|
1246
1397
|
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1249
|
-
|
|
1250
|
-
if (!
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1398
|
+
// Выделение тянут клавишами (Shift+стрелки): вести нужно за его подвижным концом.
|
|
1399
|
+
// Коробка всего выделения накрывает все занятые строки, и прокрутка ушла бы к концу
|
|
1400
|
+
// неподвижному, уводя подвижный за пределы видимого.
|
|
1401
|
+
if (!range.collapsed) {
|
|
1402
|
+
const toStart = selection.focusNode === range.startContainer && selection.focusOffset === range.startOffset;
|
|
1403
|
+
range.collapse(toStart);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
let { top, bottom, height } = range.getBoundingClientRect();
|
|
1407
|
+
if (!height) {
|
|
1408
|
+
const line = caretLineBox(range);
|
|
1409
|
+
if (!line) return;
|
|
1410
|
+
({ top, bottom, height } = line);
|
|
1256
1411
|
}
|
|
1257
1412
|
// раскладки нет (тестовая среда) — прокручивать не по чему
|
|
1258
|
-
if (!
|
|
1413
|
+
if (!height && !top && !bottom) return;
|
|
1259
1414
|
|
|
1260
1415
|
// Ближайший прокручиваемый предок; прокручивается только он — внешним контейнерам
|
|
1261
1416
|
// каретка не адресована, крутить страницу из редактора нельзя.
|
|
1262
1417
|
const document = this.editable.ownerDocument;
|
|
1263
1418
|
for (let el: HTMLElement | null = this.editable; el && el !== document.body; el = el.parentElement) {
|
|
1264
1419
|
if (el.scrollHeight <= el.clientHeight) continue;
|
|
1265
|
-
const
|
|
1420
|
+
const style = document.defaultView?.getComputedStyle(el);
|
|
1421
|
+
if (!style) continue;
|
|
1422
|
+
const overflow = style.overflowY;
|
|
1266
1423
|
if (overflow !== "auto" && overflow !== "scroll") continue;
|
|
1267
1424
|
|
|
1425
|
+
// Границей служит не коробка, а область, свободная от отступов: они едут вместе
|
|
1426
|
+
// с текстом (см. .editor в textbox, .ui-richeditor в messageeditor), и доведённая
|
|
1427
|
+
// до края коробки строка встала бы под них. Своего отступа у контейнера может
|
|
1428
|
+
// и не быть — тогда держим небольшой запас, чтобы строка не прилипала к краю.
|
|
1429
|
+
const gap = Math.min(height || 16, 16);
|
|
1268
1430
|
const box = el.getBoundingClientRect();
|
|
1269
|
-
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
|
|
1431
|
+
const viewTop = box.top + el.clientTop + (parseFloat(style.paddingTop) || gap);
|
|
1432
|
+
const viewBottom = box.top + el.clientTop + el.clientHeight - (parseFloat(style.paddingBottom) || gap);
|
|
1433
|
+
|
|
1434
|
+
if (bottom > viewBottom) el.scrollTop += bottom - viewBottom;
|
|
1435
|
+
else if (top < viewTop) el.scrollTop -= viewTop - top;
|
|
1273
1436
|
return;
|
|
1274
1437
|
}
|
|
1275
1438
|
}
|
|
@@ -1356,15 +1519,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1356
1519
|
if (this.readonly) return;
|
|
1357
1520
|
|
|
1358
1521
|
// В режиме block Enter — новый абзац (<p>), модификатор — мягкий перенос (<br>).
|
|
1359
|
-
// В режиме break
|
|
1360
|
-
//
|
|
1522
|
+
// В режиме break абзац и есть строка, поэтому её создают оба нажатия: мягкому переносу
|
|
1523
|
+
// там неоткуда взяться — в значении он дал бы ровно то же самое.
|
|
1361
1524
|
// Внутри блока правило берётся у его типа: из цитаты и кода Enter выходит,
|
|
1362
1525
|
// а модификатор переносит строку внутри.
|
|
1363
1526
|
const withModifier = e.shiftKey || e.ctrlKey || e.metaKey;
|
|
1364
1527
|
const current = this.currentBlock;
|
|
1365
|
-
const
|
|
1366
|
-
current === DEFAULT_BLOCK
|
|
1367
|
-
|
|
1528
|
+
const soft =
|
|
1529
|
+
current === DEFAULT_BLOCK
|
|
1530
|
+
? this.__separateParagraphs && withModifier
|
|
1531
|
+
: BLOCK_TYPES[current].enter === "break" || withModifier;
|
|
1368
1532
|
|
|
1369
1533
|
this.__history?.record("op");
|
|
1370
1534
|
// Из блока выходят тем же нажатием, что делит его: продолжать цитату или код
|
|
@@ -1408,6 +1572,16 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1408
1572
|
// форматированную вставку берём из text/html только при включённом форматировании
|
|
1409
1573
|
const html = this.format && this.formatTools.length ? data.getData("text/html") : "";
|
|
1410
1574
|
|
|
1575
|
+
this.__insertExternal(plain, html);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* Общий приём внешнего текста — вставки из буфера и брошенного файла: фильтр хоста, затем
|
|
1580
|
+
* форматированная ветка, затем простой текст. Форматирование приходит двумя путями: text/html
|
|
1581
|
+
* из буфера и, при markdown-хранении, маркеры прямо в тексте — раз значение хранится этой
|
|
1582
|
+
* разметкой, вставленный текст с ней разбирается так же, как разбиралось бы значение.
|
|
1583
|
+
*/
|
|
1584
|
+
private __insertExternal(plain: string, html: string) {
|
|
1411
1585
|
// filterPaste решает по тексту: null — отклонить; если хук ИЗМЕНИЛ текст (обрезка по длине,
|
|
1412
1586
|
// фильтр по типу) — форматирование сохранить нельзя, вставляем очищенный простой текст
|
|
1413
1587
|
let plainOverride: string | null = null;
|
|
@@ -1424,7 +1598,23 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1424
1598
|
const selection = this.selection;
|
|
1425
1599
|
if (!selection) return;
|
|
1426
1600
|
|
|
1427
|
-
if (
|
|
1601
|
+
if (plainOverride == null) {
|
|
1602
|
+
const paras = html ? sanitizePastedHtml(html, this.formatTools, this.formatMarkers, this.blockTypes) : [];
|
|
1603
|
+
const markdown = this.format && this.formatStorage === "markdown";
|
|
1604
|
+
|
|
1605
|
+
// text/html точнее описывает скопированное, но лишь когда несёт собственную разметку.
|
|
1606
|
+
// Голый текст тоже приезжает html-ем — редакторы кода отдают исходник маркдауна
|
|
1607
|
+
// строками в <div>, — и такой html не знает ничего сверх text/plain, а маркеры в
|
|
1608
|
+
// тексте понимает только разбор разметкой хранения: при плоском html первым идёт он.
|
|
1609
|
+
// Документ (абзацы <p>, заголовки, списки) плоским не считается, даже когда в нём
|
|
1610
|
+
// нет ни одного тега форматирования: буквальные символы маркеров в тексте страницы
|
|
1611
|
+
// видит и её читатель, и разбор превращал бы их в разметку (см. isDocumentHtml).
|
|
1612
|
+
const flat = markdown && !hasPastedMarkup(paras) && !isDocumentHtml(html);
|
|
1613
|
+
|
|
1614
|
+
if (flat && this.__pasteMarkdown(plain, selection)) return;
|
|
1615
|
+
if (this.__insertPasted(paras, selection)) return;
|
|
1616
|
+
if (!flat && markdown && this.__pasteMarkdown(plain, selection)) return;
|
|
1617
|
+
}
|
|
1428
1618
|
this.__pastePlain(plainOverride ?? plain, selection);
|
|
1429
1619
|
}
|
|
1430
1620
|
|
|
@@ -1442,14 +1632,91 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1442
1632
|
? text.split(/\n/)
|
|
1443
1633
|
: text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
|
|
1444
1634
|
|
|
1445
|
-
this.
|
|
1635
|
+
const mode = literal || !this.multiline ? "single" : this.__separateParagraphs ? "block" : "line";
|
|
1636
|
+
|
|
1637
|
+
this.__insertPasted(buildParagraphs(lines, mode), selection);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Бросок файлов в редактор: текстовые (`text/*`, а без типа — `.md`/`.txt` по имени)
|
|
1642
|
+
* вставляются содержимым тем же путём, что вставка из буфера, — фильтр хоста, разбор
|
|
1643
|
+
* разметкой хранения объявленным набором, история. Любой другой бросок гасится: свободное
|
|
1644
|
+
* перетаскивание прошло бы мимо истории и фильтров хоста.
|
|
1645
|
+
*/
|
|
1646
|
+
private __onDrop(e: DragEvent) {
|
|
1647
|
+
e.preventDefault();
|
|
1648
|
+
|
|
1649
|
+
if (this.readonly) return;
|
|
1650
|
+
|
|
1651
|
+
const files = Array.from(e.dataTransfer?.files ?? []).filter(isTextFile);
|
|
1652
|
+
if (!files.length) return;
|
|
1653
|
+
|
|
1654
|
+
// Каретка — в точку броска, если браузер умеет её назвать; иначе остаётся прежняя.
|
|
1655
|
+
// Фокус нужен в любом случае: бросают и в поле, которое его ещё не получало, — вставлять
|
|
1656
|
+
// тогда некуда, а без каретки focus(true) ставит её в конец.
|
|
1657
|
+
this.__caretFromPoint(e.clientX, e.clientY);
|
|
1658
|
+
this.focus(true);
|
|
1659
|
+
|
|
1660
|
+
// Файл читается асинхронно: правка на это время придержана, чтобы нормализация между
|
|
1661
|
+
// делом не перестроила содержимое под выставленной кареткой.
|
|
1662
|
+
const release = this.holdEditing();
|
|
1663
|
+
Promise.all(files.map(readFileText))
|
|
1664
|
+
.then((texts) => {
|
|
1665
|
+
// компонент могли снять, пока файл читался, — вставлять больше некуда
|
|
1666
|
+
if (this.__disposed) return;
|
|
1667
|
+
|
|
1668
|
+
// несколько файлов — подряд через пустую строку: это отдельные тексты
|
|
1669
|
+
this.__insertExternal(texts.join("\n\n"), "");
|
|
1670
|
+
})
|
|
1671
|
+
.catch(() => this.__reject())
|
|
1672
|
+
.finally(release);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/** Ставит каретку в точку на экране, если браузер умеет её назвать, и точка — в редакторе. */
|
|
1676
|
+
private __caretFromPoint(x: number, y: number) {
|
|
1677
|
+
// стандартное API и его вебкитовский предшественник; в jsdom нет обоих
|
|
1678
|
+
const doc = this.editable.ownerDocument as Document & {
|
|
1679
|
+
caretPositionFromPoint?(x: number, y: number): { offsetNode: Node; offset: number } | null;
|
|
1680
|
+
caretRangeFromPoint?(x: number, y: number): Range | null;
|
|
1681
|
+
};
|
|
1682
|
+
|
|
1683
|
+
let node: Node | null = null;
|
|
1684
|
+
let offset = 0;
|
|
1685
|
+
if (doc.caretPositionFromPoint) {
|
|
1686
|
+
const position = doc.caretPositionFromPoint(x, y);
|
|
1687
|
+
if (position) ({ offsetNode: node, offset } = position);
|
|
1688
|
+
} else if (doc.caretRangeFromPoint) {
|
|
1689
|
+
const range = doc.caretRangeFromPoint(x, y);
|
|
1690
|
+
if (range) ({ startContainer: node, startOffset: offset } = range);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
if (!node || !this.editable.contains(node)) return;
|
|
1694
|
+
|
|
1695
|
+
const selection = documentSelection(this.editable);
|
|
1696
|
+
if (!selection) return;
|
|
1697
|
+
|
|
1698
|
+
const range = doc.createRange();
|
|
1699
|
+
range.setStart(node, offset);
|
|
1700
|
+
range.collapse(true);
|
|
1701
|
+
selection.removeAllRanges();
|
|
1702
|
+
selection.addRange(range);
|
|
1446
1703
|
}
|
|
1447
1704
|
|
|
1448
|
-
// Вставка
|
|
1449
|
-
//
|
|
1450
|
-
|
|
1705
|
+
// Вставка простого текста разметкой хранения (markdown): разбор тем же deserialize и с теми же
|
|
1706
|
+
// наборами, что у значения, — маркеры снятых инструментов остаются текстом, как остались бы
|
|
1707
|
+
// в значении. Возвращает false, если вставлять нечего или разбирать нельзя.
|
|
1708
|
+
private __pasteMarkdown(text: string, selection: Selection): boolean {
|
|
1709
|
+
// внутри блока без инлайновой разметки (код) текст литерален — как и при простой вставке
|
|
1710
|
+
if (!BLOCK_TYPES[this.currentBlock].inline) return false;
|
|
1711
|
+
|
|
1451
1712
|
return this.__insertPasted(
|
|
1452
|
-
|
|
1713
|
+
parsePastedMarkdown(
|
|
1714
|
+
text,
|
|
1715
|
+
this.__valueTools,
|
|
1716
|
+
this.formatMarkers,
|
|
1717
|
+
this.blockTypes,
|
|
1718
|
+
this.__separateParagraphs
|
|
1719
|
+
),
|
|
1453
1720
|
selection
|
|
1454
1721
|
);
|
|
1455
1722
|
}
|
|
@@ -1467,7 +1734,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1467
1734
|
|
|
1468
1735
|
const range = selection.getRangeAt(0);
|
|
1469
1736
|
this.__history?.record("op");
|
|
1737
|
+
|
|
1738
|
+
const spanned = !range.collapsed;
|
|
1470
1739
|
range.deleteContents();
|
|
1740
|
+
if (spanned && this.multiline) collapseEmptyEdges(this.editable, range);
|
|
1471
1741
|
|
|
1472
1742
|
const start = selectionCharBounds(this.editable, range)[0];
|
|
1473
1743
|
let caret: number;
|