@brandup/ui-richeditor 1.0.41 → 1.0.42

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 CHANGED
@@ -49,6 +49,7 @@ editor.onChange(({ value }) => console.log(value));
49
49
  | `placeholder` | `string \| null` | Текст-заглушка |
50
50
  | `multiline` | `boolean` | Многострочный режим |
51
51
  | `paragraph` | `"block" \| "break"` | Что делает Enter: новый абзац (по умолчанию) или мягкий перенос |
52
+ | `blocks` | `BlockType[]` | Типы блоков многострочного режима: `quote`, `code` (по умолчанию только `paragraph`) |
52
53
  | `readonly` | `boolean` | Только для чтения — запрещает ввод и изменение текста (выделение и копирование остаются) |
53
54
  | `toolbarContainer` | `HTMLElement \| null` | Контейнер для панели; по умолчанию `document.body` (`position: fixed`). Если задан — панель монтируется в него и позиционируется над ним (`position: absolute`). Контейнер должен быть `position: relative` |
54
55
  | `value` | `string` | Начальное значение |
@@ -71,7 +72,13 @@ editor.onChange(({ value }) => console.log(value));
71
72
  | `getLength(): number` | Длина текста (без учёта переводов строк) |
72
73
  | `focus(): void` | Установить фокус |
73
74
  | `applyFormat(tool): void` | Переключить формат на выделении (слово целиком) |
75
+ | `applyBlock(type): void` | Переключить тип блоков под выделением; повторное применение возвращает обычный текст |
76
+ | `applyCode(): void` | Код по выделению: моноширинный для части строки, блок — для целых строк |
77
+ | `isCodeActive(): boolean` | Включён ли код в любом виде — подсветка объединённой кнопки |
78
+ | `currentBlock: BlockType` | Тип блока под кареткой |
79
+ | `blockTypes: BlockType[]` | Доступные типы блоков |
74
80
  | `isToolActive(tool): boolean` | Активен ли формат на текущем выделении |
81
+ | `isToolEnabled(tool): boolean` | Доступен ли инструмент сейчас (внутри кода остальные выключены) |
75
82
  | `activeTools(): ReadonlySet<FormatTool>` | Активные форматы всех инструментов сразу (панель обновляется на каждое движение каретки, поштучный опрос обходил бы содержимое на каждую кнопку) |
76
83
  | `clearFormat(): void` | Снять всё форматирование с выделения (без выделения — со слова под кареткой) |
77
84
  | `clearAllFormat(): void` | Снять всё форматирование со всего содержимого |
@@ -152,6 +159,49 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
152
159
 
153
160
  При нормализации (потеря фокуса, `setValue`, инициализация) пустые абзацы удаляются.
154
161
 
