@brandup/ui-richeditor 1.0.44 → 1.0.47

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.
@@ -1,6 +1,53 @@
1
1
  // Стили RichEditor: редактируемая область, отображение тегов форматирования
2
2
  // и общий тулбар форматирования (живёт в document.body, см. ./toolbar).
3
- // CSS-переменные --input-* предоставляет @brandup/ui-kit; заданы fallback'и для standalone-использования.
3
+
4
+ // Собственные переменные пакета и их значения по умолчанию. Держим их здесь, а не по месту:
5
+ // так виден весь набор целиком, и менять значение приходится в одном месте, а не во всех, где
6
+ // переменная встретилась (у кода их было по две на каждую).
7
+ //
8
+ // Переменные --input-*, --hover--input-*, --focus--input-*, --placeholder-* и --svg-* приходят
9
+ // из @brandup/ui-kit и здесь не объявляются: объявив их в :root, пакет перекрыл бы значения
10
+ // кита. Их запасные значения остаются по месту — они на случай использования пакета без кита.
11
+ :root {
12
+ // цитата: линия слева, как её рисуют мессенджеры
13
+ --richeditor-quote-line: rgba(0, 0, 0, 0.2);
14
+
15
+ // код — и моноширинный, и блок
16
+ --richeditor-code-fill: rgba(0, 0, 0, 0.06);
17
+ --richeditor-code-font: ui-monospace, "Cascadia Mono", Consolas, monospace;
18
+
19
+ // спойлер: у получателя текст закрыт, в поле показан плашкой
20
+ --richeditor-spoiler-fill: rgba(0, 0, 0, 0.14);
21
+
22
+ // Правка адреса. В контейнере хоста панель на это время растягивается по нему, и ширину поля
23
+ // задавать не нужно — оно занимает всё, что осталось от кнопок. В document.body растягиваться
24
+ // не по чему, там ширину задаёт первая переменная; вторая ограничивает растянутую панель.
25
+ --richeditor-link-width: 320px;
26
+ --richeditor-link-max-width: 500px;
27
+
28
+ // ссылка
29
+ --richeditor-link-color: #2481cc;
30
+ // отступ подчёркивания от базовой линии; auto — на усмотрение шрифта
31
+ --richeditor-link-underline-offset: auto;
32
+
33
+ // Место под подчёркивание последней строки. Подчёркивание рисуется, но в раскладке места
34
+ // не занимает, а краевые отступы абзаца обнулены — на единственной строке абзац разом
35
+ // первый и последний, и снизу не остаётся ничего. Прокручиваемая коробка режет линию
36
+ // по своему краю, и отступом от базовой линии это не лечится: он только придвигает её
37
+ // к этому краю. Место берётся здесь.
38
+ --richeditor-underline-room: 2px;
39
+
40
+ // Панель. Кнопка — и её же размера кнопки поповера адреса; по ней же высота поля адреса.
41
+ // Значение своё, а не считанное от высоты поля ввода: панель живёт над полем, но размером
42
+ // с ним не связана, а вычитание отступов задавало бы размер дважды и могло не сойтись.
43
+ --richeditor-toolbar-padding: 3px;
44
+ --richeditor-toolbar-button-size: 34px;
45
+
46
+ // панель смайликов
47
+ --richeditor-emoji-size: 32px;
48
+ // строк в группе — панель ставит своё значение на каждую группу, здесь оно запасное
49
+ --richeditor-emoji-rows: 8;
50
+ }
4
51
 
5
52
  // .ui-richeditor — это сам редактируемый элемент (обёртки нет, тулбар общий в body)
6
53
  .ui-richeditor {
@@ -11,6 +58,8 @@
11
58
  word-wrap: anywhere;
12
59
  white-space: pre-wrap; // сохраняем пробелы (в т.ч. ведущие/повторяющиеся)
13
60
 
61
+ --link-weight: inherit;
62
+
14
63
  // абзацы — без user-agent-полей, иначе первый <p> «съезжает» вниз (как новая строка)
15
64
  & p {
16
65
  margin: 0;
@@ -20,8 +69,9 @@
20
69
  padding-top: 0;
21
70
  }
22
71
 
72
+ // не ноль: последней строке нужно место под подчёркивание — см. --richeditor-underline-room
23
73
  &:last-child {
24
- padding-bottom: 0;
74
+ padding-bottom: var(--richeditor-underline-room);
25
75
  }
26
76
  }
