@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
@@ -32,7 +32,8 @@ import { BlockHandle } from "./ui/BlockHandle.js";
32
32
  import { expandTools } from "./features/builtins.js";
33
33
  import {
34
34
  createHandleMethods,
35
- notifyHandleEditor,
35
+ clearToolSignals,
36
+ publishToolSignals,
36
37
  type RichTextEditorHandleMethods,
37
38
  } from "./core/handle.js";
38
39
  import { EditorToolbar, ToolbarSkeleton } from "./ui/EditorToolbar.js";
@@ -47,14 +48,14 @@ import { customToolsError, FALLBACK_EXTENSIONS } from "./core/customToolEngine.j
47
48
  import { startLoad, type ToolLoad } from "./core/toolLoader.js";
48
49
  import { skeletonHtml } from "./core/skeleton.js";
49
50
  import { Button } from "../components/actions/Button.js";
50
- import { devError } from "../lib/dev-warn.js";
51
+ import { devError, devWarn } from "../lib/dev-warn.js";
51
52
  import { describeImageNotice } from "./features/image/imageNotice.js";
52
53
  import { insertItemsFor } from "./core/insertItems.js";
53
54
  import type { CustomColorKind } from "./features/color/customColor.js";
54
55
  import { isImageSelection } from "./features/image/image.js";
55
56
  import { ImagePopover } from "./features/image/ImagePopover.js";
56
57
  import { ImageToolbar } from "./features/image/ImageToolbar.js";
57
- import { textCardsOf, triggersOf } from "./core/toolDescriptor.js";
58
+ import { textCardsOf, toolOptions, triggersOf, type ToolBridge } from "./core/toolDescriptor.js";
58
59
  import type { ToolPopoverRequest } from "./core/toolButtons.js";
59
60
  import type { TextCardControl } from "./ui/TextCard.js";
60
61
  import { SelectionPopover } from "./ui/ToolButtonView.js";
