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

Merge branch 'dev/7.0.x' into feat/139307-140645-replacement-simple-line-icons

satof3 2 лет назад
Родитель
Сommit
8f516ccae8
54 измененных файлов с 466 добавлено и 418 удалено
  1. 3 5
      apps/app/src/components/DescendantsPageListModal.tsx
  2. 0 17
      apps/app/src/components/Icons/PageListIcon.jsx
  3. 0 19
      apps/app/src/components/Icons/TimeLineIcon.jsx
  4. 11 7
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  5. 2 4
      apps/app/src/components/NotFoundPage.tsx
  6. 45 44
      apps/app/src/components/PageComment/CommentEditor.tsx
  7. 3 2
      apps/app/src/components/PageControls/PageControls.tsx
  8. 5 16
      apps/app/src/components/PageEditor/PageEditor.tsx
  9. 7 5
      apps/app/src/components/PageEditor/page-path-rename-utils.ts
  10. 1 2
      apps/app/src/components/PageHeader/PagePathHeader.tsx
  11. 1 2
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  12. 44 6
      apps/app/src/components/PageSelectModal/PageSelectModal.tsx
  13. 5 2
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  14. 1 2
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  15. 45 17
      apps/app/src/components/PageTags/PageTags.tsx
  16. 14 45
      apps/app/src/components/PageTags/RenderTagLabels.tsx
  17. 14 2
      apps/app/src/components/PageTags/TagLabels.module.scss
  18. 13 0
      apps/app/src/components/SearchPage/SearchPageBase.module.scss
  19. 7 6
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  20. 2 0
      apps/app/src/components/Sidebar/Bookmarks.tsx
  21. 8 5
      apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  22. 2 0
      apps/app/src/components/Sidebar/Custom/CustomSidebar.tsx
  23. 2 0
      apps/app/src/components/Sidebar/InAppNotification/InAppNotification.tsx
  24. 1 1
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  25. 4 2
      apps/app/src/components/Sidebar/PageTree/PageTree.tsx
  26. 5 2
      apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx
  27. 2 1
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  28. 2 0
      apps/app/src/components/Sidebar/RecentChanges/RecentChanges.tsx
  29. 2 2
      apps/app/src/components/Sidebar/Sidebar.module.scss
  30. 1 1
      apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.tsx
  31. 2 0
      apps/app/src/components/Sidebar/Tag.tsx
  32. 5 5
      apps/app/src/components/TableOfContents.tsx
  33. 3 5
      apps/app/src/components/TrashPageList.tsx
  34. 6 6
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  35. 0 7
      apps/app/src/interfaces/editor-settings.ts
  36. 9 12
      apps/app/src/pages/[[...path]].page.tsx
  37. 0 5
      apps/app/src/server/routes/apiv3/page/index.js
  38. 0 8
      apps/app/src/server/service/page-grant.ts
  39. 7 2
      apps/app/src/server/service/page/index.ts
  40. 24 9
      apps/app/src/stores/context.tsx
  41. 1 1
      apps/app/src/stores/editor.tsx
  42. 11 26
      apps/app/test/integration/service/page-grant.test.js
  43. 3 3
      packages/core/src/consts/accepted-upload-file-type.ts
  44. 1 0
      packages/core/src/consts/index.ts
  45. 33 16
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  46. 0 38
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsButton.tsx
  47. 38 0
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx
  48. 28 9
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx
  49. 7 5
      packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx
  50. 10 13
      packages/editor/src/components/CodeMirrorEditorComment.tsx
  51. 8 15
      packages/editor/src/components/CodeMirrorEditorMain.tsx
  52. 3 2
      packages/editor/src/components/playground/Playground.tsx
  53. 0 1
      packages/editor/src/consts/index.ts
  54. 15 13
      packages/editor/src/services/file-dropzone/use-file-dropzone/use-file-dropzone.ts

+ 3 - 5
apps/app/src/components/DescendantsPageListModal.tsx

@@ -13,10 +13,8 @@ import { useDescendantsPageListModal } from '~/stores/modal';
 
 import { CustomNavTab } from './CustomNavigation/CustomNav';
 import CustomTabContent from './CustomNavigation/CustomTabContent';
-import { DescendantsPageListProps } from './DescendantsPageList';
+import type { DescendantsPageListProps } from './DescendantsPageList';
 import ExpandOrContractButton from './ExpandOrContractButton';
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
 
 import styles from './DescendantsPageListModal.module.scss';
 