162
+ ## Блоки: цитата и код
163
+
164
+ Кроме абзаца многострочный режим знает и другие типы блоков верхнего уровня. Они подключаются **явно** — их понимает не каждый получатель:
165
+
166
+ ```typescript
167
+ new RichEditor(elem, { format: true, multiline: true, blocks: ["quote", "code"] });
168
+ ```
169
+
170
+ | Тип | Тег | Разметка | Enter внутри |
171
+ | --- | --- | --- | --- |
172
+ | `paragraph` | `<p>` | — | по режиму `paragraph` |
173
+ | `quote` | `<blockquote>` | `>` с пробелом в начале каждой строки | заканчивает блок |
174
+ | `code` | `<pre>` | ограждение ```` ``` ```` | заканчивает блок |
175
+
176
+ Обычный текст — такой же тип, а не «тип не задан»: он есть в наборе всегда, им становится содержимое, не попавшее ни в какой блок, и в него же блок возвращают. Кнопки в панели (`.block-button`) получают только остальные типы.
177
+
178
+ Кнопки кода (моноширинного и блока) и спойлера временно скрыты (`HIDDEN_TOOLS`/`HIDDEN_BLOCKS` в `./toolbar`): сами возможности работают — значение разбирается, показывается и сохраняется, правку можно вызвать из кода (`applyFormat`, `applyBlock`, `applyCode`), — но в панель они пока не выводятся.
179
+
180
+ ### Одна кнопка на моноширинный и блок кода
181
+
182
+ Когда включены и инструмент `code`, и тип блока `code`, панель показывает **одну** кнопку — так это устроено в мессенджерах. Вид выбирается по выделению, как и в самой разметке:
183
+
184
+ - выделена одна строка или её часть → моноширинный (`` `код` ``);
185
+ - выделено больше одной строки → блок кода из этих строк. Остальные строки остаются как были: в мессенджерском режиме всё сообщение — это один блок, и иначе кодом становился бы весь текст;
186
+ - без выделения → блок кода из строки под кареткой. Моноширинным там делать нечего, а иначе блок был бы недостижим: в пустом поле не выделить строки, которых ещё нет;
187
+ - код уже включён → повторное нажатие снимает именно его вид, целиком по блоку.
188
+
189
+ Тем же занимается `applyCode()`, а `isCodeActive()` отвечает, включён ли код в любом виде — им подсвечена кнопка.
190
+
191
+ В коде разметки нет — ни в моноширинном, ни в блоке: значение берёт оттуда голый текст, и любое форматирование внутри до получателя не доедет. Поэтому при переходе в моноширинный прежнее форматирование с этого куска снимается, внутри кода остальные инструменты недоступны (`isToolEnabled()` — кнопки гаснут), а разметка, попавшая внутрь как-то ещё (вставка, чужое значение), вычищается при первой же нормализации. Если включено только что-то одно, кнопка остаётся обычной: инструмента (`.format-button`) или блока (`.block-button`).
192
+
193
+ Переключение:
194
+
195
+ - кнопка панели или `applyBlock(type)` — на все блоки, которых касается выделение; повторное применение того же типа возвращает обычный текст;
196
+ - **Enter** внутри цитаты или кода заканчивает блок и начинает обычный абзац, **Shift/Ctrl/Cmd+Enter** переносит строку внутри блока. Выходить из блока приходится чаще, чем продолжать его, а иначе уйти можно было бы только мышью;
197
+ - **Backspace** в начале блока возвращает его к обычному тексту, не трогая содержимого.
198
+
199
+ Особенности блока кода: инлайновое форматирование внутри не размечается (написанное остаётся буквальным) и снимается при переключении в этот тип, а пробелы внутри не схлопываются — отступы там часть текста.
200
+
201
+ Подряд идущие строки с маркером цитаты — одна цитата; пустая строка между ними разделяет цитаты. Незакрытое ограждение блоком не считается: его строки остаются текстом, иначе одна случайная кавычка съедала бы весь остаток сообщения. Блок отключённого типа, пришедший вставкой или из значения, сохраняется как обычный текст — разметки от него в значении не будет, но текст не теряется.
202
+
203
+ В режиме `paragraph: "break"` блоки работают так же, но пустая строка их не разделяет — там она сама по себе строка сообщения. Блок узнаётся по собственной разметке, поэтому между обычным текстом и цитатой в значении стоит один перенос, а не два.
204
+
155
205
  ## Вставка текста
156
206
 
157
207
  При включённом форматировании вставка (`paste`) сохраняет форматирование из буфера обмена (`text/html`):
@@ -172,8 +222,8 @@ new RichEditor(elem, { format: true, actions: ["emoji", "erase", "undo", "redo"]
172
222
 
173
223
  | `storage` | Хранение | Абзац / мягкий перенос | Маркеры форматирования |
174
224
  | --- | --- | --- | --- |
175
- | `html` | Санитизированный HTML | `<p>…</p>` / `<br>` | `<b>`, `<i>`, `<s>`, `<u>` |
176
- | `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`, `_курсив_`, `~зачёркнутый~`, `__подчёркнутый__` |
225
+ | `html` | Санитизированный HTML | `<p>…</p>` / `<br>` | `<b>`, `<i>`, `<s>`, `<u>`, `<spoiler>`, `<code>` |
226
+ | `markdown` | Лёгкая разметка | `\n\n` / `\n` | `**жирный**`, `_курсив_`, `~зачёркнутый~`, `__подчёркнутый__`, `\|\|спойлер\|\|`, `` `моноширинный` `` |
177
227
 
178
228
  Без форматирования (plain) значение хранится как markdown без инструментов: абзацы `\n\n`, мягкий перенос `\n`.
179
229
 
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.41",
30
+ "version": "1.0.42",
31
31
  "main": "source/index.ts",
32
32
  "types": "source/index.ts",
33
33
  "dependencies": {
34
34
  "@brandup/ui": "^2.0.7",
35
- "@brandup/ui-kit": "^1.0.41"
35
+ "@brandup/ui-kit": "^1.0.42"
36
36
  },
37
37
  "files": [
38
38
  "source",
package/source/editing.ts CHANGED
@@ -3,14 +3,12 @@
3
3
  // редактора и без истории — вызывающий сам решает, когда записывать undo-шаг.
4
4
 
5
5
  import { deserialize } from "./serialize";
6
- import { isBlock } from "./paragraphs";
6
+ import { blockAt, blockTypeOf, blocksInRange, createBlock, isBlock } from "./paragraphs";
7
7
  import { documentSelection, innerSelection } from "./selection";
8
- import type { FormatMarkers, FormatTool } from "./format-config";
8
+ import { BLOCK_TYPES, DEFAULT_BLOCK, type BlockType, type FormatMarkers, type FormatTool } from "./format-config";
9
9
 
10
10
  function emptyParagraph(): HTMLParagraphElement {
11
- const p = document.createElement("p");
12
- p.appendChild(document.createElement("br"));
13
- return p;
11
+ return createBlock(DEFAULT_BLOCK) as HTMLParagraphElement;
14
12
  }
15
13
 
16
14
  // убирает пустые текст-узлы и ставит <br>-заполнитель в пустой абзац (для видимости и каретки)
@@ -51,21 +49,155 @@ export function selectAllContent(editable: HTMLElement) {
51
49
  documentSelection(editable)?.selectAllChildren(editable);
52
50
  }
53
51
 
54
- /** Enter в multiline: разбить текущий абзац по каретке на два <p>. */
55
- export function insertParagraph(editable: HTMLElement) {
52
+ /** Что сделала правка блоков: менялось ли содержимое и не появился ли блок из части строк. */
53
+ export interface BlockChange {
54
+ changed: boolean;
55
+ /** Блок, собранный из выделенных строк (разделение); null — блоки меняли целиком. */
56
+ created: HTMLElement | null;
57
+ }
58
+
59
+ /**
60
+ * Меняет тип блоков, которых касается выделение. `changed: false` — менять было нечего:
61
+ * тогда ни содержимое, ни история не трогаются.
62
+ *
63
+ * У типа без инлайновой разметки (код) форматирование снимается: внутри него написанное
64
+ * остаётся буквальным, и сохранить его всё равно было бы негде.
65
+ */
66
+ export function applyBlocks(editable: HTMLElement, range: Range, type: BlockType): BlockChange {
67
+ const blocks = blocksInRange(editable, range);
68
+
69
+ // Пустой редактор: блоков ещё нет, но тип задать можно — иначе в пустое поле его было бы
70
+ // не поставить вовсе, а набор блока обычно с этого и начинают.
71
+ if (!blocks.length && !editable.firstChild && type !== DEFAULT_BLOCK) {
72
+ const block = createBlock(type);
73
+ editable.appendChild(block);
74
+ caretToStart(block);
75
+
76
+ return { changed: true, created: block };
77
+ }
78
+
79
+ // Выделена часть строк блока — правим их, а не весь блок: иначе в мессенджерском режиме,
80
+ // где всё сообщение это один блок, кодом становился бы весь текст.
81
+ if (blocks.length === 1 && type !== DEFAULT_BLOCK && blockTypeOf(blocks[0]) !== type) {
82
+ const created = splitLines(blocks[0], range, type);
83
+ if (created) return { changed: true, created };
84
+ }
85
+
86
+ let changed = false;
87
+
88
+ for (const block of blocks) {
89
+ if (blockTypeOf(block) === type) continue;
90
+
91
+ const replacement = retagBlock(block, type);
92
+ if (!BLOCK_TYPES[type].inline) unwrapFormatting(replacement);
93
+
94
+ block.replaceWith(replacement);
95
+ fillEmptyParagraph(replacement);
96
+ changed = true;
97
+ }
98
+
99
+ return { changed, created: null };
100
+ }
101
+
102
+ /**
103
+ * Собирает блок нужного типа из строк, которых коснулось выделение; остальные строки остаются
104
+ * блоками прежнего типа до и после. Строки берутся целиком: код из половины строки — это уже
105
+ * моноширинный, а не блок.
106
+ *
107
+ * null — делить нечего: выделение и так захватило все строки блока.
108
+ */
109
+ function splitLines(block: HTMLElement, range: Range, type: BlockType): HTMLElement | null {
110
+ const breaks = Array.from(block.querySelectorAll("br"));
111
+
112
+ // последний перенос перед выделением и первый после него — по ним и режем
113
+ const head = breaks.filter((br) => range.comparePoint(br, 0) < 0).pop();
114
+ const tail = breaks.find((br) => range.comparePoint(br, 0) > 0);
115
+ if (!head && !tail) return null;
116
+
117
+ const original = blockTypeOf(block) ?? DEFAULT_BLOCK;
118
+
119
+ // Хвост выносим первым: он дальше по дереву, и вынос головы сдвинул бы его границы.
120
+ // Сам перенос-разделитель уходит вместе с ним — строки разъезжаются по блокам.
121
+ const cut = (from: "before" | "after", br: HTMLElement): HTMLElement => {
122
+ const part = document.createRange();
123
+
124
+ if (from === "after") {
125
+ part.setStartAfter(br);
126
+ part.setEnd(block, block.childNodes.length);
127
+ } else {
128
+ part.setStart(block, 0);
129
+ part.setEndBefore(br);
130
+ }
131
+
132
+ const piece = document.createElement(BLOCK_TYPES[original].tag);
133
+ piece.appendChild(part.extractContents());
134
+ br.remove();
135
+ fillEmptyParagraph(piece);
136
+
137
+ return piece;
138
+ };
139
+
140
+ const after = tail ? cut("after", tail) : null;
141
+ const before = head ? cut("before", head) : null;
142
+
143
+ const created = retagBlock(block, type);
144
+ if (!BLOCK_TYPES[type].inline) unwrapFormatting(created);
145
+
146
+ block.replaceWith(created);
147
+ fillEmptyParagraph(created);
148
+
149
+ if (before) created.before(before);
150
+ if (after) created.after(after);
151
+
152
+ return created;
153
+ }
154
+
155
+ // Разворачивает инлайновые теги, оставляя текст и мягкие переносы. Список снимается заранее:
156
+ // разворот внешнего тега поднимает вложенные к блоку, и обойти их нужно тоже.
157
+ function unwrapFormatting(block: HTMLElement) {
158
+ for (const el of Array.from(block.querySelectorAll<HTMLElement>("*"))) {
159
+ if (el.tagName === "BR") continue;
160
+ el.replaceWith(...Array.from(el.childNodes));
161
+ }
162
+ block.normalize();
163
+ }
164
+
165
+ /**
166
+ * Стоит ли каретка в начале своего блока — по тексту до неё, а не по узлу: началом считается
167
+ * и позиция перед вложенным форматированием, и позиция в его первом текстовом узле.
168
+ */
169
+ export function atBlockStart(editable: HTMLElement, range: Range): boolean {
170
+ if (!range.collapsed) return false;
171
+
172
+ const block = blockAt(editable, range.startContainer);
173
+ if (!block) return false;
174
+
175
+ const before = document.createRange();
176
+ before.selectNodeContents(block);
177
+ before.setEnd(range.startContainer, range.startOffset);
178
+
179
+ // Начало блока — это и начало его первой строки. Перед кареткой стоит перенос — значит
180
+ // строка не первая, и удалять нужно сам перенос, а не тип блока.
181
+ if (before.cloneContents().querySelector("br")) return false;
182
+
183
+ return before.toString().length === 0;
184
+ }
185
+
186
+ /** Enter в multiline: разбить текущий блок по каретке; хвост становится блоком типа `type`. */
187
+ export function insertParagraph(editable: HTMLElement, type: BlockType = DEFAULT_BLOCK) {
56
188
  const selection = innerSelection(editable);
57
189
  if (!selection) return;
58
190
 
59
191
  const range = selection.getRangeAt(0);
60
192
  range.deleteContents();
61
193
 
62
- // текущий абзац (ближайший <p>/<div> внутри редактора)
194
+ // текущий блок (ближайший блочный предок внутри редактора)
63
195
  let para: Node | null = range.startContainer;
64
196
  while (para && para !== editable && !isBlock(para)) para = para.parentNode;
65
197
 
66
198
  // каретка не внутри абзаца — создаём абзац сразу с видимым результатом (иначе Enter «срабатывает со 2-го раза»)
67
199
  if (!para || para === editable) {
68
- const next = emptyParagraph();
200
+ const next = createBlock(type);
69
201
  if (editable.childNodes.length === 0) {
70
202
  // пустой редактор: пустая строка-источник + новая строка с кареткой
71
203
  editable.appendChild(emptyParagraph());
@@ -85,10 +217,13 @@ export function insertParagraph(editable: HTMLElement) {
85
217
  tail.setStart(range.endContainer, range.endOffset);
86
218
  const fragment = tail.extractContents();
87
219
 
88
- const next = document.createElement("p");
220
+ const next = document.createElement(BLOCK_TYPES[type].tag);
89
221
  next.appendChild(fragment);
90
222
  (para as ChildNode).after(next);
91
223
 
224
+ // хвост уехал в блок другого типа — его правила распространяются и на содержимое
225
+ if (!BLOCK_TYPES[type].inline) unwrapFormatting(next);
226
+
92
227
  // extractContents в конце абзаца оставляет пустой текст-узел → <p></p> без заполнителя
93
228
  // (невидим/нефокусируем, каретка не встаёт). Чистим и ставим <br> в опустевшие абзацы.
94
229
  fillEmptyParagraph(para as HTMLElement);
@@ -141,6 +276,9 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
141
276
  }
142
277
 
143
278
  const block = para as HTMLElement;
279
+ // Вставка в цитату или код остаётся в них: разорвать блок посреди вставки — не то,
280
+ // чего ждут, а тип целевого блока диктует и правила его содержимого.
281
+ const type = blockTypeOf(block) ?? DEFAULT_BLOCK;
144
282
 
145
283
  // хвост текущего абзаца после каретки — выносим, чтобы вернуть в конец вставки
146
284
  const tailRange = document.createRange();
@@ -148,6 +286,8 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
148
286
  tailRange.setStart(range.startContainer, range.startOffset);
149
287
  const tail = tailRange.extractContents();
150
288
 
289
+ if (!BLOCK_TYPES[type].inline) for (const p of paras) unwrapFormatting(p);
290
+
151
291
  // первый вставляемый абзац вливается в текущий (после содержимого до каретки)
152
292
  while (paras[0].firstChild) block.appendChild(paras[0].firstChild);
153
293
 
@@ -156,15 +296,27 @@ export function insertPastedParagraphs(editable: HTMLElement, paras: HTMLElement
156
296
  return;
157
297
  }
158
298
 
159
- // остальные абзацы — отдельными <p> после текущего; хвост — в конец последнего
299
+ // остальные абзацы — отдельными блоками того же типа после текущего; хвост — в конец последнего
160
300
  let anchor: ChildNode = block;
161
301
  for (let i = 1; i < paras.length; i++) {
302
+ paras[i] = retagBlock(paras[i], type);
162
303
  anchor.after(paras[i]);
163
304
  anchor = paras[i];
164
305
  }
165
306
  paras[paras.length - 1].appendChild(tail);
166
307
  }
167
308
 
309
+ // Тот же блок, но другим тегом. Содержимое переносится как есть — правила типа к нему
310
+ // применяет вызывающий, он же знает, откуда это содержимое взялось.
311
+ function retagBlock(block: HTMLElement, type: BlockType): HTMLElement {
312
+ if (blockTypeOf(block) === type) return block;
313
+
314
+ const replacement = document.createElement(BLOCK_TYPES[type].tag);
315
+ while (block.firstChild) replacement.appendChild(block.firstChild);
316
+
317
+ return replacement;
318
+ }
319
+
168
320
  /** Обрезает пробелы по краям абзаца (после схлопывания) — у крайних текстовых узлов. */
169
321
  function trimParagraphEdges(p: HTMLElement) {
170
322
  const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
@@ -215,7 +367,12 @@ export function buildParagraphs(lines: string[], blocks: boolean): HTMLElement[]
215
367
  * Разбирает HTML из буфера обмена в абзацы `<p>`, оставляя только разрешённые инструменты.
216
368
  * Пустой результат — вставлять нечего (вызывающий откатится на простой текст).
217
369
  */
218
- export function sanitizePastedHtml(html: string, tools: FormatTool[], markers: FormatMarkers): HTMLElement[] {
370
+ export function sanitizePastedHtml(
371
+ html: string,
372
+ tools: FormatTool[],
373
+ markers: FormatMarkers,
374
+ types: BlockType[] = [DEFAULT_BLOCK]
375
+ ): HTMLElement[] {
219
376
  // убираем мусорные элементы (Word/браузер: стили, скрипты, заголовок документа)
220
377
  const source = document.createElement("template");
221
378
  source.innerHTML = html;
@@ -223,7 +380,7 @@ export function sanitizePastedHtml(html: string, tools: FormatTool[], markers: F
223
380
 
224
381
  // единый источник санитизации — deserialize (теги-синонимы → канонические, лишнее развёрнуто)
225
382
  const holder = document.createElement("template");
226
- holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true);
383
+ holder.innerHTML = deserialize(source.innerHTML, "html", tools, markers, true, types);
227
384
 
228
385
  // внешний HTML: пробелы/переводы строк между тегами не значимы — схлопываем,
229
386
  // иначе литеральные \n (pre-wrap) и отступы дают лишние переносы
@@ -1,6 +1,6 @@
1
1
  // Конфигурация форматирования: типы, набор инструментов, markdown-маркеры и Ctrl/Cmd-хоткеи.
2
2
 
3
- export type FormatTool = "bold" | "italic" | "strike" | "underline";
3
+ export type FormatTool = "bold" | "italic" | "strike" | "underline" | "spoiler" | "code";
4
4
  export type FormatStorage = "html" | "markdown";
5
5
 
6
6
  /**
@@ -15,7 +15,85 @@ export type ParagraphMode = "block" | "break";
15
15
  /** Действие редактора (не формат): вставка смайлика, очистка форматирования, отмена и повтор. */
16
16
  export type EditorAction = "emoji" | "erase" | "undo" | "redo";
17
17
 
18
- export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline"];
18
+ /**
19
+ * Тип блока верхнего уровня. Обычный абзац — такой же тип, а не «тип не задан»: тогда
20
+ * и разбор, и печать, и переключение в панели работают одинаково для всех блоков.
21
+ */
22
+ export type BlockType = "paragraph" | "quote" | "code";
23
+
24
+ export const ALL_BLOCK_TYPES: BlockType[] = ["paragraph", "quote", "code"];
25
+
26
+ /** Куда попадает содержимое, не попавшее ни в какой блок: вставка, чужой `<div>`, блуждающий текст. */
27
+ export const DEFAULT_BLOCK: BlockType = "paragraph";
28
+
29
+ interface BlockDef {
30
+ /** Канонический тег при построении и сериализации. */
31
+ tag: string;
32
+ /** Теги, распознаваемые при разборе входного HTML. */
33
+ matchTags: string[];
34
+ /** Подсказка на кнопке панели. */
35
+ title: string;
36
+ /**
37
+ * Маркер в начале каждой строки блока (цитата). Подряд идущие строки с ним — один блок:
38
+ * так эту разметку понимают и мессенджеры.
39
+ */
40
+ linePrefix?: string;
41
+ /** Ограждение блока целиком (код): строка до и строка после содержимого. */
42
+ fence?: string;
43
+ /**
44
+ * Размечается ли содержимое инлайновыми инструментами. У кода нет: написанное в нём
45
+ * буквально таким и остаётся — как и внутри моноширинного.
46
+ */
47
+ inline: boolean;
48
+ /**
49
+ * Что делает Enter внутри: `paragraph` — заканчивает блок (хвост уходит в обычный текст),
50
+ * `break` — переносит строку. Модификатор делает обратное.
51
+ */
52
+ enter: "paragraph" | "break";
53
+ }
54
+
55
+ export const BLOCK_TYPES: Record<BlockType, BlockDef> = {
56
+ paragraph: {
57
+ tag: "p",
58
+ matchTags: ["P", "DIV"],
59
+ title: "Обычный текст",
60
+ inline: true,
61
+ enter: "paragraph",
62
+ },
63
+ quote: {
64
+ tag: "blockquote",
65
+ matchTags: ["BLOCKQUOTE"],
66
+ title: "Цитата",
67
+ linePrefix: "> ",
68
+ inline: true,
69
+ // Enter заканчивает цитату, строка внутри — Shift+Enter: выход нужен чаще, чем
70
+ // продолжение, а уйти из блока иначе можно только мышью.
71
+ enter: "paragraph",
72
+ },
73
+ code: {
74
+ tag: "pre",
75
+ matchTags: ["PRE"],
76
+ title: "Блок кода",
77
+ fence: "```",
78
+ inline: false,
79
+ enter: "paragraph",
80
+ },
81
+ };
82
+
83
+ // Тег → тип блока. Собирается один раз: спрашивается на каждый узел верхнего уровня и на каждую
84
+ // единицу обхода координат каретки.
85
+ const BLOCK_TAGS: Record<string, BlockType> = (() => {
86
+ const map: Record<string, BlockType> = {};
87
+ for (const type of ALL_BLOCK_TYPES) for (const tag of BLOCK_TYPES[type].matchTags) map[tag] = type;
88
+ return map;
89
+ })();
90
+
91
+ /** Тип блока по имени тега (как `tagName`, в верхнем регистре); null — тег не блочный. */
92
+ export function blockTypeOfTag(tagName: string): BlockType | null {
93
+ return BLOCK_TAGS[tagName] ?? null;
94
+ }
95
+
96
+ export const ALL_FORMAT_TOOLS: FormatTool[] = ["bold", "italic", "strike", "underline", "spoiler", "code"];
19
97
 
