Просмотр исходного кода

Merge pull request #8476 from weseek/imprv/attachment

imprv: Attachment
Yuki Takei 2 лет назад
Родитель
Сommit
f98072a010

+ 45 - 44
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -12,17 +12,19 @@ import {
   Button, TabContent, TabPane,
 } from 'reactstrap';
 
-import { apiPostForm } from '~/client/util/apiv1-client';
+import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import type { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment, useSWRxEditingCommentsNum } from '~/stores/comment';
 import {
-  useCurrentUser, useIsSlackConfigured,
-  useIsUploadAllFileAllowed, useIsUploadEnabled,
+  useCurrentUser, useIsSlackConfigured, useAcceptedUploadFileType,
 } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning } from '~/stores/editor';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import { useNextThemes } from '~/stores/use-next-themes';
+import loggerFactory from '~/utils/logger';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
@@ -30,9 +32,13 @@ import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 
 import { CommentPreview } from './CommentPreview';
 
+import '@growi/editor/dist/style.css';
 import styles from './CommentEditor.module.scss';
 
 
+const logger = loggerFactory('growi:components:CommentEditor');
+
+
 const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
 
 
@@ -70,10 +76,9 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackConfigured } = useIsSlackConfigured();
-  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
-  const { data: isUploadEnabled } = useIsUploadEnabled();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const {
     increment: incrementEditingCommentsNum,
@@ -201,48 +206,43 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     updateComment, comment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment,
   ]);
 
