@brandup/ui-richeditor 1.0.49 → 1.0.51
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 +33 -13
- package/package.json +2 -2
- package/source/editing.ts +219 -19
- package/source/emoji.ts +99 -2
- package/source/format.ts +1 -0
- package/source/index.ts +13 -1
- package/source/paragraphs.ts +70 -10
- package/source/richeditor.less +51 -6
- package/source/richeditor.ts +323 -53
- package/source/serialize.ts +30 -17
- package/source/toolbar.ts +28 -8
package/source/index.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
export { default } from "./richeditor";
|
|
2
2
|
export * from "./richeditor";
|
|
3
|
-
export {
|
|
3
|
+
export {
|
|
4
|
+
EMOJIS,
|
|
5
|
+
EMOJI_GROUPS,
|
|
6
|
+
EMOJI_PICKER_CLASS,
|
|
7
|
+
RECENT_EMOJIS_KEY,
|
|
8
|
+
RECENT_EMOJIS_LIMIT,
|
|
9
|
+
RECENT_GROUP_CLASS,
|
|
10
|
+
createEmojiPicker,
|
|
11
|
+
recentEmojis,
|
|
12
|
+
rememberEmoji,
|
|
13
|
+
refreshRecentEmojis,
|
|
14
|
+
type EmojiGroup,
|
|
15
|
+
} from "./emoji";
|
|
4
16
|
export {
|
|
5
17
|
ALL_BLOCK_TYPES,
|
|
6
18
|
ALL_EDITOR_ACTIONS,
|
package/source/paragraphs.ts
CHANGED
|
@@ -153,9 +153,25 @@ export function normalizeWhitespace(root: HTMLElement) {
|
|
|
153
153
|
/**
|
|
154
154
|
* Нормализует абзацы многострочного режима: удаляет пустые абзацы (без текстового содержимого).
|
|
155
155
|
* Если содержимого нет вовсе — редактор остаётся пустым (показывается placeholder).
|
|
156
|
+
*
|
|
157
|
+
* В режиме мягких переносов (`softBreaks`) абзац — это строка: пустой абзац там осмыслен сам по
|
|
158
|
+
* себе (пустая строка сообщения) и не удаляется, а мягкий перенос внутри абзаца приводится
|
|
159
|
+
* к той же модели — делит его на строки-абзацы.
|
|
156
160
|
*/
|
|
157
|
-
export function normalizeParagraphs(root: HTMLElement,
|
|
158
|
-
if (
|
|
161
|
+
export function normalizeParagraphs(root: HTMLElement, softBreaks = false) {
|
|
162
|
+
if (softBreaks) {
|
|
163
|
+
mergeAdjacentBlocks(root);
|
|
164
|
+
splitSoftBreaks(root);
|
|
165
|
+
|
|
166
|
+
// Пустая строка здесь осмысленна сама по себе, поэтому пустые абзацы не удаляются —
|
|
167
|
+
// кроме случая, когда всё поле из них и состоит: в значение они не идут (хвост
|
|
168
|
+
// обрезается), а пока они в поле, оно не пустое и не покажет заглушку.
|
|
169
|
+
const lines = Array.from(root.children);
|
|
170
|
+
const blank = (el: Element) => blockTypeOf(el) === DEFAULT_BLOCK && !(el.textContent ?? "").trim();
|
|
171
|
+
if (lines.length && lines.every(blank)) for (const el of lines) el.remove();
|
|
172
|
+
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
159
175
|
|
|
160
176
|
for (const el of Array.from(root.children)) {
|
|
161
177
|
// Пустой блок другого типа не трогаем: его завели осознанно и в него сейчас будут писать,
|
|
@@ -178,18 +194,18 @@ export function normalizeParagraphs(root: HTMLElement, merge = false) {
|
|
|
178
194
|
}
|
|
179
195
|
|
|
180
196
|
/**
|
|
181
|
-
* Склеивает соседние блоки одного типа — в режиме мягких переносов, где граница между
|
|
182
|
-
* в значение не
|
|
183
|
-
*
|
|
184
|
-
* значении она была бы. Два блока в поле показывали бы то, чего в сообщении не будет.
|
|
197
|
+
* Склеивает соседние блоки одного типа — в режиме мягких переносов, где граница между ними
|
|
198
|
+
* в значение не попадает: подряд идущие строки с маркером цитаты разбор собирает в одну цитату,
|
|
199
|
+
* и два блока в поле показывали бы то, чего в сообщении не будет.
|
|
185
200
|
*
|
|
186
|
-
*
|
|
201
|
+
* Обычные абзацы не трогаем: там граница блоков — это перевод строки, и она в значение как раз
|
|
202
|
+
* попадает. Блоки с ограждением (код) — тоже: у них есть свои границы, и два подряд разбираются
|
|
187
203
|
* ровно как два.
|
|
188
204
|
*/
|
|
189
205
|
export function mergeAdjacentBlocks(root: HTMLElement) {
|
|
190
206
|
for (const el of Array.from(root.children) as HTMLElement[]) {
|
|
191
207
|
const type = blockTypeOf(el);
|
|
192
|
-
if (!type || BLOCK_TYPES[type].fence) continue;
|
|
208
|
+
if (!type || type === DEFAULT_BLOCK || BLOCK_TYPES[type].fence) continue;
|
|
193
209
|
|
|
194
210
|
const previous = el.previousElementSibling;
|
|
195
211
|
if (!previous || blockTypeOf(previous) !== type) continue;
|
|
@@ -204,6 +220,46 @@ export function mergeAdjacentBlocks(root: HTMLElement) {
|
|
|
204
220
|
}
|
|
205
221
|
}
|
|
206
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Делит обычные абзацы по мягким переносам — в режиме, где абзац это строка. Собственный перенос
|
|
225
|
+
* приходит извне (вставка документа, чужое значение), и без деления одна строка модели была бы
|
|
226
|
+
* то `<br>`, то границей абзацев.
|
|
227
|
+
*
|
|
228
|
+
* Хвостовой перенос строкой не считается: это заполнитель, которым браузер показывает последнюю
|
|
229
|
+
* пустую строку (см. ensureParagraphs), — от него делить нечего.
|
|
230
|
+
*/
|
|
231
|
+
export function splitSoftBreaks(root: HTMLElement) {
|
|
232
|
+
for (const el of Array.from(root.children) as HTMLElement[]) {
|
|
233
|
+
if (blockTypeOf(el) !== DEFAULT_BLOCK) continue;
|
|
234
|
+
|
|
235
|
+
const parts: ChildNode[][] = [[]];
|
|
236
|
+
for (const node of Array.from(el.childNodes)) {
|
|
237
|
+
if (node.nodeName === "BR") parts.push([]);
|
|
238
|
+
else parts[parts.length - 1].push(node);
|
|
239
|
+
}
|
|
240
|
+
if (parts.length === 1) continue;
|
|
241
|
+
|
|
242
|
+
// Перенос, спрятанный внутри инлайнового тега (мягкий перенос его не разрезает), делит
|
|
243
|
+
// строки наравне с верхним, но по границам тега — с ним и хвостовой перенос абзаца уже
|
|
244
|
+
// не обязательно заполнитель. Такой абзац оставляем как есть: делить его по половине
|
|
245
|
+
// переносов значило бы терять строки.
|
|
246
|
+
if (el.querySelectorAll("br").length !== parts.length - 1) continue;
|
|
247
|
+
|
|
248
|
+
const empty = (nodes: ChildNode[]) => !nodes.some((node) => node.textContent);
|
|
249
|
+
if (empty(parts[parts.length - 1])) parts.pop();
|
|
250
|
+
|
|
251
|
+
el.replaceWith(
|
|
252
|
+
...parts.map((nodes) => {
|
|
253
|
+
const p = document.createElement(BLOCK_TYPES[DEFAULT_BLOCK].tag);
|
|
254
|
+
for (const node of nodes) p.appendChild(node);
|
|
255
|
+
if (empty(nodes)) p.appendChild(document.createElement("br")); // заполнитель пустой строки
|
|
256
|
+
|
|
257
|
+
return p;
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
207
263
|
/**
|
|
208
264
|
* Есть ли что нормализовать {@link ensureParagraphs}: блуждающий текст/инлайн, чужой `<div>`,
|
|
209
265
|
* пустой абзац без заполнителя или лишний хвостовой перенос.
|
|
@@ -217,7 +273,7 @@ export function paragraphsNormalized(root: HTMLElement): boolean {
|
|
|
217
273
|
for (const node of root.childNodes) {
|
|
218
274
|
const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
|
|
219
275
|
if (!el || !isBlock(el) || el.tagName === "DIV") return false;
|
|
220
|
-
if (!el.
|
|
276
|
+
if (!(el.textContent ?? "") && !el.querySelector("br")) return false;
|
|
221
277
|
|
|
222
278
|
const tail = el.lastChild!;
|
|
223
279
|
if (tail.nodeName === "BR" && tail.previousSibling?.nodeName !== "BR" && (el.textContent ?? "").length > 0)
|
|
@@ -266,7 +322,11 @@ export function ensureParagraphs(root: HTMLElement): boolean {
|
|
|
266
322
|
flushRun(null);
|
|
267
323
|
|
|
268
324
|
for (const p of Array.from(root.children) as HTMLElement[]) {
|
|
269
|
-
|
|
325
|
+
// Пустой абзац опознаём по содержимому, а не по наличию узлов: из буфера обмена приходят
|
|
326
|
+
// абзацы из одних пробелов, и после обрезки в них остаются пустые текстовые узлы. Без
|
|
327
|
+
// заполнителя такой абзац не занимает строки, и в режиме мягких переносов склейка теряет
|
|
328
|
+
// его вместе с пустой строкой между абзацами (см. mergeAdjacentBlocks).
|
|
329
|
+
if (!(p.textContent ?? "") && !p.querySelector("br")) {
|
|
270
330
|
p.appendChild(document.createElement("br")); // пустой абзац — заполнитель для видимости строки
|
|
271
331
|
changed = true;
|
|
272
332
|
continue;
|
package/source/richeditor.less
CHANGED
|
@@ -13,8 +13,14 @@
|
|
|
13
13
|
@richeditor-border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
|
|
14
14
|
|
|
15
15
|
:root {
|
|
16
|
-
//
|
|
17
|
-
|
|
16
|
+
// Цитата: линия слева и подложка, как её рисуют мессенджеры. Вертикальный отступ — тот же,
|
|
17
|
+
// что у абзаца: строка не должна прыгать при смене типа блока. Горизонтальный задаётся
|
|
18
|
+
// отдельно — от него зависит, насколько текст цитаты отходит от линии.
|
|
19
|
+
--richeditor-quote-line: #1872bc;
|
|
20
|
+
--richeditor-quote-line-width: 3px;
|
|
21
|
+
--richeditor-quote-fill: rgba(0, 0, 0, 0.04);
|
|
22
|
+
--richeditor-quote-padding-tb: 5px;
|
|
23
|
+
--richeditor-quote-padding-lr: 10px;
|
|
18
24
|
|
|
19
25
|
// код — и моноширинный, и блок
|
|
20
26
|
--richeditor-code-fill: rgba(0, 0, 0, 0.06);
|
|
@@ -46,6 +52,8 @@
|
|
|
46
52
|
// с ним не связана, а вычитание отступов задавало бы размер дважды и могло не сойтись.
|
|
47
53
|
--richeditor-toolbar-padding: 3px;
|
|
48
54
|
--richeditor-toolbar-button-size: 34px;
|
|
55
|
+
// зазор панели от краёв экрана; то же значение — в EDGE_GAP (toolbar.ts)
|
|
56
|
+
--richeditor-toolbar-edge-gap: 4px;
|
|
49
57
|
|
|
50
58
|
// панель смайликов
|
|
51
59
|
--richeditor-emoji-size: 32px;
|
|
@@ -79,12 +87,31 @@
|
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
90
|
+
// Режим мягких переносов: абзац здесь — строка, а не абзац. Отступ между ними читался бы
|
|
91
|
+
// пустой строкой, которой в сообщении нет.
|
|
92
|
+
&.breaks p {
|
|
93
|
+
padding-top: 0;
|
|
94
|
+
padding-bottom: 0;
|
|
95
|
+
|
|
96
|
+
&:last-child {
|
|
97
|
+
padding-bottom: var(--richeditor-underline-room);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Цитата: линия слева и подложка, как её рисуют мессенджеры. Значения — переменными
|
|
102
|
+
// (см. :root выше): подложке нужен и отступ справа, иначе текст упирался бы в её край.
|
|
84
103
|
& blockquote {
|
|
85
104
|
margin: 0;
|
|
86
|
-
|
|
87
|
-
|
|
105
|
+
// Ширина по содержимому, а не во всю строку: с подложкой цитата — это плашка, и пустое
|
|
106
|
+
// место справа от короткой строки читалось бы её частью. Прижата влево, потому что
|
|
107
|
+
// поля по краям не заданы; длинная цитата переносится по границе редактора, а не вылезает
|
|
108
|
+
// за неё — на это max-width.
|
|
109
|
+
width: fit-content;
|
|
110
|
+
max-width: 100%;
|
|
111
|
+
padding: var(--richeditor-quote-padding-tb) var(--richeditor-quote-padding-lr);
|
|
112
|
+
border-left: var(--richeditor-quote-line-width) solid var(--richeditor-quote-line);
|
|
113
|
+
background-color: var(--richeditor-quote-fill);
|
|
114
|
+
border-radius: 3px;
|
|
88
115
|
}
|
|
89
116
|
|
|
90
117
|
// Блок кода: подложка на всю строку и моноширинный шрифт. Собственные поля тут нужны —
|
|
@@ -204,6 +231,9 @@
|
|
|
204
231
|
display: none;
|
|
205
232
|
position: fixed;
|
|
206
233
|
z-index: 1000;
|
|
234
|
+
// Не шире экрана: полный набор кнопок на телефоне в строку не влезает, и без ограничения
|
|
235
|
+
// панель уезжала бы за правый край. Что не поместилось — прокручивается (см. .toolbar-body).
|
|
236
|
+
max-width: calc(100vw - var(--richeditor-toolbar-edge-gap) * 2);
|
|
207
237
|
|
|
208
238
|
--svg-size: 18px;
|
|
209
239
|
--svg-fill: var(--input-color, #222);
|
|
@@ -218,6 +248,7 @@
|
|
|
218
248
|
left: 0;
|
|
219
249
|
bottom: 100%;
|
|
220
250
|
margin-bottom: 6px;
|
|
251
|
+
max-width: 100%; // предел задаёт контейнер: панель не шире поля, над которым стоит
|
|
221
252
|
|
|
222
253
|
&.link-editing {
|
|
223
254
|
right: 0;
|
|
@@ -229,6 +260,17 @@
|
|
|
229
260
|
flex-flow: row nowrap;
|
|
230
261
|
align-items: center;
|
|
231
262
|
gap: 2px;
|
|
263
|
+
// Что не поместилось по ширине — прокручивается: кнопки не переносятся и не прячутся,
|
|
264
|
+
// панель остаётся в одну строку. Полоса появляется только когда набор не влез.
|
|
265
|
+
max-width: 100%;
|
|
266
|
+
overflow-x: auto;
|
|
267
|
+
// дойдя до конца, не пролистывать страницу под панелью и не вызывать жест «назад»
|
|
268
|
+
overscroll-behavior-x: contain;
|
|
269
|
+
// Полоса тоньше обычной: она внутри панели высотой в одну кнопку, и обычная
|
|
270
|
+
// заметно её раздувала бы.
|
|
271
|
+
--scrollbar-size: 3px;
|
|
272
|
+
--scrollbar-edge-inset: 2px;
|
|
273
|
+
|
|
232
274
|
padding: var(--richeditor-toolbar-padding);
|
|
233
275
|
background-color: var(--input-fill, #fff);
|
|
234
276
|
border: @richeditor-border;
|
|
@@ -240,6 +282,9 @@
|
|
|
240
282
|
|
|
241
283
|
// разделитель между инструментами форматирования и действиями
|
|
242
284
|
& .split {
|
|
285
|
+
// Сжиматься нельзя: кнопки этого не умеют (flex: 0 0 auto), и всю нехватку ширины
|
|
286
|
+
// выбирал бы один разделитель — при полном наборе на узком экране он схлопывался в ноль.
|
|
287
|
+
flex: 0 0 auto;
|
|
243
288
|
width: 1px;
|
|
244
289
|
align-self: stretch;
|
|
245
290
|
margin: 2px 3px;
|