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

Merge remote-tracking branch 'origin/master' into imprv/page-path-nav-title-spacing

Yuki Takei 3 месяцев назад
Родитель
Сommit
8e84035a2a
53 измененных файлов с 2920 добавлено и 1743 удалено
  1. 11 1
      CHANGELOG.md
  2. 4 0
      apps/app/.eslintrc.js
  3. 1 1
      apps/app/docker/README.md
  4. 2 2
      apps/app/package.json
  5. 22 27
      apps/app/src/client/components/Hotkeys/HotkeysDetector.jsx
  6. 5 4
      apps/app/src/client/components/Hotkeys/HotkeysManager.jsx
  7. 0 2
      apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx
  8. 13 12
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx
  9. 4 3
      apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx
  10. 7 5
      apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx
  11. 14 3
      apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.jsx
  12. 0 2
      apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx
  13. 213 123
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  14. 27 30
      apps/app/src/client/components/Navbar/GrowiNavbarBottom.tsx
  15. 32 34
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  16. 23 15
      apps/app/src/client/components/PageEditor/Cheatsheet.tsx
  17. 131 73
      apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx
  18. 4 1
      apps/app/src/client/components/PageEditor/ConflictDiffModal/dynamic.tsx
  19. 24 20
      apps/app/src/client/components/PageEditor/DrawioModal/DrawioCommunicationHelper.ts
  20. 69 50
      apps/app/src/client/components/PageEditor/DrawioModal/DrawioModal.tsx
  21. 1 3
      apps/app/src/client/components/PageEditor/DrawioModal/dynamic.tsx
  22. 18 7
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx
  23. 10 8
      apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  24. 4 2
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx
  25. 16 12
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx
  26. 195 93
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx
  27. 276 194
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx
  28. 212 157
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx
  29. 62 29
      apps/app/src/client/components/PageEditor/GridEditModal.jsx
  30. 32 8
      apps/app/src/client/components/PageEditor/GridEditorUtil.js
  31. 207 95
      apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx
  32. 4 2
      apps/app/src/client/components/PageEditor/HandsontableModal/dynamic.tsx
  33. 174 92
      apps/app/src/client/components/PageEditor/LinkEditModal/LinkEditModal.tsx
  34. 2 2
      apps/app/src/client/components/PageEditor/LinkEditModal/dynamic.tsx
  35. 8 9
      apps/app/src/client/components/PageEditor/MarkdownListUtil.js
  36. 39 24
      apps/app/src/client/components/PageEditor/MarkdownTableDataImportForm.tsx
  37. 232 139
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  38. 55 42
      apps/app/src/client/components/PageEditor/PageEditorReadOnly.tsx
  39. 18 24
      apps/app/src/client/components/PageEditor/Preview.tsx
  40. 93 45
      apps/app/src/client/components/PageEditor/ScrollSyncHelper.tsx
  41. 20 12
      apps/app/src/client/components/PageEditor/SimpleCheatsheet.jsx
  42. 128 67
      apps/app/src/client/components/PageEditor/conflict.tsx
  43. 14 3
      apps/app/src/client/components/PageEditor/markdown-drawio-util-for-editor.ts
  44. 40 31
      apps/app/src/client/components/PageEditor/markdown-table-util-for-editor.ts
  45. 48 41
      apps/app/src/client/components/PageEditor/page-path-rename-utils.ts
  46. 3 5
      apps/app/src/client/components/PageHeader/PageHeader.tsx
  47. 68 52
      apps/app/src/client/components/PageHeader/PagePathHeader.tsx
  48. 5 12
      apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx
  49. 73 35
      apps/app/src/client/components/PageHeader/PageTitleHeader.tsx
  50. 1 1
      apps/slackbot-proxy/package.json
  51. 0 4
      biome.json
  52. 1 1
      package.json
  53. 255 84
      pnpm-lock.yaml

+ 11 - 1
CHANGELOG.md

@@ -1,9 +1,19 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.4.0...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.4.1...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.4.1](https://github.com/growilabs/compare/v7.4.0...v7.4.1) - 2025-12-26
+
+### 🚀 Improvement
+
+* imprv: Show page name and link for affected pages in Activity Log (#10590) @arvid-e
+
+### 🧰 Maintenance
+
+* support: Update terraform settings and the policy for OIDC GitHub (#10653) @yuki-takei
+
 ## [v7.4.0](https://github.com/growilabs/compare/v7.3.9...v7.4.0) - 2025-12-24
 
 ### 💎 Features

+ 4 - 0
apps/app/.eslintrc.js

@@ -41,6 +41,10 @@ module.exports = {
     'src/client/components/*.jsx',
     'src/client/components/*.ts',
     'src/client/components/*.js',
+    'src/client/components/PageEditor/**',
+    'src/client/components/Hotkeys/**',
+    'src/client/components/Navbar/**',
+    'src/client/components/PageHeader/**',
     'src/client/components/Sidebar/**',
     'src/services/**',
     'src/states/**',

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.4.0`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.0/apps/app/docker/Dockerfile)
+* [`7.4.1`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.1/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 

+ 2 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.1-RC.0",
+  "version": "7.4.2-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -173,7 +173,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.32",
+    "next": "^14.2.35",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^1.0.7",

+ 22 - 27
apps/app/src/client/components/Hotkeys/HotkeysDetector.jsx

@@ -1,22 +1,17 @@
-import React, { useMemo, useCallback } from 'react';
-
+import React, { useCallback, useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { GlobalHotKeys } from 'react-hotkeys';
 
 import HotkeyStroke from '~/client/models/HotkeyStroke';
 
 const HotkeysDetector = (props) => {
-
   const { keySet, strokeSet, onDetected } = props;
 
   // memorize HotkeyStroke instances
-  const hotkeyStrokes = useMemo(
-    () => {
-      const strokes = Array.from(strokeSet);
-      return strokes.map(stroke => new HotkeyStroke(stroke));
-    },
-    [strokeSet],
-  );
+  const hotkeyStrokes = useMemo(() => {
+    const strokes = Array.from(strokeSet);
+    return strokes.map((stroke) => new HotkeyStroke(stroke));
+  }, [strokeSet]);
 
   /**
    * return key expression string includes modifier
@@ -43,19 +38,22 @@ const HotkeysDetector = (props) => {
   /**
    * evaluate the key user pressed and trigger onDetected
    */
-  const checkHandler = useCallback((event) => {
-    const eventKey = getKeyExpression(event);
-
-    hotkeyStrokes.forEach((hotkeyStroke) => {
-      // if any stroke is completed
-      if (hotkeyStroke.evaluate(eventKey)) {
-        // cancel the key event
-        event.preventDefault();
-        // invoke detected handler
-        onDetected(hotkeyStroke.stroke);
-      }
-    });
-  }, [hotkeyStrokes, getKeyExpression, onDetected]);
+  const checkHandler = useCallback(
+    (event) => {
+      const eventKey = getKeyExpression(event);
+
+      hotkeyStrokes.forEach((hotkeyStroke) => {
+        // if any stroke is completed
+        if (hotkeyStroke.evaluate(eventKey)) {
+          // cancel the key event
+          event.preventDefault();
+          // invoke detected handler
+          onDetected(hotkeyStroke.stroke);
+        }
+      });
+    },
+    [hotkeyStrokes, getKeyExpression, onDetected],
+  );
 
   // memorize keyMap for GlobalHotKeys
   const keyMap = useMemo(() => {
@@ -67,10 +65,7 @@ const HotkeysDetector = (props) => {
     return { check: checkHandler };
   }, [checkHandler]);
 
-  return (
-    <GlobalHotKeys keyMap={keyMap} handlers={handlers} />
-  );
-
+  return <GlobalHotKeys keyMap={keyMap} handlers={handlers} />;
 };
 
 HotkeysDetector.propTypes = {

+ 5 - 4
apps/app/src/client/components/Hotkeys/HotkeysManager.jsx

@@ -27,7 +27,9 @@ SUPPORTED_COMPONENTS.forEach((comp) => {
 
   strokes.forEach((stroke) => {
     // register key
-    stroke.forEach(key => KEY_SET.add(key));
+    stroke.forEach((key) => {
+      KEY_SET.add(key);
+    });
     // register stroke
     STROKE_SET.add(stroke);
     // register component
@@ -58,7 +60,7 @@ const HotkeysManager = (props) => {
     const key = (Math.random() * 1000).toString();
     const components = STROKE_TO_COMPONENT_MAP[strokeDetermined.toString()];
 
-    const newViews = components.map(Component => (
+    const newViews = components.map((Component) => (
       <Component key={key} onDeleteRender={deleteRender} />
     ));
     setView(view.concat(newViews).flat());
@@ -67,14 +69,13 @@ const HotkeysManager = (props) => {
   return (
     <>
       <HotkeysDetector
-        onDetected={stroke => onDetected(stroke)}
+        onDetected={(stroke) => onDetected(stroke)}
         keySet={KEY_SET}
         strokeSet={STROKE_SET}
       />
       {view}
     </>
   );
-
 };
 
 export default HotkeysManager;

+ 0 - 2
apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,12 +1,10 @@
 import React, { useEffect } from 'react';
-
 import PropTypes from 'prop-types';
 
 import { useCurrentPagePath } from '~/states/page';
 import { usePageCreateModalActions } from '~/states/ui/modal/page-create';
 
 const CreatePage = React.memo((props) => {
-
   const { open: openCreateModal } = usePageCreateModalActions();
   const currentPath = useCurrentPagePath();
 

+ 13 - 12
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx

@@ -1,22 +1,21 @@
 import { useCallback, useEffect, useRef } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { useStartEditing } from '~/client/services/use-start-editing';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentPathname } from '~/states/global';
-import { useIsEditable, useCurrentPagePath } from '~/states/page';
+import { useCurrentPagePath, useIsEditable } from '~/states/page';
 
 type Props = {
-  onDeleteRender: () => void,
-}
+  onDeleteRender: () => void;
+};
 
 /**
  * Custom hook for edit page logic
  */
 const useEditPage = (
-    onCompleted: () => void,
-    onError?: (path: string) => void,
+  onCompleted: () => void,
+  onError?: (path: string) => void,
 ): void => {
   const isEditable = useIsEditable();
   const startEditing = useStartEditing();
@@ -26,7 +25,7 @@ const useEditPage = (
   const isExecutedRef = useRef(false);
 
   useEffect(() => {
-    (async() => {
+    (async () => {
       // Prevent multiple executions
       if (isExecutedRef.current) return;
       isExecutedRef.current = true;
@@ -42,8 +41,7 @@ const useEditPage = (
 
       try {
         await startEditing(path);
-      }
-      catch (err) {
+      } catch (err) {
         onError?.(path);
       }
 
@@ -58,9 +56,12 @@ const useEditPage = (
 const EditPage = (props: Props): null => {
   const { t } = useTranslation('commons');
 
-  const handleError = useCallback((path: string) => {
-    toastError(t('toaster.create_failed', { target: path }));
-  }, [t]);
+  const handleError = useCallback(
+    (path: string) => {
+      toastError(t('toaster.create_failed', { target: path }));
+    },
+    [t],
+  );
 
   useEditPage(props.onDeleteRender, handleError);
 

+ 4 - 3
apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx

@@ -1,9 +1,11 @@
 import { useEffect } from 'react';
 
-import { useSearchModalStatus, useSearchModalActions } from '~/features/search/client/states/modal/search';
+import {
+  useSearchModalActions,
+  useSearchModalStatus,
+} from '~/features/search/client/states/modal/search';
 import { useIsEditable } from '~/states/page';
 
-
 const FocusToGlobalSearch = (props) => {
   const isEditable = useIsEditable();
   const searchModalData = useSearchModalStatus();
@@ -20,7 +22,6 @@ const FocusToGlobalSearch = (props) => {
       // remove this
       props.onDeleteRender();
     }
-
   }, [isEditable, openSearchModal, props, searchModalData.isOpened]);
 
   return null;

+ 7 - 5
apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx

@@ -1,12 +1,14 @@
-import React, { useEffect, type JSX } from 'react';
+import React, { type JSX, useEffect } from 'react';
 
-import { useShortcutsModalStatus, useShortcutsModalActions } from '~/states/ui/modal/shortcuts';
+import {
+  useShortcutsModalActions,
+  useShortcutsModalStatus,
+} from '~/states/ui/modal/shortcuts';
 
 type Props = {
-  onDeleteRender: () => void,
-}
+  onDeleteRender: () => void;
+};
 const ShowShortcutsModal = (props: Props): JSX.Element => {
-
   const status = useShortcutsModalStatus();
   const { open } = useShortcutsModalActions();
 

+ 14 - 3
apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.jsx

@@ -3,9 +3,7 @@ import PropTypes from 'prop-types';
 import StaffCredit from '../../StaffCredit/StaffCredit';
 
 const ShowStaffCredit = (props) => {
-
   return <StaffCredit onClosed={() => props.onDeleteRender(this)} />;
-
 };
 
 ShowStaffCredit.propTypes = {
@@ -13,7 +11,20 @@ ShowStaffCredit.propTypes = {
 };
 
 ShowStaffCredit.getHotkeyStrokes = () => {
-  return [['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']];
+  return [
+    [
+      'ArrowUp',
+      'ArrowUp',
+      'ArrowDown',
+      'ArrowDown',
+      'ArrowLeft',
+      'ArrowRight',
+      'ArrowLeft',
+      'ArrowRight',
+      'b',
+      'a',
+    ],
+  ];
 };
 
 export default ShowStaffCredit;

+ 0 - 2
apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx

@@ -1,9 +1,7 @@
 import React, { useEffect } from 'react';
-
 import PropTypes from 'prop-types';
 
 const SwitchToMirrorMode = (props) => {
-
   // setup effect
   useEffect(() => {
     document.body.classList.add('mirror');

+ 213 - 123
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,32 +1,42 @@
-import React, {
-  useState, useCallback, useMemo, type JSX,
-} from 'react';
-
-
-import { isPopulated } from '@growi/core';
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
+import dynamic from 'next/dynamic';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
 import type {
+  IPageInfoForEntity,
   IPagePopulatedToShowRevision,
-  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
+  IPageToRenameWithMeta,
+  IPageWithMeta,
 } from '@growi/core';
+import { isPopulated } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import Link from 'next/link';
-import { useRouter } from 'next/router';
 import Sticky from 'react-stickynode';
-import { DropdownItem, UncontrolledTooltip, Tooltip } from 'reactstrap';
+import { DropdownItem, Tooltip, UncontrolledTooltip } from 'reactstrap';
 
-import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
+import {
+  exportAsMarkdown,
+  syncLatestRevisionBody,
+  updateContentWidth,
+} from '~/client/services/page-operation';
 import { usePrintMode } from '~/client/services/use-print-mode';
-import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
+import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { usePageBulkExportSelectModalActions } from '~/features/page-bulk-export/client/states/modal';
-import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import type {
+  OnDeletedFunction,
+  OnDuplicatedFunction,
+  OnRenamedFunction,
+} from '~/interfaces/ui';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
 import { useCurrentPathname, useCurrentUser } from '~/states/global';
 import { useCurrentPageId, useFetchCurrentPage } from '~/states/page';
 import { useShareLinkId } from '~/states/page/hooks';
@@ -38,18 +48,22 @@ import {
 } from '~/states/server-configurations';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useEditorMode } from '~/states/ui/editor';
-import { PageAccessoriesModalContents, usePageAccessoriesModalActions } from '~/states/ui/modal/page-accessories';
+import {
+  PageAccessoriesModalContents,
+  usePageAccessoriesModalActions,
+} from '~/states/ui/modal/page-accessories';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
-import { usePageDuplicateModalActions, type IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
+import {
+  type IPageForPageDuplicateModal,
+  usePageDuplicateModalActions,
+} from '~/states/ui/modal/page-duplicate';
 import { usePresentationModalActions } from '~/states/ui/modal/page-presentation';
 import { usePageRenameModalActions } from '~/states/ui/modal/page-rename';
 import {
-  useIsAbleToShowPageManagement,
   useIsAbleToChangeEditorMode,
+  useIsAbleToShowPageManagement,
 } from '~/states/ui/page-abilities';
-import {
-  useSWRxPageInfo,
-} from '~/stores/page';
+import { useSWRxPageInfo } from '~/stores/page';
 import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
 
 import { CreateTemplateModalLazyLoaded } from '../CreateTemplateModal';
@@ -63,27 +77,34 @@ const moduleClass = styles['grw-contextual-sub-navigation'];
 const minHeightSubNavigation = styles['grw-min-height-sub-navigation'];
 
 const PageEditorModeManager = dynamic(
-  () => import('./PageEditorModeManager').then(mod => mod.PageEditorModeManager),
-  { ssr: false, loading: () => <Skeleton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skeleton']}`} /> },
+  () =>
+    import('./PageEditorModeManager').then((mod) => mod.PageEditorModeManager),
+  {
+    ssr: false,
+    loading: () => (
+      <Skeleton
+        additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skeleton']}`}
+      />
+    ),
+  },
 );
 const PageControls = dynamic(
-  () => import('../PageControls').then(mod => mod.PageControls),
+  () => import('../PageControls').then((mod) => mod.PageControls),
   { ssr: false, loading: () => <></> },
 );
 
-
 type PageOperationMenuItemsProps = {
-  pageId: string,
-  revisionId: string,
-  isLinkSharingDisabled?: boolean,
-}
+  pageId: string;
+  revisionId: string;
+  isLinkSharingDisabled?: boolean;
+};
 
-const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element => {
+const PageOperationMenuItems = (
+  props: PageOperationMenuItemsProps,
+): JSX.Element => {
   const { t } = useTranslation();
 
-  const {
-    pageId, revisionId, isLinkSharingDisabled,
-  } = props;
+  const { pageId, revisionId, isLinkSharingDisabled } = props;
 
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
@@ -93,9 +114,12 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
 
   const { open: openPresentationModal } = usePresentationModalActions();
   const { open: openAccessoriesModal } = usePageAccessoriesModalActions();
-  const { open: openPageBulkExportSelectModal } = usePageBulkExportSelectModalActions();
+  const { open: openPageBulkExportSelectModal } =
+    usePageBulkExportSelectModalActions();
 
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
+    GlobalCodeMirrorEditorKey.MAIN,
+  );
 
   const [isBulkExportTooltipOpen, setIsBulkExportTooltipOpen] = useState(false);
 
@@ -119,8 +143,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         }
 
         toastSuccess(t('sync-latest-revision-body.success-toaster'));
-      }
-      catch {
+      } catch {
         toastError(t('sync-latest-revision-body.error-toaster'));
       }
     }
@@ -132,7 +155,9 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         onClick={() => syncLatestRevisionBodyHandler()}
         className="grw-page-control-dropdown-item"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">sync</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          sync
+        </span>
         {t('sync-latest-revision-body.menuitem')}
       </DropdownItem>
 
@@ -142,7 +167,9 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         data-testid="open-presentation-modal-btn"
         className="grw-page-control-dropdown-item"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">jamboard_kiosk</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          jamboard_kiosk
+        </span>
         {t('Presentation Mode')}
       </DropdownItem>
 
@@ -151,7 +178,9 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
         className="grw-page-control-dropdown-item"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          cloud_download
+        </span>
         {t('page_export.export_page_markdown')}
       </DropdownItem>
 
@@ -164,7 +193,9 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
               className="grw-page-control-dropdown-item"
               disabled={!isUploadEnabled ?? true}
             >
-              <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
+              <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                cloud_download
+              </span>
               {t('page_export.bulk_export')}
             </DropdownItem>
           </span>
@@ -187,32 +218,47 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         refs: PageAccessoriesModalControl
       */}
       <DropdownItem
-        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
+        onClick={() =>
+          openAccessoriesModal(PageAccessoriesModalContents.PageHistory)
+        }
         disabled={!!isGuestUser || !!isSharedUser}
         data-testid="open-page-accessories-modal-btn-with-history-tab"
         className="grw-page-control-dropdown-item"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">history</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          history
+        </span>
         {t('History')}
       </DropdownItem>
 
       <DropdownItem
-        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
+        onClick={() =>
+          openAccessoriesModal(PageAccessoriesModalContents.Attachment)
+        }
         data-testid="open-page-accessories-modal-btn-with-attachment-data-tab"
         className="grw-page-control-dropdown-item"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">attachment</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          attachment
+        </span>
         {t('attachment_data')}
       </DropdownItem>
 
       {!isGuestUser && !isReadOnlyUser && !isSharedUser && (
-        <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
+        <NotAvailable
+          isDisabled={isLinkSharingDisabled ?? false}
+          title="Disabled by admin"
+        >
           <DropdownItem
-            onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
+            onClick={() =>
+              openAccessoriesModal(PageAccessoriesModalContents.ShareLink)
+            }
             data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
             className="grw-page-control-dropdown-item"
           >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">share</span>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+              share
+            </span>
             {t('share_links.share_link_management')}
           </DropdownItem>
         </NotAvailable>
@@ -222,10 +268,12 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
 };
 
 type CreateTemplateMenuItemsProps = {
-  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
-}
+  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void;
+};
 
-const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Element => {
+const CreateTemplateMenuItems = (
+  props: CreateTemplateMenuItemsProps,
+): JSX.Element => {
   const { t } = useTranslation();
 
   const { onClickTemplateMenuItem } = props;
@@ -242,7 +290,9 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
         className="grw-page-control-dropdown-item"
         data-testid="open-page-template-modal-btn"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">contract_edit</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          contract_edit
+        </span>
         {t('template.option_label.create/edit')}
       </DropdownItem>
     </>
@@ -250,11 +300,12 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
 };
 
 type GrowiContextualSubNavigationProps = {
-  currentPage?: IPagePopulatedToShowRevision | null,
+  currentPage?: IPagePopulatedToShowRevision | null;
 };
 
-const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
-
+const GrowiContextualSubNavigation = (
+  props: GrowiContextualSubNavigationProps,
+): JSX.Element => {
   const { currentPage } = props;
 
   const { t } = useTranslation();
@@ -269,14 +320,17 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
 
   const revision = currentPage?.revision;
-  const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
+  const revisionId =
+    revision != null && isPopulated(revision) ? revision._id : undefined;
 
   const { editorMode } = useEditorMode();
   const pageId = useCurrentPageId(true);
   const currentUser = useCurrentUser();
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
-  const isLocalAccountRegistrationEnabled = useAtomValue(isLocalAccountRegistrationEnabledAtom);
+  const isLocalAccountRegistrationEnabled = useAtomValue(
+    isLocalAccountRegistrationEnabledAtom,
+  );
   const isLinkSharingDisabled = useAtomValue(disableLinkSharingAtom);
   const isSharedUser = useIsSharedUser();
 
@@ -295,67 +349,87 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const path = currentPage?.path ?? currentPathname;
 
-  const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
-
-  const duplicateItemClickedHandler = useCallback(async (page: IPageForPageDuplicateModal) => {
-    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
-      router.push(toPath);
-    };
-    openDuplicateModal(page, { onDuplicated: duplicatedHandler });
-  }, [openDuplicateModal, router]);
-
-  const renameItemClickedHandler = useCallback(async (page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
-    const renamedHandler: OnRenamedFunction = () => {
-      fetchCurrentPage({ force: true });
-      mutatePageInfo();
-      mutatePageTree();
-      mutateRecentlyUpdated();
-    };
-    openRenameModal(page, { onRenamed: renamedHandler });
-  }, [fetchCurrentPage, mutatePageInfo, openRenameModal]);
-
-  const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
-    const deletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') {
-        return;
-      }
+  const [isPageTemplateModalShown, setIsPageTempleteModalShown] =
+    useState(false);
+
+  const duplicateItemClickedHandler = useCallback(
+    async (page: IPageForPageDuplicateModal) => {
+      const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+        router.push(toPath);
+      };
+      openDuplicateModal(page, { onDuplicated: duplicatedHandler });
+    },
+    [openDuplicateModal, router],
+  );
 
-      const path = pathOrPathsToDelete;
+  const renameItemClickedHandler = useCallback(
+    async (page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
+      const renamedHandler: OnRenamedFunction = () => {
+        fetchCurrentPage({ force: true });
+        mutatePageInfo();
+        mutatePageTree();
+        mutateRecentlyUpdated();
+      };
+      openRenameModal(page, { onRenamed: renamedHandler });
+    },
+    [fetchCurrentPage, mutatePageInfo, openRenameModal],
+  );
 
-      if (isCompletely) {
-        // redirect to NotFound Page
-        router.push(path);
-      }
-      else if (currentPathname != null) {
-        router.push(currentPathname);
-      }
+  const deleteItemClickedHandler = useCallback(
+    (pageWithMeta: IPageWithMeta) => {
+      const deletedHandler: OnDeletedFunction = (
+        pathOrPathsToDelete,
+        isRecursively,
+        isCompletely,
+      ) => {
+        if (typeof pathOrPathsToDelete !== 'string') {
+          return;
+        }
 
-      fetchCurrentPage({ force: true });
-      mutatePageInfo();
-      mutatePageTree();
-      mutateRecentlyUpdated();
-    };
-    openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [currentPathname, fetchCurrentPage, openDeleteModal, router, mutatePageInfo]);
-
-  const switchContentWidthHandler = useCallback(async (pageId: string, value: boolean) => {
-    if (!isSharedPage) {
-      await updateContentWidth(pageId, value);
-      fetchCurrentPage({ force: true });
-    }
-  }, [isSharedPage, fetchCurrentPage]);
+        const path = pathOrPathsToDelete;
+
+        if (isCompletely) {
+          // redirect to NotFound Page
+          router.push(path);
+        } else if (currentPathname != null) {
+          router.push(currentPathname);
+        }
+
+        fetchCurrentPage({ force: true });
+        mutatePageInfo();
+        mutatePageTree();
+        mutateRecentlyUpdated();
+      };
+      openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
+    },
+    [
+      currentPathname,
+      fetchCurrentPage,
+      openDeleteModal,
+      router,
+      mutatePageInfo,
+    ],
+  );
+
+  const switchContentWidthHandler = useCallback(
+    async (pageId: string, value: boolean) => {
+      if (!isSharedPage) {
+        await updateContentWidth(pageId, value);
+        fetchCurrentPage({ force: true });
+      }
+    },
+    [isSharedPage, fetchCurrentPage],
+  );
 
   const additionalMenuItemsRenderer = useCallback(() => {
     if (revisionId == null || pageId == null) {
       return (
         <>
-          {!isReadOnlyUser
-            && (
-              <CreateTemplateMenuItems
-                onClickTemplateMenuItem={() => setIsPageTempleteModalShown(true)}
-              />
-            )
-          }
+          {!isReadOnlyUser && (
+            <CreateTemplateMenuItems
+              onClickTemplateMenuItem={() => setIsPageTempleteModalShown(true)}
+            />
+          )}
         </>
       );
     }
@@ -373,8 +447,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               onClickTemplateMenuItem={() => setIsPageTempleteModalShown(true)}
             />
           </>
-        )
-        }
+        )}
       </>
     );
   }, [isLinkSharingDisabled, pageId, revisionId, isReadOnlyUser]);
@@ -390,13 +463,17 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       <GroundGlassBar className="py-4 d-block d-md-none d-print-none border-bottom" />
 
       {/* for Sub Navigation */}
-      <GroundGlassBar className={`position-fixed z-1 d-edit-none d-print-none w-100 end-0 ${minHeightSubNavigation}`} />
+      <GroundGlassBar
+        className={`position-fixed z-1 d-edit-none d-print-none w-100 end-0 ${minHeightSubNavigation}`}
+      />
 
       <Sticky
         className="z-3"
         enabled={!isPrinting}
-        onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}
-        innerActiveClass="end-0"
+        onStateChange={(status) =>
+          setStickyActive(status.status === Sticky.STATUS_FIXED)
+        }
+        innerActiveClass="w-100 end-0"
       >
         <nav
           className={`${moduleClass} ${minHeightSubNavigation}
@@ -405,7 +482,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
           data-testid="grw-contextual-sub-nav"
           id="grw-contextual-sub-nav"
         >
-
           <PageControls
             pageId={pageId}
             revisionId={revisionId}
@@ -435,12 +511,23 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               <span>
                 <span className="d-inline-block" id="sign-up-link">
                   <Link
-                    href={!isLocalAccountRegistrationEnabled ? '#' : '/login#register'}
+                    href={
+                      !isLocalAccountRegistrationEnabled
+                        ? '#'
+                        : '/login#register'
+                    }
                     className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
-                    style={{ pointerEvents: !isLocalAccountRegistrationEnabled ? 'none' : undefined }}
+                    style={{
+                      pointerEvents: !isLocalAccountRegistrationEnabled
+                        ? 'none'
+                        : undefined,
+                    }}
                     prefetch={false}
                   >
-                    <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
+                    <span className="material-symbols-outlined me-1">
+                      person_add
+                    </span>
+                    {t('Sign up')}
                   </Link>
                 </span>
                 {!isLocalAccountRegistrationEnabled && (
@@ -449,8 +536,13 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                   </UncontrolledTooltip>
                 )}
               </span>
-              <Link href="/login#login" className="btn btn-primary" prefetch={false}>
-                <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
+              <Link
+                href="/login#login"
+                className="btn btn-primary"
+                prefetch={false}
+              >
+                <span className="material-symbols-outlined me-1">login</span>
+                {t('Sign in')}
               </Link>
             </div>
           )}
@@ -466,8 +558,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       )}
     </>
   );
-
 };
 
-
 export default GrowiContextualSubNavigation;

+ 27 - 30
apps/app/src/client/components/Navbar/GrowiNavbarBottom.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { type JSX, useCallback } from 'react';
 
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { useSearchModalActions } from '~/features/search/client/states/modal/search';
@@ -9,9 +9,7 @@ import { useDrawerOpened } from '~/states/ui/sidebar';
 
 import styles from './GrowiNavbarBottom.module.scss';
 
-
 export const GrowiNavbarBottom = (): JSX.Element => {
-
   const [isDrawerOpened, setIsDrawerOpened] = useDrawerOpened();
   const { open: openCreateModal } = usePageCreateModalActions();
   const currentPagePath = useCurrentPagePath();
@@ -23,61 +21,60 @@ export const GrowiNavbarBottom = (): JSX.Element => {
   }, [openSearchModal]);
 
   return (
-    <GroundGlassBar className={`
+    <GroundGlassBar
+      className={`
       ${styles['grw-navbar-bottom']}
       ${isDrawerOpened ? styles['grw-navbar-bottom-drawer-opened'] : ''}
       d-md-none d-edit-none d-print-none fixed-bottom`}
     >
       <div className="navbar navbar-expand px-4 px-sm-5">
-
         <ul className="navbar-nav flex-grow-1 d-flex align-items-center justify-content-between">
           <li className="nav-item">
-            <a
-              role="button"
+            <button
+              type="button"
               className="nav-link btn-lg"
               onClick={() => setIsDrawerOpened(true)}
             >
               <span className="material-symbols-outlined fs-2">reorder</span>
-            </a>
+            </button>
           </li>
 
           <li className="nav-item">
-            <a
-              role="button"
+            <button
+              type="button"
               className="nav-link btn-lg"
               onClick={() => openCreateModal(currentPagePath || '')}
             >
               <span className="material-symbols-outlined fs-2">edit</span>
-            </a>
+            </button>
           </li>
 
-          {
-            !isSearchPage && (
-              <li className="nav-item">
-                <a
-                  role="button"
-                  className="nav-link btn-lg"
-                  onClick={searchButtonClickHandler}
-                >
-                  <span className="material-symbols-outlined fs-2">search</span>
-                </a>
-              </li>
-            )
-          }
+          {!isSearchPage && (
+            <li className="nav-item">
+              <button
+                type="button"
+                className="nav-link btn-lg"
+                onClick={searchButtonClickHandler}
+              >
+                <span className="material-symbols-outlined fs-2">search</span>
+              </button>
+            </li>
+          )}
 
           <li className="nav-item">
-            <a
-              role="button"
+            <button
+              type="button"
               className="nav-link btn-lg"
               onClick={() => {}}
+              aria-label="Notifications"
             >
-              <span className="material-symbols-outlined fs-2">notifications</span>
-            </a>
+              <span className="material-symbols-outlined fs-2">
+                notifications
+              </span>
+            </button>
           </li>
-
         </ul>
       </div>
-
     </GroundGlassBar>
   );
 };

+ 32 - 34
apps/app/src/client/components/Navbar/PageEditorModeManager.tsx

@@ -1,7 +1,4 @@
-import React, {
-  type ReactNode, useCallback, useMemo, type JSX,
-} from 'react';
-
+import React, { type JSX, type ReactNode, useCallback, useMemo } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { useCreatePage } from '~/client/services/create-page';
@@ -9,24 +6,24 @@ import { useStartEditing } from '~/client/services/use-start-editing';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
-import { useEditorMode, EditorMode } from '~/states/ui/editor';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
 
 import styles from './PageEditorModeManager.module.scss';
 
-
 type PageEditorModeButtonProps = {
-  currentEditorMode: EditorMode,
-  editorMode: EditorMode,
-  children?: ReactNode,
-  isBtnDisabled?: boolean,
-  onClick?: () => void,
-}
+  currentEditorMode: EditorMode;
+  editorMode: EditorMode;
+  children?: ReactNode;
+  isBtnDisabled?: boolean;
+  onClick?: () => void;
+};
 const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
-  const {
-    currentEditorMode, isBtnDisabled, editorMode, children, onClick,
-  } = props;
+  const { currentEditorMode, isBtnDisabled, editorMode, children, onClick } =
+    props;
 
