@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
@@ -9,11 +9,14 @@ import type { ShortcutCommand } from "../core/shortcuts.js";
9
9
  import { CODE_BLOCK_TOOL } from "./code/codeBlockTool.js";
10
10
  import { EMOJI_TOOL } from "./emoji/emojiTool.js";
11
11
  import { LINK_TOOL } from "./link/linkTool.js";
12
- import type { CustomTool, ToolEntry } from "../core/customTool.js";
13
- import { describeCustomTool } from "../core/customToolEngine.js";
12
+ import { devError } from "../../lib/dev-warn.js";
13
+ import type { ToolEntry } from "../core/customTool.js";
14
+ import { describeCustomTool, describeDefinition } from "../core/customToolEngine.js";
14
15
  import { toolList, type ToolDescriptor, type ToolList } from "../core/toolDescriptor.js";
16
+ import { isConfiguredTool, mergeOptions, type ConfiguredTool } from "../core/toolOptions.js";
15
17
  import { SEARCH_TOOL } from "./search/searchTool.js";
16
18
  import { TABLE_TOOL } from "./table/tableTool.js";
19
+ import { TOC_TOOL } from "./toc/tocTool.js";
17
20
  import { resolveTools, TOOLS, type RichTextEditorPreset, type Tool } from "../core/tools.js";
18
21
  import { unwrapWrapperAt, WRAPPER_NODE } from "./wrapper/wrapper.js";
19
22
 
