Shun Miyazawa 2 лет назад
Родитель
Сommit
6b4194da59
77 измененных файлов с 1683 добавлено и 1607 удалено
  1. 3 2
      apps/app/package.json
  2. BIN
      apps/app/public/images/icons/sublime.png
  3. BIN
      apps/app/public/images/icons/vscode.png
  4. 2 0
      apps/app/src/client/services/create-page/index.ts
  5. 110 0
      apps/app/src/client/services/create-page/use-create-page-and-transit.tsx
  6. 38 0
      apps/app/src/client/services/create-page/use-create-template-page.ts
  7. 18 78
      apps/app/src/client/services/page-operation.ts
  8. 10 27
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  9. 11 28
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  10. 0 123
      apps/app/src/client/services/use-create-page-and-transit.tsx
  11. 0 55
      apps/app/src/client/services/use-on-template-button-clicked.ts
  12. 3 2
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  13. 33 20
      apps/app/src/components/Common/ClosableTextInput.tsx
  14. 2 1
      apps/app/src/components/Common/CountBadge.tsx
  15. 5 0
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  16. 13 4
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  17. 11 7
      apps/app/src/components/CreateTemplateModal.tsx
  18. 24 23
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  19. 2 2
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  20. 24 57
      apps/app/src/components/PageEditor/PageEditor.tsx
  21. 14 0
      apps/app/src/components/PageHeader/PageHeader.module.scss
  22. 8 8
      apps/app/src/components/PageHeader/PageHeader.tsx
  23. 82 80
      apps/app/src/components/PageHeader/PagePathHeader.tsx
  24. 81 18
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  25. 0 76
      apps/app/src/components/PageHeader/TextInputForPageTitleAndPath.tsx
  26. 7 5
      apps/app/src/components/PageHeader/page-header-utils.ts
  27. 3 2
      apps/app/src/components/PageSideContents/PageAccessoriesControl.tsx
  28. 1 0
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  29. 13 7
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  30. 28 22
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  31. 27 39
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  32. 0 109
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  33. 2 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks/index.ts
  34. 29 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  35. 45 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  36. 51 44
      apps/app/src/components/Sidebar/SidebarNav/PersonalDropdown.tsx
  37. 2 1
      apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx
  38. 2 1
      apps/app/src/components/Sidebar/SidebarNav/SidebarNav.tsx
  39. 1 1
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  40. 1 0
      apps/app/src/interfaces/apiv3/index.ts
  41. 37 0
      apps/app/src/interfaces/apiv3/page.ts
  42. 1 1
      apps/app/src/interfaces/editor-settings.ts
  43. 0 10
      apps/app/src/interfaces/page-operation.ts
  44. 17 2
      apps/app/src/interfaces/page.ts
  45. 6 4
      apps/app/src/pages/_private-legacy-pages.page.tsx
  46. 6 4
      apps/app/src/pages/_search.page.tsx
  47. 7 5
      apps/app/src/pages/me/[[...path]].page.tsx
  48. 9 6
      apps/app/src/pages/tags.page.tsx
  49. 0 43
      apps/app/src/server/models/interfaces/page-operation.ts
  50. 35 6
      apps/app/src/server/models/page-operation.ts
  51. 5 11
      apps/app/src/server/models/page.ts
  52. 54 0
      apps/app/src/server/routes/apiv3/page/check-page-existence.ts
  53. 254 0
      apps/app/src/server/routes/apiv3/page/create-page.ts
  54. 0 234
      apps/app/src/server/routes/apiv3/page/cteate-page.ts
  55. 109 0
      apps/app/src/server/routes/apiv3/page/index.js
  56. 172 0
      apps/app/src/server/routes/apiv3/page/update-page.ts
  57. 0 58
      apps/app/src/server/routes/apiv3/pages/index.js
  58. 0 2
      apps/app/src/server/routes/index.js
  59. 21 225
      apps/app/src/server/routes/page.js
  60. 40 48
      apps/app/src/server/service/installer.ts
  61. 3 3
      apps/app/src/server/service/page-grant.ts
  62. 43 52
      apps/app/src/server/service/page/index.ts
  63. 10 3
      apps/app/src/server/service/page/page-service.ts
  64. 10 3
      packages/core/src/models/devided-page-path.ts
  65. 3 0
      packages/core/src/utils/page-path-utils/index.spec.ts
  66. 4 3
      packages/core/src/utils/page-path-utils/index.ts
  67. 3 0
      packages/editor/package.json
  68. 16 1
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  69. 4 1
      packages/editor/src/components/CodeMirrorEditorMain.tsx
  70. 3 1
      packages/editor/src/components/playground/Playground.tsx
  71. 27 28
      packages/editor/src/components/playground/PlaygroundController.tsx
  72. 4 4
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  73. 1 0
      packages/editor/src/services/index.ts
  74. 31 0
      packages/editor/src/services/keymaps/index.ts
  75. 13 0
      packages/editor/src/services/keymaps/vim.ts
  76. 7 7
      packages/ui/src/components/Attachment.tsx
  77. 22 0
      yarn.lock

+ 3 - 2
apps/app/package.json

@@ -69,8 +69,8 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/custom-icons": "link:../../packages/custom-icons",
     "@growi/core": "link:../../packages/core",
     "@growi/core": "link:../../packages/core",
+    "@growi/custom-icons": "link:../../packages/custom-icons",
     "@growi/pluginkit": "link:../../packages/pluginkit",
     "@growi/pluginkit": "link:../../packages/pluginkit",
     "@growi/preset-templates": "link:../../packages/preset-templates",
     "@growi/preset-templates": "link:../../packages/preset-templates",
     "@growi/preset-themes": "link:../../packages/preset-themes",
     "@growi/preset-themes": "link:../../packages/preset-themes",
@@ -235,8 +235,8 @@
     "@types/jest": "^29.5.2",
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "@types/throttle-debounce": "^5.0.1",
     "@types/throttle-debounce": "^5.0.1",
-    "@types/url-join": "^4.0.2",
     "@types/unzip-stream": "^0.3.4",
     "@types/unzip-stream": "^0.3.4",
+    "@types/url-join": "^4.0.2",
     "@vitejs/plugin-react": "^4.2.1",
     "@vitejs/plugin-react": "^4.2.1",
     "@vitest/coverage-v8": "^0.34.6",
     "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",
@@ -270,6 +270,7 @@
     "react-copy-to-clipboard": "^5.0.1",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dropzone": "^11.2.4",
     "react-dropzone": "^11.2.4",
     "react-hotkeys": "^2.0.0",
     "react-hotkeys": "^2.0.0",
+    "react-input-autosize": "^3.0.0",
     "rehype-rewrite": "^3.0.6",
     "rehype-rewrite": "^3.0.6",
     "replacestream": "^4.0.3",
     "replacestream": "^4.0.3",
     "sass": "^1.53.0",
     "sass": "^1.53.0",

BIN
apps/app/public/images/icons/sublime.png


BIN
apps/app/public/images/icons/vscode.png


+ 2 - 0
apps/app/src/client/services/create-page/index.ts

@@ -0,0 +1,2 @@
+export * from './use-create-page-and-transit';
+export * from './use-create-template-page';

+ 110 - 0
apps/app/src/client/services/create-page/use-create-page-and-transit.tsx

@@ -0,0 +1,110 @@
+import { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { useCurrentPagePath } from '~/stores/page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
+
+/**
+ * Invoked when creation and transition has finished
+ */
+type OnCreated = () => void;
+/**
+ * Invoked when either creation or transition has aborted
+ */
+type OnAborted = () => void;
+/**
+ * Always invoked after processing is terminated
+ */
+type OnTerminated = () => void;
+
+type CreatePageAndTransitOpts = {
+  shouldCheckPageExists?: boolean,
+  onCreationStart?: OnCreated,
+  onCreated?: OnCreated,
+  onAborted?: OnAborted,
+  onTerminated?: OnTerminated,
+}
+
+type CreatePageAndTransit = (
+  params: IApiv3PageCreateParams,
+  opts?: CreatePageAndTransitOpts,
+) => Promise<void>;
+
+type UseCreatePageAndTransit = () => {
+  isCreating: boolean,
+  createAndTransit: CreatePageAndTransit,
+};
+
+export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
+
+  const router = useRouter();
+
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const [isCreating, setCreating] = useState(false);
+
+  const createAndTransit: CreatePageAndTransit = useCallback(async(params, opts = {}) => {
+    const {
+      shouldCheckPageExists,
+      onCreationStart, onCreated, onAborted, onTerminated,
+    } = opts;
+
+    // check the page existence
+    if (shouldCheckPageExists && params.path != null) {
+      const pagePath = params.path;
+
+      try {
+        const { isExist } = await exist(pagePath);
+
+        if (isExist) {
+          // routing
+          if (pagePath !== currentPagePath) {
+            await router.push(`${pagePath}#edit`);
+          }
+          mutateEditorMode(EditorMode.Editor);
+          onAborted?.();
+          return;
+        }
+      }
+      catch (err) {
+        throw err;
+      }
+      finally {
+        onTerminated?.();
+      }
+    }
+
+    // create and transit
+    try {
+      setCreating(true);
+      onCreationStart?.();
+
+      const response = await createPage(params);
+
+      await router.push(`/${response.page._id}#edit`);
+      mutateEditorMode(EditorMode.Editor);
+
+      onCreated?.();
+    }
+    catch (err) {
+      throw err;
+    }
+    finally {
+      onTerminated?.();
+      setCreating(false);
+    }
+
+  }, [currentPagePath, mutateEditorMode, router]);
+
+  return {
+    isCreating,
+    createAndTransit,
+  };
+};

+ 38 - 0
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+
+import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
+
+import type { LabelType } from '~/interfaces/template';
+import { useCurrentPagePath } from '~/stores/page';
+
+import { useCreatePageAndTransit } from './use-create-page-and-transit';
+
+type UseCreateTemplatePage = () => {
+  isCreatable: boolean,
+  isCreating: boolean,
+  createTemplate?: (label: LabelType) => Promise<void>,
+}
+
+export const useCreateTemplatePage: UseCreateTemplatePage = () => {
+
+  const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
+
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
+
+  const createTemplate = useCallback(async(label: LabelType) => {
+    if (isLoadingPagePath || !isCreatable) return;
+
+    return createAndTransit(
+      { path: normalizePath(`${currentPagePath}/${label}`) },
+      { shouldCheckPageExists: true },
+    );
+  }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
+
+  return {
+    isCreatable,
+    isCreating,
+    createTemplate: isCreatable ? createTemplate : undefined,
+  };
+};

+ 18 - 78
apps/app/src/client/services/page-operation.ts

@@ -1,20 +1,23 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
-import { SubscriptionStatusType, type Nullable } from '@growi/core';
+import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
-import { OptionsToSave } from '~/interfaces/page-operation';
-import { useEditingMarkdown, useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
+import type {
+  IApiv3PageCreateParams, IApiv3PageCreateResponse, IApiv3PageUpdateParams, IApiv3PageUpdateResponse,
+} from '~/interfaces/apiv3';
+import { useEditingMarkdown, usePageTagsForEditors } from '~/stores/editor';
 import { useCurrentPageId, useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useCurrentPageId, useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { apiGet, apiPost } from '../util/apiv1-client';
-import { apiv3Post, apiv3Put } from '../util/apiv3-client';
+import { apiPost } from '../util/apiv1-client';
+import { apiv3Get, apiv3Post, apiv3Put } from '../util/apiv3-client';
 import { toastError } from '../util/toastr';
 import { toastError } from '../util/toastr';
 
 
 const logger = loggerFactory('growi:services:page-operation');
 const logger = loggerFactory('growi:services:page-operation');
 
 
+
 export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
 export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
   try {
   try {
     const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
     const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
@@ -87,71 +90,14 @@ export const resumeRenameOperation = async(pageId: string): Promise<void> => {
   await apiv3Post('/pages/resume-rename', { pageId });
   await apiv3Post('/pages/resume-rename', { pageId });
 };
 };
 
 
-// TODO: define return type
-export const createPage = async(pagePath: string, markdown: string, tmpParams: OptionsToSave) => {
-  // clone
-  const params = Object.assign(tmpParams, {
-    path: pagePath,
-    body: markdown,
-  });
-
-  const res = await apiv3Post('/pages/', params);
-  const { page, tags, revision } = res.data;
-
-  return { page, tags, revision };
+export const createPage = async(params: IApiv3PageCreateParams): Promise<IApiv3PageCreateResponse> => {
+  const res = await apiv3Post<IApiv3PageCreateResponse>('/page', params);
+  return res.data;
 };
 };
 
 
-// TODO: define return type
-const updatePage = async(pageId: string, revisionId: string, markdown: string, tmpParams: OptionsToSave) => {
-  // clone
-  const params = Object.assign(tmpParams, {
-    page_id: pageId,
-    revision_id: revisionId,
-    body: markdown,
-  });
-
-  const res: any = await apiPost('/pages.update', params);
-  if (!res.ok) {
-    throw new Error(res.error);
-  }
-  return res;
-};
-
-type PageInfo= {
-  path: string,
-  pageId: Nullable<string>,
-  revisionId: Nullable<string>,
-}
-
-type SaveOrUpdateFunction = (markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => any;
-
-// TODO: define return type
-export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
-  /* eslint-disable react-hooks/rules-of-hooks */
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
-  /* eslint-enable react-hooks/rules-of-hooks */
-
-  return useCallback(async(markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => {
-    const { path, pageId, revisionId } = pageInfo;
-
-    const options: OptionsToSave = Object.assign({}, optionsToSave);
-
-    let res;
-    if (pageId == null || revisionId == null) {
-      res = await createPage(path, markdown, options);
-    }
-    else {
-      if (revisionId == null) {
-        const msg = '\'revisionId\' is required to update page';
-        throw new Error(msg);
-      }
-      res = await updatePage(pageId, revisionId, markdown, options);
-    }
-
-    mutateIsEnabledUnsavedWarning(false);
-
-    return res;
-  }, [mutateIsEnabledUnsavedWarning]);
+export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
+  const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
+  return res.data;
 };
 };
 
 
 export type UpdateStateAfterSaveOption = {
 export type UpdateStateAfterSaveOption = {
@@ -205,17 +151,11 @@ export const unlink = async(path: string): Promise<void> => {
 };
 };
 
 
 
 
-interface PageExistRequest {
-  pagePaths: string;
-}
-
 interface PageExistResponse {
 interface PageExistResponse {
-  pages: Record<string, boolean>;
-  ok: boolean
+  isExist: boolean,
 }
 }
 
 
-export const exist = async(pagePaths: string): Promise<PageExistResponse> => {
-  const request: PageExistRequest = { pagePaths };
-  const res = await apiGet<PageExistResponse>('/pages.exist', request);
-  return res;
+export const exist = async(path: string): Promise<PageExistResponse> => {
+  const res = await apiv3Get<PageExistResponse>('/page/exist', { path });
+  return res.data;
 };
 };

+ 10 - 27
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -1,17 +1,17 @@
 import { useCallback, useEffect } from 'react';
 import { useCallback, useEffect } from 'react';
 
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 
 
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
 
-import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { replaceDrawioInMarkdown } from '~/components/Page/markdown-drawio-util-for-view';
 import { replaceDrawioInMarkdown } from '~/components/Page/markdown-drawio-util-for-view';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { useShareLinkId } from '~/stores/context';
 import { useShareLinkId } from '~/stores/context';
 import { useDrawioModal } from '~/stores/modal';
 import { useDrawioModal } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { updatePage } from '../page-operation';
+
 
 
 const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
 const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
 
 
@@ -33,8 +33,6 @@ export const useDrawioModalLauncherForView = (opts?: {
 
 
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openDrawioModal } = useDrawioModal();
 
 
-  const saveOrUpdate = useSaveOrUpdate();
-
   const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
   const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
     if (currentPage == null || shareLinkId != null) {
     if (currentPage == null || shareLinkId != null) {
       return;
       return;
@@ -43,28 +41,13 @@ export const useDrawioModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
     const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
 
-    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
-      return {
-        type: g.type,
-        item: g.item._id,
-      };
-    });
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      // grantUserGroupIds,
-      // pageTags: tagsInfo.tags,
-    };
-
     try {
     try {
       const currentRevisionId = currentPage.revision._id;
       const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
+      await updatePage({
+        pageId: currentPage._id,
+        revisionId: currentRevisionId,
+        body: newMarkdown,
+      });
 
 
       opts?.onSaveSuccess?.();
       opts?.onSaveSuccess?.();
     }
     }
@@ -72,7 +55,7 @@ export const useDrawioModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
       opts?.onSaveError?.(error);
     }
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
+  }, [currentPage, opts, shareLinkId]);
 
 
 
 
   // set handler to open DrawioModal
   // set handler to open DrawioModal

+ 11 - 28
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -1,16 +1,16 @@
 import { useCallback, useEffect } from 'react';
 import { useCallback, useEffect } from 'react';
 
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 
 
-import MarkdownTable from '~/client/models/MarkdownTable';
-import { useSaveOrUpdate } from '~/client/services/page-operation';
+import type MarkdownTable from '~/client/models/MarkdownTable';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { useShareLinkId } from '~/stores/context';
 import { useShareLinkId } from '~/stores/context';
 import { useHandsontableModal } from '~/stores/modal';
 import { useHandsontableModal } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { updatePage } from '../page-operation';
+
 
 
 const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
 const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
 
 
@@ -32,8 +32,6 @@ export const useHandsontableModalLauncherForView = (opts?: {
 
 
   const { open: openHandsontableModal } = useHandsontableModal();
   const { open: openHandsontableModal } = useHandsontableModal();
 
 
-  const saveOrUpdate = useSaveOrUpdate();
-
   const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
   const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
     if (currentPage == null || shareLinkId != null) {
     if (currentPage == null || shareLinkId != null) {
       return;
       return;
@@ -42,28 +40,13 @@ export const useHandsontableModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
     const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
 
 
-    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
-      return {
-        type: g.type,
-        item: g.item._id,
-      };
-    });
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      // grantUserGroupIds,
-      // pageTags: tagsInfo.tags,
-    };
-
     try {
     try {
       const currentRevisionId = currentPage.revision._id;
       const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
+      await updatePage({
+        pageId: currentPage._id,
+        revisionId: currentRevisionId,
+        body: newMarkdown,
+      });
 
 
       opts?.onSaveSuccess?.();
       opts?.onSaveSuccess?.();
     }
     }
@@ -71,7 +54,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
       opts?.onSaveError?.(error);
     }
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
+  }, [currentPage, opts, shareLinkId]);
 
 
 
 
   // set handler to open HandsonTableModal
   // set handler to open HandsonTableModal

+ 0 - 123
apps/app/src/client/services/use-create-page-and-transit.tsx

