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

Merge branch 'dev/7.0.x' into support/137468-customtheme-firered

satof3 2 лет назад
Родитель
Сommit
c822d6ff63
34 измененных файлов с 948 добавлено и 213 удалено
  1. 81 0
      .github/workflows/release-rc-v7.yml
  2. 1 0
      apps/app/package.json
  3. 6 0
      apps/app/public/static/locales/en_US/commons.json
  4. 6 0
      apps/app/public/static/locales/ja_JP/commons.json
  5. 6 0
      apps/app/public/static/locales/zh_CN/commons.json
  6. 2 2
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  7. 1 1
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  8. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  9. 4 15
      apps/app/src/components/Navbar/GrowiNavbarBottom.tsx
  10. 21 0
      apps/app/src/components/Page/markdown-drawio-util-for-view.ts
  11. 0 0
      apps/app/src/components/Page/markdown-table-util-for-view.ts
  12. 6 1
      apps/app/src/components/PageControls/PageControls.tsx
  13. 14 0
      apps/app/src/components/PageControls/SearchButton.module.scss
  14. 28 0
      apps/app/src/components/PageControls/SearchButton.tsx
  15. 1 1
      apps/app/src/components/PageEditor/DrawioCommunicationHelper.ts
  16. 22 9
      apps/app/src/components/PageEditor/DrawioModal.tsx
  17. 0 179
      apps/app/src/components/PageEditor/MarkdownDrawioUtil.js
  18. 134 0
      apps/app/src/components/PageEditor/markdown-drawio-util-for-editor.ts
  19. 1 1
      apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss
  20. 58 0
      apps/app/src/features/search/client/components/SearchForm.tsx
  21. 60 0
      apps/app/src/features/search/client/components/SearchHelp.tsx
  22. 34 0
      apps/app/src/features/search/client/components/SearchMenuItem.module.scss
  23. 35 0
      apps/app/src/features/search/client/components/SearchMenuItem.tsx
  24. 75 0
      apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx
  25. 113 0
      apps/app/src/features/search/client/components/SearchModal.tsx
  26. 75 0
      apps/app/src/features/search/client/components/SearchResultMenuItem.tsx
  27. 6 0
      apps/app/src/features/search/client/interfaces/downshift.ts
  28. 22 0
      apps/app/src/features/search/client/stores/search.ts
  29. 15 2
      packages/editor/src/components/CodeMirrorEditor/Toolbar/DiagramButton.tsx
  30. 1 1
      packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx
  31. 4 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  32. 50 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/fold-drawio.ts
  33. 40 0
      packages/editor/src/stores/use-drawio.ts
  34. 24 1
      yarn.lock

+ 81 - 0
.github/workflows/release-rc-v7.yml

@@ -0,0 +1,81 @@
+name: Release Docker Images for RC (for dev/7.0.x)
+
+on:
+  push:
+    branches:
+      - dev/7.0.x
+
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+
+jobs:
+
+  determine-tags:
+    runs-on: ubuntu-latest
+
+    outputs:
+      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_GHCR: ${{ steps.meta-ghcr.outputs.tags }}
+
+    steps:
+    - uses: actions/checkout@v3
+
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@1.2.0
+      id: package-json
+
+    - name: Docker meta for docker.io
+      uses: docker/metadata-action@v4
+      id: meta
+      with:
+        images: docker.io/weseek/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
+
+    - name: Docker meta for ghcr.io
+      uses: docker/metadata-action@v4
+      id: meta-ghcr
+      with:
+        images: ghcr.io/weseek/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
+
+
+  build-image-rc:
+    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    with:
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
+
+
+  publish-image-rc:
+    needs: [determine-tags, build-image-rc]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      registry: docker.io
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+  publish-image-rc-ghcr:
+    needs: [determine-tags, build-image-rc]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_GHCR }}
+      registry: ghcr.io
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+

+ 1 - 0
apps/app/package.json

@@ -234,6 +234,7 @@
     "bootstrap": "^5.3.1",
     "bootstrap": "^5.3.1",
     "connect-browser-sync": "^2.1.0",
     "connect-browser-sync": "^2.1.0",
     "diff2html": "^3.4.35",
     "diff2html": "^3.4.35",
+    "downshift": "^8.2.3",
     "eazy-logger": "^3.1.0",
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-cypress": "^2.12.1",

+ 6 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -42,6 +42,12 @@
     }
     }
   },
   },
 
 
+  "search_method_menu_item": {
+    "search_in_all": "Search in all",
+    "only_children_of_this_tree": "Only children of this tree",
+    "exact_mutch": "Exact match"
+  },
+
   "share_links": {
   "share_links": {
     "Share Link": "Share Link",
     "Share Link": "Share Link",
     "Page Path": "Page Path",
     "Page Path": "Page Path",

+ 6 - 0
apps/app/public/static/locales/ja_JP/commons.json

@@ -44,6 +44,12 @@
     }
     }
   },
   },
 
 
+  "search_method_menu_item": {
+    "search_in_all": "全てのページ",
+    "only_children_of_this_tree": "この階層下の子ページのみ",
+    "exact_mutch": "キーワードに完全一致した文字を含むページのみ"
+  },
+
   "share_links": {
   "share_links": {
     "Share Link": "共有用リンク",
     "Share Link": "共有用リンク",
     "Page Path": "ページパス",
     "Page Path": "ページパス",

+ 6 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -45,6 +45,12 @@
 		}
 		}
   },
   },
 
 
+  "search_method_menu_item": {
+    "search_in_all": "所有页面",
+    "only_children_of_this_tree": "当前分支以下内容",
+    "exact_mutch": "完全匹配"
+  },
+
   "share_links": {
   "share_links": {
     "Share Link": "Share Link",
     "Share Link": "Share Link",
     "Page Path": "Page Path",
     "Page Path": "Page Path",

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

@@ -5,7 +5,7 @@ import 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 { useSaveOrUpdate } from '~/client/services/page-operation';
-import mdu from '~/components/PageEditor/MarkdownDrawioUtil';
+import { replaceDrawioInMarkdown } from '~/components/Page/markdown-drawio-util-for-view';
 import type { OptionsToSave } from '~/interfaces/page-operation';
 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';
@@ -41,7 +41,7 @@ export const useDrawioModalLauncherForView = (opts?: {
     }
     }
 
 
     const currentMarkdown = currentPage.revision.body;
     const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
+    const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
 
     const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
     const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
       return {
       return {

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

@@ -4,7 +4,7 @@ import EventEmitter from 'events';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { useSaveOrUpdate } from '~/client/services/page-operation';
-import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/PageEditor/markdown-table-util-for-view';
+import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
 import type { OptionsToSave } from '~/interfaces/page-operation';
 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';

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -23,6 +23,7 @@ const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
+const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 
 
 
 
 type Props = {
 type Props = {
@@ -57,6 +58,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <DeleteAttachmentModal />
         <DeleteAttachmentModal />
         <DeleteBookmarkFolderModal />
         <DeleteBookmarkFolderModal />
         <PutbackPageModal />
         <PutbackPageModal />
+        <SearchModal />
       </DndProvider>
       </DndProvider>
 
 
       <PagePresentationModal />
       <PagePresentationModal />

+ 4 - 15
apps/app/src/components/Navbar/GrowiNavbarBottom.tsx

@@ -1,11 +1,10 @@
 import React from 'react';
 import React from 'react';
 
 
+import { useSearchModal } from '~/features/search/client/stores/search';
 import { useIsSearchPage } from '~/stores/context';
 import { useIsSearchPage } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
-import { useIsDeviceLargerThanMd, useDrawerOpened } from '~/stores/ui';
-
-import { GlobalSearch } from './GlobalSearch';
+import { useDrawerOpened } from '~/stores/ui';
 
 
 import styles from './GrowiNavbarBottom.module.scss';
 import styles from './GrowiNavbarBottom.module.scss';
 
 
@@ -13,10 +12,10 @@ import styles from './GrowiNavbarBottom.module.scss';
 export const GrowiNavbarBottom = (): JSX.Element => {
 export const GrowiNavbarBottom = (): JSX.Element => {
 
 
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { open: openCreateModal } = usePageCreateModal();
   const { open: openCreateModal } = usePageCreateModal();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSearchPage } = useIsSearchPage();
   const { data: isSearchPage } = useIsSearchPage();
+  const { open: openSearchModal } = useSearchModal();
 
 
   return (
   return (
     <div className={`
     <div className={`
@@ -24,15 +23,6 @@ export const GrowiNavbarBottom = (): JSX.Element => {
       ${isDrawerOpened ? styles['grw-navbar-bottom-drawer-opened'] : ''}
       ${isDrawerOpened ? styles['grw-navbar-bottom-drawer-opened'] : ''}
       d-md-none d-edit-none d-print-none fixed-bottom`}
       d-md-none d-edit-none d-print-none fixed-bottom`}
     >
     >
-
-      { !isDeviceLargerThanMd && !isSearchPage && (
-        <div id="grw-global-search-collapse" className="grw-global-search collapse bg-dark">
-          <div className="p-3">
-            <GlobalSearch dropup />
-          </div>
-        </div>
-      ) }
-
       <div className="navbar navbar-expand px-4 px-sm-5">
       <div className="navbar navbar-expand px-4 px-sm-5">
 
 
         <ul className="navbar-nav flex-grow-1 d-flex align-items-center justify-content-between">
         <ul className="navbar-nav flex-grow-1 d-flex align-items-center justify-content-between">
@@ -62,8 +52,7 @@ export const GrowiNavbarBottom = (): JSX.Element => {
                 <a
                 <a
                   role="button"
                   role="button"
                   className="nav-link btn-lg"
                   className="nav-link btn-lg"
-                  data-bs-target="#grw-global-search-collapse"
-                  data-bs-toggle="collapse"
+                  onClick={openSearchModal}
                 >
                 >
                   <span className="material-symbols-outlined fs-2">search</span>
                   <span className="material-symbols-outlined fs-2">search</span>
                 </a>
                 </a>

+ 21 - 0
apps/app/src/components/Page/markdown-drawio-util-for-view.ts

@@ -0,0 +1,21 @@
+/**
+ * return markdown where the drawioData specified by line number params is replaced to the drawioData specified by drawioData param
+ */
+export const replaceDrawioInMarkdown = (drawioData: string, markdown: string, beginLineNumber: number, endLineNumber: number): string => {
+  const splitMarkdown = markdown.split(/\r\n|\r|\n/);
+  const markdownBeforeDrawio = splitMarkdown.slice(0, beginLineNumber - 1);
+  const markdownAfterDrawio = splitMarkdown.slice(endLineNumber);
+
+  let newMarkdown = '';
+  if (markdownBeforeDrawio.length > 0) {
+    newMarkdown += `${markdownBeforeDrawio.join('\n')}\n`;
+  }
+  newMarkdown += '``` drawio\n';
+  newMarkdown += drawioData;
+  newMarkdown += '\n```';
+  if (markdownAfterDrawio.length > 0) {
+    newMarkdown += `\n${markdownAfterDrawio.join('\n')}`;
+  }
+
+  return newMarkdown;
+};

+ 0 - 0
apps/app/src/components/PageEditor/markdown-table-util-for-view.ts → apps/app/src/components/Page/markdown-table-util-for-view.ts


+ 6 - 1
apps/app/src/components/PageControls/PageControls.tsx

@@ -15,7 +15,7 @@ import {
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
-import { EditorMode, useEditorMode } from '~/stores/ui';
+import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
@@ -27,6 +27,7 @@ import {
 
 
 import { BookmarkButtons } from './BookmarkButtons';
 import { BookmarkButtons } from './BookmarkButtons';
 import LikeButtons from './LikeButtons';
 import LikeButtons from './LikeButtons';
+import SearchButton from './SearchButton';
 import SeenUserInfo from './SeenUserInfo';
 import SeenUserInfo from './SeenUserInfo';
 import SubscribeButton from './SubscribeButton';
 import SubscribeButton from './SubscribeButton';
 
 
@@ -123,6 +124,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
 
@@ -250,6 +252,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
 
   return (
   return (
     <div className={`grw-page-controls ${styles['grw-page-controls']} d-flex`} style={{ gap: '2px' }}>
     <div className={`grw-page-controls ${styles['grw-page-controls']} d-flex`} style={{ gap: '2px' }}>
+      { isDeviceLargerThanMd && (
+        <SearchButton />
+      )}
       {revisionId != null && !isViewMode && (
       {revisionId != null && !isViewMode && (
         <Tags
         <Tags
           onClickEditTagsButton={onClickEditTagsButton}
           onClickEditTagsButton={onClickEditTagsButton}

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

@@ -0,0 +1,14 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '@growi/ui/scss/atoms/btn-muted';
+
+@use './button-styles';
+
+.btn-search :global {
+  @extend %btn-basis;
+}
+
+// == Colors
+.btn-search {
+  @include btn-muted.colorize(bs.$success);
+}

+ 28 - 0
apps/app/src/components/PageControls/SearchButton.tsx

@@ -0,0 +1,28 @@
+import React, { useCallback } from 'react';
+
+import { useSearchModal } from '../../features/search/client/stores/search';
+
+import styles from './SearchButton.module.scss';
+
+
+const SearchButton = (): JSX.Element => {
+
+  const { open: openSearchModal } = useSearchModal();
+
+  const searchButtonClickHandler = useCallback(() => {
+    openSearchModal();
+  }, [openSearchModal]);
+
+
+  return (
+    <button
+      type="button"
+      className={`me-3 btn btn-search ${styles['btn-search']}`}
+      onClick={searchButtonClickHandler}
+    >
+      <span className="material-symbols-outlined">search</span>
+    </button>
+  );
+};
+
+export default SearchButton;

+ 1 - 1
apps/app/src/components/PageEditor/DrawioCommunicationHelper.ts

@@ -29,7 +29,7 @@ export class DrawioCommunicationHelper {
     this.callbackOpts = callbackOpts;
     this.callbackOpts = callbackOpts;
   }
   }
 
 
-  onReceiveMessage(event: MessageEvent, drawioMxFile: string): void {
+  onReceiveMessage(event: MessageEvent, drawioMxFile: string | null): void {
 
 
     // check origin
     // check origin
     if (event.origin != null && this.drawioUri != null) {
     if (event.origin != null && this.drawioUri != null) {

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

@@ -4,12 +4,15 @@ import React, {
   useMemo,
   useMemo,
 } from 'react';
 } from 'react';
 
 
+import { useCodeMirrorEditorIsolated } from '@growi/editor';
+import { useDrawioModalForEditor } from '@growi/editor/src/stores/use-drawio';
 import {
 import {
   Modal,
   Modal,
   ModalBody,
   ModalBody,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
 import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
+import { replaceFocusedDrawioWithEditor, getMarkdownDrawioMxfile } from '~/components/PageEditor/markdown-drawio-util-for-editor';
 import { useRendererConfig } from '~/stores/context';
 import { useRendererConfig } from '~/stores/context';
 import { useDrawioModal } from '~/stores/modal';
 import { useDrawioModal } from '~/stores/modal';
 import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePersonalSettings } from '~/stores/personal-settings';
@@ -47,6 +50,11 @@ export const DrawioModal = (): JSX.Element => {
   });
   });
 
 
   const { data: drawioModalData, close: closeDrawioModal } = useDrawioModal();
   const { data: drawioModalData, close: closeDrawioModal } = useDrawioModal();
+  const { data: drawioModalDataInEditor, close: closeDrawioModalInEditor } = useDrawioModalForEditor();
+  const editorKey = drawioModalDataInEditor?.editorKey ?? null;
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
+  const editor = codeMirrorEditor?.view;
+  const isOpenedInEditor = (drawioModalDataInEditor?.isOpened ?? false) && (editor != null);
   const isOpened = drawioModalData?.isOpened ?? false;
   const isOpened = drawioModalData?.isOpened ?? false;
 
 
   const drawioUriWithParams = useMemo(() => {
   const drawioUriWithParams = useMemo(() => {
@@ -78,23 +86,28 @@ export const DrawioModal = (): JSX.Element => {
       return undefined;
       return undefined;
     }
     }
 
 
+    const save = editor != null ? (drawioMxFile: string) => {
+      replaceFocusedDrawioWithEditor(editor, drawioMxFile);
+    } : drawioModalData?.onSave;
+
     return new DrawioCommunicationHelper(
     return new DrawioCommunicationHelper(
       rendererConfig.drawioUri,
       rendererConfig.drawioUri,
       drawioConfig,
       drawioConfig,
-      { onClose: closeDrawioModal, onSave: drawioModalData?.onSave },
+      { onClose: isOpened ? closeDrawioModal : closeDrawioModalInEditor, onSave: save },
     );
     );
-  }, [closeDrawioModal, drawioModalData?.onSave, rendererConfig]);
+  }, [closeDrawioModal, closeDrawioModalInEditor, drawioModalData?.onSave, editor, isOpened, rendererConfig]);
 
 
   const receiveMessageHandler = useCallback((event: MessageEvent) => {
   const receiveMessageHandler = useCallback((event: MessageEvent) => {
     if (drawioModalData == null) {
     if (drawioModalData == null) {
       return;
       return;
     }
     }
 
 
-    drawioCommunicationHelper?.onReceiveMessage(event, drawioModalData.drawioMxFile);
-  }, [drawioCommunicationHelper, drawioModalData]);
+    const drawioMxFile = editor != null ? getMarkdownDrawioMxfile(editor) : drawioModalData.drawioMxFile;
+    drawioCommunicationHelper?.onReceiveMessage(event, drawioMxFile);
+  }, [drawioCommunicationHelper, drawioModalData, editor]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (isOpened) {
+    if (isOpened || isOpenedInEditor) {
       window.addEventListener('message', receiveMessageHandler);
       window.addEventListener('message', receiveMessageHandler);
     }
     }
     else {
     else {
@@ -105,12 +118,12 @@ export const DrawioModal = (): JSX.Element => {
     return function() {
     return function() {
       window.removeEventListener('message', receiveMessageHandler);
       window.removeEventListener('message', receiveMessageHandler);
     };
     };
-  }, [isOpened, receiveMessageHandler]);
+  }, [isOpened, isOpenedInEditor, receiveMessageHandler]);
 
 
   return (
   return (
     <Modal
     <Modal
-      isOpen={isOpened}
-      toggle={() => closeDrawioModal()}
+      isOpen={isOpened || isOpenedInEditor}
+      toggle={() => (isOpened ? closeDrawioModal() : closeDrawioModalInEditor())}
       backdrop="static"
       backdrop="static"
       className="drawio-modal grw-body-only-modal-expanded"
       className="drawio-modal grw-body-only-modal-expanded"
       size="xl"
       size="xl"
@@ -126,7 +139,7 @@ export const DrawioModal = (): JSX.Element => {
         {/* iframe */}
         {/* iframe */}
         { drawioUriWithParams != null && (
         { drawioUriWithParams != null && (
           <div className="w-100 h-100 position-absolute d-flex">
           <div className="w-100 h-100 position-absolute d-flex">
-            { isOpened && (
+            { (isOpened || isOpenedInEditor) && (
               <iframe
               <iframe
                 src={drawioUriWithParams.href}
                 src={drawioUriWithParams.href}
                 className="border-0 flex-grow-1"
                 className="border-0 flex-grow-1"

+ 0 - 179
apps/app/src/components/PageEditor/MarkdownDrawioUtil.js

@@ -1,179 +0,0 @@
-/**
- * Utility for markdown drawio
- */
-class MarkdownDrawioUtil {
-
-  constructor() {
-    this.lineBeginPartOfDrawioRE = /^```(\s.*)drawio$/;
-    this.lineEndPartOfDrawioRE = /^```$/;
-  }
-
-  /**
-   * return the postion of the BOD(beginning of drawio)
-   * (If the BOD is not found after the cursor or the EOD is found before the BOD, return null)
-   */
-  getBod(editor) {
-    const curPos = editor.getCursor();
-    const firstLine = editor.getDoc().firstLine();
-
-    if (this.lineBeginPartOfDrawioRE.test(editor.getDoc().getLine(curPos.line))) {
-      return { line: curPos.line, ch: 0 };
-    }
-
-    let line = curPos.line - 1;
-    let isFound = false;
-    for (; line >= firstLine; line--) {
-      const strLine = editor.getDoc().getLine(line);
-      if (this.lineBeginPartOfDrawioRE.test(strLine)) {
-        isFound = true;
-        break;
-      }
-
-      if (this.lineEndPartOfDrawioRE.test(strLine)) {
-        isFound = false;
-        break;
-      }
-    }
-
-    if (!isFound) {
-      return null;
-    }
-
-    const bodLine = Math.max(firstLine, line);
-    return { line: bodLine, ch: 0 };
-  }
-
-  /**
-   * return the postion of the EOD(end of drawio)
-   * (If the EOD is not found after the cursor or the BOD is found before the EOD, return null)
-   */
-  getEod(editor) {
-    const curPos = editor.getCursor();
-    const lastLine = editor.getDoc().lastLine();
-
-    if (this.lineEndPartOfDrawioRE.test(editor.getDoc().getLine(curPos.line))) {
-      return { line: curPos.line, ch: editor.getDoc().getLine(curPos.line).length };
-    }
-
-    let line = curPos.line + 1;
-    let isFound = false;
-    for (; line <= lastLine; line++) {
-      const strLine = editor.getDoc().getLine(line);
-      if (this.lineEndPartOfDrawioRE.test(strLine)) {
-        isFound = true;
-        break;
-      }
-
-      if (this.lineBeginPartOfDrawioRE.test(strLine)) {
-        isFound = false;
-        break;
-      }
-    }
-
-    if (!isFound) {
-      return null;
-    }
-
-    const eodLine = Math.min(line, lastLine);
-    const lineLength = editor.getDoc().getLine(eodLine).length;
-    return { line: eodLine, ch: lineLength };
-  }
-
-  /**
-   * return boolean value whether the cursor position is in a drawio
-   */
-  isInDrawioBlock(editor) {
-    const bod = this.getBod(editor);
-    const eod = this.getEod(editor);
-    if (bod === null || eod === null) {
-      return false;
-    }
-    return JSON.stringify(bod) !== JSON.stringify(eod);
-  }
-
-  /**
-   * return drawioData instance where the cursor is
-   * (If the cursor is not in a drawio block, return null)
-   */
-  getMarkdownDrawioMxfile(editor) {
-    if (this.isInDrawioBlock(editor)) {
-      const bod = this.getBod(editor);
-      const eod = this.getEod(editor);
-
-      // skip block begin sesion("``` drawio")
-      bod.line++;
-      // skip block end sesion("```")
-      eod.line--;
-      eod.ch = editor.getDoc().getLine(eod.line).length;
-
-      return editor.getDoc().getRange(bod, eod);
-    }
-    return null;
-  }
-
-  replaceFocusedDrawioWithEditor(editor, drawioData) {
-    const curPos = editor.getCursor();
-    const drawioBlock = ['``` drawio', drawioData.toString(), '```'].join('\n');
-    let beginPos;
-    let endPos;
-
-    if (this.isInDrawioBlock(editor)) {
-      beginPos = this.getBod(editor);
-      endPos = this.getEod(editor);
-    }
-    else {
-      beginPos = { line: curPos.line, ch: curPos.ch };
-      endPos = { line: curPos.line, ch: curPos.ch };
-    }
-
-    editor.getDoc().replaceRange(drawioBlock, beginPos, endPos);
-  }
-
-  /**
-   * return markdown where the drawioData specified by line number params is replaced to the drawioData specified by drawioData param
-   * @param {string} drawioData
-   * @param {string} markdown
-   * @param beginLineNumber
-   * @param endLineNumber
-   */
-  replaceDrawioInMarkdown(drawioData, markdown, beginLineNumber, endLineNumber) {
-    const splitMarkdown = markdown.split(/\r\n|\r|\n/);
-    const markdownBeforeDrawio = splitMarkdown.slice(0, beginLineNumber - 1);
-    const markdownAfterDrawio = splitMarkdown.slice(endLineNumber);
-
-    let newMarkdown = '';
-    if (markdownBeforeDrawio.length > 0) {
-      newMarkdown += `${markdownBeforeDrawio.join('\n')}\n`;
-    }
-    newMarkdown += '``` drawio\n';
-    newMarkdown += drawioData;
-    newMarkdown += '\n```';
-    if (markdownAfterDrawio.length > 0) {
-      newMarkdown += `\n${markdownAfterDrawio.join('\n')}`;
-    }
-
-    return newMarkdown;
-  }
-
-  /**
-   * return an array of the starting line numbers of the drawio sections found in markdown
-   */
-  findAllDrawioSection(editor) {
-    const lineNumbers = [];
-    // refs: https://github.com/codemirror/CodeMirror/blob/5.64.0/addon/fold/foldcode.js#L106-L111
-    for (let i = editor.firstLine(), e = editor.lastLine(); i <= e; i++) {
-      const line = editor.getLine(i);
-      const match = this.lineBeginPartOfDrawioRE.exec(line);
-      if (match) {
-        lineNumbers.push(i);
-      }
-    }
-    return lineNumbers;
-  }
-
-}
-
-// singleton pattern
-const instance = new MarkdownDrawioUtil();
-Object.freeze(instance);
-export default instance;

+ 134 - 0
apps/app/src/components/PageEditor/markdown-drawio-util-for-editor.ts

@@ -0,0 +1,134 @@
+import { EditorView } from '@codemirror/view';
+
+const lineBeginPartOfDrawioRE = /^```(\s.*)drawio$/;
+const lineEndPartOfDrawioRE = /^```$/;
+const firstLineNum = 1;
+
+const curPos = (editor: EditorView) => {
+  return editor.state.selection.main.head;
+};
+
+const doc = (editor: EditorView) => {
+  return editor.state.doc;
+};
+
+const lastLineNum = (editor: EditorView) => {
+  return doc(editor).lines;
+};
+
+const getCursorLine = (editor: EditorView) => {
+  return doc(editor).lineAt(curPos(editor));
+};
+
+const getLine = (editor: EditorView, lineNum: number) => {
+  return doc(editor).line(lineNum);
+};
+
+/**
+ * return the postion of the BOD(beginning of drawio)
+ * (If the BOD is not found after the cursor or the EOD is found before the BOD, return null)
+ */
+const getBod = (editor: EditorView) => {
+  const strLine = getCursorLine(editor).text;
+  if (lineBeginPartOfDrawioRE.test(strLine)) {
+    // get the beginning of the line where the cursor is located
+    return getCursorLine(editor).from;
+  }
+
+  let line = getCursorLine(editor).number - 1;
+  let isFound = false;
+  for (; line >= firstLineNum; line--) {
+    const strLine = getLine(editor, line).text;
+    if (lineBeginPartOfDrawioRE.test(strLine)) {
+      isFound = true;
+      break;
+    }
+
+    if (lineEndPartOfDrawioRE.test(strLine)) {
+      isFound = false;
+      break;
+    }
+  }
+
+  if (!isFound) {
+    return null;
+  }
+
+  const botLine = Math.max(firstLineNum, line);
+  return getLine(editor, botLine).from;
+};
+
+/**
+ * return the postion of the EOD(end of drawio)
+ * (If the EOD is not found after the cursor or the BOD is found before the EOD, return null)
+ */
+const getEod = (editor: EditorView) => {
+  const lastLine = lastLineNum(editor);
+
+  const strLine = getCursorLine(editor).text;
+  if (lineEndPartOfDrawioRE.test(strLine)) {
+    // get the end of the line where the cursor is located
+    return getCursorLine(editor).to;
+  }
+
+  let line = getCursorLine(editor).number + 1;
+  let isFound = false;
+  for (; line <= lastLine; line++) {
+    const strLine = getLine(editor, line).text;
+    if (lineEndPartOfDrawioRE.test(strLine)) {
+      isFound = true;
+      break;
+    }
+
+    if (lineBeginPartOfDrawioRE.test(strLine)) {
+      isFound = false;
+      break;
+    }
+  }
+
+  if (!isFound) {
+    return null;
+  }
+
+  const eodLine = Math.min(line, lastLine);
+  return getLine(editor, eodLine).to;
+};
+
+/**
+ * return drawioData instance where the cursor is
+ * (If the cursor is not in a drawio block, return null)
+ */
+export const getMarkdownDrawioMxfile = (editor: EditorView): string | null => {
+  const bod = getBod(editor);
+  const eod = getEod(editor);
+  if (bod == null || eod == null || JSON.stringify(bod) === JSON.stringify(eod)) {
+    return null;
+  }
+
+  // skip block begin sesion("``` drawio")
+  const bodLineNum = doc(editor).lineAt(bod).number + 1;
+  const bodLine = getLine(editor, bodLineNum).from;
+  // skip block end sesion("```")
+  const eodLineNum = doc(editor).lineAt(eod).number - 1;
+  const eodLine = getLine(editor, eodLineNum).to;
+
+  return editor.state.sliceDoc(bodLine, eodLine);
+};
+
+export const replaceFocusedDrawioWithEditor = (editor: EditorView, drawioData: string): void => {
+  const drawioBlock = ['``` drawio', drawioData.toString(), '```'].join('\n');
+  let bod = getBod(editor);
+  let eod = getEod(editor);
+  if (bod == null || eod == null || JSON.stringify(bod) === JSON.stringify(eod)) {
+    bod = curPos(editor);
+    eod = curPos(editor);
+  }
+
+  editor.dispatch({
+    changes: {
+      from: bod,
+      to: eod,
+      insert: drawioBlock,
+    },
+  });
+};

+ 1 - 1
apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss

@@ -38,7 +38,7 @@
 // == App title truncation
 // == App title truncation
 .on-subnavigation {
 .on-subnavigation {
   // set width for truncation
   // set width for truncation
-  $grw-page-controls-width: 226px;
+  $grw-page-controls-width: 280px;
   $grw-page-editor-mode-manager-width: 90px;
   $grw-page-editor-mode-manager-width: 90px;
   $grw-contextual-subnavigation-padding-right: 12px;
   $grw-contextual-subnavigation-padding-right: 12px;
   $gap: 8px;
   $gap: 8px;

+ 58 - 0
apps/app/src/features/search/client/components/SearchForm.tsx

@@ -0,0 +1,58 @@
+import React, {
+  useCallback, useRef, useEffect, useMemo,
+} from 'react';
+
+import { GetInputProps } from '../interfaces/downshift';
+
+type Props = {
+  searchKeyword: string,
+  onChange?: (text: string) => void,
+  onSubmit?: () => void,
+  getInputProps: GetInputProps,
+}
+
+export const SearchForm = (props: Props): JSX.Element => {
+  const {
+    searchKeyword, onChange, onSubmit, getInputProps,
+  } = props;
+
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const changeSearchTextHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    onChange?.(e.target.value);
+  }, [onChange]);
+
+  const submitHandler = useCallback((e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+
+    const isEmptyKeyword = searchKeyword.trim().length === 0;
+    if (isEmptyKeyword) {
+      return;
+    }
+
+    onSubmit?.();
+  }, [searchKeyword, onSubmit]);
+
+  const inputOptions = useMemo(() => {
+    return getInputProps({
+      type: 'search',
+      placeholder: 'Search...',
+      className: 'form-control',
+      ref: inputRef,
+      value: searchKeyword,
+      onChange: changeSearchTextHandler,
+    });
+  }, [getInputProps, searchKeyword, changeSearchTextHandler]);
+
+  useEffect(() => {
+    if (inputRef.current != null) {
+      inputRef.current.focus();
+    }
+  });
+
+  return (
+    <form className="w-100" onSubmit={submitHandler}>
+      <input {...inputOptions} />
+    </form>
+  );
+};

+ 60 - 0
apps/app/src/features/search/client/components/SearchHelp.tsx

@@ -0,0 +1,60 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { Collapse } from 'reactstrap';
+
+export const SearchHelp = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isOpen, setIsOpen] = useState(false);
+
+  return (
+    <>
+      <button type="button" className="btn border-0 text-muted d-flex justify-content-center align-items-center ms-1 p-0" onClick={() => setIsOpen(!isOpen)}>
+        <span className="material-symbols-outlined me-2">help</span>
+        { t('search_help.title') }
+        <span className="material-symbols-outlined ms-2">{isOpen ? 'expand_less' : 'expand_more'}</span>
+      </button>
+      <Collapse isOpen={isOpen}>
+        <table className="table m-0">
+          <tbody>
+            <tr>
+              <th className="py-2">
+                <code>word1</code> <code>word2</code><br />
+                <small className="text-muted">({ t('search_help.and.syntax help') })</small>
+              </th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.and.desc', { word1: 'word1', word2: 'word2' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2">
+                <code>&quot;This is GROWI&quot;</code><br />
+                <small className="text-muted">({ t('search_help.phrase.syntax help') })</small>
+              </th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.phrase.desc', { phrase: 'This is GROWI' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2"><code>-keyword</code></th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.exclude.desc', { word: 'keyword' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2"><code>prefix:/user/</code></th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.prefix.desc', { path: '/user/' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2"><code>-prefix:/user/</code></th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2"><code>tag:wiki</code></th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.tag.desc', { tag: 'wiki' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2"><code>-tag:wiki</code></th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.exclude_tag.desc', { tag: 'wiki' }) }</h6></td>
+            </tr>
+          </tbody>
+        </table>
+      </Collapse>
+    </>
+  );
+};

+ 34 - 0
apps/app/src/features/search/client/components/SearchMenuItem.module.scss

@@ -0,0 +1,34 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '~/styles/variables' as var;
+
+.search-menu-item :global {
+  li {
+    cursor: pointer;
+  }
+}
+
+// == Colors
+@include bs.color-mode(light) {
+  .search-menu-item :global {
+    li.active {
+      background-color: var(--grw-primary-100)
+    }
+
+    li:hover {
+      background-color: bs.$gray-200;
+    }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .search-menu-item :global {
+    li.active {
+      background-color: var(--grw-primary-800)
+    }
+
+    li:hover {
+      background-color: bs.$gray-800;
+    }
+  }
+}

+ 35 - 0
apps/app/src/features/search/client/components/SearchMenuItem.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+import type { GetItemProps } from '../interfaces/downshift';
+
+import styles from './SearchMenuItem.module.scss';
+
+type Props = {
+  url: string
+  index: number
+  isActive: boolean
+  getItemProps: GetItemProps
+  children: React.ReactNode
+}
+
+export const SearchMenuItem = (props: Props): JSX.Element => {
+  const {
+    url, index, isActive, getItemProps, children,
+  } = props;
+
+  const itemMenuOptions = (
+    getItemProps({
+      index,
+      item: { url },
+      className: `d-flex p-1 text-muted ${isActive ? 'active' : ''}`,
+    })
+  );
+
+  return (
+    <div className={`search-menu-item ${styles['search-menu-item']}`}>
+      <li {...itemMenuOptions}>
+        { children }
+      </li>
+    </div>
+  );
+};

+ 75 - 0
apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx

@@ -0,0 +1,75 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useCurrentPagePath } from '~/stores/page';
+
+import type { GetItemProps } from '../interfaces/downshift';
+
+import { SearchMenuItem } from './SearchMenuItem';
+
+type Props = {
+  activeIndex: number | null
+  searchKeyword: string
+  getItemProps: GetItemProps
+}
+
+export const SearchMethodMenuItem = (props: Props): JSX.Element => {
+  const {
+    activeIndex, searchKeyword, getItemProps,
+  } = props;
+
+  const { t } = useTranslation('commons');
+
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const shouldShowMenuItem = searchKeyword.trim().length > 0;
+
+  return (
+    <>
+      { shouldShowMenuItem && (
+        <SearchMenuItem
+          index={0}
+          isActive={activeIndex === 0}
+          getItemProps={getItemProps}
+          url={`/_search?q=${searchKeyword}`}
+        >
+          <span className="material-symbols-outlined fs-4 me-3">search</span>
+          <span>{searchKeyword}</span>
+          <div className="ms-auto">
+            <span>{t('search_method_menu_item.search_in_all')}</span>
+          </div>
+        </SearchMenuItem>
+      )}
+
+      <SearchMenuItem
+        index={shouldShowMenuItem ? 1 : 0}
+        isActive={activeIndex === (shouldShowMenuItem ? 1 : 0)}
+        getItemProps={getItemProps}
+        url={`/_search?q=prefix:${currentPagePath} ${searchKeyword}`}
+      >
+        <span className="material-symbols-outlined fs-4 me-3">search</span>
+        <code>prefix: {currentPagePath}</code>
+        <span className="ms-2">{searchKeyword}</span>
+        <div className="ms-auto">
+          <span>{t('search_method_menu_item.only_children_of_this_tree')}</span>
+        </div>
+      </SearchMenuItem>
+
+      { shouldShowMenuItem && (
+        <SearchMenuItem
+          index={2}
+          isActive={activeIndex === 2}
+          getItemProps={getItemProps}
+          url={`/_search?q="${searchKeyword}"`}
+        >
+          <span className="material-symbols-outlined fs-4 me-3">search</span>
+          <span>{`"${searchKeyword}"`}</span>
+          <div className="ms-auto">
+            <span>{t('search_method_menu_item.exact_mutch')}</span>
+          </div>
+        </SearchMenuItem>
+      ) }
+    </>
+  );
+};

+ 113 - 0
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -0,0 +1,113 @@
+
+import React, {
+  useState, useCallback, useEffect,
+} from 'react';
+
+import Downshift, { type DownshiftState, type StateChangeOptions } from 'downshift';
+import { useRouter } from 'next/router';
+import { Modal, ModalBody } from 'reactstrap';
+
+import type { DownshiftItem } from '../interfaces/downshift';
+import { useSearchModal } from '../stores/search';
+
+import { SearchForm } from './SearchForm';
+import { SearchHelp } from './SearchHelp';
+import { SearchMethodMenuItem } from './SearchMethodMenuItem';
+import { SearchResultMenuItem } from './SearchResultMenuItem';
+
+const SearchModal = (): JSX.Element => {
+  const [searchKeyword, setSearchKeyword] = useState('');
+
+  const { data: searchModalData, close: closeSearchModal } = useSearchModal();
+
+  const router = useRouter();
+
+  const changeSearchTextHandler = useCallback((searchText: string) => {
+    setSearchKeyword(searchText);
+  }, []);
+
+  const selectSearchMenuItemHandler = useCallback((selectedItem: DownshiftItem) => {
+    router.push(selectedItem.url);
+    closeSearchModal();
+  }, [closeSearchModal, router]);
+
+  const submitHandler = useCallback(() => {
+    router.push(`/_search?q=${searchKeyword}`);
+    closeSearchModal();
+  }, [closeSearchModal, router, searchKeyword]);
+
+  const stateReducer = (state: DownshiftState<DownshiftItem>, changes: StateChangeOptions<DownshiftItem>) => {
+    // Do not update highlightedIndex on mouse hover
+    if (changes.type === Downshift.stateChangeTypes.itemMouseEnter) {
+      return {
+        ...changes,
+        highlightedIndex: state.highlightedIndex,
+      };
+    }
+
+    return changes;
+  };
+
+  useEffect(() => {
+    if (!searchModalData?.isOpened) {
+      setSearchKeyword('');
+    }
+  }, [searchModalData?.isOpened]);
+
+  return (
+    <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal}>
+      <ModalBody>
+        <Downshift
+          onSelect={selectSearchMenuItemHandler}
+          stateReducer={stateReducer}
+          defaultIsOpen
+        >
+          {({
+            getRootProps,
+            getInputProps,
+            getItemProps,
+            getMenuProps,
+            highlightedIndex,
+          }) => (
+            <div {...getRootProps({}, { suppressRefError: true })}>
+              <div className="text-muted d-flex justify-content-center align-items-center p-1">
+                <span className="material-symbols-outlined fs-4 me-3">search</span>
+                <SearchForm
+                  searchKeyword={searchKeyword}
+                  onChange={changeSearchTextHandler}
+                  onSubmit={submitHandler}
+                  getInputProps={getInputProps}
+                />
+                <button
+                  type="button"
+                  className="btn border-0 d-flex justify-content-center p-0"
+                  onClick={closeSearchModal}
+                >
+                  <span className="material-symbols-outlined fs-4 ms-3">close</span>
+                </button>
+              </div>
+
+              <ul {...getMenuProps()} className="list-unstyled">
+                <div className="border-top mt-3 mb-2" />
+                <SearchMethodMenuItem
+                  activeIndex={highlightedIndex}
+                  searchKeyword={searchKeyword}
+                  getItemProps={getItemProps}
+                />
+                <div className="border-top mt-2 mb-2" />
+                <SearchResultMenuItem
+                  activeIndex={highlightedIndex}
+                  searchKeyword={searchKeyword}
+                  getItemProps={getItemProps}
+                />
+              </ul>
+            </div>
+          )}
+        </Downshift>
+        <SearchHelp />
+      </ModalBody>
+    </Modal>
+  );
+};
+
+export default SearchModal;

+ 75 - 0
apps/app/src/features/search/client/components/SearchResultMenuItem.tsx

@@ -0,0 +1,75 @@
+import React, { useCallback } from 'react';
+
+import { PagePathLabel, UserPicture } from '@growi/ui/dist/components';
+import { useDebounce } from 'usehooks-ts';
+
+import { useSWRxSearch } from '~/stores/search';
+
+import type { GetItemProps } from '../interfaces/downshift';
+
+import { SearchMenuItem } from './SearchMenuItem';
+
+type Props = {
+  activeIndex: number | null,
+  searchKeyword: string,
+  getItemProps: GetItemProps,
+}
+export const SearchResultMenuItem = (props: Props): JSX.Element => {
+  const { activeIndex, searchKeyword, getItemProps } = props;
+
+  const debouncedKeyword = useDebounce(searchKeyword, 500);
+
+  const isEmptyKeyword = searchKeyword.trim().length === 0;
+
+  const { data: searchResult, isLoading } = useSWRxSearch(isEmptyKeyword ? null : debouncedKeyword, null, { limit: 10 });
+
+  /**
+   *  SearchMenu is a combination of a list of SearchMethodMenuItem and SearchResultMenuItem (this component).
+   *  If no keywords are entered into SearchForm, SearchMethodMenuItem returns a single item. Conversely, when keywords are entered, three items are returned.
+   *  For these reasons, the starting index of SearchResultMemuItem changes depending on the presence or absence of the searchKeyword.
+   */
+  const getFiexdIndex = useCallback((index: number) => {
+    return (isEmptyKeyword ? 1 : 3) + index;
+  }, [isEmptyKeyword]);
+
+  if (isLoading) {
+    return (
+      <>
+        Searching...
+        <div className="border-top mt-3" />
+      </>
+    );
+  }
+
+  if (isEmptyKeyword || searchResult == null || searchResult.data.length === 0) {
+    return <></>;
+  }
+
+  return (
+    <>
+      {searchResult?.data
+        .map((item, index) => (
+          <SearchMenuItem
+            key={item.data._id}
+            index={getFiexdIndex(index)}
+            isActive={getFiexdIndex(index) === activeIndex}
+            getItemProps={getItemProps}
+            url={item.data._id}
+          >
+            <UserPicture user={item.data.creator} />
+
+            <span className="ms-3 text-break text-wrap">
+              <PagePathLabel path={item.data.path} />
+            </span>
+
+            <span className="ms-2 d-flex justify-content-center align-items-center">
+              <span className="material-symbols-outlined fs-5">footprint</span>
+              <span>{item.data.seenUsers.length}</span>
+            </span>
+          </SearchMenuItem>
+        ))
+      }
+      <div className="border-top mt-2 mb-2" />
+    </>
+  );
+};

+ 6 - 0
apps/app/src/features/search/client/interfaces/downshift.ts

@@ -0,0 +1,6 @@
+import type { ControllerStateAndHelpers } from 'downshift';
+
+export type DownshiftItem = { url: string };
+
+export type GetItemProps = ControllerStateAndHelpers<DownshiftItem>['getItemProps']
+export type GetInputProps = ControllerStateAndHelpers<DownshiftItem>['getInputProps']

+ 22 - 0
apps/app/src/features/search/client/stores/search.ts

@@ -0,0 +1,22 @@
+import { SWRResponse } from 'swr';
+
+import { useStaticSWR } from '~/stores/use-static-swr';
+
+type SearchModalStatus = {
+  isOpened: boolean,
+}
+
+type SearchModalUtils = {
+  open(): void
+  close(): void
+}
+export const useSearchModal = (status?: SearchModalStatus): SWRResponse<SearchModalStatus, Error> & SearchModalUtils => {
+  const initialStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<SearchModalStatus, Error>('SearchModal', status, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    open: () => swrResponse.mutate({ isOpened: true }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};

+ 15 - 2
packages/editor/src/components/CodeMirrorEditor/Toolbar/DiagramButton.tsx

@@ -1,6 +1,19 @@
-export const DiagramButton = (): JSX.Element => {
+import { useCallback } from 'react';
+
+import { useDrawioModalForEditor } from '../../../stores/use-drawio';
+
+type Props = {
+  editorKey: string,
+}
+
+export const DiagramButton = (props: Props): JSX.Element => {
+  const { editorKey } = props;
+  const { open: openDrawioModal } = useDrawioModalForEditor();
+  const onClickDiagramButton = useCallback(() => {
+    openDrawioModal(editorKey);
+  }, [editorKey, openDrawioModal]);
   return (
   return (
-    <button type="button" className="btn btn-toolbar-button">
+    <button type="button" className="btn btn-toolbar-button" onClick={onClickDiagramButton}>
       <span className="material-symbols-outlined fs-5">lan</span>
       <span className="material-symbols-outlined fs-5">lan</span>
     </button>
     </button>
   );
   );

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

@@ -28,7 +28,7 @@ export const Toolbar = memo((props: Props): JSX.Element => {
         editorKey={editorKey}
         editorKey={editorKey}
       />
       />
       <TableButton editorKey={editorKey} />
       <TableButton editorKey={editorKey} />
-      <DiagramButton />
+      <DiagramButton editorKey={editorKey} />
       <TemplateButton editorKey={editorKey} />
       <TemplateButton editorKey={editorKey} />
     </div>
     </div>
   );
   );

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

@@ -14,6 +14,7 @@ import { emojiAutocompletionSettings } from '../../extensions/emojiAutocompletio
 
 
 import { useAppendExtensions, type AppendExtensions } from './utils/append-extensions';
 import { useAppendExtensions, type AppendExtensions } from './utils/append-extensions';
 import { useFocus, type Focus } from './utils/focus';
 import { useFocus, type Focus } from './utils/focus';
+import { FoldDrawio, useFoldDrawio } from './utils/fold-drawio';
 import { useGetDoc, type GetDoc } from './utils/get-doc';
 import { useGetDoc, type GetDoc } from './utils/get-doc';
 import { useInitDoc, type InitDoc } from './utils/init-doc';
 import { useInitDoc, type InitDoc } from './utils/init-doc';
 import { useInsertMarkdownElements, type InsertMarkdowElements } from './utils/insert-markdown-elements';
 import { useInsertMarkdownElements, type InsertMarkdowElements } from './utils/insert-markdown-elements';
@@ -41,6 +42,7 @@ type UseCodeMirrorEditorUtils = {
   replaceText: ReplaceText,
   replaceText: ReplaceText,
   insertMarkdownElements: InsertMarkdowElements,
   insertMarkdownElements: InsertMarkdowElements,
   insertPrefix: InsertPrefix,
   insertPrefix: InsertPrefix,
+  foldDrawio: FoldDrawio,
 }
 }
 export type UseCodeMirrorEditor = {
 export type UseCodeMirrorEditor = {
   state: EditorState | undefined;
   state: EditorState | undefined;
@@ -95,6 +97,7 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
   const replaceText = useReplaceText(view);
   const replaceText = useReplaceText(view);
   const insertMarkdownElements = useInsertMarkdownElements(view);
   const insertMarkdownElements = useInsertMarkdownElements(view);
   const insertPrefix = useInsertPrefix(view);
   const insertPrefix = useInsertPrefix(view);
+  const foldDrawio = useFoldDrawio(view);
 
 
   return {
   return {
     state,
     state,
@@ -108,5 +111,6 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
     replaceText,
     replaceText,
     insertMarkdownElements,
     insertMarkdownElements,
     insertPrefix,
     insertPrefix,
+    foldDrawio,
   };
   };
 };
 };

+ 50 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/fold-drawio.ts

@@ -0,0 +1,50 @@
+import { useEffect } from 'react';
+
+import { foldEffect } from '@codemirror/language';
+import { EditorView } from '@codemirror/view';
+
+export type FoldDrawio = void;
+
+const findAllDrawioSection = (view?: EditorView) => {
+  if (view == null) {
+    return;
+  }
+  const lineBeginPartOfDrawioRE = /^```(\s.*)drawio$/;
+  const lineNumbers: number[] = [];
+  // repeat the process in each line from the top to the bottom in the editor
+  for (let i = 1, e = view.state.doc.lines; i <= e; i++) {
+    // get each line text
+    const lineTxt = view.state.doc.line(i).text;
+    const match = lineBeginPartOfDrawioRE.exec(lineTxt);
+    if (match) {
+      lineNumbers.push(i);
+    }
+  }
+  return lineNumbers;
+};
+
+const foldDrawioSection = (lineNumbers?: number[], view?: EditorView) => {
+  if (view == null || lineNumbers == null) {
+    return;
+  }
+  lineNumbers.forEach((lineNumber) => {
+    // get the end of the lines containing '''drawio
+    const from = view.state.doc.line(lineNumber).to;
+    // get the end of the lines containing '''
+    const to = view.state.doc.line(lineNumber + 2).to;
+    view?.dispatch({
+      effects: foldEffect.of({
+        from,
+        to,
+      }),
+    });
+  });
+};
+
+export const useFoldDrawio = (view?: EditorView): FoldDrawio => {
+  const lineNumbers = findAllDrawioSection(view);
+
+  useEffect(() => {
+    foldDrawioSection(lineNumbers, view);
+  }, [view, lineNumbers]);
+};

+ 40 - 0
packages/editor/src/stores/use-drawio.ts

@@ -0,0 +1,40 @@
+import { useCallback } from 'react';
+
+import { useSWRStatic } from '@growi/core/dist/swr';
+import type { SWRResponse } from 'swr';
+
+type DrawioModalStatus = {
+  isOpened: boolean,
+  editorKey: string | undefined,
+}
+
+type DrawioModalStatusUtils = {
+  open(
+    editorKey: string,
+  ): void,
+  close(): void,
+}
+
+export const useDrawioModalForEditor = (status?: DrawioModalStatus): SWRResponse<DrawioModalStatus, Error> & DrawioModalStatusUtils => {
+  const initialData: DrawioModalStatus = {
+    isOpened: false,
+    editorKey: undefined,
+  };
+  const swrResponse = useSWRStatic<DrawioModalStatus, Error>('drawioModalStatusForEditor', status, { fallbackData: initialData });
+
+  const { mutate } = swrResponse;
+
+  const open = useCallback((editorKey: string | undefined): void => {
+    mutate({ isOpened: true, editorKey });
+  }, [mutate]);
+
+  const close = useCallback((): void => {
+    mutate({ isOpened: false, editorKey: undefined });
+  }, [mutate]);
+
+  return {
+    ...swrResponse,
+    open,
+    close,
+  };
+};

+ 24 - 1
yarn.lock

@@ -1189,6 +1189,13 @@
   dependencies:
   dependencies:
     regenerator-runtime "^0.14.0"
     regenerator-runtime "^0.14.0"
 
 
+"@babel/runtime@^7.22.15":
+  version "7.23.6"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d"
+  integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==
+  dependencies:
+    regenerator-runtime "^0.14.0"
+
 "@babel/template@^7.22.5", "@babel/template@^7.3.3":
 "@babel/template@^7.22.5", "@babel/template@^7.3.3":
   version "7.22.5"
   version "7.22.5"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec"
@@ -5974,6 +5981,11 @@ compute-scroll-into-view@^1.0.17:
   resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab"
   resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab"
   integrity sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==
   integrity sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==
 
 
+compute-scroll-into-view@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz#753f11d972596558d8fe7c6bcbc8497690ab4c87"
+  integrity sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==
+
 concat-map@0.0.1:
 concat-map@0.0.1:
   version "0.0.1"
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -7025,6 +7037,17 @@ dotignore@^0.1.2:
   dependencies:
   dependencies:
     minimatch "^3.0.4"
     minimatch "^3.0.4"
 
 
+downshift@^8.2.3:
+  version "8.2.3"
+  resolved "https://registry.yarnpkg.com/downshift/-/downshift-8.2.3.tgz#27106a5d9f408a6f6f9350ca465801d07e52db87"
+  integrity sha512-1HkvqaMTZpk24aqnXaRDnT+N5JCbpFpW+dCogB11+x+FCtfkFX0MbAO4vr/JdXi1VYQF174KjNUveBXqaXTPtg==
+  dependencies:
+    "@babel/runtime" "^7.22.15"
+    compute-scroll-into-view "^3.0.3"
+    prop-types "^15.8.1"
+    react-is "^18.2.0"
+    tslib "^2.6.2"
+
 dtrace-provider@~0.8:
 dtrace-provider@~0.8:
   version "0.8.8"
   version "0.8.8"
   resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.8.tgz#2996d5490c37e1347be263b423ed7b297fb0d97e"
   resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.8.tgz#2996d5490c37e1347be263b423ed7b297fb0d97e"
@@ -13505,7 +13528,7 @@ react-is@^17.0.1:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
   integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
   integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
 
 
-react-is@^18.0.0:
+react-is@^18.0.0, react-is@^18.2.0:
   version "18.2.0"
   version "18.2.0"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
   integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
   integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==