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,231 @@
1
+ import { forwardRef, useMemo, useRef, useState } from "react"
2
+ import { type Editor } from "@tiptap/react"
3
+
4
+ // --- Hooks ---
5
+ import { useMenuNavigation } from "@/hooks/use-menu-navigation"
6
+ import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
7
+ import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
8
+
9
+ // --- Icons ---
10
+ import { BanIcon } from "@/components/tiptap-icons/ban-icon"
11
+ import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
12
+
13
+ // --- UI Primitives ---
14
+ import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
15
+ import { Button } from "@/components/tiptap-ui-primitive/button"
16
+ import {
17
+ Popover,
18
+ PopoverTrigger,
19
+ PopoverContent,
20
+ } from "@/components/tiptap-ui-primitive/popover"
21
+ import { Separator } from "@/components/tiptap-ui-primitive/separator"
22
+ import {
23
+ Card,
24
+ CardBody,
25
+ CardItemGroup,
26
+ } from "@/components/tiptap-ui-primitive/card"
27
+
28
+ // --- Tiptap UI ---
29
+ import type {
30
+ HighlightColor,
31
+ UseColorHighlightConfig,
32
+ } from "@/components/tiptap-ui/color-highlight-button"
33
+ import {
34
+ ColorHighlightButton,
35
+ pickHighlightColorsByValue,
36
+ useColorHighlight,
37
+ } from "@/components/tiptap-ui/color-highlight-button"
38
+ import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group"
39
+
40
+ export interface ColorHighlightPopoverContentProps {
41
+ /**
42
+ * The Tiptap editor instance.
43
+ */
44
+ editor?: Editor | null
45
+ /**
46
+ * Optional colors to use in the highlight popover.
47
+ * If not provided, defaults to a predefined set of colors.
48
+ */
49
+ colors?: HighlightColor[]
50
+ /**
51
+ * When true, uses the actual color value (colorValue) instead of CSS variable (value).
52
+ * @default false
53
+ */
54
+ useColorValue?: boolean
55
+ }
56
+
57
+ export interface ColorHighlightPopoverProps
58
+ extends
59
+ Omit<ButtonProps, "type">,
60
+ Pick<
61
+ UseColorHighlightConfig,
62
+ "editor" | "hideWhenUnavailable" | "onApplied"
63
+ > {
64
+ /**
65
+ * Optional colors to use in the highlight popover.
66
+ * If not provided, defaults to a predefined set of colors.
67
+ */
68
+ colors?: HighlightColor[]
69
+ /**
70
+ * When true, uses the actual color value (colorValue) instead of CSS variable (value).
71
+ * @default false
72
+ */
73
+ useColorValue?: boolean
74
+ }
75
+
76
+ export const ColorHighlightPopoverButton = forwardRef<
77
+ HTMLButtonElement,
78
+ ButtonProps
79
+ >(({ className, children, ...props }, ref) => (
80
+ <Button
81
+ type="button"
82
+ className={className}
83
+ variant="ghost"
84
+ data-appearance="default"
85
+ role="button"
86
+ tabIndex={-1}
87
+ aria-label="Highlight text"
88
+ tooltip="Highlight"
89
+ ref={ref}
90
+ {...props}
91
+ >
92
+ {children ?? <HighlighterIcon className="tiptap-button-icon" />}
93
+ </Button>
94
+ ))
95
+
96
+ ColorHighlightPopoverButton.displayName = "ColorHighlightPopoverButton"
97
+
98
+ export function ColorHighlightPopoverContent({
99
+ editor,
100
+ colors = pickHighlightColorsByValue([
101
+ "var(--tt-color-highlight-green)",
102
+ "var(--tt-color-highlight-blue)",
103
+ "var(--tt-color-highlight-red)",
104
+ "var(--tt-color-highlight-purple)",
105
+ "var(--tt-color-highlight-yellow)",
106
+ ]),
107
+ useColorValue = false,
108
+ }: ColorHighlightPopoverContentProps) {
109
+ const { handleRemoveHighlight } = useColorHighlight({ editor })
110
+ const isMobile = useIsBreakpoint()
111
+ const containerRef = useRef<HTMLDivElement>(null)
112
+
113
+ const menuItems = useMemo(
114
+ () => [...colors, { label: "Remove highlight", value: "none" }],
115
+ [colors]
116
+ )
117
+
118
+ const { selectedIndex } = useMenuNavigation({
119
+ containerRef,
120
+ items: menuItems,
121
+ orientation: "both",
122
+ onSelect: (item) => {
123
+ if (!containerRef.current) return false
124
+ const highlightedElement = containerRef.current.querySelector(
125
+ '[data-highlighted="true"]'
126
+ ) as HTMLElement
127
+ if (highlightedElement) highlightedElement.click()
128
+ if (item.value === "none") handleRemoveHighlight()
129
+ return true
130
+ },
131
+ autoSelectFirstItem: false,
132
+ })
133
+
134
+ return (
135
+ <Card
136
+ ref={containerRef}
137
+ tabIndex={0}
138
+ style={isMobile ? { boxShadow: "none", border: 0 } : {}}
139
+ >
140
+ <CardBody style={isMobile ? { padding: 0 } : {}}>
141
+ <CardItemGroup orientation="horizontal">
142
+ <ButtonGroup>
143
+ {colors.map((color, index) => (
144
+ <ButtonGroup key={color.value}>
145
+ <ColorHighlightButton
146
+ editor={editor}
147
+ highlightColor={
148
+ useColorValue ? color.colorValue : color.value
149
+ }
150
+ tooltip={color.label}
151
+ aria-label={`${color.label} highlight color`}
152
+ tabIndex={index === selectedIndex ? 0 : -1}
153
+ data-highlighted={selectedIndex === index}
154
+ useColorValue={useColorValue}
155
+ />
156
+ </ButtonGroup>
157
+ ))}
158
+ </ButtonGroup>
159
+ <Separator />
160
+ <ButtonGroup>
161
+ <Button
162
+ onClick={handleRemoveHighlight}
163
+ aria-label="Remove highlight"
164
+ tooltip="Remove highlight"
165
+ tabIndex={selectedIndex === colors.length ? 0 : -1}
166
+ type="button"
167
+ role="menuitem"
168
+ variant="ghost"
169
+ data-highlighted={selectedIndex === colors.length}
170
+ >
171
+ <BanIcon className="tiptap-button-icon" />
172
+ </Button>
173
+ </ButtonGroup>
174
+ </CardItemGroup>
175
+ </CardBody>
176
+ </Card>
177
+ )
178
+ }
179
+
180
+ export function ColorHighlightPopover({
181
+ editor: providedEditor,
182
+ colors = pickHighlightColorsByValue([
183
+ "var(--tt-color-highlight-green)",
184
+ "var(--tt-color-highlight-blue)",
185
+ "var(--tt-color-highlight-red)",
186
+ "var(--tt-color-highlight-purple)",
187
+ "var(--tt-color-highlight-yellow)",
188
+ ]),
189
+ hideWhenUnavailable = false,
190
+ useColorValue = false,
191
+ onApplied,
192
+ ...props
193
+ }: ColorHighlightPopoverProps) {
194
+ const { editor } = useTiptapEditor(providedEditor)
195
+ const [isOpen, setIsOpen] = useState(false)
196
+ const { isVisible, canColorHighlight, isActive, label, Icon } =
197
+ useColorHighlight({
198
+ editor,
199
+ hideWhenUnavailable,
200
+ onApplied,
201
+ })
202
+
203
+ if (!isVisible) return null
204
+
205
+ return (
206
+ <Popover open={isOpen} onOpenChange={setIsOpen}>
207
+ <PopoverTrigger asChild>
208
+ <ColorHighlightPopoverButton
209
+ disabled={!canColorHighlight}
210
+ data-active-state={isActive ? "on" : "off"}
211
+ data-disabled={!canColorHighlight}
212
+ aria-pressed={isActive}
213
+ aria-label={label}
214
+ tooltip={label}
215
+ {...props}
216
+ >
217
+ <Icon className="tiptap-button-icon" />
218
+ </ColorHighlightPopoverButton>
219
+ </PopoverTrigger>
220
+ <PopoverContent aria-label="Highlight colors">
221
+ <ColorHighlightPopoverContent
222
+ editor={editor}
223
+ colors={colors}
224
+ useColorValue={useColorValue}
225
+ />
226
+ </PopoverContent>
227
+ </Popover>
228
+ )
229
+ }
230
+
231
+ export default ColorHighlightPopover
@@ -0,0 +1 @@
1
+ export * from "./color-highlight-popover"
@@ -0,0 +1,124 @@
1
+ import { forwardRef, useCallback } from "react"
2
+
3
+ // --- Lib ---
4
+ import { parseShortcutKeys } from "@/lib/tiptap-utils"
5
+
6
+ // --- Tiptap UI ---
7
+ import type {
8
+ Level,
9
+ UseHeadingConfig,
10
+ } from "@/components/tiptap-ui/heading-button"
11
+ import {
12
+ HEADING_SHORTCUT_KEYS,
13
+ useHeading,
14
+ } from "@/components/tiptap-ui/heading-button"
15
+
16
+ // --- UI Primitives ---
17
+ import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
18
+ import { Button } from "@/components/tiptap-ui-primitive/button"
19
+ import { Badge } from "@/components/tiptap-ui-primitive/badge"
20
+ import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
21
+
22
+ export interface HeadingButtonProps
23
+ extends Omit<ButtonProps, "type">, UseHeadingConfig {
24
+ /**
25
+ * Optional text to display alongside the icon.
26
+ */
27
+ text?: string
28
+ /**
29
+ * Optional show shortcut keys in the button.
30
+ * @default false
31
+ */
32
+ showShortcut?: boolean
33
+ }
34
+
35
+ export function HeadingShortcutBadge({
36
+ level,
37
+ shortcutKeys = HEADING_SHORTCUT_KEYS[level],
38
+ }: {
39
+ level: Level
40
+ shortcutKeys?: string
41
+ }) {
42
+ return <Badge>{parseShortcutKeys({ shortcutKeys })}</Badge>
43
+ }
44
+
45
+ /**
46
+ * Button component for toggling heading in a Tiptap editor.
47
+ *
48
+ * For custom button implementations, use the `useHeading` hook instead.
49
+ */
50
+ export const HeadingButton = forwardRef<HTMLButtonElement, HeadingButtonProps>(
51
+ (
52
+ {
53
+ editor: providedEditor,
54
+ level,
55
+ text,
56
+ hideWhenUnavailable = false,
57
+ onToggled,
58
+ showShortcut = false,
59
+ onClick,
60
+ children,
61
+ ...buttonProps
62
+ },
63
+ ref
64
+ ) => {
65
+ const { editor } = useTiptapEditor(providedEditor)
66
+ const {
67
+ isVisible,
68
+ canToggle,
69
+ isActive,
70
+ handleToggle,
71
+ label,
72
+ Icon,
73
+ shortcutKeys,
74
+ } = useHeading({
75
+ editor,
76
+ level,
77
+ hideWhenUnavailable,
78
+ onToggled,
79
+ })
80
+
81
+ const handleClick = useCallback(
82
+ (event: React.MouseEvent<HTMLButtonElement>) => {
83
+ onClick?.(event)
84
+ if (event.defaultPrevented) return
85
+ handleToggle()
86
+ },
87
+ [handleToggle, onClick]
88
+ )
89
+
90
+ if (!isVisible) {
91
+ return null
92
+ }
93
+
94
+ return (
95
+ <Button
96
+ type="button"
97
+ variant="ghost"
98
+ data-active-state={isActive ? "on" : "off"}
99
+ role="button"
100
+ tabIndex={-1}
101
+ disabled={!canToggle}
102
+ data-disabled={!canToggle}
103
+ aria-label={label}
104
+ aria-pressed={isActive}
105
+ tooltip={label}
106
+ onClick={handleClick}
107
+ {...buttonProps}
108
+ ref={ref}
109
+ >
110
+ {children ?? (
111
+ <>
112
+ <Icon className="tiptap-button-icon" />
113
+ {text && <span className="tiptap-button-text">{text}</span>}
114
+ {showShortcut && (
115
+ <HeadingShortcutBadge level={level} shortcutKeys={shortcutKeys} />
116
+ )}
117
+ </>
118
+ )}
119
+ </Button>
120
+ )
121
+ }
122
+ )
123
+
124
+ HeadingButton.displayName = "HeadingButton"
@@ -0,0 +1,2 @@
1
+ export * from "./heading-button"
2
+ export * from "./use-heading"
@@ -0,0 +1,348 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useState } from "react"
4
+ import { type Editor } from "@tiptap/react"
5
+ import { NodeSelection, TextSelection } from "@tiptap/pm/state"
6
+
7
+ // --- Hooks ---
8
+ import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
9
+
10
+ // --- Lib ---
11
+ import {
12
+ findNodePosition,
13
+ getSelectedBlockNodes,
14
+ isNodeInSchema,
15
+ isNodeTypeSelected,
16
+ isValidPosition,
17
+ selectionWithinConvertibleTypes,
18
+ } from "@/lib/tiptap-utils"
19
+
20
+ // --- Icons ---
21
+ import { HeadingOneIcon } from "@/components/tiptap-icons/heading-one-icon"
22
+ import { HeadingTwoIcon } from "@/components/tiptap-icons/heading-two-icon"
23
+ import { HeadingThreeIcon } from "@/components/tiptap-icons/heading-three-icon"
24
+ import { HeadingFourIcon } from "@/components/tiptap-icons/heading-four-icon"
25
+ import { HeadingFiveIcon } from "@/components/tiptap-icons/heading-five-icon"
26
+ import { HeadingSixIcon } from "@/components/tiptap-icons/heading-six-icon"
27
+
28
+ export type Level = 1 | 2 | 3 | 4 | 5 | 6
29
+
30
+ /**
31
+ * Configuration for the heading functionality
32
+ */
33
+ export interface UseHeadingConfig {
34
+ /**
35
+ * The Tiptap editor instance.
36
+ */
37
+ editor?: Editor | null
38
+ /**
39
+ * The heading level.
40
+ */
41
+ level: Level
42
+ /**
43
+ * Whether the button should hide when heading is not available.
44
+ * @default false
45
+ */
46
+ hideWhenUnavailable?: boolean
47
+ /**
48
+ * Callback function called after a successful heading toggle.
49
+ */
50
+ onToggled?: () => void
51
+ }
52
+
53
+ export const headingIcons = {
54
+ 1: HeadingOneIcon,
55
+ 2: HeadingTwoIcon,
56
+ 3: HeadingThreeIcon,
57
+ 4: HeadingFourIcon,
58
+ 5: HeadingFiveIcon,
59
+ 6: HeadingSixIcon,
60
+ }
61
+
62
+ export const HEADING_SHORTCUT_KEYS: Record<Level, string> = {
63
+ 1: "ctrl+alt+1",
64
+ 2: "ctrl+alt+2",
65
+ 3: "ctrl+alt+3",
66
+ 4: "ctrl+alt+4",
67
+ 5: "ctrl+alt+5",
68
+ 6: "ctrl+alt+6",
69
+ }
70
+
71
+ /**
72
+ * Checks if heading can be toggled in the current editor state
73
+ */
74
+ export function canToggle(
75
+ editor: Editor | null,
76
+ level?: Level,
77
+ turnInto: boolean = true
78
+ ): boolean {
79
+ if (!editor || !editor.isEditable) return false
80
+ if (
81
+ !isNodeInSchema("heading", editor) ||
82
+ isNodeTypeSelected(editor, ["image"])
83
+ )
84
+ return false
85
+
86
+ if (!turnInto) {
87
+ return level
88
+ ? editor.can().setNode("heading", { level })
89
+ : editor.can().setNode("heading")
90
+ }
91
+
92
+ // Ensure selection is in nodes we're allowed to convert
93
+ if (
94
+ !selectionWithinConvertibleTypes(editor, [
95
+ "paragraph",
96
+ "heading",
97
+ "bulletList",
98
+ "orderedList",
99
+ "taskList",
100
+ "blockquote",
101
+ "codeBlock",
102
+ ])
103
+ )
104
+ return false
105
+
106
+ // Either we can set heading directly on the selection,
107
+ // or we can clear formatting/nodes to arrive at a heading.
108
+ return level
109
+ ? editor.can().setNode("heading", { level }) || editor.can().clearNodes()
110
+ : editor.can().setNode("heading") || editor.can().clearNodes()
111
+ }
112
+
113
+ /**
114
+ * Checks if heading is currently active
115
+ */
116
+ export function isHeadingActive(
117
+ editor: Editor | null,
118
+ level?: Level | Level[]
119
+ ): boolean {
120
+ if (!editor || !editor.isEditable) return false
121
+
122
+ if (Array.isArray(level)) {
123
+ return level.some((l) => editor.isActive("heading", { level: l }))
124
+ }
125
+
126
+ return level
127
+ ? editor.isActive("heading", { level })
128
+ : editor.isActive("heading")
129
+ }
130
+
131
+ /**
132
+ * Toggles heading in the editor
133
+ */
134
+ export function toggleHeading(
135
+ editor: Editor | null,
136
+ level: Level | Level[]
137
+ ): boolean {
138
+ if (!editor || !editor.isEditable) return false
139
+
140
+ const levels = Array.isArray(level) ? level : [level]
141
+ const toggleLevel = levels.find((l) => canToggle(editor, l))
142
+
143
+ if (!toggleLevel) return false
144
+
145
+ try {
146
+ const view = editor.view
147
+ let state = view.state
148
+ let tr = state.tr
149
+
150
+ const blocks = getSelectedBlockNodes(editor)
151
+
152
+ // In case a selection contains multiple blocks, we only allow
153
+ // toggling to node if there's exactly one block selected
154
+ // we also dont block the canToggle since it will fall back to the bottom logic
155
+ const isPossibleToTurnInto =
156
+ selectionWithinConvertibleTypes(editor, [
157
+ "paragraph",
158
+ "heading",
159
+ "bulletList",
160
+ "orderedList",
161
+ "taskList",
162
+ "blockquote",
163
+ "codeBlock",
164
+ ]) && blocks.length === 1
165
+
166
+ // No selection, find the the cursor position
167
+ if (
168
+ (state.selection.empty || state.selection instanceof TextSelection) &&
169
+ isPossibleToTurnInto
170
+ ) {
171
+ const pos = findNodePosition({
172
+ editor,
173
+ node: state.selection.$anchor.node(1),
174
+ })?.pos
175
+ if (!isValidPosition(pos)) return false
176
+
177
+ tr = tr.setSelection(NodeSelection.create(state.doc, pos))
178
+ view.dispatch(tr)
179
+ state = view.state
180
+ }
181
+
182
+ const selection = state.selection
183
+ let chain = editor.chain().focus()
184
+
185
+ // Handle NodeSelection
186
+ if (selection instanceof NodeSelection) {
187
+ const firstChild = selection.node.firstChild?.firstChild
188
+ const lastChild = selection.node.lastChild?.lastChild
189
+
190
+ const from = firstChild
191
+ ? selection.from + firstChild.nodeSize
192
+ : selection.from + 1
193
+
194
+ const to = lastChild
195
+ ? selection.to - lastChild.nodeSize
196
+ : selection.to - 1
197
+
198
+ const resolvedFrom = state.doc.resolve(from)
199
+ const resolvedTo = state.doc.resolve(to)
200
+
201
+ chain = chain
202
+ .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo))
203
+ .clearNodes()
204
+ }
205
+
206
+ const isActive = levels.some((l) =>
207
+ editor.isActive("heading", { level: l })
208
+ )
209
+
210
+ const toggle = isActive
211
+ ? chain.setNode("paragraph")
212
+ : chain.setNode("heading", { level: toggleLevel })
213
+
214
+ toggle.run()
215
+
216
+ editor.chain().focus().selectTextblockEnd().run()
217
+
218
+ return true
219
+ } catch {
220
+ return false
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Determines if the heading button should be shown
226
+ */
227
+ export function shouldShowButton(props: {
228
+ editor: Editor | null
229
+ level?: Level | Level[]
230
+ hideWhenUnavailable: boolean
231
+ }): boolean {
232
+ const { editor, level, hideWhenUnavailable } = props
233
+
234
+ if (!editor) return false
235
+
236
+ if (!hideWhenUnavailable) {
237
+ return true
238
+ }
239
+
240
+ if (!editor.isEditable) return false
241
+
242
+ if (!isNodeInSchema("heading", editor)) return false
243
+
244
+ if (!editor.isActive("code")) {
245
+ if (Array.isArray(level)) {
246
+ return level.some((l) => canToggle(editor, l))
247
+ }
248
+ return canToggle(editor, level)
249
+ }
250
+
251
+ return true
252
+ }
253
+
254
+ /**
255
+ * Custom hook that provides heading functionality for Tiptap editor
256
+ *
257
+ * @example
258
+ * ```tsx
259
+ * // Simple usage
260
+ * function MySimpleHeadingButton() {
261
+ * const { isVisible, isActive, handleToggle, Icon } = useHeading({ level: 1 })
262
+ *
263
+ * if (!isVisible) return null
264
+ *
265
+ * return (
266
+ * <button
267
+ * onClick={handleToggle}
268
+ * aria-pressed={isActive}
269
+ * >
270
+ * <Icon />
271
+ * Heading 1
272
+ * </button>
273
+ * )
274
+ * }
275
+ *
276
+ * // Advanced usage with configuration
277
+ * function MyAdvancedHeadingButton() {
278
+ * const { isVisible, isActive, handleToggle, label, Icon } = useHeading({
279
+ * level: 2,
280
+ * editor: myEditor,
281
+ * hideWhenUnavailable: true,
282
+ * onToggled: (isActive) => console.log('Heading toggled:', isActive)
283
+ * })
284
+ *
285
+ * if (!isVisible) return null
286
+ *
287
+ * return (
288
+ * <MyButton
289
+ * onClick={handleToggle}
290
+ * aria-label={label}
291
+ * aria-pressed={isActive}
292
+ * >
293
+ * <Icon />
294
+ * Toggle Heading 2
295
+ * </MyButton>
296
+ * )
297
+ * }
298
+ * ```
299
+ */
300
+ export function useHeading(config: UseHeadingConfig) {
301
+ const {
302
+ editor: providedEditor,
303
+ level,
304
+ hideWhenUnavailable = false,
305
+ onToggled,
306
+ } = config
307
+
308
+ const { editor } = useTiptapEditor(providedEditor)
309
+ const [isVisible, setIsVisible] = useState<boolean>(true)
310
+ const canToggleState = canToggle(editor, level)
311
+ const isActive = isHeadingActive(editor, level)
312
+
313
+ useEffect(() => {
314
+ if (!editor) return
315
+
316
+ const handleSelectionUpdate = () => {
317
+ setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable }))
318
+ }
319
+
320
+ handleSelectionUpdate()
321
+
322
+ editor.on("selectionUpdate", handleSelectionUpdate)
323
+
324
+ return () => {
325
+ editor.off("selectionUpdate", handleSelectionUpdate)
326
+ }
327
+ }, [editor, level, hideWhenUnavailable])
328
+
329
+ const handleToggle = useCallback(() => {
330
+ if (!editor) return false
331
+
332
+ const success = toggleHeading(editor, level)
333
+ if (success) {
334
+ onToggled?.()
335
+ }
336
+ return success
337
+ }, [editor, level, onToggled])
338
+
339
+ return {
340
+ isVisible,
341
+ isActive,
342
+ handleToggle,
343
+ canToggle: canToggleState,
344
+ label: `Heading ${level}`,
345
+ shortcutKeys: HEADING_SHORTCUT_KEYS[level],
346
+ Icon: headingIcons[level],
347
+ }
348
+ }