@@ -1,123 +0,0 @@
-import { useCallback } from 'react';
-
-import { useRouter } from 'next/router';
-
-import { createPage } from '~/client/services/page-operation';
-import { useIsNotFound, useSWRxCurrentPage } from '~/stores/page';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
-
-/**
- * Invoked when creation and transition has finished
- */
-type OnCreated = () => void;
-/**
- * Invoked when either creation or transition has aborted
- */
-type OnAborted = () => void;
-/**
- * Invoked when an error is occured
- */
-type OnError = (err) => void;
-/**
- * Always invoked after processing is terminated
- */
-type OnTerminated = () => void;
-
-type CreatePageAndTransitOpts = {
-  onCreationStart?: OnCreated,
-  onCreated?: OnCreated,
-  onAborted?: OnAborted,
-  onError?: OnError,
-  onTerminated?: OnTerminated,
-}
-
-type CreatePageAndTransit = (
-  pagePath: string | undefined,
-  // grant?: number,
-  // grantUserGroupId?: string,
-  opts?: CreatePageAndTransitOpts,
-) => Promise<void>;
-
-export const useCreatePageAndTransit = (): CreatePageAndTransit => {
-
-  const router = useRouter();
-
-  const { data: isNotFound } = useIsNotFound();
-  const { data: currentPage, isLoading } = useSWRxCurrentPage();
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  // const {
-  //   path: currentPagePath,
-  //   grant: currentPageGrant,
-  //   grantedGroups: currentPageGrantedGroups,
-  // } = currentPage ?? {};
-
-  return useCallback(async(pagePath, opts = {}) => {
-    if (isLoading) {
-      return;
-    }
-
-    const {
-      onCreationStart, onCreated, onAborted, onError, onTerminated,
-    } = opts;
-
-    if (isNotFound == null || !isNotFound || pagePath == null) {
-      mutateEditorMode(EditorMode.Editor);
-
-      onAborted?.();
-      onTerminated?.();
-      return;
-    }
-
-    try {
-      onCreationStart?.();
-
-      /**
-       * !! NOTICE !! - Verification of page createable or not is checked on the server side.
-       * since the new page path is not generated on the client side.
-       * need shouldGeneratePath flag.
-       */
-      // const shouldCreateUnderRoot = currentPagePath == null || currentPageGrant == null;
-      // const parentPath = shouldCreateUnderRoot
-      //   ? '/'
-      //   : currentPagePath;
-
-      // const params = {
-      //   isSlackEnabled: false,
-      //   slackChannels: '',
-      //   grant: shouldCreateUnderRoot ? 1 : currentPageGrant,
-      //   grantUserGroupIds: shouldCreateUnderRoot ? undefined : currentPageGrantedGroups,
-      //   shouldGeneratePath: true,
-      // };
-
-      // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
-      // const response = await createPage(parentPath, '', params);
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-        // grant,
-        // grantUserGroupId,
-      };
-
-      const response = await createPage(pagePath, '', params);
-
-      await router.push(`${response.page.id}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-
-      onCreated?.();
-    }
-    catch (err) {
-      logger.warn(err);
-      onError?.(err);
-    }
-    finally {
-      onTerminated?.();
-    }
-
-  }, [isLoading, isNotFound, mutateEditorMode, router]);
-};

+ 0 - 55
apps/app/src/client/services/use-on-template-button-clicked.ts

@@ -1,55 +0,0 @@
-import { useCallback, useState } from 'react';
-
-import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
-import { useRouter } from 'next/router';
-
-import { createPage, exist } from '~/client/services/page-operation';
-import type { LabelType } from '~/interfaces/template';
-
-export const useOnTemplateButtonClicked = (
-    currentPagePath?: string,
-    isLoading?: boolean,
-): {
-  onClickHandler: (label: LabelType) => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const onClickHandler = useCallback(async(label: LabelType) => {
-    if (isLoading) return;
-
-    try {
-      setIsPageCreating(true);
-
-      const targetPath = currentPagePath == null || currentPagePath === '/'
-        ? `/${label}`
-        : `${currentPagePath}/${label}`;
-
-      const path = isCreatablePage(targetPath) ? targetPath : `/${label}`;
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        const params = {
-          isSlackEnabled: false,
-          slackChannels: '',
-          grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-        };
-
-        await createPage(path, '', params);
-      }
-
-      router.push(`${path}#edit`);
-    }
-    catch (err) {
-      throw err;
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [currentPagePath, isLoading, router]);
-
-  return { onClickHandler, isPageCreating };
-};

+ 3 - 2
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -14,7 +14,8 @@ import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { ValidationTarget } from '~/client/util/input-validator';
 import { ValidationTarget } from '~/client/util/input-validator';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { BookmarkFolderItems, DragItemDataType, DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info';
+import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { usePutBackPageModal } from '~/stores/modal';
 import { usePutBackPageModal } from '~/stores/modal';
 import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page';
 import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page';
 
 
@@ -191,7 +192,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
           target={bookmarkItemId}
           target={bookmarkItemId}
           fade={false}
           fade={false}
         >
         >
-          {formerPagePath !== null ? `${formerPagePath}/` : '/'}
+          {dPagePath.isFormerRoot ? '/' : `${formerPagePath}/`}
         </UncontrolledTooltip>
         </UncontrolledTooltip>
       </li>
       </li>
     </DragAndDropWrapper>
     </DragAndDropWrapper>

+ 33 - 20
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -1,18 +1,23 @@
+import type { FC } from 'react';
 import React, {
 import React, {
-  FC, memo, useEffect, useRef, useState,
+  memo, useEffect, useRef, useState,
 } from 'react';
 } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import AutosizeInput from 'react-input-autosize';
 
 
-import { AlertInfo, AlertType, inputValidator } from '~/client/util/input-validator';
+import type { AlertInfo } from '~/client/util/input-validator';
+import { AlertType, inputValidator } from '~/client/util/input-validator';
 
 
 type ClosableTextInputProps = {
 type ClosableTextInputProps = {
   value?: string
   value?: string
   placeholder?: string
   placeholder?: string
   validationTarget?: string,
   validationTarget?: string,
+  useAutosizeInput?: boolean
   onPressEnter?(inputText: string | null): void
   onPressEnter?(inputText: string | null): void
+  onPressEscape?: () => void
   onClickOutside?(): void
   onClickOutside?(): void
-  handleInputChange?: (string) => void
+  onChange?(inputText: string): void
 }
 }
 
 
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
@@ -40,7 +45,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     setInputText(inputText);
     setInputText(inputText);
     setIsAbleToShowAlert(true);
     setIsAbleToShowAlert(true);
 
 
-    props.handleInputChange?.(inputText);
+    props.onChange?.(inputText);
   };
   };
 
 
   const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
   const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -66,6 +71,12 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         }
         }
         onPressEnter();
         onPressEnter();
         break;
         break;
+      case 'Escape':
+        if (isComposing) {
+          return;
+        }
+        props.onPressEscape?.();
+        break;
       default:
       default:
         break;
         break;
     }
     }
@@ -106,25 +117,27 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     );
     );
   };
   };
 
 
+  const inputProps = {
+    'data-testid': 'closable-text-input',
+    value: inputText || '',
+    ref: inputRef,
+    type: 'text',
+    placeholder: props.placeholder,
+    name: 'input',
+    onFocus: onFocusHandler,
+    onChange: onChangeHandler,
+    onKeyDown: onKeyDownHandler,
+    onCompositionStart: () => setComposing(true),
+    onCompositionEnd: () => setComposing(false),
+    onBlur: onBlurHandler,
+  };
 
 
   return (
   return (
     <div>
     <div>
-      <input
-        value={inputText || ''}
-        ref={inputRef}
-        type="text"
-        className="form-control"
-        placeholder={props.placeholder}
-        name="input"
-        data-testid="closable-text-input"
-        onFocus={onFocusHandler}
-        onChange={onChangeHandler}
-        onKeyDown={onKeyDownHandler}
-        onCompositionStart={() => setComposing(true)}
-        onCompositionEnd={() => setComposing(false)}
-        onBlur={onBlurHandler}
-        autoFocus={false}
-      />
+      { props.useAutosizeInput
+        ? <AutosizeInput {...inputProps} />
+        : <input className="form-control" {...inputProps} />
+      }
       {isAbleToShowAlert && <AlertInfo />}
       {isAbleToShowAlert && <AlertInfo />}
     </div>
     </div>
   );
   );

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

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 
 type CountProps = {
 type CountProps = {
   count?: number,
   count?: number,

+ 5 - 0
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -6,6 +6,11 @@
   margin-right: 0.2em;
   margin-right: 0.2em;
 }
 }
 
 
+.grw-mx-02em {
+  margin-right: 0.2em;
+  margin-left: 0.2em;
+}
+
 .grw-page-path-nav-sticky :global {
 .grw-page-path-nav-sticky :global {
   min-height: 75px;
   min-height: 75px;
 
 

+ 13 - 4
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -27,9 +28,12 @@ type Props = {
 
 
 const CopyDropdown = dynamic(() => import('../CopyDropdown').then(mod => mod.CopyDropdown), { ssr: false });
 const CopyDropdown = dynamic(() => import('../CopyDropdown').then(mod => mod.CopyDropdown), { ssr: false });
 
 
-const Separator = (): JSX.Element => {
+const RootSlash = (): JSX.Element => {
   return <span className={styles['grw-mr-02em']}>/</span>;
   return <span className={styles['grw-mr-02em']}>/</span>;
 };
 };
+const Separator = (): JSX.Element => {
+  return <span className={styles['grw-mx-02em']}>/</span>;
+};
 
 
 export const PagePathNav: FC<Props> = (props: Props) => {
 export const PagePathNav: FC<Props> = (props: Props) => {
   const {
   const {
@@ -66,10 +70,15 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   else {
   else {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />;
-    latterLink = (
+    formerLink = (
       <>
       <>
+        <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />
         <Separator />
         <Separator />
+      </>
+    );
+    latterLink = (
+      <>
+        <RootSlash />
         <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} isInTrash={isInTrash} />
         <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} isInTrash={isInTrash} />
       </>
       </>
     );
     );

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

@@ -4,7 +4,7 @@ import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
 
-import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
+import { useCreateTemplatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import type { TargetType, LabelType } from '~/interfaces/template';
 import type { TargetType, LabelType } from '~/interfaces/template';
 
 
@@ -53,19 +53,19 @@ type CreateTemplateModalProps = {
 export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
 export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
   path, isOpen, onClose,
   path, isOpen, onClose,
 }) => {
 }) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation(['translation', 'commons']);
 
 
-  const { onClickHandler: onClickTemplateButton, isPageCreating } = useOnTemplateButtonClicked(path);
+  const { createTemplate, isCreating, isCreatable } = useCreateTemplatePage();
 
 
   const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
   const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
     try {
     try {
-      await onClickTemplateButton(label);
+      await createTemplate?.(label);
       onClose();
       onClose();
     }
     }
     catch (err) {
     catch (err) {
-      toastError(err);
+      toastError(t('toaster.create_failed', { target: path }));
     }
     }
-  }, [onClickTemplateButton, onClose]);
+  }, [createTemplate, path, t]);
 
 
   const parentPath = pathUtils.addTrailingSlash(path);
   const parentPath = pathUtils.addTrailingSlash(path);
 
 
@@ -74,12 +74,16 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
       <TemplateCard
       <TemplateCard
         target={target}
         target={target}
         label={label}
         label={label}
-        isPageCreating={isPageCreating}
+        isPageCreating={isCreating}
         onClickHandler={() => onClickTemplateButtonHandler(label)}
         onClickHandler={() => onClickTemplateButtonHandler(label)}
       />
       />
     </div>
     </div>
   );
   );
 
 
+  if (!isCreatable) {
+    return <></>;
+  }
+
   return (
   return (
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
       <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">

+ 24 - 23
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,12 +1,12 @@
-import React, { type ReactNode, useCallback, useState } from 'react';
+import React, { type ReactNode, useCallback } from 'react';
 
 
-import type { IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
+import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
+import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
 
-import { useCreatePageAndTransit } from '../../client/services/use-create-page-and-transit';
 
 
 import styles from './PageEditorModeManager.module.scss';
 import styles from './PageEditorModeManager.module.scss';
 
 
@@ -47,9 +47,6 @@ type Props = {
   editorMode: EditorMode | undefined,
   editorMode: EditorMode | undefined,
   isBtnDisabled: boolean,
   isBtnDisabled: boolean,
   path?: string,
   path?: string,
-  grant?: number,
-  // grantUserGroupId?: string
-  grantUserGroupIds?: IGrantedGroup[]
 }
 }
 
 
 export const PageEditorModeManager = (props: Props): JSX.Element => {
 export const PageEditorModeManager = (props: Props): JSX.Element => {
@@ -57,30 +54,34 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     editorMode = EditorMode.View,
     editorMode = EditorMode.View,
     isBtnDisabled,
     isBtnDisabled,
     path,
     path,
-    // grant,
-    // grantUserGroupId,
   } = props;
   } = props;
 
 
-  const { t } = useTranslation('common');
-  const [isCreating, setIsCreating] = useState(false);
+  const { t } = useTranslation('commons');
 
 
+  const { data: isNotFound } = useIsNotFound();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
 
-  const _isBtnDisabled = isCreating || isBtnDisabled;
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+
+  const editButtonClickedHandler = useCallback(async() => {
+    if (isNotFound == null || isNotFound === false) {
+      mutateEditorMode(EditorMode.Editor);
+      return;
+    }
+
+    try {
+      await createAndTransit(
+        { path },
+        { shouldCheckPageExists: true },
+      );
+    }
+    catch (err) {
+      toastError(t('toaster.create_failed', { target: path }));
+    }
+  }, [createAndTransit, isNotFound, mutateEditorMode, path, t]);
 
 
-  const createPageAndTransit = useCreatePageAndTransit();
-
-  const editButtonClickedHandler = useCallback(() => {
-    createPageAndTransit(
-      path,
-      {
-        onCreationStart: () => { setIsCreating(true) },
-        onError: () => { toastError(t('toaster.create_failed', { target: path })) },
-        onTerminated: () => { setIsCreating(false) },
-      },
-    );
-  }, [createPageAndTransit, path, t]);
+  const _isBtnDisabled = isCreating || isBtnDisabled;
 
 
   return (
   return (
     <>
     <>

+ 2 - 2
apps/app/src/components/PageEditor/OptionsSelector.tsx

@@ -10,7 +10,7 @@ import {
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useEditorSettings, useCurrentIndentSize } from '~/stores/editor';
 import { useEditorSettings, useCurrentIndentSize } from '~/stores/editor';
 
 
-import { DEFAULT_THEME, KeyMapMode } from '../../interfaces/editor-settings';
+import { DEFAULT_THEME, type KeyMapMode } from '../../interfaces/editor-settings';
 
 
 
 
 const AVAILABLE_THEMES = [
 const AVAILABLE_THEMES = [
@@ -73,7 +73,7 @@ const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
   default: 'Default',
   default: 'Default',
   vim: 'Vim',
   vim: 'Vim',
   emacs: 'Emacs',
   emacs: 'Emacs',
-  sublime: 'Sublime Text',
+  vscode: 'Visual Studio Code',
 };
 };
 
 
 const KeymapSelector = memo((): JSX.Element => {
 const KeymapSelector = memo((): JSX.Element => {

+ 24 - 57
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -19,10 +19,9 @@ import { throttle, debounce } from 'throttle-debounce';
 
 
 
 
 import { useShouldExpandContent } from '~/client/services/layout';
 import { useShouldExpandContent } from '~/client/services/layout';
-import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
+import { useUpdateStateAfterSave, updatePage } from '~/client/services/page-operation';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import {
 import {
   useDefaultIndentSize, useCurrentUser,
   useDefaultIndentSize, useCurrentUser,
@@ -32,7 +31,6 @@ import {
 import {
 import {
   useEditorSettings,
   useEditorSettings,
   useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
-  useIsEnabledUnsavedWarning,
   useIsConflict,
   useIsConflict,
   useEditingMarkdown,
   useEditingMarkdown,
   useWaitingSaveProcessing,
   useWaitingSaveProcessing,
@@ -90,10 +88,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const router = useRouter();
   const router = useRouter();
 
 
   const previewRef = useRef<HTMLDivElement>(null);
   const previewRef = useRef<HTMLDivElement>(null);
-  const codeMirrorEditorContainerRef = useRef<HTMLDivElement>(null);
 
 
   const { data: isNotFound } = useIsNotFound();
   const { data: isNotFound } = useIsNotFound();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
@@ -125,14 +122,12 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: socket } = useGlobalSocket();
   const { data: socket } = useGlobalSocket();
 
 
   const { data: rendererOptions } = usePreviewOptions();
   const { data: rendererOptions } = usePreviewOptions();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsConflict } = useIsConflict();
   const { mutate: mutateIsConflict } = useIsConflict();
 
 
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
 
 
   const shouldExpandContent = useShouldExpandContent(currentPage);
   const shouldExpandContent = useShouldExpandContent(currentPage);
 
 
-  const saveOrUpdate = useSaveOrUpdate();
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
 
   const { resolvedTheme } = useNextThemes();
   const { resolvedTheme } = useNextThemes();
@@ -216,40 +211,27 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
 
   }, [socket, checkIsConflict]);
   }, [socket, checkIsConflict]);
 
 
-  const optionsToSave = useMemo((): OptionsToSave | undefined => {
-    if (grantData == null) {
-      return;
-    }
-    const userRelatedGrantedGroups = grantData.userRelatedGrantedGroups?.map((group) => {
-      return { item: group.id, type: group.type };
-    });
-    const optionsToSave = {
-      isSlackEnabled: isSlackEnabled ?? false,
-      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
-      grant: grantData.grant,
-      // pageTags: pageTags ?? [],
-      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
-    };
-    return optionsToSave;
-  }, [grantData, isSlackEnabled]);
-
-
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
-    if (currentPathname == null || optionsToSave == null) {
-      logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
+    if (pageId == null || currentPagePath == null || currentRevisionId == null || grantData == null) {
+      logger.error('Some materials to save are invalid', {
+        pageId, currentPagePath, currentRevisionId, grantData,
+      });
       throw new Error('Some materials to save are invalid');
       throw new Error('Some materials to save are invalid');
     }
     }
 
 
-    const options = Object.assign(optionsToSave, opts);
-
     try {
     try {
       mutateWaitingSaveProcessing(true);
       mutateWaitingSaveProcessing(true);
 
 
-      const { page } = await saveOrUpdate(
-        codeMirrorEditor?.getDoc() ?? '',
-        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
-        options,
-      );
+      const { page } = await updatePage({
+        pageId,
+        revisionId: currentRevisionId,
+        body: codeMirrorEditor?.getDoc() ?? '',
+        grant: grantData?.grant,
+        userRelatedGrantUserGroupIds: grantData?.userRelatedGrantedGroups?.map((group) => {
+          return { item: group.id, type: group.type };
+        }),
+        ...(opts ?? {}),
+      });
 
 
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
       mutatePageTree();
       mutatePageTree();
@@ -271,12 +253,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       mutateWaitingSaveProcessing(false);
       mutateWaitingSaveProcessing(false);
     }
     }
 
 
-  }, [
-    codeMirrorEditor,
-    currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId,
-    currentPagePath, currentRevisionId,
-    mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser,
-  ]);
+  // eslint-disable-next-line max-len
+  }, [codeMirrorEditor, grantData, pageId, currentPagePath, currentRevisionId, mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser]);
 
 
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     const page = await save(opts);
     const page = await save(opts);
@@ -284,14 +262,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       return;
       return;
     }
     }
 
 
-    if (isNotFound) {
-      await router.push(`/${page._id}`);
-    }
-    else {
-      updateStateAfterSave?.();
-    }
     mutateEditorMode(EditorMode.View);
     mutateEditorMode(EditorMode.View);
-  }, [save, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
+    updateStateAfterSave?.();
+  }, [mutateEditorMode, save, updateStateAfterSave]);
 
 
   const saveWithShortcut = useCallback(async() => {
   const saveWithShortcut = useCallback(async() => {
     const page = await save();
     const page = await save();
@@ -299,16 +272,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       return;
       return;
     }
     }
 
 
-    if (isNotFound) {
-      await router.push(`/${page._id}#edit`);
-    }
-    else {
-      updateStateAfterSave?.();
-    }
     toastSuccess(t('toaster.save_succeeded'));
     toastSuccess(t('toaster.save_succeeded'));
-    mutateEditorMode(EditorMode.Editor);
-
-  }, [isNotFound, mutateEditorMode, router, save, t, updateStateAfterSave]);
+    updateStateAfterSave?.();
+  }, [save, t, updateStateAfterSave]);
 
 
 
 
   // the upload event handler
   // the upload event handler