27
77
 
@@ -30,7 +80,7 @@
30
80
  & blockquote {
31
81
  margin: 0;
32
82
  padding: 5px 0 5px 10px;
33
- border-left: 3px solid var(--richeditor-quote-line, rgba(0, 0, 0, 0.2));
83
+ border-left: 3px solid var(--richeditor-quote-line);
34
84
  }
35
85
 
36
86
  // Блок кода: подложка на всю строку и моноширинный шрифт. Собственные поля тут нужны —
@@ -39,8 +89,8 @@
39
89
  margin: 5px 0;
40
90
  padding: 5px 8px;
41
91
  border-radius: 3px;
42
- background-color: var(--richeditor-code-fill, rgba(0, 0, 0, 0.06));
43
- font-family: var(--richeditor-code-font, ui-monospace, "Cascadia Mono", Consolas, monospace);
92
+ background-color: var(--richeditor-code-fill);
93
+ font-family: var(--richeditor-code-font);
44
94
  font-size: 0.92em;
45
95
  // содержимое переносится по границе редактора, как и обычный текст
46
96
  white-space: pre-wrap;
@@ -74,13 +124,20 @@
74
124
  text-decoration: underline;
75
125
  }
76
126
 
127
+ & a {
128
+ color: var(--richeditor-link-color);
129
+ text-decoration: underline;
130
+ text-underline-offset: var(--richeditor-link-underline-offset);
131
+ cursor: text; // текст ссылки правят на месте, переходить по ней из редактора некуда
132
+ }
133
+
77
134
  // Спойлер: у получателя текст закрыт до нажатия, а в поле его прячут не от автора —
78
135
  // показываем плашкой, чтобы было видно и что написано, и что оно будет скрыто.
79
136
  & spoiler,
80
137
  & tg-spoiler {
81
138
  border-radius: 3px;
82
139
  padding: 0 2px;
83
- background-color: var(--richeditor-spoiler-fill, rgba(0, 0, 0, 0.14));
140
+ background-color: var(--richeditor-spoiler-fill);
84
141
  }
85
142
 
86
143
  // Строку не раздвигает. Наследовать межстрочный интервал для этого мало: у моноширинного
@@ -91,8 +148,8 @@
91
148
  & code {
92
149
  border-radius: 3px;
93
150
  padding: 0 3px;
94
- background-color: var(--richeditor-code-fill, rgba(0, 0, 0, 0.06));
95
- font-family: var(--richeditor-code-font, ui-monospace, "Cascadia Mono", Consolas, monospace);
151
+ background-color: var(--richeditor-code-fill);
152
+ font-family: var(--richeditor-code-font);
96
153
  font-size: 0.92em;
97
154
  line-height: 0;
98
155
  }
@@ -137,25 +194,18 @@
137
194
  // По умолчанию в document.body (position: fixed, координаты задаёт ./toolbar);
138
195
  // с классом .in-container — позиционируется над контейнером-родителем (position: absolute).
