Skip to content

Commit 78714e8

Browse files
author
robin
committed
feat(editor): implement image upload functionality with validation and hooks
1 parent aa7e19b commit 78714e8

5 files changed

Lines changed: 259 additions & 96 deletions

File tree

ui/src/components/Editor/ToolBars/image.tsx

Lines changed: 7 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,10 @@ import { useEffect, useState, memo, useContext } from 'react';
2121
import { Button, Form, Modal, Tab, Tabs } from 'react-bootstrap';
2222
import { useTranslation } from 'react-i18next';
2323

24-
import { Modal as AnswerModal } from '@/components';
2524
import ToolItem from '../toolItem';
2625
import { EditorContext } from '../EditorContext';
2726
import { Editor } from '../types';
28-
import { uploadImage } from '@/services';
29-
import { writeSettingStore } from '@/stores';
27+
import { useImageUpload } from '../hooks/useImageUpload';
3028

3129
const Image = () => {
3230
const editor = useContext(EditorContext);
@@ -40,12 +38,7 @@ const Image = () => {
4038
}
4139
}, [editor]);
4240
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
43-
const {
44-
max_image_size = 4,
45-
max_attachment_size = 8,
46-
authorized_image_extensions = [],
47-
authorized_attachment_extensions = [],
48-
} = writeSettingStore((state) => state.write);
41+
const { verifyImageSize, uploadFiles } = useImageUpload();
4942

5043
const loadingText = `![${t('image.uploading')}...]()`;
5144

@@ -69,89 +62,6 @@ const Image = () => {
6962
errorMsg: '',
7063
});
7164

