@brandup/ui-richeditor 1.0.45 → 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([
@@ -167,6 +172,13 @@ type RichEditorEvents = {
167
172
  export default class RichEditor extends UIElementBound<RichEditorEvents> {
168
173
  readonly editable: HTMLElement;
169
174
  readonly format: boolean;
175
+ /**
176
+ * Объявленный набор инструментов: им разбирается и сохраняется значение. Не то же, что набор
177
+ * кнопок ({@link formatTools}) — показывать разметку редактор обязан и там, где её не
178
+ * переключить. Так же разведены и блоки ({@link blockTypes} против {@link blockTools}).
179
+ */
180
+ readonly formatTypes: FormatTool[];
181
+ /** Инструменты в панели: в режиме только для чтения их нет — переключать разметку там нечем. */
170
182
  readonly formatTools: FormatTool[];
171
183
  readonly editorActions: EditorAction[];
172
184
  readonly formatStorage: FormatStorage;
@@ -186,7 +198,8 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
186
198
  // Каретка, снятая при отпускании фокуса: без фокуса браузер может убрать и выделение,
187
199
  // а вставке из попапа нужно место — см. releaseFocus.
188
200
  private __detachedCaret: [number, number] | null = null;
189
- private __emojiHold: (() => void) | null = null; // правка придержана на время панели смайликов
201
+ private __emojiHold: (() => void) | null = null; // правка придержана на время попапа смайликов
202
+ private __emojiPicker: HTMLElement | null = null; // попап, открытый этим редактором — закрыть его в destroy
190
203
  private __releasingFocus = false; // фокус снимаем сами, а не уходят из поля — см. releaseFocus
191
204
  // Компонент снят. Удержание правки переживает снятие (окно хоста закрывается позже), и по его
192
205
  // снятию трогать содержимое уже нельзя — редактора нет.
@@ -203,8 +216,11 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
203
216
  const format = !!options.format;
204
217
  const multiline = !!options.multiline;
205
218
  const readonly = !!options.readonly;
206
- // форматирование недоступно в режиме только для чтения
207
- 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;
208
224
  // действия подключаются явно — иначе панель у существующих хостов молча обзавелась бы кнопками
209
225
  const actions = format && !readonly ? (options.actions ?? []) : [];
210
226
 
@@ -218,6 +234,7 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
218
234
  this.__window = editable.ownerDocument.defaultView ?? window;
219
235
  this.__opts = options;
220
236
  this.format = format;
237
+ this.formatTypes = types;
221
238
  this.formatTools = tools;
222
239
  this.editorActions = actions;
223
240
  this.formatStorage = options.storage === "markdown" ? "markdown" : "html";
@@ -266,8 +283,10 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
266
283
  private get __valueStorage(): FormatStorage {
267
284
  return this.format ? this.formatStorage : "markdown";
268
285
  }
286
+ // разбор и сохранение значения — по объявленному набору, а не по набору кнопок:
287
+ // в readonly кнопок нет, а разметка значения от этого не меняется
269
288
  private get __valueTools(): FormatTool[] {
270
- return this.format ? this.formatTools : [];
289
+ return this.formatTypes;
271
290
  }
272
291
 
273
292
  // --- публичный API ---
@@ -323,21 +342,37 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
323
342
  }
324
343
 
325
344
  /**
326
- * Показать панель вставки смайлика у своей кнопки — когда она живёт не в тулбаре, а в разметке
327
- * хоста (например справа от поля ввода). Вызывать из обработчика `click`, погасив всплытие:
328
- * иначе попап закроется тем же кликом, которым открылся.
345
+ * Показать попап смайликов у кнопки.
329
346
  *
330
- * @param initiator Кнопка, у которой показывается панель.
331
- * @param container Куда монтировать панель; по умолчанию родитель кнопки.
347
+ * Попап приносит владелец у панели форматирования свой, у поля сообщения свой (собирает
348
+ * их {@link createEmojiPicker}). Редактор берёт на себя только своё: придержать правку,
349
+ * поставить каретку, отпустить фокус и убрать панель, если попап раскрывается не из неё.
350
+ *
351
+ * Вызывать из обработчика `click`, погасив всплытие: иначе попап закроется тем же кликом,
352
+ * которым открылся. Возвращает false, если попап этим нажатием закрылся.
332
353
  */
333
- openEmojiPicker(initiator: HTMLElement, container?: HTMLElement): void {
334
- if (this.readonly) return;
335
-
336
- const target = container ?? initiator.parentElement;
337
- 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;
338
373
 
339
- // повторное нажатие по кнопке панель закрывает — держать и придерживать больше нечего
340
- if (!formatToolbar.openEmoji(this, initiator, target)) return;
374
+ this.__emojiPicker = picker;
375
+ if (!inToolbar) formatToolbar.suspend(this);
341
376
 
342
377
  // Правку придерживаем на всё время панели: фокус мы отпустим, а снятие фокуса — не конец
343
378
  // ввода. Иначе нормализация обрезала бы пробел у каретки, и символ встал бы вплотную.
@@ -347,21 +382,14 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
347
382
  // некуда. В конец её ставит focus(true), и только если её действительно не было: снятую
348
383
  // при отпускании фокуса он вернёт на место, а иначе символ уезжал бы в конец сообщения
349
384
  // с каждым открытием панели.
350
- //
351
- // Строго после открытия панели: фокус показывает тулбар, а придержать его панель успевает
352
- // только когда открыта сама.
353
385
  if (!this.selection) this.focus(true);
354
386
 
355
387
  // Панель — слой над полем, а не вместо него: каретку видно, и видно, куда встанет символ.
356
388
  // На сенсорном устройстве фокус вместо этого поднимает клавиатуру, которая саму панель
357
389
  // и закрывает, — там его отпускаем, а каретку вернёт вставка (см. keepFocus).
358
390
  if (!this.keepFocus) this.releaseFocus();
359
- }
360
391
 
361
- /** Панель смайликов закрылась: снимаем удержание правки, взятое на время её работы. */
362
- onEmojiClosed(): void {
363
- this.__emojiHold?.();
364
- this.__emojiHold = null;
392
+ return true;
365
393
  }
366
394
 
367
395
  getLength(): number {
@@ -526,12 +554,63 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
526
554
  isToolEnabled(tool: FormatTool): boolean {
527
555
  if (this.readonly || !this.formatTools.includes(tool)) return false;
528
556
  if (tool === CODE) return true;
557
+ if (this.currentBlock === CODE || this.isToolActive(CODE)) return false;
529
558
 
530
- 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();
531
608
  }
532
609
 
533
610
  /** Переключить форматирование инструмента (вызывается общим тулбаром и хоткеями). */
534
611
  applyFormat(tool: FormatTool): void {
612
+ // у ссылки есть адрес, а переключением его не задать — она ставится через applyLink
613
+ if (tool === LINK) return;
535
614
  if (!this.isToolEnabled(tool)) return;
536
615
 
537
616
  let target = this.__formatTarget();
@@ -837,6 +916,13 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
837
916
  this.__disposed = true;
838
917
  this.flushChange(); // хост не должен остаться с устаревшей копией значения
839
918
  this.__abort.abort();
919
+
920
+ // Попап смайликов мог остаться открытым, а показывали его мы — своим он бывает и у хоста
921
+ // (см. openEmojiPicker). Оставленный, он держал бы PopupManager на удалённом элементе:
922
+ // на body висел бы класс открытого попапа и слушатель закрытия, а на узком экране
923
+ // страница осталась бы непрокручиваемой.
924
+ if (this.__emojiPicker && PopupManager.isOpened(this.__emojiPicker)) PopupManager.close();
925
+
840
926
  formatToolbar.detach(this);
841
927
 
842
928
  // элемент передан хостом — не удаляем его, только снимаем оформление редактора
@@ -964,6 +1050,17 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
964
1050
  this.editable.classList.toggle(UNSELECTABLE_CLASS, !own);
965
1051
  }
966
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
+
967
1064
  /**
968
1065
  * Снимает запрет, когда выделение перестало задевать редактор.
969
1066
  *
@@ -1001,6 +1098,15 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1001
1098
  // По изменению выделения проверять нельзя: пока запрет действует, выделение до редактора
1002
1099
  // не доходит — проверка увидела бы «не задевает» и сняла запрет сама. Только по концу протяжки.
1003
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
+ });
1004
1110
 
1005
1111
  editable.addEventListener(
1006
1112
  "mousedown",
@@ -1107,7 +1213,12 @@ export default class RichEditor extends UIElementBound<RichEditorEvents> {
1107
1213
  if (tool) {
1108
1214
  e.preventDefault();
1109
1215
  e.stopPropagation();
1110
- 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
+ }
1111
1222
  return;
1112
1223
  }
1113
1224
  }