@brandup/ui-richeditor 1.0.41 → 1.0.43
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 +53 -3
- package/package.json +2 -2
- package/source/editing.ts +170 -13
- package/source/format-config.ts +117 -3
- package/source/format.ts +18 -1
- package/source/index.ts +5 -0
- package/source/paragraphs.ts +79 -13
- package/source/richeditor.less +54 -0
- package/source/richeditor.ts +204 -15
- package/source/selection.ts +188 -30
- package/source/serialize.ts +479 -296
- package/source/toolbar.ts +119 -11
- package/svg/codeblock.svg +1 -0
- package/svg/mono.svg +3 -0
- package/svg/quote.svg +1 -0
- package/svg/spoiler.svg +1 -0
package/source/selection.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Переключение форматирования на выделении и вставка форматированного текста
|
|
2
2
|
// на чистом Selection/Range API (без execCommand), плюс сохранение/восстановление выделения.
|
|
3
3
|
|
|
4
|
-
import { ALL_FORMAT_TOOLS, FORMAT_TOOLS, type FormatTool } from "./format-config";
|
|
4
|
+
import { ALL_FORMAT_TOOLS, FORMAT_TOOLS, blockTypeOfTag, type FormatTool } from "./format-config";
|
|
5
5
|
|
|
6
6
|
/** Канонические теги форматирования (в верхнем регистре, как tagName). */
|
|
7
7
|
const FORMAT_TAG_NAMES = ALL_FORMAT_TOOLS.map((t) => FORMAT_TOOLS[t].tag.toUpperCase());
|
|
@@ -13,6 +13,10 @@ const MATCH_TAG_NAMES = Array.from(new Set(ALL_FORMAT_TOOLS.flatMap((t) => FORMA
|
|
|
13
13
|
// Селекторы считаем один раз: обе выборки идут на каждую правку формата и на каждое обновление панели.
|
|
14
14
|
const FORMAT_SELECTOR = FORMAT_TAG_NAMES.join(",").toLowerCase();
|
|
15
15
|
const MATCH_SELECTOR = MATCH_TAG_NAMES.join(",").toLowerCase();
|
|
16
|
+
// Моноширинный: внутри него разметки не бывает (см. stripFormattingInCode)
|
|
17
|
+
const CODE_SELECTOR = FORMAT_TOOLS.code.matchTags.join(",").toLowerCase();
|
|
18
|
+
// Неделимые объекты хоста: конструкции сообщения объявляют себя нередактируемыми
|
|
19
|
+
const ATOMIC_SELECTOR = '[contenteditable="false"]';
|
|
16
20
|
|
|
17
21
|
// Проверка тега идёт на каждого предка каждого текстового узла при каждом обходе — храним
|
|
18
22
|
// множествами, а не массивами: подсветка панели опрашивает их на каждое движение каретки.
|
|
@@ -89,19 +93,110 @@ export function preserveCaret(root: HTMLElement, mutate: () => boolean | void):
|
|
|
89
93
|
if (touched !== false && bounds && selection) restoreSelection(root, bounds[0], bounds[1], selection);
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Единица текстовых координат: символы текстового узла либо конец строки — мягкий перенос
|
|
98
|
+
* и граница блока. Перенос обязан занимать позицию: иначе конец строки и начало следующей
|
|
99
|
+
* неразличимы, и восстановленная по смещению каретка возвращается на строку выше (это видно
|
|
100
|
+
* сразу после Enter, когда содержимое перестраивают — например подсветкой конструкций).
|
|
101
|
+
*/
|
|
102
|
+
type CharUnit = { kind: "text"; node: Text } | { kind: "line"; node: Node; offset: number };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Обходит содержимое в текстовых координатах по порядку. `visit` возвращает true — обход
|
|
106
|
+
* прекращается (границу нашли, дальше считать нечего).
|
|
107
|
+
*
|
|
108
|
+
* Позиция единицы «конец строки» — сразу ЗА переносом (или в начале следующего блока): именно
|
|
109
|
+
* её занимает каретка, оказавшись на новой строке.
|
|
110
|
+
*/
|
|
111
|
+
function walkChars(root: HTMLElement, visit: (unit: CharUnit) => boolean | void) {
|
|
112
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
|
113
|
+
let blocks = 0;
|
|
114
|
+
|
|
115
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
116
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
117
|
+
if (visit({ kind: "text", node: node as Text })) return;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const el = node as HTMLElement;
|
|
122
|
+
|
|
123
|
+
if (el.tagName === "BR") {
|
|
124
|
+
const parent = el.parentNode;
|
|
125
|
+
if (!parent) continue;
|
|
126
|
+
|
|
127
|
+
const index = Array.prototype.indexOf.call(parent.childNodes, el) + 1;
|
|
128
|
+
if (visit({ kind: "line", node: parent, offset: index })) return;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// граница блоков — только между ними: перед первым строка ещё не кончалась
|
|
133
|
+
if (el.parentNode === root && blockTypeOfTag(el.tagName)) {
|
|
134
|
+
blocks++;
|
|
135
|
+
if (blocks > 1 && visit({ kind: "line", node: el, offset: 0 })) return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Содержимое в тех же координатах, в которых считается каретка: концы строк — переводом строки.
|
|
142
|
+
* Обычного `textContent` для этого мало — в нём переносов нет, и смещения по нему разъезжаются
|
|
143
|
+
* со смещениями каретки ровно на число строк.
|
|
144
|
+
*/
|
|
145
|
+
export function editorText(root: HTMLElement): string {
|
|
146
|
+
let text = "";
|
|
147
|
+
walkChars(root, (unit) => {
|
|
148
|
+
text += unit.kind === "text" ? unit.node.data : "\n";
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return text;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Длина содержимого в координатах каретки (текст + концы строк внутри). */
|
|
155
|
+
export function charLength(root: HTMLElement): number {
|
|
156
|
+
let length = 0;
|
|
157
|
+
walkChars(root, (unit) => {
|
|
158
|
+
length += unit.kind === "text" ? unit.node.length : 1;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return length;
|
|
162
|
+
}
|
|
163
|
+
|
|
92
164
|
/** Абсолютные текстовые смещения границ выделения внутри root (для восстановления после правок DOM). */
|
|
93
165
|
export function selectionCharBounds(root: HTMLElement, range: Range): [number, number] {
|
|
94
166
|
const probe = document.createRange();
|
|
95
167
|
probe.selectNodeContents(root);
|
|
96
168
|
probe.setEnd(range.startContainer, range.startOffset);
|
|
97
|
-
const
|
|
169
|
+
const text = probe.toString().length;
|
|
170
|
+
const start = text + linesBefore(root, range.startContainer, range.startOffset);
|
|
98
171
|
if (range.collapsed) return [start, start];
|
|
99
172
|
|
|
100
173
|
// длину выделения меряем от его начала, а не от начала редактора: иначе весь текст
|
|
101
174
|
// до каретки собирается в строку дважды
|
|
102
175
|
probe.setStart(range.startContainer, range.startOffset);
|
|
103
176
|
probe.setEnd(range.endContainer, range.endOffset);
|
|
104
|
-
|
|
177
|
+
|
|
178
|
+
return [start, text + probe.toString().length + linesBefore(root, range.endContainer, range.endOffset)];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Сколько концов строк уже пройдено к моменту точки: единица считается, если её позиция
|
|
182
|
+
// не позже самой точки. Строк на порядок меньше, чем символов, поэтому считаем их отдельно —
|
|
183
|
+
// текст быстрее собрать одной строкой, чем обходить по узлам.
|
|
184
|
+
function linesBefore(root: HTMLElement, container: Node, offset: number): number {
|
|
185
|
+
const point = document.createRange();
|
|
186
|
+
point.setStart(container, offset);
|
|
187
|
+
point.collapse(true);
|
|
188
|
+
|
|
189
|
+
let count = 0;
|
|
190
|
+
walkChars(root, (unit) => {
|
|
191
|
+
if (unit.kind === "text") return false;
|
|
192
|
+
// единицы идут по порядку: встретили позицию за точкой — дальше только такие же
|
|
193
|
+
if (point.comparePoint(unit.node, unit.offset) > 0) return true;
|
|
194
|
+
|
|
195
|
+
count++;
|
|
196
|
+
return false;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return count;
|
|
105
200
|
}
|
|
106
201
|
|
|
107
202
|
/**
|
|
@@ -124,32 +219,50 @@ export function mapCharOffset(before: string, after: string, offset: number): nu
|
|
|
124
219
|
return j;
|
|
125
220
|
}
|
|
126
221
|
|
|
127
|
-
type CharPosition = { node:
|
|
222
|
+
type CharPosition = { node: Node; offset: number };
|
|
128
223
|
|
|
129
224
|
/**
|
|
130
|
-
* Находит
|
|
131
|
-
* Смещение за пределами
|
|
225
|
+
* Находит узлы и локальные смещения для пары абсолютных смещений за один обход.
|
|
226
|
+
* Смещение за пределами содержимого прижимается к его концу.
|
|
132
227
|
*/
|
|
133
228
|
function locateChars(root: HTMLElement, lower: number, upper: number): [CharPosition, CharPosition] | null {
|
|
134
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
135
229
|
let count = 0;
|
|
136
|
-
let last:
|
|
230
|
+
let last: CharPosition | null = null;
|
|
137
231
|
let low: CharPosition | null = null;
|
|
138
232
|
let high: CharPosition | null = null;
|
|
139
233
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
234
|
+
walkChars(root, (unit) => {
|
|
235
|
+
if (unit.kind === "text") {
|
|
236
|
+
const length = unit.node.length;
|
|
237
|
+
|
|
238
|
+
// конец текстового узла — это конец строки, а не её начало: смещение, равное ему,
|
|
239
|
+
// принадлежит тексту, а следующая позиция (+1) — уже переносу
|
|
240
|
+
if (!low && count + length >= lower) low = { node: unit.node, offset: lower - count };
|
|
241
|
+
if (count + length >= upper) {
|
|
242
|
+
high = { node: unit.node, offset: upper - count };
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
count += length;
|
|
247
|
+
last = { node: unit.node, offset: length };
|
|
248
|
+
return false;
|
|
146
249
|
}
|
|
147
|
-
count += n.length;
|
|
148
|
-
}
|
|
149
250
|
|
|
150
|
-
|
|
251
|
+
count++;
|
|
252
|
+
if (!low && count === lower) low = { node: unit.node, offset: unit.offset };
|
|
253
|
+
if (count === upper) {
|
|
254
|
+
high = { node: unit.node, offset: unit.offset };
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
last = { node: unit.node, offset: unit.offset };
|
|
259
|
+
return false;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (!last && !low && !high) return null;
|
|
263
|
+
|
|
264
|
+
const tail: CharPosition = last ?? { node: root, offset: 0 };
|
|
151
265
|
|
|
152
|
-
const tail: CharPosition = { node: last, offset: last.length };
|
|
153
266
|
return [low ?? tail, high ?? tail];
|
|
154
267
|
}
|
|
155
268
|
|
|
@@ -237,16 +350,45 @@ function caretProbe(range: Range): Node {
|
|
|
237
350
|
return node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
|
|
238
351
|
}
|
|
239
352
|
|
|
240
|
-
/**
|
|
241
|
-
|
|
242
|
-
|
|
353
|
+
/**
|
|
354
|
+
* Неделимый объект в тексте — элемент, объявленный нередактируемым (конструкции хоста:
|
|
355
|
+
* переменная, рандомизация). Разметка обязана оборачивать его целиком: внутрь него ни каретка,
|
|
356
|
+
* ни правка не заходят, а хост пересобирает его содержимое по-своему.
|
|
357
|
+
*/
|
|
358
|
+
function atomicAt(node: Node, root: HTMLElement): HTMLElement | null {
|
|
359
|
+
const elem = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement;
|
|
360
|
+
const atomic = elem?.closest<HTMLElement>(ATOMIC_SELECTOR) ?? null;
|
|
361
|
+
|
|
362
|
+
// сам редактор бывает нередактируемым (disabled у хоста) — он объектом не является
|
|
363
|
+
return atomic && atomic !== root && root.contains(atomic) ? atomic : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Узлы правки: непустые текстовые узлы, целиком попавшие в диапазон (частично задетые правке
|
|
368
|
+
* не подлежат), где текст внутри неделимого объекта заменён самим объектом.
|
|
369
|
+
*
|
|
370
|
+
* Иначе разметка ложилась бы внутрь конструкции, и вместо `**раз {ИМЯ} два**` в значение
|
|
371
|
+
* уходило бы `**раз** **{ИМЯ}** **два**` — три отдельных куска вместо одного.
|
|
372
|
+
*/
|
|
373
|
+
function collectTargets(root: HTMLElement, range: Range): Node[] {
|
|
374
|
+
const nodes: Node[] = [];
|
|
375
|
+
const seen = new Set<Node>();
|
|
376
|
+
|
|
243
377
|
// intersectsNode в обходе дешевле пары compareBoundaryPoints и отсекает почти всё лишнее
|
|
244
|
-
for (const n of touchedTextNodes(root, range))
|
|
378
|
+
for (const n of touchedTextNodes(root, range)) {
|
|
379
|
+
if (!nodeWithinRange(n, range)) continue;
|
|
380
|
+
|
|
381
|
+
const target = atomicAt(n, root) ?? n;
|
|
382
|
+
if (seen.has(target)) continue;
|
|
383
|
+
|
|
384
|
+
seen.add(target);
|
|
385
|
+
nodes.push(target);
|
|
386
|
+
}
|
|
245
387
|
|
|
246
388
|
return nodes;
|
|
247
389
|
}
|
|
248
390
|
|
|
249
|
-
function
|
|
391
|
+
function wrapNode(node: Node, tag: string) {
|
|
250
392
|
const wrapper = document.createElement(tag);
|
|
251
393
|
node.parentNode?.insertBefore(wrapper, node);
|
|
252
394
|
wrapper.appendChild(node);
|
|
@@ -272,7 +414,7 @@ function unwrapAround(fmt: HTMLElement, node: Node) {
|
|
|
272
414
|
if (!fmt.firstChild) parent.removeChild(fmt);
|
|
273
415
|
}
|
|
274
416
|
|
|
275
|
-
function removeFormatFromNode(node:
|
|
417
|
+
function removeFormatFromNode(node: Node, tags: ReadonlySet<string>, root: HTMLElement) {
|
|
276
418
|
let fmt = formatAncestor(node, tags, root);
|
|
277
419
|
while (fmt) {
|
|
278
420
|
unwrapAround(fmt, node);
|
|
@@ -287,6 +429,18 @@ function unwrapElement(el: HTMLElement) {
|
|
|
287
429
|
parent.removeChild(el);
|
|
288
430
|
}
|
|
289
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Внутри моноширинного разметки нет — ни своей, ни чужой.
|
|
434
|
+
*
|
|
435
|
+
* Написанное в коде остаётся буквальным: значение берёт из него голый текст, и любое
|
|
436
|
+
* форматирование внутри до получателя не доедет. Оставлять его в поле — показывать то,
|
|
437
|
+
* чего в сообщении не будет, поэтому чистим сразу, а не при сохранении.
|
|
438
|
+
*/
|
|
439
|
+
function stripFormattingInCode(root: HTMLElement) {
|
|
440
|
+
for (const code of Array.from(root.querySelectorAll<HTMLElement>(CODE_SELECTOR)))
|
|
441
|
+
for (const el of Array.from(code.querySelectorAll<HTMLElement>(MATCH_SELECTOR))) unwrapElement(el);
|
|
442
|
+
}
|
|
443
|
+
|
|
290
444
|
/**
|
|
291
445
|
* Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст.
|
|
292
446
|
*
|
|
@@ -295,6 +449,8 @@ function unwrapElement(el: HTMLElement) {
|
|
|
295
449
|
* в режиме набора, поэтому повторные выборки по всему дереву тут заметны.
|
|
296
450
|
*/
|
|
297
451
|
export function cleanupFormatting(root: HTMLElement) {
|
|
452
|
+
stripFormattingInCode(root);
|
|
453
|
+
|
|
298
454
|
const queue: HTMLElement[] = Array.from(root.querySelectorAll<HTMLElement>(FORMAT_SELECTOR));
|
|
299
455
|
|
|
300
456
|
const enqueue = (node: Node | null | undefined) => {
|
|
@@ -306,12 +462,14 @@ export function cleanupFormatting(root: HTMLElement) {
|
|
|
306
462
|
const el = queue.pop()!;
|
|
307
463
|
if (!el.isConnected || !root.contains(el)) continue;
|
|
308
464
|
|
|
309
|
-
//
|
|
310
|
-
//
|
|
465
|
+
// Тег без текста: оформлять в нём нечего. Разворачиваем, а не удаляем — внутри может
|
|
466
|
+
// лежать перенос строки, и вместе с тегом он унёс бы разделение строк (снятие формата
|
|
467
|
+
// с нескольких строк схлопывало их в одну). Пустой совсем — исчезнет и так.
|
|
468
|
+
// После правки соседи могут стать смежными одинаковыми, а родитель — опустеть.
|
|
311
469
|
if (el.textContent === "") {
|
|
312
470
|
enqueue(el.nextSibling);
|
|
313
471
|
enqueue(el.parentElement);
|
|
314
|
-
el
|
|
472
|
+
unwrapElement(el);
|
|
315
473
|
continue;
|
|
316
474
|
}
|
|
317
475
|
|
|
@@ -354,7 +512,7 @@ function editSelection(
|
|
|
354
512
|
range: Range,
|
|
355
513
|
selection: Selection,
|
|
356
514
|
restoreBounds: [number, number] | undefined,
|
|
357
|
-
mutate: (nodes:
|
|
515
|
+
mutate: (nodes: Node[]) => void
|
|
358
516
|
) {
|
|
359
517
|
if (range.collapsed) return;
|
|
360
518
|
|
|
@@ -362,7 +520,7 @@ function editSelection(
|
|
|
362
520
|
|
|
363
521
|
splitBoundaries(range);
|
|
364
522
|
|
|
365
|
-
const nodes =
|
|
523
|
+
const nodes = collectTargets(root, range);
|
|
366
524
|
if (!nodes.length) return;
|
|
367
525
|
|
|
368
526
|
mutate(nodes);
|
|
@@ -390,7 +548,7 @@ export function toggleFormat(
|
|
|
390
548
|
if (allFormatted) {
|
|
391
549
|
for (const n of nodes) removeFormatFromNode(n, tags, root);
|
|
392
550
|
} else {
|
|
393
|
-
for (const n of nodes) if (!formatAncestor(n, tags, root))
|
|
551
|
+
for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapNode(n, def.tag);
|
|
394
552
|
}
|
|
395
553
|
});
|
|
396
554
|
}
|