@brandup/ui-richeditor 1.0.36 → 1.0.39
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 +84 -12
- package/package.json +3 -2
- package/source/editing.ts +91 -28
- package/source/emoji.ts +53 -0
- package/source/format-config.ts +45 -18
- package/source/format.ts +22 -2
- package/source/history.ts +28 -9
- package/source/index.ts +9 -0
- package/source/paragraphs.ts +33 -6
- package/source/richeditor.less +56 -1
- package/source/richeditor.ts +396 -109
- package/source/selection.ts +297 -81
- package/source/serialize.ts +296 -201
- package/source/toolbar.ts +251 -17
- package/svg/emoji.svg +3 -0
- package/svg/erase.svg +1 -0
- package/svg/redo.svg +1 -0
- package/svg/undo.svg +1 -0
package/source/history.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// хуже того, ломается при смешивании с ручными мутациями. Поэтому ведём свою историю снимков
|
|
6
6
|
// (innerHTML + позиция выделения). Используется только при включённом форматировании.
|
|
7
7
|
|
|
8
|
-
import { restoreSelection, selectionCharBounds } from "./format";
|
|
8
|
+
import { documentSelection, innerSelection, restoreSelection, selectionCharBounds } from "./format";
|
|
9
9
|
|
|
10
10
|
interface Snapshot {
|
|
11
11
|
html: string;
|
|
@@ -18,6 +18,9 @@ export type HistoryKind = "type" | "op";
|
|
|
18
18
|
|
|
19
19
|
const COALESCE_MS = 300; // печать в пределах паузы — один шаг отмены
|
|
20
20
|
const MAX_DEPTH = 100; // ограничение глубины истории (память)
|
|
21
|
+
// Снимок — это весь innerHTML редактора, поэтому глубины мало: сто шагов на длинном тексте
|
|
22
|
+
// это мегабайты на одно поле. Ограничиваем ещё и суммарный объём, отбрасывая самые старые шаги.
|
|
23
|
+
const MAX_CHARS = 512 * 1024;
|
|
21
24
|
|
|
22
25
|
export class EditorHistory {
|
|
23
26
|
private readonly __root: HTMLElement;
|
|
@@ -25,17 +28,25 @@ export class EditorHistory {
|
|
|
25
28
|
private __redo: Snapshot[] = [];
|
|
26
29
|
private __lastKind: HistoryKind | null = null;
|
|
27
30
|
private __lastTime = 0;
|
|
31
|
+
private __chars = 0; // суммарный объём снимков отмены — см. MAX_CHARS
|
|
28
32
|
|
|
29
33
|
constructor(root: HTMLElement) {
|
|
30
34
|
this.__root = root;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
/** Есть ли что отменять (для disabled-состояния кнопки). */
|
|
38
|
+
get canUndo(): boolean {
|
|
39
|
+
return this.__undo.length > 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Есть ли что повторять. */
|
|
43
|
+
get canRedo(): boolean {
|
|
44
|
+
return this.__redo.length > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
private __snapshot(): Snapshot {
|
|
34
|
-
const sel =
|
|
35
|
-
|
|
36
|
-
let end = 0;
|
|
37
|
-
if (sel && sel.rangeCount > 0 && this.__root.contains(sel.anchorNode))
|
|
38
|
-
[start, end] = selectionCharBounds(this.__root, sel.getRangeAt(0));
|
|
48
|
+
const sel = innerSelection(this.__root);
|
|
49
|
+
const [start, end] = sel ? selectionCharBounds(this.__root, sel.getRangeAt(0)) : [0, 0];
|
|
39
50
|
|
|
40
51
|
return { html: this.__root.innerHTML, start, end };
|
|
41
52
|
}
|
|
@@ -58,7 +69,10 @@ export class EditorHistory {
|
|
|
58
69
|
if (top && top.html === snap.html) return; // состояние не изменилось — не дублируем
|
|
59
70
|
|
|
60
71
|
this.__undo.push(snap);
|
|
61
|
-
|
|
72
|
+
this.__chars += snap.html.length;
|
|
73
|
+
while (this.__undo.length > MAX_DEPTH || (this.__chars > MAX_CHARS && this.__undo.length > 1))
|
|
74
|
+
this.__chars -= this.__undo.shift()!.html.length;
|
|
75
|
+
|
|
62
76
|
this.__redo = [];
|
|
63
77
|
}
|
|
64
78
|
|
|
@@ -67,6 +81,7 @@ export class EditorHistory {
|
|
|
67
81
|
const prev = this.__undo.pop();
|
|
68
82
|
if (!prev) return false;
|
|
69
83
|
|
|
84
|
+
this.__chars -= prev.html.length;
|
|
70
85
|
this.__redo.push(this.__snapshot());
|
|
71
86
|
this.__restore(prev);
|
|
72
87
|
this.__lastKind = null; // следующая печать начнёт новый шаг
|
|
@@ -78,7 +93,9 @@ export class EditorHistory {
|
|
|
78
93
|
const next = this.__redo.pop();
|
|
79
94
|
if (!next) return false;
|
|
80
95
|
|
|
81
|
-
this.
|
|
96
|
+
const current = this.__snapshot();
|
|
97
|
+
this.__undo.push(current);
|
|
98
|
+
this.__chars += current.html.length;
|
|
82
99
|
this.__restore(next);
|
|
83
100
|
this.__lastKind = null;
|
|
84
101
|
return true;
|
|
@@ -86,7 +103,9 @@ export class EditorHistory {
|
|
|
86
103
|
|
|
87
104
|
private __restore(snap: Snapshot): void {
|
|
88
105
|
this.__root.innerHTML = snap.html;
|
|
89
|
-
|
|
106
|
+
|
|
107
|
+
// содержимое заменено целиком — выделение восстанавливаем, где бы оно ни стояло
|
|
108
|
+
const sel = documentSelection(this.__root);
|
|
90
109
|
if (sel) restoreSelection(this.__root, snap.start, snap.end, sel);
|
|
91
110
|
}
|
|
92
111
|
}
|
package/source/index.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
export { default } from "./richeditor";
|
|
2
2
|
export * from "./richeditor";
|
|
3
|
+
export { EMOJIS } from "./emoji";
|
|
3
4
|
export {
|
|
5
|
+
ALL_EDITOR_ACTIONS,
|
|
4
6
|
ALL_FORMAT_TOOLS,
|
|
7
|
+
EDITOR_ACTIONS,
|
|
5
8
|
FORMAT_TOOLS,
|
|
9
|
+
parseEditorActions,
|
|
6
10
|
parseFormatTools,
|
|
7
11
|
defaultFormatMarkers,
|
|
8
12
|
normalizeWhitespace,
|
|
13
|
+
selectionCharBounds,
|
|
14
|
+
restoreSelection,
|
|
15
|
+
preserveCaret,
|
|
16
|
+
type EditorAction,
|
|
9
17
|
type FormatTool,
|
|
10
18
|
type FormatStorage,
|
|
19
|
+
type ParagraphMode,
|
|
11
20
|
type FormatMarkers,
|
|
12
21
|
} from "./format";
|
package/source/paragraphs.ts
CHANGED
|
@@ -3,15 +3,42 @@
|
|
|
3
3
|
|
|
4
4
|
import { cleanupFormatting } from "./selection";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Что в содержимом редактора считается абзацем. Единственное определение модели абзацев:
|
|
8
|
+
* по нему идут и разбор с сериализацией, и правки каретки, и нормализация. `<div>` признаём
|
|
9
|
+
* наравне с `<p>` — его приносят вставка и чужой contenteditable, а нормализация приводит к `<p>`.
|
|
10
|
+
*/
|
|
11
|
+
export function isBlock(node: Node): boolean {
|
|
12
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
|
13
|
+
|
|
14
|
+
const tag = (node as Element).tagName;
|
|
15
|
+
return tag === "P" || tag === "DIV";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Пробел, таб и неразрывный пробел (U+00A0) — всё это «пробел» при наборе; см. normalizeWhitespace.
|
|
19
|
+
// После схлопывания в тексте остаются только обычные пробелы, поэтому дальше по коду хватает " ".
|
|
20
|
+
const SPACE_RUN = /[ \t\u00A0]+/g;
|
|
21
|
+
|
|
6
22
|
/**
|
|
7
23
|
* Нормализует пробелы в редакторе: схлопывает повторяющиеся пробелы/табы в один
|
|
8
24
|
* и обрезает пробелы по краям каждой строки. BR и блочные элементы (DIV/P) —
|
|
9
25
|
* границы строк; инлайновое форматирование (b/i/s/u) на строки не влияет.
|
|
26
|
+
*
|
|
27
|
+
* Неразрывный пробел (U+00A0) считается обычным: браузер сам подставляет его в contenteditable
|
|
28
|
+
* вместо пробела, который иначе схлопнулся бы при отображении. Без этого набранные подряд
|
|
29
|
+
* пробелы не схлопывались бы вовсе, а U+00A0 уезжал бы в сохраняемое значение.
|
|
10
30
|
*/
|
|
11
31
|
export function normalizeWhitespace(root: HTMLElement) {
|
|
12
32
|
type Item = { kind: "text"; node: Text } | { kind: "break" };
|
|
13
33
|
const items: Item[] = [];
|
|
14
34
|
|
|
35
|
+
// Присваивание Text.data — это «replace data» по всему узлу, а оно схлопывает границы
|
|
36
|
+
// живых Range внутри узла в его начало: каретка уезжает в начало строки. Нормализация
|
|
37
|
+
// чаще всего ничего не меняет (вызывается на blur), поэтому пишем только при отличии.
|
|
38
|
+
const setData = (node: Text, text: string) => {
|
|
39
|
+
if (node.data !== text) node.data = text;
|
|
40
|
+
};
|
|
41
|
+
|
|
15
42
|
const flatten = (node: Node) => {
|
|
16
43
|
for (const child of Array.from(node.childNodes)) {
|
|
17
44
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
@@ -20,7 +47,7 @@ export function normalizeWhitespace(root: HTMLElement) {
|
|
|
20
47
|
const el = child as HTMLElement;
|
|
21
48
|
if (el.tagName === "BR") {
|
|
22
49
|
items.push({ kind: "break" });
|
|
23
|
-
} else if (el
|
|
50
|
+
} else if (isBlock(el)) {
|
|
24
51
|
items.push({ kind: "break" });
|
|
25
52
|
flatten(el);
|
|
26
53
|
items.push({ kind: "break" });
|
|
@@ -38,25 +65,25 @@ export function normalizeWhitespace(root: HTMLElement) {
|
|
|
38
65
|
for (const item of items) {
|
|
39
66
|
if (item.kind === "break") {
|
|
40
67
|
if (pendingSpaceNode) {
|
|
41
|
-
pendingSpaceNode
|
|
68
|
+
setData(pendingSpaceNode, pendingSpaceNode.data.replace(/ $/, ""));
|
|
42
69
|
pendingSpaceNode = null;
|
|
43
70
|
}
|
|
44
71
|
atLineStart = true;
|
|
45
72
|
continue;
|
|
46
73
|
}
|
|
47
74
|
|
|
48
|
-
let text = item.node.data.replace(
|
|
75
|
+
let text = item.node.data.replace(SPACE_RUN, " ");
|
|
49
76
|
if (atLineStart) text = text.replace(/^ /, ""); // пробел в начале строки
|
|
50
77
|
if (pendingSpaceNode && text.startsWith(" ")) text = text.slice(1); // двойной пробел на границе узлов
|
|
51
78
|
|
|
52
|
-
item.node
|
|
79
|
+
setData(item.node, text);
|
|
53
80
|
if (text.length === 0) continue;
|
|
54
81
|
|
|
55
82
|
atLineStart = false;
|
|
56
83
|
pendingSpaceNode = text.endsWith(" ") ? item.node : null;
|
|
57
84
|
}
|
|
58
85
|
|
|
59
|
-
if (pendingSpaceNode) pendingSpaceNode
|
|
86
|
+
if (pendingSpaceNode) setData(pendingSpaceNode, pendingSpaceNode.data.replace(/ $/, "")); // хвост последней строки
|
|
60
87
|
|
|
61
88
|
cleanupFormatting(root); // убрать опустевшие теги, склеить узлы
|
|
62
89
|
}
|
|
@@ -89,7 +116,7 @@ export function ensureParagraphs(root: HTMLElement) {
|
|
|
89
116
|
for (const node of Array.from(root.childNodes)) {
|
|
90
117
|
const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
|
|
91
118
|
|
|
92
|
-
if (el && (el
|
|
119
|
+
if (el && isBlock(el)) {
|
|
93
120
|
flushRun(node);
|
|
94
121
|
if (el.tagName === "DIV") {
|
|
95
122
|
const p = document.createElement("p");
|
package/source/richeditor.less
CHANGED
|
@@ -98,7 +98,17 @@
|
|
|
98
98
|
margin-bottom: 6px;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// разделитель между инструментами форматирования и действиями
|
|
102
|
+
& .split {
|
|
103
|
+
width: 1px;
|
|
104
|
+
align-self: stretch;
|
|
105
|
+
margin: 2px 3px;
|
|
106
|
+
background-color: var(--input-border-color, #aaa);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
& .format-button,
|
|
110
|
+
& .host-button,
|
|
111
|
+
& .action-button {
|
|
102
112
|
width: calc(var(--input-height, 46px) - 12px);
|
|
103
113
|
height: calc(var(--input-height, 46px) - 12px);
|
|
104
114
|
border-radius: calc(var(--input-border-radius, 0) - 2px);
|
|
@@ -123,5 +133,50 @@
|
|
|
123
133
|
&.active {
|
|
124
134
|
background-color: var(--hover--input-toolbar-button-fill, rgba(0, 0, 0, 0.12));
|
|
125
135
|
}
|
|
136
|
+
|
|
137
|
+
&[disabled] {
|
|
138
|
+
cursor: default;
|
|
139
|
+
opacity: 0.4;
|
|
140
|
+
// хит-тест проваливается на сам тулбар: браузер не диспатчит события на disabled-кнопку,
|
|
141
|
+
// и без этого mousedown не гасится — редактор теряет фокус, а панель прячется по blur
|
|
142
|
+
pointer-events: none;
|
|
143
|
+
|
|
144
|
+
&:hover {
|
|
145
|
+
background: var(--input-toolbar-button-fill, transparent);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// панель вставки смайликов — попап под кнопкой тулбара (открытием/закрытием управляет
|
|
152
|
+
// PopupManager из @brandup/ui-kit; базовые .ui-popup-стили приходят оттуда же).
|
|
153
|
+
.ui-richeditor-emoji {
|
|
154
|
+
top: 100%;
|
|
155
|
+
left: 0;
|
|
156
|
+
margin-top: 4px;
|
|
157
|
+
width: 296px;
|
|
158
|
+
max-height: 220px;
|
|
159
|
+
overflow-y: auto;
|
|
160
|
+
padding: 4px;
|
|
161
|
+
display: flex;
|
|
162
|
+
flex-flow: row wrap;
|
|
163
|
+
|
|
164
|
+
& .emoji {
|
|
165
|
+
// в панели больше семисот кнопок: за пределами прокрутки браузер их не раскладывает
|
|
166
|
+
content-visibility: auto;
|
|
167
|
+
contain-intrinsic-size: 32px 32px;
|
|
168
|
+
width: 32px;
|
|
169
|
+
height: 32px;
|
|
170
|
+
padding: 0;
|
|
171
|
+
border: 0;
|
|
172
|
+
background: transparent;
|
|
173
|
+
cursor: pointer;
|
|
174
|
+
font-size: 20px;
|
|
175
|
+
line-height: 1;
|
|
176
|
+
border-radius: calc(var(--input-border-radius, 0) - 2px);
|
|
177
|
+
|
|
178
|
+
&:hover {
|
|
179
|
+
background-color: var(--hover--input-toolbar-button-fill, rgba(0, 0, 0, 0.06));
|
|
180
|
+
}
|
|
126
181
|
}
|
|
127
182
|
}
|