-  const classNames = ['btn py-1 px-2 d-flex align-items-center justify-content-center'];
+  const classNames = [
+    'btn py-1 px-2 d-flex align-items-center justify-content-center',
+  ];
   if (currentEditorMode === editorMode) {
     classNames.push('active');
   }
@@ -47,17 +44,13 @@ const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
 });
 
 type Props = {
-  editorMode: EditorMode | undefined,
-  isBtnDisabled: boolean,
-  path?: string,
-}
+  editorMode: EditorMode | undefined;
+  isBtnDisabled: boolean;
+  path?: string;
+};
 
 export const PageEditorModeManager = (props: Props): JSX.Element => {
-  const {
-    editorMode = EditorMode.View,
-    isBtnDisabled,
-    path,
-  } = props;
+  const { editorMode = EditorMode.View, isBtnDisabled, path } = props;
 
   const { t } = useTranslation('commons');
 
@@ -71,8 +64,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const editButtonClickedHandler = useCallback(async () => {
     try {
       await startEditing(path);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
     }
   }, [startEditing, path, t]);
@@ -91,9 +83,8 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   return (
     <>
-      <div
+      <fieldset
         className={`btn-group grw-page-editor-mode-manager ${styles['grw-page-editor-mode-manager']}`}
-        role="group"
         aria-label="page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
         data-testid="grw-page-editor-mode-manager"
@@ -105,7 +96,8 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             isBtnDisabled={_isBtnDisabled}
             onClick={() => setEditorMode(EditorMode.View)}
           >
-            <span className="material-symbols-outlined fs-4">play_arrow</span>{t('View')}
+            <span className="material-symbols-outlined fs-4">play_arrow</span>
+            {t('View')}
           </PageEditorModeButton>
         )}
         {(isDeviceLargerThanMd || editorMode === EditorMode.View) && (
@@ -115,12 +107,18 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             isBtnDisabled={_isBtnDisabled}
             onClick={editButtonClickedHandler}
           >
-            <span className="material-symbols-outlined me-1 fs-5">edit_square</span>{t('Edit')}
-            {circleColor != null && <span className={`position-absolute top-0 start-100 translate-middle p-1 rounded-circle ${circleColor}`} />}
+            <span className="material-symbols-outlined me-1 fs-5">
+              edit_square
+            </span>
+            {t('Edit')}
+            {circleColor != null && (
+              <span
+                className={`position-absolute top-0 start-100 translate-middle p-1 rounded-circle ${circleColor}`}
+              />
+            )}
           </PageEditorModeButton>
         )}
-      </div>
+      </fieldset>
     </>
   );
-
 };

+ 23 - 15
apps/app/src/client/components/PageEditor/Cheatsheet.tsx

