Skip to content

Commit 92eed22

Browse files
committed
fix: show image, video dialog with slash command
1 parent 36e89c7 commit 92eed22

File tree

20 files changed

+673
-373
lines changed

20 files changed

+673
-373
lines changed

src/components/Bubble/RichTextBubbleExcalidraw.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import { useCallback, useEffect } from 'react';
33
import { BubbleMenu } from '@tiptap/react/menus';
44

55
import { ActionButton } from '@/components/ActionButton';
6+
import { emit } from '@/components/ReactBus';
67
import { SizeSetter } from '@/components/SizeSetter/SizeSetter';
78
import type { IExcalidrawAttrs } from '@/extensions/Excalidraw';
89
import { Excalidraw } from '@/extensions/Excalidraw';
910
import { useAttributes } from '@/hooks/useAttributes';
1011
import { useLocale } from '@/locales';
1112
import { useEditorInstance } from '@/store/editor';
1213
import { useEditableEditor } from '@/store/store';
13-
import { triggerOpenExcalidrawSettingModal } from '@/utils/_event';
14+
import { EVENTS } from '@/utils/customEvents/events.constant';
1415
import { deleteNode } from '@/utils/delete-node';
1516
import { getEditorContainerDOMSize } from '@/utils/editor-container-size';
1617

@@ -39,9 +40,12 @@ export function RichTextBubbleExcalidraw() {
3940
},
4041
[editor],
4142
);
43+
4244
const openEditLinkModal = useCallback(() => {
43-
triggerOpenExcalidrawSettingModal({ ...attrs, editor });
45+
const EVENT_ID = EVENTS.EXCALIDRAW((editor as any).id);
46+
emit(EVENT_ID, attrs);
4447
}, [editor, attrs]);
48+
4549
const shouldShow = useCallback(() => editor.isActive(Excalidraw.name), [editor]);
4650
const deleteMe = useCallback(() => deleteNode(Excalidraw.name, editor), [editor]);
4751

src/components/ReactBus.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react';
2+
3+
import mitt from '@/utils/mitt';
4+
5+
const iMitt = mitt();
6+
7+
export const BusContext = React.createContext(iMitt);
8+
9+
export const useBus = () => React.useContext(BusContext);
10+
11+
export function useListener(fn: (event: any) => void, events: string[]) {
12+
const bus = useBus();
13+
14+
React.useEffect(() => {
15+
events.map(e => bus.on(e, fn));
16+
return () => {
17+
events.map(e => bus.off(e, fn));
18+
};
19+
}, [bus, events, fn]);
20+
}
21+
22+
export const emit = iMitt.emit;
23+
24+
export function ReactBusProvider({ children }: { children: React.ReactNode }) {
25+
return (
26+
<BusContext.Provider value={iMitt}>
27+
{children}
28+
</BusContext.Provider>
29+
);
30+
}

src/components/RichTextProvider.tsx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { type Editor } from '@tiptap/core';
44
import { EditorContext } from '@tiptap/react';
55