139
196
  .ui-richeditor-toolbar {
197
+ // Обёртка: только положение. Вид и содержимое — на коробке внутри, поэтому её размер
198
+ // (он меняется вместе с содержимым) точку привязки не трогает, а выпадающие слои висят
199
+ // на обёртке и её коробкой не обрезаются.
140
200
  display: none;
141
201
  position: fixed;
142
202
  z-index: 1000;
143
- flex-flow: row nowrap;
144
- align-items: center;
145
- gap: 2px;
146
- padding: 3px;
147
- background-color: var(--input-fill, #fff);
148
- border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
149
- border-radius: var(--input-border-radius, 0);
150
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
151
- // панель тоже монтируется в разметку хоста — курсор поля ввода ей не наследовать
152
- cursor: default;
153
203
 
154
204
  --svg-size: 18px;
155
205
  --svg-fill: var(--input-color, #222);
156
206
 
157
207
  &.visible {
158
- display: flex;
208
+ display: block;
159
209
  }
160
210
 
161
211
  // позиционирование относительно контейнера-родителя (например, .ui-textbox)
@@ -164,6 +214,24 @@
164
214
  left: 0;
165
215
  bottom: 100%;
166
216
  margin-bottom: 6px;
217
+
218
+ &.link-editing {
219
+ right: 0;
220
+ }
221
+ }
222
+
223
+ & .toolbar-body {
224
+ display: flex;
225
+ flex-flow: row nowrap;
226
+ align-items: center;
227
+ gap: 2px;
228
+ padding: var(--richeditor-toolbar-padding);
229
+ background-color: var(--input-fill, #fff);
230
+ border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
231
+ border-radius: var(--input-border-radius, 0);
232
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
233
+ // панель тоже монтируется в разметку хоста — курсор поля ввода ей не наследовать
234
+ cursor: default;
167
235
  }
168
236
 
169
237
  // разделитель между инструментами форматирования и действиями
@@ -174,12 +242,12 @@
174
242
  background-color: var(--input-border-color, #aaa);
175
243
  }
176
244
 
177
- & .format-button,
178
- & .block-button,
179
- & .host-button,
180
- & .action-button {
181
- width: calc(var(--input-height, 46px) - 12px);
182
- height: calc(var(--input-height, 46px) - 12px);
245
+ // Оформление у всех кнопок панели общее, поэтому и класс общий: перечислять их по одной
246
+ // значило бы вспоминать про этот список на каждую новую.
247
+ & .toolbar-button {
248
+ flex: 0 0 auto;
249
+ width: var(--richeditor-toolbar-button-size);
250
+ height: var(--richeditor-toolbar-button-size);
183
251
  border-radius: calc(var(--input-border-radius, 0) - 2px);
184
252
  border: 0;
185
253
  background: var(--input-toolbar-button-fill, transparent);
@@ -215,6 +283,67 @@
215
283
  }
216
284
  }
217
285
  }
286
+
287
+ // Правка адреса ссылки: панель показывает поле ввода вместо кнопок. Не выпадающий слой —
288
+ // то же место, та же коробка: позиционировать и ужимать ничего не приходится.
289
+ & .link-row {
290
+ display: none;
291
+ flex: 1 1 auto;
292
+ min-width: 0; // иначе поле не даст строке сжаться уже своей ширины по содержимому
293
+ align-items: center;
294
+ gap: 2px;
295
+ }
296
+
297
+ &.link-editing {
298
+ max-width: var(--richeditor-link-max-width);
299
+
300
+ // панели в document.body растягиваться не по чему — ширину задаём сами
301
+ &:not(.in-container) .toolbar-body {
302
+ width: var(--richeditor-link-width);
303
+ max-width: calc(100vw - 8px);
304
+ }
305
+
306
+ // кнопки на это время убраны, а не сдвинуты: панель узкая, рядом им не поместиться.
307
+ // Кнопки самой строки адреса лежат в ней и под это правило не попадают.
308
+ & > .toolbar-body > .toolbar-button,
309
+ & .split {
310
+ display: none;
311
+ }
312
+
313
+ & .link-row {
314
+ display: flex;
315
+ }
316
+ }
317
+
318
+ & .link-input {
319
+ // Тянется, а не «сто процентов»: процент считался бы от строки, а её ширина сама зависит
320
+ // от поля — круг, который браузер разрывает собственной шириной input. Ноль в min-width
321
+ // разрешает сжиматься: у поля ввода она по умолчанию не нулевая, и на узком экране
322
+ // строка вылезала бы за панель.
323
+ flex: 1 1 auto;
324
+ min-width: 0;
325
+ // В одну строку с кнопками — высота у них общая. Нижней границей, а не точной: поле живёт
326
+ // в чужой типографике, и от крупного шрифта ему нужно вырасти, а не обрезать строку.
327
+ min-height: var(--richeditor-toolbar-button-size);
328
+ box-sizing: border-box; // высота вместе с полями и рамкой, иначе поле выше кнопок
329
+ padding: 0 8px;
330
+ // оформление у поля общее с полями ввода кита — своих переменных ему не нужно
331
+ border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
332
+ border-radius: calc(var(--input-border-radius, 0) - 2px);
333
+ font: inherit;
334
+ color: inherit;
335
+ background: none;
336
+ outline: none;
337
+
338
+ &:focus {
339
+ border-color: var(--focus--input-border-color, #222);
340
+ }
341
+ }
342
+
343
+ // снимать нечего, пока ссылки под кареткой нет
344
+ & .link-remove[hidden] {
345
+ display: none;
346
+ }
218
347
  }
219
348
 
220
349
  // панель вставки смайликов — попап под кнопкой тулбара (открытием/закрытием управляет
@@ -247,7 +376,7 @@
247
376
  flex-flow: row wrap;
248
377
  content-visibility: auto;
249
378
  // высота ненарисованной группы; auto — дальше браузер помнит фактическую
250
- contain-intrinsic-size: auto calc(var(--emoji-rows, 8) * var(--richeditor-emoji-size, 32px));
379
+ contain-intrinsic-size: auto calc(var(--richeditor-emoji-rows) * var(--richeditor-emoji-size));
251
380
  }
252
381
 
253
382
  & .emoji-group + .emoji-group {
@@ -258,8 +387,8 @@
258
387
  }
259
388
 
260
389
  & .emoji {
261
- width: var(--richeditor-emoji-size, 32px);
262
- height: var(--richeditor-emoji-size, 32px);
390
+ width: var(--richeditor-emoji-size);
391
+ height: var(--richeditor-emoji-size);
263
392
  padding: 0;
264
393
  border: 0;
265
394
  background: transparent;
@@ -1,7 +1,7 @@
1
1
  import "./richeditor.less"; // стили редактора и панели форматирования
2
2
 
3
3
  import { DOM, UIElementBound } from "@brandup/ui";
4
- import { IS_TOUCH_DEVICE } from "@brandup/ui-kit";
4
+ import { IS_TOUCH_DEVICE, PopupManager } from "@brandup/ui-kit";
5
5
  import {
6
6
  ALL_FORMAT_TOOLS,
7
7
  BLOCK_TYPES,
@@ -35,6 +35,8 @@ import {
35
35
  selectionCharBounds,
36
36
  serialize,
37
37
  toggleFormat,
38
+ applyLink as applyLinkTo,
39
+ linkAt,
38
40
  type BlockType,
39
41
  type EditorAction,
40
42
  type FormatMarkers,
@@ -56,9 +58,9 @@ import {
56
58
  trimSelectionWhitespace,
57
59
  } from "./editing";
58
60
  import { EditorHistory } from "./history";
59
- import { formatToolbar, type ToolbarButton } from "./toolbar";
61
+ import { formatToolbar, TOOLBAR_CLASS, type ToolbarButton } from "./toolbar";
60
62
 
61
- export { TOOLBAR_CLASS, formatToolbar, type ToolbarHost, type ToolbarButton } from "./toolbar";
63
+ export { formatToolbar, TOOLBAR_CLASS, type ToolbarHost, type ToolbarButton } from "./toolbar";
62
64
 
63
65
  export const ROOT_CLASS = "ui-richeditor"; // редактируемый элемент, к нему привязан UIElement
64
66
  // Содержимое временно невыделяемо: по странице тянут выделение, начатое вне редактора (см. __holdSelectable).
@@ -70,6 +72,9 @@ const NAV_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "En
70
72
  // Код — единственное, что есть и инструментом, и типом блока: имя одно на оба (см. applyCode).
71
73
  const CODE = "code";
72
74
 
75
+ // Ссылка — единственный инструмент с данными: адрес задаётся не переключением (см. applyLink).
76
+ const LINK: FormatTool = "link";
77
+
73
78
  // нативные правки (печать/удаление), состояние до которых запоминаем для собственного undo;
74
79
  // вставка/перетаскивание и Enter обрабатываются отдельно, undo/redo — на keydown
75
80
  const NATIVE_EDIT_TYPES = new Set([
@@ -122,8 +127,9 @@ export interface RichEditorOptions {
122
127
  /** Что делает Enter: новый абзац (по умолчанию) или мягкий перенос, как в мессенджерах. */
123
128
  paragraph?: ParagraphMode;
124
129
  /**
125
- * Типы блоков в многострочном режиме: цитата, блок кода (по умолчанию только обычный текст).
126
- * Обычный текст в наборе есть всегда в него блок возвращают.
130
+ * Block types of the multiline mode: quote, code block (all of them by default). A field that
131
+ * has no use for them is limited by an empty list. Plain text is always in the set — a block
132
+ * is turned back into it.
127
133
  */
128
134
  blocks?: BlockType[];
129
135
  /**
@@ -166,6 +172,13 @@ type RichEditorEvents = {
166
172
  export default class RichEditor extends UIElementBound<RichEditorEvents> {
167
173
  readonly editable: HTMLElement;
168
174
  readonly format: boolean;
175
+ /**
176
+ * Объявленный набор инструментов: им разбирается и сохраняется значение. Не то же, что набор
177
+ * кнопок ({@link formatTools}) — показывать разметку редактор обязан и там, где её не
178
+ * переключить. Так же разведены и блоки ({@link blockTypes} против {@link blockTools}).
179
+ */
180
+ readonly formatTypes: FormatTool[];
181
+ /** Инструменты в панели: в режиме только для чтения их нет — переключать разметку там нечем. */
169
182
  readonly formatTools: FormatTool[];
170
183
  readonly editorActions: EditorAction[];
171
184
  readonly formatStorage: FormatStorage;
@@ -185,7 +198,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
185
198
  // Каретка, снятая при отпускании фокуса: без фокуса браузер может убрать и выделение,
186
199
  // а вставке из попапа нужно место — см. releaseFocus.
187
200
  private __detachedCaret: [number, number] | null = null;
188
- private __emojiHold: (() => void) | null = null; // правка придержана на время панели смайликов
201
+ private __emojiHold: (() => void) | null = null; // правка придержана на время попапа смайликов
202
+ private __emojiPicker: HTMLElement | null = null; // попап, открытый этим редактором — закрыть его в destroy
189
203
  private __releasingFocus = false; // фокус снимаем сами, а не уходят из поля — см. releaseFocus
190
204
  // Компонент снят. Удержание правки переживает снятие (окно хоста закрывается позже), и по его
191
205
  // снятию трогать содержимое уже нельзя — редактора нет.
@@ -202,8 +216,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
202
216
  const format = !!options.format;
203
217
  const multiline = !!options.multiline;
204
218
  const readonly = !!options.readonly;
205
- // форматирование недоступно в режиме только для чтения
206
- const tools = format && !readonly ? (options.tools ?? ALL_FORMAT_TOOLS.slice()) : [];
219
+ // Объявленный набор от readonly не зависит: значение обязано разбираться и показываться
220
+ // и там, где разметку не переключить, иначе редактор для чтения показывал бы вместо
221
+ // жирного сырые звёздочки. Кнопки — уже без него.
222
+ const types = format ? (options.tools ?? ALL_FORMAT_TOOLS.slice()) : [];
223
+ const tools = readonly ? [] : types;
207
224
  // действия подключаются явно — иначе панель у существующих хостов молча обзавелась бы кнопками
208
225
  const actions = format && !readonly ? (options.actions ?? []) : [];
209
226
 
@@ -217,6 +234,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
217
234
  this.__window = editable.ownerDocument.defaultView ?? window;
218
235
  this.__opts = options;
219
236
  this.format = format;
237
+ this.formatTypes = types;
220
238
  this.formatTools = tools;
221
239
  this.editorActions = actions;
222
240
  this.formatStorage = options.storage === "markdown" ? "markdown" : "html";
@@ -265,8 +283,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
265
283
  private get __valueStorage(): FormatStorage {
266
284
  return this.format ? this.formatStorage : "markdown";
267
285
  }
286
+ // разбор и сохранение значения — по объявленному набору, а не по набору кнопок:
287
+ // в readonly кнопок нет, а разметка значения от этого не меняется
268
288
  private get __valueTools(): FormatTool[] {
269
- return this.format ? this.formatTools : [];
289
+ return this.formatTypes;
270
290
  }
271
291
 
272
292
  // --- публичный API ---
@@ -322,21 +342,37 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
322
342
  }
323
343
 
324
344
  /**
325
- * Показать панель вставки смайлика у своей кнопки — когда она живёт не в тулбаре, а в разметке
326
- * хоста (например справа от поля ввода). Вызывать из обработчика `click`, погасив всплытие:
327
- * иначе попап закроется тем же кликом, которым открылся.
345
+ * Показать попап смайликов у кнопки.
328
346
  *
329
- * @param initiator Кнопка, у которой показывается панель.
330
- * @param container Куда монтировать панель; по умолчанию родитель кнопки.
347
+ * Попап приносит владелец у панели форматирования свой, у поля сообщения свой (собирает
348
+ * их {@link createEmojiPicker}). Редактор берёт на себя только своё: придержать правку,
349
+ * поставить каретку, отпустить фокус и убрать панель, если попап раскрывается не из неё.
350
+ *
351
+ * Вызывать из обработчика `click`, погасив всплытие: иначе попап закроется тем же кликом,
352
+ * которым открылся. Возвращает false, если попап этим нажатием закрылся.
331
353
  */
332
- openEmojiPicker(initiator: HTMLElement, container?: HTMLElement): void {
333
- if (this.readonly) return;
334
-
335
- const target = container ?? initiator.parentElement;
336
- if (!target) return;
354
+ openEmojiPicker(picker: HTMLElement, initiator: HTMLElement): boolean {
355
+ if (this.readonly) return false;
356
+
357
+ // Попап у кнопки хоста — самостоятельный слой, и показывать его вместе с панелью нельзя:
358
+ // это два всплывающих окна над одним полем. Попап самой панели — её собственный слой,
359
+ // прятать его носителя незачем и нечем.
360
+ const inToolbar = !!picker.closest(`.${TOOLBAR_CLASS}`);
361
+
362
+ PopupManager.open(picker, {
363
+ initiator,
364
+ onClose: () => {
365
+ this.__emojiPicker = null;
366
+ this.__emojiHold?.();
367
+ this.__emojiHold = null;
368
+ if (!inToolbar) formatToolbar.resume();
369
+ },
370
+ });
371
+ // повторное нажатие по кнопке попап закрывает — держать и придерживать больше нечего
372
+ if (!PopupManager.isOpened(picker)) return false;
337
373
 
338
- // повторное нажатие по кнопке панель закрывает — держать и придерживать больше нечего
339
- if (!formatToolbar.openEmoji(this, initiator, target)) return;
374
+ this.__emojiPicker = picker;
375
+ if (!inToolbar) formatToolbar.suspend(this);
340
376
 
341
377
  // Правку придерживаем на всё время панели: фокус мы отпустим, а снятие фокуса — не конец
342
378
  // ввода. Иначе нормализация обрезала бы пробел у каретки, и символ встал бы вплотную.
@@ -346,21 +382,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
346
382
  // некуда. В конец её ставит focus(true), и только если её действительно не было: снятую
347
383
  // при отпускании фокуса он вернёт на место, а иначе символ уезжал бы в конец сообщения
348
384
  // с каждым открытием панели.
349
- //
350
- // Строго после открытия панели: фокус показывает тулбар, а придержать его панель успевает
351
- // только когда открыта сама.
352
385
  if (!this.selection) this.focus(true);
353
386
 
354
387
  // Панель — слой над полем, а не вместо него: каретку видно, и видно, куда встанет символ.
355
388
  // На сенсорном устройстве фокус вместо этого поднимает клавиатуру, которая саму панель
356
389
  // и закрывает, — там его отпускаем, а каретку вернёт вставка (см. keepFocus).
357
390
  if (!this.keepFocus) this.releaseFocus();
358
- }
359
391
 
360
- /** Панель смайликов закрылась: снимаем удержание правки, взятое на время её работы. */
361
- onEmojiClosed(): void {
362
- this.__emojiHold?.();
363
- this.__emojiHold = null;
392
+ return true;
364
393
  }
365
394
 
366
395
  getLength(): number {
@@ -525,12 +554,63 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
525
554
  isToolEnabled(tool: FormatTool): boolean {
526
555
  if (this.readonly || !this.formatTools.includes(tool)) return false;
527
556
  if (tool === CODE) return true;
557
+ if (this.currentBlock === CODE || this.isToolActive(CODE)) return false;
528
558
 
529
- return this.currentBlock !== CODE && !this.isToolActive(CODE);
559
+ // Ссылке нужен текст, который ею станет: оборачивать нечего — и делать нечего. Каретка
560
+ // в слове за текст считается (формат применяется к слову целиком), в готовой ссылке —
561
+ // тоже: её адрес правят той же кнопкой.
562
+ return tool !== LINK || this.__hasLinkTarget();
563
+ }
564
+
565
+ /** Есть ли что делать ссылкой: текст под выделением или ссылка, в которой стоит каретка. */
566
+ private __hasLinkTarget(): boolean {
567
+ const target = this.__formatTarget();
568
+ if (!target) return false;
569
+
570
+ return !target.range.collapsed || !!linkAt(this.editable, target.range);
571
+ }
572
+
573
+ /**
574
+ * Адрес ссылки под кареткой; пусто — каретка не в ссылке.
575
+ *
576
+ * Состояние ссылки — не «включена», а «вот этот адрес»: панели нужен он сам, иначе править
577
+ * существующую ссылку было бы нечем.
578
+ */
579
+ get currentLink(): string {
580
+ const selection = this.selection;
581
+ if (!selection) return "";
582
+
583
+ return linkAt(this.editable, selection.getRangeAt(0))?.getAttribute("href") ?? "";
584
+ }
585
+
586
+ /**
587
+ * Ставит ссылку на выделение, меняет адрес у той, в которой стоит каретка, либо снимает её —
588
+ * пустым адресом. Оборачивать нечего — не делает ничего: ссылка это оформление текста,
589
+ * а не вставка (см. {@link isToolEnabled}, там же гаснет и кнопка).
590
+ *
591
+ * Не переключатель, в отличие от {@link applyFormat}: у ссылки есть данные, и повторное
592
+ * применение с другим адресом — правка, а не снятие.
593
+ */
594
+ applyLink(url: string): void {
595
+ if (!this.isToolEnabled(LINK)) return;
596
+
597
+ // Правит по выделению в поле. Поле адреса в панели забирает фокус, поэтому каретку она
598
+ // снимает при открытии и возвращает перед вызовом — см. openLink в ./toolbar.
599
+ const target = this.__formatTarget();
600
+ if (!target) return;
601
+
602
+ this.__history?.record("op");
603
+ applyLinkTo(this.editable, target.range, url.trim(), target.selection, target.original());
604
+
605
+ this.__pendingFormats.clear();
606
+ this.__emitChange();
607
+ formatToolbar.refresh();
530
608
  }
531
609
 
532
610
  /** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
533
611
  applyFormat(tool: FormatTool): void {
612
+ // у ссылки есть адрес, а переключением его не задать — она ставится через applyLink
613
+ if (tool === LINK) return;
534
614
  if (!this.isToolEnabled(tool)) return;
535
615
 
536
616
  let target = this.__formatTarget();
@@ -836,6 +916,13 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
836
916
  this.__disposed = true;
837
917
  this.flushChange(); // хост не должен остаться с устаревшей копией значения
838
918
  this.__abort.abort();
919
+
920
+ // Попап смайликов мог остаться открытым, а показывали его мы — своим он бывает и у хоста
921
+ // (см. openEmojiPicker). Оставленный, он держал бы PopupManager на удалённом элементе:
922
+ // на body висел бы класс открытого попапа и слушатель закрытия, а на узком экране
923
+ // страница осталась бы непрокручиваемой.
924
+ if (this.__emojiPicker && PopupManager.isOpened(this.__emojiPicker)) PopupManager.close();
925
+
839
926
  formatToolbar.detach(this);
840
927
 
841
928
  // элемент передан хостом — не удаляем его, только снимаем оформление редактора
@@ -963,6 +1050,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
963
1050
  this.editable.classList.toggle(UNSELECTABLE_CLASS, !own);
964
1051
  }
965
1052
 
1053
+ /**
1054
+ * Снимает запрет, когда нажали в самом редакторе.
1055
+ *
1056
+ * Для касания, где браузер начинает выделять слово прямо на жесте: к этому моменту содержимое
1057
+ * обязано быть выделяемым, а {@link __holdSelectable} снимет запрет только по мышиному
1058
+ * нажатию — оно приходит уже после жеста, а у долгого нажатия не приходит вовсе.
1059
+ */
1060
+ private __releaseSelectableAt(target: EventTarget | null) {
1061
+ if (this.editable.contains(target as Node | null)) this.editable.classList.remove(UNSELECTABLE_CLASS);
1062
+ }
1063
+
966
1064
  /**
967
1065
  * Снимает запрет, когда выделение перестало задевать редактор.
968
1066
  *
@@ -1000,6 +1098,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1000
1098
  // По изменению выделения проверять нельзя: пока запрет действует, выделение до редактора
1001
1099
  // не доходит — проверка увидела бы «не задевает» и сняла запрет сама. Только по концу протяжки.
1002
1100
  doc.addEventListener("mouseup", () => this.__releaseSelectable(), { signal, capture: true });
1101
+ // Касание: запрет только снимаем, не ставим. Протяжки выделения через страницу на касании
1102
+ // нет — там его ведут за собственные ручки, — а вот слово по двойному нажатию браузер
1103
+ // выделяет сам, ещё на жесте. Мышиные события к нему приезжают уже после (а у долгого
1104
+ // нажатия их и вовсе нет), и снятого по ним запрета жест бы не дождался.
1105
+ doc.addEventListener("touchstart", (e) => this.__releaseSelectableAt(e.target), {
1106
+ signal,
1107
+ capture: true,
1108
+ passive: true,
1109
+ });
1003
1110
 
1004
1111
  editable.addEventListener(
1005
1112
  "mousedown",
@@ -1106,7 +1213,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1106
1213
  if (tool) {
1107
1214
  e.preventDefault();
1108
1215
  e.stopPropagation();
1109
- if (this.formatTools.includes(tool)) this.applyFormat(tool);
1216
+
1217
+ if (this.formatTools.includes(tool)) {
1218
+ // у ссылки сперва спрашивается адрес — тем же полем в панели, что и у кнопки
1219
+ if (tool === LINK) formatToolbar.openLinkFor(this);
1220
+ else this.applyFormat(tool);
1221
+ }
1110
1222
  return;
1111
1223
  }
1112
1224
  }
@@ -1131,15 +1243,19 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1131
1243
  // перемещение каретки — выход из режима набора
1132
1244
  if (NAV_KEYS.includes(e.key)) this.__clearPendingFormats();
1133
1245
 
1134
- const isChar = e.key.length === 1;
1246
+ // A character, not a shortcut: Cmd is the same modifier as Ctrl, just on another platform.
1247
+ // Without it Cmd+C on a Mac would look like typing the letter "c" — copying would be
1248
+ // swallowed by the readonly guard, and the host filter would reject copy, paste and
1249
+ // select-all alike.
1250
+ const isChar = e.key.length === 1 && !e.ctrlKey && !e.metaKey;
1135
1251
 
1136
- if (this.readonly && isChar && !e.ctrlKey) {
1252
+ if (this.readonly && isChar) {
1137
1253
  e.preventDefault();
1138
1254
  e.stopPropagation();
1139
1255
  return;
1140
1256
  }
1141
1257
 
1142
- if (isChar && !e.ctrlKey && this.__opts.filterChar && !this.__opts.filterChar(e.key)) {
1258
+ if (isChar && this.__opts.filterChar && !this.__opts.filterChar(e.key)) {
1143
1259
  e.preventDefault();
1144
1260
  e.stopPropagation();
1145
1261
  this.__reject();
@@ -1275,12 +1391,13 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1275
1391
  ensureParagraphs(this.editable); // заполнить пустые абзацы, убрать краевые <br>
1276
1392
  } else {
1277
1393
  // инлайн: абзацы и переносы → пробелы, форматирование сохраняем
1278
- const fragment = document.createDocumentFragment();
1394
+ const doc = this.editable.ownerDocument;
1395
+ const fragment = doc.createDocumentFragment();
1279
1396
  paras.forEach((p, index) => {
1280
- if (index > 0) fragment.appendChild(document.createTextNode(" "));
1397
+ if (index > 0) fragment.appendChild(doc.createTextNode(" "));
1281
1398
  while (p.firstChild) fragment.appendChild(p.firstChild);
1282
1399
  });
1283
- fragment.querySelectorAll("br").forEach((br) => br.replaceWith(document.createTextNode(" ")));
1400
+ fragment.querySelectorAll("br").forEach((br) => br.replaceWith(doc.createTextNode(" ")));
1284
1401
 
1285
1402
  caret = start + (fragment.textContent ?? "").length;
1286
1403
  range.insertNode(fragment);