Forráskód Böngészése

Merge branch 'dev/7.0.x' into fix/134173-fix-tag-edit

soumaeda 2 éve
szülő
commit
63c4d30782
37 módosított fájl, 436 hozzáadás és 419 törlés
  1. 0 1
      .devcontainer/Dockerfile
  2. 1 1
      .devcontainer/devcontainer.json
  3. 10 13
      apps/app/src/components/PageComment/Comment.tsx
  4. 5 5
      apps/app/src/components/PageComment/CommentEditor.tsx
  5. 4 1
      apps/app/src/components/PageComment/DeleteCommentModal.tsx
  6. 0 106
      apps/app/src/components/PageEditor/Editor.module.scss
  7. 6 6
      apps/app/src/components/PageEditor/Editor.tsx
  8. 9 9
      apps/app/src/components/PageEditor/PageEditor.tsx
  9. 6 0
      apps/app/src/features/comment/server/events/consts.ts
  10. 3 0
      apps/app/src/features/comment/server/events/event-emitter.ts
  11. 2 0
      apps/app/src/features/comment/server/events/index.ts
  12. 2 0
      apps/app/src/features/comment/server/index.ts
  13. 121 0
      apps/app/src/features/comment/server/models/comment.ts
  14. 1 0
      apps/app/src/features/comment/server/models/index.ts
  15. 5 6
      apps/app/src/interfaces/comment.ts
  16. 2 2
      apps/app/src/interfaces/editor-settings.ts
  17. 5 5
      apps/app/src/pages/[[...path]].page.tsx
  18. 0 1
      apps/app/src/server/crowi/index.js
  19. 0 26
      apps/app/src/server/events/comment.ts
  20. 0 122
      apps/app/src/server/models/comment.js
  21. 0 1
      apps/app/src/server/models/index.js
  22. 1 2
      apps/app/src/server/models/obsolete-page.js
  23. 10 17
      apps/app/src/server/routes/comment.js
  24. 12 16
      apps/app/src/server/service/comment.ts
  25. 0 3
      apps/app/src/server/service/in-app-notification.ts
  26. 1 1
      apps/app/src/server/service/page.ts
  27. 1 1
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  28. 4 4
      apps/app/src/server/service/search.ts
  29. 4 4
      apps/app/src/stores/context.tsx
  30. 0 20
      apps/app/src/styles/_mixins.scss
  31. 0 2
      apps/app/test/integration/service/v5.public-page.test.ts
  32. 101 0
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.module.scss
  33. 47 5
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  34. 2 1
      packages/editor/src/services/file-dropzone/index.ts
  35. 0 38
      packages/editor/src/services/file-dropzone/use-file-dropzone.ts
  36. 19 0
      packages/editor/src/services/file-dropzone/use-file-dropzone/FileDropzoneOverlay.tsx
  37. 52 0
      packages/editor/src/services/file-dropzone/use-file-dropzone/use-file-dropzone.ts

+ 0 - 1
.devcontainer/Dockerfile

