@open-cloud-initiative/editor-x 0.0.1

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 (190) hide show
  1. package/.devcontainer/Dockerfile +13 -0
  2. package/.devcontainer/devcontainer.json +52 -0
  3. package/.github/dependabot.yml +10 -0
  4. package/.github/workflows/pages.yml +42 -0
  5. package/.github/workflows/publish.yml +41 -0
  6. package/.vscode/settings.json +7 -0
  7. package/LICENSE +9 -0
  8. package/README.md +9 -0
  9. package/app/_component/editor.tsx +383 -0
  10. package/app/layout.tsx +46 -0
  11. package/app/page.tsx +11 -0
  12. package/app/r/registry.json/route.ts +22 -0
  13. package/components/editorx/editor.tsx +1794 -0
  14. package/components/editorx/extensions/floating-menu.tsx +376 -0
  15. package/components/editorx/extensions/floating-toolbar.tsx +97 -0
  16. package/components/editorx/extensions/image-placeholder.tsx +316 -0
  17. package/components/editorx/extensions/image.tsx +462 -0
  18. package/components/editorx/extensions/search-and-replace.tsx +438 -0
  19. package/components/editorx/rich-text-editor.tsx +383 -0
  20. package/components/editorx/tiptap.css +421 -0
  21. package/components/editorx/toolbars/alignment.tsx +126 -0
  22. package/components/editorx/toolbars/blockquote.tsx +47 -0
  23. package/components/editorx/toolbars/bold.tsx +48 -0
  24. package/components/editorx/toolbars/bullet-list.tsx +48 -0
  25. package/components/editorx/toolbars/code-block.tsx +47 -0
  26. package/components/editorx/toolbars/code.tsx +43 -0
  27. package/components/editorx/toolbars/color-and-highlight.tsx +215 -0
  28. package/components/editorx/toolbars/editor-toolbar.tsx +77 -0
  29. package/components/editorx/toolbars/hard-break.tsx +46 -0
  30. package/components/editorx/toolbars/headings.tsx +97 -0
  31. package/components/editorx/toolbars/horizontal-rule.tsx +42 -0
  32. package/components/editorx/toolbars/image-placeholder-toolbar.tsx +47 -0
  33. package/components/editorx/toolbars/italic.tsx +48 -0
  34. package/components/editorx/toolbars/link.tsx +130 -0
  35. package/components/editorx/toolbars/mobile-toolbar-group.tsx +76 -0
  36. package/components/editorx/toolbars/ordered-list.tsx +47 -0
  37. package/components/editorx/toolbars/redo.tsx +44 -0
  38. package/components/editorx/toolbars/strikethrough.tsx +48 -0
  39. package/components/editorx/toolbars/toolbar-provider.tsx +29 -0
  40. package/components/editorx/toolbars/underline.tsx +48 -0
  41. package/components/editorx/toolbars/undo.tsx +43 -0
  42. package/components/layout/theme-switcher.tsx +26 -0
  43. package/components/main-nav.tsx +24 -0
  44. package/components/mobile-nav.tsx +46 -0
  45. package/components/open-in-v0-button.tsx +38 -0
  46. package/components/page-header.tsx +30 -0
  47. package/components/site-footer.tsx +41 -0
  48. package/components/site-header.tsx +32 -0
  49. package/components/theme-provider.tsx +8 -0
  50. package/components/ui/button.tsx +57 -0
  51. package/components/ui/checkbox.tsx +30 -0
  52. package/components/ui/collapsible.tsx +11 -0
  53. package/components/ui/command.tsx +148 -0
  54. package/components/ui/dialog.tsx +122 -0
  55. package/components/ui/drawer.tsx +118 -0
  56. package/components/ui/dropdown-menu.tsx +201 -0
  57. package/components/ui/input.tsx +22 -0
  58. package/components/ui/label.tsx +26 -0
  59. package/components/ui/popover.tsx +33 -0
  60. package/components/ui/resizable.tsx +40 -0
  61. package/components/ui/scroll-area.tsx +42 -0
  62. package/components/ui/separator.tsx +31 -0
  63. package/components/ui/sheet.tsx +140 -0
  64. package/components/ui/sidebar.tsx +763 -0
  65. package/components/ui/skeleton.tsx +15 -0
  66. package/components/ui/spinner.tsx +29 -0
  67. package/components/ui/tabs.tsx +55 -0
  68. package/components/ui/toggle-group.tsx +61 -0
  69. package/components/ui/toggle.tsx +45 -0
  70. package/components/ui/tooltip.tsx +32 -0
  71. package/components.json +21 -0
  72. package/config/site.ts +15 -0
  73. package/eslint.config.mjs +20 -0
  74. package/hooks/use-character-limit.ts +28 -0
  75. package/hooks/use-copy-to-clipboard.ts +16 -0
  76. package/hooks/use-debounce.ts +17 -0
  77. package/hooks/use-image-upload.ts +97 -0
  78. package/hooks/use-media-querry.ts +18 -0
  79. package/hooks/use-mobile.tsx +19 -0
  80. package/images/editor.png +0 -0
  81. package/lib/content.ts +39 -0
  82. package/lib/cookie-client.ts +19 -0
  83. package/lib/localstorage-client.ts +19 -0
  84. package/lib/package.ts +144 -0
  85. package/lib/preferences-config.ts +72 -0
  86. package/lib/preferences-storage.ts +20 -0
  87. package/lib/theme-utils.ts +12 -0
  88. package/lib/theme.ts +50 -0
  89. package/lib/tiptap-utils.ts +45 -0
  90. package/lib/utils.ts +11 -0
  91. package/next-env.d.ts +6 -0
  92. package/next.config.mjs +11 -0
  93. package/package.json +92 -0
  94. package/postcss.config.mjs +8 -0
  95. package/prettier.config.mjs +15 -0
  96. package/public/android-chrome-192x192.png +0 -0
  97. package/public/android-chrome-512x512.png +0 -0
  98. package/public/apple-touch-icon.png +0 -0
  99. package/public/favicon-16x16.png +0 -0
  100. package/public/favicon-32x32.png +0 -0
  101. package/public/favicon.ico +0 -0
  102. package/public/file.svg +1 -0
  103. package/public/globe.svg +1 -0
  104. package/public/next.svg +1 -0
  105. package/public/og.webp +0 -0
  106. package/public/r/editor-x.json +85 -0
  107. package/public/r/registry.json +93 -0
  108. package/public/site.webmanifest +19 -0
  109. package/public/vercel.svg +1 -0
  110. package/public/window.svg +1 -0
  111. package/registry/editor/components/editor.tsx +1794 -0
  112. package/registry/editor/components/extensions/floating-menu.tsx +376 -0
  113. package/registry/editor/components/extensions/floating-toolbar.tsx +97 -0
  114. package/registry/editor/components/extensions/image-placeholder.tsx +316 -0
  115. package/registry/editor/components/extensions/image.tsx +462 -0
  116. package/registry/editor/components/extensions/search-and-replace.tsx +438 -0
  117. package/registry/editor/components/rich-text-editor.tsx +383 -0
  118. package/registry/editor/components/tiptap.css +421 -0
  119. package/registry/editor/components/toolbars/alignment.tsx +126 -0
  120. package/registry/editor/components/toolbars/blockquote.tsx +47 -0
  121. package/registry/editor/components/toolbars/bold.tsx +48 -0
  122. package/registry/editor/components/toolbars/bullet-list.tsx +48 -0
  123. package/registry/editor/components/toolbars/code-block.tsx +47 -0
  124. package/registry/editor/components/toolbars/code.tsx +43 -0
  125. package/registry/editor/components/toolbars/color-and-highlight.tsx +215 -0
  126. package/registry/editor/components/toolbars/editor-toolbar.tsx +77 -0
  127. package/registry/editor/components/toolbars/hard-break.tsx +46 -0
  128. package/registry/editor/components/toolbars/headings.tsx +97 -0
  129. package/registry/editor/components/toolbars/horizontal-rule.tsx +42 -0
  130. package/registry/editor/components/toolbars/image-placeholder-toolbar.tsx +47 -0
  131. package/registry/editor/components/toolbars/italic.tsx +48 -0
  132. package/registry/editor/components/toolbars/link.tsx +130 -0
  133. package/registry/editor/components/toolbars/mobile-toolbar-group.tsx +76 -0
  134. package/registry/editor/components/toolbars/ordered-list.tsx +47 -0
  135. package/registry/editor/components/toolbars/redo.tsx +44 -0
  136. package/registry/editor/components/toolbars/strikethrough.tsx +48 -0
  137. package/registry/editor/components/toolbars/toolbar-provider.tsx +29 -0
  138. package/registry/editor/components/toolbars/underline.tsx +48 -0
  139. package/registry/editor/components/toolbars/undo.tsx +43 -0
  140. package/registry/editor/components/ui/button.tsx +57 -0
  141. package/registry/editor/components/ui/checkbox.tsx +30 -0
  142. package/registry/editor/components/ui/collapsible.tsx +11 -0
  143. package/registry/editor/components/ui/command.tsx +148 -0
  144. package/registry/editor/components/ui/dialog.tsx +122 -0
  145. package/registry/editor/components/ui/drawer.tsx +118 -0
  146. package/registry/editor/components/ui/dropdown-menu.tsx +201 -0
  147. package/registry/editor/components/ui/input.tsx +22 -0
  148. package/registry/editor/components/ui/label.tsx +26 -0
  149. package/registry/editor/components/ui/popover.tsx +33 -0
  150. package/registry/editor/components/ui/resizable.tsx +40 -0
  151. package/registry/editor/components/ui/scroll-area.tsx +42 -0
  152. package/registry/editor/components/ui/separator.tsx +31 -0
  153. package/registry/editor/components/ui/sheet.tsx +140 -0
  154. package/registry/editor/components/ui/sidebar.tsx +763 -0
  155. package/registry/editor/components/ui/skeleton.tsx +15 -0
  156. package/registry/editor/components/ui/spinner.tsx +29 -0
  157. package/registry/editor/components/ui/tabs.tsx +55 -0
  158. package/registry/editor/components/ui/toggle-group.tsx +61 -0
  159. package/registry/editor/components/ui/toggle.tsx +45 -0
  160. package/registry/editor/components/ui/tooltip.tsx +32 -0
  161. package/registry/editor/hooks/use-character-limit.ts +28 -0
  162. package/registry/editor/hooks/use-copy-to-clipboard.ts +16 -0
  163. package/registry/editor/hooks/use-debounce.ts +17 -0
  164. package/registry/editor/hooks/use-image-upload.ts +97 -0
  165. package/registry/editor/hooks/use-media-querry.ts +18 -0
  166. package/registry/editor/hooks/use-mobile.tsx +19 -0
  167. package/registry/editor/lib/content.ts +39 -0
  168. package/registry/editor/lib/cookie-client.ts +19 -0
  169. package/registry/editor/lib/localstorage-client.ts +19 -0
  170. package/registry/editor/lib/package.ts +144 -0
  171. package/registry/editor/lib/preferences-config.ts +72 -0
  172. package/registry/editor/lib/preferences-storage.ts +20 -0
  173. package/registry/editor/lib/theme-utils.ts +12 -0
  174. package/registry/editor/lib/theme.ts +50 -0
  175. package/registry/editor/lib/tiptap-utils.ts +45 -0
  176. package/registry/editor/lib/utils.ts +11 -0
  177. package/registry/editor/page.tsx +9 -0
  178. package/registry.json +93 -0
  179. package/reset.d.ts +1 -0
  180. package/scripts/generate-theme-presets.ts +128 -0
  181. package/scripts/postCreateCommand.sh +0 -0
  182. package/scripts/theme-boot.tsx +105 -0
  183. package/server/server-actions.ts +27 -0
  184. package/stores/preferences/preferences-provider.tsx +55 -0
  185. package/stores/preferences/preferences-store.ts +23 -0
  186. package/styles/globals.css +288 -0
  187. package/styles/presets/brutalist.css +89 -0
  188. package/styles/presets/soft-pop.css +89 -0
  189. package/styles/presets/tangerine.css +89 -0
  190. package/tsconfig.json +50 -0
