@brandup/ui-richeditor 1.0.36
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 +118 -0
- package/package.json +41 -0
- package/source/editing.ts +238 -0
- package/source/format-config.ts +89 -0
- package/source/format.ts +19 -0
- package/source/history.ts +92 -0
- package/source/index.ts +12 -0
- package/source/paragraphs.ts +119 -0
- package/source/richeditor.less +127 -0
- package/source/richeditor.ts +627 -0
- package/source/selection.ts +267 -0
- package/source/serialize.ts +201 -0
- package/source/toolbar.ts +150 -0
- package/source/typings/less.d.ts +1 -0
- package/source/typings/svg.d.ts +4 -0
- package/svg/bold.svg +3 -0
- package/svg/italic.svg +3 -0
- package/svg/strike.svg +5 -0
- package/svg/underline.svg +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# @brandup/ui-richeditor
|
|
2
|
+
|
|
3
|
+
[](https://dev.azure.com/brandup/BrandUp%20Core/_build/latest?definitionId=81&branchName=main)
|
|
4
|
+
|
|
5
|
+
Редактор текста на базе `contenteditable`. Получает в конструкторе элемент, делает его редактируемым и берёт на себя всю работу с вводом: форматирование (жирный, курсив, зачёркивание, подчёркивание), панель инструментов, режим набора, нормализацию пробелов и сериализацию значения в HTML или Markdown.
|
|
6
|
+
|
|
7
|
+
Реализован на чистом Selection/Range API — без устаревшего `document.execCommand`, поэтому разметка всегда семантическая и предсказуема между браузерами.
|
|
8
|
+
|
|
9
|
+
## Установка
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm i @brandup/ui-richeditor
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Использование
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import RichEditor from "@brandup/ui-richeditor";
|
|
19
|
+
|
|
20
|
+
const elem = document.getElementById("editor") as HTMLElement;
|
|
21
|
+
const editor = new RichEditor(elem, {
|
|
22
|
+
format: true,
|
|
23
|
+
tools: ["bold", "italic", "strike", "underline"],
|
|
24
|
+
storage: "html", // или "markdown"
|
|
25
|
+
placeholder: "Введите текст",
|
|
26
|
+
multiline: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
editor.onChange(({ value }) => console.log(value));
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Конструктор оборачивает переданный элемент в `div.ui-richeditor` (к нему привязан `UIElement`) и делает элемент редактируемым (`div.ui-richeditor-input`).
|
|
33
|
+
|
|
34
|
+
### Панель форматирования
|
|
35
|
+
|
|
36
|
+
Панель — **общая для всех редакторов** (`div.ui-richeditor-toolbar`). При фокусе редактора она перестраивается под его инструменты, позиционируется над ним и показывается; при потере фокуса — скрывается. Кнопки диспатчат форматирование напрямую активному редактору.
|
|
37
|
+
|
|
38
|
+
По умолчанию панель живёт в `document.body` (`position: fixed`) — это защищает её от обрезки `overflow: hidden` у родителей. Если задан `toolbarContainer`, панель монтируется в него и позиционируется относительно него (`position: absolute`, над контейнером) — например, `TextBox` передаёт свой контейнер `.ui-textbox`.
|
|
39
|
+
|
|
40
|
+
## Опции (`RichEditorOptions`)
|
|
41
|
+
|
|
42
|
+
| Опция | Тип | Описание |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `format` | `boolean` | Включает форматирование и панель инструментов |
|
|
45
|
+
| `tools` | `FormatTool[]` | Состав инструментов (по умолчанию все) |
|
|
46
|
+
| `storage` | `"html" \| "markdown"` | Формат сериализации значения (по умолчанию `html`) |
|
|
47
|
+
| `markers` | `Partial<FormatMarkers>` | Переопределение markdown-маркеров по инструментам |
|
|
48
|
+
| `placeholder` | `string \| null` | Текст-заглушка |
|
|
49
|
+
| `multiline` | `boolean` | Многострочный режим |
|
|
50
|
+
| `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются) |
|
|
51
|
+
| `toolbarContainer` | `HTMLElement \| null` | Контейнер для панели; по умолчанию `document.body` (`position: fixed`). Если задан — панель монтируется в него и позиционируется над ним (`position: absolute`). Контейнер должен быть `position: relative` |
|
|
52
|
+
| `value` | `string` | Начальное значение |
|
|
53
|
+
| `filterChar` | `(char) => boolean` | Хук: `false` — отклонить вводимый символ |
|
|
54
|
+
| `filterPaste` | `(text) => string \| null` | Хук: `null` — отклонить вставку; иначе очищенный текст |
|
|
55
|
+
| `onReject` | `() => void` | Хук: ввод отклонён (символ/вставка) |
|
|
56
|
+
| `onEnter` | `() => void` | Хук: Enter в однострочном режиме |
|
|
57
|
+
|
|
58
|
+
Хуки `filterChar`/`filterPaste`/`onReject`/`onEnter` позволяют хосту (например, `@brandup/ui-textbox`) накладывать собственные ограничения — фильтрацию по типу, submit формы, индикацию ошибки — не вмешиваясь в работу редактора.
|
|
59
|
+
|
|
60
|
+
## API
|
|
61
|
+
|
|
62
|
+
| Член | Описание |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `editable` | Редактируемый элемент |
|
|
65
|
+
| `format`, `formatTools`, `formatStorage`, `formatMarkers`, `multiline` | Параметры экземпляра |
|
|
66
|
+
| `getValue(): string` | Сериализованное значение (по `storage`) |
|
|
67
|
+
| `setValue(value: string): void` | Установить значение (нормализует, генерирует `change`) |
|
|
68
|
+
| `getLength(): number` | Длина текста (без учёта переводов строк) |
|
|
69
|
+
| `focus(): void` | Установить фокус |
|
|
70
|
+
| `onChange(handler)` | Подписка на событие `richeditor-change` |
|
|
71
|
+
| `destroy(): void` | Разворачивает элемент обратно и освобождает ресурсы |
|
|
72
|
+
|
|
73
|
+
## Поведение форматирования
|
|
74
|
+
|
|
75
|
+
- Формат — переключатель (toggle): повторное применение снимает его.
|
|
76
|
+
- Применяется к слову целиком: курсор внутри слова или выделение его части → формат охватывает всё слово; исходное выделение/каретка сохраняются.
|
|
77
|
+
- **Режим набора**: на пустом месте (между пробелами / в пустом поле) кнопка/хоткей включают «ожидающий» формат — он применится к следующему введённому тексту. Сбрасывается при перемещении каретки, клике или потере фокуса.
|
|
78
|
+
- Хоткеи `Ctrl/Cmd+B/I/U`. Зачёркивание — только кнопкой.
|
|
79
|
+
- **Отмена/повтор**: `Ctrl/Cmd+Z` — отмена, `Ctrl+Y` или `Ctrl/Cmd+Shift+Z` — повтор. История форматирования, абзацев, переносов и печати ведётся редактором (нативный undo не видит ручных DOM-правок), поэтому **доступна только при включённом форматировании** (`format: true`). Печать коалесится в один шаг отмены по паузе ~300 мс; глубина истории — 100 шагов.
|
|
80
|
+
- При потере фокуса и после `setValue` пробелы нормализуются (схлопывание повторов + обрезка краёв строк).
|
|
81
|
+
|
|
82
|
+
## Многострочный режим: абзацы и переносы
|
|
83
|
+
|
|
84
|
+
При `multiline: true` контент структурируется по абзацам:
|
|
85
|
+
|
|
86
|
+
- **Enter** → новый абзац (`<p>`);
|
|
87
|
+
- **Shift+Enter** или **Ctrl/Cmd+Enter** → мягкий перенос (`<br>`) внутри абзаца;
|
|
88
|
+
- блуждающий текст и `<div>` нормализуются в `<p>` при вводе.
|
|
89
|
+
|
|
90
|
+
Хвостовой перенос абзаца отбрасывается (это `<br>`-заполнитель); пустая строка делается отдельным абзацем.
|
|
91
|
+
|
|
92
|
+
При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются.
|
|
93
|
+
|
|
94
|
+
## Вставка форматированного текста
|
|
95
|
+
|
|
96
|
+
При включённом форматировании вставка (`paste`) сохраняет форматирование из буфера обмена (`text/html`):
|
|
97
|
+
|
|
98
|
+
- разметка санитизируется до включённых инструментов (синонимы `STRONG/EM/DEL/INS` → канонические `b/i/s/u`, всё прочее — `span`, стили, классы, `<style>`/`<script>` — отбрасывается, текст сохраняется);
|
|
99
|
+
- **multiline** сохраняет абзацы `<p>` и мягкие переносы `<br>`, разбивая текущий абзац по каретке; **single-line** — инлайн, абзацы/переносы становятся пробелами;
|
|
100
|
+
- если `text/html` нет — простая текстовая вставка (как раньше);
|
|
101
|
+
- хук `filterPaste` остаётся в силе: вернул `null` — вставка отклоняется; изменил текст (обрезка по длине, фильтр по типу) — форматирование не сохраняется, вставляется очищенный текст.
|
|
102
|
+
|
|
103
|
+
Вся вставка — один шаг истории (Ctrl+Z откатывает целиком).
|
|
104
|
+
|
|
105
|
+
## Формат хранения
|
|
106
|
+
|
|
107
|
+
| `storage` | Хранение | Абзац / мягкий перенос | Маркеры форматирования |
|
|
108
|
+
|---|---|---|---|
|
|
109
|
+
| `html` | Санитизированный HTML | `<p>…</p>` / `<br>` | `<b>`, `<i>`, `<s>`, `<u>` |
|
|
110
|
+
| `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`, `*курсив*`, `~~зачёркнутый~~`, `++подчёркнутый++` |
|
|
111
|
+
|
|
112
|
+
Без форматирования (plain) значение хранится как markdown без инструментов: абзацы `\n\n`, мягкий перенос `\n`.
|
|
113
|
+
|
|
114
|
+
Маркеры markdown настраиваются через `markers`. При разборе применяются по убыванию длины, поэтому длинный маркер срабатывает раньше короткого-префикса. Глубоко вложенные комбинации гарантированно сохраняются только в режиме `html`.
|
|
115
|
+
|
|
116
|
+
## CSS
|
|
117
|
+
|
|
118
|
+
Подключается `richeditor.less`. Цветовые переменные `--input-*` берутся из `@brandup/ui-kit` (с fallback-значениями для standalone). Классы: `focused` на обёртке (поле в фокусе), `visible` на общей панели (показана), `active` на кнопке инструмента (формат активен на выделении).
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"publishConfig": {
|
|
3
|
+
"access": "public"
|
|
4
|
+
},
|
|
5
|
+
"name": "@brandup/ui-richeditor",
|
|
6
|
+
"description": "Rich text editor over a contenteditable element (bold, italic, strike, underline).",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"brandup",
|
|
9
|
+
"javascript",
|
|
10
|
+
"typescript",
|
|
11
|
+
"ui",
|
|
12
|
+
"editor",
|
|
13
|
+
"richtext",
|
|
14
|
+
"contenteditable"
|
|
15
|
+
],
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "Dmitry Kovyazin",
|
|
18
|
+
"email": "it@brandup.online"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/brandup-online/brandup-ui-kit/npm/brandup-ui-richeditor",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/brandup-online/brandup-ui-kit.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/brandup-online/brandup-ui-kit/issues",
|
|
27
|
+
"email": "it@brandup.online"
|
|
28
|
+
},
|
|
29
|
+
"license": "Apache-2.0",
|
|
30
|
+
"version": "1.0.36",
|
|
31
|
+
"main": "source/index.ts",
|
|
32
|
+
"types": "source/index.ts",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@brandup/ui": "^2.0.5"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"source",
|
|
38
|
+
"svg",
|
|
39
|
+
"README.md"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Низкоуровневые операции редактирования на чистом Selection/Range: абзацы, мягкие переносы,
|
|
2
|
+
// каретка, расширение/обрезка выделения. Без состояния редактора и без истории —
|
|
3
|
+
// вызывающий сам решает, когда записывать undo-шаг.
|
|
4
|
+
|
|
5
|
+
function isBlock(node: Node): boolean {
|
|
6
|
+
return (
|
|
7
|
+
node.nodeType === Node.ELEMENT_NODE &&
|
|
8
|
+
((node as Element).tagName === "P" || (node as Element).tagName === "DIV")
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function emptyParagraph(): HTMLParagraphElement {
|
|
13
|
+
const p = document.createElement("p");
|
|
14
|
+
p.appendChild(document.createElement("br"));
|
|
15
|
+
return p;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// убирает пустые текст-узлы и ставит <br>-заполнитель в пустой абзац (для видимости и каретки)
|
|
19
|
+
function fillEmptyParagraph(p: HTMLElement) {
|
|
20
|
+
p.normalize(); // удаляет пустые Text-узлы, склеивает соседние
|
|
21
|
+
if (!p.firstChild) p.appendChild(document.createElement("br"));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function caretToStart(node: Node) {
|
|
25
|
+
const range = document.createRange();
|
|
26
|
+
range.setStart(node, 0);
|
|
27
|
+
range.collapse(true);
|
|
28
|
+
const selection = window.getSelection();
|
|
29
|
+
if (selection) {
|
|
30
|
+
selection.removeAllRanges();
|
|
31
|
+
selection.addRange(range);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Каретка в конец содержимого: в multiline — в конец последнего абзаца, иначе в конец редактора. */
|
|
36
|
+
export function caretToEnd(editable: HTMLElement, multiline: boolean) {
|
|
37
|
+
const range = document.createRange();
|
|
38
|
+
// multiline: каретку в конец последнего абзаца (а не на уровень редактора),
|
|
39
|
+
// иначе и ввод, и Enter попадают мимо <p>
|
|
40
|
+
const last = multiline ? editable.lastElementChild : null;
|
|
41
|
+
range.selectNodeContents(last && isBlock(last) ? last : editable);
|
|
42
|
+
range.collapse(false);
|
|
43
|
+
const sel = window.getSelection();
|
|
44
|
+
if (sel) {
|
|
45
|
+
sel.removeAllRanges();
|
|
46
|
+
sel.addRange(range);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Фокус и выделение всего содержимого (например, readonly-режим). */
|
|
51
|
+
export function selectAllContent(editable: HTMLElement) {
|
|
52
|
+
editable.focus();
|
|
53
|
+
window.getSelection()?.selectAllChildren(editable);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Enter в multiline: разбить текущий абзац по каретке на два <p>. */
|
|
57
|
+
export function insertParagraph(editable: HTMLElement) {
|
|
58
|
+
const selection = window.getSelection();
|
|
59
|
+
if (!selection || selection.rangeCount === 0 || !editable.contains(selection.anchorNode)) return;
|
|
60
|
+
|
|
61
|
+
const range = selection.getRangeAt(0);
|
|
62
|
+
range.deleteContents();
|
|
63
|
+
|
|
64
|
+
// текущий абзац (ближайший <p>/<div> внутри редактора)
|
|
65
|
+
let para: Node | null = range.startContainer;
|
|
66
|
+
while (para && para !== editable && !isBlock(para)) para = para.parentNode;
|
|
67
|
+
|
|
68
|
+
// каретка не внутри абзаца — создаём абзац сразу с видимым результатом (иначе Enter «срабатывает со 2-го раза»)
|
|
69
|
+
if (!para || para === editable) {
|
|
70
|
+
const next = emptyParagraph();
|
|
71
|
+
if (editable.childNodes.length === 0) {
|
|
72
|
+
// пустой редактор: пустая строка-источник + новая строка с кареткой
|
|
73
|
+
editable.appendChild(emptyParagraph());
|
|
74
|
+
editable.appendChild(next);
|
|
75
|
+
} else {
|
|
76
|
+
// каретка на уровне редактора между/после абзацев — вставляем новый абзац в эту позицию
|
|
77
|
+
const ref = editable.childNodes[range.startOffset] ?? null;
|
|
78
|
+
editable.insertBefore(next, ref);
|
|
79
|
+
}
|
|
80
|
+
caretToStart(next);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// выносим содержимое от каретки до конца абзаца в новый <p>
|
|
85
|
+
const tail = document.createRange();
|
|
86
|
+
tail.selectNodeContents(para);
|
|
87
|
+
tail.setStart(range.endContainer, range.endOffset);
|
|
88
|
+
const fragment = tail.extractContents();
|
|
89
|
+
|
|
90
|
+
const next = document.createElement("p");
|
|
91
|
+
next.appendChild(fragment);
|
|
92
|
+
(para as ChildNode).after(next);
|
|
93
|
+
|
|
94
|
+
// extractContents в конце абзаца оставляет пустой текст-узел → <p></p> без заполнителя
|
|
95
|
+
// (невидим/нефокусируем, каретка не встаёт). Чистим и ставим <br> в опустевшие абзацы.
|
|
96
|
+
fillEmptyParagraph(para as HTMLElement);
|
|
97
|
+
fillEmptyParagraph(next);
|
|
98
|
+
|
|
99
|
+
caretToStart(next);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Shift/Ctrl+Enter в multiline: вставить мягкий перенос <br>. */
|
|
103
|
+
export function insertSoftBreak(editable: HTMLElement) {
|
|
104
|
+
const selection = window.getSelection();
|
|
105
|
+
if (!selection || selection.rangeCount === 0 || !editable.contains(selection.anchorNode)) return;
|
|
106
|
+
|
|
107
|
+
const range = selection.getRangeAt(0);
|
|
108
|
+
range.deleteContents();
|
|
109
|
+
|
|
110
|
+
const br = document.createElement("br");
|
|
111
|
+
range.insertNode(br);
|
|
112
|
+
|
|
113
|
+
// insertNode в конце текст-узла расщепляет его и оставляет пустой хвост — убираем,
|
|
114
|
+
// иначе br.nextSibling != null и заполнитель не ставится (перенос в конце строки не виден)
|
|
115
|
+
const next = br.nextSibling;
|
|
116
|
+
if (next && next.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === "") next.remove();
|
|
117
|
+
|
|
118
|
+
const after = document.createRange();
|
|
119
|
+
if (!br.nextSibling) {
|
|
120
|
+
// перенос в конце строки — нужен второй <br>-заполнитель, иначе новая строка не отображается
|
|
121
|
+
// (хвостовой <br> отбрасывается при сериализации)
|
|
122
|
+
const pad = document.createElement("br");
|
|
123
|
+
br.after(pad);
|
|
124
|
+
after.setStartBefore(pad);
|
|
125
|
+
} else {
|
|
126
|
+
after.setStartAfter(br);
|
|
127
|
+
}
|
|
128
|
+
after.collapse(true);
|
|
129
|
+
selection.removeAllRanges();
|
|
130
|
+
selection.addRange(after);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Вставляет санитизированные абзацы <p> в позицию каретки, разбивая текущий абзац. */
|
|
134
|
+
export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement[], range: Range) {
|
|
135
|
+
let para: Node | null = range.startContainer;
|
|
136
|
+
while (para && para !== editable && !isBlock(para)) para = para.parentNode;
|
|
137
|
+
|
|
138
|
+
// каретка не внутри абзаца (пустой редактор / уровень редактора) — вставляем абзацы как есть
|
|
139
|
+
if (!para || para === editable) {
|
|
140
|
+
const ref = editable.childNodes[range.startOffset] ?? null;
|
|
141
|
+
for (const p of paras) editable.insertBefore(p, ref);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const block = para as HTMLElement;
|
|
146
|
+
|
|
147
|
+
// хвост текущего абзаца после каретки — выносим, чтобы вернуть в конец вставки
|
|
148
|
+
const tailRange = document.createRange();
|
|
149
|
+
tailRange.selectNodeContents(block);
|
|
150
|
+
tailRange.setStart(range.startContainer, range.startOffset);
|
|
151
|
+
const tail = tailRange.extractContents();
|
|
152
|
+
|
|
153
|
+
// первый вставляемый абзац вливается в текущий (после содержимого до каретки)
|
|
154
|
+
while (paras[0].firstChild) block.appendChild(paras[0].firstChild);
|
|
155
|
+
|
|
156
|
+
if (paras.length === 1) {
|
|
157
|
+
block.appendChild(tail); // один абзац: содержимое-до + вставка + хвост в одном <p>
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// остальные абзацы — отдельными <p> после текущего; хвост — в конец последнего
|
|
162
|
+
let anchor: ChildNode = block;
|
|
163
|
+
for (let i = 1; i < paras.length; i++) {
|
|
164
|
+
anchor.after(paras[i]);
|
|
165
|
+
anchor = paras[i];
|
|
166
|
+
}
|
|
167
|
+
paras[paras.length - 1].appendChild(tail);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Обрезает пробелы по краям абзаца (после схлопывания) — у крайних текстовых узлов. */
|
|
171
|
+
export function trimParagraphEdges(p: HTMLElement) {
|
|
172
|
+
const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
|
|
173
|
+
const texts: Text[] = [];
|
|
174
|
+
for (let t = walker.nextNode() as Text | null; t; t = walker.nextNode() as Text | null) texts.push(t);
|
|
175
|
+
if (!texts.length) return;
|
|
176
|
+
|
|
177
|
+
texts[0].textContent = (texts[0].textContent ?? "").replace(/^ /, "");
|
|
178
|
+
const last = texts[texts.length - 1];
|
|
179
|
+
last.textContent = (last.textContent ?? "").replace(/ $/, "");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Расширяет выделение до целых слов на границах (для применения формата к слову целиком). */
|
|
183
|
+
export function expandSelectionToWords(editable: HTMLElement, selection: Selection) {
|
|
184
|
+
const range = selection.getRangeAt(0);
|
|
185
|
+
|
|
186
|
+
const { startContainer, endContainer } = range;
|
|
187
|
+
let startOffset = range.startOffset;
|
|
188
|
+
let endOffset = range.endOffset;
|
|
189
|
+
|
|
190
|
+
if (startContainer.nodeType === Node.TEXT_NODE && editable.contains(startContainer)) {
|
|
191
|
+
const text = startContainer.textContent ?? "";
|
|
192
|
+
while (startOffset > 0 && !/\s/.test(text[startOffset - 1])) startOffset--;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (endContainer.nodeType === Node.TEXT_NODE && editable.contains(endContainer)) {
|
|
196
|
+
const text = endContainer.textContent ?? "";
|
|
197
|
+
while (endOffset < text.length && !/\s/.test(text[endOffset])) endOffset++;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (startOffset === range.startOffset && endOffset === range.endOffset) return;
|
|
201
|
+
|
|
202
|
+
const expanded = document.createRange();
|
|
203
|
+
expanded.setStart(startContainer, startOffset);
|
|
204
|
+
expanded.setEnd(endContainer, endOffset);
|
|
205
|
+
selection.removeAllRanges();
|
|
206
|
+
selection.addRange(expanded);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Убирает пробелы по краям выделения (например, после двойного клика по слову). */
|
|
210
|
+
export function trimSelectionWhitespace(editable: HTMLElement) {
|
|
211
|
+
const selection = window.getSelection();
|
|
212
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
|
|
213
|
+
|
|
214
|
+
const range = selection.getRangeAt(0);
|
|
215
|
+
if (!editable.contains(range.startContainer) || !editable.contains(range.endContainer)) return;
|
|
216
|
+
|
|
217
|
+
const { startContainer, endContainer } = range;
|
|
218
|
+
let startOffset = range.startOffset;
|
|
219
|
+
let endOffset = range.endOffset;
|
|
220
|
+
|
|
221
|
+
if (startContainer.nodeType === Node.TEXT_NODE) {
|
|
222
|
+
const text = startContainer.textContent ?? "";
|
|
223
|
+
while (startOffset < text.length && /\s/.test(text[startOffset])) startOffset++;
|
|
224
|
+
}
|
|
225
|
+
if (endContainer.nodeType === Node.TEXT_NODE) {
|
|
226
|
+
const text = endContainer.textContent ?? "";
|
|
227
|
+
while (endOffset > 0 && /\s/.test(text[endOffset - 1])) endOffset--;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (startContainer === endContainer && startOffset >= endOffset) return;
|
|
231
|
+
if (startOffset === range.startOffset && endOffset === range.endOffset) return;
|
|
232
|
+
|
|
233
|
+
const trimmed = document.createRange();
|
|
234
|
+
trimmed.setStart(startContainer, startOffset);
|
|
235
|
+
trimmed.setEnd(endContainer, endOffset);
|
|
236
|
+
selection.removeAllRanges();
|
|
237
|
+
selection.addRange(trimmed);
|
|
238
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Конфигурация форматирования: типы, набор инструментов, markdown-маркеры и Ctrl/Cmd-хоткеи.
|
|
2
|
+
|
|
3
|
+
export type FormatTool = "bold" | "italic" | "strike" | "underline";
|
|
4
|
+
export type FormatStorage = "html" | "markdown";
|
|
5
|
+
|
|
6
|
+
export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline"];
|
|
7
|
+
|
|
8
|
+
interface FormatToolDef {
|
|
9
|
+
/** Имя команды (атрибут command у кнопки и registerCommand). */
|
|
10
|
+
command: string;
|
|
11
|
+
/** Канонический тег при оборачивании и сериализации. */
|
|
12
|
+
tag: string;
|
|
13
|
+
/** Теги, распознаваемые при разборе входного HTML. */
|
|
14
|
+
matchTags: string[];
|
|
15
|
+
/** Маркер в Markdown. */
|
|
16
|
+
md: string;
|
|
17
|
+
/** Клавиша для Ctrl/Cmd-хоткея (пусто — без хоткея). */
|
|
18
|
+
hotkey: string;
|
|
19
|
+
/** Подсказка на кнопке. */
|
|
20
|
+
title: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
|
|
24
|
+
bold: {
|
|
25
|
+
command: "format-bold",
|
|
26
|
+
tag: "b",
|
|
27
|
+
matchTags: ["B", "STRONG"],
|
|
28
|
+
md: "**",
|
|
29
|
+
hotkey: "b",
|
|
30
|
+
title: "Жирный",
|
|
31
|
+
},
|
|
32
|
+
italic: {
|
|
33
|
+
command: "format-italic",
|
|
34
|
+
tag: "i",
|
|
35
|
+
matchTags: ["I", "EM"],
|
|
36
|
+
md: "*",
|
|
37
|
+
hotkey: "i",
|
|
38
|
+
title: "Курсив",
|
|
39
|
+
},
|
|
40
|
+
strike: {
|
|
41
|
+
command: "format-strike",
|
|
42
|
+
tag: "s",
|
|
43
|
+
matchTags: ["S", "STRIKE", "DEL"],
|
|
44
|
+
md: "~~",
|
|
45
|
+
hotkey: "",
|
|
46
|
+
title: "Зачёркнутый",
|
|
47
|
+
},
|
|
48
|
+
underline: {
|
|
49
|
+
command: "format-underline",
|
|
50
|
+
tag: "u",
|
|
51
|
+
matchTags: ["U", "INS"],
|
|
52
|
+
md: "++",
|
|
53
|
+
hotkey: "u",
|
|
54
|
+
title: "Подчёркнутый",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Markdown-маркер для каждого инструмента форматирования. */
|
|
59
|
+
export type FormatMarkers = Record<FormatTool, string>;
|
|
60
|
+
|
|
61
|
+
/** Маркеры по умолчанию (из FORMAT_TOOLS): bold=**, italic=*, strike=~~, underline=++. */
|
|
62
|
+
export function defaultFormatMarkers(): FormatMarkers {
|
|
63
|
+
const markers = {} as FormatMarkers;
|
|
64
|
+
for (const tool of ALL_FORMAT_TOOLS) markers[tool] = FORMAT_TOOLS[tool].md;
|
|
65
|
+
return markers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Карта Ctrl/Cmd-хоткеев: клавиша → инструмент. */
|
|
69
|
+
export const HOTKEY_TOOLS: Record<string, FormatTool> = (() => {
|
|
70
|
+
const map: Record<string, FormatTool> = {};
|
|
71
|
+
for (const tool of ALL_FORMAT_TOOLS) {
|
|
72
|
+
const hotkey = FORMAT_TOOLS[tool].hotkey;
|
|
73
|
+
if (hotkey) map[hotkey] = tool;
|
|
74
|
+
}
|
|
75
|
+
return map;
|
|
76
|
+
})();
|
|
77
|
+
|
|
78
|
+
/** Разбирает значение атрибута data-format-tools, оставляя только известные инструменты. */
|
|
79
|
+
export function parseFormatTools(value: string | null): FormatTool[] {
|
|
80
|
+
if (value === null) return ALL_FORMAT_TOOLS.slice();
|
|
81
|
+
|
|
82
|
+
const tools = value
|
|
83
|
+
.split(/\s+/)
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.filter((t): t is FormatTool => (ALL_FORMAT_TOOLS as string[]).includes(t));
|
|
86
|
+
|
|
87
|
+
// убираем дубли, сохраняя порядок объявления
|
|
88
|
+
return ALL_FORMAT_TOOLS.filter((t) => tools.includes(t));
|
|
89
|
+
}
|
package/source/format.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Баррель форматирования — единая точка импорта. Реализация разнесена по модулям:
|
|
2
|
+
// format-config — типы, набор инструментов, markdown-маркеры, хоткеи
|
|
3
|
+
// serialize — разбор/сериализация значения (HTML | Markdown), абзацы и переносы
|
|
4
|
+
// selection — переключение формата на выделении и вставка текста (Selection/Range)
|
|
5
|
+
// paragraphs — нормализация пробелов и приведение к абзацам <p>
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
ALL_FORMAT_TOOLS,
|
|
9
|
+
FORMAT_TOOLS,
|
|
10
|
+
HOTKEY_TOOLS,
|
|
11
|
+
defaultFormatMarkers,
|
|
12
|
+
parseFormatTools,
|
|
13
|
+
type FormatMarkers,
|
|
14
|
+
type FormatStorage,
|
|
15
|
+
type FormatTool,
|
|
16
|
+
} from "./format-config";
|
|
17
|
+
export { serialize, deserialize } from "./serialize";
|
|
18
|
+
export { selectionCharBounds, restoreSelection, toggleFormat, insertFormattedText, isFormatActive } from "./selection";
|
|
19
|
+
export { normalizeWhitespace, normalizeParagraphs, ensureParagraphs } from "./paragraphs";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Собственная история undo/redo для редактора.
|
|
2
|
+
//
|
|
3
|
+
// Форматирование и работа с абзацами выполняются ручными DOM-операциями (Selection/Range),
|
|
4
|
+
// которые проходят мимо нативного стека истории браузера — нативный Ctrl+Z их не видит и,
|
|
5
|
+
// хуже того, ломается при смешивании с ручными мутациями. Поэтому ведём свою историю снимков
|
|
6
|
+
// (innerHTML + позиция выделения). Используется только при включённом форматировании.
|
|
7
|
+
|
|
8
|
+
import { restoreSelection, selectionCharBounds } from "./format";
|
|
9
|
+
|
|
10
|
+
interface Snapshot {
|
|
11
|
+
html: string;
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Вид правки: печать коалесится в один шаг, структурные операции — всегда отдельный шаг. */
|
|
17
|
+
export type HistoryKind = "type" | "op";
|
|
18
|
+
|
|
19
|
+
const COALESCE_MS = 300; // печать в пределах паузы — один шаг отмены
|
|
20
|
+
const MAX_DEPTH = 100; // ограничение глубины истории (память)
|
|
21
|
+
|
|
22
|
+
export class EditorHistory {
|
|
23
|
+
private readonly __root: HTMLElement;
|
|
24
|
+
private __undo: Snapshot[] = [];
|
|
25
|
+
private __redo: Snapshot[] = [];
|
|
26
|
+
private __lastKind: HistoryKind | null = null;
|
|
27
|
+
private __lastTime = 0;
|
|
28
|
+
|
|
29
|
+
constructor(root: HTMLElement) {
|
|
30
|
+
this.__root = root;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
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));
|
|
39
|
+
|
|
40
|
+
return { html: this.__root.innerHTML, start, end };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Запомнить текущее (до изменения) состояние. Вызывается перед каждой правкой.
|
|
45
|
+
* Печать (kind="type") коалесится: подряд идущие символы в пределах паузы — один шаг.
|
|
46
|
+
*/
|
|
47
|
+
record(kind: HistoryKind): void {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (kind === "type" && this.__lastKind === "type" && now - this.__lastTime < COALESCE_MS) {
|
|
50
|
+
this.__lastTime = now;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.__lastKind = kind;
|
|
54
|
+
this.__lastTime = now;
|
|
55
|
+
|
|
56
|
+
const snap = this.__snapshot();
|
|
57
|
+
const top = this.__undo[this.__undo.length - 1];
|
|
58
|
+
if (top && top.html === snap.html) return; // состояние не изменилось — не дублируем
|
|
59
|
+
|
|
60
|
+
this.__undo.push(snap);
|
|
61
|
+
if (this.__undo.length > MAX_DEPTH) this.__undo.shift();
|
|
62
|
+
this.__redo = [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Откатить на шаг назад. Возвращает false, если откатывать нечего. */
|
|
66
|
+
undo(): boolean {
|
|
67
|
+
const prev = this.__undo.pop();
|
|
68
|
+
if (!prev) return false;
|
|
69
|
+
|
|
70
|
+
this.__redo.push(this.__snapshot());
|
|
71
|
+
this.__restore(prev);
|
|
72
|
+
this.__lastKind = null; // следующая печать начнёт новый шаг
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Повторить отменённый шаг. Возвращает false, если повторять нечего. */
|
|
77
|
+
redo(): boolean {
|
|
78
|
+
const next = this.__redo.pop();
|
|
79
|
+
if (!next) return false;
|
|
80
|
+
|
|
81
|
+
this.__undo.push(this.__snapshot());
|
|
82
|
+
this.__restore(next);
|
|
83
|
+
this.__lastKind = null;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private __restore(snap: Snapshot): void {
|
|
88
|
+
this.__root.innerHTML = snap.html;
|
|
89
|
+
const sel = window.getSelection();
|
|
90
|
+
if (sel) restoreSelection(this.__root, snap.start, snap.end, sel);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/source/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { default } from "./richeditor";
|
|
2
|
+
export * from "./richeditor";
|
|
3
|
+
export {
|
|
4
|
+
ALL_FORMAT_TOOLS,
|
|
5
|
+
FORMAT_TOOLS,
|
|
6
|
+
parseFormatTools,
|
|
7
|
+
defaultFormatMarkers,
|
|
8
|
+
normalizeWhitespace,
|
|
9
|
+
type FormatTool,
|
|
10
|
+
type FormatStorage,
|
|
11
|
+
type FormatMarkers,
|
|
12
|
+
} from "./format";
|