@brandup/ui-richeditor 1.0.38 → 1.0.40
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 +32 -6
- package/package.json +2 -2
- package/source/editing.ts +85 -21
- package/source/format.ts +5 -1
- package/source/history.ts +18 -9
- package/source/index.ts +3 -0
- package/source/paragraphs.ts +23 -3
- package/source/richeditor.less +4 -0
- package/source/richeditor.ts +213 -107
- package/source/selection.ts +201 -75
- package/source/serialize.ts +28 -6
- package/source/toolbar.ts +160 -14
package/README.md
CHANGED
|
@@ -65,12 +65,14 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
65
65
|
| --- | --- |
|
|
66
66
|
| `editable` | Редактируемый элемент |
|
|
67
67
|
| `format`, `formatTools`, `editorActions`, `formatStorage`, `formatMarkers`, `multiline` | Параметры экземпляра |
|
|
68
|
-
| `getValue(): string` | Сериализованное значение (по `storage`) |
|
|
68
|
+
| `getValue(): string` | Сериализованное значение (по `storage`) — считается по DOM, всегда актуально |
|
|
69
69
|
| `setValue(value: string): void` | Установить значение (нормализует, генерирует `change`) |
|
|
70
|
+
| `flushChange(): void` | Доставить отложенное `change` немедленно (см. ниже) |
|
|
70
71
|
| `getLength(): number` | Длина текста (без учёта переводов строк) |
|
|
71
72
|
| `focus(): void` | Установить фокус |
|
|
72
73
|
| `applyFormat(tool): void` | Переключить формат на выделении (слово целиком) |
|
|
73
74
|
| `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
|
|
75
|
+
| `activeTools(): ReadonlySet<FormatTool>` | Активные форматы всех инструментов сразу (панель обновляется на каждое движение каретки, поштучный опрос обходил бы содержимое на каждую кнопку) |
|
|
74
76
|
| `clearFormat(): void` | Снять всё форматирование с выделения (без выделения — со слова под кареткой) |
|
|
75
77
|
| `clearAllFormat(): void` | Снять всё форматирование со всего содержимого |
|
|
76
78
|
| `undo(): void`, `redo(): void` | Отмена и повтор |
|
|
@@ -78,17 +80,34 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
78
80
|
| `applyAction(action): void` | Выполнить действие панели (`erase`/`undo`/`redo`) |
|
|
79
81
|
| `isActionEnabled(action): boolean` | Доступно ли действие сейчас |
|
|
80
82
|
| `insertText(text): void` | Вставить текст в каретку (или вместо выделения) с учётом режима набора |
|
|
83
|
+
| `selection: Selection \| null` | Выделение, если оно внутри редактора (иначе `null`) — единая точка доступа для хоста |
|
|
84
|
+
| `selectNode(node): void` | Выделить узел внутри редактора: следующая вставка заменит его целиком |
|
|
81
85
|
| `onChange(handler)` | Подписка на событие `richeditor-change` |
|
|
82
86
|
| `destroy(): void` | Разворачивает элемент обратно и освобождает ресурсы |
|
|
83
87
|
|
|
88
|
+
## Событие изменения
|
|
89
|
+
|
|
90
|
+
`getValue()` считает значение по DOM и точен всегда. А вот **уведомление** `richeditor-change` при печати доставляется с задержкой: сериализация — самая дорогая операция редактора (обход всего содержимого), а печать даёт `input` на каждый символ.
|
|
91
|
+
|
|
92
|
+
Это троттлинг, а не debounce: при непрерывном наборе событие приходит каждые ~150 мс, а не откладывается до паузы. Откладывается только печать — вставка, форматирование, отмена/повтор, `setValue` и Enter сообщаются сразу.
|
|
93
|
+
|
|
94
|
+
Отложенное доставляется немедленно:
|
|
95
|
+
|
|
96
|
+
- при потере фокуса и при `destroy()` — редактором самостоятельно;
|
|
97
|
+
- по вызову `flushChange()` — его должен звать хост перед тем, как значение прочитают снаружи.
|
|
98
|
+
|
|
99
|
+
Хосты пакета (`@brandup/ui-textbox`, `@brandup/ui-messageeditor`) держат в поле формы копию значения и сбрасывают отложенное перед каждым чтением значения снаружи: отправка формы, `validate()`, `getValue()`. На `submit` синхронизация идёт в фазе перехвата на документе — то есть раньше любого обработчика самой формы и независимо от того, включена ли её валидация (`novalidate`). Так что в отправляемых данных значение всегда актуально; свой хост обязан делать то же самое (в `@brandup/ui-input` для этого есть `__syncValue()`).
|
|
100
|
+
|
|
101
|
+
Не покрыт единственный случай: `new FormData(form)` **вне** отправки формы, в пределах окна троттлинга после ввода. Список полей там собирается до того, как о нём можно узнать, а точечно заменить свою запись `FormData` не позволяет. Читайте в таком коде `getValue()` хоста — он синхронизирует сам.
|
|
102
|
+
|
|
84
103
|
## Поведение форматирования
|
|
85
104
|
|
|
86
105
|
- Формат — переключатель (toggle): повторное применение снимает его.
|
|
87
106
|
- Применяется к слову целиком: курсор внутри слова или выделение его части → формат охватывает всё слово; исходное выделение/каретка сохраняются.
|
|
88
107
|
- **Режим набора**: на пустом месте (между пробелами / в пустом поле) кнопка/хоткей включают «ожидающий» формат — он применится к следующему введённому тексту. Сбрасывается при перемещении каретки, клике или потере фокуса.
|
|
89
108
|
- Хоткеи `Ctrl/Cmd+B/I/U`. Зачёркивание — только кнопкой.
|
|
90
|
-
- **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100
|
|
91
|
-
- При потере фокуса и после `setValue` пробелы нормализуются (схлопывание повторов + обрезка краёв строк).
|
|
109
|
+
- **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов, но не более ~512 КБ снимков суммарно (снимок — это всё содержимое редактора, поэтому на длинном тексте старые шаги вытесняются раньше).
|
|
110
|
+
- При потере фокуса и после `setValue` пробелы нормализуются (схлопывание повторов + обрезка краёв строк). Неразрывный пробел (`U+00A0`) считается обычным: браузер сам подставляет его в `contenteditable` вместо пробела, который иначе схлопнулся бы при отображении, и в значение он не попадает.
|
|
92
111
|
|
|
93
112
|
## Очистка форматирования и действия панели
|
|
94
113
|
|
|
@@ -133,16 +152,21 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
|
|
|
133
152
|
|
|
134
153
|
При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются.
|
|
135
154
|
|
|
136
|
-
## Вставка
|
|
155
|
+
## Вставка текста
|
|
137
156
|
|
|
138
157
|
При включённом форматировании вставка (`paste`) сохраняет форматирование из буфера обмена (`text/html`):
|
|
139
158
|
|
|
140
159
|
- разметка санитизируется до включённых инструментов (синонимы `STRONG/EM/DEL/INS` → канонические `b/i/s/u`, всё прочее — `span`, стили, классы, `<style>`/`<script>` — отбрасывается, текст сохраняется);
|
|
141
160
|
- **multiline** сохраняет абзацы `<p>` и мягкие переносы `<br>`, разбивая текущий абзац по каретке; **single-line** — инлайн, абзацы/переносы становятся пробелами;
|
|
142
|
-
- если `text/html` нет — простая текстовая вставка (как раньше);
|
|
143
161
|
- хук `filterPaste` остаётся в силе: вернул `null` — вставка отклоняется; изменил текст (обрезка по длине, фильтр по типу) — форматирование не сохраняется, вставляется очищенный текст.
|
|
144
162
|
|
|
145
|
-
|
|
163
|
+
Если `text/html` нет (или форматирование выключено), вставляется простой текст — по той же модели абзацев:
|
|
164
|
+
|
|
165
|
+
- **multiline**, режим `block` — пустая строка разделяет абзацы `<p>`, одиночный перенос остаётся мягким `<br>`;
|
|
166
|
+
- **multiline**, режим `break` — абзацы не создаются, все переносы мягкие;
|
|
167
|
+
- **single-line** — строки склеиваются пробелами.
|
|
168
|
+
|
|
169
|
+
Каретка в обоих случаях встаёт сразу за вставленным текстом, а вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
|
|
146
170
|
|
|
147
171
|
## Формат хранения
|
|
148
172
|
|
|
@@ -155,6 +179,8 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
|
|
|
155
179
|
|
|
156
180
|
Маркеры markdown настраиваются через `markers`. При разборе применяются по убыванию длины, поэтому длинный маркер срабатывает раньше короткого-префикса. Глубоко вложенные комбинации гарантированно сохраняются только в режиме `html`.
|
|
157
181
|
|
|
182
|
+
Вложенные пары разбираются (`_а **б** в_`), а вот **пересекающиеся** остаются текстом: в `**а _б** в_` внутренняя пара пересекает внешнюю, разметкой такое невыразимо, и короткий маркер отбрасывается — как и в мессенджерах.
|
|
183
|
+
|
|
158
184
|
Разметка распознаётся по правилам мессенджеров: маркер стоит на **границе слова**, содержимое не начинается и не заканчивается пробелом и не пересекает перенос строки. Поэтому `5**4 = 20`, `2 ** 2 ** 2` и `файл_имя_файла.txt` остаются обычным текстом — иначе редактор показывал бы форматирование там, где получатель увидит исходные символы. Форматирование применяется к словам целиком, так что собственный вывод редактора всегда разбирается обратно.
|
|
159
185
|
|
|
160
186
|
Краевые пробелы при сериализации выносятся за маркеры (`<b> слово </b>дальше` → `**слово** дальше`), иначе разметка не сработала бы ни у нас, ни у мессенджера. А вот формат, приклеенный к соседнему слову — такое приходит только со вставкой внешнего HTML (`супер<b>бонус</b>`), — маркерами невыразим: значение сохранится как `супер**бонус**` и разметкой уже не станет. Это осознанное решение: текст остаётся ровно тем, что набрал пользователь, а не молча теряет форматирование.
|
package/package.json
CHANGED
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
"email": "it@brandup.online"
|
|
28
28
|
},
|
|
29
29
|
"license": "Apache-2.0",
|
|
30
|
-
"version": "1.0.
|
|
30
|
+
"version": "1.0.40",
|
|
31
31
|
"main": "source/index.ts",
|
|
32
32
|
"types": "source/index.ts",
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@brandup/ui": "^2.0.7",
|
|
35
|
-
"@brandup/ui-kit": "^1.0.
|
|
35
|
+
"@brandup/ui-kit": "^1.0.40"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"source",
|
package/source/editing.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
// Низкоуровневые операции редактирования на чистом Selection/Range: абзацы, мягкие переносы,
|
|
2
|
-
// каретка, расширение/обрезка
|
|
3
|
-
// вызывающий сам решает, когда записывать undo-шаг.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
);
|
|
10
|
-
}
|
|
2
|
+
// каретка, расширение/обрезка выделения, разбор вставляемого содержимого. Без состояния
|
|
3
|
+
// редактора и без истории — вызывающий сам решает, когда записывать undo-шаг.
|
|
4
|
+
|
|
5
|
+
import { deserialize } from "./serialize";
|
|
6
|
+
import { isBlock } from "./paragraphs";
|
|
7
|
+
import { documentSelection, innerSelection } from "./selection";
|
|
8
|
+
import type { FormatMarkers, FormatTool } from "./format-config";
|
|
11
9
|
|
|
12
10
|
function emptyParagraph(): HTMLParagraphElement {
|
|
13
11
|
const p = document.createElement("p");
|
|
@@ -25,7 +23,7 @@ function caretToStart(node: Node) {
|
|
|
25
23
|
const range = document.createRange();
|
|
26
24
|
range.setStart(node, 0);
|
|
27
25
|
range.collapse(true);
|
|
28
|
-
const selection =
|
|
26
|
+
const selection = documentSelection(node);
|
|
29
27
|
if (selection) {
|
|
30
28
|
selection.removeAllRanges();
|
|
31
29
|
selection.addRange(range);
|
|
@@ -40,7 +38,7 @@ export function caretToEnd(editable: HTMLElement, multiline: boolean) {
|
|
|
40
38
|
const last = multiline ? editable.lastElementChild : null;
|
|
41
39
|
range.selectNodeContents(last && isBlock(last) ? last : editable);
|
|
42
40
|
range.collapse(false);
|
|
43
|
-
const sel =
|
|
41
|
+
const sel = documentSelection(editable);
|
|
44
42
|
if (sel) {
|
|
45
43
|
sel.removeAllRanges();
|
|
46
44
|
sel.addRange(range);
|
|
@@ -50,13 +48,13 @@ export function caretToEnd(editable: HTMLElement, multiline: boolean) {
|
|
|
50
48
|
/** Фокус и выделение всего содержимого (например, readonly-режим). */
|
|
51
49
|
export function selectAllContent(editable: HTMLElement) {
|
|
52
50
|
editable.focus();
|
|
53
|
-
|
|
51
|
+
documentSelection(editable)?.selectAllChildren(editable);
|
|
54
52
|
}
|
|
55
53
|
|
|
56
54
|
/** Enter в multiline: разбить текущий абзац по каретке на два <p>. */
|
|
57
55
|
export function insertParagraph(editable: HTMLElement) {
|
|
58
|
-
const selection =
|
|
59
|
-
if (!selection
|
|
56
|
+
const selection = innerSelection(editable);
|
|
57
|
+
if (!selection) return;
|
|
60
58
|
|
|
61
59
|
const range = selection.getRangeAt(0);
|
|
62
60
|
range.deleteContents();
|
|
@@ -101,8 +99,8 @@ export function insertParagraph(editable: HTMLElement) {
|
|
|
101
99
|
|
|
102
100
|
/** Shift/Ctrl+Enter в multiline: вставить мягкий перенос <br>. */
|
|
103
101
|
export function insertSoftBreak(editable: HTMLElement) {
|
|
104
|
-
const selection =
|
|
105
|
-
if (!selection
|
|
102
|
+
const selection = innerSelection(editable);
|
|
103
|
+
if (!selection) return;
|
|
106
104
|
|
|
107
105
|
const range = selection.getRangeAt(0);
|
|
108
106
|
range.deleteContents();
|
|
@@ -168,15 +166,80 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
|
|
|
168
166
|
}
|
|
169
167
|
|
|
170
168
|
/** Обрезает пробелы по краям абзаца (после схлопывания) — у крайних текстовых узлов. */
|
|
171
|
-
|
|
169
|
+
function trimParagraphEdges(p: HTMLElement) {
|
|
172
170
|
const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
|
|
173
171
|
const texts: Text[] = [];
|
|
174
172
|
for (let t = walker.nextNode() as Text | null; t; t = walker.nextNode() as Text | null) texts.push(t);
|
|
175
173
|
if (!texts.length) return;
|
|
176
174
|
|
|
177
|
-
|
|
175
|
+
// неразрывный пробел режем наравне с обычным — из буфера обмена он приходит регулярно
|
|
176
|
+
texts[0].textContent = (texts[0].textContent ?? "").replace(/^[ \u00A0]/, "");
|
|
178
177
|
const last = texts[texts.length - 1];
|
|
179
|
-
last.textContent = (last.textContent ?? "").replace(/ $/, "");
|
|
178
|
+
last.textContent = (last.textContent ?? "").replace(/[ \u00A0]$/, "");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Строки вставляемого текста → абзацы `<p>` с мягкими переносами `<br>` внутри.
|
|
183
|
+
*
|
|
184
|
+
* При `blocks` абзацы разделяет пустая строка (режим `block` многострочного редактора);
|
|
185
|
+
* иначе весь текст — один абзац, а все переносы мягкие: так вставка ложится в ту же модель,
|
|
186
|
+
* которую даёт Enter, и значение после неё разбирается обратно.
|
|
187
|
+
*/
|
|
188
|
+
export function buildParagraphs(lines: string[], blocks: boolean): HTMLElement[] {
|
|
189
|
+
const groups: string[][] = [];
|
|
190
|
+
|
|
191
|
+
if (blocks) {
|
|
192
|
+
let group: string[] = [];
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
if (line !== "") group.push(line);
|
|
195
|
+
else if (group.length) {
|
|
196
|
+
groups.push(group);
|
|
197
|
+
group = [];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (group.length) groups.push(group);
|
|
201
|
+
} else if (lines.length) groups.push(lines);
|
|
202
|
+
|
|
203
|
+
return groups.map((group) => {
|
|
204
|
+
const p = document.createElement("p");
|
|
205
|
+
group.forEach((line, index) => {
|
|
206
|
+
if (index > 0) p.appendChild(document.createElement("br"));
|
|
207
|
+
p.appendChild(document.createTextNode(line));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return p;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Разбирает HTML из буфера обмена в абзацы `<p>`, оставляя только разрешённые инструменты.
|
|
216
|
+
* Пустой результат — вставлять нечего (вызывающий откатится на простой текст).
|
|
217
|
+
*/
|
|
218
|
+
export function sanitizePastedHtml(html: string, tools: FormatTool[], markers: FormatMarkers): HTMLElement[] {
|
|
219
|
+
// убираем мусорные элементы (Word/браузер: стили, скрипты, заголовок документа)
|
|
220
|
+
const source = document.createElement("template");
|
|
221
|
+
source.innerHTML = html;
|
|
222
|
+
source.content.querySelectorAll("script, style, head, meta, link, title, noscript").forEach((el) => el.remove());
|
|
223
|
+
|
|
224
|
+
// единый источник санитизации — deserialize (теги-синонимы → канонические, лишнее развёрнуто)
|
|
225
|
+
const holder = document.createElement("template");
|
|
226
|
+
holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true);
|
|
227
|
+
|
|
228
|
+
// внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
|
|
229
|
+
// иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
|
|
230
|
+
const walker = document.createTreeWalker(holder.content, NodeFilter.SHOW_TEXT);
|
|
231
|
+
for (let t = walker.nextNode(); t; t = walker.nextNode())
|
|
232
|
+
t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
|
|
233
|
+
|
|
234
|
+
const paras = Array.from(holder.content.children) as HTMLElement[];
|
|
235
|
+
for (const p of paras) trimParagraphEdges(p);
|
|
236
|
+
|
|
237
|
+
// отбрасываем пустые краевые абзацы (ведущие/хвостовые \n и <br>-обёртки из буфера),
|
|
238
|
+
// иначе перед и после вставленного текста появляются пустые строки
|
|
239
|
+
while (paras.length && (paras[0].textContent ?? "").trim() === "") paras.shift();
|
|
240
|
+
while (paras.length && (paras[paras.length - 1].textContent ?? "").trim() === "") paras.pop();
|
|
241
|
+
|
|
242
|
+
return paras;
|
|
180
243
|
}
|
|
181
244
|
|
|
182
245
|
/**
|
|
@@ -207,9 +270,10 @@ export function expandRangeToWords(editable: HTMLElement, range: Range): Range {
|
|
|
207
270
|
|
|
208
271
|
/** Убирает пробелы по краям выделения (например, после двойного клика по слову). */
|
|
209
272
|
export function trimSelectionWhitespace(editable: HTMLElement) {
|
|
210
|
-
const selection =
|
|
211
|
-
if (!selection || selection.
|
|
273
|
+
const selection = innerSelection(editable);
|
|
274
|
+
if (!selection || selection.isCollapsed) return;
|
|
212
275
|
|
|
276
|
+
// внутри редактора должно быть не только начало выделения, но и его конец
|
|
213
277
|
const range = selection.getRangeAt(0);
|
|
214
278
|
if (!editable.contains(range.startContainer) || !editable.contains(range.endContainer)) return;
|
|
215
279
|
|
package/source/format.ts
CHANGED
|
@@ -21,9 +21,13 @@ export {
|
|
|
21
21
|
} from "./format-config";
|
|
22
22
|
export { serialize, deserialize } from "./serialize";
|
|
23
23
|
export {
|
|
24
|
+
documentSelection,
|
|
25
|
+
innerSelection,
|
|
26
|
+
preserveCaret,
|
|
24
27
|
selectionCharBounds,
|
|
25
28
|
restoreSelection,
|
|
26
29
|
mapCharOffset,
|
|
30
|
+
activeFormats,
|
|
27
31
|
toggleFormat,
|
|
28
32
|
clearFormat,
|
|
29
33
|
clearAllFormat,
|
|
@@ -32,4 +36,4 @@ export {
|
|
|
32
36
|
insertFormattedText,
|
|
33
37
|
isFormatActive,
|
|
34
38
|
} from "./selection";
|
|
35
|
-
export { normalizeWhitespace, normalizeParagraphs, ensureParagraphs } from "./paragraphs";
|
|
39
|
+
export { isBlock, normalizeWhitespace, normalizeParagraphs, ensureParagraphs } from "./paragraphs";
|
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,6 +28,7 @@ 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;
|
|
@@ -41,11 +45,8 @@ export class EditorHistory {
|
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
private __snapshot(): Snapshot {
|
|
44
|
-
const sel =
|
|
45
|
-
|
|
46
|
-
let end = 0;
|
|
47
|
-
if (sel && sel.rangeCount > 0 && this.__root.contains(sel.anchorNode))
|
|
48
|
-
[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];
|
|
49
50
|
|
|
50
51
|
return { html: this.__root.innerHTML, start, end };
|
|
51
52
|
}
|
|
@@ -68,7 +69,10 @@ export class EditorHistory {
|
|
|
68
69
|
if (top && top.html === snap.html) return; // состояние не изменилось — не дублируем
|
|
69
70
|
|
|
70
71
|
this.__undo.push(snap);
|
|
71
|
-
|
|
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
|
+
|
|
72
76
|
this.__redo = [];
|
|
73
77
|
}
|
|
74
78
|
|
|
@@ -77,6 +81,7 @@ export class EditorHistory {
|
|
|
77
81
|
const prev = this.__undo.pop();
|
|
78
82
|
if (!prev) return false;
|
|
79
83
|
|
|
84
|
+
this.__chars -= prev.html.length;
|
|
80
85
|
this.__redo.push(this.__snapshot());
|
|
81
86
|
this.__restore(prev);
|
|
82
87
|
this.__lastKind = null; // следующая печать начнёт новый шаг
|
|
@@ -88,7 +93,9 @@ export class EditorHistory {
|
|
|
88
93
|
const next = this.__redo.pop();
|
|
89
94
|
if (!next) return false;
|
|
90
95
|
|
|
91
|
-
this.
|
|
96
|
+
const current = this.__snapshot();
|
|
97
|
+
this.__undo.push(current);
|
|
98
|
+
this.__chars += current.html.length;
|
|
92
99
|
this.__restore(next);
|
|
93
100
|
this.__lastKind = null;
|
|
94
101
|
return true;
|
|
@@ -96,7 +103,9 @@ export class EditorHistory {
|
|
|
96
103
|
|
|
97
104
|
private __restore(snap: Snapshot): void {
|
|
98
105
|
this.__root.innerHTML = snap.html;
|
|
99
|
-
|
|
106
|
+
|
|
107
|
+
// содержимое заменено целиком — выделение восстанавливаем, где бы оно ни стояло
|
|
108
|
+
const sel = documentSelection(this.__root);
|
|
100
109
|
if (sel) restoreSelection(this.__root, snap.start, snap.end, sel);
|
|
101
110
|
}
|
|
102
111
|
}
|
package/source/index.ts
CHANGED
package/source/paragraphs.ts
CHANGED
|
@@ -3,10 +3,30 @@
|
|
|
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" };
|
|
@@ -27,7 +47,7 @@ export function normalizeWhitespace(root: HTMLElement) {
|
|
|
27
47
|
const el = child as HTMLElement;
|
|
28
48
|
if (el.tagName === "BR") {
|
|
29
49
|
items.push({ kind: "break" });
|
|
30
|
-
} else if (el
|
|
50
|
+
} else if (isBlock(el)) {
|
|
31
51
|
items.push({ kind: "break" });
|
|
32
52
|
flatten(el);
|
|
33
53
|
items.push({ kind: "break" });
|
|
@@ -52,7 +72,7 @@ export function normalizeWhitespace(root: HTMLElement) {
|
|
|
52
72
|
continue;
|
|
53
73
|
}
|
|
54
74
|
|
|
55
|
-
let text = item.node.data.replace(
|
|
75
|
+
let text = item.node.data.replace(SPACE_RUN, " ");
|
|
56
76
|
if (atLineStart) text = text.replace(/^ /, ""); // пробел в начале строки
|
|
57
77
|
if (pendingSpaceNode && text.startsWith(" ")) text = text.slice(1); // двойной пробел на границе узлов
|
|
58
78
|
|
|
@@ -96,7 +116,7 @@ export function ensureParagraphs(root: HTMLElement) {
|
|
|
96
116
|
for (const node of Array.from(root.childNodes)) {
|
|
97
117
|
const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
|
|
98
118
|
|
|
99
|
-
if (el && (el
|
|
119
|
+
if (el && isBlock(el)) {
|
|
100
120
|
flushRun(node);
|
|
101
121
|
if (el.tagName === "DIV") {
|
|
102
122
|
const p = document.createElement("p");
|
package/source/richeditor.less
CHANGED
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
& .format-button,
|
|
110
|
+
& .host-button,
|
|
110
111
|
& .action-button {
|
|
111
112
|
width: calc(var(--input-height, 46px) - 12px);
|
|
112
113
|
height: calc(var(--input-height, 46px) - 12px);
|
|
@@ -161,6 +162,9 @@
|
|
|
161
162
|
flex-flow: row wrap;
|
|
162
163
|
|
|
163
164
|
& .emoji {
|
|
165
|
+
// в панели больше семисот кнопок: за пределами прокрутки браузер их не раскладывает
|
|
166
|
+
content-visibility: auto;
|
|
167
|
+
contain-intrinsic-size: 32px 32px;
|
|
164
168
|
width: 32px;
|
|
165
169
|
height: 32px;
|
|
166
170
|
padding: 0;
|