72-
const verifyImageSize = (files: FileList) => {
73-
if (files.length === 0) {
74-
return false;
75-
}
76-
77-
/**
78-
* When allowing attachments to be uploaded, verification logic for attachment information has been added. In order to avoid abnormal judgment caused by the order of drag and drop upload, the drag and drop upload verification of attachments and the drag and drop upload of images are put together.
79-
*
80-
*/
81-
const canUploadAttachment = authorized_attachment_extensions.length > 0;
82-
const allowedAllType = [
83-
...authorized_image_extensions,
84-
...authorized_attachment_extensions,
85-
];
86-
const unSupportFiles = Array.from(files).filter((file) => {
87-
const fileName = file.name.toLowerCase();
88-
return canUploadAttachment
89-
? !allowedAllType.find((v) => fileName.endsWith(v))
90-
: file.type.indexOf('image') === -1;
91-
});
92-
93-
if (unSupportFiles.length > 0) {
94-
AnswerModal.confirm({
95-
content: canUploadAttachment
96-
? t('file.not_supported', { file_type: allowedAllType.join(', ') })
97-
: t('image.form_image.fields.file.msg.only_image'),
98-
showCancel: false,
99-
});
100-
return false;
101-
}
102-
103-
const otherFiles = Array.from(files).filter((file) => {
104-
return file.type.indexOf('image') === -1;
105-
});
106-
107-
if (canUploadAttachment && otherFiles.length > 0) {
108-
const attachmentOverSizeFiles = otherFiles.filter(
109-
(file) => file.size / 1024 / 1024 > max_attachment_size,
110-
);
111-
if (attachmentOverSizeFiles.length > 0) {
112-
AnswerModal.confirm({
113-
content: t('file.max_size', { size: max_attachment_size }),
114-
showCancel: false,
115-
});
116-
return false;
117-
}
118-
}
119-
120-
const imageFiles = Array.from(files).filter(
121-
(file) => file.type.indexOf('image') > -1,
122-
);
123-
const oversizedImages = imageFiles.filter(
124-
(file) => file.size / 1024 / 1024 > max_image_size,
125-
);
126-
if (oversizedImages.length > 0) {
127-
AnswerModal.confirm({
128-
content: t('image.form_image.fields.file.msg.max_size', {
129-
size: max_image_size,
130-
}),
131-
showCancel: false,
132-
});
133-
return false;
134-
}
135-
136-
return true;
137-
};
138-
139-
const upload = (
140-
files: FileList,
141-
): Promise<{ url: string; name: string; type: string }[]> => {
142-
const promises = Array.from(files).map(async (file) => {
143-
const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment';
144-
const url = await uploadImage({ file, type });
145-
146-
return {
147-
name: file.name,
148-
url,
149-
type,
150-
};
151-
});
152-
153-
return Promise.all(promises);
154-
};
15565
function dragenter(e) {
15666
e.stopPropagation();
15767
e.preventDefault();
@@ -178,7 +88,7 @@ const Image = () => {
17888

17989
editorState.replaceSelection(loadingText);
18090
editorState.setReadOnly(true);
181-
const urls = await upload(fileList)
91+
const urls = await uploadFiles(fileList)
18292
.catch(() => {
18393
editorState.replaceRange('', startPos, endPos);
18494
})
@@ -217,7 +127,7 @@ const Image = () => {
217127

218128
editorState?.replaceSelection(loadingText);
219129
editorState?.setReadOnly(true);
220-
upload(clipboard.files)
130+
uploadFiles(clipboard.files)
221131
.then((urls) => {
222132
const text = urls.map(({ name, url, type }) => {
223133
return `${type === 'post' ? '!' : ''}[${name}](${url})`;
@@ -358,6 +268,8 @@ const Image = () => {
358268
setVisible(true);
359269
};
360270

271+
const { uploadSingleFile } = useImageUpload();
272+
361273
const onUpload = async (e) => {
362274
if (!editor) {
363275
return;
@@ -369,7 +281,7 @@ const Image = () => {
369281
return;
370282
}
371283

372-
uploadImage({ file: e.target.files[0], type: 'post' }).then((url) => {
284+
uploadSingleFile(e.target.files[0]).then((url) => {
373285
setLink({ ...link, value: url });
374286
setImageName({ ...imageName, value: files[0].name });
375287
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { useTranslation } from 'react-i18next';
21+
22+
import { Modal as AnswerModal } from '@/components';
23+
import { uploadImage } from '@/services';
24+
import { writeSettingStore } from '@/stores';
25+
26+
export const useImageUpload = () => {
27+
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
28+
const {
29+
max_image_size = 4,
30+
max_attachment_size = 8,
31+
authorized_image_extensions = [],
32+
authorized_attachment_extensions = [],
33+
} = writeSettingStore((state) => state.write);
34+
35+
const verifyImageSize = (files: FileList | File[]): boolean => {
36+
const fileArray = Array.isArray(files) ? files : Array.from(files);
37+
38+
if (fileArray.length === 0) {
39+
return false;
40+
}
41+
42+
const canUploadAttachment = authorized_attachment_extensions.length > 0;
43+
const allowedAllType = [
44+
...authorized_image_extensions,
45+
...authorized_attachment_extensions,
46+
];
47+
48+
const unSupportFiles = fileArray.filter((file) => {
49+
const fileName = file.name.toLowerCase();
50+
return canUploadAttachment
51+
? !allowedAllType.find((v) => fileName.endsWith(v))
52+
: file.type.indexOf('image') === -1;
53+
});
54+
55+
if (unSupportFiles.length > 0) {
56+
AnswerModal.confirm({
57+
content: canUploadAttachment
58+
? t('file.not_supported', { file_type: allowedAllType.join(', ') })
59+
: t('image.form_image.fields.file.msg.only_image'),
60+
showCancel: false,
61+
});
62+
return false;
63+
}
64+
65+
const otherFiles = fileArray.filter((file) => {
66+
return file.type.indexOf('image') === -1;
67+
});
68+
69+
if (canUploadAttachment && otherFiles.length > 0) {
70+
const attachmentOverSizeFiles = otherFiles.filter(
71+
(file) => file.size / 1024 / 1024 > max_attachment_size,
72+
);
73+
if (attachmentOverSizeFiles.length > 0) {
74+
AnswerModal.confirm({
75+
content: t('file.max_size', { size: max_attachment_size }),
76+
showCancel: false,
77+
});
78+
return false;
79+
}
80+
}
81+
82+
const imageFiles = fileArray.filter(
83+
(file) => file.type.indexOf('image') > -1,
84+
);
85+
const oversizedImages = imageFiles.filter(
86+
(file) => file.size / 1024 / 1024 > max_image_size,
87+
);
88+
if (oversizedImages.length > 0) {
89+
AnswerModal.confirm({
90+
content: t('image.form_image.fields.file.msg.max_size', {
91+
size: max_image_size,
92+
}),
93+
showCancel: false,
94+
});
95+
return false;
96+
}
97+
98+
return true;
99+
};
100+
101+
const uploadFiles = (
102+
files: FileList | File[],
103+
): Promise<{ url: string; name: string; type: string }[]> => {
104+
const fileArray = Array.isArray(files) ? files : Array.from(files);
105+
const promises = fileArray.map(async (file) => {
106+
const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment';
107+
const url = await uploadImage({ file, type });
108+
109+
return {
110+
name: file.name,
111+
url,
112+
type,
113+
};
114+
});
115+
116+
return Promise.all(promises);
117+
};
118+
119+
const uploadSingleFile = async (file: File): Promise<string> => {
120+
const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment';
121+
return uploadImage({ file, type });
122+
};
123+
124+
return {
125+
verifyImageSize,
126+
uploadFiles,
127+
uploadSingleFile,
128+
};
129+
};

ui/src/components/Editor/index.tsx

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,21 @@ import {
2424
forwardRef,
2525
useImperativeHandle,
2626
useCallback,
27+
useEffect,
2728
} from 'react';
29+
import { Spinner } from 'react-bootstrap';
2830

2931
import classNames from 'classnames';
3032

31-
import { PluginType, useRenderPlugin } from '@/utils/pluginKit';
33+
import {
34+
PluginType,
35+
useRenderPlugin,
36+
getReplacementPlugin,
37+
} from '@/utils/pluginKit';
38+
import { writeSettingStore } from '@/stores';
3239
import PluginRender, { PluginSlot } from '../PluginRender';
3340

41+
import { useImageUpload } from './hooks/useImageUpload';
3442
import {
3543
BlockQuote,
3644
Bold,
@@ -87,6 +95,32 @@ const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = (
8795
) => {
8896
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
8997
const previewRef = useRef<{ getHtml; element } | null>(null);
98+
const [fullEditorPlugin, setFullEditorPlugin] = useState<any>(null);
99+
const [isLoading, setIsLoading] = useState(true);
100+
const { verifyImageSize, uploadSingleFile } = useImageUpload();
101+
const {
102+
max_image_size = 4,
103+
authorized_image_extensions = [],
104+
authorized_attachment_extensions = [],
105+
} = writeSettingStore((state) => state.write);
106+
107+
useEffect(() => {
108+
let mounted = true;
109+
110+
const loadPlugin = async () => {
111+
const plugin = await getReplacementPlugin(PluginType.EditorReplacement);
112+
if (mounted) {
113+
setFullEditorPlugin(plugin);
114+
setIsLoading(false);
115+
}
116+
};
117+
118+
loadPlugin();
119+
120+
return () => {
121+
mounted = false;
122+
};
123+
}, []);
90124

91125
useRenderPlugin(previewRef.current?.element);
92126

@@ -104,6 +138,53 @@ const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = (
104138

105139
const EditorComponent = MarkdownEditor;
106140

141+
if (isLoading) {
142+
return (
143+
<div className={classNames('md-editor-wrap rounded', className)}>
144+
<div
145+
className="d-flex justify-content-center align-items-center"
146+
style={{ minHeight: '200px' }}>
147+
<Spinner animation="border" variant="secondary" />
148+
</div>
149+
</div>
150+
);
151+
}
152+
153+
if (fullEditorPlugin) {
154+
const FullEditorComponent = fullEditorPlugin.component;
155+
156+
const handleImageUpload = async (file: File | string): Promise<string> => {
157+
if (typeof file === 'string') {
158+
return file;
159+
}
160+
161+
if (!verifyImageSize([file])) {
162+
throw new Error('File validation failed');
163+
}
164+
165+
return uploadSingleFile(file);
166+
};
167+
168+
return (
169+
<FullEditorComponent
170+
value={value}
171+
onChange={onChange}
172+
onFocus={onFocus}
173+
onBlur={onBlur}
174+
placeholder={editorPlaceholder}
175+
autoFocus={autoFocus}
176+
imageUploadHandler={handleImageUpload}
177+
uploadConfig={{
178+
maxImageSizeMiB: max_image_size,
179+
allowedExtensions: [
180+
...authorized_image_extensions,
181+
...authorized_attachment_extensions,
182+
],
183+
}}
184+
/>
185+
);
186+
}
187+
107188
return (
108189
<>
109190
<div className={classNames('md-editor-wrap rounded', className)}>

0 commit comments

Comments
 (0)