@@ -254,14 +255,18 @@ export interface RichTextEditorProps {
254
255
  * клавиш, ни markdown-шорткатом, ни вставкой.
255
256
  *
256
257
  * Вперемешку с именами кита — описания своих Инструментов (`CustomTool`):
257
- * `[...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()` с одним именем — ошибка поля. Неизвестное имя отбрасывается
258
263
  * молча; описание с ошибкой (имя без Префикса потребителя, совпадение имён,
259
264
  * Часть для движка мимо правил Схемы) — ошибка поля: движок не создаётся,
260
265
  * значение формы остаётся входящим, причина — в консоли разработчика.
261
266
  *
262
- * `preset` и `tools` читаются при создании движка: Схема ProseMirror после
263
- * этого не меняется. Чтобы сменить набор у живого поля, перемонтируйте его
264
- * (`key`) — история правок при этом теряется.
267
+ * `preset` и `tools`, а с ними и Опции Инструментов, читаются при создании
268
+ * движка: Схема ProseMirror после этого не меняется. Чтобы сменить набор у
269
+ * живого поля, перемонтируйте его (`key`) — история правок при этом теряется.
265
270
  */
266
271
  tools?: readonly ToolEntry[];
267
272
  /**
@@ -362,6 +367,9 @@ export interface RichTextEditorHandle extends RichTextEditorHandleMethods {
362
367
  * Открыть панель «Поиск и замена» из кода экрана — то же, что
363
368
  * кнопка «Поиск» и `Mod+F`; `replace` — фокус в поле замены. Без Инструмента
364
369
  * `search`, в HTML-режиме и до монтажа — ничего.
370
+ *
371
+ * @deprecated Работает как `handle.tool("search")?.open(options)`; в разработке
372
+ * один раз предупреждает об этом в консоли. Будет удалён в 5.0.
365
373
  */
366
374
  openSearch(options?: { replace?: boolean }): void;
367
375
  /**
@@ -662,6 +670,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
662
670
  replace: boolean;
663
671
  query: string;
664
672
  } | null>(null);
673
+ /** Переход ядра Оглавления (`TocCore`): есть, пока оно смонтировано. */
674
+ const tocJumpRef = useRef<((index: number) => boolean) | null>(null);
665
675
  const sourceFieldRef = useRef<HTMLTextAreaElement>(null);
666
676
  const sourceTextRef = useRef<string | null>(null);
667
677
  const sourceBaseRef = useRef("");
@@ -794,8 +804,9 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
794
804
  const openSource = () => {
795
805
  const current = editorRef.current;
796
806
  if (!current) return;
797
- // Документа на экране не будет — искать в нём негде.
798
- if (searchPanel) closeSearch(false);
807
+ // Документа на экране не будет — искать в нём негде. Подсветка бывает и без
808
+ // панели (команда `setQuery`): `next()` в скрытом Документе вышла бы удачной.
809
+ if (enabledTools.has("search")) closeSearch(false);
799
810
  const text = writeSource(current);
800
811
  switchFocusRef.current = "source";
801
812
  setSource({ text, minHeight: current.view.dom.getBoundingClientRect().height });
@@ -816,18 +827,25 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
816
827
 
817
828
  /*
818
829
  Открыть панель поиска: кнопка Панели, `Mod+F`/`Mod+Alt+F` из движка и
819
- `openSearch()` Хэндла. Выделенный текст одного Блока становится запросом,
830
+ команда `open` Инструмента. Выделенный текст одного Блока становится запросом,
820
831
  как в редакторах кода; выделение через Блоки — нет: запрос не переходит
821
- границу Блока, и такой текст не нашёлся бы. В HTML-режиме Документа на
822
- экране нет — искать негде, и `Mod+F` остаётся браузеру.
832
+ границу Блока, и такой текст не нашёлся бы. Явный `query` команды главнее
833
+ выделения. В HTML-режиме Документа на экране нет — искать негде, и `Mod+F`
834
+ остаётся браузеру.
823
835
  */
824
- const openSearch = (replace: boolean): boolean => {
836
+ const openSearch = (replace: boolean, query?: string): boolean => {
825
837
  const current = editorRef.current;
826
838
  if (!current || !enabledTools.has("search") || sourceTextRef.current !== null) return false;
827
839
  const { $from, $to, empty } = current.state.selection;
828
- const query =
840
+ const selected =
829
841
  !empty && $from.sameParent($to) ? current.state.doc.textBetween($from.pos, $to.pos) : "";
830
- 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);
831
849
  return true;
832
850
  };
833
851
  const openSearchRef = useRef(openSearch);
@@ -849,6 +867,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
849
867
  chain.clearSearch().run();
850
868
  }
851
869
  setSearchPanel(null);
870
+ return true;
852
871
  };
853
872
 
854
873
  // Пункты Меню вставки — из того же замороженного набора, что и Схема.
@@ -1010,7 +1029,9 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1010
1029
  фиксированной Панелью: её высота меряется, потому что Панель переносит
1011
1030
  ряд кнопок на узком поле. В прокручиваемом поле край — верх области.
1012
1031
  */
1013
- const tocOn = enabledTools.has("toc");
1032
+ const tocOptions = toolOptions(enabledTools, "toc");
1033
+ const tocOn = enabledTools.has("toc") && tocOptions !== undefined;
1034
+ const tocOffsetVar = tocOptions?.offsetVar ?? "--topbar-h";
1014
1035
  const [toolbarShell, setToolbarShell] = useState<HTMLDivElement | null>(null);
1015
1036
  const [toolbarHeight, setToolbarHeight] = useState(0);
