@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/README.md
CHANGED
|
@@ -49,6 +49,7 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
49
49
|
| `placeholder` | `string \| null` | Текст-заглушка |
|
|
50
50
|
| `multiline` | `boolean` | Многострочный режим |
|
|
51
51
|
| `paragraph` | `"block" \| "break"` | Что делает Enter: новый абзац (по умолчанию) или мягкий перенос |
|
|
52
|
+
| `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию только `paragraph`) |
|
|
52
53
|
| `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются) |
|
|
53
54
|
| `toolbarContainer` | `HTMLElement \| null` | Контейнер для панели; по умолчанию `document.body` (`position: fixed`). Если задан — панель монтируется в него и позиционируется над ним (`position: absolute`). Контейнер должен быть `position: relative` |
|
|
54
55
|
| `value` | `string` | Начальное значение |
|
|
@@ -71,7 +72,13 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
71
72
|
| `getLength(): number` | Длина текста (без учёта переводов строк) |
|
|
72
73
|
| `focus(): void` | Установить фокус |
|
|
73
74
|
| `applyFormat(tool): void` | Переключить формат на выделении (слово целиком) |
|
|
75
|
+
| `applyBlock(type): void` | Переключить тип блоков под выделением; повторное применение возвращает обычный текст |
|
|
76
|
+
| `applyCode(): void` | Код по выделению: моноширинный для части строки, блок — для целых строк |
|
|
77
|
+
| `isCodeActive(): boolean` | Включён ли код в любом виде — подсветка объединённой кнопки |
|
|
78
|
+
| `currentBlock: BlockType` | Тип блока под кареткой |
|
|
79
|
+
| `blockTypes: BlockType[]` | Доступные типы блоков |
|
|
74
80
|
| `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
|
|
81
|
+
| `isToolEnabled(tool): boolean` | Доступен ли инструмент сейчас (внутри кода остальные выключены) |
|
|
75
82
|
| `activeTools(): ReadonlySet<FormatTool>` | Активные форматы всех инструментов сразу (панель обновляется на каждое движение каретки, поштучный опрос обходил бы содержимое на каждую кнопку) |
|
|
76
83
|
| `clearFormat(): void` | Снять всё форматирование с выделения (без выделения — со слова под кареткой) |
|
|
77
84
|
| `clearAllFormat(): void` | Снять всё форматирование со всего содержимого |
|
|
@@ -152,6 +159,49 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
|
|
|
152
159
|
|
|
153
160
|
При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются.
|
|
154
161
|
|
|
162
|
+
## Блоки: цитата и код
|
|
163
|
+
|
|
164
|
+
Кроме абзаца многострочный режим знает и другие типы блоков верхнего уровня. Они подключаются **явно** — их понимает не каждый получатель:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
new RichEditor(elem, { format: true, multiline: true, blocks: ["quote", "code"] });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
| Тип | Тег | Разметка | Enter внутри |
|
|
171
|
+
| --- | --- | --- | --- |
|
|
172
|
+
| `paragraph` | `<p>` | — | по режиму `paragraph` |
|
|
173
|
+
| `quote` | `<blockquote>` | `>` с пробелом в начале каждой строки | заканчивает блок |
|
|
174
|
+
| `code` | `<pre>` | ограждение ```` ``` ```` | заканчивает блок |
|
|
175
|
+
|
|
176
|
+
Обычный текст — такой же тип, а не «тип не задан»: он есть в наборе всегда, им становится содержимое, не попавшее ни в какой блок, и в него же блок возвращают. Кнопки в панели (`.block-button`) получают только остальные типы.
|
|
177
|
+
|
|
178
|
+
Кнопки кода (моноширинного и блока) и спойлера временно скрыты (`HIDDEN_TOOLS`/`HIDDEN_BLOCKS` в `./toolbar`): сами возможности работают — значение разбирается, показывается и сохраняется, правку можно вызвать из кода (`applyFormat`, `applyBlock`, `applyCode`), — но в панель они пока не выводятся.
|
|
179
|
+
|
|
180
|
+
### Одна кнопка на моноширинный и блок кода
|
|
181
|
+
|
|
182
|
+
Когда включены и инструмент `code`, и тип блока `code`, панель показывает **одну** кнопку — так это устроено в мессенджерах. Вид выбирается по выделению, как и в самой разметке:
|
|
183
|
+
|
|
184
|
+
- выделена одна строка или её часть → моноширинный (`` `код` ``);
|
|
185
|
+
- выделено больше одной строки → блок кода из этих строк. Остальные строки остаются как были: в мессенджерском режиме всё сообщение — это один блок, и иначе кодом становился бы весь текст;
|
|
186
|
+
- без выделения → блок кода из строки под кареткой. Моноширинным там делать нечего, а иначе блок был бы недостижим: в пустом поле не выделить строки, которых ещё нет;
|
|
187
|
+
- код уже включён → повторное нажатие снимает именно его вид, целиком по блоку.
|
|
188
|
+
|
|
189
|
+
Тем же занимается `applyCode()`, а `isCodeActive()` отвечает, включён ли код в любом виде — им подсвечена кнопка.
|
|
190
|
+
|
|
191
|
+
В коде разметки нет — ни в моноширинном, ни в блоке: значение берёт оттуда голый текст, и любое форматирование внутри до получателя не доедет. Поэтому при переходе в моноширинный прежнее форматирование с этого куска снимается, внутри кода остальные инструменты недоступны (`isToolEnabled()` — кнопки гаснут), а разметка, попавшая внутрь как-то ещё (вставка, чужое значение), вычищается при первой же нормализации. Если включено только что-то одно, кнопка остаётся обычной: инструмента (`.format-button`) или блока (`.block-button`).
|
|
192
|
+
|
|
193
|
+
Переключение:
|
|
194
|
+
|
|
195
|
+
- кнопка панели или `applyBlock(type)` — на все блоки, которых касается выделение; повторное применение того же типа возвращает обычный текст;
|
|
196
|
+
- **Enter** внутри цитаты или кода заканчивает блок и начинает обычный абзац, **Shift/Ctrl/Cmd+Enter** переносит строку внутри блока. Выходить из блока приходится чаще, чем продолжать его, а иначе уйти можно было бы только мышью;
|
|
197
|
+
- **Backspace** в начале блока возвращает его к обычному тексту, не трогая содержимого.
|
|
198
|
+
|
|
199
|
+
Особенности блока кода: инлайновое форматирование внутри не размечается (написанное остаётся буквальным) и снимается при переключении в этот тип, а пробелы внутри не схлопываются — отступы там часть текста.
|
|
200
|
+
|
|
201
|
+
Подряд идущие строки с маркером цитаты — одна цитата; пустая строка между ними разделяет цитаты. Незакрытое ограждение блоком не считается: его строки остаются текстом, иначе одна случайная кавычка съедала бы весь остаток сообщения. Блок отключённого типа, пришедший вставкой или из значения, сохраняется как обычный текст — разметки от него в значении не будет, но текст не теряется.
|
|
202
|
+
|
|
203
|
+
В режиме `paragraph: "break"` блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два.
|
|
204
|
+
|
|
155
205
|
## Вставка текста
|
|
156
206
|
|
|
157
207
|
При включённом форматировании вставка (`paste`) сохраняет форматирование из буфера обмена (`text/html`):
|
|
@@ -172,8 +222,8 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
|
|
|
172
222
|
|
|
173
223
|
| `storage` | Хранение | Абзац / мягкий перенос | Маркеры форматирования |
|
|
174
224
|
| --- | --- | --- | --- |
|
|
175
|
-
| `html` | Санитизированный HTML | `<p>…</p>` / `<br>` | `<b>`, `<i>`, `<s>`, `<u>` |
|
|
176
|
-
| `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`, `_курсив_`, `~зачёркнутый~`, `__подчёркнутый__
|
|
225
|
+
| `html` | Санитизированный HTML | `<p>…</p>` / `<br>` | `<b>`, `<i>`, `<s>`, `<u>`, `<spoiler>`, `<code>` |
|
|
226
|
+
| `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`, `_курсив_`, `~зачёркнутый~`, `__подчёркнутый__`, `\|\|спойлер\|\|`, `` `моноширинный` `` |
|
|
177
227
|
|
|
178
228
|
Без форматирования (plain) значение хранится как markdown без инструментов: абзацы `\n\n`, мягкий перенос `\n`.
|
|
179
229
|
|
package/package.json
CHANGED
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
"email": "it@brandup.online"
|
|
28
28
|
},
|
|
29
29
|
"license": "Apache-2.0",
|
|
30
|
-
"version": "1.0.
|
|
30
|
+
"version": "1.0.42",
|
|
31
31
|
"main": "source/index.ts",
|
|
32
32
|
"types": "source/index.ts",
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@brandup/ui": "^2.0.7",
|
|
35
|
-
"@brandup/ui-kit": "^1.0.
|
|
35
|
+
"@brandup/ui-kit": "^1.0.42"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"source",
|
package/source/editing.ts
CHANGED
|
@@ -3,14 +3,12 @@
|
|
|
3
3
|
// редактора и без истории — вызывающий сам решает, когда записывать undo-шаг.
|
|
4
4
|
|
|
5
5
|
import { deserialize } from "./serialize";
|
|
6
|
-
import { isBlock } from "./paragraphs";
|
|
6
|
+
import { blockAt, blockTypeOf, blocksInRange, createBlock, isBlock } from "./paragraphs";
|
|
7
7
|
import { documentSelection, innerSelection } from "./selection";
|
|
8
|
-
import type
|
|
8
|
+
import { BLOCK_TYPES, DEFAULT_BLOCK, type BlockType, type FormatMarkers, type FormatTool } from "./format-config";
|
|
9
9
|
|
|
10
10
|
function emptyParagraph(): HTMLParagraphElement {
|
|
11
|
-
|
|
12
|
-
p.appendChild(document.createElement("br"));
|
|
13
|
-
return p;
|
|
11
|
+
return createBlock(DEFAULT_BLOCK) as HTMLParagraphElement;
|
|
14
12
|
}
|
|
15
13
|
|
|
16
14
|
// убирает пустые текст-узлы и ставит <br>-заполнитель в пустой абзац (для видимости и каретки)
|
|
@@ -51,21 +49,155 @@ export function selectAllContent(editable: HTMLElement) {
|
|
|
51
49
|
documentSelection(editable)?.selectAllChildren(editable);
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
/**
|
|
55
|
-
export
|
|
52
|
+
/** Что сделала правка блоков: менялось ли содержимое и не появился ли блок из части строк. */
|
|
53
|
+
export interface BlockChange {
|
|
54
|
+
changed: boolean;
|
|
55
|
+
/** Блок, собранный из выделенных строк (разделение); null — блоки меняли целиком. */
|
|
56
|
+
created: HTMLElement | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Меняет тип блоков, которых касается выделение. `changed: false` — менять было нечего:
|
|
61
|
+
* тогда ни содержимое, ни история не трогаются.
|
|
62
|
+
*
|
|
63
|
+
* У типа без инлайновой разметки (код) форматирование снимается: внутри него написанное
|
|
64
|
+
* остаётся буквальным, и сохранить его всё равно было бы негде.
|
|
65
|
+
*/
|
|
66
|
+
export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType): BlockChange {
|
|
67
|
+
const blocks = blocksInRange(editable, range);
|
|
68
|
+
|
|
69
|
+
// Пустой редактор: блоков ещё нет, но тип задать можно — иначе в пустое поле его было бы
|
|
70
|
+
// не поставить вовсе, а набор блока обычно с этого и начинают.
|
|
71
|
+
if (!blocks.length && !editable.firstChild && type !== DEFAULT_BLOCK) {
|
|
72
|
+
const block = createBlock(type);
|
|
73
|
+
editable.appendChild(block);
|
|
74
|
+
caretToStart(block);
|
|
75
|
+
|
|
76
|
+
return { changed: true, created: block };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Выделена часть строк блока — правим их, а не весь блок: иначе в мессенджерском режиме,
|
|
80
|
+
// где всё сообщение это один блок, кодом становился бы весь текст.
|
|
81
|
+
if (blocks.length === 1 && type !== DEFAULT_BLOCK && blockTypeOf(blocks[0]) !== type) {
|
|
82
|
+
const created = splitLines(blocks[0], range, type);
|
|
83
|
+
if (created) return { changed: true, created };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let changed = false;
|
|
87
|
+
|
|
88
|
+
for (const block of blocks) {
|
|
89
|
+
if (blockTypeOf(block) === type) continue;
|
|
90
|
+
|
|
91
|
+
const replacement = retagBlock(block, type);
|
|
92
|
+
if (!BLOCK_TYPES[type].inline) unwrapFormatting(replacement);
|
|
93
|
+
|
|
94
|
+
block.replaceWith(replacement);
|
|
95
|
+
fillEmptyParagraph(replacement);
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { changed, created: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Собирает блок нужного типа из строк, которых коснулось выделение; остальные строки остаются
|
|
104
|
+
* блоками прежнего типа до и после. Строки берутся целиком: код из половины строки — это уже
|
|
105
|
+
* моноширинный, а не блок.
|
|
106
|
+
*
|
|
107
|
+
* null — делить нечего: выделение и так захватило все строки блока.
|
|
108
|
+
*/
|
|
109
|
+
function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElement | null {
|
|
110
|
+
const breaks = Array.from(block.querySelectorAll("br"));
|
|
111
|
+
|
|
112
|
+
// последний перенос перед выделением и первый после него — по ним и режем
|
|
113
|
+
const head = breaks.filter((br) => range.comparePoint(br, 0) < 0).pop();
|
|
114
|
+
const tail = breaks.find((br) => range.comparePoint(br, 0) > 0);
|
|
115
|
+
if (!head && !tail) return null;
|
|
116
|
+
|
|
117
|
+
const original = blockTypeOf(block) ?? DEFAULT_BLOCK;
|
|
118
|
+
|
|
119
|
+
// Хвост выносим первым: он дальше по дереву, и вынос головы сдвинул бы его границы.
|
|
120
|
+
// Сам перенос-разделитель уходит вместе с ним — строки разъезжаются по блокам.
|
|
121
|
+
const cut = (from: "before" | "after", br: HTMLElement): HTMLElement => {
|
|
122
|
+
const part = document.createRange();
|
|
123
|
+
|
|
124
|
+
if (from === "after") {
|
|
125
|
+
part.setStartAfter(br);
|
|
126
|
+
part.setEnd(block, block.childNodes.length);
|
|
127
|
+
} else {
|
|
128
|
+
part.setStart(block, 0);
|
|
129
|
+
part.setEndBefore(br);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const piece = document.createElement(BLOCK_TYPES[original].tag);
|
|
133
|
+
piece.appendChild(part.extractContents());
|
|
134
|
+
br.remove();
|
|
135
|
+
fillEmptyParagraph(piece);
|
|
136
|
+
|
|
137
|
+
return piece;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const after = tail ? cut("after", tail) : null;
|
|
141
|
+
const before = head ? cut("before", head) : null;
|
|
142
|
+
|
|
143
|
+
const created = retagBlock(block, type);
|
|
144
|
+
if (!BLOCK_TYPES[type].inline) unwrapFormatting(created);
|
|
145
|
+
|
|
146
|
+
block.replaceWith(created);
|
|
147
|
+
fillEmptyParagraph(created);
|
|
148
|
+
|
|
149
|
+
if (before) created.before(before);
|
|
150
|
+
if (after) created.after(after);
|
|
151
|
+
|
|
152
|
+
return created;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Разворачивает инлайновые теги, оставляя текст и мягкие переносы. Список снимается заранее:
|
|
156
|
+
// разворот внешнего тега поднимает вложенные к блоку, и обойти их нужно тоже.
|
|
157
|
+
function unwrapFormatting(block: HTMLElement) {
|
|
158
|
+
for (const el of Array.from(block.querySelectorAll<HTMLElement>("*"))) {
|
|
159
|
+
if (el.tagName === "BR") continue;
|
|
160
|
+
el.replaceWith(...Array.from(el.childNodes));
|
|
161
|
+
}
|
|
162
|
+
block.normalize();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Стоит ли каретка в начале своего блока — по тексту до неё, а не по узлу: началом считается
|
|
167
|
+
* и позиция перед вложенным форматированием, и позиция в его первом текстовом узле.
|
|
168
|
+
*/
|
|
169
|
+
export function atBlockStart(editable: HTMLElement, range: Range): boolean {
|
|
170
|
+
if (!range.collapsed) return false;
|
|
171
|
+
|
|
172
|
+
const block = blockAt(editable, range.startContainer);
|
|
173
|
+
if (!block) return false;
|
|
174
|
+
|
|
175
|
+
const before = document.createRange();
|
|
176
|
+
before.selectNodeContents(block);
|
|
177
|
+
before.setEnd(range.startContainer, range.startOffset);
|
|
178
|
+
|
|
179
|
+
// Начало блока — это и начало его первой строки. Перед кареткой стоит перенос — значит
|
|
180
|
+
// строка не первая, и удалять нужно сам перенос, а не тип блока.
|
|
181
|
+
if (before.cloneContents().querySelector("br")) return false;
|
|
182
|
+
|
|
183
|
+
return before.toString().length === 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Enter в multiline: разбить текущий блок по каретке; хвост становится блоком типа `type`. */
|
|
187
|
+
export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT_BLOCK) {
|
|
56
188
|
const selection = innerSelection(editable);
|
|
57
189
|
if (!selection) return;
|
|
58
190
|
|
|
59
191
|
const range = selection.getRangeAt(0);
|
|
60
192
|
range.deleteContents();
|
|
61
193
|
|
|
62
|
-
// текущий
|
|
194
|
+
// текущий блок (ближайший блочный предок внутри редактора)
|
|
63
195
|
let para: Node | null = range.startContainer;
|
|
64
196
|
while (para && para !== editable && !isBlock(para)) para = para.parentNode;
|
|
65
197
|
|
|
66
198
|
// каретка не внутри абзаца — создаём абзац сразу с видимым результатом (иначе Enter «срабатывает со 2-го раза»)
|
|
67
199
|
if (!para || para === editable) {
|
|
68
|
-
const next =
|
|
200
|
+
const next = createBlock(type);
|
|
69
201
|
if (editable.childNodes.length === 0) {
|
|
70
202
|
// пустой редактор: пустая строка-источник + новая строка с кареткой
|
|
71
203
|
editable.appendChild(emptyParagraph());
|
|
@@ -85,10 +217,13 @@ export function insertParagraph(editable: HTMLElement) {
|
|
|
85
217
|
tail.setStart(range.endContainer, range.endOffset);
|
|
86
218
|
const fragment = tail.extractContents();
|
|
87
219
|
|
|
88
|
-
const next = document.createElement(
|
|
220
|
+
const next = document.createElement(BLOCK_TYPES[type].tag);
|
|
89
221
|
next.appendChild(fragment);
|
|
90
222
|
(para as ChildNode).after(next);
|
|
91
223
|
|
|
224
|
+
// хвост уехал в блок другого типа — его правила распространяются и на содержимое
|
|
225
|
+
if (!BLOCK_TYPES[type].inline) unwrapFormatting(next);
|
|
226
|
+
|
|
92
227
|
// extractContents в конце абзаца оставляет пустой текст-узел → <p></p> без заполнителя
|
|
93
228
|
// (невидим/нефокусируем, каретка не встаёт). Чистим и ставим <br> в опустевшие абзацы.
|
|
94
229
|
fillEmptyParagraph(para as HTMLElement);
|
|
@@ -141,6 +276,9 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
|
|
|
141
276
|
}
|
|
142
277
|
|
|
143
278
|
const block = para as HTMLElement;
|
|
279
|
+
// Вставка в цитату или код остаётся в них: разорвать блок посреди вставки — не то,
|
|
280
|
+
// чего ждут, а тип целевого блока диктует и правила его содержимого.
|
|
281
|
+
const type = blockTypeOf(block) ?? DEFAULT_BLOCK;
|
|
144
282
|
|
|
145
283
|
// хвост текущего абзаца после каретки — выносим, чтобы вернуть в конец вставки
|
|
146
284
|
const tailRange = document.createRange();
|
|
@@ -148,6 +286,8 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
|
|
|
148
286
|
tailRange.setStart(range.startContainer, range.startOffset);
|
|
149
287
|
const tail = tailRange.extractContents();
|
|
150
288
|
|
|
289
|
+
if (!BLOCK_TYPES[type].inline) for (const p of paras) unwrapFormatting(p);
|
|
290
|
+
|
|
151
291
|
// первый вставляемый абзац вливается в текущий (после содержимого до каретки)
|
|
152
292
|
while (paras[0].firstChild) block.appendChild(paras[0].firstChild);
|
|
153
293
|
|
|
@@ -156,15 +296,27 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
|
|
|
156
296
|
return;
|
|
157
297
|
}
|
|
158
298
|
|
|
159
|
-
// остальные абзацы — отдельными
|
|
299
|
+
// остальные абзацы — отдельными блоками того же типа после текущего; хвост — в конец последнего
|
|
160
300
|
let anchor: ChildNode = block;
|
|
161
301
|
for (let i = 1; i < paras.length; i++) {
|
|
302
|
+
paras[i] = retagBlock(paras[i], type);
|
|
162
303
|
anchor.after(paras[i]);
|
|
163
304
|
anchor = paras[i];
|
|
164
305
|
}
|
|
165
306
|
paras[paras.length - 1].appendChild(tail);
|
|
166
307
|
}
|
|
167
308
|
|
|
309
|
+
// Тот же блок, но другим тегом. Содержимое переносится как есть — правила типа к нему
|
|
310
|
+
// применяет вызывающий, он же знает, откуда это содержимое взялось.
|
|
311
|
+
function retagBlock(block: HTMLElement, type: BlockType): HTMLElement {
|
|
312
|
+
if (blockTypeOf(block) === type) return block;
|
|
313
|
+
|
|
314
|
+
const replacement = document.createElement(BLOCK_TYPES[type].tag);
|
|
315
|
+
while (block.firstChild) replacement.appendChild(block.firstChild);
|
|
316
|
+
|
|
317
|
+
return replacement;
|
|
318
|
+
}
|
|
319
|
+
|
|
168
320
|
/** Обрезает пробелы по краям абзаца (после схлопывания) — у крайних текстовых узлов. */
|
|
169
321
|
function trimParagraphEdges(p: HTMLElement) {
|
|
170
322
|
const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
|
|
@@ -215,7 +367,12 @@ export function buildParagraphs(lines: string[], blocks: boolean): HTMLElement[]
|
|
|
215
367
|
* Разбирает HTML из буфера обмена в абзацы `<p>`, оставляя только разрешённые инструменты.
|
|
216
368
|
* Пустой результат — вставлять нечего (вызывающий откатится на простой текст).
|
|
217
369
|
*/
|
|
218
|
-
export function sanitizePastedHtml(
|
|
370
|
+
export function sanitizePastedHtml(
|
|
371
|
+
html: string,
|
|
372
|
+
tools: FormatTool[],
|
|
373
|
+
markers: FormatMarkers,
|
|
374
|
+
types: BlockType[] = [DEFAULT_BLOCK]
|
|
375
|
+
): HTMLElement[] {
|
|
219
376
|
// убираем мусорные элементы (Word/браузер: стили, скрипты, заголовок документа)
|
|
220
377
|
const source = document.createElement("template");
|
|
221
378
|
source.innerHTML = html;
|
|
@@ -223,7 +380,7 @@ export function sanitizePastedHtml(html: string, tools: FormatTool[], markers: F
|
|
|
223
380
|
|
|
224
381
|
// единый источник санитизации — deserialize (теги-синонимы → канонические, лишнее развёрнуто)
|
|
225
382
|
const holder = document.createElement("template");
|
|
226
|
-
holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true);
|
|
383
|
+
holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true, types);
|
|
227
384
|
|
|
228
385
|
// внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
|
|
229
386
|
// иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
|
package/source/emoji.ts
CHANGED
|
@@ -1,53 +1,129 @@
|
|
|
1
|
-
// Набор смайликов для
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Набор смайликов для панели вставки. Все символы — одиночные кодпойнты (без ZWJ-последовательностей
|
|
2
|
+
// и модификаторов), поэтому переносятся в текст как единое целое. Внимание: в UTF-16 каждый занимает
|
|
3
|
+
// две единицы, и getLength() (а значит и maxlength у хоста) считает такой символ за два.
|
|
4
|
+
//
|
|
5
|
+
// Разложены по группам: панель рисует их в том же порядке и отделяет линией, а заодно группа —
|
|
6
|
+
// это кусок, к которому применяется пропуск отрисовки (см. .emoji-group в richeditor.less).
|
|
5
7
|
|
|
6
|
-
/**
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
8
|
+
/** Группа смайликов в панели вставки. */
|
|
9
|
+
export interface EmojiGroup {
|
|
10
|
+
/** Название группы; в панели не показывается, уходит в подпись для скринридера. */
|
|
11
|
+
title: string;
|
|
12
|
+
emojis: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Смайлики по группам (порядок сохраняется в UI). */
|
|
16
|
+
export const EMOJI_GROUPS: EmojiGroup[] = [
|
|
17
|
+
{
|
|
18
|
+
title: "Смайлы и жесты",
|
|
19
|
+
// prettier-ignore
|
|
20
|
+
emojis: [
|
|
21
|
+
"😀", "😁", "😂", "😃", "😄", "😅", "😆", "😉", "😊", "😋", "😌", "😍", "😏", "😒", "😓", "😔",
|
|
22
|
+
"😖", "😘", "😚", "😜", "😝", "😞", "😠", "😡", "😢", "😣", "😤", "😥", "😨", "😩", "😪", "😫",
|
|
23
|
+
"😭", "😰", "😱", "😲", "😳", "😵", "😷", "😸", "😹", "😺", "😻", "😼", "😽", "😾", "😿", "🙀",
|
|
24
|
+
"🙅", "🙆", "🙇", "🙈", "🙉", "🙊", "🙋", "🙌", "🙍", "🙎", "🙏", "😇", "😈", "😎", "😐", "😑",
|
|
25
|
+
"😕", "😗", "😙", "😛", "😟", "😦", "😧", "😬", "😮", "😯", "😴", "😶",
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
title: "Люди",
|
|
30
|
+
// prettier-ignore
|
|
31
|
+
emojis: [
|
|
32
|
+
"👀", "👂", "👃", "👄", "👅", "👆", "👇", "👈", "👉", "👊", "👋", "👌", "👍", "👎", "👏", "👐",
|
|
33
|
+
"👑", "👒", "👓", "👔", "👕", "👖", "👗", "👘", "👙", "👚", "👛", "👜", "👝", "👞", "👟", "👠",
|
|
34
|
+
"👡", "👢", "👣", "👤", "👦", "👧", "👨", "👩", "👪", "👫", "👮", "👯", "👰", "👱", "👲", "👳",
|
|
35
|
+
"👴", "👵", "👶", "👷", "👸", "👹", "👺", "👻", "👼", "👽", "👾", "👿", "💀", "💁", "💂", "💃",
|
|
36
|
+
"💄", "💅", "💆", "💇", "💏", "💑", "👥", "👬", "👭",
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
title: "Животные",
|
|
41
|
+
// prettier-ignore
|
|
42
|
+
emojis: [
|
|
43
|
+
"🐌", "🐍", "🐎", "🐑", "🐒", "🐔", "🐗", "🐘", "🐙", "🐚", "🐛", "🐜", "🐝", "🐞", "🐟", "🐠",
|
|
44
|
+
"🐡", "🐢", "🐣", "🐤", "🐥", "🐦", "🐧", "🐨", "🐩", "🐫", "🐬", "🐭", "🐮", "🐯", "🐰", "🐱",
|
|
45
|
+
"🐲", "🐳", "🐴", "🐵", "🐶", "🐷", "🐸", "🐹", "🐺", "🐻", "🐼", "🐽", "🐾", "🐀", "🐁", "🐂",
|
|
46
|
+
"🐃", "🐄", "🐅", "🐆", "🐇", "🐈", "🐉", "🐊", "🐋", "🐏", "🐐", "🐓", "🐕", "🐖", "🐪",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
title: "Природа",
|
|
51
|
+
// prettier-ignore
|
|
52
|
+
emojis: [
|
|
53
|
+
"🌀", "🌁", "🌂", "🌃", "🌄", "🌅", "🌆", "🌇", "🌈", "🌉", "🌊", "🌋", "🌌", "🌏", "🌑", "🌓",
|
|
54
|
+
"🌔", "🌕", "🌙", "🌛", "🌟", "🌠", "🌰", "🌱", "🌴", "🌵", "🌷", "🌸", "🌹", "🌺", "🌻", "🌼",
|
|
55
|
+
"🌽", "🌾", "🌿", "🍀", "🍁", "🍂", "🍃", "🌍", "🌎", "🌐", "🌒", "🌖", "🌗", "🌘", "🌚", "🌜",
|
|
56
|
+
"🌝", "🌞", "🌲", "🌳",
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
title: "Еда",
|
|
61
|
+
// prettier-ignore
|
|
62
|
+
emojis: [
|
|
63
|
+
"🍄", "🍅", "🍆", "🍇", "🍈", "🍉", "🍊", "🍌", "🍍", "🍎", "🍏", "🍑", "🍒", "🍓", "🍔", "🍕",
|
|
64
|
+
"🍖", "🍗", "🍘", "🍙", "🍚", "🍛", "🍜", "🍝", "🍞", "🍟", "🍠", "🍡", "🍢", "🍣", "🍤", "🍥",
|
|
65
|
+
"🍦", "🍧", "🍨", "🍩", "🍪", "🍫", "🍬", "🍭", "🍮", "🍯", "🍰", "🍱", "🍲", "🍳", "🍴", "🍵",
|
|
66
|
+
"🍶", "🍷", "🍸", "🍹", "🍺", "🍻", "🍋", "🍐", "🍼",
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
title: "Праздники и спорт",
|
|
71
|
+
// prettier-ignore
|
|
72
|
+
emojis: [
|
|
73
|
+
"🎀", "🎁", "🎂", "🎃", "🎄", "🎅", "🎆", "🎇", "🎈", "🎉", "🎊", "🎋", "🎌", "🎍", "🎎", "🎏",
|
|
74
|
+
"🎐", "🎑", "🎒", "🎓", "🎠", "🎡", "🎢", "🎣", "🎤", "🎥", "🎦", "🎧", "🎨", "🎩", "🎪", "🎫",
|
|
75
|
+
"🎬", "🎭", "🎮", "🎯", "🎰", "🎱", "🎲", "🎳", "🎴", "🎵", "🎶", "🎷", "🎸", "🎹", "🎺", "🎻",
|
|
76
|
+
"🎼", "🎽", "🎾", "🎿", "🏀", "🏁", "🏂", "🏃", "🏄", "🏆", "🏈", "🏊", "🏇", "🏉",
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
title: "Места",
|
|
81
|
+
// prettier-ignore
|
|
82
|
+
emojis: [
|
|
83
|
+
"🏠", "🏡", "🏢", "🏣", "🏥", "🏦", "🏧", "🏨", "🏩", "🏪", "🏫", "🏬", "🏭", "🏮", "🏯", "🏰",
|
|
84
|
+
"🗻", "🗼", "🗽", "🗾", "🗿", "🏤",
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
title: "Транспорт",
|
|
89
|
+
// prettier-ignore
|
|
90
|
+
emojis: [
|
|
91
|
+
"🚀", "🚃", "🚄", "🚅", "🚇", "🚉", "🚌", "🚏", "🚑", "🚒", "🚓", "🚕", "🚗", "🚙", "🚚", "🚢",
|
|
92
|
+
"🚤", "🚥", "🚧", "🚨", "🚩", "🚪", "🚫", "🚬", "🚭", "🚲", "🚶", "🚹", "🚺", "🚻", "🚼", "🚽",
|
|
93
|
+
"🚾", "🛀", "🚁", "🚂", "🚆", "🚈", "🚊", "🚍", "🚎", "🚐", "🚔", "🚖", "🚘", "🚛", "🚜", "🚝",
|
|
94
|
+
"🚞", "🚟", "🚠", "🚡", "🚣", "🚦", "🚮", "🚯", "🚰", "🚱", "🚳", "🚴", "🚵", "🚷", "🚸", "🚿",
|
|
95
|
+
"🛁", "🛂", "🛃", "🛄", "🛅",
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
title: "Предметы",
|
|
100
|
+
// prettier-ignore
|
|
101
|
+
emojis: [
|
|
102
|
+
"💈", "💉", "💊", "💋", "💌", "💍", "💎", "💐", "💠", "💡", "💰", "💱", "💲", "💳", "💴", "💵",
|
|
103
|
+
"💸", "💹", "💺", "💻", "💼", "💽", "💾", "💿", "📀", "📁", "📂", "📃", "📄", "📅", "📆", "📇",
|
|
104
|
+
"📈", "📉", "📊", "📋", "📌", "📍", "📎", "📏", "📐", "📑", "📒", "📓", "📔", "📕", "📖", "📗",
|
|
105
|
+
"📘", "📙", "📚", "📛", "📜", "📝", "📞", "📟", "📠", "📡", "📢", "📣", "📤", "📥", "📦", "📧",
|
|
106
|
+
"📨", "📩", "📪", "📫", "📮", "📰", "📱", "📲", "📳", "📴", "📶", "📷", "📹", "📺", "📻", "📼",
|
|
107
|
+
"💶", "💷", "📬", "📭", "📯", "📵",
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
title: "Символы",
|
|
112
|
+
// prettier-ignore
|
|
113
|
+
emojis: [
|
|
114
|
+
"🅰", "🅱", "🅾", "🅿", "🆎", "🆑", "🆒", "🆓", "🆔", "🆕", "🆖", "🆗", "🆘", "🆙", "🆚", "🈁",
|
|
115
|
+
"🈂", "🈚", "🈯", "🈲", "🈳", "🈴", "🈵", "🈶", "🈷", "🈸", "🈹", "🈺", "🉐", "🉑", "🀄", "🃏",
|
|
116
|
+
"💒", "💓", "💔", "💕", "💖", "💗", "💘", "💙", "💚", "💛", "💜", "💝", "💞", "💟", "💢", "💣",
|
|
117
|
+
"💤", "💥", "💦", "💧", "💨", "💩", "💪", "💫", "💬", "💮", "💯", "🔃", "🔊", "🔋", "🔌", "🔍",
|
|
118
|
+
"🔎", "🔏", "🔐", "🔑", "🔒", "🔓", "🔔", "🔖", "🔗", "🔘", "🔙", "🔚", "🔛", "🔜", "🔝", "🔞",
|
|
119
|
+
"🔟", "🔠", "🔡", "🔢", "🔣", "🔤", "🔥", "🔦", "🔧", "🔨", "🔩", "🔪", "🔫", "🔮", "🔯", "🔰",
|
|
120
|
+
"🔱", "🔲", "🔳", "🔴", "🔵", "🔶", "🔷", "🔸", "🔹", "🔺", "🔻", "🔼", "🔽", "🕐", "🕑", "🕒",
|
|
121
|
+
"🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛", "💭", "🔀", "🔁", "🔂", "🔄", "🔅", "🔆",
|
|
122
|
+
"🔇", "🔉", "🔕", "🔬", "🔭", "🕜", "🕝", "🕞", "🕟", "🕠", "🕡", "🕢", "🕣", "🕤", "🕥", "🕦",
|
|
123
|
+
"🕧",
|
|
124
|
+
],
|
|
125
|
+
},
|
|
53
126
|
];
|
|
127
|
+
|
|
128
|
+
/** Все смайлики подряд, в порядке групп. */
|
|
129
|
+
export const EMOJIS: string[] = EMOJI_GROUPS.flatMap((group) => group.emojis);
|