@olddevs/ui 4.4.0 → 4.5.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 (159) hide show
  1. package/CHANGELOG.md +13 -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 +55 -10
  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 +108 -10
  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
 
@@ -320,6 +333,26 @@ export interface TocCoreProps extends RichTextTocProps {
320
333
  * уровня, а не полоса выше текста.
321
334
  */
322
335
  alignToFirstHeading?: boolean;
336
+ /**
337
+ * Текущий раздел сменился (в том числе при первом показе): Редактор кладёт
338
+ * индекс в Состояние Инструмента `toc`. Индекс считается по списку, который
339
+ * видит `TocCore`; пока заголовков меньше двух, он остаётся прежним.
340
+ */
341
+ onCurrentChange?: (index: number) => void;
342
+ /**
343
+ * Движок Редактора. Нужен команде `jumpTo` (`jumpRef`): индекс команды — это
344
+ * индекс заголовка в снимке Состояния `toc`, а снимок читает Документ движка
345
+ * на каждой транзакции. Список ядра пересобирается из DOM с задержкой
346
+ * (`REBUILD_DELAY`), и сразу после правки индексы двух списков расходились бы.
347
+ * Без движка `jumpTo` считает по списку ядра.
348
+ */
349
+ editor?: Editor;
350
+ /**
351
+ * Куда ядро кладёт переход к заголовку по индексу снимка Состояния (команда
352
+ * `jumpTo` Инструмента): ведёт себя как клик по пункту. Пока ядро смонтировано,
353
+ * там функция, после — `null`. Нет заголовка с таким индексом — `false`.
354
+ */
355
+ jumpRef?: MutableRefObject<((index: number) => boolean) | null>;
323
356
  }
324
357
 
325
358
  export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore(
@@ -327,9 +360,13 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
327
360
  target,
328
361
  offsetVar = "--topbar-h",
329
362
  listWidth = 240,
363
+ levels,
330
364
  stickyTop,
331
365
  focusHeading,
332
366
  alignToFirstHeading = false,
367
+ onCurrentChange,
368
+ editor,
369
+ jumpRef,
333
370
  className,
334
371
  style,
335
372
  ...rest
@@ -338,10 +375,12 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
338
375
  ) {
339
376
  // Настройки хозяина читаются в обработчиках прокрутки: последние значения —
340
377
  // через ссылку, чтобы новая функция на каждом рендере не пересоздавала слушателей.
341
- const latest = useRef({ stickyTop, focusHeading, offsetVar });
378
+ const latest = useRef({ stickyTop, focusHeading, offsetVar, levels, onCurrentChange, editor });
342
379
  useEffect(() => {
343
- latest.current = { stickyTop, focusHeading, offsetVar };
380
+ latest.current = { stickyTop, focusHeading, offsetVar, levels, onCurrentChange, editor };
344
381
  });
382
+ // Набор уровней сравнивается по значению: массив-литерал новый на каждом рендере.
383
+ const levelsKey = levels ? levels.join(",") : "";
345
384
  const offsetNow = useCallback(() => {
346
385
  const own = latest.current;
347
386
  return own.stickyTop ? own.stickyTop() : stickyOffset(own.offsetVar);
@@ -351,6 +390,10 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
351
390
  const [headings, setHeadings] = useState<TocHeading[]>([]);
352
391
  const [gap, setGap] = useState(STRIP_GAP);
353
392
  const [current, setCurrent] = useState(0);
393
+ // Текущий раздел — в Состояние Инструмента (`useRichTextToolState`).
394
+ useEffect(() => {
395
+ latest.current.onCurrentChange?.(current);
396
+ }, [current]);
354
397
  const [open, setOpen] = useState(false);
355
398
  const [fits, setFits] = useState(true);
356
399
  const stripRef = useRef<HTMLButtonElement>(null);
@@ -379,7 +422,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
379
422
  return;
380
423
  }
381
424
  const read = () => {
382
- const next = collectHeadings(element);
425
+ const next = collectHeadings(element, latest.current.levels);
383
426
  setHeadings((prev) => (sameHeadings(prev, next) ? prev : next));
384
427
  };
385
428
  read();
@@ -397,7 +440,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
397
440
  clearTimeout(timer);
398
441
  observer.disconnect();
399
442
  };
400
- }, [element]);
443
+ }, [element, levelsKey]);
401
444
 
402
445
  // Шаг полосы зависит от высоты окна и числа заголовков.
403
446
  useEffect(() => {
@@ -426,7 +469,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
426
469
  Пересчёт возвращается со следующей прокрутки после того, как переход
427
470
  улёгся (`settled`).
428
471
  */
429
- const pinned = useRef<{ index: number; settled: boolean; timer: number } | null>(null);
472
+ const pinned = useRef<{ heading: HTMLElement; settled: boolean; timer: number } | null>(null);
430
473
  /** (Пере)запустить ожидание: прокрутка перехода молчит `PIN_SETTLE` — переход улёгся. */
431
474
  const settle = (lock: { settled: boolean; timer: number }) => {
432
475
  window.clearTimeout(lock.timer);
@@ -434,12 +477,62 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
434
477
  lock.settled = true;
435
478
  }, PIN_SETTLE);
436
479
  };
