@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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Переключение форматирования на выделении и вставка форматированного текста
|
|
2
|
+
// на чистом Selection/Range API (без execCommand), плюс сохранение/восстановление выделения.
|
|
3
|
+
|
|
4
|
+
import { ALL_FORMAT_TOOLS, FORMAT_TOOLS, type FormatTool } from "./format-config";
|
|
5
|
+
|
|
6
|
+
/** Канонические теги форматирования (в верхнем регистре, как tagName). */
|
|
7
|
+
const FORMAT_TAG_NAMES = ALL_FORMAT_TOOLS.map((t) => FORMAT_TOOLS[t].tag.toUpperCase());
|
|
8
|
+
|
|
9
|
+
/** Ближайший предок-элемент с одним из тегов (в пределах root, не включая root). */
|
|
10
|
+
function formatAncestor(node: Node, tags: string[], root: HTMLElement): HTMLElement | null {
|
|
11
|
+
let el = node.parentElement;
|
|
12
|
+
while (el && el !== root) {
|
|
13
|
+
if (tags.includes(el.tagName)) return el;
|
|
14
|
+
el = el.parentElement;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Абсолютные текстовые смещения границ выделения внутри root (для восстановления после правок DOM). */
|
|
20
|
+
export function selectionCharBounds(root: HTMLElement, range: Range): [number, number] {
|
|
21
|
+
const probe = document.createRange();
|
|
22
|
+
probe.selectNodeContents(root);
|
|
23
|
+
probe.setEnd(range.startContainer, range.startOffset);
|
|
24
|
+
const start = probe.toString().length;
|
|
25
|
+
probe.setEnd(range.endContainer, range.endOffset);
|
|
26
|
+
const end = probe.toString().length;
|
|
27
|
+
return [start, end];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Находит текстовый узел и локальное смещение по абсолютному текстовому смещению. */
|
|
31
|
+
function locateChar(root: HTMLElement, target: number): { node: Text; offset: number } | null {
|
|
32
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
33
|
+
let count = 0;
|
|
34
|
+
let last: Text | null = null;
|
|
35
|
+
let n = walker.nextNode() as Text | null;
|
|
36
|
+
while (n) {
|
|
37
|
+
last = n;
|
|
38
|
+
if (count + n.length >= target) return { node: n, offset: Math.max(0, target - count) };
|
|
39
|
+
count += n.length;
|
|
40
|
+
n = walker.nextNode() as Text | null;
|
|
41
|
+
}
|
|
42
|
+
return last ? { node: last, offset: last.length } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Восстанавливает выделение по абсолютным текстовым смещениям (см. selectionCharBounds).
|
|
46
|
+
export function restoreSelection(root: HTMLElement, start: number, end: number, selection: Selection) {
|
|
47
|
+
const s = locateChar(root, start);
|
|
48
|
+
const e = locateChar(root, end);
|
|
49
|
+
if (!s || !e) return;
|
|
50
|
+
|
|
51
|
+
const range = document.createRange();
|
|
52
|
+
range.setStart(s.node, s.offset);
|
|
53
|
+
range.setEnd(e.node, e.offset);
|
|
54
|
+
selection.removeAllRanges();
|
|
55
|
+
selection.addRange(range);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Разбивает пограничные текстовые узлы так, чтобы Range покрывал их целиком. */
|
|
59
|
+
function splitBoundaries(range: Range) {
|
|
60
|
+
const sc = range.startContainer;
|
|
61
|
+
const ec = range.endContainer;
|
|
62
|
+
|
|
63
|
+
if (sc === ec && sc.nodeType === Node.TEXT_NODE) {
|
|
64
|
+
const t = sc as Text;
|
|
65
|
+
const s = range.startOffset;
|
|
66
|
+
const e = range.endOffset;
|
|
67
|
+
if (e < t.length) t.splitText(e);
|
|
68
|
+
let target = t;
|
|
69
|
+
if (s > 0) target = t.splitText(s);
|
|
70
|
+
range.setStart(target, 0);
|
|
71
|
+
range.setEnd(target, target.length);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (ec.nodeType === Node.TEXT_NODE) {
|
|
76
|
+
const t = ec as Text;
|
|
77
|
+
if (range.endOffset > 0 && range.endOffset < t.length) {
|
|
78
|
+
t.splitText(range.endOffset);
|
|
79
|
+
range.setEnd(t, t.length);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (sc.nodeType === Node.TEXT_NODE) {
|
|
83
|
+
const t = sc as Text;
|
|
84
|
+
if (range.startOffset > 0 && range.startOffset < t.length) {
|
|
85
|
+
const after = t.splitText(range.startOffset);
|
|
86
|
+
range.setStart(after, 0);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function nodeWithinRange(node: Node, range: Range): boolean {
|
|
92
|
+
const nr = document.createRange();
|
|
93
|
+
nr.selectNodeContents(node);
|
|
94
|
+
return (
|
|
95
|
+
range.compareBoundaryPoints(Range.START_TO_START, nr) <= 0 &&
|
|
96
|
+
range.compareBoundaryPoints(Range.END_TO_END, nr) >= 0
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function collectTextNodes(root: HTMLElement, range: Range, strict: boolean): Text[] {
|
|
101
|
+
const nodes: Text[] = [];
|
|
102
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
103
|
+
let n = walker.nextNode() as Text | null;
|
|
104
|
+
while (n) {
|
|
105
|
+
if (n.length > 0 && (strict ? nodeWithinRange(n, range) : range.intersectsNode(n))) nodes.push(n);
|
|
106
|
+
n = walker.nextNode() as Text | null;
|
|
107
|
+
}
|
|
108
|
+
return nodes;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function wrapTextNode(node: Text, tag: string) {
|
|
112
|
+
const wrapper = document.createElement(tag);
|
|
113
|
+
node.parentNode?.insertBefore(wrapper, node);
|
|
114
|
+
wrapper.appendChild(node);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Выносит ветку, содержащую node, наружу из элемента fmt (расщепляя fmt на «до» и «после»). */
|
|
118
|
+
function unwrapAround(fmt: HTMLElement, node: Node) {
|
|
119
|
+
const parent = fmt.parentNode;
|
|
120
|
+
if (!parent) return;
|
|
121
|
+
|
|
122
|
+
let child: Node = node;
|
|
123
|
+
while (child.parentNode && child.parentNode !== fmt) child = child.parentNode;
|
|
124
|
+
if (child.parentNode !== fmt) return;
|
|
125
|
+
|
|
126
|
+
const left = fmt.cloneNode(false) as HTMLElement;
|
|
127
|
+
while (fmt.firstChild && fmt.firstChild !== child) left.appendChild(fmt.firstChild);
|
|
128
|
+
|
|
129
|
+
fmt.removeChild(child);
|
|
130
|
+
parent.insertBefore(left, fmt);
|
|
131
|
+
parent.insertBefore(child, fmt);
|
|
132
|
+
|
|
133
|
+
if (!left.firstChild) parent.removeChild(left);
|
|
134
|
+
if (!fmt.firstChild) parent.removeChild(fmt);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function removeFormatFromNode(node: Text, tags: string[], root: HTMLElement) {
|
|
138
|
+
let fmt = formatAncestor(node, tags, root);
|
|
139
|
+
while (fmt) {
|
|
140
|
+
unwrapAround(fmt, node);
|
|
141
|
+
fmt = formatAncestor(node, tags, root);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function unwrapElement(el: HTMLElement) {
|
|
146
|
+
const parent = el.parentNode;
|
|
147
|
+
if (!parent) return;
|
|
148
|
+
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
149
|
+
parent.removeChild(el);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст. */
|
|
153
|
+
export function cleanupFormatting(root: HTMLElement) {
|
|
154
|
+
const selector = FORMAT_TAG_NAMES.join(",").toLowerCase();
|
|
155
|
+
|
|
156
|
+
let changed = true;
|
|
157
|
+
while (changed) {
|
|
158
|
+
changed = false;
|
|
159
|
+
|
|
160
|
+
for (const el of Array.from(root.querySelectorAll<HTMLElement>(selector))) {
|
|
161
|
+
if (!el.isConnected) continue;
|
|
162
|
+
|
|
163
|
+
// пустой тег
|
|
164
|
+
if (el.textContent === "") {
|
|
165
|
+
el.remove();
|
|
166
|
+
changed = true;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// вложен в такой же тег
|
|
171
|
+
const parent = el.parentElement;
|
|
172
|
+
if (parent && parent !== root && parent.tagName === el.tagName) {
|
|
173
|
+
unwrapElement(el);
|
|
174
|
+
changed = true;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// соседний такой же тег слева — склеиваем
|
|
179
|
+
const prev = el.previousSibling;
|
|
180
|
+
if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === el.tagName) {
|
|
181
|
+
while (el.firstChild) prev.appendChild(el.firstChild);
|
|
182
|
+
el.remove();
|
|
183
|
+
changed = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
root.normalize();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Переключает форматирование инструмента на выделении.
|
|
194
|
+
* Если весь выделенный текст уже отформатирован — снимает формат, иначе применяет.
|
|
195
|
+
*
|
|
196
|
+
* По умолчанию восстанавливает выделение, на котором работал. Через `restoreBounds`
|
|
197
|
+
* можно восстановить другое выделение (например, исходное до расширения до слова).
|
|
198
|
+
*/
|
|
199
|
+
export function toggleFormat(
|
|
200
|
+
root: HTMLElement,
|
|
201
|
+
range: Range,
|
|
202
|
+
tool: FormatTool,
|
|
203
|
+
selection: Selection,
|
|
204
|
+
restoreBounds?: [number, number]
|
|
205
|
+
) {
|
|
206
|
+
if (range.collapsed) return;
|
|
207
|
+
|
|
208
|
+
const def = FORMAT_TOOLS[tool];
|
|
209
|
+
const tags = def.matchTags;
|
|
210
|
+
const [startChar, endChar] = restoreBounds ?? selectionCharBounds(root, range);
|
|
211
|
+
|
|
212
|
+
splitBoundaries(range);
|
|
213
|
+
|
|
214
|
+
const nodes = collectTextNodes(root, range, true);
|
|
215
|
+
if (!nodes.length) return;
|
|
216
|
+
|
|
217
|
+
const allFormatted = nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
218
|
+
if (allFormatted) {
|
|
219
|
+
for (const n of nodes) removeFormatFromNode(n, tags, root);
|
|
220
|
+
} else {
|
|
221
|
+
for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
cleanupFormatting(root);
|
|
225
|
+
restoreSelection(root, startChar, endChar, selection);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Вставляет текст в позицию каретки, оборачивая его в указанные форматы (режим набора).
|
|
230
|
+
* Каретка ставится сразу после вставленного текста; соседние одинаковые теги склеиваются.
|
|
231
|
+
*/
|
|
232
|
+
export function insertFormattedText(root: HTMLElement, data: string, tools: FormatTool[], selection: Selection) {
|
|
233
|
+
if (!data || selection.rangeCount === 0) return;
|
|
234
|
+
|
|
235
|
+
const range = selection.getRangeAt(0);
|
|
236
|
+
const caret = selectionCharBounds(root, range)[0];
|
|
237
|
+
|
|
238
|
+
range.deleteContents();
|
|
239
|
+
|
|
240
|
+
let node: Node = document.createTextNode(data);
|
|
241
|
+
for (const tool of tools) {
|
|
242
|
+
const el = document.createElement(FORMAT_TOOLS[tool].tag);
|
|
243
|
+
el.appendChild(node);
|
|
244
|
+
node = el;
|
|
245
|
+
}
|
|
246
|
+
range.insertNode(node);
|
|
247
|
+
|
|
248
|
+
cleanupFormatting(root);
|
|
249
|
+
|
|
250
|
+
const offset = caret + data.length;
|
|
251
|
+
restoreSelection(root, offset, offset, selection);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Активен ли формат инструмента на текущем выделении (для подсветки кнопки). */
|
|
255
|
+
export function isFormatActive(root: HTMLElement, range: Range, tool: FormatTool): boolean {
|
|
256
|
+
const tags = FORMAT_TOOLS[tool].matchTags;
|
|
257
|
+
|
|
258
|
+
if (range.collapsed) {
|
|
259
|
+
const node = range.startContainer;
|
|
260
|
+
const probe = node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
|
|
261
|
+
return formatAncestor(probe, tags, root) !== null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const nodes = collectTextNodes(root, range, false);
|
|
265
|
+
if (!nodes.length) return false;
|
|
266
|
+
return nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
267
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Разбор и сериализация значения редактора (HTML | Markdown), модель абзацев и мягких переносов.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
FORMAT_TOOLS,
|
|
5
|
+
defaultFormatMarkers,
|
|
6
|
+
type FormatMarkers,
|
|
7
|
+
type FormatStorage,
|
|
8
|
+
type FormatTool,
|
|
9
|
+
} from "./format-config";
|
|
10
|
+
|
|
11
|
+
function escapeHtml(text: string): string {
|
|
12
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function escapeRegExp(text: string): string {
|
|
16
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Тег → инструмент, только для включённых инструментов. */
|
|
20
|
+
function buildTagMap(tools: FormatTool[]): Record<string, FormatTool> {
|
|
21
|
+
const map: Record<string, FormatTool> = {};
|
|
22
|
+
for (const tool of tools) for (const tag of FORMAT_TOOLS[tool].matchTags) map[tag] = tool;
|
|
23
|
+
return map;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function lineBreak(storage: FormatStorage): string {
|
|
27
|
+
return storage === "html" ? "<br>" : "\n";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function wrap(storage: FormatStorage, tool: FormatTool, inner: string, markers: FormatMarkers): string {
|
|
31
|
+
if (!inner) return inner;
|
|
32
|
+
|
|
33
|
+
const def = FORMAT_TOOLS[tool];
|
|
34
|
+
if (storage === "html") return `<${def.tag}>${inner}</${def.tag}>`;
|
|
35
|
+
|
|
36
|
+
const marker = markers[tool];
|
|
37
|
+
return `${marker}${inner}${marker}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Сериализует инлайновое содержимое (текст, форматирование, <br> как мягкий перенос).
|
|
41
|
+
// Абзацы (<p>/<div>) на этом уровне не учитываются — их разбирает serializeParagraphs.
|
|
42
|
+
function serializeInline(
|
|
43
|
+
nodes: ArrayLike<ChildNode>,
|
|
44
|
+
storage: FormatStorage,
|
|
45
|
+
tagMap: Record<string, FormatTool>,
|
|
46
|
+
markers: FormatMarkers
|
|
47
|
+
): string {
|
|
48
|
+
let result = "";
|
|
49
|
+
|
|
50
|
+
for (const node of Array.from(nodes)) {
|
|
51
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
52
|
+
const text = node.textContent ?? "";
|
|
53
|
+
result += storage === "html" ? escapeHtml(text) : text;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
58
|
+
|
|
59
|
+
const el = node as HTMLElement;
|
|
60
|
+
const tag = el.tagName;
|
|
61
|
+
|
|
62
|
+
if (tag === "BR") {
|
|
63
|
+
result += lineBreak(storage); // мягкий перенос
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const inner = serializeInline(el.childNodes, storage, tagMap, markers);
|
|
68
|
+
|
|
69
|
+
// вложенный блочный элемент (нестандарт) — без обёртки, просто содержимое
|
|
70
|
+
if (tag === "DIV" || tag === "P") {
|
|
71
|
+
result += inner;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tool = tagMap[tag];
|
|
76
|
+
// неизвестный или отключённый тег — отбрасываем обёртку, оставляем текст
|
|
77
|
+
result += tool ? wrap(storage, tool, inner, markers) : inner;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Хвостовые переносы абзаца отбрасываем — это <br>-заполнители, делающие последнюю строку видимой;
|
|
84
|
+
// мягкий перенос осмыслен только между содержимым (для пустой строки используйте новый абзац).
|
|
85
|
+
function trimTrailingBreaks(inline: string, storage: FormatStorage): string {
|
|
86
|
+
return storage === "html" ? inline.replace(/(?:<br>)+$/, "") : inline.replace(/\n+$/, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Разбивает верхний уровень на абзацы: <p>/<div> — отдельный абзац, остальное — неявный абзац.
|
|
90
|
+
// HTML: <p>содержимое</p>; Markdown/Plain: абзацы через \n\n, мягкие переносы внутри — \n.
|
|
91
|
+
function serializeParagraphs(
|
|
92
|
+
root: ParentNode,
|
|
93
|
+
storage: FormatStorage,
|
|
94
|
+
tagMap: Record<string, FormatTool>,
|
|
95
|
+
markers: FormatMarkers
|
|
96
|
+
): string {
|
|
97
|
+
const paragraphs: string[] = [];
|
|
98
|
+
let buffer: ChildNode[] = [];
|
|
99
|
+
|
|
100
|
+
const flush = () => {
|
|
101
|
+
if (buffer.length) paragraphs.push(serializeInline(buffer, storage, tagMap, markers));
|
|
102
|
+
buffer = [];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
for (const node of Array.from(root.childNodes)) {
|
|
106
|
+
const isBlock =
|
|
107
|
+
node.nodeType === Node.ELEMENT_NODE &&
|
|
108
|
+
((node as Element).tagName === "P" || (node as Element).tagName === "DIV");
|
|
109
|
+
|
|
110
|
+
if (isBlock) {
|
|
111
|
+
flush();
|
|
112
|
+
paragraphs.push(serializeInline((node as Element).childNodes, storage, tagMap, markers));
|
|
113
|
+
} else {
|
|
114
|
+
buffer.push(node);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
flush();
|
|
118
|
+
|
|
119
|
+
const cleaned = paragraphs.map((p) => trimTrailingBreaks(p, storage));
|
|
120
|
+
|
|
121
|
+
if (storage === "html") return cleaned.map((p) => `<p>${p}</p>`).join("");
|
|
122
|
+
return cleaned.join("\n\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Сериализует содержимое редактора в строку для хранения. Сохраняются только включённые инструменты.
|
|
127
|
+
* При paragraphs=true применяется модель «абзацы (<p>/\n\n) + мягкие переносы (<br>/\n)».
|
|
128
|
+
*/
|
|
129
|
+
export function serialize(
|
|
130
|
+
root: HTMLElement,
|
|
131
|
+
storage: FormatStorage,
|
|
132
|
+
tools: FormatTool[],
|
|
133
|
+
markers: FormatMarkers = defaultFormatMarkers(),
|
|
134
|
+
paragraphs = false
|
|
135
|
+
): string {
|
|
136
|
+
const tagMap = buildTagMap(tools);
|
|
137
|
+
|
|
138
|
+
if (paragraphs) return serializeParagraphs(root, storage, tagMap, markers).trim();
|
|
139
|
+
|
|
140
|
+
const inline = serializeInline(root.childNodes, storage, tagMap, markers);
|
|
141
|
+
if (storage === "html")
|
|
142
|
+
return inline
|
|
143
|
+
.replace(/^(?:<br>)+/, "")
|
|
144
|
+
.replace(/(?:<br>)+$/, "")
|
|
145
|
+
.trim();
|
|
146
|
+
return inline.replace(/^\n+/, "").replace(/\n+$/, "").trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Markdown-разметка одного абзаца → инлайновый HTML (escape, \n→<br>, маркеры).
|
|
150
|
+
function markdownInline(text: string, tools: FormatTool[], markers: FormatMarkers): string {
|
|
151
|
+
let html = escapeHtml(text).replace(/\r?\n/g, "<br>");
|
|
152
|
+
|
|
153
|
+
// Маркеры применяем по убыванию длины: длинный (**) раньше короткого-префикса (*).
|
|
154
|
+
const order = tools.slice().sort((a, b) => markers[b].length - markers[a].length);
|
|
155
|
+
for (const tool of order) {
|
|
156
|
+
const marker = markers[tool];
|
|
157
|
+
if (!marker) continue;
|
|
158
|
+
|
|
159
|
+
const def = FORMAT_TOOLS[tool];
|
|
160
|
+
const escaped = escapeRegExp(marker);
|
|
161
|
+
const re = new RegExp(`${escaped}([\\s\\S]+?)${escaped}`, "g");
|
|
162
|
+
html = html.replace(re, `<${def.tag}>$1</${def.tag}>`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return html;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Готовит сохранённое значение к отображению в редакторе (возвращает HTML).
|
|
170
|
+
* При paragraphs=true строит <p>-абзацы; HTML-значения санитизируются до разрешённых тегов,
|
|
171
|
+
* Markdown/Plain — разбивается на абзацы по \n\n (мягкий перенос \n → <br>).
|
|
172
|
+
*/
|
|
173
|
+
export function deserialize(
|
|
174
|
+
value: string,
|
|
175
|
+
storage: FormatStorage,
|
|
176
|
+
tools: FormatTool[],
|
|
177
|
+
markers: FormatMarkers = defaultFormatMarkers(),
|
|
178
|
+
paragraphs = false
|
|
179
|
+
): string {
|
|
180
|
+
if (!value) return "";
|
|
181
|
+
|
|
182
|
+
if (storage === "markdown") {
|
|
183
|
+
if (!paragraphs) return markdownInline(value, tools, markers);
|
|
184
|
+
return value
|
|
185
|
+
.split(/\n{2,}/)
|
|
186
|
+
.map((p) => `<p>${markdownInline(p, tools, markers) || "<br>"}</p>`)
|
|
187
|
+
.join("");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// html: парсим и пересобираем, отбрасывая всё, кроме разрешённых тегов
|
|
191
|
+
const template = document.createElement("template");
|
|
192
|
+
template.innerHTML = value;
|
|
193
|
+
const tagMap = buildTagMap(tools);
|
|
194
|
+
|
|
195
|
+
if (!paragraphs) return serializeInline(template.content.childNodes, "html", tagMap, defaultFormatMarkers());
|
|
196
|
+
|
|
197
|
+
return serializeParagraphs(template.content, "html", tagMap, defaultFormatMarkers()).replace(
|
|
198
|
+
/<p><\/p>/g,
|
|
199
|
+
"<p><br></p>"
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Общий (единый для всех редакторов) тулбар форматирования.
|
|
2
|
+
// По умолчанию живёт в document.body и позиционируется над активным редактором (position: fixed).
|
|
3
|
+
// Если редактор задал toolbarContainer — панель монтируется в него и позиционируется
|
|
4
|
+
// относительно него (position: absolute; над контейнером).
|
|
5
|
+
// Кнопки диспатчат форматирование напрямую активному редактору (без системы команд,
|
|
6
|
+
// т.к. тулбар находится вне привязанных UIElement).
|
|
7
|
+
|
|
8
|
+
import { DOM } from "@brandup/ui";
|
|
9
|
+
import { FORMAT_TOOLS, type FormatTool } from "./format";
|
|
10
|
+
import boldIcon from "../svg/bold.svg";
|
|
11
|
+
import italicIcon from "../svg/italic.svg";
|
|
12
|
+
import strikeIcon from "../svg/strike.svg";
|
|
13
|
+
import underlineIcon from "../svg/underline.svg";
|
|
14
|
+
|
|
15
|
+
const FORMAT_ICONS: Record<FormatTool, string> = {
|
|
16
|
+
bold: boldIcon,
|
|
17
|
+
italic: italicIcon,
|
|
18
|
+
strike: strikeIcon,
|
|
19
|
+
underline: underlineIcon,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
|
|
23
|
+
|
|
24
|
+
/** Редактор, которым управляет общий тулбар. */
|
|
25
|
+
export interface ToolbarHost {
|
|
26
|
+
readonly editable: HTMLElement;
|
|
27
|
+
readonly formatTools: FormatTool[];
|
|
28
|
+
/** Контейнер для тулбара; null/undefined — document.body (position: fixed над редактором). */
|
|
29
|
+
readonly toolbarContainer?: HTMLElement | null;
|
|
30
|
+
applyFormat(tool: FormatTool): void;
|
|
31
|
+
isToolActive(tool: FormatTool): boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MARGIN = 6;
|
|
35
|
+
|
|
36
|
+
class FormatToolbar {
|
|
37
|
+
private __elem: HTMLElement | null = null;
|
|
38
|
+
private __buttons: Array<[FormatTool, HTMLButtonElement]> = [];
|
|
39
|
+
private __active: ToolbarHost | null = null;
|
|
40
|
+
private __toolsKey = "";
|
|
41
|
+
private __inContainer = false;
|
|
42
|
+
private readonly __reposition = () => this.reposition();
|
|
43
|
+
private __resizeObserver: ResizeObserver | null = null;
|
|
44
|
+
|
|
45
|
+
constructor() {
|
|
46
|
+
// единый листенер на весь app: подсветка активных инструментов по текущему выделению.
|
|
47
|
+
// refresh() сам проверяет наличие активного редактора, поэтому отдельных per-editor листенеров не нужно.
|
|
48
|
+
if (typeof document !== "undefined") document.addEventListener("selectionchange", () => this.refresh());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Показать тулбар для редактора (на фокусе): перестроить кнопки, спозиционировать, показать. */
|
|
52
|
+
attach(host: ToolbarHost) {
|
|
53
|
+
if (!host.formatTools.length) return;
|
|
54
|
+
|
|
55
|
+
this.__active = host;
|
|
56
|
+
this.__build(host.formatTools);
|
|
57
|
+
this.refresh();
|
|
58
|
+
|
|
59
|
+
const elem = this.__ensure();
|
|
60
|
+
const container = host.toolbarContainer ?? document.body;
|
|
61
|
+
this.__inContainer = container !== document.body;
|
|
62
|
+
|
|
63
|
+
if (elem.parentElement !== container) container.appendChild(elem);
|
|
64
|
+
elem.classList.toggle("in-container", this.__inContainer);
|
|
65
|
+
elem.classList.add("visible");
|
|
66
|
+
|
|
67
|
+
if (this.__inContainer) {
|
|
68
|
+
// позиционирование задаёт CSS (absolute; bottom: 100% относительно контейнера) — JS не нужен;
|
|
69
|
+
// сбрасываем inline-координаты от предыдущего body-режима, чтобы не перекрывали CSS
|
|
70
|
+
elem.style.left = "";
|
|
71
|
+
elem.style.top = "";
|
|
72
|
+
this.__removeViewportListeners();
|
|
73
|
+
} else {
|
|
74
|
+
window.addEventListener("scroll", this.__reposition, { passive: true });
|
|
75
|
+
window.addEventListener("resize", this.__reposition, { passive: true });
|
|
76
|
+
// рост высоты редактора (многострочный ввод) сдвигает его верх — пересчитываем позицию
|
|
77
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
78
|
+
this.__resizeObserver ??= new ResizeObserver(this.__reposition);
|
|
79
|
+
this.__resizeObserver.observe(host.editable);
|
|
80
|
+
}
|
|
81
|
+
this.reposition();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Скрыть тулбар, если он обслуживает этот редактор (на blur/destroy). */
|
|
86
|
+
detach(host: ToolbarHost) {
|
|
87
|
+
if (this.__active !== host) return;
|
|
88
|
+
|
|
89
|
+
this.__active = null;
|
|
90
|
+
if (this.__elem) this.__elem.classList.remove("visible");
|
|
91
|
+
this.__removeViewportListeners();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Обновить подсветку активных инструментов по текущему выделению. */
|
|
95
|
+
refresh() {
|
|
96
|
+
if (!this.__active) return;
|
|
97
|
+
for (const [tool, btn] of this.__buttons) btn.classList.toggle("active", this.__active.isToolActive(tool));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Пересчитать позицию над активным редактором (только для режима body/fixed). */
|
|
101
|
+
reposition() {
|
|
102
|
+
if (!this.__active || !this.__elem || this.__inContainer) return;
|
|
103
|
+
|
|
104
|
+
const rect = this.__active.editable.getBoundingClientRect();
|
|
105
|
+
const elem = this.__elem;
|
|
106
|
+
const top = rect.top - elem.offsetHeight - MARGIN;
|
|
107
|
+
elem.style.left = `${Math.max(4, rect.left)}px`;
|
|
108
|
+
elem.style.top = `${Math.max(4, top)}px`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private __removeViewportListeners() {
|
|
112
|
+
// capture должен совпадать с addEventListener (там { passive: true } → capture=false), иначе не снимется
|
|
113
|
+
window.removeEventListener("scroll", this.__reposition);
|
|
114
|
+
window.removeEventListener("resize", this.__reposition);
|
|
115
|
+
this.__resizeObserver?.disconnect();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private __ensure(): HTMLElement {
|
|
119
|
+
if (!this.__elem) this.__elem = DOM.tag("div", { class: TOOLBAR_CLASS });
|
|
120
|
+
return this.__elem;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private __build(tools: FormatTool[]) {
|
|
124
|
+
const key = tools.join(",");
|
|
125
|
+
const elem = this.__ensure();
|
|
126
|
+
if (key === this.__toolsKey && this.__buttons.length) return; // тот же состав — переиспользуем кнопки
|
|
127
|
+
|
|
128
|
+
this.__toolsKey = key;
|
|
129
|
+
DOM.empty(elem);
|
|
130
|
+
this.__buttons = [];
|
|
131
|
+
|
|
132
|
+
for (const tool of tools) {
|
|
133
|
+
const def = FORMAT_TOOLS[tool];
|
|
134
|
+
const btn = DOM.tag(
|
|
135
|
+
"button",
|
|
136
|
+
{ type: "button", class: "format-button", "data-format-tool": tool, title: def.title },
|
|
137
|
+
FORMAT_ICONS[tool]
|
|
138
|
+
);
|
|
139
|
+
// не даём кнопке забрать фокус, иначе теряется выделение в редакторе
|
|
140
|
+
btn.addEventListener("mousedown", (e) => e.preventDefault());
|
|
141
|
+
btn.addEventListener("click", () => this.__active?.applyFormat(tool));
|
|
142
|
+
|
|
143
|
+
elem.appendChild(btn);
|
|
144
|
+
this.__buttons.push([tool, btn]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Единый экземпляр тулбара для всех редакторов. */
|
|
150
|
+
export const formatToolbar = new FormatToolbar();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module "*.less";
|
package/svg/bold.svg
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" >
|
|
2
|
+
<path d="M7 5C6.4477 5 6 5.4477 6 6V18C6 18.5523 6.4477 19 7 19H13.25C15.5972 19 17.5 17.0972 17.5 14.75C17.5 13.4203 16.8891 12.2333 15.9329 11.4546C16.451 10.7591 16.75 9.9056 16.75 8.9844C16.75 6.7853 14.9647 5 12.7656 5H7ZM12.7656 10.4688H8.5V7.5H12.7656C13.5854 7.5 14.25 8.1646 14.25 8.9844C14.25 9.8041 13.5854 10.4688 12.7656 10.4688ZM8.5 12.9688H13.25C14.2165 12.9688 15 13.7522 15 14.7188C15 15.6853 14.2165 16.4688 13.25 16.4688H8.5V12.9688Z" />
|
|
3
|
+
</svg>
|
package/svg/italic.svg
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" >
|
|
2
|
+
<path d="M10 5C9.4477 5 9 5.4477 9 6C9 6.5523 9.4477 7 10 7H12.3076L9.6924 17H8C7.4477 17 7 17.4477 7 18C7 18.5523 7.4477 19 8 19H14C14.5523 19 15 18.5523 15 18C15 17.4477 14.5523 17 14 17H11.6924L14.3076 7H16C16.5523 7 17 6.5523 17 6C17 5.4477 16.5523 5 16 5H10Z" />
|
|
3
|
+
</svg>
|
package/svg/strike.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" >
|
|
2
|
+
<path d="M4 11C3.4477 11 3 11.4477 3 12C3 12.5523 3.4477 13 4 13H20C20.5523 13 21 12.5523 21 12C21 11.4477 20.5523 11 20 11H4Z" />
|
|
3
|
+
<path d="M12 4C9.5147 4 7.5 5.7909 7.5 8C7.5 8.7286 7.7196 9.4036 8.1003 9.985C8.4029 10.4473 9.0231 10.5767 9.4854 10.2741C9.9477 9.9715 10.0771 9.3513 9.7745 8.889C9.6007 8.6235 9.5 8.3231 9.5 8C9.5 6.9601 10.5293 6 12 6C13.4707 6 14.5 6.9601 14.5 8C14.5 8.5523 14.9477 9 15.5 9C16.0523 9 16.5 8.5523 16.5 8C16.5 5.7909 14.4853 4 12 4Z" />
|
|
4
|
+
<path d="M14.7825 14C15.2241 14.5666 15.5 15.2462 15.5 16C15.5 17.0399 14.4707 18 13 18H11C9.5293 18 8.5 17.0399 8.5 16C8.5 15.4477 8.0523 15 7.5 15C6.9477 15 6.5 15.4477 6.5 16C6.5 18.2091 8.5147 20 11 20H13C15.4853 20 17.5 18.2091 17.5 16C17.5 15.2818 17.2851 14.6082 16.9106 14H14.7825Z" />
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" >
|
|
2
|
+
<path d="M7 4C6.4477 4 6 4.4477 6 5V11C6 14.3137 8.6863 17 12 17C15.3137 17 18 14.3137 18 11V5C18 4.4477 17.5523 4 17 4C16.4477 4 16 4.4477 16 5V11C16 13.2091 14.2091 15 12 15C9.7909 15 8 13.2091 8 11V5C8 4.4477 7.5523 4 7 4Z" />
|
|
3
|
+
<path d="M5 19C4.4477 19 4 19.4477 4 20C4 20.5523 4.4477 21 5 21H19C19.5523 21 20 20.5523 20 20C20 19.4477 19.5523 19 19 19H5Z" />
|
|
4
|
+
</svg>
|