@brandup/ui-richeditor 1.0.43 → 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/README.md +30 -11
- package/package.json +3 -3
- package/source/editing.ts +84 -18
- package/source/format-config.ts +21 -5
- package/source/format.ts +2 -0
- package/source/history.ts +10 -5
- package/source/paragraphs.ts +53 -7
- package/source/richeditor.ts +141 -23
- package/source/selection.ts +42 -5
- package/source/serialize.ts +143 -52
- package/source/toolbar.ts +55 -24
package/source/serialize.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { blockTypeOf } from "./paragraphs";
|
|
4
4
|
import {
|
|
5
5
|
ALL_BLOCK_TYPES,
|
|
6
|
+
ALL_FORMAT_TOOLS,
|
|
6
7
|
BLOCK_TYPES,
|
|
7
8
|
DEFAULT_BLOCK,
|
|
8
9
|
FORMAT_TOOLS,
|
|
@@ -28,17 +29,29 @@ function buildTagMap(tools: FormatTool[]): Record<string, FormatTool> {
|
|
|
28
29
|
return map;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Чем текст был размечен, если не настроенным маркером. Живёт только в разметке поля: значение
|
|
34
|
+
* собирается тегами, и атрибут в него не попадает ни при каком формате хранения.
|
|
35
|
+
*/
|
|
36
|
+
const MARKER_ATTR = "data-md";
|
|
37
|
+
|
|
31
38
|
function lineBreak(storage: FormatStorage): string {
|
|
32
39
|
return storage === "html" ? "<br>" : "\n";
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
function wrap(
|
|
42
|
+
function wrap(
|
|
43
|
+
storage: FormatStorage,
|
|
44
|
+
tool: FormatTool,
|
|
45
|
+
inner: string,
|
|
46
|
+
markers: FormatMarkers,
|
|
47
|
+
source?: HTMLElement
|
|
48
|
+
): string {
|
|
36
49
|
if (!inner) return inner;
|
|
37
50
|
|
|
38
51
|
const def = FORMAT_TOOLS[tool];
|
|
39
52
|
if (storage === "html") return `<${def.tag}>${inner}</${def.tag}>`;
|
|
40
53
|
|
|
41
|
-
const marker = markers
|
|
54
|
+
const marker = ownMarker(tool, markers, source);
|
|
42
55
|
|
|
43
56
|
// Маркер не сработает, если содержимое начинается или заканчивается пробелом, — ни у нас
|
|
44
57
|
// при разборе, ни у мессенджера. Выносим краевые пробелы наружу: разметка сохраняется,
|
|
@@ -51,6 +64,17 @@ function wrap(storage: FormatStorage, tool: FormatTool, inner: string, markers:
|
|
|
51
64
|
return `${leading}${marker}${core}${marker}${trailing}`;
|
|
52
65
|
}
|
|
53
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Маркер, которым текст был размечен: запомненный на элементе, если он известен инструменту,
|
|
69
|
+
* иначе настроенный. Чужой диалект (`*жирный*` из WhatsApp) обязан вернуться в значение таким
|
|
70
|
+
* же, каким пришёл; всё, что размечено в поле, пишется настроенным маркером.
|
|
71
|
+
*/
|
|
72
|
+
function ownMarker(tool: FormatTool, markers: FormatMarkers, source?: HTMLElement): string {
|
|
73
|
+
const marked = source?.dataset.md;
|
|
74
|
+
|
|
75
|
+
return marked && MARKER_ALIASES[tool].includes(marked) ? marked : markers[tool];
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
// Сериализует инлайновое содержимое (текст, форматирование, <br> как мягкий перенос).
|
|
55
79
|
// Абзацы (<p>/<div>) на этом уровне не учитываются — их разбирает serializeParagraphs.
|
|
56
80
|
function serializeInline(
|
|
@@ -82,9 +106,9 @@ function serializeInline(
|
|
|
82
106
|
|
|
83
107
|
// В коде разметки нет: при разборе его содержимое не размечается, и вложенное
|
|
84
108
|
// форматирование не вернулось бы — значение разошлось бы с тем, что было в поле.
|
|
85
|
-
if (tool
|
|
109
|
+
if (tool && FORMAT_TOOLS[tool].literal) {
|
|
86
110
|
const text = el.textContent ?? "";
|
|
87
|
-
result += wrap(storage, tool, storage === "html" ? escapeHtml(text) : text, markers);
|
|
111
|
+
result += wrap(storage, tool, storage === "html" ? escapeHtml(text) : text, markers, el);
|
|
88
112
|
continue;
|
|
89
113
|
}
|
|
90
114
|
|
|
@@ -97,16 +121,17 @@ function serializeInline(
|
|
|
97
121
|
}
|
|
98
122
|
|
|
99
123
|
// неизвестный или отключённый тег — отбрасываем обёртку, оставляем текст
|
|
100
|
-
result += tool ? wrap(storage, tool, inner, markers) : inner;
|
|
124
|
+
result += tool ? wrap(storage, tool, inner, markers, el) : inner;
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
return result;
|
|
104
128
|
}
|
|
105
129
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
130
|
+
// Хвостовой перенос — заполнитель: без него последняя строка блока не видна и в неё не встать
|
|
131
|
+
// кареткой. Отбрасываем ровно один; остальные набраны руками и значат пустые строки — отбросив
|
|
132
|
+
// их, редактор показывал бы строку, которой в значении нет. У всего значения хвост уберёт trim().
|
|
108
133
|
function trimTrailingBreaks(inline: string, storage: FormatStorage): string {
|
|
109
|
-
return storage === "html" ? inline.replace(
|
|
134
|
+
return storage === "html" ? inline.replace(/<br>$/, "") : inline.replace(/\n$/, "");
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
// Разбивает верхний уровень на блоки: у каждого свой тип, у не попавшего в блок содержимого —
|
|
@@ -157,6 +182,11 @@ function serializeParagraphs(
|
|
|
157
182
|
|
|
158
183
|
const cleaned = blocks.map(([type, text]) => [type, trimTrailingBreaks(text, storage)] as const);
|
|
159
184
|
|
|
185
|
+
// Пустые блоки в хвосте в значение не идут: последний из них — место каретки, оставленное
|
|
186
|
+
// нормализацией (см. normalizeParagraphs), а не строка сообщения. В markdown его срезал бы
|
|
187
|
+
// trim всего значения — html обрезаем здесь, чтобы форматы хранения не расходились.
|
|
188
|
+
while (cleaned.length && !cleaned[cleaned.length - 1][1]) cleaned.pop();
|
|
189
|
+
|
|
160
190
|
if (storage === "html")
|
|
161
191
|
return cleaned
|
|
162
192
|
.map(([type, text]) => {
|
|
@@ -220,8 +250,17 @@ export function serialize(
|
|
|
220
250
|
return inline.replace(/^\n+/, "").replace(/\n+$/, "").trim();
|
|
221
251
|
}
|
|
222
252
|
|
|
223
|
-
/**
|
|
224
|
-
|
|
253
|
+
/** Правило разбора одного маркера. */
|
|
254
|
+
interface MarkerRule {
|
|
255
|
+
tool: FormatTool;
|
|
256
|
+
marker: string;
|
|
257
|
+
/** Содержимое не может начинаться и заканчиваться символом самого маркера. */
|
|
258
|
+
standalone: boolean;
|
|
259
|
+
/** Символ маркера не считается границей: маркер не собирается из половинок длинного. */
|
|
260
|
+
guarded: boolean;
|
|
261
|
+
/** Маркер настроенный, а не из чужого диалекта. */
|
|
262
|
+
own: boolean;
|
|
263
|
+
}
|
|
225
264
|
|
|
226
265
|
/**
|
|
227
266
|
* Маркеры в порядке применения: длинный (`__`) раньше короткого-префикса (`_`), иначе
|
|
@@ -234,20 +273,47 @@ type MarkerRule = [tool: FormatTool, marker: string, standalone: boolean];
|
|
|
234
273
|
* подчёркивания. Так же поступают паттерны конвертеров.
|
|
235
274
|
*/
|
|
236
275
|
function orderedMarkers(tools: FormatTool[], markers: FormatMarkers): MarkerRule[] {
|
|
237
|
-
const active = tools.filter((tool) => markers[tool])
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
276
|
+
const active = tools.filter((tool) => markers[tool]);
|
|
277
|
+
const rules: Array<[FormatTool, string, boolean]> = active.map((tool) => [tool, markers[tool], true]);
|
|
278
|
+
const taken = new Set(rules.map(([, marker]) => marker));
|
|
279
|
+
|
|
280
|
+
// Чужие диалекты того же инструмента: запасные маркеры из реестра и его собственный, если
|
|
281
|
+
// настройка его заменила. Разбираются наравне, а ставится всегда настроенный — см. markdownInline.
|
|
282
|
+
for (const tool of active)
|
|
283
|
+
for (const alias of MARKER_ALIASES[tool])
|
|
284
|
+
if (!taken.has(alias)) {
|
|
285
|
+
taken.add(alias);
|
|
286
|
+
rules.push([tool, alias, false]);
|
|
287
|
+
}
|
|
245
288
|
|
|
246
|
-
|
|
247
|
-
|
|
289
|
+
const all = Array.from(taken);
|
|
290
|
+
|
|
291
|
+
return rules
|
|
292
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
293
|
+
.map(([tool, marker, own]) => ({
|
|
294
|
+
tool,
|
|
295
|
+
marker,
|
|
296
|
+
own,
|
|
297
|
+
standalone: all.some((value) => value.length < marker.length && marker.startsWith(value)),
|
|
298
|
+
guarded: all.some((value) => value.length > marker.length && value.startsWith(marker)),
|
|
299
|
+
}));
|
|
248
300
|
}
|
|
249
301
|
|
|
250
|
-
|
|
302
|
+
/**
|
|
303
|
+
* Маркеры инструмента, объявленные в реестре: свой и запасные. Собираются один раз — их
|
|
304
|
+
* спрашивают и на каждый разбор, и на каждый размеченный элемент при сериализации.
|
|
305
|
+
*/
|
|
306
|
+
const MARKER_ALIASES: Record<FormatTool, string[]> = ALL_FORMAT_TOOLS.reduce(
|
|
307
|
+
(map, tool) => {
|
|
308
|
+
const def = FORMAT_TOOLS[tool];
|
|
309
|
+
map[tool] = [def.md, ...(def.mdAliases ?? [])].filter(Boolean);
|
|
310
|
+
|
|
311
|
+
return map;
|
|
312
|
+
},
|
|
313
|
+
{} as Record<FormatTool, string[]>
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const TAG = /<(\/?)([a-z-]+)(?:\s[^>]*)?>/g;
|
|
251
317
|
|
|
252
318
|
/**
|
|
253
319
|
* Закрыт ли в содержимом каждый тег, который в нём открыт.
|
|
@@ -285,28 +351,34 @@ function markdownInline(text: string, order: MarkerRule[]): string {
|
|
|
285
351
|
|
|
286
352
|
// Код — первым: внутри него `*звёздочки*` остаются текстом, как у мессенджеров. Иначе
|
|
287
353
|
// содержимое кода размечалось бы, и то, что человек написал буквально, уезжало бы жирным.
|
|
288
|
-
const stash: string
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
354
|
+
const stash: Array<[inner: string, marked: string]> = []; // содержимое и чужой маркер, если был
|
|
355
|
+
for (const rule of order)
|
|
356
|
+
if (rule.tool === "code" && html.includes(rule.marker))
|
|
357
|
+
html = html.replace(pattern(rule), (_match, lead: string, inner: string) => {
|
|
358
|
+
stash.push([inner, rule.own ? "" : rule.marker]);
|
|
359
|
+
|
|
360
|
+
return `${lead}${STASH_MARK}${stash.length - 1}${STASH_MARK}`;
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
for (const rule of order) {
|
|
364
|
+
if (rule.tool === "code") continue; // уже вынесен
|
|
365
|
+
// Разбор идёт по абзацу на каждый маркер, а маркеров с диалектами уже семь. Поиск
|
|
366
|
+
// подстроки на порядок дешевле разбора регуляркой, и почти всегда он же и решает.
|
|
367
|
+
if (!html.includes(rule.marker)) continue;
|
|
368
|
+
|
|
369
|
+
const def = FORMAT_TOOLS[rule.tool];
|
|
370
|
+
html = html.replace(pattern(rule), (match, lead: string, inner: string) =>
|
|
371
|
+
balancedTags(inner) ? `${lead}${openTag(def.tag, rule.own ? "" : rule.marker)}${inner}</${def.tag}>` : match
|
|
304
372
|
);
|
|
305
373
|
}
|
|
306
374
|
|
|
307
375
|
if (stash.length) {
|
|
308
376
|
const def = FORMAT_TOOLS.code;
|
|
309
|
-
html = html.replace(STASH_PATTERN, (_match, index: string) =>
|
|
377
|
+
html = html.replace(STASH_PATTERN, (_match, index: string) => {
|
|
378
|
+
const [inner, marked] = stash[+index];
|
|
379
|
+
|
|
380
|
+
return `${openTag(def.tag, marked)}${inner}</${def.tag}>`;
|
|
381
|
+
});
|
|
310
382
|
}
|
|
311
383
|
|
|
312
384
|
// переносы — после маркеров: пока это \n, запрет на пересечение строки работает
|
|
@@ -328,26 +400,41 @@ function markdownInline(text: string, order: MarkerRule[]): string {
|
|
|
328
400
|
* Содержимое не начинается и с U+20E3: в keycap-последовательностях (`*⃣`, `#⃣`, `1⃣`) сам
|
|
329
401
|
* маркер служит базовым символом, и без этого `*⃣раз*` разбиралось бы как разметка вместо эмодзи.
|
|
330
402
|
*
|
|
331
|
-
* При `standalone` содержимое
|
|
332
|
-
*
|
|
403
|
+
* При `standalone` содержимое не начинается и не заканчивается символом самого маркера, при
|
|
404
|
+
* `guarded` этот символ вдобавок перестаёт быть границей. Так разводятся маркеры, один из
|
|
405
|
+
* которых начинается с другого (`_` и `__`, `*` и `**`) — см. {@link orderedMarkers}.
|
|
333
406
|
*/
|
|
334
|
-
function
|
|
335
|
-
const
|
|
407
|
+
function pattern(rule: MarkerRule): RegExp {
|
|
408
|
+
const { marker, standalone, guarded } = rule;
|
|
409
|
+
// в ключе различаем режимы: один и тот же маркер бывает разведён по-разному
|
|
410
|
+
const key = `${marker}|${standalone ? "s" : ""}${guarded ? "g" : ""}`;
|
|
336
411
|
|
|
337
|
-
|
|
338
|
-
if (
|
|
412
|
+
const cached = markerPatterns.get(key);
|
|
413
|
+
if (cached) return cached;
|
|
339
414
|
|
|
340
415
|
const escaped = escapeRegExp(marker);
|
|
341
|
-
const boundary = "[^\\p{L}\\p{N}]";
|
|
342
416
|
// символ маркера в классе — экранируем то, что в нём значимо
|
|
343
|
-
const own =
|
|
344
|
-
const
|
|
345
|
-
|
|
417
|
+
const own = marker.slice(-1).replace(/[\\\]^-]/g, "\\$&");
|
|
418
|
+
const edge = standalone || guarded ? own : "";
|
|
419
|
+
// Границей символ маркера перестаёт быть только у короткого. Длинному она нужна: в
|
|
420
|
+
// `___текст___` его пара стоит рядом со своим же символом, и без границы её не найти.
|
|
421
|
+
const boundary = `[^\\p{L}\\p{N}${guarded ? own : ""}]`;
|
|
422
|
+
const head = `[^\\s\\u20e3${edge}]`;
|
|
423
|
+
const tail = edge ? `[^\\s${edge}]` : "\\S";
|
|
424
|
+
|
|
425
|
+
const built = new RegExp(
|
|
426
|
+
`(^|${boundary})${escaped}(${head}|${head}[^\\n]*?${tail})${escaped}(?=$|${boundary})`,
|
|
427
|
+
"gu"
|
|
428
|
+
);
|
|
429
|
+
markerPatterns.set(key, built);
|
|
346
430
|
|
|
347
|
-
|
|
348
|
-
|
|
431
|
+
return built;
|
|
432
|
+
}
|
|
349
433
|
|
|
350
|
-
|
|
434
|
+
/** Открывающий тег: чужой маркер запоминается на элементе, чтобы значение вернулось таким же. */
|
|
435
|
+
function openTag(tag: string, marked: string): string {
|
|
436
|
+
// маркер задаёт хост — в значении атрибута экранируем и кавычку, иначе она разорвёт его
|
|
437
|
+
return marked ? `<${tag} ${MARKER_ATTR}="${escapeHtml(marked).replace(/"/g, """)}">` : `<${tag}>`;
|
|
351
438
|
}
|
|
352
439
|
|
|
353
440
|
// Набор маркеров за разбор не меняется, а разбор идёт по абзацам — компилируем каждую
|
|
@@ -380,7 +467,11 @@ export function deserialize(
|
|
|
380
467
|
const def = BLOCK_TYPES[type];
|
|
381
468
|
const inner = def.inline ? markdownInline(text, order) : escapeHtml(text).replace(/\n/g, "<br>");
|
|
382
469
|
|
|
383
|
-
|
|
470
|
+
// Пустая последняя строка видна только с заполнителем: без него браузер не рисует
|
|
471
|
+
// её и не пускает туда каретку — строка, которая в значении есть, пропала бы.
|
|
472
|
+
const filler = inner.endsWith("<br>") ? "<br>" : "";
|
|
473
|
+
|
|
474
|
+
return `<${def.tag}>${inner ? inner + filler : "<br>"}</${def.tag}>`;
|
|
384
475
|
})
|
|
385
476
|
.join("");
|
|
386
477
|
}
|
package/source/toolbar.ts
CHANGED
|
@@ -60,8 +60,8 @@ const MERGED_CODE_TITLE = "Код";
|
|
|
60
60
|
// Временно скрытые кнопки. Сами возможности работают: значение разбирается, показывается
|
|
61
61
|
// и сохраняется, правку можно вызвать из кода — в панель они просто не выводятся.
|
|
62
62
|
// Убрать отсюда, когда будут доведены.
|
|
63
|
-
const HIDDEN_TOOLS: FormatTool[] = ["spoiler"
|
|
64
|
-
const HIDDEN_BLOCKS: BlockType[] = [
|
|
63
|
+
const HIDDEN_TOOLS: FormatTool[] = ["spoiler"];
|
|
64
|
+
const HIDDEN_BLOCKS: BlockType[] = [];
|
|
65
65
|
|
|
66
66
|
export const TOOLBAR_CLASS = "ui-richeditor-toolbar";
|
|
67
67
|
export const EMOJI_PICKER_CLASS = "ui-richeditor-emoji";
|
|
@@ -92,8 +92,12 @@ export interface ToolbarHost {
|
|
|
92
92
|
readonly editorActions?: EditorAction[];
|
|
93
93
|
/** Контейнер для тулбара; null/undefined — document.body (position: fixed над редактором). */
|
|
94
94
|
readonly toolbarContainer?: HTMLElement | null;
|
|
95
|
-
/**
|
|
96
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Типы блоков, которые панель вправе предложить; пусто/undefined — кнопок блоков нет.
|
|
97
|
+
* Не то же, что набор для разбора значения: показывать цитату и код редактор обязан
|
|
98
|
+
* и там, где их не переключить (только для чтения).
|
|
99
|
+
*/
|
|
100
|
+
readonly blockTools?: BlockType[];
|
|
97
101
|
applyFormat(tool: FormatTool): void;
|
|
98
102
|
isToolActive(tool: FormatTool): boolean;
|
|
99
103
|
/** false — инструмент сейчас недоступен (например, внутри кода): кнопка гасится. */
|
|
@@ -112,6 +116,14 @@ export interface ToolbarHost {
|
|
|
112
116
|
isActionEnabled?(action: EditorAction): boolean;
|
|
113
117
|
/** Вставка текста в каретку — для панели смайликов. */
|
|
114
118
|
insertText?(text: string): void;
|
|
119
|
+
/**
|
|
120
|
+
* Открыть панель смайликов. Своя кнопка панели зовёт редактор, а не {@link openEmoji}
|
|
121
|
+
* напрямую: он придерживает правку и отпускает фокус на её время — с кнопкой хоста это
|
|
122
|
+
* должно работать одинаково.
|
|
123
|
+
*/
|
|
124
|
+
openEmojiPicker?(initiator: HTMLElement, container: HTMLElement): void;
|
|
125
|
+
/** Панель смайликов закрылась — редактор возвращает себе то, что придержал на её время. */
|
|
126
|
+
onEmojiClosed?(): void;
|
|
115
127
|
/** Собственные кнопки хоста; пусто/undefined — только штатные. */
|
|
116
128
|
readonly toolbarButtons?: ToolbarButton[];
|
|
117
129
|
}
|
|
@@ -222,10 +234,15 @@ class FormatToolbar {
|
|
|
222
234
|
const buttons = host.toolbarButtons ?? [];
|
|
223
235
|
const tools = host.formatTools.filter((tool) => !HIDDEN_TOOLS.includes(tool));
|
|
224
236
|
// Обычный текст кнопки не имеет — он не «включается», а остаётся, когда выключены остальные.
|
|
225
|
-
const blocks = (host.
|
|
237
|
+
const blocks = (host.blockTools ?? []).filter(
|
|
226
238
|
(type) => type !== DEFAULT_BLOCK && !HIDDEN_BLOCKS.includes(type)
|
|
227
239
|
);
|
|
228
|
-
|
|
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
|
+
}
|
|
229
246
|
|
|
230
247
|
this.__bindSelection();
|
|
231
248
|
this.__active = host;
|
|
@@ -258,6 +275,15 @@ class FormatToolbar {
|
|
|
258
275
|
}
|
|
259
276
|
}
|
|
260
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Убрать панель с экрана, не отпуская редактор: он отдал фокус своему слою — окну или панели
|
|
280
|
+
* смайликов. От {@link detach} отличается тем, что панель смайликов при этом остаётся: её и
|
|
281
|
+
* открыли из этого редактора, а фокус сняли как раз ради неё.
|
|
282
|
+
*/
|
|
283
|
+
suspend(host: ToolbarHost) {
|
|
284
|
+
if (this.__active === host) this.__hide();
|
|
285
|
+
}
|
|
286
|
+
|
|
261
287
|
/** Скрыть тулбар, если он обслуживает этот редактор (на blur/destroy). */
|
|
262
288
|
detach(host: ToolbarHost) {
|
|
263
289
|
// Придержанный показ снимаем первым делом, до закрытия панели: иначе её onClose поднял бы
|
|
@@ -394,16 +420,6 @@ class FormatToolbar {
|
|
|
394
420
|
this.__mergedCode = tools.includes(CODE_TOOL) && blocks.includes(CODE_BLOCK);
|
|
395
421
|
const blockTypes = this.__mergedCode ? blocks.filter((type) => type !== CODE_BLOCK) : blocks;
|
|
396
422
|
|
|
397
|
-
// Разделитель ставится только между непустыми группами — иначе панель начиналась бы
|
|
398
|
-
// с линии или показывала две подряд.
|
|
399
|
-
let filled = false;
|
|
400
|
-
const separate = (group: unknown[]) => {
|
|
401
|
-
if (filled && group.length) elem.appendChild(DOM.tag("div", { class: "split" }));
|
|
402
|
-
filled ||= group.length > 0;
|
|
403
|
-
};
|
|
404
|
-
|
|
405
|
-
separate(tools);
|
|
406
|
-
|
|
407
423
|
for (const tool of tools) {
|
|
408
424
|
const merged = this.__mergedCode && tool === CODE_TOOL;
|
|
409
425
|
const def = FORMAT_TOOLS[tool];
|
|
@@ -425,8 +441,6 @@ class FormatToolbar {
|
|
|
425
441
|
this.__buttons.push([tool, btn]);
|
|
426
442
|
}
|
|
427
443
|
|
|
428
|
-
separate(blockTypes);
|
|
429
|
-
|
|
430
444
|
for (const type of blockTypes) {
|
|
431
445
|
const def = BLOCK_TYPES[type];
|
|
432
446
|
const btn = DOM.tag(
|
|
@@ -440,8 +454,6 @@ class FormatToolbar {
|
|
|
440
454
|
this.__blockButtons.push([type, btn]);
|
|
441
455
|
}
|
|
442
456
|
|
|
443
|
-
separate(actions);
|
|
444
|
-
|
|
445
457
|
for (const action of actions) {
|
|
446
458
|
const def = EDITOR_ACTIONS[action];
|
|
447
459
|
const btn = DOM.tag(
|
|
@@ -456,7 +468,11 @@ class FormatToolbar {
|
|
|
456
468
|
this.__actionButtons.push([action, btn]);
|
|
457
469
|
}
|
|
458
470
|
|
|
459
|
-
|
|
471
|
+
// Инструменты, блоки и действия — одна группа: всё это правка оформления. Разделитель
|
|
472
|
+
// нужен только перед кнопками хоста: они про другое — переменные, рандомизацию и прочее
|
|
473
|
+
// доменное. Пустых групп разделитель не касается, иначе панель начиналась бы с линии.
|
|
474
|
+
if (buttons.length && (tools.length || blockTypes.length || actions.length))
|
|
475
|
+
elem.appendChild(DOM.tag("div", { class: "split" }));
|
|
460
476
|
|
|
461
477
|
// кнопки хоста — последними, чтобы штатные не переезжали при их появлении
|
|
462
478
|
for (const button of buttons) {
|
|
@@ -485,7 +501,7 @@ class FormatToolbar {
|
|
|
485
501
|
* закрытия на body прямо в open(), то есть во время этого же клика — до body событие ещё
|
|
486
502
|
* не дошло, и слушатель закрыл бы панель сразу после открытия.
|
|
487
503
|
*/
|
|
488
|
-
openEmoji(host: ToolbarHost, initiator: HTMLElement, container: HTMLElement) {
|
|
504
|
+
openEmoji(host: ToolbarHost, initiator: HTMLElement, container: HTMLElement): boolean {
|
|
489
505
|
// повторный клик по той же кнопке закрывает панель (это делает toggle внутри PopupManager),
|
|
490
506
|
// а вот у другой кнопки её нужно сперва закрыть — иначе toggle сочтёт открытие повторным
|
|
491
507
|
if (this.__emojiInitiator !== initiator && this.__emojiPicker?.classList.contains("opened"))
|
|
@@ -504,7 +520,17 @@ class FormatToolbar {
|
|
|
504
520
|
if (this.__active === host) this.__hide();
|
|
505
521
|
}
|
|
506
522
|
|
|
507
|
-
PopupManager.open(this.__ensureEmojiPicker(container), {
|
|
523
|
+
PopupManager.open(this.__ensureEmojiPicker(container), {
|
|
524
|
+
initiator,
|
|
525
|
+
onClose: () => {
|
|
526
|
+
this.__resume();
|
|
527
|
+
host.onEmojiClosed?.();
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Повторное нажатие по той же кнопке панель закрывает (это toggle внутри PopupManager) —
|
|
532
|
+
// вызывающему нужно знать исход: открытая панель ещё и придерживает правку редактора.
|
|
533
|
+
return !!this.__emojiPicker?.classList.contains("opened");
|
|
508
534
|
}
|
|
509
535
|
|
|
510
536
|
/** Панель смайликов закрылась — показываем придержанный тулбар, если редактор ещё в фокусе. */
|
|
@@ -521,7 +547,12 @@ class FormatToolbar {
|
|
|
521
547
|
private __toggleEmoji(initiator: HTMLButtonElement, e: MouseEvent) {
|
|
522
548
|
e.stopPropagation();
|
|
523
549
|
|
|
524
|
-
|
|
550
|
+
const host = this.__active;
|
|
551
|
+
if (!host) return;
|
|
552
|
+
|
|
553
|
+
// через редактор, а не напрямую: панель — его слой, и придерживает правку он
|
|
554
|
+
if (host.openEmojiPicker) host.openEmojiPicker(initiator, this.__ensure());
|
|
555
|
+
else this.openEmoji(host, initiator, this.__ensure());
|
|
525
556
|
}
|
|
526
557
|
|
|
527
558
|
private __ensureEmojiPicker(container: HTMLElement): HTMLElement {
|