@@ -1,7 +1,6 @@
 /* eslint-disable max-len */
 
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
@@ -10,8 +9,8 @@ export const Cheatsheet = (): JSX.Element => {
   const { t } = useTranslation();
 
   /*
-  * Each Element
-  */
+   * Each Element
+   */
   // Left Side
   const codeStr = `# ${t('sandbox.header_x', { index: '1' })}\n## ${t('sandbox.header_x', { index: '2' })}\n### ${t('sandbox.header_x', { index: '3' })}`;
   const codeBlockStr = 'text\n\ntext';
@@ -28,10 +27,10 @@ export const Cheatsheet = (): JSX.Element => {
   const taskStr = `- [ ] ${t('sandbox.task')}(${t('sandbox.task_unchecked')})\n- [x] ${t('sandbox.task')}(${t('sandbox.task_checked')})`;
   const quoteStr = `> ${t('sandbox.quote1')}\n> ${t('sandbox.quote2')}`;
   const nestedQuoteStr = `>> ${t('sandbox.quote_nested')}\n>>> ${t('sandbox.quote_nested')}\n>>>> ${t('sandbox.quote_nested')}`;
-  const tableStr = '|Left       |    Mid    |      Right|\n|:----------|:---------:|----------:|\n|col 1      |   col 2   |      col 3|\n|col 1      |   col 2   |      col 3|';
+  const tableStr =
+    '|Left       |    Mid    |      Right|\n|:----------|:---------:|----------:|\n|col 1      |   col 2   |      col 3|\n|col 1      |   col 2   |      col 3|';
   const imageStr = '![ex](https://example.com/image.png)';
 
-
   const renderCheetSheetElm = (CheetSheetElm: string) => {
     return (
       <PrismAsyncLight
@@ -45,26 +44,28 @@ export const Cheatsheet = (): JSX.Element => {
     );
   };
 
-
   return (
     <div className="row small">
       <div className="col-sm-6">
-
         {/* Header */}
         <h4>{t('sandbox.header')}</h4>
         {renderCheetSheetElm(codeStr)}
 
         {/* Block */}
         <h4>{t('sandbox.block')}</h4>
-        <p className="mb-1"><code>[{t('sandbox.empty_line')}]</code>{t('sandbox.block_detail')}</p>
+        <p className="mb-1">
+          <code>[{t('sandbox.empty_line')}]</code>
+          {t('sandbox.block_detail')}
+        </p>
         {renderCheetSheetElm(codeBlockStr)}
 
         {/* Line Break */}
         <h4>{t('sandbox.line_break')}</h4>
-        <p className="mb-1"><code>[ ][ ]</code> {t('sandbox.line_break_detail')}</p>
+        <p className="mb-1">
+          <code>[ ][ ]</code> {t('sandbox.line_break_detail')}
+        </p>
         {renderCheetSheetElm(lineBlockStr)}
 
-
         {/* Typography */}
         <h4>{t('sandbox.typography')}</h4>
         {renderCheetSheetElm(typographyStr)}
@@ -93,22 +94,29 @@ export const Cheatsheet = (): JSX.Element => {
 
         {renderCheetSheetElm(nestedQuoteStr)}
 
-
         {/* Table */}
         <h4>{t('sandbox.table')}</h4>
         {renderCheetSheetElm(tableStr)}
 
         {/* Image */}
         <h4>{t('sandbox.image')}</h4>
-        <p className="mb-1"><code> ![{t('sandbox.alt_text')}](URL)</code> {t('sandbox.insert_image')}</p>
+        <p className="mb-1">
+          <code> ![{t('sandbox.alt_text')}](URL)</code>{' '}
+          {t('sandbox.insert_image')}
+        </p>
         {renderCheetSheetElm(imageStr)}
 
         <hr />
-        <a href="/Sandbox" className="btn btn-info" target="_blank">
-          <span className="growi-custom-icons">external_link</span> {t('sandbox.open_sandbox')}
+        <a
+          href="/Sandbox"
+          className="btn btn-info"
+          target="_blank"
+          rel="noopener"
+        >
+          <span className="growi-custom-icons">external_link</span>{' '}
+          {t('sandbox.open_sandbox')}
         </a>
       </div>
     </div>
   );
-
 };

+ 131 - 73
apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx

@@ -1,7 +1,5 @@
-import React, {
-  useState, useEffect, useCallback, useMemo,
-} from 'react';
-
+import type React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import type { IUser } from '@growi/core';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { CodeMirrorEditorDiff } from '@growi/editor/dist/client/components/diff/CodeMirrorEditorDiff';
@@ -10,10 +8,7 @@ import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/co
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { useCurrentUser } from '~/states/global';
 import {
@@ -22,58 +17,73 @@ import {
   useRemoteRevisionLastUpdatedAt,
   useRemoteRevisionLastUpdateUser,
 } from '~/states/page';
-import { useConflictDiffModalActions, useConflictDiffModalStatus } from '~/states/ui/modal/conflict-diff';
+import {
+  useConflictDiffModalActions,
+  useConflictDiffModalStatus,
+} from '~/states/ui/modal/conflict-diff';
 
 import styles from './ConflictDiffModal.module.scss';
 
 type IRevisionOnConflict = {
-  revisionBody: string
-  createdAt: Date
-  user: IUser
-}
+  revisionBody: string;
+  createdAt: Date;
+  user: IUser;
+};
 
 /**
  * ConflictDiffModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  */
 type ConflictDiffModalSubstanceProps = {
-  request: IRevisionOnConflict
-  latest: IRevisionOnConflict
-  isModalExpanded: boolean
-  setIsModalExpanded: React.Dispatch<React.SetStateAction<boolean>>
+  request: IRevisionOnConflict;
+  latest: IRevisionOnConflict;
+  isModalExpanded: boolean;
+  setIsModalExpanded: React.Dispatch<React.SetStateAction<boolean>>;
 };
 
 const formatedDate = (date: Date): string => {
   return format(date, 'yyyy/MM/dd HH:mm:ss');
 };
 
-const ConflictDiffModalSubstance = (props: ConflictDiffModalSubstanceProps): React.JSX.Element => {
-  const {
-    request, latest, isModalExpanded, setIsModalExpanded,
-  } = props;
+const ConflictDiffModalSubstance = (
+  props: ConflictDiffModalSubstanceProps,
+): React.JSX.Element => {
+  const { request, latest, isModalExpanded, setIsModalExpanded } = props;
 
   const [resolvedRevision, setResolvedRevision] = useState<string>('');
   const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
-  const [revisionSelectedToggler, setRevisionSelectedToggler] = useState<boolean>(false);
+  const [revisionSelectedToggler, setRevisionSelectedToggler] =
+    useState<boolean>(false);
 
   const { t } = useTranslation();
   const conflictDiffModalStatus = useConflictDiffModalStatus();
   const { close: closeConflictDiffModal } = useConflictDiffModalActions();
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.DIFF);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
+    GlobalCodeMirrorEditorKey.DIFF,
+  );
 
   // Memoize formatted dates
-  const requestFormattedDate = useMemo(() => formatedDate(request.createdAt), [request.createdAt]);
-  const latestFormattedDate = useMemo(() => formatedDate(latest.createdAt), [latest.createdAt]);
+  const requestFormattedDate = useMemo(
+    () => formatedDate(request.createdAt),
+    [request.createdAt],
+  );
+  const latestFormattedDate = useMemo(
+    () => formatedDate(latest.createdAt),
+    [latest.createdAt],
+  );
 
-  const selectRevisionHandler = useCallback((selectedRevision: string) => {
-    setResolvedRevision(selectedRevision);
-    setRevisionSelectedToggler(prev => !prev);
+  const selectRevisionHandler = useCallback(
+    (selectedRevision: string) => {
+      setResolvedRevision(selectedRevision);
+      setRevisionSelectedToggler((prev) => !prev);
 
-    if (!isRevisionselected) {
-      setIsRevisionSelected(true);
-    }
-  }, [isRevisionselected]);
+      if (!isRevisionselected) {
+        setIsRevisionSelected(true);
+      }
+    },
+    [isRevisionselected],
+  );
 
-  const resolveConflictHandler = useCallback(async() => {
+  const resolveConflictHandler = useCallback(async () => {
     const newBody = codeMirrorEditor?.getDocString();
     if (newBody == null) {
       return;
@@ -82,56 +92,82 @@ const ConflictDiffModalSubstance = (props: ConflictDiffModalSubstanceProps): Rea
     await conflictDiffModalStatus?.onResolve?.(newBody);
   }, [codeMirrorEditor, conflictDiffModalStatus]);
 
+  // biome-ignore lint/correctness/useExhaustiveDependencies: ignore
   useEffect(() => {
     codeMirrorEditor?.initDoc(resolvedRevision);
     // Enable selecting the same revision after editing by including revisionSelectedToggler in the dependency array of useEffect
   }, [codeMirrorEditor, resolvedRevision, revisionSelectedToggler]);
 
-  const headerButtons = useMemo(() => (
-    <div className="d-flex align-items-center">
-      <button type="button" className="btn" onClick={() => setIsModalExpanded(prev => !prev)}>
-        <span className="material-symbols-outlined">{isModalExpanded ? 'close_fullscreen' : 'open_in_full'}</span>
-      </button>
-      <button type="button" className="btn" onClick={closeConflictDiffModal} aria-label="Close">
-        <span className="material-symbols-outlined">close</span>
-      </button>
-    </div>
-  ), [closeConflictDiffModal, isModalExpanded, setIsModalExpanded]);
+  const headerButtons = useMemo(
+    () => (
+      <div className="d-flex align-items-center">
+        <button
+          type="button"
+          className="btn"
+          onClick={() => setIsModalExpanded((prev) => !prev)}
+        >
+          <span className="material-symbols-outlined">
+            {isModalExpanded ? 'close_fullscreen' : 'open_in_full'}
+          </span>
+        </button>
+        <button
+          type="button"
+          className="btn"
+          onClick={closeConflictDiffModal}
+          aria-label="Close"
+        >
+          <span className="material-symbols-outlined">close</span>
+        </button>
+      </div>
+    ),
+    [closeConflictDiffModal, isModalExpanded, setIsModalExpanded],
+  );
 
   return (
     <>
-      <ModalHeader tag="h4" className="d-flex align-items-center" close={headerButtons}>
-        <span className="material-symbols-outlined me-1">error</span>{t('modal_resolve_conflict.resolve_conflict')}
+      <ModalHeader
+        tag="h4"
+        className="d-flex align-items-center"
+        close={headerButtons}
+      >
+        <span className="material-symbols-outlined me-1">error</span>
+        {t('modal_resolve_conflict.resolve_conflict')}
       </ModalHeader>
 
       <ModalBody className="mx-4 my-1">
         <div className="row">
           <div className="col-12 text-center mt-2 mb-4">
-            <h3 className="fw-bold text-muted">{t('modal_resolve_conflict.resolve_conflict_message')}</h3>
+            <h3 className="fw-bold text-muted">
+              {t('modal_resolve_conflict.resolve_conflict_message')}
+            </h3>
           </div>
 
           <div className="col-6">
-            <h4 className="fw-bold my-2 text-muted">{t('modal_resolve_conflict.requested_revision')}</h4>
+            <h4 className="fw-bold my-2 text-muted">
+              {t('modal_resolve_conflict.requested_revision')}
+            </h4>
             <div className="d-flex align-items-center my-3">
               <div>
                 <UserPicture user={request.user} size="lg" noLink noTooltip />
               </div>
               <div className="ms-3 text-muted">
                 <p className="my-0">updated by {request.user.username}</p>
-                <p className="my-0">{ requestFormattedDate }</p>
+                <p className="my-0">{requestFormattedDate}</p>
               </div>
             </div>
           </div>
 
           <div className="col-6">
-            <h4 className="fw-bold my-2 text-muted">{t('modal_resolve_conflict.latest_revision')}</h4>
+            <h4 className="fw-bold my-2 text-muted">
+              {t('modal_resolve_conflict.latest_revision')}
+            </h4>
             <div className="d-flex align-items-center my-3">
               <div>
                 <UserPicture user={latest.user} size="lg" noLink noTooltip />
               </div>
               <div className="ms-3 text-muted">
                 <p className="my-0">updated by {latest.user.username}</p>
-                <p className="my-0">{ latestFormattedDate }</p>
+                <p className="my-0">{latestFormattedDate}</p>
               </div>
             </div>
           </div>
@@ -146,10 +182,16 @@ const ConflictDiffModalSubstance = (props: ConflictDiffModalSubstanceProps): Rea
               <button
                 type="button"
                 className="btn btn-outline-primary"
-                onClick={() => { selectRevisionHandler(request.revisionBody) }}
+                onClick={() => {
+                  selectRevisionHandler(request.revisionBody);
+                }}
               >
-                <span className="material-symbols-outlined me-1">arrow_circle_down</span>
-                {t('modal_resolve_conflict.select_revision', { revision: 'mine' })}
+                <span className="material-symbols-outlined me-1">
+                  arrow_circle_down
+                </span>
+                {t('modal_resolve_conflict.select_revision', {
+                  revision: 'mine',
+                })}
               </button>
             </div>
           </div>
@@ -159,17 +201,25 @@ const ConflictDiffModalSubstance = (props: ConflictDiffModalSubstanceProps): Rea
               <button
                 type="button"
                 className="btn btn-outline-primary"
-                onClick={() => { selectRevisionHandler(latest.revisionBody) }}
+                onClick={() => {
+                  selectRevisionHandler(latest.revisionBody);
+                }}
               >
-                <span className="material-symbols-outlined me-1">arrow_circle_down</span>
-                {t('modal_resolve_conflict.select_revision', { revision: 'theirs' })}
+                <span className="material-symbols-outlined me-1">
+                  arrow_circle_down
+                </span>
+                {t('modal_resolve_conflict.select_revision', {
+                  revision: 'theirs',
+                })}
               </button>
             </div>
           </div>
 
           <div className="col-12">
             <div className="border border-dark">
-              <h4 className="fw-bold my-2 mx-2 text-muted">{t('modal_resolve_conflict.selected_editable_revision')}</h4>
+              <h4 className="fw-bold my-2 mx-2 text-muted">
+                {t('modal_resolve_conflict.selected_editable_revision')}
+              </h4>
               <CodeMirrorEditorDiff />
             </div>
           </div>
@@ -210,29 +260,37 @@ export const ConflictDiffModal = (): React.JSX.Element => {
   const remoteRevisionLastUpdateUser = useRemoteRevisionLastUpdateUser();
   const remoteRevisionLastUpdatedAt = useRemoteRevisionLastUpdatedAt();
 
-  const isRemotePageDataInappropriate = remoteRevisionBody == null || remoteRevisionLastUpdateUser == null;
+  const isRemotePageDataInappropriate =
+    remoteRevisionBody == null || remoteRevisionLastUpdateUser == null;
 
   const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
 
   // Check if all required data is available
-  const isDataReady = conflictDiffModalStatus?.isOpened
-    && currentUser != null
-    && currentPage != null
-    && !isRemotePageDataInappropriate;
+  const isDataReady =
+    conflictDiffModalStatus?.isOpened &&
+    currentUser != null &&
+    currentPage != null &&
+    !isRemotePageDataInappropriate;
 
   // Prepare data for Substance
   const currentTime: Date = new Date();
-  const request: IRevisionOnConflict | null = isDataReady ? {
-    revisionBody: conflictDiffModalStatus.requestRevisionBody ?? '',
-    createdAt: currentTime,
-    user: currentUser,
-  } : null;
-
-  const latest: IRevisionOnConflict | null = isDataReady ? {
-    revisionBody: remoteRevisionBody,
-    createdAt: new Date(remoteRevisionLastUpdatedAt ?? currentTime.toString()),
-    user: remoteRevisionLastUpdateUser,
-  } : null;
+  const request: IRevisionOnConflict | null = isDataReady
+    ? {
+        revisionBody: conflictDiffModalStatus.requestRevisionBody ?? '',
+        createdAt: currentTime,
+        user: currentUser,
+      }
+    : null;
+
+  const latest: IRevisionOnConflict | null = isDataReady
+    ? {
+        revisionBody: remoteRevisionBody,
+        createdAt: new Date(
+          remoteRevisionLastUpdatedAt ?? currentTime.toString(),
+        ),
+        user: remoteRevisionLastUpdateUser,
+      }
+    : null;
 
   return (
     <Modal

+ 4 - 1
apps/app/src/client/components/PageEditor/ConflictDiffModal/dynamic.tsx

@@ -10,7 +10,10 @@ export const ConflictDiffModalLazyLoaded = (): JSX.Element => {
 
   const ConflictDiffModal = useLazyLoader<ConflictDiffModalProps>(
     'conflict-diff-modal',
-    () => import('./ConflictDiffModal').then(mod => ({ default: mod.ConflictDiffModal })),
+    () =>
+      import('./ConflictDiffModal').then((mod) => ({
+        default: mod.ConflictDiffModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 24 - 20
apps/app/src/client/components/PageEditor/DrawioModal/DrawioCommunicationHelper.ts

@@ -1,43 +1,45 @@
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:cli:DrawioCommunicationHelper');
 
 export type DrawioConfig = {
-  css: string,
-  customFonts: string[],
-  compressXml: boolean,
-}
+  css: string;
+  customFonts: string[];
+  compressXml: boolean;
+};
 
 export type DrawioCommunicationCallbackOptions = {
   onClose?: () => void;
   onSave?: (drawioData: string) => void;
-}
+};
 
 export class DrawioCommunicationHelper {
-
   drawioUri: string;
 
   drawioConfig: DrawioConfig;
 
   callbackOpts?: DrawioCommunicationCallbackOptions;
 
-
-  constructor(drawioUri: string, drawioConfig: DrawioConfig, callbackOpts?: DrawioCommunicationCallbackOptions) {
+  constructor(
+    drawioUri: string,
+    drawioConfig: DrawioConfig,
+    callbackOpts?: DrawioCommunicationCallbackOptions,
+  ) {
     this.drawioUri = drawioUri;
     this.drawioConfig = drawioConfig;
     this.callbackOpts = callbackOpts;
   }
 
   onReceiveMessage(event: MessageEvent, drawioMxFile: string | null): void {
-
     // check origin
     if (event.origin != null && this.drawioUri != null) {
       const originUrl = new URL(event.origin);
       const drawioUrl = new URL(this.drawioUri);
 
       if (originUrl.origin !== drawioUrl.origin) {
-        logger.debug(`Skipping the event because the origins are mismatched. expected: '${drawioUrl.origin}', actual: '${originUrl.origin}'`);
+        logger.debug(
+          `Skipping the event because the origins are mismatched. expected: '${drawioUrl.origin}', actual: '${originUrl.origin}'`,
+        );
         return;
       }
     }
@@ -52,10 +54,13 @@ export class DrawioCommunicationHelper {
       //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
       //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
       //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
-      event.source.postMessage(JSON.stringify({
-        action: 'configure',
-        config: this.drawioConfig,
-      }), { targetOrigin: '*' });
+      event.source.postMessage(
+        JSON.stringify({
+          action: 'configure',
+          config: this.drawioConfig,
+        }),
+        { targetOrigin: '*' },
+      );
 
       return;
     }
@@ -73,10 +78,10 @@ export class DrawioCommunicationHelper {
         const drawioData = dom.getElementsByTagName('diagram')[0].innerHTML;
 
         /*
-        * Saving Drawio will be implemented by the following tasks
-        * https://redmine.weseek.co.jp/issues/100845
-        * https://redmine.weseek.co.jp/issues/104507
-        */
+         * Saving Drawio will be implemented by the following tasks
+         * https://redmine.weseek.co.jp/issues/100845
+         * https://redmine.weseek.co.jp/issues/104507
+         */
 
         this.callbackOpts?.onSave?.(drawioData);
       }
@@ -93,5 +98,4 @@ export class DrawioCommunicationHelper {
 
     // NOTHING DONE. (Receive unknown iframe message.)
   }
-
 }

+ 69 - 50
apps/app/src/client/components/PageEditor/DrawioModal/DrawioModal.tsx

@@ -1,27 +1,32 @@
-import React, {
-  useCallback, useEffect, useMemo, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useMemo } from 'react';
 import { Lang } from '@growi/core';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
-import { useDrawioModalForEditorStatus, useDrawioModalForEditorActions } from '@growi/editor/dist/states/modal/drawio-for-editor';
-import { LoadingSpinner } from '@growi/ui/dist/components';
 import {
-  Modal,
-  ModalBody,
-} from 'reactstrap';
+  useDrawioModalForEditorActions,
+  useDrawioModalForEditorStatus,
+} from '@growi/editor/dist/states/modal/drawio-for-editor';
+import { LoadingSpinner } from '@growi/ui/dist/components';
+import { Modal, ModalBody } from 'reactstrap';
 
-import { replaceFocusedDrawioWithEditor, getMarkdownDrawioMxfile } from '~/client/components/PageEditor/markdown-drawio-util-for-editor';
+import {
+  getMarkdownDrawioMxfile,
+  replaceFocusedDrawioWithEditor,
+} from '~/client/components/PageEditor/markdown-drawio-util-for-editor';
 import { useRendererConfig } from '~/states/server-configurations';
-import { useDrawioModalActions, useDrawioModalStatus } from '~/states/ui/modal/drawio';
+import {
+  useDrawioModalActions,
+  useDrawioModalStatus,
+} from '~/states/ui/modal/drawio';
 import { useSWRxPersonalSettings } from '~/stores/personal-settings';
 import loggerFactory from '~/utils/logger';
 
-import { type DrawioConfig, DrawioCommunicationHelper } from './DrawioCommunicationHelper';
+import {
+  DrawioCommunicationHelper,
+  type DrawioConfig,
+} from './DrawioCommunicationHelper';
 
 const logger = loggerFactory('growi:components:DrawioModal');
 
-
 // https://docs.google.com/spreadsheets/d/1FoYdyEraEQuWofzbYCDPKN7EdKgS_2ZrsDrOA8scgwQ
 const DIAGRAMS_NET_LANG_MAP = {
   en_US: 'en',
@@ -34,9 +39,9 @@ export const getDiagramsNetLangCode = (lang: Lang): string => {
   return DIAGRAMS_NET_LANG_MAP[lang];
 };
 
-
 const headerColor = '#334455';
-const fontFamily = "-apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
+const fontFamily =
+  "-apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
 
 const drawioConfig: DrawioConfig = {
   css: `
@@ -52,7 +57,6 @@ const drawioConfig: DrawioConfig = {
   compressXml: true,
 };
 
-
 const DrawioModalSubstance = (): JSX.Element => {
   const { drawioUri } = useRendererConfig();
   const { data: personalSettingsInfo } = useSWRxPersonalSettings({
@@ -69,7 +73,8 @@ const DrawioModalSubstance = (): JSX.Element => {
   const editorKey = drawioModalDataInEditor?.editorKey ?? null;
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
   const editor = codeMirrorEditor?.view;
-  const isOpenedInEditor = (drawioModalDataInEditor?.isOpened ?? false) && (editor != null);
+  const isOpenedInEditor =
+    (drawioModalDataInEditor?.isOpened ?? false) && editor != null;
   const isOpened = drawioModalData?.isOpened ?? false;
 
   // Memoize URI with parameters calculation
@@ -79,11 +84,10 @@ const DrawioModalSubstance = (): JSX.Element => {
       return undefined;
     }
 
-    let url;
+    let url: URL;
     try {
       url = new URL(drawioUri);
-    }
-    catch (err) {
+    } catch (err) {
       logger.debug(err);
       return undefined;
     }
@@ -91,7 +95,10 @@ const DrawioModalSubstance = (): JSX.Element => {
     // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
     url.searchParams.append('spin', '1');
     url.searchParams.append('embed', '1');
-    url.searchParams.append('lang', getDiagramsNetLangCode(personalSettingsInfo?.lang ?? Lang.en_US));
+    url.searchParams.append(
+      'lang',
+      getDiagramsNetLangCode(personalSettingsInfo?.lang ?? Lang.en_US),
+    );
     url.searchParams.append('ui', 'atlas');
     url.searchParams.append('configure', '1');
 
@@ -104,34 +111,47 @@ const DrawioModalSubstance = (): JSX.Element => {
       return undefined;
     }
 
-    const saveHandler = editor != null
-      ? (drawioMxFile: string) => replaceFocusedDrawioWithEditor(editor, drawioMxFile)
-      : drawioModalData?.onSave;
+    const saveHandler =
+      editor != null
+        ? (drawioMxFile: string) =>
+            replaceFocusedDrawioWithEditor(editor, drawioMxFile)
+        : drawioModalData?.onSave;
 
     const closeHandler = isOpened ? closeDrawioModal : closeDrawioModalInEditor;
 
-    return new DrawioCommunicationHelper(
-      drawioUri,
-      drawioConfig,
-      { onClose: closeHandler, onSave: saveHandler },
-    );
-  }, [drawioUri, editor, drawioModalData?.onSave, isOpened, closeDrawioModal, closeDrawioModalInEditor]);
-
-  const receiveMessageHandler = useCallback((event: MessageEvent) => {
-    if (drawioModalData == null || drawioCommunicationHelper == null) {
-      return;
-    }
-
-    const drawioMxFile = editor != null ? getMarkdownDrawioMxfile(editor) : drawioModalData.drawioMxFile;
-    drawioCommunicationHelper.onReceiveMessage(event, drawioMxFile ?? null);
-  }, [drawioCommunicationHelper, drawioModalData, editor]);
+    return new DrawioCommunicationHelper(drawioUri, drawioConfig, {
+      onClose: closeHandler,
+      onSave: saveHandler,
+    });
+  }, [
+    drawioUri,
+    editor,
+    drawioModalData?.onSave,
+    isOpened,
+    closeDrawioModal,
+    closeDrawioModalInEditor,
+  ]);
+
+  const receiveMessageHandler = useCallback(
+    (event: MessageEvent) => {
+      if (drawioModalData == null || drawioCommunicationHelper == null) {
+        return;
+      }
+
+      const drawioMxFile =
+        editor != null
+          ? getMarkdownDrawioMxfile(editor)
+          : drawioModalData.drawioMxFile;
+      drawioCommunicationHelper.onReceiveMessage(event, drawioMxFile ?? null);
+    },
+    [drawioCommunicationHelper, drawioModalData, editor],
+  );
 
   // Memoize toggle handler
   const toggleHandler = useCallback(() => {
     if (isOpened) {
       closeDrawioModal();
-    }
-    else {
+    } else {
       closeDrawioModalInEditor();
     }
   }, [isOpened, closeDrawioModal, closeDrawioModalInEditor]);
@@ -139,13 +159,12 @@ const DrawioModalSubstance = (): JSX.Element => {
   useEffect(() => {
     if (isOpened || isOpenedInEditor) {
       window.addEventListener('message', receiveMessageHandler);
-    }
-    else {
+    } else {
       window.removeEventListener('message', receiveMessageHandler);
     }
 
     // clean up
-    return function() {
+    return () => {
       window.removeEventListener('message', receiveMessageHandler);
     };
   }, [isOpened, isOpenedInEditor, receiveMessageHandler]);
@@ -167,17 +186,17 @@ const DrawioModalSubstance = (): JSX.Element => {
           </div>
         </div>
         {/* iframe */}
-        { drawioUriWithParams != null && (
+        {drawioUriWithParams != null && (
           <div className="w-100 h-100 position-absolute d-flex">
-            { (isOpened || isOpenedInEditor) && (
+            {(isOpened || isOpenedInEditor) && (
               <iframe
                 src={drawioUriWithParams.href}
                 className="border-0 flex-grow-1"
-              >
-              </iframe>
-            ) }
+                title="Draw.io editor"
+              ></iframe>
+            )}
           </div>
-        ) }
+        )}
       </ModalBody>
     </Modal>
   );

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

@@ -1,11 +1,9 @@
 import type { JSX } from 'react';
-
 import { useDrawioModalForEditorStatus } from '@growi/editor/dist/states/modal/drawio-for-editor';
 
 import { useLazyLoader } from '~/components/utils/use-lazy-loader';
 import { useDrawioModalStatus } from '~/states/ui/modal/drawio';
 
-
 type DrawioModalProps = Record<string, unknown>;
 
 export const DrawioModalLazyLoaded = (): JSX.Element => {
@@ -17,7 +15,7 @@ export const DrawioModalLazyLoaded = (): JSX.Element => {
 
   const DrawioModal = useLazyLoader<DrawioModalProps>(
     'drawio-modal',
-    () => import('./DrawioModal').then(mod => ({ default: mod.DrawioModal })),
+    () => import('./DrawioModal').then((mod) => ({ default: mod.DrawioModal })),
     isOpened || isOpenedInEditor,
   );
 

+ 18 - 7
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -1,5 +1,4 @@
 import { type FC, useState } from 'react';
-
 import type { EditingClient } from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import { Popover, PopoverBody } from 'reactstrap';
@@ -11,8 +10,8 @@ import styles from './EditingUserList.module.scss';
 const userListPopoverClass = styles['user-list-popover'] ?? '';
 
 type Props = {
-  clientList: EditingClient[]
-}
+  clientList: EditingClient[];
+};
 
 export const EditingUserList: FC<Props> = ({ clientList }) => {
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -29,7 +28,7 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
   return (
     <div className="d-flex flex-column justify-content-start justify-content-sm-end">
       <div className="d-flex justify-content-start justify-content-sm-end">
-        {firstFourUsers.map(editingClient => (
+        {firstFourUsers.map((editingClient) => (
           <div key={editingClient.clientId} className="ms-1">
             <UserPicture
               user={editingClient}
@@ -41,10 +40,22 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
 
         {remainingUsers.length > 0 && (
           <div className="ms-1">
-            <button type="button" id="btn-editing-user" className="btn border-0 bg-info-subtle rounded-pill p-0">
-              <span className="fw-bold text-info p-1">+{remainingUsers.length}</span>
+            <button
+              type="button"
+              id="btn-editing-user"
+              className="btn border-0 bg-info-subtle rounded-pill p-0"
+            >
+              <span className="fw-bold text-info p-1">
+                +{remainingUsers.length}
+              </span>
             </button>
-            <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-editing-user" toggle={togglePopover} trigger="legacy">
+            <Popover
+              placement="bottom"
+              isOpen={isPopoverOpen}
+              target="btn-editing-user"
+              toggle={togglePopover}
+              trigger="legacy"
+            >
               <PopoverBody className={userListPopoverClass}>
                 <UserPictureList users={remainingUsers} />
               </PopoverBody>

+ 10 - 8
apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx

@@ -11,18 +11,20 @@ const moduleClass = styles['editor-navbar'] ?? '';
 
 const EditingUsers = (): JSX.Element => {
   const editingClients = useEditingClients();
-  return (
-    <EditingUserList
-      clientList={editingClients}
-    />
-  );
+  return <EditingUserList clientList={editingClients} />;
 };
 
 export const EditorNavbar = (): JSX.Element => {
   return (
-    <div className={`${moduleClass} d-flex flex-column flex-sm-row justify-content-between ps-3 ps-md-5 ps-xl-4 pe-4 py-1 align-items-sm-end`}>
-      <div className="order-2 order-sm-1"><PageHeader /></div>
-      <div className="order-1 order-sm-2"><EditingUsers /></div>
+    <div
+      className={`${moduleClass} d-flex flex-column flex-sm-row justify-content-between ps-3 ps-md-5 ps-xl-4 pe-4 py-1 align-items-sm-end`}
+    >
+      <div className="order-2 order-sm-1">
+        <PageHeader />
+      </div>
+      <div className="order-1 order-sm-2">
+        <EditingUsers />
+      </div>
     </div>
   );
 };

+ 4 - 2
apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx

@@ -1,8 +1,10 @@
 import { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
-import { useAiAssistantSidebarStatus, useAiAssistantSidebarActions } from '~/features/openai/client/states';
+import {
+  useAiAssistantSidebarActions,
+  useAiAssistantSidebarStatus,
+} from '~/features/openai/client/states';
 
 export const EditorAssistantToggleButton = (): JSX.Element => {
   const { t } = useTranslation();

+ 16 - 12
apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx

@@ -1,7 +1,6 @@
 import type { JSX } from 'react';
-
-import { useAtomValue } from 'jotai';
 import dynamic from 'next/dynamic';
+import { useAtomValue } from 'jotai';
 
 import { aiEnabledAtom } from '~/states/server-configurations';
 import { useDrawerOpened } from '~/states/ui/sidebar';
@@ -10,11 +9,16 @@ import { EditorAssistantToggleButton } from './EditorAssistantToggleButton';
 
 import styles from './EditorNavbarBottom.module.scss';
 
-
 const moduleClass = styles['grw-editor-navbar-bottom'];
 
-const SavePageControls = dynamic(() => import('./SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
-const OptionsSelector = dynamic(() => import('./OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
+const SavePageControls = dynamic(
+  () => import('./SavePageControls').then((mod) => mod.SavePageControls),
+  { ssr: false },
+);
+const OptionsSelector = dynamic(
+  () => import('./OptionsSelector').then((mod) => mod.OptionsSelector),
+  { ssr: false },
+);
 
 export const EditorNavbarBottom = (): JSX.Element => {
   const isAiEnabled = useAtomValue(aiEnabledAtom);
@@ -22,19 +26,19 @@ export const EditorNavbarBottom = (): JSX.Element => {
 
   return (
     <div className="border-top" data-testid="grw-editor-navbar-bottom">
-      <div className={`flex-expand-horiz align-items-center p-2 ps-md-3 pe-md-4 ${moduleClass}`}>
-        <a
-          role="button"
+      <div
+        className={`flex-expand-horiz align-items-center p-2 ps-md-3 pe-md-4 ${moduleClass}`}
+      >
+        <button
+          type="button"
           className="nav-link btn-lg p-2 d-md-none me-3 opacity-50"
           onClick={() => setIsDrawerOpened(true)}
         >
           <span className="material-symbols-outlined fs-2">reorder</span>
-        </a>
+        </button>
         <form className="me-auto d-flex gap-2">
           <OptionsSelector />
-          {isAiEnabled && (
-            <EditorAssistantToggleButton />
-          )}
+          {isAiEnabled && <EditorAssistantToggleButton />}
         </form>
         <form>
           <SavePageControls />

+ 195 - 93
apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx

@@ -1,17 +1,21 @@
 import React, {
-  useCallback, useEffect, useState, type JSX,
+  type JSX,
+  type ReactNode,
+  useCallback,
+  useEffect,
+  useState,
 } from 'react';
-
-import {
-  PageGrant, GroupType, getIdForRef,
-} from '@growi/core';
+import { GroupType, getIdForRef, PageGrant } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import {
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  Modal,
+  ModalBody,
+  ModalHeader,
   UncontrolledDropdown,
-  DropdownToggle, DropdownMenu, DropdownItem,
-
-  Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 
 import type { UserRelatedGroupsData } from '~/interfaces/page';
@@ -21,17 +25,25 @@ import { useCurrentPageId } from '~/states/page';
 import { useSelectedGrant } from '~/states/ui/editor';
 import { useSWRxCurrentGrantData } from '~/stores/page';
 
-
 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-success', 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: PageGrant.GRANT_OWNER, iconName: 'lock', btnStyleClass: 'outline-danger', label: 'Only me',
+    grant: PageGrant.GRANT_OWNER,
+    iconName: 'lock',
+    btnStyleClass: 'outline-danger',
+    label: 'Only me',
   },
   {
     grant: PageGrant.GRANT_USER_GROUP,
@@ -42,11 +54,10 @@ const AVAILABLE_GRANTS = [
   },
 ];
 
-
 type Props = {
-  disabled?: boolean,
-  openInModal?: boolean,
-}
+  disabled?: boolean;
+  openInModal?: boolean;
+};
 
 /**
  * Page grant select component
@@ -54,11 +65,7 @@ type Props = {
 export const GrantSelector = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const {
-    disabled,
-    openInModal,
-  } = props;
-
+  const { disabled, openInModal } = props;
 
   const [isSelectGroupModalShown, setIsSelectGroupModalShown] = useState(false);
 
@@ -76,10 +83,12 @@ export const GrantSelector = (props: Props): JSX.Element => {
     const currentPageGrant = grantData?.grantData.currentPageGrant;
     if (currentPageGrant == null) return;
 
-    const userRelatedGrantedGroups = currentPageGrant.groupGrantData
-      ?.userRelatedGroups.filter(group => group.status === UserGroupPageGrantStatus.isGranted)?.map((group) => {
-        return { item: group.id, type: group.type };
-      }) ?? [];
+    const userRelatedGrantedGroups =
+      currentPageGrant.groupGrantData?.userRelatedGroups
+        .filter((group) => group.status === UserGroupPageGrantStatus.isGranted)
+        ?.map((group) => {
+          return { item: group.id, type: group.type };
+        }) ?? [];
     setSelectedGrant({
       grant: currentPageGrant.grant,
       userRelatedGrantedGroups,
@@ -98,48 +107,77 @@ export const GrantSelector = (props: Props): JSX.Element => {
   /**
    * change event handler for grant selector
    */
-  const changeGrantHandler = useCallback((grant: PageGrant) => {
-    // select group
-    if (grant === 5) {
-      if (selectedGrant?.grant !== 5) applyCurrentPageGrantToSelectedGrant();
-      showSelectGroupModal();
-      return;
-    }
-
-    setSelectedGrant({ grant, userRelatedGrantedGroups: undefined });
-  }, [setSelectedGrant, showSelectGroupModal, applyCurrentPageGrantToSelectedGrant, selectedGrant?.grant]);
+  const changeGrantHandler = useCallback(
+    (grant: PageGrant) => {
+      // select group
+      if (grant === 5) {
+        if (selectedGrant?.grant !== 5) applyCurrentPageGrantToSelectedGrant();
+        showSelectGroupModal();
+        return;
+      }
 
-  const groupListItemClickHandler = useCallback((clickedGroup: UserRelatedGroupsData) => {
-    const userRelatedGrantedGroups = selectedGrant?.userRelatedGrantedGroups ?? [];
+      setSelectedGrant({ grant, userRelatedGrantedGroups: undefined });
+    },
+    [
+      setSelectedGrant,
+      showSelectGroupModal,
+      applyCurrentPageGrantToSelectedGrant,
+      selectedGrant?.grant,
+    ],
+  );
 
-    let userRelatedGrantedGroupsCopy = [...userRelatedGrantedGroups];
-    if (userRelatedGrantedGroupsCopy.find(group => getIdForRef(group.item) === clickedGroup.id) == null) {
-      const grantGroupInfo = { item: clickedGroup.id, type: clickedGroup.type };
-      userRelatedGrantedGroupsCopy.push(grantGroupInfo);
-    }
-    else {
-      userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => getIdForRef(group.item) !== clickedGroup.id);
-    }
-    setSelectedGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
-  }, [setSelectedGrant, selectedGrant?.userRelatedGrantedGroups]);
+  const groupListItemClickHandler = useCallback(
+    (clickedGroup: UserRelatedGroupsData) => {
+      const userRelatedGrantedGroups =
+        selectedGrant?.userRelatedGrantedGroups ?? [];
+
+      let userRelatedGrantedGroupsCopy = [...userRelatedGrantedGroups];
+      if (
+        userRelatedGrantedGroupsCopy.find(
+          (group) => getIdForRef(group.item) === clickedGroup.id,
+        ) == null
+      ) {
+        const grantGroupInfo = {
+          item: clickedGroup.id,
+          type: clickedGroup.type,
+        };
+        userRelatedGrantedGroupsCopy.push(grantGroupInfo);
+      } else {
+        userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(
+          (group) => getIdForRef(group.item) !== clickedGroup.id,
+        );
+      }
+      setSelectedGrant({
+        grant: 5,
+        userRelatedGrantedGroups: userRelatedGrantedGroupsCopy,
+      });
+    },
+    [setSelectedGrant, selectedGrant?.userRelatedGrantedGroups],
+  );
 
   /**
    * Render grant selector DOM.
    */
   const renderGrantSelector = useCallback(() => {
-
-    let dropdownToggleBtnColor;
-    let dropdownToggleLabelElm;
-
-    const userRelatedGrantedGroups = groupGrantData?.userRelatedGroups.filter((group) => {
-      return selectedGrant?.userRelatedGrantedGroups?.some(grantedGroup => getIdForRef(grantedGroup.item) === group.id);
-    }) ?? [];
-    const nonUserRelatedGrantedGroups = groupGrantData?.nonUserRelatedGrantedGroups ?? [];
+    let dropdownToggleBtnColor: string | undefined;
+    let dropdownToggleLabelElm: ReactNode | undefined;
+
+    const userRelatedGrantedGroups =
+      groupGrantData?.userRelatedGroups.filter((group) => {
+        return selectedGrant?.userRelatedGrantedGroups?.some(
+          (grantedGroup) => getIdForRef(grantedGroup.item) === group.id,
+        );
+      }) ?? [];
+    const nonUserRelatedGrantedGroups =
+      groupGrantData?.nonUserRelatedGrantedGroups ?? [];
 
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && userRelatedGrantedGroups.length > 0)
-        ? opt.reselectLabel // when grantGroup is selected
-        : opt.label;
+      const label =
+        opt.grant === 5 &&
+        opt.reselectLabel != null &&
+        userRelatedGrantedGroups.length > 0
+          ? opt.reselectLabel // when grantGroup is selected
+          : opt.label;
 
       const labelElm = (
         <span className={openInModal ? 'py-2' : ''}>
@@ -154,24 +192,41 @@ export const GrantSelector = (props: Props): JSX.Element => {
         dropdownToggleLabelElm = labelElm;
       }
 
-      return <DropdownItem key={opt.grant} onClick={() => changeGrantHandler(opt.grant)}>{labelElm}</DropdownItem>;
+      return (
+        <DropdownItem
+          key={opt.grant}
+          onClick={() => changeGrantHandler(opt.grant)}
+        >
+          {labelElm}
+        </DropdownItem>
+      );
     });
 
     // add specified group option
-    if (selectedGrant?.grant === PageGrant.GRANT_USER_GROUP && (userRelatedGrantedGroups.length > 0 || nonUserRelatedGrantedGroups.length > 0)) {
-      const grantedGroupNames = [...userRelatedGrantedGroups.map(group => group.name), ...nonUserRelatedGrantedGroups.map(group => group.name)];
+    if (
+      selectedGrant?.grant === PageGrant.GRANT_USER_GROUP &&
+      (userRelatedGrantedGroups.length > 0 ||
+        nonUserRelatedGrantedGroups.length > 0)
+    ) {
+      const grantedGroupNames = [
+        ...userRelatedGrantedGroups.map((group) => group.name),
+        ...nonUserRelatedGrantedGroups.map((group) => group.name),
+      ];
       const labelElm = (
         <span>
           <span className="material-symbols-outlined me-1">account_tree</span>
           <span className="label">
-            {grantedGroupNames.length > 1
+            {grantedGroupNames.length > 1 ? (
               // substring for group name truncate
-              ? (
-                <span>
-                  {`${grantedGroupNames[0].substring(0, 30)}, ... `}
-                  <span className="badge bg-primary">+{grantedGroupNames.length - 1}</span>
+              <span>
+                {`${grantedGroupNames[0].substring(0, 30)}, ... `}
+                <span className="badge bg-primary">
+                  +{grantedGroupNames.length - 1}
                 </span>
-              ) : grantedGroupNames[0].substring(0, 30)}
+              </span>
+            ) : (
+              grantedGroupNames[0].substring(0, 30)
+            )}
           </span>
         </span>
       );
@@ -179,7 +234,9 @@ export const GrantSelector = (props: Props): JSX.Element => {
       // set dropdownToggleLabelElm
       dropdownToggleLabelElm = labelElm;
 
-      dropdownMenuElems.push(<DropdownItem key="groupSelected">{labelElm}</DropdownItem>);
+      dropdownMenuElems.push(
+        <DropdownItem key="groupSelected">{labelElm}</DropdownItem>,
+      );
     }
 
     return (
@@ -193,13 +250,23 @@ export const GrantSelector = (props: Props): JSX.Element => {
           >
             {dropdownToggleLabelElm}
           </DropdownToggle>
-          <DropdownMenu data-testid="grw-grant-selector-dropdown-menu" container={openInModal ? '' : 'body'}>
+          <DropdownMenu
+            data-testid="grw-grant-selector-dropdown-menu"
+            container={openInModal ? '' : 'body'}
+          >
             {dropdownMenuElems}
           </DropdownMenu>
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, disabled, groupGrantData, selectedGrant, t, openInModal]);
+  }, [
+    changeGrantHandler,
+    disabled,
+    groupGrantData,
+    selectedGrant,
+    t,
+    openInModal,
+  ]);
 
   /**
    * Render select grantgroup modal.
@@ -224,18 +291,26 @@ export const GrantSelector = (props: Props): JSX.Element => {
       return (
         <div>
           <h4>{t('user_group.belonging_to_no_group')}</h4>
-          { currentUser?.admin && (
-            <p><a href="/admin/user-groups"><span className="material-symbols-outlined me-1">login</span>{t('user_group.manage_user_groups')}</a></p>
-          ) }
+          {currentUser?.admin && (
+            <p>
+              <a href="/admin/user-groups">
+                <span className="material-symbols-outlined me-1">login</span>
+                {t('user_group.manage_user_groups')}
+              </a>
+            </p>
+          )}
         </div>
       );
     }
 
     return (
       <div className="d-flex flex-column">
-        { userRelatedGroups.map((group) => {
-          const isGroupGranted = selectedGrant?.userRelatedGrantedGroups?.some(grantedGroup => getIdForRef(grantedGroup.item) === group.id);
-          const cannotGrantGroup = group.status === UserGroupPageGrantStatus.cannotGrant;
+        {userRelatedGroups.map((group) => {
+          const isGroupGranted = selectedGrant?.userRelatedGrantedGroups?.some(
+            (grantedGroup) => getIdForRef(grantedGroup.item) === group.id,
+          );
+          const cannotGrantGroup =
+            group.status === UserGroupPageGrantStatus.cannotGrant;
           const activeClass = isGroupGranted ? 'active' : '';
 
           return (
@@ -246,14 +321,22 @@ export const GrantSelector = (props: Props): JSX.Element => {
               onClick={() => groupListItemClickHandler(group)}
               disabled={cannotGrantGroup}
             >
-              <input type="checkbox" checked={isGroupGranted} disabled={cannotGrantGroup} />
+              <input
+                type="checkbox"
+                checked={isGroupGranted}
+                disabled={cannotGrantGroup}
+              />
               <p className="ms-3 mb-0">{group.name}</p>
-              {group.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{group.provider}</span>}
+              {group.type === GroupType.externalUserGroup && (
+                <span className="ms-2 badge badge-pill badge-info">
+                  {group.provider}
+                </span>
+              )}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
           );
-        }) }
-        { nonUserRelatedGrantedGroups.map((group) => {
+        })}
+        {nonUserRelatedGrantedGroups.map((group) => {
           return (
             <button
               className="btn btn-outline-primary d-flex justify-content-start mb-3 mx-4 align-items-center p-3 active"
@@ -263,16 +346,32 @@ export const GrantSelector = (props: Props): JSX.Element => {
             >
               <input type="checkbox" checked disabled />
               <p className="ms-3 mb-0">{group.name}</p>
-              {group.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{group.provider}</span>}
+              {group.type === GroupType.externalUserGroup && (
+                <span className="ms-2 badge badge-pill badge-info">
+                  {group.provider}
+                </span>
+              )}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
           );
-        }) }
-        <button type="button" className="btn btn-primary mt-2 mx-auto" 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, shouldFetch, t, groupGrantData, selectedGrant?.userRelatedGrantedGroups]);
+  }, [
+    currentUser?.admin,
+    groupListItemClickHandler,
+    shouldFetch,
+    t,
+    groupGrantData,
+    selectedGrant?.userRelatedGrantedGroups,
+  ]);
 
   const renderModalCloseButton = useCallback(() => {
     return (
@@ -284,27 +383,30 @@ export const GrantSelector = (props: Props): JSX.Element => {
         <span className="material-symbols-outlined">close</span>
       </button>
     );
-  }, [setIsSelectGroupModalShown]);
+  }, []);
 
   return (
     <>
-      { renderGrantSelector() }
+      {renderGrantSelector()}
 
       {/* render modal */}
-      { !disabled && currentUser != null && (
+      {!disabled && currentUser != null && (
         <Modal
           isOpen={isSelectGroupModalShown}
           toggle={() => setIsSelectGroupModalShown(false)}
           centered
         >
-          <ModalHeader tag="p" toggle={() => setIsSelectGroupModalShown(false)} className="fs-5 text-muted fw-bold pb-2" close={renderModalCloseButton()}>
+          <ModalHeader
+            tag="p"
+            toggle={() => setIsSelectGroupModalShown(false)}
+            className="fs-5 text-muted fw-bold pb-2"
+            close={renderModalCloseButton()}
+          >
             {t('user_group.select_group')}
           </ModalHeader>
-          <ModalBody>
-            {renderSelectGroupModalContent()}
-          </ModalBody>
+          <ModalBody>{renderSelectGroupModalContent()}</ModalBody>
         </Modal>
-      ) }
+      )}
     </>
   );
 };

+ 276 - 194
apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx

@@ -1,33 +1,41 @@
-import React, {
-  memo, useCallback, useMemo, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, memo, useCallback, useMemo, useState } from 'react';
+import Image from 'next/image';
 import {
-  type EditorTheme, type KeyMapMode, PasteMode, AllPasteMode, DEFAULT_KEYMAP, DEFAULT_PASTE_MODE, DEFAULT_THEME,
+  AllPasteMode,
+  DEFAULT_KEYMAP,
+  DEFAULT_PASTE_MODE,
+  DEFAULT_THEME,
+  type EditorTheme,
+  type KeyMapMode,
+  PasteMode,
 } from '@growi/editor';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import Image from 'next/image';
 import {
-  Dropdown, DropdownToggle, DropdownMenu, Input, FormGroup,
+  Dropdown,
+  DropdownMenu,
+  DropdownToggle,
+  FormGroup,
+  Input,
 } from 'reactstrap';
 
 import { isIndentSizeForcedAtom } from '~/states/server-configurations';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
-import { useCurrentIndentSize, useCurrentIndentSizeActions } from '~/states/ui/editor';
+import {
+  useCurrentIndentSize,
+  useCurrentIndentSizeActions,
+} from '~/states/ui/editor';
 import { useEditorSettings } from '~/stores/editor';
 
 type RadioListItemProps = {
-  onClick: () => void,
-  icon?: React.ReactNode,
-  text: string,
-  checked?: boolean
-}
+  onClick: () => void;
+  icon?: React.ReactNode;
+  text: string;
+  checked?: boolean;
+};
 
 const RadioListItem = (props: RadioListItemProps): JSX.Element => {
-  const {
-    onClick, icon, text, checked,
-  } = props;
+  const { onClick, icon, text, checked } = props;
   return (
     <li className="list-group-item border-0 d-flex align-items-center">
       <input
@@ -39,40 +47,45 @@ const RadioListItem = (props: RadioListItemProps): JSX.Element => {
         checked={checked}
       />
       {icon}
-      <label className="form-check-label stretched-link fs-6" htmlFor={`editor_config_radio_item_${text}`}>{text}</label>
+      <label
+        className="form-check-label stretched-link fs-6"
+        htmlFor={`editor_config_radio_item_${text}`}
+      >
+        {text}
+      </label>
     </li>
   );
 };
 
-
 type SelectorProps = {
-  header: string,
-  onClickBefore: () => void,
-  items: JSX.Element,
-}
+  header: string;
+  onClickBefore: () => void;
+  items: JSX.Element;
+};
 
 const Selector = (props: SelectorProps): JSX.Element => {
-
   const { header, onClickBefore, items } = props;
   return (
     <div className="d-flex flex-column w-100">
-      <button type="button" className="btn border-0 d-flex align-items-center text-muted ms-2" onClick={onClickBefore}>
-        <span className="material-symbols-outlined fs-5 py-0 me-1">navigate_before</span>
-        <label>{header}</label>
+      <button
+        type="button"
+        className="btn border-0 d-flex align-items-center text-muted ms-2"
+        onClick={onClickBefore}
+      >
+        <span className="material-symbols-outlined fs-5 py-0 me-1">
+          navigate_before
+        </span>
+        <span>{header}</span>
       </button>
       <hr className="my-1" />
-      <ul className="list-group d-flex ms-2">
-        {items}
-      </ul>
+      <ul className="list-group d-flex ms-2">{items}</ul>
     </div>
   );
-
 };
 
-
 type EditorThemeToLabel = {
   [key in EditorTheme]: string;
-}
+};
 
 const EDITORTHEME_LABEL_MAP: EditorThemeToLabel = {
   defaultlight: 'DefaultLight',
@@ -87,33 +100,47 @@ const EDITORTHEME_LABEL_MAP: EditorThemeToLabel = {
   kimbie: 'Kimbie',
 };
 
-const ThemeSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
-
-  const { t } = useTranslation();
-  const { data: editorSettings, update } = useEditorSettings();
-  const selectedTheme = editorSettings?.theme ?? DEFAULT_THEME;
-
-  const listItems = useMemo(() => (
-    <>
-      {(Object.keys(EDITORTHEME_LABEL_MAP) as EditorTheme[]).map((theme) => {
-        const themeLabel = EDITORTHEME_LABEL_MAP[theme];
-        return (
-          <RadioListItem onClick={() => update({ theme })} text={themeLabel} checked={theme === selectedTheme} />
-        );
-      })}
-    </>
-  ), [update, selectedTheme]);
+const ThemeSelector = memo(
+  ({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
+    const { t } = useTranslation();
+    const { data: editorSettings, update } = useEditorSettings();
+    const selectedTheme = editorSettings?.theme ?? DEFAULT_THEME;
+
+    const listItems = useMemo(
+      () => (
+        <>
+          {(Object.keys(EDITORTHEME_LABEL_MAP) as EditorTheme[]).map(
+            (theme) => {
+              const themeLabel = EDITORTHEME_LABEL_MAP[theme];
+              return (
+                <RadioListItem
+                  key={theme}
+                  onClick={() => update({ theme })}
+                  text={themeLabel}
+                  checked={theme === selectedTheme}
+                />
+              );
+            },
+          )}
+        </>
+      ),
+      [update, selectedTheme],
+    );
 
-  return (
-    <Selector header={t('page_edit.theme')} onClickBefore={onClickBefore} items={listItems} />
-  );
-});
+    return (
+      <Selector
+        header={t('page_edit.theme')}
+        onClickBefore={onClickBefore}
+        items={listItems}
+      />
+    );
+  },
+);
 ThemeSelector.displayName = 'ThemeSelector';
 
-
 type KeyMapModeToLabel = {
   [key in KeyMapMode]: string;
-}
+};
 
 const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
   default: 'Default',
@@ -122,98 +149,138 @@ const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
   vscode: 'Visual Studio Code',
 };
 
-const KeymapSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
-
-  const { t } = useTranslation();
-  const { data: editorSettings, update } = useEditorSettings();
-  const selectedKeymapMode = editorSettings?.keymapMode ?? DEFAULT_KEYMAP;
-
-  const listItems = useMemo(() => (
-    <>
-      {(Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => {
-        const keymapLabel = KEYMAP_LABEL_MAP[keymapMode];
-        const icon = (keymapMode !== 'default')
-          ? <Image src={`/images/icons/${keymapMode}.png`} width={16} height={16} className="me-2" alt={keymapMode} />
-          : null;
-        return (
-          <RadioListItem onClick={() => update({ keymapMode })} icon={icon} text={keymapLabel} checked={keymapMode === selectedKeymapMode} />
-        );
-      })}
-    </>
-  ), [update, selectedKeymapMode]);
-
+const KeymapSelector = memo(
+  ({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
+    const { t } = useTranslation();
+    const { data: editorSettings, update } = useEditorSettings();
+    const selectedKeymapMode = editorSettings?.keymapMode ?? DEFAULT_KEYMAP;
+
+    const listItems = useMemo(
+      () => (
+        <>
+          {(Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => {
+            const keymapLabel = KEYMAP_LABEL_MAP[keymapMode];
+            const icon =
+              keymapMode !== 'default' ? (
+                <Image
+                  src={`/images/icons/${keymapMode}.png`}
+                  width={16}
+                  height={16}
+                  className="me-2"
+                  alt={keymapMode}
+                />
+              ) : null;
+            return (
+              <RadioListItem
+                key={keymapMode}
+                onClick={() => update({ keymapMode })}
+                icon={icon}
+                text={keymapLabel}
+                checked={keymapMode === selectedKeymapMode}
+              />
+            );
+          })}
+        </>
+      ),
+      [update, selectedKeymapMode],
+    );
 
-  return (
-    <Selector header={t('page_edit.keymap')} onClickBefore={onClickBefore} items={listItems} />
-  );
-});
+    return (
+      <Selector
+        header={t('page_edit.keymap')}
+        onClickBefore={onClickBefore}
+        items={listItems}
+      />
+    );
+  },
+);
 KeymapSelector.displayName = 'KeymapSelector';
 
-
 const TYPICAL_INDENT_SIZE = [2, 4];
 
-const IndentSizeSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
-
-  const { t } = useTranslation();
-  const currentIndentSize = useCurrentIndentSize();
-  const { mutate: mutateCurrentIndentSize } = useCurrentIndentSizeActions();
-
-  const listItems = useMemo(() => (
-    <>
-      {TYPICAL_INDENT_SIZE.map((indent) => {
-        return (
-          <RadioListItem onClick={() => mutateCurrentIndentSize(indent)} text={indent.toString()} checked={indent === currentIndentSize} />
-        );
-      })}
-    </>
-  ), [currentIndentSize, mutateCurrentIndentSize]);
+const IndentSizeSelector = memo(
+  ({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
+    const { t } = useTranslation();
+    const currentIndentSize = useCurrentIndentSize();
+    const { mutate: mutateCurrentIndentSize } = useCurrentIndentSizeActions();
+
+    const listItems = useMemo(
+      () => (
+        <>
+          {TYPICAL_INDENT_SIZE.map((indent) => {
+            return (
+              <RadioListItem
+                key={indent}
+                onClick={() => mutateCurrentIndentSize(indent)}
+                text={indent.toString()}
+                checked={indent === currentIndentSize}
+              />
+            );
+          })}
+        </>
+      ),
+      [currentIndentSize, mutateCurrentIndentSize],
+    );
 
-  return (
-    <Selector header={t('page_edit.indent')} onClickBefore={onClickBefore} items={listItems} />
-  );
-});
+    return (
+      <Selector
+        header={t('page_edit.indent')}
+        onClickBefore={onClickBefore}
+        items={listItems}
+      />
+    );
+  },
+);
 IndentSizeSelector.displayName = 'IndentSizeSelector';
 
+const PasteSelector = memo(
+  ({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
+    const { t } = useTranslation();
+    const { data: editorSettings, update } = useEditorSettings();
+    const selectedPasteMode = editorSettings?.pasteMode ?? DEFAULT_PASTE_MODE;
+
+    const listItems = useMemo(
+      () => (
+        <>
+          {AllPasteMode.map((pasteMode) => {
+            return (
+              <RadioListItem
+                key={pasteMode}
+                onClick={() => update({ pasteMode })}
+                text={t(`page_edit.paste.${pasteMode}`) ?? ''}
+                checked={pasteMode === selectedPasteMode}
+              />
+            );
+          })}
+        </>
+      ),
+      [update, t, selectedPasteMode],
+    );
 
-const PasteSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => {
-
-  const { t } = useTranslation();
-  const { data: editorSettings, update } = useEditorSettings();
-  const selectedPasteMode = editorSettings?.pasteMode ?? DEFAULT_PASTE_MODE;
-
-  const listItems = useMemo(() => (
-    <>
-      {(AllPasteMode).map((pasteMode) => {
-        return (
-          <RadioListItem onClick={() => update({ pasteMode })} text={t(`page_edit.paste.${pasteMode}`) ?? ''} checked={pasteMode === selectedPasteMode} />
-        );
-      })}
-    </>
-  ), [update, t, selectedPasteMode]);
-
-  return (
-    <Selector header={t('page_edit.paste.title')} onClickBefore={onClickBefore} items={listItems} />
-  );
-});
+    return (
+      <Selector
+        header={t('page_edit.paste.title')}
+        onClickBefore={onClickBefore}
+        items={listItems}
+      />
+    );
+  },
+);
 PasteSelector.displayName = 'PasteSelector';
 
-
 type SwitchItemProps = {
-  inputId: string,
-  onChange: () => void,
-  checked: boolean,
-  text: string,
+  inputId: string;
+  onChange: () => void;
+  checked: boolean;
+  text: string;
 };
 const SwitchItem = memo((props: SwitchItemProps): JSX.Element => {
-  const {
-    inputId, onChange, checked, text,
-  } = props;
+  const { inputId, onChange, checked, text } = props;
   return (
     <FormGroup switch>
       <Input id={inputId} type="switch" checked={checked} onChange={onChange} />
       <label htmlFor={inputId}>{text}</label>
     </FormGroup>
-
   );
 });
 
@@ -265,29 +332,32 @@ const ConfigurationSelector = memo((): JSX.Element => {
 });
 ConfigurationSelector.displayName = 'ConfigurationSelector';
 
-
 type ChangeStateButtonProps = {
-  onClick: () => void,
-  header: string,
-  data: string,
-  disabled?: boolean,
-}
+  onClick: () => void;
+  header: string;
+  data: string;
+  disabled?: boolean;
+};
 const ChangeStateButton = memo((props: ChangeStateButtonProps): JSX.Element => {
-  const {
-    onClick, header, data, disabled,
-  } = props;
+  const { onClick, header, data, disabled } = props;
   return (
-    <button type="button" className="d-flex align-items-center btn btn-sm border-0 my-1" disabled={disabled} onClick={onClick}>
-      <label className="ms-2 me-auto">{header}</label>
-      <label className="text-muted d-flex align-items-center ms-2 me-1">
+    <button
+      type="button"
+      className="d-flex align-items-center btn btn-sm border-0 my-1"
+      disabled={disabled}
+      onClick={onClick}
+    >
+      <span className="ms-2 me-auto">{header}</span>
+      <span className="text-muted d-flex align-items-center ms-2 me-1">
         {data}
-        <span className="material-symbols-outlined fs-5 py-0">navigate_next</span>
-      </label>
+        <span className="material-symbols-outlined fs-5 py-0">
+          navigate_next
+        </span>
+      </span>
     </button>
   );
 });
 
-
 const OptionsStatus = {
   Home: 'Home',
   Theme: 'Theme',
@@ -295,10 +365,9 @@ const OptionsStatus = {
   Indent: 'Indent',
   Paste: 'Paste',
 } as const;
-type OptionStatus = typeof OptionsStatus[keyof typeof OptionsStatus];
+type OptionStatus = (typeof OptionsStatus)[keyof typeof OptionsStatus];
 
 export const OptionsSelector = (): JSX.Element => {
-
   const { t } = useTranslation();
 
   const [dropdownOpen, setDropdownOpen] = useState(false);
@@ -309,12 +378,24 @@ export const OptionsSelector = (): JSX.Element => {
   const isIndentSizeForced = useAtomValue(isIndentSizeForcedAtom);
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
 
-  if (editorSettings == null || currentIndentSize == null || isIndentSizeForced == null) {
+  if (
+    editorSettings == null ||
+    currentIndentSize == null ||
+    isIndentSizeForced == null
+  ) {
     return <></>;
   }
 
   return (
-    <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
         className={`btn btn-sm btn-outline-neutral-secondary d-flex align-items-center justify-content-center
               ${isDeviceLargerThanMd ? '' : 'border-0'}
@@ -322,61 +403,62 @@ export const OptionsSelector = (): JSX.Element => {
               `}
       >
         <span className="material-symbols-outlined py-0 fs-5"> settings </span>
-        {
-          isDeviceLargerThanMd
-            ? <label className="ms-1 me-1">{t('page_edit.editor_config')}</label>
-            : <></>
-        }
+        {isDeviceLargerThanMd ? (
+          <span className="ms-1 me-1">{t('page_edit.editor_config')}</span>
+        ) : (
+          <></>
+        )}
       </DropdownToggle>
       <DropdownMenu container="body">
-        {
-          status === OptionsStatus.Home && (
-            <div className="d-flex flex-column">
-              <label className="text-muted ms-3">
-                {t('page_edit.editor_config')}
-              </label>
-              <hr className="my-1" />
-              <ChangeStateButton
-                onClick={() => setStatus(OptionsStatus.Theme)}
-                header={t('page_edit.theme')}
-                data={EDITORTHEME_LABEL_MAP[editorSettings.theme ?? ''] ?? ''}
-              />
-              <hr className="my-1" />
-              <ChangeStateButton
-                onClick={() => setStatus(OptionsStatus.Keymap)}
-                header={t('page_edit.keymap')}
-                data={KEYMAP_LABEL_MAP[editorSettings.keymapMode ?? ''] ?? ''}
-              />
-              <hr className="my-1" />
-              <ChangeStateButton
-                disabled={isIndentSizeForced}
-                onClick={() => setStatus(OptionsStatus.Indent)}
-                header={t('page_edit.indent')}
-                data={currentIndentSize.toString() ?? ''}
-              />
-              <hr className="my-1" />
-              <ChangeStateButton
-                onClick={() => setStatus(OptionsStatus.Paste)}
-                header={t('page_edit.paste.title')}
-                data={t(`page_edit.paste.${editorSettings.pasteMode ?? PasteMode.both}`) ?? ''}
-              />
-              <hr className="my-1" />
-              <ConfigurationSelector />
-            </div>
-          )
-        }
+        {status === OptionsStatus.Home && (
+          <div className="d-flex flex-column">
+            <span className="text-muted ms-3">
+              {t('page_edit.editor_config')}
+            </span>
+            <hr className="my-1" />
+            <ChangeStateButton
+              onClick={() => setStatus(OptionsStatus.Theme)}
+              header={t('page_edit.theme')}
+              data={EDITORTHEME_LABEL_MAP[editorSettings.theme ?? ''] ?? ''}
+            />
+            <hr className="my-1" />
+            <ChangeStateButton
+              onClick={() => setStatus(OptionsStatus.Keymap)}
+              header={t('page_edit.keymap')}
+              data={KEYMAP_LABEL_MAP[editorSettings.keymapMode ?? ''] ?? ''}
+            />
+            <hr className="my-1" />
+            <ChangeStateButton
+              disabled={isIndentSizeForced}
+              onClick={() => setStatus(OptionsStatus.Indent)}
+              header={t('page_edit.indent')}
+              data={currentIndentSize.toString() ?? ''}
+            />
+            <hr className="my-1" />
+            <ChangeStateButton
+              onClick={() => setStatus(OptionsStatus.Paste)}
+              header={t('page_edit.paste.title')}
+              data={
+                t(
+                  `page_edit.paste.${editorSettings.pasteMode ?? PasteMode.both}`,
+                ) ?? ''
+              }
+            />
+            <hr className="my-1" />
+            <ConfigurationSelector />
+          </div>
+        )}
         {status === OptionsStatus.Theme && (
           <ThemeSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
-        )
-        }
+        )}
         {status === OptionsStatus.Keymap && (
           <KeymapSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
-        )
-        }
+        )}
         {status === OptionsStatus.Indent && (
-          <IndentSizeSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
-        )
-        }
+          <IndentSizeSelector
+            onClickBefore={() => setStatus(OptionsStatus.Home)}
+          />
+        )}
         {status === OptionsStatus.Paste && (
           <PasteSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
         )}

+ 212 - 157
apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx

@@ -1,26 +1,38 @@
-import React, {
-  useCallback, useState, useEffect, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { PageGrant } from '@growi/core';
 import { globalEventTarget } from '@growi/core/dist/utils';
-import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-path-utils';
+import {
+  isTopPage,
+  isUsersProtectedPages,
+} from '@growi/core/dist/utils/page-path-utils';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import {
-  UncontrolledButtonDropdown, Button,
-  DropdownToggle, DropdownMenu, DropdownItem, Modal,
+  Button,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  Modal,
+  UncontrolledButtonDropdown,
 } from 'reactstrap';
 
-import { useIsEditable, useCurrentPageData, useCurrentPagePath } from '~/states/page';
+import {
+  useCurrentPageData,
+  useCurrentPagePath,
+  useIsEditable,
+} from '~/states/page';
 import {
   isAclEnabledAtom,
   isSlackConfiguredAtom,
 } from '~/states/server-configurations';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import {
-  useEditorMode, useSelectedGrant, useWaitingSaveProcessing, useIsSlackEnabled, EditorMode,
+  EditorMode,
+  useEditorMode,
+  useIsSlackEnabled,
+  useSelectedGrant,
+  useWaitingSaveProcessing,
 } from '~/states/ui/editor';
 import { useSWRxSlackChannels } from '~/stores/editor';
 import loggerFactory from '~/utils/logger';
@@ -28,18 +40,19 @@ import loggerFactory from '~/utils/logger';
 import { NotAvailable } from '../../NotAvailable';
 import { SlackNotification } from '../../SlackNotification';
 import type { SaveOptions } from '../PageEditor';
-
 import { GrantSelector } from './GrantSelector';
 
-
 const logger = loggerFactory('growi:SavePageControls');
 
-
-const SavePageButton = (props: { slackChannels: string, isSlackEnabled?: boolean, isDeviceLargerThanMd?: boolean }) => {
-
+const SavePageButton = (props: {
+  slackChannels: string;
+  isSlackEnabled?: boolean;
+  isDeviceLargerThanMd?: boolean;
+}) => {
   const { t } = useTranslation();
   const _isWaitingSaveProcessing = useWaitingSaveProcessing();
-  const [isSavePageModalShown, setIsSavePageModalShown] = useState<boolean>(false);
+  const [isSavePageModalShown, setIsSavePageModalShown] =
+    useState<boolean>(false);
   const [selectedGrant] = useSelectedGrant();
 
   const { slackChannels, isSlackEnabled = false, isDeviceLargerThanMd } = props;
@@ -48,35 +61,52 @@ const SavePageButton = (props: { slackChannels: string, isSlackEnabled?: boolean
 
   const save = useCallback(async (): Promise<void> => {
     // save
-    globalEventTarget.dispatchEvent(new CustomEvent<SaveOptions>('saveAndReturnToView', {
-      detail: {
-        wip: false, slackChannels, isSlackEnabled,
-      },
-    }));
+    globalEventTarget.dispatchEvent(
+      new CustomEvent<SaveOptions>('saveAndReturnToView', {
+        detail: {
+          wip: false,
+          slackChannels,
+          isSlackEnabled,
+        },
+      }),
+    );
   }, [isSlackEnabled, slackChannels]);
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
-    globalEventTarget.dispatchEvent(new CustomEvent<SaveOptions>('saveAndReturnToView', {
-      detail: {
-        wip: false, overwriteScopesOfDescendants: true, slackChannels, isSlackEnabled,
-      },
-    }));
+    globalEventTarget.dispatchEvent(
+      new CustomEvent<SaveOptions>('saveAndReturnToView', {
+        detail: {
+          wip: false,
+          overwriteScopesOfDescendants: true,
+          slackChannels,
+          isSlackEnabled,
+        },
+      }),
+    );
   }, [isSlackEnabled, slackChannels]);
 
   const saveAndMakeWip = useCallback(() => {
     // save
-    globalEventTarget.dispatchEvent(new CustomEvent<SaveOptions>('saveAndReturnToView', {
-      detail: {
-        wip: true, slackChannels, isSlackEnabled,
-      },
-    }));
+    globalEventTarget.dispatchEvent(
+      new CustomEvent<SaveOptions>('saveAndReturnToView', {
+        detail: {
+          wip: true,
+          slackChannels,
+          isSlackEnabled,
+        },
+      }),
+    );
   }, [isSlackEnabled, slackChannels]);
 
   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 restrictedGrantOverrideErrorTitle = t('Not available when "anyone with the link" is selected');
+  const restrictedGrantOverrideErrorTitle = t(
+    'Not available when "anyone with the link" is selected',
+  );
 
   return (
     <>
@@ -89,67 +119,89 @@ const SavePageButton = (props: { slackChannels: string, isSlackEnabled?: boolean
           onClick={save}
           disabled={isWaitingSaveProcessing}
         >
-          {isWaitingSaveProcessing && (
-            <LoadingSpinner />
-          )}
+          {isWaitingSaveProcessing && <LoadingSpinner />}
           {labelSubmitButton}
         </Button>
-        {
-          isDeviceLargerThanMd ? (
-            <>
-              <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} />
-              <DropdownMenu container="body" end>
+        {isDeviceLargerThanMd ? (
+          <>
+            <DropdownToggle
+              caret
+              color="primary"
+              disabled={isWaitingSaveProcessing}
+            />
+            <DropdownMenu container="body" end>
+              <NotAvailable
+                isDisabled={selectedGrant?.grant === PageGrant.GRANT_RESTRICTED}
+                classNamePrefix="grw-not-available-when-grant-restricted-is-selected"
+                title={restrictedGrantOverrideErrorTitle}
+              >
+                <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
+                  {labelOverwriteScopes}
+                </DropdownItem>
+              </NotAvailable>
+              <DropdownItem onClick={saveAndMakeWip}>
+                {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">
                 <NotAvailable
-                  isDisabled={selectedGrant?.grant === PageGrant.GRANT_RESTRICTED}
+                  isDisabled={
+                    selectedGrant?.grant === PageGrant.GRANT_RESTRICTED
+                  }
                   classNamePrefix="grw-not-available-when-grant-restricted-is-selected"
                   title={restrictedGrantOverrideErrorTitle}
                 >
-                  <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
+                  <button
+                    type="button"
+                    className="btn btn-primary"
+                    onClick={() => {
+                      setIsSavePageModalShown(false);
+                      saveAndOverwriteScopesOfDescendants();
+                    }}
+                  >
                     {labelOverwriteScopes}
-                  </DropdownItem>
+                  </button>
                 </NotAvailable>
-                <DropdownItem onClick={saveAndMakeWip}>
+                <button
+                  type="button"
+                  className="btn btn-primary"
+                  onClick={() => {
+                    setIsSavePageModalShown(false);
+                    saveAndMakeWip();
+                  }}
+                >
                   {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">
-                  <NotAvailable
-                    isDisabled={selectedGrant?.grant === PageGrant.GRANT_RESTRICTED}
-                    classNamePrefix="grw-not-available-when-grant-restricted-is-selected"
-                    title={restrictedGrantOverrideErrorTitle}
-                  >
-                    <button type="button" className="btn btn-primary" onClick={() => { setIsSavePageModalShown(false); saveAndOverwriteScopesOfDescendants() }}>
-                      {labelOverwriteScopes}
-                    </button>
-                  </NotAvailable>
-                  <button type="button" className="btn btn-primary" onClick={() => { setIsSavePageModalShown(false); saveAndMakeWip() }}>
-                    {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>
-            </>
-          )
-        }
+                </button>
+                <button
+                  type="button"
+                  className="btn btn-outline-neutral-secondary mx-auto mt-1"
+                  onClick={() => setIsSavePageModalShown(false)}
+                >
+                  <span className="mx-2">{t('Cancel')}</span>
+                </button>
+              </div>
+            </Modal>
+          </>
+        )}
       </UncontrolledButtonDropdown>
     </>
   );
 };
 
-
 export const SavePageControls = (): JSX.Element | null => {
   const { t } = useTranslation('commons');
   const currentPage = useCurrentPageData();
@@ -164,7 +216,8 @@ export const SavePageControls = (): JSX.Element | null => {
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
 
   const [slackChannels, setSlackChannels] = useState<string>('');
-  const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] = useState<boolean>(false);
+  const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] =
+    useState<boolean>(false);
 
   // DO NOT dependent on slackChannelsData directly: https://github.com/growilabs/growi/pull/7332
   const slackChannelsDataString = slackChannelsData?.toString();
@@ -175,7 +228,6 @@ export const SavePageControls = (): JSX.Element | null => {
     }
   }, [editorMode, setIsSlackEnabled, slackChannelsDataString]);
 
-
   const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
     setSlackChannels(slackChannels);
   }, []);
@@ -188,89 +240,92 @@ export const SavePageControls = (): JSX.Element | null => {
     return null;
   }
 
-  const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
+  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={setIsSlackEnabled}
-                      onChannelChange={slackChannelsChangedHandler}
-                      id="idForEditorNavbarBottom"
-                    />
-                  )}
-                </div>
-              )
-            }
+      {isDeviceLargerThanMd ? (
+        <>
+          {isSlackConfigured && (
+            <div className="me-2">
+              {isSlackEnabled != null && (
+                <SlackNotification
+                  isSlackEnabled={isSlackEnabled}
+                  slackChannels={slackChannels}
+                  onEnabledFlagChange={setIsSlackEnabled}
+                  onChannelChange={slackChannelsChangedHandler}
+                  id="idForEditorNavbarBottom"
+                />
+              )}
+            </div>
+          )}
 
-            {
-              isAclEnabled && (
-                <div className="me-2">
-                  <GrantSelector disabled={isGrantSelectorDisabledPage} />
-                </div>
-              )
-            }
+          {isAclEnabled && (
+            <div className="me-2">
+              <GrantSelector disabled={isGrantSelectorDisabledPage} />
+            </div>
+          )}
 
-            <SavePageButton isSlackEnabled={isSlackEnabled} slackChannels={slackChannels} isDeviceLargerThanMd />
-          </>
-        ) : (
-          <>
-            <SavePageButton isSlackEnabled={isSlackEnabled} 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
-                        disabled={isGrantSelectorDisabledPage}
-                        openInModal
-                      />
-                    </>
-                  )
-                }
+          <SavePageButton
+            isSlackEnabled={isSlackEnabled}
+            slackChannels={slackChannels}
+            isDeviceLargerThanMd
+          />
+        </>
+      ) : (
+        <>
+          <SavePageButton
+            isSlackEnabled={isSlackEnabled}
+            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
+                    disabled={isGrantSelectorDisabledPage}
+                    openInModal
+                  />
+                </>
+              )}
 
-                {
-                  isSlackConfigured && isSlackEnabled != null && (
-                    <>
-                      <SlackNotification
-                        isSlackEnabled={isSlackEnabled}
-                        slackChannels={slackChannels}
-                        onEnabledFlagChange={setIsSlackEnabled}
-                        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>
+              {isSlackConfigured && isSlackEnabled != null && (
+                <>
+                  <SlackNotification
+                    isSlackEnabled={isSlackEnabled}
+                    slackChannels={slackChannels}
+                    onEnabledFlagChange={setIsSlackEnabled}
+                    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>
-            </Modal>
-          </>
-        )
-      }
+            </div>
+          </Modal>
+        </>
+      )}
     </div>
   );
 };

+ 62 - 29
apps/app/src/client/components/PageEditor/GridEditModal.jsx

@@ -1,10 +1,7 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import BootstrapGrid from '~/client/models/BootstrapGrid';
 
@@ -19,7 +16,6 @@ const resSizeObj = {
   [resSizes.MD_SIZE]: { displayText: 'desktop' },
 };
 class GridEditModal extends React.Component {
-
   constructor(props) {
     super(props);
 
@@ -73,7 +69,10 @@ class GridEditModal extends React.Component {
 
   pasteCodedGrid() {
     const { colsRatios, responsiveSize } = this.state;
-    const convertedHTML = geu.convertRatiosAndSizeToHTML(colsRatios, responsiveSize);
+    const convertedHTML = geu.convertRatiosAndSizeToHTML(
+      colsRatios,
+      responsiveSize,
+    );
     const spaceTab = '    ';
     const pastedGridData = `::: editable-row\n<div class="container">\n${spaceTab}<div class="row">\n${convertedHTML}\n${spaceTab}</div>\n</div>\n:::`;
 
@@ -92,16 +91,22 @@ class GridEditModal extends React.Component {
     const { t } = this.props;
     const output = Object.entries(resSizeObj).map((responsiveSizeForMap) => {
       return (
-        <div key={responsiveSizeForMap[0]} className="form-check form-check-inline">
+        <div
+          key={responsiveSizeForMap[0]}
+          className="form-check form-check-inline"
+        >
           <input
             type="radio"
             className="form-check-input"
             id={responsiveSizeForMap[1].displayText}
             value={responsiveSizeForMap[1].displayText}
             checked={this.state.responsiveSize === responsiveSizeForMap[0]}
-            onChange={e => this.checkResposiveSize(responsiveSizeForMap[0])}
+            onChange={(e) => this.checkResposiveSize(responsiveSizeForMap[0])}
           />
-          <label className="form-label form-check-label" htmlFor={responsiveSizeForMap[1].displayText}>
+          <label
+            className="form-label form-check-label"
+            htmlFor={responsiveSizeForMap[1].displayText}
+          >
             {t(responsiveSizeForMap[1].displayText)}
           </label>
         </div>
@@ -119,17 +124,33 @@ class GridEditModal extends React.Component {
           {gridDivisions.map((gridDivision) => {
             const numOfDivisions = gridDivision.numberOfGridDivisions;
             return (
-              <div key={`${numOfDivisions}-divisions`} className="col-md-4 text-center">
-                <h6 className="dropdown-header">{numOfDivisions} {t('grid_edit.division')}</h6>
+              <div
+                key={`${numOfDivisions}-divisions`}
+                className="col-md-4 text-center"
+              >
+                <h6 className="dropdown-header">
+                  {numOfDivisions} {t('grid_edit.division')}
+                </h6>
                 {gridDivision.mapping.map((gridOneDivision) => {
                   const keyOfRow = `${numOfDivisions}-divisions-${gridOneDivision.join('-')}`;
                   return (
-                    <button key={keyOfRow} className="dropdown-item" type="button" onClick={() => { this.checkColsRatios(gridOneDivision) }}>
+                    <button
+                      key={keyOfRow}
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => {
+                        this.checkColsRatios(gridOneDivision);
+                      }}
+                    >
                       <div className="row">
                         {gridOneDivision.map((god, i) => {
                           const keyOfCol = `${keyOfRow}-${i}`;
                           const className = `bg-info col-${god} border`;
-                          return <span key={keyOfCol} className={className}>{god}</span>;
+                          return (
+                            <span key={keyOfCol} className={className}>
+                              {god}
+                            </span>
+                          );
                         })}
                       </div>
                     </button>
@@ -145,8 +166,10 @@ class GridEditModal extends React.Component {
 
   renderPreview() {
     const { t } = this.props;
-    const isMdSelected = this.state.responsiveSize === BootstrapGrid.ResponsiveSize.MD_SIZE;
-    const isXsSelected = this.state.responsiveSize === BootstrapGrid.ResponsiveSize.XS_SIZE;
+    const isMdSelected =
+      this.state.responsiveSize === BootstrapGrid.ResponsiveSize.MD_SIZE;
+    const isXsSelected =
+      this.state.responsiveSize === BootstrapGrid.ResponsiveSize.XS_SIZE;
     return (
       <div className="row grw-grid-edit-preview border my-4 p-3">
         <div className="col-lg-2">
@@ -178,19 +201,20 @@ class GridEditModal extends React.Component {
       const ratio = isBreakEnabled ? 12 : colsRatio;
       const key = `grid-preview-col-${i}`;
       const className = `col-${ratio} grid-edit-border-for-each-cols`;
-      return (
-        <div key={key} className={`${key} ${className}`}></div>
-      );
+      return <div key={key} className={`${key} ${className}`}></div>;
     });
-    return (
-      <div className="row">{convertedHTML}</div>
-    );
+    return <div className="row">{convertedHTML}</div>;
   }
 
   render() {
     const { t } = this.props;
     return (
-      <Modal isOpen={this.state.show} toggle={this.cancel} size="xl" className={`grw-grid-edit-modal ${styles['grw-grid-edit-modal']}`}>
+      <Modal
+        isOpen={this.state.show}
+        toggle={this.cancel}
+        size="xl"
+        className={`grw-grid-edit-modal ${styles['grw-grid-edit-modal']}`}
+      >
         <ModalHeader tag="h4" toggle={this.cancel}>
           {t('grid_edit.create_bootstrap_4_grid')}
         </ModalHeader>
@@ -214,7 +238,11 @@ class GridEditModal extends React.Component {
                     >
                       {this.renderSelectedGridPattern()}
                     </button>
-                    <div className="dropdown-menu grid-division-menu" aria-labelledby="dropdownMenuButton">
+                    <div
+                      className="dropdown-menu grid-division-menu"
+                      role="menu"
+                      aria-labelledby="dropdownMenuButton"
+                    >
                       {this.renderGridDivisionMenu()}
                     </div>
                   </div>
@@ -231,16 +259,22 @@ class GridEditModal extends React.Component {
             </div>
           </div>
           <h3 className="grw-modal-head">{t('preview')}</h3>
-          <div className="col-12">
-            {this.renderPreview()}
-          </div>
+          <div className="col-12">{this.renderPreview()}</div>
         </ModalBody>
         <ModalFooter className="grw-modal-footer">
           <div className="ms-auto">
-            <button type="button" className="me-2 btn btn-secondary" onClick={this.cancel}>
+            <button
+              type="button"
+              className="me-2 btn btn-secondary"
+              onClick={this.cancel}
+            >
               Cancel
             </button>
-            <button type="button" className="btn btn-primary" onClick={this.pasteCodedGrid}>
+            <button
+              type="button"
+              className="btn btn-primary"
+              onClick={this.pasteCodedGrid}
+            >
               Done
             </button>
           </div>
@@ -248,7 +282,6 @@ class GridEditModal extends React.Component {
       </Modal>
     );
   }
-
 }
 
 const GridEditModalFc = React.forwardRef((props, ref) => {

+ 32 - 8
apps/app/src/client/components/PageEditor/GridEditorUtil.js

@@ -2,7 +2,6 @@
  * Utility for grid editor
  */
 class GridEditorUtil {
-
   constructor() {
     // https://regex101.com/r/7BN2fR/11
     this.lineBeginPartOfGridRE = /^:::(\s.*)editable-row$/;
@@ -10,19 +9,42 @@ class GridEditorUtil {
     this.mappingAllGridDivisionPatterns = [
       {
         numberOfGridDivisions: 2,
-        mapping: [[2, 10], [4, 8], [6, 6], [8, 4], [10, 2]],
+        mapping: [
+          [2, 10],
+          [4, 8],
+          [6, 6],
+          [8, 4],
+          [10, 2],
+        ],
       },
       {
         numberOfGridDivisions: 3,
-        mapping: [[2, 5, 5], [5, 2, 5], [5, 5, 2], [4, 4, 4], [3, 3, 6], [3, 6, 3], [6, 3, 3]],
+        mapping: [
+          [2, 5, 5],
+          [5, 2, 5],
+          [5, 5, 2],
+          [4, 4, 4],
+          [3, 3, 6],
+          [3, 6, 3],
+          [6, 3, 3],
+        ],
       },
       {
         numberOfGridDivisions: 4,
-        mapping: [[2, 2, 4, 4], [4, 4, 2, 2], [2, 4, 2, 4], [4, 2, 4, 2], [3, 3, 3, 3], [2, 2, 2, 6], [6, 2, 2, 2]],
+        mapping: [
+          [2, 2, 4, 4],
+          [4, 4, 2, 2],
+          [2, 4, 2, 4],
+          [4, 2, 4, 2],
+          [3, 3, 3, 3],
+          [2, 2, 2, 6],
+          [6, 2, 2, 2],
+        ],
       },
     ];
     this.isInGridBlock = this.isInGridBlock.bind(this);
-    this.replaceGridWithHtmlWithEditor = this.replaceGridWithHtmlWithEditor.bind(this);
+    this.replaceGridWithHtmlWithEditor =
+      this.replaceGridWithHtmlWithEditor.bind(this);
   }
 
   /**
@@ -34,7 +56,7 @@ class GridEditorUtil {
     if (bog === null || eog === null) {
       return false;
     }
-    return (JSON.stringify(bog) !== JSON.stringify(eog));
+    return JSON.stringify(bog) !== JSON.stringify(eog);
   }
 
   /**
@@ -98,7 +120,10 @@ class GridEditorUtil {
     const lastLine = editor.getDoc().lastLine();
 
     if (this.lineEndPartOfGridRE.test(editor.getDoc().getLine(curPos.line))) {
-      return { line: curPos.line, ch: editor.getDoc().getLine(curPos.line).length };
+      return {
+        line: curPos.line,
+        ch: editor.getDoc().getLine(curPos.line).length,
+      };
     }
 
     let line = curPos.line + 1;
@@ -147,7 +172,6 @@ class GridEditorUtil {
     });
     return cols.join('\n');
   }
-
 }
 
 // singleton pattern

+ 207 - 95
apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx

@@ -1,19 +1,35 @@
 import React, {
-  useState, useCallback, useMemo, useEffect, type JSX,
+  type JSX,
+  useCallback,
+  useEffect,
+  useMemo,
+  useState,
 } from 'react';
-
-import { MarkdownTable, useHandsontableModalForEditorStatus, useHandsontableModalForEditorActions } from '@growi/editor';
+import {
+  MarkdownTable,
+  useHandsontableModalForEditorActions,
+  useHandsontableModalForEditorStatus,
+} from '@growi/editor';
 import { HotTable } from '@handsontable/react';
 import type Handsontable from 'handsontable';
 import { useTranslation } from 'next-i18next';
 import {
   Collapse,
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Modal,
+  ModalBody,
+  ModalFooter,
+  ModalHeader,
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-import { replaceFocusedMarkdownTableWithEditor, getMarkdownTable } from '~/client/components/PageEditor/markdown-table-util-for-editor';
-import { useHandsontableModalActions, useHandsontableModalStatus } from '~/states/ui/modal/handsontable';
+import {
+  getMarkdownTable,
+  replaceFocusedMarkdownTableWithEditor,
+} from '~/client/components/PageEditor/markdown-table-util-for-editor';
+import {
+  useHandsontableModalActions,
+  useHandsontableModalStatus,
+} from '~/states/ui/modal/handsontable';
 
 import ExpandOrContractButton from '../../ExpandOrContractButton';
 import { MarkdownTableDataImportForm } from '../MarkdownTableDataImportForm';
@@ -42,7 +58,9 @@ type HandsontableModalSubstanceProps = {
 /**
  * HandsontableModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  */
-const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX.Element => {
+const HandsontableModalSubstance = (
+  props: HandsontableModalSubstanceProps,
+): JSX.Element => {
   const {
     initialTable,
     autoFormatMarkdownTable,
@@ -99,11 +117,16 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
    * However, all operations are reflected in the data to be saved because the HotTable data is used when the save method is called.
    */
   const [hotTable, setHotTable] = useState<HotTable | null>();
-  const [hotTableContainer, setHotTableContainer] = useState<HTMLDivElement | null>();
-  const [isDataImportAreaExpanded, setIsDataImportAreaExpanded] = useState<boolean>(false);
-  const [markdownTable, setMarkdownTable] = useState<MarkdownTable>(defaultMarkdownTable);
-  const [markdownTableOnInit, setMarkdownTableOnInit] = useState<MarkdownTable>(defaultMarkdownTable);
-  const [handsontableHeight, setHandsontableHeight] = useState<number>(DEFAULT_HOT_HEIGHT);
+  const [hotTableContainer, setHotTableContainer] =
+    useState<HTMLDivElement | null>();
+  const [isDataImportAreaExpanded, setIsDataImportAreaExpanded] =
+    useState<boolean>(false);
+  const [markdownTable, setMarkdownTable] =
+    useState<MarkdownTable>(defaultMarkdownTable);
+  const [markdownTableOnInit, setMarkdownTableOnInit] =
+    useState<MarkdownTable>(defaultMarkdownTable);
+  const [handsontableHeight, setHandsontableHeight] =
+    useState<number>(DEFAULT_HOT_HEIGHT);
   const [handsontableWidth, setHandsontableWidth] = useState<number>(0);
 
   // Memoize window resize handler
@@ -117,13 +140,15 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
   }, [hotTableContainer]);
 
   // Memoize debounced handler
-  const debouncedHandleWindowExpandedChange = useMemo(() => (
-    debounce(100, handleWindowExpandedChange)
-  ), [handleWindowExpandedChange]);
+  const debouncedHandleWindowExpandedChange = useMemo(
+    () => debounce(100, handleWindowExpandedChange),
+    [handleWindowExpandedChange],
+  );
 
   // Initialize table data when component mounts (modal opens)
   useEffect(() => {
-    const initTableInstance = initialTable == null ? defaultMarkdownTable : initialTable.clone();
+    const initTableInstance =
+      initialTable == null ? defaultMarkdownTable : initialTable.clone();
     setMarkdownTable(initialTable ?? defaultMarkdownTable);
     setMarkdownTableOnInit(initTableInstance);
     debouncedHandleWindowExpandedChange();
@@ -172,7 +197,13 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
 
     onSave(newMarkdownTable);
     cancel();
-  }, [hotTable, markdownTable.options.align, autoFormatMarkdownTable, onSave, cancel]);
+  }, [
+    hotTable,
+    markdownTable.options.align,
+    autoFormatMarkdownTable,
+    onSave,
+    cancel,
+  ]);
 
   const beforeColumnResizeHandler = (currentColumn) => {
     /*
@@ -181,7 +212,6 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
      *
      * At the moment, using 'afterColumnResizeHandler' instead.
      */
-
     // store column index
     // this.manuallyResizedColumnIndicesSet.add(currentColumn);
   };
@@ -236,7 +266,12 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
 
     for (let i = 0; i < align.length; i++) {
       for (let j = 0; j < hotInstance.countRows(); j++) {
-        hotInstance.setCellMeta(j, i, 'className', MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]]);
+        hotInstance.setCellMeta(
+          j,
+          i,
+          'className',
+          MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]],
+        );
       }
     }
     hotInstance.render();
@@ -282,49 +317,48 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
     const removed = align.splice(columns[0], columns.length);
 
     /*
-      * The following is a description of the algorithm for the alignment synchronization.
-      *
-      * Consider the case where the target is X and the columns are [2,3] and data is as follows.
-      *
-      * 0 1 2 3 4 5 (insert position number)
-      * +-+-+-+-+-+
-      * | | | | | |
-      * +-+-+-+-+-+
-      *  0 1 2 3 4  (column index number)
-      *
-      * At first, remove columns by the splice.
-      *
-      * 0 1 2   4 5
-      * +-+-+   +-+
-      * | | |   | |
-      * +-+-+   +-+
-      *  0 1     4
-      *
-      * Next, insert those columns into a new position.
-      * However the target number is a insert position number before deletion, it may be changed.
-      * These are changed as follows.
-      *
-      * Before:
-      * 0 1 2   4 5
-      * +-+-+   +-+
-      * | | |   | |
-      * +-+-+   +-+
-      *
-      * After:
-      * 0 1 2   2 3
-      * +-+-+   +-+
-      * | | |   | |
-      * +-+-+   +-+
-      *
-      * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed.
-      * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length.
-      *
-      */
+     * The following is a description of the algorithm for the alignment synchronization.
+     *
+     * Consider the case where the target is X and the columns are [2,3] and data is as follows.
+     *
+     * 0 1 2 3 4 5 (insert position number)
+     * +-+-+-+-+-+
+     * | | | | | |
+     * +-+-+-+-+-+
+     *  0 1 2 3 4  (column index number)
+     *
+     * At first, remove columns by the splice.
+     *
+     * 0 1 2   4 5
+     * +-+-+   +-+
+     * | | |   | |
+     * +-+-+   +-+
+     *  0 1     4
+     *
+     * Next, insert those columns into a new position.
+     * However the target number is a insert position number before deletion, it may be changed.
+     * These are changed as follows.
+     *
+     * Before:
+     * 0 1 2   4 5
+     * +-+-+   +-+
+     * | | |   | |
+     * +-+-+   +-+
+     *
+     * After:
+     * 0 1 2   2 3
+     * +-+-+   +-+
+     * | | |   | |
+     * +-+-+   +-+
+     *
+     * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed.
+     * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length.
+     *
+     */
     let insertPosition = 0;
     if (target <= columns[0]) {
       insertPosition = target;
-    }
-    else if (columns[columns.length - 1] < target) {
+    } else if (columns[columns.length - 1] < target) {
       insertPosition = target - columns.length;
     }
 
@@ -334,7 +368,9 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
 
     setMarkdownTable((prevMarkdownTable) => {
       // change only align info, so share table data to avoid redundant copy
-      const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { align });
+      const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, {
+        align,
+      });
       return newMarkdownTable;
     });
 
@@ -347,7 +383,9 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
   const align = (direction: string, startCol: number, endCol: number) => {
     setMarkdownTable((prevMarkdownTable) => {
       // change only align info, so share table data to avoid redundant copy
-      const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { align: [].concat(prevMarkdownTable.options.align) });
+      const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, {
+        align: [].concat(prevMarkdownTable.options.align),
+      });
       for (let i = startCol; i <= endCol; i++) {
         newMarkdownTable.options.align[i] = direction;
       }
@@ -365,8 +403,14 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
     const selectedRange = hotTable.hotInstance.getSelectedRange();
     if (selectedRange == null) return;
 
-    const startCol = selectedRange[0].from.col < selectedRange[0].to.col ? selectedRange[0].from.col : selectedRange[0].to.col;
-    const endCol = selectedRange[0].from.col < selectedRange[0].to.col ? selectedRange[0].to.col : selectedRange[0].from.col;
+    const startCol =
+      selectedRange[0].from.col < selectedRange[0].to.col
+        ? selectedRange[0].from.col
+        : selectedRange[0].to.col;
+    const endCol =
+      selectedRange[0].from.col < selectedRange[0].to.col
+        ? selectedRange[0].to.col
+        : selectedRange[0].from.col;
 
     align(direction, startCol, endCol);
   };
@@ -413,15 +457,23 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
               {
                 name: 'Left',
                 key: 'align_columns:1',
-                callback: (key, selection) => { align('l', selection[0].start.col, selection[0].end.col) },
-              }, {
+                callback: (key, selection) => {
+                  align('l', selection[0].start.col, selection[0].end.col);
+                },
+              },
+              {
                 name: 'Center',
                 key: 'align_columns:2',
-                callback: (key, selection) => { align('c', selection[0].start.col, selection[0].end.col) },
-              }, {
+                callback: (key, selection) => {
+                  align('c', selection[0].start.col, selection[0].end.col);
+                },
+              },
+              {
                 name: 'Right',
                 key: 'align_columns:3',
-                callback: (key, selection) => { align('r', selection[0].start.col, selection[0].end.col) },
+                callback: (key, selection) => {
+                  align('r', selection[0].start.col, selection[0].end.col);
+                },
               },
             ],
           },
@@ -442,7 +494,12 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
         contractWindow={contractWindow}
         expandWindow={expandWindow}
       />
-      <button type="button" className="btn btn-close ms-2" onClick={cancel} aria-label="Close"></button>
+      <button
+        type="button"
+        className="btn btn-close ms-2"
+        onClick={cancel}
+        aria-label="Close"
+      ></button>
     </span>
   );
 
@@ -462,22 +519,51 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
             onClick={toggleDataImportArea}
           >
             <span className="me-3">{t('handsontable_modal.data_import')}</span>
-            <span className="material-symbols-outlined">{isDataImportAreaExpanded ? 'expand_less' : 'expand_more'}</span>
+            <span className="material-symbols-outlined">
+              {isDataImportAreaExpanded ? 'expand_less' : 'expand_more'}
+            </span>
           </button>
-          <div role="group" className="btn-group">
-            <button type="button" className="btn btn-outline-neutral-secondary" onClick={() => { alignButtonHandler('l') }}>
-              <span className="material-symbols-outlined">format_align_left</span>
+          <fieldset className="btn-group border-0 m-0 p-0">
+            <button
+              type="button"
+              className="btn btn-outline-neutral-secondary"
+              onClick={() => {
+                alignButtonHandler('l');
+              }}
+            >
+              <span className="material-symbols-outlined">
+                format_align_left
+              </span>
             </button>
-            <button type="button" className="btn btn-outline-neutral-secondary" onClick={() => { alignButtonHandler('c') }}>
-              <span className="material-symbols-outlined">format_align_center</span>
+            <button
+              type="button"
+              className="btn btn-outline-neutral-secondary"
+              onClick={() => {
+                alignButtonHandler('c');
+              }}
+            >
+              <span className="material-symbols-outlined">
+                format_align_center
+              </span>
             </button>
-            <button type="button" className="btn btn-outline-neutral-secondary" onClick={() => { alignButtonHandler('r') }}>
-              <span className="material-symbols-outlined">format_align_right</span>
+            <button
+              type="button"
+              className="btn btn-outline-neutral-secondary"
+              onClick={() => {
+                alignButtonHandler('r');
+              }}
+            >
+              <span className="material-symbols-outlined">
+                format_align_right
+              </span>
             </button>
-          </div>
+          </fieldset>
           <Collapse isOpen={isDataImportAreaExpanded}>
             <div className="py-3 border-bottom">
-              <MarkdownTableDataImportForm onCancel={toggleDataImportArea} onImport={importData} />
+              <MarkdownTableDataImportForm
+                onCancel={toggleDataImportArea}
+                onImport={importData}
+              />
             </div>
           </Collapse>
         </div>
@@ -505,10 +591,24 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
         </div>
       </ModalBody>
       <ModalFooter className="grw-modal-footer">
-        <button type="button" className="btn btn-outline-danger" onClick={reset}>{t('commons:Reset')}</button>
+        <button
+          type="button"
+          className="btn btn-outline-danger"
+          onClick={reset}
+        >
+          {t('commons:Reset')}
+        </button>
         <div className="ms-auto">
-          <button type="button" className="me-2 btn btn-outline-neutral-secondary" onClick={cancel}>{t('handsontable_modal.cancel')}</button>
-          <button type="button" className="btn btn-primary" onClick={save}>{t('handsontable_modal.done')}</button>
+          <button
+            type="button"
+            className="me-2 btn btn-outline-neutral-secondary"
+            onClick={cancel}
+          >
+            {t('handsontable_modal.cancel')}
+          </button>
+          <button type="button" className="btn btn-primary" onClick={save}>
+            {t('handsontable_modal.done')}
+          </button>
         </div>
       </ModalFooter>
     </>
@@ -526,7 +626,8 @@ export const HandsontableModal = (): JSX.Element => {
 
   // for Editor
   const handsontableModalForEditorData = useHandsontableModalForEditorStatus();
-  const { close: closeHandsontableModalForEditor } = useHandsontableModalForEditorActions();
+  const { close: closeHandsontableModalForEditor } =
+    useHandsontableModalForEditorActions();
 
   const isOpenedForView = handsontableModalData.isOpened;
   const isOpenedForEditor = handsontableModalForEditorData.isOpened;
@@ -541,10 +642,15 @@ export const HandsontableModal = (): JSX.Element => {
       return editor != null ? getMarkdownTable(editor) : undefined;
     }
     return handsontableModalData.table;
-  }, [isOpenedForEditor, handsontableModalForEditorData.editor, handsontableModalData.table]);
+  }, [
+    isOpenedForEditor,
+    handsontableModalForEditorData.editor,
+    handsontableModalData.table,
+  ]);
 
   // Determine autoFormatMarkdownTable based on mode
-  const autoFormatMarkdownTable = handsontableModalData.autoFormatMarkdownTable ?? false;
+  const autoFormatMarkdownTable =
+    handsontableModalData.autoFormatMarkdownTable ?? false;
 
   const toggle = useCallback(() => {
     closeHandsontableModal();
@@ -561,17 +667,23 @@ export const HandsontableModal = (): JSX.Element => {
   }, []);
 
   // Create save handler based on mode
-  const handleSave = useCallback((newMarkdownTable: MarkdownTable) => {
-    if (isOpenedForEditor) {
-      const editor = handsontableModalForEditorData.editor;
-      if (editor != null) {
-        replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
+  const handleSave = useCallback(
+    (newMarkdownTable: MarkdownTable) => {
+      if (isOpenedForEditor) {
+        const editor = handsontableModalForEditorData.editor;
+        if (editor != null) {
+          replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
+        }
+      } else {
+        handsontableModalData.onSave?.(newMarkdownTable);
       }
-    }
-    else {
-      handsontableModalData.onSave?.(newMarkdownTable);
-    }
-  }, [isOpenedForEditor, handsontableModalForEditorData.editor, handsontableModalData]);
+    },
+    [
+      isOpenedForEditor,
+      handsontableModalForEditorData.editor,
+      handsontableModalData,
+    ],
+  );
 
   return (
     <Modal

+ 4 - 2
apps/app/src/client/components/PageEditor/HandsontableModal/dynamic.tsx

@@ -1,5 +1,4 @@
 import type { JSX } from 'react';
-
 import { useHandsontableModalForEditorStatus } from '@growi/editor';
 
 import { useLazyLoader } from '~/components/utils/use-lazy-loader';
@@ -13,7 +12,10 @@ export const HandsontableModalLazyLoaded = (): JSX.Element => {
 
   const HandsontableModal = useLazyLoader<HandsontableModalProps>(
     'handsontable-modal',
-    () => import('./HandsontableModal').then(mod => ({ default: mod.HandsontableModal })),
+    () =>
+      import('./HandsontableModal').then((mod) => ({
+        default: mod.HandsontableModal,
+      })),
     status?.isOpened || statusForEditor?.isOpened || false,
   );
 

+ 174 - 92
apps/app/src/client/components/PageEditor/LinkEditModal/LinkEditModal.tsx

@@ -1,17 +1,17 @@
-import React, {
-  useEffect, useState, useCallback,
-} from 'react';
-
-import path from 'path';
-
+import type React from 'react';
+import { useCallback, useEffect, useState } from 'react';
 import { Linker } from '@growi/editor/dist/models/linker';
-import { useLinkEditModalStatus, useLinkEditModalActions } from '@growi/editor/dist/states/modal/link-edit';
+import {
+  useLinkEditModalActions,
+  useLinkEditModalStatus,
+} from '@growi/editor/dist/states/modal/link-edit';
 import { useTranslation } from 'next-i18next';
+import path from 'path';
 import {
   Modal,
-  ModalHeader,
   ModalBody,
   ModalFooter,
+  ModalHeader,
   Popover,
   PopoverBody,
 } from 'reactstrap';
@@ -25,10 +25,8 @@ import loggerFactory from '~/utils/logger';
 import SearchTypeahead from '../../SearchTypeahead';
 import Preview from '../Preview';
 
-
 import styles from './LinkEditPreview.module.scss';
 
-
 const logger = loggerFactory('growi:components:LinkEditModal');
 
 /**
@@ -52,11 +50,16 @@ const LinkEditModalSubstance: React.FC = () => {
   const [permalink, setPermalink] = useState<string>('');
   const [isPreviewOpen, setIsPreviewOpen] = useState<boolean>(false);
 
-  const getRootPath = useCallback((type: string) => {
-    // rootPaths of md link and pukiwiki link are different
-    if (currentPath == null) return '';
-    return type === Linker.types.markdownLink ? path.dirname(currentPath) : currentPath;
-  }, [currentPath]);
+  const getRootPath = useCallback(
+    (type: string) => {
+      // rootPaths of md link and pukiwiki link are different
+      if (currentPath == null) return '';
+      return type === Linker.types.markdownLink
+        ? path.dirname(currentPath)
+        : currentPath;
+    },
+    [currentPath],
+  );
 
   // parse link, link is ...
   // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga')
@@ -64,52 +67,61 @@ const LinkEditModalSubstance: React.FC = () => {
   // case-3. relative path of this growi's page (ex. '../fuga', 'hoge')
   // case-4. external link (ex. 'https://growi.org')
   // case-5. the others (ex. '')
-  const parseLinkAndSetState = useCallback((link: string, type: string) => {
-    // create url from link, add dummy origin if link is not valid url.
-    // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
-    // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
-    let isFqcn = false;
-    let isUseRelativePath = false;
-    let url;
-    try {
-      const url = new URL(link, 'http://example.com');
-      isFqcn = url.origin !== 'http://example.com';
-    }
-    catch (err) {
-      logger.debug(err);
-    }
-
-    // case-1: when link is this growi's page url, return pathname only
-    let reshapedLink = url != null && url.origin === window.location.origin
-      ? decodeURIComponent(url.pathname)
-      : link;
+  const parseLinkAndSetState = useCallback(
+    (link: string, type: string) => {
+      // create url from link, add dummy origin if link is not valid url.
+      // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
+      // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
+      let isFqcn = false;
+      let isUseRelativePath = false;
+      let url: URL | undefined;
+      try {
+        url = new URL(link, 'http://example.com');
+        isFqcn = url.origin !== 'http://example.com';
+      } catch (err) {
+        logger.debug(err);
+      }
 
-    // case-3
-    if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') {
-      isUseRelativePath = true;
-      const rootPath = getRootPath(type);
-      reshapedLink = path.resolve(rootPath, reshapedLink);
-    }
+      // case-1: when link is this growi's page url, return pathname only
+      let reshapedLink =
+        url != null && url.origin === window.location.origin
+          ? decodeURIComponent(url.pathname)
+          : link;
+
+      // case-3
+      if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') {
+        isUseRelativePath = true;
+        const rootPath = getRootPath(type);
+        reshapedLink = path.resolve(rootPath, reshapedLink);
+      }
 
-    setLinkInputValue(reshapedLink);
-    setIsUseRelativePath(isUseRelativePath);
-  }, [getRootPath]);
+      setLinkInputValue(reshapedLink);
+      setIsUseRelativePath(isUseRelativePath);
+    },
+    [getRootPath],
+  );
 
   useEffect(() => {
-    if (linkEditModalStatus == null) { return }
-    const { label = '', link = '' } = linkEditModalStatus.defaultMarkdownLink ?? {};
-    const { type = Linker.types.markdownLink } = linkEditModalStatus.defaultMarkdownLink ?? {};
+    if (linkEditModalStatus == null) {
+      return;
+    }
+    const { label = '', link = '' } =
+      linkEditModalStatus.defaultMarkdownLink ?? {};
+    const { type = Linker.types.markdownLink } =
+      linkEditModalStatus.defaultMarkdownLink ?? {};
 
     parseLinkAndSetState(link, type);
     setLabelInputValue(label);
     setIsUsePermanentLink(false);
     setPermalink('');
     setLinkerType(type);
-
   }, [linkEditModalStatus, parseLinkAndSetState]);
 
   const toggleIsUseRelativePath = useCallback(() => {
-    if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) {
+    if (
+      !linkInputValue.startsWith('/') ||
+      linkerType === Linker.types.growiLink
+    ) {
       return;
     }
 
@@ -128,7 +140,7 @@ const LinkEditModalSubstance: React.FC = () => {
     setIsUseRelativePath(false);
   }, [permalink, linkerType, isUsePermanentLink]);
 
-  const setMarkdownHandler = useCallback(async() => {
+  const setMarkdownHandler = useCallback(async () => {
     const path = linkInputValue;
     let markdown = '';
     let pagePath = '';
@@ -137,20 +149,23 @@ const LinkEditModalSubstance: React.FC = () => {
     if (path.startsWith('/')) {
       try {
         const pathWithoutFragment = new URL(path, 'http://dummy').pathname;
-        const isPermanentLink = validator.isMongoId(pathWithoutFragment.slice(1));
+        const isPermanentLink = validator.isMongoId(
+          pathWithoutFragment.slice(1),
+        );
         const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
 
-        const { data } = await apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
+        const { data } = await apiv3Get('/page', {
+          path: pathWithoutFragment,
+          page_id: pageId,
+        });
         const { page } = data;
         markdown = page.revision.body;
         pagePath = page.path;
         permalink = page.id;
-      }
-      catch (err) {
+      } catch (err) {
         setPreviewError(err.message);
       }
-    }
-    else {
+    } else {
       setPreviewError(t('link_edit.page_not_found_in_preview', { path }));
     }
 
@@ -160,11 +175,13 @@ const LinkEditModalSubstance: React.FC = () => {
   }, [linkInputValue, t]);
 
   const generateLink = useCallback(() => {
-
     let reshapedLink = linkInputValue;
     if (isUseRelativePath) {
       const rootPath = getRootPath(linkerType);
-      reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
+      reshapedLink =
+        rootPath === linkInputValue
+          ? '.'
+          : path.relative(rootPath, linkInputValue);
     }
 
     if (isUsePermanentLink && permalink != null) {
@@ -172,7 +189,15 @@ const LinkEditModalSubstance: React.FC = () => {
     }
 
     return new Linker(linkerType, labelInputValue, reshapedLink);
-  }, [linkInputValue, isUseRelativePath, getRootPath, linkerType, isUsePermanentLink, permalink, labelInputValue]);
+  }, [
+    linkInputValue,
+    isUseRelativePath,
+    getRootPath,
+    linkerType,
+    isUsePermanentLink,
+    permalink,
+    labelInputValue,
+  ]);
 
   const renderLinkPreview = (): React.JSX.Element => {
     const linker = generateLink();
@@ -180,12 +205,18 @@ const LinkEditModalSubstance: React.FC = () => {
       <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
         <div className="card card-disabled w-100 p-1 mb-0">
           <p className="text-start text-muted mb-1 small">Markdown</p>
-          <p className="text-center text-truncate text-muted">{linker.generateMarkdownText()}</p>
+          <p className="text-center text-truncate text-muted">
+            {linker.generateMarkdownText()}
+          </p>
         </div>
         <div className="d-flex align-items-center justify-content-center">
           <span className="lead mx-3">
-            <span className="d-none d-sm-block material-symbols-outlined">arrow_right</span>
-            <span className="d-sm-none material-symbols-outlined">arrow_drop_down</span>
+            <span className="d-none d-sm-block material-symbols-outlined">
+              arrow_right
+            </span>
+            <span className="d-sm-none material-symbols-outlined">
+              arrow_drop_down
+            </span>
           </span>
         </div>
         <div className="card w-100 p-1 mb-0">
@@ -212,16 +243,22 @@ const LinkEditModalSubstance: React.FC = () => {
     setLabelInputValue(label);
   }, []);
 
-  const handleChangeLinkInput = useCallback((link) => {
-    let useRelativePath = isUseRelativePath;
-    if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) {
-      useRelativePath = false;
-    }
-    setLinkInputValue(link);
-    setIsUseRelativePath(useRelativePath);
-    setIsUsePermanentLink(false);
-    setPermalink('');
-  }, [linkInputValue, isUseRelativePath, linkerType]);
+  const handleChangeLinkInput = useCallback(
+    (link) => {
+      let useRelativePath = isUseRelativePath;
+      if (
+        !linkInputValue.startsWith('/') ||
+        linkerType === Linker.types.growiLink
+      ) {
+        useRelativePath = false;
+      }
+      setLinkInputValue(link);
+      setIsUseRelativePath(useRelativePath);
+      setIsUsePermanentLink(false);
+      setPermalink('');
+    },
+    [linkInputValue, isUseRelativePath, linkerType],
+  );
 
   const save = useCallback(() => {
     const linker = generateLink();
@@ -233,7 +270,7 @@ const LinkEditModalSubstance: React.FC = () => {
     close();
   }, [generateLink, linkEditModalStatus, close]);
 
-  const toggleIsPreviewOpen = useCallback(async() => {
+  const toggleIsPreviewOpen = useCallback(async () => {
     // open popover
     if (!isPreviewOpen) {
       setMarkdownHandler();
@@ -259,18 +296,36 @@ const LinkEditModalSubstance: React.FC = () => {
                 autoFocus
               />
               <div className="d-none d-sm-block">
-                <button type="button" id="preview-btn" className={`btn btn-info btn-page-preview ${styles['btn-page-preview']}`}>
-                  <span className="material-symbols-outlined">find_in_page</span>
+                <button
+                  type="button"
+                  id="preview-btn"
+                  className={`btn btn-info btn-page-preview ${styles['btn-page-preview']}`}
+                >
+                  <span className="material-symbols-outlined">
+                    find_in_page
+                  </span>
                 </button>
-                <Popover trigger="focus" placement="right" isOpen={isPreviewOpen} target="preview-btn" toggle={toggleIsPreviewOpen}>
+                <Popover
+                  trigger="focus"
+                  placement="right"
+                  isOpen={isPreviewOpen}
+                  target="preview-btn"
+                  toggle={toggleIsPreviewOpen}
+                >
                   <PopoverBody>
-                    {markdown != null && pagePath != null && rendererOptions != null
-                    && (
-                      <div className={`linkedit-preview ${styles['linkedit-preview']}`}>
-                        <Preview markdown={markdown} pagePath={pagePath} rendererOptions={rendererOptions} />
-                      </div>
-                    )
-                    }
+                    {markdown != null &&
+                      pagePath != null &&
+                      rendererOptions != null && (
+                        <div
+                          className={`linkedit-preview ${styles['linkedit-preview']}`}
+                        >
+                          <Preview
+                            markdown={markdown}
+                            pagePath={pagePath}
+                            rendererOptions={rendererOptions}
+                          />
+                        </div>
+                      )}
                   </PopoverBody>
                 </Popover>
               </div>
@@ -286,7 +341,7 @@ const LinkEditModalSubstance: React.FC = () => {
                 className="form-control"
                 id="label"
                 value={labelInputValue}
-                onChange={e => handleChangeLabelInput(e.target.value)}
+                onChange={(e) => handleChangeLabelInput(e.target.value)}
                 disabled={linkerType === Linker.types.growiLink}
                 placeholder={linkInputValue}
               />
@@ -302,7 +357,9 @@ const LinkEditModalSubstance: React.FC = () => {
       <div className="card custom-card pt-3">
         <form className="mb-0">
           <div className="mb-0 row">
-            <label className="form-label col-sm-3">{t('link_edit.path_format')}</label>
+            <span className="form-label col-sm-3">
+              {t('link_edit.path_format')}
+            </span>
             <div className="col-sm-9">
               <div className="form-check form-check-info form-check-inline">
                 <input
@@ -311,9 +368,15 @@ const LinkEditModalSubstance: React.FC = () => {
                   type="checkbox"
                   checked={isUseRelativePath}
                   onChange={toggleIsUseRelativePath}
-                  disabled={!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink}
+                  disabled={
+                    !linkInputValue.startsWith('/') ||
+                    linkerType === Linker.types.growiLink
+                  }
                 />
-                <label className="form-label form-check-label" htmlFor="relativePath">
+                <label
+                  className="form-label form-check-label"
+                  htmlFor="relativePath"
+                >
                   {t('link_edit.use_relative_path')}
                 </label>
               </div>
@@ -324,9 +387,14 @@ const LinkEditModalSubstance: React.FC = () => {
                   type="checkbox"
                   checked={isUsePermanentLink}
                   onChange={toggleIsUsePamanentLink}
-                  disabled={permalink === '' || linkerType === Linker.types.growiLink}
+                  disabled={
+                    permalink === '' || linkerType === Linker.types.growiLink
+                  }
                 />
-                <label className="form-label form-check-label" htmlFor="permanentLink">
+                <label
+                  className="form-label form-check-label"
+                  htmlFor="permanentLink"
+                >
                   {t('link_edit.use_permanent_link')}
                 </label>
               </div>
@@ -358,11 +426,19 @@ const LinkEditModalSubstance: React.FC = () => {
         </div>
       </ModalBody>
       <ModalFooter>
-        { previewError && <span className="text-danger">{previewError}</span>}
-        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={close}>
+        {previewError && <span className="text-danger">{previewError}</span>}
+        <button
+          type="button"
+          className="btn btn-sm btn-outline-secondary mx-1"
+          onClick={close}
+        >
           {t('Cancel')}
         </button>
-        <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={save}>
+        <button
+          type="submit"
+          className="btn btn-sm btn-primary mx-1"
+          onClick={save}
+        >
           {t('Done')}
         </button>
       </ModalFooter>
@@ -380,7 +456,13 @@ export const LinkEditModal = (): React.JSX.Element => {
   const isOpened = linkEditModalStatus?.isOpened ?? false;
 
   return (
-    <Modal className="link-edit-modal" isOpen={isOpened} toggle={close} size="lg" autoFocus={false}>
+    <Modal
+      className="link-edit-modal"
+      isOpen={isOpened}
+      toggle={close}
+      size="lg"
+      autoFocus={false}
+    >
       {isOpened && <LinkEditModalSubstance />}
     </Modal>
   );

+ 2 - 2
apps/app/src/client/components/PageEditor/LinkEditModal/dynamic.tsx

@@ -1,5 +1,4 @@
 import type { JSX } from 'react';
-
 import { useLinkEditModalStatus } from '@growi/editor/dist/states/modal/link-edit';
 
 import { useLazyLoader } from '~/components/utils/use-lazy-loader';
@@ -11,7 +10,8 @@ export const LinkEditModalLazyLoaded = (): JSX.Element => {
 
   const LinkEditModal = useLazyLoader<LinkEditModalProps>(
     'link-edit-modal',
-    () => import('./LinkEditModal').then(mod => ({ default: mod.LinkEditModal })),
+    () =>
+      import('./LinkEditModal').then((mod) => ({ default: mod.LinkEditModal })),
     status?.isOpened ?? false,
   );
 

+ 8 - 9
apps/app/src/client/components/PageEditor/MarkdownListUtil.js

@@ -2,14 +2,16 @@
  * Utility for markdown list
  */
 class MarkdownListUtil {
-
   constructor() {
     // https://github.com/codemirror/CodeMirror/blob/c7853a989c77bb9f520c9c530cbe1497856e96fc/addon/edit/continuelist.js#L14
     // https://regex101.com/r/7BN2fR/5
-    this.indentAndMarkRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/;
-    this.indentAndMarkOnlyRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/;
+    this.indentAndMarkRE =
+      /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/;
+    this.indentAndMarkOnlyRE =
+      /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/;
 
-    this.newlineAndIndentContinueMarkdownList = this.newlineAndIndentContinueMarkdownList.bind(this);
+    this.newlineAndIndentContinueMarkdownList =
+      this.newlineAndIndentContinueMarkdownList.bind(this);
     this.pasteText = this.pasteText.bind(this);
   }
 
@@ -23,13 +25,11 @@ class MarkdownListUtil {
     if (this.indentAndMarkOnlyRE.test(strFromBol)) {
       // clear current line and end list
       editor.replaceBolToCurrentPos('\n');
-    }
-    else if (this.indentAndMarkRE.test(strFromBol)) {
+    } else if (this.indentAndMarkRE.test(strFromBol)) {
       // continue list
       const indentAndMark = strFromBol.match(this.indentAndMarkRE)[0];
       editor.insertText(`\n${indentAndMark}`);
-    }
-    else {
+    } else {
       editor.insertLinebreak();
     }
   }
@@ -124,7 +124,6 @@ class MarkdownListUtil {
 
     return isListful;
   }
-
 }
 
 // singleton pattern

+ 39 - 24
apps/app/src/client/components/PageEditor/MarkdownTableDataImportForm.tsx

@@ -1,30 +1,28 @@
-import React, { useState, type JSX } from 'react';
-
+import React, { type JSX, useState } from 'react';
 import { MarkdownTable } from '@growi/editor';
 import { useTranslation } from 'next-i18next';
-import {
-  Button,
-  Collapse,
-} from 'reactstrap';
-
+import { Button, Collapse } from 'reactstrap';
 
 type MarkdownTableDataImportFormProps = {
-  onCancel: () => void,
-  onImport: (table: MarkdownTable) => void,
-}
-
-export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormProps): JSX.Element => {
+  onCancel: () => void;
+  onImport: (table: MarkdownTable) => void;
+};
 
+export const MarkdownTableDataImportForm = (
+  props: MarkdownTableDataImportFormProps,
+): JSX.Element => {
   const { onCancel, onImport } = props;
 
-  const { t } = useTranslation('commons', { keyPrefix: 'handsontable_modal.data_import_form' });
+  const { t } = useTranslation('commons', {
+    keyPrefix: 'handsontable_modal.data_import_form',
+  });
 
   const [dataFormat, setDataFormat] = useState<string>('csv');
   const [data, setData] = useState<string>('');
   const [parserErrorMessage, setParserErrorMessage] = useState(null);
 
   const convertFormDataToMarkdownTable = () => {
-    let result;
+    let result: MarkdownTable;
     switch (dataFormat) {
       case 'csv':
         result = MarkdownTable.fromDSV(data, ',');
@@ -35,6 +33,8 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr
       case 'html':
         result = MarkdownTable.fromHTMLTableTag(data);
         break;
+      default:
+        throw new Error(`Unsupported format: ${dataFormat}`);
     }
     return result.normalizeCells();
   };
@@ -44,8 +44,7 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr
       const markdownTable = convertFormDataToMarkdownTable();
       onImport(markdownTable);
       setParserErrorMessage(null);
-    }
-    catch (e) {
+    } catch (e) {
       setParserErrorMessage(e.message);
     }
   };
@@ -53,12 +52,16 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr
   return (
     <form className="data-import-form">
       <div>
-        <label htmlFor="data-import-form-type-select" className="form-label">{t('select_data_format')}</label>
+        <label htmlFor="data-import-form-type-select" className="form-label">
+          {t('select_data_format')}
+        </label>
         <select
           id="data-import-form-type-select"
           className="form-select"
           value={dataFormat}
-          onChange={(e) => { return setDataFormat(e.target.value) }}
+          onChange={(e) => {
+            return setDataFormat(e.target.value);
+          }}
         >
           <option value="csv">CSV</option>
           <option value="tsv">TSV</option>
@@ -66,18 +69,27 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr
         </select>
       </div>
       <div className="mt-2">
-        <label htmlFor="data-import-form-type-textarea" className="form-label">{t('import_data')}</label>
+        <label htmlFor="data-import-form-type-textarea" className="form-label">
+          {t('import_data')}
+        </label>
         <textarea
           id="data-import-form-type-textarea"
           className="form-control"
           placeholder={t('paste_table_data')}
           rows={8}
-          onChange={(e) => { return setData(e.target.value) }}
+          onChange={(e) => {
+            return setData(e.target.value);
+          }}
         />
       </div>
       <Collapse isOpen={parserErrorMessage != null}>
         <div>
-          <label htmlFor="data-import-form-type-textarea-alert" className="form-label">{t('parse_error')}</label>
+          <label
+            htmlFor="data-import-form-type-textarea-alert"
+            className="form-label"
+          >
+            {t('parse_error')}
+          </label>
           <textarea
             id="data-import-form-type-textarea-alert"
             className="form-control"
@@ -88,10 +100,13 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr
         </div>
       </Collapse>
       <div className="mt-3 d-flex justify-content-end">
-        <Button color="outline-neutral-secondary me-2" onClick={onCancel}>{t('cancel')}</Button>
-        <Button color="primary" onClick={importButtonHandler}>{t('import')}</Button>
+        <Button color="outline-neutral-secondary me-2" onClick={onCancel}>
+          {t('cancel')}
+        </Button>
+        <Button color="primary" onClick={importButtonHandler}>
+          {t('import')}
+        </Button>
       </div>
     </form>
   );
-
 };

+ 232 - 139
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -1,13 +1,15 @@
 import type { CSSProperties, JSX } from 'react';
 import React, {
-  useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+  useMemo,
+  useRef,
+  useState,
 } from 'react';
-
-import nodePath from 'path';
-
 import { Origin } from '@growi/core';
 import type { IPageHasId } from '@growi/core/dist/interfaces';
-import { pathUtils, globalEventTarget } from '@growi/core/dist/utils';
+import { globalEventTarget, pathUtils } from '@growi/core/dist/utils';
 import { GlobalCodeMirrorEditorKey, useSetResolvedTheme } from '@growi/editor';
 import { CodeMirrorEditorMain } from '@growi/editor/dist/client/components/CodeMirrorEditorMain';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
@@ -15,22 +17,26 @@ import { useRect } from '@growi/ui/dist/utils';
 import detectIndent from 'detect-indent';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import { throttle, debounce } from 'throttle-debounce';
+import nodePath from 'path';
+import { debounce, throttle } from 'throttle-debounce';
 
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
-import { useUpdatePage, extractRemoteRevisionDataFromErrorObj } from '~/client/services/update-page';
+import {
+  extractRemoteRevisionDataFromErrorObj,
+  useUpdatePage,
+} from '~/client/services/update-page';
 import { uploadAttachments } from '~/client/services/upload-attachments';
 import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
 import { useIsEnableUnifiedMergeView } from '~/features/openai/client/states';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import { useCurrentPathname, useCurrentUser } from '~/states/global';
 import {
-  useIsEditable,
-  useCurrentPagePath,
   useCurrentPageData,
   useCurrentPageId,
-  usePageNotFound,
+  useCurrentPagePath,
+  useIsEditable,
   useIsUntitledPage,
+  usePageNotFound,
 } from '~/states/page';
 import { useTemplateBody } from '~/states/page/hooks';
 import {
@@ -40,50 +46,56 @@ import {
   useAcceptedUploadFileType,
 } from '~/states/server-configurations';
 import {
-  useCurrentIndentSize, useCurrentIndentSizeActions,
-  useEditorMode, EditorMode, useEditingMarkdown, useSelectedGrant,
-  useWaitingSaveProcessingActions, useSetReservedNextCaretLine, useReservedNextCaretLineValue,
+  EditorMode,
+  useCurrentIndentSize,
+  useCurrentIndentSizeActions,
+  useEditingMarkdown,
+  useEditorMode,
+  useReservedNextCaretLineValue,
+  useSelectedGrant,
+  useSetReservedNextCaretLine,
+  useWaitingSaveProcessingActions,
 } from '~/states/ui/editor';
 import { useSetEditingClients } from '~/states/ui/editor/editing-clients';
-import { useNextThemes } from '~/stores-universal/use-next-themes';
 import { useEditorSettings } from '~/stores/editor';
-import {
-  useSWRxCurrentGrantData,
-} from '~/stores/page';
+import { useSWRxCurrentGrantData } from '~/stores/page';
 import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
 import { usePreviewOptions } from '~/stores/renderer';
+import { useNextThemes } from '~/stores-universal/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
+import {
+  type ConflictHandler,
+  useConflictEffect,
+  useConflictResolver,
+} from './conflict';
 import { EditorNavbar } from './EditorNavbar';
 import { EditorNavbarBottom } from './EditorNavbarBottom';
 import Preview from './Preview';
 import { useScrollSync } from './ScrollSyncHelper';
-import { useConflictResolver, useConflictEffect, type ConflictHandler } from './conflict';
 
 import '@growi/editor/dist/style.css';
 
 const logger = loggerFactory('growi:PageEditor');
 
-
 export type SaveOptions = {
-  wip: boolean,
-  slackChannels: string,
-  isSlackEnabled: boolean,
-  overwriteScopesOfDescendants?: boolean
-}
+  wip: boolean;
+  slackChannels: string;
+  isSlackEnabled: boolean;
+  overwriteScopesOfDescendants?: boolean;
+};
 export type Save = (
   revisionId?: string,
   requestMarkdown?: string,
   opts?: SaveOptions,
-  onConflict?: ConflictHandler
-) => Promise<IPageHasId | null>
+  onConflict?: ConflictHandler,
+) => Promise<IPageHasId | null>;
 
 type Props = {
-  visibility?: boolean,
-}
+  visibility?: boolean;
+};
 
 export const PageEditorSubstance = (props: Props): JSX.Element => {
-
   const { t } = useTranslation();
 
   const previewRef = useRef<HTMLDivElement>(null);
@@ -96,10 +108,13 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   const currentPage = useCurrentPageData();
   const [selectedGrant] = useSelectedGrant();
   const editingMarkdown = useEditingMarkdown();
-  const isEnabledAttachTitleHeader = useAtomValue(isEnabledAttachTitleHeaderAtom);
+  const isEnabledAttachTitleHeader = useAtomValue(
+    isEnabledAttachTitleHeaderAtom,
+  );
   const templateBody = useTemplateBody();
   const isEditable = useIsEditable();
-  const { mutate: mutateWaitingSaveProcessing } = useWaitingSaveProcessingActions();
+  const { mutate: mutateWaitingSaveProcessing } =
+    useWaitingSaveProcessingActions();
   const { editorMode, setEditorMode } = useEditorMode();
   const isUntitledPage = useIsUntitledPage();
   const isIndentSizeForced = useAtomValue(isIndentSizeForcedAtom);
@@ -108,7 +123,9 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   const defaultIndentSize = useAtomValue(defaultIndentSizeAtom);
   const acceptedUploadFileType = useAcceptedUploadFileType();
   const { data: editorSettings } = useEditorSettings();
-  const { mutate: mutateIsGrantNormalized } = useSWRxCurrentGrantData(currentPage?._id);
+  const { mutate: mutateIsGrantNormalized } = useSWRxCurrentGrantData(
+    currentPage?._id,
+  );
   const user = useCurrentUser();
   const setEditingClients = useSetEditingClients();
   const onConflict = useConflictResolver();
@@ -121,7 +138,9 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   const shouldExpandContent = useShouldExpandContent(currentPage);
 
   const updatePage = useUpdatePage();
-  const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId, {
+    supressEditingMarkdownMutation: true,
+  });
 
   useConflictEffect();
 
@@ -135,7 +154,8 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
 
   // There are cases where "revisionId" is not required for revision updates
   // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
-  const isRevisionIdRequiredForPageUpdate = currentPage?.revision?.origin === undefined;
+  const isRevisionIdRequiredForPageUpdate =
+    currentPage?.revision?.origin === undefined;
 
   const initialValueRef = useRef('');
   const initialValue = useMemo(() => {
@@ -153,91 +173,141 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
     }
 
     return initialValue;
-
-  }, [isNotFound, currentPathname, editingMarkdown, isEnabledAttachTitleHeader, templateBody]);
+  }, [
+    isNotFound,
+    currentPathname,
+    editingMarkdown,
+    isEnabledAttachTitleHeader,
+    templateBody,
+  ]);
 
   useEffect(() => {
     // set to ref
     initialValueRef.current = initialValue;
   }, [initialValue]);
 
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
-
-  const [markdownToPreview, setMarkdownToPreview] = useState<string>(codeMirrorEditor?.getDocString() ?? '');
-  const setMarkdownPreviewWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string) => {
-    setMarkdownToPreview(value);
-  })), []);
-
-
-  const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(GlobalCodeMirrorEditorKey.MAIN, previewRef);
-
-  const scrollEditorHandlerThrottle = useMemo(() => throttle(25, scrollEditorHandler), [scrollEditorHandler]);
-  const scrollPreviewHandlerThrottle = useMemo(() => throttle(25, scrollPreviewHandler), [scrollPreviewHandler]);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
+    GlobalCodeMirrorEditorKey.MAIN,
+  );
 
-  const save: Save = useCallback(async (revisionId, markdown, opts, onConflict) => {
-    if (pageId == null || selectedGrant == null) {
-      logger.error('Some materials to save are invalid', {
-        pageId, selectedGrant,
-      });
-      throw new Error('Some materials to save are invalid');
-    }
+  const [markdownToPreview, setMarkdownToPreview] = useState<string>(
+    codeMirrorEditor?.getDocString() ?? '',
+  );
+  const setMarkdownPreviewWithDebounce = useMemo(
+    () =>
+      debounce(
+        100,
+        throttle(150, (value: string) => {
+          setMarkdownToPreview(value);
+        }),
+      ),
+    [],
+  );
 
-    try {
-      mutateWaitingSaveProcessing(true);
-
-      const { page } = await updatePage({
-        pageId,
-        revisionId,
-        wip: opts?.wip,
-        body: markdown ?? '',
-        grant: selectedGrant?.grant,
-        origin: Origin.Editor,
-        userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
-        ...(opts ?? {}),
-      });
+  const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(
+    GlobalCodeMirrorEditorKey.MAIN,
+    previewRef,
+  );
 
-      // to sync revision id with page tree: https://github.com/growilabs/growi/pull/7227
-      mutatePageTree();
+  const scrollEditorHandlerThrottle = useMemo(
+    () => throttle(25, scrollEditorHandler),
+    [scrollEditorHandler],
+  );
+  const scrollPreviewHandlerThrottle = useMemo(
+    () => throttle(25, scrollPreviewHandler),
+    [scrollPreviewHandler],
+  );
 
-      mutateRecentlyUpdated();
-      // sync current grant data after update
-      mutateIsGrantNormalized();
+  const save: Save = useCallback(
+    async (revisionId, markdown, opts, onConflict) => {
+      if (pageId == null || selectedGrant == null) {
+        logger.error('Some materials to save are invalid', {
+          pageId,
+          selectedGrant,
+        });
+        throw new Error('Some materials to save are invalid');
+      }
 
-      return page;
-    }
-    catch (error) {
-      logger.error('failed to save', error);
+      try {
+        mutateWaitingSaveProcessing(true);
+
+        const { page } = await updatePage({
+          pageId,
+          revisionId,
+          wip: opts?.wip,
+          body: markdown ?? '',
+          grant: selectedGrant?.grant,
+          origin: Origin.Editor,
+          userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
+          ...(opts ?? {}),
+        });
+
+        // to sync revision id with page tree: https://github.com/growilabs/growi/pull/7227
+        mutatePageTree();
+
+        mutateRecentlyUpdated();
+        // sync current grant data after update
+        mutateIsGrantNormalized();
+
+        return page;
+      } catch (error) {
+        logger.error('failed to save', error);
+
+        const remoteRevisionData = extractRemoteRevisionDataFromErrorObj(error);
+        if (remoteRevisionData != null) {
+          onConflict?.(remoteRevisionData, markdown ?? '', save, opts);
+          toastWarning(
+            t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'),
+          );
+          return null;
+        }
 
-      const remoteRevisionData = extractRemoteRevisionDataFromErrorObj(error);
-      if (remoteRevisionData != null) {
-        onConflict?.(remoteRevisionData, markdown ?? '', save, opts);
-        toastWarning(t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'));
+        toastError(error);
         return null;
+      } finally {
+        mutateWaitingSaveProcessing(false);
       }
+    },
+    [
+      pageId,
+      selectedGrant,
+      mutateWaitingSaveProcessing,
+      updatePage,
+      mutateIsGrantNormalized,
+      t,
+    ],
+  );
 
-      toastError(error);
-      return null;
-    }
-    finally {
-      mutateWaitingSaveProcessing(false);
-    }
-  }, [pageId, selectedGrant, mutateWaitingSaveProcessing, updatePage, mutateIsGrantNormalized, t]);
-
-  const saveAndReturnToViewHandler = useCallback(async(evt: CustomEvent<SaveOptions>) => {
-    const markdown = codeMirrorEditor?.getDocString();
-    const revisionId = isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined;
-    const page = await save(revisionId, markdown, evt.detail, onConflict);
-    if (page == null) {
-      return;
-    }
+  const saveAndReturnToViewHandler = useCallback(
+    async (evt: CustomEvent<SaveOptions>) => {
+      const markdown = codeMirrorEditor?.getDocString();
+      const revisionId = isRevisionIdRequiredForPageUpdate
+        ? currentRevisionId
+        : undefined;
+      const page = await save(revisionId, markdown, evt.detail, onConflict);
+      if (page == null) {
+        return;
+      }
 
-    setEditorMode(EditorMode.View);
-    updateStateAfterSave?.();
-  }, [codeMirrorEditor, currentRevisionId, isRevisionIdRequiredForPageUpdate, setEditorMode, onConflict, save, updateStateAfterSave]);
+      setEditorMode(EditorMode.View);
+      updateStateAfterSave?.();
+    },
+    [
+      codeMirrorEditor,
+      currentRevisionId,
+      isRevisionIdRequiredForPageUpdate,
+      setEditorMode,
+      onConflict,
+      save,
+      updateStateAfterSave,
+    ],
+  );
 
   const saveWithShortcut = useCallback(async () => {
     const markdown = codeMirrorEditor?.getDocString();
-    const revisionId = isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined;
+    const revisionId = isRevisionIdRequiredForPageUpdate
+      ? currentRevisionId
+      : undefined;
     const page = await save(revisionId, markdown, undefined, onConflict);
     if (page == null) {
       return;
@@ -245,50 +315,71 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
 
     toastSuccess(t('toaster.save_succeeded'));
     updateStateAfterSave?.();
-  }, [codeMirrorEditor, currentRevisionId, isRevisionIdRequiredForPageUpdate, onConflict, save, t, updateStateAfterSave]);
-
+  }, [
+    codeMirrorEditor,
+    currentRevisionId,
+    isRevisionIdRequiredForPageUpdate,
+    onConflict,
+    save,
+    t,
+    updateStateAfterSave,
+  ]);
 
   // the upload event handler
-  const uploadHandler = useCallback((files: File[]) => {
-    if (pageId == null) {
-      logger.error('pageId is invalid', {
-        pageId,
-      });
-      throw new Error('pageId is invalid');
-    }
-
-    uploadAttachments(pageId, files, {
-      onUploaded: (attachment) => {
-        const fileName = attachment.originalName;
+  const uploadHandler = useCallback(
+    (files: File[]) => {
+      if (pageId == null) {
+        logger.error('pageId is invalid', {
+          pageId,
+        });
+        throw new Error('pageId is invalid');
+      }
 
-        const prefix = attachment.fileFormat.startsWith('image/')
-          ? '!' // use "![fileName](url)" syntax when image
-          : '';
-        const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
+      uploadAttachments(pageId, files, {
+        onUploaded: (attachment) => {
+          const fileName = attachment.originalName;
 
-        codeMirrorEditor?.insertText(insertText);
-      },
-      onError: (error) => {
-        toastError(error);
-      },
-    });
-  }, [codeMirrorEditor, pageId]);
+          const prefix = attachment.fileFormat.startsWith('image/')
+            ? '!' // use "![fileName](url)" syntax when image
+            : '';
+          const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
 
-  const onChangeHandler = useCallback((value: string) => {
-    setMarkdownPreviewWithDebounce(value);
-  }, [setMarkdownPreviewWithDebounce]);
+          codeMirrorEditor?.insertText(insertText);
+        },
+        onError: (error) => {
+          toastError(error);
+        },
+      });
+    },
+    [codeMirrorEditor, pageId],
+  );
 
-  const cmProps = useMemo(() => ({
-    onChange: onChangeHandler,
-  }), [onChangeHandler]);
+  const onChangeHandler = useCallback(
+    (value: string) => {
+      setMarkdownPreviewWithDebounce(value);
+    },
+    [setMarkdownPreviewWithDebounce],
+  );
 
+  const cmProps = useMemo(
+    () => ({
+      onChange: onChangeHandler,
+    }),
+    [onChangeHandler],
+  );
 
   // set handler to save and return to View
   useEffect(() => {
-    globalEventTarget.addEventListener('saveAndReturnToView', saveAndReturnToViewHandler);
+    globalEventTarget.addEventListener(
+      'saveAndReturnToView',
+      saveAndReturnToViewHandler,
+    );
 
     return function cleanup() {
-      globalEventTarget.removeEventListener('saveAndReturnToView', saveAndReturnToViewHandler);
+      globalEventTarget.removeEventListener(
+        'saveAndReturnToView',
+        saveAndReturnToViewHandler,
+      );
     };
   }, [saveAndReturnToViewHandler]);
 
@@ -310,13 +401,15 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
     // detect from markdown
     if (initialValue != null) {
       const detectedIndent = detectIndent(initialValue);
-      if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
+      if (
+        detectedIndent.type === 'space' &&
+        new Set([2, 4]).has(detectedIndent.amount)
+      ) {
         mutateCurrentIndentSize(detectedIndent.amount);
       }
     }
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
-
   // set caret line if the edit button next to Header is clicked.
   useEffect(() => {
     if (codeMirrorEditor?.setCaretLine == null) {
@@ -325,7 +418,6 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
     if (editorMode === EditorMode.Editor) {
       codeMirrorEditor.setCaretLine(reservedNextCaretLine ?? 0, true);
     }
-
   }, [codeMirrorEditor, editorMode, reservedNextCaretLine]);
 
   // reset caret line if returning to the View.
@@ -335,7 +427,6 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
     }
   }, [editorMode, setReservedNextCaretLine]);
 
-
   // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
   // // when transitioning to a different page, if the initialValue is the same,
   // // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
@@ -407,14 +498,16 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
 
 export const PageEditor = React.memo((props: Props): JSX.Element => {
   return (
-    <div data-testid="page-editor" id="page-editor" className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}>
-
+    <div
+      data-testid="page-editor"
+      id="page-editor"
+      className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}
+    >
       <EditorNavbar />
 
       <PageEditorSubstance visibility={props.visibility} />
 
       <EditorNavbarBottom />
-
     </div>
   );
 });

+ 55 - 42
apps/app/src/client/components/PageEditor/PageEditorReadOnly.tsx

@@ -1,5 +1,4 @@
-import react, { useMemo, useRef, type JSX } from 'react';
-
+import react, { type JSX, useMemo, useRef } from 'react';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { CodeMirrorEditorReadOnly } from '@growi/editor/dist/client/components/CodeMirrorEditorReadOnly';
 import { throttle } from 'throttle-debounce';
@@ -14,52 +13,66 @@ import Preview from './Preview';
 import { useScrollSync } from './ScrollSyncHelper';
 
 type Props = {
-  visibility?: boolean,
-}
+  visibility?: boolean;
+};
 
-export const PageEditorReadOnly = react.memo(({ visibility }: Props): JSX.Element => {
-  const previewRef = useRef<HTMLDivElement>(null);
+export const PageEditorReadOnly = react.memo(
+  ({ visibility }: Props): JSX.Element => {
+    const previewRef = useRef<HTMLDivElement>(null);
 
-  const currentPage = useCurrentPageData();
-  const { data: rendererOptions } = usePreviewOptions();
-  const { data: isLatestRevision } = useSWRxIsLatestRevision();
-  const shouldExpandContent = useShouldExpandContent(currentPage);
+    const currentPage = useCurrentPageData();
+    const { data: rendererOptions } = usePreviewOptions();
+    const { data: isLatestRevision } = useSWRxIsLatestRevision();
+    const shouldExpandContent = useShouldExpandContent(currentPage);
 
-  const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(GlobalCodeMirrorEditorKey.READONLY, previewRef);
-  const scrollEditorHandlerThrottle = useMemo(() => throttle(25, scrollEditorHandler), [scrollEditorHandler]);
-  const scrollPreviewHandlerThrottle = useMemo(() => throttle(25, scrollPreviewHandler), [scrollPreviewHandler]);
+    const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(
+      GlobalCodeMirrorEditorKey.READONLY,
+      previewRef,
+    );
+    const scrollEditorHandlerThrottle = useMemo(
+      () => throttle(25, scrollEditorHandler),
+      [scrollEditorHandler],
+    );
+    const scrollPreviewHandlerThrottle = useMemo(
+      () => throttle(25, scrollPreviewHandler),
+      [scrollPreviewHandler],
+    );
 
-  const revisionBody = currentPage?.revision?.body;
+    const revisionBody = currentPage?.revision?.body;
 
-  // Show read-only editor only when viewing an old revision
-  if (rendererOptions == null || isLatestRevision !== false) {
-    return <></>;
-  }
+    // Show read-only editor only when viewing an old revision
+    if (rendererOptions == null || isLatestRevision !== false) {
+      return <></>;
+    }
 
-  return (
-    <div id="page-editor" className={`flex-expand-vert ${visibility ? '' : 'd-none'}`}>
-      <EditorNavbar />
+    return (
+      <div
+        id="page-editor"
+        className={`flex-expand-vert ${visibility ? '' : 'd-none'}`}
+      >
+        <EditorNavbar />
 
-      <div className="flex-expand-horiz">
-        <div className="page-editor-editor-container flex-expand-vert border-end">
-          <CodeMirrorEditorReadOnly
-            markdown={revisionBody}
-            onScroll={scrollEditorHandlerThrottle}
-          />
-        </div>
-        <div
-          ref={previewRef}
-          onScroll={scrollPreviewHandlerThrottle}
-          className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
-        >
-          <Preview
-            markdown={revisionBody}
-            pagePath={currentPage?.path}
-            rendererOptions={rendererOptions}
-            expandContentWidth={shouldExpandContent}
-          />
+        <div className="flex-expand-horiz">
+          <div className="page-editor-editor-container flex-expand-vert border-end">
+            <CodeMirrorEditorReadOnly
+              markdown={revisionBody}
+              onScroll={scrollEditorHandlerThrottle}
+            />
+          </div>
+          <div
+            ref={previewRef}
+            onScroll={scrollPreviewHandlerThrottle}
+            className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
+          >
+            <Preview
+              markdown={revisionBody}
+              pagePath={currentPage?.path}
+              rendererOptions={rendererOptions}
+              expandContentWidth={shouldExpandContent}
+            />
+          </div>
         </div>
       </div>
-    </div>
-  );
-});
+    );
+  },
+);

+ 18 - 24
apps/app/src/client/components/PageEditor/Preview.tsx

@@ -1,5 +1,4 @@
 import type { CSSProperties, JSX } from 'react';
-
 import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 
 import RevisionRenderer from '~/components/PageView/RevisionRenderer';
@@ -12,46 +11,41 @@ import styles from './Preview.module.scss';
 
 const moduleClass = styles['page-editor-preview-body'] ?? '';
 
-
 type Props = {
-  rendererOptions: RendererOptions,
-  markdown?: string,
-  pagePath?: string | null,
-  expandContentWidth?: boolean,
-  style?: CSSProperties,
-  onScroll?: (scrollTop: number) => void,
-}
+  rendererOptions: RendererOptions;
+  markdown?: string;
+  pagePath?: string | null;
+  expandContentWidth?: boolean;
+  style?: CSSProperties;
+  onScroll?: (scrollTop: number) => void;
+};
 
 const Preview = (props: Props): JSX.Element => {
-
-  const {
-    rendererOptions,
-    markdown, pagePath, style,
-    expandContentWidth,
-  } = props;
+  const { rendererOptions, markdown, pagePath, style, expandContentWidth } =
+    props;
 
   const { isEnabledMarp } = useRendererConfig();
   const isSlide = useSlidesByFrontmatter(markdown, isEnabledMarp);
 
   const fluidLayoutClass = expandContentWidth ? 'fluid-layout' : '';
 
-
   return (
     <div
       data-testid="page-editor-preview-body"
       className={`${moduleClass} ${fluidLayoutClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
       style={style}
     >
-      { markdown != null
-        && (
-          isSlide != null
-            ? <SlideRenderer marp={isSlide.marp} markdown={markdown} />
-            : <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown}></RevisionRenderer>
-        )
-      }
+      {markdown != null &&
+        (isSlide != null ? (
+          <SlideRenderer marp={isSlide.marp} markdown={markdown} />
+        ) : (
+          <RevisionRenderer
+            rendererOptions={rendererOptions}
+            markdown={markdown}
+          ></RevisionRenderer>
+        ))}
     </div>
   );
-
 };
 
 export default Preview;

+ 93 - 45
apps/app/src/client/components/PageEditor/ScrollSyncHelper.tsx

@@ -1,5 +1,4 @@
-import { useCallback, type RefObject, useRef } from 'react';
-
+import { type RefObject, useCallback, useRef } from 'react';
 import type { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 
@@ -13,31 +12,40 @@ const getDefaultTop = (): number => {
   return defaultTop + padding;
 };
 
-
 const getDataLine = (element: Element | null): number => {
   return element ? +(element.getAttribute('data-line') ?? '0') - 1 : 0;
 };
 
 const getEditorElements = (editorRootElement: HTMLElement): Array<Element> => {
-  return Array.from(editorRootElement.getElementsByClassName('cm-line'))
-    .filter((element) => { return !Number.isNaN(element.getAttribute('data-line') ?? Number.NaN) });
+  return Array.from(editorRootElement.getElementsByClassName('cm-line')).filter(
+    (element) => {
+      return !Number.isNaN(element.getAttribute('data-line') ?? Number.NaN);
+    },
+  );
 };
 
-const getPreviewElements = (previewRootElement: HTMLElement): Array<Element> => {
-  return Array.from(previewRootElement.getElementsByClassName('has-data-line'))
-    .filter((element) => { return !Number.isNaN(element.getAttribute('data-line') ?? Number.NaN) });
+const getPreviewElements = (
+  previewRootElement: HTMLElement,
+): Array<Element> => {
+  return Array.from(
+    previewRootElement.getElementsByClassName('has-data-line'),
+  ).filter((element) => {
+    return !Number.isNaN(element.getAttribute('data-line') ?? Number.NaN);
+  });
 };
 
 // Ref: https://github.com/mikolalysenko/binary-search-bounds/blob/f436a2a8af11bf3208434e18bbac17e18e7a3a30/search-bounds.js
-const elementBinarySearch = (list: Array<Element>, fn: (index: number) => boolean): number => {
+const elementBinarySearch = (
+  list: Array<Element>,
+  fn: (index: number) => boolean,
+): number => {
   let ok = 0;
   let ng = list.length;
   while (ok + 1 < ng) {
     const mid = Math.floor((ok + ng) / 2);
     if (fn(mid)) {
       ok = mid;
-    }
-    else {
+    } else {
       ng = mid;
     }
   }
@@ -45,7 +53,6 @@ const elementBinarySearch = (list: Array<Element>, fn: (index: number) => boolea
 };
 
 const findTopElementIndex = (elements: Array<Element>): number => {
-
   const find = (index: number): boolean => {
     return elements[index].getBoundingClientRect().top < getDefaultTop();
   };
@@ -53,8 +60,10 @@ const findTopElementIndex = (elements: Array<Element>): number => {
   return elementBinarySearch(elements, find);
 };
 
-const findElementIndexFromDataLine = (previewElements: Array<Element>, dataline: number): number => {
-
+const findElementIndexFromDataLine = (
+  previewElements: Array<Element>,
+  dataline: number,
+): number => {
   const find = (index: number): boolean => {
     return getDataLine(previewElements[index]) <= dataline;
   };
@@ -62,27 +71,33 @@ const findElementIndexFromDataLine = (previewElements: Array<Element>, dataline:
   return elementBinarySearch(previewElements, find);
 };
 
-
 type SourceElement = {
-  start?: DOMRect,
-  top?: DOMRect,
-  next?: DOMRect,
-}
+  start?: DOMRect;
+  top?: DOMRect;
+  next?: DOMRect;
+};
 
 type TargetElement = {
-  start?: DOMRect,
-  next?: DOMRect,
-}
+  start?: DOMRect;
+  next?: DOMRect;
+};
 
 const calcScrollElementToTop = (element: Element): number => {
   return element.getBoundingClientRect().top - getDefaultTop();
 };
 
-const calcScorllElementByRatio = (sourceElement: SourceElement, targetElement: TargetElement): number => {
+const calcScorllElementByRatio = (
+  sourceElement: SourceElement,
+  targetElement: TargetElement,
+): number => {
   if (sourceElement.start === sourceElement.next) {
     return 0;
   }
-  if (sourceElement.start == null || sourceElement.top == null || sourceElement.next == null) {
+  if (
+    sourceElement.start == null ||
+    sourceElement.top == null ||
+    sourceElement.next == null
+  ) {
     return 0;
   }
   if (targetElement.start == null || targetElement.next == null) {
@@ -98,19 +113,29 @@ const calcScorllElementByRatio = (sourceElement: SourceElement, targetElement: T
   return targetAllHeight * sourceRaito;
 };
 
-
-const scrollEditor = (editorRootElement: HTMLElement, previewRootElement: HTMLElement): void => {
-
+const scrollEditor = (
+  editorRootElement: HTMLElement,
+  previewRootElement: HTMLElement,
+): void => {
   setDefaultTop(editorRootElement.getBoundingClientRect().top);
 
   const editorElements = getEditorElements(editorRootElement);
   const previewElements = getPreviewElements(previewRootElement);
 
   const topEditorElementIndex = findTopElementIndex(editorElements);
-  const topPreviewElementIndex = findElementIndexFromDataLine(previewElements, getDataLine(editorElements[topEditorElementIndex]));
+  const topPreviewElementIndex = findElementIndexFromDataLine(
+    previewElements,
+    getDataLine(editorElements[topEditorElementIndex]),
+  );
 
-  const startEditorElementIndex = findElementIndexFromDataLine(editorElements, getDataLine(previewElements[topPreviewElementIndex]));
-  const nextEditorElementIndex = findElementIndexFromDataLine(editorElements, getDataLine(previewElements[topPreviewElementIndex + 1]));
+  const startEditorElementIndex = findElementIndexFromDataLine(
+    editorElements,
+    getDataLine(previewElements[topPreviewElementIndex]),
+  );
+  const nextEditorElementIndex = findElementIndexFromDataLine(
+    editorElements,
+    getDataLine(previewElements[topPreviewElementIndex + 1]),
+  );
 
   let newScrollTop = previewRootElement.scrollTop;
 
@@ -118,7 +143,9 @@ const scrollEditor = (editorRootElement: HTMLElement, previewRootElement: HTMLEl
     return;
   }
 
-  newScrollTop += calcScrollElementToTop(previewElements[topPreviewElementIndex]);
+  newScrollTop += calcScrollElementToTop(
+    previewElements[topPreviewElementIndex],
+  );
   newScrollTop += calcScorllElementByRatio(
     {
       start: editorElements[startEditorElementIndex]?.getBoundingClientRect(),
@@ -127,16 +154,19 @@ const scrollEditor = (editorRootElement: HTMLElement, previewRootElement: HTMLEl
     },
     {
       start: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
-      next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
+      next: previewElements[
+        topPreviewElementIndex + 1
+      ]?.getBoundingClientRect(),
     },
   );
 
   previewRootElement.scrollTop = newScrollTop;
-
 };
 
-const scrollPreview = (editorRootElement: HTMLElement, previewRootElement: HTMLElement): void => {
-
+const scrollPreview = (
+  editorRootElement: HTMLElement,
+  previewRootElement: HTMLElement,
+): void => {
   setDefaultTop(previewRootElement.getBoundingClientRect().y);
 
   const previewElements = getPreviewElements(previewRootElement);
@@ -144,8 +174,14 @@ const scrollPreview = (editorRootElement: HTMLElement, previewRootElement: HTMLE
 
   const topPreviewElementIndex = findTopElementIndex(previewElements);
 
-  const startEditorElementIndex = findElementIndexFromDataLine(editorElements, getDataLine(previewElements[topPreviewElementIndex]));
-  const nextEditorElementIndex = findElementIndexFromDataLine(editorElements, getDataLine(previewElements[topPreviewElementIndex + 1]));
+  const startEditorElementIndex = findElementIndexFromDataLine(
+    editorElements,
+    getDataLine(previewElements[topPreviewElementIndex]),
+  );
+  const nextEditorElementIndex = findElementIndexFromDataLine(
+    editorElements,
+    getDataLine(previewElements[topPreviewElementIndex + 1]),
+  );
 
   if (editorElements[startEditorElementIndex] == null) {
     return;
@@ -153,12 +189,16 @@ const scrollPreview = (editorRootElement: HTMLElement, previewRootElement: HTMLE
 
   let newScrollTop = editorRootElement.scrollTop;
 
-  newScrollTop += calcScrollElementToTop(editorElements[startEditorElementIndex]);
+  newScrollTop += calcScrollElementToTop(
+    editorElements[startEditorElementIndex],
+  );
   newScrollTop += calcScorllElementByRatio(
     {
       start: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
       top: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
-      next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
+      next: previewElements[
+        topPreviewElementIndex + 1
+      ]?.getBoundingClientRect(),
     },
     {
       start: editorElements[startEditorElementIndex]?.getBoundingClientRect(),
@@ -167,18 +207,23 @@ const scrollPreview = (editorRootElement: HTMLElement, previewRootElement: HTMLE
   );
 
   editorRootElement.scrollTop = newScrollTop;
-
 };
 
 // eslint-disable-next-line max-len
-export const useScrollSync = (codeMirrorKey: GlobalCodeMirrorEditorKey, previewRef: RefObject<HTMLDivElement | null>): { scrollEditorHandler: () => void; scrollPreviewHandler: () => void } => {
+export const useScrollSync = (
+  codeMirrorKey: GlobalCodeMirrorEditorKey,
+  previewRef: RefObject<HTMLDivElement | null>,
+): { scrollEditorHandler: () => void; scrollPreviewHandler: () => void } => {
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(codeMirrorKey);
 
   const isOriginOfScrollSyncEditor = useRef(false);
   const isOriginOfScrollSyncPreview = useRef(false);
 
   const scrollEditorHandler = useCallback(() => {
-    if (codeMirrorEditor?.view?.scrollDOM == null || previewRef.current == null) {
+    if (
+      codeMirrorEditor?.view?.scrollDOM == null ||
+      previewRef.current == null
+    ) {
       return;
     }
 
@@ -189,10 +234,13 @@ export const useScrollSync = (codeMirrorKey: GlobalCodeMirrorEditorKey, previewR
 
     isOriginOfScrollSyncEditor.current = true;
     scrollEditor(codeMirrorEditor.view.scrollDOM, previewRef.current);
-  }, [codeMirrorEditor, isOriginOfScrollSyncPreview, previewRef]);
+  }, [codeMirrorEditor, previewRef]);
 
   const scrollPreviewHandler = useCallback(() => {
-    if (codeMirrorEditor?.view?.scrollDOM == null || previewRef.current == null) {
+    if (
+      codeMirrorEditor?.view?.scrollDOM == null ||
+      previewRef.current == null
+    ) {
       return;
     }
 
@@ -203,7 +251,7 @@ export const useScrollSync = (codeMirrorKey: GlobalCodeMirrorEditorKey, previewR
 
     isOriginOfScrollSyncPreview.current = true;
     scrollPreview(codeMirrorEditor.view.scrollDOM, previewRef.current);
-  }, [codeMirrorEditor, isOriginOfScrollSyncEditor, previewRef]);
+  }, [codeMirrorEditor, previewRef]);
 
   return { scrollEditorHandler, scrollPreviewHandler };
 };

+ 20 - 12
apps/app/src/client/components/PageEditor/SimpleCheatsheet.jsx

@@ -1,10 +1,8 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
 class SimpleCheatsheet extends React.Component {
-
   render() {
     const { t } = this.props;
 
@@ -14,26 +12,37 @@ class SimpleCheatsheet extends React.Component {
           <div className="row">
             <div className="col-sm-6">
               <p>
-                # {t('sandbox.header_x', { index: '1' })}<br />
+                # {t('sandbox.header_x', { index: '1' })}
+                <br />
                 ## {t('sandbox.header_x', { index: '2' })}
               </p>
-              <p><i>*{t('sandbox.italics')}*</i>&nbsp;&nbsp;<b>**{t('sandbox.bold')}**</b></p>
               <p>
-                [{t('sandbox.link')}](http://..)<br />
+                <i>*{t('sandbox.italics')}*</i>&nbsp;&nbsp;
+                <b>**{t('sandbox.bold')}**</b>
+              </p>
+              <p>
+                [{t('sandbox.link')}](http://..)
+                <br />
                 [/Page1/ChildPage1]
               </p>
               <p>
-                ```javascript:index.js<br />
-                writeCode();<br />
+                ```javascript:index.js
+                <br />
+                writeCode();
+                <br />
                 ```
               </p>
             </div>
             <div className="col-sm-6">
               <p>
-                - {t('sandbox.unordered_list_x', { index: '1' })}<br />
-                &nbsp;&nbsp;&nbsp;- {t('sandbox.unordered_list_x', { index: '1.1' })}<br />
-                - {t('sandbox.unordered_list_x', { index: '2' })}<br />
-                1. {t('sandbox.ordered_list_x', { index: '1' })}<br />
+                - {t('sandbox.unordered_list_x', { index: '1' })}
+                <br />
+                &nbsp;&nbsp;&nbsp;-{' '}
+                {t('sandbox.unordered_list_x', { index: '1.1' })}
+                <br />- {t('sandbox.unordered_list_x', { index: '2' })}
+                <br />
+                1. {t('sandbox.ordered_list_x', { index: '1' })}
+                <br />
                 1. {t('sandbox.ordered_list_x', { index: '2' })}
               </p>
               <hr />
@@ -44,7 +53,6 @@ class SimpleCheatsheet extends React.Component {
       </div>
     );
   }
-
 }
 
 SimpleCheatsheet.propTypes = {

+ 128 - 67
apps/app/src/client/components/PageEditor/conflict.tsx

@@ -1,5 +1,4 @@
 import { useCallback, useEffect } from 'react';
-
 import { Origin } from '@growi/core';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
@@ -7,7 +6,11 @@ import { useTranslation } from 'react-i18next';
 
 import { SocketEventName } from '~/interfaces/websocket';
 import type { RemoteRevisionData } from '~/states/page';
-import { useCurrentPageData, useCurrentPageId, useSetRemoteLatestPageData } from '~/states/page';
+import {
+  useCurrentPageData,
+  useCurrentPageId,
+  useSetRemoteLatestPageData,
+} from '~/states/page';
 import { useGlobalSocket } from '~/states/socket-io';
 import { EditorMode, useEditorMode } from '~/states/ui/editor';
 import { useConflictDiffModalActions } from '~/states/ui/modal/conflict-diff';
@@ -15,10 +18,8 @@ import { usePageStatusAlertActions } from '~/states/ui/modal/page-status-alert';
 
 import { useUpdateStateAfterSave } from '../../services/page-operation';
 import { toastSuccess } from '../../util/toastr';
-
 import type { Save, SaveOptions } from './PageEditor';
 
-
 export type ConflictHandler = (
   remoteRevisionData: RemoteRevisionData,
   requestMarkdown: string,
@@ -30,37 +31,55 @@ type GenerateResolveConflicthandler = () => (
   revisionId: string,
   save: Save,
   saveOptions?: SaveOptions,
-  onConflict?: () => void
-) => (newMarkdown: string) => Promise<void>
-
-const useGenerateResolveConflictHandler: GenerateResolveConflicthandler = () => {
-  const { t } = useTranslation();
-
-  const pageId = useCurrentPageId();
-  const { close: closePageStatusAlert } = usePageStatusAlertActions();
-  const { close: closeConflictDiffModal } = useConflictDiffModalActions();
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
-  const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
-
-  return useCallback((revisionId, save, saveOptions, onConflict) => {
-    return async (newMarkdown) => {
-      const page = await save(revisionId, newMarkdown, saveOptions, onConflict);
-      if (page == null) {
-        return;
-      }
-
-      // Reflect conflict resolution results in CodeMirrorEditor
-      codeMirrorEditor?.initDoc(newMarkdown);
-
-      closePageStatusAlert();
-      closeConflictDiffModal();
-
-      toastSuccess(t('toaster.save_succeeded'));
-      updateStateAfterSave?.();
-    };
-  }, [closeConflictDiffModal, closePageStatusAlert, codeMirrorEditor, t, updateStateAfterSave]);
-};
-
+  onConflict?: () => void,
+) => (newMarkdown: string) => Promise<void>;
+
+const useGenerateResolveConflictHandler: GenerateResolveConflicthandler =
+  () => {
+    const { t } = useTranslation();
+
+    const pageId = useCurrentPageId();
+    const { close: closePageStatusAlert } = usePageStatusAlertActions();
+    const { close: closeConflictDiffModal } = useConflictDiffModalActions();
+    const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
+      GlobalCodeMirrorEditorKey.MAIN,
+    );
+    const updateStateAfterSave = useUpdateStateAfterSave(pageId, {
+      supressEditingMarkdownMutation: true,
+    });
+
+    return useCallback(
+      (revisionId, save, saveOptions, onConflict) => {
+        return async (newMarkdown) => {
+          const page = await save(
+            revisionId,
+            newMarkdown,
+            saveOptions,
+            onConflict,
+          );
+          if (page == null) {
+            return;
+          }
+
+          // Reflect conflict resolution results in CodeMirrorEditor
+          codeMirrorEditor?.initDoc(newMarkdown);
+
+          closePageStatusAlert();
+          closeConflictDiffModal();
+
+          toastSuccess(t('toaster.save_succeeded'));
+          updateStateAfterSave?.();
+        };
+      },
+      [
+        closeConflictDiffModal,
+        closePageStatusAlert,
+        codeMirrorEditor,
+        t,
+        updateStateAfterSave,
+      ],
+    );
+  };
 
 type ConflictResolver = () => ConflictHandler;
 
@@ -70,22 +89,42 @@ export const useConflictResolver: ConflictResolver = () => {
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
   const generateResolveConflictHandler = useGenerateResolveConflictHandler();
 
-  return useCallback((remoteRevidsionData, requestMarkdown, save, saveOptions) => {
-    const conflictHandler = () => {
-      const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, save, saveOptions, conflictHandler);
-      openPageStatusAlert({ onResolveConflict: () => openConflictDiffModal(requestMarkdown, resolveConflictHandler) });
-      setRemoteLatestPageData(remoteRevidsionData);
-    };
+  return useCallback(
+    (remoteRevidsionData, requestMarkdown, save, saveOptions) => {
+      const conflictHandler = () => {
+        const resolveConflictHandler = generateResolveConflictHandler(
+          remoteRevidsionData.remoteRevisionId,
+          save,
+          saveOptions,
+          conflictHandler,
+        );
+        openPageStatusAlert({
+          onResolveConflict: () =>
+            openConflictDiffModal(requestMarkdown, resolveConflictHandler),
+        });
+        setRemoteLatestPageData(remoteRevidsionData);
+      };
 
-    conflictHandler();
-  }, [generateResolveConflictHandler, openConflictDiffModal, openPageStatusAlert, setRemoteLatestPageData]);
+      conflictHandler();
+    },
+    [
+      generateResolveConflictHandler,
+      openConflictDiffModal,
+      openPageStatusAlert,
+      setRemoteLatestPageData,
+    ],
+  );
 };
 
 export const useConflictEffect = (): void => {
   const currentPage = useCurrentPageData();
-  const { close: closePageStatusAlert, open: openPageStatusAlert } = usePageStatusAlertActions();
-  const { close: closeConflictDiffModal, open: openConflictDiffModal } = useConflictDiffModalActions();
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const { close: closePageStatusAlert, open: openPageStatusAlert } =
+    usePageStatusAlertActions();
+  const { close: closeConflictDiffModal, open: openConflictDiffModal } =
+    useConflictDiffModalActions();
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
+    GlobalCodeMirrorEditorKey.MAIN,
+  );
   const socket = useGlobalSocket();
   const { editorMode } = useEditorMode();
 
@@ -102,35 +141,57 @@ export const useConflictEffect = (): void => {
     };
 
     openPageStatusAlert({ onResolveConflict });
-  }, [closeConflictDiffModal, closePageStatusAlert, codeMirrorEditor, openConflictDiffModal, openPageStatusAlert]);
-
-  const updateRemotePageDataHandler = useCallback((data) => {
-    const { s2cMessagePageUpdated } = data;
-
-    const remoteRevisionId = s2cMessagePageUpdated.revisionId;
-    const remoteRevisionOrigin = s2cMessagePageUpdated.revisionOrigin;
-    const currentRevisionId = currentPage?.revision?._id;
-    const isRevisionOutdated = (currentRevisionId != null || remoteRevisionId != null) && currentRevisionId !== remoteRevisionId;
-
-    // !!CAUTION!! Timing of calling openPageStatusAlert may clash with client/services/side-effects/page-updated.ts
-    if (isRevisionOutdated && editorMode === EditorMode.Editor && (remoteRevisionOrigin === Origin.View || remoteRevisionOrigin === undefined)) {
-      conflictHandler();
-    }
+  }, [
+    closeConflictDiffModal,
+    closePageStatusAlert,
+    codeMirrorEditor,
+    openConflictDiffModal,
+    openPageStatusAlert,
+  ]);
+
+  const updateRemotePageDataHandler = useCallback(
+    (data) => {
+      const { s2cMessagePageUpdated } = data;
+
+      const remoteRevisionId = s2cMessagePageUpdated.revisionId;
+      const remoteRevisionOrigin = s2cMessagePageUpdated.revisionOrigin;
+      const currentRevisionId = currentPage?.revision?._id;
+      const isRevisionOutdated =
+        (currentRevisionId != null || remoteRevisionId != null) &&
+        currentRevisionId !== remoteRevisionId;
+
+      // !!CAUTION!! Timing of calling openPageStatusAlert may clash with client/services/side-effects/page-updated.ts
+      if (
+        isRevisionOutdated &&
+        editorMode === EditorMode.Editor &&
+        (remoteRevisionOrigin === Origin.View ||
+          remoteRevisionOrigin === undefined)
+      ) {
+        conflictHandler();
+      }
 
-    // Clear cache
-    if (!isRevisionOutdated) {
-      closePageStatusAlert();
-    }
-  }, [closePageStatusAlert, currentPage?.revision?._id, editorMode, conflictHandler]);
+      // Clear cache
+      if (!isRevisionOutdated) {
+        closePageStatusAlert();
+      }
+    },
+    [
+      closePageStatusAlert,
+      currentPage?.revision?._id,
+      editorMode,
+      conflictHandler,
+    ],
+  );
 
   useEffect(() => {
-    if (socket == null) { return }
+    if (socket == null) {
+      return;
+    }
 
     socket.on(SocketEventName.PageUpdated, updateRemotePageDataHandler);
 
     return () => {
       socket.off(SocketEventName.PageUpdated, updateRemotePageDataHandler);
     };
-
   }, [socket, updateRemotePageDataHandler]);
 };

+ 14 - 3
apps/app/src/client/components/PageEditor/markdown-drawio-util-for-editor.ts

@@ -101,7 +101,11 @@ const getEod = (editor: EditorView) => {
 export const getMarkdownDrawioMxfile = (editor: EditorView): string | null => {
   const bod = getBod(editor);
   const eod = getEod(editor);
-  if (bod == null || eod == null || JSON.stringify(bod) === JSON.stringify(eod)) {
+  if (
+    bod == null ||
+    eod == null ||
+    JSON.stringify(bod) === JSON.stringify(eod)
+  ) {
     return null;
   }
 
@@ -115,11 +119,18 @@ export const getMarkdownDrawioMxfile = (editor: EditorView): string | null => {
   return editor.state.sliceDoc(bodLine, eodLine);
 };
 
-export const replaceFocusedDrawioWithEditor = (editor: EditorView, drawioData: string): void => {
+export const replaceFocusedDrawioWithEditor = (
+  editor: EditorView,
+  drawioData: string,
+): void => {
   const drawioBlock = ['``` drawio', drawioData.toString(), '```'].join('\n');
   let bod = getBod(editor);
   let eod = getEod(editor);
-  if (bod == null || eod == null || JSON.stringify(bod) === JSON.stringify(eod)) {
+  if (
+    bod == null ||
+    eod == null ||
+    JSON.stringify(bod) === JSON.stringify(eod)
+  ) {
     bod = curPos(editor);
     eod = curPos(editor);
   }

+ 40 - 31
apps/app/src/client/components/PageEditor/markdown-table-util-for-editor.ts

@@ -11,17 +11,17 @@ const curPos = (editor: EditorView): number => {
 };
 
 /**
-   * return boolean value whether the cursor position is in a table
-   */
+ * return boolean value whether the cursor position is in a table
+ */
 export const isInTable = (editor: EditorView): boolean => {
   const lineText = editor.state.doc.lineAt(curPos(editor)).text;
   return linePartOfTableRE.test(lineText);
 };
 
 /**
-   * return the postion of the BOT(beginning of table)
-   * (If the cursor is not in a table, return its position)
-   */
+ * return the postion of the BOT(beginning of table)
+ * (If the cursor is not in a table, return its position)
+ */
 const getBot = (editor: EditorView): number => {
   if (!isInTable(editor)) {
     return curPos(editor);
@@ -41,9 +41,9 @@ const getBot = (editor: EditorView): number => {
 };
 
 /**
-   * return the postion of the EOT(end of table)
-   * (If the cursor is not in a table, return its position)
-   */
+ * return the postion of the EOT(end of table)
+ * (If the cursor is not in a table, return its position)
+ */
 const getEot = (editor: EditorView): number => {
   if (!isInTable(editor)) {
     return curPos(editor);
@@ -63,24 +63,26 @@ const getEot = (editor: EditorView): number => {
 };
 
 /**
-   * return strings from BOT(beginning of table) to the cursor position
-   */
+ * return strings from BOT(beginning of table) to the cursor position
+ */
 export const getStrFromBot = (editor: EditorView): string => {
   return editor.state.sliceDoc(getBot(editor), curPos(editor));
 };
 
 /**
-   * return strings from the cursor position to EOT(end of table)
-   */
+ * return strings from the cursor position to EOT(end of table)
+ */
 export const getStrToEot = (editor: EditorView): string => {
   return editor.state.sliceDoc(curPos(editor), getEot(editor));
 };
 
 /**
-   * return MarkdownTable instance of the table where the cursor is
-   * (If the cursor is not in a table, return null)
-   */
-export const getMarkdownTable = (editor: EditorView): MarkdownTable | undefined => {
+ * return MarkdownTable instance of the table where the cursor is
+ * (If the cursor is not in a table, return null)
+ */
+export const getMarkdownTable = (
+  editor: EditorView,
+): MarkdownTable | undefined => {
   if (!isInTable(editor)) {
     return;
   }
@@ -90,28 +92,32 @@ export const getMarkdownTable = (editor: EditorView): MarkdownTable | undefined
 };
 
 /**
-   * return boolean value whether the cursor position is end of line
-   */
+ * return boolean value whether the cursor position is end of line
+ */
 export const isEndOfLine = (editor: EditorView): boolean => {
   return curPos(editor) === editor.state.doc.lineAt(curPos(editor)).to;
 };
 
 /**
-   * add a row at the end
-   * (This function overwrite directory markdown table specified as argument.)
-   */
+ * add a row at the end
+ * (This function overwrite directory markdown table specified as argument.)
+ */
 export const addRowToMarkdownTable = (mdtable: MarkdownTable): any => {
   const numCol = mdtable.table.length > 0 ? mdtable.table[0].length : 1;
   const newRow: string[] = [];
-  (new Array(numCol)).forEach(() => { return newRow.push('') }); // create cols
+  for (let index = 0; index < numCol; index += 1) {
+    newRow.push('');
+  } // create cols
   mdtable.table.push(newRow);
 };
 
 /**
-   * return markdown table that is merged all of markdown table in array
-   * (The merged markdown table options are used for the first markdown table.)
-   */
-export const mergeMarkdownTable = (mdtableList: MarkdownTable): MarkdownTable | undefined => {
+ * return markdown table that is merged all of markdown table in array
+ * (The merged markdown table options are used for the first markdown table.)
+ */
+export const mergeMarkdownTable = (
+  mdtableList: MarkdownTable,
+): MarkdownTable | undefined => {
   if (mdtableList == null || !(mdtableList instanceof Array)) {
     return undefined;
   }
@@ -121,14 +127,17 @@ export const mergeMarkdownTable = (mdtableList: MarkdownTable): MarkdownTable |
   mdtableList.forEach((mdtable) => {
     newTable = newTable.concat(mdtable.table);
   });
-  return (new MarkdownTable(newTable, options));
+  return new MarkdownTable(newTable, options);
 };
 
 /**
-   * replace focused markdown table with editor
-   * (A replaced table is reformed by markdown-table.)
-   */
-export const replaceFocusedMarkdownTableWithEditor = (editor: EditorView, table: MarkdownTable): void => {
+ * replace focused markdown table with editor
+ * (A replaced table is reformed by markdown-table.)
+ */
+export const replaceFocusedMarkdownTableWithEditor = (
+  editor: EditorView,
+  table: MarkdownTable,
+): void => {
   const botPos = getBot(editor);
   const eotPos = getEot(editor);
 

+ 48 - 41
apps/app/src/client/components/PageEditor/page-path-rename-utils.ts

@@ -1,63 +1,70 @@
 import { useCallback } from 'react';
-
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useFetchCurrentPage, useSetIsUntitledPage } from '~/states/page';
-import { mutatePageTree, mutatePageList, mutateRecentlyUpdated } from '~/stores/page-listing';
-
+import {
+  mutatePageList,
+  mutatePageTree,
+  mutateRecentlyUpdated,
+} from '~/stores/page-listing';
 
-type PagePathRenameHandler = (newPagePath: string, onRenameFinish?: () => void, onRenameFailure?: () => void, onRenamedSkipped?: () => void) => Promise<void>
+type PagePathRenameHandler = (
+  newPagePath: string,
+  onRenameFinish?: () => void,
+  onRenameFailure?: () => void,
+  onRenamedSkipped?: () => void,
+) => Promise<void>;
 
 export const usePagePathRenameHandler = (
-    currentPage?: IPagePopulatedToShowRevision | null,
+  currentPage?: IPagePopulatedToShowRevision | null,
 ): PagePathRenameHandler => {
-
   const { t } = useTranslation();
   const { fetchCurrentPage } = useFetchCurrentPage();
   const setIsUntitledPage = useSetIsUntitledPage();
 
-  const pagePathRenameHandler = useCallback(async(newPagePath, onRenameFinish, onRenameFailure) => {
+  const pagePathRenameHandler = useCallback(
+    async (newPagePath, onRenameFinish, onRenameFailure) => {
+      if (currentPage == null) {
+        return;
+      }
+
+      if (newPagePath === currentPage.path || newPagePath === '') {
+        onRenameFinish?.();
+        return;
+      }
+
+      const onRenamed = (fromPath: string | undefined, toPath: string) => {
+        mutatePageTree();
+        mutateRecentlyUpdated();
+        mutatePageList();
+        setIsUntitledPage(false);
 
-    if (currentPage == null) {
-      return;
-    }
+        if (currentPage.path === fromPath || currentPage.path === toPath) {
+          fetchCurrentPage({ force: true });
+        }
+      };
 
-    if (newPagePath === currentPage.path || newPagePath === '') {
-      onRenameFinish?.();
-      return;
-    }
+      try {
+        await apiv3Put('/pages/rename', {
+          pageId: currentPage._id,
+          revisionId: currentPage.revision?._id,
+          newPagePath,
+        });
 
-    const onRenamed = (fromPath: string | undefined, toPath: string) => {
-      mutatePageTree();
-      mutateRecentlyUpdated();
-      mutatePageList();
-      setIsUntitledPage(false);
+        onRenamed(currentPage.path, newPagePath);
+        onRenameFinish?.();
 
-      if (currentPage.path === fromPath || currentPage.path === toPath) {
-        fetchCurrentPage({ force: true });
+        toastSuccess(t('renamed_pages', { path: currentPage.path }));
+      } catch (err) {
+        onRenameFailure?.();
+        toastError(err);
       }
-    };
-
-    try {
-      await apiv3Put('/pages/rename', {
-        pageId: currentPage._id,
-        revisionId: currentPage.revision?._id,
-        newPagePath,
-      });
-
-      onRenamed(currentPage.path, newPagePath);
-      onRenameFinish?.();
-
-      toastSuccess(t('renamed_pages', { path: currentPage.path }));
-    }
-    catch (err) {
-      onRenameFailure?.();
-      toastError(err);
-    }
-  }, [currentPage, fetchCurrentPage, setIsUntitledPage, t]);
+    },
+    [currentPage, fetchCurrentPage, setIsUntitledPage, t],
+  );
 
   return pagePathRenameHandler;
 };

+ 3 - 5
apps/app/src/client/components/PageHeader/PageHeader.tsx

@@ -1,6 +1,4 @@
-import {
-  useCallback, useEffect, useRef, useState, type JSX,
-} from 'react';
+import { type JSX, useCallback, useEffect, useRef, useState } from 'react';
 
 import { useCurrentPageData } from '~/states/page';
 import { usePageControlsX } from '~/states/ui/page';
@@ -13,7 +11,6 @@ import styles from './PageHeader.module.scss';
 const moduleClass = styles['page-header'] ?? '';
 
 export const PageHeader = (): JSX.Element => {
-
   const currentPage = useCurrentPageData();
   const pageControlsX = usePageControlsX();
   const pageHeaderRef = useRef<HTMLDivElement>(null);
@@ -28,7 +25,8 @@ export const PageHeader = (): JSX.Element => {
     }
 
     // PageControls.x - PageHeader.x
-    const maxWidth = pageControlsX - pageHeaderRef.current.getBoundingClientRect().x;
+    const maxWidth =
+      pageControlsX - pageHeaderRef.current.getBoundingClientRect().x;
 
     setMaxWidth(maxWidth);
   }, [pageControlsX]);

+ 68 - 52
apps/app/src/client/components/PageHeader/PagePathHeader.tsx

@@ -1,43 +1,42 @@
 import type { ChangeEvent, JSX } from 'react';
-import {
-  useState, useCallback, memo,
-} from 'react';
-
-import nodePath from 'path';
-
+import { memo, useCallback, useState } from 'react';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
+import nodePath from 'path';
 import { debounce } from 'throttle-debounce';
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
-import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
 import type { IPageForItem } from '~/interfaces/page';
 import LinkedPagePath from '~/models/linked-page-path';
 import { usePageSelectModalActions } from '~/states/ui/modal/page-select';
 
 import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import {
+  AutosizeSubmittableInput,
+  getAdjustedMaxWidthForAutosizeInput,
+} from '../Common/SubmittableInput';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 import styles from './PagePathHeader.module.scss';
 
 const moduleClass = styles['page-path-header'];
 
-
 type Props = {
-  currentPage: IPagePopulatedToShowRevision,
-  className?: string,
-  maxWidth?: number,
-  onRenameTerminated?: () => void,
-}
+  currentPage: IPagePopulatedToShowRevision;
+  className?: string;
+  maxWidth?: number;
+  onRenameTerminated?: () => void;
+};
 
 export const PagePathHeader = memo((props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const {
-    currentPage, className, maxWidth, onRenameTerminated,
-  } = props;
+  const { currentPage, className, maxWidth, onRenameTerminated } = props;
 
   const dPagePath = new DevidedPagePath(currentPage.path, true);
   const parentPagePath = dPagePath.former;
@@ -49,17 +48,20 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
 
   const { open: openPageSelectModal } = usePageSelectModalActions();
 
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
   const inputValidator = useInputValidator(ValidationTarget.PAGE);
 
-  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
-    const validationResult = inputValidator(e.target.value);
-    setValidationResult(validationResult ?? undefined);
-  }, [inputValidator]);
+  const changeHandler = useCallback(
+    async (e: ChangeEvent<HTMLInputElement>) => {
+      const validationResult = inputValidator(e.target.value);
+      setValidationResult(validationResult ?? undefined);
+    },
+    [inputValidator],
+  );
   const changeHandlerDebounced = debounce(300, changeHandler);
 
-
   const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
 
   const onClickOpenPageSelectModalButton = useCallback(() => {
@@ -68,7 +70,8 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
         return;
       }
 
-      const currentPageTitle = nodePath.basename(currentPage?.path ?? '') || '/';
+      const currentPageTitle =
+        nodePath.basename(currentPage?.path ?? '') || '/';
       const newPagePath = nodePath.resolve(page.path, currentPageTitle);
 
       pagePathRenameHandler(newPagePath);
@@ -77,18 +80,23 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
     openPageSelectModal({ onSelected });
   }, [currentPage?.path, openPageSelectModal, pagePathRenameHandler]);
 
-  const rename = useCallback((inputText) => {
-    const pathToRename = normalizePath(`${inputText}/${dPagePath.latter}`);
-    pagePathRenameHandler(pathToRename,
-      () => {
-        setRenameInputShown(false);
-        setValidationResult(undefined);
-        onRenameTerminated?.();
-      },
-      () => {
-        setRenameInputShown(true);
-      });
-  }, [dPagePath.latter, pagePathRenameHandler, onRenameTerminated]);
+  const rename = useCallback(
+    (inputText) => {
+      const pathToRename = normalizePath(`${inputText}/${dPagePath.latter}`);
+      pagePathRenameHandler(
+        pathToRename,
+        () => {
+          setRenameInputShown(false);
+          setValidationResult(undefined);
+          onRenameTerminated?.();
+        },
+        () => {
+          setRenameInputShown(true);
+        },
+      );
+    },
+    [dPagePath.latter, pagePathRenameHandler, onRenameTerminated],
+  );
 
   const cancel = useCallback(() => {
     // reset
@@ -105,28 +113,36 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
     return <></>;
   }
 
-
   const isInvalid = validationResult != null;
 
-  const fixedMaxWidth = maxWidth != null
-    ? maxWidth - 60 // 60px is the width of the buttons
-    : undefined;
-  const inputMaxWidth = maxWidth != null
-    ? getAdjustedMaxWidthForAutosizeInput(maxWidth, 'sm', validationResult != null ? false : undefined) - 16
-    : undefined;
+  const fixedMaxWidth =
+    maxWidth != null
+      ? maxWidth - 60 // 60px is the width of the buttons
+      : undefined;
+  const inputMaxWidth =
+    maxWidth != null
+      ? getAdjustedMaxWidthForAutosizeInput(
+          maxWidth,
+          'sm',
+          validationResult != null ? false : undefined,
+        ) - 16
+      : undefined;
 
   return (
-    <div
+    <fieldset
       id="page-path-header"
-      className={`d-flex ${moduleClass} ${className ?? ''} small position-relative ms-2`}
+      className={`d-flex ${moduleClass} ${className ?? ''} small position-relative ms-2 border-0 p-0 m-0`}
+      aria-label="Page path header"
       onMouseEnter={() => setHover(true)}
       onMouseLeave={() => setHover(false)}
+      onFocus={() => setHover(true)}
+      onBlur={() => setHover(false)}
     >
       <div
         className="page-path-header-input d-inline-block"
         style={{ maxWidth: fixedMaxWidth }}
       >
-        { isRenameInputShown && (
+        {isRenameInputShown && (
           <div className="position-relative">
             <div className="position-absolute w-100">
               <AutosizeSubmittableInput
@@ -141,11 +157,11 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
               />
             </div>
           </div>
-        ) }
-        <div className={`${isRenameInputShown ? 'invisible' : ''} text-truncate`}>
-          <PagePathHierarchicalLink
-            linkedPagePath={linkedPagePath}
-          />
+        )}
+        <div
+          className={`${isRenameInputShown ? 'invisible' : ''} text-truncate`}
+        >
+          <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />
         </div>
       </div>
 
@@ -168,6 +184,6 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
           <span className="material-symbols-outlined fs-6">account_tree</span>
         </button>
       </div>
-    </div>
+    </fieldset>
   );
 });

+ 5 - 12
apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx

@@ -1,11 +1,8 @@
 import { faker } from '@faker-js/faker';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
-import {
-  fireEvent, render, screen, waitFor,
-} from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
 import { mock } from 'vitest-mock-extended';
 
-
 import { EditorMode } from '~/states/ui/editor';
 
 import { PageTitleHeader } from './PageTitleHeader';
@@ -21,13 +18,12 @@ vi.mock('~/states/page', async (importOriginal) => {
     useIsUntitledPage: mocks.useIsUntitledPageMock,
   };
 });
-vi.mock('~/states/ui/editor', async importOriginal => ({
-  ...await importOriginal(),
+vi.mock('~/states/ui/editor', async (importOriginal) => ({
+  ...(await importOriginal()),
   useEditorMode: mocks.useEditorModeMock,
 }));
 
 describe('PageTitleHeader Component with untitled page', () => {
-
   beforeAll(() => {
     mocks.useIsUntitledPageMock.mockImplementation(() => true);
   });
@@ -46,7 +42,8 @@ describe('PageTitleHeader Component with untitled page', () => {
     // header should be rendered
     const headerElement = screen.getByText('Untitled-1');
     const inputElement = screen.getByRole('textbox');
-    const inputElementByPlaceholder = screen.getByPlaceholderText('Input page name');
+    const inputElementByPlaceholder =
+      screen.getByPlaceholderText('Input page name');
     await waitFor(() => {
       expect(inputElement).toBeInTheDocument();
       expect(inputElement).toStrictEqual(inputElementByPlaceholder);
@@ -54,12 +51,9 @@ describe('PageTitleHeader Component with untitled page', () => {
       expect(headerElement).toHaveClass('invisible');
     });
   });
-
 });
 
-
 describe('PageTitleHeader Component', () => {
-
   beforeAll(() => {
     mocks.useIsUntitledPageMock.mockImplementation(() => false);
   });
@@ -112,5 +106,4 @@ describe('PageTitleHeader Component', () => {
       expect(headerElement).toHaveClass('invisible');
     });
   });
-
 });

+ 73 - 35
apps/app/src/client/components/PageHeader/PageTitleHeader.tsx

@@ -1,23 +1,25 @@
-import type { ChangeEvent, JSX } from 'react';
-import {
-  useState, useCallback, useEffect, useMemo,
-} from 'react';
-
-import nodePath from 'path';
-
+import type { ChangeEvent, JSX, KeyboardEvent } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
 import { isMovablePage } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'next-i18next';
+import nodePath from 'path';
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
-import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
 import { useIsUntitledPage } from '~/states/page';
 import { EditorMode, useEditorMode } from '~/states/ui/editor';
 
 import { CopyDropdown } from '../Common/CopyDropdown';
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import {
+  AutosizeSubmittableInput,
+  getAdjustedMaxWidthForAutosizeInput,
+} from '../Common/SubmittableInput';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 import styles from './PageTitleHeader.module.scss';
@@ -26,10 +28,10 @@ const moduleClass = styles['page-title-header'] ?? '';
 const borderColorClass = styles['page-title-header-border-color'] ?? '';
 
 type Props = {
-  currentPage: IPagePopulatedToShowRevision,
-  className?: string,
-  maxWidth?: number,
-  onMoveTerminated?: () => void,
+  currentPage: IPagePopulatedToShowRevision;
+  className?: string;
+  maxWidth?: number;
+  onMoveTerminated?: () => void;
 };
 
 export const PageTitleHeader = (props: Props): JSX.Element => {
@@ -45,7 +47,8 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [editedPagePath, setEditedPagePath] = useState(currentPagePath);
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
   const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
   const inputValidator = useInputValidator(ValidationTarget.PAGE);
@@ -55,20 +58,26 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
   const { editorMode } = useEditorMode();
   const isUntitledPage = useIsUntitledPage();
 
-  const changeHandler = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
-    const newPageTitle = pathUtils.removeHeadingSlash(e.target.value);
-    const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path));
-    const newPagePath = nodePath.resolve(parentPagePath, newPageTitle);
-
-    setEditedPagePath(newPagePath);
-
-    // validation
-    const validationResult = inputValidator(e.target.value);
-    setValidationResult(validationResult ?? undefined);
-  }, [currentPage.path, inputValidator]);
+  const changeHandler = useCallback(
+    async (e: ChangeEvent<HTMLInputElement>) => {
+      const newPageTitle = pathUtils.removeHeadingSlash(e.target.value);
+      const parentPagePath = pathUtils.addTrailingSlash(
+        nodePath.dirname(currentPage.path),
+      );
+      const newPagePath = nodePath.resolve(parentPagePath, newPageTitle);
+
+      setEditedPagePath(newPagePath);
+
+      // validation
+      const validationResult = inputValidator(e.target.value);
+      setValidationResult(validationResult ?? undefined);
+    },
+    [currentPage.path, inputValidator],
+  );
 
   const rename = useCallback(() => {
-    pagePathRenameHandler(editedPagePath,
+    pagePathRenameHandler(
+      editedPagePath,
       () => {
         setRenameInputShown(false);
         setValidationResult(undefined);
@@ -76,7 +85,8 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
       },
       () => {
         setRenameInputShown(true);
-      });
+      },
+    );
   }, [editedPagePath, onMoveTerminated, pagePathRenameHandler]);
 
   const cancel = useCallback(() => {
@@ -94,12 +104,26 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
     setRenameInputShown(true);
   }, [currentPagePath, isMovable]);
 
+  const onKeyDownPageTitle = useCallback(
+    (event: KeyboardEvent<HTMLHeadingElement>) => {
+      if (!isMovable) {
+        return;
+      }
+
+      if (event.key === 'Enter' || event.key === ' ') {
+        event.preventDefault();
+        onClickPageTitle();
+      }
+    },
+    [isMovable, onClickPageTitle],
+  );
+
   useEffect(() => {
     setEditedPagePath(currentPagePath);
     if (isUntitledPage != null) {
       setRenameInputShown(isUntitledPage && editorMode === EditorMode.Editor);
     }
-  }, [currentPage._id, currentPagePath, editorMode, isUntitledPage]);
+  }, [currentPagePath, editorMode, isUntitledPage]);
 
   const isInvalid = validationResult != null;
 
@@ -109,11 +133,18 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
       return undefined;
     }
 
-    const wipBadgeAndCopyDropdownWidth = 4 // me-1
-      + (currentPage.wip ? 49 : 0) // WIP badge + gap
-      + 24; // CopyDropdown
-
-    return getAdjustedMaxWidthForAutosizeInput(maxWidth, 'md', validationResult != null ? false : undefined) - wipBadgeAndCopyDropdownWidth;
+    const wipBadgeAndCopyDropdownWidth =
+      4 + // me-1
+      (currentPage.wip ? 49 : 0) + // WIP badge + gap
+      24; // CopyDropdown
+
+    return (
+      getAdjustedMaxWidthForAutosizeInput(
+        maxWidth,
+        'md',
+        validationResult != null ? false : undefined,
+      ) - wipBadgeAndCopyDropdownWidth
+    );
   }, [currentPage.wip, maxWidth, validationResult]);
 
   // calculate h1MaxWidth as the inputMaxWidth plus padding
@@ -126,7 +157,9 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
   }, [inputMaxWidth]);
 
   return (
-    <div className={`d-flex ${moduleClass} ${props.className ?? ''} position-relative`}>
+    <div
+      className={`d-flex ${moduleClass} ${props.className ?? ''} position-relative`}
+    >
       <div className="page-title-header-input me-1 d-inline-block">
         {isRenameInputShown && (
           <div className="position-relative">
@@ -151,12 +184,17 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
           `}
           style={{ maxWidth: h1MaxWidth }}
           onClick={onClickPageTitle}
+          onKeyDown={onKeyDownPageTitle}
+          role={isMovable ? 'button' : undefined}
+          tabIndex={isMovable ? 0 : -1}
         >
           {pageTitle}
         </h1>
       </div>
 
-      <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center gap-2`}>
+      <div
+        className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center gap-2`}
+      >
         {currentPage.wip && (
           <span className="badge rounded-pill text-bg-secondary">WIP</span>
         )}

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.4.1-slackbot-proxy.0",
+  "version": "7.4.2-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 0 - 4
biome.json

@@ -38,14 +38,12 @@
       "!apps/app/src/client/components/DescendantsPageListModal",
       "!apps/app/src/client/components/EmptyTrashModal",
       "!apps/app/src/client/components/GrantedGroupsInheritanceSelectModal",
-      "!apps/app/src/client/components/Hotkeys",
       "!apps/app/src/client/components/Icons",
       "!apps/app/src/client/components/InAppNotification",
       "!apps/app/src/client/components/ItemsTree",
       "!apps/app/src/client/components/LoginForm",
       "!apps/app/src/client/components/Maintenance",
       "!apps/app/src/client/components/Me",
-      "!apps/app/src/client/components/Navbar",
       "!apps/app/src/client/components/Page",
       "!apps/app/src/client/components/PageAccessoriesModal",
       "!apps/app/src/client/components/PageAttachment",
@@ -53,8 +51,6 @@
       "!apps/app/src/client/components/PageControls",
       "!apps/app/src/client/components/PageDeleteModal",
       "!apps/app/src/client/components/PageDuplicateModal",
-      "!apps/app/src/client/components/PageEditor",
-      "!apps/app/src/client/components/PageHeader",
       "!apps/app/src/client/components/PageHistory",
       "!apps/app/src/client/components/PageList",
       "!apps/app/src/client/components/PageManagement",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.4.1-RC.0",
+  "version": "7.4.2-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

+ 255 - 84
pnpm-lock.yaml

@@ -87,7 +87,7 @@ importers:
         version: 8.41.0
       eslint-config-next:
         specifier: ^12.1.6
-        version: 12.1.6(eslint@8.41.0)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4)
+        version: 12.1.6(eslint@8.41.0)(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4)
       eslint-config-weseek:
         specifier: ^2.1.1
         version: 2.1.1(@babel/core@7.24.6)(@babel/eslint-parser@7.24.7(@babel/core@7.24.6)(eslint@8.41.0))(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.0.4))(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint-plugin-import@2.26.0)(eslint-plugin-jsx-a11y@6.5.1(eslint@8.41.0))(eslint-plugin-react-hooks@4.6.0(eslint@8.41.0))(eslint-plugin-react@7.30.1(eslint@8.41.0))(eslint-plugin-vue@7.20.0(eslint@8.41.0))(eslint@8.41.0)
@@ -156,7 +156,7 @@ importers:
         version: 5.0.1(stylelint@16.5.0(typescript@5.0.4))
       stylelint-config-recommended-scss:
         specifier: ^14.0.0
-        version: 14.0.0(postcss@8.5.3)(stylelint@16.5.0(typescript@5.0.4))
+        version: 14.0.0(postcss@8.5.6)(stylelint@16.5.0(typescript@5.0.4))
       ts-deepmerge:
         specifier: ^6.2.0
         version: 6.2.0
@@ -336,7 +336,7 @@ importers:
         version: 3.9.1
       babel-plugin-superjson-next:
         specifier: ^0.4.2
-        version: 0.4.5(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
+        version: 0.4.5(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
       body-parser:
         specifier: ^1.20.3
         version: 1.20.3
@@ -536,20 +536,20 @@ importers:
         specifier: ^4.2.0
         version: 4.2.0
       next:
-        specifier: ^14.2.32
-        version: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+        specifier: ^14.2.35
+        version: 14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       next-dynamic-loading-props:
         specifier: ^0.1.1
         version: 0.1.1(react@18.2.0)
       next-i18next:
         specifier: ^15.3.1
-        version: 15.3.1(i18next@23.16.5)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
+        version: 15.3.1(i18next@23.16.5)(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
       next-superjson:
         specifier: ^1.0.7
-        version: 1.0.7(@swc/helpers@0.5.15)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
+        version: 1.0.7(@swc/helpers@0.5.18)(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
       next-themes:
         specifier: ^0.2.1
-        version: 0.2.1(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+        version: 0.2.1(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
       nocache:
         specifier: ^4.0.0
         version: 4.0.0
@@ -820,10 +820,10 @@ importers:
         version: 2.11.8
       '@swc-node/jest':
         specifier: ^1.8.1
-        version: 1.8.3(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)(typescript@5.4.2)
+        version: 1.8.3(@swc/core@1.10.7(@swc/helpers@0.5.18))(@swc/types@0.1.17)(typescript@5.4.2)
       '@swc/jest':
         specifier: ^0.2.36
-        version: 0.2.36(@swc/core@1.10.7(@swc/helpers@0.5.15))
+        version: 0.2.36(@swc/core@1.10.7(@swc/helpers@0.5.18))
       '@tanstack/react-virtual':
         specifier: ^3.13.12
         version: 3.13.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -901,7 +901,7 @@ importers:
         version: 8.18.1
       babel-loader:
         specifier: ^8.2.5
-        version: 8.3.0(@babel/core@7.24.6)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+        version: 8.3.0(@babel/core@7.24.6)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18)))
       bootstrap:
         specifier: '=5.3.2'
         version: 5.3.2(@popperjs/core@2.11.8)
@@ -922,7 +922,7 @@ importers:
         version: 3.1.0
       eslint-plugin-jest:
         specifier: ^26.5.3
-        version: 26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2)))(typescript@5.4.2)
+        version: 26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2)))(typescript@5.4.2)
       fastest-levenshtein:
         specifier: ^1.0.16
         version: 1.0.16
@@ -949,7 +949,7 @@ importers:
         version: 4.2.0
       jest:
         specifier: ^29.5.0
-        version: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2))
+        version: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2))
       jest-date-mock:
         specifier: ^1.0.8
         version: 1.0.10
@@ -979,7 +979,7 @@ importers:
         version: 1.10.0
       null-loader:
         specifier: ^4.0.1
-        version: 4.0.1(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+        version: 4.0.1(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18)))
       openapi-typescript:
         specifier: ^7.8.0
         version: 7.8.0(typescript@5.4.2)
@@ -1027,7 +1027,7 @@ importers:
         version: 4.8.3
       source-map-loader:
         specifier: ^4.0.1
-        version: 4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+        version: 4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18)))
       supertest:
         specifier: ^7.1.4
         version: 7.1.4
@@ -1109,10 +1109,10 @@ importers:
     devDependencies:
       '@swc-node/register':
         specifier: ^1.10.9
-        version: 1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)(typescript@5.4.2)
+        version: 1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.18))(@swc/types@0.1.17)(typescript@5.4.2)
       '@swc/core':
         specifier: ^1.9.2
-        version: 1.10.7(@swc/helpers@0.5.15)
+        version: 1.10.7(@swc/helpers@0.5.18)
       '@types/connect':
         specifier: ^3.4.38
         version: 3.4.38
@@ -1133,7 +1133,7 @@ importers:
         version: 7.1.4
       unplugin-swc:
         specifier: ^1.5.3
-        version: 1.5.5(@swc/core@1.10.7(@swc/helpers@0.5.15))(rollup@4.39.0)
+        version: 1.5.5(@swc/core@1.10.7(@swc/helpers@0.5.18))(rollup@4.39.0)
 
   apps/slackbot-proxy:
     dependencies:
@@ -3577,6 +3577,9 @@ packages:
   '@next/env@14.2.32':
     resolution: {integrity: sha512-n9mQdigI6iZ/DF6pCTwMKeWgF2e8lg7qgt5M7HXMLtyhZYMnf/u905M18sSpPmHL9MKp9JHo56C6jrD2EvWxng==}
 
+  '@next/env@14.2.35':
+    resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==}
+
   '@next/eslint-plugin-next@12.1.6':
     resolution: {integrity: sha512-yNUtJ90NEiYFT6TJnNyofKMPYqirKDwpahcbxBgSIuABwYOdkGwzos1ZkYD51Qf0diYwpQZBeVqElTk7Q2WNqw==}
 
@@ -3586,54 +3589,108 @@ packages:
     cpu: [arm64]
     os: [darwin]
 
+  '@next/swc-darwin-arm64@14.2.33':
+    resolution: {integrity: sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [darwin]
+
   '@next/swc-darwin-x64@14.2.32':
     resolution: {integrity: sha512-P9NpCAJuOiaHHpqtrCNncjqtSBi1f6QUdHK/+dNabBIXB2RUFWL19TY1Hkhu74OvyNQEYEzzMJCMQk5agjw1Qg==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [darwin]
 
+  '@next/swc-darwin-x64@14.2.33':
+    resolution: {integrity: sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [darwin]
+
   '@next/swc-linux-arm64-gnu@14.2.32':
     resolution: {integrity: sha512-v7JaO0oXXt6d+cFjrrKqYnR2ubrD+JYP7nQVRZgeo5uNE5hkCpWnHmXm9vy3g6foMO8SPwL0P3MPw1c+BjbAzA==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
 
+  '@next/swc-linux-arm64-gnu@14.2.33':
+    resolution: {integrity: sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+
   '@next/swc-linux-arm64-musl@14.2.32':
     resolution: {integrity: sha512-tA6sIKShXtSJBTH88i0DRd6I9n3ZTirmwpwAqH5zdJoQF7/wlJXR8DkPmKwYl5mFWhEKr5IIa3LfpMW9RRwKmQ==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
 
+  '@next/swc-linux-arm64-musl@14.2.33':
+    resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+
   '@next/swc-linux-x64-gnu@14.2.32':
     resolution: {integrity: sha512-7S1GY4TdnlGVIdeXXKQdDkfDysoIVFMD0lJuVVMeb3eoVjrknQ0JNN7wFlhCvea0hEk0Sd4D1hedVChDKfV2jw==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
 
+  '@next/swc-linux-x64-gnu@14.2.33':
+    resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+
   '@next/swc-linux-x64-musl@14.2.32':
     resolution: {integrity: sha512-OHHC81P4tirVa6Awk6eCQ6RBfWl8HpFsZtfEkMpJ5GjPsJ3nhPe6wKAJUZ/piC8sszUkAgv3fLflgzPStIwfWg==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
 
+  '@next/swc-linux-x64-musl@14.2.33':
+    resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+
   '@next/swc-win32-arm64-msvc@14.2.32':
     resolution: {integrity: sha512-rORQjXsAFeX6TLYJrCG5yoIDj+NKq31Rqwn8Wpn/bkPNy5rTHvOXkW8mLFonItS7QC6M+1JIIcLe+vOCTOYpvg==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [win32]
 
+  '@next/swc-win32-arm64-msvc@14.2.33':
+    resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [win32]
+
   '@next/swc-win32-ia32-msvc@14.2.32':
     resolution: {integrity: sha512-jHUeDPVHrgFltqoAqDB6g6OStNnFxnc7Aks3p0KE0FbwAvRg6qWKYF5mSTdCTxA3axoSAUwxYdILzXJfUwlHhA==}
     engines: {node: '>= 10'}
     cpu: [ia32]
     os: [win32]
 
+  '@next/swc-win32-ia32-msvc@14.2.33':
+    resolution: {integrity: sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==}
+    engines: {node: '>= 10'}
+    cpu: [ia32]
+    os: [win32]
+
   '@next/swc-win32-x64-msvc@14.2.32':
     resolution: {integrity: sha512-2N0lSoU4GjfLSO50wvKpMQgKd4HdI2UHEhQPPPnlgfBJlOgJxkjpkYBqzk08f1gItBB6xF/n+ykso2hgxuydsA==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [win32]
 
+  '@next/swc-win32-x64-msvc@14.2.33':
+    resolution: {integrity: sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [win32]
+
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
     resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==}
 
@@ -5224,6 +5281,9 @@ packages:
   '@swc/helpers@0.5.15':
     resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
 
+  '@swc/helpers@0.5.18':
+    resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
+
   '@swc/helpers@0.5.5':
     resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
 
@@ -11730,6 +11790,24 @@ packages:
       sass:
         optional: true
 
+  next@14.2.35:
+    resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==}
+    engines: {node: '>=18.17.0'}
+    hasBin: true
+    peerDependencies:
+      '@opentelemetry/api': ^1.1.0
+      '@playwright/test': ^1.41.2
+      react: ^18.2.0
+      react-dom: ^18.2.0
+      sass: ^1.3.0
+    peerDependenciesMeta:
+      '@opentelemetry/api':
+        optional: true
+      '@playwright/test':
+        optional: true
+      sass:
+        optional: true
+
   nice-try@1.0.4:
     resolution: {integrity: sha512-2NpiFHqC87y/zFke0fC0spBXL3bBsoh/p5H1EFhshxjCR5+0g2d6BiXbUFz9v1sAcxsk2htp2eQnNIci2dIYcA==}
 
@@ -12457,6 +12535,10 @@ packages:
     resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
     engines: {node: ^10 || ^12 || >=14}
 
+  postcss@8.5.6:
+    resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+    engines: {node: ^10 || ^12 || >=14}
+
   postgres-array@2.0.0:
     resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
     engines: {node: '>=4'}
@@ -17809,7 +17891,7 @@ snapshots:
       jest-util: 29.7.0
       slash: 3.0.0
 
-  '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2))':
+  '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2))':
     dependencies:
       '@jest/console': 29.7.0
       '@jest/reporters': 29.7.0
@@ -17823,7 +17905,7 @@ snapshots:
       exit: 0.1.2
       graceful-fs: 4.2.11
       jest-changed-files: 29.7.0
-      jest-config: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2))
+      jest-config: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2))
       jest-haste-map: 29.7.0
       jest-message-util: 29.7.0
       jest-regex-util: 29.6.3
@@ -18332,6 +18414,8 @@ snapshots:
 
   '@next/env@14.2.32': {}
 
+  '@next/env@14.2.35': {}
+
   '@next/eslint-plugin-next@12.1.6':
     dependencies:
       glob: 7.1.7
@@ -18339,30 +18423,57 @@ snapshots:
   '@next/swc-darwin-arm64@14.2.32':
     optional: true
 
+  '@next/swc-darwin-arm64@14.2.33':
+    optional: true
+
   '@next/swc-darwin-x64@14.2.32':
     optional: true
 
+  '@next/swc-darwin-x64@14.2.33':
+    optional: true
+
   '@next/swc-linux-arm64-gnu@14.2.32':
     optional: true
 
+  '@next/swc-linux-arm64-gnu@14.2.33':
+    optional: true
+
   '@next/swc-linux-arm64-musl@14.2.32':
     optional: true
 
+  '@next/swc-linux-arm64-musl@14.2.33':
+    optional: true
+
   '@next/swc-linux-x64-gnu@14.2.32':
     optional: true
 
+  '@next/swc-linux-x64-gnu@14.2.33':
+    optional: true
+
   '@next/swc-linux-x64-musl@14.2.32':
     optional: true
 
+  '@next/swc-linux-x64-musl@14.2.33':
+    optional: true
+
   '@next/swc-win32-arm64-msvc@14.2.32':
     optional: true
 
+  '@next/swc-win32-arm64-msvc@14.2.33':
+    optional: true
+
   '@next/swc-win32-ia32-msvc@14.2.32':
     optional: true
 
+  '@next/swc-win32-ia32-msvc@14.2.33':
+    optional: true
+
   '@next/swc-win32-x64-msvc@14.2.32':
     optional: true
 
+  '@next/swc-win32-x64-msvc@14.2.33':
+    optional: true
+
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
     dependencies:
       eslint-scope: 5.1.1
@@ -20421,12 +20532,17 @@ snapshots:
       '@swc/core': 1.10.7(@swc/helpers@0.5.15)
       '@swc/types': 0.1.17
 
-  '@swc-node/jest@1.8.3(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)(typescript@5.4.2)':
+  '@swc-node/core@1.13.3(@swc/core@1.10.7(@swc/helpers@0.5.18))(@swc/types@0.1.17)':
+    dependencies:
+      '@swc/core': 1.10.7(@swc/helpers@0.5.18)
+      '@swc/types': 0.1.17
+
+  '@swc-node/jest@1.8.3(@swc/core@1.10.7(@swc/helpers@0.5.18))(@swc/types@0.1.17)(typescript@5.4.2)':
     dependencies:
       '@node-rs/xxhash': 1.7.3
-      '@swc-node/core': 1.13.3(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)
-      '@swc-node/register': 1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)(typescript@5.4.2)
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc-node/core': 1.13.3(@swc/core@1.10.7(@swc/helpers@0.5.18))(@swc/types@0.1.17)
+      '@swc-node/register': 1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.18))(@swc/types@0.1.17)(typescript@5.4.2)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.18)
       '@swc/types': 0.1.17
       typescript: 5.4.2
     transitivePeerDependencies:
@@ -20447,11 +20563,11 @@ snapshots:
       - '@swc/types'
       - supports-color
 
-  '@swc-node/register@1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)(typescript@5.4.2)':
+  '@swc-node/register@1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.18))(@swc/types@0.1.17)(typescript@5.4.2)':
     dependencies:
-      '@swc-node/core': 1.13.3(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)
+      '@swc-node/core': 1.13.3(@swc/core@1.10.7(@swc/helpers@0.5.18))(@swc/types@0.1.17)
       '@swc-node/sourcemap-support': 0.5.1
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.18)
       colorette: 2.0.20
       debug: 4.4.3(supports-color@5.5.0)
       oxc-resolver: 1.12.0
@@ -20544,7 +20660,24 @@ snapshots:
       '@swc/core-win32-x64-msvc': 1.10.7
       '@swc/helpers': 0.5.15
 
-  '@swc/core@1.4.17(@swc/helpers@0.5.15)':
+  '@swc/core@1.10.7(@swc/helpers@0.5.18)':
+    dependencies:
+      '@swc/counter': 0.1.3
+      '@swc/types': 0.1.17
+    optionalDependencies:
+      '@swc/core-darwin-arm64': 1.10.7
+      '@swc/core-darwin-x64': 1.10.7
+      '@swc/core-linux-arm-gnueabihf': 1.10.7
+      '@swc/core-linux-arm64-gnu': 1.10.7
+      '@swc/core-linux-arm64-musl': 1.10.7
+      '@swc/core-linux-x64-gnu': 1.10.7
+      '@swc/core-linux-x64-musl': 1.10.7
+      '@swc/core-win32-arm64-msvc': 1.10.7
+      '@swc/core-win32-ia32-msvc': 1.10.7
+      '@swc/core-win32-x64-msvc': 1.10.7
+      '@swc/helpers': 0.5.18
+
+  '@swc/core@1.4.17(@swc/helpers@0.5.18)':
     dependencies:
       '@swc/counter': 0.1.3
       '@swc/types': 0.1.17
@@ -20559,7 +20692,7 @@ snapshots:
       '@swc/core-win32-arm64-msvc': 1.4.17
       '@swc/core-win32-ia32-msvc': 1.4.17
       '@swc/core-win32-x64-msvc': 1.4.17
-      '@swc/helpers': 0.5.15
+      '@swc/helpers': 0.5.18
 
   '@swc/counter@0.1.3': {}
 
@@ -20567,15 +20700,19 @@ snapshots:
     dependencies:
       tslib: 2.8.1
 
+  '@swc/helpers@0.5.18':
+    dependencies:
+      tslib: 2.8.1
+
   '@swc/helpers@0.5.5':
     dependencies:
       '@swc/counter': 0.1.3
       tslib: 2.8.1
 
-  '@swc/jest@0.2.36(@swc/core@1.10.7(@swc/helpers@0.5.15))':
+  '@swc/jest@0.2.36(@swc/core@1.10.7(@swc/helpers@0.5.18))':
     dependencies:
       '@jest/create-cache-key-function': 29.7.0
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.18)
       '@swc/counter': 0.1.3
       jsonc-parser: 3.2.0
 
@@ -22232,7 +22369,7 @@ snapshots:
 
   apache-arrow@19.0.1:
     dependencies:
-      '@swc/helpers': 0.5.15
+      '@swc/helpers': 0.5.18
       '@types/command-line-args': 5.2.3
       '@types/command-line-usage': 5.0.4
       '@types/node': 20.19.17
@@ -22246,7 +22383,7 @@ snapshots:
 
   apache-arrow@20.0.0:
     dependencies:
-      '@swc/helpers': 0.5.15
+      '@swc/helpers': 0.5.18
       '@types/command-line-args': 5.2.3
       '@types/command-line-usage': 5.0.4
       '@types/node': 20.19.17
@@ -22508,14 +22645,14 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  babel-loader@8.3.0(@babel/core@7.24.6)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))):
+  babel-loader@8.3.0(@babel/core@7.24.6)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18))):
     dependencies:
       '@babel/core': 7.24.6
       find-cache-dir: 3.3.2
       loader-utils: 2.0.4
       make-dir: 3.1.0
       schema-utils: 2.7.1
-      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))
+      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18))
 
   babel-plugin-istanbul@6.1.1:
     dependencies:
@@ -22534,12 +22671,12 @@ snapshots:
       '@types/babel__core': 7.20.5
       '@types/babel__traverse': 7.0.7
 
-  babel-plugin-superjson-next@0.4.5(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
+  babel-plugin-superjson-next@0.4.5(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
     dependencies:
       '@babel/helper-module-imports': 7.24.6
       '@babel/types': 7.25.6
       hoist-non-react-statics: 3.3.2
-      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       superjson: 2.2.2
 
   babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.6):
@@ -23540,13 +23677,13 @@ snapshots:
       isobject: 3.0.1
       lazy-cache: 2.0.2
 
-  create-jest@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2)):
+  create-jest@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2)):
     dependencies:
       '@jest/types': 29.6.3
       chalk: 4.1.2
       exit: 0.1.2
       graceful-fs: 4.2.11
-      jest-config: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2))
+      jest-config: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2))
       jest-util: 29.7.0
       prompts: 2.4.2
     transitivePeerDependencies:
@@ -24501,7 +24638,7 @@ snapshots:
       object.assign: 4.1.5
       object.entries: 1.1.5
 
-  eslint-config-next@12.1.6(eslint@8.41.0)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4):
+  eslint-config-next@12.1.6(eslint@8.41.0)(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4):
     dependencies:
       '@next/eslint-plugin-next': 12.1.6
       '@rushstack/eslint-patch': 1.1.3
@@ -24513,7 +24650,7 @@ snapshots:
       eslint-plugin-jsx-a11y: 6.5.1(eslint@8.41.0)
       eslint-plugin-react: 7.30.1(eslint@8.41.0)
       eslint-plugin-react-hooks: 4.6.0(eslint@8.41.0)
-      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
     optionalDependencies:
       typescript: 5.0.4
     transitivePeerDependencies:
@@ -24648,13 +24785,13 @@ snapshots:
       - typescript
     optional: true
 
-  eslint-plugin-jest@26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2)))(typescript@5.4.2):
+  eslint-plugin-jest@26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2)))(typescript@5.4.2):
     dependencies:
       '@typescript-eslint/utils': 5.59.7(eslint@8.41.0)(typescript@5.4.2)
       eslint: 8.41.0
     optionalDependencies:
       '@typescript-eslint/eslint-plugin': 5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2)