@@ -473,7 +439,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
 
   return (
   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'}`}>
-      <div className="flex-expand-vert justify-content-center" style={{ minHeight: '72px' }}>
+      <div className="ms-3 mt-2">
         <PageHeader />
         <PageHeader />
       </div>
       </div>
       <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
       <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
@@ -502,6 +468,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             initialValue={initialValue}
             initialValue={initialValue}
             onOpenEditor={markdown => setMarkdownToPreview(markdown)}
             onOpenEditor={markdown => setMarkdownToPreview(markdown)}
             editorTheme={editorSettings?.theme}
             editorTheme={editorSettings?.theme}
+            editorKeymap={editorSettings?.keymapMode}
           />
           />
         </div>
         </div>
         <div ref={previewRef} onScroll={scrollPreviewHandlerThrottle} className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">
         <div ref={previewRef} onScroll={scrollPreviewHandlerThrottle} className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">

+ 14 - 0
apps/app/src/components/PageHeader/PageHeader.module.scss

@@ -0,0 +1,14 @@
+.page-header :global {
+  .page-title-header-input {
+    input {
+      font-size: 2rem;
+    }
+  }
+
+  .page-path-header-buttons {
+    button {
+      width: 25px;
+      height: 20px;
+    }
+  }
+}

+ 8 - 8
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -1,28 +1,28 @@
-import { FC } from 'react';
+import type { FC } from 'react';
 
 
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 
 
 import { PagePathHeader } from './PagePathHeader';
 import { PagePathHeader } from './PagePathHeader';
 import { PageTitleHeader } from './PageTitleHeader';
 import { PageTitleHeader } from './PageTitleHeader';
 
 
+import styles from './PageHeader.module.scss';
+
+
 export const PageHeader: FC = () => {
 export const PageHeader: FC = () => {
-  const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
 
 
-  if (currentPage == null || currentPagePath == null) {
+  if (currentPage == null) {
     return <></>;
     return <></>;
   }
   }
 
 
   return (
   return (
-    <>
+    <div className={`${styles['page-header']}`}>
       <PagePathHeader
       <PagePathHeader
-        currentPagePath={currentPagePath}
         currentPage={currentPage}
         currentPage={currentPage}
       />
       />
       <PageTitleHeader
       <PageTitleHeader
-        currentPagePath={currentPagePath}
         currentPage={currentPage}
         currentPage={currentPage}
       />
       />
-    </>
+    </div>
   );
   );
 };
 };

+ 82 - 80
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -1,84 +1,77 @@
-import {
-  FC, useEffect, useMemo, useState,
-} from 'react';
+import { useState, useEffect, useCallback } from 'react';
+import type { FC } from 'react';
 
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { useTranslation } from 'next-i18next';
 
 
+import { ValidationTarget } from '~/client/util/input-validator';
+import LinkedPagePath from '~/models/linked-page-path';
 import { usePageSelectModal } from '~/stores/modal';
 import { usePageSelectModal } from '~/stores/modal';
-import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
-import { PagePathNav } from '../Common/PagePathNav';
+import ClosableTextInput from '../Common/ClosableTextInput';
+import { PagePathHierarchicalLink } from '../Common/PagePathHierarchicalLink';
 import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
 import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
 
 
-import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
 import { usePagePathRenameHandler } from './page-header-utils';
 import { usePagePathRenameHandler } from './page-header-utils';
 
 
-type Props = {
-  currentPagePath: string
+export type Props = {
   currentPage: IPagePopulatedToShowRevision
   currentPage: IPagePopulatedToShowRevision
 }
 }
 
 
 export const PagePathHeader: FC<Props> = (props) => {
 export const PagePathHeader: FC<Props> = (props) => {
-  const { currentPagePath, currentPage } = props;
+  const { t } = useTranslation();
+  const { currentPage } = props;
+
+  const currentPagePath = currentPage.path;
+  const linkedPagePath = new LinkedPagePath(currentPagePath);
 
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isButtonsShown, setButtonShown] = useState(false);
   const [isButtonsShown, setButtonShown] = useState(false);
-  const [inputText, setInputText] = useState('');
+  const [editedPagePath, setEditedPagePath] = useState(currentPagePath);
 
 
-  const { data: editorMode } = useEditorMode();
   const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal();
   const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal();
+  const isOpened = PageSelectModalData?.isOpened ?? false;
 
 
-  const onRenameFinish = () => {
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+
+  const onRenameFinish = useCallback(() => {
     setRenameInputShown(false);
     setRenameInputShown(false);
-  };
+  }, []);
 
 
-  const onRenameFailure = () => {
+  const onRenameFailure = useCallback(() => {
     setRenameInputShown(true);
     setRenameInputShown(true);
-  };
+  }, []);
 
 
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
+  const onInputChange = useCallback((inputText: string) => {
+    setEditedPagePath(inputText);
+  }, []);
 
 
-  const stateHandler = { isRenameInputShown, setRenameInputShown };
+  const onPressEnter = useCallback(() => {
+    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
 
 
-  const isOpened = PageSelectModalData?.isOpened ?? false;
+  const onPressEscape = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(false);
+  }, [currentPagePath]);
 
 
-  const isViewMode = editorMode === EditorMode.View;
-  const isEditorMode = !isViewMode;
-
-  const PagePath = useMemo(() => (
-    <>
-      {currentPagePath != null && (
-        <PagePathNav
-          pageId={currentPage._id}
-          pagePath={currentPagePath}
-          isSingleLineMode={isEditorMode}
-        />
-      )}
-    </>
-  ), [currentPage._id, currentPagePath, isEditorMode]);
-
-  const handleInputChange = (inputText: string) => {
-    setInputText(inputText);
-  };
-
-  const handleEditButtonClick = () => {
+  const onClickEditButton = useCallback(() => {
     if (isRenameInputShown) {
     if (isRenameInputShown) {
-      pagePathRenameHandler(inputText);
+      pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
     }
     }
     else {
     else {
+      setEditedPagePath(currentPagePath);
       setRenameInputShown(true);
       setRenameInputShown(true);
     }
     }
-  };
+  }, [currentPagePath, editedPagePath, isRenameInputShown, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
 
 
-  const buttonStyle = isButtonsShown ? '' : 'd-none';
-
-  const clickOutSideHandler = (e) => {
+  const clickOutSideHandler = useCallback((e) => {
     const container = document.getElementById('page-path-header');
     const container = document.getElementById('page-path-header');
 
 
     if (container && !container.contains(e.target)) {
     if (container && !container.contains(e.target)) {
       setRenameInputShown(false);
       setRenameInputShown(false);
     }
     }
-  };
+  }, []);
 
 
   useEffect(() => {
   useEffect(() => {
     document.addEventListener('click', clickOutSideHandler);
     document.addEventListener('click', clickOutSideHandler);
@@ -86,45 +79,54 @@ export const PagePathHeader: FC<Props> = (props) => {
     return () => {
     return () => {
       document.removeEventListener('click', clickOutSideHandler);
       document.removeEventListener('click', clickOutSideHandler);
     };
     };
-  }, []);
+  }, [clickOutSideHandler]);
+
 
 
   return (
   return (
-    <>
-      <div
-        id="page-path-header"
-        onMouseLeave={() => setButtonShown(false)}
-      >
-        <div className="row">
-          <div
-            className="col-4"
-            onMouseEnter={() => setButtonShown(true)}
-          >
-            <TextInputForPageTitleAndPath
-              currentPage={currentPage}
-              stateHandler={stateHandler}
-              inputValue={currentPagePath}
-              CustomComponent={PagePath}
-              handleInputChange={handleInputChange}
+    <div
+      id="page-path-header"
+      className="d-flex"
+      onMouseEnter={() => setButtonShown(true)}
+      onMouseLeave={() => setButtonShown(false)}
+    >
+      <div className="me-2">
+        {isRenameInputShown
+          ? (
+            <ClosableTextInput
+              useAutosizeInput
+              value={editedPagePath}
+              placeholder={t('Input page name')}
+              onPressEnter={onPressEnter}
+              onPressEscape={onPressEscape}
+              onChange={onInputChange}
+              validationTarget={ValidationTarget.PAGE}
             />
             />
-          </div>
-          <div className={`${buttonStyle} col-4 row`}>
-            <div className="col-4">
-              <button type="button" onClick={handleEditButtonClick}>
-                {isRenameInputShown ? <span className="material-symbols-outlined">check_circle</span> : <span className="material-symbols-outlined">edit</span>}
-              </button>
-            </div>
-            <div className="col-4">
-              <button type="button" onClick={openPageSelectModal}>
-                <span className="material-symbols-outlined">account_tree</span>
-              </button>
-            </div>
-          </div>
-          {isOpened
-            && (
-              <PageSelectModal />
-            )}
-        </div>
+          )
+          : (
+            <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />
+          )
+        }
       </div>
       </div>
-    </>
+
+      <div className={`page-path-header-buttons d-flex align-items-center ${isButtonsShown ? '' : 'd-none'}`}>
+        <button
+          type="button"
+          className="btn btn-sm text-muted border border-secondary me-2 d-flex align-items-center justify-content-center"
+          onClick={onClickEditButton}
+        >
+          <span className="material-symbols-outlined fs-5 mt-1">{isRenameInputShown ? 'check_circle' : 'edit'}</span>
+        </button>
+
+        <button
+          type="button"
+          className="btn btn-sm text-muted border border-secondary d-flex align-items-center justify-content-center"
+          onClick={openPageSelectModal}
+        >
+          <span className="material-symbols-outlined fs-5 mt-1">account_tree</span>
+        </button>
+      </div>
+
+      {isOpened && <PageSelectModal />}
+    </div>
   );
   );
 };
 };

+ 81 - 18
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -1,35 +1,98 @@
-import { FC, useState, useMemo } from 'react';
+import type { FC } from 'react';
+import { useState, useCallback } from 'react';
 
 
 import nodePath from 'path';
 import nodePath from 'path';
 
 
-import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
 
 
-import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
+import { ValidationTarget } from '~/client/util/input-validator';
 
 
-type Props = {
-  currentPagePath: string,
-  currentPage: IPagePopulatedToShowRevision;
-}
+import ClosableTextInput from '../Common/ClosableTextInput';
+import { CopyDropdown } from '../Common/CopyDropdown';
+
+import type { Props } from './PagePathHeader';
+import { usePagePathRenameHandler } from './page-header-utils';
 
 
 
 
 export const PageTitleHeader: FC<Props> = (props) => {
 export const PageTitleHeader: FC<Props> = (props) => {
-  const { currentPagePath, currentPage } = props;
+  const { t } = useTranslation();
+  const { currentPage } = props;
+
+  const currentPagePath = currentPage.path;
+
+  const pageTitle = nodePath.basename(currentPagePath) || '/';
 
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isRenameInputShown, setRenameInputShown] = useState(false);
-  const pageName = nodePath.basename(currentPagePath ?? '') || '/';
+  const [editedPagePath, setEditedPagePath] = useState(currentPagePath);
+
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+
+  const editedPageTitle = nodePath.basename(editedPagePath);
+
+  const onRenameFinish = useCallback(() => {
+    setRenameInputShown(false);
+  }, []);
 
 
-  const stateHandler = { isRenameInputShown, setRenameInputShown };
+  const onRenameFailure = useCallback(() => {
+    setRenameInputShown(true);
+  }, []);
+
+  const onInputChange = useCallback((inputText: string) => {
+    const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path));
+    const newPagePath = nodePath.resolve(parentPagePath, inputText);
+
+    setEditedPagePath(newPagePath);
+  }, [currentPage?.path, setEditedPagePath]);
+
+  const onPressEnter = useCallback(() => {
+    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
+
+  const onPressEscape = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(false);
+  }, [currentPagePath]);
+
+  const onClickPageTitle = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(true);
+  }, [currentPagePath]);
 
 
-  const PageTitle = useMemo(() => (<div onClick={() => setRenameInputShown(true)}>{pageName}</div>), [pageName]);
 
 
   return (
   return (
-    <div onBlur={() => setRenameInputShown(false)}>
-      <TextInputForPageTitleAndPath
-        currentPage={currentPage}
-        stateHandler={stateHandler}
-        inputValue={pageName}
-        CustomComponent={PageTitle}
-      />
+    <div className="d-flex">
+      <div className="me-1">
+        {isRenameInputShown
+          ? (
+            <div className="page-title-header-input">
+              <ClosableTextInput
+                useAutosizeInput
+                value={editedPageTitle}
+                placeholder={t('Input page name')}
+                onPressEnter={onPressEnter}
+                onPressEscape={onPressEscape}
+                onChange={onInputChange}
+                onClickOutside={() => setRenameInputShown(false)}
+                validationTarget={ValidationTarget.PAGE}
+              />
+            </div>
+          )
+          : (
+            <h2 onClick={onClickPageTitle}>
+              {pageTitle}
+            </h2>
+          )}
+      </div>
+
+      <CopyDropdown
+        pageId={currentPage._id}
+        pagePath={currentPage.path}
+        dropdownToggleId={`copydropdown-${currentPage._id}`}
+        dropdownToggleClassName="p-2"
+      >
+        <span className="material-symbols-outlined fs-5">content_paste</span>
+      </CopyDropdown>
     </div>
     </div>
   );
   );
 };
 };

+ 0 - 76
apps/app/src/components/PageHeader/TextInputForPageTitleAndPath.tsx

@@ -1,76 +0,0 @@
-import { FC, useCallback } from 'react';
-import type { Dispatch, SetStateAction } from 'react';
-
-import nodePath from 'path';
-
-import type { IPagePopulatedToShowRevision } from '@growi/core';
-import { pathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-
-import { ValidationTarget } from '~/client/util/input-validator';
-
-import ClosableTextInput from '../Common/ClosableTextInput';
-
-
-import { usePagePathRenameHandler } from './page-header-utils';
-
-
-type StateHandler = {
-  isRenameInputShown: boolean
-  setRenameInputShown: Dispatch<SetStateAction<boolean>>
-}
-
-type Props = {
-  currentPage: IPagePopulatedToShowRevision
-  stateHandler: StateHandler
-  inputValue: string
-  CustomComponent: JSX.Element
-  handleInputChange?: (string) => void
-}
-
-export const TextInputForPageTitleAndPath: FC<Props> = (props) => {
-  const {
-    currentPage, stateHandler, inputValue, CustomComponent, handleInputChange,
-  } = props;
-
-  const { t } = useTranslation();
-
-  const { isRenameInputShown, setRenameInputShown } = stateHandler;
-
-  const onRenameFinish = () => {
-    setRenameInputShown(false);
-  };
-
-  const onRenameFailure = () => {
-    setRenameInputShown(true);
-  };
-
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
-
-  const onPressEnter = useCallback((inputPagePath: string) => {
-
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputPagePath);
-
-    pagePathRenameHandler(newPagePath);
-
-  }, [currentPage.path, pagePathRenameHandler]);
-
-  return (
-    <>
-      {isRenameInputShown ? (
-        <div className="flex-fill">
-          <ClosableTextInput
-            value={inputValue}
-            placeholder={t('Input page name')}
-            onPressEnter={onPressEnter}
-            validationTarget={ValidationTarget.PAGE}
-            handleInputChange={handleInputChange}
-          />
-        </div>
-      ) : (
-        <>{ CustomComponent }</>
-      )}
-    </>
-  );
-};

+ 7 - 5
apps/app/src/components/PageHeader/page-header-utils.ts

@@ -8,16 +8,18 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import { useSWRMUTxCurrentPage } from '~/stores/page';
 import { useSWRMUTxCurrentPage } from '~/stores/page';
 import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
 import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
 
 
+type PagePathRenameHandler = (newPagePath: string, onRenameFinish?: () => void, onRenameFailure?: () => void) => Promise<void>
+
 export const usePagePathRenameHandler = (
 export const usePagePathRenameHandler = (
-    currentPage: IPagePopulatedToShowRevision, onRenameFinish?: () => void, onRenameFailure?: () => void,
-): (newPagePath: string) => Promise<void> => {
+    currentPage: IPagePopulatedToShowRevision,
+): PagePathRenameHandler => {
 
 
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const currentPagePath = currentPage.path;
   const currentPagePath = currentPage.path;
 
 
-  const pagePathRenameHandler = useCallback(async(newPagePath: string) => {
+  const pagePathRenameHandler = useCallback(async(newPagePath, onRenameFinish, onRenameFailure) => {
 
 
     const onRenamed = (fromPath: string | undefined, toPath: string) => {
     const onRenamed = (fromPath: string | undefined, toPath: string) => {
       mutatePageTree();
       mutatePageTree();
@@ -34,7 +36,6 @@ export const usePagePathRenameHandler = (
     }
     }
 
 
     try {
     try {
-      onRenameFinish?.();
       await apiv3Put('/pages/rename', {
       await apiv3Put('/pages/rename', {
         pageId: currentPage._id,
         pageId: currentPage._id,
         revisionId: currentPage.revision._id,
         revisionId: currentPage.revision._id,
@@ -42,6 +43,7 @@ export const usePagePathRenameHandler = (
       });
       });
 
 
       onRenamed(currentPage.path, newPagePath);
       onRenamed(currentPage.path, newPagePath);
+      onRenameFinish?.();
 
 
       toastSuccess(t('renamed_pages', { path: currentPage.path }));
       toastSuccess(t('renamed_pages', { path: currentPage.path }));
     }
     }
@@ -49,7 +51,7 @@ export const usePagePathRenameHandler = (
       onRenameFailure?.();
       onRenameFailure?.();
       toastError(err);
       toastError(err);
     }
     }
-  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, onRenameFailure, onRenameFinish, t]);
+  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, t]);
 
 
   return pagePathRenameHandler;
   return pagePathRenameHandler;
 };
 };

+ 3 - 2
apps/app/src/components/PageSideContents/PageAccessoriesControl.tsx

@@ -13,12 +13,13 @@ type Props = {
   icon: ReactNode,
   icon: ReactNode,
   label: ReactNode,
   label: ReactNode,
   count?: number,
   count?: number,
+  offset?: number,
   onClick?: () => void,
   onClick?: () => void,
 }
 }
 
 
 export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
 export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
   const {
   const {
-    icon, label, count,
+    icon, label, count, offset,
     className,
     className,
     onClick,
     onClick,
   } = props;
   } = props;
@@ -34,7 +35,7 @@ export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
         {label}
         {label}
         {/* Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600 */}
         {/* Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600 */}
         { count != null
         { count != null
-          ? <CountBadge count={count} />
+          ? <CountBadge count={count} offset={offset} />
           : <div className="px-2"></div>}
           : <div className="px-2"></div>}
       </span>
       </span>
     </button>
     </button>

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

@@ -105,6 +105,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
               label={t('page_list')}
               label={t('page_list')}
               // Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600
               // Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600
               count={!isTrash && pageInfo != null ? (pageInfo as IPageInfoForOperation).descendantCount : undefined}
               count={!isTrash && pageInfo != null ? (pageInfo as IPageInfoForOperation).descendantCount : undefined}
+              offset={1}
               onClick={() => openDescendantPageListModal(pagePath)}
               onClick={() => openDescendantPageListModal(pagePath)}
             />
             />
           </div>
           </div>

+ 13 - 7
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -1,6 +1,8 @@
 import React, { useCallback, useState } from 'react';
 import React, { useCallback, useState } from 'react';
 
 
-import { isPopulated, GroupType, type IGrantedGroup } from '@growi/core';
+import {
+  PageGrant, isPopulated, GroupType, type IGrantedGroup,
+} from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   UncontrolledDropdown,
   UncontrolledDropdown,