437
- const pin = (index: number) => {
480
+ /*
481
+ Раздел перехода помнится узлом, а не индексом: список может пересобраться,
482
+ пока идёт прокрутка (правка моложе `REBUILD_DELAY`), и индекс повёл бы к
483
+ соседнему заголовку.
484
+ */
485
+ const pin = (index: number, heading: HTMLElement) => {
438
486
  if (pinned.current) window.clearTimeout(pinned.current.timer);
439
- pinned.current = { index, settled: false, timer: 0 };
487
+ pinned.current = { heading, settled: false, timer: 0 };
440
488
  settle(pinned.current);
441
489
  setCurrent(index);
442
490
  };
491
+ /*
492
+ Переход к пункту списка: клик и команда `jumpTo` Инструмента идут одним
493
+ путём. Заголовок, уже вышедший из Документа (правка моложе `REBUILD_DELAY`),
494
+ не пункт: список вот-вот пересоберётся.
495
+ */
496
+ const go = (index: number): boolean => {
497
+ const heading = headings[index];
498
+ if (!heading?.element.isConnected) return false;
499
+ pin(index, heading.element);
500
+ jumpTo(heading.element, offsetNow(), latest.current.focusHeading);
501
+ return true;
502
+ };
503
+ /*
504
+ Команда `jumpTo(index)`: индекс — из снимка Состояния, а снимок читает
505
+ Документ движка (`tocDocumentHeadings`, тот же обход), поэтому заголовок
506
+ берётся оттуда: `view.nodeDOM` отдаёт его узел, ProseMirror обновляет DOM
507
+ синхронно с транзакцией. Список ядра при этом может отставать до
508
+ `REBUILD_DELAY`, поэтому читается заново, а не из состояния: от него нужен
509
+ только индекс пункта для подсветки.
510
+ */
511
+ const goToSnapshotIndex = (index: number): boolean => {
512
+ const { editor: engine, levels: shown } = latest.current;
513
+ if (!engine) return go(index);
514
+ if (engine.isDestroyed) return false;
515
+ const found = tocDocumentHeadings(engine, { levels: shown ?? ALL_LEVELS })[index];
516
+ const node = found && engine.view.nodeDOM(found.pos);
517
+ if (!(node instanceof HTMLElement) || !node.isConnected || !element) return false;
518
+ const fresh = collectHeadings(element, shown);
519
+ setHeadings((prev) => (sameHeadings(prev, fresh) ? prev : fresh));
520
+ const listed = fresh.findIndex((heading) => heading.element === node);
521
+ if (listed >= 0) pin(listed, node);
522
+ jumpTo(node, offsetNow(), latest.current.focusHeading);
523
+ return true;
524
+ };
525
+ const goRef = useRef(goToSnapshotIndex);
526
+ useEffect(() => {
527
+ goRef.current = goToSnapshotIndex;
528
+ });
529
+ useEffect(() => {
530
+ if (!jumpRef) return;
531
+ jumpRef.current = (index) => goRef.current(index);
532
+ return () => {
533
+ jumpRef.current = null;
534
+ };
535
+ }, [jumpRef]);
443
536
 
444
537
  // Подсветка текущего раздела. Слушатель прокрутки — на документе с захватом:
445
538
  // так его достигает прокрутка и окна, и любого контейнера вокруг цели.
@@ -448,7 +541,13 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
448
541
  let frame = 0;
449
542
  const update = () => {
450
543
  frame = 0;
451
- setCurrent(currentIndex(headings, offsetNow()));
544
+ // Список пересобрался, пока идёт прокрутка перехода: раздел перехода тот
545
+ // же заголовок, но под новым индексом. Замер посреди прокрутки назвал бы
546
+ // соседний, и он остался бы текущим, когда прокрутка уляжется.
547
+ const lock = pinned.current;
548
+ const held =
549
+ lock && !lock.settled ? headings.findIndex((h) => h.element === lock.heading) : -1;
550
+ setCurrent(held >= 0 ? held : currentIndex(headings, offsetNow()));
452
551
  };
453
552
  const schedule = (event?: Event) => {
454
553
  // Считается только прокрутка, которая двигает Документ: окна или
@@ -670,8 +769,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
670
769
  index === current ? "bg-bg-2 text-fg-0" : "text-fg-2 hover:text-fg-0"
671
770
  )}
672
771
  onClick={(event) => {
673
- pin(index);
674
- jumpTo(heading.element, offsetNow(), latest.current.focusHeading);
772
+ go(index);
675
773
  /*
676
774
  В Редакторе в правке фокус на заголовок не переходит и
677
775
  остаётся на пункте, а фокус в списке держит его открытым:
@@ -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,