-      jest: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2))
+      jest: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2))
     transitivePeerDependencies:
       - supports-color
       - typescript
@@ -26493,16 +26630,16 @@ snapshots:
       - babel-plugin-macros
       - supports-color
 
-  jest-cli@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2)):
+  jest-cli@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2)):
     dependencies:
-      '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2))
+      '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2))
       '@jest/test-result': 29.7.0
       '@jest/types': 29.6.3
       chalk: 4.1.2
-      create-jest: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2))
+      create-jest: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2))
       exit: 0.1.2
       import-local: 3.1.0
-      jest-config: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2))
+      jest-config: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2))
       jest-util: 29.7.0
       jest-validate: 29.7.0
       yargs: 17.7.2
@@ -26512,7 +26649,7 @@ snapshots:
       - supports-color
       - ts-node
 
-  jest-config@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2)):
+  jest-config@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2)):
     dependencies:
       '@babel/core': 7.24.6
       '@jest/test-sequencer': 29.7.0
@@ -26538,7 +26675,7 @@ snapshots:
       strip-json-comments: 3.1.1
     optionalDependencies:
       '@types/node': 20.19.17
-      ts-node: 10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2)
+      ts-node: 10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2)
     transitivePeerDependencies:
       - babel-plugin-macros
       - supports-color
