create-starlight-cms 0.9.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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/THIRD_PARTY_NOTICES.md +4 -0
  4. package/bin/index.js +147 -0
  5. package/package.json +46 -0
  6. package/template/.nvmrc +1 -0
  7. package/template/LICENSE +21 -0
  8. package/template/README.md +68 -0
  9. package/template/THIRD_PARTY_NOTICES.md +12 -0
  10. package/template/_gitignore +14 -0
  11. package/template/astro.config.mjs +26 -0
  12. package/template/migrations/0001_schema.sql +73 -0
  13. package/template/migrations/0002_publish_delivery.sql +16 -0
  14. package/template/package-lock.json.template +9123 -0
  15. package/template/package.json +89 -0
  16. package/template/scripts/build-admin.mjs +38 -0
  17. package/template/scripts/check-linux-bindings.mjs +18 -0
  18. package/template/scripts/dev.mjs +94 -0
  19. package/template/src/admin/admin.scss +182 -0
  20. package/template/src/admin/app.ts +130 -0
  21. package/template/src/admin/client.tsx +684 -0
  22. package/template/src/admin/components/tiptap-extension/node-background-extension.ts +150 -0
  23. package/template/src/admin/components/tiptap-icons/align-center-icon.tsx +38 -0
  24. package/template/src/admin/components/tiptap-icons/align-justify-icon.tsx +38 -0
  25. package/template/src/admin/components/tiptap-icons/align-left-icon.tsx +38 -0
  26. package/template/src/admin/components/tiptap-icons/align-right-icon.tsx +38 -0
  27. package/template/src/admin/components/tiptap-icons/arrow-left-icon.tsx +24 -0
  28. package/template/src/admin/components/tiptap-icons/arrow-right-icon.tsx +26 -0
  29. package/template/src/admin/components/tiptap-icons/ban-icon.tsx +26 -0
  30. package/template/src/admin/components/tiptap-icons/blockquote-icon.tsx +44 -0
  31. package/template/src/admin/components/tiptap-icons/bold-icon.tsx +26 -0
  32. package/template/src/admin/components/tiptap-icons/case-sensitive-icon.tsx +32 -0
  33. package/template/src/admin/components/tiptap-icons/check-icon.tsx +26 -0
  34. package/template/src/admin/components/tiptap-icons/chevron-down-icon.tsx +26 -0
  35. package/template/src/admin/components/tiptap-icons/chevron-up-icon.tsx +22 -0
  36. package/template/src/admin/components/tiptap-icons/close-icon.tsx +24 -0
  37. package/template/src/admin/components/tiptap-icons/code-block-icon.tsx +38 -0
  38. package/template/src/admin/components/tiptap-icons/code2-icon.tsx +32 -0
  39. package/template/src/admin/components/tiptap-icons/corner-down-left-icon.tsx +26 -0
  40. package/template/src/admin/components/tiptap-icons/external-link-icon.tsx +28 -0
  41. package/template/src/admin/components/tiptap-icons/heading-five-icon.tsx +28 -0
  42. package/template/src/admin/components/tiptap-icons/heading-four-icon.tsx +28 -0
  43. package/template/src/admin/components/tiptap-icons/heading-icon.tsx +24 -0
  44. package/template/src/admin/components/tiptap-icons/heading-one-icon.tsx +28 -0
  45. package/template/src/admin/components/tiptap-icons/heading-six-icon.tsx +30 -0
  46. package/template/src/admin/components/tiptap-icons/heading-three-icon.tsx +36 -0
  47. package/template/src/admin/components/tiptap-icons/heading-two-icon.tsx +28 -0
  48. package/template/src/admin/components/tiptap-icons/highlighter-icon.tsx +26 -0
  49. package/template/src/admin/components/tiptap-icons/image-plus-icon.tsx +26 -0
  50. package/template/src/admin/components/tiptap-icons/italic-icon.tsx +24 -0
  51. package/template/src/admin/components/tiptap-icons/link-icon.tsx +28 -0
  52. package/template/src/admin/components/tiptap-icons/list-icon.tsx +56 -0
  53. package/template/src/admin/components/tiptap-icons/list-ordered-icon.tsx +56 -0
  54. package/template/src/admin/components/tiptap-icons/list-todo-icon.tsx +50 -0
  55. package/template/src/admin/components/tiptap-icons/moon-star-icon.tsx +30 -0
  56. package/template/src/admin/components/tiptap-icons/redo2-icon.tsx +26 -0
  57. package/template/src/admin/components/tiptap-icons/search-icon.tsx +26 -0
  58. package/template/src/admin/components/tiptap-icons/strike-icon.tsx +28 -0
  59. package/template/src/admin/components/tiptap-icons/subscript-icon.tsx +38 -0
  60. package/template/src/admin/components/tiptap-icons/sun-icon.tsx +58 -0
  61. package/template/src/admin/components/tiptap-icons/superscript-icon.tsx +38 -0
  62. package/template/src/admin/components/tiptap-icons/trash-icon.tsx +26 -0
  63. package/template/src/admin/components/tiptap-icons/underline-icon.tsx +26 -0
  64. package/template/src/admin/components/tiptap-icons/undo2-icon.tsx +26 -0
  65. package/template/src/admin/components/tiptap-icons/whole-word-icon.tsx +36 -0
  66. package/template/src/admin/components/tiptap-node/blockquote-node/blockquote-node.scss +37 -0
  67. package/template/src/admin/components/tiptap-node/code-block-node/code-block-node.scss +54 -0
  68. package/template/src/admin/components/tiptap-node/heading-node/heading-node.scss +45 -0
  69. package/template/src/admin/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts +14 -0
  70. package/template/src/admin/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss +25 -0
  71. package/template/src/admin/components/tiptap-node/image-node/image-node.scss +35 -0
  72. package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node-extension.ts +161 -0
  73. package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node.scss +249 -0
  74. package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node.tsx +554 -0
  75. package/template/src/admin/components/tiptap-node/image-upload-node/index.tsx +1 -0
  76. package/template/src/admin/components/tiptap-node/list-node/list-node.scss +208 -0
  77. package/template/src/admin/components/tiptap-node/paragraph-node/paragraph-node.scss +217 -0
  78. package/template/src/admin/components/tiptap-templates/simple/NOTICE.md +14 -0
  79. package/template/src/admin/components/tiptap-templates/simple/data/content.json +477 -0
  80. package/template/src/admin/components/tiptap-templates/simple/simple-editor.scss +97 -0
  81. package/template/src/admin/components/tiptap-templates/simple/simple-editor.tsx +348 -0
  82. package/template/src/admin/components/tiptap-templates/simple/theme-toggle.tsx +44 -0
  83. package/template/src/admin/components/tiptap-ui/blockquote-button/blockquote-button.tsx +122 -0
  84. package/template/src/admin/components/tiptap-ui/blockquote-button/index.tsx +2 -0
  85. package/template/src/admin/components/tiptap-ui/blockquote-button/use-blockquote.ts +273 -0
  86. package/template/src/admin/components/tiptap-ui/code-block-button/code-block-button.tsx +122 -0
  87. package/template/src/admin/components/tiptap-ui/code-block-button/index.tsx +2 -0
  88. package/template/src/admin/components/tiptap-ui/code-block-button/use-code-block.ts +283 -0
  89. package/template/src/admin/components/tiptap-ui/color-highlight-button/color-highlight-button.scss +49 -0
  90. package/template/src/admin/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx +170 -0
  91. package/template/src/admin/components/tiptap-ui/color-highlight-button/index.tsx +2 -0
  92. package/template/src/admin/components/tiptap-ui/color-highlight-button/use-color-highlight.ts +380 -0
  93. package/template/src/admin/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +231 -0
  94. package/template/src/admin/components/tiptap-ui/color-highlight-popover/index.tsx +1 -0
  95. package/template/src/admin/components/tiptap-ui/heading-button/heading-button.tsx +124 -0
  96. package/template/src/admin/components/tiptap-ui/heading-button/index.tsx +2 -0
  97. package/template/src/admin/components/tiptap-ui/heading-button/use-heading.ts +348 -0
  98. package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +128 -0
  99. package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/index.tsx +2 -0
  100. package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts +132 -0
  101. package/template/src/admin/components/tiptap-ui/image-upload-button/image-upload-button.tsx +130 -0
  102. package/template/src/admin/components/tiptap-ui/image-upload-button/index.tsx +2 -0
  103. package/template/src/admin/components/tiptap-ui/image-upload-button/use-image-upload.ts +197 -0
  104. package/template/src/admin/components/tiptap-ui/link-popover/index.tsx +2 -0
  105. package/template/src/admin/components/tiptap-ui/link-popover/link-popover.scss +16 -0
  106. package/template/src/admin/components/tiptap-ui/link-popover/link-popover.tsx +315 -0
  107. package/template/src/admin/components/tiptap-ui/link-popover/use-link-popover.ts +297 -0
  108. package/template/src/admin/components/tiptap-ui/list-button/index.tsx +2 -0
  109. package/template/src/admin/components/tiptap-ui/list-button/list-button.tsx +120 -0
  110. package/template/src/admin/components/tiptap-ui/list-button/use-list.ts +353 -0
  111. package/template/src/admin/components/tiptap-ui/list-dropdown-menu/index.tsx +2 -0
  112. package/template/src/admin/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +126 -0
  113. package/template/src/admin/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts +220 -0
  114. package/template/src/admin/components/tiptap-ui/mark-button/index.tsx +2 -0
  115. package/template/src/admin/components/tiptap-ui/mark-button/mark-button.tsx +122 -0
  116. package/template/src/admin/components/tiptap-ui/mark-button/use-mark.ts +219 -0
  117. package/template/src/admin/components/tiptap-ui/search-and-replace/index.tsx +2 -0
  118. package/template/src/admin/components/tiptap-ui/search-and-replace/search-and-replace.scss +300 -0
  119. package/template/src/admin/components/tiptap-ui/search-and-replace/search-and-replace.tsx +718 -0
  120. package/template/src/admin/components/tiptap-ui/search-and-replace/use-search-and-replace.ts +528 -0
  121. package/template/src/admin/components/tiptap-ui/text-align-button/index.tsx +2 -0
  122. package/template/src/admin/components/tiptap-ui/text-align-button/text-align-button.tsx +144 -0
  123. package/template/src/admin/components/tiptap-ui/text-align-button/use-text-align.ts +237 -0
  124. package/template/src/admin/components/tiptap-ui/undo-redo-button/index.tsx +2 -0
  125. package/template/src/admin/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx +125 -0
  126. package/template/src/admin/components/tiptap-ui/undo-redo-button/use-undo-redo.ts +200 -0
  127. package/template/src/admin/components/tiptap-ui-primitive/badge/badge-colors.scss +395 -0
  128. package/template/src/admin/components/tiptap-ui-primitive/badge/badge-group.scss +16 -0
  129. package/template/src/admin/components/tiptap-ui-primitive/badge/badge.scss +99 -0
  130. package/template/src/admin/components/tiptap-ui-primitive/badge/badge.tsx +44 -0
  131. package/template/src/admin/components/tiptap-ui-primitive/badge/index.tsx +1 -0
  132. package/template/src/admin/components/tiptap-ui-primitive/button/button-colors.scss +613 -0
  133. package/template/src/admin/components/tiptap-ui-primitive/button/button.scss +390 -0
  134. package/template/src/admin/components/tiptap-ui-primitive/button/button.tsx +158 -0
  135. package/template/src/admin/components/tiptap-ui-primitive/button/index.tsx +1 -0
  136. package/template/src/admin/components/tiptap-ui-primitive/button-group/button-group.scss +89 -0
  137. package/template/src/admin/components/tiptap-ui-primitive/button-group/button-group.tsx +72 -0
  138. package/template/src/admin/components/tiptap-ui-primitive/button-group/index.tsx +1 -0
  139. package/template/src/admin/components/tiptap-ui-primitive/card/card.scss +74 -0
  140. package/template/src/admin/components/tiptap-ui-primitive/card/card.tsx +79 -0
  141. package/template/src/admin/components/tiptap-ui-primitive/card/index.tsx +1 -0
  142. package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss +152 -0
  143. package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx +262 -0
  144. package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/index.tsx +1 -0
  145. package/template/src/admin/components/tiptap-ui-primitive/input/index.tsx +1 -0
  146. package/template/src/admin/components/tiptap-ui-primitive/input/input.scss +41 -0
  147. package/template/src/admin/components/tiptap-ui-primitive/input/input.tsx +22 -0
  148. package/template/src/admin/components/tiptap-ui-primitive/input-group/index.tsx +1 -0
  149. package/template/src/admin/components/tiptap-ui-primitive/input-group/input-group.scss +225 -0
  150. package/template/src/admin/components/tiptap-ui-primitive/input-group/input-group.tsx +135 -0
  151. package/template/src/admin/components/tiptap-ui-primitive/popover/index.tsx +1 -0
  152. package/template/src/admin/components/tiptap-ui-primitive/popover/popover.scss +64 -0
  153. package/template/src/admin/components/tiptap-ui-primitive/popover/popover.tsx +35 -0
  154. package/template/src/admin/components/tiptap-ui-primitive/separator/index.tsx +1 -0
  155. package/template/src/admin/components/tiptap-ui-primitive/separator/separator.scss +23 -0
  156. package/template/src/admin/components/tiptap-ui-primitive/separator/separator.tsx +28 -0
  157. package/template/src/admin/components/tiptap-ui-primitive/spacer/index.tsx +1 -0
  158. package/template/src/admin/components/tiptap-ui-primitive/spacer/spacer.tsx +24 -0
  159. package/template/src/admin/components/tiptap-ui-primitive/switch/index.tsx +1 -0
  160. package/template/src/admin/components/tiptap-ui-primitive/switch/switch.scss +86 -0
  161. package/template/src/admin/components/tiptap-ui-primitive/switch/switch.tsx +29 -0
  162. package/template/src/admin/components/tiptap-ui-primitive/textarea/index.tsx +1 -0
  163. package/template/src/admin/components/tiptap-ui-primitive/textarea/textarea.scss +42 -0
  164. package/template/src/admin/components/tiptap-ui-primitive/textarea/textarea.tsx +16 -0
  165. package/template/src/admin/components/tiptap-ui-primitive/toolbar/index.tsx +1 -0
  166. package/template/src/admin/components/tiptap-ui-primitive/toolbar/toolbar.scss +98 -0
  167. package/template/src/admin/components/tiptap-ui-primitive/toolbar/toolbar.tsx +203 -0
  168. package/template/src/admin/components/tiptap-ui-primitive/tooltip/index.tsx +1 -0
  169. package/template/src/admin/components/tiptap-ui-primitive/tooltip/tooltip.scss +43 -0
  170. package/template/src/admin/components/tiptap-ui-primitive/tooltip/tooltip.tsx +241 -0
  171. package/template/src/admin/hooks/use-composed-ref.ts +46 -0
  172. package/template/src/admin/hooks/use-cursor-visibility.ts +69 -0
  173. package/template/src/admin/hooks/use-element-rect.ts +166 -0
  174. package/template/src/admin/hooks/use-is-breakpoint.ts +35 -0
  175. package/template/src/admin/hooks/use-menu-navigation.ts +204 -0
  176. package/template/src/admin/hooks/use-scrolling.ts +75 -0
  177. package/template/src/admin/hooks/use-throttled-callback.ts +48 -0
  178. package/template/src/admin/hooks/use-tiptap-editor.ts +69 -0
  179. package/template/src/admin/hooks/use-unmount.ts +21 -0
  180. package/template/src/admin/hooks/use-window-size.ts +93 -0
  181. package/template/src/admin/html.ts +17 -0
  182. package/template/src/admin/lib/tiptap-utils.ts +652 -0
  183. package/template/src/admin/navigation-tree.tsx +82 -0
  184. package/template/src/admin/preview.ts +73 -0
  185. package/template/src/admin/styles/_cms-theme.scss +58 -0
  186. package/template/src/admin/styles/_keyframe-animations.scss +91 -0
  187. package/template/src/admin/styles/_variables.scss +298 -0
  188. package/template/src/assets/favicon.svg +1 -0
  189. package/template/src/assets/logo.svg +1 -0
  190. package/template/src/astro-env.d.ts +1 -0
  191. package/template/src/content.config.ts +12 -0
  192. package/template/src/db/bootstrap.ts +36 -0
  193. package/template/src/db/client.ts +7 -0
  194. package/template/src/db/schema.ts +45 -0
  195. package/template/src/documents/service.ts +178 -0
  196. package/template/src/documents/validation.ts +9 -0
  197. package/template/src/env.ts +9 -0
  198. package/template/src/index.ts +10 -0
  199. package/template/src/locales.ts +10 -0
  200. package/template/src/media/service.ts +120 -0
  201. package/template/src/navigation/service.ts +171 -0
  202. package/template/src/pages/[locale]/cms-preview-shell.astro +12 -0
  203. package/template/src/pages/cms-preview-shell.astro +5 -0
  204. package/template/src/publish/deploy-hook.ts +42 -0
  205. package/template/src/publish/record.ts +20 -0
  206. package/template/src/publish/service.ts +135 -0
  207. package/template/src/scss.d.ts +5 -0
  208. package/template/src/site.config.ts +7 -0
  209. package/template/src/sql.d.ts +4 -0
  210. package/template/src/starlight/cms-origin.ts +4 -0
  211. package/template/src/starlight/loader.ts +21 -0
  212. package/template/src/starlight/preview-render.ts +162 -0
  213. package/template/src/starlight/preview-shell.astro +14 -0
  214. package/template/src/starlight/render.ts +59 -0
  215. package/template/src/starlight/schema.ts +34 -0
  216. package/template/src/starlight/sidebar.ts +35 -0
  217. package/template/src/starlight/snapshot.ts +47 -0
  218. package/template/src/starlight/source.ts +26 -0
  219. package/template/src/starlight/tiptap.ts +29 -0
  220. package/template/src/styles/starlight.css +12 -0
  221. package/template/test-files/content.test.template.ts +82 -0
  222. package/template/test-files/deploy-hook.test.template.ts +32 -0
  223. package/template/test-files/media.test.template.ts +39 -0
  224. package/template/test-files/publishing.integration.test.template.ts +156 -0
  225. package/template/tsconfig.admin.json +18 -0
  226. package/template/tsconfig.json +23 -0
  227. package/template/worker-configuration.d.ts +15389 -0
  228. package/template/wrangler.jsonc +26 -0