@@ -46,7 +44,7 @@ export const DescendantsPageListModal = (): JSX.Element => {
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: PageListIcon,
+        Icon: () => <span className="material-symbols-outlined">subject</span>,
         Content: () => {
           if (status == null || status.path == null || !status.isOpened) {
             return <></>;
@@ -57,7 +55,7 @@ export const DescendantsPageListModal = (): JSX.Element => {
         isLinkEnabled: () => !isSharedUser,
       },
       timeline: {
-        Icon: TimeLineIcon,
+        Icon: () => <span className="material-symbols-outlined">timeline</span>,
         Content: () => {
           if (status == null || !status.isOpened) {
             return <></>;

+ 0 - 17
apps/app/src/components/Icons/PageListIcon.jsx

@@ -1,17 +0,0 @@
-import React from 'react';
-
-const PageList = () => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 14 14"
-
-  >
-    <rect width="14" height="14" fillOpacity="0" />
-    <path d="M12.63,2.72H1.37a.54.54,0,0,1,0-1.08H12.63a.54.54,0,0,1,0,1.08Z" />
-    <path d="M11.82,5.94H1.37a.55.55,0,0,1,0-1.09H11.82a.55.55,0,1,1,0,1.09Z" />
-    <path d="M9.41,9.15h-8a.54.54,0,0,1,0-1.08h8a.54.54,0,0,1,0,1.08Z" />
-    <path d="M10.84,12.36H1.37a.54.54,0,1,1,0-1.08h9.47a.54.54,0,1,1,0,1.08Z" />
-  </svg>
-);
-
-export default PageList;

+ 0 - 19
apps/app/src/components/Icons/TimeLineIcon.jsx

@@ -1,19 +0,0 @@
-import React from 'react';
-
-const TimeLine = () => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 14 14"
-
-  >
-    <rect width="14" height="14" fillOpacity="0" />
-    <path
-      d="M13.6,4.6a1.2,1.2,0,0,1-1.2,1.2,1,1,0,0,1-.3,0L10,7.89a1.1,1.1,0,0,1,0,.31,1.2,1.2,0,1,1-2.4,0,1.1,1.1,0,0,1,
-      0-.31L6.11,6.36a1.3,1.3,0,0,1-.62,0L2.75,9.1a1,1,0,0,1,0,.3A1.2,1.2,0,1,1,1.6,8.2a1,1,0,0,1,.3,0L4.64,
-      5.51a1.1,1.1,0,0,1,0-.31A1.2,1.2,0,0,1,7,5.2a1.1,1.1,0,0,1,0,.31L8.49,7a1.3,1.3,0,0,1,.62,0L11.25,4.9a1,
-      1,0,0,1-.05-.3,1.2,1.2,0,1,1,2.4,0Z"
-    />
-  </svg>
-);
-
-export default TimeLine;

+ 11 - 7
apps/app/src/components/ItemsTree/ItemsTree.tsx

@@ -11,12 +11,13 @@ import { useRouter } from 'next/router';
 import { debounce } from 'throttle-debounce';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { AncestorsChildrenResult, RootPageResult, TargetAndAncestors } from '~/interfaces/page-listing-results';
-import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import { SocketEventName, UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
-import {
-  IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
-} from '~/stores/modal';
+import type { IPageForItem } from '~/interfaces/page';
+import type { AncestorsChildrenResult, RootPageResult, TargetAndAncestors } from '~/interfaces/page-listing-results';
+import type { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import type { UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
+import { SocketEventName } from '~/interfaces/websocket';
+import type { IPageForPageDuplicateModal } from '~/stores/modal';
+import { usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
 import { mutateAllPageInfo, useCurrentPagePath, useSWRMUTxCurrentPage } from '~/stores/page';
 import {
   useSWRxPageAncestorsChildren, useSWRxRootPage, mutatePageTree, mutatePageList,
@@ -31,6 +32,7 @@ import ItemsTreeContentSkeleton from './ItemsTreeContentSkeleton';
 
 import styles from './ItemsTree.module.scss';
 
+
 const logger = loggerFactory('growi:cli:ItemsTree');
 
 /*
@@ -93,6 +95,7 @@ type ItemsTreeProps = {
   targetPathOrId?: Nullable<string>
   targetAndAncestorsData?: TargetAndAncestors
   CustomTreeItem: React.FunctionComponent<TreeItemProps>
+  onClickTreeItem?: (page: IPageForItem) => void;
 }
 
 /*
@@ -100,7 +103,7 @@ type ItemsTreeProps = {
  */
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, CustomTreeItem,
+    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, CustomTreeItem, onClickTreeItem,
   } = props;
 
   const { t } = useTranslation();
@@ -282,6 +285,7 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
           onRenamed={onRenamed}
           onClickDuplicateMenuItem={onClickDuplicateMenuItem}
           onClickDeleteMenuItem={onClickDeleteMenuItem}
+          onClick={onClickTreeItem}
         />
       </ul>
     );

+ 2 - 4
apps/app/src/components/NotFoundPage.tsx

@@ -4,8 +4,6 @@ import { useTranslation } from 'next-i18next';
 
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageList } from './DescendantsPageList';
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
 import { PageTimeline } from './PageTimeline';
 
 type NotFoundPageProps = {
@@ -20,12 +18,12 @@ const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: PageListIcon,
+        Icon: () => <span className="material-symbols-outlined">subject</span>,
         Content: () => <DescendantsPageList path={path} />,
         i18n: t('page_list'),
       },
       timeLine: {
-        Icon: TimeLineIcon,
+        Icon: () => <span className="material-symbols-outlined">timeline</span>,
         Content: PageTimeline,
         i18n: t('Timeline View'),
       },

+ 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}

+ 3 - 2
apps/app/src/components/PageControls/PageControls.tsx

@@ -20,8 +20,9 @@ import loggerFactory from '~/utils/logger';
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
+import type { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import {
-  AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType,
+  MenuItemType,
   PageItemControl,
 } from '../Common/Dropdown/PageItemControl';
 
@@ -45,7 +46,7 @@ const Tags = (props: TagsProps): JSX.Element => {
   const { onClickEditTagsButton } = props;
 
   return (
-    <div className="grw-taglabels-container d-flex align-items-center">
+    <div className="grw-tag-labels-container d-flex align-items-center">
       <button
         type="button"
         className="btn btn-link btn-edit-tags text-muted border border-secondary p-1 d-flex align-items-center"

+ 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}

+ 7 - 5
apps/app/src/components/PageHeader/page-header-utils.ts → apps/app/src/components/PageEditor/page-path-rename-utils.ts

@@ -11,21 +11,23 @@ import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
 type PagePathRenameHandler = (newPagePath: string, onRenameFinish?: () => void, onRenameFailure?: () => void) => Promise<void>
 
 export const usePagePathRenameHandler = (
-    currentPage: IPagePopulatedToShowRevision,
+    currentPage?: IPagePopulatedToShowRevision | null,
 ): PagePathRenameHandler => {
 
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { t } = useTranslation();
 
-  const currentPagePath = currentPage.path;
-
   const pagePathRenameHandler = useCallback(async(newPagePath, onRenameFinish, onRenameFailure) => {
 
+    if (currentPage == null) {
+      return;
+    }
+
     const onRenamed = (fromPath: string | undefined, toPath: string) => {
       mutatePageTree();
       mutatePageList();
 
-      if (currentPagePath === fromPath || currentPagePath === toPath) {
+      if (currentPage.path === fromPath || currentPage.path === toPath) {
         mutateCurrentPage();
       }
     };
@@ -51,7 +53,7 @@ export const usePagePathRenameHandler = (
       onRenameFailure?.();
       toastError(err);
     }
-  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, t]);
+  }, [currentPage, mutateCurrentPage, t]);
 
   return pagePathRenameHandler;
 };

+ 1 - 2
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -12,10 +12,9 @@ import { usePageSelectModal } from '~/stores/modal';
 
 import ClosableTextInput from '../Common/ClosableTextInput';
 import { PagePathHierarchicalLink } from '../Common/PagePathHierarchicalLink';
+import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
 
-import { usePagePathRenameHandler } from './page-header-utils';
-
 import styles from './PagePathHeader.module.scss';
 
 const moduleClass = styles['page-path-header'];

+ 1 - 2
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -12,8 +12,7 @@ import { ValidationTarget } from '~/client/util/input-validator';
 
 import ClosableTextInput from '../Common/ClosableTextInput';
 import { CopyDropdown } from '../Common/CopyDropdown';
-
-import { usePagePathRenameHandler } from './page-header-utils';
+import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 import styles from './PageTitleHeader.module.scss';
 

+ 44 - 6
apps/app/src/components/PageSelectModal/PageSelectModal.tsx

@@ -1,15 +1,20 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import { useState, useCallback } from 'react';
+
+import nodePath from 'path';
 
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter, Button,
 } from 'reactstrap';
 
+import type { IPageForItem } from '~/interfaces/page';
 import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { usePageSelectModal } from '~/stores/modal';
-import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
+import { useCurrentPagePath, useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 
 import { ItemsTree } from '../ItemsTree';
+import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 import { TreeItemForModal } from './TreeItemForModal';
 
@@ -22,6 +27,8 @@ export const PageSelectModal: FC = () => {
 
   const isOpened = PageSelectModalData?.isOpened ?? false;
 
+  const [clickedParentPagePath, setClickedParentPagePath] = useState<string | null>(null);
+
   const { t } = useTranslation();
 
   const { data: isGuestUser } = useIsGuestUser();
@@ -29,19 +36,48 @@ export const PageSelectModal: FC = () => {
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
+  const { data: currentPage } = useSWRxCurrentPage();
+
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+
+  const onClickTreeItem = useCallback((page: IPageForItem) => {
+    const parentPagePath = page.path;
+
+    if (parentPagePath == null) {
+      return;
+    }
+
+    setClickedParentPagePath(parentPagePath);
+  }, []);
+
+  const onClickCancel = useCallback(() => {
+    setClickedParentPagePath(null);
+    closeModal();
+  }, [closeModal]);
+
+  const onClickDone = useCallback(() => {
+    if (clickedParentPagePath != null) {
+      const currentPageTitle = nodePath.basename(currentPage?.path ?? '') || '/';
+      const newPagePath = nodePath.resolve(clickedParentPagePath, currentPageTitle);
+
+      pagePathRenameHandler(newPagePath);
+    }
+
+    closeModal();
+  }, [clickedParentPagePath, closeModal, currentPage?.path, pagePathRenameHandler]);
 
   const targetPathOrId = targetId || currentPath;
 
+  const path = currentPath || '/';
+
   if (isGuestUser == null) {
     return null;
   }
 
-  const path = currentPath || '/';
-
   return (
     <Modal
       isOpen={isOpened}
-      toggle={() => closeModal()}
+      toggle={closeModal}
       centered
       size="sm"
     >
@@ -54,10 +90,12 @@ export const PageSelectModal: FC = () => {
           targetPath={path}
           targetPathOrId={targetPathOrId}
           targetAndAncestorsData={targetAndAncestorsData}
+          onClickTreeItem={onClickTreeItem}
         />
       </ModalBody>
       <ModalFooter>
-        <Button color="primary" onClick={closeModal}>{t('Done')}</Button>{' '}
+        <Button color="secondary" onClick={onClickCancel}>{t('Cancel')}</Button>
+        <Button color="primary" onClick={onClickDone}>{t('Done')}</Button>
       </ModalFooter>
     </Modal>
   );

+ 5 - 2
apps/app/src/components/PageSelectModal/TreeItemForModal.tsx

@@ -1,16 +1,18 @@
-import React, { type FC } from 'react';
+import type { FC } from 'react';
 
 import {
   SimpleItem, useNewPageInput, type TreeItemProps,
 } from '../TreeItem';
 
+
 type PageTreeItemProps = TreeItemProps & {
   key?: React.Key | null,
 };
 
 export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
 
-  const { isOpen } = props;
+  const { isOpen, onClick } = props;
+
   const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
 
   return (
@@ -27,6 +29,7 @@ export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
       customNextComponents={[NewPageInput]}
       itemClass={TreeItemForModal}
       customEndComponents={[NewPageCreateButton]}
+      onClick={onClick}
     />
   );
 };

+ 1 - 2
apps/app/src/components/PageSideContents/PageSideContents.tsx

@@ -58,7 +58,7 @@ const Tags = (props: TagsProps): JSX.Element => {
   const isTagLabelsDisabled = !!isGuestUser || !!isReadOnlyUser;
 
   return (
-    <div className="grw-taglabels-container">
+    <div className="grw-tag-labels-container">
       <PageTags
         tags={tagsInfoData.tags}
         isTagLabelsDisabled={isTagLabelsDisabled}
@@ -88,7 +88,6 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   const isUsersHomepagePath = isUsersHomepage(pagePath);
   const isTrash = isTrashPage(pagePath);
 
-
   return (
     <>
       {/* Tags */}

+ 45 - 17
apps/app/src/components/PageTags/PageTags.tsx

@@ -1,6 +1,10 @@
 import type { FC } from 'react';
-import React from 'react';
+import React, { useState } from 'react';
 
+import { useTranslation } from 'next-i18next';
+
+import { NotAvailableForGuest } from '../NotAvailableForGuest';
+import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 import { Skeleton } from '../Skeleton';
 
 import RenderTagLabels from './RenderTagLabels';
@@ -22,6 +26,8 @@ export const PageTags:FC<Props> = (props: Props) => {
   const {
     tags, isTagLabelsDisabled, onClickEditTagsButton,
   } = props;
+  const [isHovered, setIsHovered] = useState(false);
+  const { t } = useTranslation();
 
   if (tags == null) {
     return <></>;
@@ -29,24 +35,46 @@ export const PageTags:FC<Props> = (props: Props) => {
 
   const printNoneClass = tags.length === 0 ? 'd-print-none' : '';
 
+  const onMouseEnterHandler = () => setIsHovered(true);
+  const onMouseLeaveHandler = () => setIsHovered(false);
+
   return (
-    <>
-      <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center ${printNoneClass}`} data-testid="grw-tag-labels">
-        <button
-          type="button"
-          className={`btn btn-sm btn-outline-secondary rounded-pill mb-2 d-flex d-lg-none ${styles['grw-tag-icon-button']}`}
-          onClick={onClickEditTagsButton}
-        >
-          <span className="material-symbols-outlined">local_offer</span>
-        </button>
-        <div className="d-none d-lg-flex">
-          <RenderTagLabels
-            tags={tags}
-            isTagLabelsDisabled={isTagLabelsDisabled}
-            onClickEditTagsButton={onClickEditTagsButton}
-          />
+    <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center mb-2 ${printNoneClass}`} data-testid="grw-tag-labels">
+      <div className="d-flex d-lg-none">
+        <NotAvailableForGuest>
+          <NotAvailableForReadOnlyUser>
+            <button
+              type="button"
+              className={`btn btn-sm btn-outline-secondary rounded-pill ${styles['grw-tag-icon-button']}`}
+              onClick={onClickEditTagsButton}
+            >
+              <span className="material-symbols-outlined">local_offer</span>
+            </button>
+          </NotAvailableForReadOnlyUser>
+        </NotAvailableForGuest>
+      </div>
+      <div className="d-none d-lg-flex row">
+        <div className="mb-2">
+          <button
+            id="edit-tags-btn-wrapper-for-tooltip"
+            type="button"
+            className="btn btn-link text-secondary p-0 border-0"
+            onMouseEnter={onMouseEnterHandler}
+            onMouseLeave={onMouseLeaveHandler}
+            onClick={onClickEditTagsButton}
+            disabled={isTagLabelsDisabled}
+          >
+            <span className="material-symbols-outlined me-1">local_offer</span>
+            <span className="me-2">{t('Tags')}</span>
+            {(isHovered && !isTagLabelsDisabled) && (
+              <span className="material-symbols-outlined p-0">edit</span>
+            )}
+          </button>
+        </div>
+        <div>
+          <RenderTagLabels tags={tags} />
         </div>
       </div>
-    </>
+    </div>
   );
 };

+ 14 - 45
apps/app/src/components/PageTags/RenderTagLabels.tsx

@@ -1,65 +1,34 @@
 import React from 'react';
 
-import { useTranslation } from 'next-i18next';
+import SimpleBar from 'simplebar-react';
 
 import { useKeywordManager } from '~/client/services/search-operation';
 
-import { NotAvailableForGuest } from '../NotAvailableForGuest';
-import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
-
 type RenderTagLabelsProps = {
   tags: string[],
-  isTagLabelsDisabled: boolean,
-  onClickEditTagsButton: () => void,
 }
 
 const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
-  const {
-    tags, isTagLabelsDisabled, onClickEditTagsButton,
-  } = props;
-  const { t } = useTranslation();
+  const { tags } = props;
 
   const { pushState } = useKeywordManager();
 
-  const isTagsEmpty = tags.length === 0;
 
   return (
-    <>
-      {tags.map((tag) => {
-        return (
-          <a
-            key={tag}
-            type="button"
-            className="grw-tag badge me-2"
-            onClick={() => pushState(`tag:${tag}`)}
-          >
-            {tag}
-          </a>
-        );
-      })}
-      <NotAvailableForGuest>
-        <NotAvailableForReadOnlyUser>
-          <div id="edit-tags-btn-wrapper-for-tooltip" className="d-print-none">
-            <a
-              className={
-                `btn btn-link btn-edit-tags text-muted d-flex align-items-center
-                ${isTagsEmpty && 'no-tags'}
-                ${isTagLabelsDisabled && 'disabled'}`
-              }
-              onClick={onClickEditTagsButton}
-            >
-              {isTagsEmpty && <>{ t('Add tags for this page') }</>}
-              <i className={`icon-plus ${isTagsEmpty && 'ms-1'}`} />
-            </a>
-          </div>
-        </NotAvailableForReadOnlyUser>
-      </NotAvailableForGuest>
-    </>
-
+    <SimpleBar className="grw-tag-simple-bar pe-1">
+      {tags.map(tag => (
+        <a
+          key={tag}
+          type="button"
+          className="grw-tag badge me-1 mb-1 text-truncate"
+          onClick={() => pushState(`tag:${tag}`)}
+        >
+          {tag}
+        </a>
+      ))}
+    </SimpleBar>
   );
-
 });
-
 RenderTagLabels.displayName = 'RenderTagLabels';
 
 export default RenderTagLabels;

+ 14 - 2
apps/app/src/components/PageTags/TagLabels.module.scss

@@ -8,8 +8,20 @@ $grw-tag-label-font-size: 12px;
     font-weight: normal;
     border-radius: bs.$border-radius;
   }
-  .material-symbols-outlined {
-    font-size: 2em;
+
+  .grw-tag-simple-bar {
+    width: 15.5rem;
+    max-height: 5rem;
+    .grw-tag{
+      max-width: 15rem;
+    }
+  }
+
+  // apply larger font when smaller than lg
+  @include bs.media-breakpoint-down(lg) {
+    .material-symbols-outlined {
+      font-size: 2em;
+    }
   }
 }
 

+ 13 - 0
apps/app/src/components/SearchPage/SearchPageBase.module.scss

@@ -1 +1,14 @@
 @use '@growi/ui/scss/molecules/page_list';
+
+.page-list :global {
+  .highlighted-keyword {
+    font-style: normal;
+    font-weight: bold;
+  }
+}
+
+.search-result-content :global  {
+  .highlighted-keyword {
+    background:linear-gradient(transparent 40%, #FCF0C0 40%);
+  }
+}

+ 7 - 6
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -1,21 +1,22 @@
+import type { ForwardRefRenderFunction } from 'react';
 import React, {
-  forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
+  forwardRef, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 
-import { ISelectableAll } from '~/client/interfaces/selectable-all';
+import type { ISelectableAll } from '~/client/interfaces/selectable-all';
 import { toastSuccess } from '~/client/util/toastr';
-import { IFormattedSearchResult, IPageWithSearchMeta } from '~/interfaces/search';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import type { IFormattedSearchResult, IPageWithSearchMeta } from '~/interfaces/search';
+import type { OnDeletedFunction } from '~/interfaces/ui';
 import {
   useIsGuestUser, useIsReadOnlyUser, useIsSearchServiceConfigured, useIsSearchServiceReachable,
 } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { mutatePageTree } from '~/stores/page-listing';
 
-import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 // Do not import with next/dynamic
 // see: https://github.com/weseek/growi/pull/7923
@@ -213,7 +214,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
 
       </div>
 
-      <div className="flex-expand-vert d-none d-lg-flex">
+      <div className={`${styles['search-result-content']} flex-expand-vert d-none d-lg-flex`}>
         {pages != null && pages.length !== 0 && selectedPageWithMeta != null && (
           <SearchResultContent
             pageWithMeta={selectedPageWithMeta}

+ 2 - 0
apps/app/src/components/Sidebar/Bookmarks.tsx

@@ -13,6 +13,8 @@ export const Bookmarks = () : JSX.Element => {
 
   return (
     <>
+      {/* TODO : #139425 Match the space specification method to others */}
+      {/* ref.  https://redmine.weseek.co.jp/issues/139425 */}
       <div className="grw-sidebar-content-header p-3">
         <h3 className="mb-0">{t('Bookmarks')}</h3>
       </div>

+ 8 - 5
apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -38,15 +38,18 @@ export const BookmarkContents = (): JSX.Element => {
   }, [mutateBookmarkFolders]);
 
   return (
-    <>
-      <div className="col-8 mb-2 ">
+    <div className="ms-3">
+      <div className="col-8 mb-2">
         <button
           type="button"
           className="btn btn-outline-secondary rounded-pill d-flex justify-content-start align-middle"
           onClick={onClickNewBookmarkFolder}
         >
-          <FolderPlusIcon />
-          <span className="mx-2 ">{t('bookmark_folder.new_folder')}</span>
+
+          <div className="d-flex align-items-center">
+            <FolderPlusIcon />
+            <span className="ms-2">{t('bookmark_folder.new_folder')}</span>
+          </div>
         </button>
       </div>
       {isCreateAction && (
@@ -58,6 +61,6 @@ export const BookmarkContents = (): JSX.Element => {
         </div>
       )}
       <BookmarkFolderTree isOperable userId={currentUser?._id} />
-    </>
+    </div>
   );
 };

+ 2 - 0
apps/app/src/components/Sidebar/Custom/CustomSidebar.tsx

@@ -18,6 +18,8 @@ export const CustomSidebar = (): JSX.Element => {
   const { mutate, isLoading } = useSWRxPageByPath('/Sidebar');
 
   return (
+    // TODO : #139425 Match the space specification method to others
+    // ref.  https://redmine.weseek.co.jp/issues/139425
     <div className="px-3">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">

+ 2 - 0
apps/app/src/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -15,6 +15,8 @@ export const InAppNotification = (): JSX.Element => {
   const [isUnopendNotificationsVisible, setUnopendNotificationsVisible] = useState(false);
 
   return (
+    // TODO : #139425 Match the space specification method to others
+    // ref.  https://redmine.weseek.co.jp/issues/139425
     <div className="px-3">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">

+ 1 - 1
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -57,7 +57,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
   return (
     <div
-      className="d-flex flex-row"
+      className="d-flex flex-row mt-2"
       onMouseEnter={onMouseEnterHandler}
       onMouseLeave={onMouseLeaveHandler}
     >

+ 4 - 2
apps/app/src/components/Sidebar/PageTree/PageTree.tsx

@@ -16,8 +16,10 @@ export const PageTree = (): JSX.Element => {
   const { t } = useTranslation();
 
   return (
-    <div className="px-3">
-      <div className="grw-sidebar-content-header py-3 d-flex">
+    // TODO : #139425 Match the space specification method to others
+    // ref.  https://redmine.weseek.co.jp/issues/139425
+    <div className="pt-4 pb-3 px-3">
+      <div className="grw-sidebar-content-header d-flex">
         <h3 className="mb-0">{t('Page Tree')}</h3>
         <Suspense>
           <PageTreeHeader />

+ 5 - 2
apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx

@@ -1,5 +1,6 @@
+import type { FC } from 'react';
 import React, {
-  useCallback, useState, FC,
+  useCallback, useState,
 } from 'react';
 
 import nodePath from 'path';
@@ -125,10 +126,12 @@ export const Ellipsis: FC<TreeItemToolProps> = (props) => {
     }
   };
 
+  const hasChildren = page.descendantCount ? page.descendantCount > 0 : false;
+
   return (
     <>
       {isRenameInputShown ? (
-        <div className="flex-fill">
+        <div className={`position-absolute ${hasChildren ? 'ms-5' : 'ms-4'}`}>
           <NotDraggableForClosableTextInput>
             <ClosableTextInput
               value={nodePath.basename(page.path ?? '')}

+ 2 - 1
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -17,8 +17,9 @@ import type { IPageForItem } from '~/interfaces/page';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
+import type { ItemNode } from '../../TreeItem';
 import {
-  SimpleItem, useNewPageInput, ItemNode, type TreeItemProps,
+  SimpleItem, useNewPageInput, type TreeItemProps,
 } from '../../TreeItem';
 
 import { Ellipsis } from './Ellipsis';

+ 2 - 0
apps/app/src/components/Sidebar/RecentChanges/RecentChanges.tsx

@@ -18,6 +18,8 @@ export const RecentChanges = (): JSX.Element => {
   const [isSmall, setIsSmall] = useState(false);
 
   return (
+    // TODO : #139425 Match the space specification method to others
+    // ref.  https://redmine.weseek.co.jp/issues/139425
     <div className="px-3" data-testid="grw-recent-changes">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0 text-nowrap">{t('Recent Changes')}</h3>

+ 2 - 2
apps/app/src/components/Sidebar/Sidebar.module.scss

@@ -86,7 +86,7 @@
     &:global {
       &.grw-sidebar-collapsed {
         .sidebar-contents-container {
-          background-color: rgba(var(--grw-highlight-100-rgb), .5);
+          background-color: rgba(var(--grw-highlight-100-rgb), .8);
           backdrop-filter: blur(20px);
         }
       }
@@ -108,7 +108,7 @@
     &:global {
       &.grw-sidebar-collapsed {
         .sidebar-contents-container {
-          background-color: rgba(var(--grw-highlight-800-rgb), .5);
+          background-color: rgba(var(--grw-highlight-800-rgb), .8);
           backdrop-filter: blur(20px);
         }
       }

+ 1 - 1
apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.tsx

@@ -29,7 +29,7 @@ export const PrimaryItems = memo((props: Props) => {
 
   return (
     <div className={styles['grw-primary-items']}>
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onHover={onItemHover} />
+      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TREE} label="Page Tree" iconName="list" onHover={onItemHover} />
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onHover={onItemHover} />
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onHover={onItemHover} />
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.BOOKMARKS} label="Bookmarks" iconName="bookmarks" onHover={onItemHover} />

+ 2 - 0
apps/app/src/components/Sidebar/Tag.tsx

@@ -44,6 +44,8 @@ const Tag: FC = () => {
 
   // todo: adjust design by XD
   return (
+    // TODO : #139425 Match the space specification method to others
+    // ref.  https://redmine.weseek.co.jp/issues/139425
     <div className="container-lg px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>

+ 5 - 5
apps/app/src/components/TableOfContents.tsx

@@ -11,7 +11,7 @@ import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 import styles from './TableOfContents.module.scss';
 
-const { isUserPage: _isUserPage } = pagePathUtils;
+const { isUsersHomepage: _isUsersHomepage } = pagePathUtils;
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
@@ -19,7 +19,7 @@ const logger = loggerFactory('growi:TableOfContents');
 const TableOfContents = (): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
 
-  const isUserPage = currentPagePath != null && _isUserPage(currentPagePath);
+  const isUsersHomePage = currentPagePath != null && _isUsersHomepage(currentPagePath);
 
   const { data: rendererOptions } = useTocOptions();
 
@@ -41,13 +41,13 @@ const TableOfContents = (): JSX.Element => {
     // get smaller bottom line of window height - .system-version height - margin 5px) and containerTop
     let bottom = Math.min(window.innerHeight - 20 - 5, parentBottom);
 
-    if (isUserPage) {
+    if (isUsersHomePage) {
       // raise the bottom line by the height and margin-top of UserContentLinks
-      bottom -= 45;
+      bottom -= 90;
     }
     // bottom - revisionToc top
     return bottom - (containerTop + containerPaddingTop);
-  }, [isUserPage, rendererOptions]);
+  }, [isUsersHomePage, rendererOptions]);
 
   return (
     <div id="revision-toc" className={`revision-toc ${styles['revision-toc']}`}>

+ 3 - 5
apps/app/src/components/TrashPageList.tsx

@@ -5,17 +5,15 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 
 import { toastSuccess } from '~/client/util/toastr';
-import { IPagingResult } from '~/interfaces/paging-result';
+import type { IPagingResult } from '~/interfaces/paging-result';
 import { useIsReadOnlyUser, useShowPageLimitationXL } from '~/stores/context';
 import { useEmptyTrashModal } from '~/stores/modal';
 import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page-listing';
 
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import { DescendantsPageListProps } from './DescendantsPageList';
+import type { DescendantsPageListProps } from './DescendantsPageList';
 import EmptyTrashButton from './EmptyTrashButton';
-import PageListIcon from './Icons/PageListIcon';
-
 
 const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
 
@@ -83,7 +81,7 @@ export const TrashPageList = (): JSX.Element => {
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: PageListIcon,
+        Icon: () => <span className="material-symbols-outlined">subject</span>,
         Content: DescendantsPageListForTrash,
         i18n: t('page_list'),
       },

+ 6 - 6
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -1,6 +1,6 @@
 import React, { useState, type FC, useCallback } from 'react';
 
-import { apiv3Post } from '~/client/util/apiv3-client';
+import { createPage } from '~/client/services/page-operation';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
@@ -67,12 +67,12 @@ export const useNewPageInput = (): UseNewPageInput => {
 
       setShowInput(false);
 
-      await apiv3Post('/page', {
+      await createPage({
         path: newPagePath,
         body: undefined,
-        grant: page.grant,
-        // grantUserGroupId: page.grantedGroup,
-        grantUserGroupIds: page.grantedGroups,
+        // keep grant info undefined to inherit from parent
+        grant: undefined,
+        grantUserGroupIds: undefined,
       });
 
       mutateChildren();
@@ -80,7 +80,7 @@ export const useNewPageInput = (): UseNewPageInput => {
       if (!hasDescendants) {
         stateHandlers?.setIsOpen(true);
       }
-    }, [hasDescendants, mutateChildren, page.grant, page.grantedGroups, stateHandlers]);
+    }, [hasDescendants, mutateChildren, stateHandlers]);
 
     const submittionFailedHandler = useCallback(() => {
       setProcessingSubmission(false);

+ 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');
 

+ 0 - 5
apps/app/src/server/routes/apiv3/page/index.js

@@ -678,11 +678,6 @@ module.exports = (crowi) => {
     const { pageId } = req.params;
     const { grant, userRelatedGrantedGroups } = req.body;
 
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136137
-    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
     const Page = crowi.model('Page');
 
     const page = await Page.findByIdAndViewer(pageId, req.user, null, false);

+ 0 - 8
apps/app/src/server/service/page-grant.ts

@@ -507,19 +507,11 @@ class PageGrantService implements IPageGrantService {
       grantedGroupIds?: IGrantedGroup[],
       shouldCheckDescendants = false,
       includeNotMigratedPages = false,
-      previousGrantedGroupIds?: IGrantedGroup[],
   ): Promise<boolean> {
     if (isTopPage(targetPath)) {
       return true;
     }
 
-    if (previousGrantedGroupIds != null) {
-      const isGrantChangeable = await this.validateGrantChange(user, previousGrantedGroupIds, grant, grantedGroupIds);
-      if (!isGrantChangeable) {
-        return false;
-      }
-    }
-
     const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
     if (!shouldCheckDescendants) { // checking the parent is enough

+ 7 - 2
apps/app/src/server/service/page/index.ts

@@ -2406,7 +2406,7 @@ class PageService implements IPageService {
         newChildGrantedGroups = this.getNewGrantedGroupsSyncronously(userRelatedGroups, userRelatedParentGrantedGroups, childPage);
       }
       const canChangeGrant = this.pageGrantService
-        .validateGrantChangeSyncronously(userRelatedGroups, childPage.grantedGroups, PageGrant.GRANT_USER_GROUP, newChildGrantedGroups);
+        .validateGrantChangeSyncronously(userRelatedGroups, childPage.grantedGroups, grant, newChildGrantedGroups);
       if (canChangeGrant) {
         operations.push({
           updateOne: {
@@ -4133,12 +4133,17 @@ class PageService implements IPageService {
     const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
     const isChildrenExist = await Page.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(clonedPageData.path))}`), parent: { $ne: null } });
 
+    const isGrantChangeable = await this.pageGrantService.validateGrantChange(user, pageData.grantedGroups, grant, grantUserGroupIds);
+    if (!isGrantChangeable) {
+      throw Error('The selected grant or grantedGroup is not assignable to this page.');
+    }
+
     if (shouldBeOnTree) {
       let isGrantNormalized = false;
       try {
         const shouldCheckDescendants = !options.overwriteScopesOfDescendants;
         // eslint-disable-next-line max-len
-        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, clonedPageData.path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants, false, pageData.grantedGroups);
+        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, clonedPageData.path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants, false);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${clonedPageData.path}" of grant ${grant}:`, err);

+ 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';

+ 11 - 26
apps/app/test/integration/service/page-grant.test.js

@@ -542,72 +542,57 @@ describe('PageGrantService', () => {
     });
   });
 
-  describe('Test isGrantNormalized method with previousGrantedGroupIds given', () => {
+  describe('Test validateGrantChange method', () => {
     test('Should return true when Target: completely owned by User1 (belongs to all groups)', async() => {
-      const targetPath = pageMultipleGroupTreesAndUsersPath;
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
       const grantedGroupIds = [];
-      const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(
-        user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      const result = await pageGrantService.validateGrantChange(
+        user1, multipleGroupTreesAndUsersPage.grantedGroups, grant, grantedGroupIds,
       );
 
       expect(result).toBe(true);
     });
 
     test('Should return false when Target: partially owned by User2 (belongs to one of the groups), and change to public grant', async() => {
-      const targetPath = pageMultipleGroupTreesAndUsersPath;
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
       const grantedGroupIds = [];
-      const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(
-        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      const result = await pageGrantService.validateGrantChange(
+        user2, multipleGroupTreesAndUsersPage.grantedGroups, grant, grantedGroupIds,
       );
 
       expect(result).toBe(false);
     });
 
     test('Should return false when Target: partially owned by User2 (belongs to one of the groups), and change to owner grant', async() => {
-      const targetPath = pageMultipleGroupTreesAndUsersPath;
       const grant = Page.GRANT_OWNER;
-      const grantedUserIds = [user2._id];
       const grantedGroupIds = [];
-      const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(
-        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      const result = await pageGrantService.validateGrantChange(
+        user2, multipleGroupTreesAndUsersPage.grantedGroups, grant, grantedGroupIds,
       );
 
       expect(result).toBe(false);
     });
 
     test('Should return false when Target: partially owned by User2 (belongs to one of the groups), and change to restricted grant', async() => {
-      const targetPath = pageMultipleGroupTreesAndUsersPath;
       const grant = Page.GRANT_RESTRICTED;
-      const grantedUserIds = null;
       const grantedGroupIds = [];
-      const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(
-        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      const result = await pageGrantService.validateGrantChange(
+        user2, multipleGroupTreesAndUsersPage.grantedGroups, grant, grantedGroupIds,
       );
 
       expect(result).toBe(false);
     });
 
     test('Should return false when Target: partially owned by User2, and change to group grant without any groups of user2', async() => {
-      const targetPath = pageMultipleGroupTreesAndUsersPath;
       const grant = Page.GRANT_USER_GROUP;
-      const grantedUserIds = null;
       const grantedGroupIds = [{ item: differentTreeGroup._id, type: GroupType.userGroup }];
-      const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(
-        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      const result = await pageGrantService.validateGrantChange(
+        user2, multipleGroupTreesAndUsersPage.grantedGroups, grant, grantedGroupIds,
       );
 
       expect(result).toBe(false);

+ 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 {