@olddevs/ui 4.3.0 → 4.4.0

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/editor/RichTextEditor.d.ts.map +1 -1
  3. package/dist/editor/RichTextEditor.js +38 -4
  4. package/dist/editor/RichTextEditor.js.map +1 -1
  5. package/dist/editor/core/handle.d.ts +1 -5
  6. package/dist/editor/core/handle.d.ts.map +1 -1
  7. package/dist/editor/core/handle.js +1 -13
  8. package/dist/editor/core/handle.js.map +1 -1
  9. package/dist/editor/core/handleWatchers.d.ts +6 -0
  10. package/dist/editor/core/handleWatchers.d.ts.map +1 -0
  11. package/dist/editor/core/handleWatchers.js +14 -0
  12. package/dist/editor/core/handleWatchers.js.map +1 -0
  13. package/dist/editor/core/tools.d.ts +7 -2
  14. package/dist/editor/core/tools.d.ts.map +1 -1
  15. package/dist/editor/core/tools.js +2 -1
  16. package/dist/editor/core/tools.js.map +1 -1
  17. package/dist/editor/features/toc/RichTextToc.d.ts +63 -0
  18. package/dist/editor/features/toc/RichTextToc.d.ts.map +1 -0
  19. package/dist/editor/features/toc/RichTextToc.js +372 -0
  20. package/dist/editor/features/toc/RichTextToc.js.map +1 -0
  21. package/dist/editor/features/toc/headings.d.ts +31 -0
  22. package/dist/editor/features/toc/headings.d.ts.map +1 -0
  23. package/dist/editor/features/toc/headings.js +25 -0
  24. package/dist/editor/features/toc/headings.js.map +1 -0
  25. package/dist/editor/features/toc/stickyOffset.d.ts +3 -0
  26. package/dist/editor/features/toc/stickyOffset.d.ts.map +1 -0
  27. package/dist/editor/features/toc/stickyOffset.js +14 -0
  28. package/dist/editor/features/toc/stickyOffset.js.map +1 -0
  29. package/dist/editor/index.d.ts +2 -0
  30. package/dist/editor/index.d.ts.map +1 -1
  31. package/dist/editor/index.js +1 -0
  32. package/dist/editor/index.js.map +1 -1
  33. package/dist/i18n/defaults.d.ts.map +1 -1
  34. package/dist/i18n/defaults.js +1 -0
  35. package/dist/i18n/defaults.js.map +1 -1
  36. package/dist/i18n/messages.d.ts +5 -0
  37. package/dist/i18n/messages.d.ts.map +1 -1
  38. package/dist/locales/ru.d.ts.map +1 -1
  39. package/dist/locales/ru.js +1 -0
  40. package/dist/locales/ru.js.map +1 -1
  41. package/docs/api/messages.en.md +743 -0
  42. package/docs/api/messages.md +1 -0
  43. package/docs/api/utilities.en.md +233 -0
  44. package/docs/components/content/rich-text-toc.en.md +130 -0
  45. package/docs/components/content/rich-text-toc.md +129 -0
  46. package/docs/components/index.en.md +2 -1
  47. package/docs/components/index.md +1 -0
  48. package/docs/components/inputs/rich-text-editor.en.md +52 -34
  49. package/docs/components/inputs/rich-text-editor.md +20 -2
  50. package/docs/conventions.en.md +318 -0
  51. package/docs/getting-started.en.md +4 -4
  52. package/docs/getting-started.md +3 -3
  53. package/docs/glossary.en.md +2 -1
  54. package/docs/glossary.md +1 -0
  55. package/docs/index.en.md +53 -0
  56. package/llms.txt +1 -0
  57. package/package.json +1 -1
  58. package/src/editor/RichTextEditor.tsx +107 -3
  59. package/src/editor/core/handle.ts +2 -23
  60. package/src/editor/core/handleWatchers.ts +27 -0
  61. package/src/editor/core/tools.ts +9 -2
  62. package/src/editor/features/toc/RichTextToc.tsx +712 -0
  63. package/src/editor/features/toc/headings.ts +70 -0
  64. package/src/editor/features/toc/stickyOffset.ts +21 -0
  65. package/src/editor/index.ts +2 -0
  66. package/src/i18n/defaults.ts +1 -0
  67. package/src/i18n/messages.ts +5 -0
  68. package/src/locales/ru.ts +1 -0