-  const ctrlEnterHandler = useCallback((event) => {
-    if (event != null) {
-      event.preventDefault();
-    }
+  // the upload event handler
+  const uploadHandler = useCallback((files: File[]) => {
+    files.forEach(async(file) => {
+      try {
+        const { data: resLimit } = await apiv3Get('/attachment/limit', { fileSize: file.size });
 
-    postCommentHandler();
-  }, [postCommentHandler]);
+        if (!resLimit.isUploadable) {
+          throw new Error(resLimit.errorMessage);
+        }
 
-  const apiErrorHandler = useCallback((error: Error) => {
-    toastError(error.message);
-  }, []);
+        const formData = new FormData();
+        formData.append('file', file);
+        if (pageId != null) {
+          formData.append('page_id', pageId);
+        }
 
-  const uploadHandler = useCallback(async(file) => {
-    if (editorRef.current == null) { return }
+        const { data: resAdd } = await apiv3PostForm('/attachment', formData);
 
-    const pagePath = currentPagePath;
-    const endpoint = '/attachments.add';
-    const formData = new FormData();
-    formData.append('file', file);
-    formData.append('path', pagePath ?? '');
-    formData.append('page_id', pageId ?? '');
+        const attachment = resAdd.attachment;
+        const fileName = attachment.originalName;
 
-    try {
-      // TODO: typescriptize res
-      const res = await apiPostForm(endpoint, formData) as any;
-      const attachment = res.attachment;
-      const fileName = attachment.originalName;
-      let insertText = `[${fileName}](${attachment.filePathProxied})`;
-      // when image
-      if (attachment.fileFormat.startsWith('image/')) {
-        // modify to "![fileName](url)" syntax
-        insertText = `!${insertText}`;
+        let insertText = `[${fileName}](${attachment.filePathProxied})\n`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = `!${insertText}`;
+        }
+
+        codeMirrorEditor?.insertText(insertText);
       }
-      editorRef.current.insertText(insertText);
-    }
-    catch (err) {
-      apiErrorHandler(err);
-    }
-    finally {
-      editorRef.current.terminateUploadingState();
-    }
-  }, [apiErrorHandler, currentPagePath, pageId]);
+      catch (e) {
+        logger.error('failed to upload', e);
+        toastError(e);
+      }
+    });
+
+  }, [codeMirrorEditor, pageId]);
 
   const getCommentHtml = useCallback(() => {
     if (currentPagePath == null) {
@@ -325,8 +325,6 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       </Button>
     );
 
-    const isUploadable = isUploadEnabled || isUploadAllFileAllowed;
-
     return (
       <>
         <div className="comment-write">
@@ -334,7 +332,10 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
           <TabContent activeTab={activeTab}>
             <TabPane tabId="comment_editor">
               <CodeMirrorEditorComment
+                acceptedUploadFileType={acceptedUploadFileType}
                 onChange={onChangeHandler}
+                onSave={postCommentHandler}
+                onUpload={uploadHandler}
               />
               {/* <Editor
                 ref={editorRef}

+ 5 - 16
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -9,7 +9,7 @@ import type { IPageHasId } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
-  CodeMirrorEditorMain, GlobalCodeMirrorEditorKey, AcceptedUploadFileType,
+  CodeMirrorEditorMain, GlobalCodeMirrorEditorKey,
   useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
 } from '@growi/editor';
 import detectIndent from 'detect-indent';
@@ -26,7 +26,8 @@ import { SocketEventName } from '~/interfaces/websocket';
 import {
   useDefaultIndentSize, useCurrentUser,
   useCurrentPathname, useIsEnabledAttachTitleHeader,
-  useIsEditable, useIsUploadAllFileAllowed, useIsUploadEnabled, useIsIndentSizeForced,
+  useIsEditable, useIsIndentSizeForced,
+  useAcceptedUploadFileType,
 } from '~/stores/context';
 import {
   useEditorSettings,
@@ -108,8 +109,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: defaultIndentSize } = useDefaultIndentSize();
-  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
-  const { data: isUploadEnabled } = useIsUploadEnabled();
+  const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
   const { data: editorSettings } = useEditorSettings();
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
@@ -315,17 +315,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   }, [codeMirrorEditor, pageId]);
 
-  const acceptedFileType = useMemo(() => {
-    if (!isUploadEnabled) {
-      return AcceptedUploadFileType.NONE;
-    }
-    if (isUploadAllFileAllowed) {
-      return AcceptedUploadFileType.ALL;
-    }
-    return AcceptedUploadFileType.IMAGE;
-  }, [isUploadAllFileAllowed, isUploadEnabled]);
-
-
   const scrollEditorHandler = useCallback(() => {
     if (codeMirrorEditor?.view?.scrollDOM == null || previewRef.current == null) {
       return;
@@ -460,7 +449,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             onChange={markdownChangedHandler}
             onSave={saveWithShortcut}
             onUpload={uploadHandler}
-            acceptedFileType={acceptedFileType}
+            acceptedUploadFileType={acceptedUploadFileType}
             onScroll={scrollEditorHandlerThrottle}
             indentSize={currentIndentSize ?? defaultIndentSize}
             userName={user?.name}

+ 0 - 7
apps/app/src/interfaces/editor-settings.ts

@@ -15,10 +15,3 @@ export interface IEditorSettings {
   styleActiveLine: boolean,
   autoFormatMarkdownTable: boolean,
 }
-
-export type EditorConfig = {
-  upload: {
-    isUploadAllFileAllowed: boolean,
-    isUploadEnabled: boolean,
-  }
-}

+ 9 - 12
apps/app/src/pages/[[...path]].page.tsx

@@ -26,7 +26,6 @@ import { PageView } from '~/components/Page/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { PageModel, PageDocument } from '~/server/models/page';
@@ -40,7 +39,8 @@ import {
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
-  useEditorConfig, useIsAllReplyShown, useIsUploadAllFileAllowed, useIsUploadEnabled, useIsContainerFluid, useIsNotCreatable,
+  useIsAllReplyShown, useIsContainerFluid, useIsNotCreatable,
+  useIsUploadAllFileAllowed, useIsUploadEnabled,
 } from '~/stores/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
@@ -159,7 +159,8 @@ type Props = CommonProps & {
   // highlightJsStyle: string,
   isAllReplyShown: boolean,
   isContainerFluid: boolean,
-  editorConfig: EditorConfig,
+  isUploadEnabled: boolean,
+  isUploadAllFileAllowed: boolean,
   isEnabledStaleNotification: boolean,
   isEnabledAttachTitleHeader: boolean,
   // isEnabledLinebreaks: boolean,
@@ -186,7 +187,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useCurrentUser(props.currentUser ?? null);
 
   // commons
-  useEditorConfig(props.editorConfig);
   useCsrfToken(props.csrfToken);
   useGrowiCloudUri(props.growiCloudUri);
 
@@ -220,8 +220,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
   useIsAllReplyShown(props.isAllReplyShown);
 
-  useIsUploadAllFileAllowed(props.editorConfig.upload.isUploadAllFileAllowed);
-  useIsUploadEnabled(props.editorConfig.upload.isUploadEnabled);
+  useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
+  useIsUploadEnabled(props.isUploadEnabled);
 
   const { pageWithMeta } = props;
 
@@ -562,12 +562,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
   props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
   props.disableLinkSharing = configManager.getConfig('crowi', 'security:disableLinkSharing');
-  props.editorConfig = {
-    upload: {
-      isUploadAllFileAllowed: crowi.fileUploadService.getFileUploadEnabled(),
-      isUploadEnabled: crowi.fileUploadService.getIsUploadable(),
-    },
-  };
+  props.isUploadAllFileAllowed = crowi.fileUploadService.getFileUploadEnabled();
+  props.isUploadEnabled = crowi.fileUploadService.getIsUploadable();
+
   props.adminPreferredIndentSize = configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize');
   props.isIndentSizeForced = configManager.getConfig('markdown', 'markdown:isIndentSizeForced');
 

+ 24 - 9
apps/app/src/stores/context.tsx

@@ -1,13 +1,14 @@
+import { AcceptedUploadFileType } from '@growi/core';
 import type { ColorScheme, IUserHasId } from '@growi/core';
-import useSWR, { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+import useSWR from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
-import { SupportedActionType } from '~/interfaces/activity';
-import { EditorConfig } from '~/interfaces/editor-settings';
-import { RendererConfig } from '~/interfaces/services/renderer';
+import type { SupportedActionType } from '~/interfaces/activity';
+import type { RendererConfig } from '~/interfaces/services/renderer';
 import InterceptorManager from '~/services/interceptor-manager';
 
-import { TargetAndAncestors } from '../interfaces/page-listing-results';
+import type { TargetAndAncestors } from '../interfaces/page-listing-results';
 
 import { useContextSWR } from './use-context-swr';
 import { useStaticSWR } from './use-static-swr';
@@ -140,10 +141,6 @@ export const useIsEnabledStaleNotification = (initialData?: boolean): SWRRespons
   return useContextSWR('isEnabledStaleNotification', initialData);
 };
 
-export const useEditorConfig = (initialData?: EditorConfig): SWRResponse<EditorConfig, Error> => {
-  return useContextSWR<EditorConfig, Error>('editorConfig', initialData);
-};
-
 export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<RendererConfig, any> => {
   return useContextSWR('growiRendererConfig', initialData);
 };
@@ -259,3 +256,21 @@ export const useIsEditable = (): SWRResponse<boolean, Error> => {
     },
   );
 };
+
+export const useAcceptedUploadFileType = (): SWRResponse<AcceptedUploadFileType, Error> => {
+  const { data: isUploadEnabled } = useIsUploadEnabled();
+  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
+
+  return useSWRImmutable(
+    ['acceptedUploadFileType', isUploadEnabled, isUploadAllFileAllowed],
+    ([, isUploadEnabled, isUploadAllFileAllowed]) => {
+      if (!isUploadEnabled) {
+        return AcceptedUploadFileType.NONE;
+      }
+      if (isUploadAllFileAllowed) {
+        return AcceptedUploadFileType.ALL;
+      }
+      return AcceptedUploadFileType.IMAGE;
+    },
+  );
+};

+ 1 - 1
apps/app/src/stores/editor.tsx

@@ -1,6 +1,6 @@
 import { useCallback } from 'react';
 
-import type { Nullable } from '@growi/core';
+import { type Nullable } from '@growi/core';
 import { withUtils, type SWRResponseWithUtils } from '@growi/core/dist/swr';
 import useSWR, { type SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';

+ 3 - 3
packages/editor/src/consts/accepted-upload-file-type.ts → packages/core/src/consts/accepted-upload-file-type.ts

@@ -1,6 +1,6 @@
 export const AcceptedUploadFileType = {
-  ALL: '*',
-  IMAGE: 'image/*',
-  NONE: '',
+  ALL: 'all',
+  IMAGE: 'image',
+  NONE: 'none',
 } as const;
 export type AcceptedUploadFileType = typeof AcceptedUploadFileType[keyof typeof AcceptedUploadFileType];

+ 1 - 0
packages/core/src/consts/index.ts

@@ -1 +1,2 @@
+export * from './accepted-upload-file-type';
 export * from './growi-plugin';

+ 33 - 16
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -5,9 +5,10 @@ import {
 import { indentUnit } from '@codemirror/language';
 import { Prec, Extension } from '@codemirror/state';
 import { EditorView } from '@codemirror/view';
+import { AcceptedUploadFileType } from '@growi/core';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
-import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
+import { GlobalCodeMirrorEditorKey } from '../../consts';
 import {
   useFileDropzone, FileDropzoneOverlay, getEditorTheme, type EditorTheme, getKeymap, type KeyMapMode,
 } from '../../services';
@@ -27,29 +28,32 @@ const CodeMirrorEditorContainer = forwardRef<HTMLDivElement>((props, ref) => {
   );
 });
 
-type Props = {
-  editorKey: string | GlobalCodeMirrorEditorKey,
-  acceptedFileType: AcceptedUploadFileType,
+export type CodeMirrorEditorProps = {
+  acceptedUploadFileType?: AcceptedUploadFileType,
+  indentSize?: number,
+  editorTheme?: string,
+  editorKeymap?: string,
   onChange?: (value: string) => void,
   onSave?: () => void,
   onUpload?: (files: File[]) => void,
   onScroll?: () => void,
-  indentSize?: number,
-  editorTheme?: string,
-  editorKeymap?: string,
+}
+
+type Props = CodeMirrorEditorProps & {
+  editorKey: string | GlobalCodeMirrorEditorKey,
 }
 
 export const CodeMirrorEditor = (props: Props): JSX.Element => {
   const {
     editorKey,
-    acceptedFileType,
+    acceptedUploadFileType = AcceptedUploadFileType.NONE,
+    indentSize,
+    editorTheme,
+    editorKeymap,
     onChange,
     onSave,
     onUpload,
     onScroll,
-    indentSize,
-    editorTheme,
-    editorKeymap,
   } = props;
 
   const containerRef = useRef(null);
@@ -187,12 +191,20 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   const {
     getRootProps,
+    getInputProps,
     isDragActive,
     isDragAccept,
     isDragReject,
     isUploading,
-    open,
-  } = useFileDropzone({ onUpload, acceptedFileType });
+  } = useFileDropzone({
+    acceptedUploadFileType,
+    onUpload,
+    // ignore mouse and key events
+    dropzoneOpts: {
+      noClick: true,
+      noKeyboard: true,
+    },
+  });
 
   const fileUploadState = useMemo(() => {
 
@@ -200,7 +212,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
       return 'dropzone-uploading';
     }
 
-    switch (acceptedFileType) {
+    switch (acceptedUploadFileType) {
       case AcceptedUploadFileType.NONE:
         return 'dropzone-disabled';
 
@@ -224,15 +236,20 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     }
 
     return '';
-  }, [isUploading, isDragAccept, isDragReject, acceptedFileType]);
+  }, [isUploading, isDragAccept, isDragReject, acceptedUploadFileType]);
 
   return (
     <div className={`${style['codemirror-editor']} flex-expand-vert`}>
       <div {...getRootProps()} className={`dropzone ${fileUploadState} flex-expand-vert`}>
+        <input {...getInputProps()} />
         <FileDropzoneOverlay isEnabled={isDragActive} />
         <CodeMirrorEditorContainer ref={containerRef} />
-        <Toolbar editorKey={editorKey} onFileOpen={open} acceptedFileType={acceptedFileType} />
       </div>
+      <Toolbar
+        editorKey={editorKey}
+        acceptedUploadFileType={acceptedUploadFileType}
+        onUpload={onUpload}
+      />
     </div>
   );
 };

+ 0 - 38
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsButton.tsx

@@ -1,38 +0,0 @@
-import {
-  DropdownItem,
-} from 'reactstrap';
-
-import { AcceptedUploadFileType } from '../../../consts/accepted-upload-file-type';
-
-type Props = {
-  onFileOpen: () => void,
-  acceptedFileType: AcceptedUploadFileType,
-}
-
-export const AttachmentsButton = (props: Props): JSX.Element => {
-
-  const { onFileOpen, acceptedFileType } = props;
-
-  if (acceptedFileType === AcceptedUploadFileType.ALL) {
-    return (
-      <>
-        <DropdownItem className="d-flex gap-2 align-items-center" onClick={onFileOpen}>
-          <span className="material-symbols-outlined fs-5">attach_file</span>
-          Files
-        </DropdownItem>
-      </>
-    );
-  }
-  if (acceptedFileType === AcceptedUploadFileType.IMAGE) {
-    return (
-      <>
-        <DropdownItem className="d-flex gap-2 align-items-center" onClick={onFileOpen}>
-          <span className="material-symbols-outlined fs-5">image</span>
-          Images
-        </DropdownItem>
-      </>
-    );
-  }
-
-  return <></>;
-};

+ 38 - 0
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx

@@ -0,0 +1,38 @@
+import { ReactNode } from 'react';
+
+import { AcceptedUploadFileType } from '@growi/core';
+import {
+  DropdownItem,
+} from 'reactstrap';
+
+import { useFileDropzone } from '../../../services';
+
+type Props = {
+  acceptedUploadFileType: AcceptedUploadFileType,
+  children?: ReactNode,
+  onUpload?: (files: File[]) => void,
+}
+
+export const AttachmentsDropdownItem = (props: Props): JSX.Element => {
+
+  const {
+    acceptedUploadFileType,
+    children,
+    onUpload,
+  } = props;
+
+  const {
+    getRootProps,
+    getInputProps,
+    open,
+  } = useFileDropzone({ onUpload, acceptedUploadFileType });
+
+  return (
+    <div {...getRootProps()} className="dropzone">
+      <input {...getInputProps()} />
+      <DropdownItem className="d-flex gap-2 align-items-center" onClick={open}>
+        {children}
+      </DropdownItem>
+    </div>
+  );
+};

+ 28 - 9
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx

@@ -1,28 +1,32 @@
+import { useState } from 'react';
+
+import { AcceptedUploadFileType } from '@growi/core';
 import {
-  UncontrolledDropdown,
   DropdownToggle,
   DropdownMenu,
   DropdownItem,
+  Dropdown,
 } from 'reactstrap';
 
 import type { GlobalCodeMirrorEditorKey } from '../../../consts';
-import { AcceptedUploadFileType } from '../../../consts/accepted-upload-file-type';
 
-import { AttachmentsButton } from './AttachmentsButton';
+import { AttachmentsDropdownItem } from './AttachmentsDropdownItem';
 import { LinkEditButton } from './LinkEditButton';
 
 type Props = {
   editorKey: string | GlobalCodeMirrorEditorKey,
-  onFileOpen: () => void,
-  acceptedFileType: AcceptedUploadFileType,
+  acceptedUploadFileType: AcceptedUploadFileType,
+  onUpload?: (files: File[]) => void,
 }
 
 export const AttachmentsDropup = (props: Props): JSX.Element => {
-  const { onFileOpen, acceptedFileType, editorKey } = props;
+  const { acceptedUploadFileType, editorKey, onUpload } = props;
+
+  const [isOpen, setOpen] = useState(false);
 
   return (
     <>
-      <UncontrolledDropdown direction="up" className="lh-1">
+      <Dropdown isOpen={isOpen} toggle={() => setOpen(!isOpen)} direction="up" className="lh-1">
         <DropdownToggle className="btn-toolbar-button rounded-circle">
           <span className="material-symbols-outlined fs-6">add</span>
         </DropdownToggle>
@@ -30,11 +34,26 @@ export const AttachmentsDropup = (props: Props): JSX.Element => {
           <DropdownItem className="mt-1" header>
             Attachments
           </DropdownItem>
+
           <DropdownItem divider />
-          <AttachmentsButton onFileOpen={onFileOpen} acceptedFileType={acceptedFileType} />
+
+          { acceptedUploadFileType === AcceptedUploadFileType.ALL && (
+            <AttachmentsDropdownItem acceptedUploadFileType={AcceptedUploadFileType.ALL} onUpload={onUpload}>
+              <span className="material-symbols-outlined fs-5">attach_file</span>
+              Files
+            </AttachmentsDropdownItem>
+          ) }
+
+          { acceptedUploadFileType !== AcceptedUploadFileType.NONE && (
+            <AttachmentsDropdownItem acceptedUploadFileType={AcceptedUploadFileType.IMAGE} onUpload={onUpload}>
+              <span className="material-symbols-outlined fs-5">image</span>
+              Images
+            </AttachmentsDropdownItem>
+          ) }
+
           <LinkEditButton editorKey={editorKey} />
         </DropdownMenu>
-      </UncontrolledDropdown>
+      </Dropdown>
     </>
   );
 };

+ 7 - 5
packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx

@@ -1,6 +1,8 @@
 import { memo } from 'react';
 
-import type { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../../consts';
+import { AcceptedUploadFileType } from '@growi/core';
+
+import type { GlobalCodeMirrorEditorKey } from '../../../consts';
 
 import { AttachmentsDropup } from './AttachmentsDropup';
 import { DiagramButton } from './DiagramButton';
@@ -13,16 +15,16 @@ import styles from './Toolbar.module.scss';
 
 type Props = {
   editorKey: string | GlobalCodeMirrorEditorKey,
-  onFileOpen: () => void,
-  acceptedFileType: AcceptedUploadFileType
+  acceptedUploadFileType: AcceptedUploadFileType,
+  onUpload?: (files: File[]) => void,
 }
 
 export const Toolbar = memo((props: Props): JSX.Element => {
 
-  const { editorKey, onFileOpen, acceptedFileType } = props;
+  const { editorKey, acceptedUploadFileType, onUpload } = props;
   return (
     <div className={`d-flex gap-2 p-2 codemirror-editor-toolbar ${styles['codemirror-editor-toolbar']}`}>
-      <AttachmentsDropup editorKey={editorKey} onFileOpen={onFileOpen} acceptedFileType={acceptedFileType} />
+      <AttachmentsDropup editorKey={editorKey} onUpload={onUpload} acceptedUploadFileType={acceptedUploadFileType} />
       <TextFormatTools editorKey={editorKey} />
       <EmojiButton
         editorKey={editorKey}

+ 10 - 13
packages/editor/src/components/CodeMirrorEditorComment.tsx

@@ -3,10 +3,10 @@ import { useEffect } from 'react';
 import type { Extension } from '@codemirror/state';
 import { keymap, scrollPastEnd } from '@codemirror/view';
 
-import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../consts';
+import { GlobalCodeMirrorEditorKey } from '../consts';
 import { useCodeMirrorEditorIsolated } from '../stores';
 
-import { CodeMirrorEditor } from '.';
+import { CodeMirrorEditor, CodeMirrorEditorProps } from '.';
 
 
 const additionalExtensions: Extension[] = [
@@ -14,19 +14,15 @@ const additionalExtensions: Extension[] = [
 ];
 
 
-type Props = {
-  onChange?: (value: string) => void,
-  onComment?: () => void,
-  acceptedFileType?: AcceptedUploadFileType,
-}
+type Props = CodeMirrorEditorProps & object
 
 export const CodeMirrorEditorComment = (props: Props): JSX.Element => {
   const {
-    onComment, onChange, acceptedFileType,
+    acceptedUploadFileType,
+    onSave, onChange, onUpload,
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.COMMENT);
-  const acceptedFileTypeNoOpt = acceptedFileType ?? AcceptedUploadFileType.NONE;
 
   // setup additional extensions
   useEffect(() => {
@@ -35,7 +31,7 @@ export const CodeMirrorEditorComment = (props: Props): JSX.Element => {
 
   // set handler to comment with ctrl/cmd + Enter key
   useEffect(() => {
-    if (onComment == null) {
+    if (onSave == null) {
       return;
     }
 
@@ -46,7 +42,7 @@ export const CodeMirrorEditorComment = (props: Props): JSX.Element => {
         run: () => {
           const doc = codeMirrorEditor?.getDoc();
           if (doc != null) {
-            onComment();
+            onSave();
           }
           return true;
         },
@@ -56,13 +52,14 @@ export const CodeMirrorEditorComment = (props: Props): JSX.Element => {
     const cleanupFunction = codeMirrorEditor?.appendExtensions?.(keymapExtension);
 
     return cleanupFunction;
-  }, [codeMirrorEditor, onComment]);
+  }, [codeMirrorEditor, onSave]);
 
   return (
     <CodeMirrorEditor
       editorKey={GlobalCodeMirrorEditorKey.COMMENT}
+      acceptedUploadFileType={acceptedUploadFileType}
       onChange={onChange}
-      acceptedFileType={acceptedFileTypeNoOpt}
+      onUpload={onUpload}
     />
   );
 };

+ 8 - 15
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -3,11 +3,11 @@ import { useEffect } from 'react';
 import { type Extension } from '@codemirror/state';
 import { keymap, scrollPastEnd } from '@codemirror/view';
 
-import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../consts';
+import { GlobalCodeMirrorEditorKey } from '../consts';
 import { setDataLine } from '../services/extensions/setDataLine';
 import { useCodeMirrorEditorIsolated, useCollaborativeEditorMode } from '../stores';
 
-import { CodeMirrorEditor } from '.';
+import { CodeMirrorEditor, CodeMirrorEditorProps } from '.';
 
 const additionalExtensions: Extension[] = [
   [
@@ -16,32 +16,25 @@ const additionalExtensions: Extension[] = [
   ],
 ];
 
-type Props = {
-  onChange?: (value: string) => void,
-  onSave?: () => void,
-  onUpload?: (files: File[]) => void,
-  onScroll?: () => void,
-  acceptedFileType?: AcceptedUploadFileType,
-  indentSize?: number,
+type Props = CodeMirrorEditorProps & {
   userName?: string,
   pageId?: string,
   initialValue?: string,
   onOpenEditor?: (markdown: string) => void,
-  editorTheme?: string,
-  editorKeymap?: string,
 }
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize, userName, pageId, initialValue, onOpenEditor, editorTheme, editorKeymap,
+    acceptedUploadFileType,
+    indentSize, userName, pageId, initialValue,
+    editorTheme, editorKeymap,
+    onSave, onChange, onUpload, onScroll, onOpenEditor,
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
   useCollaborativeEditorMode(userName, pageId, initialValue, onOpenEditor, codeMirrorEditor);
 
-  const acceptedFileTypeNoOpt = acceptedFileType ?? AcceptedUploadFileType.NONE;
-
   // setup additional extensions
   useEffect(() => {
     return codeMirrorEditor?.appendExtensions?.(additionalExtensions);
@@ -80,7 +73,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
       onSave={onSave}
       onUpload={onUpload}
       onScroll={onScroll}
-      acceptedFileType={acceptedFileTypeNoOpt}
+      acceptedUploadFileType={acceptedUploadFileType}
       indentSize={indentSize}
       editorTheme={editorTheme}
       editorKeymap={editorKeymap}

+ 3 - 2
packages/editor/src/components/playground/Playground.tsx

@@ -2,9 +2,10 @@ import {
   useCallback, useEffect, useState,
 } from 'react';
 
+import { AcceptedUploadFileType } from '@growi/core';
 import { toast } from 'react-toastify';
 
-import { AcceptedUploadFileType, GlobalCodeMirrorEditorKey } from '../../consts';
+import { GlobalCodeMirrorEditorKey } from '../../consts';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 import { CodeMirrorEditorMain } from '../CodeMirrorEditorMain';
 
@@ -62,7 +63,7 @@ export const Playground = (): JSX.Element => {
             onChange={setMarkdownToPreview}
             onUpload={uploadHandler}
             indentSize={4}
-            acceptedFileType={AcceptedUploadFileType.ALL}
+            acceptedUploadFileType={AcceptedUploadFileType.ALL}
             editorTheme={editorTheme}
             editorKeymap={editorKeymap}
           />

+ 0 - 1
packages/editor/src/consts/index.ts

@@ -1,3 +1,2 @@
 export * from './global-code-mirror-editor-key';
 export * from './ydoc-awareness-user-color';
-export * from './accepted-upload-file-type';

+ 15 - 13
packages/editor/src/services/file-dropzone/use-file-dropzone/use-file-dropzone.ts

@@ -1,22 +1,23 @@
 import { useCallback, useState } from 'react';
 
+import { AcceptedUploadFileType } from '@growi/core';
 import { useDropzone, Accept } from 'react-dropzone';
-import type { DropzoneState } from 'react-dropzone';
+import type { DropzoneOptions, DropzoneState } from 'react-dropzone';
 
-import { AcceptedUploadFileType } from '../../../consts';
 
 type FileDropzoneState = DropzoneState & {
   isUploading: boolean,
 }
 
-type DropzoneEditor = {
+type Props = {
+  acceptedUploadFileType: AcceptedUploadFileType,
+  dropzoneOpts?: DropzoneOptions,
   onUpload?: (files: File[]) => void,
-  acceptedFileType: AcceptedUploadFileType,
 }
 
-export const useFileDropzone = (props: DropzoneEditor): FileDropzoneState => {
+export const useFileDropzone = (props: Props): FileDropzoneState => {
 
-  const { onUpload, acceptedFileType } = props;
+  const { acceptedUploadFileType, dropzoneOpts, onUpload } = props;
 
   const [isUploading, setIsUploading] = useState(false);
 
@@ -24,7 +25,7 @@ export const useFileDropzone = (props: DropzoneEditor): FileDropzoneState => {
     if (onUpload == null) {
       return;
     }
-    if (acceptedFileType === AcceptedUploadFileType.NONE) {
+    if (acceptedUploadFileType === AcceptedUploadFileType.NONE) {
       return;
     }
 
@@ -32,17 +33,18 @@ export const useFileDropzone = (props: DropzoneEditor): FileDropzoneState => {
     onUpload(acceptedFiles);
     setIsUploading(false);
 
-  }, [onUpload, setIsUploading, acceptedFileType]);
+  }, [onUpload, setIsUploading, acceptedUploadFileType]);
 
-  const accept: Accept = {
-  };
-  accept[acceptedFileType] = [];
+  const accept: Accept | undefined = acceptedUploadFileType === AcceptedUploadFileType.IMAGE
+    ? {
+      'image/*': [],
+    }
+    : undefined;
 
   const dzState = useDropzone({
-    noKeyboard: true,
-    noClick: true,
     onDrop: dropHandler,
     accept,
+    ...dropzoneOpts,
   });
 
   return {