@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/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 = window.getSelection();
35
- let start = 0;
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
- if (this.__undo.length > MAX_DEPTH) this.__undo.shift();
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.__undo.push(this.__snapshot());
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
- const sel = window.getSelection();
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";
@@ -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.tagName === "DIV" || el.tagName === "P") {
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.data = pendingSpaceNode.data.replace(/ $/, "");
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(/[ \t]+/g, " ");
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.data = text;
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.data = pendingSpaceNode.data.replace(/ $/, ""); // хвост последней строки
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.tagName === "P" || el.tagName === "DIV")) {
119
+ if (el && isBlock(el)) {
93
120
  flushRun(node);
94
121
  if (el.tagName === "DIV") {
95
122
  const p = document.createElement("p");
@@ -98,7 +98,17 @@
98
98
  margin-bottom: 6px;
99
99
  }
100
100
 
101
- & .format-button {
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
  }