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,652 @@
1
+ import type { Node as PMNode } from "@tiptap/pm/model"
2
+ import type { Transaction } from "@tiptap/pm/state"
3
+ import { clsx, type ClassValue } from "clsx"
4
+ import {
5
+ AllSelection,
6
+ NodeSelection,
7
+ Selection,
8
+ TextSelection,
9
+ } from "@tiptap/pm/state"
10
+ import { cellAround, CellSelection } from "@tiptap/pm/tables"
11
+ import {
12
+ findParentNodeClosestToPos,
13
+ type Editor,
14
+ type NodeWithPos,
15
+ } from "@tiptap/react"
16
+
17
+ export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
18
+
19
+ export const MAC_SYMBOLS: Record<string, string> = {
20
+ mod: "⌘",
21
+ command: "⌘",
22
+ meta: "⌘",
23
+ ctrl: "⌃",
24
+ control: "⌃",
25
+ alt: "⌥",
26
+ option: "⌥",
27
+ shift: "⇧",
28
+ backspace: "Del",
29
+ delete: "⌦",
30
+ enter: "⏎",
31
+ escape: "⎋",
32
+ capslock: "⇪",
33
+ } as const
34
+
35
+ export const SR_ONLY = {
36
+ position: "absolute",
37
+ width: "1px",
38
+ height: "1px",
39
+ padding: 0,
40
+ margin: "-1px",
41
+ overflow: "hidden",
42
+ clip: "rect(0, 0, 0, 0)",
43
+ whiteSpace: "nowrap",
44
+ borderWidth: 0,
45
+ } as const
46
+
47
+ export function cn(...inputs: ClassValue[]): string {
48
+ return clsx(inputs)
49
+ }
50
+
51
+ /**
52
+ * Determines if the current platform is macOS
53
+ * @returns boolean indicating if the current platform is Mac
54
+ */
55
+ export function isMac(): boolean {
56
+ return (
57
+ typeof navigator !== "undefined" &&
58
+ navigator.platform.toLowerCase().includes("mac")
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Formats a shortcut key based on the platform (Mac or non-Mac)
64
+ * @param key - The key to format (e.g., "ctrl", "alt", "shift")
65
+ * @param isMac - Boolean indicating if the platform is Mac
66
+ * @param capitalize - Whether to capitalize the key (default: true)
67
+ * @returns Formatted shortcut key symbol
68
+ */
69
+ export const formatShortcutKey = (
70
+ key: string,
71
+ isMac: boolean,
72
+ capitalize: boolean = true
73
+ ) => {
74
+ if (isMac) {
75
+ const lowerKey = key.toLowerCase()
76
+ return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key)
77
+ }
78
+
79
+ return capitalize ? key.charAt(0).toUpperCase() + key.slice(1) : key
80
+ }
81
+
82
+ /**
83
+ * Parses a shortcut key string into an array of formatted key symbols
84
+ * @param shortcutKeys - The string of shortcut keys (e.g., "ctrl-alt-shift")
85
+ * @param delimiter - The delimiter used to split the keys (default: "-")
86
+ * @param capitalize - Whether to capitalize the keys (default: true)
87
+ * @returns Array of formatted shortcut key symbols
88
+ */
89
+ export const parseShortcutKeys = (props: {
90
+ shortcutKeys: string | undefined
91
+ delimiter?: string
92
+ capitalize?: boolean
93
+ }) => {
94
+ const { shortcutKeys, delimiter = "+", capitalize = true } = props
95
+
96
+ if (!shortcutKeys) return []
97
+
98
+ return shortcutKeys
99
+ .split(delimiter)
100
+ .map((key) => key.trim())
101
+ .map((key) => formatShortcutKey(key, isMac(), capitalize))
102
+ }
103
+
104
+ /**
105
+ * Checks if a mark exists in the editor schema
106
+ * @param markName - The name of the mark to check
107
+ * @param editor - The editor instance
108
+ * @returns boolean indicating if the mark exists in the schema
109
+ */
110
+ export const isMarkInSchema = (
111
+ markName: string,
112
+ editor: Editor | null
113
+ ): boolean => {
114
+ if (!editor?.schema) return false
115
+ return editor.schema.spec.marks.get(markName) !== undefined
116
+ }
117
+
118
+ /**
119
+ * Checks if a node exists in the editor schema
120
+ * @param nodeName - The name of the node to check
121
+ * @param editor - The editor instance
122
+ * @returns boolean indicating if the node exists in the schema
123
+ */
124
+ export const isNodeInSchema = (
125
+ nodeName: string,
126
+ editor: Editor | null
127
+ ): boolean => {
128
+ if (!editor?.schema) return false
129
+ return editor.schema.spec.nodes.get(nodeName) !== undefined
130
+ }
131
+
132
+ /**
133
+ * Moves the focus to the next node in the editor
134
+ * @param editor - The editor instance
135
+ * @returns boolean indicating if the focus was moved
136
+ */
137
+ export function focusNextNode(editor: Editor) {
138
+ const { state, view } = editor
139
+ const { doc, selection } = state
140
+
141
+ const nextSel = Selection.findFrom(selection.$to, 1, true)
142
+ if (nextSel) {
143
+ view.dispatch(state.tr.setSelection(nextSel).scrollIntoView())
144
+ return true
145
+ }
146
+
147
+ const paragraphType = state.schema.nodes.paragraph
148
+ if (!paragraphType) {
149
+ console.warn("No paragraph node type found in schema.")
150
+ return false
151
+ }
152
+
153
+ const end = doc.content.size
154
+ const para = paragraphType.create()
155
+ let tr = state.tr.insert(end, para)
156
+
157
+ // Place the selection inside the new paragraph
158
+ const $inside = tr.doc.resolve(end + 1)
159
+ tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView()
160
+ view.dispatch(tr)
161
+ return true
162
+ }
163
+
164
+ /**
165
+ * Checks if a value is a valid number (not null, undefined, or NaN)
166
+ * @param value - The value to check
167
+ * @returns boolean indicating if the value is a valid number
168
+ */
169
+ export function isValidPosition(pos: number | null | undefined): pos is number {
170
+ return typeof pos === "number" && pos >= 0
171
+ }
172
+
173
+ /**
174
+ * Checks if one or more extensions are registered in the Tiptap editor.
175
+ * @param editor - The Tiptap editor instance
176
+ * @param extensionNames - A single extension name or an array of names to check
177
+ * @returns True if at least one of the extensions is available, false otherwise
178
+ */
179
+ // This check runs from render/selection paths, so an absent extension would
180
+ // otherwise repeat the hint on every update — remember what was already
181
+ // reported and say it once.
182
+ const warnedMissingExtensions = new Set<string>()
183
+
184
+ export function isExtensionAvailable(
185
+ editor: Editor | null,
186
+ extensionNames: string | string[]
187
+ ): boolean {
188
+ if (!editor || editor.isDestroyed) return false
189
+
190
+ const names = Array.isArray(extensionNames)
191
+ ? extensionNames
192
+ : [extensionNames]
193
+
194
+ const extensions = editor.extensionManager?.extensions
195
+ if (!extensions) return false
196
+
197
+ const found = names.some((name) =>
198
+ extensions.some((ext) => ext.name === name)
199
+ )
200
+
201
+ if (!found) {
202
+ const key = names.join(", ")
203
+
204
+ if (!warnedMissingExtensions.has(key)) {
205
+ warnedMissingExtensions.add(key)
206
+ console.warn(
207
+ `None of the extensions [${key}] were found in the editor schema. Ensure they are included in the editor configuration.`
208
+ )
209
+ }
210
+ }
211
+
212
+ return found
213
+ }
214
+
215
+ /**
216
+ * Finds a node at the specified position with error handling
217
+ * @param editor The Tiptap editor instance
218
+ * @param position The position in the document to find the node
219
+ * @returns The node at the specified position, or null if not found
220
+ */
221
+ export function findNodeAtPosition(editor: Editor, position: number) {
222
+ try {
223
+ const node = editor.state.doc.nodeAt(position)
224
+ if (!node) {
225
+ console.warn(`No node found at position ${position}`)
226
+ return null
227
+ }
228
+ return node
229
+ } catch (error) {
230
+ console.error(`Error getting node at position ${position}:`, error)
231
+ return null
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Finds the position and instance of a node in the document
237
+ * @param props Object containing editor, node (optional), and nodePos (optional)
238
+ * @param props.editor The Tiptap editor instance
239
+ * @param props.node The node to find (optional if nodePos is provided)
240
+ * @param props.nodePos The position of the node to find (optional if node is provided)
241
+ * @returns An object with the position and node, or null if not found
242
+ */
243
+ export function findNodePosition(props: {
244
+ editor: Editor | null
245
+ node?: PMNode | null
246
+ nodePos?: number | null
247
+ }): { pos: number; node: PMNode } | null {
248
+ const { editor, node, nodePos } = props
249
+
250
+ if (!editor || !editor.state?.doc) return null
251
+
252
+ // Zero is valid position
253
+ const hasValidNode = node !== undefined && node !== null
254
+ const hasValidPos = isValidPosition(nodePos)
255
+
256
+ if (!hasValidNode && !hasValidPos) {
257
+ return null
258
+ }
259
+
260
+ // First search for the node in the document if we have a node
261
+ if (hasValidNode) {
262
+ let foundPos = -1
263
+ let foundNode: PMNode | null = null
264
+
265
+ editor.state.doc.descendants((currentNode, pos) => {
266
+ // TODO: Needed?
267
+ // if (currentNode.type && currentNode.type.name === node!.type.name) {
268
+ if (currentNode === node) {
269
+ foundPos = pos
270
+ foundNode = currentNode
271
+ return false
272
+ }
273
+ return true
274
+ })
275
+
276
+ if (foundPos !== -1 && foundNode !== null) {
277
+ return { pos: foundPos, node: foundNode }
278
+ }
279
+ }
280
+
281
+ // If we have a valid position, use findNodeAtPosition
282
+ if (hasValidPos) {
283
+ const nodeAtPos = findNodeAtPosition(editor, nodePos!)
284
+ if (nodeAtPos) {
285
+ return { pos: nodePos!, node: nodeAtPos }
286
+ }
287
+ }
288
+
289
+ return null
290
+ }
291
+
292
+ /**
293
+ * Determines whether the current selection contains a node whose type matches
294
+ * any of the provided node type names.
295
+ * @param editor Tiptap editor instance
296
+ * @param nodeTypeNames List of node type names to match against
297
+ * @param checkAncestorNodes Whether to check ancestor node types up the depth chain
298
+ */
299
+ export function isNodeTypeSelected(
300
+ editor: Editor | null,
301
+ nodeTypeNames: string[] = [],
302
+ checkAncestorNodes: boolean = false
303
+ ): boolean {
304
+ if (!editor || !editor.state.selection) return false
305
+
306
+ const { selection } = editor.state
307
+ if (selection.empty) return false
308
+
309
+ // Direct node selection check
310
+ if (selection instanceof NodeSelection) {
311
+ const selectedNode = selection.node
312
+ return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false
313
+ }
314
+
315
+ // Depth-based ancestor node check
316
+ if (checkAncestorNodes) {
317
+ const { $from } = selection
318
+ for (let depth = $from.depth; depth > 0; depth--) {
319
+ const ancestorNode = $from.node(depth)
320
+ if (nodeTypeNames.includes(ancestorNode.type.name)) {
321
+ return true
322
+ }
323
+ }
324
+ }
325
+
326
+ return false
327
+ }
328
+
329
+ /**
330
+ * Check whether the current selection is fully within nodes
331
+ * whose type names are in the provided `types` list.
332
+ *
333
+ * - NodeSelection → checks the selected node.
334
+ * - Text/AllSelection → ensures all textblocks within [from, to) are allowed.
335
+ */
336
+ export function selectionWithinConvertibleTypes(
337
+ editor: Editor,
338
+ types: string[] = []
339
+ ): boolean {
340
+ if (!editor || types.length === 0) return false
341
+
342
+ const { state } = editor
343
+ const { selection } = state
344
+ const allowed = new Set(types)
345
+
346
+ if (selection instanceof NodeSelection) {
347
+ const nodeType = selection.node?.type?.name
348
+ return !!nodeType && allowed.has(nodeType)
349
+ }
350
+
351
+ if (selection instanceof TextSelection || selection instanceof AllSelection) {
352
+ let valid = true
353
+ state.doc.nodesBetween(selection.from, selection.to, (node) => {
354
+ if (node.isTextblock && !allowed.has(node.type.name)) {
355
+ valid = false
356
+ return false // stop early
357
+ }
358
+ return valid
359
+ })
360
+ return valid
361
+ }
362
+
363
+ return false
364
+ }
365
+
366
+ /**
367
+ * Handles image upload with progress tracking and abort capability
368
+ * @param file The file to upload
369
+ * @param onProgress Optional callback for tracking upload progress
370
+ * @param abortSignal Optional AbortSignal for cancelling the upload
371
+ * @returns Promise resolving to the URL of the uploaded image
372
+ */
373
+ export const handleImageUpload = async (
374
+ file: File,
375
+ onProgress?: (event: { progress: number }) => void,
376
+ abortSignal?: AbortSignal
377
+ ): Promise<string> => {
378
+ // Validate file
379
+ if (!file) {
380
+ throw new Error("No file provided")
381
+ }
382
+
383
+ if (file.size > MAX_FILE_SIZE) {
384
+ throw new Error(
385
+ `File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)`
386
+ )
387
+ }
388
+
389
+ // For demo/testing: Simulate upload progress. In production, replace the following code
390
+ // with your own upload implementation.
391
+ for (let progress = 0; progress <= 100; progress += 10) {
392
+ if (abortSignal?.aborted) {
393
+ throw new Error("Upload cancelled")
394
+ }
395
+ await new Promise((resolve) => setTimeout(resolve, 500))
396
+ onProgress?.({ progress })
397
+ }
398
+
399
+ return "/images/tiptap-ui-placeholder-image.jpg"
400
+ }
401
+
402
+ type ProtocolOptions = {
403
+ /**
404
+ * The protocol scheme to be registered.
405
+ * @default '''
406
+ * @example 'ftp'
407
+ * @example 'git'
408
+ */
409
+ scheme: string
410
+
411
+ /**
412
+ * If enabled, it allows optional slashes after the protocol.
413
+ * @default false
414
+ * @example true
415
+ */
416
+ optionalSlashes?: boolean
417
+ }
418
+
419
+ type ProtocolConfig = Array<ProtocolOptions | string>
420
+
421
+ const ATTR_WHITESPACE =
422
+ // eslint-disable-next-line no-control-regex
423
+ /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g
424
+
425
+ export function isAllowedUri(
426
+ uri: string | undefined,
427
+ protocols?: ProtocolConfig
428
+ ) {
429
+ const allowedProtocols: string[] = [
430
+ "http",
431
+ "https",
432
+ "ftp",
433
+ "ftps",
434
+ "mailto",
435
+ "tel",
436
+ "callto",
437
+ "sms",
438
+ "cid",
439
+ "xmpp",
440
+ ]
441
+
442
+ if (protocols) {
443
+ protocols.forEach((protocol) => {
444
+ const nextProtocol =
445
+ typeof protocol === "string" ? protocol : protocol.scheme
446
+
447
+ if (nextProtocol) {
448
+ allowedProtocols.push(nextProtocol)
449
+ }
450
+ })
451
+ }
452
+
453
+ return (
454
+ !uri ||
455
+ uri.replace(ATTR_WHITESPACE, "").match(
456
+ new RegExp(
457
+ // eslint-disable-next-line no-useless-escape
458
+ `^(?:(?:${allowedProtocols.join("|")}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`,
459
+ "i"
460
+ )
461
+ )
462
+ )
463
+ }
464
+
465
+ export function sanitizeUrl(
466
+ inputUrl: string,
467
+ baseUrl: string,
468
+ protocols?: ProtocolConfig
469
+ ): string {
470
+ try {
471
+ const url = new URL(inputUrl, baseUrl)
472
+
473
+ if (isAllowedUri(url.href, protocols)) {
474
+ return url.href
475
+ }
476
+ } catch {
477
+ // If URL creation fails, it's considered invalid
478
+ }
479
+ return "#"
480
+ }
481
+
482
+ /**
483
+ * Update a single attribute on multiple nodes.
484
+ *
485
+ * @param tr - The transaction to mutate
486
+ * @param targets - Array of { node, pos }
487
+ * @param attrName - Attribute key to update
488
+ * @param next - New value OR updater function receiving previous value
489
+ * Pass `undefined` to remove the attribute.
490
+ * @returns true if at least one node was updated, false otherwise
491
+ */
492
+ export function updateNodesAttr<A extends string = string, V = unknown>(
493
+ tr: Transaction,
494
+ targets: readonly NodeWithPos[],
495
+ attrName: A,
496
+ next: V | ((prev: V | undefined) => V | undefined)
497
+ ): boolean {
498
+ if (!targets.length) return false
499
+
500
+ let changed = false
501
+
502
+ for (const { pos } of targets) {
503
+ // Always re-read from the transaction's current doc
504
+ const currentNode = tr.doc.nodeAt(pos)
505
+ if (!currentNode) continue
506
+
507
+ const prevValue = (currentNode.attrs as Record<string, unknown>)[
508
+ attrName
509
+ ] as V | undefined
510
+ const resolvedNext =
511
+ typeof next === "function"
512
+ ? (next as (p: V | undefined) => V | undefined)(prevValue)
513
+ : next
514
+
515
+ if (prevValue === resolvedNext) continue
516
+
517
+ const nextAttrs: Record<string, unknown> = { ...currentNode.attrs }
518
+ if (resolvedNext === undefined) {
519
+ // Remove the key entirely instead of setting null
520
+ delete nextAttrs[attrName]
521
+ } else {
522
+ nextAttrs[attrName] = resolvedNext
523
+ }
524
+
525
+ tr.setNodeMarkup(pos, undefined, nextAttrs)
526
+ changed = true
527
+ }
528
+
529
+ return changed
530
+ }
531
+
532
+ /**
533
+ * Selects the entire content of the current block node if the selection is empty.
534
+ * If the selection is not empty, it does nothing.
535
+ * @param editor The Tiptap editor instance
536
+ */
537
+ export function selectCurrentBlockContent(editor: Editor) {
538
+ const { selection, doc } = editor.state
539
+
540
+ if (!selection.empty) return
541
+
542
+ const $pos = selection.$from
543
+ let blockNode = null
544
+ let blockPos = -1
545
+
546
+ for (let depth = $pos.depth; depth >= 0; depth--) {
547
+ const node = $pos.node(depth)
548
+ const pos = $pos.start(depth)
549
+
550
+ if (node.isBlock && node.textContent.trim()) {
551
+ blockNode = node
552
+ blockPos = pos
553
+ break
554
+ }
555
+ }
556
+
557
+ if (blockNode && blockPos >= 0) {
558
+ const from = blockPos
559
+ const to = blockPos + blockNode.nodeSize - 2 // -2 to exclude the closing tag
560
+
561
+ if (from < to) {
562
+ const $from = doc.resolve(from)
563
+ const $to = doc.resolve(to)
564
+ const newSelection = TextSelection.between($from, $to, 1)
565
+
566
+ if (newSelection && !selection.eq(newSelection)) {
567
+ editor.view.dispatch(editor.state.tr.setSelection(newSelection))
568
+ }
569
+ }
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Retrieves all nodes of specified types from the current selection.
575
+ * @param selection The current editor selection
576
+ * @param allowedNodeTypes An array of node type names to look for (e.g., ["image", "table"])
577
+ * @returns An array of objects containing the node and its position
578
+ */
579
+ export function getSelectedNodesOfType(
580
+ selection: Selection,
581
+ allowedNodeTypes: string[]
582
+ ): NodeWithPos[] {
583
+ const results: NodeWithPos[] = []
584
+ const allowed = new Set(allowedNodeTypes)
585
+
586
+ if (selection instanceof CellSelection) {
587
+ selection.forEachCell((node: PMNode, pos: number) => {
588
+ if (allowed.has(node.type.name)) {
589
+ results.push({ node, pos })
590
+ }
591
+ })
592
+ return results
593
+ }
594
+
595
+ if (selection instanceof NodeSelection) {
596
+ const { node, from: pos } = selection
597
+ if (node && allowed.has(node.type.name)) {
598
+ results.push({ node, pos })
599
+ }
600
+ return results
601
+ }
602
+
603
+ const { $anchor } = selection
604
+ const cell = cellAround($anchor)
605
+
606
+ if (cell) {
607
+ const cellNode = selection.$anchor.doc.nodeAt(cell.pos)
608
+ if (cellNode && allowed.has(cellNode.type.name)) {
609
+ results.push({ node: cellNode, pos: cell.pos })
610
+ return results
611
+ }
612
+ }
613
+
614
+ // Fallback: find parent nodes of allowed types
615
+ const parentNode = findParentNodeClosestToPos($anchor, (node) =>
616
+ allowed.has(node.type.name)
617
+ )
618
+
619
+ if (parentNode) {
620
+ results.push({ node: parentNode.node, pos: parentNode.pos })
621
+ }
622
+
623
+ return results
624
+ }
625
+
626
+ /**
627
+ * Clamps a value between min and max bounds
628
+ */
629
+ export function clamp(value: number, min: number, max: number): number {
630
+ return Math.max(min, Math.min(value, max))
631
+ }
632
+
633
+ export function getSelectedBlockNodes(editor: Editor): PMNode[] {
634
+ const { doc } = editor.state
635
+ const { from, to } = editor.state.selection
636
+
637
+ const blocks: PMNode[] = []
638
+ const seen = new Set<number>()
639
+
640
+ doc.nodesBetween(from, to, (node, pos) => {
641
+ if (!node.isBlock) return
642
+
643
+ if (!seen.has(pos)) {
644
+ seen.add(pos)
645
+ blocks.push(node)
646
+ }
647
+
648
+ return false
649
+ })
650
+
651
+ return blocks
652
+ }