@@ -0,0 +1,462 @@
1
+ 'use client'
2
+ // @ts-nocheck
3
+ /* eslint-disable */
4
+ import Image from "@tiptap/extension-image";
5
+ import {
6
+ type NodeViewProps,
7
+ NodeViewWrapper,
8
+ ReactNodeViewRenderer,
9
+ } from "@tiptap/react";
10
+ import {
11
+ AlignCenter,
12
+ AlignLeft,
13
+ AlignRight,
14
+ Edit,
15
+ ImageIcon,
16
+ Loader2,
17
+ Maximize,
18
+ MoreVertical,
19
+ Trash,
20
+ } from "lucide-react";
21
+ import { useEffect, useRef, useState } from "react";
22
+
23
+ import { Button } from "@/components/ui/button";
24
+ import {
25
+ DropdownMenu,
26
+ DropdownMenuContent,
27
+ DropdownMenuItem,
28
+ DropdownMenuSeparator,
29
+ DropdownMenuSub,
30
+ DropdownMenuSubContent,
31
+ DropdownMenuSubTrigger,
32
+ DropdownMenuTrigger,
33
+ } from "@/components/ui/dropdown-menu";
34
+ import { Input } from "@/components/ui/input";
35
+ import { Separator } from "@/components/ui/separator";
36
+ import { useImageUpload } from "@/hooks/use-image-upload";
37
+ import { cn } from "@/lib/utils";
38
+
39
+ export const ImageExtension = Image.extend({
40
+ addAttributes() {
41
+ return {
42
+ src: {
43
+ default: null,
44
+ },
45
+ alt: {
46
+ default: null,
47
+ },
48
+ title: {
49
+ default: null,
50
+ },
51
+ width: {
52
+ default: "100%",
53
+ },
54
+ height: {
55
+ default: null,
56
+ },
57
+ align: {
58
+ default: "center",
59
+ },
60
+ caption: {
61
+ default: "",
62
+ },
63
+ aspectRatio: {
64
+ default: null,
65
+ },
66
+ };
67
+ },
68
+
69
+ addNodeView: () => {
70
+ return ReactNodeViewRenderer(TiptapImage);
71
+ },
72
+ });
73
+
74
+ function TiptapImage(props: NodeViewProps) {
75
+ const { node, editor, selected, deleteNode, updateAttributes } = props;
76
+ const imageRef = useRef<HTMLImageElement | null>(null);
77
+ const nodeRef = useRef<HTMLDivElement | null>(null);
78
+ const [resizing, setResizing] = useState(false);
79
+ const [resizingPosition, setResizingPosition] = useState<"left" | "right">(
80
+ "left"
81
+ );
82
+ const [resizeInitialWidth, setResizeInitialWidth] = useState(0);
83
+ const [resizeInitialMouseX, setResizeInitialMouseX] = useState(0);
84
+ const [editingCaption, setEditingCaption] = useState(false);
85
+ const [caption, setCaption] = useState(node.attrs.caption || "");
86
+ const [openedMore, setOpenedMore] = useState(false);
87
+ const [imageUrl, setImageUrl] = useState("");
88
+ const [altText, setAltText] = useState(node.attrs.alt || "");
89
+
90
+ const {
91
+ previewUrl,
92
+ fileInputRef,
93
+ handleFileChange,
94
+ handleRemove,
95
+ uploading,
96
+ error,
97
+ } = useImageUpload({
98
+ onUpload: (imageUrl) => {
99
+ updateAttributes({
100
+ src: imageUrl,
101
+ alt: altText || fileInputRef.current?.files?.[0]?.name,
102
+ });
103
+ handleRemove();
104
+ setOpenedMore(false);
105
+ },
106
+ });
107
+
108
+ function handleResizingPosition({
109
+ e,
110
+ position,
111
+ }: {
112
+ e: React.MouseEvent<HTMLDivElement, MouseEvent>;
113
+ position: "left" | "right";
114
+ }) {
115
+ startResize(e);
116
+ setResizingPosition(position);
117
+ }
118
+
119
+ function startResize(event: React.MouseEvent<HTMLDivElement>) {
120
+ event.preventDefault();
121
+ setResizing(true);
122
+ setResizeInitialMouseX(event.clientX);
123
+ if (imageRef.current) {
124
+ setResizeInitialWidth(imageRef.current.offsetWidth);
125
+ }
126
+ }
127
+
128
+ function resize(event: MouseEvent) {
129
+ if (!resizing) return;
130
+
131
+ let dx = event.clientX - resizeInitialMouseX;
132
+ if (resizingPosition === "left") {
133
+ dx = resizeInitialMouseX - event.clientX;
134
+ }
135
+
136
+ const newWidth = Math.max(resizeInitialWidth + dx, 150);
137
+ const parentWidth = nodeRef.current?.parentElement?.offsetWidth ?? 0;
138
+
139
+ if (newWidth < parentWidth) {
140
+ updateAttributes({
141
+ width: newWidth,
142
+ });
143
+ }
144
+ }
145
+
146
+ function endResize() {
147
+ setResizing(false);
148
+ setResizeInitialMouseX(0);
149
+ setResizeInitialWidth(0);
150
+ }
151
+
152
+ function handleTouchStart(
153
+ event: React.TouchEvent,
154
+ position: "left" | "right"
155
+ ) {
156
+ event.preventDefault();
157
+ setResizing(true);
158
+ setResizingPosition(position);
159
+ setResizeInitialMouseX(event.touches[0]?.clientX ?? 0);
160
+ if (imageRef.current) {
161
+ setResizeInitialWidth(imageRef.current.offsetWidth);
162
+ }
163
+ }
164
+
165
+ function handleTouchMove(event: TouchEvent) {
166
+ if (!resizing) return;
167
+
168
+ let dx = (event.touches[0]?.clientX ?? resizeInitialMouseX) - resizeInitialMouseX;
169
+ if (resizingPosition === "left") {
170
+ dx = resizeInitialMouseX - (event.touches[0]?.clientX ?? resizeInitialMouseX);
171
+ }
172
+
173
+ const newWidth = Math.max(resizeInitialWidth + dx, 150);
174
+ const parentWidth = nodeRef.current?.parentElement?.offsetWidth ?? 0;
175
+
176
+ if (newWidth < parentWidth) {
177
+ updateAttributes({
178
+ width: newWidth,
179
+ });
180
+ }
181
+ }
182
+
183
+ function handleTouchEnd() {
184
+ setResizing(false);
185
+ setResizeInitialMouseX(0);
186
+ setResizeInitialWidth(0);
187
+ }
188
+
189
+ function handleCaptionChange(e: React.ChangeEvent<HTMLInputElement>) {
190
+ const newCaption = e.target.value;
191
+ setCaption(newCaption);
192
+ }
193
+
194
+ function handleCaptionBlur() {
195
+ updateAttributes({ caption });
196
+ setEditingCaption(false);
197
+ }
198
+
199
+ function handleCaptionKeyDown(e: React.KeyboardEvent) {
200
+ if (e.key === "Enter") {
201
+ handleCaptionBlur();
202
+ }
203
+ }
204
+
205
+ const handleImageUrlSubmit = () => {
206
+ if (imageUrl) {
207
+ updateAttributes({
208
+ src: imageUrl,
209
+ alt: altText,
210
+ });
211
+ setImageUrl("");
212
+ setAltText("");
213
+ setOpenedMore(false);
214
+ }
215
+ };
216
+
217
+ useEffect(() => {
218
+ window.addEventListener("mousemove", resize);
219
+ window.addEventListener("mouseup", endResize);
220
+ window.addEventListener("touchmove", handleTouchMove);
221
+ window.addEventListener("touchend", handleTouchEnd);
222
+ return () => {
223
+ window.removeEventListener("mousemove", resize);
224
+ window.removeEventListener("mouseup", endResize);
225
+ window.removeEventListener("touchmove", handleTouchMove);
226
+ window.removeEventListener("touchend", handleTouchEnd);
227
+ };
228
+ }, [resizing, resizeInitialMouseX, resizeInitialWidth]);
229
+
230
+ return (
231
+ <NodeViewWrapper
232
+ ref={nodeRef}
233
+ className={cn(
234
+ "relative flex flex-col rounded-md border-2 border-transparent transition-all duration-200",
235
+ selected ? "border-blue-300" : "",
236
+ node.attrs.align === "left" && "left-0 -translate-x-0",
237
+ node.attrs.align === "center" && "left-1/2 -translate-x-1/2",
238
+ node.attrs.align === "right" && "left-full -translate-x-full"
239
+ )}
240
+ style={{ width: node.attrs.width }}
241
+ >
242
+ <div
243
+ className={cn(
244
+ "group relative flex flex-col rounded-md",
245
+ resizing && ""
246
+ )}
247
+ >
248
+ <figure className="relative m-0">
249
+ <img
250
+ ref={imageRef}
251
+ src={node.attrs.src}
252
+ alt={node.attrs.alt}
253
+ title={node.attrs.title}
254
+ className="rounded-lg transition-shadow duration-200 hover:shadow-lg"
255
+ onLoad={(e) => {
256
+ const img = e.currentTarget;
257
+ const aspectRatio = img.naturalWidth / img.naturalHeight;
258
+ updateAttributes({ aspectRatio });
259
+ }}
260
+ />
261
+ {editor?.isEditable && (
262
+ <>
263
+ <div
264
+ className="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-start p-2"
265
+ style={{ left: 0 }}
266
+ onMouseDown={(event) => {
267
+ handleResizingPosition({ e: event, position: "left" });
268
+ }}
269
+ onTouchStart={(event) => handleTouchStart(event, "left")}
270
+ >
271
+ <div className="z-20 h-[70px] w-1 rounded-xl border bg-[rgba(0,0,0,0.65)] opacity-0 transition-all group-hover:opacity-100" />
272
+ </div>
273
+ <div
274
+ className="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-end p-2"
275
+ style={{ right: 0 }}
276
+ onMouseDown={(event) => {
277
+ handleResizingPosition({ e: event, position: "right" });
278
+ }}
279
+ onTouchStart={(event) => handleTouchStart(event, "right")}
280
+ >
281
+ <div className="z-20 h-[70px] w-1 rounded-xl border bg-[rgba(0,0,0,0.65)] opacity-0 transition-all group-hover:opacity-100" />
282
+ </div>
283
+ </>
284
+ )}
285
+ </figure>
286
+
287
+ {editingCaption ? (
288
+ <Input
289
+ value={caption}
290
+ onChange={handleCaptionChange}
291
+ onBlur={handleCaptionBlur}
292
+ onKeyDown={handleCaptionKeyDown}
293
+ className="mt-2 text-center text-sm text-muted-foreground focus:ring-0"
294
+ placeholder="Add a caption..."
295
+ autoFocus
296
+ />
297
+ ) : (
298
+ <div
299
+ className="mt-2 cursor-text text-center text-sm text-muted-foreground"
300
+ onClick={() => editor?.isEditable && setEditingCaption(true)}
301
+ >
302
+ {caption || "Add a caption..."}
303
+ </div>
304
+ )}
305
+
306
+ {editor?.isEditable && (
307
+ <div
308
+ className={cn(
309
+ "absolute right-4 top-4 flex items-center gap-1 rounded-md border bg-background/80 p-1 opacity-0 backdrop-blur transition-opacity",
310
+ !resizing && "group-hover:opacity-100",
311
+ openedMore && "opacity-100"
312
+ )}
313
+ >
314
+ <Button
315
+ size="icon"
316
+ className={cn(
317
+ "size-7",
318
+ node.attrs.align === "left" && "bg-accent"
319
+ )}
320
+ variant="ghost"
321
+ onClick={() => updateAttributes({ align: "left" })}
322
+ >
323
+ <AlignLeft className="size-4" />
324
+ </Button>
325
+ <Button
326
+ size="icon"
327
+ className={cn(
328
+ "size-7",
329
+ node.attrs.align === "center" && "bg-accent"
330
+ )}
331
+ variant="ghost"
332
+ onClick={() => updateAttributes({ align: "center" })}
333
+ >
334
+ <AlignCenter className="size-4" />
335
+ </Button>
336
+ <Button
337
+ size="icon"
338
+ className={cn(
339
+ "size-7",
340
+ node.attrs.align === "right" && "bg-accent"
341
+ )}
342
+ variant="ghost"
343
+ onClick={() => updateAttributes({ align: "right" })}
344
+ >
345
+ <AlignRight className="size-4" />
346
+ </Button>
347
+ <Separator orientation="vertical" className="h-[20px]" />
348
+ <DropdownMenu open={openedMore} onOpenChange={setOpenedMore}>
349
+ <DropdownMenuTrigger asChild>
350
+ <Button size="icon" className="size-7" variant="ghost">
351
+ <MoreVertical className="size-4" />
352
+ </Button>
353
+ </DropdownMenuTrigger>
354
+ <DropdownMenuContent
355
+ align="start"
356
+ alignOffset={-90}
357
+ className="mt-1 text-sm"
358
+ >
359
+ <DropdownMenuItem onClick={() => setEditingCaption(true)}>
360
+ <Edit className="mr-2 size-4" /> Edit Caption
361
+ </DropdownMenuItem>
362
+ <DropdownMenuSub>
363
+ <DropdownMenuSubTrigger>
364
+ <ImageIcon className="mr-2 size-4" /> Replace Image
365
+ </DropdownMenuSubTrigger>
366
+ <DropdownMenuSubContent className="p-2 w-fit min-w-52">
367
+ <div className="space-y-4">
368
+ <div>
369
+ <p className="mb-2 text-xs font-medium">Upload Image</p>
370
+ <input
371
+ ref={fileInputRef}
372
+ type="file"
373
+ accept="image/*"
374
+ onChange={handleFileChange}
375
+ className="hidden"
376
+ id="replace-image-upload"
377
+ />
378
+ <label
379
+ htmlFor="replace-image-upload"
380
+ className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-md border-2 border-dashed p-4 hover:bg-accent"
381
+ >
382
+ {uploading ? (
383
+ <>
384
+ <Loader2 className="h-4 w-4 animate-spin" />
385
+ <span>Uploading...</span>
386
+ </>
387
+ ) : (
388
+ <>
389
+ <ImageIcon className="h-4 w-4" />
390
+ <span>Choose Image</span>
391
+ </>
392
+ )}
393
+ </label>
394
+ {error && (
395
+ <p className="mt-2 text-xs text-destructive">
396
+ {error}
397
+ </p>
398
+ )}
399
+ </div>
400
+
401
+ <div>
402
+ <p className="mb-2 text-xs font-medium">Or use URL</p>
403
+ <div className="space-y-2">
404
+ <Input
405
+ value={imageUrl}
406
+ onChange={(e) => setImageUrl(e.target.value)}
407
+ placeholder="Enter image URL..."
408
+ className="text-xs"
409
+ />
410
+ <Button
411
+ onClick={handleImageUrlSubmit}
412
+ className="w-full"
413
+ disabled={!imageUrl}
414
+ size="sm"
415
+ >
416
+ Replace with URL
417
+ </Button>
418
+ </div>
419
+ </div>
420
+
421
+ <div>
422
+ <p className="mb-2 text-xs font-medium">Alt Text</p>
423
+ <Input
424
+ value={altText}
425
+ onChange={(e) => setAltText(e.target.value)}
426
+ placeholder="Alt text (optional)"
427
+ className="text-xs"
428
+ />
429
+ </div>
430
+ </div>
431
+ </DropdownMenuSubContent>
432
+ </DropdownMenuSub>
433
+ <DropdownMenuItem
434
+ onClick={() => {
435
+ const aspectRatio = node.attrs.aspectRatio;
436
+ if (aspectRatio) {
437
+ const parentWidth =
438
+ nodeRef.current?.parentElement?.offsetWidth ?? 0;
439
+ updateAttributes({
440
+ width: parentWidth,
441
+ height: parentWidth / aspectRatio,
442
+ });
443
+ }
444
+ }}
445
+ >
446
+ <Maximize className="mr-2 size-4" /> Full Width
447
+ </DropdownMenuItem>
448
+ <DropdownMenuSeparator />
449
+ <DropdownMenuItem
450
+ className="text-destructive focus:text-destructive"
451
+ onClick={deleteNode}
452
+ >
453
+ <Trash className="mr-2 size-4" /> Delete Image
454
+ </DropdownMenuItem>
455
+ </DropdownMenuContent>
456
+ </DropdownMenu>
457
+ </div>
458
+ )}
459
+ </div>
460
+ </NodeViewWrapper>
461
+ );
462
+ }