1016
1037
  useEffect(() => {
@@ -1029,8 +1050,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1029
1050
  подсветка и переход считают от верха области и этот край не используют.
1030
1051
  */
1031
1052
  const tocStickyTop = useCallback(
1032
- () => (stickyOffset("--topbar-h") ?? 0) + toolbarHeight,
1033
- [toolbarHeight]
1053
+ () => (stickyOffset(tocOffsetVar) ?? 0) + toolbarHeight,
1054
+ [tocOffsetVar, toolbarHeight]
1034
1055
  );
1035
1056
  /*
1036
1057
  Переход в правке ставит каретку в конец заголовка, как структура документа
@@ -1351,22 +1372,36 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1351
1372
  };
1352
1373
  }, [throttle, name]);
1353
1374
 
1354
- useImperativeHandle(
1355
- ref,
1356
- () => ({
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({
1357
1385
  editor,
1358
- ...createHandleMethods({
1359
- editor,
1360
- editable,
1361
- known: () => knownRef.current,
1362
- initialDocument: () => initialDocumentRef.current,
1363
- tables: enabledTools.has("table"),
1364
- sourceOpen: () => sourceTextRef.current !== null,
1365
- sourceField: () => sourceFieldRef.current,
1366
- applySource: () => applySourceRef.current(),
1367
- rewriteSource,
1368
- fieldEmpty,
1369
- }),
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,
1370
1405
  getHTML: () => {
1371
1406
  applySourceRef.current();
1372
1407
  return editor ? serialize(editor) : knownRef.current;
@@ -1400,7 +1435,12 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1400
1435
  rewriteSource(editor);
1401
1436
  return true;
1402
1437
  },
1403
- 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
+ },
1404
1444
  clear: () => {
1405
1445
  if (!editor) return;
1406
1446
  throttle.cancel();
@@ -1413,11 +1453,20 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1413
1453
  emit("");
1414
1454
  rewriteSource(editor);
1415
1455
  },
1416
- }),
1417
- [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]
1418
1467
  );
1419
- // Хук `useRichTextEditorState` не видит смену движка у `ref` сам — сообщаем.
1420
- useEffect(() => notifyHandleEditor(ref), [ref, editor]);
1468
+ // Размонтирование (и смена `ref`) забирает сигналы: хук экрана отдаёт `null`.
1469
+ useEffect(() => () => clearToolSignals(ref), [ref]);
1421
1470
 
1422
1471
  /*
1423
1472
  Готовность (спека, словарь): один раз за монтаж, когда движок создан.
@@ -1588,15 +1637,21 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1588
1637
  <Suspense fallback={null}>
1589
1638
  <TocCore
1590
1639
  target={tocTarget}
1640
+ offsetVar={tocOptions.offsetVar}
1641
+ levels={tocOptions.levels}
1642
+ listWidth={tocOptions.listWidth}
1591
1643
  stickyTop={tocStickyTop}
1592
1644
  focusHeading={tocFocusHeading}
1593
1645
  alignToFirstHeading
1646
+ onCurrentChange={(tocAt) => publishToolSignals(ref, { tocAt })}
1647
+ editor={editor}
1648
+ jumpRef={tocJumpRef}
1594
1649
  data-toc-gutter=""
1595
1650
  className="sticky mr-3 shrink-0"
1596
1651
  style={{
1597
1652
  top: scrolls
1598
1653
  ? "0.5rem"
1599
- : `calc(var(--topbar-h, 0px) + ${toolbarHeight}px + 0.5rem)`,
1654
+ : `calc(var(${tocOffsetVar}, 0px) + ${toolbarHeight}px + 0.5rem)`,
1600
1655
  }}
1601
1656
  />
1602
1657
  </Suspense>
@@ -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
  // Поповер пункта вставки и Действия открывает та же команда, что у кнопки.
@@ -6,8 +6,15 @@ import type { Fragment } from "@tiptap/pm/model";
6
6
  import type { Editor } from "@tiptap/react";
7
7
  import { textBetween } from "./atomText.js";
8
8
  import { isRichTextDocument, type RichTextDocument } from "./jsonDocument.js";
9
+ import type { ToolCommandsOf } from "./toolCommands.js";
10
+ import type { RichTextTools } from "./toolOptions.js";
9
11
 
10
- export { notifyHandleEditor, watchHandleEditor } from "./handleWatchers.js";
12
+ export {
13
+ clearToolSignals,
14
+ notifyHandleEditor,
15
+ publishToolSignals,
16
+ watchHandleEditor,
17
+ } from "./handleWatchers.js";
11
18
 
12
19
  /**
13
20
  * Плоские методы Хэндла (ADR-0058, раздел 8): то, что экран делает с Редактором
@@ -81,6 +88,19 @@ export interface RichTextEditorHandleMethods {
81
88
  canUndo(): boolean;
82
89
  /** Есть ли что повторить. Без Инструмента `history` и до монтажа — `false`. */
83
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;
84
104
  }
85
105
 
86
106
  /** Что Хэндлу нужно от Редактора: движок и то, что знает только он. */
@@ -109,6 +129,8 @@ export interface HandleHost {
109
129
  rewriteSource: (editor: Editor) => void;
110
130
  /** Пуст ли Документ по правилу кита, с учётом неприменённой правки поля HTML. */
111
131
  fieldEmpty: (editor: Editor) => boolean;
132
+ /** Команды Инструмента набора; `null` — Инструмента или Команд нет (`ToolDescriptor.commands`). */
133
+ commands: (name: string) => object | null;
112
134
  }
113
135
 
114
136
  /** Тег целиком; комментарий и содержимое `script`/`style`/`template` разбор Схемой не видит. */
@@ -286,5 +308,7 @@ export function createHandleMethods(host: HandleHost): RichTextEditorHandleMetho
286
308
  redo: () => history("redo"),
287
309
  canUndo: () => available("undo"),
288
310
  canRedo: () => available("redo"),
311
+ // Типы Команд выводятся из карты, а реализация знает только имя.
312
+ tool: host.commands as RichTextEditorHandleMethods["tool"],
289
313
  };
