@btst/stack 2.8.0 → 2.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 (214) hide show
  1. package/README.md +3 -2
  2. package/dist/components/markdown/index.d.cts +15 -2
  3. package/dist/components/markdown/index.d.mts +15 -2
  4. package/dist/components/markdown/index.d.ts +15 -2
  5. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.cjs +30 -1
  6. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.mjs +30 -1
  7. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.cjs +49 -9
  8. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.mjs +50 -10
  9. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.cjs +77 -9
  10. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.mjs +77 -9
  11. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -5
  12. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +24 -5
  13. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.cjs +47 -13
  14. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.mjs +47 -13
  15. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.cjs +1 -1
  16. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.mjs +1 -1
  17. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +6 -2
  18. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +6 -2
  19. package/dist/packages/stack/src/plugins/media/api/adapters/local.cjs +55 -0
  20. package/dist/packages/stack/src/plugins/media/api/adapters/local.mjs +37 -0
  21. package/dist/packages/stack/src/plugins/media/api/getters.cjs +83 -0
  22. package/dist/packages/stack/src/plugins/media/api/getters.mjs +78 -0
  23. package/dist/packages/stack/src/plugins/media/api/mutations.cjs +88 -0
  24. package/dist/packages/stack/src/plugins/media/api/mutations.mjs +82 -0
  25. package/dist/packages/stack/src/plugins/media/api/plugin.cjs +525 -0
  26. package/dist/packages/stack/src/plugins/media/api/plugin.mjs +523 -0
  27. package/dist/packages/stack/src/plugins/media/api/query-key-defs.cjs +19 -0
  28. package/dist/packages/stack/src/plugins/media/api/query-key-defs.mjs +16 -0
  29. package/dist/packages/stack/src/plugins/media/api/serializers.cjs +17 -0
  30. package/dist/packages/stack/src/plugins/media/api/serializers.mjs +14 -0
  31. package/dist/packages/stack/src/plugins/media/api/storage-adapter.cjs +15 -0
  32. package/dist/packages/stack/src/plugins/media/api/storage-adapter.mjs +11 -0
  33. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.cjs +129 -0
  34. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.mjs +127 -0
  35. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.cjs +58 -0
  36. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.mjs +56 -0
  37. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.cjs +94 -0
  38. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.mjs +92 -0
  39. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.cjs +171 -0
  40. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.mjs +168 -0
  41. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.cjs +308 -0
  42. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.mjs +305 -0
  43. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.cjs +104 -0
  44. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.mjs +102 -0
  45. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.cjs +70 -0
  46. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.mjs +68 -0
  47. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.cjs +21 -0
  48. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.mjs +17 -0
  49. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.cjs +35 -0
  50. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.cjs +125 -0
  51. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.mjs +123 -0
  52. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.mjs +33 -0
  53. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.cjs +222 -0
  54. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.mjs +214 -0
  55. package/dist/packages/stack/src/plugins/media/client/plugin.cjs +94 -0
  56. package/dist/packages/stack/src/plugins/media/client/plugin.mjs +92 -0
  57. package/dist/packages/stack/src/plugins/media/client/upload.cjs +121 -0
  58. package/dist/packages/stack/src/plugins/media/client/upload.mjs +119 -0
  59. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.cjs +67 -0
  60. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.mjs +65 -0
  61. package/dist/packages/stack/src/plugins/media/db.cjs +62 -0
  62. package/dist/packages/stack/src/plugins/media/db.mjs +60 -0
  63. package/dist/packages/stack/src/plugins/media/schemas.cjs +41 -0
  64. package/dist/packages/stack/src/plugins/media/schemas.mjs +35 -0
  65. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.cjs +18 -1
  66. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.mjs +19 -2
  67. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.cjs +2 -2
  68. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.mjs +2 -2
  69. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.cjs +3 -2
  70. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.mjs +3 -2
  71. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.cjs +12 -5
  72. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.mjs +12 -5
  73. package/dist/plugins/blog/api/index.d.cts +2 -2
  74. package/dist/plugins/blog/api/index.d.mts +2 -2
  75. package/dist/plugins/blog/api/index.d.ts +2 -2
  76. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  77. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  78. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  79. package/dist/plugins/blog/client/index.d.cts +60 -3
  80. package/dist/plugins/blog/client/index.d.mts +60 -3
  81. package/dist/plugins/blog/client/index.d.ts +60 -3
  82. package/dist/plugins/blog/query-keys.d.cts +2 -2
  83. package/dist/plugins/blog/query-keys.d.mts +2 -2
  84. package/dist/plugins/blog/query-keys.d.ts +2 -2
  85. package/dist/plugins/cms/client/index.d.cts +73 -3
  86. package/dist/plugins/cms/client/index.d.mts +73 -3
  87. package/dist/plugins/cms/client/index.d.ts +73 -3
  88. package/dist/plugins/kanban/api/index.d.cts +1 -1
  89. package/dist/plugins/kanban/api/index.d.mts +1 -1
  90. package/dist/plugins/kanban/api/index.d.ts +1 -1
  91. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  92. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  93. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  94. package/dist/plugins/kanban/client/index.d.cts +1 -1
  95. package/dist/plugins/kanban/client/index.d.mts +1 -1
  96. package/dist/plugins/kanban/client/index.d.ts +1 -1
  97. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  98. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  99. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  100. package/dist/plugins/media/api/adapters/s3.cjs +106 -0
  101. package/dist/plugins/media/api/adapters/s3.d.cts +60 -0
  102. package/dist/plugins/media/api/adapters/s3.d.mts +60 -0
  103. package/dist/plugins/media/api/adapters/s3.d.ts +60 -0
  104. package/dist/plugins/media/api/adapters/s3.mjs +104 -0
  105. package/dist/plugins/media/api/adapters/vercel-blob.cjs +54 -0
  106. package/dist/plugins/media/api/adapters/vercel-blob.d.cts +41 -0
  107. package/dist/plugins/media/api/adapters/vercel-blob.d.mts +41 -0
  108. package/dist/plugins/media/api/adapters/vercel-blob.d.ts +41 -0
  109. package/dist/plugins/media/api/adapters/vercel-blob.mjs +52 -0
  110. package/dist/plugins/media/api/index.cjs +26 -0
  111. package/dist/plugins/media/api/index.d.cts +116 -0
  112. package/dist/plugins/media/api/index.d.mts +116 -0
  113. package/dist/plugins/media/api/index.d.ts +116 -0
  114. package/dist/plugins/media/api/index.mjs +6 -0
  115. package/dist/plugins/media/client/components/index.cjs +10 -0
  116. package/dist/plugins/media/client/components/index.d.cts +55 -0
  117. package/dist/plugins/media/client/components/index.d.mts +55 -0
  118. package/dist/plugins/media/client/components/index.d.ts +55 -0
  119. package/dist/plugins/media/client/components/index.mjs +2 -0
  120. package/dist/plugins/media/client/hooks/index.cjs +13 -0
  121. package/dist/plugins/media/client/hooks/index.d.cts +53 -0
  122. package/dist/plugins/media/client/hooks/index.d.mts +53 -0
  123. package/dist/plugins/media/client/hooks/index.d.ts +53 -0
  124. package/dist/plugins/media/client/hooks/index.mjs +1 -0
  125. package/dist/plugins/media/client/index.cjs +9 -0
  126. package/dist/plugins/media/client/index.d.cts +242 -0
  127. package/dist/plugins/media/client/index.d.mts +242 -0
  128. package/dist/plugins/media/client/index.d.ts +242 -0
  129. package/dist/plugins/media/client/index.mjs +2 -0
  130. package/dist/plugins/media/client.css +1 -0
  131. package/dist/plugins/media/query-keys.cjs +72 -0
  132. package/dist/plugins/media/query-keys.d.cts +49 -0
  133. package/dist/plugins/media/query-keys.d.mts +49 -0
  134. package/dist/plugins/media/query-keys.d.ts +49 -0
  135. package/dist/plugins/media/query-keys.mjs +70 -0
  136. package/dist/plugins/media/style.css +1 -0
  137. package/dist/shared/{stack.DOZ1EXjM.d.mts → stack.6mEHS2WH.d.mts} +3 -3
  138. package/dist/shared/{stack.DX-tQ93o.d.cts → stack.AJTXI7kw.d.cts} +3 -3
  139. package/dist/shared/{stack.DRpeDS6X.d.ts → stack.BMx2QYOK.d.ts} +25 -0
  140. package/dist/shared/stack.BUTXWiG-.d.ts +286 -0
  141. package/dist/shared/stack.C7Y9sBDg.d.mts +286 -0
  142. package/dist/shared/stack.C7vfOBmO.d.mts +63 -0
  143. package/dist/shared/stack.CAni8dnD.d.cts +63 -0
  144. package/dist/shared/stack.CLcnSF_b.d.cts +25 -0
  145. package/dist/shared/stack.CLcnSF_b.d.mts +25 -0
  146. package/dist/shared/stack.CLcnSF_b.d.ts +25 -0
  147. package/dist/shared/stack.CYSwntXC.d.ts +63 -0
  148. package/dist/shared/{stack.Jb0kQDJC.d.mts → stack.Cd6McBu1.d.mts} +25 -0
  149. package/dist/shared/stack.CoBj86jf.d.cts +109 -0
  150. package/dist/shared/stack.CoBj86jf.d.mts +109 -0
  151. package/dist/shared/stack.CoBj86jf.d.ts +109 -0
  152. package/dist/shared/{stack.BXxrFL9R.d.ts → stack.D7HSzZdG.d.ts} +5 -5
  153. package/dist/shared/{stack.DzOhpIYM.d.mts → stack.DjgpFWq3.d.cts} +5 -5
  154. package/dist/shared/{stack.BxFl46lB.d.cts → stack.DxQl8Wa1.d.cts} +25 -0
  155. package/dist/shared/{stack.BSqJrCTM.d.cts → stack.IUeyQKrm.d.mts} +5 -5
  156. package/dist/shared/{stack.VF6FhyZw.d.ts → stack.QYn-Px94.d.ts} +3 -3
  157. package/dist/shared/stack.vxskCkim.d.cts +286 -0
  158. package/package.json +115 -6
  159. package/src/plugins/blog/client/components/forms/image-field.tsx +35 -4
  160. package/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx +67 -12
  161. package/src/plugins/blog/client/components/forms/markdown-editor.tsx +106 -10
  162. package/src/plugins/blog/client/overrides.ts +58 -1
  163. package/src/plugins/cms/client/components/forms/content-form.tsx +26 -7
  164. package/src/plugins/cms/client/components/forms/file-upload.tsx +73 -15
  165. package/src/plugins/cms/client/overrides.ts +57 -2
  166. package/src/plugins/kanban/client/components/forms/board-form.tsx +1 -1
  167. package/src/plugins/kanban/client/components/forms/task-form.tsx +7 -1
  168. package/src/plugins/kanban/client/overrides.ts +25 -0
  169. package/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts +9 -0
  170. package/src/plugins/media/__tests__/getters.test.ts +274 -0
  171. package/src/plugins/media/__tests__/mutations.test.ts +299 -0
  172. package/src/plugins/media/__tests__/plugin.test.ts +752 -0
  173. package/src/plugins/media/__tests__/query-key-defs.test.ts +54 -0
  174. package/src/plugins/media/__tests__/storage-adapters.test.ts +351 -0
  175. package/src/plugins/media/api/adapters/local.ts +79 -0
  176. package/src/plugins/media/api/adapters/s3.ts +198 -0
  177. package/src/plugins/media/api/adapters/vercel-blob.ts +132 -0
  178. package/src/plugins/media/api/getters.ts +174 -0
  179. package/src/plugins/media/api/index.ts +41 -0
  180. package/src/plugins/media/api/mutations.ts +179 -0
  181. package/src/plugins/media/api/plugin.ts +855 -0
  182. package/src/plugins/media/api/query-key-defs.ts +41 -0
  183. package/src/plugins/media/api/serializers.ts +28 -0
  184. package/src/plugins/media/api/storage-adapter.ts +139 -0
  185. package/src/plugins/media/client/components/index.tsx +6 -0
  186. package/src/plugins/media/client/components/media-picker/asset-card.tsx +150 -0
  187. package/src/plugins/media/client/components/media-picker/asset-preview-button.tsx +67 -0
  188. package/src/plugins/media/client/components/media-picker/browse-tab.tsx +116 -0
  189. package/src/plugins/media/client/components/media-picker/folder-tree.tsx +188 -0
  190. package/src/plugins/media/client/components/media-picker/index.tsx +347 -0
  191. package/src/plugins/media/client/components/media-picker/upload-tab.tsx +108 -0
  192. package/src/plugins/media/client/components/media-picker/url-tab.tsx +72 -0
  193. package/src/plugins/media/client/components/media-picker/utils.ts +17 -0
  194. package/src/plugins/media/client/components/pages/library-page.internal.tsx +134 -0
  195. package/src/plugins/media/client/components/pages/library-page.tsx +42 -0
  196. package/src/plugins/media/client/hooks/index.tsx +9 -0
  197. package/src/plugins/media/client/hooks/use-media.tsx +289 -0
  198. package/src/plugins/media/client/index.ts +4 -0
  199. package/src/plugins/media/client/overrides.ts +127 -0
  200. package/src/plugins/media/client/plugin.tsx +184 -0
  201. package/src/plugins/media/client/upload.ts +171 -0
  202. package/src/plugins/media/client/utils/image-compression.ts +131 -0
  203. package/src/plugins/media/client.css +1 -0
  204. package/src/plugins/media/db.ts +62 -0
  205. package/src/plugins/media/query-keys.ts +96 -0
  206. package/src/plugins/media/schemas.ts +37 -0
  207. package/src/plugins/media/style.css +1 -0
  208. package/src/plugins/media/types.ts +26 -0
  209. package/dist/shared/{stack.BWp0hcm9.d.ts → stack.BQmuNl5p.d.cts} +3 -3
  210. package/dist/shared/{stack.BWp0hcm9.d.cts → stack.BQmuNl5p.d.mts} +3 -3
  211. package/dist/shared/{stack.BWp0hcm9.d.mts → stack.BQmuNl5p.d.ts} +3 -3
  212. package/dist/shared/{stack.BvCR4-9H.d.ts → stack.D4Cea8II.d.ts} +3 -3
  213. package/dist/shared/{stack.CWxAl9K3.d.mts → stack.HE_IvqV5.d.mts} +3 -3
  214. package/dist/shared/{stack.BOokfhZD.d.cts → stack.Rtcvl8sS.d.cts} +3 -3