20
98
  export const ALL_EDITOR_ACTIONS: EditorAction[] = ["emoji", "erase", "undo", "redo"];
21
99
 
@@ -61,6 +139,22 @@ export const FORMAT_TOOLS: Record<FormatTool, FormatToolDef> = {
61
139
  hotkey: "u",
62
140
  title: "Подчёркнутый",
63
141
  },
142
+ spoiler: {
143
+ // Своего элемента для скрытого текста в HTML нет. Берём собственный тег, а разбирать
144
+ // умеем и телеграмный: значения приходят и из его разметки.
145
+ tag: "spoiler",
146
+ matchTags: ["SPOILER", "TG-SPOILER"],
147
+ md: "||",
148
+ hotkey: "",
149
+ title: "Спойлер",
150
+ },
151
+ code: {
152
+ tag: "code",
153
+ matchTags: ["CODE"],
154
+ md: "`",
155
+ hotkey: "",
156
+ title: "Моноширинный",
157
+ },
64
158
  };
65
159
 
66
160
  interface EditorActionDef {
@@ -78,7 +172,7 @@ export const EDITOR_ACTIONS: Record<EditorAction, EditorActionDef> = {
78
172
  /** Markdown-маркер для каждого инструмента форматирования. */
79
173
  export type FormatMarkers = Record<FormatTool, string>;
80
174
 
81
- /** Маркеры по умолчанию (из FORMAT_TOOLS): bold=**, italic=*, strike=~~, underline=++. */
175
+ /** Маркеры по умолчанию (из FORMAT_TOOLS): bold=**, italic=_, strike=~, underline=__, spoiler=||, code=`. */
82
176
  export function defaultFormatMarkers(): FormatMarkers {
83
177
  const markers = {} as FormatMarkers;
84
178
  for (const tool of ALL_FORMAT_TOOLS) markers[tool] = FORMAT_TOOLS[tool].md;
@@ -114,3 +208,23 @@ export function parseFormatTools(value: string | null): FormatTool[] {
114
208
  export function parseEditorActions(value: string | null): EditorAction[] {
115
209
  return value === null ? [] : parseList(value, ALL_EDITOR_ACTIONS);
116
210
  }
211
+
212
+ /**
213
+ * Разбирает значение атрибута data-blocks. Блоки подключаются явно: без атрибута доступен
214
+ * только обычный текст. Он в наборе есть всегда — иначе блок некуда было бы вернуть.
215
+ */
216
+ export function parseBlockTypes(value: string | null): BlockType[] {
217
+ return value === null ? [DEFAULT_BLOCK] : normalizeBlockTypes(parseList(value, ALL_BLOCK_TYPES));
218
+ }
219
+
220
+ /**
221
+ * Приводит набор типов к порядку объявления. Обычный текст в наборе есть всегда: им становится
222
+ * содержимое, не попавшее ни в какой блок, и в него же блок возвращают.
223
+ */
224
+ export function normalizeBlockTypes(types: BlockType[] | undefined): BlockType[] {
225
+ if (!types) return [DEFAULT_BLOCK];
226
+
227
+ const list = ALL_BLOCK_TYPES.filter((type) => types.includes(type));
228
+
229
+ return list.includes(DEFAULT_BLOCK) ? list : [DEFAULT_BLOCK, ...list];
230
+ }
package/source/format.ts CHANGED
@@ -5,14 +5,20 @@
5
5
  // paragraphs — нормализация пробелов и приведение к абзацам <p>
6
6
 
7
7
  export {
8
+ ALL_BLOCK_TYPES,
8
9
  ALL_EDITOR_ACTIONS,
9
10
  ALL_FORMAT_TOOLS,
11
+ BLOCK_TYPES,
12
+ DEFAULT_BLOCK,
10
13
  EDITOR_ACTIONS,
11
14
  FORMAT_TOOLS,
12
15
  HOTKEY_TOOLS,
13
16
  defaultFormatMarkers,
17
+ normalizeBlockTypes,
18
+ parseBlockTypes,
14
19
  parseEditorActions,
15
20
  parseFormatTools,
21
+ type BlockType,
16
22
  type EditorAction,
17
23
  type FormatMarkers,
18
24
  type FormatStorage,
@@ -25,6 +31,8 @@ export {
25
31
  innerSelection,
26
32
  preserveCaret,
27
33
  selectionCharBounds,
34
+ editorText,
35
+ charLength,
28
36
  restoreSelection,
29
37
  mapCharOffset,
30
38
  activeFormats,
@@ -36,4 +44,13 @@ export {
36
44
  insertFormattedText,
37
45
  isFormatActive,
38
46
  } from "./selection";
39
- export { isBlock, normalizeWhitespace, normalizeParagraphs, ensureParagraphs } from "./paragraphs";
47
+ export {
48
+ isBlock,
49
+ blockAt,
50
+ blockTypeOf,
51
+ blocksInRange,
52
+ createBlock,
53
+ normalizeWhitespace,
54
+ normalizeParagraphs,
55
+ ensureParagraphs,
56
+ } from "./paragraphs";
package/source/index.ts CHANGED
@@ -2,10 +2,14 @@ export { default } from "./richeditor";
2
2
  export * from "./richeditor";
3
3
  export { EMOJIS, EMOJI_GROUPS, type EmojiGroup } from "./emoji";
4
4
  export {
5
+ ALL_BLOCK_TYPES,
5
6
  ALL_EDITOR_ACTIONS,
6
7
  ALL_FORMAT_TOOLS,
8
+ BLOCK_TYPES,
9
+ DEFAULT_BLOCK,
7
10
  EDITOR_ACTIONS,
8
11
  FORMAT_TOOLS,
12
+ parseBlockTypes,
9
13
  parseEditorActions,
10
14
  parseFormatTools,
11
15
  defaultFormatMarkers,
@@ -13,6 +17,7 @@ export {
13
17
  selectionCharBounds,
14
18
  restoreSelection,
15
19
  preserveCaret,
20
+ type BlockType,
16
21
  type EditorAction,
17
22
  type FormatTool,
18
23
  type FormatStorage,