@olddevs/ui 4.3.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 (182) hide show
  1. package/CHANGELOG.md +36 -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 +124 -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 -5
  24. package/dist/editor/core/handle.d.ts.map +1 -1
  25. package/dist/editor/core/handle.js +2 -13
  26. package/dist/editor/core/handle.js.map +1 -1
  27. package/dist/editor/core/handleWatchers.d.ts +36 -0
  28. package/dist/editor/core/handleWatchers.d.ts.map +1 -0
  29. package/dist/editor/core/handleWatchers.js +29 -0
  30. package/dist/editor/core/handleWatchers.js.map +1 -0
  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 +10 -3
  59. package/dist/editor/core/tools.d.ts.map +1 -1
  60. package/dist/editor/core/tools.js +2 -1
  61. package/dist/editor/core/tools.js.map +1 -1
  62. package/dist/editor/core/viewIsland.d.ts.map +1 -1
  63. package/dist/editor/core/viewIsland.js +2 -1
  64. package/dist/editor/core/viewIsland.js.map +1 -1
  65. package/dist/editor/features/builtins.d.ts +9 -4
  66. package/dist/editor/features/builtins.d.ts.map +1 -1
  67. package/dist/editor/features/builtins.js +36 -7
  68. package/dist/editor/features/builtins.js.map +1 -1
  69. package/dist/editor/features/search/SearchPanel.d.ts.map +1 -1
  70. package/dist/editor/features/search/SearchPanel.js +26 -12
  71. package/dist/editor/features/search/SearchPanel.js.map +1 -1
  72. package/dist/editor/features/search/revealMatch.d.ts +16 -0
  73. package/dist/editor/features/search/revealMatch.d.ts.map +1 -1
  74. package/dist/editor/features/search/revealMatch.js +22 -0
  75. package/dist/editor/features/search/revealMatch.js.map +1 -1
  76. package/dist/editor/features/search/searchPlugin.d.ts +8 -3
  77. package/dist/editor/features/search/searchPlugin.d.ts.map +1 -1
  78. package/dist/editor/features/search/searchPlugin.js +17 -6
  79. package/dist/editor/features/search/searchPlugin.js.map +1 -1
  80. package/dist/editor/features/search/searchSnapshot.d.ts +14 -0
  81. package/dist/editor/features/search/searchSnapshot.d.ts.map +1 -0
  82. package/dist/editor/features/search/searchSnapshot.js +17 -0
  83. package/dist/editor/features/search/searchSnapshot.js.map +1 -0
  84. package/dist/editor/features/search/searchTool.d.ts +3 -0
  85. package/dist/editor/features/search/searchTool.d.ts.map +1 -1
  86. package/dist/editor/features/search/searchTool.js +30 -1
  87. package/dist/editor/features/search/searchTool.js.map +1 -1
  88. package/dist/editor/features/toc/RichTextToc.d.ts +91 -0
  89. package/dist/editor/features/toc/RichTextToc.d.ts.map +1 -0
  90. package/dist/editor/features/toc/RichTextToc.js +417 -0
  91. package/dist/editor/features/toc/RichTextToc.js.map +1 -0
  92. package/dist/editor/features/toc/documentHeadings.d.ts +30 -0
  93. package/dist/editor/features/toc/documentHeadings.d.ts.map +1 -0
  94. package/dist/editor/features/toc/documentHeadings.js +20 -0
  95. package/dist/editor/features/toc/documentHeadings.js.map +1 -0
  96. package/dist/editor/features/toc/headings.d.ts +35 -0
  97. package/dist/editor/features/toc/headings.d.ts.map +1 -0
  98. package/dist/editor/features/toc/headings.js +27 -0
  99. package/dist/editor/features/toc/headings.js.map +1 -0
  100. package/dist/editor/features/toc/stickyOffset.d.ts +3 -0
  101. package/dist/editor/features/toc/stickyOffset.d.ts.map +1 -0
  102. package/dist/editor/features/toc/stickyOffset.js +14 -0
  103. package/dist/editor/features/toc/stickyOffset.js.map +1 -0
  104. package/dist/editor/features/toc/tocSnapshot.d.ts +10 -0
  105. package/dist/editor/features/toc/tocSnapshot.d.ts.map +1 -0
  106. package/dist/editor/features/toc/tocSnapshot.js +8 -0
  107. package/dist/editor/features/toc/tocSnapshot.js.map +1 -0
  108. package/dist/editor/features/toc/tocTool.d.ts +6 -0
  109. package/dist/editor/features/toc/tocTool.d.ts.map +1 -0
  110. package/dist/editor/features/toc/tocTool.js +21 -0
  111. package/dist/editor/features/toc/tocTool.js.map +1 -0
  112. package/dist/editor/hooks/useRichTextToolState.d.ts +43 -0
  113. package/dist/editor/hooks/useRichTextToolState.d.ts.map +1 -0
  114. package/dist/editor/hooks/useRichTextToolState.js +77 -0
  115. package/dist/editor/hooks/useRichTextToolState.js.map +1 -0
  116. package/dist/editor/index.d.ts +10 -0
  117. package/dist/editor/index.d.ts.map +1 -1
  118. package/dist/editor/index.js +4 -0
  119. package/dist/editor/index.js.map +1 -1
  120. package/dist/i18n/defaults.d.ts.map +1 -1
  121. package/dist/i18n/defaults.js +1 -0
  122. package/dist/i18n/defaults.js.map +1 -1
  123. package/dist/i18n/messages.d.ts +5 -0
  124. package/dist/i18n/messages.d.ts.map +1 -1
  125. package/dist/locales/ru.d.ts.map +1 -1
  126. package/dist/locales/ru.js +1 -0
  127. package/dist/locales/ru.js.map +1 -1
  128. package/docs/api/editor-extension.en.md +223 -23
  129. package/docs/api/editor-extension.md +224 -22
  130. package/docs/api/messages.en.md +743 -0
  131. package/docs/api/messages.md +1 -0
  132. package/docs/api/migration-notes.en.md +36 -1
  133. package/docs/api/migration-notes.md +35 -0
  134. package/docs/api/utilities.en.md +233 -0
  135. package/docs/components/content/rich-text-toc.en.md +132 -0
  136. package/docs/components/content/rich-text-toc.md +131 -0
  137. package/docs/components/index.en.md +3 -2
  138. package/docs/components/index.md +2 -1
  139. package/docs/components/inputs/rich-text-editor.en.md +277 -43
  140. package/docs/components/inputs/rich-text-editor.md +247 -11
  141. package/docs/conventions.en.md +318 -0
  142. package/docs/getting-started.en.md +7 -7
  143. package/docs/getting-started.md +6 -6
  144. package/docs/glossary.en.md +52 -47
  145. package/docs/glossary.md +51 -46
  146. package/docs/index.en.md +53 -0
  147. package/llms.txt +1 -0
  148. package/package.json +1 -1
  149. package/src/editor/RichTextEditor.tsx +197 -38
  150. package/src/editor/RichTextView.tsx +2 -1
  151. package/src/editor/core/containers.ts +8 -6
  152. package/src/editor/core/customTool.ts +8 -2
  153. package/src/editor/core/customToolEngine.ts +20 -1
  154. package/src/editor/core/extensions.ts +2 -1
  155. package/src/editor/core/handle.ts +26 -23
  156. package/src/editor/core/handleWatchers.ts +72 -0
  157. package/src/editor/core/nodeNames.ts +7 -0
  158. package/src/editor/core/schema.ts +5 -2
  159. package/src/editor/core/toolCommands.ts +85 -0
  160. package/src/editor/core/toolDefinition.ts +208 -0
  161. package/src/editor/core/toolDescriptor.ts +94 -3
  162. package/src/editor/core/toolOptions.ts +211 -0
  163. package/src/editor/core/toolState.ts +56 -0
  164. package/src/editor/core/tools.ts +12 -3
  165. package/src/editor/core/viewIsland.ts +5 -4
  166. package/src/editor/features/builtins.ts +72 -13
  167. package/src/editor/features/search/SearchPanel.tsx +38 -14
  168. package/src/editor/features/search/revealMatch.ts +45 -0
  169. package/src/editor/features/search/searchPlugin.ts +40 -11
  170. package/src/editor/features/search/searchSnapshot.ts +29 -0
  171. package/src/editor/features/search/searchTool.ts +51 -1
  172. package/src/editor/features/toc/RichTextToc.tsx +810 -0
  173. package/src/editor/features/toc/documentHeadings.ts +59 -0
  174. package/src/editor/features/toc/headings.ts +77 -0
  175. package/src/editor/features/toc/stickyOffset.ts +21 -0
  176. package/src/editor/features/toc/tocSnapshot.ts +24 -0
  177. package/src/editor/features/toc/tocTool.ts +41 -0
  178. package/src/editor/hooks/useRichTextToolState.ts +153 -0
  179. package/src/editor/index.ts +21 -0
  180. package/src/i18n/defaults.ts +1 -0
  181. package/src/i18n/messages.ts +5 -0
  182. package/src/locales/ru.ts +1 -0
