@brandup/ui-textbox 1.0.34 → 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 +3 -1
- package/package.json +9 -5
- package/source/index.ts +3 -1
- package/source/textbox.less +9 -107
- package/source/textbox.ts +116 -541
- package/source/format.ts +0 -546
- package/svg/bold.svg +0 -3
- package/svg/italic.svg +0 -3
- package/svg/strike.svg +0 -5
- package/svg/underline.svg +0 -4
package/source/format.ts
DELETED
|
@@ -1,546 +0,0 @@
|
|
|
1
|
-
// Опциональное форматирование текста (жирный, курсив, зачёркивание, подчёркивание)
|
|
2
|
-
// для TextBox. Включается атрибутом data-format, состав — data-format-tools,
|
|
3
|
-
// формат хранения значения — data-format-storage ("html" | "markdown").
|
|
4
|
-
|
|
5
|
-
export type FormatTool = "bold" | "italic" | "strike" | "underline";
|
|
6
|
-
export type FormatStorage = "html" | "markdown";
|
|
7
|
-
|
|
8
|
-
export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline"];
|
|
9
|
-
|
|
10
|
-
interface FormatToolDef {
|
|
11
|
-
/** Имя команды (атрибут command у кнопки и registerCommand). */
|
|
12
|
-
command: string;
|
|
13
|
-
/** Канонический тег при оборачивании и сериализации. */
|
|
14
|
-
tag: string;
|
|
15
|
-
/** Теги, распознаваемые при разборе входного HTML. */
|
|
16
|
-
matchTags: string[];
|
|
17
|
-
/** Маркер в Markdown. */
|
|
18
|
-
md: string;
|
|
19
|
-
/** Клавиша для Ctrl/Cmd-хоткея (пусто — без хоткея). */
|
|
20
|
-
hotkey: string;
|
|
21
|
-
/** Подсказка на кнопке. */
|
|
22
|
-
title: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
|
|
26
|
-
bold: {
|
|
27
|
-
command: "format-bold",
|
|
28
|
-
tag: "b",
|
|
29
|
-
matchTags: ["B", "STRONG"],
|
|
30
|
-
md: "**",
|
|
31
|
-
hotkey: "b",
|
|
32
|
-
title: "Жирный",
|
|
33
|
-
},
|
|
34
|
-
italic: {
|
|
35
|
-
command: "format-italic",
|
|
36
|
-
tag: "i",
|
|
37
|
-
matchTags: ["I", "EM"],
|
|
38
|
-
md: "*",
|
|
39
|
-
hotkey: "i",
|
|
40
|
-
title: "Курсив",
|
|
41
|
-
},
|
|
42
|
-
strike: {
|
|
43
|
-
command: "format-strike",
|
|
44
|
-
tag: "s",
|
|
45
|
-
matchTags: ["S", "STRIKE", "DEL"],
|
|
46
|
-
md: "~~",
|
|
47
|
-
hotkey: "",
|
|
48
|
-
title: "Зачёркнутый",
|
|
49
|
-
},
|
|
50
|
-
underline: {
|
|
51
|
-
command: "format-underline",
|
|
52
|
-
tag: "u",
|
|
53
|
-
matchTags: ["U", "INS"],
|
|
54
|
-
md: "++",
|
|
55
|
-
hotkey: "u",
|
|
56
|
-
title: "Подчёркнутый",
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
/** Канонические теги форматирования (в верхнем регистре, как tagName). */
|
|
61
|
-
const FORMAT_TAG_NAMES = ALL_FORMAT_TOOLS.map((t) => FORMAT_TOOLS[t].tag.toUpperCase());
|
|
62
|
-
|
|
63
|
-
/** Markdown-маркер для каждого инструмента форматирования. */
|
|
64
|
-
export type FormatMarkers = Record<FormatTool, string>;
|
|
65
|
-
|
|
66
|
-
/** Маркеры по умолчанию (из FORMAT_TOOLS): bold=**, italic=*, strike=~~, underline=++. */
|
|
67
|
-
export function defaultFormatMarkers(): FormatMarkers {
|
|
68
|
-
const markers = {} as FormatMarkers;
|
|
69
|
-
for (const tool of ALL_FORMAT_TOOLS) markers[tool] = FORMAT_TOOLS[tool].md;
|
|
70
|
-
return markers;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Карта Ctrl/Cmd-хоткеев: клавиша → инструмент. */
|
|
74
|
-
export const HOTKEY_TOOLS: Record<string, FormatTool> = (() => {
|
|
75
|
-
const map: Record<string, FormatTool> = {};
|
|
76
|
-
for (const tool of ALL_FORMAT_TOOLS) {
|
|
77
|
-
const hotkey = FORMAT_TOOLS[tool].hotkey;
|
|
78
|
-
if (hotkey) map[hotkey] = tool;
|
|
79
|
-
}
|
|
80
|
-
return map;
|
|
81
|
-
})();
|
|
82
|
-
|
|
83
|
-
/** Разбирает значение атрибута data-format-tools, оставляя только известные инструменты. */
|
|
84
|
-
export function parseFormatTools(value: string | null): FormatTool[] {
|
|
85
|
-
if (value === null) return ALL_FORMAT_TOOLS.slice();
|
|
86
|
-
|
|
87
|
-
const tools = value
|
|
88
|
-
.split(/\s+/)
|
|
89
|
-
.filter(Boolean)
|
|
90
|
-
.filter((t): t is FormatTool => (ALL_FORMAT_TOOLS as string[]).includes(t));
|
|
91
|
-
|
|
92
|
-
// убираем дубли, сохраняя порядок объявления
|
|
93
|
-
return ALL_FORMAT_TOOLS.filter((t) => tools.includes(t));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function escapeHtml(text: string): string {
|
|
97
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function escapeRegExp(text: string): string {
|
|
101
|
-
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Тег → инструмент, только для включённых инструментов. */
|
|
105
|
-
function buildTagMap(tools: FormatTool[]): Record<string, FormatTool> {
|
|
106
|
-
const map: Record<string, FormatTool> = {};
|
|
107
|
-
for (const tool of tools) for (const tag of FORMAT_TOOLS[tool].matchTags) map[tag] = tool;
|
|
108
|
-
return map;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function lineBreak(storage: FormatStorage): string {
|
|
112
|
-
return storage === "html" ? "<br>" : "\n";
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function wrap(storage: FormatStorage, tool: FormatTool, inner: string, markers: FormatMarkers): string {
|
|
116
|
-
if (!inner) return inner;
|
|
117
|
-
|
|
118
|
-
const def = FORMAT_TOOLS[tool];
|
|
119
|
-
if (storage === "html") return `<${def.tag}>${inner}</${def.tag}>`;
|
|
120
|
-
|
|
121
|
-
const marker = markers[tool];
|
|
122
|
-
return `${marker}${inner}${marker}`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function serializeNodes(
|
|
126
|
-
nodes: ArrayLike<ChildNode>,
|
|
127
|
-
storage: FormatStorage,
|
|
128
|
-
tagMap: Record<string, FormatTool>,
|
|
129
|
-
markers: FormatMarkers
|
|
130
|
-
): string {
|
|
131
|
-
let result = "";
|
|
132
|
-
const list = Array.from(nodes);
|
|
133
|
-
|
|
134
|
-
for (const node of list) {
|
|
135
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
136
|
-
const text = node.textContent ?? "";
|
|
137
|
-
result += storage === "html" ? escapeHtml(text) : text;
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
142
|
-
|
|
143
|
-
const el = node as HTMLElement;
|
|
144
|
-
const tag = el.tagName;
|
|
145
|
-
|
|
146
|
-
if (tag === "BR") {
|
|
147
|
-
result += lineBreak(storage);
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const inner = serializeNodes(el.childNodes, storage, tagMap, markers);
|
|
152
|
-
|
|
153
|
-
if (tag === "DIV" || tag === "P") {
|
|
154
|
-
// блочный элемент = новая строка
|
|
155
|
-
const nl = lineBreak(storage);
|
|
156
|
-
if (result && !result.endsWith(nl)) result += nl;
|
|
157
|
-
result += inner;
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const tool = tagMap[tag];
|
|
162
|
-
// неизвестный или отключённый тег — отбрасываем обёртку, оставляем текст
|
|
163
|
-
result += tool ? wrap(storage, tool, inner, markers) : inner;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return result;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Сериализует содержимое contenteditable-редактора в строку для хранения.
|
|
171
|
-
* Сохраняются только включённые инструменты форматирования.
|
|
172
|
-
*/
|
|
173
|
-
export function serialize(
|
|
174
|
-
root: HTMLElement,
|
|
175
|
-
storage: FormatStorage,
|
|
176
|
-
tools: FormatTool[],
|
|
177
|
-
markers: FormatMarkers = defaultFormatMarkers()
|
|
178
|
-
): string {
|
|
179
|
-
const value = serializeNodes(root.childNodes, storage, buildTagMap(tools), markers);
|
|
180
|
-
|
|
181
|
-
// нормализуем переводы строк по краям
|
|
182
|
-
if (storage === "html")
|
|
183
|
-
return value
|
|
184
|
-
.replace(/^(?:<br>)+/, "")
|
|
185
|
-
.replace(/(?:<br>)+$/, "")
|
|
186
|
-
.trim();
|
|
187
|
-
return value.replace(/^\n+/, "").replace(/\n+$/, "").trim();
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function markdownToHtml(value: string, tools: FormatTool[], markers: FormatMarkers): string {
|
|
191
|
-
let html = escapeHtml(value).replace(/\r?\n/g, "<br>");
|
|
192
|
-
|
|
193
|
-
// Маркеры применяем по убыванию длины: длинный (**) раньше короткого-префикса (*),
|
|
194
|
-
// иначе одиночная звезда «съест» двойную.
|
|
195
|
-
const order = tools.slice().sort((a, b) => markers[b].length - markers[a].length);
|
|
196
|
-
for (const tool of order) {
|
|
197
|
-
const marker = markers[tool];
|
|
198
|
-
if (!marker) continue;
|
|
199
|
-
|
|
200
|
-
const def = FORMAT_TOOLS[tool];
|
|
201
|
-
const escaped = escapeRegExp(marker);
|
|
202
|
-
const re = new RegExp(`${escaped}([\\s\\S]+?)${escaped}`, "g");
|
|
203
|
-
html = html.replace(re, `<${def.tag}>$1</${def.tag}>`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return html;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Готовит сохранённое значение к отображению в редакторе (возвращает HTML).
|
|
211
|
-
* HTML-значения санитизируются до разрешённых тегов, Markdown — конвертируется в HTML.
|
|
212
|
-
*/
|
|
213
|
-
export function deserialize(
|
|
214
|
-
value: string,
|
|
215
|
-
storage: FormatStorage,
|
|
216
|
-
tools: FormatTool[],
|
|
217
|
-
markers: FormatMarkers = defaultFormatMarkers()
|
|
218
|
-
): string {
|
|
219
|
-
if (!value) return "";
|
|
220
|
-
|
|
221
|
-
if (storage === "markdown") return markdownToHtml(value, tools, markers);
|
|
222
|
-
|
|
223
|
-
// html: парсим и пересобираем, отбрасывая всё, кроме разрешённых тегов и переводов строк
|
|
224
|
-
const template = document.createElement("template");
|
|
225
|
-
template.innerHTML = value;
|
|
226
|
-
return serializeNodes(template.content.childNodes, "html", buildTagMap(tools), defaultFormatMarkers());
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// --- Переключение форматирования на выделении (Selection/Range API, без execCommand) ---
|
|
230
|
-
|
|
231
|
-
/** Ближайший предок-элемент с одним из тегов (в пределах root, не включая root). */
|
|
232
|
-
function formatAncestor(node: Node, tags: string[], root: HTMLElement): HTMLElement | null {
|
|
233
|
-
let el = node.parentElement;
|
|
234
|
-
while (el && el !== root) {
|
|
235
|
-
if (tags.includes(el.tagName)) return el;
|
|
236
|
-
el = el.parentElement;
|
|
237
|
-
}
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Абсолютные текстовые смещения границ выделения внутри root (для восстановления после правок DOM). */
|
|
242
|
-
export function selectionCharBounds(root: HTMLElement, range: Range): [number, number] {
|
|
243
|
-
const probe = document.createRange();
|
|
244
|
-
probe.selectNodeContents(root);
|
|
245
|
-
probe.setEnd(range.startContainer, range.startOffset);
|
|
246
|
-
const start = probe.toString().length;
|
|
247
|
-
probe.setEnd(range.endContainer, range.endOffset);
|
|
248
|
-
const end = probe.toString().length;
|
|
249
|
-
return [start, end];
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** Находит текстовый узел и локальное смещение по абсолютному текстовому смещению. */
|
|
253
|
-
function locateChar(root: HTMLElement, target: number): { node: Text; offset: number } | null {
|
|
254
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
255
|
-
let count = 0;
|
|
256
|
-
let last: Text | null = null;
|
|
257
|
-
let n = walker.nextNode() as Text | null;
|
|
258
|
-
while (n) {
|
|
259
|
-
last = n;
|
|
260
|
-
if (count + n.length >= target) return { node: n, offset: Math.max(0, target - count) };
|
|
261
|
-
count += n.length;
|
|
262
|
-
n = walker.nextNode() as Text | null;
|
|
263
|
-
}
|
|
264
|
-
return last ? { node: last, offset: last.length } : null;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function restoreSelection(root: HTMLElement, start: number, end: number, selection: Selection) {
|
|
268
|
-
const s = locateChar(root, start);
|
|
269
|
-
const e = locateChar(root, end);
|
|
270
|
-
if (!s || !e) return;
|
|
271
|
-
|
|
272
|
-
const range = document.createRange();
|
|
273
|
-
range.setStart(s.node, s.offset);
|
|
274
|
-
range.setEnd(e.node, e.offset);
|
|
275
|
-
selection.removeAllRanges();
|
|
276
|
-
selection.addRange(range);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** Разбивает пограничные текстовые узлы так, чтобы Range покрывал их целиком. */
|
|
280
|
-
function splitBoundaries(range: Range) {
|
|
281
|
-
const sc = range.startContainer;
|
|
282
|
-
const ec = range.endContainer;
|
|
283
|
-
|
|
284
|
-
if (sc === ec && sc.nodeType === Node.TEXT_NODE) {
|
|
285
|
-
const t = sc as Text;
|
|
286
|
-
const s = range.startOffset;
|
|
287
|
-
const e = range.endOffset;
|
|
288
|
-
if (e < t.length) t.splitText(e);
|
|
289
|
-
let target = t;
|
|
290
|
-
if (s > 0) target = t.splitText(s);
|
|
291
|
-
range.setStart(target, 0);
|
|
292
|
-
range.setEnd(target, target.length);
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (ec.nodeType === Node.TEXT_NODE) {
|
|
297
|
-
const t = ec as Text;
|
|
298
|
-
if (range.endOffset > 0 && range.endOffset < t.length) {
|
|
299
|
-
t.splitText(range.endOffset);
|
|
300
|
-
range.setEnd(t, t.length);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
if (sc.nodeType === Node.TEXT_NODE) {
|
|
304
|
-
const t = sc as Text;
|
|
305
|
-
if (range.startOffset > 0 && range.startOffset < t.length) {
|
|
306
|
-
const after = t.splitText(range.startOffset);
|
|
307
|
-
range.setStart(after, 0);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function nodeWithinRange(node: Node, range: Range): boolean {
|
|
313
|
-
const nr = document.createRange();
|
|
314
|
-
nr.selectNodeContents(node);
|
|
315
|
-
return (
|
|
316
|
-
range.compareBoundaryPoints(Range.START_TO_START, nr) <= 0 &&
|
|
317
|
-
range.compareBoundaryPoints(Range.END_TO_END, nr) >= 0
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function collectTextNodes(root: HTMLElement, range: Range, strict: boolean): Text[] {
|
|
322
|
-
const nodes: Text[] = [];
|
|
323
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
324
|
-
let n = walker.nextNode() as Text | null;
|
|
325
|
-
while (n) {
|
|
326
|
-
if (n.length > 0 && (strict ? nodeWithinRange(n, range) : range.intersectsNode(n))) nodes.push(n);
|
|
327
|
-
n = walker.nextNode() as Text | null;
|
|
328
|
-
}
|
|
329
|
-
return nodes;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function wrapTextNode(node: Text, tag: string) {
|
|
333
|
-
const wrapper = document.createElement(tag);
|
|
334
|
-
node.parentNode?.insertBefore(wrapper, node);
|
|
335
|
-
wrapper.appendChild(node);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/** Выносит ветку, содержащую node, наружу из элемента fmt (расщепляя fmt на «до» и «после»). */
|
|
339
|
-
function unwrapAround(fmt: HTMLElement, node: Node) {
|
|
340
|
-
const parent = fmt.parentNode;
|
|
341
|
-
if (!parent) return;
|
|
342
|
-
|
|
343
|
-
let child: Node = node;
|
|
344
|
-
while (child.parentNode && child.parentNode !== fmt) child = child.parentNode;
|
|
345
|
-
if (child.parentNode !== fmt) return;
|
|
346
|
-
|
|
347
|
-
const left = fmt.cloneNode(false) as HTMLElement;
|
|
348
|
-
while (fmt.firstChild && fmt.firstChild !== child) left.appendChild(fmt.firstChild);
|
|
349
|
-
|
|
350
|
-
fmt.removeChild(child);
|
|
351
|
-
parent.insertBefore(left, fmt);
|
|
352
|
-
parent.insertBefore(child, fmt);
|
|
353
|
-
|
|
354
|
-
if (!left.firstChild) parent.removeChild(left);
|
|
355
|
-
if (!fmt.firstChild) parent.removeChild(fmt);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function removeFormatFromNode(node: Text, tags: string[], root: HTMLElement) {
|
|
359
|
-
let fmt = formatAncestor(node, tags, root);
|
|
360
|
-
while (fmt) {
|
|
361
|
-
unwrapAround(fmt, node);
|
|
362
|
-
fmt = formatAncestor(node, tags, root);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function unwrapElement(el: HTMLElement) {
|
|
367
|
-
const parent = el.parentNode;
|
|
368
|
-
if (!parent) return;
|
|
369
|
-
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
370
|
-
parent.removeChild(el);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст. */
|
|
374
|
-
function cleanupFormatting(root: HTMLElement) {
|
|
375
|
-
const selector = FORMAT_TAG_NAMES.join(",").toLowerCase();
|
|
376
|
-
|
|
377
|
-
let changed = true;
|
|
378
|
-
while (changed) {
|
|
379
|
-
changed = false;
|
|
380
|
-
|
|
381
|
-
for (const el of Array.from(root.querySelectorAll<HTMLElement>(selector))) {
|
|
382
|
-
if (!el.isConnected) continue;
|
|
383
|
-
|
|
384
|
-
// пустой тег
|
|
385
|
-
if (el.textContent === "") {
|
|
386
|
-
el.remove();
|
|
387
|
-
changed = true;
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// вложен в такой же тег
|
|
392
|
-
const parent = el.parentElement;
|
|
393
|
-
if (parent && parent !== root && parent.tagName === el.tagName) {
|
|
394
|
-
unwrapElement(el);
|
|
395
|
-
changed = true;
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// соседний такой же тег слева — склеиваем
|
|
400
|
-
const prev = el.previousSibling;
|
|
401
|
-
if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === el.tagName) {
|
|
402
|
-
while (el.firstChild) prev.appendChild(el.firstChild);
|
|
403
|
-
el.remove();
|
|
404
|
-
changed = true;
|
|
405
|
-
continue;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
root.normalize();
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Переключает форматирование инструмента на выделении.
|
|
415
|
-
* Если весь выделенный текст уже отформатирован — снимает формат, иначе применяет.
|
|
416
|
-
*
|
|
417
|
-
* По умолчанию восстанавливает выделение, на котором работал. Через `restoreBounds`
|
|
418
|
-
* можно восстановить другое выделение (например, исходное до расширения до слова).
|
|
419
|
-
*/
|
|
420
|
-
export function toggleFormat(
|
|
421
|
-
root: HTMLElement,
|
|
422
|
-
range: Range,
|
|
423
|
-
tool: FormatTool,
|
|
424
|
-
selection: Selection,
|
|
425
|
-
restoreBounds?: [number, number]
|
|
426
|
-
) {
|
|
427
|
-
if (range.collapsed) return;
|
|
428
|
-
|
|
429
|
-
const def = FORMAT_TOOLS[tool];
|
|
430
|
-
const tags = def.matchTags;
|
|
431
|
-
const [startChar, endChar] = restoreBounds ?? selectionCharBounds(root, range);
|
|
432
|
-
|
|
433
|
-
splitBoundaries(range);
|
|
434
|
-
|
|
435
|
-
const nodes = collectTextNodes(root, range, true);
|
|
436
|
-
if (!nodes.length) return;
|
|
437
|
-
|
|
438
|
-
const allFormatted = nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
439
|
-
if (allFormatted) {
|
|
440
|
-
for (const n of nodes) removeFormatFromNode(n, tags, root);
|
|
441
|
-
} else {
|
|
442
|
-
for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapTextNode(n, def.tag);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
cleanupFormatting(root);
|
|
446
|
-
restoreSelection(root, startChar, endChar, selection);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Вставляет текст в позицию каретки, оборачивая его в указанные форматы (режим набора).
|
|
451
|
-
* Каретка ставится сразу после вставленного текста; соседние одинаковые теги склеиваются.
|
|
452
|
-
*/
|
|
453
|
-
export function insertFormattedText(root: HTMLElement, data: string, tools: FormatTool[], selection: Selection) {
|
|
454
|
-
if (!data || selection.rangeCount === 0) return;
|
|
455
|
-
|
|
456
|
-
const range = selection.getRangeAt(0);
|
|
457
|
-
const caret = selectionCharBounds(root, range)[0];
|
|
458
|
-
|
|
459
|
-
range.deleteContents();
|
|
460
|
-
|
|
461
|
-
let node: Node = document.createTextNode(data);
|
|
462
|
-
for (const tool of tools) {
|
|
463
|
-
const el = document.createElement(FORMAT_TOOLS[tool].tag);
|
|
464
|
-
el.appendChild(node);
|
|
465
|
-
node = el;
|
|
466
|
-
}
|
|
467
|
-
range.insertNode(node);
|
|
468
|
-
|
|
469
|
-
cleanupFormatting(root);
|
|
470
|
-
|
|
471
|
-
const offset = caret + data.length;
|
|
472
|
-
restoreSelection(root, offset, offset, selection);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/** Активен ли формат инструмента на текущем выделении (для подсветки кнопки). */
|
|
476
|
-
export function isFormatActive(root: HTMLElement, range: Range, tool: FormatTool): boolean {
|
|
477
|
-
const tags = FORMAT_TOOLS[tool].matchTags;
|
|
478
|
-
|
|
479
|
-
if (range.collapsed) {
|
|
480
|
-
const node = range.startContainer;
|
|
481
|
-
const probe = node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
|
|
482
|
-
return formatAncestor(probe, tags, root) !== null;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const nodes = collectTextNodes(root, range, false);
|
|
486
|
-
if (!nodes.length) return false;
|
|
487
|
-
return nodes.every((n) => formatAncestor(n, tags, root) !== null);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Нормализует пробелы в редакторе: схлопывает повторяющиеся пробелы/табы в один
|
|
492
|
-
* и обрезает пробелы по краям каждой строки. BR и блочные элементы (DIV/P) —
|
|
493
|
-
* границы строк; инлайновое форматирование (b/i/s/u) на строки не влияет.
|
|
494
|
-
*/
|
|
495
|
-
export function normalizeWhitespace(root: HTMLElement) {
|
|
496
|
-
type Item = { kind: "text"; node: Text } | { kind: "break" };
|
|
497
|
-
const items: Item[] = [];
|
|
498
|
-
|
|
499
|
-
const flatten = (node: Node) => {
|
|
500
|
-
for (const child of Array.from(node.childNodes)) {
|
|
501
|
-
if (child.nodeType === Node.TEXT_NODE) {
|
|
502
|
-
items.push({ kind: "text", node: child as Text });
|
|
503
|
-
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
504
|
-
const el = child as HTMLElement;
|
|
505
|
-
if (el.tagName === "BR") {
|
|
506
|
-
items.push({ kind: "break" });
|
|
507
|
-
} else if (el.tagName === "DIV" || el.tagName === "P") {
|
|
508
|
-
items.push({ kind: "break" });
|
|
509
|
-
flatten(el);
|
|
510
|
-
items.push({ kind: "break" });
|
|
511
|
-
} else {
|
|
512
|
-
flatten(el); // инлайновый тег — не разрывает строку
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
};
|
|
517
|
-
flatten(root);
|
|
518
|
-
|
|
519
|
-
let atLineStart = true;
|
|
520
|
-
let pendingSpaceNode: Text | null = null; // узел, заканчивающийся пробелом (возможно хвостовым)
|
|
521
|
-
|
|
522
|
-
for (const item of items) {
|
|
523
|
-
if (item.kind === "break") {
|
|
524
|
-
if (pendingSpaceNode) {
|
|
525
|
-
pendingSpaceNode.data = pendingSpaceNode.data.replace(/ $/, "");
|
|
526
|
-
pendingSpaceNode = null;
|
|
527
|
-
}
|
|
528
|
-
atLineStart = true;
|
|
529
|
-
continue;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
let text = item.node.data.replace(/[ \t]+/g, " ");
|
|
533
|
-
if (atLineStart) text = text.replace(/^ /, ""); // пробел в начале строки
|
|
534
|
-
if (pendingSpaceNode && text.startsWith(" ")) text = text.slice(1); // двойной пробел на границе узлов
|
|
535
|
-
|
|
536
|
-
item.node.data = text;
|
|
537
|
-
if (text.length === 0) continue;
|
|
538
|
-
|
|
539
|
-
atLineStart = false;
|
|
540
|
-
pendingSpaceNode = text.endsWith(" ") ? item.node : null;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
if (pendingSpaceNode) pendingSpaceNode.data = pendingSpaceNode.data.replace(/ $/, ""); // хвост последней строки
|
|
544
|
-
|
|
545
|
-
cleanupFormatting(root); // убрать опустевшие теги, склеить узлы
|
|
546
|
-
}
|
package/svg/bold.svg
DELETED
|
@@ -1,3 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,3 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
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>
|
package/svg/underline.svg
DELETED
|
@@ -1,4 +0,0 @@
|
|
|
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>
|