@@ -51,6 +54,8 @@ const BLOCKQUOTE_ITEMS: readonly InsertItem[] = [
51
54
  */
52
55
  export const BUILTIN_TOOLS: Partial<Record<Tool, ToolDescriptor>> = {
53
56
  search: SEARCH_TOOL,
57
+ // Оглавление несёт только Опции: полоса и список рисуются в `RichTextEditor`.
58
+ toc: TOC_TOOL,
54
59
  /*
55
60
  Обёртка переехала только Действием над Блоком, поэтому
56
61
  `legacy`: её узел, вставка и сочетания остаются в старых реестрах. «Тон ›» —
@@ -115,31 +120,85 @@ export const BUILTIN_TOOLS: Partial<Record<Tool, ToolDescriptor>> = {
115
120
  table: TABLE_TOOL,
116
121
  };
117
122
 
123
+ /**
124
+ * Опции Инструмента для Редактора: умолчания описания, поверх них — те, что
125
+ * передал `tool()`. Значение, не прошедшее проверку, и опция, которой у
126
+ * Инструмента нет (проверки для неё тоже нет), отбрасываются, в разработке —
127
+ * одним `console.error` на Инструмент: потребитель на JS обходит типы карты, а
128
+ * поле от этого не падает.
129
+ */
130
+ function resolveOptions(
131
+ descriptor: ToolDescriptor,
132
+ configured: ConfiguredTool | undefined
133
+ ): ToolDescriptor {
134
+ const { defaults, checks } = descriptor;
135
+ const given = Object.entries(configured?.options ?? {}).filter(
136
+ ([, value]) => value !== undefined
137
+ );
138
+ const valid = given.filter(([key, value]) => checks?.[key]?.(value));
139
+ if (valid.length < given.length) {
140
+ devError(
141
+ `RichTextEditor: tool "${descriptor.name}": ignored invalid options: ` +
142
+ given
143
+ .filter((entry) => !valid.includes(entry))
144
+ .map(([key]) => key)
145
+ .join(", ")
146
+ );
147
+ }
148
+ return defaults
149
+ ? { ...descriptor, options: mergeOptions(defaults, Object.fromEntries(valid)) }
150
+ : descriptor;
151
+ }
152
+
118
153
  /**
119
154
  * Развернуть `tools` (или `preset`) в один список описаний.
120
155
  *
121
156
  * Имена — те же, что у `resolveTools`: точный `tools` или пресет, неизвестная
122
- * строка отбрасывается молча. Порядок встроенных — порядок кита (`TOOLS`), а
123
- * не `tools`: место встроенного на поверхности решает кит. Свои Инструменты
124
- * (описания в `tools`) идут следом, в порядке `tools` (ADR-0058, п. 5.3);
125
- * набор имён (`Set`) — только имена кита.
157
+ * строка отбрасывается молча. Запись `tool()` включает Инструмент так же, как
158
+ * его имя, а если в `tools` есть и имя (из пресета), и запись, побеждает
159
+ * Настроенный Инструмент; две записи с одним именем — ошибка набора
160
+ * (`ToolList.error`). Порядок встроенных — порядок кита (`TOOLS`), а не
161
+ * `tools`: место встроенного на поверхности решает кит, и Настроенный стоит
162
+ * там же, где голое имя. Свои Инструменты (описания в `tools`) идут следом, в
163
+ * порядке `tools` (ADR-0058, п. 5.3); набор имён (`Set`) — только имена кита.
164
+ *
165
+ * Опции Инструментов читаются здесь, один раз, вместе с набором.
126
166
  */
127
167
  export function expandTools(
128
168
  preset: RichTextEditorPreset,
129
169
  tools: readonly ToolEntry[] | undefined
130
170
  ): ToolList {
171
+ // Настроенные встроенные: запись с определением настраивает свой Инструмент, она ниже.
172
+ const configured = (tools ?? []).filter(
173
+ (entry): entry is ConfiguredTool => isConfiguredTool(entry) && !entry.definition
174
+ );
175
+ const twice = configured.find((entry, index) =>
176
+ configured.some((e, i) => i < index && e.name === entry.name)
177
+ );
131
178
  const names = resolveTools(
132
179
  preset,
133
- tools?.filter((tool): tool is Tool => typeof tool === "string")
180
+ tools && [
181
+ ...tools.filter((entry) => typeof entry === "string"),
182
+ ...configured.map((entry) => entry.name),
183
+ ]
134
184
  );
135
- const builtin = TOOLS.filter((name) => names.has(name)).map(
136
- (name): ToolDescriptor => BUILTIN_TOOLS[name] ?? { name, legacy: true }
185
+ const builtin = TOOLS.filter((name) => names.has(name)).map((name) =>
186
+ resolveOptions(
187
+ BUILTIN_TOOLS[name] ?? { name, legacy: true },
188
+ configured.find((entry) => entry.name === name)
189
+ )
137
190
  );
138
- const custom = (tools ?? [])
139
- .filter((tool): tool is CustomTool => typeof tool === "object" && tool !== null)
140
- .map(describeCustomTool);
191
+ // Свои: определение (с `tool()` и без) и обычное описание; уникальность имени проверяет набор.
192
+ const custom = (tools ?? []).flatMap((entry) => {
193
+ if (typeof entry !== "object" || entry === null) return [];
194
+ if (isConfiguredTool(entry)) {
195
+ return entry.definition ? describeDefinition(entry.definition, entry.options) : [];
196
+ }
197
+ return "defined" in entry ? describeDefinition(entry) : describeCustomTool(entry);
198
+ });
141
199
  return toolList(
142
200
  builtin.map((descriptor) => descriptor.name as Tool),
143
- [...builtin, ...custom]
201
+ [...builtin, ...custom],
202
+ twice && `tool "${twice.name}": two tool() entries share this name`
144
203
  );
145
204
  }
@@ -12,9 +12,12 @@ import { useMessages } from "../../../i18n/useMessages.js";
12
12
  import { cn } from "../../../lib/cn.js";
13
13
  import { GLASS_PANEL_CLASS } from "../../styles/glass.js";
14
14
  import type { SearchOptions } from "./searchMatch.js";
15
- import { revealCurrentMatch } from "./revealMatch.js";
15
+ import { cancelRevealMatch, registerSearchOverlays, scheduleRevealMatch } from "./revealMatch.js";
16
16
  import { searchState } from "./searchPlugin.js";
17
17
 
18
+ const NO_OPTIONS: SearchOptions = { matchCase: false, wholeWord: false, regex: false };
19
+ const FLAGS = ["matchCase", "wholeWord", "regex"] as const;
20
+
18
21
  export interface SearchPanelProps {
19
22
  editor: Editor;
20
23
  /**
@@ -27,8 +30,6 @@ export interface SearchPanelProps {
27
30
  className?: string;
28
31
  }
29
32
 
30
- const NO_OPTIONS: SearchOptions = { matchCase: false, wholeWord: false, regex: false };
31
-
32
33
  /**
33
34
  * Панель «Поиск и замена»: немодальная, в углу поля. Состояние
34
35
  * поиска — в плагине движка (`searchPlugin.ts`); панель держит только то, что
@@ -44,9 +45,14 @@ const NO_OPTIONS: SearchOptions = { matchCase: false, wholeWord: false, regex: f
44
45
  */
45
46
  export function SearchPanel({ editor, focus, onClose, className }: SearchPanelProps) {
46
47
  const messages = useMessages().editor;
47
- const [query, setQuery] = useState(focus.query);
48
+ /*
49
+ Запрос и флажки панели — зеркало поиска в движке: на открытии она берёт
50
+ то, что в нём уже есть (стартовые флажки — Опции Инструмента, запрос — если
51
+ экран задал его командой), а позже подхватывает такие же правки снаружи.
52
+ */
53
+ const [query, setQuery] = useState(() => focus.query || searchState(editor.state)?.query || "");
48
54
  const [replacement, setReplacement] = useState("");
49
- const [options, setOptions] = useState(NO_OPTIONS);
55
+ const [options, setOptions] = useState(() => searchState(editor.state)?.options ?? NO_OPTIONS);
50
56
  const searchRef = useRef<HTMLInputElement>(null);
51
57
  const replaceRef = useRef<HTMLInputElement>(null);
52
58
  const panelRef = useRef<HTMLElement>(null);
@@ -60,6 +66,8 @@ export function SearchPanel({ editor, focus, onClose, className }: SearchPanelPr
60
66
  current: state?.current ?? -1,
61
67
  total: state?.total ?? 0,
62
68
  error: state?.error,
69
+ engineQuery: state?.query ?? "",
70
+ engineOptions: state?.options ?? NO_OPTIONS,
63
71
  editable: current.isEditable,
64
72
  };
65
73
  },
@@ -91,15 +99,18 @@ export function SearchPanel({ editor, focus, onClose, className }: SearchPanelPr
91
99
  разложена и её край можно учесть. Панель и её липкий родитель (Панель
92
100
  Редактора) лежат поверх Документа: совпадение под ними не считается видимым.
93
101
  */
94
- const revealFrame = useRef(0);
95
- const reveal = () => {
96
- cancelAnimationFrame(revealFrame.current);
97
- revealFrame.current = requestAnimationFrame(() => {
98
- const panel = panelRef.current;
99
- revealCurrentMatch(editor.view.dom, [panel, panel?.parentElement]);
100
- });
101
- };
102
- useEffect(() => () => cancelAnimationFrame(revealFrame.current), []);
102
+ const reveal = () => scheduleRevealMatch(editor);
103
+ // Команды Инструмента показывают совпадение тем же путём и обходят эту же панель.
104
+ useEffect(() => {
105
+ const unregister = registerSearchOverlays(editor, () => [
106
+ panelRef.current,
107
+ panelRef.current?.parentElement,
108
+ ]);
109
+ return () => {
110
+ unregister();
111
+ cancelRevealMatch(editor);
112
+ };
113
+ }, [editor]);
103
114
 
104
115
  useEffect(() => {
105
116
  editor.commands.setSearch(query, options);
@@ -109,6 +120,19 @@ export function SearchPanel({ editor, focus, onClose, className }: SearchPanelPr
109
120
  // `reveal` — новая на каждый рендер, но читает только рефы и `editor`.
110
121
  }, [editor, query, options]);
111
122
 
123
+ /*
124
+ Запрос и флажки, заданные в движке мимо панели (команда `setQuery`), встают
125
+ в поля. Сверка с движком «вживую», а не по снимку `search`: эффект выше уже
126
+ протолкнул набранное в этом же коммите, и устаревший снимок откатил бы его.
127
+ */
128
+ useEffect(() => {
129
+ const live = searchState(editor.state);
130
+ if (!live) return;
131
+ if (live.query !== query) setQuery(live.query);
132
+ if (FLAGS.some((flag) => live.options[flag] !== options[flag])) setOptions(live.options);
133
+ // Только на смену поиска в движке; набор в полях уходит в движок эффектом выше.
134
+ }, [search.engineQuery, search.engineOptions]);
135
+
112
136
  const step = (direction: 1 | -1) => {
113
137
  editor.commands.searchStep(direction);
114
138
  reveal();
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import type { Editor } from "@tiptap/core";
4
+
3
5
  interface Band {
4
6
  top: number;
5
7
  bottom: number;
@@ -67,3 +69,46 @@ export function revealCurrentMatch(
67
69
  delta -= scroller.scrollTop - before;
68
70
  }
69
71
  }
72
+
73
+ /** Что лежит поверх Документа у каждого поля: панель поиска, пока она открыта. */
74
+ const overlayProviders = new WeakMap<Editor, () => readonly (Element | null | undefined)[]>();
75
+ const frames = new WeakMap<Editor, number>();
76
+
77
+ /**
78
+ * Панель поиска сообщает, что лежит поверх Документа, пока открыта. Команды
79
+ * Инструмента (`next`, `previous`, `setQuery`) живут вне панели, но должны
80
+ * обходить её так же. Возвращает снятие регистрации.
81
+ */
82
+ export function registerSearchOverlays(
83
+ editor: Editor,
84
+ overlays: () => readonly (Element | null | undefined)[]
85
+ ): () => void {
86
+ overlayProviders.set(editor, overlays);
87
+ return () => {
88
+ if (overlayProviders.get(editor) === overlays) overlayProviders.delete(editor);
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Показать текущее совпадение кадром позже, когда панель уже разложена. Вызовы
94
+ * одного поля в пределах кадра сливаются в одну прокрутку: панель и команда
95
+ * Инструмента, сработавшие на один переход, не прокручивают дважды. Фокус не
96
+ * трогает.
97
+ */
98
+ export function scheduleRevealMatch(editor: Editor): void {
99
+ cancelAnimationFrame(frames.get(editor) ?? 0);
100
+ frames.set(
101
+ editor,
102
+ requestAnimationFrame(() => {
103
+ frames.delete(editor);
104
+ if (editor.isDestroyed) return;
105
+ revealCurrentMatch(editor.view.dom, overlayProviders.get(editor)?.() ?? []);
106
+ })
107
+ );
108
+ }
109
+
110
+ /** Отменить отложенную прокрутку (панель закрылась или поле разобрано). */
111
+ export function cancelRevealMatch(editor: Editor): void {
112
+ cancelAnimationFrame(frames.get(editor) ?? 0);
113
+ frames.delete(editor);
114
+ }
@@ -23,7 +23,7 @@ interface SearchPluginState {
23
23
  }
24
24
 
25
25
  type SearchMeta =
26
- | { type: "set"; query: string; options: SearchOptions }
26
+ | { type: "set"; query: string; options?: Partial<SearchOptions> }
27
27
  | { type: "select"; index: number }
28
28
  | { type: "clear" };
29
29
 
@@ -85,17 +85,39 @@ function build(
85
85
  };
86
86
  }
87
87
 
88
- function searchPlugin() {
88
+ /**
89
+ * Флажки после `setSearch`: названные в `named` заменяют прежние, остальные
90
+ * остаются. Ключ со значением `undefined` — не названный: обычное слияние
91
+ * `{ ...a, ...b }` стёрло бы им флажок, а вызов `setQuery(q, { matchCase: value })`
92
+ * с необязательным значением экрана не просит ничего менять.
93
+ */
94
+ function withNamedFlags(current: SearchOptions, named: Partial<SearchOptions> = {}): SearchOptions {
95
+ const next = { ...current };
96
+ for (const key of Object.keys(named) as (keyof SearchOptions)[]) {
97
+ const value = named[key];
98
+ if (value !== undefined) next[key] = value;
99
+ }
100
+ return next;
101
+ }
102
+
103
+ /**
104
+ * @param flags флажки без запроса: до первого `setSearch` и после `clearSearch`
105
+ * (Опции Инструмента). Поиск, который идёт, держит свои.
106
+ */
107
+ function searchPlugin(flags: SearchOptions) {
89
108
  return new Plugin<SearchPluginState>({
90
109
  key: SEARCH_KEY,
91
110
  state: {
92
- init: (_, state) => build(state, "", EMPTY_OPTIONS, () => -1),
111
+ init: (_, state) => build(state, "", flags, () => -1),
93
112
  apply(tr: Transaction, previous, _old, state) {
94
113
  const meta = tr.getMeta(SEARCH_KEY) as SearchMeta | undefined;
95
- if (meta?.type === "clear") return build(state, "", previous.options, () => -1);
114
+ if (meta?.type === "clear") return build(state, "", flags, () => -1);
96
115
  if (meta?.type === "set") {
97
- return build(state, meta.query, meta.options, (matches) =>
98
- indexFrom(matches, state.selection.from)
116
+ return build(
117
+ state,
118
+ meta.query,
119
+ withNamedFlags(previous.options, meta.options),
120
+ (matches) => indexFrom(matches, state.selection.from)
99
121
  );
100
122
  }
101
123
  if (meta?.type === "select") {
@@ -151,8 +173,11 @@ function replaceRange(tr: Transaction, { from, to }: SearchMatch, text: string)
151
173
  declare module "@tiptap/core" {
152
174
  interface Commands<ReturnType> {
153
175
  search: {
154
- /** Новый запрос или опции: совпадения пересчитываются, текущее — первое после каретки. */
155
- setSearch: (query: string, options: SearchOptions) => ReturnType;
176
+ /**
177
+ * Новый запрос или флажки: совпадения пересчитываются, текущее — первое после
178
+ * каретки. Не названные флажки остаются как были.
179
+ */
180
+ setSearch: (query: string, options?: Partial<SearchOptions>) => ReturnType;
156
181
  /**
157
182
  * Следующее (`1`) или предыдущее (`-1`) совпадение по кругу; выделяет его.
158
183
  * Прокрутка ProseMirror срабатывает, только когда фокус в Документе; из
@@ -163,13 +188,15 @@ declare module "@tiptap/core" {
163
188
  replaceMatch: (replacement: string) => ReturnType;
164
189
  /** Заменить все совпадения — одна транзакция, одна отмена. */
165
190
  replaceAllMatches: (replacement: string) => ReturnType;
166
- /** Убрать запрос и подсветку. */
191
+ /** Убрать запрос и подсветку; флажки вернутся к Опциям Инструмента. */
167
192
  clearSearch: () => ReturnType;
168
193
  };
169
194
  }
170
195
  }
171
196
 
172
197
  export interface SearchExtensionOptions {
198
+ /** Флажки, с которых стартует поиск и к которым он возвращается: Опции Инструмента. */
199
+ flags: SearchOptions;
173
200
  /**
174
201
  * Открыть панель поиска (`replace` — с фокусом в поле замены). Панель держит
175
202
  * `RichTextEditor`; `false` — панели нет, и `Mod+F` уходит браузеру.
@@ -186,8 +213,10 @@ export interface SearchExtensionOptions {
186
213
  */
187
214
  export const SearchExtension = Extension.create<SearchExtensionOptions>({
188
215
  name: "search",
189
- addOptions: () => ({ open: () => false }),
190
- addProseMirrorPlugins: () => [searchPlugin()],
216
+ addOptions: () => ({ open: () => false, flags: EMPTY_OPTIONS }),
217
+ addProseMirrorPlugins() {
218
+ return [searchPlugin(this.options.flags)];
219
+ },
191
220
  addKeyboardShortcuts() {
192
221
  return {
193
222
  "Mod-f": () => this.options.open(false),
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import type { Editor } from "@tiptap/core";
4
+ import type { SearchToolState } from "../../core/toolState.js";
5
+ import { searchState } from "./searchPlugin.js";
6
+
7
+ /**
8
+ * Снимок Состояния `search` для `useRichTextToolState`: открытые поля
9
+ * `searchState` плагина плюс открытость панели, без позиций совпадений. Лежит
10
+ * отдельно от `searchPlugin.ts`: вычисление нужно только хуку и не должно
11
+ * попадать в статический граф Редактора.
12
+ *
13
+ * Флажки — из плагина и при закрытой панели: их держит движок, а не панель.
14
+ * Закрытие возвращает их к стартовым из Опций, а `setQuery` без панели ставит
15
+ * свои. `null` — плагина нет (Инструмент `search` не вошёл в движок).
16
+ */
17
+ export function searchSnapshot(editor: Editor, open: boolean): SearchToolState | null {
18
+ const state = searchState(editor.state);
19
+ if (!state) return null;
20
+ const { matchCase, wholeWord, regex } = state.options;
21
+ return {
22
+ open,
23
+ query: state.query,
24
+ flags: { matchCase, wholeWord, regex },
25
+ total: state.total,
26
+ current: state.current,
27
+ error: state.error ?? null,
28
+ };
29
+ }
@@ -1,6 +1,9 @@
1
1
  "use client";
2
2
 
3
+ import type { Editor } from "@tiptap/core";
3
4
  import type { ToolDescriptor } from "../../core/toolDescriptor.js";
5
+ import type { SearchToolCommands } from "../../core/toolCommands.js";
6
+ import type { SearchToolOptions } from "../../core/toolOptions.js";
4
7
  import { SearchExtension } from "./searchPlugin.js";
5
8
 
6
9
  /**
@@ -14,9 +17,56 @@ import { SearchExtension } from "./searchPlugin.js";
14
17
  /** Сочетание кнопки «Поиск»: строка справочника и подсказка кнопки — из одной записи. */
15
18
  export const SEARCH_KEYS = ["mod", "F"] as const;
16
19
 
20
+ /** Стартовые флажки панели: все выключены. */
21
+ export const SEARCH_DEFAULTS: SearchToolOptions = {
22
+ matchCase: false,
23
+ wholeWord: false,
24
+ regex: false,
25
+ };
26
+
27
+ const isFlag = (value: unknown) => typeof value === "boolean";
28
+
29
+ /*
30
+ Показать текущее совпадение после команды. ProseMirror прокручивает только
31
+ при фокусе в Документе, а команды зовут и с кнопки экрана. Код прокрутки
32
+ (`revealMatch.ts`) лежит в ленивом куске панели и в старт не входит:
33
+ грузится при первом вызове, поэтому прокрутка приходит на микрозадачу и кадр
34
+ позже. Открытая панель уже подгрузила его, и её переходы сливаются с этим
35
+ вызовом в одну прокрутку.
36
+ */
37
+ const reveal = (editor: Editor, done: boolean) => {
38
+ if (done) void import("./revealMatch.js").then((module) => module.scheduleRevealMatch(editor));
39
+ return done;
40
+ };
41
+
17
42
  export const SEARCH_TOOL: ToolDescriptor = {
18
43
  name: "search",
19
- engine: (context) => [SearchExtension.configure({ open: context.openSearch ?? (() => false) })],
44
+ defaults: SEARCH_DEFAULTS,
45
+ checks: { matchCase: isFlag, wholeWord: isFlag, regex: isFlag },
46
+ /*
47
+ Запрос, переходы и замена — команды движка, как у панели; панель и HTML-режим
48
+ знает мост. `editor.commands` читается при каждом вызове, а не один раз:
49
+ Tiptap собирает его вместе с транзакцией, и сохранённый набор отправил бы
50
+ устаревшую.
51
+ */
52
+ commands: ({ editor, bridge }): SearchToolCommands => ({
53
+ open: (options) => bridge.openSearch(options?.replace === true, options?.query),
54
+ close: bridge.closeSearch,
55
+ setQuery: (query, flags) =>
56
+ !bridge.sourceOpen() &&
57
+ editor.commands.setSearch(query, flags) &&
58
+ (reveal(editor, query !== ""), true),
59
+ next: () => reveal(editor, editor.commands.searchStep(1)),
60
+ previous: () => reveal(editor, editor.commands.searchStep(-1)),
61
+ replace: (text) => editor.commands.replaceMatch(text),
62
+ replaceAll: (text) => editor.commands.replaceAllMatches(text),
63
+ }),
64
+ engine: (context, options) => [
65
+ SearchExtension.configure({
66
+ open: context.openSearch ?? (() => false),
67
+ flags: options as SearchToolOptions,
68
+ }),
69
+ ],
20
70
  shortcuts: [
21
71
  { id: "search", group: "search", tool: "search", keys: SEARCH_KEYS, label: (m) => m.search },
22
72
  {