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,69 @@
1
+ import type { Editor } from "@tiptap/react"
2
+ import { useWindowSize } from "@/hooks/use-window-size"
3
+ import { useBodyRect } from "@/hooks/use-element-rect"
4
+ import { useEffect } from "react"
5
+
6
+ export interface CursorVisibilityOptions {
7
+ /**
8
+ * The Tiptap editor instance
9
+ */
10
+ editor?: Editor | null
11
+ /**
12
+ * Reference to the toolbar element that may obscure the cursor
13
+ */
14
+ overlayHeight?: number
15
+ }
16
+
17
+ /**
18
+ * Custom hook that ensures the cursor remains visible when typing in a Tiptap editor.
19
+ * Automatically scrolls the window when the cursor would be hidden by the toolbar.
20
+ *
21
+ * @param options.editor The Tiptap editor instance
22
+ * @param options.overlayHeight Toolbar height to account for
23
+ * @returns The bounding rect of the body
24
+ */
25
+ export function useCursorVisibility({
26
+ editor,
27
+ overlayHeight = 0,
28
+ }: CursorVisibilityOptions) {
29
+ const { height: windowHeight } = useWindowSize()
30
+ const rect = useBodyRect({
31
+ enabled: true,
32
+ throttleMs: 100,
33
+ useResizeObserver: true,
34
+ })
35
+
36
+ useEffect(() => {
37
+ const ensureCursorVisibility = () => {
38
+ if (!editor) return
39
+
40
+ const { state, view } = editor
41
+ if (!view.hasFocus()) return
42
+
43
+ // Get current cursor position coordinates
44
+ const { from } = state.selection
45
+ const cursorCoords = view.coordsAtPos(from)
46
+
47
+ if (windowHeight < rect.height && cursorCoords) {
48
+ const availableSpace = windowHeight - cursorCoords.top
49
+
50
+ // If the cursor is hidden behind the overlay or offscreen, scroll it into view
51
+ if (availableSpace < overlayHeight) {
52
+ const targetCursorY = Math.max(windowHeight / 2, overlayHeight)
53
+ const currentScrollY = window.scrollY
54
+ const cursorAbsoluteY = cursorCoords.top + currentScrollY
55
+ const newScrollY = cursorAbsoluteY - targetCursorY
56
+
57
+ window.scrollTo({
58
+ top: Math.max(0, newScrollY),
59
+ behavior: "smooth",
60
+ })
61
+ }
62
+ }
63
+ }
64
+
65
+ ensureCursorVisibility()
66
+ }, [editor, overlayHeight, windowHeight, rect.height])
67
+
68
+ return rect
69
+ }
@@ -0,0 +1,166 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useState } from "react"
4
+ import { useThrottledCallback } from "@/hooks/use-throttled-callback"
5
+
6
+ export type RectState = Omit<DOMRect, "toJSON">
7
+
8
+ export interface ElementRectOptions {
9
+ /**
10
+ * The element to track. Can be an Element, ref, or selector string.
11
+ * Defaults to document.body if not provided.
12
+ */
13
+ element?: Element | React.RefObject<Element> | string | null
14
+ /**
15
+ * Whether to enable rect tracking
16
+ */
17
+ enabled?: boolean
18
+ /**
19
+ * Throttle delay in milliseconds for rect updates
20
+ */
21
+ throttleMs?: number
22
+ /**
23
+ * Whether to use ResizeObserver for more accurate tracking
24
+ */
25
+ useResizeObserver?: boolean
26
+ }
27
+
28
+ const initialRect: RectState = {
29
+ x: 0,
30
+ y: 0,
31
+ width: 0,
32
+ height: 0,
33
+ top: 0,
34
+ right: 0,
35
+ bottom: 0,
36
+ left: 0,
37
+ }
38
+
39
+ const isSSR = typeof window === "undefined"
40
+ const hasResizeObserver = !isSSR && typeof ResizeObserver !== "undefined"
41
+
42
+ /**
43
+ * Helper function to check if code is running on client side
44
+ */
45
+ const isClientSide = (): boolean => !isSSR
46
+
47
+ /**
48
+ * Custom hook that tracks an element's bounding rectangle and updates on resize, scroll, etc.
49
+ *
50
+ * @param options Configuration options for element rect tracking
51
+ * @returns The current bounding rectangle of the element
52
+ */
53
+ export function useElementRect({
54
+ element,
55
+ enabled = true,
56
+ throttleMs = 100,
57
+ useResizeObserver = true,
58
+ }: ElementRectOptions = {}): RectState {
59
+ const [rect, setRect] = useState<RectState>(initialRect)
60
+
61
+ const getTargetElement = useCallback((): Element | null => {
62
+ if (!enabled || !isClientSide()) return null
63
+
64
+ if (!element) {
65
+ return document.body
66
+ }
67
+
68
+ if (typeof element === "string") {
69
+ return document.querySelector(element)
70
+ }
71
+
72
+ if ("current" in element) {
73
+ return element.current
74
+ }
75
+
76
+ return element
77
+ }, [element, enabled])
78
+
79
+ const updateRect = useThrottledCallback(
80
+ () => {
81
+ if (!enabled || !isClientSide()) return
82
+
83
+ const targetElement = getTargetElement()
84
+ if (!targetElement) {
85
+ setRect(initialRect)
86
+ return
87
+ }
88
+
89
+ const newRect = targetElement.getBoundingClientRect()
90
+ setRect({
91
+ x: newRect.x,
92
+ y: newRect.y,
93
+ width: newRect.width,
94
+ height: newRect.height,
95
+ top: newRect.top,
96
+ right: newRect.right,
97
+ bottom: newRect.bottom,
98
+ left: newRect.left,
99
+ })
100
+ },
101
+ throttleMs,
102
+ [enabled, getTargetElement],
103
+ { leading: true, trailing: true }
104
+ )
105
+
106
+ useEffect(() => {
107
+ if (!enabled || !isClientSide()) {
108
+ setRect(initialRect)
109
+ return
110
+ }
111
+
112
+ const targetElement = getTargetElement()
113
+ if (!targetElement) return
114
+
115
+ updateRect()
116
+
117
+ const cleanup: (() => void)[] = []
118
+
119
+ if (useResizeObserver && hasResizeObserver) {
120
+ const resizeObserver = new ResizeObserver(() => {
121
+ window.requestAnimationFrame(updateRect)
122
+ })
123
+ resizeObserver.observe(targetElement)
124
+ cleanup.push(() => resizeObserver.disconnect())
125
+ }
126
+
127
+ const handleUpdate = () => updateRect()
128
+
129
+ window.addEventListener("scroll", handleUpdate, true)
130
+ window.addEventListener("resize", handleUpdate, true)
131
+
132
+ cleanup.push(() => {
133
+ window.removeEventListener("scroll", handleUpdate)
134
+ window.removeEventListener("resize", handleUpdate)
135
+ })
136
+
137
+ return () => {
138
+ cleanup.forEach((fn) => fn())
139
+ setRect(initialRect)
140
+ }
141
+ }, [enabled, getTargetElement, updateRect, useResizeObserver])
142
+
143
+ return rect
144
+ }
145
+
146
+ /**
147
+ * Convenience hook for tracking document.body rect
148
+ */
149
+ export function useBodyRect(
150
+ options: Omit<ElementRectOptions, "element"> = {}
151
+ ): RectState {
152
+ return useElementRect({
153
+ ...options,
154
+ element: isClientSide() ? document.body : null,
155
+ })
156
+ }
157
+
158
+ /**
159
+ * Convenience hook for tracking a ref element's rect
160
+ */
161
+ export function useRefRect<T extends Element>(
162
+ ref: React.RefObject<T>,
163
+ options: Omit<ElementRectOptions, "element"> = {}
164
+ ): RectState {
165
+ return useElementRect({ ...options, element: ref })
166
+ }
@@ -0,0 +1,35 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ type BreakpointMode = "min" | "max"
4
+
5
+ /**
6
+ * Hook to detect whether the current viewport matches a given breakpoint rule.
7
+ * Example:
8
+ * useIsBreakpoint("max", 768) // true when width < 768
9
+ * useIsBreakpoint("min", 1024) // true when width >= 1024
10
+ */
11
+ export function useIsBreakpoint(
12
+ mode: BreakpointMode = "max",
13
+ breakpoint = 768
14
+ ) {
15
+ const [matches, setMatches] = useState<boolean | undefined>(undefined)
16
+
17
+ useEffect(() => {
18
+ const query =
19
+ mode === "min"
20
+ ? `(min-width: ${breakpoint}px)`
21
+ : `(max-width: ${breakpoint - 1}px)`
22
+
23
+ const mql = window.matchMedia(query)
24
+ const onChange = (e: MediaQueryListEvent) => setMatches(e.matches)
25
+
26
+ // Set initial value
27
+ setMatches(mql.matches)
28
+
29
+ // Add listener
30
+ mql.addEventListener("change", onChange)
31
+ return () => mql.removeEventListener("change", onChange)
32
+ }, [mode, breakpoint])
33
+
34
+ return !!matches
35
+ }
@@ -0,0 +1,204 @@
1
+ import type { Editor } from "@tiptap/react"
2
+ import { useEffect, useState } from "react"
3
+
4
+ type Orientation = "horizontal" | "vertical" | "both"
5
+
6
+ interface MenuNavigationOptions<T> {
7
+ /**
8
+ * The Tiptap editor instance, if using with a Tiptap editor.
9
+ */
10
+ editor?: Editor | null
11
+ /**
12
+ * Reference to the container element for handling keyboard events.
13
+ */
14
+ containerRef?: React.RefObject<HTMLElement | null>
15
+ /**
16
+ * Search query that affects the selected item.
17
+ */
18
+ query?: string
19
+ /**
20
+ * Array of items to navigate through.
21
+ */
22
+ items: T[]
23
+ /**
24
+ * Callback fired when an item is selected.
25
+ */
26
+ onSelect?: (item: T) => void
27
+ /**
28
+ * Callback fired when the menu should close.
29
+ */
30
+ onClose?: () => void
31
+ /**
32
+ * The navigation orientation of the menu.
33
+ * @default "vertical"
34
+ */
35
+ orientation?: Orientation
36
+ /**
37
+ * Whether to automatically select the first item when the menu opens.
38
+ * @default true
39
+ */
40
+ autoSelectFirstItem?: boolean
41
+ /**
42
+ * Whether Tab/Shift+Tab should loop through the items.
43
+ * Disable this for ARIA toolbars, where Tab must leave the toolbar.
44
+ * @default true
45
+ */
46
+ loopOnTab?: boolean
47
+ }
48
+
49
+ /**
50
+ * Hook that implements keyboard navigation for dropdown menus and command palettes.
51
+ *
52
+ * Handles arrow keys, tab, home/end, enter for selection, and escape to close.
53
+ * Works with both Tiptap editors and regular DOM elements.
54
+ *
55
+ * @param options - Configuration options for the menu navigation
56
+ * @returns Object containing the selected index and a setter function
57
+ */
58
+ export function useMenuNavigation<T>({
59
+ editor,
60
+ containerRef,
61
+ query,
62
+ items,
63
+ onSelect,
64
+ onClose,
65
+ orientation = "vertical",
66
+ autoSelectFirstItem = true,
67
+ loopOnTab = true,
68
+ }: MenuNavigationOptions<T>) {
69
+ const [selectedIndex, setSelectedIndex] = useState<number>(
70
+ autoSelectFirstItem ? 0 : -1
71
+ )
72
+
73
+ useEffect(() => {
74
+ const handleKeyboardNavigation = (event: KeyboardEvent) => {
75
+ if (!items.length) return false
76
+
77
+ const moveNext = () =>
78
+ setSelectedIndex((currentIndex) => {
79
+ if (currentIndex === -1) return 0
80
+ return (currentIndex + 1) % items.length
81
+ })
82
+
83
+ const movePrev = () =>
84
+ setSelectedIndex((currentIndex) => {
85
+ if (currentIndex === -1) return items.length - 1
86
+ return (currentIndex - 1 + items.length) % items.length
87
+ })
88
+
89
+ switch (event.key) {
90
+ case "ArrowUp": {
91
+ if (orientation === "horizontal") return false
92
+ event.preventDefault()
93
+ movePrev()
94
+ return true
95
+ }
96
+
97
+ case "ArrowDown": {
98
+ if (orientation === "horizontal") return false
99
+ event.preventDefault()
100
+ moveNext()
101
+ return true
102
+ }
103
+
104
+ case "ArrowLeft": {
105
+ if (orientation === "vertical") return false
106
+ event.preventDefault()
107
+ movePrev()
108
+ return true
109
+ }
110
+
111
+ case "ArrowRight": {
112
+ if (orientation === "vertical") return false
113
+ event.preventDefault()
114
+ moveNext()
115
+ return true
116
+ }
117
+
118
+ case "Tab": {
119
+ if (!loopOnTab) return false
120
+ event.preventDefault()
121
+ if (event.shiftKey) {
122
+ movePrev()
123
+ } else {
124
+ moveNext()
125
+ }
126
+ return true
127
+ }
128
+
129
+ case "Home": {
130
+ event.preventDefault()
131
+ setSelectedIndex(0)
132
+ return true
133
+ }
134
+
135
+ case "End": {
136
+ event.preventDefault()
137
+ setSelectedIndex(items.length - 1)
138
+ return true
139
+ }
140
+
141
+ case "Enter": {
142
+ if (event.isComposing) return false
143
+ event.preventDefault()
144
+ if (selectedIndex !== -1 && items[selectedIndex]) {
145
+ onSelect?.(items[selectedIndex])
146
+ }
147
+ return true
148
+ }
149
+
150
+ case "Escape": {
151
+ if (!onClose) return false
152
+ event.preventDefault()
153
+ onClose?.()
154
+ return true
155
+ }
156
+
157
+ default:
158
+ return false
159
+ }
160
+ }
161
+
162
+ let targetElement: HTMLElement | null = null
163
+
164
+ if (editor) {
165
+ targetElement = editor.view.dom
166
+ } else if (containerRef?.current) {
167
+ targetElement = containerRef.current
168
+ }
169
+
170
+ if (targetElement) {
171
+ targetElement.addEventListener("keydown", handleKeyboardNavigation, true)
172
+
173
+ return () => {
174
+ targetElement?.removeEventListener(
175
+ "keydown",
176
+ handleKeyboardNavigation,
177
+ true
178
+ )
179
+ }
180
+ }
181
+
182
+ return undefined
183
+ }, [
184
+ editor,
185
+ containerRef,
186
+ items,
187
+ selectedIndex,
188
+ onSelect,
189
+ onClose,
190
+ orientation,
191
+ loopOnTab,
192
+ ])
193
+
194
+ useEffect(() => {
195
+ if (query) {
196
+ setSelectedIndex(autoSelectFirstItem ? 0 : -1)
197
+ }
198
+ }, [query, autoSelectFirstItem])
199
+
200
+ return {
201
+ selectedIndex: items.length ? selectedIndex : undefined,
202
+ setSelectedIndex,
203
+ }
204
+ }
@@ -0,0 +1,75 @@
1
+ import type { RefObject } from "react"
2
+ import { useEffect, useState } from "react"
3
+
4
+ type ScrollTarget = RefObject<HTMLElement> | Window | null | undefined
5
+ type EventTargetWithScroll = Window | HTMLElement | Document
6
+
7
+ interface UseScrollingOptions {
8
+ debounce?: number
9
+ fallbackToDocument?: boolean
10
+ }
11
+
12
+ export function useScrolling(
13
+ target?: ScrollTarget,
14
+ options: UseScrollingOptions = {}
15
+ ): boolean {
16
+ const { debounce = 150, fallbackToDocument = true } = options
17
+ const [isScrolling, setIsScrolling] = useState(false)
18
+
19
+ useEffect(() => {
20
+ // Resolve element or window
21
+ const element: EventTargetWithScroll =
22
+ target && typeof Window !== "undefined" && target instanceof Window
23
+ ? target
24
+ : ((target as RefObject<HTMLElement>)?.current ?? window)
25
+
26
+ // Mobile: fallback to document when using window
27
+ const eventTarget: EventTargetWithScroll =
28
+ fallbackToDocument &&
29
+ element === window &&
30
+ typeof document !== "undefined"
31
+ ? document
32
+ : element
33
+
34
+ const on = (
35
+ el: EventTargetWithScroll,
36
+ event: string,
37
+ handler: EventListener
38
+ ) => el.addEventListener(event, handler, true)
39
+
40
+ const off = (
41
+ el: EventTargetWithScroll,
42
+ event: string,
43
+ handler: EventListener
44
+ ) => el.removeEventListener(event, handler)
45
+
46
+ let timeout: ReturnType<typeof setTimeout>
47
+ const supportsScrollEnd = element === window && "onscrollend" in window
48
+
49
+ const handleScroll: EventListener = () => {
50
+ if (!isScrolling) setIsScrolling(true)
51
+
52
+ if (!supportsScrollEnd) {
53
+ clearTimeout(timeout)
54
+ timeout = setTimeout(() => setIsScrolling(false), debounce)
55
+ }
56
+ }
57
+
58
+ const handleScrollEnd: EventListener = () => setIsScrolling(false)
59
+
60
+ on(eventTarget, "scroll", handleScroll)
61
+ if (supportsScrollEnd) {
62
+ on(eventTarget, "scrollend", handleScrollEnd)
63
+ }
64
+
65
+ return () => {
66
+ off(eventTarget, "scroll", handleScroll)
67
+ if (supportsScrollEnd) {
68
+ off(eventTarget, "scrollend", handleScrollEnd)
69
+ }
70
+ clearTimeout(timeout)
71
+ }
72
+ }, [target, debounce, fallbackToDocument, isScrolling])
73
+
74
+ return isScrolling
75
+ }
@@ -0,0 +1,48 @@
1
+ import throttle from "lodash.throttle"
2
+
3
+ import { useUnmount } from "@/hooks/use-unmount"
4
+ import { useMemo } from "react"
5
+
6
+ interface ThrottleSettings {
7
+ leading?: boolean | undefined
8
+ trailing?: boolean | undefined
9
+ }
10
+
11
+ const defaultOptions: ThrottleSettings = {
12
+ leading: false,
13
+ trailing: true,
14
+ }
15
+
16
+ /**
17
+ * A hook that returns a throttled callback function.
18
+ *
19
+ * @param fn The function to throttle
20
+ * @param wait The time in ms to wait before calling the function
21
+ * @param dependencies The dependencies to watch for changes
22
+ * @param options The throttle options
23
+ */
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ export function useThrottledCallback<T extends (...args: any[]) => any>(
26
+ fn: T,
27
+ wait = 250,
28
+ dependencies: React.DependencyList = [],
29
+ options: ThrottleSettings = defaultOptions
30
+ ): {
31
+ (this: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T>
32
+ cancel: () => void
33
+ flush: () => void
34
+ } {
35
+ const handler = useMemo(
36
+ () => throttle<T>(fn, wait, options),
37
+ // eslint-disable-next-line react-hooks/exhaustive-deps
38
+ dependencies
39
+ )
40
+
41
+ useUnmount(() => {
42
+ handler.cancel()
43
+ })
44
+
45
+ return handler
46
+ }
47
+
48
+ export default useThrottledCallback
@@ -0,0 +1,69 @@
1
+ import type { Editor } from "@tiptap/react"
2
+ import { useCurrentEditor, useEditorState } from "@tiptap/react"
3
+ import { useEffect, useState } from "react"
4
+
5
+ function getActivePageEditor(editor: Editor): Editor | null {
6
+ const storage = editor.storage as unknown as Record<string, unknown>
7
+ const pages = storage.pages as { activeEditor?: Editor | null } | undefined
8
+ if (!pages || !("activeEditor" in pages)) return null
9
+ return pages.activeEditor ?? null
10
+ }
11
+
12
+ export function useTiptapEditor(providedEditor?: Editor | null): {
13
+ editor: Editor | null
14
+ editorState?: Editor["state"]
15
+ canCommand?: Editor["can"]
16
+ } {
17
+ const { editor: coreEditor } = useCurrentEditor()
18
+ const mainEditor = providedEditor ?? coreEditor
19
+
20
+ const [storageEditor, setStorageEditor] = useState<Editor | null>(null)
21
+
22
+ useEffect(() => {
23
+ if (!mainEditor) {
24
+ setStorageEditor(null)
25
+ return
26
+ }
27
+
28
+ const updateHandler = () =>
29
+ setStorageEditor(getActivePageEditor(mainEditor))
30
+
31
+ updateHandler()
32
+
33
+ mainEditor.on("update", updateHandler)
34
+ mainEditor.on("selectionUpdate", updateHandler)
35
+
36
+ return () => {
37
+ mainEditor.off("update", updateHandler)
38
+ mainEditor.off("selectionUpdate", updateHandler)
39
+ }
40
+ }, [mainEditor])
41
+
42
+ useEffect(() => {
43
+ if (!storageEditor) return
44
+
45
+ const handleDestroy = () => setStorageEditor(null)
46
+
47
+ storageEditor.on("destroy", handleDestroy)
48
+ return () => {
49
+ storageEditor.off("destroy", handleDestroy)
50
+ }
51
+ }, [storageEditor])
52
+
53
+ const editorState = useEditorState({
54
+ editor: storageEditor ?? mainEditor,
55
+ selector(context) {
56
+ if (!context.editor) {
57
+ return { editor: null, editorState: undefined, canCommand: undefined }
58
+ }
59
+
60
+ return {
61
+ editor: context.editor,
62
+ editorState: context.editor.state,
63
+ canCommand: context.editor.can,
64
+ }
65
+ },
66
+ })
67
+
68
+ return editorState ?? { editor: null }
69
+ }
@@ -0,0 +1,21 @@
1
+ import { useRef, useEffect } from "react"
2
+
3
+ /**
4
+ * Hook that executes a callback when the component unmounts.
5
+ *
6
+ * @param callback Function to be called on component unmount
7
+ */
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ export const useUnmount = (callback: (...args: Array<any>) => any) => {
10
+ const ref = useRef(callback)
11
+ ref.current = callback
12
+
13
+ useEffect(
14
+ () => () => {
15
+ ref.current()
16
+ },
17
+ []
18
+ )
19
+ }
20
+
21
+ export default useUnmount