@@ -0,0 +1,712 @@
1
+ "use client";
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ type HTMLAttributes,
10
+ type KeyboardEvent,
11
+ type RefObject,
12
+ } from "react";
13
+ import {
14
+ HoverCard,
15
+ HoverCardContent,
16
+ HoverCardTrigger,
17
+ } from "../../../components/overlays/HoverCard.js";
18
+ import { useMessages } from "../../../i18n/useMessages.js";
19
+ import { cn } from "../../../lib/cn.js";
20
+ import { watchHandleEditor } from "../../core/handleWatchers.js";
21
+ import type { RichTextEditorHandle } from "../../RichTextEditor.js";
22
+ import { collectHeadings, documentRoot, type TocHeading } from "./headings.js";
23
+ import { stickyOffset } from "./stickyOffset.js";
24
+
25
+ export interface RichTextTocProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
26
+ /**
27
+ * Ref на Просмотр (`RichTextView`) или на Хэндл Редактора (`RichTextEditor`),
28
+ * чьи заголовки показывает Оглавление. Заголовки читаются из DOM цели после
29
+ * монтирования: до гидратации полосы нет.
30
+ *
31
+ * Список следит за Документом: новый `html` Просмотра, новое значение
32
+ * Редактора и правка в нём пересобирают его с короткой задержкой. Редактор
33
+ * в `readOnly` работает так же, как Просмотр. Редактор в правке тоже
34
+ * поддержан — список пересобирается, а переход не двигает каретку, — но
35
+ * контрактом этот режим не обещан. Движок Редактора появляется после
36
+ * монтирования, и Оглавление ждёт его само.
37
+ */
38
+ target: RefObject<HTMLElement | RichTextEditorHandle | null>;
39
+ /**
40
+ * Имя CSS-переменной с высотой липкой шапки экрана ([ADR-0016]). Переход
41
+ * ставит заголовок под шапку, а не за неё, и подсветка считает раздел
42
+ * текущим от нижнего края шапки. Переменной нет — отступом служит
43
+ * `scroll-margin-top` заголовка. По умолчанию `--topbar-h`: её публикует `TopBar`.
44
+ */
45
+ offsetVar?: string;
46
+ /**
47
+ * Ширина списка — длина CSS или пиксели. Постоянная, а не по самому длинному
48
+ * заголовку: список не меняет размер от Документа к Документу. Заголовок, что
49
+ * не помещается, обрезается многоточием, полный текст — в подсказке пункта.
50
+ * По умолчанию 240px.
51
+ */
52
+ listWidth?: number | string;
53
+ }
54
+
55
+ /**
56
+ * Длина штриха по глубине: чем глубже уровень, тем короче штрих. Ступеней
57
+ * четыре — по числу уровней H1–H4, так что у каждого уровня своя длина.
58
+ */
59
+ const STROKE_WIDTH = ["w-3.5", "w-3", "w-2.5", "w-2"] as const;
60
+ /**
61
+ * Штрих текущего раздела толще и чуть длиннее старшего уровня: его видно в
62
+ * полосе с первого взгляда, но он не выпирает из неё.
63
+ */
64
+ const CURRENT_STROKE = "bg-accent-strong h-[3px] w-4";
65
+ /** Отступ текста пункта по глубине. */
66
+ const ITEM_INDENT = ["pl-0", "pl-2.5", "pl-5", "pl-7.5"] as const;
67
+
68
+ const strokeWidth = (depth: number) => STROKE_WIDTH[Math.min(depth, STROKE_WIDTH.length - 1)];
69
+ const itemIndent = (depth: number) => ITEM_INDENT[Math.min(depth, ITEM_INDENT.length - 1)];
70
+
71
+ /*
72
+ Задержки наведения короче, чем у `HoverCard` по умолчанию (700 и 300 мс).
73
+ Карточка висит на ссылке посреди текста, и курсор пролетает её случайно;
74
+ полоса Оглавления стоит в своей колонке, и навели на неё — значит, хотят
75
+ список. Механика та же, что у карточки, — компонент собран на ней.
76
+ */
77
+ const OPEN_DELAY = 200;
78
+ const CLOSE_DELAY = 300;
79
+
80
+ /** Пауза после последней правки Документа, по которой список пересобирается. */
81
+ const REBUILD_DELAY = 150;
82
+
83
+ /**
84
+ * Шаг между штрихами полосы и его границы. Ячейка штриха — 3px (см. полосу),
85
+ * `p-1` даёт по 4px сверху и снизу; запас оставляет воздух над и под полосой.
86
+ */
87
+ const STRIP_GAP = 6;
88
+ const STRIP_CELL = 3;
89
+ const STRIP_PADDING = 8;
90
+ const STRIP_MARGIN = 16;
91
+
92
+ /** Сколько прокрутка от перехода должна молчать, чтобы считаться законченной. */
93
+ const PIN_SETTLE = 150;
94
+
95
+ /** Запас под строкой отсчёта: заголовок, вставший вплотную под шапку, уже текущий. */
96
+ const SPY_SLACK = 8;
97
+
98
+ function prefersReducedMotion(): boolean {
99
+ return (
100
+ typeof window.matchMedia === "function" &&
101
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
102
+ );
103
+ }
104
+
105
+ /** Ближайший прокручиваемый предок цели; `null` — прокручивается окно. */
106
+ function scrollParent(node: HTMLElement): HTMLElement | null {
107
+ for (let parent = node.parentElement; parent; parent = parent.parentElement) {
108
+ if (parent === document.body || parent === document.documentElement) return null;
109
+ const { overflowY } = getComputedStyle(parent);
110
+ if (/(auto|scroll|overlay)/.test(overflowY) && parent.scrollHeight > parent.clientHeight) {
111
+ return parent;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** Сколько гаснет подсветка заголовка после перехода. */
118
+ const FLASH_DURATION = 1600;
119
+
120
+ /**
121
+ * Подсветить заголовок, к которому перешли: фон цветом акцента гаснет до
122
+ * прозрачного. Глаз находит, куда приехала страница, — особенно когда
123
+ * заголовок не встал к верхнему краю (конец Документа).
124
+ *
125
+ * Анимацией Web Animations, а не классом: в Документе не появляется ни класса,
126
+ * ни `style` — Схема и сохранённый HTML прежние, а Редактор не принимает
127
+ * подсветку за правку. Цвет читается из токена на самом заголовке: так он
128
+ * верен и на Листе, и во вложенной теме. Нет API — нет и подсветки.
129
+ */
130
+ /** Кривая шкалы движения, прочитанная с узла; нет токена — штатная `ease`. */
131
+ function scaleCurve(node: HTMLElement, token: string): string {
132
+ return getComputedStyle(node).getPropertyValue(token).trim() || "ease";
133
+ }
134
+
135
+ function flash(heading: HTMLElement) {
136
+ if (typeof heading.animate !== "function") return;
137
+ const accent = getComputedStyle(heading).getPropertyValue("--accent-strong").trim();
138
+ const color = accent ? `color-mix(in srgb, ${accent} 24%, transparent)` : "transparent";
139
+ // Поле вокруг текста — тенью без размытия, а не `padding`: заголовок не
140
+ // сдвигается ни на пиксель, пока подсветка гаснет.
141
+ heading.animate(
142
+ [
143
+ { backgroundColor: color, boxShadow: `0 0 0 6px ${color}`, borderRadius: "4px" },
144
+ { backgroundColor: "transparent", boxShadow: "0 0 0 6px transparent", borderRadius: "4px" },
145
+ ],
146
+ // Кривая — ступень шкалы `--ease-enter` (ADR-0047): гаснет быстро и
147
+ // дотягивает медленно. Длительность вне шкалы намеренно: это не переход
148
+ // между состояниями, а след, который глаз должен успеть заметить.
149
+ { duration: FLASH_DURATION, easing: scaleCurve(heading, "--ease-enter") }
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Перейти к заголовку: прокрутить к нему и поставить на него фокус.
155
+ *
156
+ * Без якорей: адрес страницы не меняется, `id` в Документе не появляются
157
+ * (ADR-0066). Фокус нужен скринридеру и клавиатуре — следующий Tab идёт от
158
+ * заголовка, а не от Оглавления. `tabindex="-1"` ставится только на время
159
+ * фокуса: в Документе после ухода фокуса не остаётся следа.
160
+ */
161
+ function jumpTo(
162
+ heading: HTMLElement,
163
+ offset: number | null,
164
+ focusHeading?: (heading: HTMLElement) => boolean
165
+ ) {
166
+ /*
167
+ В Редакторе в правке заголовок лежит внутри `contenteditable`, и ProseMirror
168
+ следит за его DOM: чужой `tabindex` он счёл бы правкой, перерисовал заголовок
169
+ без атрибута, и фокус пропал бы вместе с ним. Фокус на заголовок здесь и не
170
+ нужен: поставить его, не тронув каретку, нельзя, а каретка остаётся там, где
171
+ её оставил автор. Прокрутка работает как обычно.
172
+ */
173
+ // Хозяин поля (Инструмент `toc` Редактора) может поставить фокус сам — каретку
174
+ // в заголовок; иначе — фокус на сам заголовок, если он не в правке.
175
+ if (focusHeading?.(heading)) {
176
+ // Фокус поставлен.
177
+ } else if (!heading.parentElement?.closest('[contenteditable="true"]')) {
178
+ if (!heading.hasAttribute("tabindex")) {
179
+ heading.setAttribute("tabindex", "-1");
180
+ heading.addEventListener("blur", () => heading.removeAttribute("tabindex"), { once: true });
181
+ }
182
+ heading.focus({ preventScroll: true });
183
+ }
184
+ // Подсветка — анимацией, без следа в DOM: годится и Редактору в правке.
185
+ flash(heading);
186
+
187
+ const behavior: ScrollBehavior = prefersReducedMotion() ? "auto" : "smooth";
188
+ // Без шапки браузер сам учтёт `scroll-margin-top` заголовка.
189
+ if (offset === null) {
190
+ heading.scrollIntoView({ behavior, block: "start" });
191
+ return;
192
+ }
193
+ const scroller = scrollParent(heading);
194
+ const top = heading.getBoundingClientRect().top;
195
+ if (scroller) {
196
+ const edge = scroller.getBoundingClientRect().top;
197
+ scroller.scrollTo({ top: scroller.scrollTop + top - edge, behavior });
198
+ } else {
199
+ window.scrollTo({ top: window.scrollY + top - offset, behavior });
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Текущий раздел: последний заголовок, поднявшийся до строки отсчёта — нижнего
205
+ * края шапки или верха прокручиваемого контейнера. Ни один не поднялся —
206
+ * текущий первый. Документ докручен до конца — текущий последний видимый:
207
+ * короткие последние разделы до строки отсчёта не доезжают никогда.
208
+ */
209
+ function currentIndex(headings: TocHeading[], offset: number | null): number {
210
+ const first = headings[0];
211
+ if (!first) return -1;
212
+ const scroller = scrollParent(first.element);
213
+ const edge = scroller ? scroller.getBoundingClientRect().top : (offset ?? 0);
214
+ const line = edge + SPY_SLACK;
215
+
216
+ const atEnd = scroller
217
+ ? scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2
218
+ : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
219
+ const bottom = scroller ? scroller.getBoundingClientRect().bottom : window.innerHeight;
220
+
221
+ let current = 0;
222
+ headings.forEach((heading, index) => {
223
+ const top = heading.element.getBoundingClientRect().top;
224
+ if (top <= line || (atEnd && top < bottom)) current = index;
225
+ });
226
+ return current;
227
+ }
228
+
229
+ /** Прокрутить `box` ровно настолько, чтобы `item` был виден целиком. */
230
+ function keepVisible(item: HTMLElement | undefined, box: HTMLElement | null) {
231
+ if (!item || !box) return;
232
+ const itemRect = item.getBoundingClientRect();
233
+ const boxRect = box.getBoundingClientRect();
234
+ if (itemRect.top < boxRect.top) box.scrollTop -= boxRect.top - itemRect.top;
235
+ else if (itemRect.bottom > boxRect.bottom) box.scrollTop += itemRect.bottom - boxRect.bottom;
236
+ }
237
+
238
+ /**
239
+ * Элемент Документа по ref цели. Просмотр — сам элемент; Редактор — корень
240
+ * ProseMirror его движка: Панель и строка статуса вокруг в Документ не входят.
241
+ * Движка ещё нет (или он уничтожен) — `null`.
242
+ */
243
+ function resolveTarget(target: RefObject<HTMLElement | RichTextEditorHandle | null>) {
244
+ const value = target.current;
245
+ if (!value) return null;
246
+ if (!("editor" in value)) return value;
247
+ const { editor } = value;
248
+ return editor && !editor.isDestroyed ? editor.view.dom : null;
249
+ }
250
+
251
+ /** Тот же набор заголовков: правка текста вне заголовков не должна ничего перерисовывать. */
252
+ function sameHeadings(a: TocHeading[], b: TocHeading[]): boolean {
253
+ return (
254
+ a.length === b.length &&
255
+ a.every(
256
+ (heading, index) =>
257
+ heading.element === b[index]!.element &&
258
+ heading.text === b[index]!.text &&
259
+ heading.depth === b[index]!.depth
260
+ )
261
+ );
262
+ }
263
+
264
+ /**
265
+ * Шаг между штрихами, при котором полоса не выше окна. Хватает места — обычный
266
+ * шаг; не хватает — он сжимается вплоть до нуля. Ячейки остаются постоянного
267
+ * размера, меняется только промежуток между ними, и считается он лишь от
268
+ * размера окна и числа заголовков, но не от текущего раздела: полоса не
269
+ * вздрагивает при прокрутке.
270
+ */
271
+ function stripGap(count: number, offset: number | null): number {
272
+ if (count < 2) return STRIP_GAP;
273
+ const room =
274
+ window.innerHeight - (offset ?? 0) - STRIP_MARGIN * 2 - STRIP_PADDING - count * STRIP_CELL;
275
+ return Math.max(0, Math.min(STRIP_GAP, room / (count - 1)));
276
+ }
277
+
278
+ /**
279
+ * Помещается ли полоса слева от текста: целиком в окне и не заходит на
280
+ * Документ. Колонка под Оглавление бывает полем страницы, которое сужается
281
+ * вместе с окном, — тогда полоса наползла бы на текст или ушла за край.
282
+ */
283
+ function fitsBeside(strip: HTMLElement, target: HTMLElement): boolean {
284
+ const own = strip.getBoundingClientRect();
285
+ const text = documentRoot(target).getBoundingClientRect();
286
+ return own.left >= 0 && own.right <= text.left;
287
+ }
288
+
289
+ /**
290
+ * Оглавление Документа: полоса штрихов, по одному на заголовок, штрих текущего
291
+ * раздела подсвечен. Наведение раскрывает список заголовков, пункт ведёт к
292
+ * заголовку.
293
+ *
294
+ * Отдельный компонент, а не проп Просмотра (ADR-0066): `RichTextView` остаётся
295
+ * серверным, а место Оглавления — колонку, липкость, скрытие на узком экране —
296
+ * решает вёрстка экрана. Меньше двух заголовков — компонент ничего не рисует.
297
+ */
298
+ export const RichTextToc = forwardRef<HTMLDivElement, RichTextTocProps>(
299
+ function RichTextToc(props, ref) {
300
+ return <TocCore ref={ref} {...props} />;
301
+ }
302
+ );
303
+
304
+ /**
305
+ * Ядро Оглавления: общее у `RichTextToc` и Инструмента `toc` Редактора
306
+ * (ADR-0068). Две настройки — только для кита, наружу не выходят: Редактор
307
+ * сам знает, где край его липкой Панели, и сам ставит каретку при переходе.
308
+ */
309
+ export interface TocCoreProps extends RichTextTocProps {
310
+ /**
311
+ * Нижний край липкого верха в пикселях: от него считают подсветка, переход и
312
+ * шаг полосы. Нет — высота из `offsetVar`.
313
+ */
314
+ stickyTop?: () => number | null;
315
+ /** Поставить фокус при переходе самому; `true` — поставлен. */
316
+ focusHeading?: (heading: HTMLElement) => boolean;
317
+ /**
318
+ * Опустить полосу так, чтобы первый штрих стоял на середине первой строки
319
+ * первого заголовка: в поле Редактора полоса и заголовок начинаются с одного
320
+ * уровня, а не полоса выше текста.
321
+ */
322
+ alignToFirstHeading?: boolean;
323
+ }
324
+
325
+ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore(
326
+ {
327
+ target,
328
+ offsetVar = "--topbar-h",
329
+ listWidth = 240,
330
+ stickyTop,
331
+ focusHeading,
332
+ alignToFirstHeading = false,
333
+ className,
334
+ style,
335
+ ...rest
336
+ },
337
+ ref
338
+ ) {
339
+ // Настройки хозяина читаются в обработчиках прокрутки: последние значения —
340
+ // через ссылку, чтобы новая функция на каждом рендере не пересоздавала слушателей.
341
+ const latest = useRef({ stickyTop, focusHeading, offsetVar });
342
+ useEffect(() => {
343
+ latest.current = { stickyTop, focusHeading, offsetVar };
344
+ });
345
+ const offsetNow = useCallback(() => {
346
+ const own = latest.current;
347
+ return own.stickyTop ? own.stickyTop() : stickyOffset(own.offsetVar);
348
+ }, []);
349
+ const messages = useMessages();
350
+ const [element, setElement] = useState<HTMLElement | null>(null);
351
+ const [headings, setHeadings] = useState<TocHeading[]>([]);
352
+ const [gap, setGap] = useState(STRIP_GAP);
353
+ const [current, setCurrent] = useState(0);
354
+ const [open, setOpen] = useState(false);
355
+ const [fits, setFits] = useState(true);
356
+ const stripRef = useRef<HTMLButtonElement>(null);
357
+ /** Список открыт с клавиатуры: фокус уходит в него, на текущий пункт. */
358
+ const focusOnOpen = useRef(false);
359
+ const navRef = useRef<HTMLElement | null>(null);
360
+ /** Enter или Space уже открыли список: следующий за ними клик — их же эхо. */
361
+ const keyHandled = useRef(false);
362
+
363
+ // Элемент цели. У Редактора движок появляется после монтирования (и лениво),
364
+ // поэтому на его смену подписка: Редактор сообщает по тому же `ref`.
365
+ useEffect(() => {
366
+ const resolve = () => setElement(resolveTarget(target));
367
+ resolve();
368
+ return watchHandleEditor(target, resolve);
369
+ }, [target]);
370
+
371
+ // Заголовки следуют за Документом. Наблюдатель стоит на корне Документа и не
372
+ // зависит от того, нарисована ли полоса: меньше двух заголовков она не
373
+ // рисуется, а появиться должна, когда пришёл второй. Атрибуты не наблюдаются:
374
+ // переход сам ставит и снимает `tabindex` на заголовке, и пересборка по нему
375
+ // подменила бы узлы списка под рукой.
376
+ useEffect(() => {
377
+ if (!element) {
378
+ setHeadings((prev) => (prev.length ? [] : prev));
379
+ return;
380
+ }
381
+ const read = () => {
382
+ const next = collectHeadings(element);
383
+ setHeadings((prev) => (sameHeadings(prev, next) ? prev : next));
384
+ };
385
+ read();
386
+ let timer: ReturnType<typeof setTimeout> | undefined;
387
+ const observer = new MutationObserver(() => {
388
+ clearTimeout(timer);
389
+ timer = setTimeout(read, REBUILD_DELAY);
390
+ });
391
+ observer.observe(documentRoot(element), {
392
+ childList: true,
393
+ subtree: true,
394
+ characterData: true,
395
+ });
396
+ return () => {
397
+ clearTimeout(timer);
398
+ observer.disconnect();
399
+ };
400
+ }, [element]);
401
+
402
+ // Шаг полосы зависит от высоты окна и числа заголовков.
403
+ useEffect(() => {
404
+ if (headings.length < 2) return;
405
+ let frame = 0;
406
+ const update = () => {
407
+ frame = 0;
408
+ setGap(stripGap(headings.length, offsetNow()));
409
+ };
410
+ const schedule = () => {
411
+ if (!frame) frame = requestAnimationFrame(update);
412
+ };
413
+ update();
414
+ window.addEventListener("resize", schedule);
415
+ return () => {
416
+ if (frame) cancelAnimationFrame(frame);
417
+ window.removeEventListener("resize", schedule);
418
+ };
419
+ }, [headings.length, offsetVar, offsetNow]);
420
+
421
+ /*
422
+ Раздел, выбранный переходом, держится подсвеченным, пока идёт прокрутка от
423
+ самого перехода. Иначе у конца Документа правило «докручено до конца —
424
+ текущий последний видимый» отдавало подсветку соседнему заголовку, и
425
+ читатель, выбравший «Поверхности», видел подсвеченным «Что дальше».
426
+ Пересчёт возвращается со следующей прокрутки после того, как переход
427
+ улёгся (`settled`).
428
+ */
429
+ const pinned = useRef<{ index: number; settled: boolean; timer: number } | null>(null);
430
+ /** (Пере)запустить ожидание: прокрутка перехода молчит `PIN_SETTLE` — переход улёгся. */
431
+ const settle = (lock: { settled: boolean; timer: number }) => {
432
+ window.clearTimeout(lock.timer);
433
+ lock.timer = window.setTimeout(() => {
434
+ lock.settled = true;
435
+ }, PIN_SETTLE);
436
+ };
437
+ const pin = (index: number) => {
438
+ if (pinned.current) window.clearTimeout(pinned.current.timer);
439
+ pinned.current = { index, settled: false, timer: 0 };
440
+ settle(pinned.current);
441
+ setCurrent(index);
442
+ };
443
+
444
+ // Подсветка текущего раздела. Слушатель прокрутки — на документе с захватом:
445
+ // так его достигает прокрутка и окна, и любого контейнера вокруг цели.
446
+ useEffect(() => {
447
+ if (headings.length < 2) return;
448
+ let frame = 0;
449
+ const update = () => {
450
+ frame = 0;
451
+ setCurrent(currentIndex(headings, offsetNow()));
452
+ };
453
+ const schedule = (event?: Event) => {
454
+ // Считается только прокрутка, которая двигает Документ: окна или
455
+ // контейнера вокруг него. Прокрутка самого списка или чужой карусели на
456
+ // странице подсветку не трогает — и не снимает раздел перехода.
457
+ const scrolled = event?.target;
458
+ if (scrolled instanceof Element && !scrolled.contains(headings[0]!.element)) {
459
+ return;
460
+ }
461
+ const lock = pinned.current;
462
+ if (lock && !lock.settled) {
463
+ // Прокрутка самого перехода: ждём, пока она уляжется.
464
+ settle(lock);
465
+ return;
466
+ }
467
+ pinned.current = null;
468
+ if (!frame) frame = requestAnimationFrame(update);
469
+ };
470
+ update();
471
+ document.addEventListener("scroll", schedule, { capture: true, passive: true });
472
+ window.addEventListener("resize", schedule);
473
+ return () => {
474
+ if (frame) cancelAnimationFrame(frame);
475
+ document.removeEventListener("scroll", schedule, { capture: true });
476
+ window.removeEventListener("resize", schedule);
477
+ };
478
+ }, [headings, offsetVar, offsetNow]);
479
+
480
+ // Уборка таймера выбранного раздела при размонтировании.
481
+ useEffect(() => () => window.clearTimeout(pinned.current?.timer), []);
482
+
483
+ // Места слева нет — Оглавление прячется само (ADR-0066). `visibility`, а не
484
+ // размонтирование: полоса продолжает занимать своё место, и замер остаётся
485
+ // верным, когда место появится снова.
486
+ useEffect(() => {
487
+ const strip = stripRef.current;
488
+ const node = element;
489
+ if (!strip || !node) return;
490
+ let frame = 0;
491
+ const measure = () => {
492
+ frame = 0;
493
+ const next = fitsBeside(strip, node);
494
+ setFits(next);
495
+ if (!next) setOpen(false);
496
+ };
497
+ const schedule = () => {
498
+ if (!frame) frame = requestAnimationFrame(measure);
499
+ };
500
+ measure();
501
+ window.addEventListener("resize", schedule);
502
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
503
+ observer?.observe(node);
504
+ observer?.observe(document.documentElement);
505
+ return () => {
506
+ if (frame) cancelAnimationFrame(frame);
507
+ window.removeEventListener("resize", schedule);
508
+ observer?.disconnect();
509
+ };
510
+ }, [element, headings.length]);
511
+
512
+ // Выравнивание по первому заголовку: отступ сверху меряется от верха колонки,
513
+ // в которой стоит Оглавление, до середины первой строки заголовка, и
514
+ // пересчитывается при смене Документа и ширины поля (перенос строк).
515
+ const [lead, setLead] = useState(0);
516
+ useEffect(() => {
517
+ const first = headings[0]?.element;
518
+ const root = stripRef.current?.parentElement;
519
+ const column = root?.parentElement;
520
+ if (!alignToFirstHeading || !first || !column || !element) return;
521
+ const measure = () => {
522
+ const box = first.getBoundingClientRect();
523
+ const line = parseFloat(getComputedStyle(first).lineHeight) || box.height;
524
+ const center = box.top + Math.min(line, box.height) / 2;
525
+ const next = center - column.getBoundingClientRect().top - STRIP_PADDING / 2 - STRIP_CELL / 2;
526
+ setLead(Math.max(0, Math.round(next)));
527
+ };
528
+ measure();
529
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
530
+ observer?.observe(element);
531
+ return () => observer?.disconnect();
532
+ }, [alignToFirstHeading, headings, element]);
533
+
534
+ const listRef = useCallback(
535
+ (node: HTMLElement | null) => {
536
+ navRef.current = node;
537
+ if (!node) return;
538
+ const items = node.querySelectorAll<HTMLButtonElement>("button");
539
+ // Длинный список прокручивается: текущий пункт держится в видимой части
540
+ // списка, когда он открылся и когда раздел сменился при открытом списке.
541
+ // Прокручивается только сам список: `scrollIntoView` двигал бы и страницу.
542
+ keepVisible(items[current], node.parentElement);
543
+ if (!focusOnOpen.current) return;
544
+ focusOnOpen.current = false;
545
+ (items[current] ?? items[0])?.focus();
546
+ },
547
+ [current]
548
+ );
549
+
550
+ if (headings.length < 2) return null;
551
+
552
+ const label = messages.editor.toc;
553
+
554
+ const onStripKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
555
+ if (event.key !== "Enter" && event.key !== " ") return;
556
+ event.preventDefault();
557
+ keyHandled.current = true;
558
+ if (open && navRef.current) {
559
+ // Список уже открыт наведением: фокус переводится в него сразу.
560
+ const items = navRef.current.querySelectorAll<HTMLButtonElement>("button");
561
+ (items[current] ?? items[0])?.focus();
562
+ return;
563
+ }
564
+ focusOnOpen.current = true;
565
+ setOpen(true);
566
+ };
567
+
568
+ const onListKeyDown = (event: KeyboardEvent<HTMLElement>) => {
569
+ const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button")];
570
+ const index = items.indexOf(document.activeElement as HTMLButtonElement);
571
+ const next =
572
+ event.key === "ArrowDown"
573
+ ? Math.min(index + 1, items.length - 1)
574
+ : event.key === "ArrowUp"
575
+ ? Math.max(index - 1, 0)
576
+ : event.key === "Home"
577
+ ? 0
578
+ : event.key === "End"
579
+ ? items.length - 1
580
+ : null;
581
+ if (next === null) return;
582
+ event.preventDefault();
583
+ items[next]?.focus();
584
+ };
585
+
586
+ return (
587
+ <div
588
+ ref={ref}
589
+ data-hidden={fits ? undefined : ""}
590
+ className={cn("inline-flex", !fits && "invisible", className)}
591
+ style={alignToFirstHeading ? { ...style, marginTop: lead } : style}
592
+ {...rest}
593
+ >
594
+ <HoverCard open={open} onOpenChange={setOpen} openDelay={OPEN_DELAY} closeDelay={CLOSE_DELAY}>
595
+ <HoverCardTrigger asChild>
596
+ <button
597
+ ref={stripRef}
598
+ type="button"
599
+ aria-label={label}
600
+ aria-expanded={open}
601
+ onClick={() => {
602
+ // Enter и Space уже разобраны в `onKeyDown`; их клик пропускается.
603
+ if (keyHandled.current) {
604
+ keyHandled.current = false;
605
+ return;
606
+ }
607
+ /*
608
+ Мышь, касание — и скринридер: NVDA, JAWS и VoiceOver нажимают
609
+ кнопку синтетическим кликом без клавиши (`detail === 0`). Для
610
+ него фокус уходит в список, как с клавиатуры: иначе открытый
611
+ список остался бы ему недоступен.
612
+ */
613
+ if (!open) focusOnOpen.current = true;
614
+ setOpen(true);
615
+ }}
616
+ onKeyDown={onStripKeyDown}
617
+ style={{ rowGap: gap }}
618
+ className="focus-visible:outline-accent-strong flex cursor-pointer flex-col items-start rounded-sm p-1 outline-offset-2 focus-visible:outline-2"
619
+ >
620
+ {/* Ячейка штриха постоянного размера: толщина и длина текущего
621
+ анимируются, а габариты полосы — нет. Иначе список, привязанный
622
+ к её краю, вздрагивал бы при каждой смене раздела. */}
623
+ {headings.map((heading, index) => (
624
+ <span key={index} className="flex h-[3px] w-4 items-center">
625
+ <span
626
+ data-toc-stroke=""
627
+ data-depth={heading.depth}
628
+ data-current={index === current || undefined}
629
+ className={cn(
630
+ "rounded-full transition-[width,height,background-color]",
631
+ index === current
632
+ ? CURRENT_STROKE
633
+ : cn("bg-fg-3 h-0.5", strokeWidth(heading.depth))
634
+ )}
635
+ />
636
+ </span>
637
+ ))}
638
+ </button>
639
+ </HoverCardTrigger>
640
+ <HoverCardContent
641
+ side="right"
642
+ align="start"
643
+ sideOffset={8}
644
+ className="max-h-[min(70dvh,32rem)] overflow-y-auto overscroll-contain p-1"
645
+ style={{ width: listWidth }}
646
+ onEscapeKeyDown={() => stripRef.current?.focus()}
647
+ onFocusOutside={(event) => {
648
+ // Переход ставит фокус на заголовок, а список остаётся открытым:
649
+ // его закрывает уход курсора, а не выбор пункта. С клавиатуры фокус
650
+ // в списке больше не держит его, и он закрывается тем же таймером.
651
+ if (element?.contains(event.target as Node)) event.preventDefault();
652
+ }}
653
+ onPointerDownOutside={(event) => {
654
+ // Полоса — якорь, а не триггер Radix: без этого нажатие на неё при
655
+ // открытом от наведения списке считалось бы нажатием «вовне» и
656
+ // закрывало список, который это же нажатие просит открыть.
657
+ if (stripRef.current?.contains(event.target as Node)) event.preventDefault();
658
+ }}
659
+ >
660
+ <nav ref={listRef} aria-label={label} onKeyDown={onListKeyDown}>
661
+ <ul>
662
+ {headings.map((heading, index) => (
663
+ <li key={index}>
664
+ <button
665
+ type="button"
666
+ data-depth={heading.depth}
667
+ aria-current={index === current ? "location" : undefined}
668
+ className={cn(
669
+ "hover:bg-bg-2 focus-visible:bg-bg-2 flex w-full cursor-pointer items-center gap-2.5 rounded-sm py-0.5 pr-2 pl-1.5 text-left text-[13px] leading-[18px] outline-none",
670
+ index === current ? "bg-bg-2 text-fg-0" : "text-fg-2 hover:text-fg-0"
671
+ )}
672
+ onClick={(event) => {
673
+ pin(index);
674
+ jumpTo(heading.element, offsetNow(), latest.current.focusHeading);
675
+ /*
676
+ В Редакторе в правке фокус на заголовок не переходит и
677
+ остаётся на пункте, а фокус в списке держит его открытым:
678
+ уход курсора его бы не закрыл. После клика мышью пункт
679
+ фокус отпускает; с клавиатуры (`detail === 0`) фокус
680
+ остаётся в списке — по нему ещё ходят стрелками.
681
+ */
682
+ const item = event.currentTarget;
683
+ if (event.detail > 0 && document.activeElement === item) item.blur();
684
+ }}
685
+ >
686
+ {/* Штрихи стоят в колонке одной ширины: текст пунктов начинается ровно, а отступ даёт глубина. */}
687
+ <span aria-hidden className="flex w-4 shrink-0 items-center">
688
+ <span
689
+ className={cn(
690
+ "rounded-full",
691
+ index === current
692
+ ? CURRENT_STROKE
693
+ : cn("bg-fg-3 h-0.5", strokeWidth(heading.depth))
694
+ )}
695
+ />
696
+ </span>
697
+ <span
698
+ className={cn("min-w-0 truncate", itemIndent(heading.depth))}
699
+ title={heading.text}
700
+ >
701
+ {heading.text}
702
+ </span>
703
+ </button>
704
+ </li>
705
+ ))}
706
+ </ul>
707
+ </nav>
708
+ </HoverCardContent>
709
+ </HoverCard>
710
+ </div>
711
+ );
712
+ });