@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/source/index.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  export { default } from "./richeditor";
2
2
  export * from "./richeditor";
3
- export { EMOJIS, EMOJI_GROUPS, EMOJI_PICKER_CLASS, createEmojiPicker, type EmojiGroup } from "./emoji";
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,
@@ -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, merge = false) {
158
- if (merge) mergeAdjacentBlocks(root);
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.firstChild) return false;
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
- if (!p.firstChild) {
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;
@@ -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
- --richeditor-quote-line: rgba(0, 0, 0, 0.2);
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
- padding: 5px 0 5px 10px;
87
- border-left: 3px solid var(--richeditor-quote-line);
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;