Procházet zdrojové kódy

Merge branch 'dev/7.0.x' into feat/142112-resolve-conflict-from-editor

Shun Miyazawa před 2 roky
rodič
revize
6869f4fe1f
51 změnil soubory, kde provedl 569 přidání a 481 odebrání
  1. 1 2
      apps/app/package.json
  2. 3 0
      apps/app/public/images/icons/slack/slack-logo-background.svg
  3. 3 0
      apps/app/public/images/icons/slack/slack-logo-dark-background.svg
  4. 1 1
      apps/app/public/static/locales/en_US/translation.json
  5. 2 1
      apps/app/src/components/Admin/App/QuestionnaireSettings.tsx
  6. 1 1
      apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  7. 6 6
      apps/app/src/components/Admin/AuditLogManagement.tsx
  8. 3 2
      apps/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  9. 3 1
      apps/app/src/components/Admin/ElasticsearchManagement/ReconnectControls.tsx
  10. 1 1
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  11. 2 1
      apps/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  12. 2 1
      apps/app/src/components/Admin/Users/PasswordResetModal.jsx
  13. 2 1
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  14. 5 4
      apps/app/src/components/DescendantsPageList.tsx
  15. 5 2
      apps/app/src/components/InAppNotification/InAppNotificationList.tsx
  16. 4 4
      apps/app/src/components/InAppNotification/InAppNotificationPage.tsx
  17. 5 4
      apps/app/src/components/InfiniteScroll.tsx
  18. 11 4
      apps/app/src/components/InstallerForm.tsx
  19. 9 1
      apps/app/src/components/InvitedForm.tsx
  20. 1 2
      apps/app/src/components/LoginForm.tsx
  21. 3 1
      apps/app/src/components/Me/QuestionnaireSettings.tsx
  22. 4 1
      apps/app/src/components/Page/RevisionLoader.tsx
  23. 2 1
      apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx
  24. 2 1
      apps/app/src/components/PageControls/BookmarkButtons.tsx
  25. 3 1
      apps/app/src/components/PageEditor/DrawioModal.tsx
  26. 3 15
      apps/app/src/components/PageEditor/EditorNavbarBottom.module.scss
  27. 5 98
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  28. 9 4
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  29. 4 3
      apps/app/src/components/PageList/PageList.tsx
  30. 2 1
      apps/app/src/components/PagePresentationModal.tsx
  31. 1 1
      apps/app/src/components/PageRenameModal.tsx
  32. 11 9
      apps/app/src/components/PrivateLegacyPages.tsx
  33. 205 55
      apps/app/src/components/SavePageControls.tsx
  34. 18 14
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  35. 2 1
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  36. 0 19
      apps/app/src/components/SlackLogo.jsx
  37. 55 39
      apps/app/src/components/SlackNotification.module.scss
  38. 31 31
      apps/app/src/components/SlackNotification.tsx
  39. 2 1
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  40. 2 1
      apps/app/src/components/TreeItem/SimpleItem.tsx
  41. 2 1
      apps/app/src/pages/tags.page.tsx
  42. 0 27
      apps/app/src/styles/_draft.scss
  43. 4 12
      apps/app/src/styles/_editor.scss
  44. 95 92
      apps/app/src/styles/atoms/_custom_control.scss
  45. 1 1
      apps/app/src/styles/style-app.scss
  46. 16 0
      packages/core/scss/_rotate.scss
  47. 1 1
      packages/core/scss/bootstrap/_variables.scss
  48. 2 0
      packages/editor/src/@types/y-codemirror.next.d.ts
  49. 4 0
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.module.scss
  50. 10 6
      packages/editor/src/stores/use-collaborative-editor-mode.ts
  51. 0 5
      packages/editor/src/stores/use-default-extensions.ts

+ 1 - 2
apps/app/package.json

@@ -280,7 +280,6 @@
     "socket.io-client": "^4.2.0",
     "socket.io-client": "^4.2.0",
     "source-map-loader": "^4.0.1",
     "source-map-loader": "^4.0.1",
     "swagger2openapi": "^7.0.8",
     "swagger2openapi": "^7.0.8",
-    "tsc-alias": "^1.2.9",
-    "y-codemirror.next": "^0.3.2"
+    "tsc-alias": "^1.2.9"
   }
   }
 }
 }

+ 3 - 0
apps/app/public/images/icons/slack/slack-logo-background.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600">
+  <circle fill="white" cx="300" cy="300" r="300" />
+</svg>

+ 3 - 0
apps/app/public/images/icons/slack/slack-logo-dark-background.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600">
+  <circle fill="#370f38" cx="300" cy="300" r="300" />
+</svg>

