@brandup/ui-richeditor 1.0.48 → 1.0.49
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 +3 -1
- package/package.json +2 -2
- package/source/editing.ts +37 -13
- package/source/format.ts +1 -0
- package/source/history.ts +12 -1
- package/source/paragraphs.ts +36 -2
- package/source/richeditor.less +7 -4
- package/source/richeditor.ts +125 -28
- package/source/selection.ts +44 -11
- package/source/serialize.ts +39 -8
- package/source/toolbar.ts +20 -9
package/README.md
CHANGED
|
@@ -54,6 +54,7 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
54
54
|
| `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию все); пустой список оставляет только `paragraph` |
|
|
55
55
|
| `keepFocus` | `boolean` | Держать ли фокус в поле, пока открыта панель смайликов (по умолчанию да, а на сенсорном устройстве нет) |
|
|
56
56
|
| `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются); разметка значения при этом разбирается и показывается, кнопок для неё просто нет |
|
|
57
|
+
| `disabled` | `boolean` | Выключенное поле: всё то же, что `readonly`, плюс снятый `contenteditable` — редактор не принимает ни фокус, ни выделение. Значение при этом не нормализуется — выключенное поле его не меняет |
|
|
57
58
|
| `toolbarContainer` | `HTMLElement \| null` | Контейнер для панели; по умолчанию `document.body` (`position: fixed`). Если задан — панель монтируется в него и позиционируется над ним (`position: absolute`). Контейнер должен быть `position: relative` |
|
|
58
59
|
| `value` | `string` | Начальное значение |
|
|
59
60
|
| `filterChar` | `(char) => boolean` | Хук: `false` — отклонить вводимый символ |
|
|
@@ -88,7 +89,8 @@ editor.onChange(({ value }) => console.log(value));
|
|
|
88
89
|
| `currentBlock: BlockType` | Тип блока под кареткой |
|
|
89
90
|
| `blockTypes: BlockType[]` | Доступные типы блоков |
|
|
90
91
|
| `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
|
|
91
|
-
| `isToolEnabled(tool): boolean` | Доступен ли инструмент сейчас (внутри кода остальные выключены) |
|
|
92
|
+
| `isToolEnabled(tool, codeActive?): boolean` | Доступен ли инструмент сейчас (внутри кода остальные выключены). Второй аргумент — уже посчитанный признак «в коде»: панель считает его один раз на обновление и передаёт сюда, чтобы не обходить выделение на каждую кнопку |
|
|
93
|
+
| `readonly`, `disabled` | Состояния поля. `readonly` истинно и у выключенного поля: выключенное — это «только чтение плюс снятый contenteditable» |
|
|
92
94
|
| `activeTools(): ReadonlySet<FormatTool>` | Активные форматы всех инструментов сразу (панель обновляется на каждое движение каретки, поштучный опрос обходил бы содержимое на каждую кнопку) |
|
|
93
95
|
| `clearFormat(): void` | Снять всё форматирование с выделения (без выделения — со слова под кареткой) |
|
|
94
96
|
| `clearAllFormat(): void` | Снять всё форматирование со всего содержимого |
|
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.49",
|
|
31
31
|
"main": "source/index.ts",
|
|
32
32
|
"types": "source/index.ts",
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@brandup/ui": "^2.0.9",
|
|
35
|
-
"@brandup/ui-kit": "^1.0.
|
|
35
|
+
"@brandup/ui-kit": "^1.0.49"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"source",
|
package/source/editing.ts
CHANGED
|
@@ -103,7 +103,13 @@ export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType
|
|
|
103
103
|
* null — делить нечего: выделение и так захватило все строки блока.
|
|
104
104
|
*/
|
|
105
105
|
function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElement | null {
|
|
106
|
-
|
|
106
|
+
// Хвостовой перенос — заполнитель последней строки, а не разделитель: резать по нему
|
|
107
|
+
// нельзя, иначе каретка за ним считалась бы отдельной строкой и в значении появлялась бы
|
|
108
|
+
// пустая строка, которой не набирали. Ищется по дереву, как в pad(): мягкий перенос
|
|
109
|
+
// (а с ним и заполнитель) живёт и внутри инлайнового тега.
|
|
110
|
+
let trailing: Node | null = block.lastChild;
|
|
111
|
+
while (trailing?.lastChild) trailing = trailing.lastChild;
|
|
112
|
+
const breaks = Array.from(block.querySelectorAll("br")).filter((br) => br !== trailing);
|
|
107
113
|
|
|
108
114
|
// последний перенос перед выделением и первый после него — по ним и режем
|
|
109
115
|
const head = breaks.filter((br) => range.comparePoint(br, 0) < 0).pop();
|
|
@@ -112,6 +118,16 @@ function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElem
|
|
|
112
118
|
|
|
113
119
|
const original = blockTypeOf(block) ?? DEFAULT_BLOCK;
|
|
114
120
|
|
|
121
|
+
// Кусок, потерявший хвостовой разделитель, кончается переносом — значит, его последняя
|
|
122
|
+
// строка пустая. Одинокий хвостовой перенос — это заполнитель (см. ensureParagraphs),
|
|
123
|
+
// и без второго пустая строка пропала бы и с экрана, и из значения. Перенос ищется по
|
|
124
|
+
// дереву, а не по верхнему уровню: мягкий перенос живёт и внутри инлайнового тега.
|
|
125
|
+
const pad = (piece: HTMLElement) => {
|
|
126
|
+
let last: Node | null = piece.lastChild;
|
|
127
|
+
while (last?.lastChild) last = last.lastChild;
|
|
128
|
+
if (last?.nodeName === "BR") piece.appendChild(document.createElement("br"));
|
|
129
|
+
};
|
|
130
|
+
|
|
115
131
|
// Хвост выносим первым: он дальше по дереву, и вынос головы сдвинул бы его границы.
|
|
116
132
|
// Сам перенос-разделитель уходит вместе с ним — строки разъезжаются по блокам.
|
|
117
133
|
const cut = (from: "before" | "after", br: HTMLElement): HTMLElement => {
|
|
@@ -128,6 +144,8 @@ function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElem
|
|
|
128
144
|
const piece = document.createElement(BLOCK_TYPES[original].tag);
|
|
129
145
|
piece.appendChild(part.extractContents());
|
|
130
146
|
br.remove();
|
|
147
|
+
|
|
148
|
+
if (from === "before") pad(piece);
|
|
131
149
|
fillEmptyParagraph(piece);
|
|
132
150
|
|
|
133
151
|
return piece;
|
|
@@ -139,6 +157,11 @@ function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElem
|
|
|
139
157
|
const created = retagBlock(block, type);
|
|
140
158
|
if (!BLOCK_TYPES[type].inline) unwrapFormatting(created);
|
|
141
159
|
|
|
160
|
+
// Вынос хвоста забрал разделитель и у самого блока: пустая строка в конце выделения
|
|
161
|
+
// осталась бы без заполнителя. Кусок «после» в заполнителе не нуждается — его хвост
|
|
162
|
+
// и есть прежний хвост блока, заполнитель там свой.
|
|
163
|
+
if (tail) pad(created);
|
|
164
|
+
|
|
142
165
|
block.replaceWith(created);
|
|
143
166
|
fillEmptyParagraph(created);
|
|
144
167
|
|
|
@@ -276,11 +299,6 @@ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT
|
|
|
276
299
|
caretToStart(next);
|
|
277
300
|
}
|
|
278
301
|
|
|
279
|
-
/**
|
|
280
|
-
* Разрезает элемент по каретке: содержимое после неё уходит в такой же элемент следом,
|
|
281
|
-
* а диапазон встаёт между половинами — вставленное туда окажется снаружи обоих. Пустые
|
|
282
|
-
* половины не оставляем: печатать в них было бы нечего, а каретка попадала бы внутрь.
|
|
283
|
-
*/
|
|
284
302
|
/**
|
|
285
303
|
* Выводит хвост ссылки из неё, разрезав по каретке: перед переносом строки.
|
|
286
304
|
*
|
|
@@ -478,14 +496,20 @@ export function sanitizePastedHtml(
|
|
|
478
496
|
const holder = document.createElement("template");
|
|
479
497
|
holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true, types);
|
|
480
498
|
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
for (let t = walker.nextNode(); t; t = walker.nextNode())
|
|
485
|
-
t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
|
|
486
|
-
|
|
499
|
+
// Внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем, иначе
|
|
500
|
+
// литеральные \n (pre-wrap) и отступы дают лишние переносы. Кроме блока без инлайновой
|
|
501
|
+
// разметки (код): там переносы и отступы — содержимое, а не вёрстка.
|
|
487
502
|
const paras = Array.from(holder.content.children) as HTMLElement[];
|
|
488
|
-
for (const p of paras)
|
|
503
|
+
for (const p of paras) {
|
|
504
|
+
const type = blockTypeOf(p);
|
|
505
|
+
if (type && !BLOCK_TYPES[type].inline) continue;
|
|
506
|
+
|
|
507
|
+
const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
|
|
508
|
+
for (let t = walker.nextNode(); t; t = walker.nextNode())
|
|
509
|
+
t.textContent = (t.textContent ?? "").replace(/\s+/g, " ");
|
|
510
|
+
|
|
511
|
+
trimParagraphEdges(p);
|
|
512
|
+
}
|
|
489
513
|
|
|
490
514
|
// отбрасываем пустые краевые абзацы (ведущие/хвостовые \n и <br>-обёртки из буфера),
|
|
491
515
|
// иначе перед и после вставленного текста появляются пустые строки
|
package/source/format.ts
CHANGED
package/source/history.ts
CHANGED
|
@@ -29,6 +29,7 @@ export class EditorHistory {
|
|
|
29
29
|
private __lastKind: HistoryKind | null = null;
|
|
30
30
|
private __lastTime = 0;
|
|
31
31
|
private __chars = 0; // суммарный объём снимков отмены — см. MAX_CHARS
|
|
32
|
+
private __redoChars = 0; // объём снимков повтора: длинная серия undo копит их так же
|
|
32
33
|
|
|
33
34
|
constructor(root: HTMLElement) {
|
|
34
35
|
this.__root = root;
|
|
@@ -70,6 +71,7 @@ export class EditorHistory {
|
|
|
70
71
|
|
|
71
72
|
this.__push(snap);
|
|
72
73
|
this.__redo = [];
|
|
74
|
+
this.__redoChars = 0;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
// Puts a snapshot on the undo stack, dropping the oldest ones beyond the limits. Every push
|
|
@@ -89,7 +91,15 @@ export class EditorHistory {
|
|
|
89
91
|
if (!prev) return false;
|
|
90
92
|
|
|
91
93
|
this.__chars -= prev.html.length;
|
|
92
|
-
|
|
94
|
+
|
|
95
|
+
// Бюджет у повтора тот же, что и у отмены: серия undo перекладывает снимки сюда,
|
|
96
|
+
// и без учёта их объёма ограничение MAX_CHARS теряло бы смысл.
|
|
97
|
+
const snap = this.__snapshot();
|
|
98
|
+
this.__redo.push(snap);
|
|
99
|
+
this.__redoChars += snap.html.length;
|
|
100
|
+
while (this.__redo.length > MAX_DEPTH || (this.__redoChars > MAX_CHARS && this.__redo.length > 1))
|
|
101
|
+
this.__redoChars -= this.__redo.shift()!.html.length;
|
|
102
|
+
|
|
93
103
|
this.__restore(prev);
|
|
94
104
|
this.__lastKind = null; // следующая печать начнёт новый шаг
|
|
95
105
|
return true;
|
|
@@ -100,6 +110,7 @@ export class EditorHistory {
|
|
|
100
110
|
const next = this.__redo.pop();
|
|
101
111
|
if (!next) return false;
|
|
102
112
|
|
|
113
|
+
this.__redoChars -= next.html.length;
|
|
103
114
|
this.__push(this.__snapshot());
|
|
104
115
|
this.__restore(next);
|
|
105
116
|
this.__lastKind = null;
|
package/source/paragraphs.ts
CHANGED
|
@@ -204,11 +204,37 @@ export function mergeAdjacentBlocks(root: HTMLElement) {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Есть ли что нормализовать {@link ensureParagraphs}: блуждающий текст/инлайн, чужой `<div>`,
|
|
209
|
+
* пустой абзац без заполнителя или лишний хвостовой перенос.
|
|
210
|
+
*
|
|
211
|
+
* Проверка зеркалит правки ensureParagraphs и существует ради горячего пути: нормализация
|
|
212
|
+
* вызывается на каждое нажатие клавиши, а сохранение каретки вокруг неё собирает весь текст
|
|
213
|
+
* в строку. Почти всегда менять нечего — и дешёвый осмотр верхнего уровня позволяет не платить
|
|
214
|
+
* ни за снимок каретки, ни за сравнение содержимого.
|
|
215
|
+
*/
|
|
216
|
+
export function paragraphsNormalized(root: HTMLElement): boolean {
|
|
217
|
+
for (const node of root.childNodes) {
|
|
218
|
+
const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
|
|
219
|
+
if (!el || !isBlock(el) || el.tagName === "DIV") return false;
|
|
220
|
+
if (!el.firstChild) return false;
|
|
221
|
+
|
|
222
|
+
const tail = el.lastChild!;
|
|
223
|
+
if (tail.nodeName === "BR" && tail.previousSibling?.nodeName !== "BR" && (el.textContent ?? "").length > 0)
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
207
230
|
/**
|
|
208
231
|
* Нормализует верхний уровень редактора к абзацам <p>: блуждающие текст/инлайн оборачиваются в <p>,
|
|
209
232
|
* <div> заменяются на <p>, пустые абзацы получают <br>-заполнитель (чтобы строка была видимой).
|
|
233
|
+
*
|
|
234
|
+
* Возвращает, менялось ли содержимое: если нет — выделение живо, и переставлять его не нужно.
|
|
210
235
|
*/
|
|
211
|
-
export function ensureParagraphs(root: HTMLElement) {
|
|
236
|
+
export function ensureParagraphs(root: HTMLElement): boolean {
|
|
237
|
+
let changed = false;
|
|
212
238
|
let run: ChildNode[] = [];
|
|
213
239
|
|
|
214
240
|
const flushRun = (before: Node | null) => {
|
|
@@ -217,6 +243,7 @@ export function ensureParagraphs(root: HTMLElement) {
|
|
|
217
243
|
for (const node of run) block.appendChild(node);
|
|
218
244
|
root.insertBefore(block, before);
|
|
219
245
|
run = [];
|
|
246
|
+
changed = true;
|
|
220
247
|
};
|
|
221
248
|
|
|
222
249
|
for (const node of Array.from(root.childNodes)) {
|
|
@@ -230,6 +257,7 @@ export function ensureParagraphs(root: HTMLElement) {
|
|
|
230
257
|
const p = document.createElement(BLOCK_TYPES[DEFAULT_BLOCK].tag);
|
|
231
258
|
while (el.firstChild) p.appendChild(el.firstChild);
|
|
232
259
|
root.replaceChild(p, el);
|
|
260
|
+
changed = true;
|
|
233
261
|
}
|
|
234
262
|
} else {
|
|
235
263
|
run.push(node);
|
|
@@ -240,6 +268,7 @@ export function ensureParagraphs(root: HTMLElement) {
|
|
|
240
268
|
for (const p of Array.from(root.children) as HTMLElement[]) {
|
|
241
269
|
if (!p.firstChild) {
|
|
242
270
|
p.appendChild(document.createElement("br")); // пустой абзац — заполнитель для видимости строки
|
|
271
|
+
changed = true;
|
|
243
272
|
continue;
|
|
244
273
|
}
|
|
245
274
|
|
|
@@ -252,7 +281,12 @@ export function ensureParagraphs(root: HTMLElement) {
|
|
|
252
281
|
// начать печатать в следующей.
|
|
253
282
|
if ((p.textContent ?? "").length > 0) {
|
|
254
283
|
const tail = p.lastChild;
|
|
255
|
-
if (tail?.nodeName === "BR" && tail.previousSibling?.nodeName !== "BR")
|
|
284
|
+
if (tail?.nodeName === "BR" && tail.previousSibling?.nodeName !== "BR") {
|
|
285
|
+
p.removeChild(tail);
|
|
286
|
+
changed = true;
|
|
287
|
+
}
|
|
256
288
|
}
|
|
257
289
|
}
|
|
290
|
+
|
|
291
|
+
return changed;
|
|
258
292
|
}
|
package/source/richeditor.less
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// Переменные --input-*, --hover--input-*, --focus--input-*, --placeholder-* и --svg-* приходят
|
|
9
9
|
// из @brandup/ui-kit и здесь не объявляются: объявив их в :root, пакет перекрыл бы значения
|
|
10
10
|
// кита. Их запасные значения остаются по месту — они на случай использования пакета без кита.
|
|
11
|
+
// Рамка в цветах кита — одна на все коробки пакета (панель, поле адреса, разделитель
|
|
12
|
+
// групп смайликов): менять её значит менять в одном месте.
|
|
13
|
+
@richeditor-border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
|
|
14
|
+
|
|
11
15
|
:root {
|
|
12
16
|
// цитата: линия слева, как её рисуют мессенджеры
|
|
13
17
|
--richeditor-quote-line: rgba(0, 0, 0, 0.2);
|
|
@@ -227,7 +231,7 @@
|
|
|
227
231
|
gap: 2px;
|
|
228
232
|
padding: var(--richeditor-toolbar-padding);
|
|
229
233
|
background-color: var(--input-fill, #fff);
|
|
230
|
-
border:
|
|
234
|
+
border: @richeditor-border;
|
|
231
235
|
border-radius: var(--input-border-radius, 0);
|
|
232
236
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
233
237
|
// панель тоже монтируется в разметку хоста — курсор поля ввода ей не наследовать
|
|
@@ -328,7 +332,7 @@
|
|
|
328
332
|
box-sizing: border-box; // высота вместе с полями и рамкой, иначе поле выше кнопок
|
|
329
333
|
padding: 0 8px;
|
|
330
334
|
// оформление у поля общее с полями ввода кита — своих переменных ему не нужно
|
|
331
|
-
border:
|
|
335
|
+
border: @richeditor-border;
|
|
332
336
|
border-radius: calc(var(--input-border-radius, 0) - 2px);
|
|
333
337
|
font: inherit;
|
|
334
338
|
color: inherit;
|
|
@@ -382,8 +386,7 @@
|
|
|
382
386
|
& .emoji-group + .emoji-group {
|
|
383
387
|
margin-top: 6px;
|
|
384
388
|
padding-top: 6px;
|
|
385
|
-
border-top:
|
|
386
|
-
var(--input-border-color, #aaa);
|
|
389
|
+
border-top: @richeditor-border;
|
|
387
390
|
}
|
|
388
391
|
|
|
389
392
|
& .emoji {
|
package/source/richeditor.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
normalizeBlockTypes,
|
|
28
28
|
editorText,
|
|
29
29
|
charLength,
|
|
30
|
+
paragraphsNormalized,
|
|
30
31
|
preserveCaret,
|
|
31
32
|
mergeAdjacentBlocks,
|
|
32
33
|
normalizeParagraphs,
|
|
@@ -142,6 +143,12 @@ export interface RichEditorOptions {
|
|
|
142
143
|
keepFocus?: boolean;
|
|
143
144
|
/** Только для чтения — запрещает ввод и изменение текста (но не выделение/копирование). */
|
|
144
145
|
readonly?: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* Полное отключение: тот же запрет правок, что и readonly, но вдобавок с редактируемого
|
|
148
|
+
* элемента снимается contenteditable — он не берёт ни фокус, ни выделение (в отличие от
|
|
149
|
+
* readonly, где выделение и копирование остаются).
|
|
150
|
+
*/
|
|
151
|
+
disabled?: boolean;
|
|
145
152
|
/** Контейнер для панели форматирования; по умолчанию document.body (position: fixed над редактором). */
|
|
146
153
|
toolbarContainer?: HTMLElement | null;
|
|
147
154
|
/** Собственные кнопки хоста в панели — для действий, которых редактор не знает. */
|
|
@@ -215,7 +222,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
215
222
|
constructor(editable: HTMLElement, options: RichEditorOptions = {}) {
|
|
216
223
|
const format = !!options.format;
|
|
217
224
|
const multiline = !!options.multiline;
|
|
218
|
-
|
|
225
|
+
// disabled — тот же запрет правок, что и readonly, поэтому вся readonly-механика
|
|
226
|
+
// (блокировка ввода, снятие кнопок, выделение-только-чтение) действует и в нём
|
|
227
|
+
const disabled = !!options.disabled;
|
|
228
|
+
const readonly = !!options.readonly || disabled;
|
|
219
229
|
// Объявленный набор — от readonly не зависит: значение обязано разбираться и показываться
|
|
220
230
|
// и там, где разметку не переключить, иначе редактор для чтения показывал бы вместо
|
|
221
231
|
// жирного сырые звёздочки. Кнопки — уже без него.
|
|
@@ -252,8 +262,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
252
262
|
this.__history = format ? new EditorHistory(editable) : null;
|
|
253
263
|
|
|
254
264
|
// редактируемость есть всегда; правки в readonly блокируются на beforeinput,
|
|
255
|
-
// при этом остаётся возможность фокуса, выделения и
|
|
256
|
-
|
|
265
|
+
// при этом остаётся возможность фокуса, выделения и копирования.
|
|
266
|
+
// В disabled редактируемости нет вовсе: элемент не берёт ни фокус, ни выделение.
|
|
267
|
+
editable.contentEditable = disabled ? "false" : "true";
|
|
257
268
|
|
|
258
269
|
if (options.placeholder != null) editable.dataset.placeholder = options.placeholder;
|
|
259
270
|
if (multiline) editable.classList.add("multiline");
|
|
@@ -266,7 +277,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
get readonly(): boolean {
|
|
269
|
-
|
|
280
|
+
// disabled включает весь запрет правок readonly: на этот getter опирается вся
|
|
281
|
+
// readonly-механика редактора, и в disabled она обязана работать так же
|
|
282
|
+
return !!this.__opts.readonly || this.disabled;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Редактор отключён: как readonly, но без фокуса и выделения (contenteditable снят). */
|
|
286
|
+
get disabled(): boolean {
|
|
287
|
+
return !!this.__opts.disabled;
|
|
270
288
|
}
|
|
271
289
|
|
|
272
290
|
/**
|
|
@@ -551,10 +569,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
551
569
|
* оставляют написанное буквальным, поэтому остальные инструменты там выключены — иначе
|
|
552
570
|
* форматирование ставилось бы только чтобы пропасть при сохранении.
|
|
553
571
|
*/
|
|
554
|
-
isToolEnabled(tool: FormatTool): boolean {
|
|
572
|
+
isToolEnabled(tool: FormatTool, codeActive?: boolean): boolean {
|
|
555
573
|
if (this.readonly || !this.formatTools.includes(tool)) return false;
|
|
556
574
|
if (tool === CODE) return true;
|
|
557
|
-
|
|
575
|
+
// Признак «в коде» — обход выделения: панель считает его один раз на обновление
|
|
576
|
+
// и передаёт сюда, чтобы не обходить выделение заново на каждую кнопку.
|
|
577
|
+
if (codeActive ?? this.isCodeActive()) return false;
|
|
558
578
|
|
|
559
579
|
// Ссылке нужен текст, который ею станет: оборачивать нечего — и делать нечего. Каретка
|
|
560
580
|
// в слове за текст считается (формат применяется к слову целиком), в готовой ссылке —
|
|
@@ -599,6 +619,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
599
619
|
const target = this.__formatTarget();
|
|
600
620
|
if (!target) return;
|
|
601
621
|
|
|
622
|
+
// Выделение без единого символа правки не даёт (см. applyFormat) — кроме каретки
|
|
623
|
+
// в готовой ссылке, где правится её адрес.
|
|
624
|
+
if (!target.range.toString().length && !linkAt(this.editable, target.range)) return;
|
|
625
|
+
|
|
602
626
|
this.__history?.record("op");
|
|
603
627
|
applyLinkTo(this.editable, target.range, url.trim(), target.selection, target.original());
|
|
604
628
|
|
|
@@ -641,6 +665,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
641
665
|
|
|
642
666
|
this.__pendingFormats.clear();
|
|
643
667
|
|
|
668
|
+
// Выделение без единого символа (граница элементов, голый перенос) правки не даёт —
|
|
669
|
+
// пустой шаг истории делал бы следующий Ctrl+Z «ничего не делающим».
|
|
670
|
+
if (!target.range.toString().length) return;
|
|
671
|
+
|
|
644
672
|
this.__history?.record("op");
|
|
645
673
|
|
|
646
674
|
// Код не уживается с другой разметкой: значение берёт из него голый текст. Снимаем всё,
|
|
@@ -1133,7 +1161,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1133
1161
|
// держать в двух местах, и стоило добавить кнопки хоста, как они разошлись бы
|
|
1134
1162
|
formatToolbar.attach(this);
|
|
1135
1163
|
|
|
1136
|
-
|
|
1164
|
+
// В readonly фокус выделяет всё содержимое — его можно копировать;
|
|
1165
|
+
// в disabled выделения нет, как и самого содержимого для правки.
|
|
1166
|
+
if (this.readonly) {
|
|
1167
|
+
if (!this.disabled) selectAllContent(this.editable);
|
|
1168
|
+
}
|
|
1137
1169
|
// Каретку в конец ставим только когда её нет: клик ставит сам, а уже стоящую
|
|
1138
1170
|
// (фокус вернули из кода после вызова метода) двигать нельзя — уедет в конец текста.
|
|
1139
1171
|
else if (!this.__hasInputClick && !this.selection) caretToEnd(this.editable, this.multiline);
|
|
@@ -1181,15 +1213,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1181
1213
|
"input",
|
|
1182
1214
|
() => {
|
|
1183
1215
|
if (this.multiline) {
|
|
1184
|
-
//
|
|
1185
|
-
// каретку — сохраняем её; если структура не менялась, выделение живо и переставлять
|
|
1186
|
-
// его не нужно (лишний сброс способен прервать IME-набор)
|
|
1187
|
-
preserveCaret(editable, () => {
|
|
1188
|
-
const before = editable.innerHTML;
|
|
1189
|
-
ensureParagraphs(editable); // блуждающий текст/div → <p>
|
|
1190
|
-
|
|
1191
|
-
return editable.innerHTML !== before;
|
|
1192
|
-
});
|
|
1216
|
+
this.__ensureParagraphs(); // блуждающий текст/div → <p>
|
|
1193
1217
|
|
|
1194
1218
|
// единственный пустой абзац → очищаем, чтобы показать placeholder
|
|
1195
1219
|
if (editable.children.length === 1) {
|
|
@@ -1205,6 +1229,63 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1205
1229
|
);
|
|
1206
1230
|
}
|
|
1207
1231
|
|
|
1232
|
+
/**
|
|
1233
|
+
* Доводит прокрутку до каретки. Переносы строк редактор делает сам (preventDefault), и
|
|
1234
|
+
* браузер к новой строке не прокручивает; к тому же у хостов (textbox, messageeditor)
|
|
1235
|
+
* прокручивается не редактируемый элемент, а его обёртка — после достижения предельной
|
|
1236
|
+
* высоты каретка у нижнего края уходила бы за пределы видимого.
|
|
1237
|
+
*/
|
|
1238
|
+
private __scrollCaretIntoView() {
|
|
1239
|
+
const selection = this.selection;
|
|
1240
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
1241
|
+
|
|
1242
|
+
const range = selection.getRangeAt(0);
|
|
1243
|
+
|
|
1244
|
+
// в среде без раскладки (jsdom) у Range коробок нет вовсе
|
|
1245
|
+
if (typeof range.getBoundingClientRect !== "function") return;
|
|
1246
|
+
|
|
1247
|
+
// У схлопнутой каретки на пустой строке нет собственной коробки — берём ближайший
|
|
1248
|
+
// измеримый ориентир: узел перед кареткой (обычно свежий <br>), иначе родителя.
|
|
1249
|
+
let rect = range.getBoundingClientRect();
|
|
1250
|
+
if (!rect.height) {
|
|
1251
|
+
const { startContainer: node, startOffset: offset } = range;
|
|
1252
|
+
const anchor = node.nodeType === Node.ELEMENT_NODE ? (node.childNodes[offset - 1] ?? node) : node;
|
|
1253
|
+
const el = anchor.nodeType === Node.ELEMENT_NODE ? (anchor as Element) : anchor.parentElement;
|
|
1254
|
+
if (!el) return;
|
|
1255
|
+
rect = el.getBoundingClientRect();
|
|
1256
|
+
}
|
|
1257
|
+
// раскладки нет (тестовая среда) — прокручивать не по чему
|
|
1258
|
+
if (!rect.height && !rect.top && !rect.bottom) return;
|
|
1259
|
+
|
|
1260
|
+
// Ближайший прокручиваемый предок; прокручивается только он — внешним контейнерам
|
|
1261
|
+
// каретка не адресована, крутить страницу из редактора нельзя.
|
|
1262
|
+
const document = this.editable.ownerDocument;
|
|
1263
|
+
for (let el: HTMLElement | null = this.editable; el && el !== document.body; el = el.parentElement) {
|
|
1264
|
+
if (el.scrollHeight <= el.clientHeight) continue;
|
|
1265
|
+
const overflow = document.defaultView?.getComputedStyle(el).overflowY;
|
|
1266
|
+
if (overflow !== "auto" && overflow !== "scroll") continue;
|
|
1267
|
+
|
|
1268
|
+
const box = el.getBoundingClientRect();
|
|
1269
|
+
// небольшой запас, чтобы строка не прилипала к самому краю
|
|
1270
|
+
const pad = Math.min(rect.height || 16, 16);
|
|
1271
|
+
if (rect.bottom > box.bottom - pad) el.scrollTop += rect.bottom - (box.bottom - pad);
|
|
1272
|
+
else if (rect.top < box.top + pad) el.scrollTop -= box.top + pad - rect.top;
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Приведение к абзацам с сохранением каретки. Дешёвая проверка — до снимка каретки:
|
|
1279
|
+
* нормализация идёт на каждое нажатие, менять почти всегда нечего, а снимок собирает
|
|
1280
|
+
* весь текст в строку. Если структура не менялась, выделение живо и переставлять его
|
|
1281
|
+
* не нужно (лишний сброс способен прервать IME-набор).
|
|
1282
|
+
*/
|
|
1283
|
+
private __ensureParagraphs() {
|
|
1284
|
+
if (paragraphsNormalized(this.editable)) return;
|
|
1285
|
+
|
|
1286
|
+
preserveCaret(this.editable, () => ensureParagraphs(this.editable));
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1208
1289
|
private __onKeydown(e: KeyboardEvent) {
|
|
1209
1290
|
// хоткеи форматирования (Ctrl/Cmd+B/I/U); зачёркивание — только кнопкой.
|
|
1210
1291
|
// Сверяем по e.key и e.code — иначе на не-латинской раскладке (кириллица) хоткеи не срабатывают.
|
|
@@ -1291,6 +1372,9 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1291
1372
|
if (soft) insertSoftBreak(this.editable);
|
|
1292
1373
|
else insertParagraph(this.editable, DEFAULT_BLOCK);
|
|
1293
1374
|
|
|
1375
|
+
// Перенос сделан вручную (preventDefault) — браузер к новой строке не прокрутит
|
|
1376
|
+
this.__scrollCaretIntoView();
|
|
1377
|
+
|
|
1294
1378
|
this.__clearPendingFormats();
|
|
1295
1379
|
this.__emitChange();
|
|
1296
1380
|
formatToolbar.refresh();
|
|
@@ -1350,9 +1434,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1350
1434
|
private __pastePlain(text: string, selection: Selection) {
|
|
1351
1435
|
if (!text) return;
|
|
1352
1436
|
|
|
1353
|
-
|
|
1437
|
+
// Внутри блока без инлайновой разметки (код) пробелы значимы: отступы там — часть
|
|
1438
|
+
// текста, и обрезка краёв строк ломала бы вставленный код. Пустая строка там — тоже
|
|
1439
|
+
// содержимое, а не разделитель абзацев: деление разваливало бы один блок на два.
|
|
1440
|
+
const literal = !BLOCK_TYPES[this.currentBlock].inline;
|
|
1441
|
+
const lines = literal
|
|
1442
|
+
? text.split(/\n/)
|
|
1443
|
+
: text.split(/\n/).map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
|
|
1354
1444
|
|
|
1355
|
-
this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs), selection);
|
|
1445
|
+
this.__insertPasted(buildParagraphs(lines, this.__blockParagraphs && !literal), selection);
|
|
1356
1446
|
}
|
|
1357
1447
|
|
|
1358
1448
|
// Вставка форматированного текста из text/html. Возвращает false, если вставлять нечего —
|
|
@@ -1383,12 +1473,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1383
1473
|
let caret: number;
|
|
1384
1474
|
|
|
1385
1475
|
if (this.multiline) {
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1388
|
-
|
|
1476
|
+
// Длина вставки меряется по факту — разницей длины содержимого: ensureParagraphs
|
|
1477
|
+
// подчищает вставленное (краевые <br>-заполнители), и посчитанная заранее длина
|
|
1478
|
+
// уводила бы каретку за конец вставленного, в чужой текст.
|
|
1479
|
+
const before = charLength(this.editable);
|
|
1389
1480
|
|
|
1390
1481
|
insertPastedParagraphs(this.editable, paras, range);
|
|
1391
1482
|
ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
|
|
1483
|
+
|
|
1484
|
+
caret = start + (charLength(this.editable) - before);
|
|
1392
1485
|
} else {
|
|
1393
1486
|
// инлайн: абзацы и переносы → пробелы, форматирование сохраняем
|
|
1394
1487
|
const doc = this.editable.ownerDocument;
|
|
@@ -1471,20 +1564,24 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
|
|
|
1471
1564
|
const range = selection.getRangeAt(0);
|
|
1472
1565
|
const inherited = range.collapsed ? [] : activeFormats(this.editable, range, this.formatTools);
|
|
1473
1566
|
const formats = new Set([...this.__pendingFormats, ...inherited]);
|
|
1567
|
+
// Адрес наследуемой ссылки читается до правки: после deleteContents исходный тег
|
|
1568
|
+
// пустеет и убирается, и адреса взять уже неоткуда.
|
|
1569
|
+
const href = formats.has("link") ? (linkAt(this.editable, range)?.getAttribute("href") ?? "") : "";
|
|
1474
1570
|
|
|
1475
|
-
insertFormattedText(this.editable, data, Array.from(formats), selection);
|
|
1571
|
+
insertFormattedText(this.editable, data, Array.from(formats), selection, href);
|
|
1476
1572
|
|
|
1477
1573
|
// В пустом поле абзацев ещё нет, и каретке некуда встать внутрь — вставка из кода
|
|
1478
1574
|
// (смайлик, переменная) ложится прямо в редактор. Приводим к модели абзацев тем же
|
|
1479
1575
|
// способом, что и обработчик input при печати: он на программную вставку не приходит,
|
|
1480
1576
|
// а без <p> следующий Enter правит текст мимо абзаца.
|
|
1481
|
-
if (this.multiline)
|
|
1482
|
-
preserveCaret(this.editable, () => {
|
|
1483
|
-
const before = this.editable.innerHTML;
|
|
1484
|
-
ensureParagraphs(this.editable);
|
|
1577
|
+
if (this.multiline) this.__ensureParagraphs();
|
|
1485
1578
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1579
|
+
// Вставка сдвинула каретку; пока фокус отпущен, её место хранит снимок — обновляем,
|
|
1580
|
+
// иначе следующая вставка (второй смайлик подряд) легла бы по старому смещению,
|
|
1581
|
+
// ПЕРЕД только что вставленным.
|
|
1582
|
+
if (this.__detachedCaret) this.__detachedCaret = this.caretSnapshot() ?? this.__detachedCaret;
|
|
1583
|
+
|
|
1584
|
+
this.__scrollCaretIntoView(); // вставка из кода браузерной прокрутки к каретке не даёт
|
|
1488
1585
|
|
|
1489
1586
|
this.__emitChange();
|
|
1490
1587
|
formatToolbar.refresh();
|
package/source/selection.ts
CHANGED
|
@@ -78,6 +78,10 @@ export function innerSelection(root: HTMLElement): Selection | null {
|
|
|
78
78
|
const selection = documentSelection(root);
|
|
79
79
|
if (!selection || selection.rangeCount === 0 || !root.contains(selection.anchorNode)) return null;
|
|
80
80
|
|
|
81
|
+
// Оба конца, а не только якорь: выделение можно вытянуть из редактора на страницу,
|
|
82
|
+
// и правка по такому диапазону трогала бы DOM за пределами содержимого.
|
|
83
|
+
if (!root.contains(selection.focusNode)) return null;
|
|
84
|
+
|
|
81
85
|
return selection;
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -233,7 +237,10 @@ function linesBefore(root: HTMLElement, container: Node, offset: number): number
|
|
|
233
237
|
* пробелов и может уехать в соседнее слово.
|
|
234
238
|
*/
|
|
235
239
|
export function mapCharOffset(before: string, after: string, offset: number): number {
|
|
236
|
-
|
|
240
|
+
// Пробелом после нормализации становится и таб, и неразрывный пробел (U+00A0) — тот
|
|
241
|
+
// браузер сам подставляет в contenteditable; без него выравнивание срывалось бы на
|
|
242
|
+
// первом же nbsp, и каретка уезжала к нему.
|
|
243
|
+
const same = (a: string, b: string) => a === b || (b === " " && (a === " " || a === "\t" || a === " "));
|
|
237
244
|
|
|
238
245
|
let i = 0;
|
|
239
246
|
let j = 0;
|
|
@@ -433,13 +440,34 @@ function wrapNode(node: Node, tag: string): HTMLElement {
|
|
|
433
440
|
return wrapper;
|
|
434
441
|
}
|
|
435
442
|
|
|
436
|
-
/** Выносит
|
|
443
|
+
/** Выносит node наружу из элемента fmt (расщепляя fmt на «до» и «после»). */
|
|
437
444
|
function unwrapAround(fmt: HTMLElement, node: Node) {
|
|
438
445
|
const parent = fmt.parentNode;
|
|
439
446
|
if (!parent) return;
|
|
440
447
|
|
|
448
|
+
// Промежуточные предки расщепляются вокруг узла: ветка целиком несла бы наружу и чужой
|
|
449
|
+
// текст — в <b><i>hello world</i></b> снятие жирного с «hello» уносило бы из <b> весь <i>,
|
|
450
|
+
// и « world» терял бы формат, который с него не снимали.
|
|
451
|
+
//
|
|
452
|
+
// Осознанная цена: расщеплённая ссылка становится двумя <a> с одним адресом — в значении
|
|
453
|
+
// два соседних куска вместо одного. Целая ссылка с чужим форматом снаружи стоила бы дороже:
|
|
454
|
+
// формат снимался бы с текста, которого не выделяли.
|
|
441
455
|
let child: Node = node;
|
|
442
|
-
while (child.parentNode && child.parentNode !== fmt)
|
|
456
|
+
while (child.parentNode && child.parentNode !== fmt) {
|
|
457
|
+
const holder = child.parentNode as HTMLElement;
|
|
458
|
+
|
|
459
|
+
const left = holder.cloneNode(false) as HTMLElement;
|
|
460
|
+
while (holder.firstChild && holder.firstChild !== child) left.appendChild(holder.firstChild);
|
|
461
|
+
if (left.firstChild) holder.parentNode?.insertBefore(left, holder);
|
|
462
|
+
|
|
463
|
+
if (child.nextSibling) {
|
|
464
|
+
const right = holder.cloneNode(false) as HTMLElement;
|
|
465
|
+
while (child.nextSibling) right.appendChild(child.nextSibling);
|
|
466
|
+
holder.parentNode?.insertBefore(right, holder.nextSibling);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
child = holder; // держит теперь только выносимую ветку
|
|
470
|
+
}
|
|
443
471
|
if (child.parentNode !== fmt) return;
|
|
444
472
|
|
|
445
473
|
const left = fmt.cloneNode(false) as HTMLElement;
|
|
@@ -610,7 +638,9 @@ const LINK_TAGS = TOOL_TAG_SETS.link;
|
|
|
610
638
|
* По ней панель узнаёт текущий адрес: у ссылки состояние — не «включена», а «вот этот адрес».
|
|
611
639
|
*/
|
|
612
640
|
export function linkAt(root: HTMLElement, range: Range): HTMLAnchorElement | null {
|
|
613
|
-
|
|
641
|
+
// Пробой бывает и сам тег ссылки, а не текст внутри него: границы выделения встают на
|
|
642
|
+
// элементы при Ctrl+A и selectAllContent — искать надо включая сам узел (см. formatAt).
|
|
643
|
+
return formatAt(caretProbe(range), LINK_TAGS, root) as HTMLAnchorElement | null;
|
|
614
644
|
}
|
|
615
645
|
|
|
616
646
|
/**
|
|
@@ -658,12 +688,6 @@ export function clearAllFormat(root: HTMLElement) {
|
|
|
658
688
|
root.normalize();
|
|
659
689
|
}
|
|
660
690
|
|
|
661
|
-
/**
|
|
662
|
-
* Состояние форматирования на выделении указанными тегами.
|
|
663
|
-
* `every` — отформатирован весь текст (подсветка кнопки инструмента),
|
|
664
|
-
* `some` — отформатирована хоть какая-то часть (доступность очистки).
|
|
665
|
-
* Обход прерывается на первом узле, решающем исход.
|
|
666
|
-
*/
|
|
667
691
|
/** Есть ли форматирование хоть на части выделения (или под кареткой) — доступность кнопки очистки. */
|
|
668
692
|
export function hasFormatting(root: HTMLElement, range: Range): boolean {
|
|
669
693
|
if (range.collapsed) return formatAncestor(caretProbe(range), MATCH_TAG_SET, root) !== null;
|
|
@@ -713,7 +737,13 @@ export function hasAnyFormatting(root: HTMLElement): boolean {
|
|
|
713
737
|
* Вставляет текст в позицию каретки, оборачивая его в указанные форматы (режим набора).
|
|
714
738
|
* Каретка ставится сразу после вставленного текста; соседние одинаковые теги склеиваются.
|
|
715
739
|
*/
|
|
716
|
-
export function insertFormattedText(
|
|
740
|
+
export function insertFormattedText(
|
|
741
|
+
root: HTMLElement,
|
|
742
|
+
data: string,
|
|
743
|
+
tools: FormatTool[],
|
|
744
|
+
selection: Selection,
|
|
745
|
+
href = ""
|
|
746
|
+
) {
|
|
717
747
|
if (!data || selection.rangeCount === 0) return;
|
|
718
748
|
|
|
719
749
|
const range = selection.getRangeAt(0);
|
|
@@ -724,6 +754,9 @@ export function insertFormattedText(root: HTMLElement, data: string, tools: Form
|
|
|
724
754
|
let node: Node = document.createTextNode(data);
|
|
725
755
|
for (const tool of tools) {
|
|
726
756
|
const el = document.createElement(FORMAT_TOOLS[tool].tag);
|
|
757
|
+
// Ссылка — не просто тег: без адреса она не переживёт сериализацию. Адрес передаёт
|
|
758
|
+
// вызывающий — с выделения, чьё оформление наследуется.
|
|
759
|
+
if (tool === "link" && href) el.setAttribute("href", href);
|
|
727
760
|
el.appendChild(node);
|
|
728
761
|
node = el;
|
|
729
762
|
}
|
package/source/serialize.ts
CHANGED
|
@@ -72,7 +72,9 @@ function markdownUrl(href: string): string {
|
|
|
72
72
|
function balancedParens(text: string): boolean {
|
|
73
73
|
let depth = 0;
|
|
74
74
|
for (const char of text) {
|
|
75
|
-
|
|
75
|
+
// Глубже одного уровня разбор ссылки не читает (LINK_PATTERN) — такой адрес
|
|
76
|
+
// должен уйти в угловые скобки, иначе ссылка при чтении распалась бы в текст.
|
|
77
|
+
if (char === "(" && ++depth > 1) return false;
|
|
76
78
|
else if (char === ")" && --depth < 0) return false;
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -101,6 +103,15 @@ function wrap(
|
|
|
101
103
|
const core = inner.slice(leading.length, inner.length - trailing.length);
|
|
102
104
|
if (!core) return inner; // одни пробелы — оборачивать нечего
|
|
103
105
|
|
|
106
|
+
// Маркер не пересекает перенос строки — тоже ни у нас, ни у мессенджера. Перенос внутри
|
|
107
|
+
// содержимого (Shift+Enter в жирном): размечаем каждую строку отдельно, иначе `**а\nб**`
|
|
108
|
+
// вернулось бы из значения буквальным текстом с маркерами.
|
|
109
|
+
if (core.includes("\n"))
|
|
110
|
+
return `${leading}${core
|
|
111
|
+
.split("\n")
|
|
112
|
+
.map((line) => wrap(storage, tool, line, markers, source))
|
|
113
|
+
.join("\n")}${trailing}`;
|
|
114
|
+
|
|
104
115
|
return `${leading}${marker}${core}${marker}${trailing}`;
|
|
105
116
|
}
|
|
106
117
|
|
|
@@ -146,9 +157,19 @@ function serializeInline(
|
|
|
146
157
|
|
|
147
158
|
// В коде разметки нет: при разборе его содержимое не размечается, и вложенное
|
|
148
159
|
// форматирование не вернулось бы — значение разошлось бы с тем, что было в поле.
|
|
160
|
+
// Перенос внутри — тоже содержимое: textContent съел бы его, склеив строки. В html
|
|
161
|
+
// перенос пишется тегом: сырой \n виден только под pre-wrap самого редактора.
|
|
149
162
|
if (tool && FORMAT_TOOLS[tool].literal) {
|
|
150
|
-
const text = el.
|
|
151
|
-
|
|
163
|
+
const text = Array.from(el.childNodes)
|
|
164
|
+
.map((child) => (child.nodeName === "BR" ? "\n" : (child.textContent ?? "")))
|
|
165
|
+
.join("");
|
|
166
|
+
result += wrap(
|
|
167
|
+
storage,
|
|
168
|
+
tool,
|
|
169
|
+
storage === "html" ? escapeHtml(text).replace(/\n/g, "<br>") : text,
|
|
170
|
+
markers,
|
|
171
|
+
el
|
|
172
|
+
);
|
|
152
173
|
continue;
|
|
153
174
|
}
|
|
154
175
|
|
|
@@ -242,6 +263,10 @@ function serializeParagraphs(
|
|
|
242
263
|
}
|
|
243
264
|
flush();
|
|
244
265
|
|
|
266
|
+
// Пустой обычный блок ВНУТРИ содержимого — осознанная пустая строка (за цитатой или кодом
|
|
267
|
+
// её держит и normalizeParagraphs), поэтому здесь он не отбрасывается: этой же функцией
|
|
268
|
+
// значение и ЧИТАЕТСЯ (deserialize html идёт через неё), и фильтр молча терял бы пустые
|
|
269
|
+
// строки уже сохранённых значений при первой же загрузке.
|
|
245
270
|
const cleaned = blocks.map(([type, text]) => [type, trimTrailingBreaks(text, storage)] as const);
|
|
246
271
|
|
|
247
272
|
// Пустые блоки в хвосте в значение не идут: последний из них — место каретки, оставленное
|
|
@@ -448,11 +473,13 @@ function markdownInline(text: string, order: MarkerRule[], linked: boolean): str
|
|
|
448
473
|
|
|
449
474
|
// Код — первым: внутри него `*звёздочки*` остаются текстом, как у мессенджеров. Иначе
|
|
450
475
|
// содержимое кода размечалось бы, и то, что человек написал буквально, уезжало бы жирным.
|
|
451
|
-
|
|
476
|
+
// содержимое, чужой маркер (если был) и исходный текст целиком — для мест, где разметка
|
|
477
|
+
// не действует и спрятанное надо вернуть буквально (адрес ссылки)
|
|
478
|
+
const stash: Array<[inner: string, marked: string, literal: string]> = [];
|
|
452
479
|
for (const rule of order)
|
|
453
480
|
if (rule.tool === "code" && html.includes(rule.marker))
|
|
454
481
|
html = html.replace(pattern(rule), (_match, lead: string, inner: string) => {
|
|
455
|
-
stash.push([inner, rule.own ? "" : rule.marker]);
|
|
482
|
+
stash.push([inner, rule.own ? "" : rule.marker, `${rule.marker}${inner}${rule.marker}`]);
|
|
456
483
|
|
|
457
484
|
return `${lead}${STASH_MARK}${stash.length - 1}${STASH_MARK}`;
|
|
458
485
|
});
|
|
@@ -465,7 +492,10 @@ function markdownInline(text: string, order: MarkerRule[], linked: boolean): str
|
|
|
465
492
|
if (linked && html.includes("("))
|
|
466
493
|
html = html.replace(LINK_PATTERN, (match, inner: string, url: string) => {
|
|
467
494
|
// адрес в угловых скобках — форма для адресов с пробелами; сами скобки в него не входят
|
|
468
|
-
const
|
|
495
|
+
const bare = url.startsWith("<") ? url.slice(4, -4) : url;
|
|
496
|
+
// Бэктики в адресе — буквальные символы: код успел спрятаться раньше (он разбирается
|
|
497
|
+
// первым), и без возврата метка уехала бы в href, а содержимое кода пропало бы.
|
|
498
|
+
const raw = bare.replace(STASH_PATTERN, (_m, index: string) => stash[+index][2]);
|
|
469
499
|
const href = safeUrl(raw);
|
|
470
500
|
const text = unescapeLinkText(inner);
|
|
471
501
|
if (!text || !href) return match; // без текста или без адреса ссылки нет
|
|
@@ -603,9 +633,10 @@ export function deserialize(
|
|
|
603
633
|
template.innerHTML = value;
|
|
604
634
|
const tagMap = buildTagMap(tools);
|
|
605
635
|
|
|
606
|
-
|
|
636
|
+
// markers html-ветке не нужны (wrap выходит раньше), передаются лишь ради сигнатуры
|
|
637
|
+
if (!paragraphs) return serializeInline(template.content.childNodes, "html", tagMap, markers);
|
|
607
638
|
|
|
608
|
-
return serializeParagraphs(template.content, "html", tagMap,
|
|
639
|
+
return serializeParagraphs(template.content, "html", tagMap, markers, types, separate).replace(
|
|
609
640
|
/<([a-z]+)><\/\1>/g,
|
|
610
641
|
"<$1><br></$1>"
|
|
611
642
|
);
|
package/source/toolbar.ts
CHANGED
|
@@ -124,8 +124,12 @@ export interface ToolbarHost {
|
|
|
124
124
|
readonly blockTools?: BlockType[];
|
|
125
125
|
applyFormat(tool: FormatTool): void;
|
|
126
126
|
isToolActive(tool: FormatTool): boolean;
|
|
127
|
-
/**
|
|
128
|
-
|
|
127
|
+
/**
|
|
128
|
+
* false — инструмент сейчас недоступен (например, внутри кода): кнопка гасится.
|
|
129
|
+
* Признак «в коде» дорог (обход выделения) — панель считает его один раз на обновление
|
|
130
|
+
* и передаёт вторым аргументом, чтобы хост не обходил выделение на каждую кнопку.
|
|
131
|
+
*/
|
|
132
|
+
isToolEnabled?(tool: FormatTool, codeActive?: boolean): boolean;
|
|
129
133
|
/** Тип блока под кареткой — им подсвечивается активная кнопка блока. */
|
|
130
134
|
readonly currentBlock?: BlockType;
|
|
131
135
|
applyBlock?(type: BlockType): void;
|
|
@@ -326,17 +330,27 @@ class FormatToolbar {
|
|
|
326
330
|
};
|
|
327
331
|
|
|
328
332
|
const active = host.activeTools?.();
|
|
333
|
+
// Признак «в коде» гасит остальные кнопки и подсвечивает объединённую; сам он — обход
|
|
334
|
+
// выделения, поэтому считается один раз на обновление, а не на каждую кнопку. Вывести
|
|
335
|
+
// его из activeTools можно только вместе с currentBlock (блок кода — не формат): хосту
|
|
336
|
+
// без блоков остаётся его собственный isCodeActive.
|
|
337
|
+
const inCode =
|
|
338
|
+
active && host.currentBlock !== undefined
|
|
339
|
+
? host.currentBlock === CODE_TOOL || active.has(CODE_TOOL)
|
|
340
|
+
: host.isCodeActive?.();
|
|
341
|
+
|
|
329
342
|
for (const [tool, btn] of this.__buttons) {
|
|
330
343
|
// объединённая кнопка подсвечена и на блоке кода, а не только на моноширинном
|
|
331
344
|
const isActive =
|
|
332
345
|
this.__mergedCode && tool === CODE_TOOL
|
|
333
|
-
? !!
|
|
346
|
+
? !!inCode
|
|
334
347
|
: active
|
|
335
348
|
? active.has(tool)
|
|
336
349
|
: host.isToolActive(tool);
|
|
337
350
|
|
|
338
|
-
|
|
339
|
-
|
|
351
|
+
// toggle с force не трогает атрибут, когда состояние уже нужное, — мутации не будет
|
|
352
|
+
btn.classList.toggle("active", isActive);
|
|
353
|
+
setDisabled(btn, host.isToolEnabled?.(tool, inCode) === false);
|
|
340
354
|
}
|
|
341
355
|
|
|
342
356
|
// тип под кареткой спрашиваем, только если есть что подсвечивать: обновление идёт
|
|
@@ -344,10 +358,7 @@ class FormatToolbar {
|
|
|
344
358
|
if (this.__blockButtons.length) {
|
|
345
359
|
const block = host.currentBlock;
|
|
346
360
|
|
|
347
|
-
for (const [type, btn] of this.__blockButtons)
|
|
348
|
-
const isActive = block === type;
|
|
349
|
-
if (btn.classList.contains("active") !== isActive) btn.classList.toggle("active", isActive);
|
|
350
|
-
}
|
|
361
|
+
for (const [type, btn] of this.__blockButtons) btn.classList.toggle("active", block === type);
|
|
351
362
|
}
|
|
352
363
|
|
|
353
364
|
// хост может не реализовывать isActionEnabled — тогда кнопка всегда доступна
|