@@ -16,24 +18,28 @@ import { useMyUserGroups } from './use-my-user-groups';
 
 
 const AVAILABLE_GRANTS = [
 const AVAILABLE_GRANTS = [
   {
   {
-    grant: 1, iconClass: 'icon-people', btnStyleClass: 'outline-info', label: 'Public',
+    grant: PageGrant.GRANT_PUBLIC, iconClass: 'icon-people', btnStyleClass: 'outline-info', label: 'Public',
   },
   },
   {
   {
-    grant: 2, iconClass: 'icon-link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
+    grant: PageGrant.GRANT_RESTRICTED, iconClass: 'icon-link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
   },
   },
   // { grant: 3, iconClass: '', label: 'Specified users only' },
   // { grant: 3, iconClass: '', label: 'Specified users only' },
   {
   {
-    grant: 4, iconClass: 'icon-lock', btnStyleClass: 'outline-danger', label: 'Only me',
+    grant: PageGrant.GRANT_OWNER, iconClass: 'icon-lock', btnStyleClass: 'outline-danger', label: 'Only me',
   },
   },
   {
   {
-    grant: 5, iconClass: 'icon-options', btnStyleClass: 'outline-purple', label: 'Only inside the group', reselectLabel: 'Reselect the group',
+    grant: PageGrant.GRANT_USER_GROUP,
+    iconClass: 'icon-options',
+    btnStyleClass: 'outline-purple',
+    label: 'Only inside the group',
+    reselectLabel: 'Reselect the group',
   },
   },
 ];
 ];
 
 
 
 
 type Props = {
 type Props = {
   disabled?: boolean,
   disabled?: boolean,
-  grant: number,
+  grant: PageGrant,
   userRelatedGrantedGroups?: {
   userRelatedGrantedGroups?: {
     id: string,
     id: string,
     name: string,
     name: string,
@@ -72,7 +78,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
   /**
   /**
    * change event handler for grant selector
    * change event handler for grant selector
    */
    */
-  const changeGrantHandler = useCallback((grant: number) => {
+  const changeGrantHandler = useCallback((grant: PageGrant) => {
     // select group
     // select group
     if (grant === 5) {
     if (grant === 5) {
       showSelectGroupModal();
       showSelectGroupModal();

+ 28 - 22
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -3,21 +3,21 @@ import React from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { DropdownMenu, DropdownItem } from 'reactstrap';
 import { DropdownMenu, DropdownItem } from 'reactstrap';
 
 
-import { LabelType } from '~/interfaces/template';
+import type { LabelType } from '~/interfaces/template';
 
 
 
 
 type DropendMenuProps = {
 type DropendMenuProps = {
-  onClickCreateNewPageButtonHandler: () => Promise<void>
-  onClickCreateTodaysButtonHandler: () => Promise<void>
-  onClickTemplateButtonHandler: (label: LabelType) => Promise<void>
+  onClickCreateNewPage: () => Promise<void>
+  onClickCreateTodaysMemo: () => Promise<void>
+  onClickCreateTemplate?: (label: LabelType) => Promise<void>
   todaysPath: string | null,
   todaysPath: string | null,
 }
 }
 
 
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
   const {
   const {
-    onClickCreateNewPageButtonHandler,
-    onClickCreateTodaysButtonHandler,
-    onClickTemplateButtonHandler,
+    onClickCreateNewPage,
+    onClickCreateTodaysMemo,
+    onClickCreateTemplate,
     todaysPath,
     todaysPath,
   } = props;
   } = props;
 
 
@@ -28,33 +28,39 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
       container="body"
       container="body"
     >
     >
       <DropdownItem
       <DropdownItem
-        onClick={onClickCreateNewPageButtonHandler}
+        onClick={onClickCreateNewPage}
       >
       >
         {t('create_page_dropdown.new_page')}
         {t('create_page_dropdown.new_page')}
       </DropdownItem>
       </DropdownItem>
-      {todaysPath != null && (
+
+      { todaysPath != null && (
         <>
         <>
           <DropdownItem divider />
           <DropdownItem divider />
           <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
           <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
           <DropdownItem
           <DropdownItem
-            onClick={onClickCreateTodaysButtonHandler}
+            onClick={onClickCreateTodaysMemo}
           >
           >
             {todaysPath}
             {todaysPath}
           </DropdownItem>
           </DropdownItem>
         </>
         </>
       )}
       )}
-      <DropdownItem divider />
-      <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
-      <DropdownItem
-        onClick={() => onClickTemplateButtonHandler('_template')}
-      >
-        {t('create_page_dropdown.template.children')}
-      </DropdownItem>
-      <DropdownItem
-        onClick={() => onClickTemplateButtonHandler('__template')}
-      >
-        {t('create_page_dropdown.template.descendants')}
-      </DropdownItem>
+
+      { onClickCreateTemplate != null && (
+        <>
+          <DropdownItem divider />
+          <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
+          <DropdownItem
+            onClick={() => onClickCreateTemplate('_template')}
+          >
+            {t('create_page_dropdown.template.children')}
+          </DropdownItem>
+          <DropdownItem
+            onClick={() => onClickCreateTemplate('__template')}
+          >
+            {t('create_page_dropdown.template.descendants')}
+          </DropdownItem>
+        </>
+      ) }
     </DropdownMenu>
     </DropdownMenu>
   );
   );
 });
 });

+ 27 - 39
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -1,60 +1,48 @@
 import React, { useState, useCallback } from 'react';
 import React, { useState, useCallback } from 'react';
 
 
-import type { IUserHasId } from '@growi/core';
-import { pagePathUtils } from '@growi/core/dist/utils';
-import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Dropdown } from 'reactstrap';
 import { Dropdown } from 'reactstrap';
 
 
-import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
+import { useCreateTemplatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-import { LabelType } from '~/interfaces/template';
-import { useCurrentUser } from '~/stores/context';
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 
 
 import { CreateButton } from './CreateButton';
 import { CreateButton } from './CreateButton';
 import { DropendMenu } from './DropendMenu';
 import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
 import { DropendToggle } from './DropendToggle';
-import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
+import { useCreateNewPage, useCreateTodaysMemo } from './hooks';
 
 
 
 
-const generateTodaysPath = (currentUser: IUserHasId, parentDirName: string) => {
-  const now = format(new Date(), 'yyyy/MM/dd');
-  const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
-  return `${userHomepagePath}/${parentDirName}/${now}`;
-};
-
-export const PageCreateButton = React.memo((): JSX.Element => {
+const useToastrOnError = <P, R>(method?: (param?: P) => Promise<R|undefined>): (param?: P) => Promise<R|undefined> => {
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
 
 
-  const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
-  const { data: currentPage, isLoading } = useSWRxCurrentPage();
-  const { data: currentUser } = useCurrentUser();
+  return useCallback(async(param) => {
+    try {
+      return await method?.(param);
+    }
+    catch (err) {
+      toastError(t('toaster.create_failed', { target: 'a page' }));
+    }
+  }, [method, t]);
+};
+
 
 
+export const PageCreateButton = React.memo((): JSX.Element => {
   const [isHovered, setIsHovered] = useState(false);
   const [isHovered, setIsHovered] = useState(false);
 
 
   const [dropdownOpen, setDropdownOpen] = useState(false);
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
 
-  const todaysPath = currentUser == null
-    ? null
-    : generateTodaysPath(currentUser, t('create_page_dropdown.todays.memo'));
-
-  const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(
-    currentPage?.path, currentPage?.grant, currentPage?.grantedGroups, isLoading,
-  );
+  const { createNewPage, isCreating: isNewPageCreating } = useCreateNewPage();
   // TODO: https://redmine.weseek.co.jp/issues/138806
   // TODO: https://redmine.weseek.co.jp/issues/138806
-  const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath);
+  const { createTodaysMemo, isCreating: isTodaysPageCreating, todaysPath } = useCreateTodaysMemo();
   // TODO: https://redmine.weseek.co.jp/issues/138805
   // TODO: https://redmine.weseek.co.jp/issues/138805
-  const { onClickHandler: onClickTemplateButton, isPageCreating: isTemplatePageCreating } = useOnTemplateButtonClicked(currentPagePath, isLoadingPagePath);
+  const {
+    createTemplate,
+    isCreating: isTemplatePageCreating, isCreatable: isTemplatePageCreatable,
+  } = useCreateTemplatePage();
 
 
-  const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
-    try {
-      await onClickTemplateButton(label);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [onClickTemplateButton]);
+  const createNewPageWithToastr = useToastrOnError(createNewPage);
+  const createTodaysMemoWithToastr = useToastrOnError(createTodaysMemo);
+  const createTemplateWithToastr = useToastrOnError(createTemplate);
 
 
   const onMouseEnterHandler = () => {
   const onMouseEnterHandler = () => {
     setIsHovered(true);
     setIsHovered(true);
@@ -76,7 +64,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       <div className="btn-group flex-grow-1">
       <div className="btn-group flex-grow-1">
         <CreateButton
         <CreateButton
           className="z-2"
           className="z-2"
-          onClick={onClickNewButton}
+          onClick={createNewPageWithToastr}
           disabled={isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating}
           disabled={isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating}
         />
         />
       </div>
       </div>
@@ -89,9 +77,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
         >
         >
           <DropendToggle />
           <DropendToggle />
           <DropendMenu
           <DropendMenu
-            onClickCreateNewPageButtonHandler={onClickNewButton}
-            onClickCreateTodaysButtonHandler={onClickTodaysButton}
-            onClickTemplateButtonHandler={onClickTemplateButtonHandler}
+            onClickCreateNewPage={createNewPageWithToastr}
+            onClickCreateTodaysMemo={createTodaysMemoWithToastr}
+            onClickCreateTemplate={isTemplatePageCreatable ? createTemplateWithToastr : undefined}
             todaysPath={todaysPath}
             todaysPath={todaysPath}
           />
           />
         </Dropdown>
         </Dropdown>

+ 0 - 109
apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx

@@ -1,109 +0,0 @@
-import { useCallback, useState } from 'react';
-
-import type { PageGrant, IGrantedGroup } from '@growi/core';
-import { useRouter } from 'next/router';
-
-import { createPage, exist } from '~/client/services/page-operation';
-import { toastError } from '~/client/util/toastr';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-export const useOnNewButtonClicked = (
-    currentPagePath?: string,
-    currentPageGrant?: PageGrant,
-    currentPageGrantedGroups?: IGrantedGroup[],
-    isLoading?: boolean,
-): {
-  onClickHandler: () => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  const onClickHandler = useCallback(async() => {
-    if (isLoading) return;
-
-    try {
-      setIsPageCreating(true);
-
-      /**
-       * !! NOTICE !! - Verification of page createable or not is checked on the server side.
-       * since the new page path is not generated on the client side.
-       * need shouldGeneratePath flag.
-       */
-      const shouldUseRootPath = currentPagePath == null || currentPageGrant == null;
-      const parentPath = shouldUseRootPath
-        ? '/'
-        : currentPagePath;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: shouldUseRootPath ? 1 : currentPageGrant,
-        grantUserGroupIds: shouldUseRootPath ? undefined : currentPageGrantedGroups,
-        shouldGeneratePath: true,
-      };
-
-      // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
-      const response = await createPage(parentPath, '', params);
-
-      await router.push(`/${response.page.id}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [currentPageGrant, currentPageGrantedGroups, currentPagePath, isLoading, mutateEditorMode, router]);
-
-  return { onClickHandler, isPageCreating };
-};
-
-export const useOnTodaysButtonClicked = (
-    todaysPath: string | null,
-): {
-  onClickHandler: () => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  const onClickHandler = useCallback(async() => {
-    if (todaysPath == null) {
-      return;
-    }
-
-    try {
-      setIsPageCreating(true);
-
-      // TODO: get grant, grantUserGroupId data from parent page
-      // https://redmine.weseek.co.jp/issues/133892
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-      };
-
-      const res = await exist(JSON.stringify([todaysPath]));
-      if (!res.pages[todaysPath]) {
-        await createPage(todaysPath, '', params);
-      }
-
-      await router.push(`${todaysPath}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [mutateEditorMode, router, todaysPath]);
-
-  return { onClickHandler, isPageCreating };
-};

+ 2 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks/index.ts

@@ -0,0 +1,2 @@
+export * from './use-create-new-page';
+export * from './use-create-todays-memo';

+ 29 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -0,0 +1,29 @@
+import { useCallback } from 'react';
+
+import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCurrentPagePath } from '~/stores/page';
+
+
+type UseCreateNewPage = () => {
+  isCreating: boolean,
+  createNewPage: () => Promise<void>,
+}
+
+export const useCreateNewPage: UseCreateNewPage = () => {
+  const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
+
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+
+  const createNewPage = useCallback(async() => {
+    if (isLoadingPagePath) return;
+
+    return createAndTransit(
+      { parentPath: currentPagePath, optionalParentPath: '/' },
+    );
+  }, [createAndTransit, currentPagePath, isLoadingPagePath]);
+
+  return {
+    isCreating,
+    createNewPage,
+  };
+};

+ 45 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -0,0 +1,45 @@
+import { useCallback } from 'react';
+
+import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCurrentUser } from '~/stores/context';
+
+
+type UseCreateTodaysMemo = () => {
+  isCreating: boolean,
+  todaysPath: string | null,
+  createTodaysMemo: () => Promise<void>,
+}
+
+export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
+  const { t } = useTranslation('commons');
+
+  const { data: currentUser } = useCurrentUser();
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+
+  const isCreatable = currentUser != null;
+
+  const parentDirName = t('create_page_dropdown.todays.memo');
+  const now = format(new Date(), 'yyyy/MM/dd');
+  const todaysPath = isCreatable
+    ? `${userHomepagePath(currentUser)}/${parentDirName}/${now}`
+    : null;
+
+  const createTodaysMemo = useCallback(async() => {
+    if (!isCreatable || todaysPath == null) return;
+
+    return createAndTransit(
+      { path: todaysPath },
+      { shouldCheckPageExists: true },
+    );
+  }, [createAndTransit, isCreatable, todaysPath]);
+
+  return {
+    isCreating,
+    todaysPath,
+    createTodaysMemo,
+  };
+};

+ 51 - 44
apps/app/src/components/Sidebar/SidebarNav/PersonalDropdown.tsx

@@ -5,6 +5,9 @@ import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import Link from 'next/link';
+import {
+  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
 
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
@@ -36,71 +39,75 @@ export const PersonalDropdown = (): JSX.Element => {
 
 
   return (
   return (
     <>
     <>
-      <div className="dropend">
-        {/* Button */}
-        {/* remove .dropdown-toggle for hide caret */}
-        {/* See https://stackoverflow.com/a/44577512/13183572 */}
-        <button
-          type="button"
+      <UncontrolledDropdown
+        direction="end"
+      >
+        <DropdownToggle
           className="btn btn-primary"
           className="btn btn-primary"
-          data-bs-toggle="dropdown"
           data-testid="personal-dropdown-button"
           data-testid="personal-dropdown-button"
-          aria-expanded="false"
         >
         >
           <UserPicture user={currentUser} noLink noTooltip />
           <UserPicture user={currentUser} noLink noTooltip />
-        </button>
+        </DropdownToggle>
 
 
-        {/* Menu */}
-        <ul className="dropdown-menu" data-testid="personal-dropdown-menu">
-          <li className="px-4 pt-3 pb-2">
-            <UserPicture user={currentUser} size="lg" noLink noTooltip />
-            <h5>{currentUser.name}</h5>
+        <DropdownMenu
+          container="body"
+          data-testid="personal-dropdown-menu"
+        >
+          <DropdownItem header>
+            <div className="mt-2 mb-3">
+              <UserPicture user={currentUser} size="lg" noLink noTooltip />
+            </div>
+            <h5 className="ms-1">{currentUser.name}</h5>
             <div className="d-flex align-items-center">
             <div className="d-flex align-items-center">
-              <i className="icon-user icon-fw"></i>{currentUser.username}
+              <span className="material-symbols-outlined me-1">person</span>
+              {currentUser.username}
             </div>
             </div>
             <div className="d-flex align-items-center">
             <div className="d-flex align-items-center">
-              <i className="icon-envelope icon-fw"></i><span className="grw-email-sm">{currentUser.email}</span>
+              <span className="material-symbols-outlined me-1">mail</span>
+              <span className="grw-email-sm">{currentUser.email}</span>
             </div>
             </div>
-          </li>
+          </DropdownItem>
 
 
-          <li className="dropdown-divider"></li>
+          <DropdownItem divider />
 
 
-          <li>
+          <DropdownItem>
             <Link
             <Link
               href={pagePathUtils.userHomepagePath(currentUser)}
               href={pagePathUtils.userHomepagePath(currentUser)}
-              className="dropdown-item"
               data-testid="grw-personal-dropdown-menu-user-home"
               data-testid="grw-personal-dropdown-menu-user-home"
             >
             >
-              <i className="icon-fw icon-home"></i>{t('personal_dropdown.home')}
+              <span className="text-muted">
+                <span className="material-symbols-outlined me-1">home</span>{t('personal_dropdown.home')}
+              </span>
             </Link>
             </Link>
-          </li>
-          <li>
+          </DropdownItem>
+
+          <DropdownItem>
             <Link
             <Link
               href="/me"
               href="/me"
-              className="dropdown-item"
               data-testid="grw-personal-dropdown-menu-user-settings"
               data-testid="grw-personal-dropdown-menu-user-settings"
             >
             >
-              <i className="icon-fw icon-wrench"></i>{t('personal_dropdown.settings')}
+              <span className="text-muted">
+                <span className="material-symbols-outlined me-1">build</span>{t('personal_dropdown.settings')}
+              </span>
             </Link>
             </Link>
-          </li>
-          <li>
-            <button
-              data-testid="grw-proactive-questionnaire-modal-toggle-btn"
-              type="button"
-              className="dropdown-item"
-              onClick={() => setQuestionnaireModalOpen(true)}
-            >
-              <span className="material-symbols-outlined">edit</span>{t('personal_dropdown.feedback')}
-            </button>
-          </li>
-          <li>
-            <button type="button" className="dropdown-item" onClick={logoutHandler}>
-              <i className="icon-fw icon-power"></i>{t('Sign out')}
-            </button>
-          </li>
-        </ul>
-
-      </div>
+          </DropdownItem>
+
+          <DropdownItem
+            data-testid="grw-proactive-questionnaire-modal-toggle-btn"
+            onClick={() => setQuestionnaireModalOpen(true)}
+          >
+            <span className="text-muted">
+              <span className="material-symbols-outlined me-1">edit</span>{t('personal_dropdown.feedback')}
+            </span>
+          </DropdownItem>
+
+          <DropdownItem onClick={logoutHandler}>
+            <span className="text-muted">
+              <span className="material-symbols-outlined me-1">logout</span>{t('Sign out')}
+            </span>
+          </DropdownItem>
+        </DropdownMenu>
+      </UncontrolledDropdown>
 
 
       <ProactiveQuestionnaireModal isOpen={isQuestionnaireModalOpen} onClose={() => setQuestionnaireModalOpen(false)} />
       <ProactiveQuestionnaireModal isOpen={isQuestionnaireModalOpen} onClose={() => setQuestionnaireModalOpen(false)} />
     </>
     </>

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

@@ -1,4 +1,5 @@
-import { FC, memo } from 'react';
+import type { FC } from 'react';
+import { memo } from 'react';
 
 
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import Link from 'next/link';

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

@@ -1,6 +1,6 @@
 import React, { memo } from 'react';
 import React, { memo } from 'react';
 
 
-import { SidebarContentsType } from '~/interfaces/ui';
+import type { SidebarContentsType } from '~/interfaces/ui';
 
 
 import { PageCreateButton } from '../PageCreateButton';
 import { PageCreateButton } from '../PageCreateButton';
 
 
@@ -23,6 +23,7 @@ export const SidebarNav = memo((props: SidebarNavProps) => {
       <div className="grw-sidebar-nav-primary-container" data-vrt-blackout-sidebar-nav>
       <div className="grw-sidebar-nav-primary-container" data-vrt-blackout-sidebar-nav>
         <PrimaryItems onItemHover={onPrimaryItemHover} />
         <PrimaryItems onItemHover={onPrimaryItemHover} />
       </div>
       </div>
+
       <div className="grw-sidebar-nav-secondary-container">
       <div className="grw-sidebar-nav-secondary-container">
         <SecondaryItems />
         <SecondaryItems />
       </div>
       </div>

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

@@ -67,7 +67,7 @@ export const useNewPageInput = (): UseNewPageInput => {
 
 
       setShowInput(false);
       setShowInput(false);
 
 
-      await apiv3Post('/pages/', {
+      await apiv3Post('/page', {
         path: newPagePath,
         path: newPagePath,
         body: undefined,
         body: undefined,
         grant: page.grant,
         grant: page.grant,

+ 1 - 0
apps/app/src/interfaces/apiv3/index.ts

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

+ 37 - 0
apps/app/src/interfaces/apiv3/page.ts

@@ -0,0 +1,37 @@
+import type {
+  IPageHasId, IRevisionHasId, ITag,
+} from '@growi/core';
+
+import type { IOptionsForCreate, IOptionsForUpdate } from '../page';
+
+export type IApiv3PageCreateParams = IOptionsForCreate & {
+  path?: string,
+  parentPath?: string,
+  optionalParentPath?: string,
+
+  body?: string,
+  pageTags?: string[],
+
+  isSlackEnabled?: boolean,
+  slackChannels?: string,
+};
+
+export type IApiv3PageCreateResponse = {
+  page: IPageHasId,
+  tags: ITag[],
+  revision: IRevisionHasId,
+};
+
+export type IApiv3PageUpdateParams = IOptionsForUpdate & {
+  pageId: string,
+  revisionId: string,
+  body: string,
+
+  isSlackEnabled?: boolean,
+  slackChannels?: string,
+};
+
+export type IApiv3PageUpdateResponse = {
+  page: IPageHasId,
+  revision: IRevisionHasId,
+};

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

@@ -4,7 +4,7 @@ const KeyMapMode = {
   default: 'default',
   default: 'default',
   vim: 'vim',
   vim: 'vim',
   emacs: 'emacs',
   emacs: 'emacs',
-  sublime: 'sublime',
+  vscode: 'vscode',
 } as const;
 } as const;
 
 
 export type KeyMapMode = typeof KeyMapMode[keyof typeof KeyMapMode];
 export type KeyMapMode = typeof KeyMapMode[keyof typeof KeyMapMode];

+ 0 - 10
apps/app/src/interfaces/page-operation.ts

@@ -1,5 +1,3 @@
-import type { IGrantedGroup } from '@growi/core';
-
 export const PageActionType = {
 export const PageActionType = {
   Create: 'Create',
   Create: 'Create',
   Update: 'Update',
   Update: 'Update',
@@ -28,11 +26,3 @@ export type IPageOperationProcessData = {
 export type IPageOperationProcessInfo = {
 export type IPageOperationProcessInfo = {
   [pageId: string]: IPageOperationProcessData,
   [pageId: string]: IPageOperationProcessData,
 }
 }
-
-export type OptionsToSave = {
-  isSlackEnabled: boolean;
-  slackChannels: string;
-  grant: number;
-  // userRelatedGrantUserGroupIds?: IGrantedGroup[];
-  // isSyncRevisionToHackmd?: boolean;
-};

+ 17 - 2
apps/app/src/interfaces/page.ts

@@ -1,4 +1,6 @@
-import type { GroupType, IPageHasId, Nullable } from '@growi/core';
+import type {
+  GroupType, IGrantedGroup, IPageHasId, Nullable, PageGrant,
+} from '@growi/core';
 
 
 import type { IPageOperationProcessData } from './page-operation';
 import type { IPageOperationProcessData } from './page-operation';
 
 
@@ -9,7 +11,7 @@ export {
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData?: IPageOperationProcessData}>;
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData?: IPageOperationProcessData}>;
 
 
 export type IPageGrantData = {
 export type IPageGrantData = {
-  grant: number,
+  grant: PageGrant,
   userRelatedGrantedGroups?: {
   userRelatedGrantedGroups?: {
     id: string,
     id: string,
     name: string,
     name: string,
@@ -29,3 +31,16 @@ export type IDeleteManyPageApiv3Result = {
   isRecursively: Nullable<true>,
   isRecursively: Nullable<true>,
   isCompletely: Nullable<true>,
   isCompletely: Nullable<true>,
 };
 };
+
+export type IOptionsForUpdate = {
+  grant?: PageGrant,
+  userRelatedGrantUserGroupIds?: IGrantedGroup[],
+  // isSyncRevisionToHackmd?: boolean,
+  overwriteScopesOfDescendants?: boolean,
+};
+
+export type IOptionsForCreate = {
+  grant?: PageGrant,
+  grantUserGroupIds?: IGrantedGroup[],
+  overwriteScopesOfDescendants?: boolean,
+};

+ 6 - 4
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -1,5 +1,5 @@
-import type { IUser, IUserHasId } from '@growi/core';
-import {
+import type { IUser } from '@growi/core';
+import type {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -12,9 +12,9 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
 import {
   useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useGrowiCloudUri, useIsEnabledMarp,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useGrowiCloudUri, useIsEnabledMarp, useCurrentPathname,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 
 
 import type { CommonProps } from './utils/commons';
 import type { CommonProps } from './utils/commons';
 import {
 import {
@@ -50,6 +50,8 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   // clear the cache for the current page
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/_private-legacy-pages');
 
 
   // Search
   // Search
   useIsSearchPage(true);
   useIsSearchPage(true);

+ 6 - 4
apps/app/src/pages/_search.page.tsx

@@ -1,6 +1,6 @@
-import { ReactNode } from 'react';
+import type { ReactNode } from 'react';
 
 
-import type { IUser, IUserHasId } from '@growi/core';
+import type { IUser } from '@growi/core';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
@@ -12,9 +12,9 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
 import {
   useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL, useGrowiCloudUri,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL, useGrowiCloudUri, useCurrentPathname,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 
 
 import { SearchPage } from '../components/SearchPage';
 import { SearchPage } from '../components/SearchPage';
 
 
@@ -54,6 +54,8 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
   // clear the cache for the current page
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/_search');
 
 
   // Search
   // Search
   useIsSearchPage(true);
   useIsSearchPage(true);

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

@@ -1,6 +1,6 @@
 import React, { type ReactNode, useMemo } from 'react';
 import React, { type ReactNode, useMemo } from 'react';
 
 
-import {
+import type {
   GetServerSideProps, GetServerSidePropsContext,
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -10,18 +10,18 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { BasicLayout } from '~/components/Layout/BasicLayout';
-import { CrowiRequest } from '~/interfaces/crowi-request';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
 import {
   useCurrentUser, useIsSearchPage, useGrowiCloudUri,
   useCurrentUser, useIsSearchPage, useGrowiCloudUri,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
-  useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig, useIsEnabledMarp,
+  useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig, useIsEnabledMarp, useCurrentPathname,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { NextPageWithLayout } from '../_app.page';
+import type { NextPageWithLayout } from '../_app.page';
 import type { CommonProps } from '../utils/commons';
 import type { CommonProps } from '../utils/commons';
 import {
 import {
   getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle, useInitSidebarConfig,
   getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle, useInitSidebarConfig,
@@ -101,6 +101,8 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
   // clear the cache for the current page
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/me');
 
 
   // init sidebar config with UserUISettings and sidebarConfig
   // init sidebar config with UserUISettings and sidebarConfig
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);

+ 9 - 6
apps/app/src/pages/tags.page.tsx

@@ -1,7 +1,8 @@
-import React, { useState, useCallback, ReactNode } from 'react';
+import type { ReactNode } from 'react';
+import React, { useState, useCallback } from 'react';
 
 
-import type { IUser, IUserHasId } from '@growi/core';
-import { GetServerSideProps, GetServerSidePropsContext } from 'next';
+import type { IUser } from '@growi/core';
+import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
@@ -10,17 +11,17 @@ import Head from 'next/head';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IDataTagCount } from '~/interfaces/tag';
 import type { IDataTagCount } from '~/interfaces/tag';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxTagsList } from '~/stores/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
 import {
   useCurrentUser, useIsSearchPage,
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useGrowiCloudUri,
+  useIsSearchScopeChildrenAsDefault, useGrowiCloudUri, useCurrentPathname,
 } from '../stores/context';
 } from '../stores/context';
 
 
-import { NextPageWithLayout } from './_app.page';
+import type { NextPageWithLayout } from './_app.page';
 import type { CommonProps } from './utils/commons';
 import type { CommonProps } from './utils/commons';
 import {
 import {
   getServerSideCommonProps, getNextI18NextConfig, generateCustomTitle, useInitSidebarConfig,
   getServerSideCommonProps, getNextI18NextConfig, generateCustomTitle, useInitSidebarConfig,
@@ -49,6 +50,8 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
   // clear the cache for the current page
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/tags');
 
 
   const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
   const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
   const { t } = useTranslation('');
   const { t } = useTranslation('');

+ 0 - 43
apps/app/src/server/models/interfaces/page-operation.ts

@@ -1,43 +0,0 @@
-import { PageGrant, type IGrantedGroup } from '@growi/core';
-
-import { ObjectIdLike } from '../../interfaces/mongoose-utils';
-
-export type IPageForResuming = {
-  _id: ObjectIdLike,
-  path: string,
-  isEmpty: boolean,
-  parent?: ObjectIdLike,
-  grant?: number,
-  grantedUsers?: ObjectIdLike[],
-  grantedGroups: IGrantedGroup[],
-  descendantCount: number,
-  status?: number,
-  revision?: ObjectIdLike,
-  lastUpdateUser?: ObjectIdLike,
-  creator?: ObjectIdLike,
-};
-
-export type IUserForResuming = {
-  _id: ObjectIdLike,
-};
-
-export type IOptionsForUpdate = {
-  grant?: PageGrant,
-  userRelatedGrantUserGroupIds?: IGrantedGroup[],
-  isSyncRevisionToHackmd?: boolean,
-  overwriteScopesOfDescendants?: boolean,
-};
-
-export type IOptionsForCreate = {
-  format?: string,
-  grantUserGroupIds?: IGrantedGroup[],
-  grant?: PageGrant,
-  overwriteScopesOfDescendants?: boolean,
-  isSynchronously?: boolean,
-};
-
-export type IOptionsForResuming = {
-  updateMetadata?: boolean,
-  createRedirectPage?: boolean,
-  prevDescendantCount?: number,
-} & IOptionsForUpdate & IOptionsForCreate;

+ 35 - 6
apps/app/src/server/models/page-operation.ts

@@ -1,17 +1,18 @@
+import type { IGrantedGroup } from '@growi/core';
 import { GroupType } from '@growi/core';
 import { GroupType } from '@growi/core';
 import { addSeconds } from 'date-fns';
 import { addSeconds } from 'date-fns';
+import type {
+  Model, Document, QueryOptions, FilterQuery,
+} from 'mongoose';
 import mongoose, {
 import mongoose, {
-  Schema, Model, Document, QueryOptions, FilterQuery,
+  Schema,
 } from 'mongoose';
 } from 'mongoose';
 
 
+import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
 import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
-import {
-  IPageForResuming, IUserForResuming, IOptionsForResuming,
-} from '~/server/models/interfaces/page-operation';
-
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
 const TIME_TO_ADD_SEC = 10;
 const TIME_TO_ADD_SEC = 10;
@@ -20,6 +21,34 @@ const logger = loggerFactory('growi:models:page-operation');
 
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 
+
+type IPageForResuming = {
+  _id: ObjectIdLike,
+  path: string,
+  isEmpty: boolean,
+  parent?: ObjectIdLike,
+  grant?: number,
+  grantedUsers?: ObjectIdLike[],
+  grantedGroups: IGrantedGroup[],
+  descendantCount: number,
+  status?: number,
+  revision?: ObjectIdLike,
+  lastUpdateUser?: ObjectIdLike,
+  creator?: ObjectIdLike,
+};
+
+type IUserForResuming = {
+  _id: ObjectIdLike,
+};
+
+type IOptionsForResuming = {
+  format: 'md' | 'pdf',
+  updateMetadata?: boolean,
+  createRedirectPage?: boolean,
+  prevDescendantCount?: number,
+} & IOptionsForUpdate & IOptionsForCreate;
+
+
 /*
 /*
  * Main Schema
  * Main Schema
  */
  */

+ 5 - 11
apps/app/src/server/models/page.ts

@@ -5,10 +5,8 @@ import nodePath from 'path';
 
 
 import {
 import {
   type IPage,
   type IPage,
-  type IGrantedGroup,
   GroupType, type HasObjectId,
   GroupType, type HasObjectId,
 } from '@growi/core';
 } from '@growi/core';
-import type { ITag } from '@growi/core/dist/interfaces';
 import { isPopulated } from '@growi/core/dist/interfaces';
 import { isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
@@ -21,6 +19,7 @@ import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import type { IOptionsForCreate } from '~/interfaces/page';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
@@ -61,7 +60,8 @@ type PaginatedPages = {
   offset: number
   offset: number
 }
 }
 
 
-export type CreateMethod = (path: string, body: string, user, options: PageCreateOptions) => Promise<PageDocument & { _id: any }>
+export type CreateMethod = (path: string, body: string, user, options: IOptionsForCreate) => Promise<PageDocument & { _id: any }>
+
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
   [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
@@ -74,6 +74,8 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
   generateGrantCondition(
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   ): { $or: any[] }
+  findNonEmptyClosestAncestor(path: string): Promise<PageDocument | undefined>
+  findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
   findTemplate(path: string): Promise<{
   findTemplate(path: string): Promise<{
     templateBody?: string,
     templateBody?: string,
@@ -92,7 +94,6 @@ export interface PageModel extends Model<PageDocument> {
   STATUS_DELETED
   STATUS_DELETED
 }
 }
 
 
-type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 
 const schema = new Schema<PageDocument, PageModel>({
 const schema = new Schema<PageDocument, PageModel>({
@@ -1050,13 +1051,6 @@ schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function(this:
   await this.save();
   await this.save();
 };
 };
 
 
-export type PageCreateOptions = {
-  format?: string
-  grantUserGroupIds?: IGrantedGroup[],
-  grant?: number
-  overwriteScopesOfDescendants?: boolean
-}
-
 /*
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
  */

+ 54 - 0
apps/app/src/server/routes/apiv3/page/check-page-existence.ts

@@ -0,0 +1,54 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { query } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:check-page-existence');
+
+
+type ReqQuery = {
+  path: string,
+}
+
+interface Req extends Request<ReqQuery, ApiV3Response> {
+  user: IUserHasId,
+}
+
+type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    query('path').isString().withMessage('The param "path" must be specified'),
+  ];
+
+  return [
+    accessTokenParser, loginRequired,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { path } = req.query;
+
+      if (path == null || Array.isArray(path)) {
+        return res.apiv3Err(new ErrorV3('The param "path" must be an page id'));
+      }
+
+      const count = await Page.countByPathAndViewer(path.toString(), req.user);
+      res.apiv3({ isExist: count > 0 });
+    },
+  ];
+};

+ 254 - 0
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -0,0 +1,254 @@
+import type {
+  IPage, IUser, IUserHasId,
+} from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+import { attachTitleHeader, normalizePath } from '@growi/core/dist/utils/path-utils';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import mongoose from 'mongoose';
+
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import type { IOptionsForCreate } from '~/interfaces/page';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import {
+  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
+} from '~/server/models';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import PageTagRelation from '~/server/models/page-tag-relation';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:create-page');
+
+
+async function generateUntitledPath(parentPath: string, basePathname: string, index = 1): Promise<string> {
+  const Page = mongoose.model<IPage>('Page');
+
+  const path = normalizePath(`${normalizePath(parentPath)}/${basePathname}-${index}`);
+  if (await Page.exists({ path, isEmpty: false }) != null) {
+    return generateUntitledPath(parentPath, basePathname, index + 1);
+  }
+  return path;
+}
+
+async function determinePath(_parentPath?: string, _path?: string, optionalParentPath?: string): Promise<string> {
+  // TODO: i18n
+  const basePathname = 'Untitled';
+
+  if (_path != null) {
+    const path = normalizePath(_path);
+
+    // when path is valid
+    if (isCreatablePage(path)) {
+      return normalizePath(path);
+    }
+    // when optionalParentPath is set
+    if (optionalParentPath != null) {
+      return generateUntitledPath(optionalParentPath, basePathname);
+    }
+    // when path is invalid
+    throw new Error('Could not create the page for the path');
+  }
+
+  if (_parentPath != null) {
+    const parentPath = normalizePath(_parentPath);
+
+    // when parentPath is valid
+    if (isCreatablePage(parentPath)) {
+      return generateUntitledPath(parentPath, basePathname);
+    }
+    // when optionalParentPath is set
+    if (optionalParentPath != null) {
+      return generateUntitledPath(optionalParentPath, basePathname);
+    }
+    // when parentPath is invalid
+    throw new Error('Could not create the page for the parentPath');
+  }
+
+  // when both path and parentPath are not specified
+  return generateUntitledPath('/', basePathname);
+}
+
+
+type ReqBody = IApiv3PageCreateParams
+
+interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>('User');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    body('path').optional().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("The empty value is not allowd for the 'path'"),
+    body('parentPath').optional().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("The empty value is not allowd for the 'parentPath'"),
+    body('optionalParentPath').optional().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("The empty value is not allowd for the 'optionalParentPath'"),
+    body('body').optional().isString()
+      .withMessage('body must be string or undefined'),
+    body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('pageTags').optional().isArray().withMessage('pageTags must be array'),
+    body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+  ];
+
+
+  async function determineBodyAndTags(
+      path: string,
+      _body: string | null | undefined, _tags: string[] | null | undefined,
+  ): Promise<{ body: string, tags: string[] }> {
+
+    let body: string = _body ?? '';
+    let tags: string[] = _tags ?? [];
+
+    if (_body == null) {
+      const isEnabledAttachTitleHeader = await configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
+      if (isEnabledAttachTitleHeader) {
+        body += `${attachTitleHeader(path)}\n`;
+      }
+
+      const templateData = await Page.findTemplate(path);
+      if (templateData.templateTags != null) {
+        tags = templateData.templateTags;
+      }
+      if (templateData.templateBody != null) {
+        body += `${templateData.templateBody}\n`;
+      }
+    }
+
+    return { body, tags };
+  }
+
+  async function saveTags({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
+    const tagEvent = crowi.event('tag');
+    await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+    tagEvent.emit('update', createdPage, pageTags);
+    return PageTagRelation.listTagNamesByPage(createdPage.id);
+  }
+
+  async function postAction(req: CreatePageRequest, res: ApiV3Response, createdPage: PageDocument) {
+    // persist activity
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: createdPage,
+      action: SupportedAction.ACTION_PAGE_CREATE,
+    };
+    const activityEvent = crowi.event('activity');
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
+    // global notification
+    try {
+      await crowi.globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
+    }
+    catch (err) {
+      logger.error('Create grobal notification failed', err);
+    }
+
+    // user notification
+    const { isSlackEnabled, slackChannels } = req.body;
+    if (isSlackEnabled) {
+      try {
+        const results = await crowi.userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
+    }
+
+    // create subscription
+    try {
+      await crowi.inAppNotificationService.createSubscription(req.user._id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
+    }
+    catch (err) {
+      logger.error('Failed to create subscription document', err);
+    }
+  }
+
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  return [
+    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    validator, apiV3FormValidator,
+    async(req: CreatePageRequest, res: ApiV3Response) => {
+      const {
+        body: bodyByParam, pageTags: tagsByParam,
+      } = req.body;
+
+      let pathToCreate: string;
+      try {
+        const { path, parentPath, optionalParentPath } = req.body;
+        pathToCreate = await determinePath(parentPath, path, optionalParentPath);
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3(err.toString(), 'could_not_create_page'));
+      }
+
+      if (isUserPage(pathToCreate)) {
+        const isExistUser = await User.isExistUserByUserPagePath(pathToCreate);
+        if (!isExistUser) {
+          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
+        }
+      }
+
+      const { body, tags } = await determineBodyAndTags(pathToCreate, bodyByParam, tagsByParam);
+
+      let createdPage;
+      try {
+        const { grant, grantUserGroupIds, overwriteScopesOfDescendants } = req.body;
+        const options: IOptionsForCreate = { overwriteScopesOfDescendants };
+        if (grant != null) {
+          options.grant = grant;
+          options.grantUserGroupIds = grantUserGroupIds;
+        }
+        createdPage = await crowi.pageService.create(
+          pathToCreate,
+          body,
+          req.user,
+          options,
+        );
+      }
+      catch (err) {
+        logger.error('Error occurred while creating a page.', err);
+        return res.apiv3Err(err);
+      }
+
+      const savedTags = await saveTags({ createdPage, pageTags: tags });
+
+      const result = {
+        page: serializePageSecurely(createdPage),
+        tags: savedTags,
+        revision: serializeRevisionSecurely(createdPage.revision),
+      };
+
+      res.apiv3(result, 201);
+
+      postAction(req, res, createdPage);
+    },
+  ];
+};

+ 0 - 234
apps/app/src/server/routes/apiv3/page/cteate-page.ts

@@ -1,234 +0,0 @@
-import type {
-  IGrantedGroup,
-  IPage, IUser, IUserHasId, PageGrant,
-} from '@growi/core';
-import { ErrorV3 } from '@growi/core/dist/models';
-import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
-import { addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
-import type { Request, RequestHandler } from 'express';
-import type { ValidationChain } from 'express-validator';
-import { body } from 'express-validator';
-import mongoose from 'mongoose';
-
-import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
-import { subscribeRuleNames } from '~/interfaces/in-app-notification';
-import type Crowi from '~/server/crowi';
-import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
-import {
-  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
-} from '~/server/models';
-import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
-import type { PageDocument, PageModel } from '~/server/models/page';
-import PageTagRelation from '~/server/models/page-tag-relation';
-import { configManager } from '~/server/service/config-manager';
-import loggerFactory from '~/utils/logger';
-
-import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
-import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
-import type { ApiV3Response } from '../interfaces/apiv3-response';
-
-
-const logger = loggerFactory('growi:routes:apiv3:page:create-page');
-
-
-async function generateUniquePath(basePath: string, index = 1): Promise<string> {
-  const Page = mongoose.model<IPage>('Page');
-
-  const path = basePath + index;
-  const existingPageId = await Page.exists({ path, isEmpty: false });
-  if (existingPageId != null) {
-    return generateUniquePath(basePath, index + 1);
-  }
-  return path;
-}
-
-type ReqBody = {
-  path: string,
-
-  grant?: PageGrant,
-  grantUserGroupIds?: IGrantedGroup[],
-
-  body?: string,
-  overwriteScopesOfDescendants?: boolean,
-  isSlackEnabled?: boolean,
-  slackChannels?: any,
-  pageTags?: string[],
-  shouldGeneratePath?: boolean,
-}
-
-interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
-  user: IUserHasId,
-}
-
-type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
-export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
-  const Page = mongoose.model<IPage, PageModel>('Page');
-  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>('User');
-
-  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
-
-  const activityEvent = crowi.event('activity');
-  const addActivity = generateAddActivityMiddleware(crowi);
-
-  const globalNotificationService = crowi.getGlobalNotificationService();
-  const userNotificationService = crowi.getUserNotificationService();
-
-
-  async function saveTagsAction({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
-    if (pageTags != null) {
-      const tagEvent = crowi.event('tag');
-      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
-      tagEvent.emit('update', createdPage, pageTags);
-      return PageTagRelation.listTagNamesByPage(createdPage.id);
-    }
-
-    return [];
-  }
-
-  const validator: ValidationChain[] = [
-    body('body').optional().isString()
-      .withMessage('body must be string or undefined'),
-    body('path').exists().not().isEmpty({ ignore_whitespace: true })
-      .withMessage('path is required'),
-    body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
-    body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
-    body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
-    body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
-    body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
-    body('shouldGeneratePath').optional().isBoolean().withMessage('shouldGeneratePath is must be boolean or undefined'),
-  ];
-
-  return [
-    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
-    validator, apiV3FormValidator,
-    async(req: CreatePageRequest, res: ApiV3Response) => {
-      const {
-        body, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
-      } = req.body;
-
-      let { path, grant, grantUserGroupIds } = req.body;
-
-      // check whether path starts slash
-      path = addHeadingSlash(path);
-
-      if (shouldGeneratePath) {
-        try {
-          const rootPath = '/';
-          const defaultTitle = '/Untitled';
-          const basePath = path === rootPath ? defaultTitle : path + defaultTitle;
-          path = await generateUniquePath(basePath);
-
-          // if the generated path is not creatable, create the path under the root path
-          if (!isCreatablePage(path)) {
-            path = await generateUniquePath(defaultTitle);
-            // initialize grant data
-            grant = 1;
-            grantUserGroupIds = undefined;
-          }
-        }
-        catch (err) {
-          return res.apiv3Err(new ErrorV3('Failed to generate unique path'));
-        }
-      }
-
-      if (!isCreatablePage(path)) {
-        return res.apiv3Err(`Could not use the path '${path}'`);
-      }
-
-      if (isUserPage(path)) {
-        const isExistUser = await User.isExistUserByUserPagePath(path);
-        if (!isExistUser) {
-          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
-        }
-      }
-
-      const options: IOptionsForCreate = { overwriteScopesOfDescendants };
-      if (grant != null) {
-        options.grant = grant;
-        options.grantUserGroupIds = grantUserGroupIds;
-      }
-
-      const isNoBodyPage = body === undefined;
-      let initialTags: string[] = [];
-      let initialBody = '';
-      if (isNoBodyPage) {
-        const isEnabledAttachTitleHeader = await configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
-        if (isEnabledAttachTitleHeader) {
-          initialBody += `${attachTitleHeader(path)}\n`;
-        }
-
-        const templateData = await Page.findTemplate(path);
-        if (templateData.templateTags != null) {
-          initialTags = templateData.templateTags;
-        }
-        if (templateData.templateBody != null) {
-          initialBody += `${templateData.templateBody}\n`;
-        }
-      }
-
-      let createdPage;
-      try {
-        createdPage = await crowi.pageService.create(
-          path,
-          body ?? initialBody,
-          req.user,
-          options,
-        );
-      }
-      catch (err) {
-        logger.error('Error occurred while creating a page.', err);
-        return res.apiv3Err(err);
-      }
-
-      const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : (pageTags ?? ['']) });
-
-      const result = {
-        page: serializePageSecurely(createdPage),
-        tags: savedTags,
-        revision: serializeRevisionSecurely(createdPage.revision),
-      };
-
-      const parameters = {
-        targetModel: SupportedTargetModel.MODEL_PAGE,
-        target: createdPage,
-        action: SupportedAction.ACTION_PAGE_CREATE,
-      };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      res.apiv3(result, 201);
-
-      try {
-      // global notification
-        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
-      }
-      catch (err) {
-        logger.error('Create grobal notification failed', err);
-      }
-
-      // user notification
-      if (isSlackEnabled) {
-        try {
-          const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
-          results.forEach((result) => {
-            if (result.status === 'rejected') {
-              logger.error('Create user notification failed', result.reason);
-            }
-          });
-        }
-        catch (err) {
-          logger.error('Create user notification failed', err);
-        }
-      }
-
-      // create subscription
-      try {
-        await crowi.inAppNotificationService.createSubscription(req.user._id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
-      }
-      catch (err) {
-        logger.error('Failed to create subscription document', err);
-      }
-    },
-  ];
-};

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

@@ -20,6 +20,10 @@ import { preNotifyService } from '~/server/service/pre-notify';
 import { divideByType } from '~/server/util/granted-group';
 import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { checkPageExistenceHandlersFactory } from './check-page-existence';
+import { createPageHandlersFactory } from './create-page';
+import { updatePageHandlersFactory } from './update-page';
+
 
 
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 
@@ -311,6 +315,111 @@ module.exports = (crowi) => {
     return res.apiv3({ page, pages });
     return res.apiv3({ page, pages });
   });
   });
 
 
+  router.get('/exist', checkPageExistenceHandlersFactory(crowi));
+
+  /**
+   * @swagger
+   *
+   *    /page:
+   *      post:
+   *        tags: [Page]
+   *        operationId: createPage
+   *        description: Create page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  body:
+   *                    type: string
+   *                    description: Text of page
+   *                  path:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  grant:
+   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                  grantUserGroupId:
+   *                    type: string
+   *                    description: UserGroup ID
+   *                    example: 5ae5fccfc5577b0004dbd8ab
+   *                  pageTags:
+   *                    type: array
+   *                    items:
+   *                      $ref: '#/components/schemas/Tag'
+   *                  shouldGeneratePath:
+   *                    type: boolean
+   *                    description: Determine whether a new path should be generated
+   *                required:
+   *                  - body
+   *                  - path
+   *        responses:
+   *          201:
+   *            description: Succeeded to create page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    data:
+   *                      type: object
+   *                      properties:
+   *                        page:
+   *                          $ref: '#/components/schemas/Page'
+   *                        tags:
+   *                          type: array
+   *                          items:
+   *                            $ref: '#/components/schemas/Tags'
+   *                        revision:
+   *                          $ref: '#/components/schemas/Revision'
+   *          409:
+   *            description: page path is already existed
+   */
+  router.post('/', createPageHandlersFactory(crowi));
+
+  /**
+   * @swagger
+   *
+   *    /page:
+   *      put:
+   *        tags: [Page]
+   *        operationId: updatePage
+   *        description: Update page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  body:
+   *                    $ref: '#/components/schemas/Revision/properties/body'
+   *                  page_id:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  revision_id:
+   *                    $ref: '#/components/schemas/Revision/properties/_id'
+   *                  grant:
+   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                required:
+   *                  - body
+   *                  - page_id
+   *                  - revision_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to update page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    data:
+   *                      type: object
+   *                      properties:
+   *                        page:
+   *                          $ref: '#/components/schemas/Page'
+   *                        revision:
+   *                          $ref: '#/components/schemas/Revision'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
+  router.put('/', updatePageHandlersFactory(crowi));
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *

+ 172 - 0
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -0,0 +1,172 @@
+import type {
+  IPage, IRevisionHasId, IUserHasId,
+} from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import mongoose from 'mongoose';
+
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type { IApiv3PageUpdateParams } from '~/interfaces/apiv3';
+import type { IOptionsForUpdate } from '~/interfaces/page';
+import { RehypeSanitizeOption } from '~/interfaces/rehype';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import {
+  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely, serializeUserSecurely,
+} from '~/server/models';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import { configManager } from '~/server/service/config-manager';
+import { preNotifyService } from '~/server/service/pre-notify';
+import Xss from '~/services/xss';
+import XssOption from '~/services/xss/xssOption';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:page:update-page');
+
+
+type ReqBody = IApiv3PageUpdateParams;
+
+interface UpdatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type UpdatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const Revision = mongoose.model<IRevisionHasId>('Revision');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+
+  const xss = (() => {
+    const initializedConfig = {
+      isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+      tagWhitelist: crowi.xssService.getTagWhitelist(),
+      attrWhitelist: crowi.xssService.getAttrWhitelist(),
+      // TODO: Omit rehype related property from XssOptionConfig type
+      //  Server side xss implementation does not require it.
+      xssOption: RehypeSanitizeOption.CUSTOM,
+    };
+    const xssOption = new XssOption(initializedConfig);
+    return new Xss(xssOption);
+  })();
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    body('pageId').exists().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("'pageId' must be specified"),
+    body('revisionId').exists().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("'revisionId' must be specified"),
+    body('body').exists().isString()
+      .withMessage("The empty value is not allowd for the 'body'"),
+    body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('userRelatedGrantUserGroupIds').optional().isArray().withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
+    body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+  ];
+
+
+  async function postAction(req: UpdatePageRequest, res: ApiV3Response, updatedPage: PageDocument) {
+    // persist activity
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: updatedPage,
+      action: SupportedAction.ACTION_PAGE_UPDATE,
+    };
+    const activityEvent = crowi.event('activity');
+    activityEvent.emit(
+      'update', res.locals.activity._id, parameters,
+      { path: updatedPage.path, creator: updatedPage.creator._id.toString() },
+      preNotifyService.generatePreNotify,
+    );
+
+    // global notification
+    try {
+      await crowi.globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, updatedPage, req.user);
+    }
+    catch (err) {
+      logger.error('Edit notification failed', err);
+    }
+
+    // user notification
+    const { revisionId, isSlackEnabled, slackChannels } = req.body;
+    if (isSlackEnabled) {
+      try {
+        const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', { previousRevision: revisionId });
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
+    }
+  }
+
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  return [
+    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    validator, apiV3FormValidator,
+    async(req: UpdatePageRequest, res: ApiV3Response) => {
+      const { pageId, revisionId, body } = req.body;
+
+      // check page existence
+      const isExist = await Page.count({ _id: pageId }) > 0;
+      if (!isExist) {
+        return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
+      }
+
+      // check revision
+      const currentPage = await Page.findByIdAndViewer(pageId, req.user);
+      if (currentPage != null && !currentPage.isUpdatable(revisionId)) {
+        const latestRevision = await Revision.findById(currentPage.revision).populate('author');
+        const returnLatestRevision = {
+          revisionId: latestRevision?._id.toString(),
+          revisionBody: xss.process(latestRevision?.body),
+          createdAt: latestRevision?.createdAt,
+          user: serializeUserSecurely(latestRevision?.author),
+        };
+        return res.apiv3Err(new ErrorV3('Posted param "revisionId" is outdated.', 'conflict'), 409, {
+          returnLatestRevision,
+        });
+      }
+
+      let updatedPage;
+      try {
+        const { grant, userRelatedGrantUserGroupIds, overwriteScopesOfDescendants } = req.body;
+        const options: IOptionsForUpdate = { overwriteScopesOfDescendants };
+        if (grant != null) {
+          options.grant = grant;
+          options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
+        }
+        const previousRevision = await Revision.findById(revisionId);
+        updatedPage = await crowi.pageService.updatePage(currentPage, body, previousRevision?.body ?? null, req.user, options);
+      }
+      catch (err) {
+        logger.error('Error occurred while updating a page.', err);
+        return res.apiv3Err(err);
+      }
+
+      const result = {
+        page: serializePageSecurely(updatedPage),
+        revision: serializeRevisionSecurely(updatedPage.revision),
+      };
+
+      res.apiv3(result, 201);
+
+      postAction(req, res, updatedPage);
+    },
+  ];
+};

+ 0 - 58
apps/app/src/server/routes/apiv3/pages/index.js

@@ -19,7 +19,6 @@ import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user
 import { serializePageSecurely } from '../../../models/serializers/page-serializer';
 import { serializePageSecurely } from '../../../models/serializers/page-serializer';
 import { serializeUserSecurely } from '../../../models/serializers/user-serializer';
 import { serializeUserSecurely } from '../../../models/serializers/user-serializer';
 import { isV5ConversionError } from '../../../models/vo/v5-conversion-error';
 import { isV5ConversionError } from '../../../models/vo/v5-conversion-error';
-import { createPageHandlersFactory } from '../page/cteate-page';
 
 
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
@@ -205,63 +204,6 @@ module.exports = (crowi) => {
     ],
     ],
   };
   };
 
 
-  /**
-   * @swagger
-   *
-   *    /pages:
-   *      post:
-   *        tags: [Pages]
-   *        operationId: createPage
-   *        description: Create page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  body:
-   *                    type: string
-   *                    description: Text of page
-   *                  path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
-   *                  grant:
-   *                    $ref: '#/components/schemas/Page/properties/grant'
-   *                  grantUserGroupId:
-   *                    type: string
-   *                    description: UserGroup ID
-   *                    example: 5ae5fccfc5577b0004dbd8ab
-   *                  pageTags:
-   *                    type: array
-   *                    items:
-   *                      $ref: '#/components/schemas/Tag'
-   *                  shouldGeneratePath:
-   *                    type: boolean
-   *                    description: Determine whether a new path should be generated
-   *                required:
-   *                  - body
-   *                  - path
-   *        responses:
-   *          201:
-   *            description: Succeeded to create page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    data:
-   *                      type: object
-   *                      properties:
-   *                        page:
-   *                          $ref: '#/components/schemas/Page'
-   *                        tags:
-   *                          type: array
-   *                          items:
-   *                            $ref: '#/components/schemas/Tags'
-   *                        revision:
-   *                          $ref: '#/components/schemas/Revision'
-   *          409:
-   *            description: page path is already existed
-   */
-  router.post('/', createPageHandlersFactory(crowi));
-
   /**
   /**
    * @swagger
    * @swagger
    *
    *

+ 0 - 2
apps/app/src/server/routes/index.js

@@ -121,8 +121,6 @@ module.exports = function(crowi, app) {
   apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
   apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
 
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, page.api.update);
-  apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
   // allow posting to guests because the client doesn't know whether the user logged in

+ 21 - 225
apps/app/src/server/routes/page.js

@@ -1,7 +1,6 @@
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import XssOption from '~/services/xss/xssOption';
 import XssOption from '~/services/xss/xssOption';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -9,11 +8,7 @@ import { GlobalNotificationSettingEvent } from '../models';
 import { PathAlreadyExistsError } from '../models/errors';
 import { PathAlreadyExistsError } from '../models/errors';
 import PageTagRelation from '../models/page-tag-relation';
 import PageTagRelation from '../models/page-tag-relation';
 import UpdatePost from '../models/update-post';
 import UpdatePost from '../models/update-post';
-import { preNotifyService } from '../service/pre-notify';
-
-const { serializePageSecurely } = require('../models/serializers/page-serializer');
-const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
-const { serializeUserSecurely } = require('../models/serializers/user-serializer');
+import { configManager } from '../service/config-manager';
 
 
 /**
 /**
  * @swagger
  * @swagger
@@ -135,22 +130,24 @@ const { serializeUserSecurely } = require('../models/serializers/user-serializer
  */
  */
 
 
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
+/**
+ * @type { (crowi: import('../crowi').default, app) => any }
+ */
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const debug = require('debug')('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
 
   const { pagePathUtils } = require('@growi/core/dist/utils');
   const { pagePathUtils } = require('@growi/core/dist/utils');
 
 
+  /** @type {import('../models/page').PageModel} */
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
+
   const PageRedirect = mongoose.model('PageRedirect');
   const PageRedirect = mongoose.model('PageRedirect');
 
 
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
 
 
-  const { configManager, xssService } = crowi;
+  const { xssService } = crowi;
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const userNotificationService = crowi.getUserNotificationService();
-
-  const activityEvent = crowi.event('activity');
 
 
   const Xss = require('~/services/xss/index');
   const Xss = require('~/services/xss/index');
   const initializedConfig = {
   const initializedConfig = {
@@ -220,211 +217,6 @@ module.exports = function(crowi, app) {
   actions.api = api;
   actions.api = api;
   actions.validator = validator;
   actions.validator = validator;
 
 
-  /**
-   * @swagger
-   *
-   *    /pages.update:
-   *      post:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: updatePage
-   *        summary: /pages.update
-   *        description: Update page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  body:
-   *                    $ref: '#/components/schemas/Revision/properties/body'
-   *                  page_id:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *                  revision_id:
-   *                    $ref: '#/components/schemas/Revision/properties/_id'
-   *                  grant:
-   *                    $ref: '#/components/schemas/Page/properties/grant'
-   *                required:
-   *                  - body
-   *                  - page_id
-   *                  - revision_id
-   *        responses:
-   *          200:
-   *            description: Succeeded to update page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    revision:
-   *                      $ref: '#/components/schemas/Revision'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.update Update page
-   * @apiName UpdatePage
-   * @apiGroup Page
-   *
-   * @apiParam {String} body
-   * @apiParam {String} page_id
-   * @apiParam {String} revision_id
-   * @apiParam {String} grant
-   *
-   * In the case of the page exists:
-   * - If revision_id is specified => update the page,
-   * - If revision_id is not specified => force update by the new contents.
-   */
-  api.update = async function(req, res) {
-    const pageBody = req.body.body ?? null;
-    const pageId = req.body.page_id || null;
-    const revisionId = req.body.revision_id || null;
-    const grant = req.body.grant || null;
-    const userRelatedGrantUserGroupIds = req.body.userRelatedGrantUserGroupIds || null;
-    const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
-    const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
-    const slackChannels = req.body.slackChannels || null;
-
-    if (pageId === null || pageBody === null || revisionId === null) {
-      return res.json(ApiResponse.error('page_id, body and revision_id are required.'));
-    }
-
-    // check page existence
-    const isExist = await Page.count({ _id: pageId }) > 0;
-    if (!isExist) {
-      return res.json(ApiResponse.error(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
-    }
-
-    // check revision
-    const Revision = crowi.model('Revision');
-    let page = await Page.findByIdAndViewer(pageId, req.user);
-    if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
-      const latestRevision = await Revision.findById(page.revision).populate('author');
-      const returnLatestRevision = {
-        revisionId: latestRevision._id.toString(),
-        revisionBody: xss.process(latestRevision.body),
-        createdAt: latestRevision.createdAt,
-        user: serializeUserSecurely(latestRevision.author),
-      };
-      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
-    }
-
-    const previousRevision = await Revision.findById(revisionId);
-    try {
-      page = await crowi.pageService.updatePage(page, pageBody, previousRevision.body, req.user, options);
-    }
-    catch (err) {
-      logger.error('error on _api/pages.update', err);
-      return res.json(ApiResponse.error(err));
-    }
-
-
-    const result = {
-      page: serializePageSecurely(page),
-      revision: serializeRevisionSecurely(page.revision),
-    };
-    res.json(ApiResponse.success(result));
-
-    // global notification
-    try {
-      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, page, req.user);
-    }
-    catch (err) {
-      logger.error('Edit notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(page, req.user, slackChannels, 'update', { previousRevision });
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_UPDATE,
-    };
-
-    activityEvent.emit(
-      'update', res.locals.activity._id, parameters, { path: page.path, creator: page.creator._id.toString() }, preNotifyService.generatePreNotify,
-    );
-  };
-
-  /**
-   * @swagger
-   *
-   *    /pages.exist:
-   *      get:
-   *        tags: [Pages]
-   *        operationId: getPageExistence
-   *        summary: /pages.exist
-   *        description: Get page existence
-   *        parameters:
-   *          - in: query
-   *            name: pagePaths
-   *            schema:
-   *              type: string
-   *              description: Page path list in JSON Array format
-   *              example: '["/", "/user/unknown"]'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get page existence.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    pages:
-   *                      type: string
-   *                      description: Properties of page path and existence
-   *                      example: '{"/": true, "/user/unknown": false}'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /pages.exist Get if page exists
-   * @apiName GetPage
-   * @apiGroup Page
-   *
-   * @apiParam {String} pages (stringified JSON)
-   */
-  api.exist = async function(req, res) {
-    const pagePaths = JSON.parse(req.query.pagePaths || '[]');
-
-    const pages = {};
-    await Promise.all(pagePaths.map(async(path) => {
-      // check page existence
-      const isExist = await Page.count({ path }) > 0;
-      pages[path] = isExist;
-      return;
-    }));
-
-    const result = { pages };
-
-    return res.json(ApiResponse.success(result));
-  };
-
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -563,6 +355,7 @@ module.exports = function(crowi, app) {
       endpoint: req.originalUrl,
       endpoint: req.originalUrl,
     };
     };
 
 
+    /** @type {import('../models/page').PageDocument | undefined} */
     const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
     const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
 
     if (page == null) {
     if (page == null) {
@@ -573,15 +366,18 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Empty pages cannot be single deleted', 'single_deletion_empty_pages'));
       return res.json(ApiResponse.error('Empty pages cannot be single deleted', 'single_deletion_empty_pages'));
     }
     }
 
 
-    let creator;
-    if (page.isEmpty) {
-      // If empty, the creator is inherited from the closest non-empty ancestor page.
-      const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
-      creator = notEmptyClosestAncestor.creator;
-    }
-    else {
-      creator = page.creator;
-    }
+    // -- canDelete no longer needs creator,
+    //  however it might be required to retrieve the closest non-empty ancestor page's owner -- 2024.02.09 Yuki Takei
+    //
+    // let creator;
+    // if (page.isEmpty) {
+    //   // If empty, the creator is inherited from the closest non-empty ancestor page.
+    //   const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
+    //   creator = notEmptyClosestAncestor.creator;
+    // }
+    // else {
+    //   creator = page.creator;
+    // }
 
 
     debug('Delete page', page._id, page.path);
     debug('Delete page', page._id, page.path);
 
 
@@ -615,7 +411,7 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
         }
 
 
-        if (!crowi.pageService.canDelete(page.path, creator, req.user, isRecursively)) {
+        if (!crowi.pageService.canDelete(page, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
         }
 
 

+ 40 - 48
apps/app/src/server/service/installer.ts

@@ -1,7 +1,8 @@
 import path from 'path';
 import path from 'path';
 
 
-import { Lang } from '@growi/core';
-import type { IPage, IUser } from '@growi/core';
+import type {
+  Lang, IPage, IUser,
+} from '@growi/core';
 import { addSeconds } from 'date-fns';
 import { addSeconds } from 'date-fns';
 import ExtensibleCustomError from 'extensible-custom-error';
 import ExtensibleCustomError from 'extensible-custom-error';
 import fs from 'graceful-fs';
 import fs from 'graceful-fs';
@@ -9,10 +10,10 @@ import mongoose from 'mongoose';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import type Crowi from '../crowi';
 import { generateConfigsForInstalling } from '../models/config';
 import { generateConfigsForInstalling } from '../models/config';
 
 
-import type { ConfigManager } from './config-manager';
-import SearchService from './search';
+import { configManager } from './config-manager';
 
 
 const logger = loggerFactory('growi:service:installer');
 const logger = loggerFactory('growi:service:installer');
 
 
@@ -26,17 +27,16 @@ export type AutoInstallOptions = {
 
 
 export class InstallerService {
 export class InstallerService {
 
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  crowi: any;
+  crowi: Crowi;
 
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
-  constructor(crowi: any) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
   private async initSearchIndex() {
   private async initSearchIndex() {
-    const searchService: SearchService = this.crowi.searchService;
-    if (!searchService.isReachable) {
+    const { searchService } = this.crowi;
+
+    if (searchService == null || !searchService.isReachable) {
       return;
       return;
     }
     }
 
 
@@ -48,18 +48,19 @@ export class InstallerService {
     }
     }
   }
   }
 
 
-  private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
+  private async createPage(filePath, pagePath): Promise<IPage|undefined> {
+    const { pageService } = this.crowi;
+
     try {
     try {
       const markdown = fs.readFileSync(filePath);
       const markdown = fs.readFileSync(filePath);
-      return this.crowi.pageService.create(pagePath, markdown, owner, { isSynchronously: true }) as IPage;
+      return pageService.forceCreateBySystem(pagePath, markdown.toString(), {});
     }
     }
     catch (err) {
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
       logger.error(`Failed to create ${pagePath}`, err);
     }
     }
   }
   }
 
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  private async createInitialPages(owner, lang: Lang, initialPagesCreatedAt?: Date): Promise<any> {
+  private async createInitialPages(lang: Lang, initialPagesCreatedAt?: Date): Promise<any> {
     const { localeDir } = this.crowi;
     const { localeDir } = this.crowi;
     // create /Sandbox/*
     // create /Sandbox/*
     /*
     /*
@@ -67,10 +68,10 @@ export class InstallerService {
      *   1. avoid creating the same pages
      *   1. avoid creating the same pages
      *   2. avoid difference for order in VRT
      *   2. avoid difference for order in VRT
      */
      */
-    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox', owner);
-    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner);
-    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner);
-    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner);
+    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox');
+    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4');
+    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams');
+    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math');
 
 
     // update createdAt and updatedAt fields of all pages
     // update createdAt and updatedAt fields of all pages
     if (initialPagesCreatedAt != null) {
     if (initialPagesCreatedAt != null) {
@@ -110,8 +111,6 @@ export class InstallerService {
    * Execute only once for installing application
    * Execute only once for installing application
    */
    */
   private async initDB(globalLang: Lang, options?: AutoInstallOptions): Promise<void> {
   private async initDB(globalLang: Lang, options?: AutoInstallOptions): Promise<void> {
-    const configManager: ConfigManager = this.crowi.configManager;
-
     const initialConfig = generateConfigsForInstalling();
     const initialConfig = generateConfigsForInstalling();
     initialConfig['app:globalLang'] = globalLang;
     initialConfig['app:globalLang'] = globalLang;
 
 
@@ -125,45 +124,38 @@ export class InstallerService {
   async install(firstAdminUserToSave: Pick<IUser, 'name' | 'username' | 'email' | 'password'>, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
   async install(firstAdminUserToSave: Pick<IUser, 'name' | 'username' | 'email' | 'password'>, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
     await this.initDB(globalLang, options);
     await this.initDB(globalLang, options);
 
 
-    // TODO typescriptize models/user.js and remove eslint-disable-next-line
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const User = mongoose.model('User') as any;
-    const Page = mongoose.model('Page') as any;
+    const User = mongoose.model<IUser, { createUser }>('User');
 
 
     // create portal page for '/' before creating admin user
     // create portal page for '/' before creating admin user
-    await this.createPage(
-      path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
-      '/',
-      { _id: '000000000000000000000000' }, // use 0 as a mock user id
-    );
-
-    // create first admin user
-    // TODO: with transaction
-    let adminUser;
     try {
     try {
+      await this.createPage(
+        path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
+        '/',
+      );
+    }
+    catch (err) {
+      logger.error(err);
+      throw err;
+    }
+
+    try {
+      // create first admin user
       const {
       const {
         name, username, email, password,
         name, username, email, password,
       } = firstAdminUserToSave;
       } = firstAdminUserToSave;
-      adminUser = await User.createUser(name, username, email, password, globalLang);
-      await adminUser.asyncGrantAdmin();
+      const adminUser = await User.createUser(name, username, email, password, globalLang);
+      await (adminUser as any).asyncGrantAdmin();
+
+      // create initial pages
+      await this.createInitialPages(globalLang, options?.serverDate);
+
+      return adminUser;
     }
     }
     catch (err) {
     catch (err) {
+      logger.error(err);
       throw new FailedToCreateAdminUserError(err);
       throw new FailedToCreateAdminUserError(err);
     }
     }
 
 
-    // add owner after creating admin user
-    const Revision = this.crowi.model('Revision');
-    const rootPage = await Page.findOne({ path: '/' });
-    const rootRevision = await Revision.findOne({ path: '/' });
-    rootPage.creator = adminUser._id;
-    rootPage.lastUpdateUser = adminUser._id;
-    rootRevision.author = adminUser._id;
-    await Promise.all([rootPage.save(), rootRevision.save()]);
-
-    // create initial pages
-    await this.createInitialPages(adminUser, globalLang, options?.serverDate);
-
-    return adminUser;
   }
   }
 
 
 }
 }

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

@@ -10,12 +10,12 @@ import mongoose from 'mongoose';
 
 
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
-import { PageDocument, PageModel } from '~/server/models/page';
+import type { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
 import { includesObjectIds, excludeTestIdsFromTargetIds, hasIntersection } from '~/server/util/compare-objectId';
 import { includesObjectIds, excludeTestIdsFromTargetIds, hasIntersection } from '~/server/util/compare-objectId';
 
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import UserGroupRelation from '../models/user-group-relation';
 import UserGroupRelation from '../models/user-group-relation';
 import { divideByType } from '../util/granted-group';
 import { divideByType } from '../util/granted-group';
 
 

+ 43 - 52
apps/app/src/server/service/page/index.ts

@@ -21,6 +21,7 @@ import { Comment } from '~/features/comment/server';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config';
 import type { IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config';
 import {
 import {
   PageDeleteConfigValue, PageSingleDeleteCompConfigValue,
   PageDeleteConfigValue, PageSingleDeleteCompConfigValue,
@@ -30,8 +31,9 @@ import {
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
 } from '~/interfaces/page-operation';
 } from '~/interfaces/page-operation';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
+import type { CreateMethod } from '~/server/models/page';
 import {
 import {
-  type CreateMethod, type PageCreateOptions, type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
+  type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
 } from '~/server/models/page';
 } from '~/server/models/page';
 import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
 import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
@@ -42,7 +44,6 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 import type { ObjectIdLike } from '../../interfaces/mongoose-utils';
 import type { ObjectIdLike } from '../../interfaces/mongoose-utils';
 import { Attachment } from '../../models';
 import { Attachment } from '../../models';
 import { PathAlreadyExistsError } from '../../models/errors';
 import { PathAlreadyExistsError } from '../../models/errors';
-import type { IOptionsForCreate, IOptionsForUpdate } from '../../models/interfaces/page-operation';
 import type { PageOperationDocument } from '../../models/page-operation';
 import type { PageOperationDocument } from '../../models/page-operation';
 import PageOperation from '../../models/page-operation';
 import PageOperation from '../../models/page-operation';
 import PageRedirect from '../../models/page-redirect';
 import PageRedirect from '../../models/page-redirect';
@@ -420,14 +421,6 @@ class PageService implements IPageService {
 
 
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
 
 
-    let creatorId = page.creator;
-    if (page.isEmpty) {
-      // Need non-empty ancestor page to get its creatorId because empty page does NOT have it.
-      // Use creatorId of ancestor page to determine whether the empty page is deletable
-      const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
-      creatorId = notEmptyClosestAncestor.creator;
-    }
-
     const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
     const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
 
 
     const isDeletable = this.canDelete(page, user, false);
     const isDeletable = this.canDelete(page, user, false);
@@ -1124,7 +1117,7 @@ class PageService implements IPageService {
     const copyPage = { ...page };
     const copyPage = { ...page };
 
 
     // 3. Duplicate target
     // 3. Duplicate target
-    const options: PageCreateOptions = {
+    const options: IOptionsForCreate = {
       grant,
       grant,
       grantUserGroupIds: grantedGroupIds,
       grantUserGroupIds: grantedGroupIds,
     };
     };
@@ -2626,7 +2619,7 @@ class PageService implements IPageService {
   }
   }
 
 
   async normalizeParentByPath(path: string, user): Promise<void> {
   async normalizeParentByPath(path: string, user): Promise<void> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
 
 
     // This validation is not 100% correct since it ignores user to count
     // This validation is not 100% correct since it ignores user to count
@@ -2664,16 +2657,14 @@ class PageService implements IPageService {
     if (shouldCreateNewPage) {
     if (shouldCreateNewPage) {
       const notEmptyParent = await Page.findNotEmptyParentByPathRecursively(path);
       const notEmptyParent = await Page.findNotEmptyParentByPathRecursively(path);
 
 
-      const options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] | undefined } = {
-        grant: notEmptyParent.grant,
-        grantUserGroupIds: notEmptyParent.grantedGroups,
-        grantedUsers: notEmptyParent.grantedUsers,
-      };
-
       systematicallyCreatedPage = await this.forceCreateBySystem(
       systematicallyCreatedPage = await this.forceCreateBySystem(
         path,
         path,
         '',
         '',
-        options,
+        {
+          grant: notEmptyParent?.grant,
+          grantUserIds: notEmptyParent?.grantedUsers.map(u => getIdForRef(u)),
+          grantUserGroupIds: notEmptyParent?.grantedGroups,
+        },
       );
       );
       page = systematicallyCreatedPage;
       page = systematicallyCreatedPage;
     }
     }
@@ -3680,12 +3671,12 @@ class PageService implements IPageService {
       path: string,
       path: string,
       grantData: {
       grantData: {
         grant?: PageGrant,
         grant?: PageGrant,
-        grantedUserIds?: ObjectIdLike[],
+        grantUserIds?: ObjectIdLike[],
         grantUserGroupIds?: IGrantedGroup[],
         grantUserGroupIds?: IGrantedGroup[],
       },
       },
       shouldValidateGrant: boolean,
       shouldValidateGrant: boolean,
       user?,
       user?,
-      options?: Partial<PageCreateOptions>,
+      options?: IOptionsForCreate,
   ): Promise<boolean> {
   ): Promise<boolean> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
@@ -3704,7 +3695,7 @@ class PageService implements IPageService {
     }
     }
 
 
     // UserGroup & Owner validation
     // UserGroup & Owner validation
-    const { grant, grantedUserIds, grantUserGroupIds } = grantData;
+    const { grant, grantUserIds, grantUserGroupIds } = grantData;
     if (shouldValidateGrant) {
     if (shouldValidateGrant) {
       if (user == null) {
       if (user == null) {
         throw Error('user is required to validate grant');
         throw Error('user is required to validate grant');
@@ -3716,7 +3707,7 @@ class PageService implements IPageService {
         const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
         const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
         const shouldCheckDescendants = isEmptyPageAlreadyExist && !options?.overwriteScopesOfDescendants;
         const shouldCheckDescendants = isEmptyPageAlreadyExist && !options?.overwriteScopesOfDescendants;
 
 
-        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants);
+        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, path, grant, grantUserIds, grantUserGroupIds, shouldCheckDescendants);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
         logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
@@ -3743,25 +3734,32 @@ class PageService implements IPageService {
    * Create a page
    * Create a page
    * Set options.isSynchronously to true to await all process when you want to run this method multiple times at short intervals.
    * Set options.isSynchronously to true to await all process when you want to run this method multiple times at short intervals.
    */
    */
-  async create(path: string, body: string, user, options: IOptionsForCreate = {}): Promise<PageDocument> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
+  async create(_path: string, body: string, user: HasObjectId, options: IOptionsForCreate = {}): Promise<PageDocument> {
     // Switch method
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
     if (!isV5Compatible) {
-      return this.createV4(path, body, user, options);
+      return this.createV4(_path, body, user, options);
     }
     }
 
 
     // Values
     // Values
-    // eslint-disable-next-line no-param-reassign
-    path = this.crowi.xss.process(path); // sanitize path
-    const {
-      format = 'markdown', grantUserGroupIds,
-    } = options;
-    const grant = isTopPage(path) ? PageGrant.GRANT_PUBLIC : options.grant;
+    const path: string = this.crowi.xss.process(_path); // sanitize path
+
+    // Retrieve closest ancestor document
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
+    const closestAncestor = await Page.findNonEmptyClosestAncestor(path);
+
+    // Determine grantData
+    const grant = options.grant ?? closestAncestor?.grant ?? PageGrant.GRANT_PUBLIC;
+    const grantedUserIds = grant === PageGrant.GRANT_OWNER ? [user._id] : undefined;
+    const grantUserGroupIds = options.grantUserGroupIds
+      ?? (
+        closestAncestor != null
+          ? await this.pageGrantService.getUserRelatedGrantedGroups(closestAncestor, user)
+          : undefined
+      );
     const grantData = {
     const grantData = {
       grant,
       grant,
-      grantedUserIds: grant === PageGrant.GRANT_OWNER ? [user._id] : undefined,
+      grantedUserIds,
       grantUserGroupIds,
       grantUserGroupIds,
     };
     };
 
 
@@ -3797,7 +3795,7 @@ class PageService implements IPageService {
 
 
     // Create revision
     // Create revision
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user);
     savedPage = await pushRevision(savedPage, newRevision, user);
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
     await savedPage.populateDataToShowRevision();
 
 
@@ -3821,12 +3819,7 @@ class PageService implements IPageService {
       throw err;
       throw err;
     }
     }
 
 
-    if (options.isSynchronously) {
-      await this.createSubOperation(savedPage, user, options, pageOp._id);
-    }
-    else {
-      this.createSubOperation(savedPage, user, options, pageOp._id);
-    }
+    this.createSubOperation(savedPage, user, options, pageOp._id);
 
 
     return savedPage;
     return savedPage;
   }
   }
@@ -3912,7 +3905,7 @@ class PageService implements IPageService {
       path: string,
       path: string,
       grantData: {
       grantData: {
         grant: PageGrant,
         grant: PageGrant,
-        grantedUserIds?: ObjectIdLike[],
+        grantUserIds?: ObjectIdLike[],
         grantUserGroupId?: ObjectIdLike,
         grantUserGroupId?: ObjectIdLike,
       },
       },
   ): Promise<boolean> {
   ): Promise<boolean> {
@@ -3921,13 +3914,13 @@ class PageService implements IPageService {
 
 
   /**
   /**
    * @private
    * @private
-   * This method receives the same arguments as the PageService.create method does except for the added type '{ grantedUsers?: ObjectIdLike[] }'.
+   * This method receives the same arguments as the PageService.create method does except for the added type '{ grantUserIds?: ObjectIdLike[] }'.
    * This additional value is used to determine the grantedUser of the page to be created by system.
    * This additional value is used to determine the grantedUser of the page to be created by system.
    * This method must not run isGrantNormalized method to validate grant. **If necessary, run it before use this method.**
    * This method must not run isGrantNormalized method to validate grant. **If necessary, run it before use this method.**
    * -- Reason 1: This is because it is not expected to use this method when the grant validation is required.
    * -- Reason 1: This is because it is not expected to use this method when the grant validation is required.
    * -- Reason 2: This is because it is not expected to use this method when the program cannot determine the operator.
    * -- Reason 2: This is because it is not expected to use this method when the program cannot determine the operator.
    */
    */
-  private async forceCreateBySystem(path: string, body: string, options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] }): Promise<PageDocument> {
+  async forceCreateBySystem(path: string, body: string, options: IOptionsForCreate & { grantUserIds?: ObjectIdLike[] }): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
@@ -3940,7 +3933,7 @@ class PageService implements IPageService {
     path = this.crowi.xss.process(path); // sanitize path
     path = this.crowi.xss.process(path); // sanitize path
 
 
     const {
     const {
-      format = 'markdown', grantUserGroupIds, grantedUsers,
+      grantUserGroupIds, grantUserIds,
     } = options;
     } = options;
     const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
     const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
 
 
@@ -3949,12 +3942,12 @@ class PageService implements IPageService {
 
 
     const grantData = {
     const grantData = {
       grant,
       grant,
-      grantedUserIds: isGrantOwner ? grantedUsers : undefined,
+      grantUserIds: isGrantOwner ? grantUserIds : undefined,
       grantUserGroupIds,
       grantUserGroupIds,
     };
     };
 
 
     // Validate
     // Validate
-    if (isGrantOwner && grantedUsers?.length !== 1) {
+    if (isGrantOwner && grantUserIds?.length !== 1) {
       throw Error('grantedUser must exist when grant is GRANT_OWNER');
       throw Error('grantedUser must exist when grant is GRANT_OWNER');
     }
     }
     const canProcessForceCreateBySystem = await this.canProcessForceCreateBySystem(path, grantData);
     const canProcessForceCreateBySystem = await this.canProcessForceCreateBySystem(path, grantData);
@@ -3970,7 +3963,7 @@ class PageService implements IPageService {
     this.setFieldExceptForGrantRevisionParent(page, path);
     this.setFieldExceptForGrantRevisionParent(page, path);
 
 
     // Apply scope
     // Apply scope
-    page.applyScope({ _id: grantedUsers?.[0] }, grant, grantUserGroupIds);
+    page.applyScope({ _id: grantUserIds?.[0] }, grant, grantUserGroupIds);
 
 
     // Set parent
     // Set parent
     if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
     if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
@@ -3987,7 +3980,7 @@ class PageService implements IPageService {
     // Create revision
     // Create revision
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const dummyUser = { _id: new mongoose.Types.ObjectId() };
     const dummyUser = { _id: new mongoose.Types.ObjectId() };
-    const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser, { format });
+    const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser);
     savedPage = await pushRevision(savedPage, newRevision, dummyUser);
     savedPage = await pushRevision(savedPage, newRevision, dummyUser);
 
 
     // Update descendantCount
     // Update descendantCount
@@ -4016,7 +4009,6 @@ class PageService implements IPageService {
     const options: IOptionsForUpdate = {
     const options: IOptionsForUpdate = {
       grant,
       grant,
       userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
       userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
-      isSyncRevisionToHackmd: false,
     };
     };
 
 
     return this.updatePage(page, null, null, user, options);
     return this.updatePage(page, null, null, user, options);
@@ -4091,7 +4083,7 @@ class PageService implements IPageService {
       pageData: PageDocument,
       pageData: PageDocument,
       body: string | null,
       body: string | null,
       previousBody: string | null,
       previousBody: string | null,
-      user,
+      user: IUserHasId,
       options: IOptionsForUpdate = {},
       options: IOptionsForUpdate = {},
   ): Promise<PageDocument> {
   ): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
@@ -4230,7 +4222,6 @@ class PageService implements IPageService {
     const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
     const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
       ? (await this.getNewGrantedGroups(options.userRelatedGrantUserGroupIds, pageData, user))
       ? (await this.getNewGrantedGroups(options.userRelatedGrantUserGroupIds, pageData, user))
       : pageData.grantedGroups;
       : pageData.grantedGroups;
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
 
 
     // validate multiple group grant before save using pageData and options
     // validate multiple group grant before save using pageData and options
     await this.pageGrantService.validateGrantChange(user, pageData.grantedGroups, grant, grantUserGroupIds);
     await this.pageGrantService.validateGrantChange(user, pageData.grantedGroups, grant, grantUserGroupIds);

+ 10 - 3
apps/app/src/server/service/page/page-service.ts

@@ -1,15 +1,20 @@
 import type EventEmitter from 'events';
 import type EventEmitter from 'events';
 
 
-import type { IPageInfo, IPageInfoForEntity, IUser } from '@growi/core';
+import type {
+  HasObjectId,
+  IPageInfo, IPageInfoForEntity, IUser,
+} from '@growi/core';
 import type { ObjectId } from 'mongoose';
 import type { ObjectId } from 'mongoose';
 
 
+import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
 import type { PageDocument } from '~/server/models/page';
 import type { PageDocument } from '~/server/models/page';
 
 
 export interface IPageService {
 export interface IPageService {
-  create(path: string, body: string, user: IUser, options: IOptionsForCreate): Promise<PageDocument>,
+  create(path: string, body: string, user: HasObjectId, options: IOptionsForCreate): Promise<PageDocument>,
+  forceCreateBySystem(path: string, body: string, options: IOptionsForCreate): Promise<PageDocument>,
+  updatePage(pageData: PageDocument, body: string | null, previousBody: string | null, user: IUser, options: IOptionsForUpdate,): Promise<PageDocument>,
   updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
   updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
   deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
   deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
   getEventEmitter: () => EventEmitter,
   getEventEmitter: () => EventEmitter,
@@ -18,5 +23,7 @@ export interface IPageService {
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
   shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
   shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
+  canDelete(page: PageDocument, operator: any | null, isRecursively: boolean): boolean,
   canDeleteCompletely(page: PageDocument, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
   canDeleteCompletely(page: PageDocument, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
+  canDeleteCompletelyAsMultiGroupGrantedPage(page: PageDocument, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
 }
 }

+ 10 - 3
packages/core/src/models/devided-page-path.js → packages/core/src/models/devided-page-path.ts

@@ -5,12 +5,19 @@ const PATTERN_INCLUDE_DATE = /^(.+\/[^/]+)\/(\d{4}|\d{4}\/\d{2}|\d{4}\/\d{2}\/\d
 
 
 export class DevidedPagePath {
 export class DevidedPagePath {
 
 
-  constructor(path, skipNormalize = false, evalDatePath = false) {
+  isRoot: boolean;
+
+  isFormerRoot: boolean;
+
+  former: string;
+
+  latter: string;
+
+  constructor(path: string, skipNormalize = false, evalDatePath = false) {
 
 
     this.isRoot = false;
     this.isRoot = false;
     this.isFormerRoot = false;
     this.isFormerRoot = false;
-    this.former = null;
-    this.latter = null;
+    this.former = '';
 
 
     // root
     // root
     if (path == null || path === '' || path === '/') {
     if (path == null || path === '' || path === '/') {

+ 3 - 0
packages/core/src/utils/page-path-utils/index.spec.ts

@@ -52,6 +52,9 @@ describe.concurrent('isCreatablePage test', () => {
   test('should decide creatable or not', () => {
   test('should decide creatable or not', () => {
     expect(isCreatablePage('/hoge')).toBeTruthy();
     expect(isCreatablePage('/hoge')).toBeTruthy();
 
 
+    // starts with multiple slash
+    expect(isCreatablePage('//multiple-slash')).toBeFalsy();
+
     // edge cases
     // edge cases
     expect(isCreatablePage('/me')).toBeFalsy();
     expect(isCreatablePage('/me')).toBeFalsy();
     expect(isCreatablePage('/me/')).toBeFalsy();
     expect(isCreatablePage('/me/')).toBeFalsy();

+ 4 - 3
packages/core/src/utils/page-path-utils/index.ts

@@ -1,5 +1,7 @@
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 
 
+import { IUser } from '~/interfaces';
+
 import { isValidObjectId } from '../objectid-utils';
 import { isValidObjectId } from '../objectid-utils';
 import { addTrailingSlash } from '../path-utils';
 import { addTrailingSlash } from '../path-utils';
 
 
@@ -124,9 +126,8 @@ export const isCreatablePage = (path: string): boolean => {
  * return user's homepage path
  * return user's homepage path
  * @param user
  * @param user
  */
  */
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export const userHomepagePath = (user: any): string => {
-  if (!user || !user.username) {
+export const userHomepagePath = (user: IUser | null | undefined): string => {
+  if (user?.username == null) {
     return '';
     return '';
   }
   }
   return `/user/${user.username}`;
   return `/user/${user.username}`;

+ 3 - 0
packages/editor/package.json

@@ -27,6 +27,9 @@
     "@codemirror/state": "^6.2.1",
     "@codemirror/state": "^6.2.1",
     "@codemirror/view": "^6.15.3",
     "@codemirror/view": "^6.15.3",
     "@popperjs/core": "^2.11.8",
     "@popperjs/core": "^2.11.8",
+    "@replit/codemirror-emacs": "^6.0.1",
+    "@replit/codemirror-vim": "6.0.14",
+    "@replit/codemirror-vscode-keymap": "^6.0.2",
     "@types/react": "^18.2.14",
     "@types/react": "^18.2.14",
     "@types/react-dom": "^18.2.6",
     "@types/react-dom": "^18.2.6",
     "@uiw/codemirror-theme-eclipse": "^4.21.21",
     "@uiw/codemirror-theme-eclipse": "^4.21.21",

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

@@ -9,7 +9,7 @@ import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
 import {
 import {
-  useFileDropzone, FileDropzoneOverlay, getEditorTheme, type EditorTheme,
+  useFileDropzone, FileDropzoneOverlay, getEditorTheme, type EditorTheme, getKeyMap, type KeyMapMode,
 } from '../../services';
 } from '../../services';
 import {
 import {
   adjustPasteData, getStrFromBol,
   adjustPasteData, getStrFromBol,
@@ -31,10 +31,12 @@ type Props = {
   editorKey: string | GlobalCodeMirrorEditorKey,
   editorKey: string | GlobalCodeMirrorEditorKey,
   acceptedFileType: AcceptedUploadFileType,
   acceptedFileType: AcceptedUploadFileType,
   onChange?: (value: string) => void,
   onChange?: (value: string) => void,
+  onSave?: () => void,
   onUpload?: (files: File[]) => void,
   onUpload?: (files: File[]) => void,
   onScroll?: () => void,
   onScroll?: () => void,
   indentSize?: number,
   indentSize?: number,
   editorTheme?: string,
   editorTheme?: string,
+  editorKeymap?: string,
 }
 }
 
 
 export const CodeMirrorEditor = (props: Props): JSX.Element => {
 export const CodeMirrorEditor = (props: Props): JSX.Element => {
@@ -42,10 +44,12 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     editorKey,
     editorKey,
     acceptedFileType,
     acceptedFileType,
     onChange,
     onChange,
+    onSave,
     onUpload,
     onUpload,
     onScroll,
     onScroll,
     indentSize,
     indentSize,
     editorTheme,
     editorTheme,
+    editorKeymap,
   } = props;
   } = props;
 
 
   const containerRef = useRef(null);
   const containerRef = useRef(null);
@@ -160,6 +164,17 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     return cleanupFunction;
     return cleanupFunction;
   }, [codeMirrorEditor, themeExtension]);
   }, [codeMirrorEditor, themeExtension]);
 
 
+
+  useEffect(() => {
+    const keymap = (editorKeymap ?? 'default') as KeyMapMode;
+    const extension = getKeyMap(keymap, onSave);
+
+    // Prevent these Keybind from overwriting the originally defined keymap.
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.low(extension));
+    return cleanupFunction;
+
+  }, [codeMirrorEditor, editorKeymap, onSave]);
+
   const {
   const {
     getRootProps,
     getRootProps,
     isDragActive,
     isDragActive,

+ 4 - 1
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -28,11 +28,12 @@ type Props = {
   initialValue?: string,
   initialValue?: string,
   onOpenEditor?: (markdown: string) => void,
   onOpenEditor?: (markdown: string) => void,
   editorTheme?: string,
   editorTheme?: string,
+  editorKeymap?: string,
 }
 }
 
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
   const {
-    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize, userName, pageId, initialValue, onOpenEditor, editorTheme,
+    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize, userName, pageId, initialValue, onOpenEditor, editorTheme, editorKeymap,
   } = props;
   } = props;
 
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
@@ -76,11 +77,13 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
     <CodeMirrorEditor
     <CodeMirrorEditor
       editorKey={GlobalCodeMirrorEditorKey.MAIN}
       editorKey={GlobalCodeMirrorEditorKey.MAIN}
       onChange={onChange}
       onChange={onChange}
+      onSave={onSave}
       onUpload={onUpload}
       onUpload={onUpload}
       onScroll={onScroll}
       onScroll={onScroll}
       acceptedFileType={acceptedFileTypeNoOpt}
       acceptedFileType={acceptedFileTypeNoOpt}
       indentSize={indentSize}
       indentSize={indentSize}
       editorTheme={editorTheme}
       editorTheme={editorTheme}
+      editorKeymap={editorKeymap}
     />
     />
   );
   );
 };
 };

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

@@ -15,6 +15,7 @@ export const Playground = (): JSX.Element => {
 
 
   const [markdownToPreview, setMarkdownToPreview] = useState('');
   const [markdownToPreview, setMarkdownToPreview] = useState('');
   const [editorTheme, setEditorTheme] = useState('');
   const [editorTheme, setEditorTheme] = useState('');
+  const [editorKeymap, setEditorKeymap] = useState('');
 
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
 
@@ -63,11 +64,12 @@ export const Playground = (): JSX.Element => {
             indentSize={4}
             indentSize={4}
             acceptedFileType={AcceptedUploadFileType.ALL}
             acceptedFileType={AcceptedUploadFileType.ALL}
             editorTheme={editorTheme}
             editorTheme={editorTheme}
+            editorKeymap={editorKeymap}
           />
           />
         </div>
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">
           <Preview markdown={markdownToPreview} />
           <Preview markdown={markdownToPreview} />
-          <PlaygroundController setEditorTheme={setEditorTheme} />
+          <PlaygroundController setEditorTheme={setEditorTheme} setEditorKeymap={setEditorKeymap} />
         </div>
         </div>
       </div>
       </div>
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '50px' }}>
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '50px' }}>

+ 27 - 28
packages/editor/src/components/playground/PlaygroundController.tsx

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
 
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
 import { GlobalCodeMirrorEditorKey } from '../../consts';
-import { AllEditorTheme } from '../../services';
+import { AllEditorTheme, AllKeyMap } from '../../services';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
 
 export const InitEditorValueRow = (): JSX.Element => {
 export const InitEditorValueRow = (): JSX.Element => {
@@ -69,38 +69,35 @@ export const SetCaretLineRow = (): JSX.Element => {
   );
   );
 };
 };
 
 
-type SetThemeRowProps = {
-  setEditorTheme: (value: string) => void,
+
+type SetParamRowProps = {
+    update: (value: string) => void,
+    items: string[],
 }
 }
-const SetThemeRow = (props: SetThemeRowProps): JSX.Element => {
-
-  const { setEditorTheme } = props;
-
-  const createItems = (items: string[]): JSX.Element => {
-    return (
-      <div>
-        { items.map((theme) => {
-          return (
-            <button
-              type="button"
-              className="btn btn-outline-secondary"
-              onClick={() => {
-                setEditorTheme(theme);
-              }}
-            >{theme}
-            </button>
-          );
-        }) }
-      </div>
-    );
-  };
 
 
+const SetParamRow = (
+    props: SetParamRowProps,
+): JSX.Element => {
+  const { update, items } = props;
   return (
   return (
     <>
     <>
       <div className="row mt-3">
       <div className="row mt-3">
         <h2>default</h2>
         <h2>default</h2>
         <div className="col">
         <div className="col">
-          {createItems(AllEditorTheme)}
+          <div>
+            { items.map((item) => {
+              return (
+                <button
+                  type="button"
+                  className="btn btn-outline-secondary"
+                  onClick={() => {
+                    update(item);
+                  }}
+                >{item}
+                </button>
+              );
+            }) }
+          </div>
         </div>
         </div>
       </div>
       </div>
     </>
     </>
@@ -110,15 +107,17 @@ const SetThemeRow = (props: SetThemeRowProps): JSX.Element => {
 
 
 type PlaygroundControllerProps = {
 type PlaygroundControllerProps = {
   setEditorTheme: (value: string) => void
   setEditorTheme: (value: string) => void
+  setEditorKeymap: (value: string) => void
 };
 };
 
 
 export const PlaygroundController = (props: PlaygroundControllerProps): JSX.Element => {
 export const PlaygroundController = (props: PlaygroundControllerProps): JSX.Element => {
-  const { setEditorTheme } = props;
+  const { setEditorTheme, setEditorKeymap } = props;
   return (
   return (
     <div className="container mt-5">
     <div className="container mt-5">
       <InitEditorValueRow />
       <InitEditorValueRow />
       <SetCaretLineRow />
       <SetCaretLineRow />
-      <SetThemeRow setEditorTheme={setEditorTheme} />
+      <SetParamRow update={setEditorTheme} items={AllEditorTheme} />
+      <SetParamRow update={setEditorKeymap} items={AllKeyMap} />
     </div>
     </div>
   );
   );
 };
 };

+ 4 - 4
packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts

@@ -1,8 +1,8 @@
 import { useMemo } from 'react';
 import { useMemo } from 'react';
 
 
-import { indentWithTab, defaultKeymap } from '@codemirror/commands';
+import { indentWithTab, defaultKeymap, deleteCharBackward } from '@codemirror/commands';
 import {
 import {
-  markdown, markdownLanguage, deleteMarkupBackward,
+  markdown, markdownLanguage,
 } from '@codemirror/lang-markdown';
 } from '@codemirror/lang-markdown';
 import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language';
 import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language';
 import { languages } from '@codemirror/language-data';
 import { languages } from '@codemirror/language-data';
@@ -32,11 +32,11 @@ import { useInsertText, type InsertText } from './utils/insert-text';
 import { useReplaceText, type ReplaceText } from './utils/replace-text';
 import { useReplaceText, type ReplaceText } from './utils/replace-text';
 import { useSetCaretLine, type SetCaretLine } from './utils/set-caret-line';
 import { useSetCaretLine, type SetCaretLine } from './utils/set-caret-line';
 
 
+
 // set new markdownKeymap instead of default one
 // set new markdownKeymap instead of default one
-// I also bound the deleteMarkupBackward to the backspace key to align with the existing keymap
 // https://github.com/codemirror/lang-markdown/blob/main/src/index.ts#L17
 // https://github.com/codemirror/lang-markdown/blob/main/src/index.ts#L17
 const markdownKeymap = [
 const markdownKeymap = [
-  { key: 'Backspace', run: deleteMarkupBackward },
+  { key: 'Backspace', run: deleteCharBackward },
   { key: 'Enter', run: insertNewlineContinueMarkup },
   { key: 'Enter', run: insertNewlineContinueMarkup },
 ];
 ];
 
 

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

@@ -1,3 +1,4 @@
 export * from './codemirror-editor';
 export * from './codemirror-editor';
 export * from './file-dropzone';
 export * from './file-dropzone';
 export * from './editor-theme';
 export * from './editor-theme';
+export * from './keymaps';

+ 31 - 0
packages/editor/src/services/keymaps/index.ts

@@ -0,0 +1,31 @@
+import { defaultKeymap } from '@codemirror/commands';
+import { Extension } from '@codemirror/state';
+import { keymap } from '@codemirror/view';
+import { emacs } from '@replit/codemirror-emacs';
+import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
+
+import { vimKeymap } from './vim';
+
+
+export const getKeyMap = (keyMapName: KeyMapMode, onSave?: () => void): Extension => {
+  switch (keyMapName) {
+    case 'vim':
+      return vimKeymap(onSave);
+    case 'emacs':
+      return emacs();
+    case 'vscode':
+      return keymap.of(vscodeKeymap);
+    case 'default':
+      return keymap.of(defaultKeymap);
+  }
+};
+
+const KeyMapMode = {
+  default: 'default',
+  vim: 'vim',
+  emacs: 'emacs',
+  vscode: 'vscode',
+} as const;
+
+export const AllKeyMap = Object.values(KeyMapMode);
+export type KeyMapMode = typeof KeyMapMode[keyof typeof KeyMapMode];

+ 13 - 0
packages/editor/src/services/keymaps/vim.ts

@@ -0,0 +1,13 @@
+import { Extension } from '@codemirror/state';
+import { Vim, vim } from '@replit/codemirror-vim';
+
+// vim useful keymap custom
+Vim.map('jj', '<Esc>', 'insert');
+Vim.map('jk', '<Esc>', 'insert');
+
+export const vimKeymap = (onSave?: () => void): Extension => {
+  if (onSave != null) {
+    Vim.defineEx('write', 'w', onSave);
+  }
+  return vim();
+};

+ 7 - 7
packages/ui/src/components/Attachment.tsx

@@ -37,8 +37,8 @@ export const Attachment = (props: AttachmentProps): JSX.Element => {
       </a>
       </a>
     )
     )
     : '';
     : '';
-  const fileType = <span className="attachment-filetype badge badge-pill badge-secondary">{attachment.fileFormat}</span>;
-  const fileInUse = (inUse) ? <span className="attachment-in-use badge badge-pill badge-info">In Use</span> : '';
+  const fileType = <span className="attachment-filetype badge bg-secondary rounded-pill">{attachment.fileFormat}</span>;
+  const fileInUse = (inUse) ? <span className="attachment-in-use badge bg-info rounded-pill">In Use</span> : '';
   // Should UserDate be used like PageRevisionTable ?
   // Should UserDate be used like PageRevisionTable ?
   const formatType = 'yyyy/MM/dd HH:mm:ss';
   const formatType = 'yyyy/MM/dd HH:mm:ss';
   const createdAt = format(new Date(attachment.createdAt), formatType);
   const createdAt = format(new Date(attachment.createdAt), formatType);
@@ -51,11 +51,11 @@ export const Attachment = (props: AttachmentProps): JSX.Element => {
       <a className="me-2" href={attachment.filePathProxied} target="_blank" rel="noopener noreferrer">
       <a className="me-2" href={attachment.filePathProxied} target="_blank" rel="noopener noreferrer">
         <i className={formatIcon}></i> {attachment.originalName}
         <i className={formatIcon}></i> {attachment.originalName}
       </a>
       </a>
-      <span className="mr-2">{fileType}</span>
-      <span className="mr-2">{createdAt}</span>
-      <span className="mr-2">{fileInUse}</span>
-      <span className="mr-2">{btnDownload}</span>
-      <span className="mr-2">{btnTrash}</span>
+      <span className="me-2">{fileType}</span>
+      <span className="me-2">{createdAt}</span>
+      <span className="me-2">{fileInUse}</span>
+      <span className="me-2">{btnDownload}</span>
+      <span className="me-2">{btnTrash}</span>
     </div>
     </div>
   );
   );
 };
 };

+ 22 - 0
yarn.lock

@@ -2858,6 +2858,21 @@
   resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a"
   resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a"
   integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==
   integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==
 
 
+"@replit/codemirror-emacs@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@replit/codemirror-emacs/-/codemirror-emacs-6.0.1.tgz#6e74453e456f40cbb18ed1d15030fa0dbd218098"
+  integrity sha512-2WYkODZGH1QVAXWuOxTMCwktkoZyv/BjYdJi2A5w4fRrmOQFuIACzb6pO9dgU3J+Pm2naeiX2C8veZr/3/r6AA==
+
+"@replit/codemirror-vim@6.0.14":
+  version "6.0.14"
+  resolved "https://registry.yarnpkg.com/@replit/codemirror-vim/-/codemirror-vim-6.0.14.tgz#8f44740b0497406b551726946c9b30f21c867671"
+  integrity sha512-wwhqhvL76FdRTdwfUWpKCbv0hkp2fvivfMosDVlL/popqOiNLtUhL02ThgHZH8mus/NkVr5Mj582lyFZqQrjOA==
+
+"@replit/codemirror-vscode-keymap@^6.0.2":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@replit/codemirror-vscode-keymap/-/codemirror-vscode-keymap-6.0.2.tgz#cc9b9092db5afb9800fda5a03801b4f6600b427e"
+  integrity sha512-j45qTwGxzpsv82lMD/NreGDORFKSctMDVkGRopaP+OrzSzv+pXDQuU3LnFvKpasyjVT0lf+PKG1v2DSCn/vxxg==
+
 "@restart/hooks@^0.3.26":
 "@restart/hooks@^0.3.26":
   version "0.3.27"
   version "0.3.27"
   resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.3.27.tgz#91f356d66d4699a8cd8b3d008402708b6a9dc505"
   resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.3.27.tgz#91f356d66d4699a8cd8b3d008402708b6a9dc505"
@@ -14784,6 +14799,13 @@ react-image-crop@^8.3.0:
     core-js "^3.2.1"
     core-js "^3.2.1"
     prop-types "^15.7.2"
     prop-types "^15.7.2"
 
 
+react-input-autosize@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-3.0.0.tgz#6b5898c790d4478d69420b55441fcc31d5c50a85"
+  integrity sha512-nL9uS7jEs/zu8sqwFE5MAPx6pPkNAriACQ2rGLlqmKr2sPGtN7TXTyDdQt4lbNXVx7Uzadb40x8qotIuru6Rhg==
+  dependencies:
+    prop-types "^15.5.8"
+
 react-is@^16.13.1, react-is@^16.7.0:
 react-is@^16.13.1, react-is@^16.7.0:
   version "16.13.1"
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"