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

Merge remote-tracking branch 'origin/dev/7.0.x' into feat/134154-135811-update-create-page-method

Yuki Takei 2 лет назад
Родитель
Сommit
d074685eda
46 измененных файлов с 1118 добавлено и 257 удалено
  1. 81 0
      .github/workflows/release-rc-v7.yml
  2. 3 3
      .vscode/settings.json
  3. 1 0
      apps/app/package.json
  4. 7 1
      apps/app/public/static/locales/en_US/commons.json
  5. 7 0
      apps/app/public/static/locales/en_US/translation.json
  6. 7 1
      apps/app/public/static/locales/ja_JP/commons.json
  7. 7 0
      apps/app/public/static/locales/ja_JP/translation.json
  8. 7 1
      apps/app/public/static/locales/zh_CN/commons.json
  9. 7 0
      apps/app/public/static/locales/zh_CN/translation.json
  10. 2 2
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  11. 1 1
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  12. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  13. 15 0
      apps/app/src/components/Me/ColorModeSettings.module.scss
  14. 62 0
      apps/app/src/components/Me/ColorModeSettings.tsx
  15. 6 1
      apps/app/src/components/Me/OtherSettings.tsx
  16. 4 15
      apps/app/src/components/Navbar/GrowiNavbarBottom.tsx
  17. 21 0
      apps/app/src/components/Page/markdown-drawio-util-for-view.ts
  18. 0 0
      apps/app/src/components/Page/markdown-table-util-for-view.ts
  19. 6 1
      apps/app/src/components/PageControls/PageControls.tsx
  20. 14 0
      apps/app/src/components/PageControls/SearchButton.module.scss
  21. 28 0
      apps/app/src/components/PageControls/SearchButton.tsx
  22. 1 1
      apps/app/src/components/PageEditor/DrawioCommunicationHelper.ts
  23. 22 9
      apps/app/src/components/PageEditor/DrawioModal.tsx
  24. 0 179
      apps/app/src/components/PageEditor/MarkdownDrawioUtil.js
  25. 134 0
      apps/app/src/components/PageEditor/markdown-drawio-util-for-editor.ts
  26. 1 1
      apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss
  27. 4 1
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  28. 1 1
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  29. 58 0
      apps/app/src/features/search/client/components/SearchForm.tsx
  30. 60 0
      apps/app/src/features/search/client/components/SearchHelp.tsx
  31. 34 0
      apps/app/src/features/search/client/components/SearchMenuItem.module.scss
  32. 35 0
      apps/app/src/features/search/client/components/SearchMenuItem.tsx
  33. 75 0
      apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx
  34. 113 0
      apps/app/src/features/search/client/components/SearchModal.tsx
  35. 75 0
      apps/app/src/features/search/client/components/SearchResultMenuItem.tsx
  36. 6 0
      apps/app/src/features/search/client/interfaces/downshift.ts
  37. 22 0
      apps/app/src/features/search/client/stores/search.ts
  38. 0 32
      apps/app/src/stores/modal.tsx
  39. 15 2
      packages/editor/src/components/CodeMirrorEditor/Toolbar/DiagramButton.tsx
  40. 24 2
      packages/editor/src/components/CodeMirrorEditor/Toolbar/TemplateButton.tsx
  41. 2 2
      packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx
  42. 4 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  43. 50 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/fold-drawio.ts
  44. 40 0
      packages/editor/src/stores/use-drawio.ts
  45. 30 0
      packages/editor/src/stores/use-template-modal.ts
  46. 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 }}
+

+ 3 - 3
.vscode/settings.json

@@ -12,9 +12,9 @@
   "scss.validate": false,
   "scss.validate": false,
 
 
   "editor.codeActionsOnSave": {
   "editor.codeActionsOnSave": {
-    "source.fixAll.eslint": true,
-    "source.fixAll.markdownlint": true,
-    "source.fixAll.stylelint": true
+    "source.fixAll.eslint": "explicit",
+    "source.fixAll.markdownlint": "explicit",
+    "source.fixAll.stylelint": "explicit"
   },
   },
 
 
   "githubPullRequests.ignoredPullRequestBranches": [
   "githubPullRequests.ignoredPullRequestBranches": [

+ 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",

+ 7 - 1
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",
@@ -71,7 +77,7 @@
   "create_page_dropdown": {
   "create_page_dropdown": {
     "new_page": "Create New Page",
     "new_page": "Create New Page",
     "todays": {
     "todays": {
-      "desc": "Create today's ...",
+      "desc": "Create today's memo",
       "memo": "memo"
       "memo": "memo"
     },
     },
     "template": {
     "template": {

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

@@ -257,6 +257,13 @@
       "description": "You can set whether or not the sidebar will always be open when the screen width is large. If the screen width is small, the sidebar will always be closed."
       "description": "You can set whether or not the sidebar will always be open when the screen width is large. If the screen width is small, the sidebar will always be closed."
     }
     }
   },
   },
+  "color_mode_settings": {
+    "light": "Light",
+    "dark": "Dark",
+    "system": "System",
+    "settings": "Color mode settings",
+    "description": "Select whether to display in light mode, dark mode, or a system-specific display.<br>Only supported themes can be switched."
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "Editor Settings"
     "editor_settings": "Editor Settings"
   },
   },

+ 7 - 1
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": "ページパス",
@@ -73,7 +79,7 @@
   "create_page_dropdown": {
   "create_page_dropdown": {
     "new_page": "新規ページ作成",
     "new_page": "新規ページ作成",
     "todays": {
     "todays": {
-      "desc": "今日の◯◯を作成",
+      "desc": "今日のメモを作成",
       "memo": "メモ"
       "memo": "メモ"
     },
     },
     "template": {
     "template": {

+ 7 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -258,6 +258,13 @@
       "description": "画面幅が大きい場合に、サイドバーを常時開いた状態にするかどうかを設定できます。画面幅が小さい場合はサイドバーは常に閉じた状態となります。"
       "description": "画面幅が大きい場合に、サイドバーを常時開いた状態にするかどうかを設定できます。画面幅が小さい場合はサイドバーは常に閉じた状態となります。"
     }
     }
   },
   },
+  "color_mode_settings": {
+    "light": "ライト",
+    "dark": "ダーク",
+    "system": "システム",
+    "settings": "カラーモードの設定",
+    "description": "ライトモードかダークモード、もしくはシステム合わせた表示をするか選択します。<br>対応したテーマのみ切り替えることができます。"
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "エディター設定",
     "editor_settings": "エディター設定",
     "common_settings": {
     "common_settings": {

+ 7 - 1
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",
@@ -74,7 +80,7 @@
   "create_page_dropdown": {
   "create_page_dropdown": {
     "new_page": "新页面",
     "new_page": "新页面",
     "todays": {
     "todays": {
-      "desc": "Create today's ...",
+      "desc": "Create today's memo",
       "memo": "memo"
       "memo": "memo"
     },
     },
     "template": {
     "template": {

+ 7 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -248,6 +248,13 @@
       "description": "您可以设置当屏幕宽度较大时,侧边栏是否始终打开。 如果屏幕宽度较小,侧边栏将始终关闭。"
       "description": "您可以设置当屏幕宽度较大时,侧边栏是否始终打开。 如果屏幕宽度较小,侧边栏将始终关闭。"
     }
     }
   },
   },
+  "color_mode_settings": {
+    "light": "灯光",
+    "dark": "暗处",
+    "system": "系统",
+    "settings": "色彩模式设置",
+    "description": "选择是以浅色模式、深色模式还是系统特定的显示方式显示。<br>只能切换支持的主题。"
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "编辑器设置"
     "editor_settings": "编辑器设置"
   },
   },

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

+ 15 - 0
apps/app/src/components/Me/ColorModeSettings.module.scss

@@ -0,0 +1,15 @@
+@use '@growi/core/scss/bootstrap/init' as *;
+
+.color-settings :global {
+  .btn {
+    font-weight: bold;
+    color: var(--color-global);
+    background-color: transparent;
+    border-width: 3px;
+  }
+
+  .btn-outline-secondary {
+    border-color: $gray-400;
+  }
+}
+

+ 62 - 0
apps/app/src/components/Me/ColorModeSettings.tsx

@@ -0,0 +1,62 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { Themes, useNextThemes } from '~/stores/use-next-themes';
+
+import styles from './ColorModeSettings.module.scss';
+
+export const ColorModeSettings = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { setTheme, theme } = useNextThemes();
+
+  const isActive = useCallback((targetTheme: Themes) => {
+    return targetTheme === theme;
+  }, [theme]);
+
+  return (
+    <div className={`color-settings ${styles['color-settings']}`}>
+      <h2 className="border-bottom mb-4">{t('color_mode_settings.settings')}</h2>
+
+      <div className="offset-md-3">
+        <div className="d-flex">
+          <button
+            type="button"
+            onClick={() => { setTheme(Themes.LIGHT) }}
+            // eslint-disable-next-line max-len
+            className={`btn py-2 px-4 me-4 d-flex align-items-center justify-content-center ${isActive(Themes.LIGHT) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">light_mode</span>
+            <span>{t('color_mode_settings.light')}</span>
+          </button>
+
+          <button
+            type="button"
+            onClick={() => { setTheme(Themes.DARK) }}
+            // eslint-disable-next-line max-len
+            className={`btn py-2 px-4 me-4 d-flex align-items-center justify-content-center ${isActive(Themes.DARK) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">dark_mode</span>
+            <span>{t('color_mode_settings.dark')}</span>
+          </button>
+
+          <button
+            type="button"
+            onClick={() => { setTheme(Themes.SYSTEM) }}
+            // eslint-disable-next-line max-len
+            className={`btn py-2 px-4 d-flex align-items-center justify-content-center ${isActive(Themes.SYSTEM) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">devices</span>
+            <span>{t('color_mode_settings.system')}</span>
+          </button>
+        </div>
+
+        <div className="mt-3 text-muted">
+          {/* eslint-disable-next-line react/no-danger */}
+          <span dangerouslySetInnerHTML={{ __html: t('color_mode_settings.description') }} />
+        </div>
+      </div>
+    </div>
+  );
+};

+ 6 - 1
apps/app/src/components/Me/OtherSettings.tsx

@@ -1,3 +1,4 @@
+import { ColorModeSettings } from './ColorModeSettings';
 import { QuestionnaireSettings } from './QuestionnaireSettings';
 import { QuestionnaireSettings } from './QuestionnaireSettings';
 import { UISettings } from './UISettings';
 import { UISettings } from './UISettings';
 
 
@@ -6,12 +7,16 @@ const OtherSettings = (): JSX.Element => {
   return (
   return (
     <>
     <>
       <div className="mt-4">
       <div className="mt-4">
-        <QuestionnaireSettings />
+        <ColorModeSettings />
       </div>
       </div>
 
 
       <div className="mt-4">
       <div className="mt-4">
         <UISettings />
         <UISettings />
       </div>
       </div>
+
+      <div className="mt-4">
+        <QuestionnaireSettings />
+      </div>
     </>
     </>
   );
   );
 };
 };

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

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

@@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react';
 
 
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
 
 
 import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
 import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
@@ -15,6 +16,8 @@ import { DropendToggle } from './DropendToggle';
 import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 
 
 export const PageCreateButton = React.memo((): JSX.Element => {
 export const PageCreateButton = React.memo((): JSX.Element => {
+  const { t } = useTranslation('commons');
+
   const { data: currentPagePath, isLoading } = useCurrentPagePath();
   const { data: currentPagePath, isLoading } = useCurrentPagePath();
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
@@ -22,7 +25,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
 
   const now = format(new Date(), 'yyyy/MM/dd');
   const now = format(new Date(), 'yyyy/MM/dd');
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
-  const todaysPath = `${userHomepagePath}/memo/${now}`;
+  const todaysPath = `${userHomepagePath}/${t('create_page_dropdown.todays.memo')}/${now}`;
 
 
   const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(currentPagePath, isLoading);
   const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(currentPagePath, isLoading);
   const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath, currentUser);
   const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath, currentUser);

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

@@ -5,6 +5,7 @@ import React, {
 import assert from 'assert';
 import assert from 'assert';
 
 
 import { Lang } from '@growi/core';
 import { Lang } from '@growi/core';
+import { useTemplateModal, type TemplateModalStatus } from '@growi/editor/src/stores/use-template-modal';
 import {
 import {
   extractSupportedLocales, getLocalizedTemplate, type TemplateSummary,
   extractSupportedLocales, getLocalizedTemplate, type TemplateSummary,
 } from '@growi/pluginkit/dist/v4';
 } from '@growi/pluginkit/dist/v4';
@@ -21,7 +22,6 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { useSWRxTemplate, useSWRxTemplates } from '~/features/templates/stores';
 import { useSWRxTemplate, useSWRxTemplates } from '~/features/templates/stores';
-import { useTemplateModal, type TemplateModalStatus } from '~/stores/modal';
 import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePreviewOptions } from '~/stores/renderer';
 import { usePreviewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';

+ 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 }),
+  };
+};

+ 0 - 32
apps/app/src/stores/modal.tsx

@@ -630,38 +630,6 @@ export const useBookmarkFolderDeleteModal = (status?: DeleteBookmarkFolderModalS
   };
   };
 };
 };
 
 
-/*
- * TemplateModal
- */
-
-type TemplateSelectedCallback = (templateText: string) => void;
-type TemplateModalOptions = {
-  onSubmit?: TemplateSelectedCallback,
-}
-export type TemplateModalStatus = TemplateModalOptions & {
-  isOpened: boolean,
-}
-
-type TemplateModalUtils = {
-  open(opts: TemplateModalOptions): void,
-  close(): void,
-}
-
-export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & TemplateModalUtils => {
-
-  const initialStatus: TemplateModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<TemplateModalStatus, Error>('templateModal', undefined, { fallbackData: initialStatus });
-
-  return Object.assign(swrResponse, {
-    open: (opts: TemplateModalOptions) => {
-      swrResponse.mutate({ isOpened: true, onSubmit: opts.onSubmit });
-    },
-    close: () => {
-      swrResponse.mutate({ isOpened: false });
-    },
-  });
-};
-
 /**
 /**
  * DeleteAttachmentModal
  * DeleteAttachmentModal
  */
  */

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

+ 24 - 2
packages/editor/src/components/CodeMirrorEditor/Toolbar/TemplateButton.tsx

@@ -1,6 +1,28 @@
-export const TemplateButton = (): JSX.Element => {
+import { useCallback } from 'react';
+
+import { useCodeMirrorEditorIsolated } from '../../../stores';
+import { useTemplateModal } from '../../../stores/use-template-modal';
+
+type Props = {
+  editorKey: string,
+}
+
+export const TemplateButton = (props: Props): JSX.Element => {
+  const { editorKey } = props;
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
+  const { open: openTemplateModal } = useTemplateModal();
+
+  const onClickTempleteButton = useCallback(() => {
+    const editor = codeMirrorEditor?.view;
+    if (editor != null) {
+      const insertText = (text: string) => editor.dispatch(editor.state.replaceSelection(text));
+      const onSubmit = (templateText: string) => insertText(templateText);
+      openTemplateModal({ onSubmit });
+    }
+  }, [codeMirrorEditor?.view, openTemplateModal]);
+
   return (
   return (
-    <button type="button" className="btn btn-toolbar-button">
+    <button type="button" className="btn btn-toolbar-button" onClick={onClickTempleteButton}>
       <span className="material-symbols-outlined fs-5">file_copy</span>
       <span className="material-symbols-outlined fs-5">file_copy</span>
     </button>
     </button>
   );
   );

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

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

+ 30 - 0
packages/editor/src/stores/use-template-modal.ts

@@ -0,0 +1,30 @@
+import { useSWRStatic } from '@growi/core/dist/swr';
+import type { SWRResponse } from 'swr';
+
+type TemplateSelectedCallback = (templateText: string) => void;
+type TemplateModalOptions = {
+  onSubmit?: TemplateSelectedCallback,
+}
+export type TemplateModalStatus = TemplateModalOptions & {
+  isOpened: boolean,
+}
+
+type TemplateModalUtils = {
+  open(opts: TemplateModalOptions): void,
+  close(): void,
+}
+
+export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & TemplateModalUtils => {
+
+  const initialStatus: TemplateModalStatus = { isOpened: false };
+  const swrResponse = useSWRStatic<TemplateModalStatus, Error>('templateModal', undefined, { fallbackData: initialStatus });
+
+  return Object.assign(swrResponse, {
+    open: (opts: TemplateModalOptions) => {
+      swrResponse.mutate({ isOpened: true, onSubmit: opts.onSubmit });
+    },
+    close: () => {
+      swrResponse.mutate({ isOpened: false });
+    },
+  });
+};

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