@@ -26768,12 +26905,12 @@ snapshots:
       merge-stream: 2.0.0
       supports-color: 8.1.1
 
-  jest@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2)):
+  jest@29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2)):
     dependencies:
-      '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2))
+      '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2))
       '@jest/types': 29.6.3
       import-local: 3.1.0
-      jest-cli: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2))
+      jest-cli: 29.7.0(@types/node@20.19.17)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2))
     transitivePeerDependencies:
       - '@types/node'
       - babel-plugin-macros
@@ -28383,7 +28520,7 @@ snapshots:
     dependencies:
       react: 18.2.0
 
-  next-i18next@15.3.1(i18next@23.16.5)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
+  next-i18next@15.3.1(i18next@23.16.5)(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
     dependencies:
       '@babel/runtime': 7.25.4
       '@types/hoist-non-react-statics': 3.3.5
@@ -28391,29 +28528,29 @@ snapshots:
       hoist-non-react-statics: 3.3.2
       i18next: 23.16.5
       i18next-fs-backend: 2.3.2
-      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       react: 18.2.0
       react-i18next: 15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
 
-  next-superjson-plugin@0.6.3(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
+  next-superjson-plugin@0.6.3(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
     dependencies:
       hoist-non-react-statics: 3.3.2
-      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       superjson: 2.2.2
 
-  next-superjson@1.0.7(@swc/helpers@0.5.15)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
+  next-superjson@1.0.7(@swc/helpers@0.5.18)(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
     dependencies:
-      '@swc/core': 1.4.17(@swc/helpers@0.5.15)
+      '@swc/core': 1.4.17(@swc/helpers@0.5.18)
       '@swc/types': 0.1.12
-      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
-      next-superjson-plugin: 0.6.3(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
+      next: 14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next-superjson-plugin: 0.6.3(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
     transitivePeerDependencies:
       - '@swc/helpers'
       - superjson
 
-  next-themes@0.2.1(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
+  next-themes@0.2.1(next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
     dependencies:
-      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
 
@@ -28445,6 +28582,34 @@ snapshots:
       - '@babel/core'
       - babel-plugin-macros
 
+  next@14.2.35(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6):
+    dependencies:
+      '@next/env': 14.2.35
+      '@swc/helpers': 0.5.5
+      busboy: 1.6.0
+      caniuse-lite: 1.0.30001761
+      graceful-fs: 4.2.11
+      postcss: 8.4.31
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+      styled-jsx: 5.1.1(@babel/core@7.24.6)(react@18.2.0)
+    optionalDependencies:
+      '@next/swc-darwin-arm64': 14.2.33
+      '@next/swc-darwin-x64': 14.2.33
+      '@next/swc-linux-arm64-gnu': 14.2.33
+      '@next/swc-linux-arm64-musl': 14.2.33
+      '@next/swc-linux-x64-gnu': 14.2.33
+      '@next/swc-linux-x64-musl': 14.2.33
+      '@next/swc-win32-arm64-msvc': 14.2.33
+      '@next/swc-win32-ia32-msvc': 14.2.33
+      '@next/swc-win32-x64-msvc': 14.2.33
+      '@opentelemetry/api': 1.9.0
+      '@playwright/test': 1.49.1
+      sass: 1.77.6
+    transitivePeerDependencies:
+      - '@babel/core'
+      - babel-plugin-macros
+
   nice-try@1.0.4: {}
 
   nimma@0.2.2:
@@ -28631,11 +28796,11 @@ snapshots:
     dependencies:
       boolbase: 1.0.0
 
-  null-loader@4.0.1(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))):
+  null-loader@4.0.1(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18))):
     dependencies:
       loader-utils: 2.0.4
       schema-utils: 3.3.0
-      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))
+      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18))
 
   numbro@2.5.0:
     dependencies:
@@ -29244,18 +29409,18 @@ snapshots:
     dependencies:
       postcss: 8.5.3
 
-  postcss-scss@4.0.9(postcss@8.5.3):
+  postcss-scss@4.0.9(postcss@8.5.6):
     dependencies:
-      postcss: 8.5.3
+      postcss: 8.5.6
 
   postcss-selector-parser@6.1.0:
     dependencies:
       cssesc: 3.0.0
       util-deprecate: 1.0.2
 
-  postcss-sorting@8.0.2(postcss@8.5.3):
+  postcss-sorting@8.0.2(postcss@8.5.6):
     dependencies:
-      postcss: 8.5.3
+      postcss: 8.5.6
 
   postcss-value-parser@4.2.0: {}
 
@@ -29271,6 +29436,12 @@ snapshots:
       picocolors: 1.1.1
       source-map-js: 1.2.1
 
+  postcss@8.5.6:
+    dependencies:
+      nanoid: 3.3.11
+      picocolors: 1.1.1
+      source-map-js: 1.2.1
+
   postgres-array@2.0.0: {}
 
   postgres-bytea@1.0.0: {}
@@ -30806,11 +30977,11 @@ snapshots:
 
   source-map-js@1.2.1: {}
 
-  source-map-loader@4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))):
+  source-map-loader@4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18))):
     dependencies:
       iconv-lite: 0.6.3
       source-map-js: 1.2.1