@@ -0,0 +1,528 @@
1
+ import { useCallback, useEffect, useState } from "react"
2
+ import type { Editor } from "@tiptap/react"
3
+ import { useCurrentEditor } from "@tiptap/react"
4
+ import type { FindAndReplaceStorage } from "@tiptap/extension-find-and-replace"
5
+
6
+ // --- Hooks ---
7
+ import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
8
+
9
+ // --- Icons ---
10
+ import { SearchIcon } from "@/components/tiptap-icons/search-icon"
11
+
12
+ // --- Lib ---
13
+ import { isExtensionAvailable } from "@/lib/tiptap-utils"
14
+
15
+ export const SEARCH_AND_REPLACE_SHORTCUT_KEY = "mod+f"
16
+ export const NEXT_RESULT_SHORTCUT_KEY = "mod+shift+f"
17
+ export const PREVIOUS_RESULT_SHORTCUT_KEY = "mod+shift+d"
18
+
19
+ /**
20
+ * CSS classes rendered by the Find and Replace extension for result
21
+ * decorations inside the editor.
22
+ */
23
+ export const SEARCH_RESULT_CLASS = "find-and-replace-result"
24
+ export const SEARCH_RESULT_CURRENT_CLASS = "find-and-replace-result-current"
25
+
26
+ export const DEFAULT_SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = {
27
+ block: "nearest",
28
+ inline: "nearest",
29
+ }
30
+
31
+ /**
32
+ * Configuration for the search and replace functionality
33
+ */
34
+ export interface UseSearchAndReplaceConfig {
35
+ /**
36
+ * The Tiptap editor instance.
37
+ */
38
+ editor?: Editor | null
39
+ /**
40
+ * Whether the trigger should hide when the Find and Replace extension
41
+ * is not registered on the editor.
42
+ * @default false
43
+ */
44
+ hideWhenUnavailable?: boolean
45
+ /**
46
+ * How the current result is scrolled into view. `behavior` is forced to
47
+ * `auto` when the user prefers reduced motion.
48
+ * @default DEFAULT_SCROLL_INTO_VIEW_OPTIONS
49
+ */
50
+ scrollIntoViewOptions?: ScrollIntoViewOptions
51
+ }
52
+
53
+ /**
54
+ * Reactive snapshot of the Find and Replace extension storage.
55
+ */
56
+ export interface SearchAndReplaceEditorState {
57
+ /**
58
+ * Number of results for the applied search term.
59
+ */
60
+ total: number
61
+ /**
62
+ * Zero-based index of the current result, or `null` when none is selected.
63
+ */
64
+ currentIndex: number | null
65
+ /**
66
+ * The search term the extension has applied (post-debounce).
67
+ */
68
+ appliedSearchTerm: string
69
+ /**
70
+ * Whether the search is case sensitive.
71
+ */
72
+ caseSensitive: boolean
73
+ /**
74
+ * Whether only whole words are matched.
75
+ */
76
+ wholeWord: boolean
77
+ /**
78
+ * Whether the search term is treated as a regular expression.
79
+ */
80
+ useRegex: boolean
81
+ }
82
+
83
+ const EMPTY_EDITOR_STATE: SearchAndReplaceEditorState = {
84
+ total: 0,
85
+ currentIndex: null,
86
+ appliedSearchTerm: "",
87
+ caseSensitive: false,
88
+ wholeWord: false,
89
+ useRegex: false,
90
+ }
91
+
92
+ /**
93
+ * Checks if the Find and Replace extension is registered on the editor
94
+ */
95
+ export function isFindAndReplaceAvailable(editor: Editor | null): boolean {
96
+ return isExtensionAvailable(editor, "findAndReplace")
97
+ }
98
+
99
+ /**
100
+ * Returns the Find and Replace extension storage, or `null` when unavailable
101
+ */
102
+ export function getFindAndReplaceStorage(
103
+ editor: Editor | null
104
+ ): FindAndReplaceStorage | null {
105
+ if (!isFindAndReplaceAvailable(editor)) return null
106
+ return editor?.storage.findAndReplace ?? null
107
+ }
108
+
109
+ /**
110
+ * Determines if the search and replace trigger should be shown
111
+ */
112
+ export function shouldShowSearchAndReplace(props: {
113
+ editor: Editor | null
114
+ hideWhenUnavailable: boolean
115
+ }): boolean {
116
+ const { editor, hideWhenUnavailable } = props
117
+
118
+ if (!editor) return false
119
+ if (hideWhenUnavailable) return isFindAndReplaceAvailable(editor)
120
+
121
+ return true
122
+ }
123
+
124
+ /**
125
+ * Formats the result counter as `current / total` for display.
126
+ * The extension index is zero-based and can be `null`, so the counter
127
+ * renders `0 / 0` without results and one-based positions otherwise.
128
+ */
129
+ export function formatResultCount(
130
+ currentIndex: number | null,
131
+ total: number
132
+ ): string {
133
+ if (total === 0) return "0 / 0"
134
+ if (currentIndex === null) return `0 / ${total}`
135
+
136
+ return `${currentIndex + 1} / ${total}`
137
+ }
138
+
139
+ interface NavigateSearchResultOptions {
140
+ editor: Editor
141
+ direction: "next" | "previous"
142
+ }
143
+
144
+ function navigateSearchResult({
145
+ editor,
146
+ direction,
147
+ }: NavigateSearchResultOptions): boolean {
148
+ const selection = editor.state.selection
149
+ const chain = editor.chain()
150
+ const navigation =
151
+ direction === "next" ? chain.goToNextResult() : chain.goToPreviousResult()
152
+
153
+ // Search navigation should update its highlight without changing the
154
+ // editor selection that drives toolbar state.
155
+ return navigation
156
+ .command(({ tr }) => {
157
+ tr.setSelection(selection.map(tr.doc, tr.mapping))
158
+ return true
159
+ })
160
+ .run()
161
+ }
162
+
163
+ /**
164
+ * Scrolls the current result into view without stealing focus. The element
165
+ * is resolved from the result's document position rather than the highlight
166
+ * decoration, so it works regardless of how decorations are rendered.
167
+ * Skipped while the editor itself has focus so typing inside the document
168
+ * never causes scroll jumps, and honors `prefers-reduced-motion`.
169
+ */
170
+ export function scrollCurrentResultIntoView(
171
+ editor: Editor | null,
172
+ scrollIntoViewOptions: ScrollIntoViewOptions = DEFAULT_SCROLL_INTO_VIEW_OPTIONS
173
+ ): void {
174
+ if (!editor || editor.isDestroyed) return
175
+
176
+ const storage = getFindAndReplaceStorage(editor)
177
+ const currentIndex = storage?.currentIndex
178
+ if (!storage || currentIndex === null || currentIndex === undefined) return
179
+
180
+ const result = storage.results[currentIndex]
181
+ if (!result) return
182
+
183
+ const editorDom = editor.view.dom
184
+ if (editorDom.contains(document.activeElement)) return
185
+
186
+ let target: HTMLElement | null = null
187
+ try {
188
+ const { node } = editor.view.domAtPos(result.from)
189
+ target = node instanceof HTMLElement ? node : node.parentElement
190
+ } catch {
191
+ return
192
+ }
193
+
194
+ if (!target || typeof target.scrollIntoView !== "function") return
195
+
196
+ const prefersReducedMotion =
197
+ typeof window.matchMedia === "function" &&
198
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
199
+
200
+ target.scrollIntoView({
201
+ behavior: "smooth",
202
+ ...scrollIntoViewOptions,
203
+ ...(prefersReducedMotion ? { behavior: "auto" } : {}),
204
+ })
205
+ }
206
+
207
+ /**
208
+ * Main hook that provides search and replace functionality for a Tiptap editor.
209
+ *
210
+ * The `searchTerm`/`replaceTerm` values mirror the inputs immediately, while
211
+ * search execution is forwarded to the extension's `setSearchTerm`, which
212
+ * applies its own debounce (`searchDebounceMs`) — no additional UI debounce is
213
+ * added. An empty search cancels pending work and clears highlights
214
+ * immediately.
215
+ *
216
+ * @example
217
+ * ```tsx
218
+ * function MySearchPanel() {
219
+ * const {
220
+ * isVisible,
221
+ * resultCountLabel,
222
+ * searchTerm,
223
+ * setSearchTerm,
224
+ * goToNext,
225
+ * } = useSearchAndReplace()
226
+ *
227
+ * if (!isVisible) return null
228
+ *
229
+ * return (
230
+ * <div>
231
+ * <input
232
+ * value={searchTerm}
233
+ * onChange={(event) => setSearchTerm(event.target.value)}
234
+ * />
235
+ * <span>{resultCountLabel}</span>
236
+ * <button onClick={goToNext}>Next</button>
237
+ * </div>
238
+ * )
239
+ * }
240
+ * ```
241
+ */
242
+ export function useSearchAndReplace(config?: UseSearchAndReplaceConfig) {
243
+ const {
244
+ editor: providedEditor,
245
+ hideWhenUnavailable = false,
246
+ scrollIntoViewOptions = DEFAULT_SCROLL_INTO_VIEW_OPTIONS,
247
+ } = config || {}
248
+
249
+ // `useTiptapEditor` resolves through `useEditorState`, whose snapshot stays
250
+ // `null` until the editor's first transaction (with `immediatelyRender:
251
+ // false` there may be none on mount). Fall back to the always-fresh context
252
+ // editor so the trigger and commands work before any interaction.
253
+ const { editor: resolvedEditor } = useTiptapEditor(providedEditor)
254
+ const { editor: contextEditor } = useCurrentEditor()
255
+ const editor = resolvedEditor ?? providedEditor ?? contextEditor ?? null
256
+ const canSearch = isFindAndReplaceAvailable(editor)
257
+
258
+ // Visibility defaults to true and is refined once the editor reports
259
+ // activity. The editor resolved above stays `null` until its first
260
+ // transaction (with `immediatelyRender: false` there may be none on
261
+ // mount), so deriving visibility directly would hide the trigger on load.
262
+ const [isVisible, setIsVisible] = useState(true)
263
+
264
+ useEffect(() => {
265
+ if (!editor) return
266
+
267
+ const handleUpdate = () => {
268
+ setIsVisible(shouldShowSearchAndReplace({ editor, hideWhenUnavailable }))
269
+ }
270
+
271
+ handleUpdate()
272
+
273
+ editor.on("transaction", handleUpdate)
274
+
275
+ return () => {
276
+ editor.off("transaction", handleUpdate)
277
+ }
278
+ }, [editor, hideWhenUnavailable])
279
+
280
+ // Immediate input values. Extension storage lags behind the search input by
281
+ // the extension's debounce, so the inputs keep their own state while the
282
+ // extension owns matching, navigation and replacement.
283
+ const [searchTerm, setSearchTermState] = useState("")
284
+ const [replaceTerm, setReplaceTermState] = useState("")
285
+
286
+ // Adopt terms configured on the extension (e.g. an initial `searchTerm`
287
+ // option) once the editor is available.
288
+ useEffect(() => {
289
+ const storage = getFindAndReplaceStorage(editor)
290
+ if (!storage) return
291
+
292
+ if (storage.searchTerm) {
293
+ setSearchTermState((previous) => previous || storage.searchTerm)
294
+ }
295
+ if (storage.replaceTerm) {
296
+ setReplaceTermState((previous) => previous || storage.replaceTerm)
297
+ }
298
+ }, [editor])
299
+
300
+ // Subscribe to extension transactions so count, current index and disabled
301
+ // states update immediately. Synced eagerly on subscribe: the extension may
302
+ // already hold results from a configured `searchTerm` before any
303
+ // transaction happens (e.g. on a freshly loaded page).
304
+ const [editorState, setEditorState] =
305
+ useState<SearchAndReplaceEditorState>(EMPTY_EDITOR_STATE)
306
+
307
+ useEffect(() => {
308
+ if (!editor || !isFindAndReplaceAvailable(editor)) {
309
+ setEditorState(EMPTY_EDITOR_STATE)
310
+ return
311
+ }
312
+
313
+ const sync = () => {
314
+ const storage = getFindAndReplaceStorage(editor)
315
+ if (!storage) return
316
+
317
+ const next: SearchAndReplaceEditorState = {
318
+ total: storage.results.length,
319
+ currentIndex: storage.currentIndex,
320
+ appliedSearchTerm: storage.searchTerm,
321
+ caseSensitive: storage.caseSensitive,
322
+ wholeWord: storage.wholeWord,
323
+ useRegex: storage.useRegex,
324
+ }
325
+
326
+ setEditorState((previous) =>
327
+ previous.total === next.total &&
328
+ previous.currentIndex === next.currentIndex &&
329
+ previous.appliedSearchTerm === next.appliedSearchTerm &&
330
+ previous.caseSensitive === next.caseSensitive &&
331
+ previous.wholeWord === next.wholeWord &&
332
+ previous.useRegex === next.useRegex
333
+ ? previous
334
+ : next
335
+ )
336
+ }
337
+
338
+ sync()
339
+
340
+ editor.on("transaction", sync)
341
+
342
+ return () => {
343
+ editor.off("transaction", sync)
344
+ }
345
+ }, [editor])
346
+
347
+ const {
348
+ total,
349
+ currentIndex,
350
+ appliedSearchTerm,
351
+ caseSensitive,
352
+ wholeWord,
353
+ useRegex,
354
+ } = editorState
355
+
356
+ const setSearchTerm = useCallback(
357
+ (value: string) => {
358
+ setSearchTermState(value)
359
+
360
+ if (!editor || !isFindAndReplaceAvailable(editor)) return
361
+
362
+ if (value.length === 0) {
363
+ // Cancels the pending debounced search and clears highlights and
364
+ // the result count immediately.
365
+ editor.commands.clearSearch()
366
+ } else {
367
+ editor.commands.setSearchTerm(value)
368
+ }
369
+ },
370
+ [editor]
371
+ )
372
+
373
+ const setReplaceTerm = useCallback(
374
+ (value: string) => {
375
+ setReplaceTermState(value)
376
+
377
+ if (!editor || !isFindAndReplaceAvailable(editor)) return
378
+ editor.commands.setReplaceTerm(value)
379
+ },
380
+ [editor]
381
+ )
382
+
383
+ const toggleCaseSensitive = useCallback(() => {
384
+ if (!editor || !isFindAndReplaceAvailable(editor)) return
385
+ editor.commands.setCaseSensitive(
386
+ !getFindAndReplaceStorage(editor)?.caseSensitive
387
+ )
388
+ }, [editor])
389
+
390
+ const toggleWholeWord = useCallback(() => {
391
+ if (!editor || !isFindAndReplaceAvailable(editor)) return
392
+ editor.commands.setWholeWord(!getFindAndReplaceStorage(editor)?.wholeWord)
393
+ }, [editor])
394
+
395
+ const toggleUseRegex = useCallback(() => {
396
+ if (!editor || !isFindAndReplaceAvailable(editor)) return
397
+ editor.commands.setUseRegex(!getFindAndReplaceStorage(editor)?.useRegex)
398
+ }, [editor])
399
+
400
+ const goToNext = useCallback(() => {
401
+ if (!editor) return false
402
+ return navigateSearchResult({ editor, direction: "next" })
403
+ }, [editor])
404
+
405
+ const goToPrevious = useCallback(() => {
406
+ if (!editor) return false
407
+ return navigateSearchResult({ editor, direction: "previous" })
408
+ }, [editor])
409
+
410
+ const replaceCurrent = useCallback(() => {
411
+ if (!editor || !editor.isEditable) return false
412
+ return editor.commands.replace()
413
+ }, [editor])
414
+
415
+ const replaceAll = useCallback(() => {
416
+ if (!editor || !editor.isEditable) return false
417
+ return editor.commands.replaceAll()
418
+ }, [editor])
419
+
420
+ /**
421
+ * Re-applies the stored terms to the editor. Used when the search UI
422
+ * reopens while the hook instance stayed mounted (e.g. popover reopen).
423
+ */
424
+ const applySearch = useCallback(() => {
425
+ if (!editor || !isFindAndReplaceAvailable(editor)) return
426
+
427
+ editor.commands.setReplaceTerm(replaceTerm)
428
+ if (searchTerm) {
429
+ editor.commands.setSearchTerm(searchTerm)
430
+ }
431
+ }, [editor, searchTerm, replaceTerm])
432
+
433
+ /**
434
+ * Clears highlights and cancels pending work while keeping the entered
435
+ * terms, so closing the search UI does not erase non-empty field values.
436
+ */
437
+ const suspendSearch = useCallback(() => {
438
+ if (!editor || !isFindAndReplaceAvailable(editor) || editor.isDestroyed) {
439
+ return
440
+ }
441
+ editor.commands.clearSearch()
442
+ }, [editor])
443
+
444
+ /**
445
+ * Clears the search entirely: highlights, pending work and field values.
446
+ */
447
+ const clearSearch = useCallback(() => {
448
+ setSearchTermState("")
449
+ setReplaceTermState("")
450
+
451
+ if (!editor || !isFindAndReplaceAvailable(editor) || editor.isDestroyed) {
452
+ return
453
+ }
454
+ editor.commands.clearSearch()
455
+ }, [editor])
456
+
457
+ // Clear highlights and cancel the pending debounced search when the search
458
+ // UI unmounts, so no stale decorations remain without a way to clear them.
459
+ // Unconditional on purpose: a debounced search may still be pending without
460
+ // being reflected in the extension storage yet.
461
+ useEffect(() => {
462
+ if (!editor || !canSearch) return
463
+
464
+ return () => {
465
+ if (editor.isDestroyed) return
466
+ editor.commands.clearSearch()
467
+ }
468
+ }, [editor, canSearch])
469
+
470
+ // Keep the current result visible whenever results become available or the
471
+ // active result changes. Deferred a frame so layout-heavy integrations
472
+ // (e.g. paginated documents) have settled before measuring.
473
+ useEffect(() => {
474
+ if (total === 0 || currentIndex === null) return
475
+
476
+ const frame = requestAnimationFrame(() => {
477
+ scrollCurrentResultIntoView(editor, scrollIntoViewOptions)
478
+ })
479
+
480
+ return () => cancelAnimationFrame(frame)
481
+ }, [editor, total, currentIndex, appliedSearchTerm, scrollIntoViewOptions])
482
+
483
+ const isEditable = !!editor?.isEditable
484
+ const canNavigate = canSearch && total > 0
485
+ const hasReplaceTerm = replaceTerm.length > 0
486
+ const canReplace =
487
+ canSearch &&
488
+ isEditable &&
489
+ hasReplaceTerm &&
490
+ total > 0 &&
491
+ currentIndex !== null
492
+ const canReplaceAll = canSearch && isEditable && hasReplaceTerm && total > 0
493
+
494
+ return {
495
+ editor,
496
+ isVisible,
497
+ canSearch,
498
+ searchTerm,
499
+ replaceTerm,
500
+ appliedSearchTerm,
501
+ total,
502
+ currentIndex,
503
+ resultCountLabel: formatResultCount(currentIndex, total),
504
+ caseSensitive,
505
+ wholeWord,
506
+ useRegex,
507
+ canNavigate,
508
+ canReplace,
509
+ canReplaceAll,
510
+ setSearchTerm,
511
+ setReplaceTerm,
512
+ toggleCaseSensitive,
513
+ toggleWholeWord,
514
+ toggleUseRegex,
515
+ goToNext,
516
+ goToPrevious,
517
+ replaceCurrent,
518
+ replaceAll,
519
+ applySearch,
520
+ suspendSearch,
521
+ clearSearch,
522
+ label: "Search and replace",
523
+ shortcutKeys: SEARCH_AND_REPLACE_SHORTCUT_KEY,
524
+ Icon: SearchIcon,
525
+ }
526
+ }
527
+
528
+ export type UseSearchAndReplaceReturn = ReturnType<typeof useSearchAndReplace>
@@ -0,0 +1,2 @@
1
+ export * from "./text-align-button"
2
+ export * from "./use-text-align"
@@ -0,0 +1,144 @@
1
+ "use client"
2
+
3
+ import { forwardRef, useCallback } from "react"
4
+
5
+ // --- Lib ---
6
+ import { parseShortcutKeys } from "@/lib/tiptap-utils"
7
+
8
+ // --- Hooks ---
9
+ import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
10
+
11
+ // --- Tiptap UI ---
12
+ import type {
13
+ TextAlign,
14
+ UseTextAlignConfig,
15
+ } from "@/components/tiptap-ui/text-align-button"
16
+ import {
17
+ TEXT_ALIGN_SHORTCUT_KEYS,
18
+ useTextAlign,
19
+ } from "@/components/tiptap-ui/text-align-button"
20
+
21
+ // --- UI Primitives ---
22
+ import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
23
+ import { Button } from "@/components/tiptap-ui-primitive/button"
24
+ import { Badge } from "@/components/tiptap-ui-primitive/badge"
25
+
26
+ type IconProps = React.SVGProps<SVGSVGElement>
27
+ type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
28
+
29
+ export interface TextAlignButtonProps
30
+ extends Omit<ButtonProps, "type">, UseTextAlignConfig {
31
+ /**
32
+ * Optional text to display alongside the icon.
33
+ */
34
+ text?: string
35
+ /**
36
+ * Optional show shortcut keys in the button.
37
+ * @default false
38
+ */
39
+ showShortcut?: boolean
40
+ /**
41
+ * Optional custom icon component to render instead of the default.
42
+ */
43
+ icon?: React.MemoExoticComponent<IconComponent> | React.FC<IconProps>
44
+ }
45
+
46
+ export function TextAlignShortcutBadge({
47
+ align,
48
+ shortcutKeys = TEXT_ALIGN_SHORTCUT_KEYS[align],
49
+ }: {
50
+ align: TextAlign
51
+ shortcutKeys?: string
52
+ }) {
53
+ return <Badge>{parseShortcutKeys({ shortcutKeys })}</Badge>
54
+ }
55
+
56
+ /**
57
+ * Button component for setting text alignment in a Tiptap editor.
58
+ *
59
+ * For custom button implementations, use the `useTextAlign` hook instead.
60
+ */
61
+ export const TextAlignButton = forwardRef<
62
+ HTMLButtonElement,
63
+ TextAlignButtonProps
64
+ >(
65
+ (
66
+ {
67
+ editor: providedEditor,
68
+ align,
69
+ text,
70
+ hideWhenUnavailable = false,
71
+ onAligned,
72
+ showShortcut = false,
73
+ onClick,
74
+ icon: CustomIcon,
75
+ children,
76
+ ...buttonProps
77
+ },
78
+ ref
79
+ ) => {
80
+ const { editor } = useTiptapEditor(providedEditor)
81
+ const {
82
+ isVisible,
83
+ handleTextAlign,
84
+ label,
85
+ canAlign,
86
+ isActive,
87
+ Icon,
88
+ shortcutKeys,
89
+ } = useTextAlign({
90
+ editor,
91
+ align,
92
+ hideWhenUnavailable,
93
+ onAligned,
94
+ })
95
+
96
+ const handleClick = useCallback(
97
+ (event: React.MouseEvent<HTMLButtonElement>) => {
98
+ onClick?.(event)
99
+ if (event.defaultPrevented) return
100
+ handleTextAlign()
101
+ },
102
+ [handleTextAlign, onClick]
103
+ )
104
+
105
+ if (!isVisible) {
106
+ return null
107
+ }
108
+
109
+ const RenderIcon = CustomIcon ?? Icon
110
+
111
+ return (
112
+ <Button
113
+ type="button"
114
+ disabled={!canAlign}
115
+ variant="ghost"
116
+ data-active-state={isActive ? "on" : "off"}
117
+ data-disabled={!canAlign}
118
+ role="button"
119
+ tabIndex={-1}
120
+ aria-label={label}
121
+ aria-pressed={isActive}
122
+ tooltip={label}
123
+ onClick={handleClick}
124
+ {...buttonProps}
125
+ ref={ref}
126
+ >
127
+ {children ?? (
128
+ <>
129
+ <RenderIcon className="tiptap-button-icon" />
130
+ {text && <span className="tiptap-button-text">{text}</span>}
131
+ {showShortcut && (
132
+ <TextAlignShortcutBadge
133
+ align={align}
134
+ shortcutKeys={shortcutKeys}
135
+ />
136
+ )}
137
+ </>
138
+ )}
139
+ </Button>
140
+ )
141
+ }
142
+ )
143
+
144
+ TextAlignButton.displayName = "TextAlignButton"