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,273 @@
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
+ // --- Icons ---
11
+ import { BlockquoteIcon } from "@/components/tiptap-icons/blockquote-icon"
12
+
13
+ // --- UI Utils ---
14
+ import {
15
+ findNodePosition,
16
+ getSelectedBlockNodes,
17
+ isNodeInSchema,
18
+ isNodeTypeSelected,
19
+ isValidPosition,
20
+ selectionWithinConvertibleTypes,
21
+ } from "@/lib/tiptap-utils"
22
+
23
+ export const BLOCKQUOTE_SHORTCUT_KEY = "mod+shift+b"
24
+
25
+ /**
26
+ * Configuration for the blockquote functionality
27
+ */
28
+ export interface UseBlockquoteConfig {
29
+ /**
30
+ * The Tiptap editor instance.
31
+ */
32
+ editor?: Editor | null
33
+ /**
34
+ * Whether the button should hide when blockquote is not available.
35
+ * @default false
36
+ */
37
+ hideWhenUnavailable?: boolean
38
+ /**
39
+ * Callback function called after a successful toggle.
40
+ */
41
+ onToggled?: () => void
42
+ }
43
+
44
+ /**
45
+ * Checks if blockquote can be toggled in the current editor state
46
+ */
47
+ export function canToggleBlockquote(
48
+ editor: Editor | null,
49
+ turnInto: boolean = true
50
+ ): boolean {
51
+ if (!editor || !editor.isEditable) return false
52
+ if (
53
+ !isNodeInSchema("blockquote", editor) ||
54
+ isNodeTypeSelected(editor, ["image"])
55
+ )
56
+ return false
57
+
58
+ if (!turnInto) {
59
+ return editor.can().toggleWrap("blockquote")
60
+ }
61
+
62
+ // Ensure selection is in nodes we're allowed to convert
63
+ if (
64
+ !selectionWithinConvertibleTypes(editor, [
65
+ "paragraph",
66
+ "heading",
67
+ "bulletList",
68
+ "orderedList",
69
+ "taskList",
70
+ "blockquote",
71
+ "codeBlock",
72
+ ])
73
+ )
74
+ return false
75
+
76
+ // Either we can wrap in blockquote directly on the selection,
77
+ // or we can clear formatting/nodes to arrive at a blockquote.
78
+ return editor.can().toggleWrap("blockquote") || editor.can().clearNodes()
79
+ }
80
+
81
+ /**
82
+ * Toggles blockquote formatting for a specific node or the current selection
83
+ */
84
+ export function toggleBlockquote(editor: Editor | null): boolean {
85
+ if (!editor || !editor.isEditable) return false
86
+ if (!canToggleBlockquote(editor)) return false
87
+
88
+ try {
89
+ const view = editor.view
90
+ let state = view.state
91
+ let tr = state.tr
92
+
93
+ const blocks = getSelectedBlockNodes(editor)
94
+
95
+ // In case a selection contains multiple blocks, we only allow
96
+ // toggling to node if there's exactly one block selected
97
+ // we also dont block the canToggle since it will fall back to the bottom logic
98
+ const isPossibleToTurnInto =
99
+ selectionWithinConvertibleTypes(editor, [
100
+ "paragraph",
101
+ "heading",
102
+ "bulletList",
103
+ "orderedList",
104
+ "taskList",
105
+ "blockquote",
106
+ "codeBlock",
107
+ ]) && blocks.length === 1
108
+
109
+ // No selection, find the the cursor position
110
+ if (
111
+ (state.selection.empty || state.selection instanceof TextSelection) &&
112
+ isPossibleToTurnInto
113
+ ) {
114
+ const pos = findNodePosition({
115
+ editor,
116
+ node: state.selection.$anchor.node(1),
117
+ })?.pos
118
+ if (!isValidPosition(pos)) return false
119
+
120
+ tr = tr.setSelection(NodeSelection.create(state.doc, pos))
121
+ view.dispatch(tr)
122
+ state = view.state
123
+ }
124
+
125
+ const selection = state.selection
126
+
127
+ let chain = editor.chain().focus()
128
+
129
+ // Handle NodeSelection
130
+ if (selection instanceof NodeSelection) {
131
+ const firstChild = selection.node.firstChild?.firstChild
132
+ const lastChild = selection.node.lastChild?.lastChild
133
+
134
+ const from = firstChild
135
+ ? selection.from + firstChild.nodeSize
136
+ : selection.from + 1
137
+
138
+ const to = lastChild
139
+ ? selection.to - lastChild.nodeSize
140
+ : selection.to - 1
141
+
142
+ const resolvedFrom = state.doc.resolve(from)
143
+ const resolvedTo = state.doc.resolve(to)
144
+
145
+ chain = chain
146
+ .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo))
147
+ .clearNodes()
148
+ }
149
+
150
+ const toggle = editor.isActive("blockquote")
151
+ ? chain.lift("blockquote")
152
+ : chain.wrapIn("blockquote")
153
+
154
+ toggle.run()
155
+
156
+ editor.chain().focus().selectTextblockEnd().run()
157
+
158
+ return true
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Determines if the blockquote button should be shown
166
+ */
167
+ export function shouldShowButton(props: {
168
+ editor: Editor | null
169
+ hideWhenUnavailable: boolean
170
+ }): boolean {
171
+ const { editor, hideWhenUnavailable } = props
172
+
173
+ if (!editor) return false
174
+
175
+ if (!hideWhenUnavailable) {
176
+ return true
177
+ }
178
+
179
+ if (!editor.isEditable) return false
180
+
181
+ if (!isNodeInSchema("blockquote", editor)) return false
182
+
183
+ if (!editor.isActive("code")) {
184
+ return canToggleBlockquote(editor)
185
+ }
186
+
187
+ return true
188
+ }
189
+
190
+ /**
191
+ * Custom hook that provides blockquote functionality for Tiptap editor
192
+ *
193
+ * @example
194
+ * ```tsx
195
+ * // Simple usage - no params needed
196
+ * function MySimpleBlockquoteButton() {
197
+ * const { isVisible, handleToggle, isActive } = useBlockquote()
198
+ *
199
+ * if (!isVisible) return null
200
+ *
201
+ * return <button onClick={handleToggle}>Blockquote</button>
202
+ * }
203
+ *
204
+ * // Advanced usage with configuration
205
+ * function MyAdvancedBlockquoteButton() {
206
+ * const { isVisible, handleToggle, label, isActive } = useBlockquote({
207
+ * editor: myEditor,
208
+ * hideWhenUnavailable: true,
209
+ * onToggled: () => console.log('Blockquote toggled!')
210
+ * })
211
+ *
212
+ * if (!isVisible) return null
213
+ *
214
+ * return (
215
+ * <MyButton
216
+ * onClick={handleToggle}
217
+ * aria-label={label}
218
+ * aria-pressed={isActive}
219
+ * >
220
+ * Toggle Blockquote
221
+ * </MyButton>
222
+ * )
223
+ * }
224
+ * ```
225
+ */
226
+ export function useBlockquote(config?: UseBlockquoteConfig) {
227
+ const {
228
+ editor: providedEditor,
229
+ hideWhenUnavailable = false,
230
+ onToggled,
231
+ } = config || {}
232
+
233
+ const { editor } = useTiptapEditor(providedEditor)
234
+ const [isVisible, setIsVisible] = useState<boolean>(true)
235
+ const canToggle = canToggleBlockquote(editor)
236
+ const isActive = editor?.isActive("blockquote") || false
237
+
238
+ useEffect(() => {
239
+ if (!editor) return
240
+
241
+ const handleSelectionUpdate = () => {
242
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
243
+ }
244
+
245
+ handleSelectionUpdate()
246
+
247
+ editor.on("selectionUpdate", handleSelectionUpdate)
248
+
249
+ return () => {
250
+ editor.off("selectionUpdate", handleSelectionUpdate)
251
+ }
252
+ }, [editor, hideWhenUnavailable])
253
+
254
+ const handleToggle = useCallback(() => {
255
+ if (!editor) return false
256
+
257
+ const success = toggleBlockquote(editor)
258
+ if (success) {
259
+ onToggled?.()
260
+ }
261
+ return success
262
+ }, [editor, onToggled])
263
+
264
+ return {
265
+ isVisible,
266
+ isActive,
267
+ handleToggle,
268
+ canToggle,
269
+ label: "Blockquote",
270
+ shortcutKeys: BLOCKQUOTE_SHORTCUT_KEY,
271
+ Icon: BlockquoteIcon,
272
+ }
273
+ }
@@ -0,0 +1,122 @@
1
+ import { forwardRef, useCallback } from "react"
2
+
3
+ // --- Hooks ---
4
+ import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
5
+
6
+ // --- Lib ---
7
+ import { parseShortcutKeys } from "@/lib/tiptap-utils"
8
+
9
+ // --- Tiptap UI ---
10
+ import type { UseCodeBlockConfig } from "@/components/tiptap-ui/code-block-button"
11
+ import {
12
+ CODE_BLOCK_SHORTCUT_KEY,
13
+ useCodeBlock,
14
+ } from "@/components/tiptap-ui/code-block-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
+
21
+ export interface CodeBlockButtonProps
22
+ extends Omit<ButtonProps, "type">, UseCodeBlockConfig {
23
+ /**
24
+ * Optional text to display alongside the icon.
25
+ */
26
+ text?: string
27
+ /**
28
+ * Optional show shortcut keys in the button.
29
+ * @default false
30
+ */
31
+ showShortcut?: boolean
32
+ }
33
+
34
+ export function CodeBlockShortcutBadge({
35
+ shortcutKeys = CODE_BLOCK_SHORTCUT_KEY,
36
+ }: {
37
+ shortcutKeys?: string
38
+ }) {
39
+ return <Badge>{parseShortcutKeys({ shortcutKeys })}</Badge>
40
+ }
41
+
42
+ /**
43
+ * Button component for toggling code block in a Tiptap editor.
44
+ *
45
+ * For custom button implementations, use the `useCodeBlock` hook instead.
46
+ */
47
+ export const CodeBlockButton = forwardRef<
48
+ HTMLButtonElement,
49
+ CodeBlockButtonProps
50
+ >(
51
+ (
52
+ {
53
+ editor: providedEditor,
54
+ text,
55
+ hideWhenUnavailable = false,
56
+ onToggled,
57
+ showShortcut = false,
58
+ onClick,
59
+ children,
60
+ ...buttonProps
61
+ },
62
+ ref
63
+ ) => {
64
+ const { editor } = useTiptapEditor(providedEditor)
65
+ const {
66
+ isVisible,
67
+ canToggle,
68
+ isActive,
69
+ handleToggle,
70
+ label,
71
+ shortcutKeys,
72
+ Icon,
73
+ } = useCodeBlock({
74
+ editor,
75
+ hideWhenUnavailable,
76
+ onToggled,
77
+ })
78
+
79
+ const handleClick = useCallback(
80
+ (event: React.MouseEvent<HTMLButtonElement>) => {
81
+ onClick?.(event)
82
+ if (event.defaultPrevented) return
83
+ handleToggle()
84
+ },
85
+ [handleToggle, onClick]
86
+ )
87
+
88
+ if (!isVisible) {
89
+ return null
90
+ }
91
+
92
+ return (
93
+ <Button
94
+ type="button"
95
+ variant="ghost"
96
+ data-active-state={isActive ? "on" : "off"}
97
+ role="button"
98
+ disabled={!canToggle}
99
+ data-disabled={!canToggle}
100
+ tabIndex={-1}
101
+ aria-label={label}
102
+ aria-pressed={isActive}
103
+ tooltip="Code Block"
104
+ onClick={handleClick}
105
+ {...buttonProps}
106
+ ref={ref}
107
+ >
108
+ {children ?? (
109
+ <>
110
+ <Icon className="tiptap-button-icon" />
111
+ {text && <span className="tiptap-button-text">{text}</span>}
112
+ {showShortcut && (
113
+ <CodeBlockShortcutBadge shortcutKeys={shortcutKeys} />
114
+ )}
115
+ </>
116
+ )}
117
+ </Button>
118
+ )
119
+ }
120
+ )
121
+
122
+ CodeBlockButton.displayName = "CodeBlockButton"
@@ -0,0 +1,2 @@
1
+ export * from "./code-block-button"
2
+ export * from "./use-code-block"
@@ -0,0 +1,283 @@
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 { CodeBlockIcon } from "@/components/tiptap-icons/code-block-icon"
22
+
23
+ export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c"
24
+
25
+ /**
26
+ * Configuration for the code block functionality
27
+ */
28
+ export interface UseCodeBlockConfig {
29
+ /**
30
+ * The Tiptap editor instance.
31
+ */
32
+ editor?: Editor | null
33
+ /**
34
+ * Whether the button should hide when code block is not available.
35
+ * @default false
36
+ */
37
+ hideWhenUnavailable?: boolean
38
+ /**
39
+ * Callback function called after a successful code block toggle.
40
+ */
41
+ onToggled?: () => void
42
+ }
43
+
44
+ /**
45
+ * Checks if code block can be toggled in the current editor state
46
+ */
47
+ export function canToggle(
48
+ editor: Editor | null,
49
+ turnInto: boolean = true
50
+ ): boolean {
51
+ if (!editor || !editor.isEditable) return false
52
+ if (
53
+ !isNodeInSchema("codeBlock", editor) ||
54
+ isNodeTypeSelected(editor, ["image"])
55
+ )
56
+ return false
57
+
58
+ if (!turnInto) {
59
+ return editor.can().toggleNode("codeBlock", "paragraph")
60
+ }
61
+
62
+ // Ensure selection is in nodes we're allowed to convert
63
+ if (
64
+ !selectionWithinConvertibleTypes(editor, [
65
+ "paragraph",
66
+ "heading",
67
+ "bulletList",
68
+ "orderedList",
69
+ "taskList",
70
+ "blockquote",
71
+ "codeBlock",
72
+ ])
73
+ )
74
+ return false
75
+
76
+ // Either we can toggle code block directly on the selection,
77
+ // or we can clear formatting/nodes to arrive at a code block.
78
+ return (
79
+ editor.can().toggleNode("codeBlock", "paragraph") ||
80
+ editor.can().clearNodes()
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Toggles code block in the editor
86
+ */
87
+ export function toggleCodeBlock(editor: Editor | null): boolean {
88
+ if (!editor || !editor.isEditable) return false
89
+ if (!canToggle(editor)) return false
90
+
91
+ try {
92
+ const view = editor.view
93
+ let state = view.state
94
+ let tr = state.tr
95
+
96
+ const blocks = getSelectedBlockNodes(editor)
97
+
98
+ // In case a selection contains multiple blocks, we only allow
99
+ // toggling to node if there's exactly one block selected
100
+ // we also dont block the canToggle since it will fall back to the bottom logic
101
+ const isPossibleToTurnInto =
102
+ selectionWithinConvertibleTypes(editor, [
103
+ "paragraph",
104
+ "heading",
105
+ "bulletList",
106
+ "orderedList",
107
+ "taskList",
108
+ "blockquote",
109
+ "codeBlock",
110
+ ]) && blocks.length === 1
111
+
112
+ // No selection, find the the cursor position
113
+ if (
114
+ (state.selection.empty || state.selection instanceof TextSelection) &&
115
+ isPossibleToTurnInto
116
+ ) {
117
+ const pos = findNodePosition({
118
+ editor,
119
+ node: state.selection.$anchor.node(1),
120
+ })?.pos
121
+ if (!isValidPosition(pos)) return false
122
+
123
+ tr = tr.setSelection(NodeSelection.create(state.doc, pos))
124
+ view.dispatch(tr)
125
+ state = view.state
126
+ }
127
+
128
+ const selection = state.selection
129
+
130
+ let chain = editor.chain().focus()
131
+
132
+ // Handle NodeSelection
133
+ if (selection instanceof NodeSelection) {
134
+ const firstChild = selection.node.firstChild?.firstChild
135
+ const lastChild = selection.node.lastChild?.lastChild
136
+
137
+ const from = firstChild
138
+ ? selection.from + firstChild.nodeSize
139
+ : selection.from + 1
140
+
141
+ const to = lastChild
142
+ ? selection.to - lastChild.nodeSize
143
+ : selection.to - 1
144
+
145
+ const resolvedFrom = state.doc.resolve(from)
146
+ const resolvedTo = state.doc.resolve(to)
147
+
148
+ chain = chain
149
+ .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo))
150
+ .clearNodes()
151
+ }
152
+
153
+ const toggle = editor.isActive("codeBlock")
154
+ ? chain.setNode("paragraph")
155
+ : chain.toggleNode("codeBlock", "paragraph")
156
+
157
+ toggle.run()
158
+
159
+ editor.chain().focus().selectTextblockEnd().run()
160
+
161
+ return true
162
+ } catch {
163
+ return false
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Determines if the code block button should be shown
169
+ */
170
+ export function shouldShowButton(props: {
171
+ editor: Editor | null
172
+ hideWhenUnavailable: boolean
173
+ }): boolean {
174
+ const { editor, hideWhenUnavailable } = props
175
+
176
+ if (!editor) return false
177
+
178
+ if (!hideWhenUnavailable) {
179
+ return true
180
+ }
181
+
182
+ if (!editor.isEditable) return false
183
+
184
+ if (!isNodeInSchema("codeBlock", editor)) return false
185
+
186
+ if (!editor.isActive("code")) {
187
+ return canToggle(editor)
188
+ }
189
+
190
+ return true
191
+ }
192
+
193
+ /**
194
+ * Custom hook that provides code block functionality for Tiptap editor
195
+ *
196
+ * @example
197
+ * ```tsx
198
+ * // Simple usage - no params needed
199
+ * function MySimpleCodeBlockButton() {
200
+ * const { isVisible, isActive, handleToggle } = useCodeBlock()
201
+ *
202
+ * if (!isVisible) return null
203
+ *
204
+ * return (
205
+ * <button
206
+ * onClick={handleToggle}
207
+ * aria-pressed={isActive}
208
+ * >
209
+ * Code Block
210
+ * </button>
211
+ * )
212
+ * }
213
+ *
214
+ * // Advanced usage with configuration
215
+ * function MyAdvancedCodeBlockButton() {
216
+ * const { isVisible, isActive, handleToggle, label } = useCodeBlock({
217
+ * editor: myEditor,
218
+ * hideWhenUnavailable: true,
219
+ * onToggled: (isActive) => console.log('Code block toggled:', isActive)
220
+ * })
221
+ *
222
+ * if (!isVisible) return null
223
+ *
224
+ * return (
225
+ * <MyButton
226
+ * onClick={handleToggle}
227
+ * aria-label={label}
228
+ * aria-pressed={isActive}
229
+ * >
230
+ * Toggle Code Block
231
+ * </MyButton>
232
+ * )
233
+ * }
234
+ * ```
235
+ */
236
+ export function useCodeBlock(config?: UseCodeBlockConfig) {
237
+ const {
238
+ editor: providedEditor,
239
+ hideWhenUnavailable = false,
240
+ onToggled,
241
+ } = config || {}
242
+
243
+ const { editor } = useTiptapEditor(providedEditor)
244
+ const [isVisible, setIsVisible] = useState<boolean>(true)
245
+ const canToggleState = canToggle(editor)
246
+ const isActive = editor?.isActive("codeBlock") || false
247
+
248
+ useEffect(() => {
249
+ if (!editor) return
250
+
251
+ const handleSelectionUpdate = () => {
252
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
253
+ }
254
+
255
+ handleSelectionUpdate()
256
+
257
+ editor.on("selectionUpdate", handleSelectionUpdate)
258
+
259
+ return () => {
260
+ editor.off("selectionUpdate", handleSelectionUpdate)
261
+ }
262
+ }, [editor, hideWhenUnavailable])
263
+
264
+ const handleToggle = useCallback(() => {
265
+ if (!editor) return false
266
+
267
+ const success = toggleCodeBlock(editor)
268
+ if (success) {
269
+ onToggled?.()
270
+ }
271
+ return success
272
+ }, [editor, onToggled])
273
+
274
+ return {
275
+ isVisible,
276
+ isActive,
277
+ handleToggle,
278
+ canToggle: canToggleState,
279
+ label: "Code Block",
280
+ shortcutKeys: CODE_BLOCK_SHORTCUT_KEY,
281
+ Icon: CodeBlockIcon,
282
+ }
283
+ }