@brandup/ui-richeditor 1.0.44 → 1.0.45

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/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.44",
30
+ "version": "1.0.45",
31
31
  "main": "source/index.ts",
32
32
  "types": "source/index.ts",
33
33
  "dependencies": {
34
- "@brandup/ui": "^2.0.7",
35
- "@brandup/ui-kit": "^1.0.44"
34
+ "@brandup/ui": "^2.0.9",
35
+ "@brandup/ui-kit": "^1.0.45"
36
36
  },
37
37
  "files": [
38
38
  "source",
package/source/editing.ts CHANGED
@@ -184,6 +184,35 @@ export function atBlockStart(editable: HTMLElement, range: Range): boolean {
184
184
  return before.toString().length === 0;
185
185
  }
186
186
 
187
+ /**
188
+ * Block the caret sits in, or null when it stands at the editor level — an empty editor, or text
189
+ * that has not been wrapped into a paragraph yet.
190
+ */
191
+ function blockOf(editable: HTMLElement, node: Node): HTMLElement | null {
192
+ let current: Node | null = node;
193
+ while (current && current !== editable && !isBlock(current)) current = current.parentNode;
194
+
195
+ return current && current !== editable ? (current as HTMLElement) : null;
196
+ }
197
+
198
+ /**
199
+ * Where to insert at the editor level: the node the new content goes before (null — at the end).
200
+ *
201
+ * A position addresses a child only when the container is the editor itself. A caret inside stray
202
+ * top-level text sits in a text node, and there the offset counts characters rather than children:
203
+ * taken as a child index it would send the insertion to an arbitrary place. From such a node we
204
+ * measure the node itself and insert after it, which is what a caret inside it asks for.
205
+ */
206
+ function topLevelRef(editable: HTMLElement, range: Range): ChildNode | null {
207
+ const container = range.startContainer;
208
+ if (container === editable) return editable.childNodes[range.startOffset] ?? null;
209
+
210
+ let node: Node = container;
211
+ while (node.parentNode && node.parentNode !== editable) node = node.parentNode;
212
+
213
+ return node.parentNode === editable ? (node as ChildNode).nextSibling : null;
214
+ }
215
+
187
216
  /** Enter в multiline: разбить текущий блок по каретке; хвост становится блоком типа `type`. */
188
217
  export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT_BLOCK) {
189
218
  const selection = innerSelection(editable);
@@ -193,11 +222,10 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
193
222
  range.deleteContents();
194
223
 
195
224
  // текущий блок (ближайший блочный предок внутри редактора)
196
- let para: Node | null = range.startContainer;
197
- while (para && para !== editable && !isBlock(para)) para = para.parentNode;
225
+ const para = blockOf(editable, range.startContainer);
198
226
 
199
227
  // каретка не внутри абзаца — создаём абзац сразу с видимым результатом (иначе Enter «срабатывает со 2-го раза»)
200
- if (!para || para === editable) {
228
+ if (!para) {
201
229
  const next = createBlock(type);
202
230
  if (editable.childNodes.length === 0) {
203
231
  // пустой редактор: пустая строка-источник + новая строка с кареткой
@@ -205,8 +233,7 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
205
233
  editable.appendChild(next);
206
234
  } else {
207
235
  // каретка на уровне редактора между/после абзацев — вставляем новый абзац в эту позицию
208
- const ref = editable.childNodes[range.startOffset] ?? null;
209
- editable.insertBefore(next, ref);
236
+ editable.insertBefore(next, topLevelRef(editable, range));
210
237
  }
211
238
  caretToStart(next);
212
239
  return;
@@ -220,11 +247,11 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
220
247
 
221
248
  // Выходим из блока, а за ним уже стоит пустой абзац — переходим в него. Иначе одно нажатие
222
249
  // давало бы две пустые строки: одна тут заводится, вторая уже была заведена под каретку.
223
- const following = (para as HTMLElement).nextElementSibling;
250
+ const following = para.nextElementSibling;
224
251
  const empty = !(fragment.textContent ?? "") && !fragment.querySelector("br");
225
252
 
226
253
  if (empty && following && blockTypeOf(following) === type && !(following.textContent ?? "")) {
227
- fillEmptyParagraph(para as HTMLElement);
254
+ fillEmptyParagraph(para);
228
255
  caretToStart(following);
229
256
 
230
257
  return;
@@ -232,14 +259,14 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
232
259
 
233
260
  const next = document.createElement(BLOCK_TYPES[type].tag);
234
261
  next.appendChild(fragment);
235
- (para as ChildNode).after(next);
262
+ para.after(next);
236
263
 
237
264
  // хвост уехал в блок другого типа — его правила распространяются и на содержимое
238
265
  if (!BLOCK_TYPES[type].inline) unwrapFormatting(next);
239
266
 
240
267
  // extractContents в конце абзаца оставляет пустой текст-узел → <p></p> без заполнителя
241
268
  // (невидим/нефокусируем, каретка не встаёт). Чистим и ставим <br> в опустевшие абзацы.
242
- fillEmptyParagraph(para as HTMLElement);
269
+ fillEmptyParagraph(para);
243
270
  fillEmptyParagraph(next);
244
271
 
245
272
  caretToStart(next);
@@ -306,17 +333,15 @@ export function insertSoftBreak(editable: HTMLElement) {
306
333
 
307
334
  /** Вставляет санитизированные абзацы <p> в позицию каретки, разбивая текущий абзац. */
308
335
  export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement[], range: Range) {
309
- let para: Node | null = range.startContainer;
310
- while (para && para !== editable && !isBlock(para)) para = para.parentNode;
336
+ const block = blockOf(editable, range.startContainer);
311
337
 
312
338
  // каретка не внутри абзаца (пустой редактор / уровень редактора) — вставляем абзацы как есть
313
- if (!para || para === editable) {
314
- const ref = editable.childNodes[range.startOffset] ?? null;
339
+ if (!block) {
340
+ const ref = topLevelRef(editable, range);
315
341
  for (const p of paras) editable.insertBefore(p, ref);
316
342
  return;
317
343
  }
318
344
 
319
- const block = para as HTMLElement;
320
345
  // Вставка в цитату или код остаётся в них: разорвать блок посреди вставки — не то,
321
346
  // чего ждут, а тип целевого блока диктует и правила его содержимого.
322
347
  const type = blockTypeOf(block) ?? DEFAULT_BLOCK;
package/source/history.ts CHANGED
@@ -68,12 +68,19 @@ export class EditorHistory {
68
68
  const top = this.__undo[this.__undo.length - 1];
69
69
  if (top && top.html === snap.html) return; // состояние не изменилось — не дублируем
70
70
 
71
+ this.__push(snap);
72
+ this.__redo = [];
73
+ }
74
+
75
+ // Puts a snapshot on the undo stack, dropping the oldest ones beyond the limits. Every push
76
+ // goes through here: redo adds a snapshot just like an edit does, and without trimming the
77
+ // history would outgrow the limits over a run of undo/redo.
78
+ private __push(snap: Snapshot): void {
71
79
  this.__undo.push(snap);
72
80
  this.__chars += snap.html.length;
81
+
73
82
  while (this.__undo.length > MAX_DEPTH || (this.__chars > MAX_CHARS && this.__undo.length > 1))
74
83
  this.__chars -= this.__undo.shift()!.html.length;
75
-
76
- this.__redo = [];
77
84
  }
78
85
 
79
86
  /** Откатить на шаг назад. Возвращает false, если откатывать нечего. */
@@ -93,9 +100,7 @@ export class EditorHistory {
93
100
  const next = this.__redo.pop();
94
101
  if (!next) return false;
95
102
 
96
- const current = this.__snapshot();
97
- this.__undo.push(current);
98
- this.__chars += current.html.length;
103
+ this.__push(this.__snapshot());
99
104
  this.__restore(next);
100
105
  this.__lastKind = null;
101
106
  return true;
@@ -122,8 +122,9 @@ export interface RichEditorOptions {
122
122
  /** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
123
123
  paragraph?: ParagraphMode;
124
124
  /**
125
- * Типы блоков в многострочном режиме: цитата, блок кода (по умолчанию только обычный текст).
126
- * Обычный текст в наборе есть всегда в него блок возвращают.
125
+ * Block types of the multiline mode: quote, code block (all of them by default). A field that
126
+ * has no use for them is limited by an empty list. Plain text is always in the set — a block
127
+ * is turned back into it.
127
128
  */
128
129
  blocks?: BlockType[];
129
130
  /**
@@ -1131,15 +1132,19 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1131
1132
  // перемещение каретки — выход из режима набора
1132
1133
  if (NAV_KEYS.includes(e.key)) this.__clearPendingFormats();
1133
1134
 
1134
- const isChar = e.key.length === 1;
1135
+ // A character, not a shortcut: Cmd is the same modifier as Ctrl, just on another platform.
1136
+ // Without it Cmd+C on a Mac would look like typing the letter "c" — copying would be
1137
+ // swallowed by the readonly guard, and the host filter would reject copy, paste and
1138
+ // select-all alike.
1139
+ const isChar = e.key.length === 1 && !e.ctrlKey && !e.metaKey;
1135
1140
 
1136
- if (this.readonly && isChar && !e.ctrlKey) {
1141
+ if (this.readonly && isChar) {
1137
1142
  e.preventDefault();
1138
1143
  e.stopPropagation();
1139
1144
  return;
1140
1145
  }
1141
1146
 
1142
- if (isChar && !e.ctrlKey && this.__opts.filterChar && !this.__opts.filterChar(e.key)) {
1147
+ if (isChar && this.__opts.filterChar && !this.__opts.filterChar(e.key)) {
1143
1148
  e.preventDefault();
1144
1149
  e.stopPropagation();
1145
1150
  this.__reject();
@@ -1275,12 +1280,13 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1275
1280
  ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
1276
1281
  } else {
1277
1282
  // инлайн: абзацы и переносы → пробелы, форматирование сохраняем
1278
- const fragment = document.createDocumentFragment();
1283
+ const doc = this.editable.ownerDocument;
1284
+ const fragment = doc.createDocumentFragment();
1279
1285
  paras.forEach((p, index) => {
1280
- if (index > 0) fragment.appendChild(document.createTextNode(" "));
1286
+ if (index > 0) fragment.appendChild(doc.createTextNode(" "));
1281
1287
  while (p.firstChild) fragment.appendChild(p.firstChild);
1282
1288
  });
1283
- fragment.querySelectorAll("br").forEach((br) => br.replaceWith(document.createTextNode(" ")));
1289
+ fragment.querySelectorAll("br").forEach((br) => br.replaceWith(doc.createTextNode(" ")));
1284
1290
 
1285
1291
  caret = start + (fragment.textContent ?? "").length;
1286
1292
  range.insertNode(fragment);
package/source/toolbar.ts CHANGED
@@ -237,7 +237,12 @@ class FormatToolbar {
237
237
  const blocks = (host.blockTools ?? []).filter(
238
238
  (type) => type !== DEFAULT_BLOCK && !HIDDEN_BLOCKS.includes(type)
239
239
  );
240
- if (!tools.length && !blocks.length && !actions.length && !buttons.length) return;
240
+ // Nothing to show and the previous editor's panel must not stay on screen either: focus
241
+ // moved to this one, while its buttons would still edit the neighbour.
242
+ if (!tools.length && !blocks.length && !actions.length && !buttons.length) {
243
+ if (this.__active) this.__hide();
244
+ return;
245
+ }
241
246
 
242
247
  this.__bindSelection();
243
248
  this.__active = host;