@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 +3 -3
- package/source/editing.ts +39 -14
- package/source/history.ts +10 -5
- package/source/richeditor.ts +14 -8
- package/source/toolbar.ts +6 -1
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.45",
|
|
31
31
|
"main": "source/index.ts",
|
|
32
32
|
"types": "source/index.ts",
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@brandup/ui": "^2.0.
|
|
35
|
-
"@brandup/ui-kit": "^1.0.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
310
|
-
while (para && para !== editable && !isBlock(para)) para = para.parentNode;
|
|
336
|
+
const block = blockOf(editable, range.startContainer);
|
|
311
337
|
|
|
312
338
|
// каретка не внутри абзаца (пустой редактор / уровень редактора) — вставляем абзацы как есть
|
|
313
|
-
if (!
|
|
314
|
-
const ref = editable
|
|
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
|
-
|
|
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;
|
package/source/richeditor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1141
|
+
if (this.readonly && isChar) {
|
|
1137
1142
|
e.preventDefault();
|
|
1138
1143
|
e.stopPropagation();
|
|
1139
1144
|
return;
|
|
1140
1145
|
}
|
|
1141
1146
|
|
|
1142
|
-
if (isChar &&
|
|
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
|
|
1283
|
+
const doc = this.editable.ownerDocument;
|
|
1284
|
+
const fragment = doc.createDocumentFragment();
|
|
1279
1285
|
paras.forEach((p, index) => {
|
|
1280
|
-
if (index > 0) fragment.appendChild(
|
|
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(
|
|
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
|
-
|
|
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;
|