+ 1 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -311,7 +311,7 @@
     }
     }
   },
   },
   "page_edit": {
   "page_edit": {
-    "input_channels": "Input channels",
+    "input_channels": "Slack channel name...",
     "theme": "Theme",
     "theme": "Theme",
     "keymap": "Keymap",
     "keymap": "Keymap",
     "indent": "Indent",
     "indent": "Indent",

+ 2 - 1
apps/app/src/components/Admin/App/QuestionnaireSettings.tsx

@@ -6,6 +6,7 @@ import { useTranslation } from 'next-i18next';
 
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
@@ -65,7 +66,7 @@ const QuestionnaireSettings = (): JSX.Element => {
 
 
       {isLoading && (
       {isLoading && (
         <div className="text-muted text-center mb-5">
         <div className="text-muted text-center mb-5">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1" />
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
         </div>
       )}
       )}
 
 

+ 1 - 1
apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -55,7 +55,7 @@ export const AuditLogSettings: FC = () => {
       </p>
       </p>
       <p className="mt-1">
       <p className="mt-1">
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
-          <span className={`material-symbols-outlined me-1 ${isExpandActionList ? 'fa-rotate-90' : ''}`}>navigate_next</span>
+          <span className={`material-symbols-outlined me-1 ${isExpandActionList ? 'rotate-90' : ''}`}>navigate_next</span>
           { t('admin:audit_log_management.action_list') }
           { t('admin:audit_log_management.action_list') }
         </button>
         </button>
       </p>
       </p>

+ 6 - 6
apps/app/src/components/Admin/AuditLogManagement.tsx

@@ -1,16 +1,16 @@
-import React, {
-  FC, useState, useCallback, useRef,
-} from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback, useRef } from 'react';
 
 
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { IClearable } from '~/client/interfaces/clearable';
+import type { IClearable } from '~/client/interfaces/clearable';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-import { SupportedActionType } from '~/interfaces/activity';
+import type { SupportedActionType } from '~/interfaces/activity';
 import { useSWRxActivity } from '~/stores/activity';
 import { useSWRxActivity } from '~/stores/activity';
 import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
 import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
 
 
+import { LoadingSpinner } from '../LoadingSpinner';
 import PaginationWrapper from '../PaginationWrapper';
 import PaginationWrapper from '../PaginationWrapper';
 
 
 import { ActivityTable } from './AuditLog/ActivityTable';
 import { ActivityTable } from './AuditLog/ActivityTable';
@@ -213,7 +213,7 @@ export const AuditLogManagement: FC = () => {
           { isLoading
           { isLoading
             ? (
             ? (
               <div className="text-muted text-center mb-5">
               <div className="text-muted text-center mb-5">
-                <i className="fa fa-2x fa-spinner fa-pulse me-1" />
+                <LoadingSpinner className="me-1 fs-3" />
               </div>
               </div>
             )
             )
             : (
             : (

+ 3 - 2
apps/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -5,6 +5,7 @@ import React, {
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import { useSWRxLayoutSetting } from '~/stores/admin/customize';
 import { useSWRxLayoutSetting } from '~/stores/admin/customize';
 import { useNextThemes } from '~/stores/use-next-themes';
 import { useNextThemes } from '~/stores/use-next-themes';
 
 
@@ -44,8 +45,8 @@ const CustomizeLayoutSetting = (): JSX.Element => {
 
 
   if (isContainerFluid == null) {
   if (isContainerFluid == null) {
     return (
     return (
-      <div className="text-muted text-center">
-        <i className="fa fa-2x fa-spinner fa-pulse"></i>
+      <div className="text-muted text-center fs-3">
+        <LoadingSpinner />
       </div>
       </div>
     );
     );
   }
   }

+ 3 - 1
apps/app/src/components/Admin/ElasticsearchManagement/ReconnectControls.tsx

@@ -2,6 +2,8 @@ import React from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
+import { LoadingSpinner } from '~/components/LoadingSpinner';
+
 type Props = {
 type Props = {
   isEnabled?: boolean,
   isEnabled?: boolean,
   isProcessing?: boolean,
   isProcessing?: boolean,
@@ -21,7 +23,7 @@ const ReconnectControls = (props: Props): JSX.Element => {
         onClick={() => { props.onReconnectingRequested() }}
         onClick={() => { props.onReconnectingRequested() }}
         disabled={!isEnabled}
         disabled={!isEnabled}
       >
       >
-        { isProcessing && <i className="fa fa-spinner fa-pulse me-2"></i> }
+        { isProcessing && <LoadingSpinner className="me-2" /> }
         { t('full_text_search_management.reconnect_button') }
         { t('full_text_search_management.reconnect_button') }
       </button>
       </button>
 
 

+ 1 - 1
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -296,7 +296,7 @@ class SecuritySetting extends React.Component {
                     aria-expanded="false"
                     aria-expanded="false"
                     onClick={() => this.setExpantOtherDeleteOptionsState(deletionType, !expantDeleteOptionsState)}
                     onClick={() => this.setExpantOtherDeleteOptionsState(deletionType, !expantDeleteOptionsState)}
                   >
                   >
-                    <span className={`material-symbols-outlined me-1 ${expantDeleteOptionsState ? 'fa-rotate-90' : ''}`}>navigate_next</span>
+                    <span className={`material-symbols-outlined me-1 ${expantDeleteOptionsState ? 'rotate-90' : ''}`}>navigate_next</span>
                     { t('security_settings.other_options') }
                     { t('security_settings.other_options') }
                   </button>
                   </button>
                   <Collapse isOpen={expantDeleteOptionsState}>
                   <Collapse isOpen={expantDeleteOptionsState}>

+ 2 - 1
apps/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -8,6 +8,7 @@ import {
   apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
   apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
 } from '~/client/util/apiv3-client';
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 
 
 import BotTypeCard from './BotTypeCard';
 import BotTypeCard from './BotTypeCard';
 import ConfirmBotChangeModal from './ConfirmBotChangeModal';
 import ConfirmBotChangeModal from './ConfirmBotChangeModal';
@@ -187,7 +188,7 @@ const SlackIntegration = () => {
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <div className="text-muted text-center">
       <div className="text-muted text-center">
-        <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+        <LoadingSpinner className="me-1 fs-3" />
       </div>
       </div>
     );
     );
   }
   }

+ 2 - 1
apps/app/src/components/Admin/Users/PasswordResetModal.jsx

@@ -10,6 +10,7 @@ import {
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import { useIsMailerSetup } from '~/stores/context';
 import { useIsMailerSetup } from '~/stores/context';
 
 
 class PasswordResetModal extends React.Component {
 class PasswordResetModal extends React.Component {
@@ -53,7 +54,7 @@ class PasswordResetModal extends React.Component {
           onClick={this.onClickSendNewPasswordButton}
           onClick={this.onClickSendNewPasswordButton}
           disabled={!isMailerSetup || isEmailSending || isEmailSent}
           disabled={!isMailerSetup || isEmailSending || isEmailSent}
         >
         >
-          {isEmailSending && <i className="fa fa-spinner fa-pulse mx-2" />}
+          {isEmailSending && <LoadingSpinner className="mx-2" />}
           {!isEmailSending && (isEmailSent ? t('commons:Done') : t('commons:Send'))}
           {!isEmailSending && (isEmailSent ? t('commons:Done') : t('commons:Send'))}
         </button>
         </button>
         <button type="submit" className="btn btn-danger" onClick={this.props.onClose}>
         <button type="submit" className="btn btn-danger" onClick={this.props.onClose}>

+ 2 - 1
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -10,6 +10,7 @@ import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import type { IPageOperationProcessData } from '~/interfaces/page-operation';
 import type { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useSWRxPageInfo } from '~/stores/page';
@@ -133,7 +134,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   if (isLoading) {
   if (isLoading) {
     contents = (
     contents = (
       <div className="text-muted text-center my-2">
       <div className="text-muted text-center my-2">
-        <i className="fa fa-spinner fa-pulse"></i>
+        <LoadingSpinner />
       </div>
       </div>
     );
     );
   }
   }

+ 5 - 4
apps/app/src/components/DescendantsPageList.tsx

@@ -8,15 +8,16 @@ import type {
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { toastSuccess } from '~/client/util/toastr';
 import { toastSuccess } from '~/client/util/toastr';
-import { IPagingResult } from '~/interfaces/paging-result';
-import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+import type { IPagingResult } from '~/interfaces/paging-result';
+import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/stores/context';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/stores/context';
 import {
 import {
   mutatePageTree,
   mutatePageTree,
   useSWRxPageInfoForList, useSWRxPageList,
   useSWRxPageInfoForList, useSWRxPageList,
 } from '~/stores/page-listing';
 } from '~/stores/page-listing';
 
 
-import { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
+import type { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
+import { LoadingSpinner } from './LoadingSpinner';
 import PageList from './PageList/PageList';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 
 
@@ -86,7 +87,7 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
     return (
     return (
       <div className="wiki">
       <div className="wiki">
         <div className="text-muted text-center">
         <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
         </div>
       </div>
       </div>
     );
     );

+ 5 - 2
apps/app/src/components/InAppNotification/InAppNotificationList.tsx

@@ -1,9 +1,12 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 
 import type { HasObjectId } from '@growi/core';
 import type { HasObjectId } from '@growi/core';
 
 
 import type { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
 import type { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
 
 
+import { LoadingSpinner } from '../LoadingSpinner';
+
 import InAppNotificationElm from './InAppNotificationElm';
 import InAppNotificationElm from './InAppNotificationElm';
 
 
 
 
@@ -19,7 +22,7 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
     return (
     return (
       <div className="wiki">
       <div className="wiki">
         <div className="text-muted text-center">
         <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
         </div>
       </div>
       </div>
     );
     );

+ 4 - 4
apps/app/src/components/InAppNotification/InAppNotificationPage.tsx

@@ -1,6 +1,5 @@
-import React, {
-  FC, useState, useEffect, useCallback,
-} from 'react';
+import type { FC } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
@@ -11,6 +10,7 @@ import loggerFactory from '~/utils/logger';
 
 
 import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '../../stores/in-app-notification';
 import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '../../stores/in-app-notification';
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
+import { LoadingSpinner } from '../LoadingSpinner';
 import PaginationWrapper from '../PaginationWrapper';
 import PaginationWrapper from '../PaginationWrapper';
 
 
 import InAppNotificationList from './InAppNotificationList';
 import InAppNotificationList from './InAppNotificationList';
@@ -66,7 +66,7 @@ export const InAppNotificationPage: FC = () => {
       return (
       return (
         <div className="wiki" data-testid="grw-in-app-notification-page-spinner">
         <div className="wiki" data-testid="grw-in-app-notification-page-spinner">
           <div className="text-muted text-center">
           <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+            <LoadingSpinner className="me-1 fs-3" />
           </div>
           </div>
         </div>
         </div>
       );
       );

+ 5 - 4
apps/app/src/components/InfiniteScroll.tsx

@@ -1,9 +1,10 @@
-import React, {
-  Ref, useEffect, useState,
-} from 'react';
+import type { Ref } from 'react';
+import React, { useEffect, useState } from 'react';
 
 
 import type { SWRInfiniteResponse } from 'swr/infinite';
 import type { SWRInfiniteResponse } from 'swr/infinite';
 
 
+import { LoadingSpinner } from './LoadingSpinner';
+
 type Props<T> = {
 type Props<T> = {
   swrInifiniteResponse: SWRInfiniteResponse<T>
   swrInifiniteResponse: SWRInfiniteResponse<T>
   children: React.ReactNode,
   children: React.ReactNode,
@@ -32,7 +33,7 @@ const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
 const LoadingIndicator = (): React.ReactElement => {
 const LoadingIndicator = (): React.ReactElement => {
   return (
   return (
     <div className="text-muted text-center">
     <div className="text-muted text-center">
-      <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+      <LoadingSpinner className="me-1 fs-3" />
     </div>
     </div>
   );
   );
 };
 };

+ 11 - 4
apps/app/src/components/InstallerForm.tsx

@@ -1,6 +1,5 @@
-import {
-  FormEventHandler, memo, useCallback, useState,
-} from 'react';
+import type { FormEventHandler } from 'react';
+import { memo, useCallback, useState } from 'react';
 
 
 import { Lang, AllLang } from '@growi/core';
 import { Lang, AllLang } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -11,6 +10,8 @@ import { i18n as i18nConfig } from '^/config/next-i18next.config';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 
 
+import { LoadingSpinner } from './LoadingSpinner';
+
 const InstallerForm = memo((): JSX.Element => {
 const InstallerForm = memo((): JSX.Element => {
   const { t, i18n } = useTranslation();
   const { t, i18n } = useTranslation();
 
 
@@ -203,7 +204,13 @@ const InstallerForm = memo((): JSX.Element => {
               disabled={isLoading}
               disabled={isLoading}
             >
             >
               <div className="eff"></div>
               <div className="eff"></div>
-              <span className="btn-label"><i className={isLoading ? 'fa fa-spinner fa-pulse me-1' : 'icon-user-follow'} /></span>
+              <span className="btn-label">
+                {isLoading ? (
+                  <LoadingSpinner />
+                ) : (
+                  <i className="icon-user-follow" />
+                )}
+              </span>
               <span className="btn-label-text">{ t('Create') }</span>
               <span className="btn-label-text">{ t('Create') }</span>
             </button>
             </button>
           </div>
           </div>

+ 9 - 1
apps/app/src/components/InvitedForm.tsx

@@ -7,6 +7,8 @@ import { apiv3Post } from '~/client/util/apiv3-client';
 
 
 import { useCurrentUser } from '../stores/context';
 import { useCurrentUser } from '../stores/context';
 
 
+import { LoadingSpinner } from './LoadingSpinner';
+
 
 
 export type InvitedFormProps = {
 export type InvitedFormProps = {
   invitedFormUsername: string,
   invitedFormUsername: string,
@@ -141,7 +143,13 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         <div className="input-group justify-content-center d-flex mt-4">
         <div className="input-group justify-content-center d-flex mt-4">
           <button type="submit" className="btn btn-fill" id="register" disabled={isLoading}>
           <button type="submit" className="btn btn-fill" id="register" disabled={isLoading}>
             <div className="eff"></div>
             <div className="eff"></div>
-            <span className="btn-label"><i className={isLoading ? 'fa fa-spinner fa-pulse me-1' : 'icon-user-follow'} /></span>
+            <span className="btn-label">
+              {isLoading ? (
+                <LoadingSpinner />
+              ) : (
+                <i className="icon-user-follow" />
+              )}
+            </span>
             <span className="btn-label-text">{t('Create')}</span>
             <span className="btn-label-text">{t('Create')}</span>
           </button>
           </button>
         </div>
         </div>

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

@@ -181,8 +181,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         {/* !! - DO NOT DELETE HIDDEN ELEMENT - !! -- 7.12 ryoji-s */}
         {/* !! - DO NOT DELETE HIDDEN ELEMENT - !! -- 7.12 ryoji-s */}
         {/* Import font-awesome to prevent MongoStore.js "Unable to find the session to touch" error */}
         {/* Import font-awesome to prevent MongoStore.js "Unable to find the session to touch" error */}
         <div className="visually-hidden">
         <div className="visually-hidden">
-          {/* Unsettled 11.17 meiri-k */}
-          <i className="fa fa-spinner fa-pulse" />
+          <LoadingSpinner />
         </div>
         </div>
         {/* !! - END OF HIDDEN ELEMENT - !! */}
         {/* !! - END OF HIDDEN ELEMENT - !! */}
         {isLdapSetupFailed && (
         {isLdapSetupFailed && (

+ 3 - 1
apps/app/src/components/Me/QuestionnaireSettings.tsx

@@ -8,6 +8,8 @@ import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
 import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 
 
+import { LoadingSpinner } from '../LoadingSpinner';
+
 
 
 export const QuestionnaireSettings = (): JSX.Element => {
 export const QuestionnaireSettings = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -45,7 +47,7 @@ export const QuestionnaireSettings = (): JSX.Element => {
 
 
       {isLoadingCurrentUser && (
       {isLoadingCurrentUser && (
         <div className="text-muted text-center mb-5">
         <div className="text-muted text-center mb-5">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1" />
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
         </div>
       )}
       )}
 
 

+ 4 - 1
apps/app/src/components/Page/RevisionLoader.tsx

@@ -7,6 +7,9 @@ import type { RendererOptions } from '~/interfaces/renderer-options';
 import { useSWRxPageRevision } from '~/stores/page';
 import { useSWRxPageRevision } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
+import { LoadingSpinner } from '../LoadingSpinner';
+
 import RevisionRenderer from './RevisionRenderer';
 import RevisionRenderer from './RevisionRenderer';
 
 
 export const ROOT_ELEM_ID = 'revision-loader' as const;
 export const ROOT_ELEM_ID = 'revision-loader' as const;
@@ -64,7 +67,7 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
     return (
     return (
       <div className="wiki">
       <div className="wiki">
         <div className="text-muted text-center">
         <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
         </div>
       </div>
       </div>
     );
     );

+ 2 - 1
apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx

@@ -9,6 +9,7 @@ import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
 
+import { LoadingSpinner } from '../LoadingSpinner';
 import { PageAttachmentList } from '../PageAttachment/PageAttachmentList';
 import { PageAttachmentList } from '../PageAttachment/PageAttachmentList';
 import PaginationWrapper from '../PaginationWrapper';
 import PaginationWrapper from '../PaginationWrapper';
 
 
@@ -63,7 +64,7 @@ const PageAttachment = (): JSX.Element => {
     if (dataAttachments == null || inUseAttachmentsMap == null) {
     if (dataAttachments == null || inUseAttachmentsMap == null) {
       return (
       return (
         <div className="text-muted text-center">
         <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
         </div>
       );
       );
     }
     }

+ 2 - 1
apps/app/src/components/PageControls/BookmarkButtons.tsx

@@ -12,6 +12,7 @@ import { useIsGuestUser } from '~/stores/context';
 
 
 import { BookmarkFolderMenu } from '../Bookmarks/BookmarkFolderMenu';
 import { BookmarkFolderMenu } from '../Bookmarks/BookmarkFolderMenu';
 import UserPictureList from '../Common/UserPictureList';
 import UserPictureList from '../Common/UserPictureList';
+import { LoadingSpinner } from '../LoadingSpinner';
 
 
 import styles from './BookmarkButtons.module.scss';
 import styles from './BookmarkButtons.module.scss';
 import popoverStyles from './user-list-popover.module.scss';
 import popoverStyles from './user-list-popover.module.scss';
@@ -94,7 +95,7 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
       </button>
       </button>
       <Popover placement="bottom" isOpen={isBookmarkUsersPopoverOpen} target="po-total-bookmarks" toggle={toggleBookmarkUsersPopover} trigger="legacy">
       <Popover placement="bottom" isOpen={isBookmarkUsersPopoverOpen} target="po-total-bookmarks" toggle={toggleBookmarkUsersPopover} trigger="legacy">
         <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
         <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
-          { isLoadingBookmarkedUsers && <i className="fa fa-spinner fa-pulse"></i> }
+          { isLoadingBookmarkedUsers && <LoadingSpinner /> }
           { !isLoadingBookmarkedUsers && bookmarkedUsers != null && (
           { !isLoadingBookmarkedUsers && bookmarkedUsers != null && (
             <>
             <>
               { bookmarkedUsers.length > 0
               { bookmarkedUsers.length > 0

+ 3 - 1
apps/app/src/components/PageEditor/DrawioModal.tsx

@@ -18,6 +18,8 @@ import { useDrawioModal } from '~/stores/modal';
 import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePersonalSettings } from '~/stores/personal-settings';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { LoadingSpinner } from '../LoadingSpinner';
+
 import { type DrawioConfig, DrawioCommunicationHelper } from './DrawioCommunicationHelper';
 import { type DrawioConfig, DrawioCommunicationHelper } from './DrawioCommunicationHelper';
 
 
 const logger = loggerFactory('growi:components:DrawioModal');
 const logger = loggerFactory('growi:components:DrawioModal');
@@ -133,7 +135,7 @@ export const DrawioModal = (): JSX.Element => {
         {/* Loading spinner */}
         {/* Loading spinner */}
         <div className="w-100 h-100 position-absolute d-flex">
         <div className="w-100 h-100 position-absolute d-flex">
           <div className="mx-auto my-auto">
           <div className="mx-auto my-auto">
-            <i className="fa fa-3x fa-spinner fa-pulse mx-auto text-muted"></i>
+            <LoadingSpinner className="mx-auto text-muted fs-2" />
           </div>
           </div>
         </div>
         </div>
         {/* iframe */}
         {/* iframe */}

+ 3 - 15
apps/app/src/components/PageEditor/EditorNavbarBottom.module.scss

@@ -5,23 +5,11 @@
 @include mixins.editing() {
 @include mixins.editing() {
   .grw-editor-navbar-bottom :global {
   .grw-editor-navbar-bottom :global {
     .grw-grant-selector {
     .grw-grant-selector {
-      @include bs.media-breakpoint-down(sm) {
-        .btn .label {
-          display: none;
-        }
-      }
-      @include bs.media-breakpoint-up(md) {
-        .dropdown-toggle {
-          min-width: 100px;
-
-          // caret
-          &::after {
-            margin-left: 1em;
-          }
-        }
+      .material-symbols-outlined  {
+        padding-bottom: 2px;
+        font-size: 19px;
       }
       }
     }
     }
-
     .btn-submit {
     .btn-submit {
       width: 100px;
       width: 100px;
     }
     }

+ 5 - 98
apps/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -1,114 +1,21 @@
-import React, { useCallback, useState, useEffect } from 'react';
-
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
-import { Collapse, Button } from 'reactstrap';
-
-
-import type { SavePageControlsProps } from '~/components/SavePageControls';
-import { useIsSlackConfigured } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
-import { useCurrentPagePath } from '~/stores/page';
-import {
-  useEditorMode, useIsDeviceLargerThanLg, useIsDeviceLargerThanMd,
-} from '~/stores/ui';
-
 
 
 import styles from './EditorNavbarBottom.module.scss';
 import styles from './EditorNavbarBottom.module.scss';
 
 
 const moduleClass = styles['grw-editor-navbar-bottom'];
 const moduleClass = styles['grw-editor-navbar-bottom'];
 
 
-
-const SavePageControls = dynamic<SavePageControlsProps>(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
-const SlackLogo = dynamic(() => import('~/components/SlackLogo').then(mod => mod.SlackLogo), { ssr: false });
-const SlackNotification = dynamic(() => import('~/components/SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
+const SavePageControls = dynamic(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
 const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 
 
-
 const EditorNavbarBottom = (): JSX.Element => {
 const EditorNavbarBottom = (): JSX.Element => {
-
-  const [isSlackExpanded, setSlackExpanded] = useState(false);
-
-  const { data: editorMode } = useEditorMode();
-  const { data: isSlackConfigured } = useIsSlackConfigured();
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
-  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-
-  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
-
-  const [slackChannelsStr, setSlackChannelsStr] = useState<string>('');
-
-  // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
-  const slackChannelsDataString = slackChannelsData?.toString();
-  useEffect(() => {
-    if (editorMode === 'editor') {
-      setSlackChannelsStr(slackChannelsDataString ?? '');
-      mutateIsSlackEnabled(false);
-    }
-  }, [editorMode, mutateIsSlackEnabled, slackChannelsDataString]);
-
-  const isSlackEnabledToggleHandler = (bool: boolean) => {
-    mutateIsSlackEnabled(bool, false);
-  };
-
-  const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
-    setSlackChannelsStr(slackChannels);
-  }, []);
-
   return (
   return (
     <div className="border-top" data-testid="grw-editor-navbar-bottom">
     <div className="border-top" data-testid="grw-editor-navbar-bottom">
-      {/* Collapsed SlackNotification */}
-      {isSlackConfigured && (
-        <Collapse isOpen={isSlackExpanded && !isDeviceLargerThanLg}>
-          <nav className={`navbar navbar-expand-lg border-top ${moduleClass}`}>
-            {isSlackEnabled != null
-            && (
-              <SlackNotification
-                isSlackEnabled={isSlackEnabled}
-                slackChannels={slackChannelsStr}
-                onEnabledFlagChange={isSlackEnabledToggleHandler}
-                onChannelChange={slackChannelsChangedHandler}
-                id="idForEditorNavbarBottomForMobile"
-              />
-            )
-            }
-          </nav>
-        </Collapse>
-      )
-      }
       <div className={`flex-expand-horiz align-items-center px-2 py-1 py-md-2 px-md-3 ${moduleClass}`}>
       <div className={`flex-expand-horiz align-items-center px-2 py-1 py-md-2 px-md-3 ${moduleClass}`}>
-        <form>
-          <OptionsSelector collapsed={!isDeviceLargerThanMd} />
+        <form className="m-2 me-auto">
+          <OptionsSelector />
         </form>
         </form>
-        <form className="row row-cols-lg-auto g-3 align-items-center ms-auto">
-          {/* Responsive Design for the SlackNotification */}
-          {/* Button or the normal Slack banner */}
-          {isSlackConfigured && (!isDeviceLargerThanMd ? (
-            <Button
-              className="grw-btn-slack border me-2"
-              onClick={() => (setSlackExpanded(!isSlackExpanded))}
-            >
-              <div className="grw-slack-logo">
-                <SlackLogo />
-                <span className="grw-btn-slack-triangle material-symbols-outlined ms-2">arrow_drop_up</span>
-              </div>
-            </Button>
-          ) : (
-            <div className="me-2">
-              {isSlackEnabled != null
-              && (
-                <SlackNotification
-                  isSlackEnabled={isSlackEnabled}
-                  slackChannels={slackChannelsStr}
-                  onEnabledFlagChange={isSlackEnabledToggleHandler}
-                  onChannelChange={slackChannelsChangedHandler}
-                  id="idForEditorNavbarBottom"
-                />
-              )}
-            </div>
-          ))}
-          <SavePageControls slackChannels={slackChannelsStr} />
+        <form className="m-2">
+          <SavePageControls />
         </form>
         </form>
       </div>
       </div>
     </div>
     </div>

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

@@ -13,6 +13,9 @@ import {
 
 
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useEditorSettings, useCurrentIndentSize } from '~/stores/editor';
 import { useEditorSettings, useCurrentIndentSize } from '~/stores/editor';
+import {
+  useIsDeviceLargerThanMd,
+} from '~/stores/ui';
 
 
 type RadioListItemProps = {
 type RadioListItemProps = {
   onClick: () => void,
   onClick: () => void,
@@ -256,7 +259,7 @@ const OptionsStatus = {
 } as const;
 } as const;
 type OptionStatus = typeof OptionsStatus[keyof typeof OptionsStatus];
 type OptionStatus = typeof OptionsStatus[keyof typeof OptionsStatus];
 
 
-export const OptionsSelector = ({ collapsed }: {collapsed?: boolean}): JSX.Element => {
+export const OptionsSelector = (): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -266,6 +269,7 @@ export const OptionsSelector = ({ collapsed }: {collapsed?: boolean}): JSX.Eleme
   const { data: editorSettings } = useEditorSettings();
   const { data: editorSettings } = useEditorSettings();
   const { data: currentIndentSize } = useCurrentIndentSize();
   const { data: currentIndentSize } = useCurrentIndentSize();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
 
   if (editorSettings == null || currentIndentSize == null || isIndentSizeForced == null) {
   if (editorSettings == null || currentIndentSize == null || isIndentSizeForced == null) {
     return <></>;
     return <></>;
@@ -275,14 +279,15 @@ export const OptionsSelector = ({ collapsed }: {collapsed?: boolean}): JSX.Eleme
     <Dropdown isOpen={dropdownOpen} toggle={() => { setStatus(OptionsStatus.Home); setDropdownOpen(!dropdownOpen) }} direction="up" className="">
     <Dropdown isOpen={dropdownOpen} toggle={() => { setStatus(OptionsStatus.Home); setDropdownOpen(!dropdownOpen) }} direction="up" className="">
       <DropdownToggle
       <DropdownToggle
         className={`btn btn-sm btn-outline-neutral-secondary d-flex align-items-center justify-content-center
         className={`btn btn-sm btn-outline-neutral-secondary d-flex align-items-center justify-content-center
-              ${collapsed ? 'border-0' : 'border border-secondary'}
+              ${isDeviceLargerThanMd ? '' : 'border-0'}
               ${dropdownOpen ? 'active' : ''}
               ${dropdownOpen ? 'active' : ''}
               `}
               `}
       >
       >
         <span className="material-symbols-outlined py-0 fs-5"> settings </span>
         <span className="material-symbols-outlined py-0 fs-5"> settings </span>
         {
         {
-          collapsed ? <></>
-            : <label className="ms-1 me-1">{t('page_edit.editor_config')}</label>
+          isDeviceLargerThanMd
+            ? <label className="ms-1 me-1">{t('page_edit.editor_config')}</label>
+            : <></>
         }
         }
       </DropdownToggle>
       </DropdownToggle>
       <DropdownMenu container="body">
       <DropdownMenu container="body">

+ 4 - 3
apps/app/src/components/PageList/PageList.tsx

@@ -3,9 +3,10 @@ import React from 'react';
 import type { IPageInfoForEntity, IPageWithMeta } from '@growi/core';
 import type { IPageInfoForEntity, IPageWithMeta } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 
 
-import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import { LoadingSpinner } from '../LoadingSpinner';
 
 
 import { PageListItemL } from './PageListItemL';
 import { PageListItemL } from './PageListItemL';
 
 
@@ -30,7 +31,7 @@ const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
     return (
     return (
       <div className="wiki">
       <div className="wiki">
         <div className="text-muted text-center">
         <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
         </div>
       </div>
       </div>
     );
     );

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

@@ -14,6 +14,7 @@ import { useSWRxCurrentPage } from '~/stores/page';
 import { usePresentationViewOptions } from '~/stores/renderer';
 import { usePresentationViewOptions } from '~/stores/renderer';
 import { useNextThemes } from '~/stores/use-next-themes';
 import { useNextThemes } from '~/stores/use-next-themes';
 
 
+import { LoadingSpinner } from './LoadingSpinner';
 
 
 import styles from './PagePresentationModal.module.scss';
 import styles from './PagePresentationModal.module.scss';
 
 
@@ -21,7 +22,7 @@ import styles from './PagePresentationModal.module.scss';
 const Presentation = dynamic<PresentationProps>(() => import('./Presentation/Presentation').then(mod => mod.Presentation), {
 const Presentation = dynamic<PresentationProps>(() => import('./Presentation/Presentation').then(mod => mod.Presentation), {
   ssr: false,
   ssr: false,
   loading: () => (
   loading: () => (
-    <i className="fa fa-4x fa-spinner fa-pulse text-muted"></i>
+    <LoadingSpinner className="text-muted fs-1" />
   ),
   ),
 });
 });
 
 

+ 1 - 1
apps/app/src/components/PageRenameModal.tsx

@@ -287,7 +287,7 @@ const PageRenameModal = (): JSX.Element => {
 
 
         <p className="mt-2">
         <p className="mt-2">
           <button type="button" className="btn btn-link mt-2 p-0" aria-expanded="false" onClick={() => setExpandOtherOptions(!expandOtherOptions)}>
           <button type="button" className="btn btn-link mt-2 p-0" aria-expanded="false" onClick={() => setExpandOtherOptions(!expandOtherOptions)}>
-            <span className={`material-symbols-outlined me-1 ${expandOtherOptions ? 'fa-rotate-90' : ''}`}>navigate_next</span>
+            <span className={`material-symbols-outlined me-1 ${expandOtherOptions ? 'rotate-90' : ''}`}>navigate_next</span>
             { t('modal_rename.label.Other options') }
             { t('modal_rename.label.Other options') }
           </button>
           </button>
         </p>
         </p>

+ 11 - 9
apps/app/src/components/PrivateLegacyPages.tsx

@@ -8,28 +8,30 @@ import {
   UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
   UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import type { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
-import { V5MigrationStatus } from '~/interfaces/page-listing-results';
-import { IFormattedSearchResult } from '~/interfaces/search';
-import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
+import type { V5MigrationStatus } from '~/interfaces/page-listing-results';
+import type { IFormattedSearchResult } from '~/interfaces/search';
+import type { PageMigrationErrorData } from '~/interfaces/websocket';
+import { SocketEventName } from '~/interfaces/websocket';
 import { useIsAdmin } from '~/stores/context';
 import { useIsAdmin } from '~/stores/context';
-import {
-  ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
-} from '~/stores/modal';
+import type { ILegacyPrivatePage } from '~/stores/modal';
+import { usePrivateLegacyPagesMigrationModal } from '~/stores/modal';
 import { mutatePageTree, useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import { mutatePageTree, useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import {
 import {
   useSWRxSearch,
   useSWRxSearch,
 } from '~/stores/search';
 } from '~/stores/search';
 
 
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
+import { LoadingSpinner } from './LoadingSpinner';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 import { PrivateLegacyPagesMigrationModal } from './PrivateLegacyPagesMigrationModal';
 import { PrivateLegacyPagesMigrationModal } from './PrivateLegacyPagesMigrationModal';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
 import SearchControl from './SearchPage/SearchControl';
-import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage/SearchPageBase';
+import type { IReturnSelectedPageIds } from './SearchPage/SearchPageBase';
+import { SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage/SearchPageBase';
 
 
 
 
 // TODO: replace with "customize:showPageLimitationS"
 // TODO: replace with "customize:showPageLimitationS"
@@ -61,7 +63,7 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   if (migrationStatus == null) {
   if (migrationStatus == null) {
     return (
     return (
       <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
       <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
-        <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+        <LoadingSpinner className="me-1 fs-3" />
       </div>
       </div>
     );
     );
   }
   }

+ 205 - 55
apps/app/src/components/SavePageControls.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useState, useEffect } from 'react';
 
 
 import type EventEmitter from 'events';
 import type EventEmitter from 'events';
 
 
@@ -6,23 +6,31 @@ import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-pa
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   UncontrolledButtonDropdown, Button,
   UncontrolledButtonDropdown, Button,
-  DropdownToggle, DropdownMenu, DropdownItem,
+  DropdownToggle, DropdownMenu, DropdownItem, Modal,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
 import {
 import {
   useIsEditable, useIsAclEnabled,
   useIsEditable, useIsAclEnabled,
+  useIsSlackConfigured,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useWaitingSaveProcessing } from '~/stores/editor';
-import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
+import { useWaitingSaveProcessing, useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
+import { useSWRMUTxCurrentPage, useSWRxCurrentPage, useCurrentPagePath } from '~/stores/page';
 import { mutatePageTree } from '~/stores/page-listing';
 import { mutatePageTree } from '~/stores/page-listing';
-import { useSelectedGrant } from '~/stores/ui';
+import {
+  useSelectedGrant,
+  useEditorMode, useIsDeviceLargerThanMd,
+  EditorMode,
+} from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
 import { unpublish } from '../client/services/page-operation';
 import { unpublish } from '../client/services/page-operation';
 
 
+import { LoadingSpinner } from './LoadingSpinner';
 import { GrantSelector } from './SavePageControls/GrantSelector';
 import { GrantSelector } from './SavePageControls/GrantSelector';
+import { SlackNotification } from './SlackNotification';
 
 
 
 
 declare global {
 declare global {
@@ -33,25 +41,19 @@ declare global {
 
 
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 
-export type SavePageControlsProps = {
-  slackChannels: string
-}
 
 
-export const SavePageControls = (props: SavePageControlsProps): JSX.Element | null => {
-  const { slackChannels } = props;
+const SavePageButton = (props: {slackChannels: string, isDeviceLargerThanMd?: boolean}) => {
+
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: isEditable } = useIsEditable();
-  const { data: isAclEnabled } = useIsAclEnabled();
-  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { mutate: mutateEditorMode } = useEditorMode();
+  const [isSavePageModalShown, setIsSavePageModalShown] = useState<boolean>(false);
 
 
-  const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
+  const { slackChannels, isDeviceLargerThanMd } = props;
 
 
-  const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
-    mutateGrant(grantData);
-  }, [mutateGrant]);
+  const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
 
 
   const save = useCallback(async(): Promise<void> => {
   const save = useCallback(async(): Promise<void> => {
     // save
     // save
@@ -74,46 +76,21 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
       await unpublish(pageId);
       await unpublish(pageId);
       await mutateCurrentPage();
       await mutateCurrentPage();
       await mutatePageTree();
       await mutatePageTree();
+      await mutateEditorMode(EditorMode.View);
       toastSuccess(t('wip_page.success_save_as_wip'));
       toastSuccess(t('wip_page.success_save_as_wip'));
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
       toastError(t('wip_page.fail_save_as_wip'));
       toastError(t('wip_page.fail_save_as_wip'));
     }
     }
-  }, [currentPage?._id, mutateCurrentPage, t]);
-
-
-  if (isEditable == null || isAclEnabled == null || grantData == null) {
-    return null;
-  }
+  }, [currentPage?._id, mutateCurrentPage, mutateEditorMode, t]);
 
 
-  if (!isEditable) {
-    return null;
-  }
-
-  const { grant, userRelatedGrantedGroups } = grantData;
-
-  const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = t('Update');
   const labelSubmitButton = t('Update');
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
   const labelUnpublishPage = t('wip_page.save_as_wip');
   const labelUnpublishPage = t('wip_page.save_as_wip');
 
 
   return (
   return (
-    <div className="d-flex align-items-center flex-nowrap">
-
-      {isAclEnabled
-        && (
-          <div className="me-2">
-            <GrantSelector
-              grant={grant}
-              disabled={isGrantSelectorDisabledPage}
-              userRelatedGrantedGroups={userRelatedGrantedGroups}
-              onUpdateGrant={updateGrantHandler}
-            />
-          </div>
-        )
-      }
-
+    <>
       <UncontrolledButtonDropdown direction="up" size="sm">
       <UncontrolledButtonDropdown direction="up" size="sm">
         <Button
         <Button
           id="caret"
           id="caret"
@@ -124,21 +101,194 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
           disabled={isWaitingSaveProcessing}
           disabled={isWaitingSaveProcessing}
         >
         >
           {isWaitingSaveProcessing && (
           {isWaitingSaveProcessing && (
-            <i className="fa fa-spinner fa-pulse me-1"></i>
+            <LoadingSpinner />
           )}
           )}
           {labelSubmitButton}
           {labelSubmitButton}
         </Button>
         </Button>
-        <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} />
-        <DropdownMenu container="body" end>
-          <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
-            {labelOverwriteScopes}
-          </DropdownItem>
-          <DropdownItem onClick={clickUnpublishButtonHandler}>
-            {labelUnpublishPage}
-          </DropdownItem>
-        </DropdownMenu>
+        {
+          isDeviceLargerThanMd ? (
+            <>
+              <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} />
+              <DropdownMenu container="body" end>
+                <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
+                  {labelOverwriteScopes}
+                </DropdownItem>
+                <DropdownItem onClick={clickUnpublishButtonHandler}>
+                  {labelUnpublishPage}
+                </DropdownItem>
+              </DropdownMenu>
+            </>
+          ) : (
+            <>
+              <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} onClick={() => setIsSavePageModalShown(true)} />
+              <Modal
+                centered
+                isOpen={isSavePageModalShown}
+                toggle={() => setIsSavePageModalShown(false)}
+              >
+                <div className="d-flex flex-column pt-4 pb-3 px-4 gap-4">
+                  <button type="button" className="btn btn-primary" onClick={() => { setIsSavePageModalShown(false); saveAndOverwriteScopesOfDescendants() }}>
+                    {labelOverwriteScopes}
+                  </button>
+                  <button type="button" className="btn btn-primary" onClick={() => { setIsSavePageModalShown(false); clickUnpublishButtonHandler() }}>
+                    {labelUnpublishPage}
+                  </button>
+                  <button type="button" className="btn btn-outline-neutral-secondary mx-auto mt-1" onClick={() => setIsSavePageModalShown(false)}>
+                    <label className="mx-2">
+                      {t('Cancel')}
+                    </label>
+                  </button>
+                </div>
+              </Modal>
+            </>
+          )
+        }
       </UncontrolledButtonDropdown>
       </UncontrolledButtonDropdown>
+    </>
+  );
+};
+
 
 
+export const SavePageControls = (): JSX.Element | null => {
+  const { t } = useTranslation('commons');
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: isEditable } = useIsEditable();
+  const { data: isAclEnabled } = useIsAclEnabled();
+  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
+
+  const { data: editorMode } = useEditorMode();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isSlackConfigured } = useIsSlackConfigured();
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+
+  const [slackChannels, setSlackChannels] = useState<string>('');
+  const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] = useState<boolean>(false);
+
+  // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
+  const slackChannelsDataString = slackChannelsData?.toString();
+  useEffect(() => {
+    if (editorMode === 'editor') {
+      setSlackChannels(slackChannelsDataString ?? '');
+      mutateIsSlackEnabled(false);
+    }
+  }, [editorMode, mutateIsSlackEnabled, slackChannelsDataString]);
+
+
+  const isSlackEnabledToggleHandler = (bool: boolean) => {
+    mutateIsSlackEnabled(bool, false);
+  };
+
+  const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
+    setSlackChannels(slackChannels);
+  }, []);
+
+  const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
+    mutateGrant(grantData);
+  }, [mutateGrant]);
+
+  if (isEditable == null || isAclEnabled == null || grantData == null) {
+    return null;
+  }
+
+  if (!isEditable) {
+    return null;
+  }
+
+  const { grant, userRelatedGrantedGroups } = grantData;
+
+  const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
+
+  return (
+    <div className="d-flex align-items-center flex-nowrap">
+      {
+        isDeviceLargerThanMd ? (
+          <>
+            {
+              isSlackConfigured && (
+                <div className="me-2">
+                  {isSlackEnabled != null && (
+                    <SlackNotification
+                      isSlackEnabled={isSlackEnabled}
+                      slackChannels={slackChannels}
+                      onEnabledFlagChange={isSlackEnabledToggleHandler}
+                      onChannelChange={slackChannelsChangedHandler}
+                      id="idForEditorNavbarBottom"
+                    />
+                  )}
+                </div>
+              )
+            }
+
+            {
+              isAclEnabled && (
+                <div className="me-2">
+                  <GrantSelector
+                    grant={grant}
+                    disabled={isGrantSelectorDisabledPage}
+                    userRelatedGrantedGroups={userRelatedGrantedGroups}
+                    onUpdateGrant={updateGrantHandler}
+                  />
+                </div>
+              )
+            }
+
+            <SavePageButton slackChannels={slackChannels} isDeviceLargerThanMd />
+          </>
+        ) : (
+          <>
+            <SavePageButton slackChannels={slackChannels} />
+            <button
+              type="button"
+              className="btn btn-outline-neutral-secondary border-0 fs-5 p-0 ms-1 text-muted"
+              onClick={() => setIsSavePageControlsModalShown(true)}
+            >
+              <span className="material-symbols-outlined">more_vert</span>
+            </button>
+            <Modal
+              className="save-page-controls-modal"
+              centered
+              isOpen={isSavePageControlsModalShown}
+            >
+              <div className="d-flex flex-column pt-5 pb-3 px-4 gap-3">
+                {
+                  isAclEnabled && (
+                    <>
+                      <GrantSelector
+                        grant={grant}
+                        disabled={isGrantSelectorDisabledPage}
+                        openInModal
+                        userRelatedGrantedGroups={userRelatedGrantedGroups}
+                        onUpdateGrant={updateGrantHandler}
+                      />
+                    </>
+                  )
+                }
+
+                {
+                  isSlackConfigured && isSlackEnabled != null && (
+                    <>
+                      <SlackNotification
+                        isSlackEnabled={isSlackEnabled}
+                        slackChannels={slackChannels}
+                        onEnabledFlagChange={isSlackEnabledToggleHandler}
+                        onChannelChange={slackChannelsChangedHandler}
+                        id="idForEditorNavbarBottom"
+                      />
+                    </>
+                  )
+                }
+                <div className="d-flex">
+                  <button type="button" className="mx-auto btn btn-primary rounded-1" onClick={() => setIsSavePageControlsModalShown(false)}>
+                    {t('Done')}
+                  </button>
+                </div>
+              </div>
+            </Modal>
+          </>
+        )
+      }
     </div>
     </div>
   );
   );
 };
 };

+ 18 - 14
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -11,6 +11,7 @@ import {
   Modal, ModalHeader, ModalBody,
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 
 
@@ -21,7 +22,7 @@ const AVAILABLE_GRANTS = [
     grant: PageGrant.GRANT_PUBLIC, iconName: 'group', btnStyleClass: 'outline-info', label: 'Public',
     grant: PageGrant.GRANT_PUBLIC, iconName: 'group', btnStyleClass: 'outline-info', label: 'Public',
   },
   },
   {
   {
-    grant: PageGrant.GRANT_RESTRICTED, iconName: 'link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
+    grant: PageGrant.GRANT_RESTRICTED, iconName: 'link', btnStyleClass: 'outline-success', label: 'Anyone with the link',
   },
   },
   // { grant: 3, iconClass: '', label: 'Specified users only' },
   // { grant: 3, iconClass: '', label: 'Specified users only' },
   {
   {
@@ -30,7 +31,7 @@ const AVAILABLE_GRANTS = [
   {
   {
     grant: PageGrant.GRANT_USER_GROUP,
     grant: PageGrant.GRANT_USER_GROUP,
     iconName: 'more_horiz',
     iconName: 'more_horiz',
-    btnStyleClass: 'outline-purple',
+    btnStyleClass: 'outline-warning',
     label: 'Only inside the group',
     label: 'Only inside the group',
     reselectLabel: 'Reselect the group',
     reselectLabel: 'Reselect the group',
   },
   },
@@ -39,6 +40,7 @@ const AVAILABLE_GRANTS = [
 
 
 type Props = {
 type Props = {
   disabled?: boolean,
   disabled?: boolean,
+  openInModal?: boolean,
   grant: PageGrant,
   grant: PageGrant,
   userRelatedGrantedGroups?: {
   userRelatedGrantedGroups?: {
     id: string,
     id: string,
@@ -57,6 +59,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
 
   const {
   const {
     disabled,
     disabled,
+    openInModal,
     userRelatedGrantedGroups,
     userRelatedGrantedGroups,
     onUpdateGrant,
     onUpdateGrant,
     grant: currentGrant,
     grant: currentGrant,
@@ -118,7 +121,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
         : opt.label;
         : opt.label;
 
 
       const labelElm = (
       const labelElm = (
-        <span>
+        <span className={openInModal ? 'py-2' : ''}>
           <span className="material-symbols-outlined me-2">{opt.iconName}</span>
           <span className="material-symbols-outlined me-2">{opt.iconName}</span>
           <span className="label">{t(label)}</span>
           <span className="label">{t(label)}</span>
         </span>
         </span>
@@ -158,17 +161,17 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
 
     return (
     return (
       <div className="grw-grant-selector mb-0" data-testid="grw-grant-selector">
       <div className="grw-grant-selector mb-0" data-testid="grw-grant-selector">
-        <UncontrolledDropdown direction="up" size="sm">
-          <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={disabled}>
+        <UncontrolledDropdown direction={openInModal ? 'down' : 'up'} size="sm">
+          <DropdownToggle color={dropdownToggleBtnColor} caret className="w-100 d-flex justify-content-between align-items-center" disabled={disabled}>
             {dropdownToggleLabelElm}
             {dropdownToggleLabelElm}
           </DropdownToggle>
           </DropdownToggle>
-          <DropdownMenu container="body">
+          <DropdownMenu container={openInModal ? '' : 'body'}>
             {dropdownMenuElems}
             {dropdownMenuElems}
           </DropdownMenu>
           </DropdownMenu>
         </UncontrolledDropdown>
         </UncontrolledDropdown>
       </div>
       </div>
     );
     );
-  }, [changeGrantHandler, currentGrant, disabled, userRelatedGrantedGroups, t]);
+  }, [changeGrantHandler, currentGrant, disabled, userRelatedGrantedGroups, t, openInModal]);
 
 
   /**
   /**
    * Render select grantgroup modal.
    * Render select grantgroup modal.
@@ -182,7 +185,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     if (myUserGroups == null) {
     if (myUserGroups == null) {
       return (
       return (
         <div className="my-3 text-center">
         <div className="my-3 text-center">
-          <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
+          <LoadingSpinner className="mx-auto text-muted fs-4" />
         </div>
         </div>
       );
       );
     }
     }
@@ -199,7 +202,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
     }
 
 
     return (
     return (
-      <>
+      <div className="d-flex flex-column">
         { myUserGroups.map((group) => {
         { myUserGroups.map((group) => {
           const groupIsGranted = userRelatedGrantedGroups?.find(g => g.id === group.item._id) != null;
           const groupIsGranted = userRelatedGrantedGroups?.find(g => g.id === group.item._id) != null;
           const activeClass = groupIsGranted ? 'active' : '';
           const activeClass = groupIsGranted ? 'active' : '';
@@ -212,14 +215,14 @@ export const GrantSelector = (props: Props): JSX.Element => {
               onClick={() => groupListItemClickHandler(group)}
               onClick={() => groupListItemClickHandler(group)}
             >
             >
               <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
               <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
-              <h5 className="d-inline-block ml-3">{group.item.name}</h5>
-              {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
+              <h5 className="d-inline-block ms-3">{group.item.name}</h5>
+              {group.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
             </button>
           );
           );
         }) }
         }) }
-        <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsSelectGroupModalShown(false)}>{t('Done')}</button>
-      </>
+        <button type="button" className="btn btn-primary mt-2 mx-auto" onClick={() => setIsSelectGroupModalShown(false)}>{t('Done')}</button>
+      </div>
     );
     );
 
 
   }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, userRelatedGrantedGroups]);
   }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, userRelatedGrantedGroups]);
@@ -233,8 +236,9 @@ export const GrantSelector = (props: Props): JSX.Element => {
         <Modal
         <Modal
           isOpen={isSelectGroupModalShown}
           isOpen={isSelectGroupModalShown}
           toggle={() => setIsSelectGroupModalShown(false)}
           toggle={() => setIsSelectGroupModalShown(false)}
+          centered
         >
         >
-          <ModalHeader tag="h4" toggle={() => setIsSelectGroupModalShown(false)} className="bg-purple text-light">
+          <ModalHeader tag="h4" toggle={() => setIsSelectGroupModalShown(false)} className="bg-purple text-muted">
             {t('user_group.select_group')}
             {t('user_group.select_group')}
           </ModalHeader>
           </ModalHeader>
           <ModalBody>
           <ModalBody>

+ 2 - 1
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -17,6 +17,7 @@ import { usePageDeleteModal } from '~/stores/modal';
 import { mutatePageTree } from '~/stores/page-listing';
 import { mutatePageTree } from '~/stores/page-listing';
 
 
 import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import { LoadingSpinner } from '../LoadingSpinner';
 
 
 // Do not import with next/dynamic
 // Do not import with next/dynamic
 // see: https://github.com/weseek/growi/pull/7923
 // see: https://github.com/weseek/growi/pull/7923
@@ -181,7 +182,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
           {/* Loading */}
           {/* Loading */}
           { pages == null && (
           { pages == null && (
             <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
             <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
-              <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+              <LoadingSpinner className="me-1 fs-3" />
             </div>
             </div>
           ) }
           ) }
 
 

+ 0 - 19
apps/app/src/components/SlackLogo.jsx

@@ -1,19 +0,0 @@
-import React from 'react';
-
-export const SlackLogo = () => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 448 448"
-    height="20"
-    width="20"
-  >
-    <path
-      d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,
-      0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,
-      47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,
-      0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,
-      47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,
-      0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"
-    />
-  </svg>
-);

+ 55 - 39
apps/app/src/components/SlackNotification.module.scss

@@ -1,45 +1,61 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 @use '@growi/core/scss/bootstrap/init' as bs;
 
 
-// TODO: activate (https://redmine.weseek.co.jp/issues/128307)
-.grw-slack-notification :global {
-  // $input-height-slack: bs.$form-check-indicator-size * 1.5;
-  // border-color: bs.$gray-200;
 
 
-  // border-style: solid;
-  // border-width: 1px;
-  // border-radius: $input-height-slack/2 2px 2px $input-height-slack/2;
+.grw-slack-switch :global {
+  .input-group-text {
+    background-color: inherit;
+  }
+  .form-check-input {
+    cursor: pointer;
+    background-repeat: no-repeat;
+    background-attachment:scroll;
+    background-clip: border-box;
+    background-origin: padding-box;
+    background-size: 30%, 45%;
+    box-shadow: none;
+    transition: all 0.4s ease-out;
+  }
+  .form-control::placeholder {
+    color: bs.$gray-500
+  }
+}
+
+:root[data-bs-theme='light'] {
+  .grw-slack-switch :global {
+    .form-check-input:not(:checked) {
+      background-color: bs.$gray-200;
+      background-image:
+        url('/images/icons/slack/slack-logo-off.svg'),
+        url('/images/icons/slack/slack-logo-background.svg');
+      background-position: 15%, 5%, 50%, 50%;
+    }
+
+    .form-check-input:checked {
+      background-color: #E7A9E8;
+      background-image:
+        url('/images/icons/slack/slack-logo-on.svg'),
+        url('/images/icons/slack/slack-logo-background.svg');
+      background-position: 85%, 95%, 50%, 50%;
+    }
+  }
+}
 
 
-  // .form-control {
-  //   height: $input-height-slack;
-  //   border: transparent;
-  //   @include bs.media-breakpoint-up(sm) {
-  //     width: 130px;
-  //   }
-  //   @include bs.media-breakpoint-up(md) {
-  //     width: 180px;
-  //   }
-  // }
-  // // height settings for slack button's responsive design
-  // // in the input and form-control element
-  // .grw-form-control-slack-notification.form-control {
-  //   height: $input-height-slack;
-  // }
-  // .grw-input-group-slack-notification {
-  //   height: $input-height-slack;
-  //   label {
-  //     display: flex;
-  //     align-items: center;
-  //     justify-content: center;
-  //     margin-bottom: 0;
-  //   }
-  // }
+:root[data-bs-theme='dark'] {
+  .grw-slack-switch :global {
+    .form-check-input:not(:checked) {
+      background-color: bs.$gray-200;
+      background-image:
+        url('/images/icons/slack/slack-logo-dark-off.svg'),
+        url('/images/icons/slack/slack-logo-dark-background.svg');
+      background-position: 14%, 4%, 50%, 50%;
+    }
 
 
-  // .form-check-label {
-  //   &::before {
-  //     border: transparent;
-  //   }
-  // }
+    .form-check-input:checked {
+      background-color: #731f74;
+      background-image:
+        url('/images/icons/slack/slack-logo-dark-on.svg'),
+        url('/images/icons/slack/slack-logo-dark-background.svg');
+      background-position: 86%, 95%, 50%, 50%;
+    }
+  }
 }
 }
-// TODO デザインの使用が確定して実装、本タスクのスコープ外
-// .grw-slack-notification-xd {
-// }

+ 31 - 31
apps/app/src/components/SlackNotification.tsx

@@ -1,9 +1,11 @@
 /* eslint-disable react/prop-types */
 /* eslint-disable react/prop-types */
 import type { FC } from 'react';
 import type { FC } from 'react';
-import React from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import { PopoverBody, PopoverHeader, UncontrolledPopover } from 'reactstrap';
+import {
+  FormGroup, Input, InputGroup, InputGroupText,
+  PopoverBody, PopoverHeader, UncontrolledPopover,
+} from 'reactstrap';
 
 
 import styles from './SlackNotification.module.scss';
 import styles from './SlackNotification.module.scss';
 
 
@@ -19,6 +21,7 @@ type SlackNotificationProps = {
 export const SlackNotification: FC<SlackNotificationProps> = ({
 export const SlackNotification: FC<SlackNotificationProps> = ({
   id, isSlackEnabled, slackChannels, onEnabledFlagChange, onChannelChange,
   id, isSlackEnabled, slackChannels, onEnabledFlagChange, onChannelChange,
 }) => {
 }) => {
+
   const { t } = useTranslation();
   const { t } = useTranslation();
   const idForSlackPopover = `${id}ForSlackPopover`;
   const idForSlackPopover = `${id}ForSlackPopover`;
 
 
@@ -38,34 +41,31 @@ export const SlackNotification: FC<SlackNotificationProps> = ({
 
 
 
 
   return (
   return (
-    <div className={`grw-slack-notification ${styles['grw-slack-notification']} w-100`}>
-      <div className="grw-input-group-slack-notification input-group extended-setting">
-        <label className="form-label input-group-addon">
-          <div className="form-check form-switch form-switch-lg form-switch-slack">
-            <input
-              type="checkbox"
-              className="form-check-input border-0"
-              id={id}
-              checked={isSlackEnabled}
-              onChange={updateCheckboxHandler}
-            />
-            <label className="form-label form-check-label align-center" htmlFor={id}></label>
-          </div>
-        </label>
-        <input
-          className="grw-form-control-slack-notification form-control align-top ps-0"
-          id={idForSlackPopover}
-          type="text"
-          value={slackChannels}
-          placeholder={t('page_edit.input_channels', 'Input channels')}
-          onChange={updateSlackChannelsHandler}
-        />
-        <UncontrolledPopover trigger="focus" placement="top" target={idForSlackPopover}>
-          <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
-          <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
-        </UncontrolledPopover>
-      </div>
-    </div>
-
+    <InputGroup className={`d-flex align-items-center ${styles['grw-slack-switch']}`}>
+      <InputGroupText className="rounded-pill rounded-end border-end-0 p-0 pe-1 grw-slack-switch">
+        <FormGroup switch className="position-relative pe-4 py-3 m-0 me-2">
+          <Input
+            className="position-absolute bottom-0 start-0 p-0 m-0 w-100 h-100 border-0"
+            type="switch"
+            role="switch"
+            id={id}
+            checked={isSlackEnabled}
+            onChange={updateCheckboxHandler}
+          />
+        </FormGroup>
+      </InputGroupText>
+      <Input
+        className="rounded-pill rounded-start border-start-0 py-1"
+        id={idForSlackPopover}
+        type="text"
+        value={slackChannels}
+        placeholder="Input channels"
+        onChange={updateSlackChannelsHandler}
+      />
+      <UncontrolledPopover trigger="focus" placement="top" target={idForSlackPopover}>
+        <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
+        <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
+      </UncontrolledPopover>
+    </InputGroup>
   );
   );
 };
 };

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

@@ -26,6 +26,7 @@ import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePreviewOptions } from '~/stores/renderer';
 import { usePreviewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { LoadingSpinner } from '../LoadingSpinner';
 import Preview from '../PageEditor/Preview';
 import Preview from '../PageEditor/Preview';
 
 
 import { useFormatter } from './use-formatter';
 import { useFormatter } from './use-formatter';
@@ -186,7 +187,7 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
 
 
             { isLoading && (
             { isLoading && (
               <div className="h-100 d-flex justify-content-center align-items-center">
               <div className="h-100 d-flex justify-content-center align-items-center">
-                <i className="fa fa-2x fa-spinner fa-pulse text-muted mx-auto"></i>
+                <LoadingSpinner className="mx-auto text-muted fs-3" />
               </div>
               </div>
             ) }
             ) }
 
 

+ 2 - 1
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -15,6 +15,7 @@ import { usePageTreeDescCountMap } from '~/stores/ui';
 import { shouldRecoverPagePaths } from '~/utils/page-operation';
 import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 
 import CountBadge from '../Common/CountBadge';
 import CountBadge from '../Common/CountBadge';
+import { LoadingSpinner } from '../LoadingSpinner';
 
 
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 import { useNewPageInput } from './NewPageInput';
 import { useNewPageInput } from './NewPageInput';
@@ -248,7 +249,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
               <ItemClassFixed {...itemProps} />
               <ItemClassFixed {...itemProps} />
               {isProcessingSubmission && (currentChildren.length - 1 === index) && (
               {isProcessingSubmission && (currentChildren.length - 1 === index) && (
                 <div className="text-muted text-center">
                 <div className="text-muted text-center">
-                  <i className="fa fa-spinner fa-pulse mr-1"></i>
+                  <LoadingSpinner className="mr-1" />
                 </div>
                 </div>
               )}
               )}
             </div>
             </div>

+ 2 - 1
apps/app/src/pages/tags.page.tsx

@@ -8,6 +8,7 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import Head from 'next/head';
 
 
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IDataTagCount } from '~/interfaces/tag';
 import type { IDataTagCount } from '~/interfaces/tag';
@@ -90,7 +91,7 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
           { isLoading
           { isLoading
             ? (
             ? (
               <div className="text-muted text-center">
               <div className="text-muted text-center">
-                <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+                <LoadingSpinner className="mt-3 fs-3" />
               </div>
               </div>
             )
             )
             : (
             : (

+ 0 - 27
apps/app/src/styles/_draft.scss

@@ -1,27 +0,0 @@
-.draft-list-item {
-  .panel-heading {
-    .icon-container {
-      a:hover {
-        text-decoration: unset;
-      }
-      i {
-        opacity: 0.5;
-      }
-    }
-
-    &:hover {
-      .icon-container {
-        i {
-          opacity: 1;
-        }
-      }
-    }
-  }
-
-  .draft-copy {
-    cursor: pointer;
-  }
-  .draft-path {
-    cursor: pointer;
-  }
-}

+ 4 - 12
apps/app/src/styles/_editor.scss

@@ -4,6 +4,10 @@
 
 
 @import './organisms/wiki-custom-sidebar';
 @import './organisms/wiki-custom-sidebar';
 
 
+:root {
+  --grw-codemirror-editor-content-font-family: var(--font-family-monospace);
+}
+
 // global imported
 // global imported
 .layout-root.editing {
 .layout-root.editing {
   overflow: hidden !important;
   overflow: hidden !important;
@@ -69,10 +73,6 @@
         }
         }
       }
       }
 
 
-      .textarea-editor {
-        font-family: var(--font-family-monospace);
-        border: none;
-      }
     }
     }
 
 
     .page-editor-preview-container {
     .page-editor-preview-container {
@@ -95,14 +95,6 @@
       }
       }
     }
     }
 
 
-    .grw-editor-configuration-dropdown {
-      .icon-container {
-        width: 20px;
-      }
-      .menuitem-label {
-        min-width: 130px;
-      }
-    }
   }
   }
 
 
   // .builtin-editor .tab-pane#edit
   // .builtin-editor .tab-pane#edit

+ 95 - 92
apps/app/src/styles/atoms/_custom_control.scss

@@ -2,95 +2,98 @@
 
 
 // TODO: activate (https://redmine.weseek.co.jp/issues/128307)
 // TODO: activate (https://redmine.weseek.co.jp/issues/128307)
 
 
-// .form-check .form-check-label::before {
-//   border-radius: $border-radius !important;
-// }
-
-// label.form-check-label {
-//   font-weight: normal;
-// }
-
-// .form-switch.form-switch-sm {
-//   $form-check-indicator-size-sm: $form-check-indicator-size * 0.8;
-//   $form-switch-width-sm: $form-check-indicator-size-sm * 1.75;
-//   $form-check-gutter-sm: $form-check-gutter * 0.8;
-//   $form-check-indicator-size-sm: $form-check-indicator-size * 0.8;
-//   $form-switch-indicator-size-sm: subtract($form-check-indicator-size-sm, $form-check-indicator-border-width * 4);
-
-//   padding-left: $form-switch-width-sm + $form-check-gutter-sm;
-
-//   .form-check-label {
-//     &::before {
-//       left: -($form-switch-width-sm + $form-check-gutter-sm);
-//       width: $form-switch-width-sm;
-//       height: $form-check-indicator-size-sm;
-//     }
-
-//     &::after {
-//       top: add(($font-size-base * $line-height-base - $form-check-indicator-size) / 2, $form-check-indicator-border-width * 2);
-//       left: add(-($form-switch-width-sm + $form-check-gutter-sm), $form-check-indicator-border-width * 2);
-//       width: $form-switch-indicator-size-sm;
-//       height: $form-switch-indicator-size-sm;
-//     }
-//   }
-
-//   .form-check-input:checked ~ .form-check-label {
-//     &::after {
-//       transform: translateX($form-switch-width-sm - $form-check-indicator-size-sm);
-//     }
-//   }
-// }
-
-// //lg
-// .form-switch.form-switch-lg {
-//   $form-check-indicator-size-lg: $form-check-indicator-size * 1.5;
-//   $form-switch-width-lg: $form-check-indicator-size-lg * 1.75;
-//   $form-check-gutter-lg: $form-check-gutter * 1.5;
-//   $form-check-indicator-size-lg: $form-check-indicator-size * 1.5;
-//   $form-switch-indicator-size-lg: subtract($form-check-indicator-size-lg, $form-check-indicator-border-width * 4);
-
-//   padding-left: $form-switch-width-lg + $form-check-gutter-lg;
-
-//   line-height: $form-check-indicator-size-lg;
-//   .form-check-label {
-//     &::before {
-//       top: ($font-size-base * $line-height-base - $form-check-indicator-size-lg) / 2;
-
-//       left: -($form-switch-width-lg + $form-check-gutter-lg);
-//       width: $form-switch-width-lg;
-//       height: $form-check-indicator-size-lg;
-//       border-radius: $form-check-indicator-size-lg/2;
-//     }
-
-//     &::after {
-//       top: add(($font-size-base * $line-height-base - $form-check-indicator-size-lg) / 2, $form-check-indicator-border-width * 2);
-//       left: add(-($form-switch-width-lg + $form-check-gutter-lg), $form-check-indicator-border-width * 2);
-//       width: $form-switch-indicator-size-lg;
-//       height: $form-switch-indicator-size-lg;
-//       border-radius: $form-check-indicator-size-lg/2;
-//     }
-//   }
-
-//   .form-check-input:checked ~ .form-check-label {
-//     &::after {
-//       transform: translateX($form-switch-width-lg - $form-check-indicator-size-lg);
-//     }
-//   }
-// }
-
-// .form-switch.form-switch-slack {
-//   .form-check-label {
-//     &::before {
-//       background-color: $gray-200;
-//       border-color: transparent;
-//     }
-//     &::after {
-//       background-size: 15px;
-//     }
-//   }
-//   .input-group-addon {
-//     input {
-//       vertical-align: middle;
-//     }
-//   }
-// }
+$form-check-gutter: .5rem;
+$form-check-indicator-border-width: 1px;
+
+.form-check .form-check-label::before {
+  border-radius: $border-radius !important;
+}
+
+label.form-check-label {
+  font-weight: normal;
+}
+
+.form-switch.form-switch-sm {
+  $form-check-indicator-size-sm: $form-check-indicator-size * 0.8;
+  $form-switch-width-sm: $form-check-indicator-size-sm * 1.75;
+  $form-check-gutter-sm: $form-check-gutter * 0.8;
+  $form-check-indicator-size-sm: $form-check-indicator-size * 0.8;
+  $form-switch-indicator-size-sm: subtract($form-check-indicator-size-sm, $form-check-indicator-border-width * 4);
+
+  padding-left: $form-switch-width-sm + $form-check-gutter-sm;
+
+  .form-check-label {
+    &::before {
+      left: -($form-switch-width-sm + $form-check-gutter-sm);
+      width: $form-switch-width-sm;
+      height: $form-check-indicator-size-sm;
+    }
+
+    &::after {
+      top: add(($font-size-base * $line-height-base - $form-check-indicator-size) / 2, $form-check-indicator-border-width * 2);
+      left: add(-($form-switch-width-sm + $form-check-gutter-sm), $form-check-indicator-border-width * 2);
+      width: $form-switch-indicator-size-sm;
+      height: $form-switch-indicator-size-sm;
+    }
+  }
+
+  .form-check-input:checked ~ .form-check-label {
+    &::after {
+      transform: translateX($form-switch-width-sm - $form-check-indicator-size-sm);
+    }
+  }
+}
+
+//lg
+.form-switch.form-switch-lg {
+  $form-check-indicator-size-lg: $form-check-indicator-size * 1.5;
+  $form-switch-width-lg: $form-check-indicator-size-lg * 1.75;
+  $form-check-gutter-lg: $form-check-gutter * 1.5;
+  $form-check-indicator-size-lg: $form-check-indicator-size * 1.5;
+  $form-switch-indicator-size-lg: subtract($form-check-indicator-size-lg, $form-check-indicator-border-width * 4);
+
+  padding-left: $form-switch-width-lg + $form-check-gutter-lg;
+
+  line-height: $form-check-indicator-size-lg;
+  .form-check-label {
+    &::before {
+      top: ($font-size-base * $line-height-base - $form-check-indicator-size-lg) / 2;
+
+      left: -($form-switch-width-lg + $form-check-gutter-lg);
+      width: $form-switch-width-lg;
+      height: $form-check-indicator-size-lg;
+      border-radius: $form-check-indicator-size-lg/2;
+    }
+
+    &::after {
+      top: add(($font-size-base * $line-height-base - $form-check-indicator-size-lg) / 2, $form-check-indicator-border-width * 2);
+      left: add(-($form-switch-width-lg + $form-check-gutter-lg), $form-check-indicator-border-width * 2);
+      width: $form-switch-indicator-size-lg;
+      height: $form-switch-indicator-size-lg;
+      border-radius: $form-check-indicator-size-lg/2;
+    }
+  }
+
+  .form-check-input:checked ~ .form-check-label {
+    &::after {
+      transform: translateX($form-switch-width-lg - $form-check-indicator-size-lg);
+    }
+  }
+}
+
+.form-switch.form-switch-slack {
+  .form-check-label {
+    &::before {
+      background-color: $gray-200;
+      border-color: transparent;
+    }
+    &::after {
+      background-size: 15px;
+    }
+  }
+  .input-group-addon {
+    input {
+      vertical-align: middle;
+    }
+  }
+}

+ 1 - 1
apps/app/src/styles/style-app.scss

@@ -1,4 +1,5 @@
 @import '@growi/core/scss/flex-expand';
 @import '@growi/core/scss/flex-expand';
+@import '@growi/core/scss/rotate';
 
 
 @import 'mixins';
 @import 'mixins';
 
 
@@ -19,7 +20,6 @@
 @import 'organisms/wiki';
 @import 'organisms/wiki';
 
 
 // // growi component
 // // growi component
-// @import 'draft';
 @import 'editor';
 @import 'editor';
 @import 'fonts';
 @import 'fonts';
 @import 'layout';
 @import 'layout';

+ 16 - 0
packages/core/scss/_rotate.scss

@@ -0,0 +1,16 @@
+// refs: https://fastbootstrap.com/docs/rotate/
+$rotate-degrees: [0, 1, 2, 3, 6, 12, 45, 90, 180];
+
+.rotate-none {
+  transform: none;
+}
+
+@each $degree in $rotate-degrees {
+  .rotate-#{$degree} {
+    transform: rotate(#{$degree}deg);
+  }
+  // negative rotation
+  .-rotate-#{$degree} {
+    transform: rotate(-#{$degree}deg);
+  }
+}

+ 1 - 1
packages/core/scss/bootstrap/_variables.scss

@@ -164,4 +164,4 @@ $pre-color: dummyinvalildcolor; // disable pre color specification with invalid
 //== Custom Checkbox
 //== Custom Checkbox
 // $form-check-indicator-border-radius: 0px;
 // $form-check-indicator-border-radius: 0px;
 // $form-check-indicator-focus-box-shadow: none;
 // $form-check-indicator-focus-box-shadow: none;
-// $form-check-indicator-size: 1.2rem;
+$form-check-indicator-size: 1.2rem;

+ 2 - 0
packages/editor/src/@types/y-codemirror.next.d.ts

@@ -0,0 +1,2 @@
+// https://github.com/yjs/y-codemirror.next/issues/27
+declare module 'y-codemirror.next';

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

@@ -7,6 +7,10 @@
     width: 100%;
     width: 100%;
     height: 100%;
     height: 100%;
 
 
+    .cm-content {
+      font-family: var(--grw-codemirror-editor-content-font-family, monospace);
+    }
+
     // Header highlight style
     // Header highlight style
     .cm-header {
     .cm-header {
       text-decoration: none;
       text-decoration: none;

+ 10 - 6
packages/editor/src/stores/use-collaborative-editor-mode.ts

@@ -1,11 +1,9 @@
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
 
 
+import { keymap } from '@codemirror/view';
 import { GlobalSocketEventName, type IUserHasId } from '@growi/core/dist/interfaces';
 import { GlobalSocketEventName, type IUserHasId } from '@growi/core/dist/interfaces';
 import { useGlobalSocket, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
 import { useGlobalSocket, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
-// see: https://github.com/yjs/y-codemirror.next#example
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-ignore
-import { yCollab } from 'y-codemirror.next';
+import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
 import { SocketIOProvider } from 'y-socket.io';
 import { SocketIOProvider } from 'y-socket.io';
 import * as Y from 'yjs';
 import * as Y from 'yjs';
 
 
@@ -118,11 +116,17 @@ export const useCollaborativeEditorMode = (
 
 
     codeMirrorEditor.initDoc(ytext.toString());
     codeMirrorEditor.initDoc(ytext.toString());
 
 
-    const cleanup = codeMirrorEditor.appendExtensions([
+    const cleanupYUndoManagerKeymap = codeMirrorEditor.appendExtensions([
+      keymap.of(yUndoManagerKeymap),
+    ]);
+    const cleanupYCollab = codeMirrorEditor.appendExtensions([
       yCollab(ytext, provider.awareness, { undoManager }),
       yCollab(ytext, provider.awareness, { undoManager }),
     ]);
     ]);
 
 
-    return cleanup;
+    return () => {
+      cleanupYUndoManagerKeymap?.();
+      cleanupYCollab?.();
+    };
   };
   };
 
 
   useEffect(cleanupYDoc, [cPageId, onEditorsUpdated, pageId, provider, socket, ydoc]);
   useEffect(cleanupYDoc, [cPageId, onEditorsUpdated, pageId, provider, socket, ydoc]);

+ 0 - 5
packages/editor/src/stores/use-default-extensions.ts

@@ -9,10 +9,6 @@ import {
 } from '@codemirror/state';
 } from '@codemirror/state';
 import { keymap, EditorView, KeyBinding } from '@codemirror/view';
 import { keymap, EditorView, KeyBinding } from '@codemirror/view';
 import { tags } from '@lezer/highlight';
 import { tags } from '@lezer/highlight';
-// see: https://github.com/yjs/y-codemirror.next#example
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-ignore
-import { yUndoManagerKeymap } from 'y-codemirror.next';
 
 
 import type { UseCodeMirrorEditor } from '../services';
 import type { UseCodeMirrorEditor } from '../services';
 import { emojiAutocompletionSettings } from '../services/extensions/emojiAutocompletionSettings';
 import { emojiAutocompletionSettings } from '../services/extensions/emojiAutocompletionSettings';
@@ -39,7 +35,6 @@ const defaultExtensions: Extension[] = [
   keymap.of(markdownKeymap),
   keymap.of(markdownKeymap),
   keymap.of([indentWithTab]),
   keymap.of([indentWithTab]),
   Prec.lowest(keymap.of(defaultKeymap)),
   Prec.lowest(keymap.of(defaultKeymap)),
-  keymap.of(yUndoManagerKeymap),
   syntaxHighlighting(markdownHighlighting),
   syntaxHighlighting(markdownHighlighting),
   Prec.lowest(syntaxHighlighting(defaultHighlightStyle)),
   Prec.lowest(syntaxHighlighting(defaultHighlightStyle)),
   emojiAutocompletionSettings,
   emojiAutocompletionSettings,