290
314
  }
@@ -1,4 +1,5 @@
1
1
  import type { RefObject } from "react";
2
+ import type { ToolList } from "./toolDescriptor.js";
2
3
 
3
4
  /*
4
5
  Хук-селектор не знает, когда у `ref` появится движок: Редактор создаёт его
@@ -25,3 +26,47 @@ export function notifyHandleEditor(ref: unknown): void {
25
26
  if (typeof ref !== "object" || ref === null) return;
26
27
  watchers.get(ref)?.forEach((listener) => listener());
27
28
  }
29
+
30
+ /**
31
+ * Сигналы кита для `useRichTextToolState`: то, что меняется не транзакцией
32
+ * движка, а интерфейсом Инструмента, и поэтому в движке его не прочитать.
33
+ * Редактор кладёт их по `ref`, как и сообщение о движке, и хук получает
34
+ * уведомление той же подпиской.
35
+ */
36
+ export interface ToolSignals {
37
+ /** Набор Инструментов этого Редактора: есть ли Инструмент и с какими Опциями. */
38
+ tools?: ToolList;
39
+ /** Панель Поиска открыта (состояние React в `RichTextEditor`). */
40
+ searchOpen?: boolean;
41
+ /** Индекс текущего раздела Оглавления (вычисляет `TocCore` от прокрутки). */
42
+ tocAt?: number;
43
+ }
44
+
45
+ const signals = new WeakMap<object, ToolSignals>();
46
+
47
+ /**
48
+ * Редактор сообщает сигналы Инструментов: слитые с прежними, подписчикам идёт то
49
+ * же уведомление, что и о смене движка.
50
+ */
51
+ export function publishToolSignals(ref: unknown, next: ToolSignals): void {
52
+ if (typeof ref !== "object" || ref === null) return;
53
+ signals.set(ref, { ...signals.get(ref), ...next });
54
+ notifyHandleEditor(ref);
55
+ }
56
+
57
+ /**
58
+ * Редактор размонтирован: его сигналы уходят вместе с ним. Иначе они пережили бы
59
+ * его в таблице по `ref`, и хук отдавал бы снимок уже не существующего Редактора
60
+ * (а новый Редактор под тем же `ref` начинал бы с чужих сигналов). Подписчики
61
+ * получают уведомление: компонент, который из-за размонтирования не
62
+ * перерисовывается сам, узнаёт о нём только так.
63
+ */
64
+ export function clearToolSignals(ref: unknown): void {
65
+ if (typeof ref !== "object" || ref === null || !signals.delete(ref)) return;
66
+ notifyHandleEditor(ref);
67
+ }
68
+
69
+ /** Сигналы Инструментов Редактора, привязанного к `ref`. */
70
+ export function toolSignals(ref: object): ToolSignals {
71
+ return signals.get(ref) ?? {};
72
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Имена узлов Схемы, которые знает не только движок. Модуль без Tiptap: имя
3
+ * нужно и Оглавлению (`features/toc`), которому движок в бандл не нужен.
4
+ */
5
+
6
+ /** Имя узла Обёртки в Схеме движка (ADR-0057). */
7
+ export const WRAPPER_NODE = "wrapper";
@@ -3,6 +3,7 @@ import { EDITOR_LAYOUTS } from "../features/layout/columnLayouts.js";
3
3
  import { CUSTOM_COLOR_STYLE_PATTERN } from "../features/color/customColor.js";
4
4
  import { devWarn } from "../../lib/dev-warn.js";
5
5
  import type { CustomAttribute, PrefixedName, ToolEntry } from "./customTool.js";
6
+ import { customToolOf } from "./toolDefinition.js";
6
7
  import {
7
8
  EDITOR_ALIGNMENTS,
8
9
  EDITOR_MARKER_NAMES,
@@ -375,8 +376,10 @@ function customAttributes(
375
376
  export function richTextSchema(tools: readonly ToolEntry[] = []): RichTextSchema {
376
377
  const customBlocks: Record<PrefixedName, RichTextCustomBlockRules> = {};
377
378
  const customMarks: Record<PrefixedName, RichTextCustomMarkRules> = {};
378
- for (const tool of tools) {
379
- if (typeof tool !== "object" || tool === null) continue;
379
+ for (const entry of tools) {
380
+ // Строка — имя кита, Настроенный встроенный — его опции: Схемы у них нет.
381
+ const tool = customToolOf(entry);
382
+ if (!tool) continue;
380
383
  for (const [name, block] of Object.entries(tool.blocks ?? {})) {
381
384
  if (!CUSTOM_NAME.test(name)) continue;
382
385
  const kind = block.kind === "inline" ? "inline" : "block";
@@ -0,0 +1,85 @@
1
+ import type { RichTextTools, SearchToolOptions } from "./toolOptions.js";
2
+
3
+ /*
4
+ Команды Инструмента: типы того, что экран вызывает через `handle.tool(name)`.
5
+ Модуль серверно-безопасен, как `toolOptions.ts`: одни типы, без Tiptap и React.
6
+ Реализации встроенных лежат рядом с их описаниями (`searchTool.ts`, `tocTool.ts`).
7
+ */
8
+
9
+ /**
10
+ * Команды Инструмента `search`. Каждая возвращает `boolean`: выполнена или нет
11
+ * (HTML-режим, нет совпадений, поле нельзя править). Команды ведут себя как
12
+ * панель «Поиск и замена»: тот же запрос, те же флажки, та же подсветка.
13
+ * Запрос и флажки живут в движке, поэтому `setQuery`, `next` и `replace` работают
14
+ * и без открытой панели — на них можно собрать свой интерфейс поиска.
15
+ */
16
+ export interface SearchToolCommands {
17
+ /**
18
+ * Открыть панель — то же, что кнопка «Поиск» и `Mod+F`. `query` сразу ставит
19
+ * запрос в поле и в подсветку документа; без него запросом становится текст,
20
+ * выделенный в одном Блоке. `replace` ставит фокус в поле замены. Флажки на
21
+ * открытой панели не меняются, на закрытой панель стартует с Опций Инструмента.
22
+ * `false` — в HTML-режиме (Документа на экране нет).
23
+ */
24
+ open(options?: { replace?: boolean; query?: string }): boolean;
25
+ /**
26
+ * Закрыть панель и убрать подсветку. Фокус и каретка остаются где были:
27
+ * в отличие от Escape в панели, команда в Документ их не переводит. Панель,
28
+ * уже закрытая, — не ошибка: подсветка всё равно убирается.
29
+ */
30
+ close(): boolean;
31
+ /**
32
+ * Задать запрос. Не названные в `flags` флажки (и флажки со значением
33
+ * `undefined`) берутся из открытой панели, а если её нет — из Опций
34
+ * Инструмента. Открытая панель показывает новый запрос
35
+ * и флажки. Непустой запрос прокручивает к первому совпадению после каретки,
36
+ * как `next` (прокрутка приходит чуть позже). `true` — запрос применён, даже
37
+ * если совпадений нет (их число — Состояние Инструмента); `false` — в
38
+ * HTML-режиме.
39
+ */
40
+ setQuery(query: string, flags?: Partial<SearchToolOptions>): boolean;
41
+ /**
42
+ * Следующее совпадение по кругу, выделяет его и прокручивает к нему ближайшую
43
+ * прокручиваемую область поля или окно — как панель, даже когда фокус вне
44
+ * Документа; фокус при этом не забирается. Прокрутка приходит чуть позже
45
+ * вызова (микрозадачей и кадром): её код грузится при первом вызове. `false` —
46
+ * совпадений нет.
47
+ */
48
+ next(): boolean;
49
+ /** Предыдущее совпадение по кругу; выделяет и прокручивает, как `next`. `false` — совпадений нет. */
50
+ previous(): boolean;
51
+ /**
52
+ * Заменить текущее совпадение: одна правка, одна отмена. Текущим становится
53
+ * следующее. `false` — совпадений нет или поле нельзя править.
54
+ */
55
+ replace(text: string): boolean;
56
+ /** Заменить все совпадения: одна правка, одна отмена. Условия — как у `replace`. */
57
+ replaceAll(text: string): boolean;
58
+ }
59
+
60
+ /** Команды Инструмента `toc`. */
61
+ export interface TocToolCommands {
62
+ /**
63
+ * Перейти к заголовку списка Оглавления — как клик по пункту. Индекс считается
64
+ * в том же списке, что и пункты: с учётом `levels`, с нуля, пустые заголовки
65
+ * пропускаются. В правке каретка встаёт в конец заголовка, в `readOnly` фокус
66
+ * получает сам заголовок; прокрутка ставит его под липкую шапку (`offsetVar`) и
67
+ * Панель, подсветка заголовка гаснет.
68
+ *
69
+ * `false` — нет заголовка с таким индексом, либо Оглавление ещё не готово: его
70
+ * UI грузится отдельным куском после создания движка, и в HTML-режиме его нет.
71
+ * Заголовков меньше двух — не причина отказа: полоса не рисуется, но список
72
+ * есть, и переход работает.
73
+ */
74
+ jumpTo(index: number): boolean;
75
+ }
76
+
77
+ /**
78
+ * Команды Инструмента по имени из карты `RichTextTools`. Имя вне карты — `unknown`:
79
+ * типизация добровольна, Инструмент работает и без неё.
80
+ */
81
+ export type ToolCommandsOf<Name extends string> = Name extends keyof RichTextTools
82
+ ? RichTextTools[Name] extends { commands: infer Commands }
83
+ ? Commands
84
+ : unknown
85
+ : unknown;