@olddevs/ui 4.4.0 → 4.5.1

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 (159) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/editor/RichTextEditor.d.ts +11 -4
  3. package/dist/editor/RichTextEditor.d.ts.map +1 -1
  4. package/dist/editor/RichTextEditor.js +90 -60
  5. package/dist/editor/RichTextEditor.js.map +1 -1
  6. package/dist/editor/RichTextView.d.ts +2 -1
  7. package/dist/editor/RichTextView.d.ts.map +1 -1
  8. package/dist/editor/RichTextView.js.map +1 -1
  9. package/dist/editor/core/containers.d.ts +2 -6
  10. package/dist/editor/core/containers.d.ts.map +1 -1
  11. package/dist/editor/core/containers.js +2 -1
  12. package/dist/editor/core/containers.js.map +1 -1
  13. package/dist/editor/core/customTool.d.ts +8 -2
  14. package/dist/editor/core/customTool.d.ts.map +1 -1
  15. package/dist/editor/core/customTool.js.map +1 -1
  16. package/dist/editor/core/customToolEngine.d.ts +9 -1
  17. package/dist/editor/core/customToolEngine.d.ts.map +1 -1
  18. package/dist/editor/core/customToolEngine.js +12 -0
  19. package/dist/editor/core/customToolEngine.js.map +1 -1
  20. package/dist/editor/core/extensions.d.ts.map +1 -1
  21. package/dist/editor/core/extensions.js +1 -1
  22. package/dist/editor/core/extensions.js.map +1 -1
  23. package/dist/editor/core/handle.d.ts +18 -1
  24. package/dist/editor/core/handle.d.ts.map +1 -1
  25. package/dist/editor/core/handle.js +2 -1
  26. package/dist/editor/core/handle.js.map +1 -1
  27. package/dist/editor/core/handleWatchers.d.ts +30 -0
  28. package/dist/editor/core/handleWatchers.d.ts.map +1 -1
  29. package/dist/editor/core/handleWatchers.js +15 -0
  30. package/dist/editor/core/handleWatchers.js.map +1 -1
  31. package/dist/editor/core/nodeNames.d.ts +7 -0
  32. package/dist/editor/core/nodeNames.d.ts.map +1 -0
  33. package/dist/editor/core/nodeNames.js +2 -0
  34. package/dist/editor/core/nodeNames.js.map +1 -0
  35. package/dist/editor/core/schema.d.ts.map +1 -1
  36. package/dist/editor/core/schema.js +4 -2
  37. package/dist/editor/core/schema.js.map +1 -1
  38. package/dist/editor/core/toolCommands.d.ts +78 -0
  39. package/dist/editor/core/toolCommands.d.ts.map +1 -0
  40. package/dist/editor/core/toolCommands.js +2 -0
  41. package/dist/editor/core/toolCommands.js.map +1 -0
  42. package/dist/editor/core/toolDefinition.d.ts +139 -0
  43. package/dist/editor/core/toolDefinition.d.ts.map +1 -0
  44. package/dist/editor/core/toolDefinition.js +28 -0
  45. package/dist/editor/core/toolDefinition.js.map +1 -0
  46. package/dist/editor/core/toolDescriptor.d.ts +71 -2
  47. package/dist/editor/core/toolDescriptor.d.ts.map +1 -1
  48. package/dist/editor/core/toolDescriptor.js +6 -2
  49. package/dist/editor/core/toolDescriptor.js.map +1 -1
  50. package/dist/editor/core/toolOptions.d.ts +157 -0
  51. package/dist/editor/core/toolOptions.d.ts.map +1 -0
  52. package/dist/editor/core/toolOptions.js +21 -0
  53. package/dist/editor/core/toolOptions.js.map +1 -0
  54. package/dist/editor/core/toolState.d.ts +46 -0
  55. package/dist/editor/core/toolState.d.ts.map +1 -0
  56. package/dist/editor/core/toolState.js +2 -0
  57. package/dist/editor/core/toolState.js.map +1 -0
  58. package/dist/editor/core/tools.d.ts +3 -1
  59. package/dist/editor/core/tools.d.ts.map +1 -1
  60. package/dist/editor/core/tools.js.map +1 -1
  61. package/dist/editor/core/viewIsland.d.ts.map +1 -1
  62. package/dist/editor/core/viewIsland.js +2 -1
  63. package/dist/editor/core/viewIsland.js.map +1 -1
  64. package/dist/editor/features/builtins.d.ts +9 -4
  65. package/dist/editor/features/builtins.d.ts.map +1 -1
  66. package/dist/editor/features/builtins.js +36 -7
  67. package/dist/editor/features/builtins.js.map +1 -1
  68. package/dist/editor/features/search/SearchPanel.d.ts.map +1 -1
  69. package/dist/editor/features/search/SearchPanel.js +26 -12
  70. package/dist/editor/features/search/SearchPanel.js.map +1 -1
  71. package/dist/editor/features/search/revealMatch.d.ts +16 -0
  72. package/dist/editor/features/search/revealMatch.d.ts.map +1 -1
  73. package/dist/editor/features/search/revealMatch.js +22 -0
  74. package/dist/editor/features/search/revealMatch.js.map +1 -1
  75. package/dist/editor/features/search/searchPlugin.d.ts +8 -3
  76. package/dist/editor/features/search/searchPlugin.d.ts.map +1 -1
  77. package/dist/editor/features/search/searchPlugin.js +17 -6
  78. package/dist/editor/features/search/searchPlugin.js.map +1 -1
  79. package/dist/editor/features/search/searchSnapshot.d.ts +14 -0
  80. package/dist/editor/features/search/searchSnapshot.d.ts.map +1 -0
  81. package/dist/editor/features/search/searchSnapshot.js +17 -0
  82. package/dist/editor/features/search/searchSnapshot.js.map +1 -0
  83. package/dist/editor/features/search/searchTool.d.ts +3 -0
  84. package/dist/editor/features/search/searchTool.d.ts.map +1 -1
  85. package/dist/editor/features/search/searchTool.js +30 -1
  86. package/dist/editor/features/search/searchTool.js.map +1 -1
  87. package/dist/editor/features/toc/RichTextToc.d.ts +29 -1
  88. package/dist/editor/features/toc/RichTextToc.d.ts.map +1 -1
  89. package/dist/editor/features/toc/RichTextToc.js +64 -12
  90. package/dist/editor/features/toc/RichTextToc.js.map +1 -1
  91. package/dist/editor/features/toc/documentHeadings.d.ts +30 -0
  92. package/dist/editor/features/toc/documentHeadings.d.ts.map +1 -0
  93. package/dist/editor/features/toc/documentHeadings.js +20 -0
  94. package/dist/editor/features/toc/documentHeadings.js.map +1 -0
  95. package/dist/editor/features/toc/headings.d.ts +6 -2
  96. package/dist/editor/features/toc/headings.d.ts.map +1 -1
  97. package/dist/editor/features/toc/headings.js +4 -2
  98. package/dist/editor/features/toc/headings.js.map +1 -1
  99. package/dist/editor/features/toc/tocSnapshot.d.ts +10 -0
  100. package/dist/editor/features/toc/tocSnapshot.d.ts.map +1 -0
  101. package/dist/editor/features/toc/tocSnapshot.js +8 -0
  102. package/dist/editor/features/toc/tocSnapshot.js.map +1 -0
  103. package/dist/editor/features/toc/tocTool.d.ts +6 -0
  104. package/dist/editor/features/toc/tocTool.d.ts.map +1 -0
  105. package/dist/editor/features/toc/tocTool.js +21 -0
  106. package/dist/editor/features/toc/tocTool.js.map +1 -0
  107. package/dist/editor/hooks/useRichTextToolState.d.ts +43 -0
  108. package/dist/editor/hooks/useRichTextToolState.d.ts.map +1 -0
  109. package/dist/editor/hooks/useRichTextToolState.js +77 -0
  110. package/dist/editor/hooks/useRichTextToolState.js.map +1 -0
  111. package/dist/editor/index.d.ts +8 -0
  112. package/dist/editor/index.d.ts.map +1 -1
  113. package/dist/editor/index.js +3 -0
  114. package/dist/editor/index.js.map +1 -1
  115. package/docs/api/editor-extension.en.md +223 -23
  116. package/docs/api/editor-extension.md +224 -22
  117. package/docs/api/migration-notes.en.md +36 -1
  118. package/docs/api/migration-notes.md +35 -0
  119. package/docs/components/content/rich-text-toc.en.md +13 -11
  120. package/docs/components/content/rich-text-toc.md +12 -10
  121. package/docs/components/index.en.md +2 -2
  122. package/docs/components/index.md +1 -1
  123. package/docs/components/inputs/rich-text-editor.en.md +227 -11
  124. package/docs/components/inputs/rich-text-editor.md +228 -10
  125. package/docs/getting-started.en.md +7 -7
  126. package/docs/getting-started.md +6 -6
  127. package/docs/glossary.en.md +52 -48
  128. package/docs/glossary.md +51 -47
  129. package/package.json +1 -1
  130. package/src/editor/RichTextEditor.tsx +94 -39
  131. package/src/editor/RichTextView.tsx +2 -1
  132. package/src/editor/core/containers.ts +8 -6
  133. package/src/editor/core/customTool.ts +8 -2
  134. package/src/editor/core/customToolEngine.ts +20 -1
  135. package/src/editor/core/extensions.ts +2 -1
  136. package/src/editor/core/handle.ts +25 -1
  137. package/src/editor/core/handleWatchers.ts +45 -0
  138. package/src/editor/core/nodeNames.ts +7 -0
  139. package/src/editor/core/schema.ts +5 -2
  140. package/src/editor/core/toolCommands.ts +85 -0
  141. package/src/editor/core/toolDefinition.ts +208 -0
  142. package/src/editor/core/toolDescriptor.ts +94 -3
  143. package/src/editor/core/toolOptions.ts +211 -0
  144. package/src/editor/core/toolState.ts +56 -0
  145. package/src/editor/core/tools.ts +3 -1
  146. package/src/editor/core/viewIsland.ts +5 -4
  147. package/src/editor/features/builtins.ts +72 -13
  148. package/src/editor/features/search/SearchPanel.tsx +38 -14
  149. package/src/editor/features/search/revealMatch.ts +45 -0
  150. package/src/editor/features/search/searchPlugin.ts +40 -11
  151. package/src/editor/features/search/searchSnapshot.ts +29 -0
  152. package/src/editor/features/search/searchTool.ts +51 -1
  153. package/src/editor/features/toc/RichTextToc.tsx +122 -13
  154. package/src/editor/features/toc/documentHeadings.ts +59 -0
  155. package/src/editor/features/toc/headings.ts +10 -3
  156. package/src/editor/features/toc/tocSnapshot.ts +24 -0
  157. package/src/editor/features/toc/tocTool.ts +41 -0
  158. package/src/editor/hooks/useRichTextToolState.ts +153 -0
  159. package/src/editor/index.ts +19 -0
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import type { Editor } from "@tiptap/core";
3
4
  import {
4
5
  forwardRef,
5
6
  useCallback,
@@ -8,6 +9,7 @@ import {
8
9
  useState,
9
10
  type HTMLAttributes,
10
11
  type KeyboardEvent,
12
+ type MutableRefObject,
11
13
  type RefObject,
12
14
  } from "react";
13
15
  import {
@@ -19,6 +21,7 @@ import { useMessages } from "../../../i18n/useMessages.js";
19
21
  import { cn } from "../../../lib/cn.js";
20
22
  import { watchHandleEditor } from "../../core/handleWatchers.js";
21
23
  import type { RichTextEditorHandle } from "../../RichTextEditor.js";
24
+ import { tocDocumentHeadings } from "./documentHeadings.js";
22
25
  import { collectHeadings, documentRoot, type TocHeading } from "./headings.js";
23
26
  import { stickyOffset } from "./stickyOffset.js";
24
27
 
@@ -50,6 +53,13 @@ export interface RichTextTocProps extends Omit<HTMLAttributes<HTMLDivElement>, "
50
53
  * По умолчанию 240px.
51
54
  */
52
55
  listWidth?: number | string;
56
+ /**
57
+ * Уровни заголовков, которые попадают в Оглавление: подмножество 1–4. Заголовка
58
+ * другого уровня нет ни в полосе, ни в списке, а отступ пунктов считается от
59
+ * самого крупного из оставшихся. Читается при каждой сборке списка, поэтому
60
+ * смена набора уровней пересобирает его. По умолчанию все четыре.
61
+ */
62
+ levels?: readonly (1 | 2 | 3 | 4)[];
53
63
  }
54
64
 
55
65
  /**
@@ -77,6 +87,9 @@ const itemIndent = (depth: number) => ITEM_INDENT[Math.min(depth, ITEM_INDENT.le
77
87
  const OPEN_DELAY = 200;
78
88
  const CLOSE_DELAY = 300;
79
89
 
90
+ /** Все уровни заголовков: Оглавление без `levels`. */
91
+ const ALL_LEVELS = [1, 2, 3, 4] as const;
92
+
80
93
  /** Пауза после последней правки Документа, по которой список пересобирается. */
81
94
  const REBUILD_DELAY = 150;
82
95
 
@@ -185,12 +198,23 @@ function jumpTo(
185
198
  flash(heading);
186
199
 
187
200
  const behavior: ScrollBehavior = prefersReducedMotion() ? "auto" : "smooth";
188
- // Без шапки браузер сам учтёт `scroll-margin-top` заголовка.
201
+ const scroller = scrollParent(heading);
189
202
  if (offset === null) {
190
- heading.scrollIntoView({ behavior, block: "start" });
203
+ // Без шапки отступ задаёт `scroll-margin-top` заголовка (ADR-0016). Окно
204
+ // прокручивает браузер. Но `scrollIntoView` двигает ещё и всех
205
+ // прокручиваемых предков, а значит, при Документе в своей прокручиваемой
206
+ // области уехала бы и страница, — поэтому область прокручивается сама,
207
+ // а окно не трогается.
208
+ if (!scroller) {
209
+ heading.scrollIntoView({ behavior, block: "start" });
210
+ return;
211
+ }
212
+ const margin = parseFloat(getComputedStyle(heading).scrollMarginTop) || 0;
213
+ const edge = scroller.getBoundingClientRect().top;
214
+ const top = heading.getBoundingClientRect().top;
215
+ scroller.scrollTo({ top: scroller.scrollTop + top - edge - margin, behavior });
191
216
  return;
192
217
  }
193
- const scroller = scrollParent(heading);
194
218
  const top = heading.getBoundingClientRect().top;
195
219
  if (scroller) {
196
220
  const edge = scroller.getBoundingClientRect().top;
@@ -320,6 +344,26 @@ export interface TocCoreProps extends RichTextTocProps {
320
344
  * уровня, а не полоса выше текста.
321
345
  */
322
346
  alignToFirstHeading?: boolean;
347
+ /**
348
+ * Текущий раздел сменился (в том числе при первом показе): Редактор кладёт
349
+ * индекс в Состояние Инструмента `toc`. Индекс считается по списку, который
350
+ * видит `TocCore`; пока заголовков меньше двух, он остаётся прежним.
351
+ */
352
+ onCurrentChange?: (index: number) => void;
353
+ /**
354
+ * Движок Редактора. Нужен команде `jumpTo` (`jumpRef`): индекс команды — это
355
+ * индекс заголовка в снимке Состояния `toc`, а снимок читает Документ движка
356
+ * на каждой транзакции. Список ядра пересобирается из DOM с задержкой
357
+ * (`REBUILD_DELAY`), и сразу после правки индексы двух списков расходились бы.
358
+ * Без движка `jumpTo` считает по списку ядра.
359
+ */
360
+ editor?: Editor;
361
+ /**
362
+ * Куда ядро кладёт переход к заголовку по индексу снимка Состояния (команда
363
+ * `jumpTo` Инструмента): ведёт себя как клик по пункту. Пока ядро смонтировано,
364
+ * там функция, после — `null`. Нет заголовка с таким индексом — `false`.
365
+ */
366
+ jumpRef?: MutableRefObject<((index: number) => boolean) | null>;
323
367
  }
324
368
 
325
369
  export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore(
@@ -327,9 +371,13 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
327
371
  target,
328
372
  offsetVar = "--topbar-h",
329
373
  listWidth = 240,
374
+ levels,
330
375
  stickyTop,
331
376
  focusHeading,
332
377
  alignToFirstHeading = false,
378
+ onCurrentChange,
379
+ editor,
380
+ jumpRef,
333
381
  className,
334
382
  style,
335
383
  ...rest
@@ -338,10 +386,12 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
338
386
  ) {
339
387
  // Настройки хозяина читаются в обработчиках прокрутки: последние значения —
340
388
  // через ссылку, чтобы новая функция на каждом рендере не пересоздавала слушателей.
341
- const latest = useRef({ stickyTop, focusHeading, offsetVar });
389
+ const latest = useRef({ stickyTop, focusHeading, offsetVar, levels, onCurrentChange, editor });
342
390
  useEffect(() => {
343
- latest.current = { stickyTop, focusHeading, offsetVar };
391
+ latest.current = { stickyTop, focusHeading, offsetVar, levels, onCurrentChange, editor };
344
392
  });
393
+ // Набор уровней сравнивается по значению: массив-литерал новый на каждом рендере.
394
+ const levelsKey = levels ? levels.join(",") : "";
345
395
  const offsetNow = useCallback(() => {
346
396
  const own = latest.current;
347
397
  return own.stickyTop ? own.stickyTop() : stickyOffset(own.offsetVar);
@@ -351,6 +401,10 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
351
401
  const [headings, setHeadings] = useState<TocHeading[]>([]);
352
402
  const [gap, setGap] = useState(STRIP_GAP);
353
403
  const [current, setCurrent] = useState(0);
404
+ // Текущий раздел — в Состояние Инструмента (`useRichTextToolState`).
405
+ useEffect(() => {
406
+ latest.current.onCurrentChange?.(current);
407
+ }, [current]);
354
408
  const [open, setOpen] = useState(false);
355
409
  const [fits, setFits] = useState(true);
356
410
  const stripRef = useRef<HTMLButtonElement>(null);
@@ -379,7 +433,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
379
433
  return;
380
434
  }
381
435
  const read = () => {
382
- const next = collectHeadings(element);
436
+ const next = collectHeadings(element, latest.current.levels);
383
437
  setHeadings((prev) => (sameHeadings(prev, next) ? prev : next));
384
438
  };
385
439
  read();
@@ -397,7 +451,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
397
451
  clearTimeout(timer);
398
452
  observer.disconnect();
399
453
  };
400
- }, [element]);
454
+ }, [element, levelsKey]);
401
455
 