@@ -50,7 +50,6 @@ RUN apt-get update \
     && rm -rf /var/lib/apt/lists/*
 ENV DEBIAN_FRONTEND=dialog
 
-RUN git-lfs pull
 RUN yarn global add turbo
 RUN yarn global add node-gyp
 

+ 1 - 1
.devcontainer/devcontainer.json

@@ -34,7 +34,7 @@
   // "shutdownAction": "none",
 
   // Use 'postCreateCommand' to run commands after the container is created.
-  "postCreateCommand": "yarn global add turbo node-gyp && yarn install",
+  "postCreateCommand": "git-lfs pull & yarn install",
 
   // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
   "remoteUser": "node"

+ 10 - 13
apps/app/src/components/PageComment/Comment.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useMemo, useState } from 'react';
 
-import type { IUser } from '@growi/core';
+import { isPopulated, type IUser } from '@growi/core';
 import * as pathUtils from '@growi/core/dist/utils/path-utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format, parseISO } from 'date-fns';
@@ -51,8 +51,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
   const [isReEdit, setIsReEdit] = useState(false);
 
   const commentId = comment._id;
-  const creator = comment.creator;
-  const isMarkdown = comment.isMarkdown;
+  const creator = isPopulated(comment.creator) ? comment.creator : undefined;
   const createdAt = new Date(comment.createdAt);
   const updatedAt = new Date(comment.updatedAt);
   const isEdited = createdAt < updatedAt;
@@ -122,16 +121,14 @@ export const Comment = (props: CommentProps): JSX.Element => {
       return <></>;
     }
 
-    return isMarkdown
-      ? (
-        <RevisionRenderer
-          rendererOptions={rendererOptions}
-          markdown={markdown}
-          additionalClassName="comment"
-        />
-      )
-      : renderText(comment.comment);
-  }, [comment, isMarkdown, markdown, rendererOptions]);
+    return (
+      <RevisionRenderer
+        rendererOptions={rendererOptions}
+        markdown={markdown}
+        additionalClassName="comment"
+      />
+    );
+  }, [markdown, rendererOptions]);
 
   const rootClassName = getRootClassName(comment);
   const revHref = `?revisionId=${comment.revision}`;

+ 5 - 5
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -16,7 +16,7 @@ import { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment, useSWRxEditingCommentsNum } from '~/stores/comment';
 import {
   useCurrentUser, useIsSlackConfigured,
-  useIsUploadableFile, useIsUploadableImage,
+  useIsUploadAllFileAllowed, useIsUploadEnabled,
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
@@ -71,8 +71,8 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackConfigured } = useIsSlackConfigured();
-  const { data: isUploadableFile } = useIsUploadableFile();
-  const { data: isUploadableImage } = useIsUploadableImage();
+  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
+  const { data: isUploadEnabled } = useIsUploadEnabled();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const {
     increment: incrementEditingCommentsNum,
@@ -303,7 +303,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       </Button>
     );
 
-    const isUploadable = isUploadableImage || isUploadableFile;
+    const isUploadable = isUploadEnabled || isUploadAllFileAllowed;
 
     return (
       <>
@@ -315,7 +315,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
                 ref={editorRef}
                 value={commentBody ?? ''} // DO NOT use state
                 isUploadable={isUploadable}
-                isUploadableFile={isUploadableFile}
+                isUploadAllFileAllowed={isUploadAllFileAllowed}
                 onChange={onChangeHandler}
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}

+ 4 - 1
apps/app/src/components/PageComment/DeleteCommentModal.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { isPopulated } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns';
 import {
@@ -47,6 +48,8 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
 
     const commentDate = format(new Date(comment.createdAt), 'yyyy/MM/dd HH:mm');
 
+    const creator = isPopulated(comment.creator) ? comment.creator : undefined;
+
     let commentBody = comment.comment;
     if (commentBody.length > OMIT_BODY_THRES) { // omit
       commentBody = `${commentBody.substr(0, OMIT_BODY_THRES)}...`;
@@ -55,7 +58,7 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
 
     return (
       <>
-        <UserPicture user={comment.creator} size="xs" /> <strong><Username user={comment.creator}></Username></strong> wrote on {commentDate}:
+        <UserPicture user={creator} size="xs" /> <strong><Username user={creator}></Username></strong> wrote on {commentDate}:
         <p className="card custom-card comment-body mt-2 p-2">{commentBodyElement}</p>
       </>
     );

+ 0 - 106
apps/app/src/components/PageEditor/Editor.module.scss

@@ -4,112 +4,6 @@
 
 
 .editor-container :global {
-  // overlay in .editor-container
-  .overlay {
-    position: absolute;
-    top: 0;
-    right: 0;
-    bottom: 0;
-    left: 0;
-    z-index: 7; // forward than .CodeMirror-vscrollbar
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-
-  // loading keymap
-  @include ms.overlay-processing-style(overlay-loading-keymap, 2.5em, 0.3em);
-
-  // for Dropzone
-  .dropzone {
-    position: relative; // against .overlay position: absolute
-
-    @include ms.overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
-
-    // unuploadable or rejected
-    &.dropzone-unuploadable,
-    &.dropzone-rejected {
-      .overlay.overlay-dropzone-active {
-        background: rgba(200, 200, 200, 0.8);
-
-        .overlay-content {
-          color: bs.$gray-300;
-        }
-      }
-    }
-
-    // uploading
-    &.dropzone-uploading {
-      @include ms.overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
-    }
-
-    // unuploadable
-    &.dropzone-unuploadable {
-      .overlay.overlay-dropzone-active {
-        .overlay-content {
-          // insert content
-          @include ms.insertSimpleLineIcons('\e617'); // icon-exclamation
-
-          &:after {
-            content: 'File uploading is disabled';
-          }
-        }
-      }
-    }
-
-    // uploadable
-    &.dropzone-uploadable {
-      // accepted
-      &.dropzone-accepted:not(.dropzone-rejected) {
-        .overlay.overlay-dropzone-active {
-          border: 4px dashed bs.$gray-300;
-
-          .overlay-content {
-            // insert content
-            @include ms.insertSimpleLineIcons('\e084'); // icon-cloud-upload
-
-            &:after {
-              content: 'Drop here to upload';
-            }
-
-            // style
-            color: bs.$secondary;
-            background: rgba(200, 200, 200, 0.8);
-          }
-        }
-      }
-
-      // file type mismatch
-      &.dropzone-rejected:not(.dropzone-uploadablefile) {
-        .overlay.overlay-dropzone-active {
-          .overlay-content {
-            // insert content
-            @include ms.insertSimpleLineIcons('\e032'); // icon-picture
-
-            &:after {
-              content: 'Only an image file is allowed';
-            }
-          }
-        }
-      }
-
-      // multiple files
-      &.dropzone-accepted.dropzone-rejected {
-        .overlay.overlay-dropzone-active {
-          .overlay-content {
-            // insert content
-            @include ms.insertSimpleLineIcons('\e617'); // icon-exclamation
-
-            &:after {
-              content: 'Only 1 file is allowed';
-            }
-          }
-        }
-      }
-    }
-
-    /* end of.dropzone */
-  }
 
   .btn.btn-open-dropzone {
     z-index: 2;

+ 6 - 6
apps/app/src/components/PageEditor/Editor.tsx

@@ -32,7 +32,7 @@ export type EditorPropsType = {
   isGfmMode?: boolean,
   noCdn?: boolean,
   isUploadable?: boolean,
-  isUploadableFile?: boolean,
+  isUploadAllFileAllowed?: boolean,
   onChange?: (newValue: string, isClean?: boolean) => void,
   onUpload?: (file) => void,
   editorSettings?: IEditorSettings,
@@ -54,7 +54,7 @@ type DropzoneRef = {
 
 const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props, ref): JSX.Element => {
   const {
-    onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
+    onUpload, isUploadable, isUploadAllFileAllowed, indentSize, isGfmMode = true,
   } = props;
 
   const [dropzoneActive, setDropzoneActive] = useState(false);
@@ -121,7 +121,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
   const getAcceptableType = useCallback(() => {
     let accept = 'null'; // reject all
     if (isUploadable) {
-      if (!isUploadableFile) {
+      if (!isUploadAllFileAllowed) {
         accept = 'image/*'; // image only
       }
       else {
@@ -130,7 +130,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     }
 
     return accept;
-  }, [isUploadable, isUploadableFile]);
+  }, [isUploadable, isUploadAllFileAllowed]);
 
   const pasteFilesHandler = useCallback((event) => {
     const items = event.clipboardData.items || event.clipboardData.files || [];
@@ -191,7 +191,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     else {
       className += ' dropzone-uploadable';
 
-      if (isUploadableFile) {
+      if (isUploadAllFileAllowed) {
         className += ' dropzone-uploadablefile';
       }
     }
@@ -210,7 +210,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     }
 
     return className;
-  }, [isUploadable, isUploading, isUploadableFile]);
+  }, [isUploadable, isUploading, isUploadAllFileAllowed]);
 
   const renderDropzoneOverlay = useCallback(() => {
     return (

+ 9 - 9
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -24,7 +24,7 @@ import { SocketEventName } from '~/interfaces/websocket';
 import {
   useDefaultIndentSize,
   useCurrentPathname, useIsEnabledAttachTitleHeader,
-  useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsIndentSizeForced,
+  useIsEditable, useIsUploadAllFileAllowed, useIsUploadEnabled, useIsIndentSizeForced,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
@@ -110,8 +110,8 @@ 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: isUploadableFile } = useIsUploadableFile();
-  const { data: isUploadableImage } = useIsUploadableImage();
+  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
+  const { data: isUploadEnabled } = useIsUploadEnabled();
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
   const { mutate: mutateRemotePageId } = useRemoteRevisionId();
@@ -356,14 +356,14 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   }, [codeMirrorEditor, currentPagePath, pageId]);
 
   const acceptedFileType = useMemo(() => {
-    if (!isUploadableFile) {
+    if (!isUploadEnabled) {
       return AcceptedUploadFileType.NONE;
     }
-    if (isUploadableImage) {
-      return AcceptedUploadFileType.IMAGE;
+    if (isUploadAllFileAllowed) {
+      return AcceptedUploadFileType.ALL;
     }
-    return AcceptedUploadFileType.ALL;
-  }, [isUploadableFile, isUploadableImage]);
+    return AcceptedUploadFileType.IMAGE;
+  }, [isUploadAllFileAllowed, isUploadEnabled]);
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
     if (previewRef.current == null) {
@@ -572,7 +572,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             ref={editorRef}
             value={initialValue}
             isUploadable={isUploadable}
-            isUploadableFile={isUploadableFile}
+            isUploadAllFileAllowed={isUploadAllFileAllowed}
             indentSize={currentIndentSize}
             onScroll={editorScrolledHandler}
             onScrollCursorIntoView={editorScrollCursorIntoViewHandler}

+ 6 - 0
apps/app/src/features/comment/server/events/consts.ts

@@ -0,0 +1,6 @@
+export const CommentEvent = {
+  CREATE: 'create',
+  UPDATE: 'update',
+  DELETE: 'delete',
+} as const;
+export type CommentEvent = typeof CommentEvent[keyof typeof CommentEvent];

+ 3 - 0
apps/app/src/features/comment/server/events/event-emitter.ts

@@ -0,0 +1,3 @@
+import { EventEmitter } from 'events';
+
+export const commentEvent = new EventEmitter();

+ 2 - 0
apps/app/src/features/comment/server/events/index.ts

@@ -0,0 +1,2 @@
+export * from './consts';
+export * from './event-emitter';

+ 2 - 0
apps/app/src/features/comment/server/index.ts

@@ -0,0 +1,2 @@
+export * from './events';
+export * from './models';

+ 121 - 0
apps/app/src/features/comment/server/models/comment.ts

@@ -0,0 +1,121 @@
+import type { IUser } from '@growi/core/dist/interfaces';
+import {
+  Types, Document, Model, Schema, Query,
+} from 'mongoose';
+
+import { IComment } from '~/interfaces/comment';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:models:comment');
+
+export interface CommentDocument extends IComment, Document {
+  removeWithReplies: () => Promise<void>
+  findCreatorsByPage: (pageId: Types.ObjectId) => Promise<CommentDocument[]>
+}
+
+
+type Add = (
+  pageId: Types.ObjectId,
+  creatorId: Types.ObjectId,
+  revisionId: Types.ObjectId,
+  comment: string,
+  commentPosition: number,
+  replyTo?: Types.ObjectId | null,
+) => Promise<CommentDocument>;
+type FindCommentsByPageId = (pageId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
+type FindCommentsByRevisionId = (revisionId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
+type FindCreatorsByPage = (pageId: Types.ObjectId) => Promise<IUser[]>
+type GetPageIdToCommentMap = (pageIds: Types.ObjectId[]) => Promise<Record<string, CommentDocument[]>>
+type CountCommentByPageId = (pageId: Types.ObjectId) => Promise<number>
+
+export interface CommentModel extends Model<CommentDocument> {
+  add: Add
+  findCommentsByPageId: FindCommentsByPageId
+  findCommentsByRevisionId: FindCommentsByRevisionId
+  findCreatorsByPage: FindCreatorsByPage
+  getPageIdToCommentMap: GetPageIdToCommentMap
+  countCommentByPageId: CountCommentByPageId
+}
+
+const commentSchema = new Schema<CommentDocument, CommentModel>({
+  page: { type: Schema.Types.ObjectId, ref: 'Page', index: true },
+  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  revision: { type: Schema.Types.ObjectId, ref: 'Revision', index: true },
+  comment: { type: String, required: true },
+  commentPosition: { type: Number, default: -1 },
+  replyTo: { type: Schema.Types.ObjectId },
+}, {
+  timestamps: true,
+});
+
+const add: Add = async function(
+    this: CommentModel,
+    pageId,
+    creatorId,
+    revisionId,
+    comment,
+    commentPosition,
+    replyTo?,
+): Promise<CommentDocument> {
+  try {
+    const data = await this.create({
+      page: pageId.toString(),
+      creator: creatorId.toString(),
+      revision: revisionId.toString(),
+      comment,
+      commentPosition,
+      replyTo,
+    });
+    logger.debug('Comment saved.', data);
+
+    return data;
+  }
+  catch (err) {
+    logger.debug('Error on saving comment.', err);
+    throw err;
+  }
+};
+commentSchema.statics.add = add;
+
+commentSchema.statics.findCommentsByPageId = function(id) {
+  return this.find({ page: id }).sort({ createdAt: -1 });
+};
+
+commentSchema.statics.findCommentsByRevisionId = function(id) {
+  return this.find({ revision: id }).sort({ createdAt: -1 });
+};
+
+commentSchema.statics.findCreatorsByPage = async function(page) {
+  return this.distinct('creator', { page }).exec();
+};
+
+/**
+ * @return {object} key: page._id, value: comments
+ */
+commentSchema.statics.getPageIdToCommentMap = async function(pageIds) {
+  const results = await this.aggregate()
+    .match({ page: { $in: pageIds } })
+    .group({ _id: '$page', comments: { $push: '$comment' } });
+
+  // convert to map
+  const idToCommentMap = {};
+  results.forEach((result, i) => {
+    idToCommentMap[result._id] = result.comments;
+  });
+
+  return idToCommentMap;
+};
+
+commentSchema.statics.countCommentByPageId = async function(page) {
+  return this.count({ page });
+};
+
+commentSchema.methods.removeWithReplies = async function(comment) {
+  await this.remove({
+    $or: (
+      [{ replyTo: this._id }, { _id: this._id }]),
+  });
+};
+
+export const Comment = getOrCreateModel<CommentDocument, CommentModel>('Comment', commentSchema);

+ 1 - 0
apps/app/src/features/comment/server/models/index.ts

@@ -0,0 +1 @@
+export * from './comment';

+ 5 - 6
apps/app/src/interfaces/comment.ts

@@ -1,18 +1,17 @@
 import type {
-  Nullable, Ref, HasObjectId,
+  Ref, HasObjectId,
   IPage, IRevision, IUser,
 } from '@growi/core';
 
 export type IComment = {
+  page: Ref<IPage>,
+  creator: Ref<IUser>,
+  revision: Ref<IRevision>,
   comment: string;
   commentPosition: number,
-  isMarkdown: boolean,
-  replyTo: Nullable<string>,
+  replyTo?: string,
   createdAt: Date,
   updatedAt: Date,
-  page: Ref<IPage>,
-  revision: Ref<IRevision>,
-  creator: IUser,
 };
 
 export interface ICommentPostArgs {

+ 2 - 2
apps/app/src/interfaces/editor-settings.ts

@@ -18,7 +18,7 @@ export interface IEditorSettings {
 
 export type EditorConfig = {
   upload: {
-    isUploadableFile: boolean,
-    isUploadableImage: boolean,
+    isUploadAllFileAllowed: boolean,
+    isUploadEnabled: boolean,
   }
 }

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

@@ -37,7 +37,7 @@ import {
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useIsContainerFluid, useIsNotCreatable,
+  useEditorConfig, useIsAllReplyShown, useIsUploadAllFileAllowed, useIsUploadEnabled, useIsContainerFluid, useIsNotCreatable,
 } from '~/stores/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
@@ -217,8 +217,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
   useIsAllReplyShown(props.isAllReplyShown);
 
-  useIsUploadableFile(props.editorConfig.upload.isUploadableFile);
-  useIsUploadableImage(props.editorConfig.upload.isUploadableImage);
+  useIsUploadAllFileAllowed(props.editorConfig.upload.isUploadAllFileAllowed);
+  useIsUploadEnabled(props.editorConfig.upload.isUploadEnabled);
 
   const { pageWithMeta } = props;
 
@@ -561,8 +561,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.disableLinkSharing = configManager.getConfig('crowi', 'security:disableLinkSharing');
   props.editorConfig = {
     upload: {
-      isUploadableFile: crowi.fileUploadService.getFileUploadEnabled(),
-      isUploadableImage: crowi.fileUploadService.getIsUploadable(),
+      isUploadAllFileAllowed: crowi.fileUploadService.getFileUploadEnabled(),
+      isUploadEnabled: crowi.fileUploadService.getIsUploadable(),
     },
   };
   props.adminPreferredIndentSize = configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize');

+ 0 - 1
apps/app/src/server/crowi/index.js

@@ -100,7 +100,6 @@ function Crowi() {
     page: new (require('../events/page'))(this),
     activity: new (require('../events/activity'))(this),
     bookmark: new (require('../events/bookmark'))(this),
-    comment: new (require('../events/comment'))(this),
     tag: new (require('../events/tag'))(this),
     admin: new (require('../events/admin'))(this),
   };

+ 0 - 26
apps/app/src/server/events/comment.ts

@@ -1,26 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:events:comment');
-
-const events = require('events');
-const util = require('util');
-
-
-function CommentEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(CommentEvent, events.EventEmitter);
-
-CommentEvent.prototype.onCreate = function(comment) {
-  logger.info('onCreate comment event fired');
-};
-CommentEvent.prototype.onUpdate = function(comment) {
-  logger.info('onUpdate comment event fired');
-};
-CommentEvent.prototype.onDelete = function(comment) {
-  logger.info('onDelete comment event fired');
-};
-
-module.exports = CommentEvent;

+ 0 - 122
apps/app/src/server/models/comment.js

@@ -1,122 +0,0 @@
-module.exports = function(crowi) {
-  const debug = require('debug')('growi:models:comment');
-  const mongoose = require('mongoose');
-  const ObjectId = mongoose.Schema.Types.ObjectId;
-  const commentEvent = crowi.event('comment');
-
-  const commentSchema = new mongoose.Schema({
-    page: { type: ObjectId, ref: 'Page', index: true },
-    creator: { type: ObjectId, ref: 'User', index: true },
-    revision: { type: ObjectId, ref: 'Revision', index: true },
-    comment: { type: String, required: true },
-    commentPosition: { type: Number, default: -1 },
-    isMarkdown: { type: Boolean, default: false },
-    replyTo: { type: ObjectId },
-  }, {
-    timestamps: true,
-  });
-
-  commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position, isMarkdown, replyTo) {
-    const Comment = this;
-
-    return new Promise(((resolve, reject) => {
-      const newComment = new Comment();
-
-      newComment.page = pageId;
-      newComment.creator = creatorId;
-      newComment.revision = revisionId;
-      newComment.comment = comment;
-      newComment.commentPosition = position;
-      newComment.isMarkdown = isMarkdown || false;
-      newComment.replyTo = replyTo;
-
-      newComment.save((err, data) => {
-        if (err) {
-          debug('Error on saving comment.', err);
-          return reject(err);
-        }
-        debug('Comment saved.', data);
-        return resolve(data);
-      });
-    }));
-  };
-
-  commentSchema.statics.getCommentsByPageId = function(id) {
-    return this.find({ page: id }).sort({ createdAt: -1 });
-  };
-
-  commentSchema.statics.getCommentsByRevisionId = function(id) {
-    return this.find({ revision: id }).sort({ createdAt: -1 });
-  };
-
-
-  /**
-   * @return {object} key: page._id, value: comments
-   */
-  commentSchema.statics.getPageIdToCommentMap = async function(pageIds) {
-    const results = await this.aggregate()
-      .match({ page: { $in: pageIds } })
-      .group({ _id: '$page', comments: { $push: '$comment' } });
-
-    // convert to map
-    const idToCommentMap = {};
-    results.forEach((result, i) => {
-      idToCommentMap[result._id] = result.comments;
-    });
-
-    return idToCommentMap;
-  };
-
-  commentSchema.statics.countCommentByPageId = function(page) {
-    const self = this;
-
-    return new Promise(((resolve, reject) => {
-      self.count({ page }, (err, data) => {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(data);
-      });
-    }));
-  };
-
-  commentSchema.statics.updateCommentsByPageId = async function(comment, isMarkdown, commentId) {
-    const Comment = this;
-
-    const commentData = await Comment.findOneAndUpdate(
-      { _id: commentId },
-      { $set: { comment, isMarkdown } },
-    );
-
-    await commentEvent.emit('update', commentData);
-
-    return commentData;
-  };
-
-
-  /**
-   * post remove hook
-   */
-  commentSchema.post('reomove', async(savedComment) => {
-    await commentEvent.emit('delete', savedComment);
-  });
-
-  commentSchema.methods.removeWithReplies = async function(comment) {
-    const Comment = crowi.model('Comment');
-
-    await Comment.remove({
-      $or: (
-        [{ replyTo: this._id }, { _id: this._id }]),
-    });
-
-    await commentEvent.emit('delete', comment);
-    return;
-  };
-
-  commentSchema.statics.findCreatorsByPage = async function(page) {
-    return this.distinct('creator', { page }).exec();
-  };
-
-  return mongoose.model('Comment', commentSchema);
-};

+ 0 - 1
apps/app/src/server/models/index.js

@@ -9,7 +9,6 @@ module.exports = {
   Revision: require('./revision'),
   Tag: require('./tag'),
   Bookmark: require('./bookmark'),
-  Comment: require('./comment'),
   Attachment: require('./attachment'),
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),

+ 1 - 2
apps/app/src/server/models/obsolete-page.js

@@ -2,6 +2,7 @@ import { PageGrant } from '@growi/core';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
 
+import { Comment } from '~/features/comment/server';
 import loggerFactory from '~/utils/logger';
 
 
@@ -274,7 +275,6 @@ export const getPageSchema = (crowi) => {
     validateCrowi();
 
     const self = this;
-    const Comment = crowi.model('Comment');
     return Comment.countCommentByPageId(pageId)
       .then((count) => {
         self.update({ _id: pageId }, { commentCount: count }, {}, (err, data) => {
@@ -702,7 +702,6 @@ export const getPageSchema = (crowi) => {
   };
 
   pageSchema.methods.getNotificationTargetUsers = async function() {
-    const Comment = mongoose.model('Comment');
     const Revision = mongoose.model('Revision');
 
     const [commentCreators, revisionAuthors] = await Promise.all([Comment.findCreatorsByPage(this), Revision.findAuthorsByPage(this)]);

+ 10 - 17
apps/app/src/server/routes/comment.js

@@ -1,4 +1,5 @@
 
+import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -49,7 +50,6 @@ const { serializeUserSecurely } = require('../models/serializers/user-serializer
 
 module.exports = function(crowi, app) {
   const logger = loggerFactory('growi:routes:comment');
-  const Comment = crowi.model('Comment');
   const User = crowi.model('User');
   const Page = crowi.model('Page');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
@@ -124,21 +124,21 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
 
-    let fetcher = null;
+    let query = null;
 
     try {
       if (revisionId) {
-        fetcher = Comment.getCommentsByRevisionId(revisionId);
+        query = Comment.findCommentsByRevisionId(revisionId);
       }
       else {
-        fetcher = Comment.getCommentsByPageId(pageId);
+        query = Comment.findCommentsByPageId(pageId);
       }
     }
     catch (err) {
       return res.json(ApiResponse.error(err));
     }
 
-    const comments = await fetcher.populate('creator');
+    const comments = await query.populate('creator');
     comments.forEach((comment) => {
       if (comment.creator != null && comment.creator instanceof User) {
         comment.creator = serializeUserSecurely(comment.creator);
@@ -233,9 +233,7 @@ module.exports = function(crowi, app) {
     const revisionId = commentForm.revision_id;
     const comment = commentForm.comment;
     const position = commentForm.comment_position || -1;
-    const isMarkdown = commentForm.is_markdown ?? true; // comment is always markdown (https://github.com/weseek/growi/pull/6096)
     const replyTo = commentForm.replyTo;
-    const commentEvent = crowi.event('comment');
 
     // check whether accessible
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
@@ -245,8 +243,8 @@ module.exports = function(crowi, app) {
 
     let createdComment;
     try {
-      createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown, replyTo);
-      commentEvent.emit('create', createdComment);
+      createdComment = await Comment.add(pageId, req.user._id, revisionId, comment, position, replyTo);
+      commentEvent.emit(CommentEvent.CREATE, createdComment);
     }
     catch (err) {
       logger.error(err);
@@ -355,12 +353,9 @@ module.exports = function(crowi, app) {
     const { commentForm } = req.body;
 
     const commentStr = commentForm.comment;
-    const isMarkdown = commentForm.is_markdown ?? true; // comment is always markdown (https://github.com/weseek/growi/pull/6096)
     const commentId = commentForm.comment_id;
     const revision = commentForm.revision_id;
 
-    const commentEvent = crowi.event('comment');
-
     if (commentStr === '') {
       return res.json(ApiResponse.error('Comment text is required'));
     }
@@ -389,9 +384,9 @@ module.exports = function(crowi, app) {
 
       updatedComment = await Comment.findOneAndUpdate(
         { _id: commentId },
-        { $set: { comment: commentStr, isMarkdown, revision } },
+        { $set: { comment: commentStr, revision } },
       );
-      commentEvent.emit('update', updatedComment);
+      commentEvent.emit(CommentEvent.UPDATE, updatedComment);
     }
     catch (err) {
       logger.error(err);
@@ -448,8 +443,6 @@ module.exports = function(crowi, app) {
    * @apiParam {String} comment_id Comment Id.
    */
   api.remove = async function(req, res) {
-    const commentEvent = crowi.event('comment');
-
     const commentId = req.body.comment_id;
     if (!commentId) {
       return Promise.resolve(res.json(ApiResponse.error('\'comment_id\' is undefined')));
@@ -474,7 +467,7 @@ module.exports = function(crowi, app) {
 
       await comment.removeWithReplies(comment);
       await Page.updateCommentCount(comment.page);
-      commentEvent.emit('delete', comment);
+      commentEvent.emit(CommentEvent.DELETE, comment);
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 12 - 16
apps/app/src/server/service/comment.ts

@@ -1,5 +1,7 @@
 import { Types } from 'mongoose';
 
+import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
+
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import { getModelSafely } from '../util/mongoose-utils';
@@ -17,22 +19,18 @@ class CommentService {
 
   inAppNotificationService!: any;
 
-  commentEvent!: any;
-
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.activityService = crowi.activityService;
     this.inAppNotificationService = crowi.inAppNotificationService;
 
-    this.commentEvent = crowi.event('comment');
-
     // init
     this.initCommentEventListeners();
   }
 
   initCommentEventListeners(): void {
     // create
-    this.commentEvent.on('create', async(savedComment) => {
+    commentEvent.on(CommentEvent.CREATE, async(savedComment) => {
 
       try {
         const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
@@ -45,19 +43,11 @@ class CommentService {
     });
 
     // update
-    this.commentEvent.on('update', async() => {
-      try {
-        this.commentEvent.onUpdate();
-      }
-      catch (err) {
-        logger.error('Error occurred while handling the comment update event:\n', err);
-      }
+    commentEvent.on(CommentEvent.UPDATE, async() => {
     });
 
     // remove
-    this.commentEvent.on('delete', async(removedComment) => {
-      this.commentEvent.onDelete();
-
+    commentEvent.on(CommentEvent.DELETE, async(removedComment) => {
       try {
         const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
         await Page.updateCommentCount(removedComment.page);
@@ -69,11 +59,17 @@ class CommentService {
   }
 
   getMentionedUsers = async(commentId: Types.ObjectId): Promise<Types.ObjectId[]> => {
-    const Comment = getModelSafely('Comment') || require('../models/comment')(this.crowi);
     const User = getModelSafely('User') || require('../models/user')(this.crowi);
 
     // Get comment by comment ID
     const commentData = await Comment.findOne({ _id: commentId });
+
+    // not found
+    if (commentData == null) {
+      logger.warn(`The comment ('${commentId.toString()}') is not found.`);
+      return [];
+    }
+
     const { comment } = commentData;
 
     const usernamesFromComment = comment.match(USERNAME_PATTERN);

+ 0 - 3
apps/app/src/server/service/in-app-notification.ts

@@ -35,9 +35,6 @@ export default class InAppNotificationService {
 
   activityEvent!: any;
 
-  commentEvent!: any;
-
-
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.activityEvent = crowi.event('activity');

+ 1 - 1
apps/app/src/server/service/page.ts

@@ -14,6 +14,7 @@ import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, Cursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
+import { Comment } from '~/features/comment/server';
 import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import {
@@ -1687,7 +1688,6 @@ class PageService {
   private async deleteCompletelyOperation(pageIds, pagePaths) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');
-    const Comment = this.crowi.model('Comment');
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
     const ShareLink = this.crowi.model('ShareLink');

+ 1 - 1
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -5,6 +5,7 @@ import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
+import { Comment } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import {
   ISearchResult, ISearchResultData, SORT_AXIS, SORT_ORDER,
@@ -458,7 +459,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
-    const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;

+ 4 - 4
apps/app/src/server/service/search.ts

@@ -2,6 +2,7 @@ import type { IPageHasId } from '@growi/core';
 import mongoose from 'mongoose';
 import { FilterXSS } from 'xss';
 
+import { CommentEvent, commentEvent } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { IFormattedSearchResult, IPageWithSearchMeta, ISearchResult } from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
@@ -166,10 +167,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
     const tagEvent = this.crowi.event('tag');
     tagEvent.on('update', this.fullTextSearchDelegator.syncTagChanged.bind(this.fullTextSearchDelegator));
 
-    const commentEvent = this.crowi.event('comment');
-    commentEvent.on('create', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
-    commentEvent.on('update', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
-    commentEvent.on('delete', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on(CommentEvent.CREATE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on(CommentEvent.UPDATE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on(CommentEvent.DELETE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
   }
 
   resetErrorStatus() {

+ 4 - 4
apps/app/src/stores/context.tsx

@@ -157,12 +157,12 @@ export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boo
   return useContextSWR('isBlinkedAtBoot', initialData, { fallbackData: false });
 };
 
-export const useIsUploadableImage = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isUploadableImage', initialData);
+export const useIsUploadEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useContextSWR('isUploadEnabled', initialData);
 };
 
-export const useIsUploadableFile = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isUploadableFile', initialData);
+export const useIsUploadAllFileAllowed = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useContextSWR('isUploadAllFileAllowed', initialData);
 };
 
 export const useShowPageLimitationL = (initialData?: number): SWRResponse<number, Error> => {

+ 0 - 20
apps/app/src/styles/_mixins.scss

@@ -59,26 +59,6 @@
   }
 }
 
-@mixin overlay-processing-style($additionalSelector, $contentFontSize: inherit, $contentPadding: inherit) {
-  .overlay.#{$additionalSelector} {
-    background: rgba(255, 255, 255, 0.5);
-    .overlay-content {
-      padding: $contentPadding;
-      font-size: $contentFontSize;
-      color: bs.$gray-700;
-      background: rgba(200, 200, 200, 0.5);
-    }
-  }
-}
-
-@mixin insertSimpleLineIcons($code) {
-  &:before {
-    margin-right: 0.2em;
-    font-family: 'simple-line-icons';
-    content: $code;
-  }
-}
-
 @mixin grw-skeleton-text($font-size, $line-height) {
   height: $line-height;
   padding: (($line-height - $font-size)  / 2) 0;

+ 0 - 2
apps/app/test/integration/service/v5.public-page.test.ts

@@ -739,7 +739,6 @@ describe('PageService page operations with only public pages', () => {
     await Comment.insertMany([
       {
         commentPosition: -1,
-        isMarkdown: true,
         page: pageIdForDuplicate11,
         creator: dummyUser1._id,
         revision: revisionIdForDuplicate10,
@@ -982,7 +981,6 @@ describe('PageService page operations with only public pages', () => {
     await Comment.insertMany([
       {
         commentPosition: -1,
-        isMarkdown: true,
         page: pageIdForDeleteCompletely2,
         creator: dummyUser1._id,
         revision: revisionIdForDeleteCompletely4,

+ 101 - 0
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.module.scss

@@ -1,3 +1,6 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+
 .codemirror-editor-container :global {
 
   .cm-editor {
@@ -35,3 +38,101 @@
   }
 
 }
+
+
+@mixin insertMaterialSymbolAndMessage($code, $message) {
+  .overlay-icon:before {
+    margin-right: 0.2em;
+    font-size:1.4em;
+    content: $code;
+  }
+  &:after {
+    content: $message;
+  }
+}
+
+.codemirror-editor :global {
+
+  // overlay in .codemirror-editor
+  .overlay {
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    z-index: 7; // forward than .CodeMirror-vscrollbar
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  // for Dropzone
+  .dropzone {
+    position: relative; // against .overlay position: absolute
+
+    .overlay.overlay-dropzone-active {
+      background: rgba(255, 255, 255, 0.5);
+      .overlay-content {
+        padding: 0.5em;
+        font-size: 2.5em;
+        color: bs.$gray-700;
+        background: rgba(200, 200, 200, 0.5);
+      }
+    }
+
+    // uploading
+    &.dropzone-uploading {
+      .overlay.overlay-dropzone-active {
+        .overlay-content {
+          @include insertMaterialSymbolAndMessage('upload_file', 'Uploading...');
+        }
+      }
+    }
+
+    // diabled
+    &.dropzone-disabled {
+      .overlay.overlay-dropzone-active {
+        .overlay-content {
+          @include insertMaterialSymbolAndMessage('error', 'File uploading is disabled');
+        }
+      }
+    }
+
+    // accepted
+    &.dropzone-accepted {
+      .overlay.overlay-dropzone-active {
+        // style
+        color: bs.$secondary;
+        background: rgba(200, 200, 200, 0.8);
+        border: 4px dashed bs.$gray-300;
+
+        .overlay-content {
+          @include insertMaterialSymbolAndMessage('cloud_upload', 'Drop here to upload');
+        }
+      }
+    }
+
+    // file type mismatch
+    &.dropzone-mismatch-picture {
+      .overlay.overlay-dropzone-active {
+        .overlay-content {
+          @include insertMaterialSymbolAndMessage('photo', 'Only an image file is allowed');
+        }
+      }
+    }
+
+    // rejected
+    &.dropzone-rejected {
+      .overlay.overlay-dropzone-active {
+        background: rgba(200, 200, 200, 0.8);
+
+        .overlay-content {
+          @include insertMaterialSymbolAndMessage('error', 'This file is not allowed');
+        }
+
+      }
+    }
+    /* end of.dropzone */
+  }
+}
+

+ 47 - 5
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -7,7 +7,7 @@ import { EditorView } from '@codemirror/view';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
-import { useFileDropzone } from '../../services';
+import { useFileDropzone, FileDropzoneOverlay } from '../../services';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
 import { Toolbar } from './Toolbar';
@@ -100,12 +100,54 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   }, [codeMirrorEditor]);
 
-  const { getRootProps, open } = useFileDropzone({ onUpload, acceptedFileType });
+  const {
+    getRootProps,
+    isDragActive,
+    isDragAccept,
+    isDragReject,
+    isUploading,
+    open,
+  } = useFileDropzone({ onUpload, acceptedFileType });
+
+  const fileUploadState = useMemo(() => {
+
+    if (isUploading) {
+      return 'dropzone-uploading';
+    }
+
+    switch (acceptedFileType) {
+      case AcceptedUploadFileType.NONE:
+        return 'dropzone-disabled';
+
+      case AcceptedUploadFileType.IMAGE:
+        if (isDragAccept) {
+          return 'dropzone-accepted';
+        }
+        if (isDragReject) {
+          return 'dropzone-mismatch-picture';
+        }
+        break;
+
+      case AcceptedUploadFileType.ALL:
+        if (isDragAccept) {
+          return 'dropzone-accepted';
+        }
+        if (isDragReject) {
+          return 'dropzone-rejected';
+        }
+        break;
+    }
+
+    return '';
+  }, [isUploading, isDragAccept,isDragReject, acceptedFileType]);
 
   return (
-    <div {...getRootProps()} className="flex-expand-vert">
-      <CodeMirrorEditorContainer ref={containerRef} />
-      <Toolbar editorKey={editorKey} onFileOpen={open} acceptedFileType={acceptedFileType} />
+    <div className={`${style['codemirror-editor']} flex-expand-vert`}>
+      <div {...getRootProps()} className={`dropzone ${fileUploadState} flex-expand-vert`}>
+        <FileDropzoneOverlay isEnabled={isDragActive}/>
+        <CodeMirrorEditorContainer ref={containerRef} />
+        <Toolbar editorKey={editorKey} onFileOpen={open} acceptedFileType={acceptedFileType} />
+      </div>
     </div>
   );
 };

+ 2 - 1
packages/editor/src/services/file-dropzone/index.ts

@@ -1 +1,2 @@
-export * from './use-file-dropzone';
+export * from './use-file-dropzone/use-file-dropzone';
+export * from './use-file-dropzone/FileDropzoneOverlay';

+ 0 - 38
packages/editor/src/services/file-dropzone/use-file-dropzone.ts

@@ -1,38 +0,0 @@
-import { useCallback } from 'react';
-
-import { useDropzone, Accept } from 'react-dropzone';
-import type { DropzoneState } from 'react-dropzone';
-
-import { AcceptedUploadFileType } from '../../consts';
-
-type DropzoneEditor = {
-  onUpload?: (files: File[]) => void,
-  acceptedFileType: AcceptedUploadFileType,
-}
-
-export const useFileDropzone = (props: DropzoneEditor): DropzoneState => {
-
-  const { onUpload, acceptedFileType } = props;
-
-  const dropHandler = useCallback((acceptedFiles: File[]) => {
-    if (onUpload == null) {
-      return;
-    }
-    onUpload(acceptedFiles);
-  }, [onUpload]);
-
-  const accept: Accept = {
-    acceptedFileType: [],
-  };
-
-  const disabled = acceptedFileType === AcceptedUploadFileType.NONE;
-
-  return useDropzone({
-    noKeyboard: true,
-    noClick: true,
-    disabled,
-    onDrop: dropHandler,
-    accept,
-  });
-
-};

+ 19 - 0
packages/editor/src/services/file-dropzone/use-file-dropzone/FileDropzoneOverlay.tsx

@@ -0,0 +1,19 @@
+type Props = {
+  isEnabled: boolean,
+}
+
+export const FileDropzoneOverlay = (props: Props) => {
+  const { isEnabled } = props;
+
+    if (isEnabled) {
+      return (
+        <div className="overlay overlay-dropzone-active">
+          <span className="overlay-content">
+            <span className="overlay-icon material-symbols-outlined">
+            </span>
+          </span>
+        </div>
+      );
+    }
+    return <></>;
+}

+ 52 - 0
packages/editor/src/services/file-dropzone/use-file-dropzone/use-file-dropzone.ts

@@ -0,0 +1,52 @@
+import { useCallback, useState } from 'react';
+
+import { useDropzone, Accept } from 'react-dropzone';
+import type { DropzoneState } from 'react-dropzone';
+
+import { AcceptedUploadFileType } from '../../../consts';
+
+type FileDropzoneState = DropzoneState & {
+  isUploading: boolean,
+}
+
+type DropzoneEditor = {
+  onUpload?: (files: File[]) => void,
+  acceptedFileType: AcceptedUploadFileType,
+}
+
+export const useFileDropzone = (props: DropzoneEditor): FileDropzoneState => {
+
+  const { onUpload, acceptedFileType } = props;
+
+  const [isUploading, setIsUploading] = useState(false);
+
+  const dropHandler = useCallback((acceptedFiles: File[]) => {
+    if (onUpload == null) {
+      return;
+    }
+    if (acceptedFileType === AcceptedUploadFileType.NONE) {
+      return;
+    }
+
+    setIsUploading(true);
+    onUpload(acceptedFiles);
+    setIsUploading(false);
+
+  }, [onUpload, setIsUploading, acceptedFileType]);
+
+  const accept: Accept = {
+  };
+  accept[acceptedFileType] = [];
+
+  const dzState = useDropzone({
+    noKeyboard: true,
+    noClick: true,
+    onDrop: dropHandler,
+    accept,
+  });
+
+  return {
+    ...dzState,
+    isUploading,
+  };
+};