@@ -16,6 +16,7 @@ import {
16
16
  useId,
17
17
  useImperativeHandle,
18
18
  useLayoutEffect,
19
+ useCallback,
19
20
  useMemo,
20
21
  useRef,
21
22
  useState,
@@ -31,7 +32,8 @@ import { BlockHandle } from "./ui/BlockHandle.js";
31
32
  import { expandTools } from "./features/builtins.js";
32
33
  import {
33
34
  createHandleMethods,
34
- notifyHandleEditor,
35
+ clearToolSignals,
36
+ publishToolSignals,
35
37
  type RichTextEditorHandleMethods,
36
38
  } from "./core/handle.js";
37
39
  import { EditorToolbar, ToolbarSkeleton } from "./ui/EditorToolbar.js";
@@ -46,18 +48,19 @@ import { customToolsError, FALLBACK_EXTENSIONS } from "./core/customToolEngine.j
46
48
  import { startLoad, type ToolLoad } from "./core/toolLoader.js";
47
49
  import { skeletonHtml } from "./core/skeleton.js";
48
50
  import { Button } from "../components/actions/Button.js";
49
- import { devError } from "../lib/dev-warn.js";
51
+ import { devError, devWarn } from "../lib/dev-warn.js";
50
52
  import { describeImageNotice } from "./features/image/imageNotice.js";
51
53
  import { insertItemsFor } from "./core/insertItems.js";
52
54
  import type { CustomColorKind } from "./features/color/customColor.js";
53
55
  import { isImageSelection } from "./features/image/image.js";
54
56
  import { ImagePopover } from "./features/image/ImagePopover.js";
55
57
  import { ImageToolbar } from "./features/image/ImageToolbar.js";
56
- import { textCardsOf, triggersOf } from "./core/toolDescriptor.js";
58
+ import { textCardsOf, toolOptions, triggersOf, type ToolBridge } from "./core/toolDescriptor.js";
57
59
  import type { ToolPopoverRequest } from "./core/toolButtons.js";
58
60
  import type { TextCardControl } from "./ui/TextCard.js";
59
61
  import { SelectionPopover } from "./ui/ToolButtonView.js";
60
62
  import { tableChunk } from "./features/table/tableTool.js";
63
+ import { stickyOffset } from "./features/toc/stickyOffset.js";
61
64
  import { replaceWithMarkdown } from "./core/markdown.js";
62
65
  import {
63
66
  documentFromJSON,
@@ -107,6 +110,15 @@ const TextCard = lazy(() =>
107
110
  * `basic`, который меряется бюджетом «старта». Плагин подсветки и сочетания
108
111
  * маленькие и стоят в движке сразу.
109
112
  */
113
+ /*
114
+ Ядро Оглавления (Инструмент `toc`) — ленивым чанком: его включают единицы, а
115
+ полоса со списком и `HoverCard` стоили бы каждому Редактору ~5 kB gzip на
116
+ старте. Чанк приходит, только когда Инструмент включён и движок готов.
117
+ */
118
+ const TocCore = lazy(() =>
119
+ import("./features/toc/RichTextToc.js").then((module) => ({ default: module.TocCore }))
120
+ );
121
+
110
122
  const SearchPanel = lazy(() =>
111
123
  import("./features/search/SearchPanel.js").then((module) => ({ default: module.SearchPanel }))
112
124
  );
@@ -243,14 +255,18 @@ export interface RichTextEditorProps {
243
255
  * клавиш, ни markdown-шорткатом, ни вставкой.
244
256
  *
245
257
  * Вперемешку с именами кита — описания своих Инструментов (`CustomTool`):
246
- * `[...RICH_TEXT_PRESETS.full, mergeTagTool]`. Неизвестное имя отбрасывается
258
+ * `[...RICH_TEXT_PRESETS.full, mergeTagTool]`. Встроенный Инструмент с
259
+ * Опциями (`search`, `toc`) настраивается записью `tool()`:
260
+ * `[...RICH_TEXT_PRESETS.full, tool("toc", { levels: [2, 3] })]` — запись
261
+ * включает Инструмент и без имени, а имя из пресета уступает ей; две записи
262
+ * `tool()` с одним именем — ошибка поля. Неизвестное имя отбрасывается
247
263
  * молча; описание с ошибкой (имя без Префикса потребителя, совпадение имён,
248
264
  * Часть для движка мимо правил Схемы) — ошибка поля: движок не создаётся,
249
265
  * значение формы остаётся входящим, причина — в консоли разработчика.
250
266
  *
251
- * `preset` и `tools` читаются при создании движка: Схема ProseMirror после
252
- * этого не меняется. Чтобы сменить набор у живого поля, перемонтируйте его
253
- * (`key`) — история правок при этом теряется.
267
+ * `preset` и `tools`, а с ними и Опции Инструментов, читаются при создании
268
+ * движка: Схема ProseMirror после этого не меняется. Чтобы сменить набор у
269
+ * живого поля, перемонтируйте его (`key`) — история правок при этом теряется.
254
270
  */
255
271
  tools?: readonly ToolEntry[];
256
272
  /**
@@ -351,6 +367,9 @@ export interface RichTextEditorHandle extends RichTextEditorHandleMethods {
351
367
  * Открыть панель «Поиск и замена» из кода экрана — то же, что
352
368
  * кнопка «Поиск» и `Mod+F`; `replace` — фокус в поле замены. Без Инструмента
353
369
  * `search`, в HTML-режиме и до монтажа — ничего.
370
+ *
371
+ * @deprecated Работает как `handle.tool("search")?.open(options)`; в разработке
372
+ * один раз предупреждает об этом в консоли. Будет удалён в 5.0.
354
373
  */
355
374
  openSearch(options?: { replace?: boolean }): void;
356
375
  /**
@@ -651,6 +670,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
651
670
  replace: boolean;
652
671
  query: string;
653
672
  } | null>(null);
673
+ /** Переход ядра Оглавления (`TocCore`): есть, пока оно смонтировано. */
674
+ const tocJumpRef = useRef<((index: number) => boolean) | null>(null);
654
675
  const sourceFieldRef = useRef<HTMLTextAreaElement>(null);
655
676
  const sourceTextRef = useRef<string | null>(null);
656
677
  const sourceBaseRef = useRef("");
@@ -783,8 +804,9 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
783
804
  const openSource = () => {
784
805
  const current = editorRef.current;
785
806
  if (!current) return;
786
- // Документа на экране не будет — искать в нём негде.
787
- if (searchPanel) closeSearch(false);
807
+ // Документа на экране не будет — искать в нём негде. Подсветка бывает и без
808
+ // панели (команда `setQuery`): `next()` в скрытом Документе вышла бы удачной.
809
+ if (enabledTools.has("search")) closeSearch(false);
788
810
  const text = writeSource(current);
789
811
  switchFocusRef.current = "source";
790
812
  setSource({ text, minHeight: current.view.dom.getBoundingClientRect().height });
@@ -805,18 +827,25 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
805
827
 
806
828
  /*
807
829
  Открыть панель поиска: кнопка Панели, `Mod+F`/`Mod+Alt+F` из движка и
808
- `openSearch()` Хэндла. Выделенный текст одного Блока становится запросом,
830
+ команда `open` Инструмента. Выделенный текст одного Блока становится запросом,
809
831
  как в редакторах кода; выделение через Блоки — нет: запрос не переходит
810
- границу Блока, и такой текст не нашёлся бы. В HTML-режиме Документа на
811
- экране нет — искать негде, и `Mod+F` остаётся браузеру.
832
+ границу Блока, и такой текст не нашёлся бы. Явный `query` команды главнее
833
+ выделения. В HTML-режиме Документа на экране нет — искать негде, и `Mod+F`
834
+ остаётся браузеру.
812
835
  */
813
- const openSearch = (replace: boolean): boolean => {
836
+ const openSearch = (replace: boolean, query?: string): boolean => {
814
837
  const current = editorRef.current;
815
838
  if (!current || !enabledTools.has("search") || sourceTextRef.current !== null) return false;
816
839
  const { $from, $to, empty } = current.state.selection;
817
- const query =
840
+ const selected =
818
841
  !empty && $from.sameParent($to) ? current.state.doc.textBetween($from.pos, $to.pos) : "";
819
- setSearchPanel((previous) => ({ token: (previous?.token ?? 0) + 1, replace, query }));
842
+ setSearchPanel((previous) => ({
843
+ token: (previous?.token ?? 0) + 1,
844
+ replace,
845
+ query: query ?? selected,
846
+ }));
847
+ // Подсветка и переходы — сразу, не дожидаясь ленивой панели.
848
+ if (query !== undefined) current.commands.setSearch(query);
820
849
  return true;
821
850
  };
822
851
  const openSearchRef = useRef(openSearch);
@@ -838,6 +867,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
838
867
  chain.clearSearch().run();
839
868
  }
840
869
  setSearchPanel(null);
870
+ return true;
841
871
  };
842
872
 
843
873
  // Пункты Меню вставки — из того же замороженного набора, что и Схема.
@@ -994,6 +1024,54 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
994
1024
  }
995
1025
  : { maxHeight: areaLength };
996
1026
 
1027
+ /*
1028
+ Оглавление в левом поле (Инструмент `toc`, ADR-0068). Полоса липнет под
1029
+ фиксированной Панелью: её высота меряется, потому что Панель переносит
1030
+ ряд кнопок на узком поле. В прокручиваемом поле край — верх области.
1031
+ */
1032
+ const tocOptions = toolOptions(enabledTools, "toc");
1033
+ const tocOn = enabledTools.has("toc") && tocOptions !== undefined;
1034
+ const tocOffsetVar = tocOptions?.offsetVar ?? "--topbar-h";
1035
+ const [toolbarShell, setToolbarShell] = useState<HTMLDivElement | null>(null);
1036
+ const [toolbarHeight, setToolbarHeight] = useState(0);
1037
+ useEffect(() => {
1038
+ if (!toolbarShell || typeof ResizeObserver === "undefined") {
1039
+ setToolbarHeight(toolbarShell?.offsetHeight ?? 0);
1040
+ return;
1041
+ }
1042
+ const observer = new ResizeObserver(() => setToolbarHeight(toolbarShell.offsetHeight));
1043
+ observer.observe(toolbarShell);
1044
+ return () => observer.disconnect();
1045
+ }, [toolbarShell]);
1046
+ /*
1047
+ Нижний край липкого верха поля: шапка приложения и Панель над Документом.
1048
+ Не `null` никогда, даже без шапки: иначе переход ушёл бы в `scrollIntoView`,
1049
+ а тот двигает и страницу вокруг поля с `maxHeight`. В прокручиваемом поле
1050
+ подсветка и переход считают от верха области и этот край не используют.
1051
+ */
1052
+ const tocStickyTop = useCallback(
1053
+ () => (stickyOffset(tocOffsetVar) ?? 0) + toolbarHeight,
1054
+ [tocOffsetVar, toolbarHeight]
1055
+ );
1056
+ /*
1057
+ Переход в правке ставит каретку в конец заголовка, как структура документа
1058
+ в Google Docs: с ним можно сразу писать. Прокрутку делает Оглавление, а
1059
+ движок не прокручивает, чтобы не спорить с ним.
1060
+ В `readOnly` каретки нет — фокус на заголовок ставит само Оглавление.
1061
+ */
1062
+ const tocFocusHeading = useCallback((heading: HTMLElement) => {
1063
+ const current = editorRef.current;
1064
+ if (!current || current.isDestroyed || !current.isEditable) return false;
1065
+ const inside = current.view.posAtDOM(heading, 0);
1066
+ const end = inside + current.state.doc.resolve(inside).parent.content.size;
1067
+ current.commands.setTextSelection(end);
1068
+ // `view.focus()`, а не команда `focus`: та ставит фокус кадром позже, и
1069
+ // пункт списка, отпускающий фокус после клика, отдал бы его странице.
1070
+ // ProseMirror фокусирует без прокрутки — прокрутку делает Оглавление.
1071
+ current.view.focus();
1072
+ return true;
1073
+ }, []);
1074
+
997
1075
  /*
998
1076
  `Alt+F10` — фокус на первое доступное действие Панели. Плавающую Панель
999
1077
  клавиша показывает сама, не дожидаясь задержки показа: человек с
@@ -1294,22 +1372,36 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1294
1372
  };
1295
1373
  }, [throttle, name]);
1296
1374
 
1297
- useImperativeHandle(
1298
- ref,
1299
- () => ({
1375
+ const toolBridge: ToolBridge = {
1376
+ openSearch,
1377
+ // Фокус и каретку не трогаем: команду зовёт код экрана, а не автор в панели.
1378
+ closeSearch: () => closeSearch(false),
1379
+ sourceOpen: () => sourceTextRef.current !== null,
1380
+ jumpToc: (index) => tocJumpRef.current?.(index) ?? false,
1381
+ };
1382
+
1383
+ useImperativeHandle(ref, () => {
1384
+ const methods = createHandleMethods({
1300
1385
  editor,
1301
- ...createHandleMethods({
1302
- editor,
1303
- editable,
1304
- known: () => knownRef.current,
1305
- initialDocument: () => initialDocumentRef.current,
1306
- tables: enabledTools.has("table"),
1307
- sourceOpen: () => sourceTextRef.current !== null,
1308
- sourceField: () => sourceFieldRef.current,
1309
- applySource: () => applySourceRef.current(),
1310
- rewriteSource,
1311
- fieldEmpty,
1312
- }),
1386
+ editable,
1387
+ known: () => knownRef.current,
1388
+ initialDocument: () => initialDocumentRef.current,
1389
+ tables: enabledTools.has("table"),
1390
+ sourceOpen: () => sourceTextRef.current !== null,
1391
+ sourceField: () => sourceFieldRef.current,
1392
+ applySource: () => applySourceRef.current(),
1393
+ rewriteSource,
1394
+ fieldEmpty,
1395
+ commands: (name) => {
1396
+ const descriptor = enabledTools.descriptors.find((entry) => entry.name === name);
1397
+ return editor && !editor.isDestroyed && descriptor?.commands
1398
+ ? descriptor.commands({ editor, bridge: toolBridge })
1399
+ : null;
1400
+ },
1401
+ });
1402
+ return {
1403
+ editor,
1404
+ ...methods,
1313
1405
  getHTML: () => {
1314
1406
  applySourceRef.current();
1315
1407
  return editor ? serialize(editor) : knownRef.current;
@@ -1343,7 +1435,12 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1343
1435
  rewriteSource(editor);
1344
1436
  return true;
1345
1437
  },
1346
- openSearch: (options) => void openSearchRef.current(options?.replace ?? false),
1438
+ openSearch: (options) => {
1439
+ devWarn(
1440
+ 'RichTextEditor: handle.openSearch() is deprecated; use handle.tool("search")?.open() instead'
1441
+ );
1442
+ methods.tool("search")?.open(options);
1443
+ },
1347
1444
  clear: () => {
1348
1445
  if (!editor) return;
1349
1446
  throttle.cancel();
@@ -1356,11 +1453,20 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1356
1453
  emit("");
1357
1454
  rewriteSource(editor);
1358
1455
  },
1359
- }),
1360
- [editor, editable, throttle]
1456
+ };
1457
+ }, [editor, editable, throttle]);
1458
+ /*
1459
+ Хуки `useRichTextEditorState` и `useRichTextToolState` не видят смену
1460
+ движка у `ref` сами — сообщаем. Тем же сообщением идут сигналы кита для
1461
+ Состояния Инструмента: набор Инструментов и открытость панели Поиска.
1462
+ */
1463
+ const searchOpen = searchPanel !== null;
1464
+ useEffect(
1465
+ () => publishToolSignals(ref, { tools: enabledTools, searchOpen }),
1466
+ [ref, editor, enabledTools, searchOpen]
1361
1467
  );
1362
- // Хук `useRichTextEditorState` не видит смену движка у `ref` сам — сообщаем.
1363
- useEffect(() => notifyHandleEditor(ref), [ref, editor]);
1468
+ // Размонтирование (и смена `ref`) забирает сигналы: хук экрана отдаёт `null`.
1469
+ useEffect(() => () => clearToolSignals(ref), [ref]);
1364
1470
 
1365
1471
  /*
1366
1472
  Готовность (спека, словарь): один раз за монтаж, когда движок создан.
@@ -1395,6 +1501,15 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1395
1501
  const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
1396
1502
  useEffect(() => () => clearTimeout(leaveTimerRef.current), []);
1397
1503
 
1504
+ /** Цель Оглавления — Документ движка; объект новый с каждым движком. */
1505
+ const tocTarget = useMemo(
1506
+ () => ({
1507
+ get current() {
1508
+ return editor && !editor.isDestroyed ? (editor.view.dom as HTMLElement) : null;
1509
+ },
1510
+ }),
1511
+ [editor]
1512
+ );
1398
1513
  const searchPanelElement = (className: string) =>
1399
1514
  editor &&
1400
1515
  searchPanel && (
@@ -1450,7 +1565,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1450
1565
  `z-[1]` — внутри поля, а не ступень стека слоёв: Панель не
1451
1566
  оверлей и должна оставаться ниже любого из них.
1452
1567
  */
1453
- <div className={TOOLBAR_SHELL_CLASS}>
1568
+ <div ref={setToolbarShell} className={TOOLBAR_SHELL_CLASS}>
1454
1569
  <EditorToolbar
1455
1570
  editor={editor}
1456
1571
  tools={enabledTools}
@@ -1511,7 +1626,43 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1511
1626
  style={areaStyle}
1512
1627
  >
1513
1628
  {/* В HTML-режиме Документ скрыт, но смонтирован: движок и история живы. */}
1514
- <EditorContent editor={editor} hidden={sourceOpen} />
1629
+ {tocOn ? (
1630
+ /*
1631
+ Левое поле Оглавления: колонка есть, только пока есть полоса —
1632
+ `TocCore` с меньше чем двумя заголовками не рисует ничего, и
1633
+ текст стоит у края, как без Инструмента (ADR-0068, п. 2).
1634
+ */
1635
+ <div className="flex items-start">
1636
+ {editor && !sourceOpen && (
1637
+ <Suspense fallback={null}>
1638
+ <TocCore
1639
+ target={tocTarget}
1640
+ offsetVar={tocOptions.offsetVar}
1641
+ levels={tocOptions.levels}
1642
+ listWidth={tocOptions.listWidth}
1643
+ stickyTop={tocStickyTop}
1644
+ focusHeading={tocFocusHeading}
1645
+ alignToFirstHeading
1646
+ onCurrentChange={(tocAt) => publishToolSignals(ref, { tocAt })}
1647
+ editor={editor}
1648
+ jumpRef={tocJumpRef}
1649
+ data-toc-gutter=""
1650
+ className="sticky mr-3 shrink-0"
1651
+ style={{
1652
+ top: scrolls
1653
+ ? "0.5rem"
1654
+ : `calc(var(${tocOffsetVar}, 0px) + ${toolbarHeight}px + 0.5rem)`,
1655
+ }}
1656
+ />
1657
+ </Suspense>
1658
+ )}
1659
+ <div className="min-w-0 flex-1">
1660
+ <EditorContent editor={editor} hidden={sourceOpen} />
1661
+ </div>
1662
+ </div>
1663
+ ) : (
1664
+ <EditorContent editor={editor} hidden={sourceOpen} />
1665
+ )}
1515
1666
  {!editor && (
1516
1667
  /*
1517
1668
  Скелет (ADR-0058, п. 11.4): до Готовности и при сбое загрузчика —
@@ -1737,7 +1888,15 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1737
1888
  ref={hiddenRef}
1738
1889
  type="hidden"
1739
1890
  name={name}
1740
- defaultValue={initial}
1891
+ /*
1892
+ Значение скрытого поля пишет Редактор (`emit`, эффекты движка), а
1893
+ не React. Но React на КАЖДОМ обновлении заново ставит `defaultValue`,
1894
+ а у `type="hidden"` это и есть его значение: любой рендер родителя
1895
+ откатывал бы поле формы к входящей строке. Поэтому после монтажа в
1896
+ `defaultValue` уходит то, что уже лежит в поле, — React видит равенство
1897
+ и ничего не трогает. Серверный рендер и первый кадр несут `initial`.
1898
+ */
1899
+ defaultValue={hiddenRef.current?.value ?? initial}
1741
1900
  // Выключенное поле не уходит в форму — как нативное. Поле только
1742
1901
  // для чтения уходит: это показанное значение, а не недоступный
1743
1902
  // контрол.
@@ -31,7 +31,8 @@ export interface RichTextViewProps extends Omit<
31
31
  surface?: RichTextSurface;
32
32
  /**
33
33
  * Свои Инструменты Документа — тот же массив, что у `RichTextEditor` и
34
- * `sanitizeRichText`. Просмотр читает из него только Острова своих Блоков
34
+ * `sanitizeRichText`; Настроенный Инструмент (`tool()`) в нём допустим и
35
+ * Просмотром игнорируется. Просмотр читает из него только Острова своих Блоков
35
36
  * (`blocks[имя].island`): Блок, у которого он есть и который есть в
36
37
  * Документе, оживает клиентским компонентом, а серверный HTML Блока виден до
37
38
  * загрузки и без JS. Остальное в описании Просмотру не нужно. Без `tools`, и
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { Node as PmNode, NodeType, ResolvedPos } from "@tiptap/pm/model";
4
4
  import { TextSelection, type Transaction } from "@tiptap/pm/state";
5
+ import { WRAPPER_NODE } from "./nodeNames.js";
5
6
 
6
7
  /**
7
8
  * Общее у контейнерных Блоков — Раскладки (`layout.ts`, ADR-0056) и Обёртки
@@ -13,12 +14,13 @@ import { TextSelection, type Transaction } from "@tiptap/pm/state";
13
14
  * арифметика позиций не разъезжались молча.
14
15
  */
15
16
 
16
- /**
17
- * Имя узла Обёртки в Схеме движка. Живёт здесь, а не в `wrapper.ts`: оно же
18
- * входит в общий список ниже, а `wrapper.ts` импортирует этот модуль —
19
- * определение там дало бы цикл импортов. `wrapper.ts` его реэкспортирует.
20
- */
21
- export const WRAPPER_NODE = "wrapper";
17
+ /*
18
+ Имя узла Обёртки живёт в `nodeNames.ts`, без Tiptap: его читает и Оглавление.
19
+ Не в `wrapper.ts`: оно же входит в общий список ниже, а `wrapper.ts`
20
+ импортирует этот модуль — определение там дало бы цикл импортов. `wrapper.ts`
21
+ его реэкспортирует.
22
+ */
23
+ export { WRAPPER_NODE };
22
24
 
23
25
  /**
24
26
  * Узлы, внутри которых контейнерного Блока нет ни одного: Обёртка, цитата,
@@ -3,6 +3,8 @@ import type { Mark } from "@tiptap/pm/model";
3
3
  import type { EditorState } from "@tiptap/pm/state";
4
4
  import type { ComponentType, ReactNode } from "react";
5
5
  import type { IconProps } from "../../icons.js";
6
+ import type { ToolDefinition } from "./toolDefinition.js";
7
+ import type { ConfiguredTool } from "./toolOptions.js";
6
8
  import type { Tool as ToolName } from "./tools.js";
7
9
 
8
10
  /*
@@ -428,8 +430,12 @@ export type CustomToolButton = CustomToolButtonFields &
428
430
  }
429
431
  );
430
432
 
431
- /** Элемент `tools`: имя Инструмента кита или описание своего. */
432
- export type ToolEntry = ToolName | CustomTool;
433
+ /**
434
+ * Элемент `tools`: имя Инструмента кита, Настроенный Инструмент (`tool()`),
435
+ * определение своего Инструмента (`defineTool()`) или описание своего
436
+ * Инструмента обычным объектом.
437
+ */
438
+ export type ToolEntry = ToolName | ConfiguredTool<string> | ToolDefinition | CustomTool;
433
439
 
434
440
  /**
435
441
  * Годится ли значение атрибута по его виду (`CustomAttribute`). Живёт в
@@ -34,6 +34,7 @@ import {
34
34
  type ToolLabel,
35
35
  type ToolList,
36
36
  } from "./toolDescriptor.js";
37
+ import { resolveDefinition, type ToolDefinition } from "./toolDefinition.js";
37
38
  import { markdownFallback } from "./customMarkdown.js";
38
39
  import { CUSTOM_ATTRIBUTE_NAME, CUSTOM_NAME, customElement } from "./schema.js";
39
40
  import { loaded } from "./toolLoader.js";
@@ -237,6 +238,22 @@ export function describeCustomTool(tool: CustomTool): ToolDescriptor {
237
238
  };
238
239
  }
239
240
 
241
+ /**
242
+ * Внутреннее описание Инструмента из определения (`defineTool`): тело из
243
+ * `create` и имя определения собираются в обычный `CustomTool` и идут тем же
244
+ * путём, а Опции этого Редактора — умолчания, слитые с `tool()`, — достаются
245
+ * `create`, `state` и `commands`.
246
+ */
247
+ export function describeDefinition(definition: ToolDefinition, given: object = {}): ToolDescriptor {
248
+ const { options, tool } = resolveDefinition(definition, given);
249
+ return {
250
+ ...describeCustomTool(tool),
251
+ options,
252
+ state: definition.state && ((editor) => definition.state!({ editor, options })),
253
+ commands: definition.commands && (({ editor }) => definition.commands!({ editor, options })),
254
+ };
255
+ }
256
+
240
257
  /**
241
258
  * Набор движка, который `RichTextEditor` собирает при ошибке набора: движок
242
259
  * нужен хуку `useEditor` в любом случае, но наружу он не выходит.
@@ -311,7 +328,7 @@ export interface CustomToolsError {
311
328
  }
312
329
 
313
330
  /**
314
- * Ошибка сборки набора своих Инструментов или `null` (ADR-0058, п. 1.5, 2.4).
331
+ * Ошибка сборки набора Инструментов или `null` (ADR-0058, п. 1.5, 2.4).
315
332
  *
316
333
  * Имя без Префикса потребителя, совпадение имён, Node или Mark Части для
317
334
  * движка без правил Схемы (и правило без них), атрибут, которого нет в
@@ -325,6 +342,8 @@ export interface CustomToolsError {
325
342
  * Исключение движка — отдельно, в `cause`: текст — ключ «один раз» у `devError`.
326
343
  */
327
344
  export function customToolsError(tools: ToolList, extensions: Extensions): CustomToolsError | null {
345
+ // Ошибка, найденная при развороте `tools` (две записи `tool()` с одним именем), идёт тем же путём.
346
+ if (tools.error) return { message: tools.error };
328
347
  const custom = tools.descriptors.flatMap((descriptor) => descriptor.custom ?? []);
329
348
  if (custom.length === 0) return null;
330
349
  // Имена Инструментов и имена Блоков и Меток — разные пространства: Инструмент
@@ -268,7 +268,8 @@ export function createEditorExtensions(options: EditorExtensionOptions): Extensi
268
268
  (`codeBlockTool.ts`): его расширение здесь, после `ToolbarFocus`, и
269
269
  опрашивается раньше.
270
270
  */
271
- for (const descriptor of described) extensions.push(...(descriptor.engine?.(options) ?? []));
271
+ for (const descriptor of described)
272
+ extensions.push(...(descriptor.engine?.(options, descriptor.options) ?? []));
272
273
  // Сочетания кнопок описаний вешает кит: поле записи — их единственный источник.
273
274
  const buttons = described.flatMap((descriptor) => descriptor.buttons ?? []);
274
275
  // Поповер пункта вставки и Действия открывает та же команда, что у кнопки.
@@ -4,9 +4,17 @@ import { createNodeFromContent, type JSONContent } from "@tiptap/core";
4
4
  import { closeHistory } from "@tiptap/pm/history";
5
5
  import type { Fragment } from "@tiptap/pm/model";
6
6
  import type { Editor } from "@tiptap/react";
7
- import type { RefObject } from "react";
8
7
  import { textBetween } from "./atomText.js";
9
8
  import { isRichTextDocument, type RichTextDocument } from "./jsonDocument.js";
9
+ import type { ToolCommandsOf } from "./toolCommands.js";
10
+ import type { RichTextTools } from "./toolOptions.js";
11
+
12
+ export {
13
+ clearToolSignals,
14
+ notifyHandleEditor,
15
+ publishToolSignals,
16
+ watchHandleEditor,
17
+ } from "./handleWatchers.js";
10
18
 
11
19
  /**
12
20
  * Плоские методы Хэндла (ADR-0058, раздел 8): то, что экран делает с Редактором
@@ -80,6 +88,19 @@ export interface RichTextEditorHandleMethods {
80
88
  canUndo(): boolean;
81
89
  /** Есть ли что повторить. Без Инструмента `history` и до монтажа — `false`. */
82
90
  canRedo(): boolean;
91
+ /**
92
+ * Команды Инструмента: `handle.tool("search")?.open({ query: "TODO" })`,
93
+ * `handle.tool("toc")?.jumpTo(2)`. Тип Команд выводится из имени по карте
94
+ * `RichTextTools`. `null`, если Инструмента нет в наборе `tools`, у него нет
95
+ * Команд или движок ещё не создан, — поэтому Команды выключенного Инструмента
96
+ * недоступны и в типах (`?.`), и во время работы.
97
+ *
98
+ * Каждая Команда возвращает `boolean`: выполнена или нет (HTML-режим, нет
99
+ * совпадений, нет заголовка, поле нельзя править) — как `insertHTML`. Имя вне
100
+ * карты (свой Инструмент без типизации) даёт `unknown`.
101
+ */
102
+ tool<Name extends keyof RichTextTools>(name: Name): ToolCommandsOf<Name> | null;
103
+ tool(name: string): unknown;
83
104
  }
84
105
 
85
106
  /** Что Хэндлу нужно от Редактора: движок и то, что знает только он. */
@@ -108,6 +129,8 @@ export interface HandleHost {
108
129
  rewriteSource: (editor: Editor) => void;
109
130
  /** Пуст ли Документ по правилу кита, с учётом неприменённой правки поля HTML. */
110
131
  fieldEmpty: (editor: Editor) => boolean;
132
+ /** Команды Инструмента набора; `null` — Инструмента или Команд нет (`ToolDescriptor.commands`). */
133
+ commands: (name: string) => object | null;
111
134
  }
112
135
 
113
136
  /** Тег целиком; комментарий и содержимое `script`/`style`/`template` разбор Схемой не видит. */
@@ -285,27 +308,7 @@ export function createHandleMethods(host: HandleHost): RichTextEditorHandleMetho
285
308
  redo: () => history("redo"),
286
309
  canUndo: () => available("undo"),
287
310
  canRedo: () => available("redo"),
311
+ // Типы Команд выводятся из карты, а реализация знает только имя.
312
+ tool: host.commands as RichTextEditorHandleMethods["tool"],
288
313
  };
289
314
  }
290
-
291
- /*
292
- Хук-селектор не знает, когда у `ref` появится движок: Редактор создаёт его
293
- после первого рендера экрана и перерисовывается сам, а экран — нет. Поэтому
294
- подписки живут по самому объекту `ref`, который есть с первого рендера, и
295
- Редактор сообщает об смене движка по нему.
296
- */
297
- const watchers = new WeakMap<object, Set<() => void>>();
298
-
299
- /** Подписка хука на смену движка у Редактора, привязанного к этому `ref`. */
300
- export function watchHandleEditor(ref: RefObject<unknown>, listener: () => void): () => void {
301
- let set = watchers.get(ref);
302
- if (!set) watchers.set(ref, (set = new Set()));
303
- set.add(listener);
304
- return () => void set.delete(listener);
305
- }
306
-
307
- /** Редактор сообщает: движок у `ref` появился или сменился. */
308
- export function notifyHandleEditor(ref: unknown): void {
309
- if (typeof ref !== "object" || ref === null) return;
310
- watchers.get(ref)?.forEach((listener) => listener());
311
- }