402
456
  // Шаг полосы зависит от высоты окна и числа заголовков.
403
457
  useEffect(() => {
@@ -426,7 +480,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
426
480
  Пересчёт возвращается со следующей прокрутки после того, как переход
427
481
  улёгся (`settled`).
428
482
  */
429
- const pinned = useRef<{ index: number; settled: boolean; timer: number } | null>(null);
483
+ const pinned = useRef<{ heading: HTMLElement; settled: boolean; timer: number } | null>(null);
430
484
  /** (Пере)запустить ожидание: прокрутка перехода молчит `PIN_SETTLE` — переход улёгся. */
431
485
  const settle = (lock: { settled: boolean; timer: number }) => {
432
486
  window.clearTimeout(lock.timer);
@@ -434,12 +488,62 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
434
488
  lock.settled = true;
435
489
  }, PIN_SETTLE);
436
490
  };
437
- const pin = (index: number) => {
491
+ /*
492
+ Раздел перехода помнится узлом, а не индексом: список может пересобраться,
493
+ пока идёт прокрутка (правка моложе `REBUILD_DELAY`), и индекс повёл бы к
494
+ соседнему заголовку.
495
+ */
496
+ const pin = (index: number, heading: HTMLElement) => {
438
497
  if (pinned.current) window.clearTimeout(pinned.current.timer);
439
- pinned.current = { index, settled: false, timer: 0 };
498
+ pinned.current = { heading, settled: false, timer: 0 };
440
499
  settle(pinned.current);
441
500
  setCurrent(index);
442
501
  };
502
+ /*
503
+ Переход к пункту списка: клик и команда `jumpTo` Инструмента идут одним
504
+ путём. Заголовок, уже вышедший из Документа (правка моложе `REBUILD_DELAY`),
505
+ не пункт: список вот-вот пересоберётся.
506
+ */
507
+ const go = (index: number): boolean => {
508
+ const heading = headings[index];
509
+ if (!heading?.element.isConnected) return false;
510
+ pin(index, heading.element);
511
+ jumpTo(heading.element, offsetNow(), latest.current.focusHeading);
512
+ return true;
513
+ };
514
+ /*
515
+ Команда `jumpTo(index)`: индекс — из снимка Состояния, а снимок читает
516
+ Документ движка (`tocDocumentHeadings`, тот же обход), поэтому заголовок
517
+ берётся оттуда: `view.nodeDOM` отдаёт его узел, ProseMirror обновляет DOM
518
+ синхронно с транзакцией. Список ядра при этом может отставать до
519
+ `REBUILD_DELAY`, поэтому читается заново, а не из состояния: от него нужен
520
+ только индекс пункта для подсветки.
521
+ */
522
+ const goToSnapshotIndex = (index: number): boolean => {
523
+ const { editor: engine, levels: shown } = latest.current;
524
+ if (!engine) return go(index);
525
+ if (engine.isDestroyed) return false;
526
+ const found = tocDocumentHeadings(engine, { levels: shown ?? ALL_LEVELS })[index];
527
+ const node = found && engine.view.nodeDOM(found.pos);
528
+ if (!(node instanceof HTMLElement) || !node.isConnected || !element) return false;
529
+ const fresh = collectHeadings(element, shown);
530
+ setHeadings((prev) => (sameHeadings(prev, fresh) ? prev : fresh));
531
+ const listed = fresh.findIndex((heading) => heading.element === node);
532
+ if (listed >= 0) pin(listed, node);
533
+ jumpTo(node, offsetNow(), latest.current.focusHeading);
534
+ return true;
535
+ };
536
+ const goRef = useRef(goToSnapshotIndex);
537
+ useEffect(() => {
538
+ goRef.current = goToSnapshotIndex;
539
+ });
540
+ useEffect(() => {
541
+ if (!jumpRef) return;
542
+ jumpRef.current = (index) => goRef.current(index);
543
+ return () => {
544
+ jumpRef.current = null;
545
+ };
546
+ }, [jumpRef]);
443
547
 
444
548
  // Подсветка текущего раздела. Слушатель прокрутки — на документе с захватом:
445
549
  // так его достигает прокрутка и окна, и любого контейнера вокруг цели.
@@ -448,7 +552,13 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
448
552
  let frame = 0;
449
553
  const update = () => {
450
554
  frame = 0;
451
- setCurrent(currentIndex(headings, offsetNow()));
555
+ // Список пересобрался, пока идёт прокрутка перехода: раздел перехода тот
556
+ // же заголовок, но под новым индексом. Замер посреди прокрутки назвал бы
557
+ // соседний, и он остался бы текущим, когда прокрутка уляжется.
558
+ const lock = pinned.current;
559
+ const held =
560
+ lock && !lock.settled ? headings.findIndex((h) => h.element === lock.heading) : -1;
561
+ setCurrent(held >= 0 ? held : currentIndex(headings, offsetNow()));
452
562
  };
453
563
  const schedule = (event?: Event) => {
454
564
  // Считается только прокрутка, которая двигает Документ: окна или
@@ -670,8 +780,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
670
780
  index === current ? "bg-bg-2 text-fg-0" : "text-fg-2 hover:text-fg-0"
671
781
  )}
