@brandup/ui-richeditor 1.0.40 → 1.0.42
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 +52 -2
- package/package.json +2 -2
- package/source/editing.ts +170 -13
- package/source/emoji.ts +127 -51
- package/source/format-config.ts +117 -3
- package/source/format.ts +18 -1
- package/source/index.ts +6 -1
- package/source/paragraphs.ts +79 -13
- package/source/richeditor.less +103 -10
- package/source/richeditor.ts +340 -18
- package/source/selection.ts +195 -29
- package/source/serialize.ts +479 -296
- package/source/toolbar.ts +172 -24
- 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;
|
|
249
|
+
}
|
|
250
|
+
|
|
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;
|
|
146
256
|
}
|
|
147
|
-
count += n.length;
|
|
148
|
-
}
|
|
149
257
|
|
|
150
|
-
|
|
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
|
|
|
@@ -217,8 +330,18 @@ function nodeWithinRange(node: Node, range: Range): boolean {
|
|
|
217
330
|
function* touchedTextNodes(root: HTMLElement, range: Range): Generator<Text> {
|
|
218
331
|
const walker = document.createTreeWalker(rangeScope(root, range), NodeFilter.SHOW_TEXT);
|
|
219
332
|
|
|
220
|
-
for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null)
|
|
221
|
-
if (n.length
|
|
333
|
+
for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
|
|
334
|
+
if (!n.length || !range.intersectsNode(n)) continue;
|
|
335
|
+
|
|
336
|
+
// Узел, задетый одной лишь границей, не входит в диапазон ни одним символом: выделение
|
|
337
|
+
// кончается там, где он начинается, или начинается там, где он кончается. Учитывать его
|
|
338
|
+
// нельзя — соседний по тексту узел лежит уже вне тегов выделенного, и от него состояние
|
|
339
|
+
// всего выделения выглядело бы неотформатированным.
|
|
340
|
+
if (range.startContainer === n && range.startOffset === n.length) continue;
|
|
341
|
+
if (range.endContainer === n && range.endOffset === 0) continue;
|
|
342
|
+
|
|
343
|
+
yield n;
|
|
344
|
+
}
|
|
222
345
|
}
|
|
223
346
|
|
|
224
347
|
/** Узел под схлопнутой кареткой — от него и ищется формат. */
|
|
@@ -227,16 +350,45 @@ function caretProbe(range: Range): Node {
|
|
|
227
350
|
return node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[range.startOffset] ?? node);
|
|
228
351
|
}
|
|
229
352
|
|
|
230
|
-
/**
|
|
231
|
-
|
|
232
|
-
|
|
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
|
+
|
|
233
377
|
// intersectsNode в обходе дешевле пары compareBoundaryPoints и отсекает почти всё лишнее
|
|
234
|
-
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
|
+
}
|
|
235
387
|
|
|
236
388
|
return nodes;
|
|
237
389
|
}
|
|
238
390
|
|
|
239
|
-
function
|
|
391
|
+
function wrapNode(node: Node, tag: string) {
|
|
240
392
|
const wrapper = document.createElement(tag);
|
|
241
393
|
node.parentNode?.insertBefore(wrapper, node);
|
|
242
394
|
wrapper.appendChild(node);
|
|
@@ -262,7 +414,7 @@ function unwrapAround(fmt: HTMLElement, node: Node) {
|
|
|
262
414
|
if (!fmt.firstChild) parent.removeChild(fmt);
|
|
263
415
|
}
|
|
264
416
|
|
|
265
|
-
function removeFormatFromNode(node:
|
|
417
|
+
function removeFormatFromNode(node: Node, tags: ReadonlySet<string>, root: HTMLElement) {
|
|
266
418
|
let fmt = formatAncestor(node, tags, root);
|
|
267
419
|
while (fmt) {
|
|
268
420
|
unwrapAround(fmt, node);
|
|
@@ -277,6 +429,18 @@ function unwrapElement(el: HTMLElement) {
|
|
|
277
429
|
parent.removeChild(el);
|
|
278
430
|
}
|
|
279
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
|
+
|
|
280
444
|
/**
|
|
281
445
|
* Чистит разметку: убирает пустые теги, схлопывает вложенные и соседние одинаковые, склеивает текст.
|
|
282
446
|
*
|
|
@@ -285,6 +449,8 @@ function unwrapElement(el: HTMLElement) {
|
|
|
285
449
|
* в режиме набора, поэтому повторные выборки по всему дереву тут заметны.
|
|
286
450
|
*/
|
|
287
451
|
export function cleanupFormatting(root: HTMLElement) {
|
|
452
|
+
stripFormattingInCode(root);
|
|
453
|
+
|
|
288
454
|
const queue: HTMLElement[] = Array.from(root.querySelectorAll<HTMLElement>(FORMAT_SELECTOR));
|
|
289
455
|
|
|
290
456
|
const enqueue = (node: Node | null | undefined) => {
|
|
@@ -344,7 +510,7 @@ function editSelection(
|
|
|
344
510
|
range: Range,
|
|
345
511
|
selection: Selection,
|
|
346
512
|
restoreBounds: [number, number] | undefined,
|
|
347
|
-
mutate: (nodes:
|
|
513
|
+
mutate: (nodes: Node[]) => void
|
|
348
514
|
) {
|
|
349
515
|
if (range.collapsed) return;
|
|
350
516
|
|
|
@@ -352,7 +518,7 @@ function editSelection(
|
|
|
352
518
|
|
|
353
519
|
splitBoundaries(range);
|
|
354
520
|
|
|
355
|
-
const nodes =
|
|
521
|
+
const nodes = collectTargets(root, range);
|
|
356
522
|
if (!nodes.length) return;
|
|
357
523
|
|
|
358
524
|
mutate(nodes);
|
|
@@ -380,7 +546,7 @@ export function toggleFormat(
|
|
|
380
546
|
if (allFormatted) {
|
|
381
547
|
for (const n of nodes) removeFormatFromNode(n, tags, root);
|
|
382
548
|
} else {
|
|
383
|
-
for (const n of nodes) if (!formatAncestor(n, tags, root))
|
|
549
|
+
for (const n of nodes) if (!formatAncestor(n, tags, root)) wrapNode(n, def.tag);
|
|
384
550
|
}
|
|
385
551
|
});
|
|
386
552
|
}
|