@@ -8,7 +8,12 @@ import { editorViewCtx, parserCtx } from "@milkdown/kit/core";
8
8
  import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
9
9
  import { Slice } from "@milkdown/kit/prose/model";
10
10
  import { Selection } from "@milkdown/kit/prose/state";
11
- import { useLayoutEffect, useRef, useState } from "react";
11
+ import {
12
+ useLayoutEffect,
13
+ useRef,
14
+ useState,
15
+ type MutableRefObject,
16
+ } from "react";
12
17
 
13
18
  export interface MarkdownEditorProps {
14
19
  value?: string;
@@ -18,6 +23,19 @@ export interface MarkdownEditorProps {
18
23
  uploadImage?: (file: File) => Promise<string>;
19
24
  /** Placeholder text shown when the editor is empty. */
20
25
  placeholder?: string;
26
+ /**
27
+ * Optional ref that will be populated with an `insertImage(url)` function.
28
+ * Call `insertImageRef.current?.(url)` to programmatically insert an image.
29
+ * The URL must be a valid, percent-encoded URL (storage adapters guarantee this).
30
+ */
31
+ insertImageRef?: MutableRefObject<((url: string) => void) | null>;
32
+ /**
33
+ * When provided, clicking the Crepe image block's upload area opens a media
34
+ * picker instead of the native file dialog. The callback receives a `setUrl`
35
+ * function — call it with the chosen URL to set it into the image block.
36
+ * The URL must be a valid, percent-encoded URL (storage adapters guarantee this).
37
+ */
38
+ openMediaPickerForImageBlock?: (setUrl: (url: string) => void) => void;
21
39
  }
22
40
 
23
41
  export function MarkdownEditor({
@@ -26,6 +44,8 @@ export function MarkdownEditor({
26
44
  className,
27
45
  uploadImage,
28
46
  placeholder = "Write something...",
47
+ insertImageRef,
48
+ openMediaPickerForImageBlock,
29
49
  }: MarkdownEditorProps) {
30
50
  const containerRef = useRef<HTMLDivElement | null>(null);
31
51
  const crepeRef = useRef<Crepe | null>(null);
@@ -33,6 +53,9 @@ export function MarkdownEditor({
33
53
  const [isReady, setIsReady] = useState(false);
34
54
  const onChangeRef = useRef<typeof onChange>(onChange);
35
55
  const initialValueRef = useRef<string>(value ?? "");
56
+ const openMediaPickerRef = useRef<typeof openMediaPickerForImageBlock>(
57
+ openMediaPickerForImageBlock,
58
+ );
36
59
  type ThrottledFn = ((markdown: string) => void) & {
37
60
  cancel?: () => void;
38
61
  flush?: () => void;
@@ -40,12 +63,24 @@ export function MarkdownEditor({
40
63
  const throttledOnChangeRef = useRef<ThrottledFn | null>(null);
41
64
 
42
65
  onChangeRef.current = onChange;
66
+ openMediaPickerRef.current = openMediaPickerForImageBlock;
43
67
 
44
68
  useLayoutEffect(() => {
45
69
  if (crepeRef.current) return;
46
70
  const container = containerRef.current;
47
71
  if (!container) return;
48
72
 
73
+ const hasMediaPicker = !!openMediaPickerRef.current;
74
+
75
+ const imageBlockConfig: Record<string, unknown> = {};
76
+ if (uploadImage) {
77
+ imageBlockConfig.onUpload = async (file: File) => uploadImage(file);
78
+ }
79
+ if (hasMediaPicker) {
80
+ imageBlockConfig.blockUploadPlaceholderText = "Media Picker";
81
+ imageBlockConfig.inlineUploadPlaceholderText = "Media Picker";
82
+ }
83
+
49
84
  const crepe = new Crepe({
50
85
  root: container,
51
86
  defaultValue: initialValueRef.current,
@@ -53,19 +88,47 @@ export function MarkdownEditor({
53
88
  [CrepeFeature.Placeholder]: {
54
89
  text: placeholder,
55
90
  },
56
- ...(uploadImage
57
- ? {
58
- [CrepeFeature.ImageBlock]: {
59
- onUpload: async (file: File) => {
60
- const url = await uploadImage(file);
61
- return url;
62
- },
63
- },
64
- }
91
+ ...(Object.keys(imageBlockConfig).length > 0
92
+ ? { [CrepeFeature.ImageBlock]: imageBlockConfig }
65
93
  : {}),
66
94
  },
67
95
  });
68
96
 
97
+ // Intercept clicks on Crepe image-block upload placeholders so that the
98
+ // native file dialog is suppressed and the media picker is opened instead.
99
+ const interceptHandler = (e: MouseEvent) => {
100
+ if (!openMediaPickerRef.current) return;
101
+ const target = e.target as Element;
102
+ // Only intercept clicks inside the upload placeholder area.
103
+ const inPlaceholder = target.closest(".image-edit .placeholder");
104
+ if (!inPlaceholder) return;
105
+ // Let the hidden file <input> itself through (shouldn't receive clicks normally).
106
+ if ((target as HTMLElement).matches("input")) return;
107
+
108
+ e.preventDefault();
109
+ e.stopPropagation();
110
+
111
+ const imageEdit = inPlaceholder.closest(".image-edit");
112
+ const linkInput = imageEdit?.querySelector(
113
+ ".link-input-area",
114
+ ) as HTMLInputElement | null;
115
+
116
+ openMediaPickerRef.current((url: string) => {
117
+ if (!linkInput) return;
118
+ // Use the native setter so Vue's reactivity picks up the change.
119
+ const nativeSetter = Object.getOwnPropertyDescriptor(
120
+ HTMLInputElement.prototype,
121
+ "value",
122
+ )?.set;
123
+ nativeSetter?.call(linkInput, url);
124
+ linkInput.dispatchEvent(new Event("input", { bubbles: true }));
125
+ linkInput.dispatchEvent(
126
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
127
+ );
128
+ });
129
+ };
130
+ container.addEventListener("click", interceptHandler, true);
131
+
69
132
  // Prepare throttled onChange once per editor instance
70
133
  throttledOnChangeRef.current = throttle((markdown: string) => {
71
134
  if (onChangeRef.current) onChangeRef.current(markdown);
@@ -86,6 +149,7 @@ export function MarkdownEditor({
86
149
  crepeRef.current = crepe;
87
150
 
88
151
  return () => {
152
+ container.removeEventListener("click", interceptHandler, true);
89
153
  try {
90
154
  isReadyRef.current = false;
91
155
  throttledOnChangeRef.current?.cancel?.();
@@ -133,6 +197,38 @@ export function MarkdownEditor({
133
197
  });
134
198
  }, [value, isReady]);
135
199
 
200
+ // Expose insertImage via ref so the parent can insert images programmatically
201
+ useLayoutEffect(() => {
202
+ if (!insertImageRef) return;
203
+ insertImageRef.current = (url: string) => {
204
+ if (!crepeRef.current || !isReadyRef.current) return;
205
+ try {
206
+ const currentMarkdown = crepeRef.current.getMarkdown?.() ?? "";
207
+ const imageMarkdown = `\n\n![](${url})\n\n`;
208
+ const newMarkdown = currentMarkdown.trimEnd() + imageMarkdown;
209
+ crepeRef.current.editor.action((ctx) => {
210
+ const view = ctx.get(editorViewCtx);
211
+ const parser = ctx.get(parserCtx);
212
+ const doc = parser(newMarkdown);
213
+ if (!doc) return;
214
+ const state = view.state;
215
+ const tr = state.tr.replace(
216
+ 0,
217
+ state.doc.content.size,
218
+ new Slice(doc.content, 0, 0),
219
+ );
220
+ view.dispatch(tr);
221
+ });
222
+ if (onChangeRef.current) onChangeRef.current(newMarkdown);
223
+ } catch {
224
+ // Editor may not be ready yet
225
+ }
226
+ };
227
+ return () => {
228
+ if (insertImageRef) insertImageRef.current = null;
229
+ };
230
+ }, [insertImageRef]);
231
+
136
232
  return (
137
233
  <div ref={containerRef} className={cn("milkdown-custom", className)} />
138
234
  );
@@ -2,6 +2,18 @@ import type { SerializedPost } from "../types";
2
2
  import type { ComponentType, ReactNode } from "react";
3
3
  import type { BlogLocalization } from "./localization";
4
4
 
5
+ /**
6
+ * Props for the overridable blog featured image input component.
7
+ */
8
+ export interface BlogImageInputFieldProps {
9
+ /** Current image URL value */
10
+ value: string;
11
+ /** Called when the image URL changes */
12
+ onChange: (value: string) => void;
13
+ /** Whether the field is required */
14
+ isRequired?: boolean;
15
+ }
16
+
5
17
  /**
6
18
  * Context passed to lifecycle hooks
7
19
  */
@@ -48,9 +60,54 @@ export interface BlogPluginOverrides {
48
60
  React.ImgHTMLAttributes<HTMLImageElement> & Record<string, any>
49
61
  >;
50
62
  /**
51
- * Function used to upload an image and return its URL.
63
+ * Function used to upload a new image file and return its URL.
64
+ * This is separate from `imagePicker`, which selects an existing asset URL.
52
65
  */
53
66
  uploadImage: (file: File) => Promise<string>;
67
+ /**
68
+ * Optional custom component for the featured image field.
69
+ *
70
+ * When provided it replaces the default file-upload input entirely.
71
+ * The component receives `value` (current URL string) and `onChange` (setter).
72
+ *
73
+ * Typical use case: render a preview when a value is set, and a media-picker
74
+ * trigger when no value is set.
75
+ *
76
+ * @example
77
+ * ```tsx
78
+ * imageInputField: ({ value, onChange }) =>
79
+ * value ? (
80
+ * <div>
81
+ * <img src={value} alt="Preview" />
82
+ * <MediaPicker trigger={<button>Change</button>} accept={["image/*"]}
83
+ * onSelect={(assets) => onChange(assets[0].url)} />
84
+ * </div>
85
+ * ) : (
86
+ * <MediaPicker trigger={<button>Browse media</button>} accept={["image/*"]}
87
+ * onSelect={(assets) => onChange(assets[0].url)} />
88
+ * )
89
+ * ```
90
+ */
91
+ imageInputField?: ComponentType<BlogImageInputFieldProps>;
92
+
93
+ /**
94
+ * Optional trigger component for a media picker.
95
+ * When provided, it is rendered adjacent to the Markdown editor and allows
96
+ * users to browse and select previously uploaded assets.
97
+ * Receives `onSelect(url)` — insert the chosen URL into the editor.
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * imagePicker: ({ onSelect }) => (
102
+ * <MediaPicker
103
+ * trigger={<Button size="sm" variant="outline">Browse media</Button>}
104
+ * accept={["image/*"]}
105
+ * onSelect={(assets) => onSelect(assets[0].url)}
106
+ * />
107
+ * )
108
+ * ```
109
+ */
110
+ imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
54
111
  /**
55
112
  * Localization object for the blog plugin
56
113
  */
@@ -56,6 +56,12 @@ function buildFieldConfigFromJsonSchema(
56
56
  string,
57
57
  React.ComponentType<AutoFormInputComponentProps>
58
58
  >,
59
+ imagePicker?: React.ComponentType<{ onSelect: (url: string) => void }>,
60
+ imageInputField?: React.ComponentType<{
61
+ value: string;
62
+ onChange: (value: string) => void;
63
+ isRequired?: boolean;
64
+ }>,
59
65
  ): FieldConfig<Record<string, unknown>> {
60
66
  // Get base config from shared utility (handles fieldType from JSON Schema)
61
67
  const baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);
@@ -73,14 +79,14 @@ function buildFieldConfigFromJsonSchema(
73
79
  // Handle "file" fieldType when there's NO custom component for "file"
74
80
  if (prop.fieldType === "file" && !fieldComponents?.["file"]) {
75
81
  // Use CMSFileUpload as the default file component
76
- if (!uploadImage) {
77
- // Show a clear error message if uploadImage is not provided
82
+ if (!uploadImage && !imageInputField) {
83
+ // Show a clear error message if neither uploadImage nor imageInputField is provided
78
84
  baseConfig[key] = {
79
85
  ...baseConfig[key],
80
86
  fieldType: () => (
81
87
  <div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
82
- File upload requires an <code>uploadImage</code> function in CMS
83
- overrides.
88
+ File upload requires an <code>uploadImage</code> or{" "}
89
+ <code>imageInputField</code> function in CMS overrides.
84
90
  </div>
85
91
  ),
86
92
  };
@@ -88,7 +94,12 @@ function buildFieldConfigFromJsonSchema(
88
94
  baseConfig[key] = {
89
95
  ...baseConfig[key],
90
96
  fieldType: (props: AutoFormInputComponentProps) => (
91
- <CMSFileUpload {...props} uploadImage={uploadImage} />
97
+ <CMSFileUpload
98
+ {...props}
99
+ uploadImage={uploadImage ?? (() => Promise.resolve(""))}
100
+ imageInputField={imageInputField}
101
+ imagePicker={imagePicker}
102
+ />
92
103
  ),
93
104
  };
94
105
  }
@@ -151,6 +162,8 @@ export function ContentForm({
151
162
  const {
152
163
  localization: customLocalization,
153
164
  uploadImage,
165
+ imagePicker,
166
+ imageInputField,
154
167
  fieldComponents,
155
168
  } = usePluginOverrides<CMSPluginOverrides>("cms");
156
169
  const localization = { ...CMS_LOCALIZATION, ...customLocalization };
@@ -214,8 +227,14 @@ export function ContentForm({
214
227
  // Build field config for AutoForm (fieldType is now embedded in jsonSchema)
215
228
  const fieldConfig = useMemo(
216
229
  () =>
217
- buildFieldConfigFromJsonSchema(jsonSchema, uploadImage, fieldComponents),
218
- [jsonSchema, uploadImage, fieldComponents],
230
+ buildFieldConfigFromJsonSchema(
231
+ jsonSchema,
232
+ uploadImage,
233
+ fieldComponents,
234
+ imagePicker,
235
+ imageInputField,
236
+ ),
237
+ [jsonSchema, uploadImage, fieldComponents, imagePicker, imageInputField],
219
238
  );
220
239
 
221
240
  // Find the field to use for slug auto-generation
@@ -1,6 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { useState, useCallback, useEffect, type ChangeEvent } from "react";
3
+ import {
4
+ useState,
5
+ useCallback,
6
+ useEffect,
7
+ type ChangeEvent,
8
+ type ComponentType,
9
+ } from "react";
4
10
  import { toast } from "sonner";
5
11
  import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
6
12
  import { Input } from "@workspace/ui/components/input";
@@ -23,6 +29,20 @@ export interface CMSFileUploadProps extends AutoFormInputComponentProps {
23
29
  * This is required - consumers must provide an upload implementation.
24
30
  */
25
31
  uploadImage: (file: File) => Promise<string>;
32
+ /**
33
+ * Optional custom component for the image field.
34
+ * When provided, it replaces the default file-upload input entirely.
35
+ */
36
+ imageInputField?: ComponentType<{
37
+ value: string;
38
+ onChange: (value: string) => void;
39
+ isRequired?: boolean;
40
+ }>;
41
+ /**
42
+ * Optional trigger component for a media picker.
43
+ * When provided, it is rendered as a "Browse media" option.
44
+ */
45
+ imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
26
46
  }
27
47
 
28
48
  /**
@@ -54,6 +74,8 @@ export function CMSFileUpload({
54
74
  fieldProps,
55
75
  field,
56
76
  uploadImage,
77
+ imageInputField: ImageInputField,
78
+ imagePicker: ImagePickerTrigger,
57
79
  }: CMSFileUploadProps) {
58
80
  // Exclude showLabel and value from props spread
59
81
  // File inputs cannot have their value set programmatically (browser security)
@@ -63,6 +85,8 @@ export function CMSFileUpload({
63
85
  ...safeFieldProps
64
86
  } = fieldProps;
65
87
  const showLabel = _showLabel === undefined ? true : _showLabel;
88
+
89
+ // All hooks must be called unconditionally before any early return.
66
90
  const [isUploading, setIsUploading] = useState(false);
67
91
  const [previewUrl, setPreviewUrl] = useState<string | null>(
68
92
  field.value || null,
@@ -80,7 +104,6 @@ export function CMSFileUpload({
80
104
  const file = e.target.files?.[0];
81
105
  if (!file) return;
82
106
 
83
- // Check if it's an image
84
107
  if (!file.type.startsWith("image/")) {
85
108
  toast.error("Please select an image file");
86
109
  return;
@@ -106,6 +129,29 @@ export function CMSFileUpload({
106
129
  field.onChange("");
107
130
  }, [field]);
108
131
 
132
+ // When a custom imageInputField component is provided via overrides, delegate to it.
133
+ if (ImageInputField) {
134
+ return (
135
+ <FormItem>
136
+ {showLabel && (
137
+ <AutoFormLabel
138
+ label={fieldConfigItem?.label || label}
139
+ isRequired={isRequired}
140
+ />
141
+ )}
142
+ <FormControl>
143
+ <ImageInputField
144
+ value={field.value || ""}
145
+ onChange={field.onChange}
146
+ isRequired={isRequired}
147
+ />
148
+ </FormControl>
149
+ <AutoFormTooltip fieldConfigItem={fieldConfigItem} />
150
+ <FormMessage />
151
+ </FormItem>
152
+ );
153
+ }
154
+
109
155
  return (
110
156
  <FormItem>
111
157
  {showLabel && (
@@ -116,19 +162,31 @@ export function CMSFileUpload({
116
162
  )}
117
163
  {!previewUrl && (
118
164
  <FormControl>
119
- <div className="relative">
120
- <Input
121
- type="file"
122
- accept="image/*"
123
- {...safeFieldProps}
124
- onChange={handleFileChange}
125
- disabled={isUploading}
126
- className="cursor-pointer"
127
- data-testid="image-upload-input"
128
- />
129
- {isUploading && (
130
- <div className="absolute inset-0 flex items-center justify-center bg-background/80">
131
- <Loader2 className="h-4 w-4 animate-spin" />
165
+ <div className="space-y-2">
166
+ <div className="relative">
167
+ <Input
168
+ type="file"
169
+ accept="image/*"
170
+ {...safeFieldProps}
171
+ onChange={handleFileChange}
172
+ disabled={isUploading}
173
+ className="cursor-pointer"
174
+ data-testid="image-upload-input"
175
+ />
176
+ {isUploading && (
177
+ <div className="absolute inset-0 flex items-center justify-center bg-background/80">
178
+ <Loader2 className="h-4 w-4 animate-spin" />
179
+ </div>
180
+ )}
181
+ </div>
182
+ {ImagePickerTrigger && (
183
+ <div data-testid="image-picker-trigger">
184
+ <ImagePickerTrigger
185
+ onSelect={(url: string) => {
186
+ setPreviewUrl(url);
187
+ field.onChange(url);
188
+ }}
189
+ />
132
190
  </div>
133
191
  )}
134
192
  </div>
@@ -2,6 +2,18 @@ import type { ComponentType } from "react";
2
2
  import type { CMSLocalization } from "./localization";
3
3
  import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
4
4
 
5
+ /**
6
+ * Props for the overridable CMS image input field component.
7
+ */
8
+ export interface CmsImageInputFieldProps {
9
+ /** Current image URL value */
10
+ value: string;
11
+ /** Called when the image URL changes */
12
+ onChange: (value: string) => void;
13
+ /** Whether the field is required */
14
+ isRequired?: boolean;
15
+ }
16
+
5
17
  /**
6
18
  * Context passed to lifecycle hooks
7
19
  */
@@ -46,11 +58,54 @@ export interface CMSPluginOverrides {
46
58
  >;
47
59
 
48
60
  /**
49
- * Function used to upload an image and return its URL.
50
- * Used by the default "file" field component.
61
+ * Function used to upload a new image file and return its URL.
62
+ * Used by the default "file" field component when not selecting an existing
63
+ * asset via `imagePicker` or `imageInputField`.
51
64
  */
52
65
  uploadImage?: (file: File) => Promise<string>;
53
66
 
67
+ /**
68
+ * Optional custom component for image fields (fieldType: "file").
69
+ *
70
+ * When provided it replaces the default file-upload input entirely.
71
+ * The component receives `value` (current URL string) and `onChange` (setter).
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * imageInputField: ({ value, onChange }) =>
76
+ * value ? (
77
+ * <div>
78
+ * <img src={value} alt="Preview" />
79
+ * <MediaPicker trigger={<button>Change</button>} accept={["image/*"]}
80
+ * onSelect={(assets) => onChange(assets[0].url)} />
81
+ * </div>
82
+ * ) : (
83
+ * <MediaPicker trigger={<button>Browse media</button>} accept={["image/*"]}
84
+ * onSelect={(assets) => onChange(assets[0].url)} />
85
+ * )
86
+ * ```
87
+ */
88
+ imageInputField?: ComponentType<CmsImageInputFieldProps>;
89
+
90
+ /**
91
+ * Optional trigger component for a media picker.
92
+ * When provided, it is rendered inside the default "file" field component as a
93
+ * "Browse media" option, letting users select a previously uploaded asset.
94
+ * Receives `onSelect(url)` — the URL is set as the field value.
95
+ *
96
+ * @example
97
+ * ```tsx
98
+ * imagePicker: ({ onSelect }) => (
99
+ * <MediaPicker
100
+ * trigger={<Button size="sm" variant="outline">Browse media</Button>}
101
+ * accept={["image/*"]}
102
+ * onSelect={(assets) => onSelect(assets[0].url)}
103
+ * />
104
+ * )
105
+ * ```
106
+ */
107
+ imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
108
+
54
109
  /**
55
110
  * Custom field components for AutoForm fields.
56
111
  *
@@ -50,7 +50,7 @@ export function BoardForm({ board, onClose, onSuccess }: BoardFormProps) {
50
50
  };
51
51
 
52
52
  return (
53
- <form onSubmit={handleSubmit} className="space-y-4">
53
+ <form onSubmit={handleSubmit} className="space-y-4 overflow-x-hidden">
54
54
  <div className="space-y-2">
55
55
  <Label htmlFor="name">Name *</Label>
56
56
  <Input
@@ -14,7 +14,9 @@ import {
14
14
  } from "@workspace/ui/components/select";
15
15
  import { MinimalTiptapEditor } from "@workspace/ui/components/minimal-tiptap";
16
16
  import SearchSelect from "@workspace/ui/components/search-select";
17
+ import { usePluginOverrides } from "@btst/stack/context";
17
18
  import { useTaskMutations, useSearchUsers } from "../../hooks/kanban-hooks";
19
+ import type { KanbanPluginOverrides } from "../../overrides";
18
20
  import { PRIORITY_OPTIONS } from "../../../utils";
19
21
  import type {
20
22
  SerializedColumn,
@@ -44,6 +46,8 @@ export function TaskForm({
44
46
  onDelete,
45
47
  }: TaskFormProps) {
46
48
  const isEditing = !!taskId;
49
+ const { uploadImage, imagePicker: imagePickerTrigger } =
50
+ usePluginOverrides<KanbanPluginOverrides>("kanban");
47
51
  const {
48
52
  createTask,
49
53
  updateTask,
@@ -155,7 +159,7 @@ export function TaskForm({
155
159
  };
156
160
 
157
161
  return (
158
- <form onSubmit={handleSubmit} className="space-y-4">
162
+ <form onSubmit={handleSubmit} className="space-y-4 overflow-x-hidden">
159
163
  <div className="space-y-2">
160
164
  <Label htmlFor="title">Title *</Label>
161
165
  <Input
@@ -227,6 +231,8 @@ export function TaskForm({
227
231
  output="markdown"
228
232
  placeholder="Describe the task..."
229
233
  className="min-h-[150px]"
234
+ uploader={uploadImage}
235
+ imagePickerTrigger={imagePickerTrigger}
230
236
  />
231
237
  </div>
232
238
 
@@ -73,6 +73,31 @@ export interface KanbanPluginOverrides {
73
73
  */
74
74
  headers?: HeadersInit;
75
75
 
76
+ /**
77
+ * Function used to upload a new image file from the task description editor
78
+ * and return its URL. This is separate from `imagePicker`, which selects an
79
+ * existing asset URL.
80
+ */
81
+ uploadImage?: (file: File) => Promise<string>;
82
+
83
+ /**
84
+ * Optional trigger component for a media picker.
85
+ * When provided, it appears inside the image insertion dialog of the task description editor,
86
+ * letting users browse and select previously uploaded assets.
87
+ *
88
+ * @example
89
+ * ```tsx
90
+ * imagePicker: ({ onSelect }) => (
91
+ * <MediaPicker
92
+ * trigger={<Button size="sm" variant="outline">Browse media</Button>}
93
+ * accept={["image/*"]}
94
+ * onSelect={(assets) => onSelect(assets[0].url)}
95
+ * />
96
+ * )
97
+ * ```
98
+ */
99
+ imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
100
+
76
101
  // ============ User Resolution (required for assignee features) ============
77
102
 
78
103
  /**
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Stub for @vercel/blob/server — used in tests only.
3
+ * The real module is an optional peer dependency.
4
+ */
5
+ export async function handleUpload(_options: unknown): Promise<unknown> {
6
+ throw new Error(
7
+ "handleUpload is not available in the installed @vercel/blob version. Use a version that exports @vercel/blob/server.",
8
+ );
9
+ }