672
782
  onClick={(event) => {
673
- pin(index);
674
- jumpTo(heading.element, offsetNow(), latest.current.focusHeading);
783
+ go(index);
675
784
  /*
676
785
  В Редакторе в правке фокус на заголовок не переходит и
677
786
  остаётся на пункте, а фокус в списке держит его открытым:
@@ -0,0 +1,59 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import type { Node as PmNode } from "@tiptap/pm/model";
3
+ import { WRAPPER_NODE } from "../../core/nodeNames.js";
4
+ import type { TocLevel, TocToolOptions } from "../../core/toolOptions.js";
5
+
6
+ /*
7
+ Модуль без Tiptap в рантайме (только типы): его читает ядро Оглавления
8
+ (`TocCore`, `RichTextToc.tsx`), которому движок в бандл не нужен.
9
+ */
10
+
11
+ /** Заголовок Документа с позицией: по ней команда `jumpTo` находит DOM-узел. */
12
+ export interface TocDocumentHeading {
13
+ level: TocLevel;
14
+ text: string;
15
+ /** Позиция узла заголовка в Документе (для `editor.view.nodeDOM`). */
16
+ pos: number;
17
+ }
18
+
19
+ /**
20
+ * Заголовки Оглавления по Документу движка, а не по DOM: ядро Оглавления
21
+ * (`TocCore`) — ленивый чанк, и пока он не пришёл (или нарисован HTML-режим),
22
+ * узлов для обхода нет, а данные экран просит с первого кадра. Это единственный
23
+ * обход, по которому считаются индексы: и снимок Состояния `toc`, и команда
24
+ * `jumpTo` берут заголовок отсюда, поэтому индекс из снимка ведёт к тому же
25
+ * заголовку сразу после правки, не дожидаясь, пока ядро пересоберёт свой список
26
+ * из DOM (`REBUILD_DELAY`).
27
+ *
28
+ * ТЕ ЖЕ ПРАВИЛА, ЧТО У `collectHeadings` (`headings.ts`), иначе индексы разойдутся
29
+ * с пунктами списка: заголовок на верхнем уровне или внутри Обёрток (колонки
30
+ * Раскладки, свои Блоки, цитаты и списки — нет), уровень из `levels`, текст без
31
+ * крайних пробелов не пуст. Текст — тот же, что у `textContent` DOM: мягкий
32
+ * перенос в него не входит, а Текст атома — входит.
33
+ *
34
+ * Обход по верхнему уровню и Обёрткам, а не по всему Документу: стоимость — число
35
+ * Блоков верхнего уровня, она не растёт от вложенности и заметна не будет даже
36
+ * на каждой транзакции.
37
+ */
38
+ export function tocDocumentHeadings(
39
+ editor: Editor,
40
+ { levels }: Pick<TocToolOptions, "levels">
41
+ ): TocDocumentHeading[] {
42
+ const headings: TocDocumentHeading[] = [];
43
+ // `start` — позиция, с которой начинается содержимое `parent`.
44
+ const visit = (parent: PmNode, start: number) =>
45
+ parent.forEach((node, offset) => {
46
+ const pos = start + offset;
47
+ if (node.type.name === WRAPPER_NODE) return visit(node, pos + 1);
48
+ if (node.type.name !== "heading") return;
49
+ const level = node.attrs.level as TocLevel;
50
+ const text = node
51
+ .textBetween(0, node.content.size, "", (leaf) =>
52
+ leaf.type.name === "hardBreak" ? "" : leaf.textContent
53
+ )
54
+ .trim();
55
+ if (text && levels.includes(level)) headings.push({ level, text, pos });
56
+ });
57
+ visit(editor.state.doc, 0);
58
+ return headings;
59
+ }
@@ -51,13 +51,20 @@ function isStructural(heading: Element, root: HTMLElement): boolean {
51
51
  return true;
52
52
  }
53
53
 
54
- /** Заголовки Документа в порядке чтения. */
55
- export function collectHeadings(target: HTMLElement): TocHeading[] {
54
+ /**
55
+ * Заголовки Документа в порядке чтения. `only` — какие уровни (1–4) берутся:
56
+ * остальные не попадают в список, и глубина (`depth`) считается от самого
57
+ * крупного из оставшихся. Нет — все.
58
+ */
59
+ export function collectHeadings(target: HTMLElement, only?: readonly number[]): TocHeading[] {
56
60
  const root = documentRoot(target);
57
61
  // Пустой заголовок (автор только начал его в Редакторе) — не пункт: у
58
62
  // кнопки без текста не было бы имени, а у полосы — лишний штрих.
59
63
  const found = [...root.querySelectorAll<HTMLElement>(HEADING)].filter(
60
- (heading) => isStructural(heading, root) && heading.textContent?.trim()
64
+ (heading) =>
65
+ isStructural(heading, root) &&
66
+ heading.textContent?.trim() &&
67
+ (!only || only.includes(Number(heading.tagName[1])))
61
68
  );
62
69
  const levels = found.map((heading) => Number(heading.tagName[1]));
63
70
  const top = Math.min(...levels);
@@ -0,0 +1,24 @@
1
+ "use client";
2
+
3
+ import type { Editor } from "@tiptap/core";
4
+ import type { TocToolOptions } from "../../core/toolOptions.js";
5
+ import type { TocToolHeading, TocToolState } from "../../core/toolState.js";
6
+ import { tocDocumentHeadings } from "./documentHeadings.js";
7
+
8
+ /**
9
+ * Снимок Состояния `toc` для `useRichTextToolState`: заголовки из
10
+ * `tocDocumentHeadings` без позиций (они в контракт не входят) и текущий раздел.
11
+ * `at` — индекс текущего раздела, который сообщило ядро Оглавления.
12
+ */
13
+ export function tocSnapshot(
14
+ editor: Editor,
15
+ options: Pick<TocToolOptions, "levels">,
16
+ at: number | undefined
17
+ ): TocToolState {
18
+ const headings: TocToolHeading[] = tocDocumentHeadings(editor, options).map(
19
+ ({ level, text }, index) => ({ level, text, index })
20
+ );
21
+ // Прокрутка сообщает индекс по прежнему списку: после правки он мог выйти за край.
22
+ const current = headings.length ? Math.min(Math.max(at ?? 0, 0), headings.length - 1) : -1;
23
+ return { headings, current };
24
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import type { ToolDescriptor } from "../../core/toolDescriptor.js";
4
+ import type { TocToolCommands } from "../../core/toolCommands.js";
5
+ import type { TocLevel, TocToolOptions } from "../../core/toolOptions.js";
6
+
7
+ /**
8
+ * Оглавление на описании Инструмента (ADR-0068). Части для движка и UI-записей
9
+ * у него нет: полосу и список рисует `RichTextEditor` в левом поле рамки
10
+ * (`TocCore`, ленивым чанком), а описание несёт Опции — умолчания и проверки,
11
+ * из которых `expandTools` собирает Опции Редактора.
12
+ *
13
+ * Тяжёлых импортов нет: описание лежит в стартовом куске Редактора, а UI
14
+ * Оглавления — нет.
15
+ */
16
+ const TOC_LEVELS: readonly TocLevel[] = [1, 2, 3, 4];
17
+
18
+ /** Умолчания Опций: переменная `TopBar` (ADR-0016), все четыре уровня, список в 240 пикселей. */
19
+ export const TOC_DEFAULTS: TocToolOptions = {
20
+ offsetVar: "--topbar-h",
21
+ levels: TOC_LEVELS,
22
+ listWidth: 240,
23
+ };
24
+
25
+ export const TOC_TOOL: ToolDescriptor = {
26
+ name: "toc",
27
+ defaults: TOC_DEFAULTS,
28
+ checks: {
29
+ // Имя вставляется в `var(…)` стиля поля, поэтому — только слово с дефисами.
30
+ offsetVar: (value) => typeof value === "string" && /^--[\w-]+$/.test(value),
31
+ levels: (value) =>
32
+ Array.isArray(value) &&
33
+ value.length > 0 &&
34
+ value.every((level) => (TOC_LEVELS as readonly unknown[]).includes(level)),
35
+ listWidth: (value) =>
36
+ (typeof value === "number" && Number.isFinite(value) && value > 0) ||
37
+ (typeof value === "string" && value.trim() !== ""),
38
+ },
39
+ // Переход живёт в ленивом ядре Оглавления: мост зовёт его, пока оно смонтировано.
40
+ commands: ({ bridge }): TocToolCommands => ({ jumpTo: bridge.jumpToc }),
41
+ };
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import type { Editor } from "@tiptap/react";
4
+ import { useCallback, useRef, useSyncExternalStore, type RefObject } from "react";
5
+ import { toolSignals, watchHandleEditor } from "../core/handleWatchers.js";
6
+ import { toolOptions } from "../core/toolDescriptor.js";
7
+ import type { RichTextTools } from "../core/toolOptions.js";
8
+ import type { RichTextEditorHandle } from "../RichTextEditor.js";
9
+ import { searchSnapshot } from "../features/search/searchSnapshot.js";
10
+ import { tocSnapshot } from "../features/toc/tocSnapshot.js";
11
+
12
+ /**
13
+ * Состояние Инструмента по имени: тип из карты `RichTextTools`. Имя, которого в
14
+ * карте нет, — `unknown`: типизация добровольна, и Инструмент без записи в карте
15
+ * не ломает сборку.
16
+ */
17
+ export type RichTextToolState<Name extends string> = Name extends keyof RichTextTools
18
+ ? RichTextTools[Name] extends { state: infer State }
19
+ ? State
20
+ : unknown
21
+ : unknown;
22
+
23
+ /** Снимок встроенного Инструмента; `null` — Инструмента в движке нет. */
24
+ const SNAPSHOTS = new Map<string, (editor: Editor, ref: object) => unknown>([
25
+ [
26
+ "search",
27
+ (editor, ref) => {
28
+ const { tools, searchOpen } = toolSignals(ref);
29
+ return tools?.has("search") ? searchSnapshot(editor, searchOpen === true) : null;
30
+ },
31
+ ],
32
+ [
33
+ "toc",
34
+ (editor, ref) => {
35
+ const { tools, tocAt } = toolSignals(ref);
36
+ const options = tools && toolOptions(tools, "toc");
37
+ return options && tocSnapshot(editor, options, tocAt);
38
+ },
39
+ ],
40
+ ]);
41
+
42
+ /** Простой объект: литерал или `Object.create(null)`, не экземпляр класса. */
43
+ function isPlainObject(value: object): boolean {
44
+ const prototype = Object.getPrototypeOf(value) as unknown;
45
+ return prototype === Object.prototype || prototype === null;
46
+ }
47
+
48
+ /**
49
+ * Равенство для снимков. По значению сравниваются только простые данные:
50
+ * примитивы, массивы и простые объекты. Всё прочее — `Date`, `Map`, `Set`,
51
+ * экземпляры классов — по ссылке: у них нет собственных ключей, по которым можно
52
+ * было бы увидеть разницу, и «равными по значению» оказались бы любые два, то есть
53
+ * экран тихо не обновлялся бы. Новый экземпляр — новый снимок.
54
+ */
55
+ function sameValue(a: unknown, b: unknown): boolean {
56
+ if (Object.is(a, b)) return true;
57
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
58
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
59
+ if (!Array.isArray(a) && !(isPlainObject(a) && isPlainObject(b))) return false;
60
+ const left = a as Record<string, unknown>;
61
+ const right = b as Record<string, unknown>;
62
+ const keys = Object.keys(left);
63
+ return (
64
+ keys.length === Object.keys(right).length &&
65
+ keys.every((key) => key in right && sameValue(left[key], right[key]))
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Состояние встроенного Инструмента для своего экрана: «3 из 12» в Строке
71
+ * статуса из Поиска, внешняя навигация из заголовков Оглавления. Имя —
72
+ * Инструмент из набора `tools`, а тип снимка выводится из карты
73
+ * `RichTextTools`: `search` отдаёт `SearchToolState`, `toc` — `TocToolState`.
74
+ *
75
+ * ПЕРЕРИСОВКА — ТОЛЬКО ПРИ СМЕНЕ СНИМКА. Снимок пересчитывается на каждую
76
+ * транзакцию движка и на сигналы кита, которых в движке нет: открытие и закрытие
77
+ * панели Поиска, смена текущего раздела Оглавления при прокрутке. Экран
78
+ * перерисовывается, когда результат стал другим (простые данные — примитивы,
79
+ * массивы, простые объекты — сравниваются по значению, как у
80
+ * `useRichTextEditorState`; `Date`, `Map`, `Set` и экземпляры классов — по
81
+ * ссылке), поэтому набор текста, не меняющий снимок, его не трогает. Срез из `selector` сравнивается так же: `(state) => state.total`
82
+ * подписывает экран на одно число.
83
+ *
84
+ * `null` — до монтажа и в первом рендере экрана, на сервере, а также когда
85
+ * Инструмента нет в наборе `tools`: пресеты `toc` не включают, его включают
86
+ * именем или записью `tool("toc")`. Селектор при `null` не вызывается. Один и
87
+ * тот же `ref` отдаётся в `RichTextEditor`.
88
+ *
89
+ * Снимок — данные без DOM-узлов и позиций ProseMirror; изменять его не нужно.
90
+ * Хук в стартовый граф `RichTextEditor` не входит: его цена — только у экрана,
91
+ * который его импортировал.
92
+ *
93
+ * @example
94
+ * const ref = useRef<RichTextEditorHandle>(null);
95
+ * const total = useRichTextToolState(ref, "search", (state) => state.total);
96
+ * const headings = useRichTextToolState(ref, "toc", (state) => state.headings);
97
+ */
98
+ export function useRichTextToolState<Name extends string>(
99
+ ref: RefObject<RichTextEditorHandle | null>,
100
+ name: Name
101
+ ): RichTextToolState<Name> | null;
102
+ export function useRichTextToolState<Name extends string, Slice>(
103
+ ref: RefObject<RichTextEditorHandle | null>,
104
+ name: Name,
105
+ selector: (state: RichTextToolState<Name>) => Slice
106
+ ): Slice | null;
107
+ export function useRichTextToolState(
108
+ ref: RefObject<RichTextEditorHandle | null>,
109
+ name: string,
110
+ selector?: (state: never) => unknown
111
+ ): unknown {
112
+ const last = useRef<{ value: unknown } | null>(null);
113
+
114
+ const subscribe = useCallback(
115
+ (listener: () => void) => {
116
+ // Транзакции движка — на текущем движке; он меняется — пересаживаем слушателя.
117
+ let followed: Editor | null = null;
118
+ const follow = () => {
119
+ followed?.off("transaction", listener);
120
+ const editor = ref.current?.editor ?? null;
121
+ followed = editor && !editor.isDestroyed ? editor : null;
122
+ followed?.on("transaction", listener);
123
+ };
124
+ follow();
125
+ const stop = watchHandleEditor(ref, () => {
126
+ follow();
127
+ listener();
128
+ });
129
+ return () => {
130
+ stop();
131
+ followed?.off("transaction", listener);
132
+ };
133
+ },
134
+ [ref]
135
+ );
136
+
137
+ const read = (): unknown => {
138
+ const editor = ref.current?.editor;
139
+ // Своё Состояние — функция описания из набора этого Редактора; встроенное — таблица.
140
+ const own = toolSignals(ref).tools?.descriptors.find((entry) => entry.name === name)?.state;
141
+ const snapshot =
142
+ editor && !editor.isDestroyed
143
+ ? ((own ? own(editor) : SNAPSHOTS.get(name)?.(editor, ref)) ?? null)
144
+ : null;
145
+ const value = snapshot === null || !selector ? snapshot : selector(snapshot as never);
146
+ // Прежняя ссылка, пока значение то же: иначе React видит новый снимок на каждый рендер.
147
+ if (last.current && sameValue(last.current.value, value)) return last.current.value;
148
+ last.current = { value };
149
+ return value;
150
+ };
151
+
152
+ return useSyncExternalStore(subscribe, read, () => null);
153
+ }
@@ -19,6 +19,8 @@ export { RichTextEditor } from "./RichTextEditor.js";
19
19
  export type { RichTextEditorHandle, RichTextEditorProps } from "./RichTextEditor.js";
20
20
  export type { RichTextDocument } from "./core/jsonDocument.js";
21
21
  export { useRichTextEditorState } from "./hooks/useRichTextEditorState.js";
22
+ export { useRichTextToolState } from "./hooks/useRichTextToolState.js";
23
+ export type { RichTextToolState } from "./hooks/useRichTextToolState.js";
22
24
  export { RichTextView } from "./RichTextView.js";
23
25
  export type { RichTextViewProps } from "./RichTextView.js";
24
26
  export { RichTextToc } from "./features/toc/RichTextToc.js";
@@ -36,6 +38,23 @@ export type {
36
38
  } from "./core/schema.js";
37
39
  export { RICH_TEXT_PRESETS } from "./core/tools.js";
38
40
  export type { RichTextEditorPreset } from "./core/tools.js";
41
+ export { tool } from "./core/toolOptions.js";
42
+ export { defineTool } from "./core/toolDefinition.js";
43
+ export type {
44
+ CustomToolBody,
45
+ ToolDefinition,
46
+ ToolDefinitionContext,
47
+ ToolDefinitionInput,
48
+ } from "./core/toolDefinition.js";
49
+ export type {
50
+ ConfiguredTool,
51
+ RichTextTools,
52
+ SearchToolOptions,
53
+ TocLevel,
54
+ TocToolOptions,
55
+ } from "./core/toolOptions.js";
56
+ export type { SearchToolCommands, TocToolCommands } from "./core/toolCommands.js";
57
+ export type { SearchToolState, TocToolHeading, TocToolState } from "./core/toolState.js";
39
58
  export { markRange } from "./core/customTool.js";
40
59
  export type {
41
60
  TextCardRange,