66
import { TooltipProvider } from '@/components';
7+
import { ReactBusProvider } from '@/components/ReactBus';
8+
import SlashDialogTrigger from '@/components/SlashDialogTrigger/SlashDialogTrigger';
79
import { RESET_CSS } from '@/constants/resetCSS';
810
import { EditorEditableReactive } from '@/store/EditorEditableReactive';
911
import { ThemeColorReactive } from '@/store/ThemeColorReactive';
@@ -40,19 +42,22 @@ export function RichTextProvider({ editor, children }: IProviderRichTextProps) {
4042

4143
return (
4244
<div className="reactjs-tiptap-editor">
43-
<EditorContext.Provider value={{ editor }}>
44-
<TooltipProvider delayDuration={0}
45-
disableHoverableContent
46-
>
47-
{children}
48-
</TooltipProvider>
49-
50-
<EditorEditableReactive
51-
editor={editor}
52-
/>
53-
54-
<ThemeColorReactive />
55-
</EditorContext.Provider>
45+
<ReactBusProvider>
46+
<EditorContext.Provider value={{ editor }}>
47+
<TooltipProvider delayDuration={0}
48+
disableHoverableContent
49+
>
50+
{children}
51+
</TooltipProvider>
52+
53+
<EditorEditableReactive
54+
editor={editor}
55+
/>
56+
57+
<SlashDialogTrigger />
58+
<ThemeColorReactive />
59+
</EditorContext.Provider>
60+
</ReactBusProvider>
5661
</div>
5762
);
5863
}
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { useMemo, useRef, useState } from 'react';
2+
3+
import { Button, Checkbox, IconComponent, Input, Label, Tabs, TabsContent, TabsList, TabsTrigger, useToast } from '@/components';
4+
import { useListener } from '@/components/ReactBus';
5+
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
6+
import { ImageCropper } from '@/extensions/Image/components/ImageCropper';
7+
import { Image } from '@/extensions/Image/Image';
8+
import { useToggleActive } from '@/hooks/useActive';
9+
// import { useButtonProps } from '@/hooks/useButtonProps';
10+
import { useExtension } from '@/hooks/useExtension';
11+
import { useLocale } from '@/locales';
12+
import { useEditorInstance } from '@/store/editor';
13+
import { EVENTS } from '@/utils/customEvents/events.constant';
14+
import { validateFiles } from '@/utils/validateFile';
15+
16+
export function RenderDialogUploadImage () {
17+
const { t } = useLocale();
18+
const { toast } = useToast();
19+
20+
const editor = useEditorInstance();
21+
// const buttonProps = useButtonProps(Image.name);
22+
23+
// const {
24+
// icon,
25+
// tooltip,
26+
// } = buttonProps?.componentProps ?? {};
27+
28+
const { editorDisabled } = useToggleActive();
29+
30+
const [open, setOpen] = useState(false);
31+
32+
const EVENT_ID = EVENTS.UPLOAD_IMAGE((editor as any).id);
33+
34+
useListener(setOpen, [EVENT_ID]);
35+
36+
const [isUploading, setIsUploading] = useState(false);
37+
const extension = useExtension(Image.name);
38+
39+
const [link, setLink] = useState<string>('');
40+
const [alt, setAlt] = useState<string>('');
41+
const fileInput = useRef<HTMLInputElement>(null);
42+
43+
const defaultInline = extension?.options.defaultInline || false;
44+
45+
const [imageInline, setImageInline] = useState(defaultInline);
46+
47+
const uploadOptions = useMemo(() => {
48+
const uploadOptions = extension?.options;
49+
50+
return uploadOptions;
51+
}, [extension]);
52+
53+
async function handleFile(event: any) {
54+
const files = event?.target?.files;
55+
if (!editor || editor.isDestroyed || files.length === 0 || isUploading) {
56+
event.target.value = '';
57+
return;
58+
}
59+
60+
const validFiles = validateFiles(files, {
61+
acceptMimes: uploadOptions?.acceptMimes,
62+
maxSize: uploadOptions?.maxSize,
63+
t,
64+
toast,
65+
onError: uploadOptions.onError,
66+
});
67+
68+
if (validFiles.length <= 0) {
69+
event.target.value = '';
70+
return;
71+
}
72+
73+
setIsUploading(true);
74+
try {
75+
if (uploadOptions?.multiple) {
76+
// Handle multiple files upload
77+
const uploadPromises = validFiles.map(async (file) => {
78+
let src = '';
79+
if (uploadOptions.upload) {
80+
src = await uploadOptions.upload(file);
81+
} else {
82+
src = URL.createObjectURL(file);
83+
}
84+
return src;
85+
});
86+
87+
const srcs = await Promise.all(uploadPromises);
88+
// Insert all images (you might want to adjust this based on your editor's capabilities)
89+
srcs.forEach(src => {
90+
editor.chain().focus().setImageInline({ src, inline: imageInline, alt }).run();
91+
});
92+
} else {
93+
// Single file upload (take the first valid file)
94+
const file = validFiles[0];
95+
let src = '';
96+
if (uploadOptions.upload) {
97+
src = await uploadOptions.upload(file);
98+
} else {
99+
src = URL.createObjectURL(file);
100+
}
101+
editor.chain().focus().setImageInline({ src, inline: imageInline, alt }).run();
102+
}
103+
104+
setOpen(false);
105+
setAlt('');
106+
setImageInline(defaultInline);
107+
} catch (error) {
108+
console.error('Error uploading image', error);
109+
if (uploadOptions.onError) {
110+
uploadOptions.onError({
111+
type: 'upload',
112+
message: t('editor.upload.error'),
113+
});
114+
} else {
115+
toast({
116+
variant: 'destructive',
117+
title: t('editor.upload.error'),
118+
});
119+
}
120+
} finally {
121+
setIsUploading(false);
122+
event.target.value = '';
123+
}
124+
}
125+
126+
function handleLink(e: any) {
127+
e.preventDefault();
128+
e.stopPropagation();
129+
130+
editor.chain().focus().setImageInline({ src: link, inline: imageInline, alt }).run();
131+
setOpen(false);
132+
setImageInline(defaultInline);
133+
setLink('');
134+
setAlt('');
135+
}
136+
137+
function handleClick(e: any) {
138+
e.preventDefault();
139+
fileInput.current?.click();
140+
}
141+
142+
if (editorDisabled) {
143+
return <></>;
144+
}
145+
146+
return (
147+
<Dialog
148+
onOpenChange={setOpen}
149+
open={open}
150+
>
151+
<DialogContent>
152+
<DialogTitle>
153+
{t('editor.image.dialog.title')}
154+
</DialogTitle>
155+
156+
<Tabs
157+
activationMode="manual"
158+
defaultValue={
159+
uploadOptions.resourceImage === 'both' || uploadOptions.resourceImage === 'upload'
160+
? 'upload'
161+
: 'link'
162+
}
163+
>
164+
165+
{(uploadOptions.resourceImage === 'both') && (
166+
<TabsList className="richtext-grid richtext-w-full richtext-grid-cols-2">
167+
<TabsTrigger value="upload">
168+
{t('editor.image.dialog.tab.upload')}
169+
</TabsTrigger>
170+
171+
<TabsTrigger value="link">
172+
{t('editor.image.dialog.tab.url')}
173+
</TabsTrigger>
174+
</TabsList>
175+
)}
176+
177+
<div className="richtext-my-[10px] richtext-flex richtext-items-center richtext-gap-[4px]">
178+
<Checkbox
179+
checked={imageInline}
180+
onCheckedChange={(v) => {
181+
setImageInline(v as boolean);
182+
}}
183+
/>
184+
185+
<Label>
186+
{t('editor.link.dialog.inline')}
187+
</Label>
188+
</div>
189+
190+
{
191+
uploadOptions.enableAlt && (
192+
<div className="richtext-my-[10px] ">
193+
<Label className="mb-[6px]">
194+
{t('editor.imageUpload.alt')}
195+
</Label>
196+
197+
<Input
198+
onChange={(e) => setAlt(e.target.value)}
199+
required
200+
type="text"
201+
value={alt}
202+
/>
203+
</div>
204+
)
205+
}
206+
207+
<TabsContent value="upload">
208+
<div className="richtext-flex richtext-items-center richtext-gap-[10px]">
209+
<Button className="richtext-mt-1 richtext-w-full"
210+
disabled={isUploading}
211+
onClick={handleClick}
212+
size="sm"
213+
>
214+
{isUploading ? (
215+
<>
216+
{t('editor.imageUpload.uploading')}
217+
218+
<IconComponent
219+
className="richtext-ml-1 richtext-animate-spin"
220+
name="Loader"
221+
/>
222+
</>
223+
) : (
224+
t('editor.image.dialog.tab.upload')
225+
)}
226+
</Button>
227+
228+
<ImageCropper
229+
alt={alt}
230+
disabled={isUploading}
231+
editor={editor}
232+
imageInline={imageInline}
233+
onClose={() => {
234+
setAlt('');
235+
}}
236+
/>
237+
</div>
238+
239+
<input
240+
// accept="image/*"
241+
accept={uploadOptions.acceptMimes.join(',') || 'image/*'}
242+
multiple={uploadOptions.multiple}
243+
onChange={handleFile}
244+
ref={fileInput}
245+
style={{ display: 'none' }}
246+
type="file"
247+
/>
248+
</TabsContent>
249+
250+
<TabsContent value="link">
251+
<form onSubmit={handleLink}>
252+
<div className="richtext-flex richtext-items-center richtext-gap-2">
253+
<Input
254+
autoFocus
255+
onChange={e => setLink(e.target.value)}
256+
placeholder={t('editor.image.dialog.placeholder')}
257+
required
258+
type="url"
259+
value={link}
260+
/>
261+
262+
<Button type="submit">
263+
{t('editor.image.dialog.button.apply')}
264+
</Button>
265+
</div>
266+
</form>
267+
</TabsContent>
268+
</Tabs>
269+
</DialogContent>
270+
</Dialog>
271+
);
272+
}

0 commit comments

Comments
 (0)