-      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))
+      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18))
 
   source-map-support@0.5.13:
     dependencies:
@@ -31119,14 +31290,14 @@ snapshots:
       stylelint: 16.5.0(typescript@5.0.4)
       stylelint-order: 6.0.4(stylelint@16.5.0(typescript@5.0.4))
 
-  stylelint-config-recommended-scss@14.0.0(postcss@8.5.3)(stylelint@16.5.0(typescript@5.0.4)):
+  stylelint-config-recommended-scss@14.0.0(postcss@8.5.6)(stylelint@16.5.0(typescript@5.0.4)):
     dependencies:
-      postcss-scss: 4.0.9(postcss@8.5.3)
+      postcss-scss: 4.0.9(postcss@8.5.6)
       stylelint: 16.5.0(typescript@5.0.4)
       stylelint-config-recommended: 14.0.0(stylelint@16.5.0(typescript@5.0.4))
       stylelint-scss: 6.3.0(stylelint@16.5.0(typescript@5.0.4))
     optionalDependencies:
-      postcss: 8.5.3
+      postcss: 8.5.6
 
   stylelint-config-recommended@14.0.0(stylelint@16.5.0(typescript@5.0.4)):
     dependencies:
@@ -31134,8 +31305,8 @@ snapshots:
 
   stylelint-order@6.0.4(stylelint@16.5.0(typescript@5.0.4)):
     dependencies:
-      postcss: 8.5.3
-      postcss-sorting: 8.0.2(postcss@8.5.3)
+      postcss: 8.5.6
+      postcss-sorting: 8.0.2(postcss@8.5.6)
       stylelint: 16.5.0(typescript@5.0.4)
 
   stylelint-scss@6.3.0(stylelint@16.5.0(typescript@5.0.4)):
@@ -31423,16 +31594,16 @@ snapshots:
 
   term-size@2.2.1: {}
 
-  terser-webpack-plugin@5.3.16(@swc/core@1.10.7(@swc/helpers@0.5.15))(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))):
+  terser-webpack-plugin@5.3.16(@swc/core@1.10.7(@swc/helpers@0.5.18))(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18))):
     dependencies:
       '@jridgewell/trace-mapping': 0.3.31
       jest-worker: 27.5.1
       schema-utils: 4.3.3
       serialize-javascript: 6.0.2
       terser: 5.44.1
-      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))
+      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18))
     optionalDependencies:
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.18)
 
   terser@5.44.1:
     dependencies:
@@ -31616,7 +31787,7 @@ snapshots:
     optionalDependencies:
       '@swc/core': 1.10.7(@swc/helpers@0.5.15)
 
-  ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@20.19.17)(typescript@5.4.2):
+  ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.18))(@types/node@20.19.17)(typescript@5.4.2):
     dependencies:
       '@cspotcode/source-map-support': 0.8.1
       '@tsconfig/node10': 1.0.9
@@ -31634,7 +31805,7 @@ snapshots:
       v8-compile-cache-lib: 3.0.1
       yn: 3.1.1
     optionalDependencies:
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.18)
     optional: true
 
   ts-patch@3.2.0:
@@ -32026,10 +32197,10 @@ snapshots:
 
   unpipe@1.0.0: {}
 
-  unplugin-swc@1.5.5(@swc/core@1.10.7(@swc/helpers@0.5.15))(rollup@4.39.0):
+  unplugin-swc@1.5.5(@swc/core@1.10.7(@swc/helpers@0.5.18))(rollup@4.39.0):
     dependencies:
       '@rollup/pluginutils': 5.2.0(rollup@4.39.0)
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.18)
       load-tsconfig: 0.2.5
       unplugin: 2.3.5
     transitivePeerDependencies:
@@ -32456,7 +32627,7 @@ snapshots:
 
   webpack-virtual-modules@0.6.2: {}
 
-  webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)):
+  webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18)):
     dependencies:
       '@types/eslint-scope': 3.7.7
       '@types/estree': 1.0.8
@@ -32479,7 +32650,7 @@ snapshots:
       neo-async: 2.6.2
       schema-utils: 3.3.0
       tapable: 2.3.0
-      terser-webpack-plugin: 5.3.16(@swc/core@1.10.7(@swc/helpers@0.5.15))(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+      terser-webpack-plugin: 5.3.16(@swc/core@1.10.7(@swc/helpers@0.5.18))(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.18)))
       watchpack: 2.5.0
       webpack-sources: 3.3.3
     transitivePeerDependencies: